Compare commits

...
10 Commits
Author SHA1 Message Date
dmg 0536d4c717 feat(presentation): hide cooling controls
Release / release (push) Successful in 8m43s
2026-09-21 00:26:10 -04:00
dmg 079555900c feat(passives): add low-light vision
Release / release (push) Successful in 8m51s
2026-09-21 00:12:43 -04:00
dmg 1f3a1fe396 feat(passives): add speed I
Release / release (push) Successful in 9m25s
2026-09-21 00:05:25 -04:00
dmg 139cc4519b feat(passives): add health steal
Release / release (push) Successful in 11m11s
2026-09-20 23:52:33 -04:00
dmg 184d5520b2 feat(passives): add quicker regeneration II
Release / release (push) Successful in 8m39s
2026-09-20 23:31:04 -04:00
dmg 16087bdfec feat(passives): add quicker regeneration
Release / release (push) Successful in 8m48s
2026-09-20 23:21:19 -04:00
dmg bc8fe44033 feat(recovery): heal outside combat
Release / release (push) Successful in 11m2s
2026-09-20 22:57:23 -04:00
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
dmg 9f5fede5e5 feat(book): add linked ability navigation
Release / release (push) Successful in 8m47s
2026-09-20 20:52:21 -04:00
73 changed files with 4249 additions and 209 deletions

No files matched your search

