From 1361ba3fb24d78debc85b10192bdb66fdf70a771 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Sun, 20 Sep 2026 06:52:12 -0400 Subject: [PATCH] feat(abilities): add Great Sword --- .../dmg/fantasystick/FantasyStickPlugin.java | 19 + .../dmg/fantasystick/ability/GreatSword.java | 116 ++++++ .../book/CharacterBookController.java | 7 +- src/main/resources/config.yml | 4 + .../fantasystick/GreatSwordNativeTest.java | 369 ++++++++++++++++++ .../dmg/fantasystick/DistributionTest.java | 2 + 6 files changed, 516 insertions(+), 1 deletion(-) create mode 100644 src/main/java/games/dmg/fantasystick/ability/GreatSword.java create mode 100644 src/nativeTest/java/games/dmg/fantasystick/GreatSwordNativeTest.java diff --git a/src/main/java/games/dmg/fantasystick/FantasyStickPlugin.java b/src/main/java/games/dmg/fantasystick/FantasyStickPlugin.java index eb0935c..52c3efc 100644 --- a/src/main/java/games/dmg/fantasystick/FantasyStickPlugin.java +++ b/src/main/java/games/dmg/fantasystick/FantasyStickPlugin.java @@ -49,6 +49,7 @@ public final class FantasyStickPlugin extends JavaPlugin { private games.dmg.fantasystick.ability.AbilityRegistry abilityRegistry; private games.dmg.fantasystick.ability.AbilityEngine abilityEngine; private games.dmg.fantasystick.ability.Fireball fireball; + private games.dmg.fantasystick.ability.GreatSword greatSword; private games.dmg.fantasystick.ability.Revive revive; private games.dmg.fantasystick.persistence.RevivalStore revivals; private games.dmg.fantasystick.session.NutritionPolicy nutritionPolicy; @@ -78,6 +79,7 @@ public final class FantasyStickPlugin extends JavaPlugin { var rules = progressionRules(); var rewardRules = combatRewardRules(); var fireballSettings = fireballSettings(); + var greatSwordSettings = greatSwordSettings(); var factory = new CharacterFactory(); characters = new CharacterStorage(new YamlCharacterRepository(getDataFolder().toPath()), Clock.systemUTC()); crafting = new ManualCraftingController(characters, () -> factory.create(rules), @@ -118,11 +120,13 @@ public final class FantasyStickPlugin extends JavaPlugin { notice -> notifyPlayer(generation, notice), diagnostics, presentation, adventureSettings::enabled, gameModes); abilityRegistry = new games.dmg.fantasystick.ability.AbilityRegistry(); fireball = new games.dmg.fantasystick.ability.Fireball(fireballSettings); + greatSword = new games.dmg.fantasystick.ability.GreatSword(greatSwordSettings); revivals = new games.dmg.fantasystick.persistence.RevivalStore(getDataFolder().toPath()); revive = new games.dmg.fantasystick.ability.Revive(characters, revivals, Clock.systemUTC(), nextTick, (size, title) -> getServer().createInventory(null, size, title), problem -> getLogger().log(Level.WARNING, "Revive transition failed", problem)); abilityRegistry.register(fireball.definition()); + abilityRegistry.register(greatSword.definition()); abilityRegistry.register(revive.definition()); sessions.attachAbilityRegistry(abilityRegistry); vitals = new games.dmg.fantasystick.session.SessionVitals(); @@ -179,6 +183,7 @@ public final class FantasyStickPlugin extends JavaPlugin { getServer().getPluginManager().registerEvents(combatExperience, this); combatExperience.install(this); getServer().getPluginManager().registerEvents(fireball, this); + getServer().getPluginManager().registerEvents(greatSword, this); getServer().getPluginManager().registerEvents(revive, this); getServer().getPluginManager().registerEvents(localBasicAttack, this); getServer().getPluginManager().registerEvents(nutritionPolicy, this); @@ -223,6 +228,7 @@ public final class FantasyStickPlugin extends JavaPlugin { var closingCombatExperience = combatExperience; combatExperience = null; var closingAbilityEngine = abilityEngine; abilityEngine = null; var closingFireball = fireball; fireball = null; + var closingGreatSword = greatSword; greatSword = null; var closingRevive = revive; revive = null; var closingRevivals = revivals; revivals = null; abilityRegistry = null; @@ -239,6 +245,7 @@ public final class FantasyStickPlugin extends JavaPlugin { if (closingBasicAttack != null) cleanup("basic attack", closingBasicAttack::close); if (closingCombatExperience != null) cleanup("combat experience", closingCombatExperience::close); if (closingFireball != null) cleanup("Fireball", closingFireball::close); + if (closingGreatSword != null) cleanup("Great Sword", closingGreatSword::close); if (closingRevive != null) cleanup("Revive", closingRevive::close); if (closingAbilityEngine != null) cleanup("ability engine", closingAbilityEngine::close); if (closingNutrition != null) cleanup("nutrition policy", closingNutrition::close); @@ -255,6 +262,7 @@ public final class FantasyStickPlugin extends JavaPlugin { unregister(closingPartyCombat); unregister(closingCombatExperience); unregister(closingFireball); + unregister(closingGreatSword); unregister(closingRevive); unregister(closingBasicAttack); unregister(closingNutrition); @@ -326,6 +334,17 @@ 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 recovery = java.time.Duration.ofNanos(seconds.movePointRight(9).toBigIntegerExact().longValueExact()); + return new games.dmg.fantasystick.ability.GreatSword.Settings(base, recovery); + } catch (ArithmeticException | NumberFormatException invalid) { + throw new IllegalArgumentException("Great Sword settings must use supported nonnegative numeric values", invalid); + } + } + private java.time.Duration switchDelay() { Object configured = getConfig().get("character-swap-cooldown-seconds", 900); String value = String.valueOf(configured); diff --git a/src/main/java/games/dmg/fantasystick/ability/GreatSword.java b/src/main/java/games/dmg/fantasystick/ability/GreatSword.java new file mode 100644 index 0000000..af4345c --- /dev/null +++ b/src/main/java/games/dmg/fantasystick/ability/GreatSword.java @@ -0,0 +1,116 @@ +package games.dmg.fantasystick.ability; + +import games.dmg.fantasystick.progression.Stat; +import games.dmg.fantasystick.session.ControlInput; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.bukkit.Material; +import org.bukkit.damage.DamageSource; +import org.bukkit.damage.DamageType; +import org.bukkit.entity.LivingEntity; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityDamageByEntityEvent; + +/** Great Sword's charge-scaled direct melee damage, replacing the contained native weapon hit. */ +public final class GreatSword implements AbilityEffect, Listener, AutoCloseable { + public record Settings(BigInteger baseDamage, Duration recovery) { + public Settings { + Objects.requireNonNull(baseDamage); Objects.requireNonNull(recovery); + if (baseDamage.signum() < 0 || recovery.isNegative() + || recovery.compareTo(Duration.ofDays(365)) > 0) { + throw new IllegalArgumentException("Invalid Great Sword settings"); + } + } + public static Settings defaults() { return new Settings(BigInteger.valueOf(7), Duration.ofMillis(1200)); } + } + + private static final class Attempt { + final AbilityExecution execution; + final LivingEntity target; + boolean reported; + Attempt(AbilityExecution execution, LivingEntity target) { + this.execution = execution; this.target = target; + } + } + + private final Settings settings; + private Attempt damaging; + private boolean closed; + + public GreatSword(Settings settings) { this.settings = Objects.requireNonNull(settings); } + + public AbilityDefinition definition() { + return new AbilityDefinition("great_sword", "Great Sword", Material.WOODEN_SWORD, + List.of("Charge a contained melee strike.", + "Fully charged: " + settings.baseDamage() + " + STR modifier damage."), + "Attack with the equipped sword; partial charge follows the native attack curve.", + Map.of(Stat.STR, BigInteger.valueOf(12)), BigInteger.ONE, + AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.ATTACK, settings.recovery(), + Map.of("base-damage", new BigDecimal(settings.baseDamage())), + AbilityDefinition.TargetPolicy.HOSTILE, this); + } + + public static BigDecimal damage(BigInteger strength, float charge) { + return damage(BigInteger.valueOf(7), strength, charge); + } + + static BigDecimal damage(BigInteger base, BigInteger strength, float charge) { + Objects.requireNonNull(base); Objects.requireNonNull(strength); + if (base.signum() < 0 || strength.signum() < 0 || !Float.isFinite(charge) || charge < 0 || charge > 1) { + throw new IllegalArgumentException("Invalid Great Sword damage input"); + } + BigInteger delta = strength.subtract(BigInteger.TEN); + BigInteger[] divided = delta.divideAndRemainder(BigInteger.TWO); + BigInteger modifier = divided[0]; + if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE); + BigDecimal full = new BigDecimal(base.add(modifier)); + BigDecimal nativeCharge = new BigDecimal(Float.toString(charge)); + BigDecimal curve = new BigDecimal("0.2").add(new BigDecimal("0.8").multiply(nativeCharge.multiply(nativeCharge))); + return full.multiply(curve).stripTrailingZeros(); + } + + @Override public boolean accepts(ControlInput input) { + return !closed && input.target() instanceof LivingEntity; + } + + @Override public void activate(AbilityExecution execution) { + if (closed || !execution.stillCurrent() || !(execution.input().target() instanceof LivingEntity target)) return; + BigDecimal amount = damage(settings.baseDamage(), execution.character().stats().get(Stat.STR), + execution.input().attackStrength()); + double nativeDamage = amount.compareTo(BigDecimal.valueOf(Double.MAX_VALUE)) > 0 + ? Double.MAX_VALUE : amount.doubleValue(); + if (!Double.isFinite(nativeDamage) || nativeDamage <= 0) { + throw new IllegalStateException("Great Sword damage is outside the native finite range"); + } + var player = execution.input().player(); + var source = DamageSource.builder(DamageType.PLAYER_ATTACK).withCausingEntity(player) + .withDirectEntity(player).build(); + var attempt = new Attempt(execution, target); + damaging = attempt; + try { target.damage(nativeDamage, source); } + finally { if (damaging == attempt) damaging = null; } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void acceptedDamage(EntityDamageByEntityEvent event) { + var attempt = damaging; + if (closed || attempt == null || attempt.reported || event.isCancelled() + || event.getDamager() != attempt.execution.input().player() || event.getEntity() != attempt.target + || (event.getDamageSource().getCausingEntity() != null + && event.getDamageSource().getCausingEntity() != attempt.execution.input().player()) + || (event.getDamageSource().getDirectEntity() != null + && event.getDamageSource().getDirectEntity() != attempt.execution.input().player()) + || event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return; + attempt.reported = true; + attempt.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(), + BigDecimal.valueOf(event.getFinalDamage()).stripTrailingZeros()); + } + + @Override public void close() { closed = true; damaging = null; } +} diff --git a/src/main/java/games/dmg/fantasystick/book/CharacterBookController.java b/src/main/java/games/dmg/fantasystick/book/CharacterBookController.java index 2b84695..00ba6c7 100644 --- a/src/main/java/games/dmg/fantasystick/book/CharacterBookController.java +++ b/src/main/java/games/dmg/fantasystick/book/CharacterBookController.java @@ -174,7 +174,7 @@ public final class CharacterBookController implements Consumer, Au String text = definition.name() + "\nIcon: " + definition.icon().name() + "\n\n" + String.join("\n", definition.lore()) + "\n\n" + definition.instructions() + "\nRequires: " + requirements + "\nCost: " + definition.pointCost() - + "\nCooldown: " + definition.cooldown().toSeconds() + "s" + parameters + "\n\n" + state; + + "\nCooldown: " + durationSeconds(definition.cooldown()) + "s" + parameters + "\n\n" + state; var pages = new java.util.ArrayList<>(BookPagination.pages(text)); if (!learned && stats && record.unspentPoints().compareTo(definition.pointCost()) >= 0) { pages.add(Component.text("Ability action\n\n").append(abilityButton("Learn", ABILITY_UNLOCK, view, definition.id(), null))); @@ -191,6 +191,11 @@ public final class CharacterBookController implements Consumer, Au return java.util.List.copyOf(pages); } + private static String durationSeconds(java.time.Duration duration) { + return java.math.BigDecimal.valueOf(duration.getSeconds()) + .add(java.math.BigDecimal.valueOf(duration.getNano(), 9)).stripTrailingZeros().toPlainString(); + } + private static Component abilityButton(String label, net.kyori.adventure.key.Key action, AbilityView view, String ability, Integer slot) { String payload = "\"" + view.nonce() + ":" + ability + (slot == null ? "" : ":" + slot) + "\""; diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 7407cb1..bb8dd7d 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -21,3 +21,7 @@ fireball-base-damage: 6 fireball-cooldown-seconds: 4 fireball-range-blocks: 32 fireball-lifetime-seconds: 5 + +# Great Sword defaults from the reviewed starter catalogue. +great-sword-base-damage: 7 +great-sword-recovery-seconds: 1.2 diff --git a/src/nativeTest/java/games/dmg/fantasystick/GreatSwordNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/GreatSwordNativeTest.java new file mode 100644 index 0000000..54ef34e --- /dev/null +++ b/src/nativeTest/java/games/dmg/fantasystick/GreatSwordNativeTest.java @@ -0,0 +1,369 @@ +package games.dmg.fantasystick; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.math.BigInteger; +import java.nio.file.Path; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class GreatSwordNativeTest { + @TempDir Path data; + @BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); } + + @Test void bookAcquisitionAndFullyChargedEquippedStrikeReplaceVanillaDamage() throws Exception { + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + f.plugin.setEnabled(true); + var a = new FantasyStickLifecycleNativeTest.Actor(data); + withStrength(data, a, 12); + f.players.put(a.core.id, a.player); f.activate(a); + + a.core.inventory.getInventory().setSelectedSlot(0); + PartyBookNativeTest.open(f, a); + String book = PartyBookNativeTest.text(a); + assertTrue(book.contains("Great Sword")); + assertTrue(book.contains("STR 12")); + assertTrue(book.contains("1.2")); + var learn = abilityButton(a, "[Learn]", "great_sword"); + PartyBookNativeTest.click(f, a, learn); PartyBookNativeTest.click(f, a, learn); + PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); + PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "great_sword")); + PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); + + var storage = (games.dmg.fantasystick.persistence.CharacterStorage) + FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters"); + var assigned = storage.find(a.core.record.id()).get().orElseThrow(); + assertTrue(assigned.learned().contains("great_sword")); + assertEquals("great_sword", assigned.loadout().getFirst()); + assertEquals(BigInteger.valueOf(39), assigned.unspentPoints()); + assertEquals(org.bukkit.Material.WOODEN_SWORD, a.core.inventory.getItem(2).getType()); + + when(a.player.getAttackCooldown()).thenReturn(1.0f); + var target = mock(org.bukkit.entity.LivingEntity.class); + when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID()); + when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE); + when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID()); + when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 4, 64, 0)); + var damages = new java.util.ArrayList(); + doAnswer(call -> { + var source = (org.bukkit.damage.DamageSource) call.getArgument(1); + var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, target, + org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, + (Double) call.getArgument(0)); + damages.add(event); f.dispatcher.accept(event); return null; + }).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class)); + + a.core.inventory.getInventory().setSelectedSlot(2); + var before = java.time.Instant.now(); + var attack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true); + f.dispatcher.accept(attack); + assertTrue(attack.isCancelled(), "The bound sword must never continue into vanilla damage"); + PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); + assertEquals(1, damages.size()); + assertEquals(8.0, damages.getFirst().getDamage()); + assertSame(a.player, damages.getFirst().getDamager()); + assertEquals(org.bukkit.damage.DamageType.PLAYER_ATTACK, damages.getFirst().getDamageSource().getDamageType()); + + 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))); + f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(target, damages.getFirst().getDamageSource(), + new java.util.ArrayList<>(), 0)); + PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); + assertEquals(BigInteger.TEN, storage.find(a.core.record.id()).get().orElseThrow().experience(), + "Accepted direct damage is attributed to the originating character"); + f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true)); + PartyBookNativeTest.drain(f); + assertEquals(1, damages.size(), "The persisted recovery prevents a repeated strike"); + assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in"))); + } + } + + @Test void partialChargeUsesTheApprovedNativeCurveWithoutVanillaContinuation() throws Exception { + java.nio.file.Files.writeString(data.resolve("config.yml"), "great-sword-recovery-seconds: 0\n"); + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + f.plugin.setEnabled(true); + var a = new FantasyStickLifecycleNativeTest.Actor(data); + withGreatSword(data, a, 12); + f.players.put(a.core.id, a.player); f.activate(a); + when(a.player.getAttackCooldown()).thenReturn(0.5f); + var target = mock(org.bukkit.entity.LivingEntity.class); + when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID()); + var damages = new java.util.ArrayList(); + doAnswer(call -> { + double amount = call.getArgument(0); damages.add(amount); + var source = (org.bukkit.damage.DamageSource) call.getArgument(1); + f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, target, + org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, amount)); + return null; + }).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class)); + a.core.inventory.getInventory().setSelectedSlot(2); + var attack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true); + f.dispatcher.accept(attack); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); + assertTrue(attack.isCancelled()); + assertEquals(java.util.List.of(3.2), damages); + } + } + + @Test void refusedPlayersAndCancelledNativeDamagePreserveProtectionWithoutFreeVanillaHits() throws Exception { + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + f.plugin.setEnabled(true); + var a = new FantasyStickLifecycleNativeTest.Actor(data); + var b = new FantasyStickLifecycleNativeTest.Actor(data); + withGreatSword(data, a, 12); + f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player); + f.activate(a); f.activate(b); + var parties = PartyLifecycleNativeTest.parties(f); + var party = parties.create(a.core.id).orElseThrow(); + assertTrue(parties.accept(b.core.id, + parties.invite(a.core.id, party.id(), b.core.id).orElseThrow().id())); + when(a.player.getAttackCooldown()).thenReturn(1.0f); + a.core.inventory.getInventory().setSelectedSlot(2); + + var partyAttack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, b.player, true); + f.dispatcher.accept(partyAttack); PartyBookNativeTest.drain(f); + assertTrue(partyAttack.isCancelled()); + verify(b.player, never()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class)); + + var ordinary = mock(org.bukkit.entity.Player.class); + when(ordinary.getUniqueId()).thenReturn(java.util.UUID.randomUUID()); + var ordinaryAttack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, ordinary, true); + f.dispatcher.accept(ordinaryAttack); PartyBookNativeTest.drain(f); + assertTrue(ordinaryAttack.isCancelled()); + verify(ordinary, never()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class)); + + var target = mock(org.bukkit.entity.LivingEntity.class); + when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID()); + when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE); + when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID()); + when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 4, 64, 0)); + var damage = new java.util.concurrent.atomic.AtomicReference(); + doAnswer(call -> { + var source = (org.bukkit.damage.DamageSource) call.getArgument(1); + var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, target, + org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, + (Double) call.getArgument(0)); + event.setCancelled(true); damage.set(event); f.dispatcher.accept(event); return null; + }).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class)); + f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true)); + PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); + assertNotNull(damage.get()); assertTrue(damage.get().isCancelled()); + var storage = (games.dmg.fantasystick.persistence.CharacterStorage) + FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters"); + assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("great_sword"), + "An acknowledged recovery is not refunded by later native cancellation"); + f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(target, damage.get().getDamageSource(), + new java.util.ArrayList<>(), 0)); + PartyBookNativeTest.drain(f); + assertEquals(BigInteger.ZERO, storage.find(a.core.record.id()).get().orElseThrow().experience()); + } + } + + @Test void unlearnRelearnAndWholeCharacterTransferRetainTheOriginalRecovery() throws Exception { + java.nio.file.Files.writeString(data.resolve("config.yml"), "great-sword-recovery-seconds: 60\n"); + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + f.plugin.setEnabled(true); + var a = new FantasyStickLifecycleNativeTest.Actor(data); withStrength(data, a, 12); + f.players.put(a.core.id, a.player); f.activate(a); + learnAndAssign(f, a); + when(a.player.getAttackCooldown()).thenReturn(1.0f); + 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(); + var source = (org.bukkit.damage.DamageSource) call.getArgument(1); + f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, target, + org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, + (Double) call.getArgument(0))); return null; + }).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class)); + a.core.inventory.getInventory().setSelectedSlot(2); + f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true)); + PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); + assertEquals(1, hits.get()); + var storage = (games.dmg.fantasystick.persistence.CharacterStorage) + FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters"); + var deadline = storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("great_sword"); + assertNotNull(deadline); + + a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a); + var unlearn = abilityButton(a, "[Unlearn]", "great_sword"); + PartyBookNativeTest.click(f, a, unlearn); PartyBookNativeTest.click(f, a, unlearn); + PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); + var refunded = storage.find(a.core.record.id()).get().orElseThrow(); + assertEquals(BigInteger.valueOf(40), refunded.unspentPoints()); + assertFalse(refunded.learned().contains("great_sword")); + assertTrue(refunded.loadout().stream().noneMatch("great_sword"::equals)); + assertEquals(deadline, refunded.cooldowns().get("great_sword")); + assertTrue(new games.dmg.fantasystick.items.SessionControls( + (games.dmg.fantasystick.ability.AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry")) + .ability(a.core.inventory.getItem(2)).isEmpty()); + + learnAndAssign(f, a); + assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("great_sword")); + a.core.inventory.getInventory().setSelectedSlot(2); + f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true)); + PartyBookNativeTest.drain(f); + assertEquals(1, hits.get()); + assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in"))); + + var sheet = a.core.inventory.getItemInOffHand().clone(); + a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a); + var b = new FantasyStickLifecycleNativeTest.Actor(data); b.core.inventory.setItemInOffHand(sheet); + f.players.put(b.core.id, b.player); f.activate(b); + when(b.player.getAttackCooldown()).thenReturn(1.0f); + b.core.inventory.getInventory().setSelectedSlot(2); + f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(b.player, target, true)); + PartyBookNativeTest.drain(f); + assertEquals(1, hits.get(), "Transfer cannot reset the character-bound recovery"); + assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("great_sword")); + } + } + + @Test void failedCooldownPersistenceDealsNoDamageOrFreeEffect() { + var storage = mock(games.dmg.fantasystick.persistence.CharacterStorage.class); + var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class); + var parties = new games.dmg.fantasystick.party.PartyRoster(java.time.Clock.systemUTC(), ignored -> true); + var registry = new games.dmg.fantasystick.ability.AbilityRegistry(); + var sword = new games.dmg.fantasystick.ability.GreatSword( + games.dmg.fantasystick.ability.GreatSword.Settings.defaults()); + registry.register(sword.definition()); + var base = new games.dmg.fantasystick.crafting.CharacterFactory().create( + games.dmg.fantasystick.progression.ProgressionRules.defaults()); + var stats = new java.util.EnumMap<>(base.stats()); + stats.put(games.dmg.fantasystick.progression.Stat.STR, BigInteger.valueOf(12)); + var record = new games.dmg.fantasystick.persistence.CharacterRecord(base.id(), base.name(), base.revision(), stats, + base.level(), base.experience(), base.unspentPoints(), base.health(), java.util.Set.of("great_sword"), + java.util.List.of("great_sword", "", "", "", "", "", ""), base.cooldowns(), base.restrictedKills(), + false, null, null); + var active = new games.dmg.fantasystick.session.EquipmentSessions.Active(java.util.UUID.randomUUID(), + java.util.UUID.randomUUID(), record.id()); + var player = mock(org.bukkit.entity.Player.class); + var target = mock(org.bukkit.entity.LivingEntity.class); + when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID()); + when(sessions.current(active)).thenReturn(true); + when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record)); + var failed = new java.util.concurrent.CompletableFuture(); + failed.completeExceptionally(new java.io.IOException("simulated Great Sword cooldown failure")); + when(storage.commitAbilityCooldown(eq(record.id()), eq(record.revision()), eq("great_sword"), any())) + .thenReturn(failed); + var failures = new java.util.ArrayList(); + var engine = new games.dmg.fantasystick.ability.AbilityEngine(registry, storage, sessions, parties, + java.time.Clock.systemUTC(), Runnable::run, (delay, action) -> () -> {}, ignored -> {}, failures::add); + 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)); + verify(target, never()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class)); + assertEquals(1, failures.size()); + engine.close(); sword.close(); parties.close(); + } + + @Test void invalidGreatSwordBalanceFailsBeforeInstallingGameplay() throws Exception { + for (String configuration : java.util.List.of("great-sword-base-damage: -1\n", + "great-sword-recovery-seconds: -0.1\n", "great-sword-recovery-seconds: 31536001\n", + "great-sword-recovery-seconds: 0.0000000001\n", "great-sword-base-damage: nope\n")) { + var directory = data.resolve(Integer.toHexString(configuration.hashCode())); + java.nio.file.Files.createDirectories(directory); + java.nio.file.Files.writeString(directory.resolve("config.yml"), configuration); + try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(directory)) { + assertThrows(IllegalArgumentException.class, () -> fixture.plugin.setEnabled(true)); + assertTrue(fixture.order.isEmpty()); assertTrue(fixture.recipes.isEmpty()); + assertFalse(java.nio.file.Files.exists(directory.resolve("characters"))); + } + } + } + + @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"); + try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) { + f.plugin.setEnabled(true); + var registry = (games.dmg.fantasystick.ability.AbilityRegistry) + FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry"); + var definition = registry.find("great_sword").orElseThrow(); + assertEquals(java.time.Duration.ofMillis(2500), definition.cooldown()); + assertEquals(new java.math.BigDecimal("9"), definition.parameters().get("base-damage")); + assertEquals("Great Sword", definition.name()); + assertEquals(org.bukkit.Material.WOODEN_SWORD, definition.icon()); + assertEquals(java.util.Map.of(games.dmg.fantasystick.progression.Stat.STR, BigInteger.valueOf(12)), + definition.requirements()); + assertEquals(games.dmg.fantasystick.session.ControlInput.Gesture.ATTACK, definition.gesture()); + var unavailable = new FantasyStickLifecycleNativeTest.Actor(data); + f.players.put(unavailable.core.id, unavailable.player); f.activate(unavailable); + unavailable.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, unavailable); + String text = PartyBookNativeTest.text(unavailable); + assertTrue(text.contains("Great Sword")); + assertTrue(text.replaceAll("\\s+", "").contains("Unavailable:requirements"), text); + assertThrows(AssertionError.class, () -> abilityButton(unavailable, "[Learn]", "great_sword")); + } + assertEquals(new java.math.BigDecimal("8"), + games.dmg.fantasystick.ability.GreatSword.damage(BigInteger.valueOf(12), 1.0f)); + assertEquals(new java.math.BigDecimal("3.2"), + games.dmg.fantasystick.ability.GreatSword.damage(BigInteger.valueOf(12), 0.5f)); + assertEquals(new java.math.BigDecimal("1.6"), + games.dmg.fantasystick.ability.GreatSword.damage(BigInteger.valueOf(12), 0.0f)); + assertEquals(new java.math.BigDecimal(BigInteger.TEN.pow(500).subtract(BigInteger.TEN) + .divide(BigInteger.TWO).add(BigInteger.valueOf(7))), + games.dmg.fantasystick.ability.GreatSword.damage(BigInteger.TEN.pow(500), 1.0f)); + } + + private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture fixture, + FantasyStickLifecycleNativeTest.Actor actor) throws Exception { + actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor); + PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[Learn]", "great_sword")); + PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture); + PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "great_sword")); + PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture); + } + + private static net.minecraft.network.chat.ClickEvent.Custom abilityButton( + FantasyStickLifecycleNativeTest.Actor actor, String label, String ability) { + var book = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast()) + .get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT); + assertNotNull(book); + for (var page : book.pages()) { + var found = abilityButton(page.raw(), label, ability); + if (found != null) return found; + } + throw new AssertionError("Missing " + label + " action for " + ability); + } + + private static net.minecraft.network.chat.ClickEvent.Custom abilityButton( + net.minecraft.network.chat.Component component, String label, String ability) { + if (component.getString().equals(label) + && component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.Custom custom + && custom.payload().orElse(null) instanceof net.minecraft.nbt.StringTag payload + && payload.value().contains(":" + ability)) return custom; + for (var child : component.getSiblings()) { + var found = abilityButton(child, label, ability); + if (found != null) return found; + } + return null; + } + + private static void withGreatSword(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(); + var stats = new java.util.EnumMap<>(old.stats()); + stats.put(games.dmg.fantasystick.progression.Stat.STR, BigInteger.valueOf(strength)); + repository.replace(new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), + old.revision().add(BigInteger.ONE), stats, old.level(), old.experience(), old.unspentPoints(), old.health(), + java.util.Set.of("great_sword"), java.util.List.of("great_sword", "", "", "", "", "", ""), + old.cooldowns(), old.restrictedKills(), false, null, null), old.revision()); + } + + 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(); + var stats = new java.util.EnumMap<>(old.stats()); + stats.put(games.dmg.fantasystick.progression.Stat.STR, BigInteger.valueOf(strength)); + repository.replace(new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), + old.revision().add(BigInteger.ONE), stats, old.level(), old.experience(), old.unspentPoints(), old.health(), + old.learned(), old.loadout(), old.cooldowns(), old.restrictedKills(), false, null, null), old.revision()); + } +} diff --git a/src/test/java/games/dmg/fantasystick/DistributionTest.java b/src/test/java/games/dmg/fantasystick/DistributionTest.java index 19e90af..bdc1d61 100644 --- a/src/test/java/games/dmg/fantasystick/DistributionTest.java +++ b/src/test/java/games/dmg/fantasystick/DistributionTest.java @@ -42,6 +42,8 @@ class DistributionTest { assertTrue(defaults.contains("character-swap-cooldown-seconds: 900"), "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(JavaPlugin.class.isAssignableFrom(FantasyStickPlugin.class)); var entrypoint = jar.getJarEntry(metadata.getMain().replace('.', '/') + ".class"); assertNotNull(entrypoint);