Compare commits

...
2 Commits
Author SHA1 Message Date
dmg 4517aa3a64 feat(abilities): show cooling controls
Release / release (push) Successful in 9m7s
2026-09-20 21:30:39 -04:00
dmg 89a57167cc feat(book): show ability prerequisites first
Release / release (push) Successful in 9m18s
2026-09-20 21:16:48 -04:00
21 changed files with 173 additions and 34 deletions

No files matched your search

@@ -221,6 +221,7 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
/** Bounded lifecycle sweep; plugin timers may call this without scanning future catalogues. */
public void sweep() {
if (closed) return;
sessions.refreshCooldownControls(64);
int checked = 0;
outer: for (var runtimes : List.copyOf(active.values())) {
for (var runtime : List.copyOf(runtimes)) {
@@ -235,14 +235,13 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
: !points ? "Unavailable: points" : !slot ? "Unavailable: slots" : "Available";
var builder = new PageBuilder();
builder.text(definition.name());
builder.text("Cost: " + definition.pointCost());
builder.text(requirements);
if (learned) builder.component(abilityButton("Unlearn", ABILITY_UNLEARN, view, definition.id()));
else if (stats && points && slot) builder.component(abilityButton("Learn", ABILITY_UNLOCK, view, definition.id()));
else builder.text(state);
builder.blank();
definition.lore().forEach(builder::text);
builder.blank(); builder.text(definition.instructions());
builder.text("Requires: " + requirements);
builder.text("Cooldown: " + durationSeconds(definition.cooldown()) + "s");
if (!definition.parameters().isEmpty()) builder.text("Parameters: " + definition.parameters().entrySet().stream()
.sorted(Map.Entry.comparingByKey()).map(entry -> entry.getKey() + "=" + entry.getValue())
@@ -1,6 +1,7 @@
package games.dmg.fantasystick.items;
import games.dmg.fantasystick.persistence.CharacterRecord;
import java.time.Clock;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
@@ -18,6 +19,7 @@ import org.bukkit.persistence.PersistentDataType;
*/
public final class SessionControls {
private final games.dmg.fantasystick.ability.AbilityRegistry registry;
private final Clock clock;
private static final NamespacedKey VERSION = key("control_version");
private static final NamespacedKey OWNER = key("control_owner_id");
private static final NamespacedKey SESSION = key("control_session_id");
@@ -52,9 +54,13 @@ public final class SessionControls {
public record Identity(UUID owner, UUID session, int slot) { }
public SessionControls() { this(new games.dmg.fantasystick.ability.AbilityRegistry()); }
public SessionControls() { this(new games.dmg.fantasystick.ability.AbilityRegistry(), Clock.systemUTC()); }
public SessionControls(games.dmg.fantasystick.ability.AbilityRegistry registry) {
this(registry, Clock.systemUTC());
}
public SessionControls(games.dmg.fantasystick.ability.AbilityRegistry registry, Clock clock) {
this.registry = Objects.requireNonNull(registry);
this.clock = Objects.requireNonNull(clock);
}
private Icon icon(String ability) {
@@ -71,13 +77,17 @@ public final class SessionControls {
Objects.requireNonNull(owner);
Objects.requireNonNull(session);
var items = new ItemStack[9];
var now = clock.instant();
for (int slot = 0; slot < items.length; slot++) {
String ability = slot < 2 ? "" : character.loadout().get(slot - 2);
Icon abilityIcon = ability.isEmpty() ? null : icon(ability);
var deadline = ability.isEmpty() ? null : character.cooldowns().get(ability);
boolean cooling = abilityIcon != null && deadline != null && now.isBefore(deadline);
Icon icon = slot == 0 ? new Icon(Material.BOOK, "Character book")
: slot == 1 ? new Icon(Material.STICK, "Basic attack")
: ability.isEmpty() ? new Icon(Material.GRAY_DYE, "No Ability")
: abilityIcon == null ? new Icon(Material.PAPER, "Unavailable ability: " + ability) : abilityIcon;
: abilityIcon == null ? new Icon(Material.PAPER, "Unavailable ability: " + ability)
: cooling ? new Icon(Material.GRAY_STAINED_GLASS_PANE, abilityIcon.name() + " — Cooling Down") : abilityIcon;
var item = new ItemStack(icon.material());
var meta = item.getItemMeta();
meta.displayName(Component.text(icon.name()));
@@ -88,7 +98,7 @@ public final class SessionControls {
data.set(SESSION, PersistentDataType.STRING, session.toString());
data.set(SLOT, PersistentDataType.INTEGER, slot);
data.set(KIND, PersistentDataType.STRING, slot == 0 ? "book" : slot == 1 ? "basic_attack"
: ability.isEmpty() ? "empty" : abilityIcon != null ? "ability" : "unavailable");
: ability.isEmpty() ? "empty" : abilityIcon == null ? "unavailable" : cooling ? "cooldown" : "ability");
if (!ability.isEmpty()) data.set(ABILITY, PersistentDataType.STRING, ability);
item.setItemMeta(meta);
items[slot] = item;
@@ -123,7 +133,8 @@ public final class SessionControls {
public Optional<String> ability(ItemStack item) {
if (identify(item).isEmpty()) return Optional.empty();
var data = item.getPersistentDataContainer();
return "ability".equals(data.get(KIND, PersistentDataType.STRING))
String kind = data.get(KIND, PersistentDataType.STRING);
return kind.equals("ability") || kind.equals("cooldown")
? Optional.of(data.get(ABILITY, PersistentDataType.STRING)) : Optional.empty();
}
@@ -137,6 +148,7 @@ public final class SessionControls {
if (ability.isBlank()) return false;
Icon icon = icon(ability);
return kind.equals("ability") ? icon != null && icon.material() == material
: kind.equals("cooldown") ? icon != null && material == Material.GRAY_STAINED_GLASS_PANE
: kind.equals("unavailable") && icon == null && material == Material.PAPER;
}
@@ -424,12 +424,11 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
private void emit(Player player, ItemStack item, EquipmentSessions.SelectedControl selected,
ControlInput.Gesture gesture, org.bukkit.entity.Entity target, int heldTicks, float strength) {
var captured = item.clone();
var kind = selected.slot() == 0 ? ControlInput.Kind.BOOK
: selected.slot() == 1 ? ControlInput.Kind.BASIC_ATTACK : ControlInput.Kind.ABILITY;
var input = new ControlInput(player, selected.session(), selected.slot(), selected.ability(), kind,
gesture, target, heldTicks, strength,
() -> !closed && sessions.selectedControl(player, captured).filter(selected::equals).isPresent());
() -> !closed && sessions.currentControl(player, selected));
if (!input.stillCurrent()) return;
try { inputs.accept(input); }
catch (RuntimeException failure) { report(player.getUniqueId(), selected.session().session(), failure); }
@@ -49,11 +49,13 @@ public final class EquipmentSessions implements AutoCloseable {
private games.dmg.fantasystick.presentation.SessionPresentation presentation = games.dmg.fantasystick.presentation.SessionPresentation.NONE;
private final SessionClaims claims = new SessionClaims();
private final EquipmentStorage equipment = new EquipmentStorage();
private SessionControls controls = new SessionControls();
private SessionControls controls;
private final CharacterSheets sheets = new CharacterSheets();
private final Map<UUID, Attempt> pending = new HashMap<>();
private final Map<UUID, Attempt> owned = new HashMap<>();
private final Map<UUID, Attempt> active = new HashMap<>();
private final java.util.ArrayDeque<UUID> cooldownRefreshOrder = new java.util.ArrayDeque<>();
private final java.util.Set<UUID> cooldownRefreshQueued = new java.util.HashSet<>();
// Covers reentrant inventory setters and client sync, not just asynchronous work.
private final Map<UUID, Attempt> transitioning = new HashMap<>();
private Consumer<UUID> cursorCustody = ignored -> { };
@@ -82,6 +84,7 @@ public final class EquipmentSessions implements AutoCloseable {
boolean effectsStarted;
String previousGameMode;
boolean modeStarted;
Instant nextCooldownVisualChange;
games.dmg.fantasystick.persistence.OrdinaryVitals ordinaryVitals;
boolean vitalsStarted;
boolean healthSaveRequested;
@@ -125,6 +128,7 @@ public final class EquipmentSessions implements AutoCloseable {
var initialSwitchDelay = Objects.requireNonNull(switchDelay);
this.switchDelay = () -> initialSwitchDelay;
this.clock = Objects.requireNonNull(clock);
this.controls = new SessionControls(new games.dmg.fantasystick.ability.AbilityRegistry(), clock);
if (switchDelay.isNegative()) throw new IllegalArgumentException("Negative switch delay");
this.nextTick = Objects.requireNonNull(nextTick);
this.notices = Objects.requireNonNull(notices);
@@ -163,7 +167,7 @@ public final class EquipmentSessions implements AutoCloseable {
/** Install before exposing entry so registered definitions drive delivered control metadata. */
public synchronized void attachAbilityRegistry(games.dmg.fantasystick.ability.AbilityRegistry registry) {
if (!owned.isEmpty()) throw new IllegalStateException("Sessions already started");
controls = new SessionControls(Objects.requireNonNull(registry));
controls = new SessionControls(Objects.requireNonNull(registry), clock);
}
/** Install before exposing entry; equipment-only adapter tests can omit the health overlay. */
@@ -259,18 +263,70 @@ public final class EquipmentSessions implements AutoCloseable {
var previous = attempt.record;
if (vitals != null) vitals.updateProgress(attempt.player, attempt.session, previous, saved);
attempt.record = saved; // Persisted truth precedes any reentrant native presentation setter.
if (!previous.loadout().equals(saved.loadout())) {
var refreshed = controls.create(saved, attempt.owner, attempt.session);
for (int slot = 2; slot < 9; slot++) {
if (active.get(attempt.owner) != attempt || !current(session)) return false;
attempt.player.getInventory().setItem(slot, refreshed[slot]);
}
attempt.player.updateInventory(); // No planned native writes after this reentrant boundary.
}
if ((!previous.loadout().equals(saved.loadout()) || !previous.cooldowns().equals(saved.cooldowns()))
&& !refreshControls(attempt, session)) return false;
return true;
}
/** Bounded server-tick refresh of active cooldown controls; persisted deadlines are not mutated. */
public synchronized void refreshCooldownControls(int maximumSessions) {
if (maximumSessions <= 0) throw new IllegalArgumentException("Cooldown refresh bound must be positive");
int checks = Math.min(maximumSessions, cooldownRefreshOrder.size());
for (int checked = 0; checked < checks; checked++) {
UUID owner = cooldownRefreshOrder.removeFirst();
var attempt = active.get(owner);
if (attempt == null || attempt.record == null || attempt.session == null) {
cooldownRefreshQueued.remove(owner);
continue;
}
cooldownRefreshOrder.addLast(owner);
if (attempt.nextCooldownVisualChange != null && !clock.instant().isBefore(attempt.nextCooldownVisualChange)) {
refreshControls(attempt, new Active(attempt.owner, attempt.session, attempt.character));
}
}
}
private void queueCooldownRefresh(Attempt attempt) {
if (cooldownRefreshQueued.add(attempt.owner)) cooldownRefreshOrder.addLast(attempt.owner);
}
private boolean refreshControls(Attempt attempt, Active session) {
var refreshed = controls.create(attempt.record, attempt.owner, attempt.session);
boolean changed = false;
for (int slot = 2; slot < 9; slot++) {
if (active.get(attempt.owner) != attempt || !current(session)) return false;
if (Objects.equals(attempt.player.getInventory().getItem(slot), refreshed[slot])) continue;
attempt.player.getInventory().setItem(slot, refreshed[slot]);
changed = true;
}
if (changed) attempt.player.updateInventory(); // No planned native writes after this reentrant boundary.
if (active.get(attempt.owner) != attempt || !current(session)) return false;
attempt.nextCooldownVisualChange = nextCooldownVisualChange(attempt.record);
return true;
}
private Instant nextCooldownVisualChange(CharacterRecord record) {
var now = clock.instant();
return record.loadout().stream().filter(ability -> !ability.isEmpty())
.map(record.cooldowns()::get).filter(Objects::nonNull).filter(deadline -> now.isBefore(deadline))
.min(Instant::compareTo).orElse(null);
}
/** Server-thread authorization for the currently held, issued control and saved loadout. */
public synchronized boolean currentControl(Player player, SelectedControl selected) {
if (selected == null) return false;
var attempt = active.get(player.getUniqueId());
if (closed || attempt == null || attempt.player != player || attempt.record == null
|| !current(selected.session()) || selected.slot() != player.getInventory().getHeldItemSlot()) return false;
var actual = player.getInventory().getItem(selected.slot());
var identity = controls.identify(actual).orElse(null);
if (identity == null || identity.slot() != selected.slot() || !identity.owner().equals(attempt.owner)
|| !identity.session().equals(attempt.session)) return false;
String ability = selected.slot() < 2 ? "" : controls.ability(actual).orElse("");
return ability.equals(selected.ability()) && (selected.slot() < 2
|| ability.equals(attempt.record.loadout().get(selected.slot() - 2)));
}
public synchronized Optional<SelectedControl> selectedControl(Player player, ItemStack reported) {
var attempt = active.get(player.getUniqueId());
if (closed || attempt == null || attempt.player != player || !player.isOnline() || attempt.record == null
@@ -456,7 +512,9 @@ public final class EquipmentSessions implements AutoCloseable {
pending.remove(attempt.owner, attempt);
if (!attempt.exitRequested) {
attempt.record = record;
attempt.nextCooldownVisualChange = nextCooldownVisualChange(record);
active.put(attempt.owner, attempt);
queueCooldownRefresh(attempt);
attempt.presentationInstalled = true;
try { presentation.activate(attempt.player, attempt.session, record); }
catch (RuntimeException | LinkageError error) { withdrawPresentation(attempt); diagnostic(attempt, error); }
@@ -147,7 +147,7 @@ class AbilityEngineNativeTest {
String compactAvailable = available.replaceAll("\\s+", "");
assertTrue(compactAvailable.contains("TestFocus"), available);
assertFalse(compactAvailable.contains("Icon:"), available);
assertTrue(compactAvailable.contains("Cost:1"), available);
assertFalse(compactAvailable.contains("Cost:"), available);
assertTrue(compactAvailable.contains("Cooldown:4s"), available);
var learn = PartyBookNativeTest.button(a, "[Learn]");
PartyBookNativeTest.click(f, a, learn); PartyBookNativeTest.click(f, a, learn);
@@ -170,9 +170,15 @@ class AbilityEngineNativeTest {
assertEquals(1, effects.get());
var cooled = storage.find(a.core.record.id()).get().orElseThrow();
assertTrue(cooled.cooldowns().get("test_focus").isAfter(java.time.Instant.now()));
var cooling = a.core.inventory.getItem(2);
assertEquals(Material.GRAY_STAINED_GLASS_PANE, cooling.getType());
assertEquals(net.kyori.adventure.text.Component.text("Test Focus — Cooling Down"), cooling.getItemMeta().displayName());
assertEquals("test_focus", new games.dmg.fantasystick.items.SessionControls(registry).ability(cooling).orElseThrow());
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(a.player, Action.RIGHT_CLICK_AIR,
a.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND));
var repeated = new org.bukkit.event.player.PlayerInteractEvent(a.player, Action.RIGHT_CLICK_AIR,
cooling, null, org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND);
f.dispatcher.accept(repeated);
assertEquals(org.bukkit.event.Event.Result.DENY, repeated.useItemInHand(), "The pane remains a contained control");
PartyBookNativeTest.drain(f);
assertEquals(1, effects.get(), "The persisted character deadline refuses an immediate repeated cast");
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
@@ -182,6 +188,7 @@ class AbilityEngineNativeTest {
PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
a.core.inventory.setItemInOffHand(sheet); f.activate(a);
a.core.inventory.getInventory().setSelectedSlot(2);
assertEquals(Material.GRAY_STAINED_GLASS_PANE, a.core.inventory.getItem(2).getType());
assertEquals("test_focus", new games.dmg.fantasystick.items.SessionControls(registry)
.ability(a.core.inventory.getItem(2)).orElseThrow());
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(a.player, Action.RIGHT_CLICK_AIR,
@@ -346,7 +346,10 @@ class CharacterBookNativeTest {
var fireballJump = changePage(pages.get(index), "Fireball");
assertNotNull(fireballJump);
var detail = pages.get(fireballJump.page() - 1);
assertTrue(detail.getString().startsWith("Fireball\nCost: 1\n[Learn]"), detail.getString());
assertTrue(detail.getString().startsWith("Fireball\nINT 12\n[Learn]"), detail.getString());
assertFalse(text(a.books.getLast()).contains("Cost:"));
assertFalse(text(a.books.getLast()).contains("Requires:"));
assertTrue(text(a.books.getLast()).contains("Shield Bash\nSTR 12, CON 12"));
assertFalse(detail.getString().contains("Icon:"));
assertFalse(text(a.books.getLast()).contains("Assign ability"));
assertFalse(text(a.books.getLast()).contains("[3]"));
@@ -359,7 +362,7 @@ class CharacterBookNativeTest {
assertTrue(refreshed.get(1).getString().contains("3: Fireball"));
var activeJump = changePage(refreshed.get(1), "Fireball");
assertNotNull(activeJump);
assertTrue(refreshed.get(activeJump.page() - 1).getString().startsWith("Fireball\nCost: 1\n[Unlearn]"));
assertTrue(refreshed.get(activeJump.page() - 1).getString().startsWith("Fireball\nINT 12\n[Unlearn]"));
}
}
@@ -1059,6 +1059,38 @@ class EquipmentSessionsNativeTest {
}
}
@Test void acknowledgedCharacterCooldownUsesAnAuthorizedPaneAcrossResumeAndRestoresAtTheExactDeadline() throws Exception {
var clock = new MutableClock();
try (var f = new Fixture(Duration.ofSeconds(900), clock)) {
var actor = f.actor(); f.activate(actor);
var active = f.sessions.active(actor.id).orElseThrow();
var current = f.sessions.currentRecord(active).orElseThrow();
var learnedMutation = f.characters.unlockAbility(current.id(), current.revision(), "fireball", java.util.Map.of(), java.math.BigInteger.ONE)
.get(5, TimeUnit.SECONDS);
var learned = learnedMutation.record().orElseThrow();
assertTrue(f.sessions.acknowledgeProgress(active, current.revision(), learned));
assertEquals(Material.SNOWBALL, actor.inventory.getItem(2).getType());
var deadline = clock.now.plusSeconds(30);
var cooldownMutation = f.characters.commitAbilityCooldown(learned.id(), learned.revision(), "fireball", deadline)
.get(5, TimeUnit.SECONDS);
var cooled = cooldownMutation.record().orElseThrow();
assertTrue(f.sessions.acknowledgeProgress(active, learned.revision(), cooled));
var pane = actor.inventory.getItem(2).clone();
assertEquals(Material.GRAY_STAINED_GLASS_PANE, pane.getType());
f.sessions.exit(actor.player); f.state(actor); f.activate(actor);
assertEquals(Material.GRAY_STAINED_GLASS_PANE, actor.inventory.getItem(2).getType(),
"Resume reconstructs the future persisted deadline");
clock.advance(Duration.ofSeconds(30));
f.sessions.refreshCooldownControls(1);
assertEquals(Material.SNOWBALL, actor.inventory.getItem(2).getType());
assertNotEquals(pane, actor.inventory.getItem(2), "The retired pane is no longer installed");
assertEquals(deadline, f.sessions.currentRecord(f.sessions.active(actor.id).orElseThrow()).orElseThrow()
.cooldowns().get("fireball"), "Visual restoration does not rewrite the deadline");
}
}
@Test void sameIdFailedEquipmentPreflightPreservesExistingDeadlineAndAllItems() throws Exception {
for (boolean full : new boolean[]{false, true}) {
var clock = new MutableClock();
@@ -157,6 +157,8 @@ class FireballNativeTest {
var b = new FantasyStickLifecycleNativeTest.Actor(data); b.core.inventory.setItemInOffHand(transferredSheet);
f.players.put(b.core.id, b.player); f.activate(b);
b.core.inventory.getInventory().setSelectedSlot(2);
assertEquals(org.bukkit.Material.GRAY_STAINED_GLASS_PANE, b.core.inventory.getItem(2).getType(),
"The transferred character reconstructs its retained cooldown pane");
assertEquals("fireball", new games.dmg.fantasystick.items.SessionControls(
(games.dmg.fantasystick.ability.AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry"))
.ability(b.core.inventory.getItem(2)).orElseThrow());
@@ -285,6 +287,8 @@ class FireballNativeTest {
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());
org.mockito.Mockito.verify(sessions, org.mockito.Mockito.never()).acknowledgeProgress(
org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
assertEquals(1, failures.size());
engine.close(); fireball.close(); parties.close();
}
@@ -74,7 +74,7 @@ class FrostNovaNativeTest {
caster.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, caster);
String text = PartyBookNativeTest.text(caster).replaceAll("\\s+", "");
assertTrue(text.contains("FrostNova")); assertTrue(text.contains("INT16")); assertTrue(text.contains("DEX10"));
assertTrue(text.contains("Cost:1")); assertTrue(text.contains("5")); assertTrue(text.contains("SlownessII"));
assertFalse(text.contains("Cost:")); assertTrue(text.contains("5")); assertTrue(text.contains("SlownessII"));
PartyBookNativeTest.click(fixture, caster, button(caster, "[Learn]", "frost_nova")); drain(fixture, 2);
use(fixture, caster); drain(fixture, 2);
assertEquals(8, damage.get(enemy.getUniqueId()), 1.0e-9); assertEquals(8, damage.get(opponent.core.id), 1.0e-9);
@@ -55,7 +55,7 @@ class GuardianBondNativeTest {
guardian.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, guardian);
String text = PartyBookNativeTest.text(guardian).replaceAll("\\s+", "");
assertTrue(text.contains("GuardianBond")); assertTrue(text.contains("CON16")); assertTrue(text.contains("WIS10"));
assertTrue(text.contains("Cost:1")); assertTrue(text.contains("30%")); assertTrue(text.contains("6seconds"));
assertFalse(text.contains("Cost:")); assertTrue(text.contains("30%")); assertTrue(text.contains("6seconds"));
PartyBookNativeTest.click(fixture, guardian, button(guardian, "[Learn]", "guardian_bond")); drain(fixture, 2);
use(fixture, guardian); drain(fixture, 2);
@@ -37,7 +37,7 @@ class HexNativeTest {
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
String book = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
assertTrue(book.contains("Hex")); assertTrue(book.contains("CHR14")); assertTrue(book.contains("WIS12"));
assertTrue(book.contains("Cost:1")); assertTrue(book.contains("12")); assertTrue(book.contains("20%")); assertTrue(book.contains("4s"));
assertFalse(book.contains("Cost:")); assertTrue(book.contains("12")); assertTrue(book.contains("20%")); assertTrue(book.contains("4s"));
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "hex")); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var enemy = mock(org.bukkit.entity.LivingEntity.class); var enemyId = java.util.UUID.randomUUID();
@@ -2,7 +2,7 @@ package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;import static org.mockito.Mockito.*;
import games.dmg.fantasystick.persistence.*;import games.dmg.fantasystick.progression.Stat;import java.math.BigInteger;import java.nio.file.Path;import java.util.*;import net.minecraft.network.chat.ClickEvent;import org.bukkit.event.player.PlayerInteractEvent;import org.bukkit.inventory.EquipmentSlot;import org.junit.jupiter.api.*;import org.junit.jupiter.api.io.TempDir;
class InspireNativeTest{@TempDir Path data;@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void bookAcquisitionSneakUseAddsOwnedTemporaryHealthAndExpiryPreservesUnrelatedAbsorption()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,false);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).replaceAll("\\s+","");assertTrue(book.contains("Inspire"));assertTrue(book.contains("CHR12"));assertTrue(book.contains("WIS10"));assertTrue(book.contains("Cost:1"));assertTrue(book.contains("12"));assertTrue(book.contains("6s"));PartyBookNativeTest.click(f,a,button(a,"[Learn]","inspire"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
@Test void bookAcquisitionSneakUseAddsOwnedTemporaryHealthAndExpiryPreservesUnrelatedAbsorption()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,false);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).replaceAll("\\s+","");assertTrue(book.contains("Inspire"));assertTrue(book.contains("CHR12"));assertTrue(book.contains("WIS10"));assertFalse(book.contains("Cost:"));assertTrue(book.contains("12"));assertTrue(book.contains("6s"));PartyBookNativeTest.click(f,a,button(a,"[Learn]","inspire"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
double maximum=a.maximumHealth.getBaseValue();var absorption=new java.util.concurrent.atomic.AtomicReference<>(2.0);doAnswer(c->absorption.get()).when(a.player).getAbsorptionAmount();doAnswer(c->{absorption.set(c.getArgument(0));return null;}).when(a.player).setAbsorptionAmount(anyDouble());doReturn(true).when(a.player).isSneaking();a.core.inventory.getInventory().setSelectedSlot(2);f.dispatcher.accept(new PlayerInteractEvent(a.player,org.bukkit.event.block.Action.RIGHT_CLICK_AIR,a.core.inventory.getItem(2),null,org.bukkit.block.BlockFace.SELF,EquipmentSlot.HAND));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(7,absorption.get(),1e-9);assertEquals(maximum,a.maximumHealth.getBaseValue(),1e-9);assertEquals(120L,f.delayedTicks.getLast());f.delayed.getLast().run();assertEquals(2,absorption.get(),1e-9);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("inspire"));}}
@Test void aimedPartyAllyActualAbsorptionCreatesPersistedSupportShare()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);withInspire(data,a,true);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()));
doReturn(false).when(a.player).isSneaking();doReturn(new org.bukkit.Location(a.world,0,64,0)).when(a.player).getLocation();doReturn(new org.bukkit.Location(a.world,8,64,0)).when(b.player).getLocation();doReturn(true).when(a.player).hasLineOfSight(b.player);doReturn(b.player).when(a.player).getTargetEntity(64);var absorption=new java.util.concurrent.atomic.AtomicReference<>(0.0);doAnswer(c->absorption.get()).when(b.player).getAbsorptionAmount();doAnswer(c->{absorption.set(c.getArgument(0));return null;}).when(b.player).setAbsorptionAmount(anyDouble());use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(5,absorption.get(),1e-9);
@@ -45,7 +45,7 @@ class LongbowNativeTest {
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
String text = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
assertTrue(text.contains("Longbow")); assertTrue(text.contains("DEX14"));
assertTrue(text.contains("Cost:1"), text); assertTrue(text.contains("Draw"), text);
assertFalse(text.contains("Cost:"), text); assertTrue(text.contains("Draw"), text);
assertTrue(text.contains("1s"), text);
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "longbow"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
@@ -58,7 +58,7 @@ class MendingLightNativeTest {
caster.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, caster);
String text = PartyBookNativeTest.text(caster).replaceAll("\\s+", "");
assertTrue(text.contains("MendingLight")); assertTrue(text.contains("WIS12"));
assertTrue(text.contains("CHR10")); assertTrue(text.contains("Cost:1"));
assertTrue(text.contains("CHR10")); assertFalse(text.contains("Cost:"));
assertTrue(text.contains("12")); assertTrue(text.contains("8"));
PartyBookNativeTest.click(fixture, caster, button(caster, "[Learn]", "mending_light"));
drain(fixture, 2);
@@ -56,7 +56,7 @@ class PurifyNativeTest {
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
String book = PartyBookNativeTest.text(actor).replaceAll("\\s+", "");
assertTrue(book.contains("Purify")); assertTrue(book.contains("WIS14")); assertTrue(book.contains("CHR10"));
assertTrue(book.contains("Cost:1")); assertTrue(book.contains("15")); assertTrue(book.contains("12"));
assertFalse(book.contains("Cost:")); assertTrue(book.contains("15")); assertTrue(book.contains("12"));
PartyBookNativeTest.click(fixture, actor, button(actor, "[Learn]", "purify")); drain(fixture, 2);
doReturn(true).when(actor.player).isSneaking(); use(fixture, actor); drain(fixture, 2);
@@ -34,7 +34,7 @@ class QuickstepNativeTest {
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
String text = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
assertTrue(text.contains("Quickstep")); assertTrue(text.contains("DEX12"));
assertTrue(text.contains("Cost:1")); assertTrue(text.contains("4")); assertTrue(text.contains("5s"));
assertFalse(text.contains("Cost:")); assertTrue(text.contains("4")); assertTrue(text.contains("5s"));
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "quickstep"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
@@ -55,7 +55,7 @@ class RallyingCryNativeTest {
caster.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, caster);
String text = PartyBookNativeTest.text(caster).replaceAll("\\s+", "");
assertTrue(text.contains("RallyingCry")); assertTrue(text.contains("CHR16")); assertTrue(text.contains("CON12"));
assertTrue(text.contains("Cost:1")); assertTrue(text.contains("20%")); assertTrue(text.contains("6seconds"));
assertFalse(text.contains("Cost:")); assertTrue(text.contains("20%")); assertTrue(text.contains("6seconds"));
PartyBookNativeTest.click(fixture, caster, button(caster, "[Learn]", "rallying_cry")); drain(fixture, 2);
use(fixture, caster); drain(fixture, 2);
assertEquals(0, casterEffects.get(PotionEffectType.SPEED).getAmplifier());
@@ -94,6 +94,30 @@ class SessionControlsNativeTest {
}
}
@Test
void futureCharacterCooldownsUseContainedPanesWhileElapsedAndUnknownControlsKeepTheirPresentation() {
var now = java.time.Instant.parse("2000-01-01T00:00:00Z");
var clock = java.time.Clock.fixed(now, java.time.ZoneOffset.UTC);
var timed = new SessionControls(new games.dmg.fantasystick.ability.AbilityRegistry(), clock);
var base = character(List.of("fireball", "fireball", "revive", "removed_spell", "", "", ""));
var record = new CharacterRecord(base.id(), base.name(), base.revision(), base.stats(), base.level(),
base.experience(), base.unspentPoints(), base.health(), base.learned(), base.loadout(),
Map.of("fireball", now.plusSeconds(5), "revive", now, "removed_spell", now.plusSeconds(5)),
base.dead(), base.deathId(), base.diedAt());
var items = timed.create(record, owner, session);
for (int slot : List.of(2, 3)) {
var pane = roundTrip(items[slot]);
assertEquals(Material.GRAY_STAINED_GLASS_PANE, pane.getType());
assertEquals(Component.text("Fireball — Cooling Down"), pane.getItemMeta().displayName());
assertEquals("cooldown", pane.getPersistentDataContainer().get(key("control_kind"), PersistentDataType.STRING));
assertEquals(java.util.Optional.of("fireball"), timed.ability(pane));
assertEquals(new SessionControls.Identity(owner, session, slot), timed.identify(pane).orElseThrow());
}
assertEquals(Material.ECHO_SHARD, items[4].getType(), "A deadline equal to now is ready");
assertEquals(Material.PAPER, items[5].getType(), "Unknown saved IDs remain unavailable, not cooling panes");
}
@Test
void unknownSavedAbilitiesRemainUnavailableWithoutChangingTheRecordOrCallerLoadout() {
var loadout = new ArrayList<>(List.of("fireball", "removed_spell", "", "revive", "", "", ""));
@@ -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"));
assertTrue(book.contains("Cost:1")); assertTrue(book.contains("3")); assertTrue(book.contains("6s"));
assertFalse(book.contains("Cost:")); assertTrue(book.contains("3")); assertTrue(book.contains("6s"));
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "shield_bash"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
@@ -31,7 +31,7 @@ class StoneSkinNativeTest {
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
String text = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
assertTrue(text.contains("StoneSkin")); assertTrue(text.contains("CON14"));
assertTrue(text.contains("Cost:1")); assertTrue(text.contains("25%")); assertTrue(text.contains("4s"));
assertFalse(text.contains("Cost:")); assertTrue(text.contains("25%")); assertTrue(text.contains("4s"));
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "stone_skin"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");