@@ -101,6 +101,18 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.persistence.SheetResourcePackSettings sheetResourcePackSettings;
private games.dmg.fantasystick.presentation.SheetResourcePack sheetResourcePack;
private games.dmg.fantasystick.session.NutritionPolicy nutritionPolicy;
private games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings outOfCombatRecoverySettings;
private games.dmg.fantasystick.session.OutOfCombatRecovery outOfCombatRecovery;
private games.dmg.fantasystick.persistence.QuickerRegenerationSettings quickerRegenerationSettings;
private games.dmg.fantasystick.passive.QuickerRegenerationI quickerRegeneration;
private games.dmg.fantasystick.persistence.QuickerRegenerationIISettings quickerRegenerationIISettings;
private games.dmg.fantasystick.passive.QuickerRegenerationII quickerRegenerationII;
private games.dmg.fantasystick.persistence.HealthStealSettings healthStealSettings;
private games.dmg.fantasystick.passive.HealthSteal healthSteal;
private games.dmg.fantasystick.passive.SpeedI speedI;
private games.dmg.fantasystick.passive.LowLightVision lowLightVision;
private games.dmg.fantasystick.passive.PassiveRegistry passiveRegistry;
private games.dmg.fantasystick.passive.PassiveEngine passiveEngine;
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
@Override public void onEnable() {
@@ -129,6 +141,17 @@ public final class FantasyStickPlugin extends JavaPlugin {
getConfig().get(games.dmg.fantasystick.persistence.RoleplaySettings.KEY));
var partyMaximum = games.dmg.fantasystick.persistence.PartySettings.parse(
getConfig().get(games.dmg.fantasystick.persistence.PartySettings.KEY));
var recoveryConfiguration = outOfCombatRecoveryConfiguration();
var recoveryInitial = games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.parse(recoveryConfiguration);
var quickerRegenerationInitial = games.dmg.fantasystick.persistence.QuickerRegenerationSettings.parse(
getConfig().getString(games.dmg.fantasystick.persistence.QuickerRegenerationSettings.KEY,
games.dmg.fantasystick.persistence.QuickerRegenerationSettings.DEFAULT));
var quickerRegenerationIIInitial = games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.parse(
getConfig().getString(games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.KEY,
games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.DEFAULT));
var healthStealInitial = games.dmg.fantasystick.persistence.HealthStealSettings.parse(
getConfig().getString(games.dmg.fantasystick.persistence.HealthStealSettings.KEY,
games.dmg.fantasystick.persistence.HealthStealSettings.DEFAULT));
var sheetPackDefaults = games.dmg.fantasystick.persistence.SheetResourcePackSettings.packagedDefaults();
sheetResourcePackSettings = new games.dmg.fantasystick.persistence.SheetResourcePackSettings(getDataFolder().toPath(),
getConfig().get(games.dmg.fantasystick.persistence.SheetResourcePackSettings.URL_KEY),
@@ -325,6 +348,43 @@ public final class FantasyStickPlugin extends JavaPlugin {
vitals = new games.dmg.fantasystick.session.SessionVitals();
sessions.attachVitals(vitals);
nutritionPolicy = new games.dmg.fantasystick.session.NutritionPolicy(sessions);
outOfCombatRecoverySettings = new games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings(
getDataFolder().toPath(), recoveryConfiguration);
outOfCombatRecovery = new games.dmg.fantasystick.session.OutOfCombatRecovery(recoveryInitial, characters, sessions,
Clock.systemUTC(), nextTick, event -> getServer().getPluginManager().callEvent(event),
games.dmg.fantasystick.session.NativeHealthLimit::read,
problem -> getLogger().log(Level.WARNING,
"Out-of-combat recovery failed for character " + problem.character(), problem.cause()));
quickerRegenerationSettings = new games.dmg.fantasystick.persistence.QuickerRegenerationSettings(
getDataFolder().toPath(), quickerRegenerationInitial.bonus().toString());
quickerRegeneration = new games.dmg.fantasystick.passive.QuickerRegenerationI(quickerRegenerationInitial);
quickerRegenerationIISettings = new games.dmg.fantasystick.persistence.QuickerRegenerationIISettings(
getDataFolder().toPath(), quickerRegenerationIIInitial.bonus().toString());
quickerRegenerationII = new games.dmg.fantasystick.passive.QuickerRegenerationII(quickerRegenerationIIInitial);
healthStealSettings = new games.dmg.fantasystick.persistence.HealthStealSettings(
getDataFolder().toPath(), healthStealInitial.healing().toString());
healthSteal = new games.dmg.fantasystick.passive.HealthSteal(healthStealInitial, characters, sessions, nextTick,
event -> getServer().getPluginManager().callEvent(event), games.dmg.fantasystick.session.NativeHealthLimit::read,
problem -> getLogger().log(Level.WARNING, "Health Steal failed for character " + problem.character(), problem.cause()));
speedI = new games.dmg.fantasystick.passive.SpeedI(sessions);
lowLightVision = new games.dmg.fantasystick.passive.LowLightVision(sessions);
passiveRegistry = new games.dmg.fantasystick.passive.PassiveRegistry();
passiveRegistry.register(quickerRegeneration.definition());
passiveRegistry.register(quickerRegenerationII.definition());
passiveRegistry.register(healthSteal.definition());
passiveRegistry.register(speedI.definition());
passiveRegistry.register(lowLightVision.definition());
passiveEngine = new games.dmg.fantasystick.passive.PassiveEngine(passiveRegistry, characters, sessions, nextTick,
problem -> getLogger().log(Level.WARNING, "Passive change failed for character " + problem.character()
+ "; passive " + problem.passive(), problem.cause()));
outOfCombatRecovery.attachBonusHealing(record -> {
if (!record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationI.ID)) {
return java.math.BigInteger.ZERO;
}
var bonus = quickerRegeneration.bonus();
return record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationII.ID)
? bonus.add(quickerRegenerationII.bonus()) : bonus;
});
games.dmg.fantasystick.ability.AbilityScheduler abilityScheduler = (delay, action) -> {
long ticks = Math.max(1, (delay.toNanos() + 49_999_999L) / 50_000_000L);
var task = getServer().getScheduler().runTaskLater(this, action, ticks);
@@ -345,7 +405,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
String name = localPresentation.get().name(id);
var player = getServer().getPlayer(id);
return net.kyori.adventure.text.Component.text(name != null ? name : player == null ? id.toString() : player.getName());
}, abilityRegistry, abilityEngine);
}, abilityRegistry, abilityEngine, passiveRegistry, passiveEngine);
var localBasicAttack = new games.dmg.fantasystick.combat.BasicAttack(sessions);
basicAttack = localBasicAttack;
sessionEvents = new EquipmentSessionEvents(sessions, nextTick, diagnostics, input -> {
@@ -379,6 +439,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
admin.attachRoleplaySettings(roleplaySettings);
admin.attachPartySettings(partySettings);
admin.attachSheetResourcePack(sheetResourcePackSettings, sheetResourcePack);
admin.attachOutOfCombatRecovery(outOfCombatRecoverySettings, outOfCombatRecovery);
admin.attachQuickerRegeneration(quickerRegenerationSettings, quickerRegeneration, passiveRegistry);
admin.attachQuickerRegenerationII(quickerRegenerationIISettings, quickerRegenerationII, passiveRegistry);
admin.attachHealthSteal(healthStealSettings, healthSteal, passiveRegistry);
admin.attachCharacterIssuer(new games.dmg.fantasystick.session.AdminCharacterIssuer(characters,
() -> factory.create(rules), nextTick, problem -> getLogger().log(Level.WARNING,
"Admin character issue " + problem.operation() + " for player " + problem.player()
@@ -394,6 +458,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
getServer().getPluginManager().registerEvents(partyEvents, this);
getServer().getPluginManager().registerEvents(partyCombat, this);
partyCombat.install(this);
getServer().getPluginManager().registerEvents(healthSteal, this);
getServer().getPluginManager().registerEvents(combatExperience, this);
combatExperience.install(this);
getServer().getPluginManager().registerEvents(fireball, this);
@@ -417,6 +482,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
getServer().getPluginManager().registerEvents(adminRevival, this);
getServer().getPluginManager().registerEvents(localBasicAttack, this);
getServer().getPluginManager().registerEvents(nutritionPolicy, this);
getServer().getPluginManager().registerEvents(outOfCombatRecovery, this);
outOfCombatRecovery.install(this);
speedI.install(this);
lowLightVision.install(this);
getServer().getPluginManager().registerEvents(books, this);
getServer().getPluginManager().registerEvents(gameModes, this);
getServer().getPluginManager().registerEvents(sheetResourcePack, this);
@@ -515,6 +584,16 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingRevivals = revivals; revivals = null;
abilityRegistry = null;
var closingNutrition = nutritionPolicy; nutritionPolicy = null;
var closingOutOfCombatRecovery = outOfCombatRecovery; outOfCombatRecovery = null;
var closingOutOfCombatRecoverySettings = outOfCombatRecoverySettings; outOfCombatRecoverySettings = null;
var closingQuickerRegenerationSettings = quickerRegenerationSettings; quickerRegenerationSettings = null;
var closingQuickerRegenerationIISettings = quickerRegenerationIISettings; quickerRegenerationIISettings = null;
var closingHealthStealSettings = healthStealSettings; healthStealSettings = null;
var closingHealthSteal = healthSteal; healthSteal = null;
var closingSpeedI = speedI; speedI = null;
var closingLowLightVision = lowLightVision; lowLightVision = null;
var closingPassiveEngine = passiveEngine; passiveEngine = null;
quickerRegeneration = null; quickerRegenerationII = null; passiveRegistry = null;
// Retire client resources independently of successful ordinary-equipment restoration.
if (closingPresentation != null) cleanup("presentation", closingPresentation::close);
if (closingProtocol != null) cleanup("protocol presentation", closingProtocol::close);
@@ -551,6 +630,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (closingPartySettings != null) cleanup("party configuration", closingPartySettings::close);
if (closingSheetResourcePackSettings != null) cleanup("sheet resource-pack configuration", closingSheetResourcePackSettings::close);
if (closingSheetResourcePack != null) cleanup("sheet resource-pack", closingSheetResourcePack::close);
if (closingOutOfCombatRecoverySettings != null) cleanup("out-of-combat recovery configuration", closingOutOfCombatRecoverySettings::close);
if (closingQuickerRegenerationSettings != null) cleanup("Quicker Regeneration I configuration", closingQuickerRegenerationSettings::close);
if (closingQuickerRegenerationIISettings != null) cleanup("Quicker Regeneration II configuration", closingQuickerRegenerationIISettings::close);
if (closingHealthStealSettings != null) cleanup("Health Steal configuration", closingHealthStealSettings::close);
configurationDrain = java.util.concurrent.CompletableFuture.allOf(
closingSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingSettings.drained().toCompletableFuture(),
@@ -597,7 +680,15 @@ public final class FantasyStickPlugin extends JavaPlugin {
closingPartySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingPartySettings.drained().toCompletableFuture(),
closingSheetResourcePackSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingSheetResourcePackSettings.drained().toCompletableFuture());
: closingSheetResourcePackSettings.drained().toCompletableFuture(),
closingOutOfCombatRecoverySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingOutOfCombatRecoverySettings.drained().toCompletableFuture(),
closingQuickerRegenerationSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingQuickerRegenerationSettings.drained().toCompletableFuture(),
closingQuickerRegenerationIISettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingQuickerRegenerationIISettings.drained().toCompletableFuture(),
closingHealthStealSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingHealthStealSettings.drained().toCompletableFuture());
if (closingBooks != null) cleanup("character book", closingBooks::close);
if (closingAdminRevival != null) cleanup("admin revival", closingAdminRevival::close);
if (closingBasicAttack != null) cleanup("basic attack", closingBasicAttack::close);
@@ -617,6 +708,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (closingRevive != null) cleanup("Revive", closingRevive::close);
if (closingAbilityEngine != null) cleanup("ability engine", closingAbilityEngine::close);
if (closingNutrition != null) cleanup("nutrition policy", closingNutrition::close);
if (closingOutOfCombatRecovery != null) cleanup("out-of-combat recovery", closingOutOfCombatRecovery::close);
if (closingPassiveEngine != null) cleanup("passive engine", closingPassiveEngine::close);
if (closingHealthSteal != null) cleanup("Health Steal", closingHealthSteal::close);
if (closingSpeedI != null) cleanup("Speed I", closingSpeedI::close);
if (closingLowLightVision != null) cleanup("Low-Light Vision", closingLowLightVision::close);
if (closingVisuals != null) cleanup("session particles", closingVisuals::close);
if (closingParties != null) cleanup("parties", closingParties::close);
if (closingPartyCombat != null) cleanup("party combat", closingPartyCombat::close);
@@ -629,6 +725,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
unregister(closingPartyEvents);
unregister(closingPartyCombat);
unregister(closingCombatExperience);
unregister(closingHealthSteal);
unregister(closingFireball);
unregister(closingFrostShard);
unregister(closingLongbow);
@@ -642,6 +739,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
unregister(closingAdminRevival);
unregister(closingBasicAttack);
unregister(closingNutrition);
unregister(closingOutOfCombatRecovery);
unregister(closingBooks);
unregister(closingEvents);
unregister(closingCrafting);
@@ -711,6 +809,16 @@ public final class FantasyStickPlugin extends JavaPlugin {
}
}
private java.util.Map<String, String> outOfCombatRecoveryConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.parse(values);
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> frostShardConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.FrostShardSettings.keys()) {
@@ -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)) {
@@ -269,7 +269,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
private void deliver(View view, CharacterRecord dead, RevivalStore.Reservation reservation) {
try {
var provisional = new CharacterRecord(dead.id(), dead.name(), dead.revision(), dead.stats(), dead.level(), dead.experience(),
dead.unspentPoints(), dead.maximumHealth(), dead.learned(), dead.loadout(), dead.cooldowns(), dead.restrictedKills(),
dead.unspentPoints(), dead.maximumHealth(), dead.learned(), dead.loadout(), dead.passives(), dead.cooldowns(), dead.restrictedKills(),
false, dead.deathId(), dead.diedAt());
ItemStack sheet = sheets.create(provisional);
ItemStack source = view.target.getInventory().getItem(reservation.sourceSlot());
@@ -9,6 +9,15 @@ public final class BookPagination {
private BookPagination() { }
public static List<Component> pages(String text) {
var lines = lines(text);
var pages = new ArrayList<Component>();
for (int start = 0; start < lines.size(); start += 13) {
pages.add(Component.text(String.join("\n", lines.subList(start, Math.min(start + 13, lines.size())))));
}
return List.copyOf(pages);
}
static List<String> lines(String text) {
var lines = new ArrayList<String>();
var line = new StringBuilder();
int width = 0;
@@ -28,10 +37,6 @@ public final class BookPagination {
line.appendCodePoint(codePoint); width += advance;
}
lines.add(line.toString());
var pages = new ArrayList<Component>();
for (int start = 0; start < lines.size(); start += 13) {
pages.add(Component.text(String.join("\n", lines.subList(start, Math.min(start + 13, lines.size())))));
}
return List.copyOf(pages);
return List.copyOf(lines);
}
}
@@ -27,23 +27,28 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
private final Consumer<Failure> failures;
private final games.dmg.fantasystick.ability.AbilityRegistry abilities;
private final games.dmg.fantasystick.ability.AbilityEngine abilityEngine;
private final games.dmg.fantasystick.passive.PassiveRegistry passives;
private final games.dmg.fantasystick.passive.PassiveEngine passiveEngine;
private final Map<UUID, Object> pending = new ConcurrentHashMap<>();
private volatile boolean closed;
private static final int VOLUME_SIZE = 80; // Leave room for navigation and the bounded party pages.
private static final int VOLUME_SIZE = 30; // Reserve room for linked ability details, navigation and party pages.
private static final net.kyori.adventure.key.Key PAGES = net.kyori.adventure.key.Key.key("fantasystick", "character_pages");
private static final net.kyori.adventure.key.Key STATS = net.kyori.adventure.key.Key.key("fantasystick", "character_stat");
private static final net.kyori.adventure.key.Key ABILITY_UNLOCK = net.kyori.adventure.key.Key.key("fantasystick", "ability_unlock");
private static final net.kyori.adventure.key.Key ABILITY_ASSIGN = net.kyori.adventure.key.Key.key("fantasystick", "ability_assign");
private static final net.kyori.adventure.key.Key ABILITY_UNLEARN = net.kyori.adventure.key.Key.key("fantasystick", "ability_unlearn");
private static final net.kyori.adventure.key.Key PASSIVE_UNLOCK = net.kyori.adventure.key.Key.key("fantasystick", "passive_unlock");
private static final net.kyori.adventure.key.Key PASSIVE_UNLEARN = net.kyori.adventure.key.Key.key("fantasystick", "passive_unlearn");
private static final java.util.regex.Pattern PAGE_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([01])\"");
private static final java.util.regex.Pattern STAT_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):(STR|DEX|CON|INT|WIS|CHR)\"");
private static final java.util.regex.Pattern ABILITY_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([a-z][a-z0-9_]{0,63})(?::([0-6]))?\"");
private static final java.util.regex.Pattern ABILITY_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([a-z][a-z0-9_]{0,63})\"");
private record View(String nonce, ControlInput input, int previous, int next) { }
private record StatView(String nonce, ControlInput input, java.math.BigInteger revision) { }
private record AbilityView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids) { }
private record PassiveView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids) { }
private final Map<UUID, View> views = new ConcurrentHashMap<>();
private final Map<UUID, StatView> statViews = new ConcurrentHashMap<>();
private final Map<UUID, AbilityView> abilityViews = new ConcurrentHashMap<>();
private final Map<UUID, PassiveView> passiveViews = new ConcurrentHashMap<>();
/** Scheduler must always defer to the server thread. Diagnostics must not access players or throw. */
public CharacterBookController(CharacterStorage characters, ProgressionRules rules,
@@ -51,7 +56,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
Consumer<Runnable> nextTick, Consumer<Failure> failures, games.dmg.fantasystick.party.PartyRoster parties,
org.bukkit.Server server, java.util.function.Function<UUID, Component> names) {
this(characters, rules, sessions, nextTick, failures, parties, server, names,
new games.dmg.fantasystick.ability.AbilityRegistry(), null);
new games.dmg.fantasystick.ability.AbilityRegistry(), null,
new games.dmg.fantasystick.passive.PassiveRegistry(), null);
}
public CharacterBookController(CharacterStorage characters, ProgressionRules rules,
@@ -60,6 +66,18 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
org.bukkit.Server server, java.util.function.Function<UUID, Component> names,
games.dmg.fantasystick.ability.AbilityRegistry abilities,
games.dmg.fantasystick.ability.AbilityEngine abilityEngine) {
this(characters, rules, sessions, nextTick, failures, parties, server, names, abilities, abilityEngine,
new games.dmg.fantasystick.passive.PassiveRegistry(), null);
}
public CharacterBookController(CharacterStorage characters, ProgressionRules rules,
games.dmg.fantasystick.session.EquipmentSessions sessions,
Consumer<Runnable> nextTick, Consumer<Failure> failures, games.dmg.fantasystick.party.PartyRoster parties,
org.bukkit.Server server, java.util.function.Function<UUID, Component> names,
games.dmg.fantasystick.ability.AbilityRegistry abilities,
games.dmg.fantasystick.ability.AbilityEngine abilityEngine,
games.dmg.fantasystick.passive.PassiveRegistry passives,
games.dmg.fantasystick.passive.PassiveEngine passiveEngine) {
this.characters = Objects.requireNonNull(characters);
this.rules = Objects.requireNonNull(rules);
this.sessions = Objects.requireNonNull(sessions);
@@ -67,6 +85,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
this.failures = Objects.requireNonNull(failures);
this.abilities = Objects.requireNonNull(abilities);
this.abilityEngine = abilityEngine;
this.passives = Objects.requireNonNull(passives);
this.passiveEngine = passiveEngine;
this.partyControls = new PartyBookControls(parties, server, names, this::accept, this::report);
}
@@ -80,6 +100,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
views.remove(owner);
statViews.remove(owner);
abilityViews.remove(owner);
passiveViews.remove(owner);
Object ticket = new Object();
pending.put(owner, ticket);
characters.find(input.session().character()).whenComplete((record, failure) -> {
@@ -107,47 +128,72 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
}
private ItemStack render(CharacterRecord record, ControlInput input, int offset) {
var first = new StringBuilder(record.name()).append("\n\n");
for (var stat : Stat.values()) first.append(stat).append(": ").append(record.stats().get(stat)).append('\n');
var maximum = record.maximumHealth().min(NativeHealthLimit.read());
String xp = record.level() >= rules.maximumLevel() ? "MAX"
: record.experience().remainder(rules.xpPerLevel()) + "/" + rules.xpPerLevel();
String learned = record.learned().isEmpty() ? "none" : String.join(", ", record.learned().stream().sorted().toList());
String second = "Level: " + record.level() + "\nXP: " + xp + "\nUnspent points: " + record.unspentPoints()
+ "\nHealth: " + java.math.BigDecimal.valueOf(input.player().getHealth()).min(maximum).stripTrailingZeros().toPlainString()
+ "/" + maximum.stripTrailingZeros().toPlainString()
+ "\n\nAbilities: " + learned;
var item = new ItemStack(Material.WRITTEN_BOOK);
var meta = (BookMeta) item.getItemMeta();
meta.setTitle("Character");
meta.setAuthor("Fantasy Stick");
var all = BookPagination.pages(first + "\n" + second);
int start = Math.min(offset, (all.size() - 1) / VOLUME_SIZE * VOLUME_SIZE);
int end = Math.min(start + VOLUME_SIZE, all.size());
var pages = new java.util.ArrayList<Component>(all.subList(start, end));
var statView = new StatView(UUID.randomUUID().toString(), input, record.revision());
statViews.put(input.session().player(), statView);
Component purchases = Component.text("Spend 1 point\n\n");
for (var stat : Stat.values()) {
purchases = purchases.append(Component.text("[+1 " + stat + "]", net.kyori.adventure.text.format.NamedTextColor.DARK_GREEN)
.clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(STATS,
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\"" + statView.nonce() + ":" + stat + "\""))))
.append(Component.newline());
}
pages.add(purchases);
var definitions = abilities.definitions();
if (!definitions.isEmpty() && end == all.size()) {
var allOverview = overviewPages(record, input, statView);
int start = Math.min(offset, (allOverview.size() - 1) / VOLUME_SIZE * VOLUME_SIZE);
int end = Math.min(start + VOLUME_SIZE, allOverview.size());
var pages = new java.util.ArrayList<Component>(allOverview.subList(start, end));
if (record.unspentPoints().signum() > 0 && start == 0) statViews.put(input.session().player(), statView);
var definitions = abilities.definitions().stream()
.sorted(java.util.Comparator.comparing(games.dmg.fantasystick.ability.AbilityDefinition::name)
.thenComparing(games.dmg.fantasystick.ability.AbilityDefinition::id))
.toList();
var passiveDefinitions = passives.definitions().stream()
.sorted(java.util.Comparator.comparing(games.dmg.fantasystick.passive.PassiveDefinition::name)
.thenComparing(games.dmg.fantasystick.passive.PassiveDefinition::id))
.toList();
if ((!definitions.isEmpty() || !passiveDefinitions.isEmpty()) && end == allOverview.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.addAll(abilityPages(record, definition, abilityView));
if (!definitions.isEmpty()) abilityViews.put(input.session().player(), abilityView);
var passiveView = new PassiveView(UUID.randomUUID().toString(), input, record.revision(),
passiveDefinitions.stream().map(games.dmg.fantasystick.passive.PassiveDefinition::id)
.collect(java.util.stream.Collectors.toUnmodifiableSet()));
if (!passiveDefinitions.isEmpty()) passiveViews.put(input.session().player(), passiveView);
var details = definitions.stream().map(definition -> abilityPages(record, definition, abilityView)).toList();
var passiveNames = passiveDefinitions.stream().collect(java.util.stream.Collectors.toMap(
games.dmg.fantasystick.passive.PassiveDefinition::id,
games.dmg.fantasystick.passive.PassiveDefinition::name));
var passiveDetails = passiveDefinitions.stream()
.map(definition -> passiveDetailPages(record, definition, passiveNames, passiveView)).toList();
var placeholderTargets = definitions.stream().collect(java.util.stream.Collectors.toMap(
games.dmg.fantasystick.ability.AbilityDefinition::id, ignored -> 1));
var passivePlaceholderTargets = passiveDefinitions.stream().collect(java.util.stream.Collectors.toMap(
games.dmg.fantasystick.passive.PassiveDefinition::id, ignored -> 1));
int activeCount = activePages(record, definitions, placeholderTargets).size();
int passiveCount = passiveSlotPages(record, passiveDefinitions, passivePlaceholderTargets).size();
int indexCount = indexPages(definitions, placeholderTargets).size();
int passiveIndexCount = passiveIndexPages(passiveDefinitions, passivePlaceholderTargets).size();
var targets = new java.util.HashMap<String, Integer>();
int target = pages.size() + activeCount + passiveCount + indexCount + passiveIndexCount + 1;
for (int index = 0; index < definitions.size(); index++) {
targets.put(definitions.get(index).id(), target);
target += details.get(index).size();
}
var passiveTargets = new java.util.HashMap<String, Integer>();
for (int index = 0; index < passiveDefinitions.size(); index++) {
passiveTargets.put(passiveDefinitions.get(index).id(), target);
target += passiveDetails.get(index).size();
}
pages.addAll(activePages(record, definitions, targets));
pages.addAll(passiveSlotPages(record, passiveDefinitions, passiveTargets));
pages.addAll(indexPages(definitions, targets));
pages.addAll(passiveIndexPages(passiveDefinitions, passiveTargets));
details.forEach(pages::addAll);
passiveDetails.forEach(pages::addAll);
}
if (all.size() > VOLUME_SIZE) {
if (allOverview.size() > VOLUME_SIZE) {
var view = new View(UUID.randomUUID().toString(), input, start > 0 ? start - VOLUME_SIZE : -1,
end < all.size() ? end : -1);
end < allOverview.size() ? end : -1);
views.put(input.session().player(), view);
Component navigation = Component.text("Character pages\n\n" + (start + 1) + "–" + end + "/" + all.size());
Component navigation = Component.text("Character pages\n\n" + (start + 1) + "–" + end + "/" + allOverview.size());
if (view.previous() >= 0) navigation = navigation.append(Component.newline()).append(pageButton("Previous pages", view, 0));
if (view.next() >= 0) navigation = navigation.append(Component.newline()).append(pageButton("Next pages", view, 1));
pages.add(navigation);
@@ -158,6 +204,122 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
return item;
}
private java.util.List<Component> overviewPages(CharacterRecord record, ControlInput input, StatView view) {
var builder = new PageBuilder();
builder.text(record.name());
builder.blank();
for (var stat : Stat.values()) {
Component action = record.unspentPoints().signum() > 0
? statButton(stat, view) : null;
builder.inline(stat + ": " + record.stats().get(stat), action);
}
var maximum = record.maximumHealth().min(NativeHealthLimit.read());
String xp = record.level() >= rules.maximumLevel() ? "MAX"
: record.experience().remainder(rules.xpPerLevel()) + "/" + rules.xpPerLevel();
builder.text("Level: " + record.level());
builder.text("XP: " + xp);
builder.text("Unspent points: " + record.unspentPoints());
builder.text("Health: " + java.math.BigDecimal.valueOf(input.player().getHealth()).min(maximum).stripTrailingZeros().toPlainString()
+ "/" + maximum.stripTrailingZeros().toPlainString());
return builder.pages();
}
private static Component statButton(Stat stat, StatView view) {
return Component.text("[+1 " + stat + "]", net.kyori.adventure.text.format.NamedTextColor.DARK_GREEN)
.clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(STATS,
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\"" + view.nonce() + ":" + stat + "\"")));
}
private static java.util.List<Component> activePages(CharacterRecord record,
java.util.List<games.dmg.fantasystick.ability.AbilityDefinition> definitions, Map<String, Integer> targets) {
var byId = definitions.stream().collect(java.util.stream.Collectors.toMap(
games.dmg.fantasystick.ability.AbilityDefinition::id, java.util.function.Function.identity()));
var builder = new PageBuilder();
builder.text("Active abilities"); builder.blank();
for (int slot = 0; slot < record.loadout().size(); slot++) {
String id = record.loadout().get(slot);
var definition = byId.get(id);
if (id.isEmpty()) builder.text((slot + 3) + ": No Ability");
else if (definition == null) builder.text((slot + 3) + ": " + id);
else builder.link((slot + 3) + ": ", definition.name(), targets.get(id));
}
var unassigned = record.learned().stream().filter(id -> !record.loadout().contains(id)).sorted().toList();
if (!unassigned.isEmpty()) {
builder.blank(); builder.text("Unassigned learned:");
for (var id : unassigned) {
var definition = byId.get(id);
if (definition == null) builder.text(id);
else builder.link("", definition.name(), targets.get(id));
}
}
return builder.pages();
}
private static java.util.List<Component> passiveSlotPages(CharacterRecord record,
java.util.List<games.dmg.fantasystick.passive.PassiveDefinition> definitions, Map<String, Integer> targets) {
var byId = definitions.stream().collect(java.util.stream.Collectors.toMap(
games.dmg.fantasystick.passive.PassiveDefinition::id, java.util.function.Function.identity()));
var builder = new PageBuilder(); builder.text("Passive abilities"); builder.blank();
for (int slot = 0; slot < record.passives().size(); slot++) {
String id = record.passives().get(slot); var definition = byId.get(id);
if (id.isEmpty()) builder.text((slot + 1) + ": No Passive");
else if (definition == null) builder.text((slot + 1) + ": " + id);
else builder.link((slot + 1) + ": ", definition.name(), targets.get(id));
}
return builder.pages();
}
private static java.util.List<Component> indexPages(
java.util.List<games.dmg.fantasystick.ability.AbilityDefinition> definitions, Map<String, Integer> targets) {
var pages = new java.util.ArrayList<Component>();
for (int start = 0; start < definitions.size(); start += 11) {
var builder = new PageBuilder(); builder.text("Ability index"); builder.blank();
for (var definition : definitions.subList(start, Math.min(start + 11, definitions.size()))) {
builder.link("", definition.name(), targets.get(definition.id()));
}
pages.addAll(builder.pages());
}
return java.util.List.copyOf(pages);
}
private static java.util.List<Component> passiveIndexPages(
java.util.List<games.dmg.fantasystick.passive.PassiveDefinition> definitions, Map<String, Integer> targets) {
var pages = new java.util.ArrayList<Component>();
for (int start = 0; start < definitions.size(); start += 11) {
var builder = new PageBuilder(); builder.text("Passive index"); builder.blank();
for (var definition : definitions.subList(start, Math.min(start + 11, definitions.size()))) {
builder.link("", definition.name(), targets.get(definition.id()));
}
pages.addAll(builder.pages());
}
return java.util.List.copyOf(pages);
}
private static java.util.List<Component> passiveDetailPages(CharacterRecord record,
games.dmg.fantasystick.passive.PassiveDefinition definition, Map<String, String> names, PassiveView view) {
String requirements = definition.requirements().entrySet().stream().sorted(Map.Entry.comparingByKey())
.map(entry -> entry.getKey() + " " + entry.getValue()).collect(java.util.stream.Collectors.joining(", "));
if (!definition.prerequisites().isEmpty()) {
String prerequisiteNames = definition.prerequisites().stream().map(id -> names.getOrDefault(id, id)).sorted()
.collect(java.util.stream.Collectors.joining(", "));
requirements = requirements.isEmpty() ? prerequisiteNames : prerequisiteNames + ", " + requirements;
}
boolean learned = record.passives().contains(definition.id());
boolean prerequisites = record.passives().containsAll(definition.prerequisites());
boolean stats = definition.requirements().entrySet().stream()
.allMatch(entry -> record.stats().get(entry.getKey()).compareTo(entry.getValue()) >= 0);
boolean points = record.unspentPoints().compareTo(definition.pointCost()) >= 0;
boolean slot = record.passives().contains("");
String state = learned ? "Learned" : !prerequisites || !stats ? "Unavailable: requirements"
: !points ? "Unavailable: points" : !slot ? "Unavailable: passive slots" : "Available";
var builder = new PageBuilder(); builder.text(definition.name()); builder.text(requirements);
if (learned) builder.component(passiveButton("Unlearn", PASSIVE_UNLEARN, view, definition.id()));
else if (prerequisites && stats && points && slot) builder.component(passiveButton("Learn", PASSIVE_UNLOCK, view, definition.id()));
else builder.text(state);
builder.blank(); definition.description().forEach(builder::text); builder.blank(); builder.text(state);
return builder.pages();
}
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()
@@ -166,29 +328,70 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
boolean learned = record.learned().contains(definition.id());
boolean stats = definition.requirements().entrySet().stream()
.allMatch(entry -> record.stats().get(entry.getKey()).compareTo(entry.getValue()) >= 0);
boolean points = record.unspentPoints().compareTo(definition.pointCost()) >= 0;
boolean slot = record.loadout().contains("");
String state = learned ? "Learned" : !stats ? "Unavailable: requirements"
: record.unspentPoints().compareTo(definition.pointCost()) < 0 ? "Unavailable: points" : "Available";
String parameters = definition.parameters().isEmpty() ? "" : "\nParameters: " + definition.parameters().entrySet().stream()
: !points ? "Unavailable: points" : !slot ? "Unavailable: slots" : "Available";
var builder = new PageBuilder();
builder.text(definition.name());
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("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())
.collect(java.util.stream.Collectors.joining(", "));
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: " + 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)));
} else if (learned) {
Component action = Component.text("Assign ability\n\nSlot:\n");
for (int slot = 0; slot < 7; slot++) {
if (slot > 0) action = action.append(slot == 4 ? Component.newline() : Component.space());
action = action.append(abilityButton(Integer.toString(slot + 3), ABILITY_ASSIGN, view, definition.id(), slot));
.collect(java.util.stream.Collectors.joining(", ")));
builder.text(state);
return builder.pages();
}
private static final class PageBuilder {
private final java.util.List<Component> lines = new java.util.ArrayList<>();
void blank() { lines.add(Component.empty()); }
void text(String text) { BookPagination.lines(text).forEach(line -> lines.add(Component.text(line))); }
void component(Component component) { lines.add(component); }
void inline(String text, Component action) {
var wrapped = BookPagination.lines(text);
for (int index = 0; index < wrapped.size() - 1; index++) lines.add(Component.text(wrapped.get(index)));
var last = Component.text(wrapped.getLast());
if (action != null && displayWidth(wrapped.getLast()) + 6 + 48 <= 108) {
lines.add(last.append(Component.space()).append(action));
} else {
lines.add(last);
if (action != null) lines.add(action);
}
action = action.append(Component.text("\n\nRefund: "))
.append(abilityButton("Unlearn", ABILITY_UNLEARN, view, definition.id(), null));
pages.add(action);
}
return java.util.List.copyOf(pages);
void link(String prefix, String label, int page) {
if (displayWidth(prefix + label) <= 108) {
lines.add(Component.text(prefix).append(Component.text(label, net.kyori.adventure.text.format.NamedTextColor.DARK_BLUE)
.clickEvent(net.kyori.adventure.text.event.ClickEvent.changePage(page))));
return;
}
for (var part : BookPagination.lines(prefix + label)) {
lines.add(Component.text(part, net.kyori.adventure.text.format.NamedTextColor.DARK_BLUE)
.clickEvent(net.kyori.adventure.text.event.ClickEvent.changePage(page)));
}
}
private static int displayWidth(String text) {
return text.codePoints().map(codePoint -> codePoint < 128 ? 6 : 9).sum();
}
java.util.List<Component> pages() {
var pages = new java.util.ArrayList<Component>();
for (int start = 0; start < lines.size(); start += 13) {
Component page = Component.empty();
for (int index = start; index < Math.min(start + 13, lines.size()); index++) {
if (index > start) page = page.append(Component.newline());
page = page.append(lines.get(index));
}
pages.add(page);
}
if (pages.isEmpty()) pages.add(Component.empty());
return java.util.List.copyOf(pages);
}
}
private static String durationSeconds(java.time.Duration duration) {
@@ -197,8 +400,16 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
}
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) + "\"";
String ability) {
String payload = "\"" + view.nonce() + ":" + ability + "\"";
return Component.text("[" + label + "]", net.kyori.adventure.text.format.NamedTextColor.DARK_GREEN)
.clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(action,
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder(payload)));
}
private static Component passiveButton(String label, net.kyori.adventure.key.Key action, PassiveView view,
String passive) {
String payload = "\"" + view.nonce() + ":" + passive + "\"";
return Component.text("[" + label + "]", net.kyori.adventure.text.format.NamedTextColor.DARK_GREEN)
.clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(action,
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder(payload)));
@@ -292,9 +503,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
@org.bukkit.event.EventHandler
public void abilityAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
boolean unlock = ABILITY_UNLOCK.equals(event.getIdentifier());
boolean assign = ABILITY_ASSIGN.equals(event.getIdentifier());
boolean unlearn = ABILITY_UNLEARN.equals(event.getIdentifier());
if (closed || abilityEngine == null || !unlock && !assign && !unlearn
if (closed || abilityEngine == null || !unlock && !unlearn
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)
|| event.getTag() == null) return;
var player = connection.getPlayer();
@@ -304,12 +514,10 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
if (raw.length() > 120) return;
var match = ABILITY_PAYLOAD.matcher(raw);
if (!match.matches() || !view.nonce().equals(match.group(1)) || !view.ids().contains(match.group(2))
|| assign != (match.group(3) != null)
|| !abilityViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
String ability = match.group(2);
var operation = unlock ? abilityEngine.purchase(view.input(), ability, view.revision())
: unlearn ? abilityEngine.unlearn(view.input(), ability, view.revision())
: abilityEngine.assign(view.input(), ability, Integer.parseInt(match.group(3)), view.revision());
: abilityEngine.unlearn(view.input(), ability, view.revision());
operation.whenComplete((mutation, failure) -> {
if (closed || !view.input().stillCurrent()) return;
if (failure != null) {
@@ -317,22 +525,66 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
player.sendMessage("Ability change could not be confirmed; reopen the character after session recovery.");
return;
}
player.sendMessage(abilityMessage(unlock, unlearn, ability, mutation.status()));
player.sendMessage(abilityMessage(unlock, ability, mutation.status()));
load(view.input(), 0);
});
}
private static String abilityMessage(boolean unlock, boolean unlearn, String ability,
@org.bukkit.event.EventHandler
public void passiveAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
boolean unlock = PASSIVE_UNLOCK.equals(event.getIdentifier());
boolean unlearn = PASSIVE_UNLEARN.equals(event.getIdentifier());
if (closed || passiveEngine == null || !unlock && !unlearn
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)
|| event.getTag() == null) return;
var player = connection.getPlayer(); var view = passiveViews.get(player.getUniqueId());
if (view == null || view.input().player() != player) return;
String raw = event.getTag().string(); if (raw.length() > 120) return;
var match = ABILITY_PAYLOAD.matcher(raw);
if (!match.matches() || !view.nonce().equals(match.group(1)) || !view.ids().contains(match.group(2))
|| !passiveViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
String passive = match.group(2);
var operation = unlock ? passiveEngine.purchase(view.input(), passive, view.revision())
: passiveEngine.unlearn(view.input(), passive, view.revision());
operation.whenComplete((mutation, failure) -> {
if (closed || !view.input().stillCurrent()) return;
if (failure != null) {
report(view.input(), failure);
player.sendMessage("Passive change could not be confirmed; reopen the character after session recovery.");
return;
}
player.sendMessage(passiveMessage(unlock, passive, mutation.status()));
load(view.input(), 0);
});
}
private static String passiveMessage(boolean unlock, String passive,
CharacterStorage.PassiveMutationStatus status) {
return switch (status) {
case APPLIED -> unlock ? "Learned " + passive + " in the first available passive slot."
: "Unlearned " + passive + " and refunded 1 point.";
case REQUIREMENTS -> "The passive prerequisites or purchased base-stat requirements are not met.";
case NO_POINTS -> "This character does not have enough unspent points.";
case PASSIVE_SLOTS_FULL -> "All three passive slots are occupied; unlearn a passive before learning another.";
case ALREADY_LEARNED -> "This character already knows " + passive + ".";
case NOT_LEARNED -> "This character no longer knows " + passive + ".";
case REQUIRED_BY_LEARNED -> "Another learned passive requires " + passive + ".";
case STALE -> "That character page is stale; reopen it before changing passives.";
case UNAVAILABLE -> "That character or passive is unavailable.";
};
}
private static String abilityMessage(boolean unlock, String ability,
CharacterStorage.AbilityMutationStatus status) {
return switch (status) {
case APPLIED -> unlock ? "Learned " + ability + "."
: unlearn ? "Unlearned " + ability + " and refunded 1 point." : "Assigned " + ability + ".";
case UNCHANGED -> ability + " is already assigned there.";
case APPLIED -> unlock ? "Learned " + ability + " and assigned it to the first available slot."
: "Unlearned " + ability + " and refunded 1 point.";
case UNCHANGED -> ability + " is unchanged.";
case REQUIREMENTS -> "The purchased base-stat requirements are not met.";
case NO_POINTS -> "This character does not have enough unspent points.";
case LOADOUT_FULL -> "All seven ability slots are occupied; unlearn an ability before learning another.";
case ALREADY_LEARNED -> "This character already knows " + ability + ".";
case NOT_LEARNED -> unlearn ? "This character no longer knows " + ability + "."
: "Learn " + ability + " before assigning it.";
case NOT_LEARNED -> "This character no longer knows " + ability + ".";
case STALE -> "That character page is stale; reopen it before changing abilities.";
case COOLDOWN -> "That ability is still on cooldown.";
case UNAVAILABLE -> "That character or ability is unavailable.";
@@ -349,10 +601,11 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
views.remove(owner);
statViews.remove(owner);
abilityViews.remove(owner);
passiveViews.remove(owner);
partyControls.forget(owner);
}
@Override public void close() {
closed = true; pending.clear(); views.clear(); statViews.clear(); abilityViews.clear(); partyControls.close();
closed = true; pending.clear(); views.clear(); statViews.clear(); abilityViews.clear(); passiveViews.clear(); partyControls.close();
}
}
@@ -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;
@@ -17,7 +18,9 @@ import org.bukkit.persistence.PersistentDataType;
* The caller must enforce session custody and suppress vanilla item uses; this class registers no events.
*/
public final class SessionControls {
public static final NamespacedKey COOLDOWN_MODEL = new NamespacedKey("fantasystick", "cooldown_control");
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 +55,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,24 +78,29 @@ 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()));
meta.setMaxStackSize(1);
if (cooling) meta.setItemModel(COOLDOWN_MODEL);
var data = meta.getPersistentDataContainer();
data.set(VERSION, PersistentDataType.INTEGER, 1);
data.set(OWNER, PersistentDataType.STRING, owner.toString());
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 +135,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 +150,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;
}
@@ -52,6 +52,14 @@ public final class TailBooks {
section.append(deadline == null ? "\nCooldown: none" : "\nCooldown until:\n" + deadline);
pages.addAll(BookPagination.pages(section.toString()));
});
if (character.passives().stream().allMatch(String::isEmpty)) {
pages.addAll(BookPagination.pages("Passives: None"));
} else {
for (int slot = 0; slot < character.passives().size(); slot++) {
String passive = character.passives().get(slot);
if (!passive.isEmpty()) pages.addAll(BookPagination.pages("Passive: " + passive + "\nSlot: " + (slot + 1)));
}
}
// Unlearned/unknown saved cooldowns remain readable without pretending they are learned abilities.
character.cooldowns().keySet().stream().filter(id -> !character.learned().contains(id)).sorted().forEach(id ->
pages.addAll(BookPagination.pages("Saved cooldown\n" + id + "\nUntil:\n" + character.cooldowns().get(id))));
@@ -111,23 +111,12 @@ public final class CombatExperience implements Listener, AutoCloseable {
public void damage(EntityDamageEvent event) {
if (closed || games.dmg.fantasystick.ability.GuardianBond.redirectedDamage()
|| event.getFinalDamage() <= 0) return;
var origin = source(event);
var origin = sources.source(event);
if (origin == null || origin.session() == null || !sources.current(origin)
|| event.getEntity().getUniqueId().equals(origin.player())) return;
contributions.accept(event.getEntity().getUniqueId(), origin.character(), clock.instant());
}
private CombatSources.Origin source(EntityDamageEvent event) {
if (event.getEntity() instanceof Player target) {
var ongoing = OngoingDamage.read(target, event.getCause());
if (ongoing != null) return ongoing.source();
}
var origin = sources.read(event.getDamageSource().getDirectEntity());
if (origin == null && event instanceof EntityDamageByEntityEvent byEntity) origin = sources.read(byEntity.getDamager());
if (origin == null) origin = sources.read(event.getDamageSource().getCausingEntity());
return origin;
}
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true)
public void beforePlayerDeath(PlayerDeathEvent event) {
if (closed) return;
@@ -10,9 +10,9 @@ import org.bukkit.entity.Projectile;
import org.bukkit.persistence.PersistentDataType;
/** Source identity travels with delayed native entities, never with a replacement sheet or shooter. */
final class CombatSources {
public final class CombatSources {
static final NamespacedKey ORIGIN = new NamespacedKey("fantasystick", "combat_origin_v1");
record Origin(UUID player, UUID session, UUID character) {
public record Origin(UUID player, UUID session, UUID character) {
String encode() { return player + "/" + (session == null ? "-/-" : session + "/" + character); }
static Origin decode(String value) {
if (value == null || value.length() > 110) return null;
@@ -26,15 +26,27 @@ final class CombatSources {
}
}
private final EquipmentSessions sessions;
CombatSources(EquipmentSessions sessions) { this.sessions = Objects.requireNonNull(sessions); }
public CombatSources(EquipmentSessions sessions) { this.sessions = Objects.requireNonNull(sessions); }
Origin player(UUID player) {
return sessions.active(player).map(active -> new Origin(player, active.session(), active.character()))
.orElseGet(() -> new Origin(player, null, null));
}
boolean current(Origin origin) {
public boolean current(Origin origin) {
return origin.equals(player(origin.player())) && (origin.session() == null
|| sessions.current(new EquipmentSessions.Active(origin.player(), origin.session(), origin.character())));
}
public Origin source(org.bukkit.event.entity.EntityDamageEvent event) {
if (event.getEntity() instanceof Player target) {
var ongoing = OngoingDamage.read(target, event.getCause());
if (ongoing != null) return ongoing.source();
}
var origin = read(event.getDamageSource().getDirectEntity());
if (origin == null && event instanceof org.bukkit.event.entity.EntityDamageByEntityEvent byEntity) {
origin = read(byEntity.getDamager());
}
if (origin == null) origin = read(event.getDamageSource().getCausingEntity());
return origin;
}
Origin read(Entity entity) { return read(entity, 0); }
private Origin read(Entity entity, int depth) {
if (entity == null || depth >= 8) return null;
@@ -0,0 +1,206 @@
package games.dmg.fantasystick.passive;
import games.dmg.fantasystick.party.CombatSources;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.util.ArrayDeque;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.function.Supplier;
import org.bukkit.attribute.Attribute;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
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.EntityDeathEvent;
import org.bukkit.event.entity.EntityRegainHealthEvent;
import org.bukkit.event.entity.EntityRemoveEvent;
import org.bukkit.event.entity.PlayerDeathEvent;
/** Consume-once final-blow attribution and persisted native healing for Health Steal. */
public final class HealthSteal implements Listener, AutoCloseable {
public static final String ID = "health_steal";
private static final int MAX_PENDING_PER_CHARACTER = 64;
private static final int MAX_CONSUMED_EVENTS = 1_024;
public record Settings(BigInteger healing) {
public Settings {
Objects.requireNonNull(healing);
if (healing.signum() < 0 || healing.compareTo(BigInteger.valueOf(1_024)) > 0) {
throw new IllegalArgumentException("Invalid Health Steal healing");
}
}
public static Settings defaults() { return new Settings(BigInteger.ONE); }
}
public record Failure(UUID character, Throwable cause) { }
private record Candidate(LivingEntity target, CombatSources.Origin origin) { }
private record Heal(EquipmentSessions.Active active, double amount) { }
private final CharacterStorage characters;
private final EquipmentSessions sessions;
private final Consumer<Runnable> nextTick;
private final Consumer<EntityRegainHealthEvent> healingEvents;
private final Supplier<BigDecimal> nativeLimit;
private final Consumer<Failure> failures;
private final CombatSources sources;
private final Map<UUID, Candidate> lethal = new java.util.HashMap<>();
private final Set<PlayerDeathEvent> fantasyDefeats = Collections.newSetFromMap(new IdentityHashMap<>());
private final Set<EntityDeathEvent> consumed = Collections.newSetFromMap(new IdentityHashMap<>());
private final ArrayDeque<EntityDeathEvent> consumedOrder = new ArrayDeque<>();
private final Map<UUID, ArrayDeque<Heal>> pending = new java.util.HashMap<>();
private volatile Settings settings;
private boolean closed;
public HealthSteal(Settings settings, CharacterStorage characters, EquipmentSessions sessions,
Consumer<Runnable> nextTick, Consumer<EntityRegainHealthEvent> healingEvents,
Supplier<BigDecimal> nativeLimit, Consumer<Failure> failures) {
this.settings = Objects.requireNonNull(settings); this.characters = Objects.requireNonNull(characters);
this.sessions = Objects.requireNonNull(sessions); this.nextTick = Objects.requireNonNull(nextTick);
this.healingEvents = Objects.requireNonNull(healingEvents); this.nativeLimit = Objects.requireNonNull(nativeLimit);
this.failures = Objects.requireNonNull(failures); sources = new CombatSources(sessions);
}
public void configure(Settings value) { settings = Objects.requireNonNull(value); }
public BigInteger healing() { return settings.healing(); }
public PassiveDefinition definition() {
return new PassiveDefinition(ID, "Health Steal",
Map.of(Stat.STR, BigInteger.valueOf(14), Stat.CON, BigInteger.valueOf(12)), Set.of(), BigInteger.ONE,
List.of("Immediately restore " + healing() + " health point" + (healing().equals(BigInteger.ONE) ? "" : "s")
+ " when this active character's accepted damage lands a qualifying final blow.",
"Passive mobs qualify; assists, ordinary players and repeated defeat callbacks do not."));
}
@EventHandler(priority = EventPriority.MONITOR)
public void damage(EntityDamageEvent event) {
if (closed || !(event.getEntity() instanceof LivingEntity target)) return;
UUID targetId = target.getUniqueId();
if (event.isCancelled() || event.getFinalDamage() <= 0) { lethal.remove(targetId); return; }
var origin = sources.source(event);
double remaining = target.getHealth() + target.getAbsorptionAmount();
if (origin == null || origin.session() == null || !sources.current(origin)
|| targetId.equals(origin.player()) || !Double.isFinite(remaining)
|| event.getFinalDamage() < remaining) {
lethal.remove(targetId); return;
}
lethal.put(targetId, new Candidate(target, origin));
}
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true)
public void beforePlayerDeath(PlayerDeathEvent event) {
if (!closed && sessions.active(event.getEntity().getUniqueId()).isPresent()) fantasyDefeats.add(event);
}
@EventHandler(priority = EventPriority.MONITOR)
public void playerDeath(PlayerDeathEvent event) {
boolean fantasy = fantasyDefeats.remove(event);
if (!closed && fantasy && event.isCancelled()) defeat(event, event.getEntity());
else lethal.remove(event.getEntity().getUniqueId());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void mobDeath(EntityDeathEvent event) {
if (!closed && !(event instanceof PlayerDeathEvent)) defeat(event, event.getEntity());
}
@EventHandler(priority = EventPriority.MONITOR)
public void removed(EntityRemoveEvent event) {
if (event.getCause() != EntityRemoveEvent.Cause.DEATH) lethal.remove(event.getEntity().getUniqueId());
}
private void defeat(EntityDeathEvent event, LivingEntity target) {
if (!consumed.add(event)) return;
consumedOrder.addLast(event);
while (consumedOrder.size() > MAX_CONSUMED_EVENTS) consumed.remove(consumedOrder.removeFirst());
var candidate = lethal.remove(target.getUniqueId());
if (candidate == null || candidate.target() != target || !sources.current(candidate.origin())) return;
var origin = candidate.origin();
var active = sessions.active(origin.player()).orElse(null);
if (active == null || !active.session().equals(origin.session()) || !active.character().equals(origin.character())
|| !sessions.current(active)) return;
var record = sessions.currentRecord(active).orElse(null);
var player = sessions.currentPlayer(active).orElse(null);
if (record == null || player == null || record.dead() || !record.passives().contains(ID)) return;
BigInteger configured = settings.healing();
if (configured.signum() == 0) return;
var maximumAttribute = player.getAttribute(Attribute.MAX_HEALTH);
if (maximumAttribute == null) return;
double current = player.getHealth();
double maximum = Math.min(maximumAttribute.getValue(), record.maximumHealth().doubleValue());
double reserved = pending.getOrDefault(active.character(), new ArrayDeque<>()).stream()
.mapToDouble(Heal::amount).sum();
double amount = configured.bitLength() > 1023 ? maximum - current - reserved : configured.doubleValue();
double proposed = Math.min(maximum - current - reserved, amount);
if (!Double.isFinite(current) || !Double.isFinite(maximum) || current < 0 || maximum <= 0
|| !Double.isFinite(proposed) || proposed <= 0) return;
var healing = new EntityRegainHealthEvent(player, proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
healingEvents.accept(healing);
double accepted = Math.min(proposed, Math.min(maximum - current - reserved, healing.getAmount()));
if (healing.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) return;
var queue = pending.computeIfAbsent(active.character(), ignored -> new ArrayDeque<>());
if (queue.size() >= MAX_PENDING_PER_CHARACTER) return;
queue.addLast(new Heal(active, accepted));
if (queue.size() == 1) attempt(active.character());
}
private void attempt(UUID character) {
var queue = pending.get(character); var operation = queue == null ? null : queue.peekFirst();
if (closed || operation == null) return;
if (!sessions.current(operation.active())) { finish(character); return; }
var record = sessions.currentRecord(operation.active()).orElse(null);
var player = sessions.currentPlayer(operation.active()).orElse(null);
if (record == null || player == null || record.dead() || !record.passives().contains(ID)) { finish(character); return; }
double nativeBefore = player.getHealth();
BigDecimal target = record.health().add(BigDecimal.valueOf(operation.amount()));
BigDecimal limit;
try { limit = nativeLimit.get(); }
catch (RuntimeException failure) { report(character, failure); finish(character); return; }
BigInteger expected = record.revision();
characters.saveHealth(character, expected, target, limit).whenComplete((saved, failure) -> {
try { nextTick.accept(() -> complete(character, operation, expected, nativeBefore, saved, failure)); }
catch (RuntimeException rejected) { report(character, rejected); }
});
}
private void complete(UUID character, Heal operation, BigInteger expected, double nativeBefore,
java.util.Optional<CharacterRecord> saved, Throwable failure) {
try {
if (closed || failure != null || saved == null || saved.isEmpty()) {
if (failure != null) report(character, failure); return;
}
var record = saved.orElseThrow();
if (!sessions.current(operation.active())
|| !sessions.acknowledgeProgress(operation.active(), expected, record)) return;
var player = sessions.currentPlayer(operation.active()).orElse(null);
if (player == null || Double.compare(player.getHealth(), nativeBefore) != 0) return;
var attribute = player.getAttribute(Attribute.MAX_HEALTH);
if (attribute == null) return;
double maximum = Math.min(attribute.getValue(), record.maximumHealth().doubleValue());
double publish = Math.min(maximum, record.health().doubleValue());
if (Double.isFinite(maximum) && Double.isFinite(publish) && publish > nativeBefore) player.setHealth(publish);
} finally { finish(character); }
}
private void finish(UUID character) {
var queue = pending.get(character);
if (queue == null) return;
queue.pollFirst();
if (queue.isEmpty()) pending.remove(character); else attempt(character);
}
private void report(UUID character, Throwable failure) {
try { failures.accept(new Failure(character, failure)); } catch (RuntimeException ignored) { }
}
@Override public void close() {
closed = true; lethal.clear(); fantasyDefeats.clear(); consumed.clear(); consumedOrder.clear(); pending.clear();
}
}
@@ -0,0 +1,29 @@
package games.dmg.fantasystick.passive;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigInteger;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.bukkit.potion.PotionEffectType;
/** Current-session native Night Vision I layer for the learned character passive. */
public final class LowLightVision implements AutoCloseable {
public static final String ID = "low_light_vision";
private final PersistentPotionEffect effect;
public LowLightVision(EquipmentSessions sessions) {
effect = new PersistentPotionEffect(ID, PotionEffectType.NIGHT_VISION, 0, false, sessions);
}
public PassiveDefinition definition() {
return new PassiveDefinition(ID, "Low-Light Vision", Map.of(Stat.WIS, BigInteger.valueOf(12)), Set.of(), BigInteger.ONE,
List.of("Continuously provides native Night Vision I while this character is active.",
"Preserves unrelated Night Vision and does not change light, darkness or mob behavior."));
}
public void install(org.bukkit.plugin.Plugin plugin) { effect.install(plugin); }
public void sweep() { effect.sweep(); }
@Override public void close() { effect.close(); }
}
@@ -0,0 +1,24 @@
package games.dmg.fantasystick.passive;
import games.dmg.fantasystick.progression.Stat;
import java.math.BigInteger;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/** Immutable internal metadata for one character-owned automatic passive. */
public record PassiveDefinition(String id, String name, Map<Stat, BigInteger> requirements,
Set<String> prerequisites, BigInteger pointCost, List<String> description) {
public PassiveDefinition {
Objects.requireNonNull(id); Objects.requireNonNull(name); Objects.requireNonNull(requirements);
Objects.requireNonNull(prerequisites); Objects.requireNonNull(pointCost); Objects.requireNonNull(description);
requirements = Map.copyOf(requirements); prerequisites = Set.copyOf(prerequisites); description = List.copyOf(description);
if (!id.matches("[a-z][a-z0-9_]{0,63}") || name.isBlank() || !pointCost.equals(BigInteger.ONE)
|| requirements.entrySet().stream().anyMatch(entry -> entry.getValue().compareTo(BigInteger.TEN) < 0)
|| prerequisites.stream().anyMatch(value -> value.isBlank() || value.equals(id))
|| description.isEmpty() || description.stream().anyMatch(String::isBlank)) {
throw new IllegalArgumentException("Invalid passive definition");
}
}
}
@@ -0,0 +1,76 @@
package games.dmg.fantasystick.passive;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigInteger;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
/** Save-before-publication passive learning/refund coordinator for native book actions. */
public final class PassiveEngine implements AutoCloseable {
public record Failure(UUID character, String passive, Throwable cause) { }
public record Mutation(CharacterStorage.PassiveMutationStatus status, CharacterRecord record) { }
private final PassiveRegistry registry;
private final CharacterStorage characters;
private final EquipmentSessions sessions;
private final Consumer<Runnable> nextTick;
private final Consumer<Failure> failures;
private boolean closed;
public PassiveEngine(PassiveRegistry registry, CharacterStorage characters, EquipmentSessions sessions,
Consumer<Runnable> nextTick, Consumer<Failure> failures) {
this.registry = Objects.requireNonNull(registry); this.characters = Objects.requireNonNull(characters);
this.sessions = Objects.requireNonNull(sessions); this.nextTick = Objects.requireNonNull(nextTick);
this.failures = Objects.requireNonNull(failures);
}
public CompletableFuture<Mutation> purchase(ControlInput input, String passive, BigInteger expectedRevision) {
var definition = registry.find(passive).orElse(null);
if (closed || definition == null || input.kind() != ControlInput.Kind.BOOK || !input.stillCurrent()) {
return CompletableFuture.completedFuture(new Mutation(CharacterStorage.PassiveMutationStatus.UNAVAILABLE, null));
}
return mutate(input, passive, expectedRevision, () -> characters.unlockPassive(input.session().character(),
expectedRevision, passive, definition.requirements(), definition.prerequisites(), definition.pointCost()));
}
public CompletableFuture<Mutation> unlearn(ControlInput input, String passive, BigInteger expectedRevision) {
if (closed || registry.find(passive).isEmpty() || input.kind() != ControlInput.Kind.BOOK || !input.stillCurrent()) {
return CompletableFuture.completedFuture(new Mutation(CharacterStorage.PassiveMutationStatus.UNAVAILABLE, null));
}
return mutate(input, passive, expectedRevision, () -> characters.unlearnPassive(input.session().character(),
expectedRevision, passive, registry.dependents(passive)));
}
private CompletableFuture<Mutation> mutate(ControlInput input, String passive, BigInteger expectedRevision,
java.util.function.Supplier<CompletableFuture<CharacterStorage.PassiveMutation>> operation) {
var result = new CompletableFuture<Mutation>();
CompletableFuture<CharacterStorage.PassiveMutation> work;
try { work = operation.get(); }
catch (RuntimeException failure) { report(input.session().character(), passive, failure); result.completeExceptionally(failure); return result; }
work.whenComplete((mutation, failure) -> {
try { nextTick.accept(() -> {
if (failure != null) { report(input.session().character(), passive, failure); result.completeExceptionally(failure); return; }
var record = mutation.record().orElse(null);
if (mutation.status() == CharacterStorage.PassiveMutationStatus.APPLIED
&& (record == null || !input.stillCurrent()
|| !sessions.acknowledgeProgress(input.session(), expectedRevision, record))) {
result.complete(new Mutation(CharacterStorage.PassiveMutationStatus.STALE, record)); return;
}
result.complete(new Mutation(mutation.status(), record));
}); } catch (RuntimeException rejected) {
report(input.session().character(), passive, rejected); result.completeExceptionally(rejected);
}
});
return result;
}
private void report(UUID character, String passive, Throwable failure) {
try { failures.accept(new Failure(character, passive, failure)); }
catch (RuntimeException ignored) { }
}
@Override public void close() { closed = true; }
}
@@ -0,0 +1,43 @@
package games.dmg.fantasystick.passive;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/** Internal stable-ID passive catalogue consumed by persistence, books and focused runtime services. */
public final class PassiveRegistry {
private final Map<String, PassiveDefinition> definitions = new LinkedHashMap<>();
public synchronized void register(PassiveDefinition definition) {
Objects.requireNonNull(definition);
if (definitions.containsKey(definition.id())) {
throw new IllegalArgumentException("Duplicate passive ID: " + definition.id());
}
validatePrerequisites(definition);
definitions.put(definition.id(), definition);
}
public synchronized void replace(PassiveDefinition definition) {
Objects.requireNonNull(definition);
if (!definitions.containsKey(definition.id())) throw new IllegalArgumentException("Passive is not registered");
validatePrerequisites(definition);
definitions.put(definition.id(), definition);
}
private void validatePrerequisites(PassiveDefinition definition) {
if (!definitions.keySet().containsAll(definition.prerequisites())) {
throw new IllegalArgumentException("Passive prerequisite is not registered");
}
}
public synchronized Optional<PassiveDefinition> find(String id) {
return Optional.ofNullable(definitions.get(id));
}
public synchronized Collection<PassiveDefinition> definitions() { return java.util.List.copyOf(definitions.values()); }
public synchronized java.util.Set<String> dependents(String id) {
return definitions.values().stream().filter(definition -> definition.prerequisites().contains(id))
.map(PassiveDefinition::id).collect(java.util.stream.Collectors.toUnmodifiableSet());
}
}
@@ -0,0 +1,145 @@
package games.dmg.fantasystick.passive;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import org.bukkit.entity.Player;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
/** Bounded current-character ownership for one persistent native potion layer. */
final class PersistentPotionEffect implements AutoCloseable {
private static final int MAX_ACTIVE_CHECKS = 256;
private final class State {
final EquipmentSessions.Active active;
final Player player;
PotionEffect owned;
PotionEffect displaced;
long displacedUntil;
long refreshedAt;
State(EquipmentSessions.Active active, Player player) { this.active = active; this.player = player; }
void refresh() {
refreshedAt = sweepGeneration;
if (displaced != null && !displaced.isInfinite() && sweepGeneration >= displacedUntil) displaced = null;
PotionEffect current;
try { current = player.getPotionEffect(type); }
catch (RuntimeException unavailable) { return; }
if (owned != null && !same(current, owned)) { owned = null; displaced = null; }
if (!eligible()) { remove(); return; }
if (current != null && (!replaceWeaker || current.getAmplifier() >= amplifier)) return;
var effect = new PotionEffect(type, PotionEffect.INFINITE_DURATION, amplifier, false, false, true);
try {
if (player.addPotionEffect(effect, true)) {
owned = effect; displaced = current;
displacedUntil = current == null ? 0 : current.isInfinite()
? Long.MAX_VALUE : sweepGeneration + current.getDuration();
}
} catch (RuntimeException unavailable) { owned = null; displaced = null; }
}
boolean eligible() {
if (closed || !sessions.current(active)) return false;
var record = sessions.currentRecord(active).orElse(null);
return record != null && !record.dead() && record.passives().contains(passive);
}
void remove() {
if (owned != null) {
PotionEffect current;
try { current = player.getPotionEffect(type); }
catch (RuntimeException unavailable) { return; }
if (same(current, owned)) {
try {
player.removePotionEffect(type);
if (displaced != null && (displaced.isInfinite() || sweepGeneration < displacedUntil)) {
int duration = displaced.isInfinite() ? PotionEffect.INFINITE_DURATION
: Math.toIntExact(Math.min(Integer.MAX_VALUE, displacedUntil - sweepGeneration));
player.addPotionEffect(new PotionEffect(displaced.getType(), duration, displaced.getAmplifier(),
displaced.isAmbient(), displaced.hasParticles(), displaced.hasIcon()), true);
}
} catch (RuntimeException unavailable) { return; }
}
owned = null; displaced = null;
}
states.remove(active.player(), this);
}
}
private final String passive;
private final PotionEffectType type;
private final int amplifier;
private final boolean replaceWeaker;
private final EquipmentSessions sessions;
private final Map<UUID, State> states = new java.util.HashMap<>();
private int activeCursor;
private int stateCursor;
private long sweepGeneration;
private org.bukkit.scheduler.BukkitTask sweepTask;
private boolean closed;
PersistentPotionEffect(String passive, PotionEffectType type, int amplifier, boolean replaceWeaker,
EquipmentSessions sessions) {
this.passive = Objects.requireNonNull(passive); this.type = Objects.requireNonNull(type);
if (passive.isBlank() || amplifier < 0) throw new IllegalArgumentException("Invalid persistent potion effect");
this.amplifier = amplifier; this.replaceWeaker = replaceWeaker; this.sessions = Objects.requireNonNull(sessions);
}
void install(org.bukkit.plugin.Plugin plugin) {
Objects.requireNonNull(plugin);
if (closed || sweepTask != null) throw new IllegalStateException("Passive potion sweep already installed or closed");
sweepTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::sweep, 1, 1);
}
void sweep() {
if (closed) return;
sweepGeneration++;
var active = sessions.activeCharacters();
int activeChecks = Math.min(MAX_ACTIVE_CHECKS, active.size());
for (int index = 0; index < activeChecks; index++) {
var snapshot = active.get((activeCursor + index) % active.size());
reconcile(snapshot);
}
activeCursor = active.isEmpty() ? 0 : (activeCursor + activeChecks) % active.size();
var known = List.copyOf(states.values());
int stateChecks = Math.min(MAX_ACTIVE_CHECKS, known.size());
for (int index = 0; index < stateChecks; index++) {
var state = known.get((stateCursor + index) % known.size());
if (state.refreshedAt != sweepGeneration) state.refresh();
}
stateCursor = known.isEmpty() ? 0 : (stateCursor + stateChecks) % known.size();
}
private void reconcile(EquipmentSessions.ActiveCharacter snapshot) {
var active = snapshot.session();
var state = states.get(active.player());
boolean learned = !snapshot.record().dead() && snapshot.record().passives().contains(passive);
if (!learned || !sessions.current(active)) { if (state != null) state.refresh(); return; }
if (state != null && !state.active.equals(active)) { state.remove(); state = states.get(active.player()); }
if (state == null) {
var player = sessions.currentPlayer(active).orElse(null);
if (player == null) return;
state = new State(active, player); states.put(active.player(), state);
}
state.refresh();
}
private static boolean same(PotionEffect first, PotionEffect second) {
return first != null && second != null && first.getType().equals(second.getType())
&& first.getAmplifier() == second.getAmplifier() && first.isInfinite() && second.isInfinite()
&& first.isAmbient() == second.isAmbient() && first.hasParticles() == second.hasParticles()
&& first.hasIcon() == second.hasIcon();
}
@Override public void close() {
if (closed) return;
closed = true;
if (sweepTask != null) sweepTask.cancel();
for (var state : List.copyOf(states.values())) state.remove();
states.clear();
}
}
@@ -0,0 +1,33 @@
package games.dmg.fantasystick.passive;
import games.dmg.fantasystick.progression.Stat;
import java.math.BigInteger;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/** Quicker Regeneration I metadata and live configured pulse bonus. */
public final class QuickerRegenerationI {
public static final String ID = "quicker_regeneration_i";
public record Settings(BigInteger bonus) {
public Settings {
Objects.requireNonNull(bonus);
if (bonus.signum() < 0 || bonus.compareTo(BigInteger.valueOf(1_024)) > 0) {
throw new IllegalArgumentException("Invalid Quicker Regeneration I bonus");
}
}
public static Settings defaults() { return new Settings(BigInteger.ONE); }
}
private volatile Settings settings;
public QuickerRegenerationI(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public void configure(Settings value) { settings = Objects.requireNonNull(value); }
public BigInteger bonus() { return settings.bonus(); }
public PassiveDefinition definition() {
return new PassiveDefinition(ID, "Quicker Regeneration I",
Map.of(Stat.CON, BigInteger.valueOf(12), Stat.WIS, BigInteger.valueOf(12)), Set.of(), BigInteger.ONE,
List.of("Add " + bonus() + " health point" + (bonus().equals(BigInteger.ONE) ? "" : "s")
+ " to each eligible out-of-combat recovery pulse.",
"Uses the same capped persisted custom-healing transaction as base recovery."));
}
}
@@ -0,0 +1,34 @@
package games.dmg.fantasystick.passive;
import games.dmg.fantasystick.progression.Stat;
import java.math.BigInteger;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/** Quicker Regeneration II metadata and live configured pulse bonus. */
public final class QuickerRegenerationII {
public static final String ID = "quicker_regeneration_ii";
public record Settings(BigInteger bonus) {
public Settings {
Objects.requireNonNull(bonus);
if (bonus.signum() < 0 || bonus.compareTo(BigInteger.valueOf(1_024)) > 0) {
throw new IllegalArgumentException("Invalid Quicker Regeneration II bonus");
}
}
public static Settings defaults() { return new Settings(BigInteger.TWO); }
}
private volatile Settings settings;
public QuickerRegenerationII(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public void configure(Settings value) { settings = Objects.requireNonNull(value); }
public BigInteger bonus() { return settings.bonus(); }
public PassiveDefinition definition() {
return new PassiveDefinition(ID, "Quicker Regeneration II",
Map.of(Stat.CON, BigInteger.valueOf(16), Stat.WIS, BigInteger.valueOf(14)),
Set.of(QuickerRegenerationI.ID), BigInteger.ONE,
List.of("Add " + bonus() + " health point" + (bonus().equals(BigInteger.ONE) ? "" : "s")
+ " beyond Quicker Regeneration I to each eligible out-of-combat recovery pulse.",
"Both bonuses use the same capped persisted custom-healing transaction as base recovery."));
}
}
@@ -0,0 +1,29 @@
package games.dmg.fantasystick.passive;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigInteger;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.bukkit.potion.PotionEffectType;
/** Current-session native Speed I layer for the learned character passive. */
public final class SpeedI implements AutoCloseable {
public static final String ID = "speed_i";
private final PersistentPotionEffect effect;
public SpeedI(EquipmentSessions sessions) {
effect = new PersistentPotionEffect(ID, PotionEffectType.SPEED, 0, true, sessions);
}
public PassiveDefinition definition() {
return new PassiveDefinition(ID, "Speed I", Map.of(Stat.DEX, BigInteger.valueOf(14)), Set.of(), BigInteger.ONE,
List.of("Continuously provides the standard native Speed I effect while this character is active.",
"Does not stack with equal Speed I and preserves stronger unrelated Speed effects."));
}
public void install(org.bukkit.plugin.Plugin plugin) { effect.install(plugin); }
public void sweep() { effect.sweep(); }
@Override public void close() { effect.close(); }
}
@@ -16,7 +16,7 @@ import java.util.UUID;
public record CharacterRecord(
UUID id, String name, BigInteger revision, Map<Stat, BigInteger> stats,
int level, BigInteger experience, BigInteger unspentPoints, BigDecimal health,
Set<String> learned, List<String> loadout, Map<String, Instant> cooldowns,
Set<String> learned, List<String> loadout, List<String> passives, Map<String, Instant> cooldowns,
Map<UUID, CombatRewardRules.KillPosition> restrictedKills,
boolean dead, UUID deathId, Instant diedAt) {
public CharacterRecord(UUID id, String name, BigInteger revision, Map<Stat, BigInteger> stats,
@@ -24,7 +24,16 @@ public record CharacterRecord(
Set<String> learned, List<String> loadout, Map<String, Instant> cooldowns,
boolean dead, UUID deathId, Instant diedAt) {
this(id, name, revision, stats, level, experience, unspentPoints, health, learned, loadout,
cooldowns, Map.of(), dead, deathId, diedAt);
List.of("", "", ""), cooldowns, Map.of(), dead, deathId, diedAt);
}
public CharacterRecord(UUID id, String name, BigInteger revision, Map<Stat, BigInteger> stats,
int level, BigInteger experience, BigInteger unspentPoints, BigDecimal health,
Set<String> learned, List<String> loadout, Map<String, Instant> cooldowns,
Map<UUID, CombatRewardRules.KillPosition> restrictedKills,
boolean dead, UUID deathId, Instant diedAt) {
this(id, name, revision, stats, level, experience, unspentPoints, health, learned, loadout,
List.of("", "", ""), cooldowns, restrictedKills, dead, deathId, diedAt);
}
public CharacterRecord {
@@ -37,6 +46,7 @@ public record CharacterRecord(
stats = Map.copyOf(stats);
learned = Set.copyOf(learned);
loadout = List.copyOf(loadout);
passives = List.copyOf(passives);
cooldowns = Map.copyOf(cooldowns);
restrictedKills = Map.copyOf(restrictedKills);
if (revision.signum() < 0 || experience.signum() < 0 || unspentPoints.signum() < 0 || level < 1
@@ -44,7 +54,11 @@ public record CharacterRecord(
|| stats.values().stream().anyMatch(value -> value.signum() < 0)
|| health.signum() < 0
|| health.compareTo(new BigDecimal(stats.get(Stat.CON).add(BigInteger.TEN))) > 0
|| loadout.size() != 7 || learned.stream().anyMatch(String::isBlank)
|| loadout.size() != 7 || passives.size() != 3
|| passives.stream().filter(passiveId -> !passiveId.isEmpty()).anyMatch(String::isBlank)
|| passives.stream().filter(passiveId -> !passiveId.isEmpty()).distinct().count()
!= passives.stream().filter(passiveId -> !passiveId.isEmpty()).count()
|| learned.stream().anyMatch(String::isBlank)
|| cooldowns.keySet().stream().anyMatch(String::isBlank)
|| restrictedKills.size() > 1024
|| restrictedKills.entrySet().stream().anyMatch(entry -> !entry.getKey().equals(entry.getValue().world()))
@@ -53,7 +53,8 @@ public final class CharacterStorage implements AutoCloseable {
|| record.dead() || record.deathId() != null || record.level() != 1
|| record.experience().signum() != 0 || record.stats().values().stream().anyMatch(value -> value.signum() != 0)
|| record.health().compareTo(java.math.BigDecimal.TEN) != 0
|| !record.learned().isEmpty() || !record.cooldowns().isEmpty()
|| !record.learned().isEmpty() || record.passives().stream().anyMatch(id -> !id.isEmpty())
|| !record.cooldowns().isEmpty()
|| record.loadout().stream().anyMatch(ability -> !ability.isEmpty())) {
throw new IllegalArgumentException("Craft batch requires distinct new characters");
}
@@ -205,7 +206,7 @@ public final class CharacterStorage implements AutoCloseable {
var old = found.orElseThrow();
if (old.name().equals(name)) return Optional.of(old);
var renamed = new CharacterRecord(old.id(), name, old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.cooldowns(),
old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(), old.cooldowns(),
old.restrictedKills(), false, old.deathId(), old.diedAt());
repository.replace(renamed, old.revision());
return Optional.of(renamed);
@@ -246,7 +247,7 @@ public final class CharacterStorage implements AutoCloseable {
int gained = Math.max(0, level - old.level());
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
level, experience, old.unspentPoints().add(BigInteger.valueOf(gained)), old.health(), old.learned(),
old.loadout(), old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt());
old.loadout(), old.passives(), old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt());
repository.replace(saved, old.revision());
return new ExperienceAward(ExperienceAwardStatus.APPLIED, Optional.of(saved), gained);
});
@@ -288,14 +289,82 @@ public final class CharacterStorage implements AutoCloseable {
}
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
level, experience, old.unspentPoints().add(BigInteger.valueOf(gained)), old.health(), old.learned(),
old.loadout(), old.cooldowns(), history, false, old.deathId(), old.diedAt());
old.loadout(), old.passives(), old.cooldowns(), history, false, old.deathId(), old.diedAt());
repository.replace(saved, old.revision());
return new CombatAward(status, Optional.of(saved), gained);
});
}
public enum PassiveMutationStatus {
APPLIED, STALE, REQUIREMENTS, NO_POINTS, PASSIVE_SLOTS_FULL, ALREADY_LEARNED,
NOT_LEARNED, REQUIRED_BY_LEARNED, UNAVAILABLE
}
public record PassiveMutation(PassiveMutationStatus status, Optional<CharacterRecord> record) { }
/** Learn one registered passive in the lowest empty independent slot as one character revision. */
public CompletableFuture<PassiveMutation> unlockPassive(UUID characterId, BigInteger expectedRevision, String passive,
java.util.Map<Stat, BigInteger> requirements, Set<String> prerequisites, BigInteger cost) {
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision); Objects.requireNonNull(passive);
Objects.requireNonNull(requirements); Objects.requireNonNull(prerequisites); Objects.requireNonNull(cost);
if (!passive.matches("[a-z][a-z0-9_]{0,63}") || !cost.equals(BigInteger.ONE)
|| requirements.entrySet().stream().anyMatch(entry -> entry.getValue().compareTo(BigInteger.TEN) < 0)
|| prerequisites.stream().anyMatch(String::isBlank)) {
throw new IllegalArgumentException("Invalid passive unlock");
}
return submit(() -> {
var found = repository.load(characterId);
if (found.isEmpty() || found.orElseThrow().dead()) {
return new PassiveMutation(PassiveMutationStatus.UNAVAILABLE, Optional.empty());
}
var old = found.orElseThrow();
if (!old.revision().equals(expectedRevision)) return new PassiveMutation(PassiveMutationStatus.STALE, Optional.of(old));
if (old.passives().contains(passive)) return new PassiveMutation(PassiveMutationStatus.ALREADY_LEARNED, Optional.of(old));
if (!old.passives().containsAll(prerequisites) || requirements.entrySet().stream()
.anyMatch(entry -> old.stats().get(entry.getKey()).compareTo(entry.getValue()) < 0)) {
return new PassiveMutation(PassiveMutationStatus.REQUIREMENTS, Optional.of(old));
}
if (old.unspentPoints().compareTo(cost) < 0) return new PassiveMutation(PassiveMutationStatus.NO_POINTS, Optional.of(old));
int slot = old.passives().indexOf("");
if (slot < 0) return new PassiveMutation(PassiveMutationStatus.PASSIVE_SLOTS_FULL, Optional.of(old));
var passives = new java.util.ArrayList<>(old.passives()); passives.set(slot, passive);
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints().subtract(cost), old.health(), old.learned(), old.loadout(), passives,
old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt());
repository.replace(saved, old.revision());
return new PassiveMutation(PassiveMutationStatus.APPLIED, Optional.of(saved));
});
}
/** Refund one passive once while refusing removal beneath a registered learned dependent. */
public CompletableFuture<PassiveMutation> unlearnPassive(UUID characterId, BigInteger expectedRevision,
String passive, Set<String> learnedDependents) {
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision); Objects.requireNonNull(passive);
Objects.requireNonNull(learnedDependents);
if (!passive.matches("[a-z][a-z0-9_]{0,63}") || learnedDependents.stream().anyMatch(String::isBlank)) {
throw new IllegalArgumentException("Invalid passive refund");
}
return submit(() -> {
var found = repository.load(characterId);
if (found.isEmpty() || found.orElseThrow().dead()) {
return new PassiveMutation(PassiveMutationStatus.UNAVAILABLE, Optional.empty());
}
var old = found.orElseThrow();
if (!old.revision().equals(expectedRevision)) return new PassiveMutation(PassiveMutationStatus.STALE, Optional.of(old));
if (!old.passives().contains(passive)) return new PassiveMutation(PassiveMutationStatus.NOT_LEARNED, Optional.of(old));
if (old.passives().stream().anyMatch(learnedDependents::contains)) {
return new PassiveMutation(PassiveMutationStatus.REQUIRED_BY_LEARNED, Optional.of(old));
}
var passives = old.passives().stream().map(id -> id.equals(passive) ? "" : id).toList();
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints().add(BigInteger.ONE), old.health(), old.learned(), old.loadout(), passives,
old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt());
repository.replace(saved, old.revision());
return new PassiveMutation(PassiveMutationStatus.APPLIED, Optional.of(saved));
});
}
public enum AbilityMutationStatus {
APPLIED, UNCHANGED, STALE, REQUIREMENTS, NO_POINTS, ALREADY_LEARNED, NOT_LEARNED, COOLDOWN, UNAVAILABLE
APPLIED, UNCHANGED, STALE, REQUIREMENTS, NO_POINTS, LOADOUT_FULL, ALREADY_LEARNED, NOT_LEARNED, COOLDOWN, UNAVAILABLE
}
public record AbilityMutation(AbilityMutationStatus status, Optional<CharacterRecord> record) { }
@@ -317,9 +386,12 @@ public final class CharacterStorage implements AutoCloseable {
return new AbilityMutation(AbilityMutationStatus.REQUIREMENTS, Optional.of(old));
}
if (old.unspentPoints().compareTo(cost) < 0) return new AbilityMutation(AbilityMutationStatus.NO_POINTS, Optional.of(old));
int slot = old.loadout().indexOf("");
if (slot < 0) return new AbilityMutation(AbilityMutationStatus.LOADOUT_FULL, Optional.of(old));
var learned = new java.util.HashSet<>(old.learned()); learned.add(ability);
var loadout = new java.util.ArrayList<>(old.loadout()); loadout.set(slot, ability);
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints().subtract(cost), old.health(), learned, old.loadout(), old.cooldowns(),
old.experience(), old.unspentPoints().subtract(cost), old.health(), learned, loadout, old.passives(), old.cooldowns(),
old.restrictedKills(), false, old.deathId(), old.diedAt());
repository.replace(saved, old.revision());
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
@@ -339,7 +411,7 @@ public final class CharacterStorage implements AutoCloseable {
var learned = new java.util.HashSet<>(old.learned()); learned.remove(ability);
var loadout = old.loadout().stream().map(assigned -> assigned.equals(ability) ? "" : assigned).toList();
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints().add(BigInteger.ONE), old.health(), learned, loadout, old.cooldowns(),
old.experience(), old.unspentPoints().add(BigInteger.ONE), old.health(), learned, loadout, old.passives(), old.cooldowns(),
old.restrictedKills(), false, old.deathId(), old.diedAt());
repository.replace(saved, old.revision());
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
@@ -362,7 +434,7 @@ public final class CharacterStorage implements AutoCloseable {
loadout.set(slot, ability);
if (loadout.equals(old.loadout())) return new AbilityMutation(AbilityMutationStatus.UNCHANGED, Optional.of(old));
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints(), old.health(), old.learned(), loadout, old.cooldowns(),
old.experience(), old.unspentPoints(), old.health(), old.learned(), loadout, old.passives(), old.cooldowns(),
old.restrictedKills(), false, old.deathId(), old.diedAt());
repository.replace(saved, old.revision());
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
@@ -386,7 +458,7 @@ public final class CharacterStorage implements AutoCloseable {
if (readyAt.equals(previous)) return new AbilityMutation(AbilityMutationStatus.UNCHANGED, Optional.of(old));
var cooldowns = new java.util.HashMap<>(old.cooldowns()); cooldowns.put(ability, readyAt);
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), cooldowns,
old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(), cooldowns,
old.restrictedKills(), false, old.deathId(), old.diedAt());
repository.replace(saved, old.revision());
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
@@ -431,7 +503,7 @@ public final class CharacterStorage implements AutoCloseable {
var gain = rawMaximum.min(nativeLimit).subtract(oldMaximum);
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
old.level(), old.experience(), old.unspentPoints().subtract(BigInteger.ONE),
currentHealth.add(gain), old.learned(), old.loadout(), old.cooldowns(), old.restrictedKills(), false,
currentHealth.add(gain), old.learned(), old.loadout(), old.passives(), old.cooldowns(), old.restrictedKills(), false,
old.deathId(), old.diedAt());
repository.replace(saved, old.revision());
return new StatPurchase(StatPurchaseStatus.APPLIED, Optional.of(saved));
@@ -457,7 +529,7 @@ public final class CharacterStorage implements AutoCloseable {
var bounded = health.min(old.maximumHealth()).min(nativeLimit);
if (old.health().compareTo(bounded) == 0) return Optional.of(old);
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints(), bounded, old.learned(), old.loadout(), old.cooldowns(),
old.experience(), old.unspentPoints(), bounded, old.learned(), old.loadout(), old.passives(), old.cooldowns(),
old.restrictedKills(), false, old.deathId(), old.diedAt());
repository.replace(saved, old.revision());
return Optional.of(saved);
@@ -489,7 +561,7 @@ public final class CharacterStorage implements AutoCloseable {
if (old.dead() || expectedRevision != null && !old.revision().equals(expectedRevision)) return Optional.empty();
var dead = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
old.level(), old.experience(), old.unspentPoints(), java.math.BigDecimal.ZERO, old.learned(),
old.loadout(), old.cooldowns(), old.restrictedKills(), true, deathId, diedAt);
old.loadout(), old.passives(), old.cooldowns(), old.restrictedKills(), true, deathId, diedAt);
repository.replace(dead, old.revision());
return Optional.of(dead);
});
@@ -525,7 +597,7 @@ public final class CharacterStorage implements AutoCloseable {
|| ageLimited && age.compareTo(Duration.ofHours(24)) >= 0) return Optional.empty();
var health = nativeLimit == null ? old.maximumHealth() : old.maximumHealth().min(nativeLimit);
var restored = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
old.level(), old.experience(), old.unspentPoints(), health, old.learned(), old.loadout(), old.cooldowns(),
old.level(), old.experience(), old.unspentPoints(), health, old.learned(), old.loadout(), old.passives(), old.cooldowns(),
old.restrictedKills(), false, old.deathId(), old.diedAt());
repository.replace(restored, old.revision());
return Optional.of(restored);
@@ -42,6 +42,7 @@ final class CharacterYaml {
root.put("health", record.health().toString());
root.put("learned-abilities", record.learned().stream().sorted().toList());
root.put("loadout", record.loadout());
root.put("learned-passives", record.passives());
List<Map<String, String>> cooldowns = new ArrayList<>();
record.cooldowns().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry ->
cooldowns.add(Map.of("ability", entry.getKey(), "ready-at", entry.getValue().toString())));
@@ -93,8 +94,9 @@ final class CharacterYaml {
return new CharacterRecord(UUID.fromString(text(root.get("character-id"))), text(root.get("name")),
number(root.get("revision")), stats, number(root.get("level")).intValueExact(),
number(root.get("experience")), number(root.get("unspent-points")),
new BigDecimal(text(root.get("health"))), learned, strings(root.get("loadout")), cooldowns, restrictedKills,
dead, root.containsKey("death-id") ? UUID.fromString(text(root.get("death-id"))) : null,
new BigDecimal(text(root.get("health"))), learned, strings(root.get("loadout")),
root.containsKey("learned-passives") ? strings(root.get("learned-passives")) : List.of("", "", ""),
cooldowns, restrictedKills, dead, root.containsKey("death-id") ? UUID.fromString(text(root.get("death-id"))) : null,
root.containsKey("died-at") ? instant(root.get("died-at")) : null);
}
@@ -0,0 +1,113 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.passive.HealthSteal;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Durable save-before-publication Health Steal balance. */
public final class HealthStealSettings implements AutoCloseable {
public static final String KEY = "health-steal-healing-per-kill";
public static final String DEFAULT = "1";
private static final int MAX_BYTES = 1_048_576;
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String value, HealthSteal.Settings settings, long sequence) { }
private final Path directory; private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-health-steal-configuration"));
private volatile String effective; private String persisted; private long persistedSequence, publishedSequence;
private boolean closed; private int pending; private final CompletableFuture<Void> drained = new CompletableFuture<>();
public HealthStealSettings(Path directory, String initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
HealthStealSettings(Path directory, String initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath(); this.replacement = Objects.requireNonNull(replacement);
effective = canonical(initial); persisted = effective;
}
public static HealthSteal.Settings parse(String raw) {
try { return new HealthSteal.Settings(new java.math.BigInteger(raw)); }
catch (NumberFormatException invalid) { throw new IllegalArgumentException("Bonus must be a whole number", invalid); }
}
private static String canonical(String raw) { var parsed = parse(raw); return parsed.healing().toString(); }
public String value() { return effective; }
public HealthSteal.Settings settings() { return parse(effective); }
public CompletionStage<Update> set(String raw) {
String accepted = canonical(raw);
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed")); pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try { save(accepted); persisted = accepted; return new Update(accepted, parse(accepted), ++persistedSequence); }
catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("Health Steal configuration save failed; effective value unchanged", failure));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished(); return CompletableFuture.failedStage(new IOException("Configuration store is closed", rejected));
}
}
public synchronized boolean publish(Update update) {
if (update.sequence() <= publishedSequence) return false;
effective = canonical(update.value()); publishedSequence = update.sequence(); return true;
}
private void save(String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml"); var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS) && !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Unsafe lock");
}
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false); options.setMaxAliasesForCollections(20);
options.setCodePointLimit(MAX_BYTES); var yaml = new Yaml(new SafeConstructor(options));
Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes; try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String key)) throw new IOException("Invalid configuration key");
document.put(key, entry.getValue());
}
}
}
document.put(KEY, value); byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy", busy); }
}
private synchronized void finished() { pending--; if (closed && pending == 0) drained.complete(null); }
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() { closed = true; worker.shutdown(); if (pending == 0) drained.complete(null); }
}
@@ -0,0 +1,184 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.session.OutOfCombatRecovery;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Durable save-before-publication settings for active-session out-of-combat recovery. */
public final class OutOfCombatRecoverySettings implements AutoCloseable {
public static final String DELAY = "out-of-combat-healing-delay-seconds";
public static final String INTERVAL = "out-of-combat-healing-interval-seconds";
public static final String AMOUNT = "out-of-combat-healing-amount";
private static final List<String> KEYS = List.of(DELAY, INTERVAL, AMOUNT);
private static final Map<String, String> DEFAULTS = Map.of(DELAY, "30", INTERVAL, "10", AMOUNT, "1");
private static final int MAX_BYTES = 1_048_576;
private static final long MAX_SECONDS = 31_536_000L;
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, OutOfCombatRecovery.Settings settings,
Map<String, String> values, long sequence) {
public Update { values = Map.copyOf(values); }
}
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-out-of-combat-recovery-configuration"));
private volatile Map<String, String> effectiveValues;
private Map<String, String> persistedValues;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public OutOfCombatRecoverySettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
OutOfCombatRecoverySettings(Path directory, Map<String, String> initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
this.replacement = Objects.requireNonNull(replacement);
var copy = new LinkedHashMap<String, String>();
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
parse(copy);
effectiveValues = Map.copyOf(copy);
persistedValues = Map.copyOf(copy);
}
public static List<String> keys() { return KEYS; }
public static String defaultValue(String key) { return DEFAULTS.get(key); }
public String value(String key) { return effectiveValues.get(key); }
public OutOfCombatRecovery.Settings settings() { return parse(effectiveValues); }
public static OutOfCombatRecovery.Settings parse(Map<String, String> values) {
Objects.requireNonNull(values);
try {
long delay = whole(values.get(DELAY), 0, MAX_SECONDS, DELAY);
long interval = whole(values.get(INTERVAL), 1, MAX_SECONDS, INTERVAL);
long amount = whole(values.get(AMOUNT), 0, 1_024, AMOUNT);
return new OutOfCombatRecovery.Settings(Duration.ofSeconds(delay), Duration.ofSeconds(interval),
java.math.BigInteger.valueOf(amount));
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Out-of-combat recovery settings must be whole numbers in range", invalid);
}
}
private static long whole(String raw, long minimum, long maximum, String key) {
var value = new java.math.BigInteger(raw);
long result = value.longValueExact();
if (result < minimum || result > maximum) throw new IllegalArgumentException(key + " is outside its supported range");
return result;
}
public CompletionStage<Update> set(String key, String raw) {
Objects.requireNonNull(key); Objects.requireNonNull(raw);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted;
try { accepted = new java.math.BigInteger(raw).toString(); }
catch (NumberFormatException invalid) { throw new IllegalArgumentException("Setting must be a whole number", invalid); }
var preview = new LinkedHashMap<>(effectiveValues); preview.put(key, accepted); parse(preview);
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
var next = new LinkedHashMap<>(persistedValues); next.put(key, accepted);
var parsed = parse(next);
save(key, accepted);
persistedValues = Map.copyOf(next);
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("Out-of-combat recovery configuration save failed; effective settings unchanged", failure));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished();
return CompletableFuture.failedStage(new IOException("Configuration store is closed", rejected));
}
}
/** Publish only an acknowledged newest persisted snapshot. */
public synchronized boolean publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return false;
parse(update.values());
effectiveValues = update.values();
publishedSequence = update.sequence();
return true;
}
private void save(String key, String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml");
var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS)
&& !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe lock");
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options));
Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
document.put(savedKey, entry.getValue());
}
}
}
document.put(key, value);
byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy", busy); }
}
private synchronized void finished() {
pending--;
if (closed && pending == 0) drained.complete(null);
}
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() {
closed = true; worker.shutdown(); if (pending == 0) drained.complete(null);
}
}
@@ -0,0 +1,113 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.passive.QuickerRegenerationII;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Durable save-before-publication Quicker Regeneration II balance. */
public final class QuickerRegenerationIISettings implements AutoCloseable {
public static final String KEY = "quicker-regeneration-ii-bonus-healing";
public static final String DEFAULT = "2";
private static final int MAX_BYTES = 1_048_576;
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String value, QuickerRegenerationII.Settings settings, long sequence) { }
private final Path directory; private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-quicker-regeneration-ii-configuration"));
private volatile String effective; private String persisted; private long persistedSequence, publishedSequence;
private boolean closed; private int pending; private final CompletableFuture<Void> drained = new CompletableFuture<>();
public QuickerRegenerationIISettings(Path directory, String initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
QuickerRegenerationIISettings(Path directory, String initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath(); this.replacement = Objects.requireNonNull(replacement);
effective = canonical(initial); persisted = effective;
}
public static QuickerRegenerationII.Settings parse(String raw) {
try { return new QuickerRegenerationII.Settings(new java.math.BigInteger(raw)); }
catch (NumberFormatException invalid) { throw new IllegalArgumentException("Bonus must be a whole number", invalid); }
}
private static String canonical(String raw) { var parsed = parse(raw); return parsed.bonus().toString(); }
public String value() { return effective; }
public QuickerRegenerationII.Settings settings() { return parse(effective); }
public CompletionStage<Update> set(String raw) {
String accepted = canonical(raw);
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed")); pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try { save(accepted); persisted = accepted; return new Update(accepted, parse(accepted), ++persistedSequence); }
catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("Quicker Regeneration II configuration save failed; effective value unchanged", failure));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished(); return CompletableFuture.failedStage(new IOException("Configuration store is closed", rejected));
}
}
public synchronized boolean publish(Update update) {
if (update.sequence() <= publishedSequence) return false;
effective = canonical(update.value()); publishedSequence = update.sequence(); return true;
}
private void save(String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml"); var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS) && !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Unsafe lock");
}
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false); options.setMaxAliasesForCollections(20);
options.setCodePointLimit(MAX_BYTES); var yaml = new Yaml(new SafeConstructor(options));
Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes; try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String key)) throw new IOException("Invalid configuration key");
document.put(key, entry.getValue());
}
}
}
document.put(KEY, value); byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy", busy); }
}
private synchronized void finished() { pending--; if (closed && pending == 0) drained.complete(null); }
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() { closed = true; worker.shutdown(); if (pending == 0) drained.complete(null); }
}
@@ -0,0 +1,113 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.passive.QuickerRegenerationI;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Durable save-before-publication Quicker Regeneration I balance. */
public final class QuickerRegenerationSettings implements AutoCloseable {
public static final String KEY = "quicker-regeneration-i-bonus-healing";
public static final String DEFAULT = "1";
private static final int MAX_BYTES = 1_048_576;
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String value, QuickerRegenerationI.Settings settings, long sequence) { }
private final Path directory; private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-quicker-regeneration-i-configuration"));
private volatile String effective; private String persisted; private long persistedSequence, publishedSequence;
private boolean closed; private int pending; private final CompletableFuture<Void> drained = new CompletableFuture<>();
public QuickerRegenerationSettings(Path directory, String initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
QuickerRegenerationSettings(Path directory, String initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath(); this.replacement = Objects.requireNonNull(replacement);
effective = canonical(initial); persisted = effective;
}
public static QuickerRegenerationI.Settings parse(String raw) {
try { return new QuickerRegenerationI.Settings(new java.math.BigInteger(raw)); }
catch (NumberFormatException invalid) { throw new IllegalArgumentException("Bonus must be a whole number", invalid); }
}
private static String canonical(String raw) { var parsed = parse(raw); return parsed.bonus().toString(); }
public String value() { return effective; }
public QuickerRegenerationI.Settings settings() { return parse(effective); }
public CompletionStage<Update> set(String raw) {
String accepted = canonical(raw);
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed")); pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try { save(accepted); persisted = accepted; return new Update(accepted, parse(accepted), ++persistedSequence); }
catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("Quicker Regeneration I configuration save failed; effective value unchanged", failure));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished(); return CompletableFuture.failedStage(new IOException("Configuration store is closed", rejected));
}
}
public synchronized boolean publish(Update update) {
if (update.sequence() <= publishedSequence) return false;
effective = canonical(update.value()); publishedSequence = update.sequence(); return true;
}
private void save(String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml"); var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS) && !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Unsafe lock");
}
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false); options.setMaxAliasesForCollections(20);
options.setCodePointLimit(MAX_BYTES); var yaml = new Yaml(new SafeConstructor(options));
Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes; try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String key)) throw new IOException("Invalid configuration key");
document.put(key, entry.getValue());
}
}
}
document.put(KEY, value); byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy", busy); }
}
private synchronized void finished() { pending--; if (closed && pending == 0) drained.complete(null); }
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() { closed = true; worker.shutdown(); if (pending == 0) drained.complete(null); }
}
@@ -60,7 +60,7 @@ public final class AdminRevival implements Listener, AutoCloseable {
private void confirm(View view,Choice choice){if(!view.current()||!unchanged(view.target,choice.candidate)){view.close();return;}characters.find(choice.record.id()).whenComplete((found,failure)->schedule(()->{if(failure!=null){report(failure);view.admin.sendMessage("Admin revival could not recheck that Tail; no item changed.");view.close();return;}if(!view.current()||found.isEmpty()||!eligible(found.orElseThrow(),choice.candidate.identity)||!unchanged(view.target,choice.candidate)){view.admin.sendMessage("That Tail selection is stale; no item changed.");view.close();return;}reserve(view,choice.candidate,found.orElseThrow());}));}
private void reserve(View view,Candidate candidate,CharacterRecord dead){var source=view.target.getInventory().getItem(candidate.slot);int destination=source.getAmount()==1?-1:emptySlot(view.target,candidate.slot);if(source.getAmount()>1&&destination<0){view.admin.sendMessage("Admin revival needs one empty direct inventory slot to preserve copied Tails.");view.close();return;}var reservation=new RevivalStore.Reservation(UUID.randomUUID(),view.admin.getUniqueId(),view.target.getUniqueId(),dead.id(),dead.deathId(),clock.instant().truncatedTo(ChronoUnit.SECONDS),candidate.slot,source.getAmount(),destination,true);reservations.reserve(reservation).whenComplete((saved,failure)->schedule(()->{if(failure!=null){report(failure);view.admin.sendMessage("Admin revival reservation failed; no item changed.");view.close();return;}if(saved.isEmpty()||!view.current()||!unchanged(view.target,candidate)){view.admin.sendMessage("Admin revival became stale after reservation; no item changed.");view.close();return;}deliver(view,dead,reservation);}));}
private static int emptySlot(Player target,int source){for(int slot=0;slot<=40;slot++){if(slot>35&&slot!=40||slot==source)continue;var item=target.getInventory().getItem(slot);if(item==null||item.isEmpty())return slot;}return-1;}
private void deliver(View view,CharacterRecord dead,RevivalStore.Reservation reservation){try{var provisional=new CharacterRecord(dead.id(),dead.name(),dead.revision(),dead.stats(),dead.level(),dead.experience(),dead.unspentPoints(),dead.maximumHealth(),dead.learned(),dead.loadout(),dead.cooldowns(),dead.restrictedKills(),false,dead.deathId(),dead.diedAt());var sheet=sheets.create(provisional);var source=view.target.getInventory().getItem(reservation.sourceSlot());if(reservation.sourceAmount()==1)view.target.getInventory().setItem(reservation.sourceSlot(),sheet);else{view.target.getInventory().setItem(reservation.destinationSlot(),sheet);var remaining=source.clone();remaining.setAmount(reservation.sourceAmount()-1);view.target.getInventory().setItem(reservation.sourceSlot(),remaining);}view.target.updateInventory();}catch(RuntimeException uncertain){report(uncertain);view.admin.sendMessage("Admin revival delivery is uncertain; retained proof requires recovery.");view.close();return;}characters.restoreDeathRecordAdministratively(reservation.character(),reservation.death(),NativeHealthLimit.read()).whenComplete((restored,failure)->schedule(()->{if(failure!=null||restored.isEmpty()){if(failure!=null)report(failure);view.admin.sendMessage("The Tail changed, but restoration is pending proof-based recovery.");view.close();return;}if(installFinal(view.target,restored.orElseThrow())){reservations.clear(reservation.character(),reservation.id()).exceptionally(error->{report(error);return false;});view.admin.sendMessage("Administratively restored "+restored.orElseThrow().name()+" for "+view.target.getName()+".");view.target.sendMessage("An administrator restored character sheet: "+restored.orElseThrow().name()+".");}else view.admin.sendMessage("Character restoration was saved, but sheet custody is ambiguous; retained proof requires recovery.");view.close();}));}
private void deliver(View view,CharacterRecord dead,RevivalStore.Reservation reservation){try{var provisional=new CharacterRecord(dead.id(),dead.name(),dead.revision(),dead.stats(),dead.level(),dead.experience(),dead.unspentPoints(),dead.maximumHealth(),dead.learned(),dead.loadout(),dead.passives(),dead.cooldowns(),dead.restrictedKills(),false,dead.deathId(),dead.diedAt());var sheet=sheets.create(provisional);var source=view.target.getInventory().getItem(reservation.sourceSlot());if(reservation.sourceAmount()==1)view.target.getInventory().setItem(reservation.sourceSlot(),sheet);else{view.target.getInventory().setItem(reservation.destinationSlot(),sheet);var remaining=source.clone();remaining.setAmount(reservation.sourceAmount()-1);view.target.getInventory().setItem(reservation.sourceSlot(),remaining);}view.target.updateInventory();}catch(RuntimeException uncertain){report(uncertain);view.admin.sendMessage("Admin revival delivery is uncertain; retained proof requires recovery.");view.close();return;}characters.restoreDeathRecordAdministratively(reservation.character(),reservation.death(),NativeHealthLimit.read()).whenComplete((restored,failure)->schedule(()->{if(failure!=null||restored.isEmpty()){if(failure!=null)report(failure);view.admin.sendMessage("The Tail changed, but restoration is pending proof-based recovery.");view.close();return;}if(installFinal(view.target,restored.orElseThrow())){reservations.clear(reservation.character(),reservation.id()).exceptionally(error->{report(error);return false;});view.admin.sendMessage("Administratively restored "+restored.orElseThrow().name()+" for "+view.target.getName()+".");view.target.sendMessage("An administrator restored character sheet: "+restored.orElseThrow().name()+".");}else view.admin.sendMessage("Character restoration was saved, but sheet custody is ambiguous; retained proof requires recovery.");view.close();}));}
private boolean installFinal(Player target,CharacterRecord record){int found=-1;for(int slot=0;slot<=40;slot++){if(slot>35&&slot!=40)continue;if(sheets.identify(target.getInventory().getItem(slot)).filter(record.id()::equals).isEmpty())continue;if(found>=0)return false;found=slot;}if(found<0)return false;target.getInventory().setItem(found,sheets.create(record));target.updateInventory();return true;}
private void schedule(Runnable action){try{nextTick.accept(action);}catch(RuntimeException rejected){report(rejected);}}
private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}
@@ -66,6 +66,15 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private games.dmg.fantasystick.persistence.PartySettings partySettings;
private games.dmg.fantasystick.persistence.SheetResourcePackSettings sheetResourcePackSettings;
private games.dmg.fantasystick.presentation.SheetResourcePack sheetResourcePack;
private games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings outOfCombatRecoverySettings;
private OutOfCombatRecovery outOfCombatRecovery;
private games.dmg.fantasystick.persistence.QuickerRegenerationSettings quickerRegenerationSettings;
private games.dmg.fantasystick.passive.QuickerRegenerationI quickerRegeneration;
private games.dmg.fantasystick.persistence.QuickerRegenerationIISettings quickerRegenerationIISettings;
private games.dmg.fantasystick.passive.QuickerRegenerationII quickerRegenerationII;
private games.dmg.fantasystick.persistence.HealthStealSettings healthStealSettings;
private games.dmg.fantasystick.passive.HealthSteal healthSteal;
private games.dmg.fantasystick.passive.PassiveRegistry passiveRegistry;
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics) {
@@ -293,6 +302,36 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
public void attachRoleplaySettings(games.dmg.fantasystick.persistence.RoleplaySettings settings){if(closed||roleplaySettings!=null)throw new IllegalStateException("Roleplay settings already attached or command closed");roleplaySettings=Objects.requireNonNull(settings);}
public void attachPartySettings(games.dmg.fantasystick.persistence.PartySettings settings){if(closed||partySettings!=null)throw new IllegalStateException("Party settings already attached or command closed");partySettings=Objects.requireNonNull(settings);}
public void attachSheetResourcePack(games.dmg.fantasystick.persistence.SheetResourcePackSettings settings,games.dmg.fantasystick.presentation.SheetResourcePack delivery){if(closed||sheetResourcePackSettings!=null)throw new IllegalStateException("Sheet resource pack already attached or command closed");sheetResourcePackSettings=Objects.requireNonNull(settings);sheetResourcePack=Objects.requireNonNull(delivery);}
public void attachOutOfCombatRecovery(games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings settings,
OutOfCombatRecovery recovery) {
if (closed || outOfCombatRecoverySettings != null) throw new IllegalStateException("Out-of-combat recovery already attached or command closed");
outOfCombatRecoverySettings = Objects.requireNonNull(settings);
outOfCombatRecovery = Objects.requireNonNull(recovery);
}
public void attachQuickerRegeneration(games.dmg.fantasystick.persistence.QuickerRegenerationSettings settings,
games.dmg.fantasystick.passive.QuickerRegenerationI passive,
games.dmg.fantasystick.passive.PassiveRegistry registry) {
if (closed || quickerRegenerationSettings != null) throw new IllegalStateException("Quicker Regeneration I already attached or command closed");
quickerRegenerationSettings = Objects.requireNonNull(settings); quickerRegeneration = Objects.requireNonNull(passive);
passiveRegistry = Objects.requireNonNull(registry);
}
public void attachQuickerRegenerationII(games.dmg.fantasystick.persistence.QuickerRegenerationIISettings settings,
games.dmg.fantasystick.passive.QuickerRegenerationII passive,
games.dmg.fantasystick.passive.PassiveRegistry registry) {
if (closed || quickerRegenerationIISettings != null) throw new IllegalStateException("Quicker Regeneration II already attached or command closed");
quickerRegenerationIISettings = Objects.requireNonNull(settings); quickerRegenerationII = Objects.requireNonNull(passive);
if (passiveRegistry != Objects.requireNonNull(registry)) throw new IllegalArgumentException("Passive registry mismatch");
}
public void attachHealthSteal(games.dmg.fantasystick.persistence.HealthStealSettings settings,
games.dmg.fantasystick.passive.HealthSteal passive,
games.dmg.fantasystick.passive.PassiveRegistry registry) {
if (closed || healthStealSettings != null) throw new IllegalStateException("Health Steal already attached or command closed");
healthStealSettings = Objects.requireNonNull(settings); healthSteal = Objects.requireNonNull(passive);
if (passiveRegistry != Objects.requireNonNull(registry)) throw new IllegalArgumentException("Passive registry mismatch");
}
public void attachExperienceGrant(AdminExperienceGrant grant) {
if (closed || experienceGrant != null) throw new IllegalStateException("Experience grant already attached or command closed");
@@ -471,6 +510,21 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (args[1].equals(games.dmg.fantasystick.persistence.PartySettings.KEY)) { partyConfiguration(sender,args); return; }
if (args[1].equals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.URL_KEY)||args[1].equals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.SHA1_KEY)) { sheetResourcePackConfiguration(sender,args); return; }
if (args[1].equals(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY)) { adminReviveConfiguration(sender,args); return; }
if (outOfCombatRecoverySettings != null
&& games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.keys().contains(args[1])) {
outOfCombatRecoveryConfiguration(sender, args); return;
}
if (args[1].equals(games.dmg.fantasystick.persistence.QuickerRegenerationSettings.KEY)
&& quickerRegenerationSettings != null) {
quickerRegenerationConfiguration(sender, args); return;
}
if (args[1].equals(games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.KEY)
&& quickerRegenerationIISettings != null) {
quickerRegenerationIIConfiguration(sender, args); return;
}
if (args[1].equals(games.dmg.fantasystick.persistence.HealthStealSettings.KEY) && healthStealSettings != null) {
healthStealConfiguration(sender, args); return;
}
if (longbowSettings != null && games.dmg.fantasystick.persistence.LongbowSettings.keys().contains(args[1])) {
longbowConfiguration(sender, args); return;
}
@@ -553,6 +607,126 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
});
}
private void outOfCombatRecoveryConfiguration(CommandSender sender, String[] args) {
String key = args[1];
if (args.length == 2) {
sender.sendMessage(key + "=" + outOfCombatRecoverySettings.value(key)
+ " (applies to future recovery evaluations; the current activity timestamp is retained)");
return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.Update> operation;
try { operation = outOfCombatRecoverySettings.set(key, args[2]); }
catch (IllegalArgumentException invalid) {
sender.sendMessage("Out-of-combat recovery setting is invalid; the effective value is unchanged."); return;
}
operation.whenComplete((saved, failure) -> {
if (failure != null) report(null, "config-" + key, failure);
if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
boolean applied = false;
if (failure == null) {
applied = outOfCombatRecoverySettings.publish(saved);
if (applied) outOfCombatRecovery.configure(saved.settings());
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
if (failure != null) sender.sendMessage("Out-of-combat recovery configuration save failed; effective values are unchanged.");
else if (!applied) sender.sendMessage("Saved request was superseded; effective " + key + "="
+ outOfCombatRecoverySettings.value(key) + ".");
else sender.sendMessage("Saved " + key + "=" + saved.value()
+ "; future evaluations use it and current activity timestamps are retained.");
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
private void quickerRegenerationConfiguration(CommandSender sender, String[] args) {
if (args.length == 2) {
sender.sendMessage(games.dmg.fantasystick.persistence.QuickerRegenerationSettings.KEY + "="
+ quickerRegenerationSettings.value() + " (applies to future recovery pulses only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.QuickerRegenerationSettings.Update> operation;
try { operation = quickerRegenerationSettings.set(args[2]); }
catch (IllegalArgumentException invalid) {
sender.sendMessage("Quicker Regeneration I setting is invalid; the effective value is unchanged."); return;
}
operation.whenComplete((saved, failure) -> {
if (failure != null) report(null, "config-quicker-regeneration-i", failure); if (closed) return;
try { nextTick.accept(() -> {
if (closed) return; boolean applied = false;
if (failure == null) {
applied = quickerRegenerationSettings.publish(saved);
if (applied) { quickerRegeneration.configure(saved.settings()); passiveRegistry.replace(quickerRegeneration.definition()); }
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
if (failure != null) sender.sendMessage("Quicker Regeneration I configuration save failed; effective value is unchanged.");
else if (!applied) sender.sendMessage("Saved request was superseded; effective "
+ games.dmg.fantasystick.persistence.QuickerRegenerationSettings.KEY + "=" + quickerRegenerationSettings.value() + ".");
else sender.sendMessage("Saved " + games.dmg.fantasystick.persistence.QuickerRegenerationSettings.KEY
+ "=" + saved.value() + " for future recovery pulses.");
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
private void quickerRegenerationIIConfiguration(CommandSender sender, String[] args) {
if (args.length == 2) {
sender.sendMessage(games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.KEY + "="
+ quickerRegenerationIISettings.value() + " (applies to future recovery pulses only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.Update> operation;
try { operation = quickerRegenerationIISettings.set(args[2]); }
catch (IllegalArgumentException invalid) {
sender.sendMessage("Quicker Regeneration II setting is invalid; the effective value is unchanged."); return;
}
operation.whenComplete((saved, failure) -> {
if (failure != null) report(null, "config-quicker-regeneration-ii", failure); if (closed) return;
try { nextTick.accept(() -> {
if (closed) return; boolean applied = false;
if (failure == null) {
applied = quickerRegenerationIISettings.publish(saved);
if (applied) { quickerRegenerationII.configure(saved.settings()); passiveRegistry.replace(quickerRegenerationII.definition()); }
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
if (failure != null) sender.sendMessage("Quicker Regeneration II configuration save failed; effective value is unchanged.");
else if (!applied) sender.sendMessage("Saved request was superseded; effective "
+ games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.KEY + "=" + quickerRegenerationIISettings.value() + ".");
else sender.sendMessage("Saved " + games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.KEY
+ "=" + saved.value() + " for future recovery pulses.");
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
private void healthStealConfiguration(CommandSender sender, String[] args) {
if (args.length == 2) {
sender.sendMessage(games.dmg.fantasystick.persistence.HealthStealSettings.KEY + "="
+ healthStealSettings.value() + " (applies to later qualifying final blows only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.HealthStealSettings.Update> operation;
try { operation = healthStealSettings.set(args[2]); }
catch (IllegalArgumentException invalid) {
sender.sendMessage("Health Steal setting is invalid; the effective value is unchanged."); return;
}
operation.whenComplete((saved, failure) -> {
if (failure != null) report(null, "config-health-steal", failure); if (closed) return;
try { nextTick.accept(() -> {
if (closed) return; boolean applied = false;
if (failure == null) {
applied = healthStealSettings.publish(saved);
if (applied) { healthSteal.configure(saved.settings()); passiveRegistry.replace(healthSteal.definition()); }
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
if (failure != null) sender.sendMessage("Health Steal configuration save failed; effective value is unchanged.");
else if (!applied) sender.sendMessage("Saved request was superseded; effective "
+ games.dmg.fantasystick.persistence.HealthStealSettings.KEY + "=" + healthStealSettings.value() + ".");
else sender.sendMessage("Saved " + games.dmg.fantasystick.persistence.HealthStealSettings.KEY
+ "=" + saved.value() + " for later qualifying final blows.");
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
private void longbowConfiguration(CommandSender sender, String[] args) {
String key = args[1];
if (args.length == 2) {
@@ -980,6 +1154,10 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
.filter(org.bukkit.entity.Player::isOnline).map(org.bukkit.entity.Player::getName).toList(), args[1]);
if (args.length == 2 && args[0].equals("config")) {
var keys = new java.util.ArrayList<String>(); keys.add("adventure-mode");keys.add(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY);keys.add(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY);keys.add(games.dmg.fantasystick.persistence.RoleplaySettings.KEY);keys.add(games.dmg.fantasystick.persistence.PartySettings.KEY);keys.add(games.dmg.fantasystick.persistence.SheetResourcePackSettings.URL_KEY);keys.add(games.dmg.fantasystick.persistence.SheetResourcePackSettings.SHA1_KEY);
keys.addAll(games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.keys());
keys.add(games.dmg.fantasystick.persistence.QuickerRegenerationSettings.KEY);
keys.add(games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.KEY);
keys.add(games.dmg.fantasystick.persistence.HealthStealSettings.KEY);
keys.addAll(games.dmg.fantasystick.persistence.FrostShardSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.LongbowSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.QuickstepSettings.keys());
@@ -1006,6 +1184,29 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if(args[1].equals(games.dmg.fantasystick.persistence.RoleplaySettings.KEY)&&roleplaySettings!=null)return matching(List.of("false","true"),args[2]);
if(args[1].equals(games.dmg.fantasystick.persistence.PartySettings.KEY)&&partySettings!=null){String current=partySettings.value();return matching(current.equals(games.dmg.fantasystick.persistence.PartySettings.DEFAULT)?List.of(current):List.of(current,games.dmg.fantasystick.persistence.PartySettings.DEFAULT),args[2]);}
if(sheetResourcePackSettings!=null&&(args[1].equals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.URL_KEY)||args[1].equals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.SHA1_KEY)))return matching(List.of(sheetResourcePackSettings.value(args[1])),args[2]);
if (outOfCombatRecoverySettings != null
&& games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.keys().contains(args[1])) {
String current = outOfCombatRecoverySettings.value(args[1]);
String defaults = games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.defaultValue(args[1]);
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
}
if (args[1].equals(games.dmg.fantasystick.persistence.QuickerRegenerationSettings.KEY)
&& quickerRegenerationSettings != null) {
String current = quickerRegenerationSettings.value();
return matching(current.equals(games.dmg.fantasystick.persistence.QuickerRegenerationSettings.DEFAULT)
? List.of(current) : List.of(current, games.dmg.fantasystick.persistence.QuickerRegenerationSettings.DEFAULT), args[2]);
}
if (args[1].equals(games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.KEY)
&& quickerRegenerationIISettings != null) {
String current = quickerRegenerationIISettings.value();
return matching(current.equals(games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.DEFAULT)
? List.of(current) : List.of(current, games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.DEFAULT), args[2]);
}
if (args[1].equals(games.dmg.fantasystick.persistence.HealthStealSettings.KEY) && healthStealSettings != null) {
String current = healthStealSettings.value();
return matching(current.equals(games.dmg.fantasystick.persistence.HealthStealSettings.DEFAULT)
? List.of(current) : List.of(current, games.dmg.fantasystick.persistence.HealthStealSettings.DEFAULT), args[2]);
}
if (frostSettings != null && games.dmg.fantasystick.persistence.FrostShardSettings.keys().contains(args[1])) {
String current = frostSettings.value(args[1]);
String defaults = games.dmg.fantasystick.persistence.FrostShardSettings.defaultValue(args[1]);
@@ -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. */
@@ -242,6 +246,12 @@ public final class EquipmentSessions implements AutoCloseable {
.map(attempt -> new Active(attempt.owner, attempt.session, attempt.character)).findFirst();
}
/** Current native holder for a still-authorized server-thread action. */
public synchronized Optional<Player> currentPlayer(Active session) {
var attempt = active.get(session.player());
return current(session) && attempt != null ? Optional.of(attempt.player) : Optional.empty();
}
/** Current transferable character snapshot for a still-authorized server-thread action. */
public synchronized Optional<CharacterRecord> currentRecord(Active session) {
var attempt = active.get(session.player());
@@ -259,18 +269,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 +518,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); }
@@ -0,0 +1,244 @@
package games.dmg.fantasystick.session;
import games.dmg.fantasystick.party.CombatSources;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayDeque;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.function.Supplier;
import org.bukkit.attribute.Attribute;
import org.bukkit.entity.Player;
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.EntityRegainHealthEvent;
/** Active-session damage clock and persist-before-native gradual recovery. */
public final class OutOfCombatRecovery implements Listener, AutoCloseable {
public record Settings(Duration delay, Duration interval, BigInteger amount) {
private static final Duration MAX = Duration.ofSeconds(31_536_000);
public Settings {
Objects.requireNonNull(delay); Objects.requireNonNull(interval); Objects.requireNonNull(amount);
if (delay.isNegative() || delay.compareTo(MAX) > 0 || interval.isZero() || interval.isNegative()
|| interval.compareTo(MAX) > 0 || amount.signum() < 0
|| amount.compareTo(BigInteger.valueOf(1_024)) > 0) {
throw new IllegalArgumentException("Invalid out-of-combat recovery settings");
}
}
public static Settings defaults() {
return new Settings(Duration.ofSeconds(30), Duration.ofSeconds(10), BigInteger.ONE);
}
}
public record Failure(UUID character, Throwable cause) { }
private static final int MAXIMUM_SESSION_CHECKS = 64;
private static final class State {
final EquipmentSessions.Active active;
Instant activity;
Instant lastAttempt;
long generation;
Object pending;
State(EquipmentSessions.Active active, Instant activity) { this.active = active; this.activity = activity; }
}
private final CharacterStorage characters;
private final EquipmentSessions sessions;
private final Clock clock;
private final Consumer<Runnable> nextTick;
private final Consumer<EntityRegainHealthEvent> healingEvents;
private final Supplier<BigDecimal> nativeLimit;
private final Consumer<Failure> failures;
private final CombatSources sources;
private java.util.function.Function<CharacterRecord, BigInteger> bonusHealing = ignored -> BigInteger.ZERO;
private final Map<UUID, State> states = new HashMap<>();
private final ArrayDeque<UUID> order = new ArrayDeque<>();
private final Set<UUID> queued = new HashSet<>();
private volatile Settings settings;
private org.bukkit.scheduler.BukkitTask task;
private boolean closed;
public OutOfCombatRecovery(Settings settings, CharacterStorage characters, EquipmentSessions sessions, Clock clock,
Consumer<Runnable> nextTick, Consumer<EntityRegainHealthEvent> healingEvents,
Supplier<BigDecimal> nativeLimit, Consumer<Failure> failures) {
this.settings = Objects.requireNonNull(settings); this.characters = Objects.requireNonNull(characters);
this.sessions = Objects.requireNonNull(sessions); this.clock = Objects.requireNonNull(clock);
this.nextTick = Objects.requireNonNull(nextTick); this.healingEvents = Objects.requireNonNull(healingEvents);
this.nativeLimit = Objects.requireNonNull(nativeLimit); this.failures = Objects.requireNonNull(failures);
sources = new CombatSources(sessions);
}
public void configure(Settings value) { settings = Objects.requireNonNull(value); }
/** Install the learned-passive bonus before recovery evaluation begins. */
public synchronized void attachBonusHealing(java.util.function.Function<CharacterRecord, BigInteger> bonus) {
if (closed || task != null || !states.isEmpty()) throw new IllegalStateException("Recovery evaluation already started");
bonusHealing = Objects.requireNonNull(bonus);
}
public synchronized void install(org.bukkit.plugin.Plugin plugin) {
Objects.requireNonNull(plugin);
if (closed || task != null) throw new IllegalStateException("Out-of-combat recovery is already installed or closed");
task = plugin.getServer().getScheduler().runTaskTimer(plugin, this::sweep, 1, 1);
}
/** Bounded active-session evaluation. Exact deadlines are eligible, never replayed in a burst. */
public synchronized void sweep() {
if (closed) return;
Instant now = clock.instant();
var active = new HashMap<UUID, EquipmentSessions.ActiveCharacter>();
for (var snapshot : sessions.activeCharacters()) {
active.put(snapshot.session().player(), snapshot);
var state = states.get(snapshot.session().player());
if (state == null || !state.active.equals(snapshot.session())) {
state = new State(snapshot.session(), now);
states.put(snapshot.session().player(), state);
}
if (queued.add(snapshot.session().player())) order.addLast(snapshot.session().player());
}
for (UUID owner : java.util.List.copyOf(states.keySet())) {
var snapshot = active.get(owner);
if (snapshot == null || !states.get(owner).active.equals(snapshot.session())) remove(owner);
}
int checks = Math.min(MAXIMUM_SESSION_CHECKS, order.size());
for (int checked = 0; checked < checks; checked++) {
UUID owner = order.removeFirst();
var state = states.get(owner);
var snapshot = active.get(owner);
if (state == null || snapshot == null || !state.active.equals(snapshot.session())) {
remove(owner); continue;
}
order.addLast(owner);
evaluate(state, snapshot.record(), now);
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public synchronized void acceptedDamage(EntityDamageEvent event) {
if (closed || event.isCancelled() || !Double.isFinite(event.getFinalDamage()) || event.getFinalDamage() <= 0) return;
if (event.getEntity() instanceof Player target) {
sessions.active(target.getUniqueId()).filter(sessions::current).ifPresent(this::reset);
}
var origin = sources.source(event);
if (origin != null && origin.session() != null && sources.current(origin)) {
reset(new EquipmentSessions.Active(origin.player(), origin.session(), origin.character()));
}
}
private void reset(EquipmentSessions.Active active) {
Instant now = clock.instant();
var state = states.get(active.player());
if (state == null || !state.active.equals(active)) {
state = new State(active, now);
states.put(active.player(), state);
if (queued.add(active.player())) order.addLast(active.player());
} else {
state.activity = now;
state.lastAttempt = null;
state.generation++;
}
}
private void evaluate(State state, CharacterRecord record, Instant now) {
if (state.pending != null || !sessions.current(state.active)) return;
var configured = settings;
Instant eligible = state.lastAttempt == null
? state.activity.plus(configured.delay()) : state.lastAttempt.plus(configured.interval());
if (now.isBefore(eligible) || configured.amount().signum() == 0) return;
var player = sessions.currentPlayer(state.active).orElse(null);
if (player == null) return;
var attribute = player.getAttribute(Attribute.MAX_HEALTH);
if (attribute == null) return;
double current = player.getHealth();
double maximum = Math.min(attribute.getValue(), record.maximumHealth().doubleValue());
if (!Double.isFinite(current) || !Double.isFinite(maximum) || current < 0 || maximum <= 0) return;
if (current >= maximum) {
state.lastAttempt = now; // Full-health checks are not banked for later replay.
return;
}
BigInteger exactAmount;
try { exactAmount = configured.amount().add(Objects.requireNonNull(bonusHealing.apply(record))); }
catch (RuntimeException failure) { state.lastAttempt = now; report(record.id(), failure); return; }
if (exactAmount.signum() < 0) { state.lastAttempt = now; report(record.id(), new IllegalStateException("Negative recovery bonus")); return; }
double amount = exactAmount.bitLength() > 1023 ? maximum - current : exactAmount.doubleValue();
double proposed = Math.min(maximum - current, amount);
if (!Double.isFinite(proposed) || proposed <= 0) return;
var event = new EntityRegainHealthEvent(player, proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
healingEvents.accept(event);
double accepted = Math.min(proposed, Math.min(maximum - current, event.getAmount()));
state.lastAttempt = now;
if (event.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) return;
Object token = new Object();
state.pending = token;
long generation = state.generation;
BigInteger expected = record.revision();
BigDecimal target = BigDecimal.valueOf(current + accepted);
BigDecimal limit;
try { limit = nativeLimit.get(); }
catch (RuntimeException failure) { state.pending = null; state.lastAttempt = clock.instant(); report(record.id(), failure); return; }
characters.saveHealth(record.id(), expected, target, limit).whenComplete((saved, failure) -> {
try {
nextTick.accept(() -> complete(state, token, generation, expected, current, saved, failure));
} catch (RuntimeException rejected) {
synchronized (OutOfCombatRecovery.this) {
if (state.pending == token) { state.pending = null; state.lastAttempt = clock.instant(); }
}
report(record.id(), rejected);
}
});
}
private synchronized void complete(State state, Object token, long generation, BigInteger expected,
double nativeBefore, java.util.Optional<CharacterRecord> saved, Throwable failure) {
if (state.pending != token) return;
state.pending = null;
if (failure != null || saved == null || saved.isEmpty()) {
state.lastAttempt = clock.instant();
report(state.active.character(), failure != null ? failure : new IllegalStateException("Character unavailable for recovery save"));
return;
}
var persisted = saved.orElseThrow();
boolean acknowledged = sessions.acknowledgeProgress(state.active, expected, persisted);
if (!acknowledged) {
report(state.active.character(), new IllegalStateException("Saved recovery could not synchronize with its active session"));
return;
}
if (closed || generation != state.generation || states.get(state.active.player()) != state
|| !sessions.current(state.active)) return;
var player = sessions.currentPlayer(state.active).orElse(null);
if (player == null || Double.compare(player.getHealth(), nativeBefore) != 0) return;
var attribute = player.getAttribute(Attribute.MAX_HEALTH);
if (attribute == null) return;
double maximum = Math.min(attribute.getValue(), persisted.maximumHealth().doubleValue());
double publish = Math.min(maximum, persisted.health().doubleValue());
if (!Double.isFinite(maximum) || !Double.isFinite(publish) || publish <= nativeBefore) return;
player.setHealth(publish);
}
private void remove(UUID owner) {
states.remove(owner); queued.remove(owner); order.remove(owner);
}
private void report(UUID character, Throwable failure) {
try { failures.accept(new Failure(character, failure)); }
catch (RuntimeException ignored) { /* Diagnostics cannot authorize a retry or native heal. */ }
}
@Override public synchronized void close() {
closed = true;
if (task != null) task.cancel();
states.clear(); order.clear(); queued.clear();
}
}
+9
View File
@@ -10,6 +10,15 @@ party-max-size: 6
sheet-resource-pack-url: ''
sheet-resource-pack-sha1: ''
# Active-session recovery after actual accepted damage has stopped.
# Admin command: /fantasystick config <key> [value]
out-of-combat-healing-delay-seconds: 30
out-of-combat-healing-interval-seconds: 10
out-of-combat-healing-amount: 1
quicker-regeneration-i-bonus-healing: 1
quicker-regeneration-ii-bonus-healing: 2
health-steal-healing-per-kill: 1
# New-character and transferable fantasy progression defaults.
starting-points: 40
xp-per-level: 100
@@ -146,8 +146,8 @@ class AbilityEngineNativeTest {
String available = PartyBookNativeTest.text(a);
String compactAvailable = available.replaceAll("\\s+", "");
assertTrue(compactAvailable.contains("TestFocus"), available);
assertTrue(compactAvailable.contains("AMETHYST_SHARD"), available);
assertTrue(compactAvailable.contains("Cost:1"), available);
assertFalse(compactAvailable.contains("Icon:"), 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);
@@ -157,10 +157,7 @@ class AbilityEngineNativeTest {
assertEquals(java.util.Set.of("test_focus"), learned.learned());
assertEquals(BigInteger.valueOf(39), learned.unspentPoints(), "Repeated stale book action cannot spend twice");
var assign = PartyBookNativeTest.button(a, "[3]");
PartyBookNativeTest.click(f, a, assign); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var assigned = storage.find(a.core.record.id()).get().orElseThrow();
assertEquals("test_focus", assigned.loadout().getFirst());
assertEquals("test_focus", learned.loadout().getFirst());
var control = a.core.inventory.getItem(2);
assertEquals(Material.AMETHYST_SHARD, control.getType());
assertEquals("test_focus", new games.dmg.fantasystick.items.SessionControls(registry).ability(control).orElseThrow());
@@ -173,9 +170,17 @@ 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(games.dmg.fantasystick.items.SessionControls.COOLDOWN_MODEL,
cooling.getItemMeta().getItemModel());
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")));
@@ -185,6 +190,9 @@ 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(games.dmg.fantasystick.items.SessionControls.COOLDOWN_MODEL,
a.core.inventory.getItem(2).getItemMeta().getItemModel());
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,
@@ -38,7 +38,6 @@ class ChainLightningNativeTest {
String text = PartyBookNativeTest.text(caster).replaceAll("\\s+", "");
assertTrue(text.contains("ChainLightning")); assertTrue(text.contains("INT20")); assertTrue(text.contains("DEX12"));
PartyBookNativeTest.click(fixture, caster, button(caster, "[Learn]", "chain_lightning")); drain(fixture, 2);
PartyBookNativeTest.click(fixture, caster, button(caster, "[3]", "chain_lightning")); drain(fixture, 2);
var first = mock(LivingEntity.class); var second = mock(LivingEntity.class);
locate(first, caster.world, UUID.randomUUID(), 10); locate(second, caster.world, UUID.randomUUID(), 14);
doReturn(new Location(caster.world, 0, 64, 0)).when(caster.player).getLocation();
@@ -72,7 +72,7 @@ class CharacterBookNativeTest {
assertEquals(1, a.books.size());
String rendered = text(a.books.getFirst()).replace("\n", "");
for (String expected : java.util.List.of("Level: 30", "XP: MAX", "Unspent points: 9", "STR: " + strength,
"revive", "unavailable_saved_id")) assertTrue(rendered.contains(expected), expected);
"Revive", "unavailable_saved_id")) assertTrue(rendered.contains(expected), expected);
assertEquals(saved, characters(f).find(old.id()).get(5, TimeUnit.SECONDS).orElseThrow());
}
}
@@ -218,7 +218,7 @@ class CharacterBookNativeTest {
var stats = new java.util.EnumMap<games.dmg.fantasystick.progression.Stat, java.math.BigInteger>(games.dmg.fantasystick.progression.Stat.class);
stats.putAll(old.stats()); stats.put(games.dmg.fantasystick.progression.Stat.STR, new java.math.BigInteger(digits));
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE),
stats, old.level(), old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.cooldowns(), false, null, null);
stats, old.level(), old.experience(), java.math.BigInteger.ZERO, old.health(), old.learned(), old.loadout(), old.cooldowns(), false, null, null);
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(saved, old.revision());
byte[] bytes = Files.readAllBytes(data.resolve("characters").resolve(saved.id() + ".yml"));
click(f, a); drainReads(f); runQueued(f);
@@ -307,6 +307,123 @@ class CharacterBookNativeTest {
}
}
@Test void linkedBookOrdersOverviewActiveIndexAndConciseAbilityDetailsWithAutomaticAssignment() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(a.core.record.id()).orElseThrow();
var stats = new java.util.EnumMap<>(old.stats());
stats.put(games.dmg.fantasystick.progression.Stat.INT, java.math.BigInteger.valueOf(12));
var eligible = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE),
stats, old.level(), old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(),
old.cooldowns(), old.restrictedKills(), false, null, null);
repository.replace(eligible, old.revision());
f.players.put(a.core.id, a.player); f.activate(a);
PartyBookNativeTest.open(f, a);
var pages = nativePages(a);
assertTrue(pages.size() <= 100, "Keep each native linked-book view bounded");
assertTrue(pages.get(0).getString().contains("STR: 0 [+1 STR]"));
assertTrue(pages.get(0).getString().contains("INT: 12 [+1 INT]"));
assertTrue(pages.get(1).getString().startsWith("Active abilities"));
assertTrue(pages.get(1).getString().contains("3: No Ability"));
int index = firstPageStarting(pages, "Ability index");
assertTrue(index > 1);
var indexedNames = new java.util.ArrayList<String>();
int partyPage = firstPageStarting(pages, "Party");
for (int page = index; page < pages.size() && pages.get(page).getString().startsWith("Ability index"); page++) {
for (String line : pages.get(page).getString().split("\\n")) if (!line.isBlank() && !line.equals("Ability index")) {
indexedNames.add(line);
var jump = changePage(pages.get(page), line);
assertNotNull(jump, line);
assertTrue(jump.page() - 1 < partyPage, "Ability details must precede party controls");
}
}
var sortedNames = indexedNames.stream().sorted().toList();
assertEquals(sortedNames, indexedNames);
assertEquals(20, indexedNames.size());
var fireballJump = changePage(pages.get(index), "Fireball");
assertNotNull(fireballJump);
var detail = pages.get(fireballJump.page() - 1);
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]"));
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[Learn]"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var saved = characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
assertEquals("fireball", saved.loadout().get(0));
var refreshed = nativePages(a);
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\nINT 12\n[Unlearn]"));
}
}
@Test void unlearnAndRelearnReordersIntoLowestOpenSlotsWhileFullLoadoutsRefuse() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(a.core.record.id()).orElseThrow();
var stats = new java.util.EnumMap<>(old.stats());
for (var stat : games.dmg.fantasystick.progression.Stat.values()) stats.put(stat, java.math.BigInteger.valueOf(100));
var prepared = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE),
stats, old.level(), old.experience(), java.math.BigInteger.TEN, old.health(),
java.util.Set.of("unknown_saved", "orphan_unknown"), java.util.List.of("unknown_saved", "", "", "", "", "", ""),
old.cooldowns(), old.restrictedKills(), false, null, null);
repository.replace(prepared, old.revision());
f.players.put(a.core.id, a.player); f.activate(a); PartyBookNativeTest.open(f, a);
assertTrue(PartyBookNativeTest.text(a).contains("orphan_unknown"));
mutate(f, a, "[Learn]", "fireball");
mutate(f, a, "[Learn]", "frost_shard");
mutate(f, a, "[Unlearn]", "fireball");
mutate(f, a, "[Unlearn]", "frost_shard");
mutate(f, a, "[Learn]", "frost_shard");
mutate(f, a, "[Learn]", "fireball");
var reordered = characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
assertEquals(java.util.List.of("unknown_saved", "frost_shard", "fireball", "", "", "", ""), reordered.loadout());
assertTrue(reordered.learned().contains("orphan_unknown"));
var fullActor = new FantasyStickLifecycleNativeTest.Actor(data);
var fullOld = repository.load(fullActor.core.record.id()).orElseThrow();
var fullStats = new java.util.EnumMap<>(fullOld.stats());
for (var stat : games.dmg.fantasystick.progression.Stat.values()) fullStats.put(stat, java.math.BigInteger.valueOf(100));
var ids = java.util.List.of("unknown_0", "unknown_1", "unknown_2", "unknown_3", "unknown_4", "unknown_5", "unknown_6");
var full = new games.dmg.fantasystick.persistence.CharacterRecord(fullOld.id(), fullOld.name(), fullOld.revision().add(java.math.BigInteger.ONE),
fullStats, fullOld.level(), fullOld.experience(), java.math.BigInteger.TEN, fullOld.health(), java.util.Set.copyOf(ids), ids,
fullOld.cooldowns(), fullOld.restrictedKills(), false, null, null);
repository.replace(full, fullOld.revision());
f.players.put(fullActor.core.id, fullActor.player); f.activate(fullActor); PartyBookNativeTest.open(f, fullActor);
assertTrue(PartyBookNativeTest.text(fullActor).contains("Unavailable: slots"));
assertNull(abilityButtonOrNull(fullActor, "[Learn]", "fireball"));
assertEquals(full, characters(f).find(full.id()).get(5, TimeUnit.SECONDS).orElseThrow());
}
}
@Test void overviewHidesEveryStatActionWhenNoPointsRemain() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(a.core.record.id()).orElseThrow();
var noPoints = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE),
old.stats(), old.level(), old.experience(), java.math.BigInteger.ZERO, old.health(), old.learned(), old.loadout(),
old.cooldowns(), old.restrictedKills(), false, null, null);
repository.replace(noPoints, old.revision());
f.players.put(a.core.id, a.player); f.activate(a);
PartyBookNativeTest.open(f, a);
assertFalse(nativePages(a).get(0).getString().contains("[+1"));
}
}
@Test void bookAdapterIgnoresOtherControlsAndNativeItemVetoes() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
@@ -321,6 +438,51 @@ class CharacterBookNativeTest {
}
}
private static void mutate(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor, String label, String ability) throws Exception {
PartyBookNativeTest.click(fixture, actor, abilityButtonOrNull(actor, label, ability));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
}
private static net.minecraft.network.chat.ClickEvent.Custom abilityButtonOrNull(
FantasyStickLifecycleNativeTest.Actor actor, String label, String ability) {
for (var page : nativePages(actor)) {
var found = abilityButtonOrNull(page, label, ability);
if (found != null) return found;
}
return null;
}
private static net.minecraft.network.chat.ClickEvent.Custom abilityButtonOrNull(
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 = abilityButtonOrNull(child, label, ability);
if (found != null) return found;
}
return null;
}
private static java.util.List<net.minecraft.network.chat.Component> nativePages(FantasyStickLifecycleNativeTest.Actor actor) {
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
assertNotNull(content);
return content.pages().stream().map(page -> page.raw()).toList();
}
private static int firstPageStarting(java.util.List<net.minecraft.network.chat.Component> pages, String prefix) {
for (int index = 0; index < pages.size(); index++) if (pages.get(index).getString().startsWith(prefix)) return index;
throw new AssertionError("Missing page " + prefix);
}
private static net.minecraft.network.chat.ClickEvent.ChangePage changePage(net.minecraft.network.chat.Component component,
String label) {
if (component.getString().equals(label)
&& component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.ChangePage page) return page;
for (var child : component.getSiblings()) {
var found = changePage(child, label);
if (found != null) return found;
}
return null;
}
private static String text(org.bukkit.inventory.ItemStack book) {
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(book)
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
@@ -1059,6 +1059,47 @@ 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());
assertEquals(SessionControls.COOLDOWN_MODEL, pane.getItemMeta().getItemModel(),
"Only the acknowledged persisted cooldown control selects the cosmetic model");
assertFalse(f.sessions.acknowledgeProgress(active, current.revision(), learned),
"A stale pre-cooldown acknowledgment cannot replace the current presentation");
assertEquals(SessionControls.COOLDOWN_MODEL,
actor.inventory.getItem(2).getItemMeta().getItemModel());
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");
assertEquals(SessionControls.COOLDOWN_MODEL, actor.inventory.getItem(2).getItemMeta().getItemModel());
clock.advance(Duration.ofSeconds(30));
f.sessions.refreshCooldownControls(1);
assertEquals(Material.SNOWBALL, actor.inventory.getItem(2).getType());
assertNull(actor.inventory.getItem(2).getItemMeta().getItemModel(),
"Exact-deadline restoration replaces the whole control without stale cosmetic metadata");
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();
@@ -60,8 +60,8 @@ class FantasyStickLifecycleNativeTest {
assertTrue(fixture.order.indexOf("CharacterChatNames") >= 0);
assertTrue(fixture.order.indexOf("CharacterChatNames") < recipe);
assertNotNull(fixture.packetListener);
assertEquals(8, fixture.timers.size(),
"Presentation, ability/bond/rally/banner cleanup, provenance, reward retry and session particles");
assertEquals(11, fixture.timers.size(),
"Presentation, ability/bond/rally/banner cleanup, provenance, reward retry, recovery, passive effects and session particles");
assertTrue(fixture.order.indexOf("PartyCombat") >= 0);
assertTrue(fixture.order.indexOf("PartyCombat") < recipe);
assertTrue(fixture.order.indexOf("CombatExperience") >= 0);
@@ -74,6 +74,8 @@ class FantasyStickLifecycleNativeTest {
assertTrue(fixture.order.indexOf("BasicAttack") < recipe);
assertTrue(fixture.order.indexOf("NutritionPolicy") >= 0);
assertTrue(fixture.order.indexOf("NutritionPolicy") < recipe);
assertTrue(fixture.order.indexOf("OutOfCombatRecovery") >= 0);
assertTrue(fixture.order.indexOf("OutOfCombatRecovery") < recipe);
assertTrue(fixture.order.indexOf("RecipeDiscovery") > recipe,
"Discovery must not become active before successful recipe registration");
assertNotNull(fixture.command);
@@ -105,7 +107,7 @@ class FantasyStickLifecycleNativeTest {
assertNotNull(content);
String text = content.pages().stream().map(page -> page.raw().getString()).collect(java.util.stream.Collectors.joining("\n"));
for (String expected : List.of("Current saved name", "STR: 0", "DEX: 0", "CON: 0", "INT: 0", "WIS: 0", "CHR: 0",
"Level: 1", "XP: 0/100", "Unspent points: 40", "Health: 10/10", "Abilities: none")) {
"Level: 1", "XP: 0/100", "Unspent points: 40", "Health: 10/10", "Active abilities", "3: No Ability")) {
assertTrue(text.contains(expected), expected + " missing from " + text);
}
assertArrayEquals(original, a.core.snapshot(), "Opening a virtual book never replaces or grants an inventory item");
@@ -15,7 +15,7 @@ class FireballNativeTest {
@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);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withIntAndHealthSteal(data, a, 12);
f.players.put(a.core.id, a.player); f.activate(a);
a.core.inventory.getInventory().setSelectedSlot(0);
PartyBookNativeTest.open(f, a);
@@ -23,8 +23,6 @@ class FireballNativeTest {
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();
@@ -56,6 +54,8 @@ class FireballNativeTest {
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.getHealth()).thenReturn(1d);
org.mockito.Mockito.when(target.getAbsorptionAmount()).thenReturn(0d);
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 -> {
@@ -72,8 +72,11 @@ class FireballNativeTest {
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(),
var rewarded = storage.find(a.core.record.id()).get().orElseThrow();
assertEquals(BigInteger.TEN, rewarded.experience(),
"Native projectile attribution reaches the originating character's combat XP");
assertEquals(5, a.player.getHealth(), "An actual Fireball final blow activates learned Health Steal");
assertEquals(0, java.math.BigDecimal.valueOf(5).compareTo(rewarded.health()));
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");
@@ -85,11 +88,15 @@ class FireballNativeTest {
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()));
assertEquals(games.dmg.fantasystick.items.SessionControls.COOLDOWN_MODEL,
a.core.inventory.getItem(2).getItemMeta().getItemModel());
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(games.dmg.fantasystick.items.SessionControls.COOLDOWN_MODEL,
a.core.inventory.getItem(2).getItemMeta().getItemModel());
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());
@@ -109,8 +116,6 @@ class FireballNativeTest {
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
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");
org.mockito.Mockito.when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
@@ -144,6 +149,8 @@ class FireballNativeTest {
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());
assertNull(a.core.inventory.getItem(2).getItemMeta().getItemModel(),
"Unlearning replaces the cooling control and leaves no stale invisible model");
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[+1 WIS]"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
@@ -152,9 +159,10 @@ class FireballNativeTest {
assertEquals(BigInteger.valueOf(39), spent.unspentPoints());
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);
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("fireball"));
assertEquals(games.dmg.fantasystick.items.SessionControls.COOLDOWN_MODEL,
a.core.inventory.getItem(2).getItemMeta().getItemModel(),
"Relearning reconstructs the retained future cooldown model");
cast(f, a); assertEquals(1, launches.get());
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
@@ -163,6 +171,10 @@ 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(games.dmg.fantasystick.items.SessionControls.COOLDOWN_MODEL,
b.core.inventory.getItem(2).getItemMeta().getItemModel());
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());
@@ -291,6 +303,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();
}
@@ -362,6 +376,17 @@ class FireballNativeTest {
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
}
private static void withIntAndHealthSteal(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(), java.math.BigDecimal.valueOf(4), old.learned(), old.loadout(),
java.util.List.of("health_steal", "", ""), old.cooldowns(), old.restrictedKills(), false, null, null);
repository.replace(saved, old.revision());
}
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();
@@ -74,9 +74,8 @@ 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);
PartyBookNativeTest.click(fixture, caster, button(caster, "[3]", "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);
assertEquals(2, damage.size(), "Duplicate, cancelled, party and ordinary targets cannot add accepted hits");
@@ -28,8 +28,6 @@ class FrostShardNativeTest {
var learn = abilityButton(a, "[Learn]", "frost_shard");
PartyBookNativeTest.click(f, a, learn); PartyBookNativeTest.click(f, a, learn);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "frost_shard"));
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();
@@ -447,8 +445,6 @@ class FrostShardNativeTest {
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[Learn]", "frost_shard"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "frost_shard"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
}
private static void cast(FantasyStickLifecycleNativeTest.Fixture fixture,
@@ -30,8 +30,6 @@ class GreatSwordNativeTest {
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");
@@ -317,8 +315,6 @@ class GreatSwordNativeTest {
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(
@@ -55,9 +55,8 @@ 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);
PartyBookNativeTest.click(fixture, guardian, button(guardian, "[3]", "guardian_bond")); drain(fixture, 2);
use(fixture, guardian); drain(fixture, 2);
var enemy = mock(org.bukkit.entity.Zombie.class); when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
@@ -0,0 +1,270 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyDouble;
import static org.mockito.Mockito.*;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.Stat;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.entity.EntityType;
import org.bukkit.entity.LivingEntity;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class HealthStealNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void connectedLearnBasicProjectileFantasyDefeatDuplicateAssistAndTransferPathPersistsOnlyFinalBlows() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"character-swap-cooldown-seconds: 0\nhealth-steal-healing-per-kill: 1\n"
+ "out-of-combat-healing-delay-seconds: 31536000\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); assertTrue(f.order.contains("HealthSteal"));
var a = new FantasyStickLifecycleNativeTest.Actor(data); makeEligible(data, a, BigDecimal.valueOf(4));
var b = new FantasyStickLifecycleNativeTest.Actor(data); makeEligibleAtLevelCap(data, b, BigDecimal.valueOf(4));
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player); f.activate(a); f.activate(b);
PartyBookNativeTest.open(f, a);
assertTrue(PartyBookNativeTest.text(a).replaceAll("\\s+", "").contains("HealthStealSTR14,CON12[Learn]"));
PartyBookNativeTest.click(f, a, action(a, "[Learn]", "health_steal")); settle(f);
var learned = record(f, a); assertEquals("health_steal", learned.passives().getFirst());
assertEquals(BigInteger.valueOf(39), learned.unspentPoints()); var activeLoadout = learned.loadout();
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
var basicTarget = mob(EntityType.COW, new Location(world, 1, 64, 0), 1);
doAnswer(call -> { hit(f, a.player, basicTarget, (Double) call.getArgument(0), false); return null; })
.when(basicTarget).damage(anyDouble(), same(a.player));
a.core.inventory.getInventory().setSelectedSlot(1);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, basicTarget, true));
die(f, basicTarget); settle(f); settle(f);
assertEquals(5, a.player.getHealth()); assertEquals(0, BigDecimal.valueOf(5).compareTo(record(f, a).health()));
die(f, basicTarget); settle(f); assertEquals(5, a.player.getHealth(), "A repeated death callback is consumed");
var projectileTarget = mob(EntityType.ZOMBIE, new Location(world, 2, 64, 0), 1);
var arrow = PartyCombatNativeTest.projectile(f, a.player);
hit(f, arrow, projectileTarget, 4, false); die(f, projectileTarget); settle(f); settle(f);
assertEquals(6, a.player.getHealth(), "Originating-character projectile attribution heals its final blow");
var spacingBlocked = mob(EntityType.ZOMBIE, new Location(world, 2, 64, 0), 1);
hit(f, a.player, spacingBlocked, 4, false); die(f, spacingBlocked); settle(f); settle(f);
assertEquals(7, a.player.getHealth(), "Restricted-XP spacing does not suppress final-blow healing");
var assisted = mob(EntityType.ZOMBIE, new Location(world, 3, 64, 0), 10);
hit(f, a.player, assisted, 1, false); when(assisted.getHealth()).thenReturn(1d);
hit(f, b.player, assisted, 4, false); die(f, assisted); settle(f); settle(f);
assertEquals(7, a.player.getHealth(), "Contribution without the final blow does not heal");
var capped = record(f, b); assertEquals(5, b.player.getHealth()); assertEquals(30, capped.level());
assertEquals(BigInteger.valueOf(2_900), capped.experience(),
"The final blower heals with multiple contributors even when XP is already capped");
b.player.setHealth(1); hit(f, a.player, b.player, 4, false);
var defeat = new org.bukkit.event.entity.PlayerDeathEvent(b.player, mock(org.bukkit.damage.DamageSource.class),
new ArrayList<>(), 0, net.kyori.adventure.text.Component.text("defeat"), true);
f.dispatcher.accept(defeat); settle(f); settle(f);
assertTrue(defeat.isCancelled()); assertEquals(8, a.player.getHealth(), "Enemy fantasy-character final blows qualify");
var saved = record(f, a); assertEquals(activeLoadout, saved.loadout());
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
var transferred = new FantasyStickLifecycleNativeTest.Actor(data);
transferred.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(saved));
f.players.put(transferred.core.id, transferred.player); f.activate(transferred);
assertEquals(8, transferred.player.getHealth());
assertEquals(saved.passives(), storage(f).find(saved.id()).get().orElseThrow().passives());
PartyBookNativeTest.open(f, transferred);
PartyBookNativeTest.click(f, transferred, action(transferred, "[Unlearn]", "health_steal")); settle(f);
var unlearned = storage(f).find(saved.id()).get().orElseThrow();
assertFalse(unlearned.passives().contains("health_steal"));
assertEquals(saved.unspentPoints().add(BigInteger.ONE), unlearned.unspentPoints());
var afterUnlearn = mob(EntityType.COW, new Location(world, 5, 64, 0), 1);
hit(f, transferred.player, afterUnlearn, 4, false); die(f, afterUnlearn); settle(f);
assertEquals(8, transferred.player.getHealth(), "Unlearning removes only the passive's future healing");
}
}
@Test void configuredZeroAndOrdinaryPlayerTargetPerformNoHealthWriteOrPublication() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"health-steal-healing-per-kill: 0\nout-of-combat-healing-delay-seconds: 31536000\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
makeEligible(data, a, BigDecimal.valueOf(4), List.of("health_steal", "", ""));
f.players.put(a.core.id, a.player); f.activate(a);
var before = record(f, a); var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
var target = mob(EntityType.WARDEN, new Location(world, 0, 64, 0), 1);
hit(f, a.player, target, 4, false); die(f, target); settle(f);
assertEquals(0, before.health().compareTo(record(f, a).health())); assertEquals(4, a.player.getHealth());
var ordinary = mock(org.bukkit.entity.Player.class); when(ordinary.getUniqueId()).thenReturn(UUID.randomUUID());
when(ordinary.getHealth()).thenReturn(1d); when(ordinary.getAbsorptionAmount()).thenReturn(0d);
hit(f, a.player, ordinary, 4, false);
f.dispatcher.accept(new org.bukkit.event.entity.PlayerDeathEvent(ordinary, mock(org.bukkit.damage.DamageSource.class),
new ArrayList<>(), 0, net.kyori.adventure.text.Component.text("death"), false));
settle(f); assertEquals(4, a.player.getHealth(), "Ordinary player targets never qualify");
}
}
@Test void changedValueCapsAtFullHealthAndCancellationFailureOrStaleSourceNeverPublishes() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"character-swap-cooldown-seconds: 0\nhealth-steal-healing-per-kill: 3\n"
+ "out-of-combat-healing-delay-seconds: 31536000\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
class Gate implements org.bukkit.event.Listener {
final java.util.ArrayList<Double> amounts = new java.util.ArrayList<>(); boolean veto;
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST)
public void heal(org.bukkit.event.entity.EntityRegainHealthEvent event) {
amounts.add(event.getAmount()); if (veto) event.setCancelled(true);
}
}
var gate = new Gate(); f.plugin.getServer().getPluginManager().registerEvents(gate, f.plugin);
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
var changed = new FantasyStickLifecycleNativeTest.Actor(data);
makeEligible(data, changed, BigDecimal.valueOf(4), List.of("health_steal", "", ""));
f.players.put(changed.core.id, changed.player); f.activate(changed);
var first = mob(EntityType.BEE, new Location(world, 0, 64, 0), 1);
hit(f, changed.player, first, 4, false); die(f, first); settle(f); settle(f);
assertEquals(7, changed.player.getHealth()); assertEquals(List.of(3d), gate.amounts);
gate.veto = true; var cancelled = mob(EntityType.COW, new Location(world, 1, 64, 0), 1);
hit(f, changed.player, cancelled, 4, false); die(f, cancelled); settle(f);
assertEquals(7, changed.player.getHealth()); assertEquals(List.of(3d, 3d), gate.amounts);
gate.veto = false;
int beforeInvalidDamage = gate.amounts.size();
var cancelledDamage = mob(EntityType.COW, new Location(world, 1.25, 64, 0), 1);
hit(f, changed.player, cancelledDamage, 4, true); die(f, cancelledDamage); settle(f);
var zeroDamage = mob(EntityType.COW, new Location(world, 1.5, 64, 0), 1);
hit(f, changed.player, zeroDamage, 0, false); die(f, zeroDamage); settle(f);
assertEquals(beforeInvalidDamage, gate.amounts.size(), "Cancelled and zero damage cannot establish a final blow");
var full = new FantasyStickLifecycleNativeTest.Actor(data);
makeEligible(data, full, BigDecimal.valueOf(22), List.of("health_steal", "", ""));
f.players.put(full.core.id, full.player); f.activate(full); int beforeFull = gate.amounts.size();
var fullTarget = mob(EntityType.WARDEN, new Location(world, 2, 64, 0), 1);
hit(f, full.player, fullTarget, 4, false); die(f, fullTarget); settle(f);
assertEquals(beforeFull, gate.amounts.size()); assertEquals(22, full.player.getHealth());
var stale = new FantasyStickLifecycleNativeTest.Actor(data);
makeEligible(data, stale, BigDecimal.valueOf(4), List.of("health_steal", "", ""));
f.players.put(stale.core.id, stale.player); f.activate(stale);
var staleTarget = mob(EntityType.ZOMBIE, new Location(world, 3, 64, 0), 1);
hit(f, stale.player, staleTarget, 4, false);
stale.core.inventory.setItemInOffHand(null); f.changed(stale); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(stale);
int beforeStale = gate.amounts.size(); die(f, staleTarget); settle(f);
assertEquals(beforeStale, gate.amounts.size(), "A transferred source cannot receive redirected healing");
var failed = new FantasyStickLifecycleNativeTest.Actor(data);
makeEligible(data, failed, BigDecimal.valueOf(4), List.of("health_steal", "", ""));
f.players.put(failed.core.id, failed.player); f.activate(failed);
var failedTarget = mob(EntityType.COW, new Location(world, 4, 64, 0), 1);
hit(f, failed.player, failedTarget, 4, false);
Runnable callback;
try (var channel = java.nio.channels.FileChannel.open(data.resolve("characters").resolve(".records.lock"),
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
var lock = channel.lock()) {
assertTrue(lock.isValid()); die(f, failedTarget);
callback = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(callback);
}
callback.run(); assertEquals(4, failed.player.getHealth());
assertEquals(0, BigDecimal.valueOf(4).compareTo(record(f, failed).health()));
}
}
@Test void configurationIsDurablePermissionAwareAndPrefixFiltered() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var sender = new EquipmentAdminCommandNativeTest.Sender();
assertEquals(List.of("health-steal-healing-per-kill"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "health-steal"}));
assertEquals(List.of("1"), f.command.tabComplete(sender.port, "fantasystick",
new String[]{"config", "health-steal-healing-per-kill", ""}));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "health-steal-healing-per-kill"});
assertTrue(sender.messages.getLast().contains("=1")); sender.allowed = false;
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "health-steal"}).isEmpty());
f.command.execute(sender.port, "fantasystick", new String[]{"config", "health-steal-healing-per-kill", "3"});
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
f.command.execute(sender.port, "fantasystick", new String[]{"config", "health-steal-healing-per-kill", "3"});
var callback = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(callback); callback.run();
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
var settings = (games.dmg.fantasystick.persistence.HealthStealSettings)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "healthStealSettings");
assertEquals("3", settings.value());
}
}
private static void makeEligible(Path data, FantasyStickLifecycleNativeTest.Actor actor, BigDecimal health) throws Exception {
makeEligible(data, actor, health, List.of("", "", ""));
}
private static void makeEligible(Path data, FantasyStickLifecycleNativeTest.Actor actor, BigDecimal health,
List<String> passives) throws Exception {
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(actor.core.record.id()).orElseThrow(); var stats = new EnumMap<>(old.stats());
stats.put(Stat.STR, BigInteger.valueOf(14)); stats.put(Stat.CON, BigInteger.valueOf(12));
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
old.level(), old.experience(), BigInteger.valueOf(40), health, old.learned(), old.loadout(), passives,
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
}
private static void makeEligibleAtLevelCap(Path data, FantasyStickLifecycleNativeTest.Actor actor,
BigDecimal health) throws Exception {
makeEligible(data, actor, health, List.of("health_steal", "", ""));
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(actor.core.record.id()).orElseThrow();
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
30, BigInteger.valueOf(2_900), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(),
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
}
private static EntityDamageByEntityEvent hit(FantasyStickLifecycleNativeTest.Fixture f, org.bukkit.entity.Entity source,
org.bukkit.entity.Entity target, double amount, boolean cancelled) {
var damage = mock(org.bukkit.damage.DamageSource.class); when(damage.getCausingEntity()).thenReturn(source);
when(damage.getDirectEntity()).thenReturn(source);
var event = new EntityDamageByEntityEvent(source, target, EntityDamageEvent.DamageCause.ENTITY_ATTACK, damage, amount);
event.setCancelled(cancelled); f.dispatcher.accept(event); return event;
}
private static void die(FantasyStickLifecycleNativeTest.Fixture f, LivingEntity target) {
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(target,
mock(org.bukkit.damage.DamageSource.class), new ArrayList<>(), 0));
}
private static LivingEntity mob(EntityType type, Location location, double health) {
var entity = mock(LivingEntity.class); when(entity.getUniqueId()).thenReturn(UUID.randomUUID());
when(entity.getType()).thenReturn(type); when(entity.getHealth()).thenReturn(health); when(entity.getAbsorptionAmount()).thenReturn(0d);
when(entity.getLocation()).thenAnswer(ignored -> location.clone()); when(entity.getWorld()).thenReturn(location.getWorld());
when(entity.getPersistentDataContainer()).thenReturn(new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(
new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry()));
return entity;
}
private static void settle(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
}
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture f) {
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
}
private static CharacterRecord record(FantasyStickLifecycleNativeTest.Fixture f,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
return storage(f).find(actor.core.record.id()).get().orElseThrow();
}
private static net.minecraft.network.chat.ClickEvent.Custom action(
FantasyStickLifecycleNativeTest.Actor actor, String label, String passive) {
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT); assertNotNull(content);
for (var page : content.pages()) { var found = action(page.raw(), label, passive); if (found != null) return found; }
throw new AssertionError("Missing " + label + " for " + passive);
}
private static net.minecraft.network.chat.ClickEvent.Custom action(
net.minecraft.network.chat.Component component, String label, String passive) {
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(":" + passive)) return custom;
for (var child : component.getSiblings()) { var found = action(child, label, passive); if (found != null) return found; }
return null;
}
}
@@ -37,9 +37,8 @@ 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);
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "hex")); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var enemy = mock(org.bukkit.entity.LivingEntity.class); var enemyId = java.util.UUID.randomUUID();
when(enemy.getUniqueId()).thenReturn(enemyId); when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
@@ -174,7 +173,7 @@ class HexNativeTest {
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("hex"));}}
}
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a)throws Exception{a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);PartyBookNativeTest.click(f,a,abilityButton(a,"[Learn]","hex"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);PartyBookNativeTest.click(f,a,abilityButton(a,"[3]","hex"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);}
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a)throws Exception{a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);PartyBookNativeTest.click(f,a,abilityButton(a,"[Learn]","hex"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);}
private static void use(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a){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));}
@@ -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);PartyBookNativeTest.click(f,a,button(a,"[3]","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);
@@ -20,7 +20,7 @@ class InspireNativeTest{@TempDir Path data;@BeforeAll static void bootstrap()thr
var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);f.players.put(a.core.id,a.player);f.activate(a);var absorption=new java.util.concurrent.atomic.AtomicReference<>(0.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();use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(10,absorption.get(),1e-9);assertEquals(160L,f.delayedTicks.getLast());
f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health","-1"});assertTrue(sender.messages.getLast().contains("invalid"));f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health","9","extra"});assertTrue(sender.messages.getLast().contains("Usage"));f.plugin.setEnabled(false);f.plugin.setEnabled(true);var reload=(games.dmg.fantasystick.ability.AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");assertEquals(new java.math.BigDecimal("9"),reload.find("inspire").orElseThrow().parameters().get("base-temporary-health"));}
for(String invalid:List.of("inspire-base-temporary-health: -1\n","inspire-cooldown-seconds: -1\n","inspire-range-blocks: 0\n","inspire-duration-seconds: 0\n")){var dir=data.resolve(Integer.toHexString(invalid.hashCode()));java.nio.file.Files.createDirectories(dir);java.nio.file.Files.writeString(dir.resolve("config.yml"),invalid);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){assertThrows(IllegalArgumentException.class,()->f.plugin.setEnabled(true));assertTrue(f.order.isEmpty());}}}
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a)throws Exception{a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);PartyBookNativeTest.click(f,a,button(a,"[Learn]","inspire"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);PartyBookNativeTest.click(f,a,button(a,"[3]","inspire"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);}
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a)throws Exception{a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);PartyBookNativeTest.click(f,a,button(a,"[Learn]","inspire"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);}
private static void use(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a){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));}
private static ClickEvent.Custom button(FantasyStickLifecycleNativeTest.Actor a,String label,String ability){var b=org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(a.books.getLast()).get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);assertNotNull(b);for(var p:b.pages()){var x=button(p.raw(),label,ability);if(x!=null)return x;}throw new AssertionError("missing action");}
private static ClickEvent.Custom button(net.minecraft.network.chat.Component c,String label,String ability){if(c.getString().equals(label)&&c.getStyle().getClickEvent() instanceof ClickEvent.Custom x&&x.payload().orElse(null) instanceof net.minecraft.nbt.StringTag p&&p.value().contains(":"+ability))return x;for(var k:c.getSiblings()){var x=button(k,label,ability);if(x!=null)return x;}return null;}
@@ -45,12 +45,10 @@ 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);
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "longbow"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var learned = storage.find(a.core.record.id()).get().orElseThrow();
assertTrue(learned.learned().contains("longbow")); assertEquals("longbow", learned.loadout().getFirst());
@@ -411,8 +409,6 @@ class LongbowNativeTest {
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[Learn]", "longbow"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "longbow"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
}
private static void drawAndRelease(FantasyStickLifecycleNativeTest.Fixture fixture,
@@ -0,0 +1,140 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import games.dmg.fantasystick.passive.LowLightVision;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.persistence.YamlCharacterRepository;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigInteger;
import java.nio.file.Path;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.bukkit.entity.Player;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class LowLightVisionNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void connectedLearnExitTransferUnlearnFailureAndDefeatOwnOnlyCharacterNightVision() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data); withWis(data, a, List.of("", "", ""));
var aEffects = effects(a.player); f.players.put(a.core.id, a.player); f.activate(a);
var vision = (LowLightVision) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "lowLightVision");
PartyBookNativeTest.open(f, a);
assertTrue(PartyBookNativeTest.text(a).replaceAll("\\s+", "").contains("Low-LightVisionWIS12[Learn]"));
PartyBookNativeTest.click(f, a, action(a, "[Learn]", "low_light_vision")); settle(f); vision.sweep();
var learned = record(f, a); assertEquals(List.of("low_light_vision", "", ""), learned.passives());
assertEquals(BigInteger.valueOf(39), learned.unspentPoints());
assertEquals(0, aEffects.get(PotionEffectType.NIGHT_VISION).getAmplifier());
var sheet = a.core.inventory.getItemInOffHand().clone(); a.core.inventory.setItemInOffHand(null);
f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a); vision.sweep();
assertFalse(aEffects.containsKey(PotionEffectType.NIGHT_VISION));
var b = new FantasyStickLifecycleNativeTest.Actor(data); var bEffects = effects(b.player);
b.core.inventory.setItemInOffHand(sheet); f.players.put(b.core.id, b.player); f.activate(b); vision.sweep();
assertTrue(bEffects.containsKey(PotionEffectType.NIGHT_VISION));
PartyBookNativeTest.open(f, b); PartyBookNativeTest.click(f, b, action(b, "[Unlearn]", "low_light_vision")); settle(f); vision.sweep();
var unlearned = storage(f).find(learned.id()).get().orElseThrow(); assertFalse(unlearned.passives().contains("low_light_vision"));
assertEquals(learned.unspentPoints().add(BigInteger.ONE), unlearned.unspentPoints());
assertFalse(bEffects.containsKey(PotionEffectType.NIGHT_VISION));
PartyBookNativeTest.open(f, b); Runnable failed;
try (var channel = java.nio.channels.FileChannel.open(data.resolve("characters").resolve(".records.lock"),
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
var lock = channel.lock()) {
assertTrue(lock.isValid()); PartyBookNativeTest.click(f, b, action(b, "[Learn]", "low_light_vision"));
failed = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(failed);
}
failed.run(); vision.sweep(); assertFalse(bEffects.containsKey(PotionEffectType.NIGHT_VISION));
assertFalse(storage(f).find(learned.id()).get().orElseThrow().passives().contains("low_light_vision"));
b.core.inventory.setItemInOffHand(null); f.changed(b); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
storage(f).find(learned.id()).get(); f.receipts().find(b.core.id).toCompletableFuture().get();
withWis(data, a, List.of("low_light_vision", "", ""));
var c = new FantasyStickLifecycleNativeTest.Actor(data); var cEffects = effects(c.player);
c.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(
new YamlCharacterRepository(data).load(a.core.record.id()).orElseThrow()));
f.players.put(c.core.id, c.player); f.activate(c); vision.sweep(); assertTrue(cEffects.containsKey(PotionEffectType.NIGHT_VISION));
c.player.setHealth(1);
var death = new org.bukkit.event.entity.PlayerDeathEvent(c.player, mock(org.bukkit.damage.DamageSource.class),
new java.util.ArrayList<>(), 0, net.kyori.adventure.text.Component.text("defeat"), true);
f.dispatcher.accept(death); settle(f); vision.sweep(); assertFalse(cEffects.containsKey(PotionEffectType.NIGHT_VISION));
}
}
@Test void milkAndUnrelatedNightVisionReconcileWithoutTouchingBlindnessOrDarkness() {
var sessions = mock(EquipmentSessions.class); var player = mock(Player.class); var effects = effects(player);
UUID id = UUID.randomUUID(); var active = new EquipmentSessions.Active(id, UUID.randomUUID(), UUID.randomUUID());
var record = mock(CharacterRecord.class); when(record.passives()).thenReturn(List.of("low_light_vision", "", ""));
when(player.getUniqueId()).thenReturn(id); when(sessions.activeCharacters()).thenReturn(List.of(new EquipmentSessions.ActiveCharacter(active, record)));
when(sessions.current(active)).thenReturn(true); when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record));
when(sessions.currentPlayer(active)).thenReturn(java.util.Optional.of(player));
var blindness = new PotionEffect(PotionEffectType.BLINDNESS, 100, 0);
var darkness = new PotionEffect(PotionEffectType.DARKNESS, 100, 0);
effects.put(PotionEffectType.BLINDNESS, blindness); effects.put(PotionEffectType.DARKNESS, darkness);
var unrelated = new PotionEffect(PotionEffectType.NIGHT_VISION, 40, 0); effects.put(PotionEffectType.NIGHT_VISION, unrelated);
var vision = new LowLightVision(sessions); assertEquals(Map.of(Stat.WIS, BigInteger.valueOf(12)), vision.definition().requirements());
vision.sweep(); assertSame(unrelated, effects.get(PotionEffectType.NIGHT_VISION));
effects.remove(PotionEffectType.NIGHT_VISION); vision.sweep(); assertTrue(effects.get(PotionEffectType.NIGHT_VISION).isInfinite());
assertSame(blindness, effects.get(PotionEffectType.BLINDNESS)); assertSame(darkness, effects.get(PotionEffectType.DARKNESS));
effects.remove(PotionEffectType.NIGHT_VISION); vision.sweep();
verify(player, times(2)).addPotionEffect(argThat(effect -> effect.getType().equals(PotionEffectType.NIGHT_VISION)), eq(true));
when(record.passives()).thenReturn(List.of("", "", "")); vision.sweep();
assertFalse(effects.containsKey(PotionEffectType.NIGHT_VISION));
assertSame(blindness, effects.get(PotionEffectType.BLINDNESS)); assertSame(darkness, effects.get(PotionEffectType.DARKNESS));
vision.close();
}
private static Map<PotionEffectType, PotionEffect> effects(Player player) {
var result = new HashMap<PotionEffectType, PotionEffect>();
doAnswer(call -> result.get(call.getArgument(0))).when(player).getPotionEffect(any());
doAnswer(call -> { PotionEffect effect = call.getArgument(0); result.put(effect.getType(), effect); return true; })
.when(player).addPotionEffect(any(), eq(true));
doAnswer(call -> { result.remove(call.getArgument(0)); return null; }).when(player).removePotionEffect(any()); return result;
}
private static void withWis(Path data, FantasyStickLifecycleNativeTest.Actor actor, List<String> passives) throws Exception {
var repository = new YamlCharacterRepository(data); var old = repository.load(actor.core.record.id()).orElseThrow();
var stats = new EnumMap<>(old.stats()); stats.put(Stat.WIS, BigInteger.valueOf(12));
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats, old.level(), old.experience(),
old.unspentPoints(), old.health(), old.learned(), old.loadout(), passives, old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
}
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture f) {
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
}
private static CharacterRecord record(FantasyStickLifecycleNativeTest.Fixture f,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
return storage(f).find(actor.core.record.id()).get().orElseThrow();
}
private static void settle(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
}
private static net.minecraft.network.chat.ClickEvent.Custom action(
FantasyStickLifecycleNativeTest.Actor actor, String label, String passive) {
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT); assertNotNull(content);
for (var page : content.pages()) { var found = action(page.raw(), label, passive); if (found != null) return found; }
throw new AssertionError("Missing " + label + " for " + passive);
}
private static net.minecraft.network.chat.ClickEvent.Custom action(
net.minecraft.network.chat.Component component, String label, String passive) {
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(":" + passive)) return custom;
for (var child : component.getSiblings()) { var found = action(child, label, passive); if (found != null) return found; }
return null;
}
}
@@ -58,12 +58,10 @@ 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);
PartyBookNativeTest.click(fixture, caster, button(caster, "[3]", "mending_light"));
drain(fixture, 2);
use(fixture, caster); drain(fixture, 3);
var storage = storage(fixture);
@@ -272,7 +270,6 @@ class MendingLightNativeTest {
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
PartyBookNativeTest.click(fixture, actor, button(actor, "[Learn]", "mending_light")); drain(fixture, 2);
PartyBookNativeTest.click(fixture, actor, button(actor, "[3]", "mending_light")); drain(fixture, 2);
}
private static PartyRoster.Party party(FantasyStickLifecycleNativeTest.Fixture fixture,
@@ -30,7 +30,7 @@ class MeteorNativeTest {
@Test void characterBookPurchaseEquippedGroundWarningAndNativeImpact()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,false);f.players.put(caster.core.id,caster.player);f.activate(caster);var placement=placement(caster,24,BlockFace.UP);var enemy=mock(LivingEntity.class);locate(enemy,caster.world,new UUID(0,2),2,65,0);doReturn(List.of(enemy)).when(caster.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());var damaged=new ArrayList<Double>();doAnswer(call->{double amount=call.getArgument(0);damaged.add(amount);var source=(DamageSource)call.getArgument(1);f.dispatcher.accept(new EntityDamageByEntityEvent(caster.player,enemy,EntityDamageEvent.DamageCause.MAGIC,source,amount));return null;}).when(enemy).damage(anyDouble(),any(DamageSource.class));
caster.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,caster);String text=PartyBookNativeTest.text(caster).replaceAll("\\s+","");assertTrue(text.contains("Meteor"));assertTrue(text.contains("INT26"));assertTrue(text.contains("WIS20"));assertTrue(text.contains("CON12"));assertTrue(text.contains("2-secondwarning"));PartyBookNativeTest.click(f,caster,button(caster,"[Learn]","meteor"));drain(f,2);PartyBookNativeTest.click(f,caster,button(caster,"[3]","meteor"));drain(f,2);use(f,caster);drain(f,2);assertTrue(damaged.isEmpty());assertEquals(1L,f.delayedTicks.getLast());for(int i=0;i<39;i++)f.delayed.removeFirst().run();assertTrue(damaged.isEmpty(),"The 40-tick warning must remain escapable");f.delayed.removeFirst().run();assertEquals(List.of(22d),damaged);verify(caster.world,atLeast(40)).spawnParticle(eq(Particle.DUST),any(Location.class),eq(24),anyDouble(),anyDouble(),anyDouble(),eq(0d),any(Particle.DustOptions.class));verify(caster.world,atLeastOnce()).spawnParticle(eq(Particle.EXPLOSION_EMITTER),any(Location.class),eq(2),anyDouble(),anyDouble(),anyDouble(),anyDouble());verify(placement,never()).setType(any(Material.class));assertNotNull(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("meteor"));assertEquals(BigInteger.ZERO,storage(f).find(caster.core.record.id()).get().orElseThrow().unspentPoints());
caster.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,caster);String text=PartyBookNativeTest.text(caster).replaceAll("\\s+","");assertTrue(text.contains("Meteor"));assertTrue(text.contains("INT26"));assertTrue(text.contains("WIS20"));assertTrue(text.contains("CON12"));assertTrue(text.contains("2-secondwarning"));PartyBookNativeTest.click(f,caster,button(caster,"[Learn]","meteor"));drain(f,2);use(f,caster);drain(f,2);assertTrue(damaged.isEmpty());assertEquals(1L,f.delayedTicks.getLast());for(int i=0;i<39;i++)f.delayed.removeFirst().run();assertTrue(damaged.isEmpty(),"The 40-tick warning must remain escapable");f.delayed.removeFirst().run();assertEquals(List.of(22d),damaged);verify(caster.world,atLeast(40)).spawnParticle(eq(Particle.DUST),any(Location.class),eq(24),anyDouble(),anyDouble(),anyDouble(),eq(0d),any(Particle.DustOptions.class));verify(caster.world,atLeastOnce()).spawnParticle(eq(Particle.EXPLOSION_EMITTER),any(Location.class),eq(2),anyDouble(),anyDouble(),anyDouble(),anyDouble());verify(placement,never()).setType(any(Material.class));assertNotNull(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("meteor"));assertEquals(BigInteger.ZERO,storage(f).find(caster.core.record.id()).get().orElseThrow().unspentPoints());
}
}
@@ -0,0 +1,256 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.ProgressionRules;
import games.dmg.fantasystick.session.EquipmentSessions;
import games.dmg.fantasystick.session.OutOfCombatRecovery;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.ArrayDeque;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicReference;
import org.bukkit.attribute.Attribute;
import org.bukkit.attribute.AttributeInstance;
import org.bukkit.damage.DamageSource;
import org.bukkit.entity.Player;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.EntityRegainHealthEvent;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class OutOfCombatRecoveryNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void exactDelayRepeatedIntervalsAndLargeClockJumpsPersistOnePulseBeforeNativePublication() {
var f = new Direct();
f.recovery.sweep();
f.clock.advance(Duration.ofSeconds(29)); f.recovery.sweep();
assertTrue(f.events.isEmpty());
f.clock.advance(Duration.ofSeconds(1)); f.recovery.sweep();
assertEquals(1, f.events.size());
assertEquals(EntityRegainHealthEvent.RegainReason.CUSTOM, f.events.getFirst().getRegainReason());
assertEquals(4, f.health.get());
f.runNext();
assertEquals(5, f.health.get());
assertEquals(0, BigDecimal.valueOf(5).compareTo(f.record.get().health()));
f.clock.advance(Duration.ofSeconds(9)); f.recovery.sweep();
assertEquals(1, f.events.size());
f.clock.advance(Duration.ofSeconds(1)); f.recovery.sweep(); f.runNext();
assertEquals(6, f.health.get());
f.clock.advance(Duration.ofSeconds(100)); f.recovery.sweep(); f.runNext();
assertEquals(7, f.health.get(), "A late evaluation applies one pulse rather than replaying missed intervals");
assertEquals(3, f.events.size());
f.health.set(10d);
var revision = f.record.get().revision();
f.clock.advance(Duration.ofSeconds(10)); f.recovery.sweep();
assertEquals(3, f.events.size(), "Full health dispatches no native healing event");
assertEquals(revision, f.record.get().revision(), "Full health performs no persistence write");
}
@Test void acceptedEnvironmentalAndDealtDamageResetWhileCancelledAndZeroAttemptsDoNot() {
var f = new Direct(); f.recovery.sweep();
f.clock.advance(Duration.ofSeconds(29));
f.recovery.acceptedDamage(damage(f.player, 2));
f.clock.advance(Duration.ofSeconds(29)); f.recovery.sweep(); assertTrue(f.events.isEmpty());
f.clock.advance(Duration.ofSeconds(1)); f.recovery.sweep(); f.runNext(); assertEquals(5, f.health.get());
var noReset = new Direct(); noReset.recovery.sweep(); noReset.clock.advance(Duration.ofSeconds(29));
var cancelled = damage(noReset.player, 2); cancelled.setCancelled(true); noReset.recovery.acceptedDamage(cancelled);
noReset.recovery.acceptedDamage(damage(noReset.player, 0));
noReset.clock.advance(Duration.ofSeconds(1)); noReset.recovery.sweep(); noReset.runNext();
assertEquals(5, noReset.health.get());
var dealt = new Direct();
var target = mock(Player.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
dealt.recovery.sweep(); dealt.clock.advance(Duration.ofSeconds(29));
var source = DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK)
.withCausingEntity(dealt.player).withDirectEntity(dealt.player).build();
dealt.recovery.acceptedDamage(new EntityDamageByEntityEvent(dealt.player, target,
EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 2));
dealt.clock.advance(Duration.ofSeconds(29)); dealt.recovery.sweep(); assertTrue(dealt.events.isEmpty());
dealt.clock.advance(Duration.ofSeconds(1)); dealt.recovery.sweep(); dealt.runNext();
assertEquals(5, dealt.health.get(), "Accepted outgoing damage starts a fresh delay for its source character");
}
@Test void cancellationFailureAndDamageDuringPersistenceNeverPublishOrCatchUp() {
var cancelled = new Direct(); cancelled.veto = true; cancelled.recovery.sweep();
cancelled.clock.advance(Duration.ofSeconds(30)); cancelled.recovery.sweep();
assertEquals(1, cancelled.events.size()); assertTrue(cancelled.callbacks.isEmpty()); assertEquals(4, cancelled.health.get());
cancelled.clock.advance(Duration.ofSeconds(9)); cancelled.recovery.sweep(); assertEquals(1, cancelled.events.size());
var failed = new Direct(); failed.automatic = false; failed.recovery.sweep(); failed.clock.advance(Duration.ofSeconds(30));
failed.recovery.sweep(); assertNotNull(failed.pending);
failed.pending.completeExceptionally(new java.io.IOException("simulated")); failed.runNext();
assertEquals(4, failed.health.get());
failed.clock.advance(Duration.ofSeconds(9)); failed.recovery.sweep();
assertEquals(1, failed.events.size(), "Failed persistence does not immediately replay a missed pulse");
var stale = new Direct(); stale.automatic = false; stale.recovery.sweep(); stale.clock.advance(Duration.ofSeconds(30));
stale.recovery.sweep();
stale.recovery.acceptedDamage(damage(stale.player, 1));
stale.pending.complete(Optional.of(stale.saved(BigDecimal.valueOf(5)))); stale.runNext();
assertEquals(4, stale.health.get(), "Damage during the save invalidates native publication");
stale.clock.advance(Duration.ofSeconds(29)); stale.recovery.sweep(); assertEquals(1, stale.events.size());
}
@Test void passiveBonusZeroCancellationAndFullHealthStayInTheBasePulseTransaction() {
var zero = new Direct(); zero.recovery.attachBonusHealing(ignored -> BigInteger.ZERO);
zero.recovery.sweep(); zero.clock.advance(Duration.ofSeconds(30)); zero.recovery.sweep(); zero.runNext();
assertEquals(5, zero.health.get(), "A zero passive bonus leaves the base pulse unchanged");
var cancelled = new Direct(); cancelled.recovery.attachBonusHealing(ignored -> BigInteger.valueOf(3)); cancelled.veto = true;
var cancelledRevision = cancelled.record.get().revision();
cancelled.recovery.sweep(); cancelled.clock.advance(Duration.ofSeconds(30)); cancelled.recovery.sweep();
assertEquals(4, cancelled.events.getFirst().getAmount());
assertEquals(4, cancelled.health.get()); assertEquals(cancelledRevision, cancelled.record.get().revision());
var full = new Direct(); full.health.set(10d); full.record.set(full.saved(BigDecimal.TEN));
full.recovery.attachBonusHealing(ignored -> BigInteger.valueOf(3)); var fullRevision = full.record.get().revision();
full.recovery.sweep(); full.clock.advance(Duration.ofSeconds(30)); full.recovery.sweep();
assertTrue(full.events.isEmpty()); assertEquals(fullRevision, full.record.get().revision());
}
@Test void stackedRegenerationBonusProducesOneMaximumCappedNativeAndPersistedPulse() {
var f = new Direct(); f.health.set(9d); f.record.set(f.saved(BigDecimal.valueOf(9)));
f.recovery.attachBonusHealing(ignored -> BigInteger.valueOf(3));
f.recovery.sweep(); f.clock.advance(Duration.ofSeconds(30)); f.recovery.sweep();
assertEquals(1, f.events.size()); assertEquals(1, f.events.getFirst().getAmount());
f.runNext(); assertEquals(10, f.health.get()); assertEquals(0, BigDecimal.TEN.compareTo(f.record.get().health()));
}
@Test void zeroAmountDisablesPulsesAndReconfigurationRetainsTheActivityTimestamp() {
var f = new Direct(new OutOfCombatRecovery.Settings(Duration.ofSeconds(30), Duration.ofSeconds(10), BigInteger.ZERO));
f.recovery.sweep(); f.clock.advance(Duration.ofSeconds(40)); f.recovery.sweep();
assertTrue(f.events.isEmpty()); assertEquals(4, f.health.get());
f.recovery.configure(new OutOfCombatRecovery.Settings(Duration.ofSeconds(60), Duration.ofSeconds(5), BigInteger.ONE));
f.clock.advance(Duration.ofSeconds(19)); f.recovery.sweep(); assertTrue(f.events.isEmpty());
f.clock.advance(Duration.ofSeconds(1)); f.recovery.sweep(); f.runNext();
assertEquals(5, f.health.get(), "The new delay evaluates from the retained activity timestamp");
}
@Test void packagedPluginConnectsZeroDelayRecoveryConfigurationAndRestart() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"out-of-combat-healing-delay-seconds: 0\nout-of-combat-healing-interval-seconds: 10\nout-of-combat-healing-amount: 1\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); setHealth(data, a, BigDecimal.valueOf(4));
f.players.put(a.core.id, a.player); f.activate(a);
var recovery = (OutOfCombatRecovery) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "outOfCombatRecovery");
recovery.sweep(); PartyBookNativeTest.drain(f);
assertEquals(5, a.player.getHealth());
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertEquals(0, BigDecimal.valueOf(5).compareTo(storage.find(a.core.record.id()).get().orElseThrow().health()));
var sender = new EquipmentAdminCommandNativeTest.Sender();
assertEquals(List.of("out-of-combat-healing-amount", "out-of-combat-healing-delay-seconds",
"out-of-combat-healing-interval-seconds"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "out-of-combat"}));
assertEquals(List.of("1"), f.command.tabComplete(sender.port, "fantasystick",
new String[]{"config", "out-of-combat-healing-amount", ""}));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "out-of-combat-healing-amount"});
assertTrue(sender.messages.getLast().contains("=1"));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "out-of-combat-healing-amount", "3"});
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
var settings = (games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "outOfCombatRecoverySettings");
assertEquals("3", settings.value(games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.AMOUNT));
}
}
private static EntityDamageEvent damage(Player player, double amount) {
return new EntityDamageEvent(player, EntityDamageEvent.DamageCause.FALL, mock(DamageSource.class), amount);
}
private static void setHealth(Path data, FantasyStickLifecycleNativeTest.Actor actor, BigDecimal health) throws Exception {
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(actor.core.record.id()).orElseThrow();
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints(), health, old.learned(), old.loadout(), old.cooldowns(),
old.restrictedKills(), false, null, null), old.revision());
}
private static final class MutableClock extends Clock {
Instant now = Instant.parse("2026-09-21T00:00:00Z");
void advance(Duration duration) { now = now.plus(duration); }
@Override public ZoneId getZone() { return ZoneOffset.UTC; }
@Override public Clock withZone(ZoneId zone) { return this; }
@Override public Instant instant() { return now; }
}
private static final class Direct {
final CharacterStorage characters = mock(CharacterStorage.class);
final EquipmentSessions sessions = mock(EquipmentSessions.class);
final Player player = mock(Player.class);
final AttributeInstance maximum = mock(AttributeInstance.class);
final MutableClock clock = new MutableClock();
final AtomicReference<Double> health = new AtomicReference<>(4d);
final AtomicReference<CharacterRecord> record = new AtomicReference<>();
final EquipmentSessions.Active active;
final ArrayDeque<Runnable> callbacks = new ArrayDeque<>();
final java.util.ArrayList<EntityRegainHealthEvent> events = new java.util.ArrayList<>();
OutOfCombatRecovery recovery;
CompletableFuture<Optional<CharacterRecord>> pending;
boolean automatic = true;
boolean veto;
Direct() { this(OutOfCombatRecovery.Settings.defaults()); }
Direct(OutOfCombatRecovery.Settings settings) {
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(ProgressionRules.defaults());
record.set(new CharacterRecord(base.id(), base.name(), BigInteger.ONE, base.stats(), base.level(), base.experience(),
base.unspentPoints(), BigDecimal.valueOf(4), base.learned(), base.loadout(), base.cooldowns(),
base.restrictedKills(), false, null, null));
active = new EquipmentSessions.Active(java.util.UUID.randomUUID(), java.util.UUID.randomUUID(), base.id());
when(player.getUniqueId()).thenReturn(active.player()); when(player.getHealth()).thenAnswer(call -> health.get());
doAnswer(call -> { health.set(call.getArgument(0)); return null; }).when(player).setHealth(anyDouble());
when(player.getAttribute(Attribute.MAX_HEALTH)).thenReturn(maximum); when(maximum.getValue()).thenReturn(10d);
when(sessions.active(active.player())).thenReturn(Optional.of(active));
when(sessions.current(active)).thenReturn(true);
when(sessions.currentPlayer(active)).thenReturn(Optional.of(player));
when(sessions.currentRecord(active)).thenAnswer(call -> Optional.of(record.get()));
when(sessions.activeCharacters()).thenAnswer(call -> List.of(new EquipmentSessions.ActiveCharacter(active, record.get())));
when(sessions.acknowledgeProgress(eq(active), any(), any())).thenAnswer(call -> {
var expected = (BigInteger) call.getArgument(1); var saved = (CharacterRecord) call.getArgument(2);
if (!record.get().revision().equals(expected)) return false;
record.set(saved); return true;
});
when(characters.saveHealth(eq(base.id()), any(BigInteger.class), any(BigDecimal.class), any(BigDecimal.class)))
.thenAnswer(call -> {
var target = (BigDecimal) call.getArgument(2);
pending = new CompletableFuture<>();
if (automatic) pending.complete(Optional.of(saved(target)));
return pending;
});
recovery = new OutOfCombatRecovery(settings, characters, sessions, clock, callbacks::add,
event -> { events.add(event); if (veto) event.setCancelled(true); }, () -> BigDecimal.valueOf(1024), ignored -> {});
}
CharacterRecord saved(BigDecimal target) {
var old = record.get();
return new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints(), target, old.learned(), old.loadout(), old.cooldowns(),
old.restrictedKills(), false, null, null);
}
void runNext() { assertFalse(callbacks.isEmpty(), "Missing deferred recovery callback"); callbacks.removeFirst().run(); }
}
}
@@ -56,9 +56,8 @@ 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);
PartyBookNativeTest.click(fixture, actor, button(actor, "[3]", "purify")); drain(fixture, 2);
doReturn(true).when(actor.player).isSneaking(); use(fixture, actor); drain(fixture, 2);
harmful.forEach(type -> assertFalse(effects.containsKey(type), type + " and its hidden chain must be gone"));
@@ -249,7 +248,6 @@ class PurifyNativeTest {
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
PartyBookNativeTest.click(fixture, actor, button(actor, "[Learn]", "purify")); drain(fixture, 2);
PartyBookNativeTest.click(fixture, actor, button(actor, "[3]", "purify")); drain(fixture, 2);
}
private static void party(FantasyStickLifecycleNativeTest.Fixture fixture,
@@ -0,0 +1,149 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.OutOfCombatRecovery;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.file.Path;
import java.util.EnumMap;
import java.util.List;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class QuickerRegenerationIINativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void prerequisiteLearnStackTransferTailReviveAndDependencySafeRefundUseTwoPassiveSlots() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"character-swap-cooldown-seconds: 0\nout-of-combat-healing-delay-seconds: 0\n"
+ "out-of-combat-healing-interval-seconds: 10\nout-of-combat-healing-amount: 1\n"
+ "quicker-regeneration-i-bonus-healing: 1\nquicker-regeneration-ii-bonus-healing: 2\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
makeEligible(data, a, BigDecimal.valueOf(4)); f.players.put(a.core.id, a.player); f.activate(a); PartyBookNativeTest.open(f, a);
String initial = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
assertTrue(initial.contains("QuickerRegenerationIIQuickerRegenerationI,CON16,WIS14"), initial);
assertFalse(hasAction(a, "[Learn]", "quicker_regeneration_ii"));
PartyBookNativeTest.click(f, a, action(a, "[Learn]", "quicker_regeneration_i")); drain(f);
PartyBookNativeTest.click(f, a, action(a, "[Learn]", "quicker_regeneration_ii")); drain(f);
var learned = storage(f).find(a.core.record.id()).get().orElseThrow();
assertEquals(List.of("quicker_regeneration_i", "quicker_regeneration_ii", ""), learned.passives());
assertEquals(BigInteger.valueOf(38), learned.unspentPoints());
assertEquals(a.core.record.loadout(), learned.loadout());
var recovery = (OutOfCombatRecovery) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "outOfCombatRecovery");
recovery.sweep(); PartyBookNativeTest.drain(f);
assertEquals(8, a.player.getHealth(), "The single pulse is base 1 + Regeneration I 1 + Regeneration II 2");
learned = storage(f).find(learned.id()).get().orElseThrow(); assertEquals(0, new BigDecimal("8").compareTo(learned.health()));
PartyBookNativeTest.click(f, a, action(a, "[Unlearn]", "quicker_regeneration_i")); drain(f);
var refused = storage(f).find(learned.id()).get().orElseThrow();
assertEquals(learned.passives(), refused.passives()); assertEquals(learned.unspentPoints(), refused.unspentPoints());
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
var b = new FantasyStickLifecycleNativeTest.Actor(data);
b.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(refused));
f.players.put(b.core.id, b.player); f.activate(b); PartyBookNativeTest.open(f, b);
assertTrue(PartyBookNativeTest.text(b).replaceAll("\\s+", "").contains("2:QuickerRegenerationII"));
b.core.inventory.setItemInOffHand(null); f.changed(b); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(b);
var exited = storage(f).find(refused.id()).get().orElseThrow();
var dead = storage(f).recordDeath(exited.id(), exited.revision()).get().orElseThrow();
assertEquals(refused.passives(), dead.passives());
var revived = storage(f).restoreDeathRecordAdministratively(dead.id(), dead.deathId(), new BigDecimal("1024")).get().orElseThrow();
assertEquals(refused.passives(), revived.passives());
var c = new FantasyStickLifecycleNativeTest.Actor(data);
c.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(revived));
f.players.put(c.core.id, c.player); f.activate(c); PartyBookNativeTest.open(f, c);
PartyBookNativeTest.click(f, c, action(c, "[Unlearn]", "quicker_regeneration_ii")); drain(f);
PartyBookNativeTest.click(f, c, action(c, "[Unlearn]", "quicker_regeneration_i")); drain(f);
var refunded = storage(f).find(revived.id()).get().orElseThrow();
assertEquals(List.of("", "", ""), refunded.passives()); assertEquals(BigInteger.valueOf(40), refunded.unspentPoints());
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
assertEquals(refunded.passives(), storage(f).find(refunded.id()).get().orElseThrow().passives());
}
}
@Test void orphanedSavedSecondRankRemainsVisibleButCannotApplyWithoutItsPrerequisite() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"out-of-combat-healing-delay-seconds: 0\nout-of-combat-healing-interval-seconds: 10\n"
+ "out-of-combat-healing-amount: 1\nquicker-regeneration-i-bonus-healing: 1\n"
+ "quicker-regeneration-ii-bonus-healing: 2\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
makeEligible(data, a, BigDecimal.valueOf(4), List.of("quicker_regeneration_ii", "", ""));
f.players.put(a.core.id, a.player); f.activate(a); PartyBookNativeTest.open(f, a);
assertTrue(PartyBookNativeTest.text(a).replaceAll("\\s+", "").contains("1:QuickerRegenerationII"));
var recovery = (OutOfCombatRecovery) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "outOfCombatRecovery");
recovery.sweep(); PartyBookNativeTest.drain(f);
assertEquals(5, a.player.getHealth(), "An orphaned second rank cannot execute without Regeneration I");
}
}
@Test void configurationIsPermissionAwareDurableAndPrefixFiltered() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var sender = new EquipmentAdminCommandNativeTest.Sender();
assertEquals(List.of("quicker-regeneration-ii-bonus-healing"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "quicker-regeneration-ii"}));
assertEquals(List.of("2"), f.command.tabComplete(sender.port, "fantasystick",
new String[]{"config", "quicker-regeneration-ii-bonus-healing", ""}));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quicker-regeneration-ii-bonus-healing"});
assertTrue(sender.messages.getLast().contains("=2")); sender.allowed = false;
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "quicker-regeneration-ii"}).isEmpty());
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quicker-regeneration-ii-bonus-healing", "5"});
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quicker-regeneration-ii-bonus-healing", "5"});
var callback = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(callback); callback.run();
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
var settings = (games.dmg.fantasystick.persistence.QuickerRegenerationIISettings)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "quickerRegenerationIISettings");
assertEquals("5", settings.value());
}
}
private static void makeEligible(Path data, FantasyStickLifecycleNativeTest.Actor actor, BigDecimal health) throws Exception {
makeEligible(data, actor, health, List.of("", "", ""));
}
private static void makeEligible(Path data, FantasyStickLifecycleNativeTest.Actor actor, BigDecimal health,
List<String> passives) throws Exception {
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(actor.core.record.id()).orElseThrow(); var stats = new EnumMap<>(old.stats());
stats.put(Stat.CON, BigInteger.valueOf(16)); stats.put(Stat.WIS, BigInteger.valueOf(14));
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
old.level(), old.experience(), BigInteger.valueOf(40), health, old.learned(), old.loadout(), passives,
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
}
private static void drain(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
}
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture f) {
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
}
private static boolean hasAction(FantasyStickLifecycleNativeTest.Actor actor, String label, String passive) {
try { action(actor, label, passive); return true; } catch (AssertionError missing) { return false; }
}
private static net.minecraft.network.chat.ClickEvent.Custom action(
FantasyStickLifecycleNativeTest.Actor actor, String label, String passive) {
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
assertNotNull(content);
for (var page : content.pages()) { var found = action(page.raw(), label, passive); if (found != null) return found; }
throw new AssertionError("Missing " + label + " for " + passive);
}
private static net.minecraft.network.chat.ClickEvent.Custom action(
net.minecraft.network.chat.Component component, String label, String passive) {
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(":" + passive)) return custom;
for (var child : component.getSiblings()) { var found = action(child, label, passive); if (found != null) return found; }
return null;
}
}
@@ -0,0 +1,170 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.OutOfCombatRecovery;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.file.Path;
import java.util.EnumMap;
import java.util.List;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class QuickerRegenerationNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void connectedBookLearnRecoveryTransferTailReloadAndUnlearnPathUsesSeparatePassiveSlots() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"character-swap-cooldown-seconds: 0\nout-of-combat-healing-delay-seconds: 0\n"
+ "out-of-combat-healing-interval-seconds: 10\nout-of-combat-healing-amount: 1\n"
+ "quicker-regeneration-i-bonus-healing: 3\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); makeEligible(data, a, BigDecimal.valueOf(4), List.of("", "", ""));
f.players.put(a.core.id, a.player); f.activate(a); PartyBookNativeTest.open(f, a);
var pages = nativePages(a);
int active = first(pages, "Active abilities"), passive = first(pages, "Passive abilities");
int activeIndex = first(pages, "Ability index"), passiveIndex = first(pages, "Passive index");
int party = first(pages, "Party");
assertTrue(active > 0 && active < passive && passive < activeIndex && activeIndex < passiveIndex && passiveIndex < party);
assertTrue(pages.get(passive).getString().contains("1: No Passive"));
assertTrue(pages.get(passiveIndex).getString().replace("\n", "").contains("Quicker Regeneration I"));
var jump = changePageTo(pages.get(passiveIndex), pages, "Quicker Regeneration I"); assertNotNull(jump);
String detail = pages.get(jump.page() - 1).getString();
assertTrue(detail.replaceAll("\\s+", "").startsWith("QuickerRegenerationICON12,WIS12[Learn]"), detail);
assertTrue(detail.replaceAll("\\s+", "").contains("out-of-combatrecoverypulse"));
var beforeLoadout = storage(f).find(a.core.record.id()).get().orElseThrow().loadout();
PartyBookNativeTest.click(f, a, custom(List.of(pages.get(jump.page() - 1)), "fantasystick:passive_unlock"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var learned = storage(f).find(a.core.record.id()).get().orElseThrow();
assertEquals(List.of("quicker_regeneration_i", "", ""), learned.passives());
assertEquals(BigInteger.valueOf(39), learned.unspentPoints());
assertEquals(beforeLoadout, learned.loadout(), "Passive acquisition never consumes an active slot");
var recovery = (OutOfCombatRecovery) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "outOfCombatRecovery");
recovery.sweep(); PartyBookNativeTest.drain(f);
assertEquals(8, a.player.getHealth(), "Base one plus configured passive three is one native persisted pulse");
var healed = storage(f).find(a.core.record.id()).get().orElseThrow(); assertEquals(0, new BigDecimal("8").compareTo(healed.health()));
var dead = storage(f).recordDeath(healed.id(), healed.revision()).get().orElseThrow();
var tailText = text(new games.dmg.fantasystick.items.TailBooks().create(dead));
assertTrue(tailText.replaceAll("\\s+", "").contains("Passive:quicker_regeneration_i"));
var revived = storage(f).restoreDeathRecordAdministratively(dead.id(), dead.deathId(), new BigDecimal("1024")).get().orElseThrow();
assertEquals(healed.passives(), revived.passives());
// Reissue the current same-ID sheet to model the supported transfer after revival.
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
var b = new FantasyStickLifecycleNativeTest.Actor(data); b.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(revived));
f.players.put(b.core.id, b.player); f.activate(b); PartyBookNativeTest.open(f, b);
assertTrue(PartyBookNativeTest.text(b).replace("\n", "").contains("Quicker Regeneration I"));
PartyBookNativeTest.click(f, b, custom(nativePages(b), "fantasystick:passive_unlearn"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var refunded = storage(f).find(revived.id()).get().orElseThrow();
assertEquals(List.of("", "", ""), refunded.passives()); assertEquals(BigInteger.valueOf(40), refunded.unspentPoints());
assertEquals(beforeLoadout, refunded.loadout());
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
assertEquals(refunded.passives(), storage(f).find(refunded.id()).get().orElseThrow().passives());
}
}
@Test void unknownFullPassiveSlotsRemainVisibleAndRefuseBeforeSpending() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
makeEligible(data, a, BigDecimal.valueOf(4), List.of("unknown_a", "unknown_b", "unknown_c"));
f.players.put(a.core.id, a.player); f.activate(a); PartyBookNativeTest.open(f, a);
String rendered = PartyBookNativeTest.text(a);
for (String id : List.of("unknown_a", "unknown_b", "unknown_c")) assertTrue(rendered.contains(id));
assertTrue(rendered.replaceAll("\\s+", "").contains("Unavailable:passiveslots"));
var before = storage(f).find(a.core.record.id()).get().orElseThrow();
assertFalse(hasCustom(nativePages(a), "fantasystick:passive_unlock"));
assertEquals(before, storage(f).find(before.id()).get().orElseThrow());
}
}
@Test void liveBonusConfigurationIsPermissionAwareDurableAndFuturePulseOnly() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var sender = new EquipmentAdminCommandNativeTest.Sender();
assertEquals(List.of("quicker-regeneration-i-bonus-healing", "quicker-regeneration-ii-bonus-healing"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "quicker"}));
assertEquals(List.of("1"), f.command.tabComplete(sender.port, "fantasystick",
new String[]{"config", "quicker-regeneration-i-bonus-healing", ""}));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quicker-regeneration-i-bonus-healing"});
assertTrue(sender.messages.getLast().contains("=1"));
sender.allowed = false;
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "quicker"}).isEmpty());
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quicker-regeneration-i-bonus-healing", "4"});
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quicker-regeneration-i-bonus-healing", "4"});
var callback = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(callback); callback.run();
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quicker-regeneration-i-bonus-healing", "1025"});
assertTrue(sender.messages.getLast().contains("invalid"));
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
var settings = (games.dmg.fantasystick.persistence.QuickerRegenerationSettings)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "quickerRegenerationSettings");
assertEquals("4", settings.value());
}
}
private static void makeEligible(Path data, FantasyStickLifecycleNativeTest.Actor actor, BigDecimal health,
List<String> passives) throws Exception {
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(actor.core.record.id()).orElseThrow(); var stats = new EnumMap<>(old.stats());
stats.put(Stat.CON, BigInteger.valueOf(12)); stats.put(Stat.WIS, BigInteger.valueOf(12));
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
old.level(), old.experience(), BigInteger.valueOf(40), health, old.learned(), old.loadout(), passives,
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
}
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture f) {
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
}
private static List<net.minecraft.network.chat.Component> nativePages(FantasyStickLifecycleNativeTest.Actor actor) {
return org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT).pages().stream().map(page -> page.raw()).toList();
}
private static int first(List<net.minecraft.network.chat.Component> pages, String text) {
for (int i = 0; i < pages.size(); i++) if (pages.get(i).getString().startsWith(text)) return i;
return -1;
}
private static net.minecraft.network.chat.ClickEvent.ChangePage changePageTo(
net.minecraft.network.chat.Component component, List<net.minecraft.network.chat.Component> pages, String heading) {
if (component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.ChangePage page
&& page.page() > 0 && page.page() <= pages.size()
&& pages.get(page.page() - 1).getString().replaceAll("\\s+", "")
.startsWith(heading.replaceAll("\\s+", ""))) return page;
for (var child : component.getSiblings()) {
var found = changePageTo(child, pages, heading); if (found != null) return found;
}
return null;
}
private static net.minecraft.network.chat.ClickEvent.Custom custom(List<net.minecraft.network.chat.Component> pages, String id) {
for (var page : pages) { var found = custom(page, id); if (found != null) return found; }
throw new AssertionError("Missing custom action " + id);
}
private static net.minecraft.network.chat.ClickEvent.Custom custom(net.minecraft.network.chat.Component component, String id) {
if (component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.Custom action
&& action.id().toString().equals(id)) return action;
for (var child : component.getSiblings()) { var found = custom(child, id); if (found != null) return found; }
return null;
}
private static boolean hasCustom(List<net.minecraft.network.chat.Component> pages, String id) {
return pages.stream().anyMatch(page -> hasCustom(page, id));
}
private static boolean hasCustom(net.minecraft.network.chat.Component component, String id) {
if (component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.Custom custom
&& custom.id().toString().equals(id)) return true;
return component.getSiblings().stream().anyMatch(child -> hasCustom(child, id));
}
private static String text(org.bukkit.inventory.ItemStack book) {
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(book)
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
return content.pages().stream().map(page -> page.raw().getString()).collect(java.util.stream.Collectors.joining("\n"));
}
}
@@ -34,11 +34,9 @@ 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);
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "quickstep"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertEquals(BigInteger.valueOf(39), storage.find(a.core.record.id()).get().orElseThrow().unspentPoints());
@@ -247,8 +245,6 @@ class QuickstepNativeTest {
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[Learn]", "quickstep"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "quickstep"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
}
private static AtomicReference<Vector> prepareMovement(FantasyStickLifecycleNativeTest.Actor actor, Vector initial) {
@@ -55,9 +55,8 @@ 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);
PartyBookNativeTest.click(fixture, caster, button(caster, "[3]", "rallying_cry")); drain(fixture, 2);
use(fixture, caster); drain(fixture, 2);
assertEquals(0, casterEffects.get(PotionEffectType.SPEED).getAmplifier());
assertEquals(0, allyEffects.get(PotionEffectType.SPEED).getAmplifier());
@@ -83,7 +82,6 @@ class RallyingCryNativeTest {
PartyBookNativeTest.click(fixture, caster, unlearn); PartyBookNativeTest.click(fixture, caster, unlearn); drain(fixture, 2);
assertEquals(deadline, storage(fixture).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("rallying_cry"));
PartyBookNativeTest.click(fixture, caster, button(caster, "[Learn]", "rallying_cry")); drain(fixture, 2);
PartyBookNativeTest.click(fixture, caster, button(caster, "[3]", "rallying_cry")); drain(fixture, 2);
use(fixture, caster); assertTrue(caster.messages.stream().anyMatch(message -> message.contains("ready in")));
}
}
@@ -38,8 +38,6 @@ class ReviveNativeTest {
assertTrue(text.contains("CON12")); assertTrue(text.contains("Cooldown:86400s"));
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[Learn]", "revive"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "revive"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
var saved = storage.find(actor.core.record.id()).get().orElseThrow();
@@ -298,8 +296,6 @@ class ReviveNativeTest {
PartyBookNativeTest.click(fixture, caster, PartyBookNativeTest.button(caster, "[Learn]"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
PartyBookNativeTest.click(fixture, caster, PartyBookNativeTest.button(caster, "[3]"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
caster.core.inventory.getInventory().setSelectedSlot(2);
fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(caster.player, target.player,
org.bukkit.inventory.EquipmentSlot.HAND));
@@ -40,7 +40,6 @@ class SanctuaryNativeTest {
caster.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(fixture,caster);
String text=PartyBookNativeTest.text(caster).replaceAll("\\s+","");assertTrue(text.contains("Sanctuary"));assertTrue(text.contains("WIS24"));assertTrue(text.contains("CHR20"));assertTrue(text.contains("CON10"));
PartyBookNativeTest.click(fixture,caster,button(caster,"[Learn]","sanctuary"));drain(fixture,2);
PartyBookNativeTest.click(fixture,caster,button(caster,"[3]","sanctuary"));drain(fixture,2);
use(fixture,caster);drain(fixture,2);assertEquals(2L,fixture.delayedTicks.getLast());
for(int i=0;i<10;i++)fixture.delayed.removeFirst().run();drain(fixture,4);assertEquals(6,ally.core.health,0);
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
@@ -34,8 +34,6 @@ class SecondWindNativeTest {
assertTrue(text.contains("SecondWind")); assertTrue(text.contains("CON12")); assertTrue(text.contains("WIS12"));
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "second_wind"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "second_wind"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(4, a.player.getHealth());
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
@@ -173,8 +171,6 @@ class SecondWindNativeTest {
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[Learn]", "second_wind"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "second_wind"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
}
private static void use(FantasyStickLifecycleNativeTest.Fixture fixture,
@@ -48,6 +48,7 @@ class SessionControlsNativeTest {
item.getItemMeta().displayName());
assertEquals(1, item.getAmount());
assertEquals(1, item.getMaxStackSize());
assertNull(item.getItemMeta().getItemModel(), "Book, basic attack and No Ability remain unaffected");
var expected = new SessionControls.Identity(owner, session, slot);
assertEquals(expected, controls.identify(item).orElseThrow());
assertTrue(controls.owned(item, owner, session));
@@ -82,6 +83,7 @@ class SessionControlsNativeTest {
var item = roundTrip(items[abilitySlot + 2]);
assertEquals(materials.get(index), item.getType(), ids.get(index));
assertEquals(Component.text(names.get(index)), item.getItemMeta().displayName());
assertNull(item.getItemMeta().getItemModel(), "Ready ability icons remain unaffected");
assertEquals(ids.get(index), item.getItemMeta().getPersistentDataContainer()
.get(key("control_ability_id"), PersistentDataType.STRING));
assertEquals(1, item.getAmount());
@@ -94,6 +96,45 @@ 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(SessionControls.COOLDOWN_MODEL, pane.getItemMeta().getItemModel());
}
assertEquals(Material.ECHO_SHARD, items[4].getType(), "A deadline equal to now is ready");
assertNull(items[4].getItemMeta().getItemModel(), "An exact-deadline ready icon retains no stale model");
assertEquals(Material.PAPER, items[5].getType(), "Unknown saved IDs remain unavailable, not cooling panes");
assertNull(items[5].getItemMeta().getItemModel(), "Unknown controls remain visible and unaffected");
var genuineWithoutModel = items[2].clone();
var genuineMeta = genuineWithoutModel.getItemMeta();
genuineMeta.setItemModel(null);
genuineWithoutModel.setItemMeta(genuineMeta);
assertEquals(java.util.Optional.of("fireball"), timed.ability(genuineWithoutModel),
"Cosmetic model metadata is not casting or session authority");
var ordinaryPane = new ItemStack(Material.GRAY_STAINED_GLASS_PANE);
var ordinaryMeta = ordinaryPane.getItemMeta();
ordinaryMeta.setItemModel(SessionControls.COOLDOWN_MODEL);
ordinaryPane.setItemMeta(ordinaryMeta);
assertRejected(ordinaryPane);
}
@Test
void unknownSavedAbilitiesRemainUnavailableWithoutChangingTheRecordOrCallerLoadout() {
var loadout = new ArrayList<>(List.of("fireball", "removed_spell", "", "revive", "", "", ""));
@@ -32,11 +32,9 @@ 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);
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "shield_bash"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertEquals(BigInteger.valueOf(39), storage.find(a.core.record.id()).get().orElseThrow().unspentPoints());
assertEquals(org.bukkit.Material.SHIELD, a.core.inventory.getItem(2).getType());
@@ -298,8 +296,6 @@ class ShieldBashNativeTest {
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[Learn]", "shield_bash"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "shield_bash"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
}
private static ClickEvent.Custom abilityButton(FantasyStickLifecycleNativeTest.Actor actor, String label, String ability) {
@@ -0,0 +1,193 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import games.dmg.fantasystick.ability.AbilityExecution;
import games.dmg.fantasystick.ability.OutgoingDamageBonus;
import games.dmg.fantasystick.ability.RallyingCry;
import games.dmg.fantasystick.party.PartyRoster;
import games.dmg.fantasystick.passive.SpeedI;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.persistence.YamlCharacterRepository;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigInteger;
import java.nio.file.Path;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.entity.Player;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class SpeedINativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void connectedLearnExitTransferUnlearnAndDefeatOwnOnlyTheCharacterSpeedLayer() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data); withDex(data, a, List.of("", "", ""));
var aEffects = effects(a.player); f.players.put(a.core.id, a.player); f.activate(a);
var speed = (SpeedI) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "speedI");
PartyBookNativeTest.open(f, a); assertTrue(PartyBookNativeTest.text(a).replaceAll("\\s+", "").contains("SpeedIDEX14[Learn]"));
PartyBookNativeTest.click(f, a, action(a, "[Learn]", "speed_i")); settle(f); speed.sweep();
var learned = record(f, a); assertEquals(List.of("speed_i", "", ""), learned.passives());
assertEquals(BigInteger.valueOf(39), learned.unspentPoints()); assertEquals(0, aEffects.get(PotionEffectType.SPEED).getAmplifier());
var sheet = a.core.inventory.getItemInOffHand().clone(); a.core.inventory.setItemInOffHand(null);
f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a); speed.sweep();
assertFalse(aEffects.containsKey(PotionEffectType.SPEED));
var b = new FantasyStickLifecycleNativeTest.Actor(data); var bEffects = effects(b.player);
b.core.inventory.setItemInOffHand(sheet); f.players.put(b.core.id, b.player); f.activate(b); speed.sweep();
assertEquals(0, bEffects.get(PotionEffectType.SPEED).getAmplifier());
PartyBookNativeTest.open(f, b); PartyBookNativeTest.click(f, b, action(b, "[Unlearn]", "speed_i")); settle(f); speed.sweep();
var unlearned = storage(f).find(learned.id()).get().orElseThrow(); assertFalse(unlearned.passives().contains("speed_i"));
assertEquals(learned.unspentPoints().add(BigInteger.ONE), unlearned.unspentPoints()); assertFalse(bEffects.containsKey(PotionEffectType.SPEED));
PartyBookNativeTest.open(f, b); Runnable failed;
try (var channel = java.nio.channels.FileChannel.open(data.resolve("characters").resolve(".records.lock"),
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
var lock = channel.lock()) {
assertTrue(lock.isValid()); PartyBookNativeTest.click(f, b, action(b, "[Learn]", "speed_i"));
failed = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(failed);
}
failed.run(); speed.sweep(); assertFalse(bEffects.containsKey(PotionEffectType.SPEED));
assertFalse(storage(f).find(learned.id()).get().orElseThrow().passives().contains("speed_i"));
b.core.inventory.setItemInOffHand(null); f.changed(b); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
storage(f).find(learned.id()).get(); f.receipts().find(b.core.id).toCompletableFuture().get();
withDex(data, a, List.of("speed_i", "", ""));
var c = new FantasyStickLifecycleNativeTest.Actor(data); var cEffects = effects(c.player);
c.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(
new YamlCharacterRepository(data).load(a.core.record.id()).orElseThrow()));
f.players.put(c.core.id, c.player); f.activate(c); speed.sweep(); assertTrue(cEffects.containsKey(PotionEffectType.SPEED));
c.player.setHealth(1);
var death = new org.bukkit.event.entity.PlayerDeathEvent(c.player, mock(org.bukkit.damage.DamageSource.class),
new java.util.ArrayList<>(), 0, net.kyori.adventure.text.Component.text("defeat"), true);
f.dispatcher.accept(death); settle(f); speed.sweep(); assertFalse(cEffects.containsKey(PotionEffectType.SPEED));
}
}
@Test void milkAndExpiredEffectsReapplyWhileStrongerAndCleanupPathsPreserveUnrelatedSpeed() {
var sessions = mock(EquipmentSessions.class); var player = mock(Player.class); var effects = effects(player);
UUID playerId = UUID.randomUUID(); var active = new EquipmentSessions.Active(playerId, UUID.randomUUID(), UUID.randomUUID());
var learned = mock(CharacterRecord.class); when(learned.passives()).thenReturn(List.of("speed_i", "", "")); when(learned.dead()).thenReturn(false);
when(player.getUniqueId()).thenReturn(playerId); when(sessions.activeCharacters()).thenReturn(List.of(new EquipmentSessions.ActiveCharacter(active, learned)));
when(sessions.current(active)).thenReturn(true); when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(learned));
when(sessions.currentPlayer(active)).thenReturn(java.util.Optional.of(player));
var speed = new SpeedI(sessions); assertEquals(Map.of(Stat.DEX, BigInteger.valueOf(14)), speed.definition().requirements());
speed.sweep(); assertEquals(0, effects.get(PotionEffectType.SPEED).getAmplifier());
speed.sweep(); verify(player, times(1)).addPotionEffect(any(), eq(true));
effects.clear(); speed.sweep(); verify(player, times(2)).addPotionEffect(any(), eq(true));
var stronger = new PotionEffect(PotionEffectType.SPEED, 200, 1); effects.put(PotionEffectType.SPEED, stronger); speed.sweep();
assertSame(stronger, effects.get(PotionEffectType.SPEED));
when(learned.passives()).thenReturn(List.of("", "", "")); speed.sweep();
assertSame(stronger, effects.get(PotionEffectType.SPEED), "Cleanup cannot remove a stronger unrelated effect");
when(learned.passives()).thenReturn(List.of("speed_i", "", "")); effects.clear(); speed.sweep();
assertEquals(0, effects.get(PotionEffectType.SPEED).getAmplifier(), "The passive resumes after stronger Speed expires");
speed.close(); assertFalse(effects.containsKey(PotionEffectType.SPEED));
var equalSessions = mock(EquipmentSessions.class); var equalPlayer = mock(Player.class); var equalEffects = effects(equalPlayer);
UUID equalId = UUID.randomUUID(); var equalActive = new EquipmentSessions.Active(equalId, UUID.randomUUID(), UUID.randomUUID());
var equalRecord = mock(CharacterRecord.class); when(equalRecord.passives()).thenReturn(List.of("speed_i", "", ""));
when(equalPlayer.getUniqueId()).thenReturn(equalId); when(equalSessions.activeCharacters()).thenReturn(List.of(new EquipmentSessions.ActiveCharacter(equalActive, equalRecord)));
when(equalSessions.current(equalActive)).thenReturn(true); when(equalSessions.currentRecord(equalActive)).thenReturn(java.util.Optional.of(equalRecord));
when(equalSessions.currentPlayer(equalActive)).thenReturn(java.util.Optional.of(equalPlayer));
var equal = new PotionEffect(PotionEffectType.SPEED, 40, 0); equalEffects.put(PotionEffectType.SPEED, equal);
var equalSpeed = new SpeedI(equalSessions); equalSpeed.sweep(); assertSame(equal, equalEffects.get(PotionEffectType.SPEED));
equalEffects.clear(); equalSpeed.sweep(); assertTrue(equalEffects.get(PotionEffectType.SPEED).isInfinite()); equalSpeed.close();
var weaker = new PotionEffect(PotionEffectType.SPEED, 4, -1); equalEffects.put(PotionEffectType.SPEED, weaker);
var weakerSpeed = new SpeedI(equalSessions); weakerSpeed.sweep();
assertEquals(0, equalEffects.get(PotionEffectType.SPEED).getAmplifier());
when(equalRecord.passives()).thenReturn(List.of("", "", "")); weakerSpeed.sweep();
assertEquals(-1, equalEffects.get(PotionEffectType.SPEED).getAmplifier(), "An unexpired weaker native layer is preserved");
weakerSpeed.close();
when(equalRecord.passives()).thenReturn(List.of("speed_i", "", ""));
equalEffects.put(PotionEffectType.SPEED, new PotionEffect(PotionEffectType.SPEED, 1, -1));
var expiredWeak = new SpeedI(equalSessions); expiredWeak.sweep(); expiredWeak.sweep();
when(equalRecord.passives()).thenReturn(List.of("", "", "")); expiredWeak.sweep();
assertFalse(equalEffects.containsKey(PotionEffectType.SPEED), "An unrelated weaker layer that expired is not restored");
expiredWeak.close();
}
@Test void rallyOverlapNeverStacksAndEitherOwnerLeavesTheOtherApplicableLayer() {
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class); var player = mock(Player.class);
var effects = effects(player); UUID id = UUID.randomUUID(); var active = new EquipmentSessions.Active(id, UUID.randomUUID(), UUID.randomUUID());
var record = mock(CharacterRecord.class); when(record.passives()).thenReturn(List.of("speed_i", "", ""));
when(player.getUniqueId()).thenReturn(id); when(player.getLocation()).thenReturn(new Location(mock(org.bukkit.World.class), 0, 64, 0));
when(player.getNearbyEntities(anyDouble(), anyDouble(), anyDouble())).thenReturn(List.of());
when(sessions.active(id)).thenReturn(java.util.Optional.of(active)); when(sessions.current(active)).thenReturn(true);
when(sessions.activeCharacters()).thenReturn(List.of(new EquipmentSessions.ActiveCharacter(active, record)));
when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record)); when(sessions.currentPlayer(active)).thenReturn(java.util.Optional.of(player));
var speed = new SpeedI(sessions); speed.sweep();
var rally = new RallyingCry(RallyingCry.Settings.defaults(), sessions, parties,
new OutgoingDamageBonus(sessions, parties, ignored -> { }));
var execution = mock(AbilityExecution.class); var input = mock(ControlInput.class);
when(input.player()).thenReturn(player); when(input.session()).thenReturn(active); when(execution.input()).thenReturn(input);
when(execution.stillCurrent()).thenReturn(true); when(execution.definition()).thenReturn(rally.definition());
rally.activate(execution); assertEquals(0, effects.get(PotionEffectType.SPEED).getAmplifier());
rally.close(); assertTrue(effects.containsKey(PotionEffectType.SPEED), "Rally cleanup leaves the passive-owned Speed I");
speed.close(); assertFalse(effects.containsKey(PotionEffectType.SPEED));
var rallyFirst = new RallyingCry(RallyingCry.Settings.defaults(), sessions, parties,
new OutgoingDamageBonus(sessions, parties, ignored -> { }));
when(execution.definition()).thenReturn(rallyFirst.definition()); rallyFirst.activate(execution);
assertTrue(effects.containsKey(PotionEffectType.SPEED));
var passiveSecond = new SpeedI(sessions); passiveSecond.sweep(); rallyFirst.close();
assertFalse(effects.containsKey(PotionEffectType.SPEED)); passiveSecond.sweep();
assertTrue(effects.containsKey(PotionEffectType.SPEED), "The learned passive resumes after Rally-owned Speed I ends");
passiveSecond.close();
}
private static Map<PotionEffectType, PotionEffect> effects(Player player) {
var result = new HashMap<PotionEffectType, PotionEffect>();
doAnswer(call -> result.get(call.getArgument(0))).when(player).getPotionEffect(any());
doAnswer(call -> { PotionEffect effect = call.getArgument(0); result.put(effect.getType(), effect); return true; })
.when(player).addPotionEffect(any(), eq(true));
doAnswer(call -> { result.remove(call.getArgument(0)); return null; }).when(player).removePotionEffect(any()); return result;
}
private static void withDex(Path data, FantasyStickLifecycleNativeTest.Actor actor, List<String> passives) throws Exception {
var repository = new YamlCharacterRepository(data); var old = repository.load(actor.core.record.id()).orElseThrow();
var stats = new EnumMap<>(old.stats()); stats.put(Stat.DEX, BigInteger.valueOf(14));
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats, old.level(), old.experience(),
old.unspentPoints(), old.health(), old.learned(), old.loadout(), passives, old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
}
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture f) {
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
}
private static CharacterRecord record(FantasyStickLifecycleNativeTest.Fixture f,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
return storage(f).find(actor.core.record.id()).get().orElseThrow();
}
private static void settle(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
}
private static net.minecraft.network.chat.ClickEvent.Custom action(
FantasyStickLifecycleNativeTest.Actor actor, String label, String passive) {
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT); assertNotNull(content);
for (var page : content.pages()) { var found = action(page.raw(), label, passive); if (found != null) return found; }
throw new AssertionError("Missing " + label + " for " + passive);
}
private static net.minecraft.network.chat.ClickEvent.Custom action(
net.minecraft.network.chat.Component component, String label, String passive) {
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(":" + passive)) return custom;
for (var child : component.getSiblings()) { var found = action(child, label, passive); if (found != null) return found; }
return null;
}
}
@@ -31,11 +31,9 @@ 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);
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "stone_skin"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertEquals(BigInteger.valueOf(39), storage.find(a.core.record.id()).get().orElseThrow().unspentPoints());
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
@@ -58,8 +56,6 @@ class StoneSkinNativeTest {
assertEquals(BigInteger.valueOf(40), refunded.unspentPoints()); assertEquals(deadline, refunded.cooldowns().get("stone_skin"));
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "stone_skin"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "stone_skin"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); use(f, a);
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
}
@@ -35,7 +35,7 @@ class WarBannerNativeTest {
@Test void bookAcquisitionSolidTopPlacementBenefitsPartyAndRewardsUsedDamage()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);var ally=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,false,false);f.players.put(caster.core.id,caster.player);f.players.put(ally.core.id,ally.player);f.activate(caster);f.activate(ally);party(f,caster,ally);placement(caster);nearby(caster,List.of(ally.player));var resistance=attribute(ally.player);resistance.modifiers().add(new AttributeModifier(new NamespacedKey("test","unrelated_knockback"),.2,AttributeModifier.Operation.ADD_NUMBER));
caster.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,caster);String text=PartyBookNativeTest.text(caster).replaceAll("\\s+","");assertTrue(text.contains("WarBanner"));assertTrue(text.contains("STR22"));assertTrue(text.contains("CHR22"));assertTrue(text.contains("CON12"));assertTrue(text.contains("25%"));assertTrue(text.contains("50%"));
PartyBookNativeTest.click(f,caster,button(caster,"[Learn]","war_banner"));drain(f,2);PartyBookNativeTest.click(f,caster,button(caster,"[3]","war_banner"));drain(f,2);use(f,caster);drain(f,2);assertEquals(200L,f.delayedTicks.getLast());assertEquals(.7,resistance.value(),1e-9);assertNotNull(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("war_banner"));
PartyBookNativeTest.click(f,caster,button(caster,"[Learn]","war_banner"));drain(f,2);use(f,caster);drain(f,2);assertEquals(200L,f.delayedTicks.getLast());assertEquals(.7,resistance.value(),1e-9);assertNotNull(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("war_banner"));
var enemy=mock(LivingEntity.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(enemy.getLocation()).thenReturn(new Location(caster.world,4,65,0));when(caster.world.getUID()).thenReturn(UUID.randomUUID());var source=DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK).withCausingEntity(ally.player).withDirectEntity(ally.player).build();var hit=new EntityDamageByEntityEvent(ally.player,enemy,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);f.dispatcher.accept(hit);assertEquals(12.5,hit.getDamage(),1e-9);f.dispatcher.accept(new EntityDeathEvent(enemy,source,new ArrayList<>(),0));drain(f,4);assertEquals(BigInteger.valueOf(5),storage(f).find(caster.core.record.id()).get().orElseThrow().experience());assertEquals(BigInteger.valueOf(5),storage(f).find(ally.core.record.id()).get().orElseThrow().experience());
f.delayed.getLast().run();assertEquals(.2,resistance.value(),1e-9,"Owned cleanup preserves unrelated knockback modifiers");var expired=new EntityDamageByEntityEvent(ally.player,enemy,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);f.dispatcher.accept(expired);assertEquals(10,expired.getDamage(),1e-9);
}
@@ -0,0 +1,21 @@
{
"model": {
"type": "minecraft:select",
"property": "minecraft:display_context",
"cases": [
{
"when": [
"firstperson_lefthand",
"firstperson_righthand"
],
"model": {
"type": "minecraft:empty"
}
}
],
"fallback": {
"type": "minecraft:model",
"model": "minecraft:item/gray_stained_glass_pane"
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"pack": {
"description": "Fantasy Stick ${version} sheet presentation",
"description": "Fantasy Stick ${version} first-person presentation",
"min_format": [88, 0],
"max_format": [88, 0]
}
@@ -86,6 +86,12 @@ class DistributionTest {
assertTrue(defaults.contains("admin-revive-selection-timeout-seconds: 60"));
assertTrue(defaults.contains("sheet-resource-pack-url: ''"));
assertTrue(defaults.contains("sheet-resource-pack-sha1: ''"));
for (String setting : java.util.List.of("out-of-combat-healing-delay-seconds: 30",
"out-of-combat-healing-interval-seconds: 10", "out-of-combat-healing-amount: 1",
"quicker-regeneration-i-bonus-healing: 1", "quicker-regeneration-ii-bonus-healing: 2",
"health-steal-healing-per-kill: 1")) {
assertTrue(defaults.contains(setting), "Missing packaged recovery default: " + setting);
}
assertTrue(JavaPlugin.class.isAssignableFrom(FantasyStickPlugin.class));
var entrypoint = jar.getJarEntry(metadata.getMain().replace('.', '/') + ".class");
assertNotNull(entrypoint);
@@ -100,7 +106,7 @@ class DistributionTest {
}
}
@Test void packagesVersionedContextSensitiveSheetResourcePackAndMatchingRuntimeMetadata() throws Exception {
@Test void packagesVersionedContextSensitiveResourcePackAndMatchingRuntimeMetadata() throws Exception {
Path artifact = Path.of(System.getProperty("distribution.jar"));
Path pack = Path.of(System.getProperty("resource.pack.zip"));
String version = System.getProperty("distribution.version");
@@ -123,6 +129,19 @@ class DistributionTest {
assertTrue(json.contains("firstperson_righthand"));
assertTrue(json.contains("minecraft:empty"));
assertTrue(json.contains("minecraft:item/paper"), "Inventory and other contexts retain Paper presentation");
var cooldownModel = zip.getEntry("assets/fantasystick/items/cooldown_control.json");
assertNotNull(cooldownModel, "The single pack includes the dedicated cooldown-control model");
String cooldownJson;
try (var input = zip.getInputStream(cooldownModel)) {
cooldownJson = new String(input.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
}
assertTrue(cooldownJson.contains("minecraft:select"));
assertTrue(cooldownJson.contains("minecraft:display_context"));
assertTrue(cooldownJson.contains("firstperson_lefthand"));
assertTrue(cooldownJson.contains("firstperson_righthand"));
assertTrue(cooldownJson.contains("minecraft:empty"));
assertTrue(cooldownJson.contains("minecraft:item/gray_stained_glass_pane"),
"Hotbar, inventory and all other contexts retain gray-pane presentation");
}
try (var jar = new JarFile(artifact.toFile())) {
var metadata = jar.getJarEntry("sheet-resource-pack.properties"); assertNotNull(metadata);
@@ -0,0 +1,29 @@
package games.dmg.fantasystick.passive;
import static org.junit.jupiter.api.Assertions.*;
import java.math.BigInteger;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.Test;
class PassiveRegistryTest {
@Test void rejectsDuplicateAndMissingDependenciesWithoutLosingRegisteredDefinition() {
assertThrows(IllegalArgumentException.class, () -> new PassiveDefinition("costly", "Costly", Map.of(), Set.of(),
BigInteger.TWO, List.of("Effect")));
var registry = new PassiveRegistry();
var original = definition("first", Set.of()); registry.register(original);
assertThrows(IllegalArgumentException.class, () -> registry.register(original));
assertThrows(IllegalArgumentException.class, () -> registry.register(definition("second", Set.of("missing"))));
assertTrue(registry.find("second").isEmpty());
assertThrows(IllegalArgumentException.class, () -> registry.replace(definition("first", Set.of("missing"))));
assertEquals(original, registry.find("first").orElseThrow());
}
private static PassiveDefinition definition(String id, Set<String> prerequisites) {
return new PassiveDefinition(id, id, Map.of(), prerequisites, BigInteger.ONE, List.of("Effect"));
}
}
@@ -169,6 +169,38 @@ class CharacterStorageTest {
}
}
@Test
void abilityUnlockFillsLowestEmptySlotAndRefusesAFullLoadoutAtomically() throws Exception {
var repository = new YamlCharacterRepository(data);
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var partlyOccupied = new CharacterRecord(base.id(), base.name(), base.revision(), base.stats(), base.level(),
base.experience(), BigInteger.TEN, base.health(), java.util.Set.of("known", "unknown_saved"),
java.util.List.of("known", "", "unknown_saved", "", "", "", ""), base.cooldowns(),
base.restrictedKills(), false, null, null);
repository.create(partlyOccupied);
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
var learned = storage.unlockAbility(partlyOccupied.id(), partlyOccupied.revision(), "new_ability",
java.util.Map.of(), BigInteger.ONE).join();
assertEquals(CharacterStorage.AbilityMutationStatus.APPLIED, learned.status());
var saved = learned.record().orElseThrow();
assertEquals("new_ability", saved.loadout().get(1), "Learning must use the lowest empty slot");
assertEquals("unknown_saved", saved.loadout().get(2), "Unknown saved assignments must not be overwritten");
assertEquals(BigInteger.valueOf(9), saved.unspentPoints());
}
var full = new CharacterRecord(UUID.randomUUID(), base.name(), BigInteger.ZERO, base.stats(), base.level(),
base.experience(), BigInteger.TEN, base.health(), java.util.Set.of("a", "b", "c", "d", "e", "f", "g"),
java.util.List.of("a", "b", "c", "d", "e", "f", "g"), base.cooldowns(), base.restrictedKills(), false, null, null);
repository.create(full);
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
var refused = storage.unlockAbility(full.id(), full.revision(), "eighth", java.util.Map.of(), BigInteger.ONE).join();
assertEquals(CharacterStorage.AbilityMutationStatus.LOADOUT_FULL, refused.status());
assertEquals(full, refused.record().orElseThrow());
assertEquals(full, repository.load(full.id()).orElseThrow());
}
}
@Test
void abilityUnlearnRefundsExactlyOnePointClearsEverySlotAndPreservesCooldown() throws Exception {
var repository = new YamlCharacterRepository(data);
@@ -0,0 +1,42 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class HealthStealSettingsTest {
@TempDir Path data;
@Test void validatesReviewedBoundsAndPersistsBeforePublication() throws Exception {
assertEquals(java.math.BigInteger.ONE, HealthStealSettings.parse("1").healing());
assertEquals(java.math.BigInteger.ZERO, HealthStealSettings.parse("0").healing());
assertEquals(java.math.BigInteger.valueOf(1024), HealthStealSettings.parse("1024").healing());
for (String invalid : java.util.List.of("-1", "1025", "1.5", "x")) {
assertThrows(IllegalArgumentException.class, () -> HealthStealSettings.parse(invalid));
}
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
try (var settings = new HealthStealSettings(data, "1")) {
var update = settings.set("4").toCompletableFuture().get(5, TimeUnit.SECONDS);
assertEquals("1", settings.value()); assertTrue(settings.publish(update)); assertEquals("4", settings.value());
var saved = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
assertEquals("retained", saved.get("unrelated")); assertEquals("4", String.valueOf(saved.get(HealthStealSettings.KEY)));
}
}
@Test void failedWriteAndCloseRetainTheEffectiveValue() throws Exception {
try (var settings = new HealthStealSettings(data, "1", (from, to) -> { throw new java.io.IOException("simulated"); })) {
assertThrows(java.util.concurrent.ExecutionException.class,
() -> settings.set("4").toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals("1", settings.value());
}
var closed = new HealthStealSettings(data.resolve("closed"), "1"); closed.close();
assertThrows(java.util.concurrent.ExecutionException.class,
() -> closed.set("4").toCompletableFuture().get(5, TimeUnit.SECONDS));
closed.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
}
}
@@ -0,0 +1,89 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class OutOfCombatRecoverySettingsTest {
@TempDir Path data;
@Test void parsesReviewedWholeNumberBoundaries() {
var defaults = defaults();
var parsed = OutOfCombatRecoverySettings.parse(defaults);
assertEquals(Duration.ofSeconds(30), parsed.delay());
assertEquals(Duration.ofSeconds(10), parsed.interval());
assertEquals(java.math.BigInteger.ONE, parsed.amount());
var boundaries = new LinkedHashMap<>(defaults);
boundaries.put(OutOfCombatRecoverySettings.DELAY, "0");
boundaries.put(OutOfCombatRecoverySettings.INTERVAL, "31536000");
boundaries.put(OutOfCombatRecoverySettings.AMOUNT, "1024");
parsed = OutOfCombatRecoverySettings.parse(boundaries);
assertEquals(Duration.ZERO, parsed.delay());
assertEquals(Duration.ofSeconds(31_536_000), parsed.interval());
assertEquals(java.math.BigInteger.valueOf(1024), parsed.amount());
for (var invalid : java.util.List.of(
Map.entry(OutOfCombatRecoverySettings.DELAY, "-1"),
Map.entry(OutOfCombatRecoverySettings.DELAY, "31536001"),
Map.entry(OutOfCombatRecoverySettings.DELAY, "1.5"),
Map.entry(OutOfCombatRecoverySettings.INTERVAL, "0"),
Map.entry(OutOfCombatRecoverySettings.INTERVAL, "31536001"),
Map.entry(OutOfCombatRecoverySettings.AMOUNT, "-1"),
Map.entry(OutOfCombatRecoverySettings.AMOUNT, "1025"),
Map.entry(OutOfCombatRecoverySettings.AMOUNT, "1.0"))) {
var values = new LinkedHashMap<>(defaults);
values.put(invalid.getKey(), invalid.getValue());
assertThrows(IllegalArgumentException.class, () -> OutOfCombatRecoverySettings.parse(values), invalid.toString());
}
}
@Test void serializesConcurrentWritesPreservesUnrelatedAndPublishesOnlyAcknowledgedSnapshots() throws Exception {
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
try (var settings = new OutOfCombatRecoverySettings(data, defaults())) {
var delay = settings.set(OutOfCombatRecoverySettings.DELAY, "45").toCompletableFuture();
var amount = settings.set(OutOfCombatRecoverySettings.AMOUNT, "3").toCompletableFuture();
var d = delay.get(5, TimeUnit.SECONDS); var a = amount.get(5, TimeUnit.SECONDS);
assertEquals("30", settings.value(OutOfCombatRecoverySettings.DELAY));
settings.publish(a); settings.publish(d);
assertEquals("45", settings.value(OutOfCombatRecoverySettings.DELAY));
assertEquals("3", settings.value(OutOfCombatRecoverySettings.AMOUNT));
var saved = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
assertEquals("retained", saved.get("unrelated"));
assertEquals("45", String.valueOf(saved.get(OutOfCombatRecoverySettings.DELAY)));
assertEquals("3", String.valueOf(saved.get(OutOfCombatRecoverySettings.AMOUNT)));
}
}
@Test void failedReplacementUnsafeFilesAndCloseRetainEffectiveValues() throws Exception {
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
try (var settings = new OutOfCombatRecoverySettings(data, defaults(), (from, to) -> {
if (fail.getAndSet(false)) throw new java.io.IOException("simulated");
Files.move(from, to, StandardCopyOption.REPLACE_EXISTING);
})) {
assertThrows(java.util.concurrent.ExecutionException.class, () ->
settings.set(OutOfCombatRecoverySettings.AMOUNT, "2").toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals("1", settings.value(OutOfCombatRecoverySettings.AMOUNT));
}
var closed = new OutOfCombatRecoverySettings(data.resolve("closed"), defaults());
closed.close();
assertThrows(java.util.concurrent.ExecutionException.class, () ->
closed.set(OutOfCombatRecoverySettings.DELAY, "1").toCompletableFuture().get(5, TimeUnit.SECONDS));
closed.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
}
private static Map<String, String> defaults() {
var values = new LinkedHashMap<String, String>();
for (String key : OutOfCombatRecoverySettings.keys()) values.put(key, OutOfCombatRecoverySettings.defaultValue(key));
return values;
}
}
@@ -0,0 +1,114 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.progression.ProgressionRules;
import games.dmg.fantasystick.progression.Stat;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class PassiveStorageTest {
@TempDir Path data;
@Test void learnsLowestSlotPersistsUnknownIdsAndRefundsExactlyOnceWithoutChangingActiveLoadout() throws Exception {
var repository = new YamlCharacterRepository(data);
var eligible = eligible(List.of("", "future_unknown", "")); repository.create(eligible);
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
var learned = storage.unlockPassive(eligible.id(), eligible.revision(), "quicker_regeneration_i",
Map.of(Stat.CON, BigInteger.valueOf(12), Stat.WIS, BigInteger.valueOf(12)), Set.of(), BigInteger.ONE).get();
assertEquals(CharacterStorage.PassiveMutationStatus.APPLIED, learned.status());
var saved = learned.record().orElseThrow();
assertEquals(List.of("quicker_regeneration_i", "future_unknown", ""), saved.passives());
assertEquals(BigInteger.valueOf(39), saved.unspentPoints());
assertEquals(eligible.loadout(), saved.loadout());
assertEquals(CharacterStorage.PassiveMutationStatus.STALE, storage.unlockPassive(eligible.id(), eligible.revision(),
"quicker_regeneration_i", Map.of(Stat.CON, BigInteger.valueOf(12)), Set.of(), BigInteger.ONE).get().status());
var refunded = storage.unlearnPassive(saved.id(), saved.revision(), "quicker_regeneration_i", Set.of()).get();
assertEquals(CharacterStorage.PassiveMutationStatus.APPLIED, refunded.status());
assertEquals(List.of("", "future_unknown", ""), refunded.record().orElseThrow().passives());
assertEquals(BigInteger.valueOf(40), refunded.record().orElseThrow().unspentPoints());
assertEquals(CharacterStorage.PassiveMutationStatus.NOT_LEARNED,
storage.unlearnPassive(saved.id(), refunded.record().orElseThrow().revision(),
"quicker_regeneration_i", Set.of()).get().status());
}
}
@Test void requirementsPointsCapacityDependenciesAndDeadStateRefuseWithoutMutation() throws Exception {
for (int route = 0; route < 5; route++) {
var directory = data.resolve("route-" + route); var repository = new YamlCharacterRepository(directory);
var base = eligible(route == 2 ? List.of("unknown_a", "unknown_b", "unknown_c") : List.of("", "", ""));
var stats = new EnumMap<>(base.stats());
var points = base.unspentPoints(); var dead = false; var death = (java.util.UUID) null; var died = (Instant) null;
Set<String> prerequisite = Set.of();
if (route == 0) stats.put(Stat.CON, BigInteger.valueOf(11));
if (route == 1) points = BigInteger.ZERO;
if (route == 3) prerequisite = Set.of("required_passive");
if (route == 4) { dead = true; death = java.util.UUID.randomUUID(); died = Instant.parse("2026-09-21T00:00:00Z"); }
var record = new CharacterRecord(base.id(), base.name(), base.revision(), stats, base.level(), base.experience(), points,
dead ? BigDecimal.ZERO : base.health(), base.learned(), base.loadout(), base.passives(), base.cooldowns(),
base.restrictedKills(), dead, death, died);
repository.create(record);
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
var result = storage.unlockPassive(record.id(), record.revision(), "quicker_regeneration_i",
Map.of(Stat.CON, BigInteger.valueOf(12), Stat.WIS, BigInteger.valueOf(12)), prerequisite, BigInteger.ONE).get();
var expected = switch (route) {
case 0, 3 -> CharacterStorage.PassiveMutationStatus.REQUIREMENTS;
case 1 -> CharacterStorage.PassiveMutationStatus.NO_POINTS;
case 2 -> CharacterStorage.PassiveMutationStatus.PASSIVE_SLOTS_FULL;
default -> CharacterStorage.PassiveMutationStatus.UNAVAILABLE;
};
assertEquals(expected, result.status());
assertEquals(record, repository.load(record.id()).orElseThrow());
}
}
}
@Test void failedSaveLeavesPointsSlotsAndRevisionUnchanged() throws Exception {
var repository = new YamlCharacterRepository(data); var record = eligible(List.of("", "", "")); repository.create(record);
try (var storage = new CharacterStorage(repository, Clock.systemUTC());
var channel = java.nio.channels.FileChannel.open(data.resolve("characters").resolve(".records.lock"),
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
var lock = channel.lock()) {
assertTrue(lock.isValid());
assertThrows(java.util.concurrent.ExecutionException.class, () -> storage.unlockPassive(record.id(), record.revision(),
"quicker_regeneration_i", Map.of(Stat.CON, BigInteger.valueOf(12), Stat.WIS, BigInteger.valueOf(12)),
Set.of(), BigInteger.ONE).get());
assertEquals(record, repository.load(record.id()).orElseThrow());
}
}
@Test void yamlLegacyTailDeathAndReviveRetainOrderedPassives() throws Exception {
var repository = new YamlCharacterRepository(data); var record = eligible(List.of("quicker_regeneration_i", "future_unknown", ""));
repository.create(record);
assertEquals(record, repository.load(record.id()).orElseThrow());
String yaml = java.nio.file.Files.readString(data.resolve("characters").resolve(record.id() + ".yml"));
assertTrue(yaml.contains("learned-passives:")); assertTrue(yaml.contains("future_unknown"));
assertEquals(List.of("", "", ""), CharacterYaml.decode(yaml.replaceFirst("(?m)^learned-passives:.*\\R", "")).passives());
var clock = Clock.fixed(Instant.parse("2026-09-21T00:00:00Z"), ZoneOffset.UTC);
try (var storage = new CharacterStorage(repository, clock)) {
var dead = storage.recordDeath(record.id(), record.revision()).get().orElseThrow();
assertEquals(record.passives(), dead.passives());
var revived = storage.restoreDeathRecordAdministratively(dead.id(), dead.deathId(), new BigDecimal("1024")).get().orElseThrow();
assertEquals(record.passives(), revived.passives());
}
}
private static CharacterRecord eligible(List<String> passives) {
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(ProgressionRules.defaults());
var stats = new EnumMap<>(base.stats()); stats.put(Stat.CON, BigInteger.valueOf(12)); stats.put(Stat.WIS, BigInteger.valueOf(12));
return new CharacterRecord(base.id(), base.name(), base.revision(), stats, base.level(), base.experience(),
BigInteger.valueOf(40), BigDecimal.valueOf(10), base.learned(), base.loadout(), passives, base.cooldowns(),
base.restrictedKills(), false, null, null);
}
}
@@ -0,0 +1,50 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.passive.QuickerRegenerationI;
import games.dmg.fantasystick.passive.QuickerRegenerationII;
import games.dmg.fantasystick.progression.ProgressionRules;
import games.dmg.fantasystick.progression.Stat;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.file.Path;
import java.time.Clock;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class QuickerRegenerationIIPassiveTest {
@TempDir Path data;
@Test void secondRankRequiresFirstRankAndRefusesAFullPassiveLoadoutBeforeSpending() throws Exception {
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(ProgressionRules.defaults());
var stats = new EnumMap<>(base.stats()); stats.put(Stat.CON, BigInteger.valueOf(16)); stats.put(Stat.WIS, BigInteger.valueOf(14));
var noFirst = record(base, stats, List.of("", "", ""));
var repository = new YamlCharacterRepository(data); repository.create(noFirst);
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
var requirements = Map.of(Stat.CON, BigInteger.valueOf(16), Stat.WIS, BigInteger.valueOf(14));
assertEquals(CharacterStorage.PassiveMutationStatus.REQUIREMENTS,
storage.unlockPassive(noFirst.id(), noFirst.revision(), QuickerRegenerationII.ID, requirements,
Set.of(QuickerRegenerationI.ID), BigInteger.ONE).get().status());
var withFirst = new CharacterRecord(noFirst.id(), noFirst.name(), noFirst.revision().add(BigInteger.ONE), stats,
noFirst.level(), noFirst.experience(), noFirst.unspentPoints(), noFirst.health(), noFirst.learned(), noFirst.loadout(),
List.of(QuickerRegenerationI.ID, "unknown_a", "unknown_b"), noFirst.cooldowns(), noFirst.restrictedKills(),
false, null, null);
repository.replace(withFirst, noFirst.revision());
var full = storage.unlockPassive(withFirst.id(), withFirst.revision(), QuickerRegenerationII.ID, requirements,
Set.of(QuickerRegenerationI.ID), BigInteger.ONE).get();
assertEquals(CharacterStorage.PassiveMutationStatus.PASSIVE_SLOTS_FULL, full.status());
assertEquals(withFirst, repository.load(withFirst.id()).orElseThrow());
}
}
private static CharacterRecord record(CharacterRecord base, EnumMap<Stat, BigInteger> stats, List<String> passives) {
return new CharacterRecord(base.id(), base.name(), base.revision(), stats, base.level(), base.experience(),
BigInteger.valueOf(40), BigDecimal.valueOf(10), base.learned(), base.loadout(), passives, base.cooldowns(),
base.restrictedKills(), false, null, null);
}
}
@@ -0,0 +1,46 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class QuickerRegenerationIISettingsTest {
@TempDir Path data;
@Test void validatesBoundsAndPersistsBeforePublicationWithoutReplacingOtherKeys() throws Exception {
assertEquals(java.math.BigInteger.TWO, QuickerRegenerationIISettings.parse("2").bonus());
assertEquals(java.math.BigInteger.ZERO, QuickerRegenerationIISettings.parse("0").bonus());
assertEquals(java.math.BigInteger.valueOf(1024), QuickerRegenerationIISettings.parse("1024").bonus());
for (String invalid : java.util.List.of("-1", "1025", "2.5", "x")) {
assertThrows(IllegalArgumentException.class, () -> QuickerRegenerationIISettings.parse(invalid));
}
Files.writeString(data.resolve("config.yml"), "quicker-regeneration-i-bonus-healing: '7'\nunrelated: retained\n");
try (var settings = new QuickerRegenerationIISettings(data, "2")) {
var update = settings.set("5").toCompletableFuture().get(5, TimeUnit.SECONDS);
assertEquals("2", settings.value()); assertTrue(settings.publish(update)); assertEquals("5", settings.value());
var saved = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
assertEquals("7", String.valueOf(saved.get("quicker-regeneration-i-bonus-healing")));
assertEquals("retained", saved.get("unrelated"));
assertEquals("5", String.valueOf(saved.get(QuickerRegenerationIISettings.KEY)));
}
}
@Test void failedWriteAndCloseRetainThePriorEffectiveValue() throws Exception {
try (var settings = new QuickerRegenerationIISettings(data, "2", (from, to) -> {
throw new java.io.IOException("simulated");
})) {
assertThrows(java.util.concurrent.ExecutionException.class,
() -> settings.set("5").toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals("2", settings.value());
}
var closed = new QuickerRegenerationIISettings(data.resolve("closed"), "2"); closed.close();
assertThrows(java.util.concurrent.ExecutionException.class,
() -> closed.set("5").toCompletableFuture().get(5, TimeUnit.SECONDS));
closed.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
}
}
@@ -0,0 +1,48 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class QuickerRegenerationSettingsTest {
@TempDir Path data;
@Test void parsesReviewedBoundsAndPersistsBeforePublication() throws Exception {
assertEquals(java.math.BigInteger.ONE, QuickerRegenerationSettings.parse("1").bonus());
assertEquals(java.math.BigInteger.ZERO, QuickerRegenerationSettings.parse("0").bonus());
assertEquals(java.math.BigInteger.valueOf(1024), QuickerRegenerationSettings.parse("1024").bonus());
for (String invalid : java.util.List.of("-1", "1025", "1.5", "x")) {
assertThrows(IllegalArgumentException.class, () -> QuickerRegenerationSettings.parse(invalid));
}
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
try (var settings = new QuickerRegenerationSettings(data, "1")) {
var update = settings.set("3").toCompletableFuture().get(5, TimeUnit.SECONDS);
assertEquals("1", settings.value());
assertTrue(settings.publish(update));
assertEquals("3", settings.value());
var saved = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
assertEquals("retained", saved.get("unrelated"));
assertEquals("3", String.valueOf(saved.get(QuickerRegenerationSettings.KEY)));
}
}
@Test void failedReplacementAndCloseRetainTheEffectiveValue() throws Exception {
try (var settings = new QuickerRegenerationSettings(data, "1", (from, to) -> {
throw new java.io.IOException("simulated");
})) {
assertThrows(java.util.concurrent.ExecutionException.class,
() -> settings.set("2").toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals("1", settings.value());
}
var settings = new QuickerRegenerationSettings(data.resolve("closed"), "1"); settings.close();
assertThrows(java.util.concurrent.ExecutionException.class,
() -> settings.set("2").toCompletableFuture().get(5, TimeUnit.SECONDS));
settings.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
}
}