Compare commits

...
1 Commits
Author SHA1 Message Date
dmg 74e717c6ca feat(abilities): add Fireball
Release / release (push) Successful in 6m11s
2026-09-19 17:52:37 -04:00
11 changed files with 580 additions and 16 deletions

No files matched your search

@@ -48,6 +48,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.party.CombatExperience combatExperience;
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.session.NutritionPolicy nutritionPolicy;
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
@@ -74,6 +75,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
var switchDelay = switchDelay();
var rules = progressionRules();
var rewardRules = combatRewardRules();
var fireballSettings = fireballSettings();
var factory = new CharacterFactory();
characters = new CharacterStorage(new YamlCharacterRepository(getDataFolder().toPath()), Clock.systemUTC());
crafting = new ManualCraftingController(characters, () -> factory.create(rules),
@@ -113,6 +115,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
sessions = new EquipmentSessions(characters, receipts, switchDelay, Clock.systemUTC(), nextTick,
notice -> notifyPlayer(generation, notice), diagnostics, presentation, adventureSettings::enabled, gameModes);
abilityRegistry = new games.dmg.fantasystick.ability.AbilityRegistry();
fireball = new games.dmg.fantasystick.ability.Fireball(fireballSettings);
abilityRegistry.register(fireball.definition());
sessions.attachAbilityRegistry(abilityRegistry);
vitals = new games.dmg.fantasystick.session.SessionVitals();
sessions.attachVitals(vitals);
@@ -167,6 +171,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
partyCombat.install(this);
getServer().getPluginManager().registerEvents(combatExperience, this);
combatExperience.install(this);
getServer().getPluginManager().registerEvents(fireball, this);
getServer().getPluginManager().registerEvents(localBasicAttack, this);
getServer().getPluginManager().registerEvents(nutritionPolicy, this);
getServer().getPluginManager().registerEvents(books, this);
@@ -209,6 +214,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingBasicAttack = basicAttack; basicAttack = null;
var closingCombatExperience = combatExperience; combatExperience = null;
var closingAbilityEngine = abilityEngine; abilityEngine = null;
var closingFireball = fireball; fireball = null;
abilityRegistry = null;
var closingNutrition = nutritionPolicy; nutritionPolicy = null;
// Retire client resources independently of successful ordinary-equipment restoration.
@@ -222,6 +228,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (closingBooks != null) cleanup("character book", closingBooks::close);
if (closingBasicAttack != null) cleanup("basic attack", closingBasicAttack::close);
if (closingCombatExperience != null) cleanup("combat experience", closingCombatExperience::close);
if (closingFireball != null) cleanup("Fireball", closingFireball::close);
if (closingAbilityEngine != null) cleanup("ability engine", closingAbilityEngine::close);
if (closingNutrition != null) cleanup("nutrition policy", closingNutrition::close);
if (closingVisuals != null) cleanup("session particles", closingVisuals::close);
@@ -236,6 +243,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
unregister(closingPartyEvents);
unregister(closingPartyCombat);
unregister(closingCombatExperience);
unregister(closingFireball);
unregister(closingBasicAttack);
unregister(closingNutrition);
unregister(closingBooks);
@@ -291,6 +299,20 @@ public final class FantasyStickPlugin extends JavaPlugin {
}
}
private games.dmg.fantasystick.ability.Fireball.Settings fireballSettings() {
try {
var base = new java.math.BigInteger(getConfig().getString("fireball-base-damage", "6"));
var cooldown = java.time.Duration.ofSeconds(new java.math.BigInteger(
getConfig().getString("fireball-cooldown-seconds", "4")).longValueExact());
double range = new java.math.BigDecimal(getConfig().getString("fireball-range-blocks", "32")).doubleValue();
var lifetime = java.time.Duration.ofSeconds(new java.math.BigInteger(
getConfig().getString("fireball-lifetime-seconds", "5")).longValueExact());
return new games.dmg.fantasystick.ability.Fireball.Settings(base, cooldown, range, lifetime);
} catch (ArithmeticException | NumberFormatException invalid) {
throw new IllegalArgumentException("Fireball 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);
@@ -17,7 +17,7 @@ public record AbilityDefinition(String id, String name, Material icon, List<Stri
ControlInput.Gesture gesture, Duration cooldown, Map<String, BigDecimal> parameters,
TargetPolicy targetPolicy, AbilityEffect effect) {
public enum Activation { IMMEDIATE, DELAYED, PROJECTILE, AREA, SELECTION }
public enum TargetPolicy { SELF, HOSTILE, ALLY, PARTY, AREA, SELECTION, EXCEPTIONAL_NONCOMBAT }
public enum TargetPolicy { SELF, HOSTILE, PROJECTILE_HOSTILE, ALLY, PARTY, AREA, SELECTION, EXCEPTIONAL_NONCOMBAT }
private static final Pattern ID = Pattern.compile("[a-z][a-z0-9_]{0,63}");
private static final Pattern PARAMETER = Pattern.compile("[a-z][a-z0-9_-]{0,63}");
@@ -212,8 +212,8 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
private boolean eligible(AbilityDefinition.TargetPolicy policy, ControlInput input, Entity target) {
return switch (policy) {
case SELF -> target == null || target == input.player();
case HOSTILE -> target instanceof LivingEntity && target != input.player()
&& (!(target instanceof Player player) || fantasy(player) && !sameParty(input.player(), player));
case HOSTILE -> hostile(input, target);
case PROJECTILE_HOSTILE -> target == null || hostile(input, target);
case ALLY -> target instanceof Player player && fantasy(player);
case PARTY -> target instanceof Player player
&& (player == input.player() || fantasy(player) && sameParty(input.player(), player));
@@ -223,6 +223,11 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
};
}
private boolean hostile(ControlInput input, Entity target) {
return target instanceof LivingEntity && target != input.player()
&& (!(target instanceof Player player) || fantasy(player) && !sameParty(input.player(), player));
}
private boolean fantasy(Player player) { return sessions.active(player.getUniqueId()).filter(sessions::current).isPresent(); }
private boolean sameParty(Player source, Player target) {
return parties.partyOf(source.getUniqueId()).filter(party -> party.members().contains(target.getUniqueId())).isPresent();
@@ -255,6 +260,9 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
}
@Override public AbilityDefinition definition() { return definition; }
@Override public ControlInput input() { return input; }
@Override public CharacterRecord character() {
return sessions.currentRecord(input.session()).orElseThrow(() -> new IllegalStateException("Ability session ended"));
}
@Override public boolean stillCurrent() {
return !closed && !finished && input.stillCurrent() && sessions.current(input.session())
&& generations.getOrDefault(key, 0L) == generation
@@ -11,6 +11,7 @@ import org.bukkit.entity.Entity;
public interface AbilityExecution extends AutoCloseable {
AbilityDefinition definition();
ControlInput input();
games.dmg.fantasystick.persistence.CharacterRecord character();
boolean stillCurrent();
List<Entity> eligible(Collection<? extends Entity> candidates, int maximum);
AbilityScheduler.Task later(Duration delay, Runnable action);
@@ -0,0 +1,162 @@
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.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Particle;
import org.bukkit.damage.DamageSource;
import org.bukkit.damage.DamageType;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Snowball;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.ProjectileHitEvent;
import org.bukkit.util.Vector;
/** Fireball's owned snowball projectile, direct custom damage and bounded cleanup adapter. */
public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
private static final double SPEED_PER_TICK = 1.0;
public record Settings(BigInteger baseDamage, Duration cooldown, double range, Duration lifetime) {
public Settings {
Objects.requireNonNull(baseDamage); Objects.requireNonNull(cooldown); Objects.requireNonNull(lifetime);
if (baseDamage.signum() < 0 || cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|| !Double.isFinite(range) || range <= 0 || range > 1024
|| lifetime.isZero() || lifetime.isNegative() || lifetime.compareTo(Duration.ofSeconds(60)) > 0) {
throw new IllegalArgumentException("Invalid Fireball settings");
}
}
public static Settings defaults() {
return new Settings(BigInteger.valueOf(6), Duration.ofSeconds(4), 32, Duration.ofSeconds(5));
}
}
private final Settings settings;
private final Map<UUID, State> projectiles = new HashMap<>();
private State damaging;
private boolean closed;
private final class State implements AutoCloseable {
final Snowball projectile;
final AbilityExecution execution;
final Location origin;
final BigInteger intelligence;
LivingEntity target;
boolean finished;
State(Snowball projectile, AbilityExecution execution, Location origin, BigInteger intelligence) {
this.projectile = projectile; this.execution = execution; this.origin = origin; this.intelligence = intelligence;
}
@Override public void close() {
if (finished) return;
finished = true;
projectiles.remove(projectile.getUniqueId(), this);
projectile.remove();
}
}
public Fireball(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
return new AbilityDefinition("fireball", "Fireball", Material.SNOWBALL,
List.of("Launch a bounded fiery snowball.", "Deals " + settings.baseDamage() + " + INT modifier damage."),
"Use the equipped snowball; a miss still spends the cooldown.",
Map.of(Stat.INT, BigInteger.valueOf(12)), BigInteger.ONE,
AbilityDefinition.Activation.PROJECTILE, ControlInput.Gesture.USE, settings.cooldown(),
Map.of("base-damage", new BigDecimal(settings.baseDamage()),
"range", BigDecimal.valueOf(settings.range()).stripTrailingZeros(),
"lifetime", BigDecimal.valueOf(settings.lifetime().toMillis(), 3).stripTrailingZeros()),
AbilityDefinition.TargetPolicy.PROJECTILE_HOSTILE, this);
}
public static BigInteger damage(BigInteger intelligence) {
return damage(BigInteger.valueOf(6), intelligence);
}
static BigInteger damage(BigInteger base, BigInteger intelligence) {
Objects.requireNonNull(base); Objects.requireNonNull(intelligence);
if (base.signum() < 0 || intelligence.signum() < 0) throw new IllegalArgumentException("Invalid Fireball damage input");
BigInteger delta = intelligence.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);
return base.add(modifier);
}
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) return;
var player = execution.input().player();
Location origin = player.getEyeLocation().clone();
Vector direction = origin.getDirection();
if (!Double.isFinite(direction.getX()) || !Double.isFinite(direction.getY()) || !Double.isFinite(direction.getZ())
|| direction.lengthSquared() == 0) throw new IllegalStateException("Fireball direction is unavailable");
var projectile = player.launchProjectile(Snowball.class, direction.normalize().multiply(SPEED_PER_TICK));
projectile.setVisualFire(true);
var state = new State(projectile, execution, origin, execution.character().stats().get(Stat.INT));
if (projectiles.putIfAbsent(projectile.getUniqueId(), state) != null) {
projectile.remove(); throw new IllegalStateException("Duplicate Fireball projectile identity");
}
execution.own(state);
long rangeTicks = Math.max(1, (long) Math.ceil(settings.range() / SPEED_PER_TICK));
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), execution::finish);
execution.later(settings.lifetime(), execution::finish);
player.getWorld().spawnParticle(Particle.FLAME, origin, 8, 0.15, 0.15, 0.15, 0.01);
}
@EventHandler(priority = EventPriority.MONITOR)
public void hit(ProjectileHitEvent event) {
var state = projectiles.remove(event.getEntity().getUniqueId());
if (state == null) return;
try {
if (closed || event.isCancelled() || !state.execution.stillCurrent()
|| !(event.getHitEntity() instanceof LivingEntity target) || !inRange(state, event.getEntity().getLocation())
|| state.execution.eligible(List.of(target), 1).isEmpty()) return;
BigInteger amount = damage(settings.baseDamage(), state.intelligence);
double nativeDamage = amount.bitLength() > 1023 ? Double.MAX_VALUE : amount.doubleValue();
var source = DamageSource.builder(DamageType.FIREBALL).withCausingEntity(state.execution.input().player())
.withDirectEntity(state.projectile).withDamageLocation(state.projectile.getLocation()).build();
state.target = target;
damaging = state;
try { target.damage(nativeDamage, source); }
finally { if (damaging == state) damaging = null; }
} finally {
state.execution.finish();
}
}
private boolean inRange(State state, Location current) {
return current.getWorld() != null && state.origin.getWorld() != null
&& current.getWorld().equals(state.origin.getWorld())
&& current.distanceSquared(state.origin) <= settings.range() * settings.range();
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void acceptedDamage(EntityDamageEvent event) {
var state = damaging;
if (state == null || event.getEntity() != state.target || event.getFinalDamage() <= 0
|| !Double.isFinite(event.getFinalDamage())) return;
var direct = event.getDamageSource().getDirectEntity();
if (direct != state.projectile && (!(event instanceof org.bukkit.event.entity.EntityDamageByEntityEvent byEntity)
|| byEntity.getDamager() != state.projectile)) return;
var causing = event.getDamageSource().getCausingEntity();
if (causing != null && causing != state.execution.input().player()) return;
state.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(), BigDecimal.valueOf(event.getFinalDamage()));
}
@Override public void close() {
if (closed) return;
closed = true;
for (var state : List.copyOf(projectiles.values())) state.execution.finish();
projectiles.clear(); damaging = null;
}
}
@@ -135,12 +135,12 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
}
pages.add(purchases);
var definitions = abilities.definitions();
if (!definitions.isEmpty()) {
if (!definitions.isEmpty() && end == all.size()) {
var abilityView = new AbilityView(UUID.randomUUID().toString(), input, record.revision(),
definitions.stream().map(games.dmg.fantasystick.ability.AbilityDefinition::id)
.collect(java.util.stream.Collectors.toUnmodifiableSet()));
abilityViews.put(input.session().player(), abilityView);
for (var definition : definitions) pages.add(abilityPage(record, definition, abilityView));
for (var definition : definitions) pages.addAll(abilityPages(record, definition, abilityView));
}
if (all.size() > VOLUME_SIZE) {
var view = new View(UUID.randomUUID().toString(), input, start > 0 ? start - VOLUME_SIZE : -1,
@@ -157,7 +157,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
return item;
}
private static Component abilityPage(CharacterRecord record,
private static java.util.List<Component> abilityPages(CharacterRecord record,
games.dmg.fantasystick.ability.AbilityDefinition definition, AbilityView view) {
String requirements = definition.requirements().isEmpty() ? "none" : definition.requirements().entrySet().stream()
.sorted(Map.Entry.comparingByKey()).map(entry -> entry.getKey() + " " + entry.getValue())
@@ -170,20 +170,22 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
String parameters = definition.parameters().isEmpty() ? "" : "\nParameters: " + definition.parameters().entrySet().stream()
.sorted(Map.Entry.comparingByKey()).map(entry -> entry.getKey() + "=" + entry.getValue())
.collect(java.util.stream.Collectors.joining(", "));
Component page = Component.text(definition.name() + "\nIcon: " + definition.icon().name()
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: " + definition.cooldown().toSeconds() + "s" + parameters + "\n\n" + state;
var pages = new java.util.ArrayList<>(BookPagination.pages(text));
if (!learned && stats && record.unspentPoints().compareTo(definition.pointCost()) >= 0) {
page = page.append(Component.newline()).append(abilityButton("Learn", ABILITY_UNLOCK, view, definition.id(), null));
pages.add(Component.text("Ability action\n\n").append(abilityButton("Learn", ABILITY_UNLOCK, view, definition.id(), null)));
} else if (learned) {
page = page.append(Component.text("\nAssign slot: "));
Component action = Component.text("Assign " + definition.name() + "\n\nSlot: ");
for (int slot = 0; slot < 7; slot++) {
if (slot > 0) page = page.append(Component.space());
page = page.append(abilityButton(Integer.toString(slot + 3), ABILITY_ASSIGN, view, definition.id(), slot));
if (slot > 0) action = action.append(Component.space());
action = action.append(abilityButton(Integer.toString(slot + 3), ABILITY_ASSIGN, view, definition.id(), slot));
}
pages.add(action);
}
return page;
return java.util.List.copyOf(pages);
}
private static Component abilityButton(String label, net.kyori.adventure.key.Key action, AbilityView view,
+6
View File
@@ -12,3 +12,9 @@ passive-xp: 1
ordinary-xp: 10
boss-xp: 100
restricted-kill-spacing-blocks: 3
# Fireball defaults from the reviewed starter catalogue.
fireball-base-damage: 6
fireball-cooldown-seconds: 4
fireball-range-blocks: 32
fireball-lifetime-seconds: 5
@@ -144,8 +144,11 @@ class AbilityEngineNativeTest {
PartyBookNativeTest.open(f, a);
String available = PartyBookNativeTest.text(a);
assertTrue(available.contains("Test Focus")); assertTrue(available.contains("AMETHYST_SHARD"));
assertTrue(available.contains("Cost: 1")); assertTrue(available.contains("Cooldown: 4s"));
String compactAvailable = available.replaceAll("\\s+", "");
assertTrue(compactAvailable.contains("TestFocus"), available);
assertTrue(compactAvailable.contains("AMETHYST_SHARD"), available);
assertTrue(compactAvailable.contains("Cost:1"), available);
assertTrue(compactAvailable.contains("Cooldown:4s"), available);
var learn = PartyBookNativeTest.button(a, "[Learn]");
PartyBookNativeTest.click(f, a, learn); PartyBookNativeTest.click(f, a, learn);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
@@ -65,6 +65,8 @@ class FantasyStickLifecycleNativeTest {
assertTrue(fixture.order.indexOf("PartyCombat") < recipe);
assertTrue(fixture.order.indexOf("CombatExperience") >= 0);
assertTrue(fixture.order.indexOf("CombatExperience") < recipe);
assertTrue(fixture.order.indexOf("Fireball") >= 0);
assertTrue(fixture.order.indexOf("Fireball") < recipe);
assertNotNull(Fixture.field(fixture.plugin, "abilityEngine"));
assertNotNull(Fixture.field(fixture.plugin, "abilityRegistry"));
assertTrue(fixture.order.indexOf("BasicAttack") >= 0);
@@ -136,7 +138,7 @@ class FantasyStickLifecycleNativeTest {
@Test
void recipeCollisionAndPartialListenerRegistrationFailureRollbackOnlyThisEnableAttempt() throws Exception {
for (String stage : List.of("collision", "recipe", "ManualCraftingGuard", "CharacterAnvilNames", "EquipmentSessionEvents", "CharacterChatNames", "ProtocolPresentation", "CombatExperience", "presentation-timer")) {
for (String stage : List.of("collision", "recipe", "ManualCraftingGuard", "CharacterAnvilNames", "EquipmentSessionEvents", "CharacterChatNames", "ProtocolPresentation", "CombatExperience", "Fireball", "presentation-timer")) {
boolean collision = stage.equals("collision");
try (var fixture = new Fixture(data)) {
var existing = new org.bukkit.inventory.ShapelessRecipe(CharacterSheets.RECIPE,
@@ -872,6 +874,22 @@ class FantasyStickLifecycleNativeTest {
}
}
@Test void invalidFireballConfigurationFailsBeforeNativeRegistrationWithoutRewritingConfig() throws Exception {
int scenario = 0;
for (String row : List.of("fireball-base-damage: -1", "fireball-cooldown-seconds: -1",
"fireball-range-blocks: 0", "fireball-range-blocks: 1e1000000",
"fireball-lifetime-seconds: 0", "fireball-lifetime-seconds: 61")) {
var directory = data.resolve("invalid-fireball-" + scenario++); java.nio.file.Files.createDirectories(directory);
String config = row + "\n"; java.nio.file.Files.writeString(directory.resolve("config.yml"), config);
try (var f = new Fixture(directory)) {
assertThrows(IllegalArgumentException.class, () -> f.plugin.setEnabled(true), row);
assertTrue(f.order.isEmpty()); assertTrue(f.recipes.isEmpty()); assertTrue(f.tasks.isEmpty());
assertFalse(java.nio.file.Files.exists(directory.resolve("characters")));
assertEquals(config, java.nio.file.Files.readString(directory.resolve("config.yml")));
}
}
}
@Test void invalidCombatRewardConfigurationFailsBeforeNativeRegistrationWithoutRewritingConfig() throws Exception {
int scenario = 0;
for (String row : List.of("passive-xp: -1", "ordinary-xp: 1.5", "boss-xp: nope",
@@ -0,0 +1,315 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
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 FireballNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void bookAcquisitionEquippedUseAndDirectHitDealOneAttributedScaledDamageWithCooldown() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withInt(data, a, 12);
f.players.put(a.core.id, a.player); f.activate(a);
a.core.inventory.getInventory().setSelectedSlot(0);
PartyBookNativeTest.open(f, a);
assertTrue(PartyBookNativeTest.text(a).contains("Fireball"));
assertTrue(PartyBookNativeTest.text(a).contains("INT 12"));
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[Learn]"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[3]"));
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("fireball")); assertEquals("fireball", assigned.loadout().getFirst());
assertEquals(BigInteger.valueOf(39), assigned.unspentPoints());
assertEquals(org.bukkit.Material.SNOWBALL, a.core.inventory.getItem(2).getType());
org.mockito.Mockito.when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
var eye = new org.bukkit.Location(a.world, 0, 65.6, 0);
eye.setDirection(new org.bukkit.util.Vector(1, 0, 0));
org.mockito.Mockito.doReturn(eye).when(a.player).getEyeLocation();
var projectile = projectile(a, new org.bukkit.Location(a.world, 10, 65.6, 0));
var launches = new java.util.concurrent.atomic.AtomicInteger();
var launchedVelocity = new java.util.concurrent.atomic.AtomicReference<org.bukkit.util.Vector>();
org.mockito.Mockito.doAnswer(call -> {
launches.incrementAndGet(); launchedVelocity.set(call.getArgument(1));
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileLaunchEvent(projectile));
return projectile;
}).when(a.player).launchProjectile(org.mockito.ArgumentMatchers.eq(org.bukkit.entity.Snowball.class),
org.mockito.ArgumentMatchers.any(org.bukkit.util.Vector.class));
a.core.inventory.getInventory().setSelectedSlot(2);
var cast = new org.bukkit.event.player.PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
a.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
f.dispatcher.accept(cast); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(org.bukkit.event.Event.Result.DENY, cast.useItemInHand()); assertEquals(1, launches.get());
org.mockito.Mockito.verify(projectile).setVisualFire(true);
assertEquals(new org.bukkit.util.Vector(1, 0, 0), launchedVelocity.get());
var target = org.mockito.Mockito.mock(org.bukkit.entity.LivingEntity.class);
org.mockito.Mockito.when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
org.mockito.Mockito.when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
org.mockito.Mockito.when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 10, 65.6, 0));
var damages = new java.util.ArrayList<org.bukkit.event.entity.EntityDamageByEntityEvent>();
org.mockito.Mockito.doAnswer(call -> {
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(projectile, target,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, source, (Double) call.getArgument(0));
damages.add(event); f.dispatcher.accept(event); return null;
}).when(target).damage(org.mockito.ArgumentMatchers.anyDouble(), org.mockito.ArgumentMatchers.any(org.bukkit.damage.DamageSource.class));
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(projectile, target));
assertEquals(1, damages.size()); assertEquals(7.0, damages.getFirst().getDamage());
assertFalse(damages.getFirst().isCancelled());
assertSame(projectile, damages.getFirst().getDamager());
assertEquals(org.bukkit.damage.DamageType.FIREBALL, damages.getFirst().getDamageSource().getDamageType());
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(),
"Native projectile attribution reaches the originating character's combat XP");
org.mockito.Mockito.verify(projectile, org.mockito.Mockito.atLeastOnce()).remove();
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(projectile, target));
assertEquals(1, damages.size(), "A projectile can deal custom damage only once");
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
a.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND));
PartyBookNativeTest.drain(f);
assertEquals(1, launches.get(), "The persisted four-second deadline refuses immediate reuse");
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
var deadline = storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("fireball");
assertTrue(deadline.isAfter(java.time.Instant.now()));
var sheet = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
a.core.inventory.setItemInOffHand(sheet); f.activate(a);
a.core.inventory.getInventory().setSelectedSlot(2);
assertEquals("fireball", new games.dmg.fantasystick.items.SessionControls(
(games.dmg.fantasystick.ability.AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry"))
.ability(a.core.inventory.getItem(2)).orElseThrow());
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
a.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND));
PartyBookNativeTest.drain(f);
assertEquals(1, launches.get(), "Exit/resume and control reconstruction preserve the same Fireball deadline");
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("fireball"));
}
}
@Test void partyAndMixedModeProjectileHitsPreserveProtectionCancellation() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "fireball-cooldown-seconds: 0\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var b = new FantasyStickLifecycleNativeTest.Actor(data);
withFireball(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()));
org.mockito.Mockito.when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
var first = projectile(a, new org.bukkit.Location(a.world, 10, 65.6, 0));
var second = projectile(a, new org.bukkit.Location(a.world, 10, 65.6, 0));
prepareLaunches(f, a, first, second);
cast(f, a); var partyHit = new org.bukkit.event.entity.ProjectileHitEvent(first, b.player); f.dispatcher.accept(partyHit);
assertTrue(partyHit.isCancelled());
org.mockito.Mockito.verify(b.player, org.mockito.Mockito.never()).damage(org.mockito.ArgumentMatchers.anyDouble(),
org.mockito.ArgumentMatchers.any(org.bukkit.damage.DamageSource.class));
var ordinary = org.mockito.Mockito.mock(org.bukkit.entity.Player.class);
org.mockito.Mockito.when(ordinary.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
cast(f, a); var mixedHit = new org.bukkit.event.entity.ProjectileHitEvent(second, ordinary); f.dispatcher.accept(mixedHit);
assertTrue(mixedHit.isCancelled());
org.mockito.Mockito.verify(ordinary, org.mockito.Mockito.never()).damage(org.mockito.ArgumentMatchers.anyDouble(),
org.mockito.ArgumentMatchers.any(org.bukkit.damage.DamageSource.class));
assertTrue(org.mockito.Mockito.mockingDetails(a.world).getInvocations().stream()
.noneMatch(invocation -> invocation.getMethod().getName().equals("createExplosion")));
}
}
@Test void rangeLifetimeAndSessionEndEachRemoveOwnedProjectileWithoutLateDamage() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "fireball-cooldown-seconds: 0\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withFireball(data, a, 12);
f.players.put(a.core.id, a.player); f.activate(a);
org.mockito.Mockito.when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
var outside = projectile(a, new org.bukkit.Location(a.world, 32.01, 65.6, 0));
var boundary = projectile(a, new org.bukkit.Location(a.world, 32, 65.6, 0));
var expiring = projectile(a, new org.bukkit.Location(a.world, 1, 65.6, 0));
var exiting = projectile(a, new org.bukkit.Location(a.world, 1, 65.6, 0));
prepareLaunches(f, a, outside, boundary, expiring, exiting);
var target = org.mockito.Mockito.mock(org.bukkit.entity.LivingEntity.class);
org.mockito.Mockito.when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
cast(f, a); f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(outside, target));
org.mockito.Mockito.verify(target, org.mockito.Mockito.never()).damage(org.mockito.ArgumentMatchers.anyDouble(),
org.mockito.ArgumentMatchers.any(org.bukkit.damage.DamageSource.class));
org.mockito.Mockito.verify(outside, org.mockito.Mockito.atLeastOnce()).remove();
cast(f, a); f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(boundary, target));
org.mockito.Mockito.verify(target).damage(org.mockito.ArgumentMatchers.eq(7.0),
org.mockito.ArgumentMatchers.any(org.bukkit.damage.DamageSource.class));
cast(f, a); Runnable lifetime = f.delayed.getLast(); lifetime.run();
org.mockito.Mockito.verify(expiring, org.mockito.Mockito.atLeastOnce()).remove();
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(expiring, target));
org.mockito.Mockito.verify(target, org.mockito.Mockito.times(1)).damage(org.mockito.ArgumentMatchers.eq(7.0),
org.mockito.ArgumentMatchers.any(org.bukkit.damage.DamageSource.class));
cast(f, a);
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
((games.dmg.fantasystick.ability.AbilityEngine) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityEngine")).sweep();
org.mockito.Mockito.verify(exiting, org.mockito.Mockito.atLeastOnce()).remove();
}
}
@Test void validatedConfiguredBalanceReachesTheRegisteredDefinition() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "fireball-base-damage: 9\n"
+ "fireball-cooldown-seconds: 2\nfireball-range-blocks: 40\nfireball-lifetime-seconds: 7\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("fireball").orElseThrow();
assertEquals(java.time.Duration.ofSeconds(2), definition.cooldown());
assertEquals(new java.math.BigDecimal("9"), definition.parameters().get("base-damage"));
assertEquals(0, new java.math.BigDecimal("40").compareTo(definition.parameters().get("range")));
assertEquals(new java.math.BigDecimal("7"), definition.parameters().get("lifetime"));
assertTrue(String.join(" ", definition.lore()).contains("Deals 9"));
}
}
@Test void failedCooldownPersistenceLaunchesNoProjectileOrFreeEffect() {
var storage = org.mockito.Mockito.mock(games.dmg.fantasystick.persistence.CharacterStorage.class);
var sessions = org.mockito.Mockito.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 fireball = new games.dmg.fantasystick.ability.Fireball(games.dmg.fantasystick.ability.Fireball.Settings.defaults());
registry.register(fireball.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.INT, 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("fireball"),
java.util.List.of("fireball", "", "", "", "", "", ""), 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 = org.mockito.Mockito.mock(org.bukkit.entity.Player.class);
org.mockito.Mockito.when(sessions.current(active)).thenReturn(true);
org.mockito.Mockito.when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record));
var failed = new java.util.concurrent.CompletableFuture<games.dmg.fantasystick.persistence.CharacterStorage.AbilityMutation>();
failed.completeExceptionally(new java.io.IOException("simulated cooldown failure"));
org.mockito.Mockito.when(storage.commitAbilityCooldown(org.mockito.ArgumentMatchers.eq(record.id()),
org.mockito.ArgumentMatchers.eq(record.revision()), org.mockito.ArgumentMatchers.eq("fireball"),
org.mockito.ArgumentMatchers.any())).thenReturn(failed);
var failures = new java.util.ArrayList<games.dmg.fantasystick.ability.AbilityEngine.Failure>();
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, "fireball",
games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,
games.dmg.fantasystick.session.ControlInput.Gesture.USE, null, 0, 0, () -> true));
org.mockito.Mockito.verify(player, org.mockito.Mockito.never()).launchProjectile(
org.mockito.ArgumentMatchers.eq(org.bukkit.entity.Snowball.class), org.mockito.ArgumentMatchers.any());
assertEquals(1, failures.size());
engine.close(); fireball.close(); parties.close();
}
@Test void formulaAndPackagedDefinitionMatchTheReviewedFireballContract() throws Exception {
assertEquals(BigInteger.valueOf(6), games.dmg.fantasystick.ability.Fireball.damage(BigInteger.TEN));
assertEquals(BigInteger.valueOf(7), games.dmg.fantasystick.ability.Fireball.damage(BigInteger.valueOf(12)));
assertEquals(BigInteger.valueOf(7), games.dmg.fantasystick.ability.Fireball.damage(BigInteger.valueOf(13)));
assertEquals(BigInteger.valueOf(8), games.dmg.fantasystick.ability.Fireball.damage(BigInteger.valueOf(14)));
assertEquals(BigInteger.TEN.pow(500).subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(6)),
games.dmg.fantasystick.ability.Fireball.damage(BigInteger.TEN.pow(500)));
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 fireball = registry.find("fireball").orElseThrow();
assertEquals("Fireball", fireball.name());
assertEquals(org.bukkit.Material.SNOWBALL, fireball.icon());
assertEquals(java.util.Map.of(games.dmg.fantasystick.progression.Stat.INT, BigInteger.valueOf(12)),
fireball.requirements());
assertEquals(BigInteger.ONE, fireball.pointCost());
assertEquals(java.time.Duration.ofSeconds(4), fireball.cooldown());
assertEquals(games.dmg.fantasystick.session.ControlInput.Gesture.USE, fireball.gesture());
assertEquals(games.dmg.fantasystick.ability.AbilityDefinition.Activation.PROJECTILE, fireball.activation());
assertEquals(new java.math.BigDecimal("32"), fireball.parameters().get("range"));
assertEquals(new java.math.BigDecimal("5"), fireball.parameters().get("lifetime"));
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 unavailableText = PartyBookNativeTest.text(unavailable);
assertTrue(unavailableText.replaceAll("\\s+", "").contains("Unavailable:requirements"), unavailableText);
assertThrows(AssertionError.class, () -> PartyBookNativeTest.button(unavailable, "[Learn]"));
}
}
private static void withFireball(Path data, FantasyStickLifecycleNativeTest.Actor actor, int intelligence) 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.INT, BigInteger.valueOf(intelligence));
var saved = 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("fireball"),
java.util.List.of("fireball", "", "", "", "", "", ""), old.cooldowns(), old.restrictedKills(), false, null, null);
repository.replace(saved, old.revision());
}
private static void prepareLaunches(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor,
org.bukkit.entity.Snowball... projectiles) {
var eye = new org.bukkit.Location(actor.world, 0, 65.6, 0);
eye.setDirection(new org.bukkit.util.Vector(1, 0, 0));
org.mockito.Mockito.doReturn(eye).when(actor.player).getEyeLocation();
var queue = new java.util.ArrayDeque<>(java.util.List.of(projectiles));
org.mockito.Mockito.doAnswer(call -> {
var projectile = queue.removeFirst();
fixture.dispatcher.accept(new org.bukkit.event.entity.ProjectileLaunchEvent(projectile));
return projectile;
}).when(actor.player).launchProjectile(org.mockito.ArgumentMatchers.eq(org.bukkit.entity.Snowball.class),
org.mockito.ArgumentMatchers.any(org.bukkit.util.Vector.class));
}
private static void cast(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
actor.core.inventory.getInventory().setSelectedSlot(2);
fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(actor.player,
org.bukkit.event.block.Action.RIGHT_CLICK_AIR, actor.core.inventory.getItem(2), null,
org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
}
private static void withInt(Path data, FantasyStickLifecycleNativeTest.Actor actor, int intelligence) 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.INT, BigInteger.valueOf(intelligence));
var saved = 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);
repository.replace(saved, old.revision());
}
private static org.bukkit.entity.Snowball projectile(FantasyStickLifecycleNativeTest.Actor actor,
org.bukkit.Location location) {
var projectile = org.mockito.Mockito.mock(org.bukkit.entity.Snowball.class);
org.mockito.Mockito.when(projectile.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
org.mockito.Mockito.when(projectile.getShooter()).thenReturn(actor.player);
org.mockito.Mockito.when(projectile.getLocation()).thenAnswer(ignored -> location.clone());
org.mockito.Mockito.when(projectile.getWorld()).thenReturn(location.getWorld());
org.mockito.Mockito.when(projectile.getPersistentDataContainer()).thenReturn(
new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(
new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry()));
return projectile;
}
}
@@ -200,6 +200,33 @@ class CharacterStorageTest {
}
}
@Test void abilityCooldownRefusesBeforeAndAllowsAtTheExactPersistedDeadline() throws Exception {
var repository = new YamlCharacterRepository(data);
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var learned = new CharacterRecord(base.id(), base.name(), base.revision(), base.stats(), base.level(),
base.experience(), base.unspentPoints(), base.health(), java.util.Set.of("fireball"),
java.util.List.of("fireball", "", "", "", "", "", ""), base.cooldowns(), base.restrictedKills(), false, null, null);
repository.create(learned);
var now = new java.util.concurrent.atomic.AtomicReference<>(java.time.Instant.parse("2026-09-19T20:00:00Z"));
var clock = new Clock() {
@Override public java.time.ZoneId getZone() { return java.time.ZoneOffset.UTC; }
@Override public Clock withZone(java.time.ZoneId zone) { return this; }
@Override public java.time.Instant instant() { return now.get(); }
};
try (var storage = new CharacterStorage(repository, clock)) {
var first = storage.commitAbilityCooldown(learned.id(), learned.revision(), "fireball", now.get().plusSeconds(4)).join();
now.set(now.get().plusMillis(3999));
assertEquals(CharacterStorage.AbilityMutationStatus.COOLDOWN,
storage.commitAbilityCooldown(learned.id(), first.record().orElseThrow().revision(), "fireball", now.get().plusSeconds(4)).join().status());
now.set(java.time.Instant.parse("2026-09-19T20:00:04Z"));
var exact = storage.commitAbilityCooldown(learned.id(), first.record().orElseThrow().revision(), "fireball",
now.get().plusSeconds(4)).join();
assertEquals(CharacterStorage.AbilityMutationStatus.APPLIED, exact.status());
assertEquals(java.time.Instant.parse("2026-09-19T20:00:08Z"), exact.record().orElseThrow().cooldowns().get("fireball"));
}
}
@Test void failedAbilityUnlockSaveLeavesPointsAndLearnedStateUnchangedForRetry() throws Exception {
var repository = new YamlCharacterRepository(data);
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(