Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
710a29a43c | ||
|
|
d3f58f4223 |
No files matched your search
@@ -1171,7 +1171,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.ability.GreatSword.Settings greatSwordSettings() {
|
||||
try {
|
||||
var base = new java.math.BigInteger(getConfig().getString("great-sword-base-damage", "7"));
|
||||
var seconds = new java.math.BigDecimal(getConfig().getString("great-sword-recovery-seconds", "1.2"));
|
||||
var seconds = new java.math.BigDecimal(getConfig().getString("great-sword-recovery-seconds", "0.75"));
|
||||
var recovery = java.time.Duration.ofNanos(seconds.movePointRight(9).toBigIntegerExact().longValueExact());
|
||||
return new games.dmg.fantasystick.ability.GreatSword.Settings(base, recovery);
|
||||
} catch (ArithmeticException | NumberFormatException invalid) {
|
||||
|
||||
@@ -27,7 +27,7 @@ public final class GreatSword implements AbilityEffect, Listener, AutoCloseable
|
||||
throw new IllegalArgumentException("Invalid Great Sword settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() { return new Settings(BigInteger.valueOf(7), Duration.ofMillis(1200)); }
|
||||
public static Settings defaults() { return new Settings(BigInteger.valueOf(7), Duration.ofMillis(750)); }
|
||||
}
|
||||
|
||||
private static final class Attempt {
|
||||
|
||||
@@ -35,7 +35,7 @@ public final class ShieldBash implements AbilityEffect, Listener, AutoCloseable
|
||||
|| minimumVerticalKnockback > 8) throw new IllegalArgumentException("Invalid Shield Bash settings");
|
||||
}
|
||||
public static Settings defaults() {
|
||||
return new Settings(BigInteger.valueOf(4), Duration.ofSeconds(6), 3, 0.8, 0.35);
|
||||
return new Settings(BigInteger.valueOf(4), Duration.ofSeconds(3), 3, 0.8, 0.35);
|
||||
}
|
||||
}
|
||||
private static final class Attempt {
|
||||
|
||||
@@ -33,7 +33,7 @@ public final class ShieldBashSettings implements AutoCloseable {
|
||||
public static final String HORIZONTAL = "shield-bash-horizontal-knockback";
|
||||
public static final String VERTICAL = "shield-bash-minimum-vertical-knockback";
|
||||
private static final List<String> KEYS = List.of(BASE_DAMAGE, COOLDOWN, RANGE, HORIZONTAL, VERTICAL);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(BASE_DAMAGE, "4", COOLDOWN, "6", RANGE, "3",
|
||||
private static final Map<String, String> DEFAULTS = Map.of(BASE_DAMAGE, "4", COOLDOWN, "3", RANGE, "3",
|
||||
HORIZONTAL, "0.8", VERTICAL, "0.35");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
|
||||
@@ -69,7 +69,7 @@ fireball-projectile-speed-blocks-per-tick: 3
|
||||
|
||||
# Great Sword defaults from the reviewed starter catalogue.
|
||||
great-sword-base-damage: 7
|
||||
great-sword-recovery-seconds: 1.2
|
||||
great-sword-recovery-seconds: 0.75
|
||||
|
||||
# Frost Shard defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
@@ -111,7 +111,7 @@ stone-skin-damage-reduction-percent: 25
|
||||
# Shield Bash defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
shield-bash-base-damage: 4
|
||||
shield-bash-cooldown-seconds: 6
|
||||
shield-bash-cooldown-seconds: 3
|
||||
shield-bash-range-blocks: 3
|
||||
shield-bash-horizontal-knockback: 0.8
|
||||
shield-bash-minimum-vertical-knockback: 0.35
|
||||
|
||||
@@ -26,7 +26,7 @@ class GreatSwordNativeTest {
|
||||
String book = PartyBookNativeTest.text(a);
|
||||
assertTrue(book.contains("Great Sword"));
|
||||
assertTrue(book.contains("STR 12"));
|
||||
assertTrue(book.contains("1.2"));
|
||||
assertTrue(book.contains("0.75"));
|
||||
var learn = abilityButton(a, "[Learn]", "great_sword");
|
||||
PartyBookNativeTest.click(f, a, learn); PartyBookNativeTest.click(f, a, learn);
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
@@ -68,8 +68,8 @@ class GreatSwordNativeTest {
|
||||
var saved = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
var deadline = saved.cooldowns().get("great_sword");
|
||||
assertNotNull(deadline);
|
||||
assertFalse(deadline.isBefore(before.plusMillis(1200)));
|
||||
assertFalse(deadline.isAfter(java.time.Instant.now().plusMillis(1200)));
|
||||
assertFalse(deadline.isBefore(before.plusMillis(750)));
|
||||
assertFalse(deadline.isAfter(java.time.Instant.now().plusMillis(750)));
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(target, damages.getFirst().getDamageSource(),
|
||||
new java.util.ArrayList<>(), 0));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
@@ -259,6 +259,54 @@ class GreatSwordNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void fractionalRecoveryRefusesImmediatelyAndOneNanosecondBeforeButAcceptsExactBoundary() throws Exception {
|
||||
var now = new java.util.concurrent.atomic.AtomicReference<>(java.time.Instant.parse("2026-09-21T20:00:00Z"));
|
||||
java.time.Clock clock = new java.time.Clock() {
|
||||
@Override public java.time.ZoneId getZone() { return java.time.ZoneOffset.UTC; }
|
||||
@Override public java.time.Clock withZone(java.time.ZoneId zone) { return this; }
|
||||
@Override public java.time.Instant instant() { return now.get(); }
|
||||
};
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
var created = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var stats = new java.util.EnumMap<>(created.stats()); stats.put(games.dmg.fantasystick.progression.Stat.STR, BigInteger.valueOf(12));
|
||||
var initial = new games.dmg.fantasystick.persistence.CharacterRecord(created.id(), created.name(), created.revision(), stats,
|
||||
created.level(), created.experience(), created.unspentPoints(), created.health(), java.util.Set.of("great_sword"),
|
||||
java.util.List.of("great_sword", "", "", "", "", "", ""), created.cooldowns(), created.restrictedKills(), false, null, null);
|
||||
try (var storage = new games.dmg.fantasystick.persistence.CharacterStorage(repository, clock)) {
|
||||
storage.create(initial).get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
var current = new java.util.concurrent.atomic.AtomicReference<>(initial);
|
||||
var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class);
|
||||
var active = new games.dmg.fantasystick.session.EquipmentSessions.Active(java.util.UUID.randomUUID(),
|
||||
java.util.UUID.randomUUID(), initial.id());
|
||||
when(sessions.current(active)).thenReturn(true);
|
||||
when(sessions.currentRecord(active)).thenAnswer(ignored -> java.util.Optional.of(current.get()));
|
||||
when(sessions.acknowledgeProgress(eq(active), any(), any())).thenAnswer(call -> {
|
||||
current.set(call.getArgument(2)); return true;
|
||||
});
|
||||
var player = mock(org.bukkit.entity.Player.class); var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
var hits = new java.util.concurrent.atomic.AtomicInteger();
|
||||
doAnswer(call -> { hits.incrementAndGet(); return null; }).when(target)
|
||||
.damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
var sword = new games.dmg.fantasystick.ability.GreatSword(
|
||||
games.dmg.fantasystick.ability.GreatSword.Settings.defaults());
|
||||
var registry = new games.dmg.fantasystick.ability.AbilityRegistry(); registry.register(sword.definition());
|
||||
var parties = new games.dmg.fantasystick.party.PartyRoster(clock, ignored -> true);
|
||||
var engine = new games.dmg.fantasystick.ability.AbilityEngine(registry, storage, sessions, parties, clock,
|
||||
Runnable::run, (delay, action) -> () -> {}, ignored -> {}, failure -> fail(failure.toString()));
|
||||
Runnable cast = () -> engine.accept(new games.dmg.fantasystick.session.ControlInput(player, active, 2, "great_sword",
|
||||
games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,
|
||||
games.dmg.fantasystick.session.ControlInput.Gesture.ATTACK, target, 0, 1, () -> true));
|
||||
cast.run(); await(hits, 1);
|
||||
var deadline = now.get().plusMillis(750); assertEquals(deadline, current.get().cooldowns().get("great_sword"));
|
||||
cast.run(); assertEquals(1, hits.get());
|
||||
now.set(deadline.minusNanos(1)); cast.run(); assertEquals(1, hits.get());
|
||||
now.set(deadline); cast.run(); await(hits, 2);
|
||||
engine.close(); sword.close(); parties.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedCooldownPersistenceDealsNoDamageOrFreeEffect() {
|
||||
var storage = mock(games.dmg.fantasystick.persistence.CharacterStorage.class);
|
||||
var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class);
|
||||
@@ -312,6 +360,18 @@ class GreatSwordNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void retainedRecoveryIsHonoredWithoutRewritingConfiguration() throws Exception {
|
||||
String retained = "great-sword-recovery-seconds: 1.2\n";
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), retained);
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var definition = ((games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry")).find("great_sword").orElseThrow();
|
||||
assertEquals(java.time.Duration.ofMillis(1200), definition.cooldown());
|
||||
assertEquals(retained, java.nio.file.Files.readString(data.resolve("config.yml")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void configuredDefinitionAndChargeCurvePreserveExactUncappedScaling() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"great-sword-base-damage: 9\ngreat-sword-recovery-seconds: 2.5\n");
|
||||
@@ -389,6 +449,11 @@ class GreatSwordNativeTest {
|
||||
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
|
||||
}
|
||||
|
||||
private static void await(java.util.concurrent.atomic.AtomicInteger value, int expected) throws Exception {
|
||||
for (int attempt = 0; attempt < 500 && value.get() < expected; attempt++) Thread.sleep(10);
|
||||
assertEquals(expected, value.get());
|
||||
}
|
||||
|
||||
private static void withStrength(Path data, FantasyStickLifecycleNativeTest.Actor actor, int strength) throws Exception {
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
var old = repository.load(actor.core.record.id()).orElseThrow();
|
||||
|
||||
@@ -32,7 +32,7 @@ class ShieldBashNativeTest {
|
||||
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
|
||||
String book = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
|
||||
assertTrue(book.contains("ShieldBash")); assertTrue(book.contains("STR12")); assertTrue(book.contains("CON12"));
|
||||
assertFalse(book.contains("Cost:")); assertTrue(book.contains("3")); assertTrue(book.contains("6s"));
|
||||
assertFalse(book.contains("Cost:")); assertTrue(book.contains("3")); assertTrue(book.contains("3s"));
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "shield_bash"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
@@ -197,6 +197,71 @@ class ShieldBashNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void packagedCooldownRefusesImmediatelyAndOneNanosecondBeforeButAcceptsExactBoundary() throws Exception {
|
||||
var now = new java.util.concurrent.atomic.AtomicReference<>(java.time.Instant.parse("2026-09-21T20:00:00Z"));
|
||||
java.time.Clock clock = new java.time.Clock() {
|
||||
@Override public java.time.ZoneId getZone() { return java.time.ZoneOffset.UTC; }
|
||||
@Override public java.time.Clock withZone(java.time.ZoneId zone) { return this; }
|
||||
@Override public java.time.Instant instant() { return now.get(); }
|
||||
};
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
var created = new games.dmg.fantasystick.crafting.CharacterFactory().create(
|
||||
games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var stats = new EnumMap<>(created.stats()); stats.put(Stat.STR, BigInteger.valueOf(12)); stats.put(Stat.CON, BigInteger.valueOf(12));
|
||||
var initial = new CharacterRecord(created.id(), created.name(), created.revision(), stats, created.level(), created.experience(),
|
||||
created.unspentPoints(), created.health(), java.util.Set.of("shield_bash"),
|
||||
List.of("shield_bash", "", "", "", "", "", ""), created.cooldowns(), created.restrictedKills(), false, null, null);
|
||||
try (var storage = new CharacterStorage(repository, clock)) {
|
||||
storage.create(initial).get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
var current = new java.util.concurrent.atomic.AtomicReference<>(initial);
|
||||
var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class);
|
||||
var active = new games.dmg.fantasystick.session.EquipmentSessions.Active(java.util.UUID.randomUUID(),
|
||||
java.util.UUID.randomUUID(), initial.id());
|
||||
when(sessions.current(active)).thenReturn(true);
|
||||
when(sessions.currentRecord(active)).thenAnswer(ignored -> java.util.Optional.of(current.get()));
|
||||
when(sessions.acknowledgeProgress(eq(active), any(), any())).thenAnswer(call -> {
|
||||
current.set(call.getArgument(2)); return true;
|
||||
});
|
||||
var world = mock(org.bukkit.World.class); var player = mock(org.bukkit.entity.Player.class);
|
||||
when(player.getUniqueId()).thenReturn(active.player()); doReturn(new Location(world, 0, 64, 0)).when(player).getLocation();
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getLocation()).thenReturn(new Location(world, 2, 64, 0)); doReturn(true).when(player).hasLineOfSight(target);
|
||||
var hits = new java.util.concurrent.atomic.AtomicInteger();
|
||||
doAnswer(call -> { hits.incrementAndGet(); return null; }).when(target)
|
||||
.damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
var bash = new games.dmg.fantasystick.ability.ShieldBash(
|
||||
games.dmg.fantasystick.ability.ShieldBash.Settings.defaults(), ignored -> {});
|
||||
var registry = new games.dmg.fantasystick.ability.AbilityRegistry(); registry.register(bash.definition());
|
||||
var parties = new games.dmg.fantasystick.party.PartyRoster(clock, ignored -> true);
|
||||
var engine = new games.dmg.fantasystick.ability.AbilityEngine(registry, storage, sessions, parties, clock,
|
||||
Runnable::run, (delay, action) -> () -> {}, ignored -> {}, failure -> fail(failure.toString()));
|
||||
Runnable cast = () -> engine.accept(new games.dmg.fantasystick.session.ControlInput(player, active, 2, "shield_bash",
|
||||
games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,
|
||||
games.dmg.fantasystick.session.ControlInput.Gesture.ATTACK, target, 0, 1, () -> true));
|
||||
cast.run(); await(hits, 1);
|
||||
var deadline = now.get().plusSeconds(3); assertEquals(deadline, current.get().cooldowns().get("shield_bash"));
|
||||
cast.run(); assertEquals(1, hits.get());
|
||||
now.set(deadline.minusNanos(1)); cast.run(); assertEquals(1, hits.get());
|
||||
now.set(deadline); cast.run(); await(hits, 2);
|
||||
engine.close(); bash.close(); parties.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void retainedSixSecondCooldownIsHonoredWithoutRewritingConfiguration() throws Exception {
|
||||
String retained = "shield-bash-cooldown-seconds: 6\n";
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), retained);
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
|
||||
assertEquals(java.time.Duration.ofSeconds(6), registry.find("shield_bash").orElseThrow().cooldown());
|
||||
assertEquals(retained, java.nio.file.Files.readString(data.resolve("config.yml")));
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
assertEquals(List.of("3", "6"), f.command.tabComplete(sender.port, "fantasystick",
|
||||
new String[]{"config", "shield-bash-cooldown-seconds", ""}));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void exactDamageFormulaSupportsFiniteUncappedStats() {
|
||||
assertEquals(BigInteger.valueOf(5), games.dmg.fantasystick.ability.ShieldBash.damage(BigInteger.valueOf(12)));
|
||||
assertEquals(BigInteger.valueOf(3), games.dmg.fantasystick.ability.ShieldBash.damage(BigInteger.valueOf(9)));
|
||||
@@ -291,6 +356,11 @@ class ShieldBashNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static void await(java.util.concurrent.atomic.AtomicInteger value, int expected) throws Exception {
|
||||
for (int attempt = 0; attempt < 500 && value.get() < expected; attempt++) Thread.sleep(10);
|
||||
assertEquals(expected, value.get());
|
||||
}
|
||||
|
||||
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
|
||||
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
|
||||
|
||||
@@ -49,7 +49,7 @@ class DistributionTest {
|
||||
"The delivered configuration must expose the reviewed 15-minute default");
|
||||
assertTrue(defaults.contains("adventure-mode: true"));
|
||||
assertTrue(defaults.contains("great-sword-base-damage: 7"));
|
||||
assertTrue(defaults.contains("great-sword-recovery-seconds: 1.2"));
|
||||
assertTrue(defaults.contains("great-sword-recovery-seconds: 0.75"));
|
||||
for (String setting : java.util.List.of("frost-shard-base-damage: 4",
|
||||
"frost-shard-cooldown-seconds: 5", "frost-shard-range-blocks: 32",
|
||||
"frost-shard-lifetime-seconds: 5", "frost-shard-projectile-speed-blocks-per-tick: 3",
|
||||
@@ -74,7 +74,7 @@ class DistributionTest {
|
||||
"stone-skin-duration-seconds: 4", "stone-skin-damage-reduction-percent: 25")) {
|
||||
assertTrue(defaults.contains(setting), "Missing packaged Stone Skin default: " + setting);
|
||||
}
|
||||
for (String setting : java.util.List.of("shield-bash-base-damage: 4", "shield-bash-cooldown-seconds: 6",
|
||||
for (String setting : java.util.List.of("shield-bash-base-damage: 4", "shield-bash-cooldown-seconds: 3",
|
||||
"shield-bash-range-blocks: 3", "shield-bash-horizontal-knockback: 0.8",
|
||||
"shield-bash-minimum-vertical-knockback: 0.35")) {
|
||||
assertTrue(defaults.contains(setting), "Missing packaged Shield Bash default: " + setting);
|
||||
|
||||
@@ -9,6 +9,10 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class ShieldBashSettingsTest {
|
||||
@TempDir Path data;
|
||||
@Test void packagedDefaultCooldownIsThreeSeconds() {
|
||||
assertEquals("3", ShieldBashSettings.defaultValue(ShieldBashSettings.COOLDOWN));
|
||||
assertEquals(java.time.Duration.ofSeconds(3), ShieldBashSettings.parse(defaults()).cooldown());
|
||||
}
|
||||
@Test void serializesConcurrentKeysPreservesUnrelatedAndPublishesAcknowledgedSnapshots() throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
|
||||
try (var settings = new ShieldBashSettings(data, defaults())) {
|
||||
|
||||
Reference in new issue
Block a user