Compare commits

...
18 Commits
Author SHA1 Message Date
dmg e054a78762 feat(ability): add Shield Bash
Release / release (push) Successful in 7m4s
2026-09-20 12:19:19 -04:00
dmg 63da22e37d feat(ability): add Stone Skin
Release / release (push) Successful in 7m23s
2026-09-20 12:01:16 -04:00
dmg b2e8da536a feat(ability): add Second Wind
Release / release (push) Successful in 6m54s
2026-09-20 11:41:23 -04:00
dmg c77246321c feat(ability): add Quickstep
Release / release (push) Successful in 6m52s
2026-09-20 11:27:51 -04:00
dmg 438d6acfa4 feat(ability): add Longbow
Release / release (push) Successful in 6m48s
2026-09-20 11:11:10 -04:00
dmg e74f51cc58 feat(ability): add Frost Shard
Release / release (push) Successful in 6m53s
2026-09-20 10:47:57 -04:00
dmg 1361ba3fb2 feat(abilities): add Great Sword
Release / release (push) Successful in 6m16s
2026-09-20 06:52:12 -04:00
dmg 27f38818ae feat(abilities): add Revive
Release / release (push) Successful in 6m27s
2026-09-20 06:19:56 -04:00
dmg 624deb938d feat(abilities): add ability refunds
Release / release (push) Successful in 6m36s
2026-09-20 05:50:08 -04:00
dmg 232b2a190d fix(config): expose character switch default
Release / release (push) Successful in 6m34s
2026-09-20 05:39:26 -04:00
dmg 74e717c6ca feat(abilities): add Fireball
Release / release (push) Successful in 6m11s
2026-09-19 17:52:37 -04:00
dmg 644b343fc9 feat(abilities): add extensible engine
Release / release (push) Successful in 6m8s
2026-09-19 17:29:11 -04:00
dmg ba69a55124 feat(progression): add shared combat experience
Release / release (push) Successful in 6m53s
2026-09-19 17:00:10 -04:00
dmg db35c28f0b feat(nutrition): isolate fantasy hunger and healing
Release / release (push) Successful in 5m54s
2026-09-19 15:26:02 -04:00
dmg 636ff9f076 feat(combat): add built-in basic attack
Release / release (push) Successful in 5m48s
2026-09-19 15:17:53 -04:00
dmg 7158817726 feat(progression): add permanent stat purchases
Release / release (push) Successful in 5m55s
2026-09-19 15:08:54 -04:00
dmg 8ee0d98599 ci(fantasy-stick): avoid duplicate release builds
Release / release (push) Successful in 3m31s
2026-09-19 14:51:20 -04:00
dmg 539900eb14 fix(tests): synchronize health saves before reentry and transfer
Release / release (push) Successful in 8m26s
CI / build (push) Successful in 4m19s
2026-09-19 14:32:35 -04:00
72 changed files with 10645 additions and 63 deletions

No files matched your search

+2
View File
@@ -2,6 +2,8 @@ name: CI
on:
push:
branches-ignore: [main]
tags-ignore: ['**']
pull_request:
jobs:
@@ -21,7 +21,7 @@ import java.time.Clock;
import java.util.logging.Level;
import org.bukkit.plugin.java.JavaPlugin;
/** Crafting, native fantasy sessions, books, parties and names; progression actions and spells remain separate. */
/** Crafting, native fantasy sessions, books, parties, names and permanent-stat progression. */
public final class FantasyStickPlugin extends JavaPlugin {
private CharacterStorage characters;
private ManualCraftingController crafting;
@@ -44,6 +44,28 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.session.AdventureModeLease gameModes;
private games.dmg.fantasystick.session.SessionVitals vitals;
private games.dmg.fantasystick.session.SessionVisuals sessionVisuals;
private games.dmg.fantasystick.combat.BasicAttack basicAttack;
private games.dmg.fantasystick.party.CombatExperience combatExperience;
private games.dmg.fantasystick.ability.AbilityRegistry abilityRegistry;
private games.dmg.fantasystick.ability.AbilityEngine abilityEngine;
private games.dmg.fantasystick.ability.Fireball fireball;
private games.dmg.fantasystick.ability.FrostShard frostShard;
private games.dmg.fantasystick.persistence.FrostShardSettings frostShardSettings;
private games.dmg.fantasystick.ability.Longbow longbow;
private games.dmg.fantasystick.persistence.LongbowSettings longbowSettings;
private games.dmg.fantasystick.ability.Quickstep quickstep;
private games.dmg.fantasystick.persistence.QuickstepSettings quickstepSettings;
private games.dmg.fantasystick.ability.SecondWind secondWind;
private games.dmg.fantasystick.persistence.SecondWindSettings secondWindSettings;
private games.dmg.fantasystick.ability.CombatMitigation combatMitigation;
private games.dmg.fantasystick.ability.StoneSkin stoneSkin;
private games.dmg.fantasystick.ability.ShieldBash shieldBash;
private games.dmg.fantasystick.persistence.ShieldBashSettings shieldBashSettings;
private games.dmg.fantasystick.persistence.StoneSkinSettings stoneSkinSettings;
private games.dmg.fantasystick.ability.GreatSword greatSword;
private games.dmg.fantasystick.ability.Revive revive;
private games.dmg.fantasystick.persistence.RevivalStore revivals;
private games.dmg.fantasystick.session.NutritionPolicy nutritionPolicy;
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
@Override public void onEnable() {
@@ -67,9 +89,22 @@ public final class FantasyStickPlugin extends JavaPlugin {
var adventure = games.dmg.fantasystick.persistence.AdventureSettings.validate(getConfig().get("adventure-mode"));
adventureSettings = new games.dmg.fantasystick.persistence.AdventureSettings(getDataFolder().toPath(), adventure);
var switchDelay = switchDelay();
var defaults = ProgressionRules.defaults();
var rules = new ProgressionRules(new java.math.BigInteger(getConfig().getString("starting-points", "40")),
defaults.xpPerLevel(), defaults.maximumLevel());
var rules = progressionRules();
var rewardRules = combatRewardRules();
var fireballSettings = fireballSettings();
var greatSwordSettings = greatSwordSettings();
var frostShardConfiguration = frostShardConfiguration();
var frostShardInitial = games.dmg.fantasystick.persistence.FrostShardSettings.parse(frostShardConfiguration);
var longbowConfiguration = longbowConfiguration();
var longbowInitial = games.dmg.fantasystick.persistence.LongbowSettings.parse(longbowConfiguration);
var quickstepConfiguration = quickstepConfiguration();
var quickstepInitial = games.dmg.fantasystick.persistence.QuickstepSettings.parse(quickstepConfiguration);
var secondWindConfiguration = secondWindConfiguration();
var secondWindInitial = games.dmg.fantasystick.persistence.SecondWindSettings.parse(secondWindConfiguration);
var stoneSkinConfiguration = stoneSkinConfiguration();
var stoneSkinInitial = games.dmg.fantasystick.persistence.StoneSkinSettings.parse(stoneSkinConfiguration);
var shieldBashConfiguration = shieldBashConfiguration();
var shieldBashInitial = games.dmg.fantasystick.persistence.ShieldBashSettings.parse(shieldBashConfiguration);
var factory = new CharacterFactory();
characters = new CharacterStorage(new YamlCharacterRepository(getDataFolder().toPath()), Clock.systemUTC());
crafting = new ManualCraftingController(characters, () -> factory.create(rules),
@@ -108,25 +143,89 @@ public final class FantasyStickPlugin extends JavaPlugin {
gameModes = new games.dmg.fantasystick.session.AdventureModeLease();
sessions = new EquipmentSessions(characters, receipts, switchDelay, Clock.systemUTC(), nextTick,
notice -> notifyPlayer(generation, notice), diagnostics, presentation, adventureSettings::enabled, gameModes);
abilityRegistry = new games.dmg.fantasystick.ability.AbilityRegistry();
fireball = new games.dmg.fantasystick.ability.Fireball(fireballSettings);
frostShardSettings = new games.dmg.fantasystick.persistence.FrostShardSettings(
getDataFolder().toPath(), frostShardConfiguration);
frostShard = new games.dmg.fantasystick.ability.FrostShard(frostShardInitial);
longbowSettings = new games.dmg.fantasystick.persistence.LongbowSettings(
getDataFolder().toPath(), longbowConfiguration);
longbow = new games.dmg.fantasystick.ability.Longbow(longbowInitial);
quickstepSettings = new games.dmg.fantasystick.persistence.QuickstepSettings(
getDataFolder().toPath(), quickstepConfiguration);
quickstep = new games.dmg.fantasystick.ability.Quickstep(quickstepInitial,
event -> getServer().getPluginManager().callEvent(event));
secondWindSettings = new games.dmg.fantasystick.persistence.SecondWindSettings(
getDataFolder().toPath(), secondWindConfiguration);
secondWind = new games.dmg.fantasystick.ability.SecondWind(secondWindInitial, characters, sessions, nextTick,
event -> getServer().getPluginManager().callEvent(event), games.dmg.fantasystick.session.NativeHealthLimit::read);
stoneSkinSettings = new games.dmg.fantasystick.persistence.StoneSkinSettings(
getDataFolder().toPath(), stoneSkinConfiguration);
combatMitigation = new games.dmg.fantasystick.ability.CombatMitigation();
stoneSkin = new games.dmg.fantasystick.ability.StoneSkin(stoneSkinInitial, combatMitigation);
shieldBashSettings = new games.dmg.fantasystick.persistence.ShieldBashSettings(
getDataFolder().toPath(), shieldBashConfiguration);
shieldBash = new games.dmg.fantasystick.ability.ShieldBash(
shieldBashInitial,
event -> getServer().getPluginManager().callEvent(event));
greatSword = new games.dmg.fantasystick.ability.GreatSword(greatSwordSettings);
revivals = new games.dmg.fantasystick.persistence.RevivalStore(getDataFolder().toPath());
revive = new games.dmg.fantasystick.ability.Revive(characters, revivals, Clock.systemUTC(), nextTick,
(size, title) -> getServer().createInventory(null, size, title),
problem -> getLogger().log(Level.WARNING, "Revive transition failed", problem));
abilityRegistry.register(fireball.definition());
abilityRegistry.register(frostShard.definition());
abilityRegistry.register(longbow.definition());
abilityRegistry.register(quickstep.definition());
abilityRegistry.register(secondWind.definition());
abilityRegistry.register(stoneSkin.definition());
abilityRegistry.register(shieldBash.definition());
abilityRegistry.register(greatSword.definition());
abilityRegistry.register(revive.definition());
sessions.attachAbilityRegistry(abilityRegistry);
vitals = new games.dmg.fantasystick.session.SessionVitals();
sessions.attachVitals(vitals);
nutritionPolicy = new games.dmg.fantasystick.session.NutritionPolicy(sessions);
parties = new games.dmg.fantasystick.party.PartyRoster(Clock.systemUTC(), id -> {
var player = getServer().getPlayer(id);
return player != null && player.isOnline();
});
partyEvents = new games.dmg.fantasystick.party.PartyEvents(parties);
partyCombat = new games.dmg.fantasystick.party.PartyCombat(parties, sessions);
books = new games.dmg.fantasystick.book.CharacterBookController(characters, rules, nextTick,
combatExperience = new games.dmg.fantasystick.party.CombatExperience(characters, rules, rewardRules,
sessions, Clock.systemUTC(), nextTick, id -> getServer().getPlayer(id),
problem -> getLogger().log(Level.WARNING, "Combat reward failed for character " + problem.character(), problem.cause()));
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);
return task::cancel;
};
abilityEngine = new games.dmg.fantasystick.ability.AbilityEngine(abilityRegistry, characters, sessions, parties,
Clock.systemUTC(), nextTick, abilityScheduler, report -> { },
problem -> getLogger().log(Level.WARNING, "Ability engine failed for character " + problem.character()
+ "; ability " + problem.ability(), problem.cause()));
abilityEngine.install(this);
books = new games.dmg.fantasystick.book.CharacterBookController(characters, rules, sessions, nextTick,
problem -> getLogger().log(Level.WARNING, "Character book failed for player " + problem.player()
+ "; character " + problem.character(), problem.cause()), parties, getServer(), id -> {
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());
});
sessionEvents = new EquipmentSessionEvents(sessions, nextTick, diagnostics, books);
}, abilityRegistry, abilityEngine);
var localBasicAttack = new games.dmg.fantasystick.combat.BasicAttack(sessions);
basicAttack = localBasicAttack;
sessionEvents = new EquipmentSessionEvents(sessions, nextTick, diagnostics, input -> {
books.accept(input);
localBasicAttack.accept(input);
abilityEngine.accept(input);
});
sessionEvents.attachAbilityRegistry(abilityRegistry);
admin = new EquipmentAdminCommand(receipts, sessions, getServer(), nextTick,
problem -> getLogger().log(Level.WARNING, "Equipment admin " + problem.operation()
+ " for player " + problem.player(), problem.cause()), adventureSettings);
+ " for player " + problem.player(), problem.cause()), adventureSettings,
frostShardSettings, frostShard, longbowSettings, longbow,
quickstepSettings, quickstep, secondWindSettings, secondWind,
stoneSkinSettings, stoneSkin, shieldBashSettings, shieldBash, abilityRegistry);
var command = java.util.Objects.requireNonNull(getCommand("fantasystick"), "Missing packaged fantasystick command");
command.setExecutor(admin);
command.setTabCompleter(admin);
@@ -138,6 +237,18 @@ public final class FantasyStickPlugin extends JavaPlugin {
getServer().getPluginManager().registerEvents(partyEvents, this);
getServer().getPluginManager().registerEvents(partyCombat, this);
partyCombat.install(this);
getServer().getPluginManager().registerEvents(combatExperience, this);
combatExperience.install(this);
getServer().getPluginManager().registerEvents(fireball, this);
getServer().getPluginManager().registerEvents(frostShard, this);
getServer().getPluginManager().registerEvents(longbow, this);
getServer().getPluginManager().registerEvents(quickstep, this);
getServer().getPluginManager().registerEvents(combatMitigation, this);
getServer().getPluginManager().registerEvents(shieldBash, this);
getServer().getPluginManager().registerEvents(greatSword, this);
getServer().getPluginManager().registerEvents(revive, this);
getServer().getPluginManager().registerEvents(localBasicAttack, this);
getServer().getPluginManager().registerEvents(nutritionPolicy, this);
getServer().getPluginManager().registerEvents(books, this);
getServer().getPluginManager().registerEvents(gameModes, this);
sessionVisuals = new games.dmg.fantasystick.session.SessionVisuals(sessions,
@@ -145,7 +256,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
sessionVisuals.install(this);
recipeRegistered = getServer().addRecipe(new CharacterSheets().recipe());
if (!recipeRegistered) throw new IllegalStateException("Character-sheet recipe key is already registered");
for (var player : getServer().getOnlinePlayers()) sessions.arrive(player);
for (var player : getServer().getOnlinePlayers()) { sessions.arrive(player); revive.reconcile(player); }
} catch (RuntimeException | LinkageError failure) {
try { onDisable(); }
catch (RuntimeException | LinkageError cleanupFailure) { failure.addSuppressed(cleanupFailure); }
@@ -175,15 +286,68 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingModes = gameModes; gameModes = null;
var closingVitals = vitals; vitals = null;
var closingVisuals = sessionVisuals; sessionVisuals = null;
var closingBasicAttack = basicAttack; basicAttack = null;
var closingCombatExperience = combatExperience; combatExperience = null;
var closingAbilityEngine = abilityEngine; abilityEngine = null;
var closingFireball = fireball; fireball = null;
var closingFrostShard = frostShard; frostShard = null;
var closingFrostSettings = frostShardSettings; frostShardSettings = null;
var closingLongbow = longbow; longbow = null;
var closingLongbowSettings = longbowSettings; longbowSettings = null;
var closingQuickstep = quickstep; quickstep = null;
var closingQuickstepSettings = quickstepSettings; quickstepSettings = null;
secondWind = null;
var closingStoneSkin = stoneSkin; stoneSkin = null;
var closingShieldBash = shieldBash; shieldBash = null;
var closingShieldBashSettings = shieldBashSettings; shieldBashSettings = null;
var closingCombatMitigation = combatMitigation; combatMitigation = null;
var closingStoneSkinSettings = stoneSkinSettings; stoneSkinSettings = null;
var closingSecondWindSettings = secondWindSettings; secondWindSettings = null;
var closingGreatSword = greatSword; greatSword = null;
var closingRevive = revive; revive = null;
var closingRevivals = revivals; revivals = null;
abilityRegistry = null;
var closingNutrition = nutritionPolicy; nutritionPolicy = 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);
if (closingAdmin != null) cleanup("admin command", closingAdmin::close);
if (closingSettings != null) {
configurationDrain = closingSettings.drained();
cleanup("configuration", closingSettings::close);
}
if (closingSettings != null) cleanup("configuration", closingSettings::close);
if (closingFrostSettings != null) cleanup("Frost Shard configuration", closingFrostSettings::close);
if (closingLongbowSettings != null) cleanup("Longbow configuration", closingLongbowSettings::close);
if (closingQuickstepSettings != null) cleanup("Quickstep configuration", closingQuickstepSettings::close);
if (closingSecondWindSettings != null) cleanup("Second Wind configuration", closingSecondWindSettings::close);
if (closingStoneSkinSettings != null) cleanup("Stone Skin configuration", closingStoneSkinSettings::close);
if (closingShieldBashSettings != null) cleanup("Shield Bash configuration", closingShieldBashSettings::close);
configurationDrain = java.util.concurrent.CompletableFuture.allOf(
closingSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingSettings.drained().toCompletableFuture(),
closingFrostSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingFrostSettings.drained().toCompletableFuture(),
closingLongbowSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingLongbowSettings.drained().toCompletableFuture(),
closingQuickstepSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingQuickstepSettings.drained().toCompletableFuture(),
closingSecondWindSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingSecondWindSettings.drained().toCompletableFuture(),
closingStoneSkinSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingStoneSkinSettings.drained().toCompletableFuture(),
closingShieldBashSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingShieldBashSettings.drained().toCompletableFuture());
if (closingBooks != null) cleanup("character book", closingBooks::close);
if (closingBasicAttack != null) cleanup("basic attack", closingBasicAttack::close);
if (closingCombatExperience != null) cleanup("combat experience", closingCombatExperience::close);
if (closingFireball != null) cleanup("Fireball", closingFireball::close);
if (closingFrostShard != null) cleanup("Frost Shard", closingFrostShard::close);
if (closingLongbow != null) cleanup("Longbow", closingLongbow::close);
if (closingQuickstep != null) cleanup("Quickstep", closingQuickstep::close);
if (closingStoneSkin != null) cleanup("Stone Skin", closingStoneSkin::close);
if (closingShieldBash != null) cleanup("Shield Bash", closingShieldBash::close);
if (closingCombatMitigation != null) cleanup("combat mitigation", closingCombatMitigation::close);
if (closingGreatSword != null) cleanup("Great Sword", closingGreatSword::close);
if (closingRevive != null) cleanup("Revive", closingRevive::close);
if (closingAbilityEngine != null) cleanup("ability engine", closingAbilityEngine::close);
if (closingNutrition != null) cleanup("nutrition policy", closingNutrition::close);
if (closingVisuals != null) cleanup("session particles", closingVisuals::close);
if (closingParties != null) cleanup("parties", closingParties::close);
if (closingPartyCombat != null) cleanup("party combat", closingPartyCombat::close);
@@ -195,6 +359,17 @@ public final class FantasyStickPlugin extends JavaPlugin {
unregister(closingChat);
unregister(closingPartyEvents);
unregister(closingPartyCombat);
unregister(closingCombatExperience);
unregister(closingFireball);
unregister(closingFrostShard);
unregister(closingLongbow);
unregister(closingQuickstep);
unregister(closingCombatMitigation);
unregister(closingShieldBash);
unregister(closingGreatSword);
unregister(closingRevive);
unregister(closingBasicAttack);
unregister(closingNutrition);
unregister(closingBooks);
unregister(closingEvents);
unregister(closingCrafting);
@@ -205,6 +380,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
recipeRegistered = false; // Retain ownership for a later retry if removal failed.
});
if (closingCharacters != null) cleanup("character storage", closingCharacters::close);
if (closingRevivals != null) cleanup("revival storage", closingRevivals::close);
// Never await IO on the server thread or close receipts ahead of accepted preparations.
if (closingReceipts != null) cleanup("equipment receipt drain", () -> {
if (closingSessions == null) closingReceipts.close();
@@ -225,6 +401,114 @@ public final class FantasyStickPlugin extends JavaPlugin {
}
}
private ProgressionRules progressionRules() {
try {
var starting = new java.math.BigInteger(getConfig().getString("starting-points", "40"));
var threshold = new java.math.BigInteger(getConfig().getString("xp-per-level", "100"));
var maximum = new java.math.BigInteger(getConfig().getString("maximum-level", "30")).intValueExact();
return new ProgressionRules(starting, threshold, maximum);
} catch (ArithmeticException invalid) {
throw new IllegalArgumentException("maximum-level exceeds the supported integer range", invalid);
}
}
private games.dmg.fantasystick.progression.CombatRewardRules combatRewardRules() {
try {
var passive = new java.math.BigInteger(getConfig().getString("passive-xp", "1"));
var ordinary = new java.math.BigInteger(getConfig().getString("ordinary-xp", "10"));
var boss = new java.math.BigInteger(getConfig().getString("boss-xp", "100"));
var spacing = new java.math.BigDecimal(getConfig().getString("restricted-kill-spacing-blocks", "3"));
return new games.dmg.fantasystick.progression.CombatRewardRules(passive, ordinary, boss, spacing);
} catch (NumberFormatException invalid) {
throw new IllegalArgumentException("Combat reward settings must be decimal numbers", invalid);
}
}
private games.dmg.fantasystick.ability.Fireball.Settings fireballSettings() {
try {
var base = new java.math.BigInteger(getConfig().getString("fireball-base-damage", "6"));
var cooldown = java.time.Duration.ofSeconds(new java.math.BigInteger(
getConfig().getString("fireball-cooldown-seconds", "4")).longValueExact());
double range = new java.math.BigDecimal(getConfig().getString("fireball-range-blocks", "32")).doubleValue();
var lifetime = java.time.Duration.ofSeconds(new java.math.BigInteger(
getConfig().getString("fireball-lifetime-seconds", "5")).longValueExact());
return new games.dmg.fantasystick.ability.Fireball.Settings(base, cooldown, range, lifetime);
} catch (ArithmeticException | NumberFormatException invalid) {
throw new IllegalArgumentException("Fireball settings must use supported nonnegative numeric values", invalid);
}
}
private java.util.Map<String, String> frostShardConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.FrostShardSettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.FrostShardSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.FrostShardSettings.parse(values);
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> longbowConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.LongbowSettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.LongbowSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.LongbowSettings.parse(values);
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> quickstepConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.QuickstepSettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.QuickstepSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.QuickstepSettings.parse(values);
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> secondWindConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.SecondWindSettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.SecondWindSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.SecondWindSettings.parse(values);
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> stoneSkinConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.StoneSkinSettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.StoneSkinSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.StoneSkinSettings.parse(values);
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> shieldBashConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.ShieldBashSettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.ShieldBashSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.ShieldBashSettings.parse(values);
return java.util.Map.copyOf(values);
}
private games.dmg.fantasystick.ability.GreatSword.Settings greatSwordSettings() {
try {
var base = new java.math.BigInteger(getConfig().getString("great-sword-base-damage", "7"));
var seconds = new java.math.BigDecimal(getConfig().getString("great-sword-recovery-seconds", "1.2"));
var recovery = java.time.Duration.ofNanos(seconds.movePointRight(9).toBigIntegerExact().longValueExact());
return new games.dmg.fantasystick.ability.GreatSword.Settings(base, recovery);
} catch (ArithmeticException | NumberFormatException invalid) {
throw new IllegalArgumentException("Great Sword settings must use supported nonnegative numeric values", invalid);
}
}
private java.time.Duration switchDelay() {
Object configured = getConfig().get("character-swap-cooldown-seconds", 900);
String value = String.valueOf(configured);
@@ -0,0 +1,40 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.regex.Pattern;
import org.bukkit.Material;
/** Immutable internal ability metadata and focused effect boundary; not an external add-on API. */
public record AbilityDefinition(String id, String name, Material icon, List<String> lore, String instructions,
Map<Stat, BigInteger> requirements, BigInteger pointCost, Activation activation,
ControlInput.Gesture gesture, Duration cooldown, Map<String, BigDecimal> parameters,
TargetPolicy targetPolicy, AbilityEffect effect) {
public enum Activation { IMMEDIATE, DELAYED, PROJECTILE, AREA, SELECTION }
public enum TargetPolicy { SELF, HOSTILE, PROJECTILE_HOSTILE, ALLY, PARTY, AREA, SELECTION, EXCEPTIONAL_NONCOMBAT,
EXCEPTIONAL_NONCOMBAT_SELECTION }
private static final Pattern ID = Pattern.compile("[a-z][a-z0-9_]{0,63}");
private static final Pattern PARAMETER = Pattern.compile("[a-z][a-z0-9_-]{0,63}");
public AbilityDefinition {
Objects.requireNonNull(id); Objects.requireNonNull(name); Objects.requireNonNull(icon);
Objects.requireNonNull(lore); Objects.requireNonNull(instructions); Objects.requireNonNull(requirements);
Objects.requireNonNull(pointCost); Objects.requireNonNull(activation); Objects.requireNonNull(gesture);
Objects.requireNonNull(cooldown); Objects.requireNonNull(parameters); Objects.requireNonNull(targetPolicy);
Objects.requireNonNull(effect);
lore = List.copyOf(lore); requirements = Map.copyOf(requirements); parameters = Map.copyOf(parameters);
if (!ID.matcher(id).matches() || name.isBlank() || icon.isAir() || lore.isEmpty()
|| lore.stream().anyMatch(String::isBlank) || instructions.isBlank() || pointCost.signum() <= 0
|| cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0 || requirements.values().stream().anyMatch(value -> value.compareTo(BigInteger.TEN) < 0)
|| parameters.entrySet().stream().anyMatch(entry -> !PARAMETER.matcher(entry.getKey()).matches()
|| entry.getValue().signum() < 0 || !Double.isFinite(entry.getValue().doubleValue()))) {
throw new IllegalArgumentException("Invalid ability definition");
}
}
}
@@ -0,0 +1,9 @@
package games.dmg.fantasystick.ability;
/** Focused native effect invoked only after shared persistence, cooldown and target authorization. */
@FunctionalInterface
public interface AbilityEffect {
/** Effect-specific initial eligibility checked before the shared cooldown commit. */
default boolean accepts(games.dmg.fantasystick.session.ControlInput input) { return true; }
void activate(AbilityExecution execution);
}
@@ -0,0 +1,353 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.party.PartyRoster;
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.time.Clock;
import java.time.DateTimeException;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
import org.bukkit.entity.Entity;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
/** Shared character mutation, cooldown, target, execution, report and cleanup boundary. */
public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseable {
public record Failure(UUID character, String ability, Throwable cause) { }
public record Mutation(CharacterStorage.AbilityMutationStatus status, CharacterRecord record) { }
private record Key(UUID character, String ability) { }
private final AbilityRegistry registry;
private final CharacterStorage characters;
private final EquipmentSessions sessions;
private final PartyRoster parties;
private final Clock clock;
private final Consumer<Runnable> nextTick;
private final AbilityScheduler scheduler;
private final Consumer<AbilityReport> reports;
private final Consumer<Failure> failures;
private final Map<Key, Object> pending = new HashMap<>();
private final Map<Key, Long> generations = new HashMap<>();
private final Map<Key, Set<Runtime>> active = new HashMap<>();
private org.bukkit.scheduler.BukkitTask sweepTask;
private boolean closed;
public AbilityEngine(AbilityRegistry registry, CharacterStorage characters, EquipmentSessions sessions,
PartyRoster parties, Clock clock, Consumer<Runnable> nextTick, AbilityScheduler scheduler,
Consumer<AbilityReport> reports, Consumer<Failure> failures) {
this.registry = Objects.requireNonNull(registry); this.characters = Objects.requireNonNull(characters);
this.sessions = Objects.requireNonNull(sessions); this.parties = Objects.requireNonNull(parties);
this.clock = Objects.requireNonNull(clock); this.nextTick = Objects.requireNonNull(nextTick);
this.scheduler = Objects.requireNonNull(scheduler); this.reports = Objects.requireNonNull(reports);
this.failures = Objects.requireNonNull(failures);
}
public void install(org.bukkit.plugin.Plugin plugin) {
if (closed || sweepTask != null) throw new IllegalStateException("Ability engine is already installed or closed");
sweepTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::sweep, 1, 1);
}
public CompletableFuture<Mutation> purchase(ControlInput input, String ability, BigInteger expectedRevision) {
Objects.requireNonNull(input); Objects.requireNonNull(expectedRevision);
var definition = registry.find(ability).orElse(null);
if (closed || definition == null || input.kind() != ControlInput.Kind.BOOK || !input.stillCurrent()) {
return CompletableFuture.completedFuture(new Mutation(CharacterStorage.AbilityMutationStatus.UNAVAILABLE, null));
}
return mutation(input, ability, expectedRevision,
() -> characters.unlockAbility(input.session().character(), expectedRevision, ability,
definition.requirements(), definition.pointCost()));
}
public CompletableFuture<Mutation> assign(ControlInput input, String ability, int slot, BigInteger expectedRevision) {
Objects.requireNonNull(input); Objects.requireNonNull(expectedRevision);
if (closed || registry.find(ability).isEmpty() || input.kind() != ControlInput.Kind.BOOK || !input.stillCurrent()) {
return CompletableFuture.completedFuture(new Mutation(CharacterStorage.AbilityMutationStatus.UNAVAILABLE, null));
}
return mutation(input, ability, expectedRevision,
() -> characters.assignAbility(input.session().character(), expectedRevision, ability, slot));
}
public CompletableFuture<Mutation> unlearn(ControlInput input, String ability, BigInteger expectedRevision) {
Objects.requireNonNull(input); Objects.requireNonNull(expectedRevision);
if (closed || registry.find(ability).isEmpty() || input.kind() != ControlInput.Kind.BOOK || !input.stillCurrent()) {
return CompletableFuture.completedFuture(new Mutation(CharacterStorage.AbilityMutationStatus.UNAVAILABLE, null));
}
return mutation(input, ability, expectedRevision,
() -> characters.unlearnAbility(input.session().character(), expectedRevision, ability))
.thenApply(mutation -> {
if (mutation.record() != null && !mutation.record().learned().contains(ability)) {
remove(input.session().character(), ability);
}
return mutation;
});
}
private CompletableFuture<Mutation> mutation(ControlInput input, String ability, BigInteger expectedRevision,
java.util.function.Supplier<CompletableFuture<CharacterStorage.AbilityMutation>> operation) {
var result = new CompletableFuture<Mutation>();
CompletableFuture<CharacterStorage.AbilityMutation> work;
try { work = operation.get(); }
catch (RuntimeException failure) { report(input.session().character(), ability, failure); result.completeExceptionally(failure); return result; }
work.whenComplete((mutation, failure) -> schedule(input.session().character(), ability, result, () -> {
if (failure != null) { report(input.session().character(), ability, failure); result.completeExceptionally(failure); return; }
var record = mutation.record().orElse(null);
if (mutation.status() == CharacterStorage.AbilityMutationStatus.APPLIED) {
try {
if (record == null || !input.stillCurrent()
|| !sessions.acknowledgeProgress(input.session(), expectedRevision, record)) {
result.complete(new Mutation(CharacterStorage.AbilityMutationStatus.STALE, record)); return;
}
} catch (RuntimeException nativeFailure) {
report(input.session().character(), ability, nativeFailure);
result.completeExceptionally(nativeFailure); return;
}
}
result.complete(new Mutation(mutation.status(), record));
}));
return result;
}
@Override public void accept(ControlInput input) {
if (closed || input.kind() != ControlInput.Kind.ABILITY || !input.stillCurrent()) return;
var definition = registry.find(input.ability()).orElse(null);
if (definition == null || definition.gesture() != input.gesture()) {
input.player().sendMessage("That ability is unavailable."); return;
}
var record = sessions.currentRecord(input.session()).orElse(null);
if (record == null || !record.learned().contains(definition.id())
|| input.slot() < 2 || !record.loadout().get(input.slot() - 2).equals(definition.id())) {
input.player().sendMessage("That ability is not learned and assigned to this control."); return;
}
Instant now = clock.instant();
Instant existing = record.cooldowns().get(definition.id());
if (existing != null && now.isBefore(existing)) {
input.player().sendMessage("" + definition.name() + " is ready in "
+ Math.max(1, Duration.between(now, existing).toSeconds()) + " seconds."); return;
}
boolean accepted;
try { accepted = eligible(definition.targetPolicy(), input, input.target()) && definition.effect().accepts(input); }
catch (RuntimeException invalid) {
report(input.session().character(), definition.id(), invalid);
input.player().sendMessage("That target could not be validated for " + definition.name() + "."); return;
}
if (!accepted) {
input.player().sendMessage("That target is not eligible for " + definition.name() + "."); return;
}
var key = new Key(input.session().character(), definition.id());
Object token = new Object();
if (pending.putIfAbsent(key, token) != null) { input.player().sendMessage("That cast is still being saved."); return; }
long generation = generations.getOrDefault(key, 0L);
Instant readyAt;
try { readyAt = now.plus(definition.cooldown()); }
catch (DateTimeException | ArithmeticException invalid) {
pending.remove(key, token); report(key.character(), key.ability(), invalid);
input.player().sendMessage("That ability's cooldown is invalid."); return;
}
characters.commitAbilityCooldown(key.character(), record.revision(), key.ability(), readyAt)
.whenComplete((mutation, failure) -> {
try {
nextTick.accept(() -> completeCast(input, definition, record.revision(), readyAt,
key, token, generation, mutation, failure));
} catch (RuntimeException rejected) {
pending.remove(key, token); report(key.character(), key.ability(), rejected);
}
});
}
private void completeCast(ControlInput input, AbilityDefinition definition, BigInteger expectedRevision, Instant readyAt,
Key key, Object token, long generation, CharacterStorage.AbilityMutation mutation, Throwable failure) {
if (!pending.remove(key, token)) return;
if (closed) return;
if (failure != null) {
report(key.character(), key.ability(), failure);
input.player().sendMessage("The cast could not be saved; no effect was applied."); return;
}
boolean unchangedDeadline = mutation.status() == CharacterStorage.AbilityMutationStatus.UNCHANGED
&& mutation.record().map(record -> readyAt.equals(record.cooldowns().get(key.ability()))).orElse(false);
if ((mutation.status() != CharacterStorage.AbilityMutationStatus.APPLIED && !unchangedDeadline)
|| mutation.record().isEmpty()) {
input.player().sendMessage(switch (mutation.status()) {
case COOLDOWN -> "That ability is still on cooldown.";
case STALE -> "That character changed; retry the cast.";
case NOT_LEARNED, UNAVAILABLE -> "That ability is no longer available to this character.";
default -> "The cast was refused without applying an effect.";
});
return;
}
CharacterRecord saved = mutation.record().orElseThrow();
boolean synchronizedProgress = unchangedDeadline
? sessions.currentRecord(input.session()).filter(saved::equals).isPresent()
: sessions.acknowledgeProgress(input.session(), expectedRevision, saved);
if (!synchronizedProgress) {
input.player().sendMessage("The cooldown was saved, but the session changed; no effect was applied."); return;
}
if (!input.stillCurrent() || generations.getOrDefault(key, 0L) != generation
|| !eligible(definition.targetPolicy(), input, input.target())) {
input.player().sendMessage("The cooldown was saved, but eligibility changed; no effect was applied."); return;
}
var runtime = new Runtime(key, input, definition, generation);
active.computeIfAbsent(key, ignored -> new HashSet<>()).add(runtime);
try {
definition.effect().activate(runtime);
if (definition.activation() == AbilityDefinition.Activation.IMMEDIATE) runtime.finish();
} catch (RuntimeException effectFailure) {
runtime.close(); report(key.character(), key.ability(), effectFailure);
input.player().sendMessage("The cooldown was saved, but the ability effect failed safely.");
}
}
/** Future refund/removal paths invalidate pending callbacks and close only this ability's owned work. */
public void remove(UUID character, String ability) {
var key = new Key(Objects.requireNonNull(character), Objects.requireNonNull(ability));
generations.put(key, generations.getOrDefault(key, 0L) + 1);
pending.remove(key);
for (var runtime : List.copyOf(active.getOrDefault(key, Set.of()))) runtime.close();
}
/** Bounded lifecycle sweep; plugin timers may call this without scanning future catalogues. */
public void sweep() {
if (closed) return;
int checked = 0;
outer: for (var runtimes : List.copyOf(active.values())) {
for (var runtime : List.copyOf(runtimes)) {
if (!runtime.stillCurrent()) runtime.close();
if (++checked == 64) break outer;
}
}
}
private boolean eligible(AbilityDefinition.TargetPolicy policy, ControlInput input, Entity target) {
return switch (policy) {
case SELF -> target == null || target == input.player();
case HOSTILE -> hostile(input, target);
case PROJECTILE_HOSTILE -> target == null || hostile(input, target);
case ALLY -> target instanceof Player player && fantasy(player);
case PARTY -> target instanceof Player player
&& (player == input.player() || fantasy(player) && sameParty(input.player(), player));
case AREA -> target == null || target instanceof LivingEntity && (!(target instanceof Player player) || fantasy(player));
case SELECTION -> target == null || target instanceof LivingEntity && (!(target instanceof Player player) || fantasy(player));
case EXCEPTIONAL_NONCOMBAT -> target instanceof Player && target != input.player();
case EXCEPTIONAL_NONCOMBAT_SELECTION -> target == null || target instanceof Player && target != input.player();
};
}
private boolean hostile(ControlInput input, Entity target) {
return target instanceof LivingEntity && target != input.player()
&& (!(target instanceof Player player) || fantasy(player) && !sameParty(input.player(), player));
}
private boolean fantasy(Player player) { return sessions.active(player.getUniqueId()).filter(sessions::current).isPresent(); }
private boolean sameParty(Player source, Player target) {
return parties.partyOf(source.getUniqueId()).filter(party -> party.members().contains(target.getUniqueId())).isPresent();
}
private <T> void schedule(UUID character, String ability, CompletableFuture<T> result, Runnable action) {
try { nextTick.accept(action); }
catch (RuntimeException rejected) {
report(character, ability, rejected);
if (result != null) result.completeExceptionally(rejected);
}
}
private void report(UUID character, String ability, Throwable failure) {
try { failures.accept(new Failure(character, ability, failure)); }
catch (RuntimeException ignored) { /* Diagnostics never authorize or replay an effect. */ }
}
private final class Runtime implements AbilityExecution {
private final Key key;
private final ControlInput input;
private final AbilityDefinition definition;
private final long generation;
private final Set<AutoCloseable> owned = new HashSet<>();
private boolean finished;
private int scheduled;
Runtime(Key key, ControlInput input, AbilityDefinition definition, long generation) {
this.key = key; this.input = input; this.definition = definition; this.generation = generation;
}
@Override public AbilityDefinition definition() { return definition; }
@Override public ControlInput input() { return input; }
@Override public CharacterRecord character() {
return sessions.currentRecord(input.session()).orElseThrow(() -> new IllegalStateException("Ability session ended"));
}
@Override public boolean stillCurrent() {
return !closed && !finished && input.stillCurrent() && sessions.current(input.session())
&& generations.getOrDefault(key, 0L) == generation
&& sessions.currentRecord(input.session()).filter(record -> record.learned().contains(key.ability())).isPresent();
}
@Override public List<Entity> eligible(Collection<? extends Entity> candidates, int maximum) {
Objects.requireNonNull(candidates);
if (maximum < 1 || maximum > 64) throw new IllegalArgumentException("Recipient bound must be 1..64");
var result = new ArrayList<Entity>(); int inspected = 0;
for (var candidate : candidates) {
if (++inspected > 256 || result.size() == maximum) break;
if (stillCurrent() && AbilityEngine.this.eligible(definition.targetPolicy(), input, candidate)) result.add(candidate);
}
return List.copyOf(result);
}
@Override public AbilityScheduler.Task later(Duration delay, Runnable action) {
Objects.requireNonNull(delay); Objects.requireNonNull(action);
if (delay.isNegative() || delay.compareTo(Duration.ofDays(365)) > 0 || ++scheduled > 64) {
throw new IllegalArgumentException("Delayed effect is outside the bounded execution contract");
}
var task = scheduler.later(delay, () -> {
if (!stillCurrent() || !AbilityEngine.this.eligible(definition.targetPolicy(), input, input.target())) {
close(); return;
}
try { action.run(); }
catch (RuntimeException failure) {
close(); AbilityEngine.this.report(key.character(), key.ability(), failure);
}
});
own(task); return task;
}
@Override public void own(AutoCloseable resource) {
Objects.requireNonNull(resource);
if (finished || owned.size() >= 64) {
try { resource.close(); } catch (Exception ignored) { }
if (!finished) throw new IllegalStateException("Ability owns too many resources");
return;
}
owned.add(resource);
}
@Override public void report(AbilityReport.Type type, Entity target, java.math.BigDecimal amount) {
if (!stillCurrent() || definition.targetPolicy() == AbilityDefinition.TargetPolicy.EXCEPTIONAL_NONCOMBAT
|| definition.targetPolicy() == AbilityDefinition.TargetPolicy.EXCEPTIONAL_NONCOMBAT_SELECTION
|| target == null || !AbilityEngine.this.eligible(definition.targetPolicy(), input, target)) return;
reports.accept(new AbilityReport(type, key.character(), input.session().session(), target.getUniqueId(), amount, clock.instant()));
}
@Override public void finish() { close(); }
@Override public void close() {
if (finished) return;
finished = true;
var resources = List.copyOf(owned); owned.clear();
for (var resource : resources) try { resource.close(); } catch (Exception failure) { AbilityEngine.this.report(key.character(), key.ability(), failure); }
var runtimes = active.get(key);
if (runtimes != null) { runtimes.remove(this); if (runtimes.isEmpty()) active.remove(key); }
}
}
@Override public void close() {
if (closed) return;
closed = true;
if (sweepTask != null) sweepTask.cancel();
pending.clear();
for (var runtimes : List.copyOf(active.values())) for (var runtime : List.copyOf(runtimes)) runtime.close();
active.clear(); generations.clear();
}
}
@@ -0,0 +1,22 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.time.Duration;
import java.util.Collection;
import java.util.List;
import org.bukkit.entity.Entity;
/** Bounded owned runtime supplied by the shared engine after durable cast authorization. */
public interface AbilityExecution extends AutoCloseable {
AbilityDefinition definition();
ControlInput input();
games.dmg.fantasystick.persistence.CharacterRecord character();
boolean stillCurrent();
List<Entity> eligible(Collection<? extends Entity> candidates, int maximum);
AbilityScheduler.Task later(Duration delay, Runnable action);
void own(AutoCloseable resource);
void report(AbilityReport.Type type, Entity target, BigDecimal amount);
void finish();
@Override void close();
}
@@ -0,0 +1,36 @@
package games.dmg.fantasystick.ability;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/** Process-local validated definitions; saved unknown IDs remain character data, not executable definitions. */
public final class AbilityRegistry {
private final ConcurrentHashMap<String, AbilityDefinition> definitions = new ConcurrentHashMap<>();
public void register(AbilityDefinition definition) {
Objects.requireNonNull(definition);
if (definitions.putIfAbsent(definition.id(), definition) != null) {
throw new IllegalArgumentException("Duplicate ability ID: " + definition.id());
}
}
/** Replace one registered definition after an acknowledged runtime configuration save. */
public void replace(AbilityDefinition definition) {
Objects.requireNonNull(definition);
definitions.compute(definition.id(), (id, existing) -> {
if (existing == null) throw new IllegalArgumentException("Unknown ability ID: " + id);
return definition;
});
}
public Optional<AbilityDefinition> find(String id) {
return id == null ? Optional.empty() : Optional.ofNullable(definitions.get(id));
}
public List<AbilityDefinition> definitions() {
return definitions.values().stream().sorted(Comparator.comparing(AbilityDefinition::id)).toList();
}
}
@@ -0,0 +1,19 @@
package games.dmg.fantasystick.ability;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.Objects;
import java.util.UUID;
/** Evidence of an actual accepted effect; consumers decide rewards separately. */
public record AbilityReport(Type type, UUID sourceCharacter, UUID sourceSession, UUID target,
BigDecimal amount, Instant occurredAt) {
public enum Type { DAMAGE, HEALING, CLEANSING, PREVENTION, BOOST_USED }
public AbilityReport {
Objects.requireNonNull(type); Objects.requireNonNull(sourceCharacter); Objects.requireNonNull(sourceSession);
Objects.requireNonNull(target); Objects.requireNonNull(amount); Objects.requireNonNull(occurredAt);
if (amount.signum() <= 0 || !Double.isFinite(amount.doubleValue())) {
throw new IllegalArgumentException("An effective report requires a positive finite amount");
}
}
}
@@ -0,0 +1,13 @@
package games.dmg.fantasystick.ability;
import java.time.Duration;
/** Server-thread delayed-work boundary; returned tasks cancel only their owned callback. */
@FunctionalInterface
public interface AbilityScheduler {
Task later(Duration delay, Runnable action);
interface Task extends AutoCloseable {
@Override void close();
}
}
@@ -0,0 +1,77 @@
package games.dmg.fantasystick.ability;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
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.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
/** Internal strongest-reduction stage shared by Fantasy Stick protection effects. */
public final class CombatMitigation implements Listener, AutoCloseable {
private final class Grant implements AutoCloseable {
final AbilityExecution execution; final Player player; final BigDecimal percent; boolean closed;
Grant(AbilityExecution execution, Player player, BigDecimal percent) {
this.execution = execution; this.player = player; this.percent = percent;
}
@Override public void close() {
if (closed) return; closed = true;
var owned = grants.get(player.getUniqueId());
if (owned != null) { owned.remove(this); if (owned.isEmpty()) grants.remove(player.getUniqueId()); }
}
}
private record Prevention(Grant grant, BigDecimal amount) { }
private final Map<UUID, List<Grant>> grants = new java.util.HashMap<>();
private final Map<EntityDamageEvent, Prevention> pending = new IdentityHashMap<>();
private boolean closed;
public AutoCloseable grant(AbilityExecution execution, Player player, BigDecimal percent) {
Objects.requireNonNull(execution); Objects.requireNonNull(player); Objects.requireNonNull(percent);
if (closed || percent.signum() < 0 || percent.compareTo(BigDecimal.valueOf(100)) > 0)
throw new IllegalArgumentException("Invalid combat mitigation grant");
var grant = new Grant(execution, player, percent);
grants.computeIfAbsent(player.getUniqueId(), ignored -> new ArrayList<>()).add(grant);
return grant;
}
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
public void reduce(EntityDamageByEntityEvent event) {
if (closed || !(event.getEntity() instanceof Player player)) return;
Grant strongest = null;
var owned = grants.get(player.getUniqueId());
if (owned == null) return;
for (var grant : List.copyOf(owned)) {
if (grant.closed || grant.player != player || !grant.execution.stillCurrent()) continue;
if (strongest == null || grant.percent.compareTo(strongest.percent) > 0) strongest = grant;
}
double original = event.getDamage();
if (strongest == null || !Double.isFinite(original) || original <= 0) return;
double reduced = BigDecimal.valueOf(original)
.multiply(BigDecimal.ONE.subtract(strongest.percent.movePointLeft(2))).doubleValue();
if (!Double.isFinite(reduced) || reduced < 0) return;
event.setDamage(reduced);
BigDecimal prevented = BigDecimal.valueOf(original - reduced);
if (prevented.signum() > 0) pending.put(event, new Prevention(strongest, prevented));
}
@EventHandler(priority = EventPriority.MONITOR)
public void accepted(EntityDamageEvent event) {
var prevention = pending.remove(event);
if (prevention == null || event.isCancelled() || event.getFinalDamage() <= 0
|| !prevention.grant.execution.stillCurrent()) return;
prevention.grant.execution.report(AbilityReport.Type.PREVENTION, event.getEntity(), prevention.amount);
}
@Override public void close() {
if (closed) return; closed = true;
for (var owned : List.copyOf(grants.values())) for (var grant : List.copyOf(owned)) grant.close();
grants.clear(); pending.clear();
}
}
@@ -0,0 +1,162 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Particle;
import org.bukkit.damage.DamageSource;
import org.bukkit.damage.DamageType;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Snowball;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.ProjectileHitEvent;
import org.bukkit.util.Vector;
/** Fireball's owned snowball projectile, direct custom damage and bounded cleanup adapter. */
public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
private static final double SPEED_PER_TICK = 1.0;
public record Settings(BigInteger baseDamage, Duration cooldown, double range, Duration lifetime) {
public Settings {
Objects.requireNonNull(baseDamage); Objects.requireNonNull(cooldown); Objects.requireNonNull(lifetime);
if (baseDamage.signum() < 0 || cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|| !Double.isFinite(range) || range <= 0 || range > 1024
|| lifetime.isZero() || lifetime.isNegative() || lifetime.compareTo(Duration.ofSeconds(60)) > 0) {
throw new IllegalArgumentException("Invalid Fireball settings");
}
}
public static Settings defaults() {
return new Settings(BigInteger.valueOf(6), Duration.ofSeconds(4), 32, Duration.ofSeconds(5));
}
}
private final Settings settings;
private final Map<UUID, State> projectiles = new HashMap<>();
private State damaging;
private boolean closed;
private final class State implements AutoCloseable {
final Snowball projectile;
final AbilityExecution execution;
final Location origin;
final BigInteger intelligence;
LivingEntity target;
boolean finished;
State(Snowball projectile, AbilityExecution execution, Location origin, BigInteger intelligence) {
this.projectile = projectile; this.execution = execution; this.origin = origin; this.intelligence = intelligence;
}
@Override public void close() {
if (finished) return;
finished = true;
projectiles.remove(projectile.getUniqueId(), this);
projectile.remove();
}
}
public Fireball(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
return new AbilityDefinition("fireball", "Fireball", Material.SNOWBALL,
List.of("Launch a bounded fiery snowball.", "Deals " + settings.baseDamage() + " + INT modifier damage."),
"Use the equipped snowball; a miss still spends the cooldown.",
Map.of(Stat.INT, BigInteger.valueOf(12)), BigInteger.ONE,
AbilityDefinition.Activation.PROJECTILE, ControlInput.Gesture.USE, settings.cooldown(),
Map.of("base-damage", new BigDecimal(settings.baseDamage()),
"range", BigDecimal.valueOf(settings.range()).stripTrailingZeros(),
"lifetime", BigDecimal.valueOf(settings.lifetime().toMillis(), 3).stripTrailingZeros()),
AbilityDefinition.TargetPolicy.PROJECTILE_HOSTILE, this);
}
public static BigInteger damage(BigInteger intelligence) {
return damage(BigInteger.valueOf(6), intelligence);
}
static BigInteger damage(BigInteger base, BigInteger intelligence) {
Objects.requireNonNull(base); Objects.requireNonNull(intelligence);
if (base.signum() < 0 || intelligence.signum() < 0) throw new IllegalArgumentException("Invalid Fireball damage input");
BigInteger delta = intelligence.subtract(BigInteger.TEN);
BigInteger[] divided = delta.divideAndRemainder(BigInteger.TWO);
BigInteger modifier = divided[0];
if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
return base.add(modifier);
}
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) return;
var player = execution.input().player();
Location origin = player.getEyeLocation().clone();
Vector direction = origin.getDirection();
if (!Double.isFinite(direction.getX()) || !Double.isFinite(direction.getY()) || !Double.isFinite(direction.getZ())
|| direction.lengthSquared() == 0) throw new IllegalStateException("Fireball direction is unavailable");
var projectile = player.launchProjectile(Snowball.class, direction.normalize().multiply(SPEED_PER_TICK));
projectile.setVisualFire(true);
var state = new State(projectile, execution, origin, execution.character().stats().get(Stat.INT));
if (projectiles.putIfAbsent(projectile.getUniqueId(), state) != null) {
projectile.remove(); throw new IllegalStateException("Duplicate Fireball projectile identity");
}
execution.own(state);
long rangeTicks = Math.max(1, (long) Math.ceil(settings.range() / SPEED_PER_TICK));
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), execution::finish);
execution.later(settings.lifetime(), execution::finish);
player.getWorld().spawnParticle(Particle.FLAME, origin, 8, 0.15, 0.15, 0.15, 0.01);
}
@EventHandler(priority = EventPriority.MONITOR)
public void hit(ProjectileHitEvent event) {
var state = projectiles.remove(event.getEntity().getUniqueId());
if (state == null) return;
try {
if (closed || event.isCancelled() || !state.execution.stillCurrent()
|| !(event.getHitEntity() instanceof LivingEntity target) || !inRange(state, event.getEntity().getLocation())
|| state.execution.eligible(List.of(target), 1).isEmpty()) return;
BigInteger amount = damage(settings.baseDamage(), state.intelligence);
double nativeDamage = amount.bitLength() > 1023 ? Double.MAX_VALUE : amount.doubleValue();
var source = DamageSource.builder(DamageType.FIREBALL).withCausingEntity(state.execution.input().player())
.withDirectEntity(state.projectile).withDamageLocation(state.projectile.getLocation()).build();
state.target = target;
damaging = state;
try { target.damage(nativeDamage, source); }
finally { if (damaging == state) damaging = null; }
} finally {
state.execution.finish();
}
}
private boolean inRange(State state, Location current) {
return current.getWorld() != null && state.origin.getWorld() != null
&& current.getWorld().equals(state.origin.getWorld())
&& current.distanceSquared(state.origin) <= settings.range() * settings.range();
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void acceptedDamage(EntityDamageEvent event) {
var state = damaging;
if (state == null || event.getEntity() != state.target || event.getFinalDamage() <= 0
|| !Double.isFinite(event.getFinalDamage())) return;
var direct = event.getDamageSource().getDirectEntity();
if (direct != state.projectile && (!(event instanceof org.bukkit.event.entity.EntityDamageByEntityEvent byEntity)
|| byEntity.getDamager() != state.projectile)) return;
var causing = event.getDamageSource().getCausingEntity();
if (causing != null && causing != state.execution.input().player()) return;
state.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(), BigDecimal.valueOf(event.getFinalDamage()));
}
@Override public void close() {
if (closed) return;
closed = true;
for (var state : List.copyOf(projectiles.values())) state.execution.finish();
projectiles.clear(); damaging = null;
}
}
@@ -0,0 +1,233 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Particle;
import org.bukkit.damage.DamageSource;
import org.bukkit.damage.DamageType;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Snowball;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.ProjectileHitEvent;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
import org.bukkit.util.Vector;
/** Frost Shard's bounded direct projectile damage and owned Slowness adapter. */
public final class FrostShard implements AbilityEffect, Listener, AutoCloseable {
public record Settings(BigInteger baseDamage, Duration cooldown, double range, Duration lifetime,
double speedPerTick, int slownessAmplifier, Duration slownessDuration) {
public Settings {
Objects.requireNonNull(baseDamage); Objects.requireNonNull(cooldown); Objects.requireNonNull(lifetime);
Objects.requireNonNull(slownessDuration);
if (baseDamage.signum() < 0 || cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|| !Double.isFinite(range) || range <= 0 || range > 1024
|| lifetime.isZero() || lifetime.isNegative() || lifetime.compareTo(Duration.ofSeconds(60)) > 0
|| !Double.isFinite(speedPerTick) || speedPerTick < 0.01 || speedPerTick > 64
|| slownessAmplifier < 0 || slownessAmplifier > 255
|| slownessDuration.isZero() || slownessDuration.isNegative()
|| slownessDuration.compareTo(Duration.ofDays(1)) > 0) {
throw new IllegalArgumentException("Invalid Frost Shard settings");
}
}
public static Settings defaults() {
return new Settings(BigInteger.valueOf(4), Duration.ofSeconds(5), 32, Duration.ofSeconds(5),
1, 0, Duration.ofSeconds(2));
}
}
private final class State implements AutoCloseable {
final Snowball projectile;
final AbilityExecution execution;
final Location origin;
final BigInteger intelligence;
final Settings settings;
LivingEntity target;
boolean accepted;
boolean finished;
State(Snowball projectile, AbilityExecution execution, Location origin, BigInteger intelligence, Settings settings) {
this.projectile = projectile; this.execution = execution; this.origin = origin;
this.intelligence = intelligence; this.settings = settings;
}
@Override public void close() {
if (finished) return;
finished = true; projectiles.remove(projectile.getUniqueId(), this); projectile.remove();
}
}
private static final class SlowLease implements AutoCloseable {
private final LivingEntity target;
private final PotionEffect previous;
private final PotionEffect applied;
private final long started = System.nanoTime();
private boolean owned;
private boolean closed;
SlowLease(LivingEntity target, PotionEffect applied) {
this.target = target; this.previous = target.getPotionEffect(PotionEffectType.SLOWNESS); this.applied = applied;
}
boolean apply() { owned = target.addPotionEffect(applied); return owned; }
@Override public void close() {
if (closed) return;
closed = true;
if (!owned) return;
var current = target.getPotionEffect(PotionEffectType.SLOWNESS);
long elapsed = Math.max(0, (System.nanoTime() - started) / 50_000_000L);
int expected = (int) Math.max(0, (long) applied.getDuration() - elapsed);
if (current == null || current.getAmplifier() != applied.getAmplifier()
|| current.getDuration() > expected + 2) return;
target.removePotionEffect(PotionEffectType.SLOWNESS);
if (previous != null) {
int remaining = (int) Math.max(0, (long) previous.getDuration() - elapsed);
if (remaining > 0) target.addPotionEffect(previous.withDuration(remaining));
}
}
}
private volatile Settings settings;
private final Map<UUID, State> projectiles = new HashMap<>();
private State damaging;
private boolean closed;
public FrostShard(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
var current = settings;
return new AbilityDefinition("frost_shard", "Frost Shard", Material.PRISMARINE_SHARD,
List.of("Launch a bounded frost projectile.",
"Deals " + current.baseDamage() + " + INT modifier damage and Slowness "
+ level(current.slownessAmplifier()) + " for " + seconds(current.slownessDuration()) + "s."),
"Use the equipped shard; a miss still spends the cooldown.",
Map.of(Stat.INT, BigInteger.valueOf(12), Stat.WIS, BigInteger.TEN), BigInteger.ONE,
AbilityDefinition.Activation.PROJECTILE, ControlInput.Gesture.USE, current.cooldown(),
Map.of("base-damage", new BigDecimal(current.baseDamage()),
"range", BigDecimal.valueOf(current.range()).stripTrailingZeros(),
"lifetime", seconds(current.lifetime()), "projectile-speed", BigDecimal.valueOf(current.speedPerTick()),
"slowness-amplifier", BigDecimal.valueOf(current.slownessAmplifier()),
"slowness-duration", seconds(current.slownessDuration())),
AbilityDefinition.TargetPolicy.PROJECTILE_HOSTILE, this);
}
public static BigInteger damage(BigInteger intelligence) {
return damage(BigInteger.valueOf(4), intelligence);
}
static BigInteger damage(BigInteger base, BigInteger intelligence) {
Objects.requireNonNull(base); Objects.requireNonNull(intelligence);
if (base.signum() < 0 || intelligence.signum() < 0) throw new IllegalArgumentException("Invalid Frost Shard damage input");
BigInteger delta = intelligence.subtract(BigInteger.TEN);
BigInteger[] divided = delta.divideAndRemainder(BigInteger.TWO);
BigInteger modifier = divided[0];
if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
return base.add(modifier);
}
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) return;
var current = settings;
var player = execution.input().player();
Location origin = player.getEyeLocation().clone();
Vector direction = origin.getDirection();
if (!Double.isFinite(direction.getX()) || !Double.isFinite(direction.getY()) || !Double.isFinite(direction.getZ())
|| direction.lengthSquared() == 0) throw new IllegalStateException("Frost Shard direction is unavailable");
var projectile = player.launchProjectile(Snowball.class, direction.normalize().multiply(current.speedPerTick()));
var state = new State(projectile, execution, origin, execution.character().stats().get(Stat.INT), current);
if (projectiles.putIfAbsent(projectile.getUniqueId(), state) != null) {
projectile.remove(); throw new IllegalStateException("Duplicate Frost Shard projectile identity");
}
execution.own(state);
long rangeTicks = Math.max(1, (long) Math.ceil(current.range() / current.speedPerTick()));
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), () -> {
if (projectiles.containsKey(projectile.getUniqueId())) execution.finish();
});
execution.later(current.lifetime(), () -> {
if (projectiles.containsKey(projectile.getUniqueId())) execution.finish();
});
player.getWorld().spawnParticle(Particle.SNOWFLAKE, origin, 8, 0.15, 0.15, 0.15, 0.01);
}
@EventHandler(priority = EventPriority.MONITOR)
public void hit(ProjectileHitEvent event) {
var state = projectiles.remove(event.getEntity().getUniqueId());
if (state == null) return;
boolean retainExecution = false;
try {
if (closed || event.isCancelled() || !state.execution.stillCurrent()
|| !(event.getHitEntity() instanceof LivingEntity target) || !inRange(state, event.getEntity().getLocation())
|| state.execution.eligible(List.of(target), 1).isEmpty()) return;
var current = state.settings;
BigInteger amount = damage(current.baseDamage(), state.intelligence);
double nativeDamage = amount.bitLength() > 1023 ? Double.MAX_VALUE : amount.doubleValue();
var source = DamageSource.builder(DamageType.FREEZE).withCausingEntity(state.execution.input().player())
.withDirectEntity(state.projectile).withDamageLocation(state.projectile.getLocation()).build();
state.target = target; damaging = state;
try { target.damage(nativeDamage, source); }
finally { if (damaging == state) damaging = null; }
if (state.accepted && state.execution.stillCurrent()) {
int ticks = Math.toIntExact(Math.max(1,
(current.slownessDuration().toNanos() + 49_999_999L) / 50_000_000L));
var lease = new SlowLease(target, new PotionEffect(PotionEffectType.SLOWNESS, ticks,
current.slownessAmplifier(), false, true, true));
if (lease.apply()) {
state.execution.own(lease);
state.execution.later(current.slownessDuration(), state.execution::finish);
retainExecution = true;
}
}
} finally {
state.close();
if (!retainExecution) state.execution.finish();
}
}
private boolean inRange(State state, Location current) {
return current.getWorld() != null && state.origin.getWorld() != null
&& current.getWorld().equals(state.origin.getWorld())
&& current.distanceSquared(state.origin) <= state.settings.range() * state.settings.range();
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void acceptedDamage(EntityDamageEvent event) {
var state = damaging;
if (state == null || state.accepted || event.getEntity() != state.target || event.getFinalDamage() <= 0
|| !Double.isFinite(event.getFinalDamage())) return;
var direct = event.getDamageSource().getDirectEntity();
if (direct != state.projectile && (!(event instanceof org.bukkit.event.entity.EntityDamageByEntityEvent byEntity)
|| byEntity.getDamager() != state.projectile)) return;
state.accepted = true;
state.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(), BigDecimal.valueOf(event.getFinalDamage()));
}
private static String level(int amplifier) {
return switch (amplifier + 1) {
case 1 -> "I"; case 2 -> "II"; case 3 -> "III"; case 4 -> "IV"; case 5 -> "V";
default -> Integer.toString(amplifier + 1);
};
}
private static BigDecimal seconds(Duration duration) {
return BigDecimal.valueOf(duration.getSeconds()).add(BigDecimal.valueOf(duration.getNano(), 9))
.stripTrailingZeros();
}
@Override public void close() {
if (closed) return;
closed = true;
for (var state : List.copyOf(projectiles.values())) state.execution.finish();
projectiles.clear(); damaging = null;
}
}
@@ -0,0 +1,116 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import org.bukkit.Material;
import org.bukkit.damage.DamageSource;
import org.bukkit.damage.DamageType;
import org.bukkit.entity.LivingEntity;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
/** Great Sword's charge-scaled direct melee damage, replacing the contained native weapon hit. */
public final class GreatSword implements AbilityEffect, Listener, AutoCloseable {
public record Settings(BigInteger baseDamage, Duration recovery) {
public Settings {
Objects.requireNonNull(baseDamage); Objects.requireNonNull(recovery);
if (baseDamage.signum() < 0 || recovery.isNegative()
|| recovery.compareTo(Duration.ofDays(365)) > 0) {
throw new IllegalArgumentException("Invalid Great Sword settings");
}
}
public static Settings defaults() { return new Settings(BigInteger.valueOf(7), Duration.ofMillis(1200)); }
}
private static final class Attempt {
final AbilityExecution execution;
final LivingEntity target;
boolean reported;
Attempt(AbilityExecution execution, LivingEntity target) {
this.execution = execution; this.target = target;
}
}
private final Settings settings;
private Attempt damaging;
private boolean closed;
public GreatSword(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
return new AbilityDefinition("great_sword", "Great Sword", Material.WOODEN_SWORD,
List.of("Charge a contained melee strike.",
"Fully charged: " + settings.baseDamage() + " + STR modifier damage."),
"Attack with the equipped sword; partial charge follows the native attack curve.",
Map.of(Stat.STR, BigInteger.valueOf(12)), BigInteger.ONE,
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.ATTACK, settings.recovery(),
Map.of("base-damage", new BigDecimal(settings.baseDamage())),
AbilityDefinition.TargetPolicy.HOSTILE, this);
}
public static BigDecimal damage(BigInteger strength, float charge) {
return damage(BigInteger.valueOf(7), strength, charge);
}
static BigDecimal damage(BigInteger base, BigInteger strength, float charge) {
Objects.requireNonNull(base); Objects.requireNonNull(strength);
if (base.signum() < 0 || strength.signum() < 0 || !Float.isFinite(charge) || charge < 0 || charge > 1) {
throw new IllegalArgumentException("Invalid Great Sword damage input");
}
BigInteger delta = strength.subtract(BigInteger.TEN);
BigInteger[] divided = delta.divideAndRemainder(BigInteger.TWO);
BigInteger modifier = divided[0];
if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
BigDecimal full = new BigDecimal(base.add(modifier));
BigDecimal nativeCharge = new BigDecimal(Float.toString(charge));
BigDecimal curve = new BigDecimal("0.2").add(new BigDecimal("0.8").multiply(nativeCharge.multiply(nativeCharge)));
return full.multiply(curve).stripTrailingZeros();
}
@Override public boolean accepts(ControlInput input) {
return !closed && input.target() instanceof LivingEntity;
}
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent() || !(execution.input().target() instanceof LivingEntity target)) return;
BigDecimal amount = damage(settings.baseDamage(), execution.character().stats().get(Stat.STR),
execution.input().attackStrength());
double nativeDamage = amount.compareTo(BigDecimal.valueOf(Double.MAX_VALUE)) > 0
? Double.MAX_VALUE : amount.doubleValue();
if (!Double.isFinite(nativeDamage) || nativeDamage <= 0) {
throw new IllegalStateException("Great Sword damage is outside the native finite range");
}
var player = execution.input().player();
var source = DamageSource.builder(DamageType.PLAYER_ATTACK).withCausingEntity(player)
.withDirectEntity(player).build();
var attempt = new Attempt(execution, target);
damaging = attempt;
try { target.damage(nativeDamage, source); }
finally { if (damaging == attempt) damaging = null; }
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void acceptedDamage(EntityDamageByEntityEvent event) {
var attempt = damaging;
if (closed || attempt == null || attempt.reported || event.isCancelled()
|| event.getDamager() != attempt.execution.input().player() || event.getEntity() != attempt.target
|| (event.getDamageSource().getCausingEntity() != null
&& event.getDamageSource().getCausingEntity() != attempt.execution.input().player())
|| (event.getDamageSource().getDirectEntity() != null
&& event.getDamageSource().getDirectEntity() != attempt.execution.input().player())
|| event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
attempt.reported = true;
attempt.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(),
BigDecimal.valueOf(event.getFinalDamage()).stripTrailingZeros());
}
@Override public void close() { closed = true; damaging = null; }
}
@@ -0,0 +1,220 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.math.MathContext;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.damage.DamageSource;
import org.bukkit.damage.DamageType;
import org.bukkit.entity.AbstractArrow;
import org.bukkit.entity.Arrow;
import org.bukkit.entity.LivingEntity;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.ProjectileHitEvent;
import org.bukkit.util.Vector;
/** Longbow's ammunition-free native draw and bounded custom-arrow damage adapter. */
public final class Longbow implements AbilityEffect, Listener, AutoCloseable {
private static final BigInteger MAX_NATIVE_BASE = BigDecimal.valueOf(Double.MAX_VALUE).toBigInteger();
public record Settings(BigInteger baseDamage, Duration recovery, int minimumDrawTicks, int fullDrawTicks,
double range, Duration lifetime, double fullSpeedPerTick) {
public Settings {
Objects.requireNonNull(baseDamage); Objects.requireNonNull(recovery); Objects.requireNonNull(lifetime);
if (baseDamage.signum() < 0 || baseDamage.compareTo(MAX_NATIVE_BASE) > 0
|| recovery.compareTo(Duration.ofSeconds(1)) < 0
|| recovery.compareTo(Duration.ofDays(365)) > 0
|| minimumDrawTicks < 1 || minimumDrawTicks > 72_000
|| fullDrawTicks < minimumDrawTicks || fullDrawTicks > 72_000
|| !Double.isFinite(range) || range <= 0 || range > 1024
|| lifetime.isZero() || lifetime.isNegative() || lifetime.compareTo(Duration.ofSeconds(60)) > 0
|| !Double.isFinite(fullSpeedPerTick) || fullSpeedPerTick < 0.01 || fullSpeedPerTick > 64) {
throw new IllegalArgumentException("Invalid Longbow settings");
}
}
public static Settings defaults() {
return new Settings(BigInteger.valueOf(5), Duration.ofSeconds(1), 3, 20, 64,
Duration.ofSeconds(10), 3);
}
}
private final class State implements AutoCloseable {
final Arrow arrow;
final AbilityExecution execution;
final Location origin;
final Settings settings;
final BigDecimal damage;
LivingEntity target;
boolean accepted;
boolean finished;
State(Arrow arrow, AbilityExecution execution, Location origin, Settings settings, BigDecimal damage) {
this.arrow = arrow; this.execution = execution; this.origin = origin; this.settings = settings; this.damage = damage;
}
@Override public void close() {
if (finished) return;
finished = true; arrows.remove(arrow.getUniqueId(), this); arrow.remove();
}
}
private volatile Settings settings;
private final Map<UUID, State> arrows = new HashMap<>();
private State damaging;
private boolean closed;
public Longbow(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
var current = settings;
return new AbilityDefinition("longbow", "Longbow", Material.BOW,
List.of("Draw and release an ammunition-free owned arrow.",
"A full draw deals " + current.baseDamage() + " + DEX modifier damage."),
"Draw by holding use; release after at least " + current.minimumDrawTicks() + " ticks.",
Map.of(Stat.DEX, BigInteger.valueOf(14)), BigInteger.ONE,
AbilityDefinition.Activation.PROJECTILE, ControlInput.Gesture.DRAW_RELEASE, current.recovery(),
Map.of("base-damage", new BigDecimal(current.baseDamage()),
"minimum-draw-ticks", BigDecimal.valueOf(current.minimumDrawTicks()),
"full-draw-ticks", BigDecimal.valueOf(current.fullDrawTicks()),
"range", BigDecimal.valueOf(current.range()).stripTrailingZeros(),
"lifetime", seconds(current.lifetime()),
"full-projectile-speed", BigDecimal.valueOf(current.fullSpeedPerTick()).stripTrailingZeros()),
AbilityDefinition.TargetPolicy.PROJECTILE_HOSTILE, this);
}
@Override public boolean accepts(ControlInput input) {
return input.gesture() == ControlInput.Gesture.DRAW_RELEASE && input.heldTicks() >= settings.minimumDrawTicks();
}
public static BigDecimal power(int heldTicks, int fullDrawTicks) {
if (heldTicks < 0 || fullDrawTicks < 1) throw new IllegalArgumentException("Invalid Longbow draw");
var x = BigDecimal.valueOf(heldTicks).divide(BigDecimal.valueOf(fullDrawTicks), MathContext.DECIMAL128);
var power = x.multiply(x, MathContext.DECIMAL128).add(x.multiply(BigDecimal.TWO), MathContext.DECIMAL128)
.divide(BigDecimal.valueOf(3), MathContext.DECIMAL128);
return power.min(BigDecimal.ONE);
}
public static BigInteger fullDamage(BigInteger dexterity) {
return fullDamage(BigInteger.valueOf(5), dexterity);
}
static BigInteger fullDamage(BigInteger base, BigInteger dexterity) {
Objects.requireNonNull(base); Objects.requireNonNull(dexterity);
if (base.signum() < 0 || dexterity.signum() < 0) throw new IllegalArgumentException("Invalid Longbow damage input");
BigInteger delta = dexterity.subtract(BigInteger.TEN);
BigInteger[] divided = delta.divideAndRemainder(BigInteger.TWO);
BigInteger modifier = divided[0];
if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
return base.add(modifier);
}
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) return;
Settings shot = from(execution.definition());
BigDecimal power = power(execution.input().heldTicks(), shot.fullDrawTicks());
var player = execution.input().player();
Location origin = player.getEyeLocation().clone();
Vector direction = origin.getDirection();
if (!Double.isFinite(direction.getX()) || !Double.isFinite(direction.getY()) || !Double.isFinite(direction.getZ())
|| direction.lengthSquared() == 0) throw new IllegalStateException("Longbow direction is unavailable");
double speed = BigDecimal.valueOf(shot.fullSpeedPerTick()).multiply(power, MathContext.DECIMAL128).doubleValue();
var arrow = player.launchProjectile(Arrow.class, direction.normalize().multiply(speed));
arrow.setPickupStatus(AbstractArrow.PickupStatus.DISALLOWED);
arrow.setDamage(0); arrow.setKnockbackStrength(0); arrow.setPierceLevel(0); arrow.setCritical(false);
var damage = new BigDecimal(fullDamage(shot.baseDamage(), execution.character().stats().get(Stat.DEX)))
.multiply(power, MathContext.DECIMAL128);
var state = new State(arrow, execution, origin, shot, damage);
if (arrows.putIfAbsent(arrow.getUniqueId(), state) != null) {
arrow.remove(); throw new IllegalStateException("Duplicate Longbow arrow identity");
}
execution.own(state);
long rangeTicks = Math.max(1, (long) Math.ceil(shot.range() / speed));
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), () -> {
if (arrows.containsKey(arrow.getUniqueId())) execution.finish();
});
execution.later(shot.lifetime(), () -> {
if (arrows.containsKey(arrow.getUniqueId())) execution.finish();
});
}
@EventHandler(priority = EventPriority.HIGHEST)
public void containNativeDamage(EntityDamageByEntityEvent event) {
if (!(event.getDamager() instanceof Arrow arrow)) return;
var state = arrows.get(arrow.getUniqueId());
if (state != null && damaging != state) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.MONITOR)
public void hit(ProjectileHitEvent event) {
var state = arrows.remove(event.getEntity().getUniqueId());
if (state == null) return;
try {
if (closed || event.isCancelled() || !state.execution.stillCurrent()
|| !(event.getHitEntity() instanceof LivingEntity target)
|| !inRange(state, event.getEntity().getLocation())
|| state.execution.eligible(List.of(target), 1).isEmpty()) return;
double nativeDamage = state.damage.compareTo(BigDecimal.valueOf(Double.MAX_VALUE)) > 0
? Double.MAX_VALUE : state.damage.doubleValue();
if (!Double.isFinite(nativeDamage) || nativeDamage <= 0) return;
var source = DamageSource.builder(DamageType.ARROW).withCausingEntity(state.execution.input().player())
.withDirectEntity(state.arrow).withDamageLocation(state.arrow.getLocation()).build();
state.target = target; damaging = state;
try { target.damage(nativeDamage, source); }
finally { if (damaging == state) damaging = null; }
} finally {
state.close(); state.execution.finish();
}
}
private static boolean inRange(State state, Location current) {
return current.getWorld() != null && state.origin.getWorld() != null
&& current.getWorld().equals(state.origin.getWorld())
&& current.distanceSquared(state.origin) <= state.settings.range() * state.settings.range();
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void acceptedDamage(EntityDamageEvent event) {
var state = damaging;
if (state == null || state.accepted || event.getEntity() != state.target || event.getFinalDamage() <= 0
|| !Double.isFinite(event.getFinalDamage())) return;
var direct = event.getDamageSource().getDirectEntity();
if (direct != state.arrow && (!(event instanceof EntityDamageByEntityEvent byEntity)
|| byEntity.getDamager() != state.arrow)) return;
state.accepted = true;
state.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(), BigDecimal.valueOf(event.getFinalDamage()));
}
private static Settings from(AbilityDefinition definition) {
var p = definition.parameters();
return new Settings(p.get("base-damage").toBigIntegerExact(), definition.cooldown(),
p.get("minimum-draw-ticks").intValueExact(), p.get("full-draw-ticks").intValueExact(),
p.get("range").doubleValue(), duration(p.get("lifetime")),
p.get("full-projectile-speed").doubleValue());
}
private static Duration duration(BigDecimal seconds) {
return Duration.ofNanos(seconds.movePointRight(9).toBigIntegerExact().longValueExact());
}
private static BigDecimal seconds(Duration duration) {
return BigDecimal.valueOf(duration.getSeconds()).add(BigDecimal.valueOf(duration.getNano(), 9)).stripTrailingZeros();
}
@Override public void close() {
if (closed) return;
closed = true;
for (var state : List.copyOf(arrows.values())) state.execution.finish();
arrows.clear(); damaging = null;
}
}
@@ -0,0 +1,153 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.event.player.PlayerVelocityEvent;
import org.bukkit.util.Vector;
/** Quickstep's grounded, collision-respecting, owned horizontal velocity adapter. */
public final class Quickstep implements AbilityEffect, Listener, AutoCloseable {
public record Settings(Duration cooldown, double distance, double speedPerTick) {
public Settings {
Objects.requireNonNull(cooldown);
if (cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|| !Double.isFinite(distance) || distance <= 0 || distance > 64
|| !Double.isFinite(speedPerTick) || speedPerTick < 0.01 || speedPerTick > 8) {
throw new IllegalArgumentException("Invalid Quickstep settings");
}
}
public static Settings defaults() { return new Settings(Duration.ofSeconds(5), 4, 1); }
}
private final class State implements AutoCloseable {
final AbilityExecution execution;
final Player player;
final Location origin;
final Vector horizontal;
final Settings settings;
boolean owned = true;
boolean closed;
State(AbilityExecution execution, Player player, Location origin, Vector horizontal, Settings settings) {
this.execution = execution; this.player = player; this.origin = origin;
this.horizontal = horizontal; this.settings = settings;
}
boolean stillOwns(Vector current) {
var horizontalCurrent = new Vector(current.getX(), 0, current.getZ());
double magnitude = horizontalCurrent.length();
if (magnitude < 1.0e-6) return true;
return magnitude <= horizontal.length() * 1.05 + 1.0e-6
&& horizontalCurrent.clone().normalize().dot(horizontal.clone().normalize()) >= 0.99;
}
@Override public void close() {
if (closed) return;
closed = true; dashes.remove(player.getUniqueId(), this);
if (!owned) return;
Vector current = player.getVelocity();
if (!stillOwns(current)) return;
player.setVelocity(new Vector(0, current.getY(), 0));
}
}
private volatile Settings settings;
private final Consumer<PlayerVelocityEvent> velocityEvents;
private final Map<UUID, State> dashes = new HashMap<>();
private boolean closed;
public Quickstep(Settings settings, Consumer<PlayerVelocityEvent> velocityEvents) {
this.settings = Objects.requireNonNull(settings); this.velocityEvents = Objects.requireNonNull(velocityEvents);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
var current = settings;
return new AbilityDefinition("quickstep", "Quickstep", Material.RABBIT_FOOT,
List.of("Dash forward with native horizontal movement.",
"Travel up to " + decimal(current.distance()) + " blocks while obstacles remain authoritative."),
"Use while grounded, on foot, and not flying or gliding.",
Map.of(Stat.DEX, BigInteger.valueOf(12)), BigInteger.ONE,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("distance", BigDecimal.valueOf(current.distance()).stripTrailingZeros(),
"speed", BigDecimal.valueOf(current.speedPerTick()).stripTrailingZeros()),
AbilityDefinition.TargetPolicy.SELF, this);
}
@Override public boolean accepts(ControlInput input) {
var player = input.player();
if (!player.isOnGround() || player.isFlying() || player.isGliding() || player.isInsideVehicle()) return false;
Vector direction = player.getEyeLocation().getDirection(); direction.setY(0);
return finite(direction) && direction.lengthSquared() > 1.0e-12;
}
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) return;
var current = from(execution.definition());
var player = execution.input().player();
if (!accepts(execution.input())) { execution.finish(); return; }
Vector direction = player.getEyeLocation().getDirection(); direction.setY(0).normalize();
Vector before = player.getVelocity();
Vector horizontal = direction.multiply(current.speedPerTick());
var proposed = new Vector(horizontal.getX(), before.getY(), horizontal.getZ());
var event = new PlayerVelocityEvent(player, proposed.clone());
velocityEvents.accept(event);
if (event.isCancelled() || !finite(event.getVelocity())) { execution.finish(); return; }
Vector accepted = event.getVelocity().clone(); accepted.setY(before.getY());
Vector acceptedHorizontal = new Vector(accepted.getX(), 0, accepted.getZ());
if (acceptedHorizontal.lengthSquared() < 1.0e-12) { execution.finish(); return; }
var state = new State(execution, player, player.getLocation().clone(), acceptedHorizontal, current);
var previous = dashes.put(player.getUniqueId(), state);
if (previous != null) previous.execution.finish();
execution.own(state);
player.setVelocity(accepted);
long ticks = Math.max(1, (long) Math.ceil(current.distance() / acceptedHorizontal.length()));
execution.later(Duration.ofMillis(Math.multiplyExact(ticks + 1, 50)), execution::finish);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void moved(PlayerMoveEvent event) {
var state = dashes.get(event.getPlayer().getUniqueId());
if (state == null || state.player != event.getPlayer()) return;
var to = event.getTo();
if (to == null || !state.execution.stillCurrent()) { state.execution.finish(); return; }
Vector current = state.player.getVelocity();
if (!state.stillOwns(current)) {
state.owned = false; state.execution.finish(); return;
}
double dx = to.getX() - state.origin.getX(); double dz = to.getZ() - state.origin.getZ();
if (dx * dx + dz * dz >= state.settings.distance() * state.settings.distance()
|| new Vector(current.getX(), 0, current.getZ()).lengthSquared() < 1.0e-12) {
state.execution.finish();
}
}
private static boolean finite(Vector vector) {
return Double.isFinite(vector.getX()) && Double.isFinite(vector.getY()) && Double.isFinite(vector.getZ());
}
private static Settings from(AbilityDefinition definition) {
return new Settings(definition.cooldown(), definition.parameters().get("distance").doubleValue(),
definition.parameters().get("speed").doubleValue());
}
private static BigDecimal decimal(double value) { return BigDecimal.valueOf(value).stripTrailingZeros(); }
@Override public void close() {
if (closed) return;
closed = true;
for (var state : List.copyOf(dashes.values())) state.execution.finish();
dashes.clear();
}
}
@@ -0,0 +1,374 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.items.CharacterSheets;
import games.dmg.fantasystick.items.TailBooks;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.persistence.RevivalStore;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
import net.kyori.adventure.text.Component;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
/** Revive target selection, contained popup and proof-based Tail conversion. */
public final class Revive implements AbilityEffect, Listener, AutoCloseable {
public interface PopupFactory { Inventory create(int size, Component title); }
private static final Duration DEATH_AGE = Duration.ofHours(24);
private static final Duration VIEW_TIME = Duration.ofMinutes(5);
private static final double RANGE = 12;
private record Candidate(int slot, ItemStack captured, TailBooks.Identity identity) { }
private record Choice(Candidate candidate, CharacterRecord record) { }
private final CharacterStorage characters;
private final RevivalStore reservations;
private final Clock clock;
private final Consumer<Runnable> nextTick;
private final PopupFactory popups;
private final Consumer<Throwable> failures;
private final TailBooks tails = new TailBooks();
private final CharacterSheets sheets = new CharacterSheets();
private final Map<Inventory, View> inventories = new IdentityHashMap<>();
private final Map<UUID, View> targets = new LinkedHashMap<>();
private boolean closed;
private final class View implements AutoCloseable {
final AbilityExecution execution;
final Player target;
final Inventory inventory;
final Map<Integer, Choice> choices;
boolean selecting;
boolean finished;
View(AbilityExecution execution, Player target, Inventory inventory, Map<Integer, Choice> choices) {
this.execution = execution; this.target = target; this.inventory = inventory; this.choices = choices;
}
@Override public void close() {
if (finished) return;
finished = true;
inventories.remove(inventory, this); targets.remove(target.getUniqueId(), this);
inventory.clear();
try { target.closeInventory(); } catch (RuntimeException failure) { report(failure); }
}
}
public Revive(CharacterStorage characters, RevivalStore reservations, Clock clock, Consumer<Runnable> nextTick,
PopupFactory popups, Consumer<Throwable> failures) {
this.characters = Objects.requireNonNull(characters); this.reservations = Objects.requireNonNull(reservations);
this.clock = Objects.requireNonNull(clock); this.nextTick = Objects.requireNonNull(nextTick);
this.popups = Objects.requireNonNull(popups); this.failures = Objects.requireNonNull(failures);
}
public AbilityDefinition definition() {
return new AbilityDefinition("revive", "Revive", Material.ECHO_SHARD,
List.of("Let another player continue one fallen character.", "A valid cast spends the daily opportunity."),
"Use the equipped shard while aiming at another player within 12 blocks.",
Map.of(Stat.WIS, BigInteger.valueOf(30), Stat.CHR, BigInteger.valueOf(24), Stat.CON, BigInteger.valueOf(12)),
BigInteger.ONE, AbilityDefinition.Activation.SELECTION, ControlInput.Gesture.USE, Duration.ofHours(24),
Map.of("range", BigDecimal.valueOf(12), "selection-time", BigDecimal.valueOf(300),
"death-age", BigDecimal.valueOf(86400)),
AbilityDefinition.TargetPolicy.EXCEPTIONAL_NONCOMBAT_SELECTION, this);
}
@Override public boolean accepts(ControlInput input) { return !closed && target(input) != null; }
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) { execution.finish(); return; }
Player target = target(execution.input());
if (target == null) {
execution.input().player().sendMessage("Revive found no eligible player within 12 blocks and line of sight.");
execution.finish(); return;
}
var captured = candidates(target);
if (captured.isEmpty()) {
execution.input().player().sendMessage("That player has no direct-inventory Tail eligible for Revive.");
execution.finish(); return;
}
var loads = captured.stream().map(candidate -> characters.find(candidate.identity().characterId())).toList();
CompletableFuture.allOf(loads.toArray(CompletableFuture[]::new)).whenComplete((ignored, failure) -> schedule(() -> {
if (failure != null) { report(failure); execution.input().player().sendMessage("Revive could not inspect those Tails safely."); execution.finish(); return; }
if (closed || !execution.stillCurrent() || !target.isOnline()) { execution.finish(); return; }
var eligible = new ArrayList<Choice>();
Instant now = clock.instant();
for (int index = 0; index < captured.size(); index++) {
var found = loads.get(index).join();
if (found.isEmpty()) continue;
var record = found.orElseThrow();
var candidate = captured.get(index);
if (eligible(record, candidate.identity(), now) && unchanged(target, candidate)) eligible.add(new Choice(candidate, record));
}
if (eligible.isEmpty()) {
execution.input().player().sendMessage("That player has no current Tail younger than 24 hours.");
execution.finish(); return;
}
open(execution, target, eligible, now);
}));
}
private Player target(ControlInput input) {
Player caster = input.player();
Entity supplied = input.target();
Player selected = supplied instanceof Player player ? player : null;
if (selected == null) {
Location eye = caster.getEyeLocation();
var hit = caster.getWorld().rayTraceEntities(eye, eye.getDirection(), RANGE,
entity -> entity instanceof Player && entity != caster);
if (hit != null && hit.getHitEntity() instanceof Player player) selected = player;
}
if (selected == null || selected == caster || !selected.isOnline() || selected.getWorld() != caster.getWorld()
|| selected.getLocation().distanceSquared(caster.getLocation()) > RANGE * RANGE
|| !caster.hasLineOfSight(selected)) return null;
return selected;
}
private List<Candidate> candidates(Player target) {
var result = new ArrayList<Candidate>();
for (int slot = 0; slot <= 40; slot++) {
if (slot > 35 && slot != 40) continue;
ItemStack item = target.getInventory().getItem(slot);
if (item == null || item.isEmpty()) continue;
var identity = tails.identify(item);
if (identity.isPresent()) result.add(new Candidate(slot, item.clone(), identity.orElseThrow()));
}
return List.copyOf(result);
}
private boolean eligible(CharacterRecord record, TailBooks.Identity identity, Instant now) {
if (!record.dead() || !record.id().equals(identity.characterId()) || !Objects.equals(record.deathId(), identity.deathId())) return false;
Duration age = Duration.between(record.diedAt(), now);
return !age.isNegative() && age.compareTo(DEATH_AGE) < 0;
}
private boolean unchanged(Player target, Candidate candidate) {
ItemStack current = target.getInventory().getItem(candidate.slot());
return current != null && current.equals(candidate.captured());
}
private void open(AbilityExecution execution, Player target, List<Choice> eligible, Instant now) {
View old = targets.get(target.getUniqueId()); if (old != null) old.execution.finish();
int size = Math.min(54, Math.max(9, ((eligible.size() + 8) / 9) * 9));
Inventory inventory = popups.create(size, Component.text("Choose a Tail to Revive"));
var choices = new LinkedHashMap<Integer, Choice>();
for (int index = 0; index < eligible.size() && index < size; index++) {
Choice choice = eligible.get(index); ItemStack display = choice.candidate().captured().clone(); display.setAmount(1);
var meta = display.getItemMeta();
long seconds = Math.max(0, Duration.between(now, choice.record().diedAt().plus(DEATH_AGE)).toSeconds());
meta.lore(List.of(Component.text("Level " + choice.record().level()),
Component.text("Eligible for " + seconds + " more seconds"), Component.text("Click to continue this character")));
display.setItemMeta(meta); inventory.setItem(index, display); choices.put(index, choice);
}
var view = new View(execution, target, inventory, Map.copyOf(choices));
inventories.put(inventory, view); targets.put(target.getUniqueId(), view); execution.own(view);
target.openInventory(inventory);
execution.later(VIEW_TIME, execution::finish);
}
@EventHandler(priority = EventPriority.HIGHEST)
public void click(org.bukkit.event.inventory.InventoryClickEvent event) {
var view = inventories.get(event.getView().getTopInventory());
if (view == null) return;
event.setCancelled(true);
if (event.getWhoClicked() != view.target || event.getRawSlot() < 0 || event.getRawSlot() >= view.inventory.getSize()) return;
Choice choice = view.choices.get(event.getRawSlot());
if (choice == null || view.selecting) return;
view.selecting = true; inventories.remove(view.inventory, view); targets.remove(view.target.getUniqueId(), view);
view.inventory.clear(); view.target.closeInventory();
confirm(view, choice);
}
@EventHandler(priority = EventPriority.HIGHEST)
public void drag(org.bukkit.event.inventory.InventoryDragEvent event) {
var view = inventories.get(event.getView().getTopInventory());
if (view != null && event.getRawSlots().stream().anyMatch(slot -> slot < view.inventory.getSize())) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.MONITOR)
public void close(org.bukkit.event.inventory.InventoryCloseEvent event) {
var view = inventories.get(event.getView().getTopInventory());
if (view != null && !view.selecting) view.execution.finish();
}
@EventHandler(priority = EventPriority.MONITOR)
public void quit(org.bukkit.event.player.PlayerQuitEvent event) {
var targetView = targets.get(event.getPlayer().getUniqueId()); if (targetView != null) targetView.execution.finish();
for (var view : List.copyOf(inventories.values())) {
if (view.execution.input().player() == event.getPlayer()) view.execution.finish();
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void join(org.bukkit.event.player.PlayerJoinEvent event) { reconcile(event.getPlayer()); }
private void confirm(View view, Choice choice) {
if (closed || !view.execution.stillCurrent() || !view.target.isOnline() || !unchanged(view.target, choice.candidate())) {
view.execution.finish(); return;
}
characters.find(choice.record().id()).whenComplete((found, failure) -> schedule(() -> {
if (failure != null) { report(failure); view.execution.input().player().sendMessage("Revive could not recheck that Tail; no item changed."); view.execution.finish(); return; }
if (closed || !view.execution.stillCurrent() || !view.target.isOnline() || found.isEmpty()
|| !eligible(found.orElseThrow(), choice.candidate().identity(), clock.instant())
|| !unchanged(view.target, choice.candidate())) {
view.execution.input().player().sendMessage("That Tail is no longer eligible; no item changed.");
view.execution.finish(); return;
}
reserve(view, choice.candidate(), found.orElseThrow());
}));
}
private void reserve(View view, Candidate candidate, CharacterRecord dead) {
ItemStack source = view.target.getInventory().getItem(candidate.slot());
int destination = source.getAmount() == 1 ? -1 : emptyDirectSlot(view.target, candidate.slot());
if (source.getAmount() > 1 && destination < 0) {
view.target.sendMessage("Revive needs one empty direct inventory slot to preserve the copied Tails.");
view.execution.finish(); return;
}
var reservation = new RevivalStore.Reservation(UUID.randomUUID(), view.execution.character().id(),
view.target.getUniqueId(), dead.id(), dead.deathId(), clock.instant().truncatedTo(ChronoUnit.SECONDS),
candidate.slot(), source.getAmount(), destination);
reservations.reserve(reservation).whenComplete((reserved, failure) -> schedule(() -> {
if (failure != null) { report(failure); view.execution.input().player().sendMessage("Revive reservation could not be saved; no Tail changed."); view.execution.finish(); return; }
if (reserved.isEmpty() || closed || !view.execution.stillCurrent() || !view.target.isOnline()
|| !unchanged(view.target, candidate)) {
view.execution.input().player().sendMessage("Revive selection became stale after reservation; no Tail was changed.");
view.execution.finish(); return;
}
deliver(view, dead, reservation);
}));
}
private int emptyDirectSlot(Player target, int source) {
for (int slot = 0; slot <= 40; slot++) {
if (slot > 35 && slot != 40 || slot == source) continue;
ItemStack 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());
ItemStack sheet = sheets.create(provisional);
ItemStack 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.target.sendMessage("Revive item delivery is uncertain; retained proof requires recovery.");
view.execution.finish(); return;
}
var nativeLimit = games.dmg.fantasystick.session.NativeHealthLimit.read();
characters.restoreDeathRecord(reservation.character(), reservation.death(), nativeLimit)
.whenComplete((restored, failure) -> schedule(() -> {
if (failure != null || restored.isEmpty()) {
if (failure != null) report(failure);
view.target.sendMessage("The Tail changed, but character restoration is pending recovery; do not move the sheet.");
view.execution.finish(); return;
}
if (installFinalSheet(view.target, restored.orElseThrow())) {
reservations.clear(reservation.character(), reservation.id()).exceptionally(failure2 -> { report(failure2); return false; });
view.target.sendMessage("Restored character sheet: " + restored.orElseThrow().name() + ".");
view.execution.input().player().sendMessage("Revive restored " + restored.orElseThrow().name() + ".");
} else view.target.sendMessage("Character restoration was saved, but sheet custody is ambiguous; retained proof requires recovery.");
view.execution.finish();
}));
}
private boolean installFinalSheet(Player target, CharacterRecord record) {
int slot = uniqueSheetSlot(target, record.id());
if (slot < 0) return false;
target.getInventory().setItem(slot, sheets.create(record)); target.updateInventory(); return true;
}
private int uniqueSheetSlot(Player target, UUID character) {
int found = -1;
for (int slot = 0; slot <= 40; slot++) {
if (slot > 35 && slot != 40) continue;
if (sheets.identify(target.getInventory().getItem(slot)).filter(character::equals).isEmpty()) continue;
if (found >= 0) return -2;
found = slot;
}
return found;
}
public void reconcile(Player target) {
if (closed || !target.isOnline()) return;
reservations.target(target.getUniqueId()).whenComplete((found, failure) -> {
if (failure != null) { report(failure); return; }
for (var reservation : found) characters.find(reservation.character()).whenComplete((record, loadFailure) -> schedule(() -> {
if (loadFailure != null) { report(loadFailure); return; }
if (closed || !target.isOnline() || record.isEmpty()) return;
reconcile(target, reservation, record.orElseThrow());
}));
});
}
private void reconcile(Player target, RevivalStore.Reservation reservation, CharacterRecord record) {
int foundSheet = uniqueSheetSlot(target, reservation.character());
boolean hasSheet = foundSheet >= 0;
ItemStack source = target.getInventory().getItem(reservation.sourceSlot());
boolean unchangedTail = source != null && source.getAmount() == reservation.sourceAmount()
&& tails.identify(source).filter(id -> id.characterId().equals(reservation.character()) && id.deathId().equals(reservation.death())).isPresent();
boolean consumedTail = reservation.sourceAmount() == 1 ? hasSheet
: source != null && source.getAmount() == reservation.sourceAmount() - 1
&& tails.identify(source).filter(id -> id.deathId().equals(reservation.death())).isPresent();
if (!record.dead() && hasSheet && installFinalSheet(target, record)) {
reservations.clear(reservation.character(), reservation.id()); return;
}
if (record.dead() && unchangedTail && !hasSheet) {
reservations.clear(reservation.character(), reservation.id()); return;
}
if (record.dead() && hasSheet && (consumedTail || unchangedTail && reservation.sourceAmount() > 1)) {
if (unchangedTail && reservation.sourceAmount() > 1) {
var remaining = source.clone(); remaining.setAmount(source.getAmount() - 1);
target.getInventory().setItem(reservation.sourceSlot(), remaining); target.updateInventory();
}
var nativeLimit = games.dmg.fantasystick.session.NativeHealthLimit.read();
characters.restoreDeathRecord(reservation.character(), reservation.death(), nativeLimit)
.whenComplete((restored, failure) -> schedule(() -> {
if (failure != null) { report(failure); return; }
if (restored.isPresent() && installFinalSheet(target, restored.orElseThrow())) {
reservations.clear(reservation.character(), reservation.id());
}
}));
}
}
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) { } }
@Override public void close() {
if (closed) return; closed = true;
for (var view : List.copyOf(inventories.values())) view.execution.finish();
inventories.clear(); targets.clear();
}
}
@@ -0,0 +1,115 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Consumer;
import java.util.function.Supplier;
import org.bukkit.Material;
import org.bukkit.attribute.Attribute;
import org.bukkit.event.entity.EntityRegainHealthEvent;
/** Second Wind's validated persist-before-publication self-heal. */
public final class SecondWind implements AbilityEffect {
public record Settings(BigInteger baseHealing, Duration cooldown) {
public Settings {
Objects.requireNonNull(baseHealing); Objects.requireNonNull(cooldown);
if (baseHealing.signum() < 0 || baseHealing.compareTo(BigDecimal.valueOf(Double.MAX_VALUE).toBigInteger()) > 0
|| cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0) {
throw new IllegalArgumentException("Invalid Second Wind settings");
}
}
public static Settings defaults() { return new Settings(BigInteger.valueOf(6), Duration.ofSeconds(15)); }
}
private final CharacterStorage characters;
private final EquipmentSessions sessions;
private final Consumer<Runnable> nextTick;
private final Consumer<EntityRegainHealthEvent> healingEvents;
private final Supplier<BigDecimal> nativeLimit;
private volatile Settings settings;
public SecondWind(Settings settings, CharacterStorage characters, EquipmentSessions sessions,
Consumer<Runnable> nextTick, Consumer<EntityRegainHealthEvent> healingEvents,
Supplier<BigDecimal> nativeLimit) {
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);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
var current = settings;
return new AbilityDefinition("second_wind", "Second Wind", Material.GHAST_TEAR,
List.of("Restore missing fantasy health.",
"Heals " + current.baseHealing() + " + WIS modifier, capped at maximum health."),
"Use while injured; full health refuses without spending cooldown.",
Map.of(Stat.CON, BigInteger.valueOf(12), Stat.WIS, BigInteger.valueOf(12)), BigInteger.ONE,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("base-healing", new BigDecimal(current.baseHealing())),
AbilityDefinition.TargetPolicy.SELF, this);
}
@Override public boolean accepts(ControlInput input) {
double health = input.player().getHealth();
var attribute = input.player().getAttribute(Attribute.MAX_HEALTH);
return attribute != null && Double.isFinite(health) && health >= 0
&& Double.isFinite(attribute.getValue()) && health < attribute.getValue();
}
public static BigInteger healing(BigInteger wisdom) { return healing(BigInteger.valueOf(6), wisdom); }
static BigInteger healing(BigInteger base, BigInteger wisdom) {
Objects.requireNonNull(base); Objects.requireNonNull(wisdom);
if (base.signum() < 0 || wisdom.signum() < 0) throw new IllegalArgumentException("Invalid Second Wind healing input");
BigInteger delta = wisdom.subtract(BigInteger.TEN); BigInteger[] divided = delta.divideAndRemainder(BigInteger.TWO);
BigInteger modifier = divided[0];
if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
return base.add(modifier);
}
@Override public void activate(AbilityExecution execution) {
if (!execution.stillCurrent()) return;
var definition = execution.definition();
BigInteger base = definition.parameters().get("base-healing").toBigIntegerExact();
var player = execution.input().player();
var attribute = player.getAttribute(Attribute.MAX_HEALTH);
if (attribute == null) { execution.finish(); return; }
double current = player.getHealth(); double maximum = attribute.getValue();
double missing = maximum - current;
if (!Double.isFinite(current) || !Double.isFinite(maximum) || missing <= 0) { execution.finish(); return; }
BigInteger exact = healing(base, execution.character().stats().get(Stat.WIS));
double proposed = exact.bitLength() > 1023 ? missing : Math.min(missing, exact.doubleValue());
if (!Double.isFinite(proposed) || proposed <= 0) { execution.finish(); return; }
var event = new EntityRegainHealthEvent(player, proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
healingEvents.accept(event);
double accepted = Math.min(missing, event.getAmount());
if (event.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) { execution.finish(); return; }
var record = execution.character(); var expected = record.revision();
BigDecimal target = BigDecimal.valueOf(current + accepted);
BigDecimal limit = nativeLimit.get();
characters.saveHealth(record.id(), expected, target, limit).whenComplete((saved, failure) -> {
try {
nextTick.accept(() -> {
try {
if (failure != null || saved == null || saved.isEmpty() || !execution.stillCurrent()) {
player.sendMessage("Second Wind health could not be saved; no healing was applied."); return;
}
var persisted = saved.orElseThrow();
if (!sessions.acknowledgeProgress(execution.input().session(), expected, persisted)) {
player.sendMessage("Second Wind was saved, but the session changed; no native healing was applied."); return;
}
double publish = Math.min(attribute.getValue(), Math.max(player.getHealth(), persisted.health().doubleValue()));
player.setHealth(publish);
} finally { execution.finish(); }
});
} catch (RuntimeException rejected) { execution.finish(); }
});
}
}
@@ -0,0 +1,144 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Consumer;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.damage.DamageSource;
import org.bukkit.damage.DamageType;
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.EntityDamageByEntityEvent;
import org.bukkit.event.player.PlayerVelocityEvent;
import org.bukkit.util.Vector;
/** Shield Bash's contained short-range custom strike and post-damage knockback. */
public final class ShieldBash implements AbilityEffect, Listener, AutoCloseable {
public record Settings(BigInteger baseDamage, Duration cooldown, double range,
double horizontalKnockback, double minimumVerticalKnockback) {
public Settings {
Objects.requireNonNull(baseDamage); Objects.requireNonNull(cooldown);
if (baseDamage.signum() < 0 || cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|| !Double.isFinite(range) || range <= 0 || range > 64
|| !Double.isFinite(horizontalKnockback) || horizontalKnockback < 0 || horizontalKnockback > 8
|| !Double.isFinite(minimumVerticalKnockback) || minimumVerticalKnockback < 0
|| minimumVerticalKnockback > 8) throw new IllegalArgumentException("Invalid Shield Bash settings");
}
public static Settings defaults() {
return new Settings(BigInteger.valueOf(4), Duration.ofSeconds(6), 3, 0.8, 0.35);
}
}
private static final class Attempt {
final AbilityExecution execution; final LivingEntity target; final Settings settings; boolean accepted;
Attempt(AbilityExecution execution, LivingEntity target, Settings settings) {
this.execution = execution; this.target = target; this.settings = settings;
}
}
private volatile Settings settings;
private final Consumer<PlayerVelocityEvent> velocityEvents;
private Attempt damaging;
private boolean closed;
public ShieldBash(Settings settings, Consumer<PlayerVelocityEvent> velocityEvents) {
this.settings = Objects.requireNonNull(settings); this.velocityEvents = Objects.requireNonNull(velocityEvents);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
var current = settings;
return new AbilityDefinition("shield_bash", "Shield Bash", Material.SHIELD,
List.of("Strike within " + decimal(current.range()) + " blocks for " + current.baseDamage() + " + STR modifier damage.",
"Accepted damage adds knockback; this shield never enables ordinary blocking."),
"Attack a visible hostile at short range.", Map.of(Stat.STR, BigInteger.valueOf(12), Stat.CON, BigInteger.valueOf(12)),
BigInteger.ONE, AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.ATTACK, current.cooldown(),
Map.of("base-damage", new BigDecimal(current.baseDamage()), "range", decimalValue(current.range()),
"horizontal-knockback", decimalValue(current.horizontalKnockback()),
"minimum-vertical-knockback", decimalValue(current.minimumVerticalKnockback())),
AbilityDefinition.TargetPolicy.HOSTILE, this);
}
@Override public boolean accepts(ControlInput input) {
return !closed && input.target() instanceof LivingEntity target && inRange(input.player(), target, settings.range());
}
public static BigInteger damage(BigInteger strength) { return damage(BigInteger.valueOf(4), strength); }
static BigInteger damage(BigInteger base, BigInteger strength) {
Objects.requireNonNull(base); Objects.requireNonNull(strength);
if (base.signum() < 0 || strength.signum() < 0) throw new IllegalArgumentException("Invalid Shield Bash damage input");
var delta = strength.subtract(BigInteger.TEN); var divided = delta.divideAndRemainder(BigInteger.TWO);
var modifier = divided[0];
if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
return base.add(modifier);
}
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent() || !(execution.input().target() instanceof LivingEntity target)) return;
var current = from(execution.definition()); var player = execution.input().player();
if (!inRange(player, target, current.range())) { execution.finish(); return; }
var exact = damage(current.baseDamage(), execution.character().stats().get(Stat.STR));
double amount = exact.bitLength() > 1023 ? Double.MAX_VALUE : exact.doubleValue();
if (!Double.isFinite(amount) || amount <= 0) { execution.finish(); return; }
var source = DamageSource.builder(DamageType.PLAYER_ATTACK).withCausingEntity(player).withDirectEntity(player).build();
var attempt = new Attempt(execution, target, current); damaging = attempt;
try { target.damage(amount, source); }
finally { if (damaging == attempt) damaging = null; execution.finish(); }
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void acceptedDamage(EntityDamageByEntityEvent event) {
var attempt = damaging;
if (closed || attempt == null || attempt.accepted || event.isCancelled() || event.getFinalDamage() <= 0
|| !Double.isFinite(event.getFinalDamage()) || event.getDamager() != attempt.execution.input().player()
|| event.getEntity() != attempt.target || !attempt.execution.stillCurrent()) return;
attempt.accepted = true;
attempt.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(),
BigDecimal.valueOf(event.getFinalDamage()).stripTrailingZeros());
knockback(attempt);
}
private void knockback(Attempt attempt) {
var player = attempt.execution.input().player(); var target = attempt.target;
Vector away = target.getLocation().toVector().subtract(player.getLocation().toVector()); away.setY(0);
if (!finite(away) || away.lengthSquared() < 1.0e-12) return;
Vector current = target.getVelocity();
Vector proposed = current.clone().add(away.normalize().multiply(attempt.settings.horizontalKnockback()));
proposed.setY(Math.max(current.getY(), attempt.settings.minimumVerticalKnockback()));
if (!finite(proposed)) return;
if (target instanceof Player targetPlayer) {
var event = new PlayerVelocityEvent(targetPlayer, proposed.clone()); velocityEvents.accept(event);
if (event.isCancelled() || !finite(event.getVelocity())) return;
proposed = event.getVelocity().clone();
}
target.setVelocity(proposed);
}
private static boolean inRange(Player player, LivingEntity target, double range) {
try {
Location source = player.getLocation(); Location destination = target.getLocation();
return source.getWorld() == destination.getWorld() && source.distanceSquared(destination) <= range * range
&& player.hasLineOfSight(target);
} catch (RuntimeException invalid) { return false; }
}
private static boolean finite(Vector value) {
return Double.isFinite(value.getX()) && Double.isFinite(value.getY()) && Double.isFinite(value.getZ());
}
private static Settings from(AbilityDefinition definition) {
var p = definition.parameters();
return new Settings(p.get("base-damage").toBigIntegerExact(), definition.cooldown(), p.get("range").doubleValue(),
p.get("horizontal-knockback").doubleValue(), p.get("minimum-vertical-knockback").doubleValue());
}
private static BigDecimal decimalValue(double value) { return BigDecimal.valueOf(value).stripTrailingZeros(); }
private static String decimal(double value) { return decimalValue(value).toPlainString(); }
@Override public void close() { closed = true; damaging = null; }
}
@@ -0,0 +1,84 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import org.bukkit.Material;
import org.bukkit.entity.Player;
/** Stone Skin's captured, owned grant into the shared strongest-reduction stage. */
public final class StoneSkin implements AbilityEffect, AutoCloseable {
public record Settings(Duration cooldown, Duration duration, BigDecimal reductionPercent) {
public Settings {
Objects.requireNonNull(cooldown); Objects.requireNonNull(duration); Objects.requireNonNull(reductionPercent);
if (cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|| duration.isNegative() || duration.isZero() || duration.compareTo(Duration.ofDays(1)) > 0
|| reductionPercent.signum() < 0 || reductionPercent.compareTo(BigDecimal.valueOf(100)) > 0)
throw new IllegalArgumentException("Invalid Stone Skin settings");
}
public static Settings defaults() {
return new Settings(Duration.ofSeconds(14), Duration.ofSeconds(4), BigDecimal.valueOf(25));
}
}
private final class State implements AutoCloseable {
final AbilityExecution execution; final Player player; final AutoCloseable grant; boolean closed;
State(AbilityExecution execution, Player player, AutoCloseable grant) {
this.execution = execution; this.player = player; this.grant = grant;
}
@Override public void close() {
if (closed) return; closed = true; protections.remove(player.getUniqueId(), this);
try { grant.close(); } catch (Exception impossible) { throw new IllegalStateException(impossible); }
}
}
private volatile Settings settings;
private final CombatMitigation mitigation;
private final Map<UUID, State> protections = new java.util.HashMap<>();
private boolean closed;
public StoneSkin(Settings settings, CombatMitigation mitigation) {
this.settings = Objects.requireNonNull(settings); this.mitigation = Objects.requireNonNull(mitigation);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
var current = settings;
return new AbilityDefinition("stone_skin", "Stone Skin", Material.BRICK,
List.of("Reduce entity and projectile combat damage by " + decimal(current.reductionPercent()) + "%.",
"Lasts " + decimal(BigDecimal.valueOf(current.duration().toMillis()).movePointLeft(3)) + " seconds without stacking."),
"Use to protect yourself; environmental damage is unchanged.", Map.of(Stat.CON, BigInteger.valueOf(14)), BigInteger.ONE,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("duration-seconds", BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9),
"damage-reduction-percent", current.reductionPercent()), AbilityDefinition.TargetPolicy.SELF, this);
}
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) return;
var current = from(execution.definition()); var player = execution.input().player();
var grant = mitigation.grant(execution, player, current.reductionPercent());
var state = new State(execution, player, grant);
var previous = protections.put(player.getUniqueId(), state);
if (previous != null) previous.execution.finish();
execution.own(state); execution.later(current.duration(), execution::finish);
}
private static Settings from(AbilityDefinition definition) {
return new Settings(definition.cooldown(),
Duration.ofNanos(definition.parameters().get("duration-seconds").movePointRight(9).longValueExact()),
definition.parameters().get("damage-reduction-percent"));
}
private static String decimal(BigDecimal value) { return value.stripTrailingZeros().toPlainString(); }
@Override public void close() {
if (closed) return; closed = true;
for (var state : List.copyOf(protections.values())) state.execution.finish();
protections.clear();
}
}
@@ -16,30 +16,57 @@ import org.bukkit.Material;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.BookMeta;
/** Authoritative inspection and validated party controls; character mutation actions remain unfinished. */
/** Authoritative inspection, permanent-stat purchases and validated party controls. */
public final class CharacterBookController implements Consumer<ControlInput>, AutoCloseable, org.bukkit.event.Listener {
public record Failure(UUID player, UUID character, Throwable cause) { }
private final CharacterStorage characters;
private final ProgressionRules rules;
private final games.dmg.fantasystick.session.EquipmentSessions sessions;
private final PartyBookControls partyControls;
private final Consumer<Runnable> nextTick;
private final Consumer<Failure> failures;
private final games.dmg.fantasystick.ability.AbilityRegistry abilities;
private final games.dmg.fantasystick.ability.AbilityEngine abilityEngine;
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 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 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 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 final Map<UUID, View> views = new ConcurrentHashMap<>();
private final Map<UUID, StatView> statViews = new ConcurrentHashMap<>();
private final Map<UUID, AbilityView> abilityViews = new ConcurrentHashMap<>();
/** Scheduler must always defer to the server thread. Diagnostics must not access players or throw. */
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) {
this(characters, rules, sessions, nextTick, failures, parties, server, names,
new games.dmg.fantasystick.ability.AbilityRegistry(), 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) {
this.characters = Objects.requireNonNull(characters);
this.rules = Objects.requireNonNull(rules);
this.sessions = Objects.requireNonNull(sessions);
this.nextTick = Objects.requireNonNull(nextTick);
this.failures = Objects.requireNonNull(failures);
this.abilities = Objects.requireNonNull(abilities);
this.abilityEngine = abilityEngine;
this.partyControls = new PartyBookControls(parties, server, names, this::accept, this::report);
}
@@ -51,6 +78,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
var owner = input.session().player();
partyControls.forget(owner);
views.remove(owner);
statViews.remove(owner);
abilityViews.remove(owner);
Object ticket = new Object();
pending.put(owner, ticket);
characters.find(input.session().character()).whenComplete((record, failure) -> {
@@ -96,6 +125,24 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
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 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 (all.size() > VOLUME_SIZE) {
var view = new View(UUID.randomUUID().toString(), input, start > 0 ? start - VOLUME_SIZE : -1,
end < all.size() ? end : -1);
@@ -111,6 +158,52 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
return item;
}
private static java.util.List<Component> abilityPages(CharacterRecord record,
games.dmg.fantasystick.ability.AbilityDefinition definition, AbilityView view) {
String requirements = definition.requirements().isEmpty() ? "none" : definition.requirements().entrySet().stream()
.sorted(Map.Entry.comparingByKey()).map(entry -> entry.getKey() + " " + entry.getValue())
.collect(java.util.stream.Collectors.joining(", "));
boolean learned = record.learned().contains(definition.id());
boolean stats = definition.requirements().entrySet().stream()
.allMatch(entry -> record.stats().get(entry.getKey()).compareTo(entry.getValue()) >= 0);
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()
.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));
}
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);
}
private static String durationSeconds(java.time.Duration duration) {
return java.math.BigDecimal.valueOf(duration.getSeconds())
.add(java.math.BigDecimal.valueOf(duration.getNano(), 9)).stripTrailingZeros().toPlainString();
}
private static Component abilityButton(String label, net.kyori.adventure.key.Key action, AbilityView view,
String ability, Integer slot) {
String payload = "\"" + view.nonce() + ":" + ability + (slot == null ? "" : ":" + slot) + "\"";
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 pageButton(String label, View view, int direction) {
return Component.text("[" + label + "]", net.kyori.adventure.text.format.NamedTextColor.DARK_BLUE)
.clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(PAGES,
@@ -139,6 +232,113 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
catch (RuntimeException ignored) { /* A diagnostic failure must not replay a book request. */ }
}
@org.bukkit.event.EventHandler
public void statAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
if (closed || !STATS.equals(event.getIdentifier())
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)) return;
var player = connection.getPlayer();
var view = statViews.get(player.getUniqueId());
if (view == null || view.input().player() != player || event.getTag() == null) return;
String raw = event.getTag().string();
if (raw.length() > 80) return;
var match = STAT_PAYLOAD.matcher(raw);
if (!match.matches() || !view.nonce().equals(match.group(1))
|| !statViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
var stat = Stat.valueOf(match.group(2));
var currentHealth = java.math.BigDecimal.valueOf(player.getHealth());
var nativeLimit = NativeHealthLimit.read();
characters.purchaseStat(view.input().session().character(), view.revision(), stat, currentHealth, nativeLimit)
.whenComplete((purchase, failure) -> {
try {
nextTick.accept(() -> finishPurchase(view, stat, purchase, failure));
} catch (RuntimeException rejected) { report(view.input(), rejected); }
});
}
private void finishPurchase(StatView view, Stat stat, CharacterStorage.StatPurchase purchase, Throwable failure) {
var input = view.input();
if (closed || !input.stillCurrent()) return;
if (failure != null) {
report(input, failure);
input.player().sendMessage("Stat purchase could not be saved; no character state was changed.");
return;
}
if (purchase.status() == CharacterStorage.StatPurchaseStatus.APPLIED) {
var saved = purchase.record().orElseThrow();
try {
if (!sessions.acknowledgeProgress(input.session(), view.revision(), saved)) {
input.player().sendMessage("Stat purchase was saved; reopen the character after the session transition.");
return;
}
} catch (RuntimeException nativeFailure) {
report(input, nativeFailure);
input.player().sendMessage("Stat purchase was saved, but native health refresh failed; exit for recovery.");
return;
}
input.player().sendMessage(stat + " increased to " + saved.stats().get(stat) + ".");
load(input, 0);
return;
}
String message = switch (purchase.status()) {
case STALE -> "That character page is stale; reopen it before spending points.";
case NO_POINTS -> "This character has no unspent points.";
case UNAVAILABLE -> "This character is no longer available.";
case APPLIED -> throw new IllegalStateException("Handled applied purchase");
};
input.player().sendMessage(message);
load(input, 0);
}
@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
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)
|| event.getTag() == null) return;
var player = connection.getPlayer();
var view = abilityViews.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))
|| 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());
operation.whenComplete((mutation, failure) -> {
if (closed || !view.input().stillCurrent()) return;
if (failure != null) {
report(view.input(), failure);
player.sendMessage("Ability change could not be confirmed; reopen the character after session recovery.");
return;
}
player.sendMessage(abilityMessage(unlock, unlearn, ability, mutation.status()));
load(view.input(), 0);
});
}
private static String abilityMessage(boolean unlock, boolean unlearn, 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 REQUIREMENTS -> "The purchased base-stat requirements are not met.";
case NO_POINTS -> "This character does not have enough unspent points.";
case ALREADY_LEARNED -> "This character already knows " + ability + ".";
case NOT_LEARNED -> unlearn ? "This character no longer knows " + ability + "."
: "Learn " + ability + " before assigning it.";
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.";
};
}
@org.bukkit.event.EventHandler
public void partyAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) { partyControls.click(event); }
@@ -147,8 +347,12 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
var owner = event.getPlayer().getUniqueId();
pending.remove(owner);
views.remove(owner);
statViews.remove(owner);
abilityViews.remove(owner);
partyControls.forget(owner);
}
@Override public void close() { closed = true; pending.clear(); views.clear(); partyControls.close(); }
@Override public void close() {
closed = true; pending.clear(); views.clear(); statViews.clear(); abilityViews.clear(); partyControls.close();
}
}
@@ -0,0 +1,91 @@
package games.dmg.fantasystick.combat;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigInteger;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.entity.LivingEntity;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
/** Server-thread built-in attack consumer; the bound stick's vanilla attack is always contained upstream. */
public final class BasicAttack implements Consumer<ControlInput>, Listener, AutoCloseable {
private static final Duration CADENCE = Duration.ofSeconds(1);
private final EquipmentSessions sessions;
private final Clock clock;
private final Map<UUID, Instant> readyAt = new HashMap<>();
private Pending pending;
private boolean closed;
private static final class Pending {
final ControlInput input;
final LivingEntity target;
final Instant attackedAt;
boolean accepted;
Pending(ControlInput input, LivingEntity target, Instant attackedAt) {
this.input = input; this.target = target; this.attackedAt = attackedAt;
}
}
public BasicAttack(EquipmentSessions sessions) {
this(sessions, Clock.systemUTC());
}
public BasicAttack(EquipmentSessions sessions, Clock clock) {
this.sessions = Objects.requireNonNull(sessions);
this.clock = Objects.requireNonNull(clock);
}
@Override public void accept(ControlInput input) {
if (closed || pending != null || input.kind() != ControlInput.Kind.BASIC_ATTACK
|| input.gesture() != ControlInput.Gesture.ATTACK
|| !(input.target() instanceof LivingEntity target) || !input.stillCurrent()) return;
var record = sessions.currentRecord(input.session()).orElse(null);
if (record == null) return;
Instant now = clock.instant();
Instant ready = readyAt.get(input.session().character());
if (ready != null && now.isBefore(ready)) return;
BigInteger damage = damage(record.stats().get(Stat.STR));
double nativeDamage = damage.bitLength() > 1023 ? Double.MAX_VALUE : damage.doubleValue();
var attempt = new Pending(input, target, now);
pending = attempt;
try {
target.damage(nativeDamage, input.player());
} finally {
if (pending == attempt) pending = null;
if (attempt.accepted) readyAt.put(input.session().character(), attempt.attackedAt.plus(CADENCE));
}
}
static BigInteger damage(BigInteger strength) {
Objects.requireNonNull(strength);
if (strength.signum() < 0) throw new IllegalArgumentException("Strength must not be negative");
return strength.subtract(BigInteger.TEN).divide(BigInteger.TWO).max(BigInteger.ONE);
}
/** MONITOR observes the synchronous native result; cancelled/zero damage never spends cadence. */
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void acceptedDamage(EntityDamageByEntityEvent event) {
var attempt = pending;
if (closed || attempt == null || event.isCancelled() || event.getFinalDamage() <= 0
|| event.getDamager() != attempt.input.player() || event.getEntity() != attempt.target
|| !attempt.input.stillCurrent() || !sessions.current(attempt.input.session())) return;
attempt.accepted = true;
}
@Override public void close() {
closed = true;
pending = null;
readyAt.clear();
}
}
@@ -17,6 +17,7 @@ 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 {
private final games.dmg.fantasystick.ability.AbilityRegistry registry;
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");
@@ -51,6 +52,16 @@ public final class SessionControls {
public record Identity(UUID owner, UUID session, int slot) { }
public SessionControls() { this(new games.dmg.fantasystick.ability.AbilityRegistry()); }
public SessionControls(games.dmg.fantasystick.ability.AbilityRegistry registry) {
this.registry = Objects.requireNonNull(registry);
}
private Icon icon(String ability) {
var definition = registry.find(ability).orElse(null);
return definition == null ? ABILITIES.get(ability) : new Icon(definition.icon(), definition.name());
}
/**
* Builds the entire detached hotbar before a caller may start packing equipment.
* Slot zero is a plain book control, not the pending character-book UI.
@@ -62,10 +73,11 @@ public final class SessionControls {
var items = new ItemStack[9];
for (int slot = 0; slot < items.length; slot++) {
String ability = slot < 2 ? "" : character.loadout().get(slot - 2);
Icon abilityIcon = ability.isEmpty() ? null : icon(ability);
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")
: ABILITIES.getOrDefault(ability, new Icon(Material.PAPER, "Unavailable ability: " + ability));
: abilityIcon == null ? new Icon(Material.PAPER, "Unavailable ability: " + ability) : abilityIcon;
var item = new ItemStack(icon.material());
var meta = item.getItemMeta();
meta.displayName(Component.text(icon.name()));
@@ -76,7 +88,7 @@ public final class SessionControls {
data.set(SESSION, PersistentDataType.STRING, session.toString());
data.set(SLOT, PersistentDataType.INTEGER, slot);
data.set(KIND, PersistentDataType.STRING, slot == 0 ? "book" : slot == 1 ? "basic_attack"
: ability.isEmpty() ? "empty" : ABILITIES.containsKey(ability) ? "ability" : "unavailable");
: ability.isEmpty() ? "empty" : abilityIcon != null ? "ability" : "unavailable");
if (!ability.isEmpty()) data.set(ABILITY, PersistentDataType.STRING, ability);
item.setItemMeta(meta);
items[slot] = item;
@@ -115,7 +127,7 @@ public final class SessionControls {
? Optional.of(data.get(ABILITY, PersistentDataType.STRING)) : Optional.empty();
}
private static boolean validKind(Material material, int slot, PersistentDataContainer data) {
private boolean validKind(Material material, int slot, PersistentDataContainer data) {
String kind = data.get(KIND, PersistentDataType.STRING);
if (slot == 0) return kind.equals("book") && material == Material.BOOK && !data.has(ABILITY);
if (slot == 1) return kind.equals("basic_attack") && material == Material.STICK && !data.has(ABILITY);
@@ -123,7 +135,7 @@ public final class SessionControls {
if (!data.has(ABILITY, PersistentDataType.STRING)) return false;
String ability = data.get(ABILITY, PersistentDataType.STRING);
if (ability.isBlank()) return false;
Icon icon = ABILITIES.get(ability);
Icon icon = icon(ability);
return kind.equals("ability") ? icon != null && icon.material() == material
: kind.equals("unavailable") && icon == null && material == Material.PAPER;
}
@@ -0,0 +1,253 @@
package games.dmg.fantasystick.party;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.CombatContributions;
import games.dmg.fantasystick.progression.CombatRewardRules;
import games.dmg.fantasystick.progression.ProgressionRules;
import games.dmg.fantasystick.session.EquipmentSessions;
import games.dmg.fantasystick.session.SessionVisuals;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Clock;
import java.time.Instant;
import java.util.ArrayList;
import java.util.IdentityHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.Color;
import org.bukkit.Location;
import org.bukkit.Sound;
import org.bukkit.entity.Entity;
import org.bukkit.entity.EntityType;
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.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.EntityDeathEvent;
import org.bukkit.event.entity.EntityRemoveEvent;
import org.bukkit.event.entity.PlayerDeathEvent;
/** Accepted native contribution capture and durable character-bound XP delivery. */
public final class CombatExperience implements Listener, AutoCloseable {
public record Failure(UUID character, Throwable cause) { }
private record Defeat(EquipmentSessions.Active active, int level) { }
private static final Set<EntityType> PASSIVE = Set.of(
EntityType.ALLAY, EntityType.ARMADILLO, EntityType.AXOLOTL, EntityType.BAT, EntityType.CAMEL,
EntityType.CAT, EntityType.CHICKEN, EntityType.COD, EntityType.COPPER_GOLEM, EntityType.COW,
EntityType.DONKEY, EntityType.FOX, EntityType.FROG, EntityType.GLOW_SQUID, EntityType.HAPPY_GHAST,
EntityType.HORSE, EntityType.MOOSHROOM, EntityType.MULE, EntityType.NAUTILUS, EntityType.OCELOT,
EntityType.PARROT, EntityType.PIG, EntityType.RABBIT, EntityType.SALMON, EntityType.SHEEP,
EntityType.SKELETON_HORSE, EntityType.SNIFFER, EntityType.SNOW_GOLEM, EntityType.SQUID,
EntityType.STRIDER, EntityType.TADPOLE, EntityType.TROPICAL_FISH, EntityType.TURTLE,
EntityType.VILLAGER, EntityType.WANDERING_TRADER, EntityType.ZOMBIE_HORSE);
private final CharacterStorage characters;
private final ProgressionRules progression;
private final CombatRewardRules rewards;
private final EquipmentSessions sessions;
private final Clock clock;
private final Consumer<Runnable> nextTick;
private final Function<UUID, Player> players;
private final Consumer<Failure> failures;
private final CombatSources sources;
private final CombatContributions contributions;
private final Map<PlayerDeathEvent, Defeat> defeats = new IdentityHashMap<>();
private final Map<UUID, PendingAward> pending = new ConcurrentHashMap<>();
private final java.util.concurrent.ConcurrentLinkedQueue<UUID> retries = new java.util.concurrent.ConcurrentLinkedQueue<>();
private org.bukkit.scheduler.BukkitTask retryTask;
private boolean closed;
private final class PendingAward {
final UUID id = UUID.randomUUID();
final UUID character;
final BigInteger reward;
final CombatRewardRules.KillPosition restricted;
BigInteger expectedRevision;
boolean running;
PendingAward(UUID character, BigInteger reward, CombatRewardRules.KillPosition restricted) {
this.character = character; this.reward = reward; this.restricted = restricted;
}
}
public CombatExperience(CharacterStorage characters, ProgressionRules progression, CombatRewardRules rewards,
EquipmentSessions sessions, Clock clock, Consumer<Runnable> nextTick,
Function<UUID, Player> players, Consumer<Failure> failures) {
this.characters = Objects.requireNonNull(characters); this.progression = Objects.requireNonNull(progression);
this.rewards = Objects.requireNonNull(rewards); this.sessions = Objects.requireNonNull(sessions);
this.clock = Objects.requireNonNull(clock); this.nextTick = Objects.requireNonNull(nextTick);
this.players = Objects.requireNonNull(players); this.failures = Objects.requireNonNull(failures);
sources = new CombatSources(sessions); contributions = new CombatContributions(rewards);
}
public void install(org.bukkit.plugin.Plugin plugin) {
if (closed || retryTask != null) throw new IllegalStateException("Combat experience is already installed or closed");
retryTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::retryPending, 20, 20);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void damage(EntityDamageEvent event) {
if (closed || event.getFinalDamage() <= 0) return;
var origin = 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;
var active = sessions.active(event.getEntity().getUniqueId()).orElse(null);
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
if (record != null) defeats.put(event, new Defeat(active, record.level()));
}
@EventHandler(priority = EventPriority.MONITOR)
public void playerDeath(PlayerDeathEvent event) {
var defeat = defeats.remove(event);
if (closed || defeat == null || !event.isCancelled() || sessions.current(defeat.active())) return;
award(event.getEntity(), CombatRewardRules.Target.FANTASY_CHARACTER, defeat.level(), defeat.active().character());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void mobDeath(EntityDeathEvent event) {
if (closed || event instanceof PlayerDeathEvent) return;
award(event.getEntity(), classify(event.getEntity()), 0, null);
}
@EventHandler(priority = EventPriority.MONITOR)
public void removed(EntityRemoveEvent event) {
if (!closed && event.getCause() != EntityRemoveEvent.Cause.DEATH) {
contributions.invalidate(event.getEntity().getUniqueId());
}
}
private void award(LivingEntity target, CombatRewardRules.Target kind, int level, UUID defeatedCharacter) {
Instant now = clock.instant();
var contributors = new ArrayList<>(contributions.consume(target.getUniqueId(), now));
if (defeatedCharacter != null) contributors.remove(defeatedCharacter);
if (contributors.isEmpty()) return;
BigInteger share = rewards.share(rewards.pool(kind, level), contributors.size());
CombatRewardRules.KillPosition restricted = kind == CombatRewardRules.Target.PASSIVE
|| kind == CombatRewardRules.Target.FANTASY_CHARACTER ? null : position(target.getLocation());
for (UUID character : contributors) {
var operation = new PendingAward(character, share, restricted);
pending.put(operation.id, operation);
attempt(operation);
}
}
private static CombatRewardRules.KillPosition position(Location location) {
var world = Objects.requireNonNull(location.getWorld(), "Kill location has no world").getUID();
return new CombatRewardRules.KillPosition(Objects.requireNonNull(world, "Kill world has no UUID"),
BigDecimal.valueOf(location.getX()), BigDecimal.valueOf(location.getY()), BigDecimal.valueOf(location.getZ()));
}
private void attempt(PendingAward operation) {
synchronized (operation) {
if (closed || operation.running || !pending.containsKey(operation.id)) return;
operation.running = true;
}
if (operation.expectedRevision == null) {
characters.find(operation.character).whenComplete((record, failure) -> {
synchronized (operation) { operation.running = false; }
if (failure != null) { fail(operation, failure); return; }
if (record.isEmpty() || record.orElseThrow().dead()) { pending.remove(operation.id); return; }
operation.expectedRevision = record.orElseThrow().revision();
attempt(operation);
});
return;
}
characters.awardCombatExperience(operation.character, operation.expectedRevision, operation.reward,
progression, rewards, operation.restricted).whenComplete((award, failure) -> {
synchronized (operation) { operation.running = false; }
if (failure != null) { fail(operation, failure); return; }
if (award.status() == CharacterStorage.CombatAwardStatus.STALE && award.record().isPresent()) {
operation.expectedRevision = award.record().orElseThrow().revision(); attempt(operation); return;
}
pending.remove(operation.id);
if (award.record().isEmpty() || award.status() == CharacterStorage.CombatAwardStatus.UNAVAILABLE) return;
try { nextTick.accept(() -> acknowledge(operation, award)); }
catch (RuntimeException rejected) { report(operation.character, rejected); }
});
}
private void fail(PendingAward operation, Throwable failure) {
report(operation.character, failure);
if (!closed && pending.containsKey(operation.id)) retries.offer(operation.id);
}
public void retryPending() {
if (closed) return;
for (int attempted = 0; attempted < 64; attempted++) {
UUID id = retries.poll();
if (id == null) break;
var operation = pending.get(id);
if (operation != null) attempt(operation);
}
}
private void acknowledge(PendingAward operation, CharacterStorage.CombatAward award) {
if (closed) return;
CharacterRecord saved = award.record().orElseThrow();
var active = sessions.activeCharacter(operation.character).orElse(null);
if (active != null && !sessions.acknowledgeProgress(active, saved.revision().subtract(BigInteger.ONE), saved)
&& !sessions.currentRecord(active).filter(saved::equals).isPresent()) {
report(operation.character, new IllegalStateException("Saved combat progress could not synchronize with active session"));
return;
}
Player player = active == null ? null : players.apply(active.player());
if (player == null || !player.isOnline()) return;
if (award.status() == CharacterStorage.CombatAwardStatus.APPLIED) {
player.sendMessage("Earned " + operation.reward + " fantasy XP.");
for (int gained = 1; gained <= award.levels(); gained++) {
int level = saved.level() - award.levels() + gained;
player.sendMessage("Level " + level + "! Gained 1 character point.");
SessionVisuals.burst(player, Color.BLUE, 12);
player.playSound(player.getLocation(), Sound.ENTITY_PLAYER_LEVELUP, 1, 1);
}
} else if (award.status() == CharacterStorage.CombatAwardStatus.SPACING_BLOCKED) {
player.sendMessage("No fantasy XP: this restricted kill was too close to your previous one in this world.");
} else if (award.status() == CharacterStorage.CombatAwardStatus.AT_CAP) {
player.sendMessage("No fantasy XP: this character is already at the configured maximum level.");
} else if (award.status() == CharacterStorage.CombatAwardStatus.UNCHANGED) {
player.sendMessage("No fantasy XP: this target's configured reward is zero.");
}
}
static CombatRewardRules.Target classify(LivingEntity entity) {
EntityType type = entity.getType();
if (type == EntityType.WARDEN || type == EntityType.ENDER_DRAGON) return CombatRewardRules.Target.BOSS;
return PASSIVE.contains(type) ? CombatRewardRules.Target.PASSIVE : CombatRewardRules.Target.ORDINARY;
}
private void report(UUID character, Throwable failure) {
try { failures.accept(new Failure(character, failure)); }
catch (RuntimeException ignored) { /* Diagnostics never replay or acknowledge a reward. */ }
}
@Override public void close() {
closed = true;
if (retryTask != null) retryTask.cancel();
defeats.clear(); pending.clear(); retries.clear(); contributions.close();
}
}
@@ -1,5 +1,6 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.progression.CombatRewardRules;
import games.dmg.fantasystick.progression.Stat;
import java.math.BigDecimal;
import java.math.BigInteger;
@@ -16,7 +17,16 @@ 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,
Map<UUID, CombatRewardRules.KillPosition> restrictedKills,
boolean dead, UUID deathId, Instant 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,
boolean dead, UUID deathId, Instant diedAt) {
this(id, name, revision, stats, level, experience, unspentPoints, health, learned, loadout,
cooldowns, Map.of(), dead, deathId, diedAt);
}
public CharacterRecord {
Objects.requireNonNull(id);
Objects.requireNonNull(name);
@@ -28,6 +38,7 @@ public record CharacterRecord(
learned = Set.copyOf(learned);
loadout = List.copyOf(loadout);
cooldowns = Map.copyOf(cooldowns);
restrictedKills = Map.copyOf(restrictedKills);
if (revision.signum() < 0 || experience.signum() < 0 || unspentPoints.signum() < 0 || level < 1
|| !stats.keySet().equals(EnumSet.allOf(Stat.class))
|| stats.values().stream().anyMatch(value -> value.signum() < 0)
@@ -35,6 +46,8 @@ public record CharacterRecord(
|| health.compareTo(new BigDecimal(stats.get(Stat.CON).add(BigInteger.TEN))) > 0
|| loadout.size() != 7 || 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()))
|| (deathId == null) != (diedAt == null) || dead && deathId == null) {
throw new IllegalArgumentException("Invalid character record");
}
@@ -1,5 +1,7 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.progression.ProgressionRules;
import games.dmg.fantasystick.progression.Stat;
import java.io.IOException;
import java.math.BigInteger;
import java.time.Clock;
@@ -204,26 +206,259 @@ public final class CharacterStorage implements AutoCloseable {
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(),
false, old.deathId(), old.diedAt());
old.restrictedKills(), false, old.deathId(), old.diedAt());
repository.replace(renamed, old.revision());
return Optional.of(renamed);
});
}
public enum ExperienceAwardStatus { APPLIED, AT_CAP, UNCHANGED, STALE, UNAVAILABLE }
public record ExperienceAward(ExperienceAwardStatus status, Optional<CharacterRecord> record, int levels) { }
/** Persist configured transferable XP/level/point progression as one expected revision. */
public CompletableFuture<ExperienceAward> awardExperience(UUID characterId, BigInteger expectedRevision,
BigInteger reward, ProgressionRules rules) {
Objects.requireNonNull(characterId);
Objects.requireNonNull(expectedRevision);
Objects.requireNonNull(reward);
Objects.requireNonNull(rules);
if (reward.signum() < 0) throw new IllegalArgumentException("Experience reward must not be negative");
return submit(() -> {
var found = repository.load(characterId);
if (found.isEmpty() || found.orElseThrow().dead()) {
return new ExperienceAward(ExperienceAwardStatus.UNAVAILABLE, Optional.empty(), 0);
}
var old = found.orElseThrow();
if (!old.revision().equals(expectedRevision)) {
return new ExperienceAward(ExperienceAwardStatus.STALE, Optional.of(old), 0);
}
var cap = rules.xpPerLevel().multiply(BigInteger.valueOf(rules.maximumLevel() - 1L));
if (old.level() >= rules.maximumLevel() || old.experience().compareTo(cap) >= 0) {
return new ExperienceAward(ExperienceAwardStatus.AT_CAP, Optional.of(old), 0);
}
var experience = old.experience().add(reward).min(cap);
if (experience.equals(old.experience())) {
var status = old.level() >= rules.maximumLevel()
? ExperienceAwardStatus.AT_CAP : ExperienceAwardStatus.UNCHANGED;
return new ExperienceAward(status, Optional.of(old), 0);
}
int level = experience.divide(rules.xpPerLevel()).intValueExact() + 1;
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());
repository.replace(saved, old.revision());
return new ExperienceAward(ExperienceAwardStatus.APPLIED, Optional.of(saved), gained);
});
}
public enum CombatAwardStatus { APPLIED, SPACING_BLOCKED, AT_CAP, UNCHANGED, STALE, UNAVAILABLE }
public record CombatAward(CombatAwardStatus status, Optional<CharacterRecord> record, int levels) { }
/** Persist one combat share and its restricted-kill reference in the same character revision. */
public CompletableFuture<CombatAward> awardCombatExperience(UUID characterId, BigInteger expectedRevision,
BigInteger reward, ProgressionRules progression,
games.dmg.fantasystick.progression.CombatRewardRules rewards,
games.dmg.fantasystick.progression.CombatRewardRules.KillPosition restrictedKill) {
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision);
Objects.requireNonNull(reward); Objects.requireNonNull(progression); Objects.requireNonNull(rewards);
if (reward.signum() < 0) throw new IllegalArgumentException("Experience reward must not be negative");
return submit(() -> {
var found = repository.load(characterId);
if (found.isEmpty() || found.orElseThrow().dead()) {
return new CombatAward(CombatAwardStatus.UNAVAILABLE, Optional.empty(), 0);
}
var old = found.orElseThrow();
if (!old.revision().equals(expectedRevision)) {
return new CombatAward(CombatAwardStatus.STALE, Optional.of(old), 0);
}
boolean eligible = restrictedKill == null || old.restrictedKills().get(restrictedKill.world()) == null
|| rewards.spaced(old.restrictedKills().get(restrictedKill.world()), restrictedKill);
var history = new java.util.HashMap<>(old.restrictedKills());
if (restrictedKill != null) history.put(restrictedKill.world(), restrictedKill);
var cap = progression.xpPerLevel().multiply(BigInteger.valueOf(progression.maximumLevel() - 1L));
boolean atCap = old.level() >= progression.maximumLevel() || old.experience().compareTo(cap) >= 0;
var experience = eligible && !atCap ? old.experience().add(reward).min(cap) : old.experience();
int level = atCap ? old.level() : experience.divide(progression.xpPerLevel()).intValueExact() + 1;
int gained = Math.max(0, level - old.level());
var status = !eligible ? CombatAwardStatus.SPACING_BLOCKED : atCap ? CombatAwardStatus.AT_CAP
: experience.equals(old.experience()) ? CombatAwardStatus.UNCHANGED : CombatAwardStatus.APPLIED;
if (history.equals(old.restrictedKills()) && experience.equals(old.experience())) {
return new CombatAward(status, Optional.of(old), 0);
}
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());
repository.replace(saved, old.revision());
return new CombatAward(status, Optional.of(saved), gained);
});
}
public enum AbilityMutationStatus {
APPLIED, UNCHANGED, STALE, REQUIREMENTS, NO_POINTS, ALREADY_LEARNED, NOT_LEARNED, COOLDOWN, UNAVAILABLE
}
public record AbilityMutation(AbilityMutationStatus status, Optional<CharacterRecord> record) { }
/** Unlock one validated registered ID and spend its points in the same expected revision. */
public CompletableFuture<AbilityMutation> unlockAbility(UUID characterId, BigInteger expectedRevision, String ability,
java.util.Map<Stat, BigInteger> requirements, BigInteger cost) {
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision); Objects.requireNonNull(ability);
Objects.requireNonNull(requirements); Objects.requireNonNull(cost);
if (ability.isBlank() || cost.signum() <= 0 || requirements.entrySet().stream().anyMatch(entry -> entry.getValue().compareTo(BigInteger.TEN) < 0)) {
throw new IllegalArgumentException("Invalid ability unlock");
}
return submit(() -> {
var found = repository.load(characterId);
if (found.isEmpty() || found.orElseThrow().dead()) return new AbilityMutation(AbilityMutationStatus.UNAVAILABLE, Optional.empty());
var old = found.orElseThrow();
if (!old.revision().equals(expectedRevision)) return new AbilityMutation(AbilityMutationStatus.STALE, Optional.of(old));
if (old.learned().contains(ability)) return new AbilityMutation(AbilityMutationStatus.ALREADY_LEARNED, Optional.of(old));
if (requirements.entrySet().stream().anyMatch(entry -> old.stats().get(entry.getKey()).compareTo(entry.getValue()) < 0)) {
return new AbilityMutation(AbilityMutationStatus.REQUIREMENTS, Optional.of(old));
}
if (old.unspentPoints().compareTo(cost) < 0) return new AbilityMutation(AbilityMutationStatus.NO_POINTS, Optional.of(old));
var learned = new java.util.HashSet<>(old.learned()); learned.add(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.restrictedKills(), false, old.deathId(), old.diedAt());
repository.replace(saved, old.revision());
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
});
}
/** Refund one learned ability exactly once, clear every equipped reference and retain its deadline. */
public CompletableFuture<AbilityMutation> unlearnAbility(UUID characterId, BigInteger expectedRevision, String ability) {
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision); Objects.requireNonNull(ability);
if (ability.isBlank()) throw new IllegalArgumentException("Invalid ability refund");
return submit(() -> {
var found = repository.load(characterId);
if (found.isEmpty() || found.orElseThrow().dead()) return new AbilityMutation(AbilityMutationStatus.UNAVAILABLE, Optional.empty());
var old = found.orElseThrow();
if (!old.revision().equals(expectedRevision)) return new AbilityMutation(AbilityMutationStatus.STALE, Optional.of(old));
if (!old.learned().contains(ability)) return new AbilityMutation(AbilityMutationStatus.NOT_LEARNED, Optional.of(old));
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.restrictedKills(), false, old.deathId(), old.diedAt());
repository.replace(saved, old.revision());
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
});
}
/** Move one learned ability to one of the seven selectable slots without duplicating it. */
public CompletableFuture<AbilityMutation> assignAbility(UUID characterId, BigInteger expectedRevision,
String ability, int slot) {
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision); Objects.requireNonNull(ability);
if (ability.isBlank() || slot < 0 || slot >= 7) throw new IllegalArgumentException("Invalid ability assignment");
return submit(() -> {
var found = repository.load(characterId);
if (found.isEmpty() || found.orElseThrow().dead()) return new AbilityMutation(AbilityMutationStatus.UNAVAILABLE, Optional.empty());
var old = found.orElseThrow();
if (!old.revision().equals(expectedRevision)) return new AbilityMutation(AbilityMutationStatus.STALE, Optional.of(old));
if (!old.learned().contains(ability)) return new AbilityMutation(AbilityMutationStatus.NOT_LEARNED, Optional.of(old));
var loadout = new java.util.ArrayList<>(old.loadout());
for (int index = 0; index < loadout.size(); index++) if (loadout.get(index).equals(ability)) loadout.set(index, "");
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.restrictedKills(), false, old.deathId(), old.diedAt());
repository.replace(saved, old.revision());
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
});
}
/** Commit a real-time character/ability deadline before exposing the cast's native effect. */
public CompletableFuture<AbilityMutation> commitAbilityCooldown(UUID characterId, BigInteger expectedRevision,
String ability, Instant readyAt) {
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision); Objects.requireNonNull(ability);
Objects.requireNonNull(readyAt);
if (ability.isBlank()) throw new IllegalArgumentException("Invalid ability cooldown");
return submit(() -> {
var found = repository.load(characterId);
if (found.isEmpty() || found.orElseThrow().dead()) return new AbilityMutation(AbilityMutationStatus.UNAVAILABLE, Optional.empty());
var old = found.orElseThrow();
if (!old.revision().equals(expectedRevision)) return new AbilityMutation(AbilityMutationStatus.STALE, Optional.of(old));
if (!old.learned().contains(ability)) return new AbilityMutation(AbilityMutationStatus.NOT_LEARNED, Optional.of(old));
Instant previous = old.cooldowns().get(ability);
if (previous != null && previous.isAfter(clock.instant())) return new AbilityMutation(AbilityMutationStatus.COOLDOWN, Optional.of(old));
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.restrictedKills(), false, old.deathId(), old.diedAt());
repository.replace(saved, old.revision());
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
});
}
public enum StatPurchaseStatus { APPLIED, STALE, NO_POINTS, UNAVAILABLE }
public record StatPurchase(StatPurchaseStatus status, Optional<CharacterRecord> record) { }
/** Persist one active-session stat purchase and its current native health as one revision. */
public CompletableFuture<StatPurchase> purchaseStat(UUID characterId, BigInteger expectedRevision, Stat stat,
java.math.BigDecimal currentHealth,
java.math.BigDecimal nativeLimit) {
Objects.requireNonNull(characterId);
Objects.requireNonNull(expectedRevision);
Objects.requireNonNull(stat);
Objects.requireNonNull(currentHealth);
Objects.requireNonNull(nativeLimit);
if (currentHealth.signum() < 0 || nativeLimit.signum() <= 0) {
throw new IllegalArgumentException("Invalid stat-purchase health bound");
}
return submit(() -> {
var found = repository.load(characterId);
if (found.isEmpty() || found.orElseThrow().dead()) {
return new StatPurchase(StatPurchaseStatus.UNAVAILABLE, Optional.empty());
}
var old = found.orElseThrow();
if (!old.revision().equals(expectedRevision)) {
return new StatPurchase(StatPurchaseStatus.STALE, Optional.of(old));
}
if (old.unspentPoints().signum() == 0) {
return new StatPurchase(StatPurchaseStatus.NO_POINTS, Optional.of(old));
}
var oldMaximum = old.maximumHealth().min(nativeLimit);
if (currentHealth.compareTo(oldMaximum) > 0) {
return new StatPurchase(StatPurchaseStatus.STALE, Optional.of(old));
}
var stats = new java.util.EnumMap<Stat, BigInteger>(Stat.class);
stats.putAll(old.stats());
stats.put(stat, stats.get(stat).add(BigInteger.ONE));
var rawMaximum = new java.math.BigDecimal(stats.get(Stat.CON).add(BigInteger.TEN));
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,
old.deathId(), old.diedAt());
repository.replace(saved, old.revision());
return new StatPurchase(StatPurchaseStatus.APPLIED, Optional.of(saved));
});
}
/** Session-owned remaining health; retain all other current fields and reject concurrent revision changes. */
public CompletableFuture<Optional<CharacterRecord>> saveHealth(UUID characterId, java.math.BigDecimal health,
java.math.BigDecimal nativeLimit) {
return saveHealth(characterId, null, health, nativeLimit);
}
/** Ability-owned health mutation that refuses a changed character revision rather than healing stale state. */
public CompletableFuture<Optional<CharacterRecord>> saveHealth(UUID characterId, BigInteger expectedRevision,
java.math.BigDecimal health, java.math.BigDecimal nativeLimit) {
Objects.requireNonNull(characterId);
if (health.signum() < 0 || nativeLimit.signum() <= 0) throw new IllegalArgumentException("Invalid health bound");
return submit(() -> {
var found = repository.load(characterId);
if (found.isEmpty() || found.orElseThrow().dead()) return Optional.empty();
var old = found.orElseThrow();
if (expectedRevision != null && !old.revision().equals(expectedRevision)) return Optional.empty();
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(),
false, old.deathId(), old.diedAt());
old.restrictedKills(), false, old.deathId(), old.diedAt());
repository.replace(saved, old.revision());
return Optional.of(saved);
});
@@ -254,15 +489,22 @@ 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(), true, deathId, diedAt);
old.loadout(), old.cooldowns(), old.restrictedKills(), true, deathId, diedAt);
repository.replace(dead, old.revision());
return Optional.of(dead);
});
}
public CompletableFuture<Optional<CharacterRecord>> restoreDeathRecord(UUID characterId, UUID deathId) {
return restoreDeathRecord(characterId, deathId, null);
}
/** Native Revive supplies the current plugin-controlled health ceiling captured on the server thread. */
public CompletableFuture<Optional<CharacterRecord>> restoreDeathRecord(UUID characterId, UUID deathId,
java.math.BigDecimal nativeLimit) {
Objects.requireNonNull(characterId);
Objects.requireNonNull(deathId);
if (nativeLimit != null && nativeLimit.signum() <= 0) throw new IllegalArgumentException("Invalid revival health limit");
Instant selectedAt = clock.instant();
return submit(() -> {
Optional<CharacterRecord> found = repository.load(characterId);
@@ -272,9 +514,10 @@ public final class CharacterStorage implements AutoCloseable {
if (!deathId.equals(old.deathId()) || age.isNegative() || 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(), old.maximumHealth(), old.learned(),
old.loadout(), old.cooldowns(), false, old.deathId(), old.diedAt());
old.level(), old.experience(), old.unspentPoints(), health, old.learned(), old.loadout(), old.cooldowns(),
old.restrictedKills(), false, old.deathId(), old.diedAt());
repository.replace(restored, old.revision());
return Optional.of(restored);
});
@@ -46,6 +46,13 @@ final class CharacterYaml {
record.cooldowns().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry ->
cooldowns.add(Map.of("ability", entry.getKey(), "ready-at", entry.getValue().toString())));
root.put("cooldowns", cooldowns);
List<Map<String, String>> restrictedKills = new ArrayList<>();
record.restrictedKills().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry -> {
var position = entry.getValue();
restrictedKills.add(Map.of("world", entry.getKey().toString(), "x", position.x().toString(),
"y", position.y().toString(), "z", position.z().toString()));
});
root.put("restricted-kills", restrictedKills);
root.put("dead", record.dead());
if (record.deathId() != null) {
root.put("death-id", record.deathId().toString());
@@ -74,11 +81,19 @@ final class CharacterYaml {
throw new IllegalArgumentException("Duplicate cooldown");
}
}
Map<UUID, games.dmg.fantasystick.progression.CombatRewardRules.KillPosition> restrictedKills = new LinkedHashMap<>();
for (Object value : root.containsKey("restricted-kills") ? list(root.get("restricted-kills")) : List.of()) {
Map<?, ?> row = object(value);
UUID world = UUID.fromString(text(row.get("world")));
var position = new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(world,
new BigDecimal(text(row.get("x"))), new BigDecimal(text(row.get("y"))), new BigDecimal(text(row.get("z"))));
if (restrictedKills.putIfAbsent(world, position) != null) throw new IllegalArgumentException("Duplicate restricted-kill world");
}
if (!(root.get("dead") instanceof Boolean dead)) throw new IllegalArgumentException("Missing dead flag");
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,
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,
root.containsKey("died-at") ? instant(root.get("died-at")) : null);
}
@@ -0,0 +1,198 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.FrostShard;
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;
/** Serialized durable Frost Shard settings; publication remains a server-thread caller decision. */
public final class FrostShardSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String BASE_DAMAGE = "frost-shard-base-damage";
public static final String COOLDOWN = "frost-shard-cooldown-seconds";
public static final String RANGE = "frost-shard-range-blocks";
public static final String LIFETIME = "frost-shard-lifetime-seconds";
public static final String SPEED = "frost-shard-projectile-speed-blocks-per-tick";
public static final String AMPLIFIER = "frost-shard-slowness-amplifier";
public static final String SLOWNESS = "frost-shard-slowness-duration-seconds";
private static final List<String> KEYS = List.of(BASE_DAMAGE, COOLDOWN, LIFETIME, SPEED, RANGE, AMPLIFIER, SLOWNESS);
private static final Map<String, String> DEFAULTS = Map.of(BASE_DAMAGE, "4", COOLDOWN, "5", RANGE, "32",
LIFETIME, "5", SPEED, "1", AMPLIFIER, "0", SLOWNESS, "2");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, FrostShard.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-frost-shard-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 FrostShardSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
FrostShardSettings(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 FrostShard.Settings settings() { return parse(effectiveValues); }
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
public synchronized void publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
parse(update.values());
effectiveValues = update.values(); publishedSequence = update.sequence();
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(key, value);
var preview = new LinkedHashMap<>(effectiveValues); preview.put(key, accepted);
parse(preview); // Fail malformed/out-of-range command input before queueing accepted persistence.
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("Frost Shard configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished();
return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String key, String raw) {
try {
if (key.equals(BASE_DAMAGE) || key.equals(AMPLIFIER)) return new java.math.BigInteger(raw).toString();
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static FrostShard.Settings parse(Map<String, String> values) {
try {
var base = new java.math.BigInteger(values.get(BASE_DAMAGE));
var cooldown = seconds(values.get(COOLDOWN));
double range = finite(values.get(RANGE));
var lifetime = seconds(values.get(LIFETIME));
double speed = finite(values.get(SPEED));
int amplifier = new java.math.BigInteger(values.get(AMPLIFIER)).intValueExact();
var slowness = seconds(values.get(SLOWNESS));
return new FrostShard.Settings(base, cooldown, range, lifetime, speed, amplifier, slowness);
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Frost Shard settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new java.math.BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new java.math.BigDecimal(value).doubleValue();
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
return result;
}
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"); }
}
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,201 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.Longbow;
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;
/** Serialized durable Longbow settings; publication remains a server-thread caller decision. */
public final class LongbowSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String BASE_DAMAGE = "longbow-base-damage";
public static final String RECOVERY = "longbow-recovery-seconds";
public static final String MINIMUM_DRAW = "longbow-minimum-draw-ticks";
public static final String FULL_DRAW = "longbow-full-draw-ticks";
public static final String RANGE = "longbow-range-blocks";
public static final String LIFETIME = "longbow-lifetime-seconds";
public static final String FULL_SPEED = "longbow-full-projectile-speed-blocks-per-tick";
private static final List<String> KEYS = List.of(BASE_DAMAGE, RECOVERY, MINIMUM_DRAW, FULL_DRAW, RANGE,
LIFETIME, FULL_SPEED);
private static final Map<String, String> DEFAULTS = Map.of(BASE_DAMAGE, "5", RECOVERY, "1",
MINIMUM_DRAW, "3", FULL_DRAW, "20", RANGE, "64", LIFETIME, "10", FULL_SPEED, "3");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, Longbow.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-longbow-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 LongbowSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
LongbowSettings(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 Longbow.Settings settings() { return parse(effectiveValues); }
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
public synchronized void publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
parse(update.values());
effectiveValues = update.values(); publishedSequence = update.sequence();
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(key, value);
var preview = new LinkedHashMap<>(effectiveValues); preview.put(key, accepted);
parse(preview); // Fail malformed/out-of-range command input before queueing accepted persistence.
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("Longbow configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished();
return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String key, String raw) {
try {
if (key.equals(BASE_DAMAGE) || key.equals(MINIMUM_DRAW) || key.equals(FULL_DRAW)) {
return new java.math.BigInteger(raw).toString();
}
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static Longbow.Settings parse(Map<String, String> values) {
try {
var base = new java.math.BigInteger(values.get(BASE_DAMAGE));
var recovery = seconds(values.get(RECOVERY));
int minimumDraw = new java.math.BigInteger(values.get(MINIMUM_DRAW)).intValueExact();
int fullDraw = new java.math.BigInteger(values.get(FULL_DRAW)).intValueExact();
double range = finite(values.get(RANGE));
var lifetime = seconds(values.get(LIFETIME));
double speed = finite(values.get(FULL_SPEED));
return new Longbow.Settings(base, recovery, minimumDraw, fullDraw, range, lifetime, speed);
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Longbow settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new java.math.BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new java.math.BigDecimal(value).doubleValue();
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
return result;
}
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"); }
}
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,188 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.Quickstep;
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;
/** Serialized durable Quickstep settings; publication remains a server-thread caller decision. */
public final class QuickstepSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String COOLDOWN = "quickstep-cooldown-seconds";
public static final String DISTANCE = "quickstep-distance-blocks";
public static final String SPEED = "quickstep-speed-blocks-per-tick";
private static final List<String> KEYS = List.of(COOLDOWN, DISTANCE, SPEED);
private static final Map<String, String> DEFAULTS = Map.of(COOLDOWN, "5", DISTANCE, "4", SPEED, "1");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, Quickstep.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-quickstep-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 QuickstepSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
QuickstepSettings(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 Quickstep.Settings settings() { return parse(effectiveValues); }
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
public synchronized void publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
parse(update.values());
effectiveValues = update.values(); publishedSequence = update.sequence();
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(key, value);
var preview = new LinkedHashMap<>(effectiveValues); preview.put(key, accepted);
parse(preview); // Fail malformed/out-of-range command input before queueing accepted persistence.
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("Quickstep configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished();
return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String key, String raw) {
try {
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static Quickstep.Settings parse(Map<String, String> values) {
try {
var cooldown = seconds(values.get(COOLDOWN));
double distance = finite(values.get(DISTANCE));
double speed = finite(values.get(SPEED));
return new Quickstep.Settings(cooldown, distance, speed);
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Quickstep settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new java.math.BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new java.math.BigDecimal(value).doubleValue();
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
return result;
}
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"); }
}
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,159 @@
package games.dmg.fantasystick.persistence;
import java.io.IOException;
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.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutorService;
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;
/** Feature-scoped Revive interruption proof; never authority to guess or mint an item. */
public final class RevivalStore implements AutoCloseable {
private static final int MAX_FILE_BYTES = 32_768;
private static final int MAX_FILES = 4096;
private static final Set<String> KEYS = Set.of("schema-version", "reservation-id", "caster-character-id",
"target-player-id", "character-id", "death-id", "created-at", "source-slot", "source-amount", "destination-slot");
public record Reservation(UUID id, UUID casterCharacter, UUID targetPlayer, UUID character, UUID death,
Instant createdAt, int sourceSlot, int sourceAmount, int destinationSlot) {
public Reservation {
Objects.requireNonNull(id); Objects.requireNonNull(casterCharacter); Objects.requireNonNull(targetPlayer);
Objects.requireNonNull(character); Objects.requireNonNull(death); Objects.requireNonNull(createdAt);
if (createdAt.getNano() != 0 || sourceSlot < 0 || sourceSlot > 40 || sourceSlot > 35 && sourceSlot != 40
|| sourceAmount < 1 || sourceAmount > 99 || destinationSlot < -1 || destinationSlot > 40
|| destinationSlot > 35 && destinationSlot != 40 || destinationSlot == sourceSlot) {
throw new IllegalArgumentException("Invalid revival reservation");
}
}
}
private final Path directory;
private final ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-revival-persistence"));
public RevivalStore(Path pluginDataDirectory) {
directory = Objects.requireNonNull(pluginDataDirectory).resolve("revivals").toAbsolutePath();
}
public CompletableFuture<Optional<Reservation>> reserve(Reservation reservation) {
Objects.requireNonNull(reservation);
return submit(() -> {
Path file = file(reservation.character());
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) return Optional.empty();
safeDirectory();
Path temporary = directory.resolve("." + reservation.character() + "." + UUID.randomUUID() + ".tmp");
try {
Files.writeString(temporary, encode(reservation), StandardCharsets.UTF_8,
StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE);
} finally { Files.deleteIfExists(temporary); }
return Optional.of(reservation);
});
}
public CompletableFuture<Optional<Reservation>> find(UUID character) {
Objects.requireNonNull(character);
return submit(() -> load(file(character)));
}
public CompletableFuture<List<Reservation>> target(UUID player) {
Objects.requireNonNull(player);
return submit(() -> {
if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) return List.of();
if (Files.isSymbolicLink(directory) || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Unsafe revival directory");
}
var result = new ArrayList<Reservation>();
try (var files = Files.list(directory)) {
for (var path : files.limit(MAX_FILES + 1).toList()) {
if (result.size() >= MAX_FILES) throw new IOException("Too many revival reservations");
if (!path.getFileName().toString().endsWith(".yml")) continue;
var reservation = load(path).orElseThrow(() -> new IOException("Missing revival reservation"));
if (reservation.targetPlayer().equals(player)) result.add(reservation);
}
}
return List.copyOf(result);
});
}
public CompletableFuture<Boolean> clear(UUID character, UUID reservation) {
Objects.requireNonNull(character); Objects.requireNonNull(reservation);
return submit(() -> {
var found = load(file(character));
if (found.isEmpty() || !found.orElseThrow().id().equals(reservation)) return false;
return Files.deleteIfExists(file(character));
});
}
private void safeDirectory() throws IOException {
if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) {
if (Files.isSymbolicLink(directory) || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Unsafe revival directory");
}
} else Files.createDirectories(directory);
}
private Path file(UUID character) { return directory.resolve(character + ".yml"); }
private Optional<Reservation> load(Path file) throws IOException {
if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) return Optional.empty();
if (Files.isSymbolicLink(file) || !Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)
|| Files.size(file) > MAX_FILE_BYTES) throw new IOException("Unsafe revival reservation");
Object decoded;
try {
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false); options.setMaxAliasesForCollections(0);
decoded = new Yaml(new SafeConstructor(options)).load(Files.readString(file, StandardCharsets.UTF_8));
} catch (RuntimeException invalid) { throw new IOException("Invalid revival reservation", invalid); }
if (!(decoded instanceof Map<?, ?> raw) || !raw.keySet().equals(KEYS)) throw new IOException("Invalid revival reservation keys");
try {
if (!"1".equals(text(raw, "schema-version"))) throw new IllegalArgumentException("schema");
var value = new Reservation(uuid(raw, "reservation-id"), uuid(raw, "caster-character-id"),
uuid(raw, "target-player-id"), uuid(raw, "character-id"), uuid(raw, "death-id"),
Instant.parse(text(raw, "created-at")), integer(raw, "source-slot"), integer(raw, "source-amount"),
integer(raw, "destination-slot"));
if (!file.getFileName().toString().equals(value.character() + ".yml")) throw new IllegalArgumentException("identity");
return Optional.of(value);
} catch (RuntimeException invalid) { throw new IOException("Invalid revival reservation", invalid); }
}
private static String encode(Reservation value) {
var map = new LinkedHashMap<String, Object>();
map.put("schema-version", "1"); map.put("reservation-id", value.id().toString());
map.put("caster-character-id", value.casterCharacter().toString()); map.put("target-player-id", value.targetPlayer().toString());
map.put("character-id", value.character().toString()); map.put("death-id", value.death().toString());
map.put("created-at", value.createdAt().toString()); map.put("source-slot", Integer.toString(value.sourceSlot()));
map.put("source-amount", Integer.toString(value.sourceAmount())); map.put("destination-slot", Integer.toString(value.destinationSlot()));
return new Yaml().dump(map);
}
private static String text(Map<?, ?> map, String key) {
Object value = map.get(key); if (!(value instanceof String text) || text.isBlank()) throw new IllegalArgumentException(key); return text;
}
private static UUID uuid(Map<?, ?> map, String key) { var value = UUID.fromString(text(map, key)); if (!value.toString().equals(text(map, key))) throw new IllegalArgumentException(key); return value; }
private static int integer(Map<?, ?> map, String key) { return Integer.parseInt(text(map, key)); }
@FunctionalInterface private interface Work<T> { T run() throws IOException; }
private <T> CompletableFuture<T> submit(Work<T> work) {
try {
return CompletableFuture.supplyAsync(() -> { try { return work.run(); } catch (IOException error) { throw new CompletionException(error); } }, worker);
} catch (RejectedExecutionException rejected) { return CompletableFuture.failedFuture(rejected); }
}
@Override public void close() { worker.shutdown(); }
}
@@ -0,0 +1,187 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.SecondWind;
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;
/** Serialized durable SecondWind settings; publication remains a server-thread caller decision. */
public final class SecondWindSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String BASE_HEALING = "second-wind-base-healing";
public static final String COOLDOWN = "second-wind-cooldown-seconds";
private static final List<String> KEYS = List.of(BASE_HEALING, COOLDOWN);
private static final Map<String, String> DEFAULTS = Map.of(BASE_HEALING, "6", COOLDOWN, "15");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, SecondWind.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-second-wind-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 SecondWindSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
SecondWindSettings(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 SecondWind.Settings settings() { return parse(effectiveValues); }
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
public synchronized void publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
parse(update.values());
effectiveValues = update.values(); publishedSequence = update.sequence();
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(key, value);
var preview = new LinkedHashMap<>(effectiveValues); preview.put(key, accepted);
parse(preview); // Fail malformed/out-of-range command input before queueing accepted persistence.
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("SecondWind configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished();
return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String key, String raw) {
try {
if (key.equals(BASE_HEALING)) return new java.math.BigInteger(raw).toString();
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static SecondWind.Settings parse(Map<String, String> values) {
try {
var base = new java.math.BigInteger(values.get(BASE_HEALING));
var cooldown = seconds(values.get(COOLDOWN));
return new SecondWind.Settings(base, cooldown);
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("SecondWind settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new java.math.BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new java.math.BigDecimal(value).doubleValue();
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
return result;
}
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"); }
}
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,192 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.ShieldBash;
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;
/** Serialized durable Shield Bash settings; publication remains a server-thread caller decision. */
public final class ShieldBashSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String BASE_DAMAGE = "shield-bash-base-damage";
public static final String COOLDOWN = "shield-bash-cooldown-seconds";
public static final String RANGE = "shield-bash-range-blocks";
public static final String HORIZONTAL = "shield-bash-horizontal-knockback";
public static final String VERTICAL = "shield-bash-minimum-vertical-knockback";
private static final List<String> KEYS = List.of(BASE_DAMAGE, COOLDOWN, RANGE, HORIZONTAL, VERTICAL);
private static final Map<String, String> DEFAULTS = Map.of(BASE_DAMAGE, "4", COOLDOWN, "6", RANGE, "3",
HORIZONTAL, "0.8", VERTICAL, "0.35");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, ShieldBash.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-shield-bash-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 ShieldBashSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
ShieldBashSettings(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 ShieldBash.Settings settings() { return parse(effectiveValues); }
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
public synchronized void publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
parse(update.values());
effectiveValues = update.values(); publishedSequence = update.sequence();
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(key, value);
var preview = new LinkedHashMap<>(effectiveValues); preview.put(key, accepted);
parse(preview); // Fail malformed/out-of-range command input before queueing accepted persistence.
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("Shield Bash configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished();
return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String key, String raw) {
try {
if (key.equals(BASE_DAMAGE)) return new java.math.BigInteger(raw).toString();
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static ShieldBash.Settings parse(Map<String, String> values) {
try {
var base = new java.math.BigInteger(values.get(BASE_DAMAGE));
var cooldown = seconds(values.get(COOLDOWN));
return new ShieldBash.Settings(base, cooldown, finite(values.get(RANGE)),
finite(values.get(HORIZONTAL)), finite(values.get(VERTICAL)));
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Shield Bash settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new java.math.BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new java.math.BigDecimal(value).doubleValue();
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
return result;
}
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"); }
}
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,188 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.StoneSkin;
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;
/** Serialized durable Stone Skin settings; publication remains a server-thread caller decision. */
public final class StoneSkinSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String COOLDOWN = "stone-skin-cooldown-seconds";
public static final String DURATION = "stone-skin-duration-seconds";
public static final String REDUCTION = "stone-skin-damage-reduction-percent";
private static final List<String> KEYS = List.of(COOLDOWN, DURATION, REDUCTION);
private static final Map<String, String> DEFAULTS = Map.of(COOLDOWN, "14", DURATION, "4", REDUCTION, "25");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, StoneSkin.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-stone-skin-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 StoneSkinSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
StoneSkinSettings(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 StoneSkin.Settings settings() { return parse(effectiveValues); }
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
public synchronized void publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
parse(update.values());
effectiveValues = update.values(); publishedSequence = update.sequence();
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(key, value);
var preview = new LinkedHashMap<>(effectiveValues); preview.put(key, accepted);
parse(preview); // Fail malformed/out-of-range command input before queueing accepted persistence.
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("Stone Skin configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished();
return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String key, String raw) {
try {
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static StoneSkin.Settings parse(Map<String, String> values) {
try {
var cooldown = seconds(values.get(COOLDOWN));
var duration = seconds(values.get(DURATION));
var reduction = new java.math.BigDecimal(values.get(REDUCTION));
return new StoneSkin.Settings(cooldown, duration, reduction);
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Stone Skin settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new java.math.BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new java.math.BigDecimal(value).doubleValue();
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
return result;
}
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"); }
}
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,48 @@
package games.dmg.fantasystick.progression;
import java.time.Instant;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
/** Server-thread bounded contribution ledger keyed by target and originating character. */
public final class CombatContributions implements AutoCloseable {
private final CombatRewardRules rules;
private final Map<UUID, Map<UUID, Instant>> targets = new HashMap<>();
private boolean closed;
public CombatContributions(CombatRewardRules rules) { this.rules = Objects.requireNonNull(rules); }
public void accept(UUID target, UUID character, Instant acceptedAt) {
Objects.requireNonNull(target); Objects.requireNonNull(character); Objects.requireNonNull(acceptedAt);
if (closed) return;
targets.computeIfAbsent(target, ignored -> new HashMap<>()).merge(character, acceptedAt,
(previous, next) -> previous.isAfter(next) ? previous : next);
}
public Set<UUID> consume(UUID target, Instant deathAt) {
Objects.requireNonNull(target); Objects.requireNonNull(deathAt);
if (closed) return Set.of();
var contributions = targets.remove(target);
if (contributions == null) return Set.of();
var eligible = new LinkedHashSet<UUID>();
contributions.forEach((character, acceptedAt) -> {
if (rules.contributes(acceptedAt, deathAt)) eligible.add(character);
});
return Set.copyOf(eligible);
}
public void invalidate(UUID target) { if (!closed) targets.remove(Objects.requireNonNull(target)); }
public void sweep(Instant now) {
Objects.requireNonNull(now);
if (closed) return;
targets.values().forEach(contributions -> contributions.entrySet().removeIf(entry -> !rules.contributes(entry.getValue(), now)));
targets.values().removeIf(Map::isEmpty);
}
@Override public void close() { closed = true; targets.clear(); }
}
@@ -0,0 +1,76 @@
package games.dmg.fantasystick.progression;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import java.util.UUID;
/** Validated combat reward arithmetic and location policy; native classification lives at the adapter. */
public record CombatRewardRules(BigInteger passivePool, BigInteger ordinaryPool, BigInteger bossPool,
BigDecimal restrictedSpacing) {
public enum Target { PASSIVE, ORDINARY, BOSS, FANTASY_CHARACTER }
public static final Duration CONTRIBUTION_LIFETIME = Duration.ofMinutes(5);
public record KillPosition(UUID world, BigDecimal x, BigDecimal y, BigDecimal z) {
public KillPosition {
Objects.requireNonNull(world);
x = coordinate(x); y = coordinate(y); z = coordinate(z);
}
private static BigDecimal coordinate(BigDecimal value) {
Objects.requireNonNull(value);
if (!Double.isFinite(value.doubleValue())) throw new IllegalArgumentException("Kill coordinate is not finite");
return value;
}
}
public CombatRewardRules {
Objects.requireNonNull(passivePool); Objects.requireNonNull(ordinaryPool);
Objects.requireNonNull(bossPool); Objects.requireNonNull(restrictedSpacing);
if (passivePool.signum() < 0 || ordinaryPool.signum() < 0 || bossPool.signum() < 0
|| restrictedSpacing.signum() < 0 || !Double.isFinite(restrictedSpacing.doubleValue())) {
throw new IllegalArgumentException("Combat reward values must be finite and nonnegative");
}
}
public static CombatRewardRules defaults() {
return new CombatRewardRules(BigInteger.ONE, BigInteger.TEN, BigInteger.valueOf(100), BigDecimal.valueOf(3));
}
public BigInteger pool(Target target, int defeatedLevel) {
Objects.requireNonNull(target);
if (target == Target.FANTASY_CHARACTER) {
if (defeatedLevel < 1) throw new IllegalArgumentException("Fantasy character level must be positive");
return BigInteger.TEN.multiply(BigInteger.valueOf(defeatedLevel));
}
return switch (target) {
case PASSIVE -> passivePool;
case ORDINARY -> ordinaryPool;
case BOSS -> bossPool;
case FANTASY_CHARACTER -> throw new IllegalStateException("Handled fantasy character");
};
}
public BigInteger share(BigInteger pool, int contributors) {
Objects.requireNonNull(pool);
if (pool.signum() < 0 || contributors < 1) throw new IllegalArgumentException("Invalid reward share");
var count = BigInteger.valueOf(contributors);
return pool.add(count).subtract(BigInteger.ONE).divide(count);
}
public boolean contributes(Instant acceptedAt, Instant deathAt) {
Objects.requireNonNull(acceptedAt); Objects.requireNonNull(deathAt);
return !acceptedAt.isAfter(deathAt) && !deathAt.isAfter(acceptedAt.plus(CONTRIBUTION_LIFETIME));
}
public boolean spaced(KillPosition previous, KillPosition current) {
Objects.requireNonNull(previous); Objects.requireNonNull(current);
if (!previous.world().equals(current.world())) return true;
BigDecimal dx = current.x().subtract(previous.x());
BigDecimal dy = current.y().subtract(previous.y());
BigDecimal dz = current.z().subtract(previous.z());
BigDecimal squared = dx.multiply(dx).add(dy.multiply(dy)).add(dz.multiply(dz));
return squared.compareTo(restrictedSpacing.multiply(restrictedSpacing)) >= 0;
}
}
@@ -21,6 +21,19 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private final Consumer<Failure> diagnostics;
private volatile boolean closed;
private games.dmg.fantasystick.persistence.AdventureSettings settings;
private games.dmg.fantasystick.persistence.FrostShardSettings frostSettings;
private games.dmg.fantasystick.ability.FrostShard frostShard;
private games.dmg.fantasystick.ability.AbilityRegistry abilities;
private games.dmg.fantasystick.persistence.LongbowSettings longbowSettings;
private games.dmg.fantasystick.ability.Longbow longbow;
private games.dmg.fantasystick.persistence.QuickstepSettings quickstepSettings;
private games.dmg.fantasystick.ability.Quickstep quickstep;
private games.dmg.fantasystick.persistence.SecondWindSettings secondWindSettings;
private games.dmg.fantasystick.ability.SecondWind secondWind;
private games.dmg.fantasystick.persistence.StoneSkinSettings stoneSkinSettings;
private games.dmg.fantasystick.ability.StoneSkin stoneSkin;
private games.dmg.fantasystick.persistence.ShieldBashSettings shieldBashSettings;
private games.dmg.fantasystick.ability.ShieldBash shieldBash;
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics) {
@@ -38,6 +51,108 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
this.settings = Objects.requireNonNull(settings);
}
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics,
games.dmg.fantasystick.persistence.AdventureSettings settings,
games.dmg.fantasystick.persistence.FrostShardSettings frostSettings,
games.dmg.fantasystick.ability.FrostShard frostShard,
games.dmg.fantasystick.ability.AbilityRegistry abilities) {
this(receipts, sessions, server, nextTick, diagnostics, settings);
this.frostSettings = Objects.requireNonNull(frostSettings);
this.frostShard = Objects.requireNonNull(frostShard);
this.abilities = Objects.requireNonNull(abilities);
}
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics,
games.dmg.fantasystick.persistence.AdventureSettings settings,
games.dmg.fantasystick.persistence.FrostShardSettings frostSettings,
games.dmg.fantasystick.ability.FrostShard frostShard,
games.dmg.fantasystick.persistence.LongbowSettings longbowSettings,
games.dmg.fantasystick.ability.Longbow longbow,
games.dmg.fantasystick.ability.AbilityRegistry abilities) {
this(receipts, sessions, server, nextTick, diagnostics, settings, frostSettings, frostShard, abilities);
this.longbowSettings = Objects.requireNonNull(longbowSettings);
this.longbow = Objects.requireNonNull(longbow);
}
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics,
games.dmg.fantasystick.persistence.AdventureSettings settings,
games.dmg.fantasystick.persistence.FrostShardSettings frostSettings,
games.dmg.fantasystick.ability.FrostShard frostShard,
games.dmg.fantasystick.persistence.LongbowSettings longbowSettings,
games.dmg.fantasystick.ability.Longbow longbow,
games.dmg.fantasystick.persistence.QuickstepSettings quickstepSettings,
games.dmg.fantasystick.ability.Quickstep quickstep,
games.dmg.fantasystick.ability.AbilityRegistry abilities) {
this(receipts, sessions, server, nextTick, diagnostics, settings, frostSettings, frostShard,
longbowSettings, longbow, abilities);
this.quickstepSettings = Objects.requireNonNull(quickstepSettings);
this.quickstep = Objects.requireNonNull(quickstep);
}
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics,
games.dmg.fantasystick.persistence.AdventureSettings settings,
games.dmg.fantasystick.persistence.FrostShardSettings frostSettings,
games.dmg.fantasystick.ability.FrostShard frostShard,
games.dmg.fantasystick.persistence.LongbowSettings longbowSettings,
games.dmg.fantasystick.ability.Longbow longbow,
games.dmg.fantasystick.persistence.QuickstepSettings quickstepSettings,
games.dmg.fantasystick.ability.Quickstep quickstep,
games.dmg.fantasystick.persistence.SecondWindSettings secondWindSettings,
games.dmg.fantasystick.ability.SecondWind secondWind,
games.dmg.fantasystick.ability.AbilityRegistry abilities) {
this(receipts, sessions, server, nextTick, diagnostics, settings, frostSettings, frostShard,
longbowSettings, longbow, quickstepSettings, quickstep, abilities);
this.secondWindSettings = Objects.requireNonNull(secondWindSettings);
this.secondWind = Objects.requireNonNull(secondWind);
}
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics,
games.dmg.fantasystick.persistence.AdventureSettings settings,
games.dmg.fantasystick.persistence.FrostShardSettings frostSettings,
games.dmg.fantasystick.ability.FrostShard frostShard,
games.dmg.fantasystick.persistence.LongbowSettings longbowSettings,
games.dmg.fantasystick.ability.Longbow longbow,
games.dmg.fantasystick.persistence.QuickstepSettings quickstepSettings,
games.dmg.fantasystick.ability.Quickstep quickstep,
games.dmg.fantasystick.persistence.SecondWindSettings secondWindSettings,
games.dmg.fantasystick.ability.SecondWind secondWind,
games.dmg.fantasystick.persistence.StoneSkinSettings stoneSkinSettings,
games.dmg.fantasystick.ability.StoneSkin stoneSkin,
games.dmg.fantasystick.ability.AbilityRegistry abilities) {
this(receipts, sessions, server, nextTick, diagnostics, settings, frostSettings, frostShard,
longbowSettings, longbow, quickstepSettings, quickstep, secondWindSettings, secondWind, abilities);
this.stoneSkinSettings = Objects.requireNonNull(stoneSkinSettings);
this.stoneSkin = Objects.requireNonNull(stoneSkin);
}
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics,
games.dmg.fantasystick.persistence.AdventureSettings settings,
games.dmg.fantasystick.persistence.FrostShardSettings frostSettings,
games.dmg.fantasystick.ability.FrostShard frostShard,
games.dmg.fantasystick.persistence.LongbowSettings longbowSettings,
games.dmg.fantasystick.ability.Longbow longbow,
games.dmg.fantasystick.persistence.QuickstepSettings quickstepSettings,
games.dmg.fantasystick.ability.Quickstep quickstep,
games.dmg.fantasystick.persistence.SecondWindSettings secondWindSettings,
games.dmg.fantasystick.ability.SecondWind secondWind,
games.dmg.fantasystick.persistence.StoneSkinSettings stoneSkinSettings,
games.dmg.fantasystick.ability.StoneSkin stoneSkin,
games.dmg.fantasystick.persistence.ShieldBashSettings shieldBashSettings,
games.dmg.fantasystick.ability.ShieldBash shieldBash,
games.dmg.fantasystick.ability.AbilityRegistry abilities) {
this(receipts, sessions, server, nextTick, diagnostics, settings, frostSettings, frostShard,
longbowSettings, longbow, quickstepSettings, quickstep, secondWindSettings, secondWind,
stoneSkinSettings, stoneSkin, abilities);
this.shieldBashSettings = Objects.requireNonNull(shieldBashSettings);
this.shieldBash = Objects.requireNonNull(shieldBash);
}
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (closed) return true;
if (!sender.hasPermission(PERMISSION)) {
@@ -109,15 +224,206 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
}
private void configuration(CommandSender sender, String[] args) {
if (args.length < 2 || args.length > 3 || !args[1].equals("adventure-mode")
|| args.length == 3 && !args[2].equals("true") && !args[2].equals("false")) {
sender.sendMessage("Usage: /fantasystick config adventure-mode [true|false]");
if (args.length < 2 || args.length > 3) { configurationUsage(sender); return; }
if (args[1].equals("adventure-mode")) { adventureConfiguration(sender, args); return; }
if (longbowSettings != null && games.dmg.fantasystick.persistence.LongbowSettings.keys().contains(args[1])) {
longbowConfiguration(sender, args); return;
}
if (quickstepSettings != null && games.dmg.fantasystick.persistence.QuickstepSettings.keys().contains(args[1])) {
quickstepConfiguration(sender, args); return;
}
if (secondWindSettings != null && games.dmg.fantasystick.persistence.SecondWindSettings.keys().contains(args[1])) {
secondWindConfiguration(sender, args); return;
}
if (stoneSkinSettings != null && games.dmg.fantasystick.persistence.StoneSkinSettings.keys().contains(args[1])) {
stoneSkinConfiguration(sender, args); return;
}
if (shieldBashSettings != null && games.dmg.fantasystick.persistence.ShieldBashSettings.keys().contains(args[1])) {
shieldBashConfiguration(sender, args); return;
}
if (frostSettings == null || !games.dmg.fantasystick.persistence.FrostShardSettings.keys().contains(args[1])) {
configurationUsage(sender); return;
}
String key = args[1];
if (args.length == 2) {
sender.sendMessage(key + "=" + frostSettings.value(key) + " (applies to future Frost Shard casts only)");
return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.FrostShardSettings.Update> operation;
try { operation = frostSettings.set(key, args[2]); }
catch (IllegalArgumentException invalid) {
sender.sendMessage("Frost Shard 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;
if (failure == null) {
frostShard.configure(saved.settings());
abilities.replace(frostShard.definition());
frostSettings.publish(saved);
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(failure == null
? "Saved " + key + "=" + saved.value() + " for future Frost Shard casts; active effects and deadlines are unchanged."
: "Frost Shard configuration save failed; the effective setting is unchanged.");
});
} catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
private void longbowConfiguration(CommandSender sender, String[] args) {
String key = args[1];
if (args.length == 2) {
sender.sendMessage(key + "=" + longbowSettings.value(key) + " (applies to future Longbow shots only)");
return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.LongbowSettings.Update> operation;
try { operation = longbowSettings.set(key, args[2]); }
catch (IllegalArgumentException invalid) {
sender.sendMessage("Longbow 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;
if (failure == null) {
longbow.configure(saved.settings());
abilities.replace(longbow.definition());
longbowSettings.publish(saved);
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(failure == null
? "Saved " + key + "=" + saved.value() + " for future Longbow shots; active shots and deadlines are unchanged."
: "Longbow configuration save failed; the effective setting is unchanged.");
});
} catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
private void quickstepConfiguration(CommandSender sender, String[] args) {
String key = args[1];
if (args.length == 2) {
sender.sendMessage(key + "=" + quickstepSettings.value(key) + " (applies to future Quickstep dashes only)");
return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.QuickstepSettings.Update> operation;
try { operation = quickstepSettings.set(key, args[2]); }
catch (IllegalArgumentException invalid) {
sender.sendMessage("Quickstep 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;
if (failure == null) {
quickstep.configure(saved.settings()); abilities.replace(quickstep.definition());
quickstepSettings.publish(saved);
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(failure == null
? "Saved " + key + "=" + saved.value() + " for future Quickstep dashes; active movement and deadlines are unchanged."
: "Quickstep configuration save failed; the effective setting is unchanged.");
});
} catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
private void shieldBashConfiguration(CommandSender sender, String[] args) {
String key = args[1];
if (args.length == 2) { sender.sendMessage(key + "=" + shieldBashSettings.value(key) + " (applies to future Shield Bash strikes only)"); return; }
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.ShieldBashSettings.Update> operation;
try { operation = shieldBashSettings.set(key, args[2]); }
catch (IllegalArgumentException invalid) { sender.sendMessage("Shield Bash 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;
if (failure == null) {
shieldBash.configure(saved.settings()); abilities.replace(shieldBash.definition()); shieldBashSettings.publish(saved);
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(failure == null
? "Saved " + key + "=" + saved.value() + " for future Shield Bash strikes; active work and deadlines are unchanged."
: "Shield Bash configuration save failed; the effective setting is unchanged.");
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
private void stoneSkinConfiguration(CommandSender sender, String[] args) {
String key = args[1];
if (args.length == 2) {
sender.sendMessage(key + "=" + stoneSkinSettings.value(key) + " (applies to future Stone Skin casts only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.StoneSkinSettings.Update> operation;
try { operation = stoneSkinSettings.set(key, args[2]); }
catch (IllegalArgumentException invalid) { sender.sendMessage("Stone Skin 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;
if (failure == null) {
stoneSkin.configure(saved.settings()); abilities.replace(stoneSkin.definition()); stoneSkinSettings.publish(saved);
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(failure == null
? "Saved " + key + "=" + saved.value() + " for future Stone Skin casts; active effects and deadlines are unchanged."
: "Stone Skin configuration save failed; the effective setting is unchanged.");
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
private void secondWindConfiguration(CommandSender sender, String[] args) {
String key = args[1];
if (args.length == 2) {
sender.sendMessage(key + "=" + secondWindSettings.value(key) + " (applies to future Second Wind casts only)");
return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SecondWindSettings.Update> operation;
try { operation = secondWindSettings.set(key, args[2]); }
catch (IllegalArgumentException invalid) {
sender.sendMessage("Second Wind 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;
if (failure == null) {
secondWind.configure(saved.settings()); abilities.replace(secondWind.definition());
secondWindSettings.publish(saved);
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(failure == null
? "Saved " + key + "=" + saved.value() + " for future Second Wind casts; active work and deadlines are unchanged."
: "Second Wind configuration save failed; the effective setting is unchanged.");
});
} catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
private void adventureConfiguration(CommandSender sender, String[] args) {
if (args.length == 3 && !args[2].equals("true") && !args[2].equals("false")) {
sender.sendMessage("Usage: /fantasystick config adventure-mode [true|false]"); return;
}
if (settings == null) { sender.sendMessage("Adventure configuration is unavailable."); return; }
if (args.length == 2) {
sender.sendMessage("adventure-mode=" + settings.enabled() + " (applies to new sessions only)");
return;
sender.sendMessage("adventure-mode=" + settings.enabled() + " (applies to new sessions only)"); return;
}
settings.set(Boolean.parseBoolean(args[2])).whenComplete((saved, failure) -> {
if (failure != null) report(null, "config-adventure-mode", failure);
@@ -134,6 +440,10 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
});
}
private static void configurationUsage(CommandSender sender) {
sender.sendMessage("Usage: /fantasystick config <setting> [value]");
}
private void report(UUID owner, String operation, Throwable cause) {
try { diagnostics.accept(new Failure(owner, operation, cause)); }
catch (RuntimeException ignored) { /* Diagnostics cannot authorize recovery. */ }
@@ -141,7 +451,56 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
// Never fall back to Bukkit's online-player-name suggestions for a UUID-only admin interface.
@Override public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
if (closed || !sender.hasPermission(PERMISSION)) return List.of();
if (args.length == 1) return matching(List.of("config", "inspect", "recover"), args[0]);
if (args.length == 2 && args[0].equals("config")) {
var keys = new java.util.ArrayList<String>(); keys.add("adventure-mode");
keys.addAll(games.dmg.fantasystick.persistence.FrostShardSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.LongbowSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.QuickstepSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.SecondWindSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.StoneSkinSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.ShieldBashSettings.keys());
return matching(keys, args[1]);
}
if (args.length == 3 && args[0].equals("config")) {
if (args[1].equals("adventure-mode")) return matching(List.of("false", "true"), 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]);
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
}
if (longbowSettings != null && games.dmg.fantasystick.persistence.LongbowSettings.keys().contains(args[1])) {
String current = longbowSettings.value(args[1]);
String defaults = games.dmg.fantasystick.persistence.LongbowSettings.defaultValue(args[1]);
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
}
if (quickstepSettings != null && games.dmg.fantasystick.persistence.QuickstepSettings.keys().contains(args[1])) {
String current = quickstepSettings.value(args[1]);
String defaults = games.dmg.fantasystick.persistence.QuickstepSettings.defaultValue(args[1]);
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
}
if (secondWindSettings != null && games.dmg.fantasystick.persistence.SecondWindSettings.keys().contains(args[1])) {
String current = secondWindSettings.value(args[1]);
String defaults = games.dmg.fantasystick.persistence.SecondWindSettings.defaultValue(args[1]);
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
}
if (stoneSkinSettings != null && games.dmg.fantasystick.persistence.StoneSkinSettings.keys().contains(args[1])) {
String current = stoneSkinSettings.value(args[1]);
String defaults = games.dmg.fantasystick.persistence.StoneSkinSettings.defaultValue(args[1]);
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
}
if (shieldBashSettings != null && games.dmg.fantasystick.persistence.ShieldBashSettings.keys().contains(args[1])) {
String current = shieldBashSettings.value(args[1]);
String defaults = games.dmg.fantasystick.persistence.ShieldBashSettings.defaultValue(args[1]);
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
}
}
return List.of();
}
private static List<String> matching(java.util.Collection<String> values, String prefix) {
return values.stream().filter(value -> value.startsWith(prefix)).distinct().sorted().toList();
}
@Override public void close() { closed = true; }
}
@@ -40,7 +40,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
private final Consumer<Runnable> nextTick;
private final Consumer<EquipmentSessions.Failure> diagnostics;
private final CharacterSheets sheets = new CharacterSheets();
private final SessionControls controls = new SessionControls();
private SessionControls controls = new SessionControls();
private static final class Intent { boolean started; }
private final Map<UUID, Intent> swaps = new HashMap<>();
private final Map<UUID, UUID> observed = new HashMap<>();
@@ -88,6 +88,12 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
sessions.attachCursorCustody(this::cancelCustody);
}
/** Install before registration so extensible controls keep the same structural containment catalogue. */
public void attachAbilityRegistry(games.dmg.fantasystick.ability.AbilityRegistry registry) {
if (closed || !draws.isEmpty()) throw new IllegalStateException("Input handling already started");
controls = new SessionControls(Objects.requireNonNull(registry));
}
/** The input recipient belongs to this plugin; it does not authorize vanilla item use. */
public EquipmentSessionEvents(EquipmentSessions sessions, Consumer<Runnable> nextTick,
Consumer<EquipmentSessions.Failure> diagnostics, Consumer<ControlInput> inputs) {
@@ -443,7 +449,8 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
if (special(item)) event.setCancelled(true);
if (willAttack) {
var selected = sessions.selectedControl(player, item).orElse(null);
if (selected != null && (selected.slot() == 1 || selected.ability().equals("great_sword"))) {
if (selected != null && (selected.slot() == 1 || selected.ability().equals("great_sword")
|| selected.ability().equals("shield_bash"))) {
emit(player, item, selected, ControlInput.Gesture.ATTACK, event.getAttacked(), 0, player.getAttackCooldown());
}
}
@@ -6,6 +6,7 @@ import games.dmg.fantasystick.items.SessionControls;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.persistence.EquipmentSessionStore;
import java.math.BigInteger;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
@@ -45,7 +46,7 @@ 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 final SessionControls controls = new SessionControls();
private SessionControls controls = new SessionControls();
private final CharacterSheets sheets = new CharacterSheets();
private final Map<UUID, Attempt> pending = new HashMap<>();
private final Map<UUID, Attempt> owned = new HashMap<>();
@@ -141,6 +142,12 @@ public final class EquipmentSessions implements AutoCloseable {
this.gameModes = Objects.requireNonNull(gameModes);
}
/** 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));
}
/** Install before exposing entry; equipment-only adapter tests can omit the health overlay. */
public synchronized void attachVitals(SessionVitals value) {
if (vitals != null || !owned.isEmpty()) throw new IllegalStateException("Vitals already attached or sessions started");
@@ -201,6 +208,42 @@ public final class EquipmentSessions implements AutoCloseable {
&& sheets.identify(attempt.player.getInventory().getItemInOffHand()).filter(attempt.character::equals).isPresent();
}
/** Current active holder for a transferable identity, used only to synchronize acknowledged character writes. */
public synchronized Optional<Active> activeCharacter(UUID character) {
Objects.requireNonNull(character);
return active.values().stream().filter(attempt -> attempt.record != null && attempt.character.equals(character))
.filter(attempt -> current(new Active(attempt.owner, attempt.session, attempt.character)))
.map(attempt -> new Active(attempt.owner, attempt.session, attempt.character)).findFirst();
}
/** Current transferable character snapshot for a still-authorized server-thread action. */
public synchronized Optional<CharacterRecord> currentRecord(Active session) {
var attempt = active.get(session.player());
return current(session) && attempt != null && attempt.record != null
? Optional.of(attempt.record) : Optional.empty();
}
/** Apply a persisted progression revision to the still-current active session. */
public synchronized boolean acknowledgeProgress(Active session, BigInteger expectedRevision, CharacterRecord saved) {
var attempt = active.get(session.player());
if (closed || attempt == null || attempt.session == null || attempt.record == null
|| !attempt.session.equals(session.session()) || !attempt.character.equals(session.character())
|| !attempt.record.revision().equals(expectedRevision) || !saved.id().equals(attempt.character)
|| !saved.revision().equals(expectedRevision.add(BigInteger.ONE)) || !current(session)) return false;
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.
}
return true;
}
/** Server-thread authorization for the currently held, issued control and saved loadout. */
public synchronized Optional<SelectedControl> selectedControl(Player player, ItemStack reported) {
var attempt = active.get(player.getUniqueId());
@@ -0,0 +1,48 @@
package games.dmg.fantasystick.session;
import java.util.Objects;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
/** Active-session-only hunger/exhaustion suppression; explicit non-food healing remains native. */
public final class NutritionPolicy implements Listener, AutoCloseable {
private final EquipmentSessions sessions;
private boolean closed;
public NutritionPolicy(EquipmentSessions sessions) {
this.sessions = Objects.requireNonNull(sessions);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void food(org.bukkit.event.entity.FoodLevelChangeEvent event) {
if (!active(event.getEntity())) return;
event.setCancelled(true);
var player = (Player) event.getEntity();
player.setFoodLevel(20);
player.setExhaustion(0);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void exhaustion(org.bukkit.event.entity.EntityExhaustionEvent event) {
if (!active(event.getEntity())) return;
event.setCancelled(true);
var player = (Player) event.getEntity();
player.setFoodLevel(20);
player.setExhaustion(0);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void healing(org.bukkit.event.entity.EntityRegainHealthEvent event) {
if (event.getRegainReason() == org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.SATIATED
&& active(event.getEntity())) event.setCancelled(true);
}
private boolean active(org.bukkit.entity.Entity entity) {
return !closed && entity instanceof Player player
&& sessions.active(player.getUniqueId()).isPresent();
}
@Override public void close() { closed = true; }
}
@@ -57,10 +57,15 @@ public final class SessionVitals implements AutoCloseable {
if (closed) throw new IllegalStateException("Vitals lifetime ended during entry");
player.setHealthScale(maximum);
if (closed) throw new IllegalStateException("Vitals lifetime ended during entry");
player.setFoodLevel(20);
player.setExhaustion(0);
if (closed) throw new IllegalStateException("Vitals lifetime ended during entry");
write(player, new Mark(session, Phase.APPLIED, maximum, ordinary));
} catch (RuntimeException | LinkageError failure) {
try { releaseMaximum(player, session); }
catch (RuntimeException | LinkageError cleanup) { failure.addSuppressed(cleanup); }
try { restoreNutrition(player, ordinary); }
catch (RuntimeException | LinkageError cleanup) { failure.addSuppressed(cleanup); }
try { restoreScale(player, maximum, ordinary); }
catch (RuntimeException | LinkageError cleanup) { failure.addSuppressed(cleanup); }
// Leave APPLYING proof and the acknowledged receipt: an uncertain health setter
@@ -69,6 +74,30 @@ public final class SessionVitals implements AutoCloseable {
}
}
/** Apply an acknowledged stat revision to the live native health pool exactly once. */
public void updateProgress(Player player, UUID session, CharacterRecord previous, CharacterRecord saved) {
Objects.requireNonNull(previous);
Objects.requireNonNull(saved);
var mark = read(player);
var lease = leases.get(player.getUniqueId());
if (closed || mark == null || mark.phase() != Phase.APPLIED || !mark.session().equals(session)
|| lease == null || !lease.session().equals(session) || !previous.id().equals(saved.id())
|| !previous.revision().add(java.math.BigInteger.ONE).equals(saved.revision())) {
throw new IllegalStateException("No matching active fantasy vitals revision");
}
double nextMaximum = saved.maximumHealth().min(NativeHealthLimit.read()).doubleValue();
double gain = Math.max(0, nextMaximum - mark.maximum());
if (gain == 0 && nextMaximum == mark.maximum()) return;
var nativeAttribute = Objects.requireNonNull(((CraftPlayer) player).getHandle()
.getAttribute(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH));
lease.maximum().close();
var replacement = new NativeMaximumHealth(nativeAttribute, nextMaximum);
leases.put(player.getUniqueId(), new Lease(session, replacement));
player.setHealth(Math.min(nextMaximum, player.getHealth() + gain));
player.setHealthScale(nextMaximum);
write(player, new Mark(session, Phase.APPLIED, nextMaximum, mark.ordinary()));
}
public BigDecimal remaining(Player player, UUID session, OrdinaryVitals ordinary) {
var mark = read(player);
if (mark == null || !mark.session().equals(session) || !mark.ordinary().equals(ordinary) || mark.phase() != Phase.APPLIED) {
@@ -97,7 +126,7 @@ public final class SessionVitals implements AutoCloseable {
// completion). Restore positive health first: a zero-health packet opens a client death
// screen even when Paper cancels the death and the server-side player survives.
player.setHealth(Math.min(ordinary.health(), Objects.requireNonNull(player.getAttribute(Attribute.MAX_HEALTH)).getValue()));
player.setFoodLevel(ordinary.food()); player.setSaturation(ordinary.saturation()); player.setExhaustion(ordinary.exhaustion());
restoreNutrition(player, ordinary);
restoreScale(player, mark.maximum(), ordinary);
write(player, new Mark(session, Phase.RELEASED, mark.maximum(), ordinary));
return true;
@@ -111,6 +140,12 @@ public final class SessionVitals implements AutoCloseable {
}
}
private static void restoreNutrition(Player player, OrdinaryVitals ordinary) {
player.setFoodLevel(ordinary.food());
player.setSaturation(ordinary.saturation());
player.setExhaustion(ordinary.exhaustion());
}
private static void restoreScale(Player player, double maximum, OrdinaryVitals ordinary) {
if (player.isHealthScaled() && player.getHealthScale() == maximum) {
player.setHealthScale(ordinary.scale());
+69
View File
@@ -1,3 +1,72 @@
# Captured when a new fantasy session starts; changing this does not alter active sessions.
# Admin command: /fantasystick config adventure-mode [true|false]
adventure-mode: true
# Real-time delay before this player may enter a different character after exit.
character-swap-cooldown-seconds: 900
# New-character and transferable fantasy progression defaults.
starting-points: 40
xp-per-level: 100
maximum-level: 30
# Character-bound combat rewards. Restricted hostile/neutral/boss history uses mob death positions.
passive-xp: 1
ordinary-xp: 10
boss-xp: 100
restricted-kill-spacing-blocks: 3
# Fireball defaults from the reviewed starter catalogue.
fireball-base-damage: 6
fireball-cooldown-seconds: 4
fireball-range-blocks: 32
fireball-lifetime-seconds: 5
# Great Sword defaults from the reviewed starter catalogue.
great-sword-base-damage: 7
great-sword-recovery-seconds: 1.2
# Frost Shard defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
frost-shard-base-damage: 4
frost-shard-cooldown-seconds: 5
frost-shard-range-blocks: 32
frost-shard-lifetime-seconds: 5
frost-shard-projectile-speed-blocks-per-tick: 1
frost-shard-slowness-amplifier: 0
frost-shard-slowness-duration-seconds: 2
# Longbow defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
longbow-base-damage: 5
longbow-recovery-seconds: 1
longbow-minimum-draw-ticks: 3
longbow-full-draw-ticks: 20
longbow-range-blocks: 64
longbow-lifetime-seconds: 10
longbow-full-projectile-speed-blocks-per-tick: 3
# Quickstep defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
quickstep-cooldown-seconds: 5
quickstep-distance-blocks: 4
quickstep-speed-blocks-per-tick: 1
# Second Wind defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
second-wind-base-healing: 6
second-wind-cooldown-seconds: 15
# Stone Skin defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
stone-skin-cooldown-seconds: 14
stone-skin-duration-seconds: 4
stone-skin-damage-reduction-percent: 25
# Shield Bash defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
shield-bash-base-damage: 4
shield-bash-cooldown-seconds: 6
shield-bash-range-blocks: 3
shield-bash-horizontal-knockback: 0.8
shield-bash-minimum-vertical-knockback: 0.35
+2 -2
View File
@@ -7,8 +7,8 @@ description: Transferable characters, native fantasy health, recoverable equipme
author: dmg.games
commands:
fantasystick:
description: Inspect/recover equipment sessions or configure fantasy mode.
usage: /fantasystick <inspect|recover> <player-uuid> | config adventure-mode [true|false]
description: Inspect/recover equipment sessions or configure Fantasy Stick.
usage: /fantasystick <inspect|recover> <player-uuid> | config <setting> [value]
permission: fantasystick.admin
permissions:
fantasystick.admin:
@@ -0,0 +1,225 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.ability.AbilityDefinition;
import games.dmg.fantasystick.ability.AbilityRegistry;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import org.bukkit.Material;
import org.bukkit.event.block.Action;
import org.bukkit.inventory.EquipmentSlot;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class AbilityEngineNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void executionContractsRevalidateTargetsBoundAreasReportOnlyEffectsAndCleanOwnedWork() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var registry = new AbilityRegistry();
var reports = new java.util.ArrayList<games.dmg.fantasystick.ability.AbilityReport>();
var delayed = new java.util.ArrayList<Runnable>();
var closedResources = new AtomicInteger();
var delayedEffects = new AtomicInteger();
var selectionEffects = new AtomicInteger();
var allyEffects = new AtomicInteger();
var exceptionalEffects = new AtomicInteger();
var areaRecipients = new java.util.ArrayList<org.bukkit.entity.Entity>();
var areaCandidates = new java.util.concurrent.atomic.AtomicReference<List<? extends org.bukkit.entity.Entity>>(List.of());
registry.register(definition("report_test", AbilityDefinition.Activation.IMMEDIATE,
AbilityDefinition.TargetPolicy.HOSTILE, execution -> {
for (var type : games.dmg.fantasystick.ability.AbilityReport.Type.values()) {
execution.report(type, execution.input().target(), BigDecimal.valueOf(2));
}
}));
registry.register(definition("area_test", AbilityDefinition.Activation.IMMEDIATE,
AbilityDefinition.TargetPolicy.AREA, execution -> areaRecipients.addAll(execution.eligible(areaCandidates.get(), 2))));
registry.register(definition("delayed_test", AbilityDefinition.Activation.DELAYED,
AbilityDefinition.TargetPolicy.PARTY, execution -> execution.later(Duration.ofSeconds(1), () -> {
delayedEffects.incrementAndGet(); execution.finish();
})));
registry.register(definition("selection_test", AbilityDefinition.Activation.SELECTION,
AbilityDefinition.TargetPolicy.SELECTION, execution -> execution.later(Duration.ofSeconds(1), () -> {
selectionEffects.incrementAndGet(); execution.finish();
})));
registry.register(definition("projectile_test", AbilityDefinition.Activation.PROJECTILE,
AbilityDefinition.TargetPolicy.HOSTILE, execution -> execution.own(closedResources::incrementAndGet)));
registry.register(definition("ally_test", AbilityDefinition.Activation.IMMEDIATE,
AbilityDefinition.TargetPolicy.ALLY, execution -> allyEffects.incrementAndGet()));
registry.register(definition("exception_test", AbilityDefinition.Activation.IMMEDIATE,
AbilityDefinition.TargetPolicy.EXCEPTIONAL_NONCOMBAT, execution -> exceptionalEffects.incrementAndGet()));
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var b = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
prepare(data, a, List.of("report_test", "area_test", "delayed_test", "selection_test", "projectile_test", "ally_test", "exception_test"));
f.activate(a); f.activate(b);
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
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()));
games.dmg.fantasystick.ability.AbilityScheduler scheduler = (delay, action) -> {
delayed.add(action); return closedResources::incrementAndGet;
};
var engine = new games.dmg.fantasystick.ability.AbilityEngine(registry,
(CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters"), sessions, parties,
java.time.Clock.fixed(java.time.Instant.parse("2026-09-19T20:00:00Z"), java.time.ZoneOffset.UTC),
f.tasks::add, scheduler, reports::add, failure -> fail(failure.toString()));
var active = sessions.active(a.core.id).orElseThrow();
var hostile = org.mockito.Mockito.mock(org.bukkit.entity.LivingEntity.class);
org.mockito.Mockito.when(hostile.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
cast(engine, f, a, active, 2, "report_test", hostile);
assertEquals(java.util.Set.copyOf(java.util.Arrays.asList(games.dmg.fantasystick.ability.AbilityReport.Type.values())),
reports.stream().map(games.dmg.fantasystick.ability.AbilityReport::type).collect(java.util.stream.Collectors.toSet()));
assertEquals(5, reports.size());
assertEquals(a.core.record.id(), reports.getFirst().sourceCharacter());
var ordinary = org.mockito.Mockito.mock(org.bukkit.entity.Player.class);
org.mockito.Mockito.when(ordinary.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
areaCandidates.set(List.of(hostile, ordinary, b.player));
active = sessions.active(a.core.id).orElseThrow();
cast(engine, f, a, active, 3, "area_test", null);
assertEquals(List.of(hostile, b.player), areaRecipients);
assertEquals(5, reports.size(), "Casting and no-op/recipient discovery do not manufacture reports");
active = sessions.active(a.core.id).orElseThrow();
cast(engine, f, a, active, 7, "ally_test", b.player);
assertEquals(1, allyEffects.get());
active = sessions.active(a.core.id).orElseThrow();
cast(engine, f, a, active, 8, "exception_test", ordinary);
assertEquals(1, exceptionalEffects.get(), "Explicit non-combat policy may select an ordinary player");
active = sessions.active(a.core.id).orElseThrow();
cast(engine, f, a, active, 4, "delayed_test", b.player);
assertEquals(1, delayed.size());
assertTrue(parties.leave(a.core.id, party.id()));
delayed.removeFirst().run();
assertEquals(0, delayedEffects.get(), "Party eligibility is rechecked at delayed application");
active = sessions.active(a.core.id).orElseThrow();
cast(engine, f, a, active, 5, "selection_test", b.player);
assertEquals(1, delayed.size());
engine.remove(a.core.record.id(), "selection_test");
delayed.removeFirst().run();
assertEquals(0, selectionEffects.get(), "Removal invalidates pending selection callbacks without clearing cooldowns");
var stored = ((CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters"))
.find(a.core.record.id()).get().orElseThrow();
assertTrue(stored.cooldowns().containsKey("selection_test"));
active = sessions.active(a.core.id).orElseThrow();
cast(engine, f, a, active, 6, "projectile_test", hostile);
int beforeExit = closedResources.get();
PartyCombatNativeTest.exit(f, a);
engine.sweep();
assertEquals(beforeExit + 1, closedResources.get(), "Session end cleans the projectile definition's owned resource");
engine.close();
assertEquals(beforeExit + 1, closedResources.get(), "Repeated engine cleanup cannot close owned state twice");
}
}
@Test void registeredTestDefinitionUsesRealBookPersistenceControlAndCastRoutes() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var registry = (AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
var effects = new AtomicInteger();
registry.register(new AbilityDefinition("test_focus", "Test Focus", Material.AMETHYST_SHARD,
List.of("A registered test effect"), "Use the equipped shard", Map.of(), BigInteger.ONE,
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, Duration.ofSeconds(4),
Map.of("range", BigDecimal.valueOf(12)), AbilityDefinition.TargetPolicy.SELF,
execution -> { assertTrue(execution.stillCurrent()); effects.incrementAndGet(); }));
var a = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(a.core.id, a.player); f.activate(a);
a.core.inventory.getInventory().setSelectedSlot(0);
PartyBookNativeTest.open(f, a);
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);
assertTrue(compactAvailable.contains("Cooldown:4s"), available);
var learn = PartyBookNativeTest.button(a, "[Learn]");
PartyBookNativeTest.click(f, a, learn); PartyBookNativeTest.click(f, a, learn);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var learned = storage.find(a.core.record.id()).get().orElseThrow();
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());
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());
a.core.inventory.getInventory().setSelectedSlot(2);
var cast = new org.bukkit.event.player.PlayerInteractEvent(a.player, Action.RIGHT_CLICK_AIR, control,
null, org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND);
f.dispatcher.accept(cast); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(org.bukkit.event.Event.Result.DENY, cast.useItemInHand());
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()));
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));
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")));
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("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,
a.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND));
PartyBookNativeTest.drain(f);
assertEquals(1, effects.get(), "Exit/resume cannot reset the persisted character cooldown");
assertEquals(cooled.cooldowns(), storage.find(a.core.record.id()).get().orElseThrow().cooldowns());
}
}
private static AbilityDefinition definition(String id, AbilityDefinition.Activation activation,
AbilityDefinition.TargetPolicy targetPolicy, games.dmg.fantasystick.ability.AbilityEffect effect) {
return new AbilityDefinition(id, id, Material.AMETHYST_SHARD, List.of("Test definition"), "Use test input",
Map.of(), BigInteger.ONE, activation, ControlInput.Gesture.USE, Duration.ZERO,
Map.of(), targetPolicy, effect);
}
private static void prepare(Path data, FantasyStickLifecycleNativeTest.Actor actor, List<String> loadout) throws Exception {
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(actor.core.record.id()).orElseThrow();
var learned = loadout.stream().filter(id -> !id.isEmpty()).collect(java.util.stream.Collectors.toSet());
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE),
old.stats(), old.level(), old.experience(), old.unspentPoints(), old.health(), learned, loadout,
old.cooldowns(), old.restrictedKills(), false, null, null);
repository.replace(saved, old.revision());
}
private static void cast(games.dmg.fantasystick.ability.AbilityEngine engine,
FantasyStickLifecycleNativeTest.Fixture fixture, FantasyStickLifecycleNativeTest.Actor actor,
games.dmg.fantasystick.session.EquipmentSessions.Active active, int slot, String id,
org.bukkit.entity.Entity target) throws Exception {
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
engine.accept(new ControlInput(actor.player, active, slot, id, ControlInput.Kind.ABILITY,
ControlInput.Gesture.USE, target, 0, 0, () -> sessions.current(active)));
PartyBookNativeTest.drain(fixture);
}
}
@@ -0,0 +1,90 @@
package games.dmg.fantasystick;
import games.dmg.fantasystick.ability.AbilityDefinition;
import games.dmg.fantasystick.ability.AbilityRegistry;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import org.bukkit.Material;
import org.junit.jupiter.api.Test;
class AbilityRegistryNativeTest {
@org.junit.jupiter.api.BeforeAll static void bootstrap() throws Exception {
games.dmg.fantasystick.NativeRuntime.bootstrap();
}
@Test void registersCompleteStableDefinitionsAndRejectsDuplicateIds() {
var registry = new AbilityRegistry();
var definition = definition("test_bolt");
registry.register(definition);
assertSame(definition, registry.find("test_bolt").orElseThrow());
assertEquals(List.of(definition), registry.definitions());
assertThrows(IllegalArgumentException.class, () -> registry.register(definition("test_bolt")));
assertTrue(registry.find("unknown_saved_id").isEmpty());
assertTrue(registry.find("basic_attack").isEmpty(), "The built-in stick is not a learned/refundable definition");
}
@Test void rejectsMalformedMetadataRequirementsAndNumericParametersBeforeRegistration() {
var valid = definition("valid");
assertAll(
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "UPPER", valid.requirements(), valid.parameters(), valid.cooldown())),
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "", valid.requirements(), valid.parameters(), valid.cooldown())),
() -> assertThrows(IllegalArgumentException.class, () -> new AbilityDefinition("x", " ", Material.STONE,
List.of("Lore"), "Use", Map.of(Stat.INT, BigInteger.TEN), BigInteger.ONE,
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, Duration.ZERO,
Map.of(), AbilityDefinition.TargetPolicy.HOSTILE, ignored -> {})),
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "low", Map.of(Stat.INT, BigInteger.valueOf(9)), Map.of(), Duration.ZERO)),
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "negative", valid.requirements(), Map.of("range", BigDecimal.valueOf(-1)), Duration.ZERO)),
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "infinite", valid.requirements(), Map.of("range", new BigDecimal("1E1000000")), Duration.ZERO)),
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "cooldown", valid.requirements(), Map.of(), Duration.ofSeconds(-1))),
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "long_cooldown", valid.requirements(), Map.of(), Duration.ofDays(366))),
() -> assertThrows(IllegalArgumentException.class, () -> new AbilityDefinition("air", "Air", Material.AIR,
List.of("Lore"), "Use", Map.of(), BigInteger.ONE, AbilityDefinition.Activation.IMMEDIATE,
ControlInput.Gesture.USE, Duration.ZERO, Map.of(), AbilityDefinition.TargetPolicy.SELF, ignored -> {}))
);
}
@Test void effectReportsRequireActualPositiveFiniteEvidence() {
var character = java.util.UUID.randomUUID(); var session = java.util.UUID.randomUUID(); var target = java.util.UUID.randomUUID();
var now = java.time.Instant.parse("2026-09-19T20:00:00Z");
for (var type : games.dmg.fantasystick.ability.AbilityReport.Type.values()) {
assertDoesNotThrow(() -> new games.dmg.fantasystick.ability.AbilityReport(type, character, session, target,
BigDecimal.ONE, now));
assertThrows(IllegalArgumentException.class, () -> new games.dmg.fantasystick.ability.AbilityReport(type,
character, session, target, BigDecimal.ZERO, now));
assertThrows(IllegalArgumentException.class, () -> new games.dmg.fantasystick.ability.AbilityReport(type,
character, session, target, new BigDecimal("1E1000000"), now));
}
}
@Test void definitionsSupportEveryBoundedExecutionStyleAndTargetBoundary() {
for (var activation : AbilityDefinition.Activation.values()) {
for (var policy : AbilityDefinition.TargetPolicy.values()) {
var base = definition("x");
assertDoesNotThrow(() -> new AbilityDefinition("a_" + activation.name().toLowerCase() + "_" + policy.name().toLowerCase(),
base.name(), base.icon(), base.lore(), base.instructions(), base.requirements(), base.pointCost(), activation,
base.gesture(), base.cooldown(), base.parameters(), policy, base.effect()));
}
}
}
static AbilityDefinition definition(String id) {
return new AbilityDefinition(id, "Test Bolt", Material.AMETHYST_SHARD, List.of("A focused test effect"),
"Use the equipped control", Map.of(Stat.INT, BigInteger.valueOf(12)), BigInteger.ONE,
AbilityDefinition.Activation.PROJECTILE, ControlInput.Gesture.USE, Duration.ofSeconds(4),
Map.of("range", BigDecimal.valueOf(12), "duration", BigDecimal.valueOf(5)),
AbilityDefinition.TargetPolicy.HOSTILE, ignored -> {});
}
private static AbilityDefinition copy(AbilityDefinition value, String id, Map<Stat, BigInteger> requirements,
Map<String, BigDecimal> parameters, Duration cooldown) {
return new AbilityDefinition(id, value.name(), value.icon(), value.lore(), value.instructions(), requirements,
value.pointCost(), value.activation(), value.gesture(), cooldown, parameters, value.targetPolicy(), value.effect());
}
}
@@ -0,0 +1,114 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class BasicAttackNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void successfulHitsStartAnExactOneSecondCadenceButCancelledDamageDoesNot() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.activate(a);
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
var active = sessions.active(a.core.id).orElseThrow();
var clock = new MutableClock(Instant.parse("2026-09-19T00:00:00Z"));
var basic = new games.dmg.fantasystick.combat.BasicAttack(sessions, clock);
var target = mock(org.bukkit.entity.LivingEntity.class);
var cancelled = new java.util.concurrent.atomic.AtomicBoolean(true);
doAnswer(call -> {
var source = mock(org.bukkit.damage.DamageSource.class);
when(source.getCausingEntity()).thenReturn(a.player);
when(source.getDirectEntity()).thenReturn(a.player);
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, target,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, (Double) call.getArgument(0));
event.setCancelled(cancelled.get());
basic.acceptedDamage(event);
return null;
}).when(target).damage(anyDouble(), same(a.player));
var input = new games.dmg.fantasystick.session.ControlInput(a.player, active, 1, "",
games.dmg.fantasystick.session.ControlInput.Kind.BASIC_ATTACK,
games.dmg.fantasystick.session.ControlInput.Gesture.ATTACK, target, 0, 1,
() -> sessions.current(active));
basic.accept(input); // Cancelled: no cadence spent.
cancelled.set(false);
basic.accept(input); // Accepted at t=0.
clock.advance(java.time.Duration.ofMillis(999)); basic.accept(input);
verify(target, times(2)).damage(1.0, a.player);
clock.advance(java.time.Duration.ofMillis(1)); basic.accept(input);
verify(target, times(3)).damage(1.0, a.player);
a.core.inventory.setItemInOffHand(null); f.changed(a);
clock.advance(java.time.Duration.ofSeconds(1)); basic.accept(input);
verifyNoMoreInteractions(target);
}
}
@Test void partyCancellationPreventsDamageAndDoesNotSpendThePackagedAttackCadence() 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);
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()));
var events = new java.util.ArrayList<org.bukkit.event.entity.EntityDamageByEntityEvent>();
doAnswer(call -> {
var source = mock(org.bukkit.damage.DamageSource.class);
when(source.getCausingEntity()).thenReturn(a.player);
when(source.getDirectEntity()).thenReturn(a.player);
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, b.player,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, (Double) call.getArgument(0));
events.add(event); f.dispatcher.accept(event); return null;
}).when(b.player).damage(anyDouble(), same(a.player));
a.core.inventory.getInventory().setSelectedSlot(1);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, b.player, true));
assertEquals(1, events.size()); assertTrue(events.getFirst().isCancelled());
assertTrue(parties.leave(a.core.id, party.id()));
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, b.player, true));
assertEquals(2, events.size()); assertFalse(events.getLast().isCancelled());
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, b.player, true));
assertEquals(2, events.size(), "The accepted second hit starts cadence; the cancelled first hit did not");
}
}
@Test void packagedBasicStickInputDealsOneLowStrengthHitWithoutVanillaContinuation() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.activate(a);
a.core.inventory.getInventory().setSelectedSlot(1);
var target = mock(org.bukkit.entity.LivingEntity.class);
var attack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
f.dispatcher.accept(attack);
assertTrue(attack.isCancelled(), "The icon stick must never continue into vanilla damage");
verify(target).damage(1.0, a.player);
a.core.inventory.setItemInOffHand(null); f.changed(a); f.awaitCharacterAndReceipt(a);
assertEquals(org.bukkit.Material.DIAMOND_SWORD, a.core.inventory.getItem(0).getType());
assertTrue(a.core.inventory.getItem(1) == null || a.core.inventory.getItem(1).isEmpty(),
"Exit restores the ordinary item that occupied the basic-stick slot");
}
}
private static final class MutableClock extends Clock {
private Instant now;
MutableClock(Instant now) { this.now = now; }
void advance(java.time.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; }
}
}
@@ -77,6 +77,103 @@ class CharacterBookNativeTest {
}
}
@Test void bookPurchasePersistsConAndPointsAndAppliesTheAcknowledgedNativeHealthGain() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.activate(a);
a.player.setHealth(5);
PartyBookNativeTest.open(f, a);
var purchase = PartyBookNativeTest.button(a, "[+1 CON]");
PartyBookNativeTest.click(f, a, purchase);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var saved = characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
assertEquals(java.math.BigInteger.ONE, saved.stats().get(games.dmg.fantasystick.progression.Stat.CON));
assertEquals(java.math.BigInteger.valueOf(39), saved.unspentPoints());
assertEquals(0, saved.health().compareTo(java.math.BigDecimal.valueOf(6)));
assertEquals(11, a.maximumHealth.getValue());
assertEquals(6, a.player.getHealth());
assertTrue(PartyBookNativeTest.text(a).contains("CON: 1"));
assertTrue(PartyBookNativeTest.text(a).contains("Unspent points: 39"));
PartyBookNativeTest.click(f, a, purchase);
PartyBookNativeTest.drain(f);
assertEquals(saved, characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow(),
"A rendered purchase action is single-use");
}
}
@Test void conPurchaseKeepsItsOwnMaximumAndOneTimeGainDespiteExternalModifiers() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player);
var before = new net.minecraft.world.entity.ai.attributes.AttributeModifier(
net.minecraft.resources.Identifier.fromNamespaceAndPath("test", "before_purchase"), 5,
net.minecraft.world.entity.ai.attributes.AttributeModifier.Operation.ADD_VALUE);
a.maximumHealth.addTransientModifier(before);
f.activate(a); assertEquals(10, a.maximumHealth.getValue());
var during = new net.minecraft.world.entity.ai.attributes.AttributeModifier(
net.minecraft.resources.Identifier.fromNamespaceAndPath("test", "during_purchase"), 100,
net.minecraft.world.entity.ai.attributes.AttributeModifier.Operation.ADD_VALUE);
a.maximumHealth.addPermanentModifier(during);
assertEquals(10, a.maximumHealth.getValue());
a.player.setHealth(5);
PartyBookNativeTest.open(f, a);
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[+1 CON]"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(11, a.maximumHealth.getValue()); assertEquals(6, a.player.getHealth());
assertEquals(java.util.Set.of(before, during), a.maximumHealth.getModifiers());
a.core.inventory.setItemInOffHand(null); f.changed(a); f.awaitCharacterAndReceipt(a);
assertEquals(java.util.Set.of(before, during), a.maximumHealth.getModifiers());
var saved = characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
assertEquals(java.math.BigInteger.ONE, saved.stats().get(games.dmg.fantasystick.progression.Stat.CON));
assertEquals(0, saved.health().compareTo(java.math.BigDecimal.valueOf(6)));
}
}
@Test void failedPurchaseChangesNothingAndAcknowledgedStatsSurviveReopenTransferAndReload() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "character-swap-cooldown-seconds: 0\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var b = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player); f.activate(a);
a.player.setHealth(5);
PartyBookNativeTest.open(f, a);
var rejected = PartyBookNativeTest.button(a, "[+1 STR]");
var before = characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
try (var channel = java.nio.channels.FileChannel.open(data.resolve("characters/.records.lock"), java.nio.file.StandardOpenOption.WRITE);
var lock = channel.lock()) {
assertTrue(lock.isValid());
PartyBookNativeTest.click(f, a, rejected);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
}
assertEquals(before, characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow());
assertEquals(5, a.player.getHealth()); assertEquals(10, a.maximumHealth.getValue());
assertTrue(a.messages.stream().anyMatch(message -> message.contains("could not be saved")));
PartyBookNativeTest.click(f, a, rejected); PartyBookNativeTest.drain(f);
assertEquals(before, characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow());
PartyBookNativeTest.open(f, a);
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[+1 STR]"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var saved = characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
assertEquals(java.math.BigInteger.ONE, saved.stats().get(games.dmg.fantasystick.progression.Stat.STR));
assertEquals(java.math.BigInteger.valueOf(39), saved.unspentPoints());
var sheet = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a); f.awaitCharacterAndReceipt(a);
b.core.inventory.setItemInOffHand(sheet); f.activate(b);
PartyBookNativeTest.open(f, b);
assertTrue(PartyBookNativeTest.text(b).contains("STR: 1"));
assertTrue(PartyBookNativeTest.text(b).contains("Unspent points: 39"));
f.plugin.setEnabled(false); f.awaitWorkers();
f.players.clear(); f.plugin.setEnabled(true); f.players.put(b.core.id, b.player); f.reenter(b);
PartyBookNativeTest.open(f, b);
assertTrue(PartyBookNativeTest.text(b).contains("STR: 1"));
assertEquals(saved.stats(), characters(f).find(saved.id()).get(5, TimeUnit.SECONDS).orElseThrow().stats());
}
}
@Test void nativePagesKeepLargeNumbersUnicodeNamesAndUnknownAbilitiesReadableAndComplete() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
@@ -166,7 +263,7 @@ class CharacterBookNativeTest {
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player); f.activate(a);
click(f, a); drainReads(f); // Old holder has a queued result, not a delivered book.
a.core.inventory.setItemInOffHand(null); f.changed(a);
f.receipts().find(a.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS);
f.awaitCharacterAndReceipt(a);
var renamed = characters(f).renameCharacter(a.core.record.id(), "Traded 雪 character").get(5, TimeUnit.SECONDS).orElseThrow();
runQueued(f); assertTrue(a.books.isEmpty());
b.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(renamed));
@@ -0,0 +1,263 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.EntityType;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent.DamageCause;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class CombatExperienceNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void packagedBasicAttackToBossDeathPersistsLevelAndUpdatesBookFeedbackExactlyOnce() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
assertTrue(f.order.contains("CombatExperience"));
var a = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.activate(a);
var world = mock(org.bukkit.World.class); var worldId = UUID.randomUUID(); when(world.getUID()).thenReturn(worldId);
var target = mob(EntityType.WARDEN, new Location(world, 10, 65, -4));
doAnswer(call -> {
var source = mock(org.bukkit.damage.DamageSource.class);
when(source.getCausingEntity()).thenReturn(a.player);
when(source.getDirectEntity()).thenReturn(a.player);
var event = new EntityDamageByEntityEvent(a.player, target, DamageCause.ENTITY_ATTACK,
source, (Double) call.getArgument(0));
f.dispatcher.accept(event); return null;
}).when(target).damage(anyDouble(), same(a.player));
a.core.inventory.getInventory().setSelectedSlot(1);
var attack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
f.dispatcher.accept(attack);
assertTrue(attack.isCancelled());
var source = mock(org.bukkit.damage.DamageSource.class);
var death = new org.bukkit.event.entity.EntityDeathEvent(target, source, new ArrayList<>(), 17);
f.dispatcher.accept(death);
PartyBookNativeTest.drain(f);
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var saved = storage.find(a.core.record.id()).get().orElseThrow();
PartyBookNativeTest.drain(f); // Deliver the post-commit main-thread acknowledgment and feedback.
assertEquals(java.math.BigInteger.valueOf(100), saved.experience());
assertEquals(2, saved.level());
assertEquals(java.math.BigInteger.valueOf(41), saved.unspentPoints());
assertEquals(17, death.getDroppedExp(), "Fantasy rewards never replace native Minecraft XP");
assertTrue(a.messages.stream().anyMatch(message -> message.contains("100") && message.contains("XP")));
assertTrue(a.messages.stream().anyMatch(message -> message.contains("Level 2")));
assertTrue(mockingDetails(a.world).getInvocations().stream().anyMatch(invocation ->
invocation.getMethod().getName().equals("spawnParticle")
&& java.util.Arrays.asList(invocation.getArguments()).contains(12)));
assertFalse(a.sounds.isEmpty());
a.core.inventory.getInventory().setSelectedSlot(0);
PartyBookNativeTest.open(f, a);
String book = PartyBookNativeTest.text(a);
assertTrue(book.contains("Level: 2")); assertTrue(book.contains("XP: 0/100"));
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(target, source, new ArrayList<>(), 17));
PartyBookNativeTest.drain(f);
assertEquals(saved, storage.find(a.core.record.id()).get().orElseThrow(), "One defeated target awards only once");
}
}
@Test void sharingProjectileExitPassiveExemptionAndPerCharacterRestrictedSpacingUseNativeEvents() 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);
var c = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player); f.players.put(c.core.id, c.player);
f.activate(a); f.activate(b); f.activate(c);
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
var first = mob(EntityType.ZOMBIE, new Location(world, 0, 64, 0));
hit(f, a.player, first, false);
die(f, first); settle(f);
assertEquals(java.math.BigInteger.TEN, record(f, a).experience());
assertEquals(java.math.BigInteger.ZERO, record(f, b).experience());
var near = mob(EntityType.BEE, new Location(world, 2, 64, 0)); // Neutral, regardless of aggression.
hit(f, a.player, near, false);
var arrow = PartyCombatNativeTest.projectile(f, b.player);
hit(f, arrow, near, false);
var ordinary = PartyCombatNativeTest.entity(org.bukkit.entity.Arrow.class);
hit(f, ordinary, near, false); // Ordinary sources never enter the fantasy denominator.
PartyCombatNativeTest.exit(f, b); // The arrow's originating character still owns its accepted contribution.
die(f, near); settle(f);
assertEquals(java.math.BigInteger.TEN, record(f, a).experience(), "Blocked share is lost rather than redistributed");
assertEquals(java.math.BigInteger.valueOf(5), record(f, b).experience(), "First restricted kill is eligible for this character");
var passive = mob(EntityType.COW, new Location(world, 2, 64, 0));
hit(f, a.player, passive, false);
hit(f, c.player, passive, false);
// b is now ordinary; its direct damage cannot enter the fantasy denominator.
hit(f, b.player, passive, false);
die(f, passive); settle(f);
assertEquals(java.math.BigInteger.valueOf(11), record(f, a).experience());
assertEquals(java.math.BigInteger.valueOf(5), record(f, b).experience());
assertEquals(java.math.BigInteger.ONE, record(f, c).experience(),
"A one-XP passive pool rounds up to one for every fantasy contributor");
var exact = mob(EntityType.ZOMBIE, new Location(world, 5, 64, 0));
hit(f, a.player, exact, false);
hit(f, a.player, exact, true); // Cancelled repeats do not create another contributor.
die(f, exact); settle(f);
assertEquals(java.math.BigInteger.valueOf(21), record(f, a).experience(),
"Passive kill did not reset history; exact three-block distance from blocked kill is eligible");
assertEquals(new java.math.BigDecimal("5.0"), record(f, a).restrictedKills().values().iterator().next().x());
var repeatedPassive = mob(EntityType.SHEEP, new Location(world, 2, 64, 0));
hit(f, a.player, repeatedPassive, false); die(f, repeatedPassive); settle(f);
assertEquals(java.math.BigInteger.valueOf(22), record(f, a).experience(), "Same-location passive kills remain eligible");
}
}
@Test void failedRewardSaveRetainsOneRetryableOperationWithoutReplayingTheTarget() {
var storage = mock(games.dmg.fantasystick.persistence.CharacterStorage.class);
var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class);
var progression = games.dmg.fantasystick.progression.ProgressionRules.defaults();
var original = new games.dmg.fantasystick.crafting.CharacterFactory().create(progression);
var active = new games.dmg.fantasystick.session.EquipmentSessions.Active(UUID.randomUUID(), UUID.randomUUID(), original.id());
var attacker = mock(org.bukkit.entity.Player.class);
when(attacker.getUniqueId()).thenReturn(active.player());
when(sessions.active(active.player())).thenReturn(java.util.Optional.of(active));
when(sessions.current(active)).thenReturn(true);
when(storage.find(original.id())).thenReturn(java.util.concurrent.CompletableFuture.completedFuture(java.util.Optional.of(original)));
var failed = new java.util.concurrent.CompletableFuture<games.dmg.fantasystick.persistence.CharacterStorage.CombatAward>();
failed.completeExceptionally(new java.io.IOException("simulated write failure"));
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(original.id(), original.name(), java.math.BigInteger.ONE,
original.stats(), original.level(), java.math.BigInteger.TEN, original.unspentPoints(), original.health(),
original.learned(), original.loadout(), original.cooldowns(), original.restrictedKills(), false, null, null);
var success = java.util.concurrent.CompletableFuture.completedFuture(
new games.dmg.fantasystick.persistence.CharacterStorage.CombatAward(
games.dmg.fantasystick.persistence.CharacterStorage.CombatAwardStatus.APPLIED, java.util.Optional.of(saved), 0));
var writes = new java.util.concurrent.atomic.AtomicInteger();
when(storage.awardCombatExperience(eq(original.id()), any(), eq(java.math.BigInteger.TEN), same(progression), any(), any()))
.thenAnswer(ignored -> writes.getAndIncrement() == 0 ? failed : success);
var failures = new ArrayList<games.dmg.fantasystick.party.CombatExperience.Failure>();
var service = new games.dmg.fantasystick.party.CombatExperience(storage, progression,
games.dmg.fantasystick.progression.CombatRewardRules.defaults(), sessions,
java.time.Clock.fixed(java.time.Instant.parse("2026-09-19T20:00:00Z"), java.time.ZoneOffset.UTC), Runnable::run,
ignored -> null, failures::add);
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
var target = mob(EntityType.ZOMBIE, new Location(world, 0, 64, 0));
var damageSource = mock(org.bukkit.damage.DamageSource.class);
when(damageSource.getDirectEntity()).thenReturn(attacker); when(damageSource.getCausingEntity()).thenReturn(attacker);
service.damage(new EntityDamageByEntityEvent(attacker, target, DamageCause.ENTITY_ATTACK, damageSource, 1));
service.mobDeath(new org.bukkit.event.entity.EntityDeathEvent(target, damageSource, new ArrayList<>(), 0));
verify(storage, times(1)).awardCombatExperience(eq(original.id()), any(), any(), any(), any(), any());
assertEquals(1, failures.size());
service.retryPending();
verify(storage, times(2)).awardCombatExperience(eq(original.id()), any(), any(), any(), any(), any());
service.mobDeath(new org.bukkit.event.entity.EntityDeathEvent(target, damageSource, new ArrayList<>(), 0));
service.retryPending();
verify(storage, times(2)).awardCombatExperience(eq(original.id()), any(), any(), any(), any(), any());
service.close();
}
@Test void validatedConfiguredPoolAndZeroSpacingReachTheRegisteredRewardPath() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"xp-per-level: 10\nordinary-xp: 25\nrestricted-kill-spacing-blocks: 0\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(a.core.id, a.player); f.activate(a);
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
for (int index = 0; index < 2; index++) {
var target = mob(EntityType.ZOMBIE, new Location(world, 0, 64, 0));
hit(f, a.player, target, false); die(f, target); settle(f);
}
assertEquals(java.math.BigInteger.valueOf(50), record(f, a).experience());
assertEquals(6, record(f, a).level());
assertEquals(java.math.BigInteger.valueOf(45), record(f, a).unspentPoints());
assertEquals(5, a.sounds.size(), "Every gained level has its own feedback sound");
for (int level = 2; level <= 6; level++) {
int expected = level;
assertTrue(a.messages.stream().anyMatch(message -> message.contains("Level " + expected)));
}
}
}
@Test void interceptedFantasyDefeatUsesVictimLevelPoolAndCancelledDamageCreatesNoCredit() 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);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
f.activate(a); f.activate(b);
hit(f, a.player, b.player, true);
var source = mock(org.bukkit.damage.DamageSource.class);
var cancelled = new org.bukkit.event.entity.PlayerDeathEvent(b.player, source, new ArrayList<>(), 0,
net.kyori.adventure.text.Component.text("defeat"), true);
f.dispatcher.accept(cancelled); settle(f);
assertTrue(cancelled.isCancelled());
assertEquals(java.math.BigInteger.ZERO, record(f, a).experience(), "Cancelled damage never contributes");
}
var second = data.resolve("accepted"); java.nio.file.Files.createDirectories(second);
try (var f = new FantasyStickLifecycleNativeTest.Fixture(second)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(second);
var b = new FantasyStickLifecycleNativeTest.Actor(second);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
f.activate(a); f.activate(b);
hit(f, a.player, b.player, false);
var source = mock(org.bukkit.damage.DamageSource.class);
var defeat = new org.bukkit.event.entity.PlayerDeathEvent(b.player, source, new ArrayList<>(), 0,
net.kyori.adventure.text.Component.text("defeat"), true);
f.dispatcher.accept(defeat); settle(f); settle(f);
assertTrue(defeat.isCancelled());
assertEquals(java.math.BigInteger.TEN, record(f, a).experience(), "Level-one fantasy defeat supplies a ten-XP pool");
assertTrue(record(f, b).dead());
f.dispatcher.accept(defeat); settle(f);
assertEquals(java.math.BigInteger.TEN, record(f, a).experience(), "Repeated intercepted callback cannot award twice");
}
}
private static EntityDamageByEntityEvent hit(FantasyStickLifecycleNativeTest.Fixture f, org.bukkit.entity.Entity source,
org.bukkit.entity.Entity target, 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, DamageCause.ENTITY_ATTACK, damage, 4);
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 void settle(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
}
private static games.dmg.fantasystick.persistence.CharacterRecord record(FantasyStickLifecycleNativeTest.Fixture f,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
return storage.find(actor.core.record.id()).get().orElseThrow();
}
private static LivingEntity mob(EntityType type, Location location) {
var entity = mock(LivingEntity.class);
when(entity.getUniqueId()).thenReturn(UUID.randomUUID());
when(entity.getType()).thenReturn(type);
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;
}
}
@@ -60,9 +60,19 @@ class FantasyStickLifecycleNativeTest {
assertTrue(fixture.order.indexOf("CharacterChatNames") >= 0);
assertTrue(fixture.order.indexOf("CharacterChatNames") < recipe);
assertNotNull(fixture.packetListener);
assertEquals(3, fixture.timers.size(), "Presentation, combat-provenance cleanup and session particles");
assertEquals(5, fixture.timers.size(), "Presentation, ability cleanup, provenance, reward retry and session particles");
assertTrue(fixture.order.indexOf("PartyCombat") >= 0);
assertTrue(fixture.order.indexOf("PartyCombat") < recipe);
assertTrue(fixture.order.indexOf("CombatExperience") >= 0);
assertTrue(fixture.order.indexOf("CombatExperience") < recipe);
assertTrue(fixture.order.indexOf("Fireball") >= 0);
assertTrue(fixture.order.indexOf("Fireball") < recipe);
assertNotNull(Fixture.field(fixture.plugin, "abilityEngine"));
assertNotNull(Fixture.field(fixture.plugin, "abilityRegistry"));
assertTrue(fixture.order.indexOf("BasicAttack") >= 0);
assertTrue(fixture.order.indexOf("BasicAttack") < recipe);
assertTrue(fixture.order.indexOf("NutritionPolicy") >= 0);
assertTrue(fixture.order.indexOf("NutritionPolicy") < recipe);
assertNotNull(fixture.command);
assertEquals("EquipmentAdminCommand", fixture.command.getExecutor().getClass().getSimpleName());
assertTrue(fixture.tasks.isEmpty());
@@ -128,7 +138,7 @@ class FantasyStickLifecycleNativeTest {
@Test
void recipeCollisionAndPartialListenerRegistrationFailureRollbackOnlyThisEnableAttempt() throws Exception {
for (String stage : List.of("collision", "recipe", "ManualCraftingGuard", "CharacterAnvilNames", "EquipmentSessionEvents", "CharacterChatNames", "ProtocolPresentation", "presentation-timer")) {
for (String stage : List.of("collision", "recipe", "ManualCraftingGuard", "CharacterAnvilNames", "EquipmentSessionEvents", "CharacterChatNames", "ProtocolPresentation", "CombatExperience", "Fireball", "presentation-timer")) {
boolean collision = stage.equals("collision");
try (var fixture = new Fixture(data)) {
var existing = new org.bukkit.inventory.ShapelessRecipe(CharacterSheets.RECIPE,
@@ -206,6 +216,21 @@ class FantasyStickLifecycleNativeTest {
}
}
@Test void invalidExperienceThresholdAndMaximumLevelFailBeforeInstallingGameplay() throws Exception {
for (String configuration : List.of("xp-per-level: 0\n", "xp-per-level: -1\n",
"maximum-level: 0\n", "maximum-level: 2147483648\n", "maximum-level: not-a-number\n")) {
var directory = data.resolve(Integer.toHexString(configuration.hashCode()));
java.nio.file.Files.createDirectories(directory);
java.nio.file.Files.writeString(directory.resolve("config.yml"), configuration);
try (var fixture = new Fixture(directory)) {
assertThrows(IllegalArgumentException.class, () -> fixture.plugin.setEnabled(true));
assertTrue(fixture.order.isEmpty());
assertTrue(fixture.recipes.isEmpty());
assertFalse(java.nio.file.Files.exists(directory.resolve("characters")));
}
}
}
@Test
void installedGuardRejectsOnlyTheCharacterRecipeThroughTheNativeDispatcher() throws Exception {
try (var fixture = new Fixture(data)) {
@@ -721,6 +746,7 @@ class FantasyStickLifecycleNativeTest {
assertNull(saved.pending()); assertNull(saved.lastExit());
} else {
f.activate(a); sessions.exit(a.player);
f.awaitCharacterAndReceipt(a);
assertEquals(org.bukkit.GameMode.ADVENTURE, a.player.getGameMode());
var saved = f.receipts().find(a.core.id).toCompletableFuture().get().orElseThrow();
assertNotNull(saved.pending()); assertNotNull(saved.lastExit());
@@ -790,7 +816,7 @@ class FantasyStickLifecycleNativeTest {
var sessions = (games.dmg.fantasystick.session.EquipmentSessions) Fixture.field(f.plugin, "sessions");
sessions.exit(a.player);
assertEquals(org.bukkit.GameMode.CREATIVE, a.player.getGameMode());
f.receipts().find(a.core.id).toCompletableFuture().get();
f.awaitCharacterAndReceipt(a);
var b = new Actor(data); f.players.put(b.core.id, b.player);
b.core.gameMode = org.bukkit.GameMode.SURVIVAL;
f.activate(b);
@@ -807,27 +833,74 @@ class FantasyStickLifecycleNativeTest {
if (!value.equals("default")) java.nio.file.Files.writeString(directory.resolve("config.yml"), "character-swap-cooldown-seconds: " + value + "\n");
try (var f = new Fixture(directory)) {
f.plugin.setEnabled(true); var a = new Actor(directory); f.players.put(a.core.id, a.player); f.activate(a);
f.dispatcher.accept(new org.bukkit.event.player.PlayerQuitEvent(a.player, (net.kyori.adventure.text.Component) null));
var exit = f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().lastExit();
assertEquals(java.time.Duration.ofSeconds(value.equals("default") ? 900 : Long.parseLong(value)),
java.time.Duration.between(exit.exitedAt(), exit.deadline()));
assertEquals(a.core.record.id(), exit.characterId());
var other = new Actor(directory);
a.core.inventory.setItemInOffHand(other.core.inventory.getItemInOffHand().clone());
var before = a.core.snapshot();
f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(a.player, (net.kyori.adventure.text.Component) null));
f.tick(); f.tick(); // Join receipt reconciliation, then actual coordinator eligibility.
if (value.equals("0")) {
f.tick(); assertTrue(a.core.marker().endsWith("|PACKED"), "Configured zero reaches entry, not only persisted arithmetic");
} else {
assertArrayEquals(before, a.core.snapshot()); assertTrue(a.core.marker().endsWith("|RESTORED"));
assertTrue(a.messages.getLast().matches("(?s).*Wait [1-9][0-9]* seconds.*same character may be resumed now.*"), a.messages::toString);
var state = f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow();
assertNull(state.pending()); assertEquals(exit, state.lastExit());
a.core.inventory.setItemInOffHand(new CharacterSheets().create(a.core.record));
var characters = (CharacterStorage) Fixture.field(f.plugin, "characters");
var worker = (java.util.concurrent.ExecutorService) Fixture.field(characters, "worker");
var entered = new java.util.concurrent.CountDownLatch(1);
var release = new java.util.concurrent.CountDownLatch(1);
worker.execute(() -> {
entered.countDown();
try { release.await(); }
catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); }
});
try {
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
f.dispatcher.accept(new org.bukkit.event.player.PlayerQuitEvent(a.player, (net.kyori.adventure.text.Component) null));
assertNotNull(f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().pending(),
"Quit restores native state but must retain ownership until health persistence completes");
release.countDown();
f.awaitCharacterAndReceipt(a);
var exit = f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().lastExit();
assertEquals(java.time.Duration.ofSeconds(value.equals("default") ? 900 : Long.parseLong(value)),
java.time.Duration.between(exit.exitedAt(), exit.deadline()));
assertEquals(a.core.record.id(), exit.characterId());
var other = new Actor(directory);
a.core.inventory.setItemInOffHand(other.core.inventory.getItemInOffHand().clone());
var before = a.core.snapshot();
f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(a.player, (net.kyori.adventure.text.Component) null));
f.tick(); f.tick(); f.tick(); assertTrue(a.core.marker().endsWith("|PACKED"));
}
f.tick(); f.tick(); // Join receipt reconciliation, then actual coordinator eligibility.
if (value.equals("0")) {
f.tick(); assertTrue(a.core.marker().endsWith("|PACKED"), "Configured zero reaches entry, not only persisted arithmetic");
} else {
assertArrayEquals(before, a.core.snapshot()); assertTrue(a.core.marker().endsWith("|RESTORED"));
assertTrue(a.messages.getLast().matches("(?s).*Wait [1-9][0-9]* seconds.*same character may be resumed now.*"), a.messages::toString);
var state = f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow();
assertNull(state.pending()); assertEquals(exit, state.lastExit());
a.core.inventory.setItemInOffHand(new CharacterSheets().create(a.core.record));
f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(a.player, (net.kyori.adventure.text.Component) null));
f.tick(); f.tick(); f.tick(); assertTrue(a.core.marker().endsWith("|PACKED"));
}
} finally { release.countDown(); }
}
}
}
@Test void invalidFireballConfigurationFailsBeforeNativeRegistrationWithoutRewritingConfig() throws Exception {
int scenario = 0;
for (String row : List.of("fireball-base-damage: -1", "fireball-cooldown-seconds: -1",
"fireball-range-blocks: 0", "fireball-range-blocks: 1e1000000",
"fireball-lifetime-seconds: 0", "fireball-lifetime-seconds: 61")) {
var directory = data.resolve("invalid-fireball-" + scenario++); java.nio.file.Files.createDirectories(directory);
String config = row + "\n"; java.nio.file.Files.writeString(directory.resolve("config.yml"), config);
try (var f = new Fixture(directory)) {
assertThrows(IllegalArgumentException.class, () -> f.plugin.setEnabled(true), row);
assertTrue(f.order.isEmpty()); assertTrue(f.recipes.isEmpty()); assertTrue(f.tasks.isEmpty());
assertFalse(java.nio.file.Files.exists(directory.resolve("characters")));
assertEquals(config, java.nio.file.Files.readString(directory.resolve("config.yml")));
}
}
}
@Test void invalidCombatRewardConfigurationFailsBeforeNativeRegistrationWithoutRewritingConfig() throws Exception {
int scenario = 0;
for (String row : List.of("passive-xp: -1", "ordinary-xp: 1.5", "boss-xp: nope",
"restricted-kill-spacing-blocks: -0.1", "restricted-kill-spacing-blocks: 1e1000000")) {
var directory = data.resolve("invalid-reward-" + scenario++); java.nio.file.Files.createDirectories(directory);
String config = row + "\n"; java.nio.file.Files.writeString(directory.resolve("config.yml"), config);
try (var f = new Fixture(directory)) {
assertThrows(IllegalArgumentException.class, () -> f.plugin.setEnabled(true), row);
assertTrue(f.order.isEmpty()); assertTrue(f.recipes.isEmpty()); assertTrue(f.tasks.isEmpty());
assertFalse(java.nio.file.Files.exists(directory.resolve("characters")));
assertEquals(config, java.nio.file.Files.readString(directory.resolve("config.yml")));
}
}
}
@@ -1028,6 +1101,8 @@ class FantasyStickLifecycleNativeTest {
};
final List<BukkitTask> timers = new ArrayList<>();
final List<Runnable> pulses = new ArrayList<>();
final List<Runnable> delayed = new ArrayList<>();
final List<Long> delayedTicks = new ArrayList<>();
com.comphenix.protocol.events.PacketListener packetListener;
final List<java.util.concurrent.ExecutorService> workers = new ArrayList<>();
final Thread main = Thread.currentThread();
@@ -1096,6 +1171,12 @@ class FantasyStickLifecycleNativeTest {
tasks.add((Runnable) args[1]);
yield port(BukkitTask.class, (method, values) -> { throw new AssertionError("Unexpected task call " + method); });
}
case "runTaskLater" -> {
schedulerCalls.incrementAndGet();
assertTrue(((org.bukkit.plugin.Plugin) args[0]).isEnabled());
delayed.add((Runnable) args[1]); delayedTicks.add((Long) args[2]);
var task = mock(BukkitTask.class); timers.add(task); yield task;
}
default -> throw new AssertionError("Unexpected scheduler call " + name);
});
server = port(Server.class, (name, args) -> switch (name) {
@@ -1107,6 +1188,8 @@ class FantasyStickLifecycleNativeTest {
case "getOnlinePlayers" -> List.copyOf(players.values());
case "getPlayer" -> { assertSame(main, Thread.currentThread()); playerLookups++; yield players.get(args[0]); }
case "getPluginCommand" -> command;
case "createInventory" -> new org.bukkit.craftbukkit.inventory.CraftInventoryCustom(
(org.bukkit.inventory.InventoryHolder) args[0], (Integer) args[1], (net.kyori.adventure.text.Component) args[2]);
case "addRecipe" -> {
order.add("recipe");
if ("recipe".equals(failRegistrationAt)) throw new IllegalStateException("Recipe registration failed");
@@ -1144,6 +1227,12 @@ class FantasyStickLifecycleNativeTest {
games.dmg.fantasystick.persistence.EquipmentSessionStore receipts() {
return (games.dmg.fantasystick.persistence.EquipmentSessionStore) field(plugin, "receipts");
}
void awaitCharacterAndReceipt(Actor actor) throws Exception {
// A receipt read alone cannot await work submitted later by the character-save callback.
((CharacterStorage) field(plugin, "characters")).find(actor.core.record.id())
.get(5, java.util.concurrent.TimeUnit.SECONDS);
receipts().find(actor.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
}
void tick() throws Exception {
var task = tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS);
assertNotNull(task, "Missing deferred main-thread callback"); task.run();
@@ -1176,7 +1265,7 @@ class FantasyStickLifecycleNativeTest {
assertTrue(actor.core.marker().endsWith("|PACKED"));
}
private void captureWorkers(JavaPlugin instance) {
for (String name : List.of("characters", "receipts", "adventureSettings")) {
for (String name : List.of("characters", "receipts", "adventureSettings", "revivals")) {
var store = field(instance, name);
if (store != null) {
var worker = (java.util.concurrent.ExecutorService) field(store, "worker");
@@ -1211,7 +1300,10 @@ class FantasyStickLifecycleNativeTest {
final org.bukkit.World world = mock(org.bukkit.World.class);
final List<String> messages = new ArrayList<>();
final List<org.bukkit.inventory.ItemStack> books = new ArrayList<>();
final List<org.bukkit.inventory.Inventory> inventories = new ArrayList<>();
int inventoryCloses;
final List<Integer> particles = new ArrayList<>();
final List<org.bukkit.Sound> sounds = new ArrayList<>();
final net.minecraft.world.entity.ai.attributes.AttributeMap healthAttributes = new net.minecraft.world.entity.ai.attributes.AttributeMap(
net.minecraft.world.entity.ai.attributes.AttributeSupplier.builder().add(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH).build());
final net.minecraft.world.entity.ai.attributes.AttributeInstance maximumHealth = java.util.Objects.requireNonNull(
@@ -1220,6 +1312,7 @@ class FantasyStickLifecycleNativeTest {
boolean healthScaled;
double healthScale = 20;
Runnable scaleMutation = () -> {};
Runnable foodMutation = () -> {};
java.util.function.Consumer<org.bukkit.event.player.PlayerGameModeChangeEvent> modeEvents = ignored -> { };
final org.bukkit.craftbukkit.entity.CraftPlayer player;
final net.minecraft.server.level.ServerPlayer nativePlayer = mock(net.minecraft.server.level.ServerPlayer.class);
@@ -1253,7 +1346,7 @@ class FantasyStickLifecycleNativeTest {
}
case "getMaxHealth" -> maximumHealth.getValue();
case "getFoodLevel" -> food.getFoodLevel();
case "setFoodLevel" -> { food.setFoodLevel((Integer) args[0]); yield null; }
case "setFoodLevel" -> { food.setFoodLevel((Integer) args[0]); foodMutation.run(); yield null; }
case "getSaturation" -> food.getSaturationLevel();
case "setSaturation" -> { food.setSaturation((Float) args[0]); yield null; }
case "getExhaustion" -> food.exhaustionLevel;
@@ -1273,7 +1366,10 @@ class FantasyStickLifecycleNativeTest {
case "getScoreboard" -> craftBoard;
case "sendMessage" -> { messages.add((String) args[0]); yield null; }
case "openBook" -> { books.add(((org.bukkit.inventory.ItemStack) args[0]).clone()); yield null; }
case "openInventory" -> { inventories.add((org.bukkit.inventory.Inventory) args[0]); yield core.view; }
case "closeInventory" -> { inventoryCloses++; yield null; }
case "spawnParticle" -> { particles.add((Integer) args[2]); yield null; }
case "playSound" -> { if (args.length > 1 && args[1] instanceof org.bukkit.Sound sound) sounds.add(sound); yield null; }
default -> Proxy.getInvocationHandler(core.player).invoke(core.player, method, args);
};
});
@@ -0,0 +1,388 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import java.math.BigInteger;
import java.nio.file.Path;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class FireballNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void bookAcquisitionEquippedUseAndDirectHitDealOneAttributedScaledDamageWithCooldown() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withInt(data, a, 12);
f.players.put(a.core.id, a.player); f.activate(a);
a.core.inventory.getInventory().setSelectedSlot(0);
PartyBookNativeTest.open(f, a);
assertTrue(PartyBookNativeTest.text(a).contains("Fireball"));
assertTrue(PartyBookNativeTest.text(a).contains("INT 12"));
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[Learn]"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[3]"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var assigned = storage.find(a.core.record.id()).get().orElseThrow();
assertTrue(assigned.learned().contains("fireball")); assertEquals("fireball", assigned.loadout().getFirst());
assertEquals(BigInteger.valueOf(39), assigned.unspentPoints());
assertEquals(org.bukkit.Material.SNOWBALL, a.core.inventory.getItem(2).getType());
org.mockito.Mockito.when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
var eye = new org.bukkit.Location(a.world, 0, 65.6, 0);
eye.setDirection(new org.bukkit.util.Vector(1, 0, 0));
org.mockito.Mockito.doReturn(eye).when(a.player).getEyeLocation();
var projectile = projectile(a, new org.bukkit.Location(a.world, 10, 65.6, 0));
var launches = new java.util.concurrent.atomic.AtomicInteger();
var launchedVelocity = new java.util.concurrent.atomic.AtomicReference<org.bukkit.util.Vector>();
org.mockito.Mockito.doAnswer(call -> {
launches.incrementAndGet(); launchedVelocity.set(call.getArgument(1));
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileLaunchEvent(projectile));
return projectile;
}).when(a.player).launchProjectile(org.mockito.ArgumentMatchers.eq(org.bukkit.entity.Snowball.class),
org.mockito.ArgumentMatchers.any(org.bukkit.util.Vector.class));
a.core.inventory.getInventory().setSelectedSlot(2);
var cast = new org.bukkit.event.player.PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
a.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
f.dispatcher.accept(cast); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(org.bukkit.event.Event.Result.DENY, cast.useItemInHand()); assertEquals(1, launches.get());
org.mockito.Mockito.verify(projectile).setVisualFire(true);
assertEquals(new org.bukkit.util.Vector(1, 0, 0), launchedVelocity.get());
var target = org.mockito.Mockito.mock(org.bukkit.entity.LivingEntity.class);
org.mockito.Mockito.when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
org.mockito.Mockito.when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
org.mockito.Mockito.when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 10, 65.6, 0));
var damages = new java.util.ArrayList<org.bukkit.event.entity.EntityDamageByEntityEvent>();
org.mockito.Mockito.doAnswer(call -> {
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(projectile, target,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, source, (Double) call.getArgument(0));
damages.add(event); f.dispatcher.accept(event); return null;
}).when(target).damage(org.mockito.ArgumentMatchers.anyDouble(), org.mockito.ArgumentMatchers.any(org.bukkit.damage.DamageSource.class));
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(projectile, target));
assertEquals(1, damages.size()); assertEquals(7.0, damages.getFirst().getDamage());
assertFalse(damages.getFirst().isCancelled());
assertSame(projectile, damages.getFirst().getDamager());
assertEquals(org.bukkit.damage.DamageType.FIREBALL, damages.getFirst().getDamageSource().getDamageType());
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(target, damages.getFirst().getDamageSource(),
new java.util.ArrayList<>(), 0));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(BigInteger.TEN, storage.find(a.core.record.id()).get().orElseThrow().experience(),
"Native projectile attribution reaches the originating character's combat XP");
org.mockito.Mockito.verify(projectile, org.mockito.Mockito.atLeastOnce()).remove();
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(projectile, target));
assertEquals(1, damages.size(), "A projectile can deal custom damage only once");
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
a.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND));
PartyBookNativeTest.drain(f);
assertEquals(1, launches.get(), "The persisted four-second deadline refuses immediate reuse");
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
var deadline = storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("fireball");
assertTrue(deadline.isAfter(java.time.Instant.now()));
var sheet = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
a.core.inventory.setItemInOffHand(sheet); f.activate(a);
a.core.inventory.getInventory().setSelectedSlot(2);
assertEquals("fireball", new games.dmg.fantasystick.items.SessionControls(
(games.dmg.fantasystick.ability.AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry"))
.ability(a.core.inventory.getItem(2)).orElseThrow());
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
a.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND));
PartyBookNativeTest.drain(f);
assertEquals(1, launches.get(), "Exit/resume and control reconstruction preserve the same Fireball deadline");
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("fireball"));
}
}
@Test void bookUnlearnRefundsOnceClearsOwnedFireballAndRetainsDeadlineThroughReuse() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withInt(data, a, 12);
f.players.put(a.core.id, a.player); f.activate(a);
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
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());
var projectile = projectile(a, new org.bukkit.Location(a.world, 1, 65.6, 0));
var launches = new java.util.concurrent.atomic.AtomicInteger();
var eye = new org.bukkit.Location(a.world, 0, 65.6, 0); eye.setDirection(new org.bukkit.util.Vector(1, 0, 0));
org.mockito.Mockito.doReturn(eye).when(a.player).getEyeLocation();
org.mockito.Mockito.doAnswer(call -> { launches.incrementAndGet(); return projectile; }).when(a.player)
.launchProjectile(org.mockito.ArgumentMatchers.eq(org.bukkit.entity.Snowball.class),
org.mockito.ArgumentMatchers.any(org.bukkit.util.Vector.class));
cast(f, a);
var cast = storage.find(a.core.record.id()).get().orElseThrow();
var deadline = cast.cooldowns().get("fireball"); assertNotNull(deadline); assertEquals(1, launches.get());
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(a.books.getLast())
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
for (var page : content.pages()) for (String line : page.raw().getString().split("\\n", -1)) {
assertTrue(line.codePoints().map(cp -> cp < 128 ? 6 : 9).sum() <= 108,
"Learned ability action exceeds native visible width: " + line);
}
var unlearn = PartyBookNativeTest.button(a, "[Unlearn]");
PartyBookNativeTest.click(f, a, unlearn); PartyBookNativeTest.click(f, a, unlearn);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var refunded = storage.find(a.core.record.id()).get().orElseThrow();
assertEquals(BigInteger.valueOf(40), refunded.unspentPoints());
assertFalse(refunded.learned().contains("fireball"));
assertTrue(refunded.loadout().stream().noneMatch("fireball"::equals));
assertEquals(deadline, refunded.cooldowns().get("fireball"));
org.mockito.Mockito.verify(projectile, org.mockito.Mockito.atLeastOnce()).remove();
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());
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[+1 WIS]"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var spent = storage.find(a.core.record.id()).get().orElseThrow();
assertEquals(BigInteger.ONE, spent.stats().get(games.dmg.fantasystick.progression.Stat.WIS));
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"));
cast(f, a); assertEquals(1, launches.get());
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
var transferredSheet = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
var b = new FantasyStickLifecycleNativeTest.Actor(data); b.core.inventory.setItemInOffHand(transferredSheet);
f.players.put(b.core.id, b.player); f.activate(b);
b.core.inventory.getInventory().setSelectedSlot(2);
assertEquals("fireball", new games.dmg.fantasystick.items.SessionControls(
(games.dmg.fantasystick.ability.AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry"))
.ability(b.core.inventory.getItem(2)).orElseThrow());
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(b.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
b.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND));
PartyBookNativeTest.drain(f);
assertTrue(b.messages.stream().anyMatch(message -> message.contains("ready in")),
"Transfer/reload must retain the original Fireball deadline after refund and relearn");
}
}
@Test void partyAndMixedModeProjectileHitsPreserveProtectionCancellation() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "fireball-cooldown-seconds: 0\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var b = new FantasyStickLifecycleNativeTest.Actor(data);
withFireball(data, a, 12);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
f.activate(a); f.activate(b);
var parties = PartyLifecycleNativeTest.parties(f);
var party = parties.create(a.core.id).orElseThrow();
assertTrue(parties.accept(b.core.id, parties.invite(a.core.id, party.id(), b.core.id).orElseThrow().id()));
org.mockito.Mockito.when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
var first = projectile(a, new org.bukkit.Location(a.world, 10, 65.6, 0));
var second = projectile(a, new org.bukkit.Location(a.world, 10, 65.6, 0));
prepareLaunches(f, a, first, second);
cast(f, a); var partyHit = new org.bukkit.event.entity.ProjectileHitEvent(first, b.player); f.dispatcher.accept(partyHit);
assertTrue(partyHit.isCancelled());
org.mockito.Mockito.verify(b.player, org.mockito.Mockito.never()).damage(org.mockito.ArgumentMatchers.anyDouble(),
org.mockito.ArgumentMatchers.any(org.bukkit.damage.DamageSource.class));
var ordinary = org.mockito.Mockito.mock(org.bukkit.entity.Player.class);
org.mockito.Mockito.when(ordinary.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
cast(f, a); var mixedHit = new org.bukkit.event.entity.ProjectileHitEvent(second, ordinary); f.dispatcher.accept(mixedHit);
assertTrue(mixedHit.isCancelled());
org.mockito.Mockito.verify(ordinary, org.mockito.Mockito.never()).damage(org.mockito.ArgumentMatchers.anyDouble(),
org.mockito.ArgumentMatchers.any(org.bukkit.damage.DamageSource.class));
assertTrue(org.mockito.Mockito.mockingDetails(a.world).getInvocations().stream()
.noneMatch(invocation -> invocation.getMethod().getName().equals("createExplosion")));
}
}
@Test void rangeLifetimeAndSessionEndEachRemoveOwnedProjectileWithoutLateDamage() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "fireball-cooldown-seconds: 0\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withFireball(data, a, 12);
f.players.put(a.core.id, a.player); f.activate(a);
org.mockito.Mockito.when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
var outside = projectile(a, new org.bukkit.Location(a.world, 32.01, 65.6, 0));
var boundary = projectile(a, new org.bukkit.Location(a.world, 32, 65.6, 0));
var expiring = projectile(a, new org.bukkit.Location(a.world, 1, 65.6, 0));
var exiting = projectile(a, new org.bukkit.Location(a.world, 1, 65.6, 0));
prepareLaunches(f, a, outside, boundary, expiring, exiting);
var target = org.mockito.Mockito.mock(org.bukkit.entity.LivingEntity.class);
org.mockito.Mockito.when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
cast(f, a); f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(outside, target));
org.mockito.Mockito.verify(target, org.mockito.Mockito.never()).damage(org.mockito.ArgumentMatchers.anyDouble(),
org.mockito.ArgumentMatchers.any(org.bukkit.damage.DamageSource.class));
org.mockito.Mockito.verify(outside, org.mockito.Mockito.atLeastOnce()).remove();
cast(f, a); f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(boundary, target));
org.mockito.Mockito.verify(target).damage(org.mockito.ArgumentMatchers.eq(7.0),
org.mockito.ArgumentMatchers.any(org.bukkit.damage.DamageSource.class));
cast(f, a); Runnable lifetime = f.delayed.getLast(); lifetime.run();
org.mockito.Mockito.verify(expiring, org.mockito.Mockito.atLeastOnce()).remove();
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(expiring, target));
org.mockito.Mockito.verify(target, org.mockito.Mockito.times(1)).damage(org.mockito.ArgumentMatchers.eq(7.0),
org.mockito.ArgumentMatchers.any(org.bukkit.damage.DamageSource.class));
cast(f, a);
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
((games.dmg.fantasystick.ability.AbilityEngine) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityEngine")).sweep();
org.mockito.Mockito.verify(exiting, org.mockito.Mockito.atLeastOnce()).remove();
}
}
@Test void validatedConfiguredBalanceReachesTheRegisteredDefinition() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "fireball-base-damage: 9\n"
+ "fireball-cooldown-seconds: 2\nfireball-range-blocks: 40\nfireball-lifetime-seconds: 7\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
var definition = registry.find("fireball").orElseThrow();
assertEquals(java.time.Duration.ofSeconds(2), definition.cooldown());
assertEquals(new java.math.BigDecimal("9"), definition.parameters().get("base-damage"));
assertEquals(0, new java.math.BigDecimal("40").compareTo(definition.parameters().get("range")));
assertEquals(new java.math.BigDecimal("7"), definition.parameters().get("lifetime"));
assertTrue(String.join(" ", definition.lore()).contains("Deals 9"));
}
}
@Test void failedCooldownPersistenceLaunchesNoProjectileOrFreeEffect() {
var storage = org.mockito.Mockito.mock(games.dmg.fantasystick.persistence.CharacterStorage.class);
var sessions = org.mockito.Mockito.mock(games.dmg.fantasystick.session.EquipmentSessions.class);
var parties = new games.dmg.fantasystick.party.PartyRoster(java.time.Clock.systemUTC(), ignored -> true);
var registry = new games.dmg.fantasystick.ability.AbilityRegistry();
var fireball = new games.dmg.fantasystick.ability.Fireball(games.dmg.fantasystick.ability.Fireball.Settings.defaults());
registry.register(fireball.definition());
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var stats = new java.util.EnumMap<>(base.stats());
stats.put(games.dmg.fantasystick.progression.Stat.INT, BigInteger.valueOf(12));
var record = new games.dmg.fantasystick.persistence.CharacterRecord(base.id(), base.name(), base.revision(), stats,
base.level(), base.experience(), base.unspentPoints(), base.health(), java.util.Set.of("fireball"),
java.util.List.of("fireball", "", "", "", "", "", ""), base.cooldowns(), base.restrictedKills(), false, null, null);
var active = new games.dmg.fantasystick.session.EquipmentSessions.Active(java.util.UUID.randomUUID(),
java.util.UUID.randomUUID(), record.id());
var player = org.mockito.Mockito.mock(org.bukkit.entity.Player.class);
org.mockito.Mockito.when(sessions.current(active)).thenReturn(true);
org.mockito.Mockito.when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record));
var failed = new java.util.concurrent.CompletableFuture<games.dmg.fantasystick.persistence.CharacterStorage.AbilityMutation>();
failed.completeExceptionally(new java.io.IOException("simulated cooldown failure"));
org.mockito.Mockito.when(storage.commitAbilityCooldown(org.mockito.ArgumentMatchers.eq(record.id()),
org.mockito.ArgumentMatchers.eq(record.revision()), org.mockito.ArgumentMatchers.eq("fireball"),
org.mockito.ArgumentMatchers.any())).thenReturn(failed);
var failures = new java.util.ArrayList<games.dmg.fantasystick.ability.AbilityEngine.Failure>();
var engine = new games.dmg.fantasystick.ability.AbilityEngine(registry, storage, sessions, parties,
java.time.Clock.systemUTC(), Runnable::run, (delay, action) -> () -> {}, ignored -> {}, failures::add);
engine.accept(new games.dmg.fantasystick.session.ControlInput(player, active, 2, "fireball",
games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,
games.dmg.fantasystick.session.ControlInput.Gesture.USE, null, 0, 0, () -> true));
org.mockito.Mockito.verify(player, org.mockito.Mockito.never()).launchProjectile(
org.mockito.ArgumentMatchers.eq(org.bukkit.entity.Snowball.class), org.mockito.ArgumentMatchers.any());
assertEquals(1, failures.size());
engine.close(); fireball.close(); parties.close();
}
@Test void formulaAndPackagedDefinitionMatchTheReviewedFireballContract() throws Exception {
assertEquals(BigInteger.valueOf(6), games.dmg.fantasystick.ability.Fireball.damage(BigInteger.TEN));
assertEquals(BigInteger.valueOf(7), games.dmg.fantasystick.ability.Fireball.damage(BigInteger.valueOf(12)));
assertEquals(BigInteger.valueOf(7), games.dmg.fantasystick.ability.Fireball.damage(BigInteger.valueOf(13)));
assertEquals(BigInteger.valueOf(8), games.dmg.fantasystick.ability.Fireball.damage(BigInteger.valueOf(14)));
assertEquals(BigInteger.TEN.pow(500).subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(6)),
games.dmg.fantasystick.ability.Fireball.damage(BigInteger.TEN.pow(500)));
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
var fireball = registry.find("fireball").orElseThrow();
assertEquals("Fireball", fireball.name());
assertEquals(org.bukkit.Material.SNOWBALL, fireball.icon());
assertEquals(java.util.Map.of(games.dmg.fantasystick.progression.Stat.INT, BigInteger.valueOf(12)),
fireball.requirements());
assertEquals(BigInteger.ONE, fireball.pointCost());
assertEquals(java.time.Duration.ofSeconds(4), fireball.cooldown());
assertEquals(games.dmg.fantasystick.session.ControlInput.Gesture.USE, fireball.gesture());
assertEquals(games.dmg.fantasystick.ability.AbilityDefinition.Activation.PROJECTILE, fireball.activation());
assertEquals(new java.math.BigDecimal("32"), fireball.parameters().get("range"));
assertEquals(new java.math.BigDecimal("5"), fireball.parameters().get("lifetime"));
var unavailable = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(unavailable.core.id, unavailable.player); f.activate(unavailable);
unavailable.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, unavailable);
String unavailableText = PartyBookNativeTest.text(unavailable);
assertTrue(unavailableText.replaceAll("\\s+", "").contains("Unavailable:requirements"), unavailableText);
assertThrows(AssertionError.class, () -> PartyBookNativeTest.button(unavailable, "[Learn]"));
}
}
private static void withFireball(Path data, FantasyStickLifecycleNativeTest.Actor actor, int intelligence) throws Exception {
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(actor.core.record.id()).orElseThrow();
var stats = new java.util.EnumMap<>(old.stats());
stats.put(games.dmg.fantasystick.progression.Stat.INT, BigInteger.valueOf(intelligence));
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE),
stats, old.level(), old.experience(), old.unspentPoints(), old.health(), java.util.Set.of("fireball"),
java.util.List.of("fireball", "", "", "", "", "", ""), old.cooldowns(), old.restrictedKills(), false, null, null);
repository.replace(saved, old.revision());
}
private static void prepareLaunches(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor,
org.bukkit.entity.Snowball... projectiles) {
var eye = new org.bukkit.Location(actor.world, 0, 65.6, 0);
eye.setDirection(new org.bukkit.util.Vector(1, 0, 0));
org.mockito.Mockito.doReturn(eye).when(actor.player).getEyeLocation();
var queue = new java.util.ArrayDeque<>(java.util.List.of(projectiles));
org.mockito.Mockito.doAnswer(call -> {
var projectile = queue.removeFirst();
fixture.dispatcher.accept(new org.bukkit.event.entity.ProjectileLaunchEvent(projectile));
return projectile;
}).when(actor.player).launchProjectile(org.mockito.ArgumentMatchers.eq(org.bukkit.entity.Snowball.class),
org.mockito.ArgumentMatchers.any(org.bukkit.util.Vector.class));
}
private static void cast(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
actor.core.inventory.getInventory().setSelectedSlot(2);
fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(actor.player,
org.bukkit.event.block.Action.RIGHT_CLICK_AIR, actor.core.inventory.getItem(2), null,
org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
}
private static void withInt(Path data, FantasyStickLifecycleNativeTest.Actor actor, int intelligence) throws Exception {
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(actor.core.record.id()).orElseThrow();
var stats = new java.util.EnumMap<>(old.stats());
stats.put(games.dmg.fantasystick.progression.Stat.INT, BigInteger.valueOf(intelligence));
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE),
stats, old.level(), old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(),
old.cooldowns(), old.restrictedKills(), false, null, null);
repository.replace(saved, old.revision());
}
private static org.bukkit.entity.Snowball projectile(FantasyStickLifecycleNativeTest.Actor actor,
org.bukkit.Location location) {
var projectile = org.mockito.Mockito.mock(org.bukkit.entity.Snowball.class);
org.mockito.Mockito.when(projectile.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
org.mockito.Mockito.when(projectile.getShooter()).thenReturn(actor.player);
org.mockito.Mockito.when(projectile.getLocation()).thenAnswer(ignored -> location.clone());
org.mockito.Mockito.when(projectile.getWorld()).thenReturn(location.getWorld());
org.mockito.Mockito.when(projectile.getPersistentDataContainer()).thenReturn(
new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(
new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry()));
return projectile;
}
}
@@ -0,0 +1,537 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.math.BigInteger;
import java.nio.file.Path;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class FrostShardNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void bookAcquisitionEquippedUseAndDirectHitDealDamageAndOwnedSlow() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
withStats(data, a, 12, 10);
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("FrostShard"), book); assertTrue(book.contains("INT12"), book);
assertTrue(book.contains("WIS10"), book); assertTrue(book.contains("SlownessI"), book);
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();
assertEquals(BigInteger.valueOf(39), assigned.unspentPoints());
assertTrue(assigned.learned().contains("frost_shard"));
assertEquals("frost_shard", assigned.loadout().getFirst());
assertEquals(org.bukkit.Material.PRISMARINE_SHARD, a.core.inventory.getItem(2).getType());
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
var eye = new org.bukkit.Location(a.world, 0, 65.6, 0);
eye.setDirection(new org.bukkit.util.Vector(1, 0, 0));
doReturn(eye).when(a.player).getEyeLocation();
var projectile = projectile(a, new org.bukkit.Location(a.world, 10, 65.6, 0));
doAnswer(call -> {
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileLaunchEvent(projectile));
return projectile;
}).when(a.player).launchProjectile(eq(org.bukkit.entity.Snowball.class), any(org.bukkit.util.Vector.class));
a.core.inventory.getInventory().setSelectedSlot(2);
var cast = new org.bukkit.event.player.PlayerInteractEvent(a.player,
org.bukkit.event.block.Action.RIGHT_CLICK_AIR, a.core.inventory.getItem(2), null,
org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
f.dispatcher.accept(cast); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(org.bukkit.event.Event.Result.DENY, cast.useItemInHand());
verify(a.player).launchProjectile(eq(org.bukkit.entity.Snowball.class), any(org.bukkit.util.Vector.class));
var frost = FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "frostShard");
assertEquals(1, ((java.util.Map<?, ?>) FantasyStickLifecycleNativeTest.Fixture.field(frost, "projectiles")).size());
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 10, 65.6, 0));
when(target.addPotionEffect(any())).thenReturn(true);
var damages = new java.util.ArrayList<org.bukkit.event.entity.EntityDamageByEntityEvent>();
doAnswer(call -> {
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(projectile, target,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, source,
(Double) call.getArgument(0));
damages.add(event); f.dispatcher.accept(event); return null;
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
var hit = new org.bukkit.event.entity.ProjectileHitEvent(projectile, target);
f.dispatcher.accept(hit);
assertFalse(hit.isCancelled(), "Eligible hostile Frost Shard hit was unexpectedly cancelled");
assertEquals(1, damages.size()); assertEquals(5.0, damages.getFirst().getDamage());
var effect = org.mockito.ArgumentCaptor.forClass(org.bukkit.potion.PotionEffect.class);
verify(target).addPotionEffect(effect.capture());
assertEquals(org.bukkit.potion.PotionEffectType.SLOWNESS, effect.getValue().getType());
assertEquals(40, effect.getValue().getDuration()); assertEquals(0, effect.getValue().getAmplifier());
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(target, damages.getFirst().getDamageSource(),
new java.util.ArrayList<>(), 0));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(BigInteger.TEN, storage.find(a.core.record.id()).get().orElseThrow().experience(),
"Accepted Frost Shard damage is attributed to the originating character");
assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("frost_shard"));
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(projectile, target));
assertEquals(1, damages.size());
verify(projectile, atLeastOnce()).remove();
}
}
@Test void partyMixedModeAndCancelledDamageApplyNeitherDamageNorSlow() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "frost-shard-cooldown-seconds: 0\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); var b = new FantasyStickLifecycleNativeTest.Actor(data);
withFrostShard(data, a, 12, 10);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player); f.activate(a); f.activate(b);
var parties = PartyLifecycleNativeTest.parties(f); var party = parties.create(a.core.id).orElseThrow();
assertTrue(parties.accept(b.core.id, parties.invite(a.core.id, party.id(), b.core.id).orElseThrow().id()));
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
var first = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0));
var second = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0));
var third = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0));
prepareLaunches(f, a, first, second, third);
cast(f, a); var partyHit = new org.bukkit.event.entity.ProjectileHitEvent(first, b.player); f.dispatcher.accept(partyHit);
assertTrue(partyHit.isCancelled()); verify(b.player, never()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
var ordinary = mock(org.bukkit.entity.Player.class); when(ordinary.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
cast(f, a); var ordinaryHit = new org.bukkit.event.entity.ProjectileHitEvent(second, ordinary); f.dispatcher.accept(ordinaryHit);
assertTrue(ordinaryHit.isCancelled()); verify(ordinary, never()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
var target = mock(org.bukkit.entity.LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 2, 65.6, 0));
doAnswer(call -> {
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(third, target,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, source, (Double) call.getArgument(0));
event.setCancelled(true); f.dispatcher.accept(event); return null;
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
cast(f, a); f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(third, target));
verify(target, never()).addPotionEffect(any());
}
}
@Test void rangeLifetimeAndSessionEndRemoveOwnedProjectilesWithoutLateDamage() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "frost-shard-cooldown-seconds: 0\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withFrostShard(data, a, 12, 10);
f.players.put(a.core.id, a.player); f.activate(a); when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
var outside = projectile(a, new org.bukkit.Location(a.world, 32.01, 65.6, 0));
var boundary = projectile(a, new org.bukkit.Location(a.world, 32, 65.6, 0));
var expiring = projectile(a, new org.bukkit.Location(a.world, 1, 65.6, 0));
var exiting = projectile(a, new org.bukkit.Location(a.world, 1, 65.6, 0));
prepareLaunches(f, a, outside, boundary, expiring, exiting);
var target = mock(org.bukkit.entity.LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.addPotionEffect(any())).thenReturn(true);
var damages = new java.util.concurrent.atomic.AtomicInteger();
doAnswer(call -> {
damages.incrementAndGet();
var source = call.getArgument(1, org.bukkit.damage.DamageSource.class);
var projectile = source.getDirectEntity();
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(projectile, target,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, source, (Double) call.getArgument(0)));
return null;
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
cast(f, a); f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(outside, target));
assertEquals(0, damages.get()); verify(outside, atLeastOnce()).remove();
cast(f, a); f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(boundary, target));
assertEquals(1, damages.get());
cast(f, a); f.delayed.getLast().run(); verify(expiring, atLeastOnce()).remove();
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(expiring, target)); assertEquals(1, damages.get());
cast(f, a); a.core.inventory.setItemInOffHand(null); f.changed(a);
PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
((games.dmg.fantasystick.ability.AbilityEngine) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityEngine")).sweep();
verify(exiting, atLeastOnce()).remove();
}
}
@Test void unlearnRelearnAndTransferRetainCooldownAndClearOwnedProjectile() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "frost-shard-cooldown-seconds: 60\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStats(data, a, 12, 10);
f.players.put(a.core.id, a.player); f.activate(a); learnAndAssign(f, a);
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
var projectile = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0)); prepareLaunches(f, a, projectile);
cast(f, a);
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var deadline = storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("frost_shard");
assertNotNull(deadline);
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
var unlearn = abilityButton(a, "[Unlearn]", "frost_shard");
PartyBookNativeTest.click(f, a, unlearn); PartyBookNativeTest.click(f, a, unlearn);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var refunded = storage.find(a.core.record.id()).get().orElseThrow();
assertEquals(BigInteger.valueOf(40), refunded.unspentPoints());
assertFalse(refunded.learned().contains("frost_shard"));
assertTrue(refunded.loadout().stream().noneMatch("frost_shard"::equals));
assertEquals(deadline, refunded.cooldowns().get("frost_shard"));
verify(projectile, atLeastOnce()).remove();
learnAndAssign(f, a); cast(f, a);
verify(a.player, times(1)).launchProjectile(eq(org.bukkit.entity.Snowball.class), any(org.bukkit.util.Vector.class));
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
var sheet = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
var b = new FantasyStickLifecycleNativeTest.Actor(data); b.core.inventory.setItemInOffHand(sheet);
f.players.put(b.core.id, b.player); f.activate(b); cast(f, b);
verify(b.player, never()).launchProjectile(eq(org.bukkit.entity.Snowball.class), any(org.bukkit.util.Vector.class));
assertTrue(b.messages.stream().anyMatch(message -> message.contains("ready in")));
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("frost_shard"));
}
}
@Test void failedCooldownPersistenceLaunchesNoProjectileOrSlow() {
var storage = mock(games.dmg.fantasystick.persistence.CharacterStorage.class);
var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class);
var parties = new games.dmg.fantasystick.party.PartyRoster(java.time.Clock.systemUTC(), ignored -> true);
var registry = new games.dmg.fantasystick.ability.AbilityRegistry();
var frost = new games.dmg.fantasystick.ability.FrostShard(
games.dmg.fantasystick.ability.FrostShard.Settings.defaults()); registry.register(frost.definition());
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var stats = new java.util.EnumMap<>(base.stats());
stats.put(games.dmg.fantasystick.progression.Stat.INT, BigInteger.valueOf(12));
stats.put(games.dmg.fantasystick.progression.Stat.WIS, BigInteger.TEN);
var record = new games.dmg.fantasystick.persistence.CharacterRecord(base.id(), base.name(), base.revision(), stats,
base.level(), base.experience(), base.unspentPoints(), base.health(), java.util.Set.of("frost_shard"),
java.util.List.of("frost_shard", "", "", "", "", "", ""), base.cooldowns(), base.restrictedKills(),
false, null, null);
var active = new games.dmg.fantasystick.session.EquipmentSessions.Active(java.util.UUID.randomUUID(),
java.util.UUID.randomUUID(), record.id());
var player = mock(org.bukkit.entity.Player.class);
when(sessions.current(active)).thenReturn(true); when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record));
var failed = new java.util.concurrent.CompletableFuture<games.dmg.fantasystick.persistence.CharacterStorage.AbilityMutation>();
failed.completeExceptionally(new java.io.IOException("simulated Frost Shard cooldown failure"));
when(storage.commitAbilityCooldown(eq(record.id()), eq(record.revision()), eq("frost_shard"), any())).thenReturn(failed);
var failures = new java.util.ArrayList<games.dmg.fantasystick.ability.AbilityEngine.Failure>();
var engine = new games.dmg.fantasystick.ability.AbilityEngine(registry, storage, sessions, parties,
java.time.Clock.systemUTC(), Runnable::run, (delay, action) -> () -> {}, ignored -> {}, failures::add);
engine.accept(new games.dmg.fantasystick.session.ControlInput(player, active, 2, "frost_shard",
games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,
games.dmg.fantasystick.session.ControlInput.Gesture.USE, null, 0, 0, () -> true));
verify(player, never()).launchProjectile(eq(org.bukkit.entity.Snowball.class), any(org.bukkit.util.Vector.class));
assertEquals(1, failures.size()); engine.close(); frost.close(); parties.close();
}
@Test void configuredDefinitionAndFormulaPreserveReviewedFiniteUncappedScaling() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"frost-shard-base-damage: 9\nfrost-shard-cooldown-seconds: 2.5\n"
+ "frost-shard-range-blocks: 40\nfrost-shard-lifetime-seconds: 7\n"
+ "frost-shard-projectile-speed-blocks-per-tick: 2\n"
+ "frost-shard-slowness-amplifier: 2\nfrost-shard-slowness-duration-seconds: 3\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
var definition = registry.find("frost_shard").orElseThrow();
assertEquals(java.time.Duration.ofMillis(2500), definition.cooldown());
assertEquals(new java.math.BigDecimal("9"), definition.parameters().get("base-damage"));
assertEquals(0, new java.math.BigDecimal("40").compareTo(definition.parameters().get("range")));
assertEquals(0, new java.math.BigDecimal("7").compareTo(definition.parameters().get("lifetime")));
assertEquals(0, new java.math.BigDecimal("2").compareTo(definition.parameters().get("projectile-speed")));
assertEquals(0, new java.math.BigDecimal("2").compareTo(definition.parameters().get("slowness-amplifier")));
assertEquals(0, new java.math.BigDecimal("3").compareTo(definition.parameters().get("slowness-duration")));
var unavailable = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(unavailable.core.id, unavailable.player); f.activate(unavailable);
unavailable.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, unavailable);
assertTrue(PartyBookNativeTest.text(unavailable).replaceAll("\\s+", "")
.contains("Unavailable:requirements"));
assertThrows(AssertionError.class, () -> abilityButton(unavailable, "[Learn]", "frost_shard"));
}
assertEquals(BigInteger.valueOf(4), games.dmg.fantasystick.ability.FrostShard.damage(BigInteger.TEN));
assertEquals(BigInteger.valueOf(5), games.dmg.fantasystick.ability.FrostShard.damage(BigInteger.valueOf(12)));
assertEquals(BigInteger.TEN.pow(500).subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(4)),
games.dmg.fantasystick.ability.FrostShard.damage(BigInteger.TEN.pow(500)));
}
@Test void invalidPackagedFrostSettingsFailBeforeInstallingGameplay() throws Exception {
for (String configuration : java.util.List.of("frost-shard-base-damage: -1\n",
"frost-shard-cooldown-seconds: 31536001\n", "frost-shard-range-blocks: 0\n",
"frost-shard-lifetime-seconds: 0\n", "frost-shard-projectile-speed-blocks-per-tick: 0\n",
"frost-shard-slowness-amplifier: 256\n", "frost-shard-slowness-duration-seconds: 0\n")) {
var directory = data.resolve(Integer.toHexString(configuration.hashCode()));
java.nio.file.Files.createDirectories(directory); java.nio.file.Files.writeString(directory.resolve("config.yml"), configuration);
try (var f = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
assertThrows(IllegalArgumentException.class, () -> f.plugin.setEnabled(true));
assertTrue(f.order.isEmpty()); assertTrue(f.recipes.isEmpty());
assertFalse(java.nio.file.Files.exists(directory.resolve("characters")));
}
}
}
@Test void sourceExitRemovesOnlyOwnedSlowAndRestoresThePriorNativeLayer() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "frost-shard-cooldown-seconds: 0\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withFrostShard(data, a, 12, 10);
f.players.put(a.core.id, a.player); f.activate(a);
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
var eye = new org.bukkit.Location(a.world, 0, 65.6, 0); eye.setDirection(new org.bukkit.util.Vector(1, 0, 0));
doReturn(eye).when(a.player).getEyeLocation();
var projectile = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0));
doAnswer(call -> { f.dispatcher.accept(new org.bukkit.event.entity.ProjectileLaunchEvent(projectile)); return projectile; })
.when(a.player).launchProjectile(eq(org.bukkit.entity.Snowball.class), any(org.bukkit.util.Vector.class));
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 2, 65.6, 0));
var previous = new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.SLOWNESS, 200, 1);
var current = new java.util.concurrent.atomic.AtomicReference<org.bukkit.potion.PotionEffect>(previous);
when(target.getPotionEffect(org.bukkit.potion.PotionEffectType.SLOWNESS)).thenAnswer(call -> current.get());
when(target.addPotionEffect(any())).thenAnswer(call -> { current.set(call.getArgument(0)); return true; });
doAnswer(call -> { current.set(null); return null; }).when(target)
.removePotionEffect(org.bukkit.potion.PotionEffectType.SLOWNESS);
doAnswer(call -> {
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(projectile, target,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, source,
(Double) call.getArgument(0))); return null;
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
a.core.inventory.getInventory().setSelectedSlot(2);
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(a.player,
org.bukkit.event.block.Action.RIGHT_CLICK_AIR, a.core.inventory.getItem(2), null,
org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(projectile, target));
assertEquals(0, current.get().getAmplifier());
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
((games.dmg.fantasystick.ability.AbilityEngine) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityEngine")).sweep();
assertNotNull(current.get()); assertEquals(1, current.get().getAmplifier());
assertTrue(current.get().getDuration() > 0 && current.get().getDuration() <= 200);
}
}
@Test void reenableWaitsForAcceptedFrostConfigurationBeforeInstallingGameplay() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var settings = FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "frostShardSettings");
var worker = (java.util.concurrent.ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(settings, "worker");
var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1);
worker.execute(() -> {
entered.countDown();
try { release.await(5, java.util.concurrent.TimeUnit.SECONDS); }
catch (InterruptedException failure) { Thread.currentThread().interrupt(); }
});
try {
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
var sender = new EquipmentAdminCommandNativeTest.Sender();
f.command.execute(sender.port, "fantasystick", new String[]{"config", "frost-shard-base-damage", "9"});
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
assertNull(FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions"));
release.countDown(); f.tick();
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
assertEquals(new java.math.BigDecimal("9"), registry.find("frost_shard").orElseThrow()
.parameters().get("base-damage"));
} finally { release.countDown(); }
}
}
@Test void failedAdminPersistenceLeavesTheEffectiveDefinitionAndExternalFileUnchanged() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var outside = data.resolveSibling(data.getFileName() + "-frost-outside");
java.nio.file.Files.writeString(outside, "untouched\n");
java.nio.file.Files.createSymbolicLink(data.resolve("config.yml"), outside);
var sender = new EquipmentAdminCommandNativeTest.Sender();
f.command.execute(sender.port, "fantasystick", new String[]{"config", "frost-shard-base-damage", "9"});
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
assertTrue(sender.messages.getLast().contains("save failed"));
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
assertEquals(new java.math.BigDecimal("4"), registry.find("frost_shard").orElseThrow()
.parameters().get("base-damage"));
assertEquals("untouched\n", java.nio.file.Files.readString(outside));
java.nio.file.Files.delete(data.resolve("config.yml")); java.nio.file.Files.delete(outside);
}
}
@Test void acknowledgedUpdatesAffectFutureCastsButNotAnActiveProjectileSnapshot() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "frost-shard-cooldown-seconds: 0\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withFrostShard(data, a, 12, 10);
f.players.put(a.core.id, a.player); f.activate(a); when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
var oldProjectile = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0));
var newProjectile = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0));
prepareLaunches(f, a, oldProjectile, newProjectile); cast(f, a);
var sender = new EquipmentAdminCommandNativeTest.Sender();
f.command.execute(sender.port, "fantasystick", new String[]{"config", "frost-shard-base-damage", "9"});
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
var target = mock(org.bukkit.entity.LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 2, 65.6, 0)); when(target.addPotionEffect(any())).thenReturn(true);
var damages = new java.util.ArrayList<Double>();
doAnswer(call -> {
damages.add(call.getArgument(0)); var source = call.getArgument(1, org.bukkit.damage.DamageSource.class);
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(source.getDirectEntity(), target,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, source, (Double) call.getArgument(0)));
return null;
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(oldProjectile, target));
cast(f, a); f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(newProjectile, target));
assertEquals(java.util.List.of(5.0, 10.0), damages);
}
}
@Test void administratorCanReadUpdateAndCompleteEveryFrostShardSetting() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var sender = new EquipmentAdminCommandNativeTest.Sender();
assertEquals(java.util.List.of("frost-shard-base-damage"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "frost-shard-b"}));
assertEquals(java.util.List.of("frost-shard-base-damage", "frost-shard-cooldown-seconds",
"frost-shard-lifetime-seconds", "frost-shard-projectile-speed-blocks-per-tick",
"frost-shard-range-blocks", "frost-shard-slowness-amplifier",
"frost-shard-slowness-duration-seconds"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "frost-shard-"}));
assertEquals(java.util.List.of("4"), f.command.tabComplete(sender.port, "fantasystick",
new String[]{"config", "frost-shard-base-damage", ""}));
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "unknown"}).isEmpty());
sender.allowed = false;
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "frost"}).isEmpty());
sender.allowed = true;
f.command.execute(sender.port, "fantasystick", new String[]{"config", "frost-shard-base-damage"});
assertTrue(sender.messages.getLast().contains("frost-shard-base-damage=4"));
sender.messages.clear();
f.command.execute(sender.port, "fantasystick", new String[]{"config", "frost-shard-base-damage", "9"});
assertTrue(sender.messages.isEmpty(), "Do not publish settings before durable acknowledgment");
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
assertTrue(sender.messages.getLast().contains("frost-shard-base-damage=9"));
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
assertEquals(new java.math.BigDecimal("9"), registry.find("frost_shard").orElseThrow()
.parameters().get("base-damage"));
var saved = (java.util.Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(
java.nio.file.Files.readString(data.resolve("config.yml")));
assertEquals("9", String.valueOf(saved.get("frost-shard-base-damage")));
for (String invalid : java.util.List.of("-1", "1.5", "nope")) {
f.command.execute(sender.port, "fantasystick",
new String[]{"config", "frost-shard-base-damage", invalid});
assertTrue(sender.messages.getLast().contains("invalid"));
}
assertEquals(new java.math.BigDecimal("9"), registry.find("frost_shard").orElseThrow()
.parameters().get("base-damage"));
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
var reloaded = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
assertEquals(new java.math.BigDecimal("9"), reloaded.find("frost_shard").orElseThrow()
.parameters().get("base-damage"));
sender.messages.clear();
f.command.execute(sender.port, "fantasystick", new String[]{"config", "frost-shard-base-damage"});
assertTrue(sender.messages.getLast().contains("=9"));
}
}
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[Learn]", "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,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
actor.core.inventory.getInventory().setSelectedSlot(2);
fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(actor.player,
org.bukkit.event.block.Action.RIGHT_CLICK_AIR, actor.core.inventory.getItem(2), null,
org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
}
private static void prepareLaunches(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor,
org.bukkit.entity.Snowball... projectiles) {
var eye = new org.bukkit.Location(actor.world, 0, 65.6, 0);
eye.setDirection(new org.bukkit.util.Vector(1, 0, 0)); doReturn(eye).when(actor.player).getEyeLocation();
var queue = new java.util.ArrayDeque<>(java.util.List.of(projectiles));
doAnswer(call -> {
var projectile = queue.removeFirst(); fixture.dispatcher.accept(new org.bukkit.event.entity.ProjectileLaunchEvent(projectile));
return projectile;
}).when(actor.player).launchProjectile(eq(org.bukkit.entity.Snowball.class), any(org.bukkit.util.Vector.class));
}
private static net.minecraft.network.chat.ClickEvent.Custom abilityButton(
FantasyStickLifecycleNativeTest.Actor actor, String label, String ability) {
var book = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
assertNotNull(book);
for (var page : book.pages()) {
var found = abilityButton(page.raw(), label, ability);
if (found != null) return found;
}
throw new AssertionError("Missing " + label + " action for " + ability);
}
private static net.minecraft.network.chat.ClickEvent.Custom abilityButton(
net.minecraft.network.chat.Component component, String label, String ability) {
if (component.getString().equals(label)
&& component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.Custom custom
&& custom.payload().orElse(null) instanceof net.minecraft.nbt.StringTag payload
&& payload.value().contains(":" + ability)) return custom;
for (var child : component.getSiblings()) {
var found = abilityButton(child, label, ability);
if (found != null) return found;
}
return null;
}
private static void withFrostShard(Path data, FantasyStickLifecycleNativeTest.Actor actor,
int intelligence, int wisdom) 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));
stats.put(games.dmg.fantasystick.progression.Stat.WIS, BigInteger.valueOf(wisdom));
repository.replace(new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(),
old.revision().add(BigInteger.ONE), stats, old.level(), old.experience(), old.unspentPoints(), old.health(),
java.util.Set.of("frost_shard"), java.util.List.of("frost_shard", "", "", "", "", "", ""),
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
}
private static void withStats(Path data, FantasyStickLifecycleNativeTest.Actor actor,
int intelligence, int wisdom) 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));
stats.put(games.dmg.fantasystick.progression.Stat.WIS, BigInteger.valueOf(wisdom));
repository.replace(new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(),
old.revision().add(BigInteger.ONE), stats, old.level(), old.experience(), old.unspentPoints(), old.health(),
old.learned(), old.loadout(), old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
}
private static org.bukkit.entity.Snowball projectile(FantasyStickLifecycleNativeTest.Actor actor,
org.bukkit.Location location) {
var projectile = mock(org.bukkit.entity.Snowball.class);
when(projectile.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(projectile.getShooter()).thenReturn(actor.player);
when(projectile.getLocation()).thenAnswer(ignored -> location.clone());
when(projectile.getWorld()).thenReturn(location.getWorld());
when(projectile.getPersistentDataContainer()).thenReturn(
new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(
new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry()));
return projectile;
}
}
@@ -0,0 +1,369 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.math.BigInteger;
import java.nio.file.Path;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class GreatSwordNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void bookAcquisitionAndFullyChargedEquippedStrikeReplaceVanillaDamage() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
withStrength(data, a, 12);
f.players.put(a.core.id, a.player); f.activate(a);
a.core.inventory.getInventory().setSelectedSlot(0);
PartyBookNativeTest.open(f, a);
String book = PartyBookNativeTest.text(a);
assertTrue(book.contains("Great Sword"));
assertTrue(book.contains("STR 12"));
assertTrue(book.contains("1.2"));
var learn = abilityButton(a, "[Learn]", "great_sword");
PartyBookNativeTest.click(f, a, learn); PartyBookNativeTest.click(f, a, learn);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "great_sword"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var assigned = storage.find(a.core.record.id()).get().orElseThrow();
assertTrue(assigned.learned().contains("great_sword"));
assertEquals("great_sword", assigned.loadout().getFirst());
assertEquals(BigInteger.valueOf(39), assigned.unspentPoints());
assertEquals(org.bukkit.Material.WOODEN_SWORD, a.core.inventory.getItem(2).getType());
when(a.player.getAttackCooldown()).thenReturn(1.0f);
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 4, 64, 0));
var damages = new java.util.ArrayList<org.bukkit.event.entity.EntityDamageByEntityEvent>();
doAnswer(call -> {
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, target,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source,
(Double) call.getArgument(0));
damages.add(event); f.dispatcher.accept(event); return null;
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
a.core.inventory.getInventory().setSelectedSlot(2);
var before = java.time.Instant.now();
var attack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
f.dispatcher.accept(attack);
assertTrue(attack.isCancelled(), "The bound sword must never continue into vanilla damage");
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(1, damages.size());
assertEquals(8.0, damages.getFirst().getDamage());
assertSame(a.player, damages.getFirst().getDamager());
assertEquals(org.bukkit.damage.DamageType.PLAYER_ATTACK, damages.getFirst().getDamageSource().getDamageType());
var saved = storage.find(a.core.record.id()).get().orElseThrow();
var deadline = saved.cooldowns().get("great_sword");
assertNotNull(deadline);
assertFalse(deadline.isBefore(before.plusMillis(1200)));
assertFalse(deadline.isAfter(java.time.Instant.now().plusMillis(1200)));
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(target, damages.getFirst().getDamageSource(),
new java.util.ArrayList<>(), 0));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(BigInteger.TEN, storage.find(a.core.record.id()).get().orElseThrow().experience(),
"Accepted direct damage is attributed to the originating character");
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
PartyBookNativeTest.drain(f);
assertEquals(1, damages.size(), "The persisted recovery prevents a repeated strike");
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
}
}
@Test void partialChargeUsesTheApprovedNativeCurveWithoutVanillaContinuation() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "great-sword-recovery-seconds: 0\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
withGreatSword(data, a, 12);
f.players.put(a.core.id, a.player); f.activate(a);
when(a.player.getAttackCooldown()).thenReturn(0.5f);
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
var damages = new java.util.ArrayList<Double>();
doAnswer(call -> {
double amount = call.getArgument(0); damages.add(amount);
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, target,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, amount));
return null;
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
a.core.inventory.getInventory().setSelectedSlot(2);
var attack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
f.dispatcher.accept(attack); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertTrue(attack.isCancelled());
assertEquals(java.util.List.of(3.2), damages);
}
}
@Test void refusedPlayersAndCancelledNativeDamagePreserveProtectionWithoutFreeVanillaHits() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var b = new FantasyStickLifecycleNativeTest.Actor(data);
withGreatSword(data, a, 12);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
f.activate(a); f.activate(b);
var parties = PartyLifecycleNativeTest.parties(f);
var party = parties.create(a.core.id).orElseThrow();
assertTrue(parties.accept(b.core.id,
parties.invite(a.core.id, party.id(), b.core.id).orElseThrow().id()));
when(a.player.getAttackCooldown()).thenReturn(1.0f);
a.core.inventory.getInventory().setSelectedSlot(2);
var partyAttack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, b.player, true);
f.dispatcher.accept(partyAttack); PartyBookNativeTest.drain(f);
assertTrue(partyAttack.isCancelled());
verify(b.player, never()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
var ordinary = mock(org.bukkit.entity.Player.class);
when(ordinary.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
var ordinaryAttack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, ordinary, true);
f.dispatcher.accept(ordinaryAttack); PartyBookNativeTest.drain(f);
assertTrue(ordinaryAttack.isCancelled());
verify(ordinary, never()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 4, 64, 0));
var damage = new java.util.concurrent.atomic.AtomicReference<org.bukkit.event.entity.EntityDamageByEntityEvent>();
doAnswer(call -> {
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, target,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source,
(Double) call.getArgument(0));
event.setCancelled(true); damage.set(event); f.dispatcher.accept(event); return null;
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertNotNull(damage.get()); assertTrue(damage.get().isCancelled());
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("great_sword"),
"An acknowledged recovery is not refunded by later native cancellation");
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(target, damage.get().getDamageSource(),
new java.util.ArrayList<>(), 0));
PartyBookNativeTest.drain(f);
assertEquals(BigInteger.ZERO, storage.find(a.core.record.id()).get().orElseThrow().experience());
}
}
@Test void unlearnRelearnAndWholeCharacterTransferRetainTheOriginalRecovery() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "great-sword-recovery-seconds: 60\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStrength(data, a, 12);
f.players.put(a.core.id, a.player); f.activate(a);
learnAndAssign(f, a);
when(a.player.getAttackCooldown()).thenReturn(1.0f);
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
var hits = new java.util.concurrent.atomic.AtomicInteger();
doAnswer(call -> {
hits.incrementAndGet();
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, target,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source,
(Double) call.getArgument(0))); return null;
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
a.core.inventory.getInventory().setSelectedSlot(2);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(1, hits.get());
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var deadline = storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("great_sword");
assertNotNull(deadline);
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
var unlearn = abilityButton(a, "[Unlearn]", "great_sword");
PartyBookNativeTest.click(f, a, unlearn); PartyBookNativeTest.click(f, a, unlearn);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var refunded = storage.find(a.core.record.id()).get().orElseThrow();
assertEquals(BigInteger.valueOf(40), refunded.unspentPoints());
assertFalse(refunded.learned().contains("great_sword"));
assertTrue(refunded.loadout().stream().noneMatch("great_sword"::equals));
assertEquals(deadline, refunded.cooldowns().get("great_sword"));
assertTrue(new games.dmg.fantasystick.items.SessionControls(
(games.dmg.fantasystick.ability.AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry"))
.ability(a.core.inventory.getItem(2)).isEmpty());
learnAndAssign(f, a);
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("great_sword"));
a.core.inventory.getInventory().setSelectedSlot(2);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
PartyBookNativeTest.drain(f);
assertEquals(1, hits.get());
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
var sheet = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
var b = new FantasyStickLifecycleNativeTest.Actor(data); b.core.inventory.setItemInOffHand(sheet);
f.players.put(b.core.id, b.player); f.activate(b);
when(b.player.getAttackCooldown()).thenReturn(1.0f);
b.core.inventory.getInventory().setSelectedSlot(2);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(b.player, target, true));
PartyBookNativeTest.drain(f);
assertEquals(1, hits.get(), "Transfer cannot reset the character-bound recovery");
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("great_sword"));
}
}
@Test void failedCooldownPersistenceDealsNoDamageOrFreeEffect() {
var storage = mock(games.dmg.fantasystick.persistence.CharacterStorage.class);
var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class);
var parties = new games.dmg.fantasystick.party.PartyRoster(java.time.Clock.systemUTC(), ignored -> true);
var registry = new games.dmg.fantasystick.ability.AbilityRegistry();
var sword = new games.dmg.fantasystick.ability.GreatSword(
games.dmg.fantasystick.ability.GreatSword.Settings.defaults());
registry.register(sword.definition());
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var stats = new java.util.EnumMap<>(base.stats());
stats.put(games.dmg.fantasystick.progression.Stat.STR, BigInteger.valueOf(12));
var record = new games.dmg.fantasystick.persistence.CharacterRecord(base.id(), base.name(), base.revision(), stats,
base.level(), base.experience(), base.unspentPoints(), base.health(), java.util.Set.of("great_sword"),
java.util.List.of("great_sword", "", "", "", "", "", ""), base.cooldowns(), base.restrictedKills(),
false, null, null);
var active = new games.dmg.fantasystick.session.EquipmentSessions.Active(java.util.UUID.randomUUID(),
java.util.UUID.randomUUID(), record.id());
var player = mock(org.bukkit.entity.Player.class);
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(sessions.current(active)).thenReturn(true);
when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record));
var failed = new java.util.concurrent.CompletableFuture<games.dmg.fantasystick.persistence.CharacterStorage.AbilityMutation>();
failed.completeExceptionally(new java.io.IOException("simulated Great Sword cooldown failure"));
when(storage.commitAbilityCooldown(eq(record.id()), eq(record.revision()), eq("great_sword"), any()))
.thenReturn(failed);
var failures = new java.util.ArrayList<games.dmg.fantasystick.ability.AbilityEngine.Failure>();
var engine = new games.dmg.fantasystick.ability.AbilityEngine(registry, storage, sessions, parties,
java.time.Clock.systemUTC(), Runnable::run, (delay, action) -> () -> {}, ignored -> {}, failures::add);
engine.accept(new games.dmg.fantasystick.session.ControlInput(player, active, 2, "great_sword",
games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,
games.dmg.fantasystick.session.ControlInput.Gesture.ATTACK, target, 0, 1, () -> true));
verify(target, never()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
assertEquals(1, failures.size());
engine.close(); sword.close(); parties.close();
}
@Test void invalidGreatSwordBalanceFailsBeforeInstallingGameplay() throws Exception {
for (String configuration : java.util.List.of("great-sword-base-damage: -1\n",
"great-sword-recovery-seconds: -0.1\n", "great-sword-recovery-seconds: 31536001\n",
"great-sword-recovery-seconds: 0.0000000001\n", "great-sword-base-damage: nope\n")) {
var directory = data.resolve(Integer.toHexString(configuration.hashCode()));
java.nio.file.Files.createDirectories(directory);
java.nio.file.Files.writeString(directory.resolve("config.yml"), configuration);
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
assertThrows(IllegalArgumentException.class, () -> fixture.plugin.setEnabled(true));
assertTrue(fixture.order.isEmpty()); assertTrue(fixture.recipes.isEmpty());
assertFalse(java.nio.file.Files.exists(directory.resolve("characters")));
}
}
}
@Test void configuredDefinitionAndChargeCurvePreserveExactUncappedScaling() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"great-sword-base-damage: 9\ngreat-sword-recovery-seconds: 2.5\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
var definition = registry.find("great_sword").orElseThrow();
assertEquals(java.time.Duration.ofMillis(2500), definition.cooldown());
assertEquals(new java.math.BigDecimal("9"), definition.parameters().get("base-damage"));
assertEquals("Great Sword", definition.name());
assertEquals(org.bukkit.Material.WOODEN_SWORD, definition.icon());
assertEquals(java.util.Map.of(games.dmg.fantasystick.progression.Stat.STR, BigInteger.valueOf(12)),
definition.requirements());
assertEquals(games.dmg.fantasystick.session.ControlInput.Gesture.ATTACK, definition.gesture());
var unavailable = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(unavailable.core.id, unavailable.player); f.activate(unavailable);
unavailable.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, unavailable);
String text = PartyBookNativeTest.text(unavailable);
assertTrue(text.contains("Great Sword"));
assertTrue(text.replaceAll("\\s+", "").contains("Unavailable:requirements"), text);
assertThrows(AssertionError.class, () -> abilityButton(unavailable, "[Learn]", "great_sword"));
}
assertEquals(new java.math.BigDecimal("8"),
games.dmg.fantasystick.ability.GreatSword.damage(BigInteger.valueOf(12), 1.0f));
assertEquals(new java.math.BigDecimal("3.2"),
games.dmg.fantasystick.ability.GreatSword.damage(BigInteger.valueOf(12), 0.5f));
assertEquals(new java.math.BigDecimal("1.6"),
games.dmg.fantasystick.ability.GreatSword.damage(BigInteger.valueOf(12), 0.0f));
assertEquals(new java.math.BigDecimal(BigInteger.TEN.pow(500).subtract(BigInteger.TEN)
.divide(BigInteger.TWO).add(BigInteger.valueOf(7))),
games.dmg.fantasystick.ability.GreatSword.damage(BigInteger.TEN.pow(500), 1.0f));
}
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[Learn]", "great_sword"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "great_sword"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
}
private static net.minecraft.network.chat.ClickEvent.Custom abilityButton(
FantasyStickLifecycleNativeTest.Actor actor, String label, String ability) {
var book = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
assertNotNull(book);
for (var page : book.pages()) {
var found = abilityButton(page.raw(), label, ability);
if (found != null) return found;
}
throw new AssertionError("Missing " + label + " action for " + ability);
}
private static net.minecraft.network.chat.ClickEvent.Custom abilityButton(
net.minecraft.network.chat.Component component, String label, String ability) {
if (component.getString().equals(label)
&& component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.Custom custom
&& custom.payload().orElse(null) instanceof net.minecraft.nbt.StringTag payload
&& payload.value().contains(":" + ability)) return custom;
for (var child : component.getSiblings()) {
var found = abilityButton(child, label, ability);
if (found != null) return found;
}
return null;
}
private static void withGreatSword(Path data, FantasyStickLifecycleNativeTest.Actor actor, int strength) throws Exception {
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(actor.core.record.id()).orElseThrow();
var stats = new java.util.EnumMap<>(old.stats());
stats.put(games.dmg.fantasystick.progression.Stat.STR, BigInteger.valueOf(strength));
repository.replace(new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(),
old.revision().add(BigInteger.ONE), stats, old.level(), old.experience(), old.unspentPoints(), old.health(),
java.util.Set.of("great_sword"), java.util.List.of("great_sword", "", "", "", "", "", ""),
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
}
private static void withStrength(Path data, FantasyStickLifecycleNativeTest.Actor actor, int strength) throws Exception {
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(actor.core.record.id()).orElseThrow();
var stats = new java.util.EnumMap<>(old.stats());
stats.put(games.dmg.fantasystick.progression.Stat.STR, BigInteger.valueOf(strength));
repository.replace(new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(),
old.revision().add(BigInteger.ONE), stats, old.level(), old.experience(), old.unspentPoints(), old.health(),
old.learned(), old.loadout(), old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
}
}
@@ -0,0 +1,479 @@
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.Stat;
import java.math.BigInteger;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import net.minecraft.network.chat.ClickEvent;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.damage.DamageSource;
import org.bukkit.entity.AbstractArrow;
import org.bukkit.entity.Arrow;
import org.bukkit.entity.LivingEntity;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.EntityDeathEvent;
import org.bukkit.event.entity.ProjectileHitEvent;
import org.bukkit.event.entity.ProjectileLaunchEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.ItemStack;
import org.bukkit.util.Vector;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.ArgumentCaptor;
class LongbowNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void bookAcquisitionNativeDrawFullShotDamageAttributionAndPersistentRecovery() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStats(data, a, 0, 14);
f.players.put(a.core.id, a.player); f.activate(a);
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);
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());
assertEquals(BigInteger.valueOf(39), learned.unspentPoints());
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
var arrow = arrow(a, new Location(a.world, 6, 65.6, 0));
doAnswer(call -> { f.dispatcher.accept(new ProjectileLaunchEvent(arrow)); return arrow; })
.when(a.player).launchProjectile(eq(Arrow.class), any(Vector.class));
var target = mock(LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(target.getLocation()).thenReturn(new Location(a.world, 6, 65.6, 0)); when(target.getWorld()).thenReturn(a.world);
var damages = new ArrayList<EntityDamageByEntityEvent>();
doAnswer(call -> {
var source = call.getArgument(1, DamageSource.class);
var event = new EntityDamageByEntityEvent(arrow, target, EntityDamageEvent.DamageCause.PROJECTILE,
source, (Double) call.getArgument(0)); damages.add(event); f.dispatcher.accept(event); return null;
}).when(target).damage(anyDouble(), any(DamageSource.class));
a.core.inventory.setItem(20, new ItemStack(Material.ARROW, 32));
drawAndRelease(f, a, 20); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var velocity = ArgumentCaptor.forClass(Vector.class);
verify(a.player).launchProjectile(eq(Arrow.class), velocity.capture());
assertEquals(3.0, velocity.getValue().length(), 1.0e-9);
verify(arrow).setPickupStatus(AbstractArrow.PickupStatus.DISALLOWED);
verify(arrow).setDamage(0);
assertEquals(32, a.core.inventory.getItem(20).getAmount(), "Ordinary ammunition must remain untouched");
f.dispatcher.accept(new ProjectileHitEvent(arrow, target));
assertEquals(1, damages.size()); assertEquals(7.0, damages.getFirst().getFinalDamage(), 1.0e-9);
f.dispatcher.accept(new EntityDeathEvent(target, damages.getFirst().getDamageSource(), new ArrayList<>(), 0));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var saved = storage.find(a.core.record.id()).get().orElseThrow();
assertEquals(BigInteger.TEN, saved.experience()); assertNotNull(saved.cooldowns().get("longbow"));
f.dispatcher.accept(new ProjectileHitEvent(arrow, target)); assertEquals(1, damages.size());
}
}
@Test void partialDrawUsesReviewedCurveWhileTooShortDrawRefusesBeforeCooldown() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withLongbow(data, a, 14);
f.players.put(a.core.id, a.player); f.activate(a); when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
var arrow = arrow(a, new Location(a.world, 6, 65.6, 0));
doAnswer(call -> { f.dispatcher.accept(new ProjectileLaunchEvent(arrow)); return arrow; })
.when(a.player).launchProjectile(eq(Arrow.class), any(Vector.class));
drawAndRelease(f, a, 2);
verify(a.player, never()).launchProjectile(eq(Arrow.class), any(Vector.class));
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("longbow"));
assertTrue(a.messages.stream().anyMatch(message -> message.contains("not eligible")));
var target = mock(LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getLocation()).thenReturn(new Location(a.world, 6, 65.6, 0));
var amount = new double[1];
doAnswer(call -> {
amount[0] = call.getArgument(0); var source = call.getArgument(1, DamageSource.class);
f.dispatcher.accept(new EntityDamageByEntityEvent(arrow, target, EntityDamageEvent.DamageCause.PROJECTILE,
source, amount[0])); return null;
}).when(target).damage(anyDouble(), any(DamageSource.class));
drawAndRelease(f, a, 10); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
f.dispatcher.accept(new ProjectileHitEvent(arrow, target));
assertEquals(7.0 * (0.25 + 1.0) / 3.0, amount[0], 1.0e-9);
}
}
@Test void nativeDamagePartyTargetsAndCancelledCustomDamageProduceNoAcceptedHit() 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);
withLongbow(data, a, 14); f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
f.activate(a); f.activate(b); when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
var party = PartyLifecycleNativeTest.parties(f).create(a.core.id).orElseThrow();
assertTrue(PartyLifecycleNativeTest.parties(f).accept(b.core.id,
PartyLifecycleNativeTest.parties(f).invite(a.core.id, party.id(), b.core.id).orElseThrow().id()));
var arrow = arrow(a, new Location(a.world, 4, 65.6, 0));
doAnswer(call -> { f.dispatcher.accept(new ProjectileLaunchEvent(arrow)); return arrow; })
.when(a.player).launchProjectile(eq(Arrow.class), any(Vector.class));
drawAndRelease(f, a, 20); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var ordinaryTarget = mock(LivingEntity.class); when(ordinaryTarget.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
var nativeDamage = new EntityDamageByEntityEvent(arrow, ordinaryTarget,
EntityDamageEvent.DamageCause.PROJECTILE, 7.0);
f.dispatcher.accept(nativeDamage); assertTrue(nativeDamage.isCancelled(), "Owned arrows cannot add vanilla damage");
f.dispatcher.accept(new ProjectileHitEvent(arrow, b.player));
verify(b.player, never()).damage(anyDouble(), any(DamageSource.class));
var c = new FantasyStickLifecycleNativeTest.Actor(data); withLongbow(data, c, 14);
f.players.put(c.core.id, c.player); f.activate(c); when(c.world.getUID()).thenReturn(java.util.UUID.randomUUID());
var arrow2 = arrow(c, new Location(c.world, 4, 65.6, 0));
doAnswer(call -> { f.dispatcher.accept(new ProjectileLaunchEvent(arrow2)); return arrow2; })
.when(c.player).launchProjectile(eq(Arrow.class), any(Vector.class));
var hostile = mock(LivingEntity.class); when(hostile.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(hostile.getLocation()).thenReturn(new Location(c.world, 4, 65.6, 0));
doAnswer(call -> {
var source = call.getArgument(1, DamageSource.class);
var event = new EntityDamageByEntityEvent(arrow2, hostile, EntityDamageEvent.DamageCause.PROJECTILE,
source, (Double) call.getArgument(0)); event.setCancelled(true); f.dispatcher.accept(event); return null;
}).when(hostile).damage(anyDouble(), any(DamageSource.class));
drawAndRelease(f, c, 20); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
f.dispatcher.accept(new ProjectileHitEvent(arrow2, hostile));
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertEquals(BigInteger.ZERO, storage.find(c.core.record.id()).get().orElseThrow().experience());
assertNotNull(storage.find(c.core.record.id()).get().orElseThrow().cooldowns().get("longbow"));
}
}
@Test void rangeLifetimeAndSessionEndRemoveOwnedArrowsWithoutLateDamage() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var target = mock(LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
for (int route = 0; route < 3; route++) {
var a = new FantasyStickLifecycleNativeTest.Actor(data); withLongbow(data, a, 14);
f.players.put(a.core.id, a.player); f.activate(a); when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
var location = new Location(a.world, route == 0 ? 64.01 : 2, 65.6, 0);
var arrow = arrow(a, location);
doAnswer(call -> { f.dispatcher.accept(new ProjectileLaunchEvent(arrow)); return arrow; })
.when(a.player).launchProjectile(eq(Arrow.class), any(Vector.class));
drawAndRelease(f, a, 20); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
if (route == 0) {
f.dispatcher.accept(new ProjectileHitEvent(arrow, target));
verify(target, never()).damage(anyDouble(), any(DamageSource.class));
} else if (route == 1) {
f.delayed.getLast().run();
f.dispatcher.accept(new ProjectileHitEvent(arrow, target));
verify(target, never()).damage(anyDouble(), any(DamageSource.class));
} else {
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
((games.dmg.fantasystick.ability.AbilityEngine) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "abilityEngine")).sweep();
}
verify(arrow, atLeastOnce()).remove();
}
}
}
@Test void unlearnRelearnAndTransferRetainRecoveryAndRemoveTheOwnedArrow() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withLongbow(data, a, 14);
f.players.put(a.core.id, a.player); f.activate(a); when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
var arrow = arrow(a, new Location(a.world, 2, 65.6, 0));
doAnswer(call -> { f.dispatcher.accept(new ProjectileLaunchEvent(arrow)); return arrow; })
.when(a.player).launchProjectile(eq(Arrow.class), any(Vector.class));
drawAndRelease(f, a, 20); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var deadline = storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("longbow");
assertNotNull(deadline);
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
var unlearn = abilityButton(a, "[Unlearn]", "longbow");
PartyBookNativeTest.click(f, a, unlearn); PartyBookNativeTest.click(f, a, unlearn);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var refunded = storage.find(a.core.record.id()).get().orElseThrow();
assertEquals(BigInteger.valueOf(40), refunded.unspentPoints());
assertFalse(refunded.learned().contains("longbow")); assertEquals(deadline, refunded.cooldowns().get("longbow"));
verify(arrow, atLeastOnce()).remove();
learnAndAssign(f, a); drawAndRelease(f, a, 20);
verify(a.player, times(1)).launchProjectile(eq(Arrow.class), any(Vector.class));
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
var sheet = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
var b = new FantasyStickLifecycleNativeTest.Actor(data); b.core.inventory.setItemInOffHand(sheet);
f.players.put(b.core.id, b.player); f.activate(b); drawAndRelease(f, b, 20);
verify(b.player, never()).launchProjectile(eq(Arrow.class), any(Vector.class));
assertTrue(b.messages.stream().anyMatch(message -> message.contains("ready in")));
}
}
@Test void failedRecoveryPersistenceLaunchesNoArrow() {
var storage = mock(CharacterStorage.class);
var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class);
var parties = new games.dmg.fantasystick.party.PartyRoster(java.time.Clock.systemUTC(), ignored -> true);
var registry = new games.dmg.fantasystick.ability.AbilityRegistry();
var longbow = new games.dmg.fantasystick.ability.Longbow(
games.dmg.fantasystick.ability.Longbow.Settings.defaults()); registry.register(longbow.definition());
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var stats = new EnumMap<>(base.stats()); stats.put(Stat.DEX, BigInteger.valueOf(14));
var record = new CharacterRecord(base.id(), base.name(), base.revision(), stats, base.level(), base.experience(),
base.unspentPoints(), base.health(), java.util.Set.of("longbow"),
List.of("longbow", "", "", "", "", "", ""), base.cooldowns(), base.restrictedKills(), false, null, null);
var active = new games.dmg.fantasystick.session.EquipmentSessions.Active(java.util.UUID.randomUUID(),
java.util.UUID.randomUUID(), record.id());
var player = mock(org.bukkit.entity.Player.class);
when(sessions.current(active)).thenReturn(true); when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record));
var failed = new java.util.concurrent.CompletableFuture<CharacterStorage.AbilityMutation>();
failed.completeExceptionally(new java.io.IOException("simulated Longbow recovery failure"));
when(storage.commitAbilityCooldown(eq(record.id()), eq(record.revision()), eq("longbow"), any())).thenReturn(failed);
var failures = new ArrayList<games.dmg.fantasystick.ability.AbilityEngine.Failure>();
var engine = new games.dmg.fantasystick.ability.AbilityEngine(registry, storage, sessions, parties,
java.time.Clock.systemUTC(), Runnable::run, (delay, action) -> () -> {}, ignored -> {}, failures::add);
engine.accept(new games.dmg.fantasystick.session.ControlInput(player, active, 2, "longbow",
games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,
games.dmg.fantasystick.session.ControlInput.Gesture.DRAW_RELEASE, null, 20, 0, () -> true));
verify(player, never()).launchProjectile(eq(Arrow.class), any(Vector.class));
assertEquals(1, failures.size()); engine.close(); longbow.close(); parties.close();
}
@Test void acknowledgedConfigurationChangesOnlyFutureShotSnapshots() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withLongbow(data, a, 14);
f.players.put(a.core.id, a.player); f.activate(a); when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
var oldArrow = arrow(a, new Location(a.world, 3, 65.6, 0));
doAnswer(call -> { f.dispatcher.accept(new ProjectileLaunchEvent(oldArrow)); return oldArrow; })
.when(a.player).launchProjectile(eq(Arrow.class), any(Vector.class));
drawAndRelease(f, a, 20); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var sender = new EquipmentAdminCommandNativeTest.Sender();
f.command.execute(sender.port, "fantasystick", new String[]{"config", "longbow-base-damage", "9"});
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
var oldTarget = targetRecordingDamage(f, a, oldArrow); f.dispatcher.accept(new ProjectileHitEvent(oldArrow, oldTarget.entity()));
assertEquals(7.0, oldTarget.amount()[0], 1.0e-9);
var b = new FantasyStickLifecycleNativeTest.Actor(data); withLongbow(data, b, 14);
f.players.put(b.core.id, b.player); f.activate(b); when(b.world.getUID()).thenReturn(java.util.UUID.randomUUID());
var newArrow = arrow(b, new Location(b.world, 3, 65.6, 0));
doAnswer(call -> { f.dispatcher.accept(new ProjectileLaunchEvent(newArrow)); return newArrow; })
.when(b.player).launchProjectile(eq(Arrow.class), any(Vector.class));
var newTarget = targetRecordingDamage(f, b, newArrow);
drawAndRelease(f, b, 20); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
f.dispatcher.accept(new ProjectileHitEvent(newArrow, newTarget.entity()));
assertEquals(11.0, newTarget.amount()[0], 1.0e-9);
}
}
@Test void failedAdminWriteKeepsLongbowDefinitionAndExternalFileUnchanged() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var outside = data.resolveSibling(data.getFileName() + "-longbow-outside");
java.nio.file.Files.writeString(outside, "untouched\n");
java.nio.file.Files.createSymbolicLink(data.resolve("config.yml"), outside);
var sender = new EquipmentAdminCommandNativeTest.Sender();
f.command.execute(sender.port, "fantasystick", new String[]{"config", "longbow-base-damage", "9"});
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
assertTrue(sender.messages.getLast().contains("save failed"));
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
assertEquals(new java.math.BigDecimal("5"), registry.find("longbow").orElseThrow()
.parameters().get("base-damage"));
assertEquals("untouched\n", java.nio.file.Files.readString(outside));
java.nio.file.Files.delete(data.resolve("config.yml")); java.nio.file.Files.delete(outside);
}
}
@Test void reenableDrainsAcceptedLongbowConfigurationBeforeGameplayRegistration() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var settings = FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "longbowSettings");
var worker = (java.util.concurrent.ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(settings, "worker");
var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1);
worker.execute(() -> {
entered.countDown();
try { release.await(5, java.util.concurrent.TimeUnit.SECONDS); }
catch (InterruptedException failure) { Thread.currentThread().interrupt(); }
});
try {
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
var sender = new EquipmentAdminCommandNativeTest.Sender();
f.command.execute(sender.port, "fantasystick", new String[]{"config", "longbow-base-damage", "9"});
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
assertNull(FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions"));
release.countDown(); f.tick();
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
assertEquals(new java.math.BigDecimal("9"), registry.find("longbow").orElseThrow()
.parameters().get("base-damage"));
} finally { release.countDown(); }
}
}
@Test void configuredDefinitionFormulaValidationAndAdminRoutesCoverEverySetting() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"longbow-base-damage: 8\nlongbow-recovery-seconds: 2\nlongbow-minimum-draw-ticks: 4\n"
+ "longbow-full-draw-ticks: 30\nlongbow-range-blocks: 70\nlongbow-lifetime-seconds: 11\n"
+ "longbow-full-projectile-speed-blocks-per-tick: 4\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
var definition = registry.find("longbow").orElseThrow();
assertEquals(java.time.Duration.ofSeconds(2), definition.cooldown());
assertEquals(new java.math.BigDecimal("8"), definition.parameters().get("base-damage"));
assertEquals(new java.math.BigDecimal("4"), definition.parameters().get("minimum-draw-ticks"));
assertEquals(new java.math.BigDecimal("30"), definition.parameters().get("full-draw-ticks"));
assertEquals(0, new java.math.BigDecimal("70").compareTo(definition.parameters().get("range")));
assertEquals(0, new java.math.BigDecimal("11").compareTo(definition.parameters().get("lifetime")));
assertEquals(0, new java.math.BigDecimal("4").compareTo(definition.parameters().get("full-projectile-speed")));
assertEquals(java.math.BigDecimal.ONE, games.dmg.fantasystick.ability.Longbow.power(30, 30));
assertEquals(0, games.dmg.fantasystick.ability.Longbow.power(15, 30)
.compareTo(new java.math.BigDecimal("0.4166666666666666666666666666666667")));
assertEquals(BigInteger.valueOf(7), games.dmg.fantasystick.ability.Longbow.fullDamage(BigInteger.valueOf(14)));
assertEquals(BigInteger.TEN.pow(500).subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(5)),
games.dmg.fantasystick.ability.Longbow.fullDamage(BigInteger.TEN.pow(500)));
var unavailable = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(unavailable.core.id, unavailable.player); f.activate(unavailable);
unavailable.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, unavailable);
assertTrue(PartyBookNativeTest.text(unavailable).replaceAll("\\s+", "").contains("Unavailable:requirements"));
assertThrows(AssertionError.class, () -> abilityButton(unavailable, "[Learn]", "longbow"));
var sender = new EquipmentAdminCommandNativeTest.Sender();
assertEquals(java.util.List.of("config", "inspect", "recover"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
assertEquals(java.util.List.of("config"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"c"}));
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"unknown"}).isEmpty());
assertEquals(java.util.List.of("longbow-base-damage"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "longbow-b"}));
assertEquals(7, f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "longbow-"}).size());
assertEquals(java.util.List.of("5", "8"), f.command.tabComplete(sender.port, "fantasystick",
new String[]{"config", "longbow-base-damage", ""}));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "longbow-base-damage"});
assertTrue(sender.messages.getLast().contains("longbow-base-damage=8"));
sender.allowed = false;
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "longbow"}).isEmpty());
f.command.execute(sender.port, "fantasystick", new String[]{"config", "longbow-base-damage", "9"});
assertTrue(sender.messages.getLast().contains("Permission denied"), sender.messages.toString()); sender.allowed = true;
f.command.execute(sender.port, "fantasystick", new String[]{"config", "longbow-base-damage", "9"});
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
assertEquals(new java.math.BigDecimal("9"), registry.find("longbow").orElseThrow()
.parameters().get("base-damage"));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "longbow-base-damage", "-1"});
assertTrue(sender.messages.getLast().contains("invalid"));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "longbow-base-damage", "9", "extra"});
assertTrue(sender.messages.getLast().contains("Usage"));
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
var reloaded = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
assertEquals(new java.math.BigDecimal("9"), reloaded.find("longbow").orElseThrow()
.parameters().get("base-damage"));
}
for (String invalid : List.of("longbow-base-damage: -1\n", "longbow-recovery-seconds: 0.5\n",
"longbow-minimum-draw-ticks: 0\n", "longbow-full-draw-ticks: 2\n",
"longbow-range-blocks: 0\n", "longbow-lifetime-seconds: 0\n",
"longbow-full-projectile-speed-blocks-per-tick: 0\n")) {
var directory = data.resolve(Integer.toHexString(invalid.hashCode()));
java.nio.file.Files.createDirectories(directory); java.nio.file.Files.writeString(directory.resolve("config.yml"), invalid);
try (var f = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
assertThrows(IllegalArgumentException.class, () -> f.plugin.setEnabled(true));
assertTrue(f.order.isEmpty()); assertTrue(f.recipes.isEmpty());
}
}
}
private record DamageTarget(LivingEntity entity, double[] amount) { }
private static DamageTarget targetRecordingDamage(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor, Arrow arrow) {
var target = mock(LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getLocation()).thenReturn(new Location(actor.world, 3, 65.6, 0));
var amount = new double[1];
doAnswer(call -> {
amount[0] = call.getArgument(0); var source = call.getArgument(1, DamageSource.class);
fixture.dispatcher.accept(new EntityDamageByEntityEvent(arrow, target,
EntityDamageEvent.DamageCause.PROJECTILE, source, amount[0])); return null;
}).when(target).damage(anyDouble(), any(DamageSource.class));
return new DamageTarget(target, amount);
}
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[Learn]", "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,
FantasyStickLifecycleNativeTest.Actor actor, int ticks) {
actor.core.inventory.getInventory().setSelectedSlot(2);
var eye = new Location(actor.world, 0, 65.6, 0); eye.setDirection(new Vector(1, 0, 0));
doReturn(eye).when(actor.player).getEyeLocation();
var bow = actor.core.inventory.getItem(2).clone();
fixture.dispatcher.accept(new PlayerInteractEvent(actor.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
bow, null, org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND));
fixture.dispatcher.accept(new io.papermc.paper.event.player.PlayerStopUsingItemEvent(actor.player, bow, ticks));
}
private static Arrow arrow(FantasyStickLifecycleNativeTest.Actor actor, Location location) {
var arrow = PartyCombatNativeTest.entity(Arrow.class);
when(arrow.getShooter()).thenReturn(actor.player); when(arrow.getLocation()).thenReturn(location);
when(arrow.getWorld()).thenReturn(actor.world); return arrow;
}
private static ClickEvent.Custom abilityButton(FantasyStickLifecycleNativeTest.Actor actor, String label, String ability) {
var book = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
assertNotNull(book);
for (var page : book.pages()) {
var found = abilityButton(page.raw(), label, ability);
if (found != null) return found;
}
throw new AssertionError("Missing " + label + " action for " + ability);
}
private static ClickEvent.Custom abilityButton(net.minecraft.network.chat.Component component,
String label, String ability) {
if (component.getString().equals(label)
&& component.getStyle().getClickEvent() instanceof ClickEvent.Custom custom
&& custom.payload().orElse(null) instanceof net.minecraft.nbt.StringTag payload
&& payload.value().contains(":" + ability)) return custom;
for (var child : component.getSiblings()) {
var found = abilityButton(child, label, ability);
if (found != null) return found;
}
return null;
}
private static void withStats(Path data, FantasyStickLifecycleNativeTest.Actor actor,
int intelligence, int dexterity) 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.INT, BigInteger.valueOf(intelligence));
stats.put(Stat.DEX, BigInteger.valueOf(dexterity));
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
old.level(), old.experience(), BigInteger.valueOf(40), old.health(), old.learned(), old.loadout(),
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
}
private static void withLongbow(Path data, FantasyStickLifecycleNativeTest.Actor actor, int dexterity) throws Exception {
withStats(data, actor, 0, dexterity);
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().subtract(BigInteger.ONE), old.health(),
java.util.Set.of("longbow"), List.of("longbow", "", "", "", "", "", ""), old.cooldowns(),
old.restrictedKills(), false, null, null), old.revision());
}
}
@@ -0,0 +1,121 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Path;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class NutritionNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void exitEatingAndReentryCannotHealTheSavedCharacterOrLoseTheNewOrdinaryNutrition() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "character-swap-cooldown-seconds: 0\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player);
a.player.setHealth(12); a.player.setFoodLevel(7); a.player.setSaturation(2); a.player.setExhaustion(3);
f.activate(a); a.player.setHealth(4);
var sheet = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a); f.awaitCharacterAndReceipt(a);
assertEquals(12, a.player.getHealth()); assertEquals(7, a.player.getFoodLevel());
a.player.setHealth(a.maximumHealth.getValue());
a.player.setFoodLevel(20); a.player.setSaturation(12); a.player.setExhaustion(0.5f);
a.core.inventory.setItemInOffHand(sheet); f.activate(a);
assertEquals(4, a.player.getHealth(), "Ordinary eating/healing cannot refill transferable fantasy health");
assertEquals(20, a.player.getFoodLevel()); assertEquals(0, a.player.getExhaustion());
a.core.inventory.setItemInOffHand(null); f.changed(a); f.awaitCharacterAndReceipt(a);
assertEquals(a.maximumHealth.getValue(), a.player.getHealth());
assertEquals(20, a.player.getFoodLevel()); assertEquals(12, a.player.getSaturation());
assertEquals(0.5f, a.player.getExhaustion());
var characters = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertEquals(0, characters.find(a.core.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS)
.orElseThrow().health().compareTo(java.math.BigDecimal.valueOf(4)));
}
}
@Test void failedFantasyFoodSetterRestoresTheCapturedOrdinaryNutritionAndRetainsRecoveryProof() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player);
a.player.setFoodLevel(7); a.player.setSaturation(2); a.player.setExhaustion(3);
a.foodMutation = () -> {
a.foodMutation = () -> {};
throw new IllegalStateException("Injected food setter failure");
};
f.activate(a);
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
assertTrue(sessions.active(a.core.id).isEmpty());
assertEquals(7, a.player.getFoodLevel()); assertEquals(2, a.player.getSaturation());
assertEquals(3, a.player.getExhaustion());
assertNotNull(f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS)
.orElseThrow().pending());
}
}
@Test void fantasyDefeatRestoresOrdinaryNutritionInsteadOfLeavingTheFullFantasyState() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player);
a.player.setFoodLevel(7); a.player.setSaturation(2); a.player.setExhaustion(3);
f.activate(a);
assertEquals(20, a.player.getFoodLevel()); assertEquals(0, a.player.getExhaustion());
var source = new org.bukkit.craftbukkit.damage.CraftDamageSource(new net.minecraft.world.damagesource.DamageSource(
org.bukkit.craftbukkit.damage.CraftDamageType.bukkitToMinecraftHolder(org.bukkit.damage.DamageType.OUT_OF_WORLD)));
var death = new org.bukkit.event.entity.PlayerDeathEvent(a.player, source, new java.util.ArrayList<>(), 0,
net.kyori.adventure.text.Component.empty(), true);
f.dispatcher.accept(death); PartyBookNativeTest.drain(f);
assertTrue(death.isCancelled());
assertEquals(7, a.player.getFoodLevel()); assertEquals(2, a.player.getSaturation());
assertEquals(3, a.player.getExhaustion());
}
}
@Test void activeFantasyModeKeepsFoodFullBlocksFoodHealingAndRestoresOrdinaryNutrition() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var ordinary = new FantasyStickLifecycleNativeTest.Actor(data);
ordinary.core.inventory.setItemInOffHand(null);
f.players.put(a.core.id, a.player); f.players.put(ordinary.core.id, ordinary.player);
a.player.setFoodLevel(7); a.player.setSaturation(2); a.player.setExhaustion(3);
ordinary.player.setFoodLevel(8); ordinary.player.setExhaustion(4);
f.activate(a);
assertEquals(20, a.player.getFoodLevel()); assertEquals(0, a.player.getExhaustion());
var drain = new org.bukkit.event.entity.FoodLevelChangeEvent(a.player, 19);
var exhaustion = new org.bukkit.event.entity.EntityExhaustionEvent(a.player,
org.bukkit.event.entity.EntityExhaustionEvent.ExhaustionReason.SPRINT, 0.1f);
var foodHealing = new org.bukkit.event.entity.EntityRegainHealthEvent(a.player, 1,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.SATIATED);
var potionHealing = new org.bukkit.event.entity.EntityRegainHealthEvent(a.player, 1,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.MAGIC);
var abilityHealing = new org.bukkit.event.entity.EntityRegainHealthEvent(a.player, 1,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.CUSTOM);
f.dispatcher.accept(drain); f.dispatcher.accept(exhaustion);
f.dispatcher.accept(foodHealing); f.dispatcher.accept(potionHealing); f.dispatcher.accept(abilityHealing);
assertTrue(drain.isCancelled()); assertTrue(exhaustion.isCancelled()); assertTrue(foodHealing.isCancelled());
assertFalse(potionHealing.isCancelled(), "Explicit native magic healing remains available");
assertFalse(abilityHealing.isCancelled(), "Explicit ability healing remains available");
assertEquals(20, a.player.getFoodLevel()); assertEquals(0, a.player.getExhaustion());
var ordinaryDrain = new org.bukkit.event.entity.FoodLevelChangeEvent(ordinary.player, 7);
var ordinaryHealing = new org.bukkit.event.entity.EntityRegainHealthEvent(ordinary.player, 1,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.SATIATED);
f.dispatcher.accept(ordinaryDrain); f.dispatcher.accept(ordinaryHealing);
assertFalse(ordinaryDrain.isCancelled()); assertFalse(ordinaryHealing.isCancelled());
assertEquals(8, ordinary.player.getFoodLevel()); assertEquals(4, ordinary.player.getExhaustion());
a.core.inventory.setItemInOffHand(null); f.changed(a); f.awaitCharacterAndReceipt(a);
assertEquals(7, a.player.getFoodLevel()); assertEquals(2, a.player.getSaturation());
assertEquals(3, a.player.getExhaustion());
}
}
}
@@ -234,7 +234,7 @@ class PartyBookNativeTest {
open(f, a); var oldDisband = button(a, "[Disband party]");
var sheet = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a);
f.receipts().find(a.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS);
f.awaitCharacterAndReceipt(a);
b.core.inventory.setItemInOffHand(sheet); f.activate(b);
open(f, b);
assertTrue(text(b).contains(a.core.record.name()));
@@ -0,0 +1,308 @@
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.Stat;
import java.math.BigInteger;
import java.nio.file.Path;
import java.util.EnumMap;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import net.minecraft.network.chat.ClickEvent;
import org.bukkit.Location;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.util.Vector;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class QuickstepNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void bookAcquisitionEquippedUseAppliesAndStopsOwnedHorizontalVelocityAtFourBlocks() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStats(data, a, 12, false);
f.players.put(a.core.id, a.player); f.activate(a);
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"));
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());
var velocity = prepareMovement(a, new Vector(0, 0.25, 0));
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(new Vector(1, 0.25, 0), velocity.get());
verify(a.player, never()).setFallDistance(anyFloat());
assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("quickstep"));
var from = new Location(a.world, 0, 64, 0); var to = new Location(a.world, 4, 64, 0);
f.dispatcher.accept(new PlayerMoveEvent(a.player, from, to));
assertEquals(new Vector(0, 0.25, 0), velocity.get(), "Only remaining owned horizontal motion is removed");
}
}
@Test void airborneFlyingGlidingOrMountedUseRefusesBeforeCooldownAndMovement() throws Exception {
for (int route = 0; route < 4; route++) try (var f = new FantasyStickLifecycleNativeTest.Fixture(data.resolve(Integer.toString(route)))) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data.resolve(Integer.toString(route)));
withStats(data.resolve(Integer.toString(route)), a, 12, true);
f.players.put(a.core.id, a.player); f.activate(a);
var velocity = prepareMovement(a, new Vector(0.3, 0.4, 0.5));
doReturn(route != 0).when(a.player).isOnGround();
doReturn(route == 1).when(a.player).isFlying();
doReturn(route == 2).when(a.player).isGliding();
doReturn(route == 3).when(a.player).isInsideVehicle();
use(f, a);
assertEquals(new Vector(0.3, 0.4, 0.5), velocity.get());
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("quickstep"));
assertTrue(a.messages.stream().anyMatch(message -> message.contains("not eligible")));
}
}
@Test void velocityCancellationObstacleAndUnrelatedReplacementAreAuthoritative() throws Exception {
for (int route = 0; route < 3; route++) try (var f = new FantasyStickLifecycleNativeTest.Fixture(data.resolve("motion-" + route))) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data.resolve("motion-" + route));
withStats(data.resolve("motion-" + route), a, 12, true);
f.players.put(a.core.id, a.player); f.activate(a);
var velocity = prepareMovement(a, new Vector(0, 0.3, 0));
if (route == 0) {
class Veto implements org.bukkit.event.Listener {
@org.bukkit.event.EventHandler public void velocity(org.bukkit.event.player.PlayerVelocityEvent event) {
if (event.getPlayer() == a.player) event.setCancelled(true);
}
}
f.dispatcher.register(new Veto(), f.plugin);
}
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("quickstep"));
if (route == 0) {
assertEquals(new Vector(0, 0.3, 0), velocity.get(), "Cancelled velocity applies no movement");
} else if (route == 1) {
velocity.set(new Vector(0, 0.3, 0));
f.dispatcher.accept(new PlayerMoveEvent(a.player, new Location(a.world, 0, 64, 0),
new Location(a.world, 0.2, 64, 0)));
assertEquals(new Vector(0, 0.3, 0), velocity.get(), "Native collision stops movement early");
} else {
velocity.set(new Vector(0, 0.6, 2));
f.dispatcher.accept(new PlayerMoveEvent(a.player, new Location(a.world, 0, 64, 0),
new Location(a.world, 0.2, 64, 0)));
assertEquals(new Vector(0, 0.6, 2), velocity.get(), "Replacement movement relinquishes ownership");
}
verify(a.player, never()).setFallDistance(anyFloat());
}
}
@Test void unlearnRelearnAndTransferRetainCooldownAndClearOnlyOwnedHorizontalMotion() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStats(data, a, 12, true);
f.players.put(a.core.id, a.player); f.activate(a); var velocity = prepareMovement(a, new Vector(0, 0.4, 0));
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var deadline = storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("quickstep"); assertNotNull(deadline);
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
var unlearn = abilityButton(a, "[Unlearn]", "quickstep");
PartyBookNativeTest.click(f, a, unlearn); PartyBookNativeTest.click(f, a, unlearn);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var refunded = storage.find(a.core.record.id()).get().orElseThrow();
assertEquals(BigInteger.valueOf(40), refunded.unspentPoints()); assertEquals(deadline, refunded.cooldowns().get("quickstep"));
assertEquals(new Vector(0, 0.4, 0), velocity.get());
learnAndAssign(f, a); use(f, a);
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
assertEquals(new Vector(0, 0.4, 0), velocity.get());
var sheet = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
var b = new FantasyStickLifecycleNativeTest.Actor(data); b.core.inventory.setItemInOffHand(sheet);
f.players.put(b.core.id, b.player); f.activate(b); prepareMovement(b, new Vector(0.2, 0.3, 0.4)); use(f, b);
assertTrue(b.messages.stream().anyMatch(message -> message.contains("ready in")));
}
}
@Test void failedCooldownPersistenceAppliesNoVelocity() {
var storage = mock(CharacterStorage.class);
var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class);
var parties = new games.dmg.fantasystick.party.PartyRoster(java.time.Clock.systemUTC(), ignored -> true);
var registry = new games.dmg.fantasystick.ability.AbilityRegistry();
var quickstep = new games.dmg.fantasystick.ability.Quickstep(
games.dmg.fantasystick.ability.Quickstep.Settings.defaults(), ignored -> {});
registry.register(quickstep.definition());
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var stats = new EnumMap<>(base.stats()); stats.put(Stat.DEX, BigInteger.valueOf(12));
var record = new CharacterRecord(base.id(), base.name(), base.revision(), stats, base.level(), base.experience(),
base.unspentPoints(), base.health(), java.util.Set.of("quickstep"),
List.of("quickstep", "", "", "", "", "", ""), base.cooldowns(), base.restrictedKills(), false, null, null);
var active = new games.dmg.fantasystick.session.EquipmentSessions.Active(java.util.UUID.randomUUID(),
java.util.UUID.randomUUID(), record.id());
var player = mock(org.bukkit.entity.Player.class);
when(sessions.current(active)).thenReturn(true); when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record));
doReturn(true).when(player).isOnGround(); doReturn(false).when(player).isFlying();
doReturn(false).when(player).isGliding(); doReturn(false).when(player).isInsideVehicle();
var eye = new Location(mock(org.bukkit.World.class), 0, 0, 0); eye.setDirection(new Vector(1, 0, 0));
doReturn(eye).when(player).getEyeLocation();
var failed = new java.util.concurrent.CompletableFuture<CharacterStorage.AbilityMutation>();
failed.completeExceptionally(new java.io.IOException("simulated Quickstep cooldown failure"));
when(storage.commitAbilityCooldown(eq(record.id()), eq(record.revision()), eq("quickstep"), any())).thenReturn(failed);
var failures = new java.util.ArrayList<games.dmg.fantasystick.ability.AbilityEngine.Failure>();
var engine = new games.dmg.fantasystick.ability.AbilityEngine(registry, storage, sessions, parties,
java.time.Clock.systemUTC(), Runnable::run, (delay, action) -> () -> {}, ignored -> {}, failures::add);
engine.accept(new games.dmg.fantasystick.session.ControlInput(player, active, 2, "quickstep",
games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,
games.dmg.fantasystick.session.ControlInput.Gesture.USE, null, 0, 0, () -> true));
verify(player, never()).setVelocity(any(Vector.class)); assertEquals(1, failures.size());
engine.close(); quickstep.close(); parties.close();
}
@Test void acknowledgedUpdatesAffectFutureDashesWhileActiveDashKeepsCapturedBounds() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStats(data, a, 12, true);
f.players.put(a.core.id, a.player); f.activate(a); var oldVelocity = prepareMovement(a, new Vector(0, 0.2, 0));
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var sender = new EquipmentAdminCommandNativeTest.Sender();
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quickstep-distance-blocks", "7"});
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quickstep-speed-blocks-per-tick", "2"});
for (int i = 0; i < 2; i++) {
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
}
f.dispatcher.accept(new PlayerMoveEvent(a.player, new Location(a.world, 0, 64, 0),
new Location(a.world, 4, 64, 0)));
assertEquals(new Vector(0, 0.2, 0), oldVelocity.get(), "Active dash retains its four-block snapshot");
var b = new FantasyStickLifecycleNativeTest.Actor(data); withStats(data, b, 12, true);
f.players.put(b.core.id, b.player); f.activate(b); var newVelocity = prepareMovement(b, new Vector(0, 0.3, 0));
use(f, b); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(new Vector(2, 0.3, 0), newVelocity.get(), "Future dash uses acknowledged speed");
}
}
@Test void configuredDefinitionAdminRoutesAndStartupValidationCoverEveryQuickstepSetting() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"quickstep-cooldown-seconds: 2\nquickstep-distance-blocks: 6\nquickstep-speed-blocks-per-tick: 1.5\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
var definition = registry.find("quickstep").orElseThrow();
assertEquals(java.time.Duration.ofSeconds(2), definition.cooldown());
assertEquals(0, new java.math.BigDecimal("6").compareTo(definition.parameters().get("distance")));
assertEquals(0, new java.math.BigDecimal("1.5").compareTo(definition.parameters().get("speed")));
var sender = new EquipmentAdminCommandNativeTest.Sender();
assertEquals(List.of("config", "inspect", "recover"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
assertEquals(List.of("config"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"co"}));
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"unsupported"}).isEmpty());
sender.allowed = false;
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "quickstep"}).isEmpty());
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quickstep-distance-blocks", "7"});
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
assertEquals(List.of("quickstep-cooldown-seconds", "quickstep-distance-blocks", "quickstep-speed-blocks-per-tick"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "quickstep-"}));
assertEquals(List.of("4", "6"), f.command.tabComplete(sender.port, "fantasystick",
new String[]{"config", "quickstep-distance-blocks", ""}));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quickstep-distance-blocks"});
assertTrue(sender.messages.getLast().contains("=6"));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quickstep-distance-blocks", "7"});
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
assertEquals(0, new java.math.BigDecimal("7").compareTo(registry.find("quickstep").orElseThrow()
.parameters().get("distance")));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quickstep-distance-blocks", "0"});
assertTrue(sender.messages.getLast().contains("invalid"));
f.command.execute(sender.port, "fantasystick",
new String[]{"config", "quickstep-distance-blocks", "7", "extra"});
assertTrue(sender.messages.getLast().contains("Usage"));
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
var reloaded = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
assertEquals(0, new java.math.BigDecimal("7").compareTo(reloaded.find("quickstep").orElseThrow()
.parameters().get("distance")));
}
for (String invalid : List.of("quickstep-cooldown-seconds: -1\n", "quickstep-distance-blocks: 0\n",
"quickstep-speed-blocks-per-tick: 0\n")) {
var directory = data.resolve(Integer.toHexString(invalid.hashCode()));
java.nio.file.Files.createDirectories(directory); java.nio.file.Files.writeString(directory.resolve("config.yml"), invalid);
try (var f = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
assertThrows(IllegalArgumentException.class, () -> f.plugin.setEnabled(true));
assertTrue(f.order.isEmpty()); assertTrue(f.recipes.isEmpty());
}
}
}
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[Learn]", "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) {
var velocity = new AtomicReference<>(initial.clone());
doReturn(true).when(actor.player).isOnGround(); doReturn(false).when(actor.player).isFlying();
doReturn(false).when(actor.player).isGliding(); doReturn(false).when(actor.player).isInsideVehicle();
doAnswer(ignored -> velocity.get().clone()).when(actor.player).getVelocity();
doAnswer(call -> { velocity.set(call.getArgument(0, Vector.class).clone()); return null; }).when(actor.player).setVelocity(any(Vector.class));
var eye = new Location(actor.world, 0, 65.6, 0); eye.setDirection(new Vector(1, 0, 0));
doReturn(eye).when(actor.player).getEyeLocation();
return velocity;
}
private static void use(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor) {
actor.core.inventory.getInventory().setSelectedSlot(2);
fixture.dispatcher.accept(new PlayerInteractEvent(actor.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
actor.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND));
}
private static ClickEvent.Custom abilityButton(FantasyStickLifecycleNativeTest.Actor actor, String label, String ability) {
var book = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
assertNotNull(book);
for (var page : book.pages()) {
var found = abilityButton(page.raw(), label, ability);
if (found != null) return found;
}
throw new AssertionError("Missing " + label + " action for " + ability);
}
private static ClickEvent.Custom abilityButton(net.minecraft.network.chat.Component component,
String label, String ability) {
if (component.getString().equals(label)
&& component.getStyle().getClickEvent() instanceof ClickEvent.Custom custom
&& custom.payload().orElse(null) instanceof net.minecraft.nbt.StringTag payload
&& payload.value().contains(":" + ability)) return custom;
for (var child : component.getSiblings()) {
var found = abilityButton(child, label, ability);
if (found != null) return found;
}
return null;
}
private static void withStats(Path data, FantasyStickLifecycleNativeTest.Actor actor,
int dexterity, boolean learned) throws Exception {
java.nio.file.Files.createDirectories(data);
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.DEX, BigInteger.valueOf(dexterity));
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
old.level(), old.experience(), BigInteger.valueOf(learned ? 39 : 40), old.health(),
learned ? java.util.Set.of("quickstep") : old.learned(),
learned ? List.of("quickstep", "", "", "", "", "", "") : old.loadout(),
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
}
}
@@ -0,0 +1,404 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import java.math.BigInteger;
import java.nio.file.Path;
import java.time.Duration;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ReviveNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void completedProgressionBudgetCanLearnAndAssignReviveWithoutALevelGate() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
try (var storage = new games.dmg.fantasystick.persistence.CharacterStorage(repository, java.time.Clock.systemUTC())) {
var current = storage.awardExperience(actor.core.record.id(), actor.core.record.revision(),
BigInteger.valueOf(2700), games.dmg.fantasystick.progression.ProgressionRules.defaults()).join().record().orElseThrow();
for (var purchase : java.util.List.of(
java.util.Map.entry(games.dmg.fantasystick.progression.Stat.WIS, 30),
java.util.Map.entry(games.dmg.fantasystick.progression.Stat.CHR, 24),
java.util.Map.entry(games.dmg.fantasystick.progression.Stat.CON, 12))) {
for (int count = 0; count < purchase.getValue(); count++) {
current = storage.purchaseStat(current.id(), current.revision(), purchase.getKey(), current.health(),
new java.math.BigDecimal("1024")).join().record().orElseThrow();
}
}
assertEquals(28, current.level()); assertEquals(BigInteger.ONE, current.unspentPoints());
}
fixture.plugin.setEnabled(true); fixture.players.put(actor.core.id, actor.player); fixture.activate(actor);
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
String text = PartyBookNativeTest.text(actor).replaceAll("\\s+", "");
assertTrue(text.contains("Revive")); assertTrue(text.contains("WIS30")); assertTrue(text.contains("CHR24"));
assertTrue(text.contains("CON12")); assertTrue(text.contains("Cooldown:86400s"));
PartyBookNativeTest.click(fixture, actor, PartyBookNativeTest.button(actor, "[Learn]"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
PartyBookNativeTest.click(fixture, actor, PartyBookNativeTest.button(actor, "[3]"));
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();
assertTrue(saved.learned().contains("revive")); assertEquals("revive", saved.loadout().getFirst());
assertEquals(BigInteger.ZERO, saved.unspentPoints());
assertEquals(org.bukkit.Material.ECHO_SHARD, actor.core.inventory.getItem(2).getType());
}
}
@Test void ordinaryTargetSelectsOneTailAndReceivesTheSameLivingSheetOnce() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var caster = new FantasyStickLifecycleNativeTest.Actor(data); withRevive(data, caster);
var target = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(caster.core.id, caster.player); fixture.players.put(target.core.id, target.player);
fixture.activate(caster);
assertTrue(((games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions")).active(target.core.id).isEmpty());
var original = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var death = java.util.UUID.randomUUID();
var died = java.time.Instant.now().minusSeconds(3600).truncatedTo(java.time.temporal.ChronoUnit.SECONDS);
var dead = new games.dmg.fantasystick.persistence.CharacterRecord(original.id(), "Fallen Mira", original.revision(),
original.stats(), 7, BigInteger.valueOf(642), BigInteger.valueOf(11), java.math.BigDecimal.ZERO,
java.util.Set.of("fireball"), java.util.List.of("fireball", "", "", "", "", "", ""),
java.util.Map.of("fireball", java.time.Instant.now().plusSeconds(30)), original.restrictedKills(), true, death, died);
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).create(dead);
var tail = new games.dmg.fantasystick.items.TailBooks().create(dead);
var copiedStack = tail.clone(); copiedStack.setAmount(2); target.core.inventory.setItem(10, copiedStack);
caster.core.location.setWorld(caster.world); caster.core.location.setX(0); caster.core.location.setY(64); caster.core.location.setZ(0);
target.core.location.setWorld(caster.world); target.core.location.setX(3); target.core.location.setY(64); target.core.location.setZ(0);
var eye = new org.bukkit.Location(caster.world, 0, 65.6, 0); eye.setDirection(new org.bukkit.util.Vector(1, 0, 0));
org.mockito.Mockito.doReturn(eye).when(caster.player).getEyeLocation();
org.mockito.Mockito.doReturn(true).when(caster.player).hasLineOfSight(target.player);
caster.core.inventory.getInventory().setSelectedSlot(2);
fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(caster.player, target.player,
org.bukkit.inventory.EquipmentSlot.HAND));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
assertEquals(1, target.inventories.size());
var popup = target.inventories.getFirst();
assertEquals(1, java.util.stream.IntStream.range(0, popup.getSize()).filter(slot -> {
var item = popup.getItem(slot); return item != null && item.getType() == org.bukkit.Material.WRITTEN_BOOK;
}).count());
var view = org.mockito.Mockito.mock(org.bukkit.inventory.InventoryView.class);
org.mockito.Mockito.when(view.getTopInventory()).thenReturn(popup);
org.mockito.Mockito.when(view.getBottomInventory()).thenReturn(target.core.inventory);
org.mockito.Mockito.when(view.getPlayer()).thenReturn(target.player);
var selection = new org.bukkit.event.inventory.InventoryClickEvent(view,
org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER, 0, org.bukkit.event.inventory.ClickType.LEFT,
org.bukkit.event.inventory.InventoryAction.PICKUP_ALL);
fixture.dispatcher.accept(selection); assertTrue(selection.isCancelled(), "Popup copies cannot be exported");
var revivalStore = (games.dmg.fantasystick.persistence.RevivalStore)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "revivals");
PartyBookNativeTest.drain(fixture);
assertTrue(revivalStore.find(dead.id()).join().isPresent());
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
var restored = storage.find(dead.id()).get().orElseThrow();
assertFalse(restored.dead(), "caster=" + caster.messages + ", target=" + target.messages + ", source=" + target.core.inventory.getItem(10)
+ ", reservation=" + revivalStore.find(dead.id()).join());
assertEquals(dead.id(), restored.id()); assertEquals(dead.name(), restored.name());
assertEquals(dead.stats(), restored.stats()); assertEquals(dead.level(), restored.level());
assertEquals(dead.experience(), restored.experience()); assertEquals(dead.unspentPoints(), restored.unspentPoints());
assertEquals(dead.learned(), restored.learned()); assertEquals(dead.loadout(), restored.loadout());
assertEquals(dead.cooldowns(), restored.cooldowns()); assertEquals(restored.maximumHealth(), restored.health());
assertEquals(dead.id(), new games.dmg.fantasystick.items.CharacterSheets()
.identify(target.core.inventory.getItem(1)).orElseThrow());
assertEquals(1, target.core.inventory.getItem(10).getAmount());
assertEquals(new games.dmg.fantasystick.items.TailBooks.Identity(dead.id(), dead.deathId()),
new games.dmg.fantasystick.items.TailBooks().identify(target.core.inventory.getItem(10)).orElseThrow());
assertTrue(((games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions")).active(target.core.id).isEmpty());
fixture.dispatcher.accept(new org.bukkit.event.inventory.InventoryClickEvent(view,
org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER, 0, org.bukkit.event.inventory.ClickType.LEFT,
org.bukkit.event.inventory.InventoryAction.PICKUP_ALL));
assertEquals(dead.id(), new games.dmg.fantasystick.items.CharacterSheets()
.identify(target.core.inventory.getItem(1)).orElseThrow());
assertTrue(storage.find(caster.core.record.id()).get().orElseThrow().cooldowns().get("revive")
.isAfter(java.time.Instant.now().plusSeconds(86300)));
}
}
@Test void stackedTailWithNoSafeSheetDestinationChangesNothingAndKeepsTheCooldown() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var caster = new FantasyStickLifecycleNativeTest.Actor(data); withRevive(data, caster);
var target = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(caster.core.id, caster.player); fixture.players.put(target.core.id, target.player); fixture.activate(caster);
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var death = java.util.UUID.randomUUID(); var died = java.time.Instant.now().minusSeconds(60).truncatedTo(java.time.temporal.ChronoUnit.SECONDS);
var dead = new games.dmg.fantasystick.persistence.CharacterRecord(base.id(), "Full inventory", base.revision(), base.stats(),
base.level(), base.experience(), base.unspentPoints(), java.math.BigDecimal.ZERO, base.learned(), base.loadout(),
base.cooldowns(), base.restrictedKills(), true, death, died);
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).create(dead);
for (int slot = 0; slot <= 40; slot++) if (slot <= 35 || slot == 40) {
target.core.inventory.setItem(slot, new org.bukkit.inventory.ItemStack(org.bukkit.Material.STONE));
}
var tail = new games.dmg.fantasystick.items.TailBooks().create(dead); tail.setAmount(2); target.core.inventory.setItem(10, tail);
caster.core.location.setWorld(caster.world); caster.core.location.setY(64);
target.core.location.setWorld(caster.world); target.core.location.setX(3); target.core.location.setY(64);
org.mockito.Mockito.doReturn(new org.bukkit.Location(caster.world, 0, 65.6, 0)).when(caster.player).getEyeLocation();
org.mockito.Mockito.doReturn(true).when(caster.player).hasLineOfSight(target.player);
caster.core.inventory.getInventory().setSelectedSlot(2);
fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(caster.player, target.player,
org.bukkit.inventory.EquipmentSlot.HAND));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
var popup = target.inventories.getFirst(); var view = org.mockito.Mockito.mock(org.bukkit.inventory.InventoryView.class);
org.mockito.Mockito.when(view.getTopInventory()).thenReturn(popup);
org.mockito.Mockito.when(view.getBottomInventory()).thenReturn(target.core.inventory);
org.mockito.Mockito.when(view.getPlayer()).thenReturn(target.player);
fixture.dispatcher.accept(new org.bukkit.event.inventory.InventoryClickEvent(view,
org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER, 0, org.bukkit.event.inventory.ClickType.LEFT,
org.bukkit.event.inventory.InventoryAction.PICKUP_ALL));
PartyBookNativeTest.drain(fixture);
assertEquals(tail, target.core.inventory.getItem(10));
assertTrue(((games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters")).find(dead.id()).get().orElseThrow().dead());
assertTrue(((games.dmg.fantasystick.persistence.RevivalStore)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "revivals")).find(dead.id()).join().isEmpty());
assertTrue(target.messages.stream().anyMatch(message -> message.contains("empty direct inventory slot")));
assertNotNull(((games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters"))
.find(caster.core.record.id()).get().orElseThrow().cooldowns().get("revive"));
}
}
@Test void confirmationRechecksTheExactTwentyFourHourBoundaryBeforeChangingTheTail() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var caster = new FantasyStickLifecycleNativeTest.Actor(data); withRevive(data, caster);
var target = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(caster.core.id, caster.player); fixture.players.put(target.core.id, target.player); fixture.activate(caster);
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var death = java.util.UUID.randomUUID(); var recent = java.time.Instant.now().minusSeconds(60).truncatedTo(java.time.temporal.ChronoUnit.SECONDS);
var dead = new games.dmg.fantasystick.persistence.CharacterRecord(base.id(), "Boundary", base.revision(), base.stats(),
base.level(), base.experience(), base.unspentPoints(), java.math.BigDecimal.ZERO, base.learned(), base.loadout(),
base.cooldowns(), base.restrictedKills(), true, death, recent);
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data); repository.create(dead);
var tail = new games.dmg.fantasystick.items.TailBooks().create(dead); target.core.inventory.setItem(10, tail);
caster.core.location.setWorld(caster.world); caster.core.location.setY(64);
target.core.location.setWorld(caster.world); target.core.location.setX(3); target.core.location.setY(64);
var eye = new org.bukkit.Location(caster.world, 0, 65.6, 0);
org.mockito.Mockito.doReturn(eye).when(caster.player).getEyeLocation();
org.mockito.Mockito.doReturn(true).when(caster.player).hasLineOfSight(target.player);
caster.core.inventory.getInventory().setSelectedSlot(2);
fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(caster.player, target.player,
org.bukkit.inventory.EquipmentSlot.HAND));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
var popup = target.inventories.getFirst();
var expired = new games.dmg.fantasystick.persistence.CharacterRecord(dead.id(), dead.name(), dead.revision().add(BigInteger.ONE),
dead.stats(), dead.level(), dead.experience(), dead.unspentPoints(), dead.health(), dead.learned(), dead.loadout(),
dead.cooldowns(), dead.restrictedKills(), true, dead.deathId(),
java.time.Instant.now().minusSeconds(86400).truncatedTo(java.time.temporal.ChronoUnit.SECONDS));
repository.replace(expired, dead.revision());
var view = org.mockito.Mockito.mock(org.bukkit.inventory.InventoryView.class);
org.mockito.Mockito.when(view.getTopInventory()).thenReturn(popup);
org.mockito.Mockito.when(view.getBottomInventory()).thenReturn(target.core.inventory);
org.mockito.Mockito.when(view.getPlayer()).thenReturn(target.player);
fixture.dispatcher.accept(new org.bukkit.event.inventory.InventoryClickEvent(view,
org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER, 0, org.bukkit.event.inventory.ClickType.LEFT,
org.bukkit.event.inventory.InventoryAction.PICKUP_ALL));
PartyBookNativeTest.drain(fixture);
assertEquals(tail, target.core.inventory.getItem(10));
assertTrue(((games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters")).find(dead.id()).get().orElseThrow().dead());
assertTrue(((games.dmg.fantasystick.persistence.RevivalStore)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "revivals")).find(dead.id()).join().isEmpty());
assertTrue(caster.messages.stream().anyMatch(message -> message.contains("no longer eligible")));
}
}
@Test void eyeRayRequiresAValidTargetAndEmptyResultStillConsumesTheDailyCooldown() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var caster = new FantasyStickLifecycleNativeTest.Actor(data); withRevive(data, caster);
var target = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(caster.core.id, caster.player); fixture.players.put(target.core.id, target.player);
fixture.activate(caster);
caster.core.location.setWorld(caster.world); caster.core.location.setY(64);
target.core.location.setWorld(caster.world); target.core.location.setX(12); target.core.location.setY(64);
var eye = new org.bukkit.Location(caster.world, 0, 65.6, 0); eye.setDirection(new org.bukkit.util.Vector(1, 0, 0));
org.mockito.Mockito.doReturn(eye).when(caster.player).getEyeLocation();
org.mockito.Mockito.doReturn(true).when(caster.player).hasLineOfSight(target.player);
caster.core.inventory.getInventory().setSelectedSlot(2);
var use = new org.bukkit.event.player.PlayerInteractEvent(caster.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
caster.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
fixture.dispatcher.accept(use);
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
assertFalse(storage.find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("revive"));
org.mockito.Mockito.doReturn(new org.bukkit.util.RayTraceResult(new org.bukkit.util.Vector(12, 65, 0), target.player))
.when(caster.world).rayTraceEntities(org.mockito.ArgumentMatchers.any(org.bukkit.Location.class),
org.mockito.ArgumentMatchers.any(org.bukkit.util.Vector.class), org.mockito.ArgumentMatchers.eq(12.0),
org.mockito.ArgumentMatchers.<java.util.function.Predicate<org.bukkit.entity.Entity>>any());
fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(caster.player,
org.bukkit.event.block.Action.RIGHT_CLICK_AIR, caster.core.inventory.getItem(2), null,
org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
assertTrue(storage.find(caster.core.record.id()).get().orElseThrow().cooldowns().get("revive")
.isAfter(java.time.Instant.now().plusSeconds(86300)));
assertTrue(caster.messages.stream().anyMatch(message -> message.contains("no direct-inventory Tail")));
assertTrue(target.inventories.isEmpty());
}
}
@Test void unlearningDuringFantasyTargetPopupCancelsSelectionWithoutResettingDailyDeadline() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var caster = new FantasyStickLifecycleNativeTest.Actor(data); withRevive(data, caster);
var target = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(caster.core.id, caster.player); fixture.players.put(target.core.id, target.player);
fixture.activate(caster); fixture.activate(target);
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var death = java.util.UUID.randomUUID(); var died = java.time.Instant.now().minusSeconds(60).truncatedTo(java.time.temporal.ChronoUnit.SECONDS);
var dead = new games.dmg.fantasystick.persistence.CharacterRecord(base.id(), "Pending", base.revision(), base.stats(),
base.level(), base.experience(), base.unspentPoints(), java.math.BigDecimal.ZERO, base.learned(), base.loadout(),
base.cooldowns(), base.restrictedKills(), true, death, died);
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).create(dead);
var tail = new games.dmg.fantasystick.items.TailBooks().create(dead); target.core.inventory.setItem(10, tail);
caster.core.location.setWorld(caster.world); caster.core.location.setY(64);
target.core.location.setWorld(caster.world); target.core.location.setX(3); target.core.location.setY(64);
var eye = new org.bukkit.Location(caster.world, 0, 65.6, 0);
org.mockito.Mockito.doReturn(eye).when(caster.player).getEyeLocation();
org.mockito.Mockito.doReturn(true).when(caster.player).hasLineOfSight(target.player);
caster.core.inventory.getInventory().setSelectedSlot(2);
fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(caster.player, target.player,
org.bukkit.inventory.EquipmentSlot.HAND));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
assertEquals(1, target.inventories.size()); var popup = target.inventories.getFirst();
assertTrue(fixture.delayedTicks.contains(6000L), "The popup expires at exactly five real-time minutes");
var view = org.mockito.Mockito.mock(org.bukkit.inventory.InventoryView.class);
org.mockito.Mockito.when(view.getTopInventory()).thenReturn(popup);
org.mockito.Mockito.when(view.getBottomInventory()).thenReturn(target.core.inventory);
org.mockito.Mockito.when(view.getPlayer()).thenReturn(target.player);
caster.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, caster);
PartyBookNativeTest.click(fixture, caster, PartyBookNativeTest.button(caster, "[Unlearn]"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
var refunded = storage.find(caster.core.record.id()).get().orElseThrow();
var deadline = refunded.cooldowns().get("revive"); assertNotNull(deadline); assertFalse(refunded.learned().contains("revive"));
assertTrue(java.util.stream.IntStream.range(0, popup.getSize()).allMatch(slot -> popup.getItem(slot) == null));
fixture.dispatcher.accept(new org.bukkit.event.inventory.InventoryClickEvent(view,
org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER, 0, org.bukkit.event.inventory.ClickType.LEFT,
org.bukkit.event.inventory.InventoryAction.PICKUP_ALL));
assertTrue(storage.find(dead.id()).get().orElseThrow().dead()); assertEquals(tail, target.core.inventory.getItem(10));
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));
PartyBookNativeTest.drain(fixture);
assertEquals(1, target.inventories.size(), "Relearning cannot bypass the original daily deadline");
assertEquals(deadline, storage.find(caster.core.record.id()).get().orElseThrow().cooldowns().get("revive"));
var transferred = caster.core.inventory.getItemInOffHand().clone();
caster.core.inventory.setItemInOffHand(null); fixture.changed(caster);
PartyBookNativeTest.drain(fixture); fixture.awaitCharacterAndReceipt(caster);
var current = storage.find(caster.core.record.id()).get().orElseThrow();
var renamed = storage.renameCharacter(current.id(), "Renamed daily caster").get().orElseThrow();
var nextHolder = new FantasyStickLifecycleNativeTest.Actor(data); nextHolder.core.inventory.setItemInOffHand(transferred);
fixture.players.put(nextHolder.core.id, nextHolder.player); fixture.activate(nextHolder);
nextHolder.core.inventory.getInventory().setSelectedSlot(2);
fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(nextHolder.player, target.player,
org.bukkit.inventory.EquipmentSlot.HAND));
PartyBookNativeTest.drain(fixture);
assertTrue(nextHolder.messages.stream().anyMatch(message -> message.contains("ready in")));
assertEquals("Renamed daily caster", renamed.name()); assertEquals(deadline, renamed.cooldowns().get("revive"));
}
}
@Test void startupReconciliationCompletesOnlyAProvedDeliveredSheetAndClearsAnUnchangedTail() throws Exception {
var target = new FantasyStickLifecycleNativeTest.Actor(data); target.core.inventory.setItemInOffHand(null);
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var death = java.util.UUID.randomUUID(); var died = java.time.Instant.now().minusSeconds(60).truncatedTo(java.time.temporal.ChronoUnit.SECONDS);
var dead = new games.dmg.fantasystick.persistence.CharacterRecord(base.id(), "Interrupted", base.revision(), base.stats(),
base.level(), base.experience(), base.unspentPoints(), java.math.BigDecimal.ZERO, base.learned(), base.loadout(),
base.cooldowns(), base.restrictedKills(), true, death, died);
repository.create(dead);
var provisional = new games.dmg.fantasystick.persistence.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());
target.core.inventory.setItem(10, new games.dmg.fantasystick.items.CharacterSheets().create(provisional));
var reservation = new games.dmg.fantasystick.persistence.RevivalStore.Reservation(java.util.UUID.randomUUID(),
java.util.UUID.randomUUID(), target.core.id, dead.id(), death, java.time.Instant.now().truncatedTo(java.time.temporal.ChronoUnit.SECONDS),
10, 1, -1);
try (var store = new games.dmg.fantasystick.persistence.RevivalStore(data)) { store.reserve(reservation).join(); }
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.players.put(target.core.id, target.player); fixture.plugin.setEnabled(true);
var store = (games.dmg.fantasystick.persistence.RevivalStore)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "revivals");
store.target(target.core.id).join(); PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
assertFalse(storage.find(dead.id()).get().orElseThrow().dead());
assertTrue(store.find(dead.id()).join().isEmpty());
assertEquals(dead.id(), new games.dmg.fantasystick.items.CharacterSheets()
.identify(target.core.inventory.getItem(10)).orElseThrow());
var secondBase = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var secondDeath = java.util.UUID.randomUUID();
var second = new games.dmg.fantasystick.persistence.CharacterRecord(secondBase.id(), "Not delivered", secondBase.revision(),
secondBase.stats(), secondBase.level(), secondBase.experience(), secondBase.unspentPoints(), java.math.BigDecimal.ZERO,
secondBase.learned(), secondBase.loadout(), secondBase.cooldowns(), secondBase.restrictedKills(), true, secondDeath, died);
repository.create(second); var tail = new games.dmg.fantasystick.items.TailBooks().create(second); target.core.inventory.setItem(12, tail);
var untouched = new games.dmg.fantasystick.persistence.RevivalStore.Reservation(java.util.UUID.randomUUID(),
java.util.UUID.randomUUID(), target.core.id, second.id(), secondDeath,
java.time.Instant.now().truncatedTo(java.time.temporal.ChronoUnit.SECONDS), 12, 1, -1);
store.reserve(untouched).join();
((games.dmg.fantasystick.ability.Revive) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "revive")).reconcile(target.player);
store.target(target.core.id).join(); PartyBookNativeTest.drain(fixture);
assertTrue(storage.find(second.id()).get().orElseThrow().dead());
assertEquals(tail, target.core.inventory.getItem(12)); assertTrue(store.find(second.id()).join().isEmpty());
}
}
private static void withRevive(Path data, FantasyStickLifecycleNativeTest.Actor actor) 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.WIS, BigInteger.valueOf(30));
stats.put(games.dmg.fantasystick.progression.Stat.CHR, BigInteger.valueOf(24));
stats.put(games.dmg.fantasystick.progression.Stat.CON, BigInteger.valueOf(12));
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE),
stats, old.level(), old.experience(), old.unspentPoints(), old.maximumHealth(), java.util.Set.of("revive"),
java.util.List.of("revive", "", "", "", "", "", ""), old.cooldowns(), old.restrictedKills(), false, null, null);
repository.replace(saved, old.revision());
}
@Test void packagedDefinitionMatchesReviewedReviveContract() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry");
var revive = registry.find("revive").orElseThrow();
assertEquals(org.bukkit.Material.ECHO_SHARD, revive.icon());
assertEquals(BigInteger.ONE, revive.pointCost());
assertEquals(BigInteger.valueOf(30), revive.requirements().get(games.dmg.fantasystick.progression.Stat.WIS));
assertEquals(BigInteger.valueOf(24), revive.requirements().get(games.dmg.fantasystick.progression.Stat.CHR));
assertEquals(BigInteger.valueOf(12), revive.requirements().get(games.dmg.fantasystick.progression.Stat.CON));
assertEquals(Duration.ofHours(24), revive.cooldown());
assertEquals(games.dmg.fantasystick.session.ControlInput.Gesture.USE, revive.gesture());
assertEquals(games.dmg.fantasystick.ability.AbilityDefinition.Activation.SELECTION, revive.activation());
}
}
}
@@ -0,0 +1,214 @@
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.Stat;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.file.Path;
import java.util.EnumMap;
import java.util.List;
import net.minecraft.network.chat.ClickEvent;
import org.bukkit.event.entity.EntityRegainHealthEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class SecondWindNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void bookAcquisitionActualNativeHealPersistsBeforePublicationAndRetainsCooldown() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withSecondWind(data, a, false, BigDecimal.valueOf(4));
f.players.put(a.core.id, a.player); f.activate(a);
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
String text = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
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");
var saved = storage.find(a.core.record.id()).get().orElseThrow();
assertEquals(0, BigDecimal.valueOf(11).compareTo(saved.health())); assertEquals(11, a.player.getHealth());
assertNotNull(saved.cooldowns().get("second_wind"));
assertFalse(a.messages.stream().anyMatch(message -> message.contains("support")));
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);
assertEquals(11, a.player.getHealth(), "Persisted healing survives exit and resume");
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
var unlearn = abilityButton(a, "[Unlearn]", "second_wind");
PartyBookNativeTest.click(f, a, unlearn); PartyBookNativeTest.click(f, a, unlearn);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var refunded = storage.find(a.core.record.id()).get().orElseThrow();
assertEquals(BigInteger.valueOf(40), refunded.unspentPoints());
assertEquals(saved.cooldowns().get("second_wind"), refunded.cooldowns().get("second_wind"));
learnAndAssign(f, a); use(f, a);
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
}
}
@Test void fullHealthAndCancelledNativeHealingApplyNoHealth() throws Exception {
for (boolean full : new boolean[]{true, false}) try (var f = new FantasyStickLifecycleNativeTest.Fixture(data.resolve(Boolean.toString(full)))) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data.resolve(Boolean.toString(full)));
withSecondWind(data.resolve(Boolean.toString(full)), a, true, BigDecimal.valueOf(full ? 22 : 4));
f.players.put(a.core.id, a.player); f.activate(a);
if (!full) {
class Veto implements org.bukkit.event.Listener {
@org.bukkit.event.EventHandler public void heal(EntityRegainHealthEvent event) {
if (event.getEntity() == a.player && event.getRegainReason() == EntityRegainHealthEvent.RegainReason.CUSTOM) {
event.setCancelled(true);
}
}
}
f.dispatcher.register(new Veto(), f.plugin);
}
double before = a.player.getHealth(); use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(before, a.player.getHealth());
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var saved = storage.find(a.core.record.id()).get().orElseThrow();
if (full) assertFalse(saved.cooldowns().containsKey("second_wind"));
else assertNotNull(saved.cooldowns().get("second_wind"));
}
}
@Test void failedHealthPersistencePublishesNoNativeHealing() {
var characters = mock(CharacterStorage.class);
var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class);
var player = mock(org.bukkit.entity.Player.class);
var attribute = mock(org.bukkit.attribute.AttributeInstance.class);
when(player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).thenReturn(attribute);
when(attribute.getValue()).thenReturn(22.0); when(player.getHealth()).thenReturn(4.0);
var stats = new EnumMap<Stat, BigInteger>(Stat.class);
for (Stat stat : Stat.values()) stats.put(stat, BigInteger.ZERO);
stats.put(Stat.CON, BigInteger.valueOf(12)); stats.put(Stat.WIS, BigInteger.valueOf(12));
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var record = new CharacterRecord(base.id(), base.name(), BigInteger.ONE, stats, base.level(), base.experience(),
base.unspentPoints(), BigDecimal.valueOf(4), java.util.Set.of("second_wind"),
List.of("second_wind", "", "", "", "", "", ""), base.cooldowns(), base.restrictedKills(), false, null, null);
var input = mock(games.dmg.fantasystick.session.ControlInput.class);
var active = new games.dmg.fantasystick.session.EquipmentSessions.Active(java.util.UUID.randomUUID(),
java.util.UUID.randomUUID(), record.id());
when(input.player()).thenReturn(player); when(input.session()).thenReturn(active);
var execution = mock(games.dmg.fantasystick.ability.AbilityExecution.class);
var effect = new games.dmg.fantasystick.ability.SecondWind(
games.dmg.fantasystick.ability.SecondWind.Settings.defaults(), characters, sessions, Runnable::run,
ignored -> {}, () -> BigDecimal.valueOf(1024));
when(execution.stillCurrent()).thenReturn(true); when(execution.input()).thenReturn(input);
when(execution.character()).thenReturn(record); when(execution.definition()).thenReturn(effect.definition());
var failure = new java.util.concurrent.CompletableFuture<java.util.Optional<CharacterRecord>>();
when(characters.saveHealth(eq(record.id()), eq(record.revision()), any(), any())).thenReturn(failure);
effect.activate(execution); verify(player, never()).setHealth(anyDouble());
failure.completeExceptionally(new java.io.IOException("simulated health save failure"));
verify(player, never()).setHealth(anyDouble()); verify(execution).finish();
}
@Test void configuredDefinitionFormulaAndAdminRoutesCoverBothSettings() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"second-wind-base-healing: 8\nsecond-wind-cooldown-seconds: 20\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
var definition = registry.find("second_wind").orElseThrow();
assertEquals(java.time.Duration.ofSeconds(20), definition.cooldown());
assertEquals(new BigDecimal("8"), definition.parameters().get("base-healing"));
assertEquals(BigInteger.valueOf(7), games.dmg.fantasystick.ability.SecondWind.healing(BigInteger.valueOf(12)));
assertEquals(BigInteger.TEN.pow(500).subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(6)),
games.dmg.fantasystick.ability.SecondWind.healing(BigInteger.TEN.pow(500)));
var sender = new EquipmentAdminCommandNativeTest.Sender();
assertEquals(List.of("config", "inspect", "recover"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
sender.allowed = false;
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "second"}).isEmpty());
f.command.execute(sender.port, "fantasystick", new String[]{"config", "second-wind-base-healing", "9"});
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
assertEquals(List.of("second-wind-base-healing", "second-wind-cooldown-seconds"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "second-wind-"}));
assertEquals(List.of("6", "8"), f.command.tabComplete(sender.port, "fantasystick",
new String[]{"config", "second-wind-base-healing", ""}));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "second-wind-base-healing"});
assertTrue(sender.messages.getLast().contains("=8"));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "second-wind-base-healing", "9"});
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
assertEquals(new BigDecimal("9"), registry.find("second_wind").orElseThrow().parameters().get("base-healing"));
var a = new FantasyStickLifecycleNativeTest.Actor(data); withSecondWind(data, a, true, BigDecimal.valueOf(4));
f.players.put(a.core.id, a.player); f.activate(a); use(f, a);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(14, a.player.getHealth(), "Future cast uses acknowledged base healing");
f.command.execute(sender.port, "fantasystick", new String[]{"config", "second-wind-base-healing", "-1"});
assertTrue(sender.messages.getLast().contains("invalid"));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "second-wind-base-healing", "9", "extra"});
assertTrue(sender.messages.getLast().contains("Usage"));
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
var reloaded = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
assertEquals(new BigDecimal("9"), reloaded.find("second_wind").orElseThrow().parameters().get("base-healing"));
}
for (String invalid : List.of("second-wind-base-healing: -1\n", "second-wind-cooldown-seconds: -1\n")) {
var directory = data.resolve(Integer.toHexString(invalid.hashCode()));
java.nio.file.Files.createDirectories(directory); java.nio.file.Files.writeString(directory.resolve("config.yml"), invalid);
try (var f = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
assertThrows(IllegalArgumentException.class, () -> f.plugin.setEnabled(true));
assertTrue(f.order.isEmpty());
}
}
}
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[Learn]", "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,
FantasyStickLifecycleNativeTest.Actor actor) {
actor.core.inventory.getInventory().setSelectedSlot(2);
fixture.dispatcher.accept(new PlayerInteractEvent(actor.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
actor.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND));
}
private static ClickEvent.Custom abilityButton(FantasyStickLifecycleNativeTest.Actor actor, String label, String ability) {
var book = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
assertNotNull(book);
for (var page : book.pages()) { var found = abilityButton(page.raw(), label, ability); if (found != null) return found; }
throw new AssertionError("Missing " + label + " action for " + ability);
}
private static ClickEvent.Custom abilityButton(net.minecraft.network.chat.Component component, String label, String ability) {
if (component.getString().equals(label) && component.getStyle().getClickEvent() instanceof ClickEvent.Custom custom
&& custom.payload().orElse(null) instanceof net.minecraft.nbt.StringTag payload
&& payload.value().contains(":" + ability)) return custom;
for (var child : component.getSiblings()) { var found = abilityButton(child, label, ability); if (found != null) return found; }
return null;
}
private static void withSecondWind(Path data, FantasyStickLifecycleNativeTest.Actor actor,
boolean learned, BigDecimal health) throws Exception {
java.nio.file.Files.createDirectories(data);
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(learned ? 39 : 40), health,
learned ? java.util.Set.of("second_wind") : old.learned(),
learned ? List.of("second_wind", "", "", "", "", "", "") : old.loadout(),
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
}
}
@@ -0,0 +1,329 @@
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.Stat;
import java.math.BigInteger;
import java.nio.file.Path;
import java.util.EnumMap;
import java.util.List;
import net.minecraft.network.chat.ClickEvent;
import org.bukkit.Location;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.util.Vector;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ShieldBashNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void bookAcquisitionAndEquippedAttackReplaceVanillaDamageThenKnockBack() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withShieldBash(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("ShieldBash")); assertTrue(book.contains("STR12")); assertTrue(book.contains("CON12"));
assertTrue(book.contains("Cost:1")); 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());
a.core.inventory.getInventory().setSelectedSlot(2);
var block = new org.bukkit.event.player.PlayerInteractEvent(a.player,
org.bukkit.event.block.Action.RIGHT_CLICK_AIR, a.core.inventory.getItem(2), null,
org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
f.dispatcher.accept(block); assertTrue(block.isCancelled(), "Contained shield cannot start ordinary blocking");
assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("shield_bash"));
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID()); when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
var origin = new Location(a.world, 0, 64, 0); var destination = new Location(a.world, 2, 64, 0);
doReturn(origin).when(a.player).getLocation(); when(target.getLocation()).thenReturn(destination);
doReturn(true).when(a.player).hasLineOfSight(target); when(target.getVelocity()).thenReturn(new Vector(0.1, 0.2, 0));
var applied = new java.util.concurrent.atomic.AtomicReference<Vector>();
doAnswer(call -> { applied.set(call.getArgument(0)); return null; }).when(target).setVelocity(any(Vector.class));
var damages = new java.util.ArrayList<EntityDamageByEntityEvent>();
doAnswer(call -> {
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
var event = new EntityDamageByEntityEvent(a.player, target, EntityDamageEvent.DamageCause.ENTITY_ATTACK,
source, (Double) call.getArgument(0)); damages.add(event); f.dispatcher.accept(event); return null;
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
a.core.inventory.getInventory().setSelectedSlot(2);
var attack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
f.dispatcher.accept(attack); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertTrue(attack.isCancelled(), "Contained shield cannot continue into vanilla damage/blocking");
assertEquals(1, damages.size()); assertEquals(5, damages.getFirst().getDamage(), 1.0e-9);
assertEquals(new Vector(0.9, 0.35, 0), applied.get());
assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("shield_bash"));
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());
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
PartyBookNativeTest.drain(f); assertEquals(1, damages.size());
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
}
}
@Test void outOfRangeOrObstructedTargetRefusesBeforeCooldownWhileStillContainingShield() throws Exception {
for (int route = 0; route < 2; route++) {
var directory = data.resolve("refused-" + route);
try (var f = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(directory);
withShieldBash(directory, a, true); f.players.put(a.core.id, a.player); f.activate(a);
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID()); when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
doReturn(new Location(a.world, 0, 64, 0)).when(a.player).getLocation();
when(target.getLocation()).thenReturn(new Location(a.world, route == 0 ? 4 : 2, 64, 0));
doReturn(route == 0).when(a.player).hasLineOfSight(target);
a.core.inventory.getInventory().setSelectedSlot(2);
var attack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
f.dispatcher.accept(attack); assertTrue(attack.isCancelled());
verify(target, never()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("shield_bash"));
}
}
}
@Test void partyAndMixedModePlayerTargetsAreRefusedWithoutFreeVanillaHits() 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);
withShieldBash(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()));
a.core.inventory.getInventory().setSelectedSlot(2);
var friendly=new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player,b.player,true);f.dispatcher.accept(friendly);
assertTrue(friendly.isCancelled());verify(b.player,never()).damage(anyDouble(),any(org.bukkit.damage.DamageSource.class));
var ordinary=mock(org.bukkit.entity.Player.class);when(ordinary.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
var mixed=new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player,ordinary,true);f.dispatcher.accept(mixed);
assertTrue(mixed.isCancelled());verify(ordinary,never()).damage(anyDouble(),any(org.bukkit.damage.DamageSource.class));
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");
assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("shield_bash"));
}
}
@Test void cancelledDamageAppliesNoKnockbackAndNeverRefundsAcknowledgedCooldown() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
withShieldBash(data, a, true); f.players.put(a.core.id, a.player); f.activate(a);
var target = mock(org.bukkit.entity.LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
doReturn(new Location(a.world, 0, 64, 0)).when(a.player).getLocation();
when(target.getLocation()).thenReturn(new Location(a.world, 2, 64, 0)); doReturn(true).when(a.player).hasLineOfSight(target);
doAnswer(call -> {
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
var event = new EntityDamageByEntityEvent(a.player, target, EntityDamageEvent.DamageCause.ENTITY_ATTACK,
source, (Double) call.getArgument(0)); event.setCancelled(true); f.dispatcher.accept(event); return null;
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
a.core.inventory.getInventory().setSelectedSlot(2);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); verify(target, never()).setVelocity(any(Vector.class));
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("shield_bash"));
}
}
@Test void cancelledPlayerVelocityPreservesMovementAfterAcceptedDamage() 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); withShieldBash(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);
doReturn(new Location(a.world, 0, 64, 0)).when(a.player).getLocation();
doReturn(new Location(a.world, 2, 64, 0)).when(b.player).getLocation();
doReturn(true).when(a.player).hasLineOfSight(b.player); doReturn(new Vector(0.1, 0.2, 0)).when(b.player).getVelocity();
class Veto implements org.bukkit.event.Listener {
@org.bukkit.event.EventHandler public void velocity(org.bukkit.event.player.PlayerVelocityEvent event) {
if (event.getPlayer() == b.player) event.setCancelled(true);
}
}
f.dispatcher.register(new Veto(), f.plugin);
doAnswer(call -> {
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
f.dispatcher.accept(new EntityDamageByEntityEvent(a.player, b.player, EntityDamageEvent.DamageCause.ENTITY_ATTACK,
source, (Double) call.getArgument(0))); return null;
}).when(b.player).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
a.core.inventory.getInventory().setSelectedSlot(2);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, b.player, true));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); verify(b.player, never()).setVelocity(any(Vector.class));
}
}
@Test void unlearnRelearnAndCharacterTransferRetainCooldown() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "shield-bash-cooldown-seconds: 60\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
withShieldBash(data, a, true); f.players.put(a.core.id, a.player); f.activate(a);
var target = mock(org.bukkit.entity.LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
doReturn(new Location(a.world, 0, 64, 0)).when(a.player).getLocation();
when(target.getLocation()).thenReturn(new Location(a.world, 2, 64, 0)); doReturn(true).when(a.player).hasLineOfSight(target);
when(target.getVelocity()).thenReturn(new Vector()); var hits = new java.util.concurrent.atomic.AtomicInteger();
doAnswer(call -> { hits.incrementAndGet(); var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
f.dispatcher.accept(new EntityDamageByEntityEvent(a.player, target, EntityDamageEvent.DamageCause.ENTITY_ATTACK,
source, (Double) call.getArgument(0))); return null; }).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
a.core.inventory.getInventory().setSelectedSlot(2);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); assertEquals(1, hits.get());
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var deadline = storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("shield_bash");
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
var unlearn = abilityButton(a, "[Unlearn]", "shield_bash");
PartyBookNativeTest.click(f, a, unlearn); PartyBookNativeTest.click(f, a, unlearn);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var refunded = storage.find(a.core.record.id()).get().orElseThrow();
assertEquals(BigInteger.valueOf(40), refunded.unspentPoints()); assertEquals(deadline, refunded.cooldowns().get("shield_bash"));
learnAndAssign(f, a); a.core.inventory.getInventory().setSelectedSlot(2);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
PartyBookNativeTest.drain(f); assertEquals(1, hits.get());
var sheet = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
var b = new FantasyStickLifecycleNativeTest.Actor(data); b.core.inventory.setItemInOffHand(sheet);
f.players.put(b.core.id, b.player); f.activate(b); b.core.inventory.getInventory().setSelectedSlot(2);
doReturn(new Location(a.world, 0, 64, 0)).when(b.player).getLocation(); doReturn(true).when(b.player).hasLineOfSight(target);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(b.player, target, true));
PartyBookNativeTest.drain(f); assertEquals(1, hits.get()); assertEquals(deadline,
storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("shield_bash"));
}
}
@Test void exactDamageFormulaSupportsFiniteUncappedStats() {
assertEquals(BigInteger.valueOf(5), games.dmg.fantasystick.ability.ShieldBash.damage(BigInteger.valueOf(12)));
assertEquals(BigInteger.valueOf(3), games.dmg.fantasystick.ability.ShieldBash.damage(BigInteger.valueOf(9)));
var huge = BigInteger.TEN.pow(500);
assertEquals(huge.subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(4)),
games.dmg.fantasystick.ability.ShieldBash.damage(huge));
}
@Test void failedCooldownPersistenceDealsNoDamageOrKnockback() {
var storage=mock(CharacterStorage.class);var sessions=mock(games.dmg.fantasystick.session.EquipmentSessions.class);
var parties=new games.dmg.fantasystick.party.PartyRoster(java.time.Clock.systemUTC(),ignored->true);
var registry=new games.dmg.fantasystick.ability.AbilityRegistry();var bash=new games.dmg.fantasystick.ability.ShieldBash(
games.dmg.fantasystick.ability.ShieldBash.Settings.defaults(),ignored->{});registry.register(bash.definition());
var base=new games.dmg.fantasystick.crafting.CharacterFactory().create(games.dmg.fantasystick.progression.ProgressionRules.defaults());
var stats=new EnumMap<>(base.stats());stats.put(Stat.STR,BigInteger.valueOf(12));stats.put(Stat.CON,BigInteger.valueOf(12));
var record=new CharacterRecord(base.id(),base.name(),base.revision(),stats,base.level(),base.experience(),base.unspentPoints(),base.health(),
java.util.Set.of("shield_bash"),List.of("shield_bash","","","","","",""),base.cooldowns(),base.restrictedKills(),false,null,null);
var active=new games.dmg.fantasystick.session.EquipmentSessions.Active(java.util.UUID.randomUUID(),java.util.UUID.randomUUID(),record.id());
var player=mock(org.bukkit.entity.Player.class);when(player.getUniqueId()).thenReturn(active.player());
var world=mock(org.bukkit.World.class);doReturn(new Location(world,0,64,0)).when(player).getLocation();
var target=mock(org.bukkit.entity.LivingEntity.class);when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(target.getLocation()).thenReturn(new Location(world,2,64,0));
doReturn(true).when(player).hasLineOfSight(target);when(sessions.current(active)).thenReturn(true);
when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record));
var failed=new java.util.concurrent.CompletableFuture<CharacterStorage.AbilityMutation>();
failed.completeExceptionally(new java.io.IOException("simulated cooldown failure"));
when(storage.commitAbilityCooldown(eq(record.id()),eq(record.revision()),eq("shield_bash"),any())).thenReturn(failed);
var engine=new games.dmg.fantasystick.ability.AbilityEngine(registry,storage,sessions,parties,java.time.Clock.systemUTC(),Runnable::run,
(delay,action)->()->{},ignored->{},ignored->{});
engine.accept(new games.dmg.fantasystick.session.ControlInput(player,active,2,"shield_bash",
games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,games.dmg.fantasystick.session.ControlInput.Gesture.ATTACK,
target,0,1,()->true));
verify(storage).commitAbilityCooldown(eq(record.id()),eq(record.revision()),eq("shield_bash"),any());
verify(target,never()).damage(anyDouble(),any(org.bukkit.damage.DamageSource.class));verify(target,never()).setVelocity(any(Vector.class));
engine.close();bash.close();parties.close();
}
@Test void configuredDefinitionAdminRoutesRestartAndFutureStrikeCoverEverySetting() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"shield-bash-base-damage: 8\nshield-bash-cooldown-seconds: 0\nshield-bash-range-blocks: 5\n" +
"shield-bash-horizontal-knockback: 1.2\nshield-bash-minimum-vertical-knockback: 0.5\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
var definition = registry.find("shield_bash").orElseThrow(); assertEquals(java.time.Duration.ZERO, definition.cooldown());
assertEquals(0, new java.math.BigDecimal("8").compareTo(definition.parameters().get("base-damage")));
assertEquals(0, new java.math.BigDecimal("5").compareTo(definition.parameters().get("range")));
assertEquals(0, new java.math.BigDecimal("1.2").compareTo(definition.parameters().get("horizontal-knockback")));
assertEquals(0, new java.math.BigDecimal("0.5").compareTo(definition.parameters().get("minimum-vertical-knockback")));
var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed=false;
assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","shield-bash"}).isEmpty());
f.command.execute(sender.port,"fantasystick",new String[]{"config","shield-bash-base-damage","9"});
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed=true;
assertEquals(List.of("shield-bash-base-damage","shield-bash-cooldown-seconds","shield-bash-horizontal-knockback",
"shield-bash-minimum-vertical-knockback","shield-bash-range-blocks"),
f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","shield-bash-"}));
assertEquals(List.of("4","8"),f.command.tabComplete(sender.port,"fantasystick",
new String[]{"config","shield-bash-base-damage",""}));
f.command.execute(sender.port,"fantasystick",new String[]{"config","shield-bash-base-damage"});
assertTrue(sender.messages.getLast().contains("=8"));
f.command.execute(sender.port,"fantasystick",new String[]{"config","shield-bash-base-damage","9"});
var response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();
assertEquals(0,new java.math.BigDecimal("9").compareTo(registry.find("shield_bash").orElseThrow().parameters().get("base-damage")));
var a=new FantasyStickLifecycleNativeTest.Actor(data);withShieldBash(data,a,true);f.players.put(a.core.id,a.player);f.activate(a);
var target=mock(org.bukkit.entity.LivingEntity.class);when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
doReturn(new Location(a.world,0,64,0)).when(a.player).getLocation();when(target.getLocation()).thenReturn(new Location(a.world,4,64,0));
doReturn(true).when(a.player).hasLineOfSight(target);when(target.getVelocity()).thenReturn(new Vector());
var amount=new java.util.concurrent.atomic.AtomicReference<Double>();var movement=new java.util.concurrent.atomic.AtomicReference<Vector>();
doAnswer(call->{amount.set(call.getArgument(0));var source=(org.bukkit.damage.DamageSource)call.getArgument(1);
f.dispatcher.accept(new EntityDamageByEntityEvent(a.player,target,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,(Double)call.getArgument(0)));return null;
}).when(target).damage(anyDouble(),any(org.bukkit.damage.DamageSource.class));
doAnswer(call->{movement.set(call.getArgument(0));return null;}).when(target).setVelocity(any(Vector.class));
a.core.inventory.getInventory().setSelectedSlot(2);f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player,target,true));
PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(10,amount.get(),1.0e-9);assertEquals(new Vector(1.2,0.5,0),movement.get());
f.command.execute(sender.port,"fantasystick",new String[]{"config","shield-bash-base-damage","-1"});
assertTrue(sender.messages.getLast().contains("invalid"));
f.command.execute(sender.port,"fantasystick",new String[]{"config","shield-bash-base-damage","9","extra"});
assertTrue(sender.messages.getLast().contains("Usage"));
f.plugin.setEnabled(false);f.plugin.setEnabled(true);var reloaded=(games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");
assertEquals(0,new java.math.BigDecimal("9").compareTo(reloaded.find("shield_bash").orElseThrow().parameters().get("base-damage")));
}
for(String invalid:List.of("shield-bash-base-damage: -1\n","shield-bash-cooldown-seconds: -1\n",
"shield-bash-range-blocks: 0\n","shield-bash-horizontal-knockback: 9\n",
"shield-bash-minimum-vertical-knockback: 9\n")){
var directory=data.resolve(Integer.toHexString(invalid.hashCode()));java.nio.file.Files.createDirectories(directory);
java.nio.file.Files.writeString(directory.resolve("config.yml"),invalid);
try(var f=new FantasyStickLifecycleNativeTest.Fixture(directory)){
assertThrows(IllegalArgumentException.class,()->f.plugin.setEnabled(true));assertTrue(f.order.isEmpty());
}
}
}
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[Learn]", "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) {
var book = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT); assertNotNull(book);
for (var page : book.pages()) { var found = abilityButton(page.raw(), label, ability); if (found != null) return found; }
throw new AssertionError("Missing " + label + " for " + ability);
}
private static ClickEvent.Custom abilityButton(net.minecraft.network.chat.Component component, String label, String ability) {
if (component.getString().equals(label) && component.getStyle().getClickEvent() instanceof ClickEvent.Custom custom
&& custom.payload().orElse(null) instanceof net.minecraft.nbt.StringTag payload
&& payload.value().contains(":" + ability)) return custom;
for (var child : component.getSiblings()) { var found = abilityButton(child, label, ability); if (found != null) return found; }
return null;
}
private static void withShieldBash(Path data, FantasyStickLifecycleNativeTest.Actor actor, boolean learned) throws Exception {
java.nio.file.Files.createDirectories(data);
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(12)); 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(learned ? 39 : 40), old.health(),
learned ? java.util.Set.of("shield_bash") : old.learned(),
learned ? List.of("shield_bash", "", "", "", "", "", "") : old.loadout(),
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
}
}
@@ -0,0 +1,294 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
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.BigInteger;
import java.nio.file.Path;
import java.util.EnumMap;
import java.util.List;
import net.minecraft.network.chat.ClickEvent;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class StoneSkinNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void bookAcquisitionEquippedUseReducesNativeEntityDamageForFourSeconds() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStoneSkin(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 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"));
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);
assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("stone_skin"));
var attacker = mock(org.bukkit.entity.Zombie.class);
var hit = new EntityDamageByEntityEvent(attacker, a.player,
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(org.bukkit.damage.DamageSource.class), 8);
f.dispatcher.accept(hit); assertEquals(6, hit.getDamage(), 1.0e-9);
assertEquals(80L, f.delayedTicks.getLast());
f.delayed.getLast().run();
var expired = new EntityDamageByEntityEvent(attacker, a.player,
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(org.bukkit.damage.DamageSource.class), 8);
f.dispatcher.accept(expired); assertEquals(8, expired.getDamage(), 1.0e-9);
var deadline = storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("stone_skin");
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
var unlearn = abilityButton(a, "[Unlearn]", "stone_skin");
PartyBookNativeTest.click(f, a, unlearn); PartyBookNativeTest.click(f, a, unlearn);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var refunded = storage.find(a.core.record.id()).get().orElseThrow();
assertEquals(BigInteger.valueOf(40), refunded.unspentPoints()); 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")));
}
}
@Test void environmentalAndCancelledDamageRemainUntouchedWhileProjectileCombatIsReduced() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStoneSkin(data, a, true);
f.players.put(a.core.id, a.player); f.activate(a); use(f, a);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var fall = new EntityDamageEvent(a.player, EntityDamageEvent.DamageCause.FALL,
mock(org.bukkit.damage.DamageSource.class), 8);
f.dispatcher.accept(fall); assertEquals(8, fall.getDamage(), 1.0e-9);
var arrow = mock(org.bukkit.entity.Arrow.class);
var projectile = new EntityDamageByEntityEvent(arrow, a.player,
EntityDamageEvent.DamageCause.PROJECTILE, mock(org.bukkit.damage.DamageSource.class), 8);
f.dispatcher.accept(projectile); assertEquals(6, projectile.getDamage(), 1.0e-9);
class Veto implements org.bukkit.event.Listener {
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.LOWEST)
public void veto(EntityDamageByEntityEvent event) { event.setCancelled(true); }
}
f.dispatcher.register(new Veto(), f.plugin);
var cancelled = new EntityDamageByEntityEvent(mock(org.bukkit.entity.Zombie.class), a.player,
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(org.bukkit.damage.DamageSource.class), 8);
f.dispatcher.accept(cancelled); assertTrue(cancelled.isCancelled()); assertEquals(8, cancelled.getDamage(), 1.0e-9);
}
}
@Test void replacementExpirySessionExitAndTransferRemoveOnlyOwnedReductionAndRetainDeadline() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"stone-skin-cooldown-seconds: 0\nstone-skin-duration-seconds: 4\nstone-skin-damage-reduction-percent: 25\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStoneSkin(data, a, true);
f.players.put(a.core.id, a.player); f.activate(a); use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(2, f.delayed.size());
f.delayed.getFirst().run();
assertEquals(6, hit(f, a, 8).getDamage(), 1.0e-9, "Old expiry cannot remove replacement protection");
f.delayed.getLast().run();
assertEquals(8, hit(f, a, 8).getDamage(), 1.0e-9);
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var deadline = storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("stone_skin");
var sheet = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
assertEquals(8, hit(f, a, 8).getDamage(), 1.0e-9, "Exit clears owned reduction");
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("stone_skin"));
var b = new FantasyStickLifecycleNativeTest.Actor(data); b.core.inventory.setItemInOffHand(sheet);
f.players.put(b.core.id, b.player); f.activate(b);
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("stone_skin"));
}
}
@Test void acknowledgedConfigurationAffectsFutureCastsWhileActiveProtectionKeepsSnapshot() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"stone-skin-cooldown-seconds: 0\nstone-skin-duration-seconds: 4\nstone-skin-damage-reduction-percent: 25\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStoneSkin(data, a, true);
f.players.put(a.core.id, a.player); f.activate(a); use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var sender = new EquipmentAdminCommandNativeTest.Sender();
f.command.execute(sender.port, "fantasystick", new String[]{"config", "stone-skin-duration-seconds", "6"});
f.command.execute(sender.port, "fantasystick", new String[]{"config", "stone-skin-damage-reduction-percent", "50"});
for (int i = 0; i < 2; i++) { var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run(); }
assertEquals(6, hit(f, a, 8).getDamage(), 1.0e-9, "Active cast retains captured 25 percent");
assertEquals(80L, f.delayedTicks.getFirst());
var b = new FantasyStickLifecycleNativeTest.Actor(data); withStoneSkin(data, b, true);
f.players.put(b.core.id, b.player); f.activate(b); use(f, b); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(4, hit(f, b, 8).getDamage(), 1.0e-9); assertEquals(120L, f.delayedTicks.getLast());
}
}
@Test void acceptedDamageReportsExactPreventionWhileCancelledDamageReportsNothing() {
var mitigation = new games.dmg.fantasystick.ability.CombatMitigation();
var stone = new games.dmg.fantasystick.ability.StoneSkin(
games.dmg.fantasystick.ability.StoneSkin.Settings.defaults(), mitigation);
var execution = mock(games.dmg.fantasystick.ability.AbilityExecution.class);
var input = mock(games.dmg.fantasystick.session.ControlInput.class);
var player = mock(org.bukkit.entity.Player.class); var id = java.util.UUID.randomUUID();
when(player.getUniqueId()).thenReturn(id); when(input.player()).thenReturn(player);
when(execution.input()).thenReturn(input); when(execution.definition()).thenReturn(stone.definition());
when(execution.stillCurrent()).thenReturn(true);
stone.activate(execution);
var weaker = mock(games.dmg.fantasystick.ability.AbilityExecution.class);
when(weaker.stillCurrent()).thenReturn(true);
var unrelated = mitigation.grant(weaker, player, new java.math.BigDecimal("20"));
var accepted = new EntityDamageByEntityEvent(mock(org.bukkit.entity.Zombie.class), player,
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(org.bukkit.damage.DamageSource.class), 8);
mitigation.reduce(accepted); mitigation.accepted(accepted);
verify(execution).report(games.dmg.fantasystick.ability.AbilityReport.Type.PREVENTION,
player, new java.math.BigDecimal("2.0"));
var cancelled = new EntityDamageByEntityEvent(mock(org.bukkit.entity.Zombie.class), player,
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(org.bukkit.damage.DamageSource.class), 8);
mitigation.reduce(cancelled); cancelled.setCancelled(true); mitigation.accepted(cancelled);
verify(execution, times(1)).report(any(), any(), any());
verify(weaker, never()).report(any(), any(), any());
try { unrelated.close(); } catch (Exception failure) { fail(failure); }
stone.close(); mitigation.close();
}
@Test void failedCooldownPersistenceCreatesNoReduction() {
var storage = mock(CharacterStorage.class);
var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class);
var parties = new games.dmg.fantasystick.party.PartyRoster(java.time.Clock.systemUTC(), ignored -> true);
var registry = new games.dmg.fantasystick.ability.AbilityRegistry();
var mitigation = new games.dmg.fantasystick.ability.CombatMitigation();
var stone = new games.dmg.fantasystick.ability.StoneSkin(
games.dmg.fantasystick.ability.StoneSkin.Settings.defaults(), mitigation); registry.register(stone.definition());
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var stats = new EnumMap<>(base.stats()); stats.put(Stat.CON, BigInteger.valueOf(14));
var record = new CharacterRecord(base.id(), base.name(), base.revision(), stats, base.level(), base.experience(),
base.unspentPoints(), base.health(), java.util.Set.of("stone_skin"),
List.of("stone_skin", "", "", "", "", "", ""), base.cooldowns(), base.restrictedKills(), false, null, null);
var active = new games.dmg.fantasystick.session.EquipmentSessions.Active(java.util.UUID.randomUUID(),
java.util.UUID.randomUUID(), record.id());
var player = mock(org.bukkit.entity.Player.class); when(player.getUniqueId()).thenReturn(active.player());
when(sessions.current(active)).thenReturn(true); when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record));
var failed = new java.util.concurrent.CompletableFuture<CharacterStorage.AbilityMutation>();
failed.completeExceptionally(new java.io.IOException("simulated cooldown save failure"));
when(storage.commitAbilityCooldown(eq(record.id()), eq(record.revision()), eq("stone_skin"), any())).thenReturn(failed);
var failures = new java.util.ArrayList<games.dmg.fantasystick.ability.AbilityEngine.Failure>();
var engine = new games.dmg.fantasystick.ability.AbilityEngine(registry, storage, sessions, parties,
java.time.Clock.systemUTC(), Runnable::run, (delay, action) -> () -> {}, ignored -> {}, failures::add);
engine.accept(new games.dmg.fantasystick.session.ControlInput(player, active, 2, "stone_skin",
games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,
games.dmg.fantasystick.session.ControlInput.Gesture.USE, null, 0, 0, () -> true));
var hit = new EntityDamageByEntityEvent(mock(org.bukkit.entity.Zombie.class), player,
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(org.bukkit.damage.DamageSource.class), 8);
mitigation.reduce(hit); assertEquals(8, hit.getDamage(), 1.0e-9);
verify(storage).commitAbilityCooldown(eq(record.id()), eq(record.revision()), eq("stone_skin"), any());
engine.close(); stone.close(); mitigation.close(); parties.close();
}
@Test void configuredDefinitionAdminRoutesAndStartupValidationCoverEverySetting() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"stone-skin-cooldown-seconds: 2\nstone-skin-duration-seconds: 6\nstone-skin-damage-reduction-percent: 40\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
var definition = registry.find("stone_skin").orElseThrow();
assertEquals(java.time.Duration.ofSeconds(2), definition.cooldown());
assertEquals(0, new java.math.BigDecimal("6").compareTo(definition.parameters().get("duration-seconds")));
assertEquals(0, new java.math.BigDecimal("40").compareTo(definition.parameters().get("damage-reduction-percent")));
var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed = false;
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "stone-skin"}).isEmpty());
f.command.execute(sender.port, "fantasystick", new String[]{"config", "stone-skin-duration-seconds", "7"});
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
assertEquals(List.of("stone-skin-cooldown-seconds", "stone-skin-damage-reduction-percent", "stone-skin-duration-seconds"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "stone-skin-"}));
assertEquals(List.of("4", "6"), f.command.tabComplete(sender.port, "fantasystick",
new String[]{"config", "stone-skin-duration-seconds", ""}));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "stone-skin-duration-seconds"});
assertTrue(sender.messages.getLast().contains("=6"));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "stone-skin-duration-seconds", "7"});
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
assertEquals(0, new java.math.BigDecimal("7").compareTo(
registry.find("stone_skin").orElseThrow().parameters().get("duration-seconds")));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "stone-skin-duration-seconds", "-1"});
assertTrue(sender.messages.getLast().contains("invalid"));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "stone-skin-duration-seconds", "7", "extra"});
assertTrue(sender.messages.getLast().contains("Usage"));
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
var reloaded = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
assertEquals(0, new java.math.BigDecimal("7").compareTo(
reloaded.find("stone_skin").orElseThrow().parameters().get("duration-seconds")));
}
for (String invalid : List.of("stone-skin-cooldown-seconds: -1\n",
"stone-skin-duration-seconds: 0\n", "stone-skin-damage-reduction-percent: 101\n")) {
var directory = data.resolve(Integer.toHexString(invalid.hashCode())); java.nio.file.Files.createDirectories(directory);
java.nio.file.Files.writeString(directory.resolve("config.yml"), invalid);
try (var f = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
assertThrows(IllegalArgumentException.class, () -> f.plugin.setEnabled(true)); assertTrue(f.order.isEmpty());
}
}
}
private static EntityDamageByEntityEvent hit(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor target, double amount) {
var event = new EntityDamageByEntityEvent(mock(org.bukkit.entity.Zombie.class), target.player,
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(org.bukkit.damage.DamageSource.class), amount);
fixture.dispatcher.accept(event); return event;
}
private static void use(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor) {
actor.core.inventory.getInventory().setSelectedSlot(2);
fixture.dispatcher.accept(new PlayerInteractEvent(actor.player,
org.bukkit.event.block.Action.RIGHT_CLICK_AIR, actor.core.inventory.getItem(2), null,
org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND));
}
private static ClickEvent.Custom abilityButton(FantasyStickLifecycleNativeTest.Actor actor,
String label, String ability) {
var book = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
assertNotNull(book);
for (var page : book.pages()) {
var found = abilityButton(page.raw(), label, ability);
if (found != null) return found;
}
fail("Missing " + label + " for " + ability); return null;
}
private static ClickEvent.Custom abilityButton(net.minecraft.network.chat.Component component,
String label, String ability) {
if (component.getString().equals(label)
&& component.getStyle().getClickEvent() instanceof ClickEvent.Custom custom
&& custom.payload().orElse(null) instanceof net.minecraft.nbt.StringTag payload
&& payload.value().contains(":" + ability)) return custom;
for (var child : component.getSiblings()) {
var found = abilityButton(child, label, ability); if (found != null) return found;
}
return null;
}
private static void withStoneSkin(Path data, FantasyStickLifecycleNativeTest.Actor actor,
boolean learned) throws Exception {
java.nio.file.Files.createDirectories(data);
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(14));
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
old.level(), old.experience(), BigInteger.valueOf(learned ? 39 : 40), old.health(),
learned ? java.util.Set.of("stone_skin") : old.learned(),
learned ? List.of("stone_skin", "", "", "", "", "", "") : old.loadout(),
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
}
}
@@ -33,6 +33,46 @@ class DistributionTest {
assertEquals(1, metadata.getPermissions().size());
assertEquals("fantasystick.admin", metadata.getPermissions().getFirst().getName());
assertEquals(org.bukkit.permissions.PermissionDefault.OP, metadata.getPermissions().getFirst().getDefault());
var configuration = jar.getJarEntry("config.yml");
assertNotNull(configuration, "Ship reviewed session defaults for administrators");
String defaults;
try (var input = jar.getInputStream(configuration)) {
defaults = new String(input.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
}
assertTrue(defaults.contains("character-swap-cooldown-seconds: 900"),
"The delivered configuration must expose the reviewed 15-minute default");
assertTrue(defaults.contains("adventure-mode: true"));
assertTrue(defaults.contains("great-sword-base-damage: 7"));
assertTrue(defaults.contains("great-sword-recovery-seconds: 1.2"));
for (String setting : java.util.List.of("frost-shard-base-damage: 4",
"frost-shard-cooldown-seconds: 5", "frost-shard-range-blocks: 32",
"frost-shard-lifetime-seconds: 5", "frost-shard-projectile-speed-blocks-per-tick: 1",
"frost-shard-slowness-amplifier: 0", "frost-shard-slowness-duration-seconds: 2")) {
assertTrue(defaults.contains(setting), "Missing packaged Frost Shard default: " + setting);
}
for (String setting : java.util.List.of("longbow-base-damage: 5", "longbow-recovery-seconds: 1",
"longbow-minimum-draw-ticks: 3", "longbow-full-draw-ticks: 20",
"longbow-range-blocks: 64", "longbow-lifetime-seconds: 10",
"longbow-full-projectile-speed-blocks-per-tick: 3")) {
assertTrue(defaults.contains(setting), "Missing packaged Longbow default: " + setting);
}
for (String setting : java.util.List.of("quickstep-cooldown-seconds: 5",
"quickstep-distance-blocks: 4", "quickstep-speed-blocks-per-tick: 1")) {
assertTrue(defaults.contains(setting), "Missing packaged Quickstep default: " + setting);
}
for (String setting : java.util.List.of("second-wind-base-healing: 6",
"second-wind-cooldown-seconds: 15")) {
assertTrue(defaults.contains(setting), "Missing packaged Second Wind default: " + setting);
}
for (String setting : java.util.List.of("stone-skin-cooldown-seconds: 14",
"stone-skin-duration-seconds: 4", "stone-skin-damage-reduction-percent: 25")) {
assertTrue(defaults.contains(setting), "Missing packaged Stone Skin default: " + setting);
}
for (String setting : java.util.List.of("shield-bash-base-damage: 4", "shield-bash-cooldown-seconds: 6",
"shield-bash-range-blocks: 3", "shield-bash-horizontal-knockback: 0.8",
"shield-bash-minimum-vertical-knockback: 0.35")) {
assertTrue(defaults.contains(setting), "Missing packaged Shield Bash default: " + setting);
}
assertTrue(JavaPlugin.class.isAssignableFrom(FantasyStickPlugin.class));
var entrypoint = jar.getJarEntry(metadata.getMain().replace('.', '/') + ".class");
assertNotNull(entrypoint);
@@ -0,0 +1,20 @@
package games.dmg.fantasystick.combat;
import static org.junit.jupiter.api.Assertions.*;
import java.math.BigInteger;
import org.junit.jupiter.api.Test;
class BasicAttackTest {
@Test void followsTheApprovedStrengthFormulaWithoutAStatCap() {
assertEquals(BigInteger.ONE, BasicAttack.damage(BigInteger.ZERO));
assertEquals(BigInteger.ONE, BasicAttack.damage(BigInteger.TEN));
assertEquals(BigInteger.ONE, BasicAttack.damage(BigInteger.valueOf(11)));
assertEquals(BigInteger.ONE, BasicAttack.damage(BigInteger.valueOf(12)));
assertEquals(BigInteger.TWO, BasicAttack.damage(BigInteger.valueOf(14)));
var huge = BigInteger.TEN.pow(1000);
assertEquals(huge.subtract(BigInteger.TEN).divide(BigInteger.TWO), BasicAttack.damage(huge));
assertThrows(IllegalArgumentException.class, () -> BasicAttack.damage(BigInteger.valueOf(-1)));
assertThrows(NullPointerException.class, () -> BasicAttack.damage(null));
}
}
@@ -49,6 +49,273 @@ class CharacterStorageTest {
}
}
@Test void awardsConfiguredExperiencePersistentlyWithMultiLevelAndCapIdempotence() throws Exception {
var repository = new YamlCharacterRepository(data);
var rules = new games.dmg.fantasystick.progression.ProgressionRules(BigInteger.valueOf(2), BigInteger.valueOf(7), 3);
var live = new games.dmg.fantasystick.crafting.CharacterFactory().create(rules);
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
storage.create(live).join();
var awarded = storage.awardExperience(live.id(), live.revision(), BigInteger.valueOf(8), rules).join();
assertEquals(CharacterStorage.ExperienceAwardStatus.APPLIED, awarded.status());
assertEquals(1, awarded.levels());
var levelTwo = awarded.record().orElseThrow();
assertEquals(2, levelTwo.level());
assertEquals(BigInteger.valueOf(8), levelTwo.experience());
assertEquals(BigInteger.valueOf(3), levelTwo.unspentPoints());
var capped = storage.awardExperience(live.id(), levelTwo.revision(), BigInteger.valueOf(100), rules).join();
assertEquals(1, capped.levels());
assertEquals(3, capped.record().orElseThrow().level());
assertEquals(BigInteger.valueOf(14), capped.record().orElseThrow().experience());
assertEquals(CharacterStorage.ExperienceAwardStatus.AT_CAP,
storage.awardExperience(live.id(), capped.record().orElseThrow().revision(), BigInteger.ONE, rules).join().status());
assertEquals(CharacterStorage.ExperienceAwardStatus.STALE,
storage.awardExperience(live.id(), live.revision(), BigInteger.ONE, rules).join().status());
var lowered = new games.dmg.fantasystick.progression.ProgressionRules(BigInteger.ZERO, BigInteger.valueOf(7), 2);
assertEquals(CharacterStorage.ExperienceAwardStatus.AT_CAP,
storage.awardExperience(live.id(), capped.record().orElseThrow().revision(), BigInteger.ONE, lowered).join().status());
assertEquals(capped.record().orElseThrow(), repository.load(live.id()).orElseThrow(),
"Lowering configuration must not rewrite existing transferable progress");
}
}
@Test
void combatAwardsAtomicallyPersistXpPointsAndPerWorldRestrictedKillHistory() throws Exception {
var repository = new YamlCharacterRepository(data);
var progression = new games.dmg.fantasystick.progression.ProgressionRules(BigInteger.ZERO, BigInteger.TEN, 30);
var rewards = games.dmg.fantasystick.progression.CombatRewardRules.defaults();
var live = new games.dmg.fantasystick.crafting.CharacterFactory().create(progression);
var worldA = UUID.randomUUID(); var worldB = UUID.randomUUID();
var first = new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(worldA,
java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO);
var near = new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(worldA,
java.math.BigDecimal.valueOf(2), java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO);
var farFromUpdated = new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(worldA,
java.math.BigDecimal.valueOf(5), java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO);
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
storage.create(live).join();
var applied = storage.awardCombatExperience(live.id(), live.revision(), BigInteger.TEN,
progression, rewards, first).join();
assertEquals(CharacterStorage.CombatAwardStatus.APPLIED, applied.status());
assertEquals(1, applied.levels());
assertEquals(first, applied.record().orElseThrow().restrictedKills().get(worldA));
var blocked = storage.awardCombatExperience(live.id(), applied.record().orElseThrow().revision(), BigInteger.TEN,
progression, rewards, near).join();
assertEquals(CharacterStorage.CombatAwardStatus.SPACING_BLOCKED, blocked.status());
assertEquals(applied.record().orElseThrow().experience(), blocked.record().orElseThrow().experience());
assertEquals(near, blocked.record().orElseThrow().restrictedKills().get(worldA), "Blocked kills still update history");
var second = storage.awardCombatExperience(live.id(), blocked.record().orElseThrow().revision(), BigInteger.TEN,
progression, rewards, farFromUpdated).join();
assertEquals(CharacterStorage.CombatAwardStatus.APPLIED, second.status());
assertEquals(BigInteger.valueOf(20), second.record().orElseThrow().experience());
var otherWorld = new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(worldB,
java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO);
var firstThere = storage.awardCombatExperience(live.id(), second.record().orElseThrow().revision(), BigInteger.TEN,
progression, rewards, otherWorld).join();
assertEquals(CharacterStorage.CombatAwardStatus.APPLIED, firstThere.status());
assertEquals(2, firstThere.record().orElseThrow().restrictedKills().size());
var passive = storage.awardCombatExperience(live.id(), firstThere.record().orElseThrow().revision(), BigInteger.ONE,
progression, rewards, null).join();
assertEquals(CharacterStorage.CombatAwardStatus.APPLIED, passive.status());
assertEquals(firstThere.record().orElseThrow().restrictedKills(), passive.record().orElseThrow().restrictedKills());
var lowered = new games.dmg.fantasystick.progression.ProgressionRules(BigInteger.ZERO, BigInteger.valueOf(1000), 1);
var thirdWorld = new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(UUID.randomUUID(),
java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO);
var cappedHistory = storage.awardCombatExperience(live.id(), passive.record().orElseThrow().revision(), BigInteger.TEN,
lowered, rewards, thirdWorld).join();
assertEquals(CharacterStorage.CombatAwardStatus.AT_CAP, cappedHistory.status());
assertEquals(passive.record().orElseThrow().experience(), cappedHistory.record().orElseThrow().experience());
assertEquals(passive.record().orElseThrow().level(), cappedHistory.record().orElseThrow().level());
assertEquals(thirdWorld, cappedHistory.record().orElseThrow().restrictedKills().get(thirdWorld.world()));
assertEquals(cappedHistory.record().orElseThrow(), repository.load(live.id()).orElseThrow());
}
}
@Test
void abilityUnlockAssignmentAndCooldownAreAtomicCharacterOwnedRevisions() throws Exception {
var repository = new YamlCharacterRepository(data);
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var stats = new java.util.EnumMap<>(base.stats()); stats.put(games.dmg.fantasystick.progression.Stat.INT, BigInteger.valueOf(12));
var live = new CharacterRecord(base.id(), base.name(), base.revision(), stats, base.level(), base.experience(),
BigInteger.valueOf(2), base.health(), base.learned(), base.loadout(), base.cooldowns(), base.restrictedKills(), false, null, null);
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
storage.create(live).join();
var unlocked = storage.unlockAbility(live.id(), live.revision(), "test_bolt",
java.util.Map.of(games.dmg.fantasystick.progression.Stat.INT, BigInteger.valueOf(12)), BigInteger.ONE).join();
assertEquals(CharacterStorage.AbilityMutationStatus.APPLIED, unlocked.status());
var learned = unlocked.record().orElseThrow();
assertEquals(java.util.Set.of("test_bolt"), learned.learned());
assertEquals(BigInteger.ONE, learned.unspentPoints());
assertEquals(CharacterStorage.AbilityMutationStatus.ALREADY_LEARNED,
storage.unlockAbility(live.id(), learned.revision(), "test_bolt", java.util.Map.of(), BigInteger.ONE).join().status());
assertEquals(CharacterStorage.AbilityMutationStatus.STALE,
storage.unlockAbility(live.id(), live.revision(), "other", java.util.Map.of(), BigInteger.ONE).join().status());
var assigned = storage.assignAbility(live.id(), learned.revision(), "test_bolt", 4).join();
assertEquals(CharacterStorage.AbilityMutationStatus.APPLIED, assigned.status());
assertEquals("test_bolt", assigned.record().orElseThrow().loadout().get(4));
var moved = storage.assignAbility(live.id(), assigned.record().orElseThrow().revision(), "test_bolt", 1).join();
assertEquals("", moved.record().orElseThrow().loadout().get(4));
assertEquals("test_bolt", moved.record().orElseThrow().loadout().get(1));
var ready = java.time.Instant.parse("2026-09-20T20:00:00Z");
var cooldown = storage.commitAbilityCooldown(live.id(), moved.record().orElseThrow().revision(), "test_bolt", ready).join();
assertEquals(CharacterStorage.AbilityMutationStatus.APPLIED, cooldown.status());
assertEquals(ready, cooldown.record().orElseThrow().cooldowns().get("test_bolt"));
assertEquals(cooldown.record().orElseThrow(), new YamlCharacterRepository(data).load(live.id()).orElseThrow());
}
}
@Test
void abilityUnlearnRefundsExactlyOnePointClearsEverySlotAndPreservesCooldown() throws Exception {
var repository = new YamlCharacterRepository(data);
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var deadline = java.time.Instant.parse("2026-09-21T12:00:00Z");
var learned = new CharacterRecord(base.id(), base.name(), base.revision(), base.stats(), base.level(),
base.experience(), BigInteger.valueOf(39), base.health(), java.util.Set.of("fireball"),
java.util.List.of("fireball", "", "fireball", "", "", "", ""), java.util.Map.of("fireball", deadline),
base.restrictedKills(), false, null, null);
repository.create(learned);
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
var refunded = storage.unlearnAbility(learned.id(), learned.revision(), "fireball").join();
assertEquals(CharacterStorage.AbilityMutationStatus.APPLIED, refunded.status());
var saved = refunded.record().orElseThrow();
assertEquals(BigInteger.valueOf(40), saved.unspentPoints());
assertFalse(saved.learned().contains("fireball"));
assertTrue(saved.loadout().stream().noneMatch("fireball"::equals));
assertEquals(deadline, saved.cooldowns().get("fireball"));
assertEquals(saved, repository.load(saved.id()).orElseThrow());
assertEquals(CharacterStorage.AbilityMutationStatus.NOT_LEARNED,
storage.unlearnAbility(saved.id(), saved.revision(), "fireball").join().status());
assertEquals(saved, repository.load(saved.id()).orElseThrow());
}
}
@Test
void abilityUnlockRefusesMissingRequirementsAndPointsWithoutChangingSavedState() throws Exception {
var repository = new YamlCharacterRepository(data);
var live = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
storage.create(live).join();
assertEquals(CharacterStorage.AbilityMutationStatus.REQUIREMENTS,
storage.unlockAbility(live.id(), live.revision(), "test_bolt",
java.util.Map.of(games.dmg.fantasystick.progression.Stat.INT, BigInteger.valueOf(12)), BigInteger.ONE).join().status());
assertEquals(live, repository.load(live.id()).orElseThrow());
assertEquals(CharacterStorage.AbilityMutationStatus.NOT_LEARNED,
storage.assignAbility(live.id(), live.revision(), "test_bolt", 0).join().status());
assertEquals(CharacterStorage.AbilityMutationStatus.NOT_LEARNED,
storage.commitAbilityCooldown(live.id(), live.revision(), "test_bolt", java.time.Instant.now()).join().status());
}
var noPointsStats = new java.util.EnumMap<>(live.stats());
noPointsStats.put(games.dmg.fantasystick.progression.Stat.INT, BigInteger.valueOf(12));
var noPoints = new CharacterRecord(UUID.randomUUID(), live.name(), BigInteger.ZERO, noPointsStats, live.level(),
live.experience(), BigInteger.ZERO, live.health(), live.learned(), live.loadout(), live.cooldowns(),
live.restrictedKills(), false, null, null);
repository.create(noPoints);
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
assertEquals(CharacterStorage.AbilityMutationStatus.NO_POINTS,
storage.unlockAbility(noPoints.id(), noPoints.revision(), "test_bolt",
java.util.Map.of(games.dmg.fantasystick.progression.Stat.INT, BigInteger.valueOf(12)), BigInteger.ONE).join().status());
assertEquals(noPoints, repository.load(noPoints.id()).orElseThrow());
}
}
@Test void abilityCooldownRefusesBeforeAndAllowsAtTheExactPersistedDeadline() throws Exception {
var repository = new YamlCharacterRepository(data);
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var learned = new CharacterRecord(base.id(), base.name(), base.revision(), base.stats(), base.level(),
base.experience(), base.unspentPoints(), base.health(), java.util.Set.of("fireball"),
java.util.List.of("fireball", "", "", "", "", "", ""), base.cooldowns(), base.restrictedKills(), false, null, null);
repository.create(learned);
var now = new java.util.concurrent.atomic.AtomicReference<>(java.time.Instant.parse("2026-09-19T20:00:00Z"));
var clock = new Clock() {
@Override public java.time.ZoneId getZone() { return java.time.ZoneOffset.UTC; }
@Override public Clock withZone(java.time.ZoneId zone) { return this; }
@Override public java.time.Instant instant() { return now.get(); }
};
try (var storage = new CharacterStorage(repository, clock)) {
var first = storage.commitAbilityCooldown(learned.id(), learned.revision(), "fireball", now.get().plusSeconds(4)).join();
now.set(now.get().plusMillis(3999));
assertEquals(CharacterStorage.AbilityMutationStatus.COOLDOWN,
storage.commitAbilityCooldown(learned.id(), first.record().orElseThrow().revision(), "fireball", now.get().plusSeconds(4)).join().status());
now.set(java.time.Instant.parse("2026-09-19T20:00:04Z"));
var exact = storage.commitAbilityCooldown(learned.id(), first.record().orElseThrow().revision(), "fireball",
now.get().plusSeconds(4)).join();
assertEquals(CharacterStorage.AbilityMutationStatus.APPLIED, exact.status());
assertEquals(java.time.Instant.parse("2026-09-19T20:00:08Z"), exact.record().orElseThrow().cooldowns().get("fireball"));
}
}
@Test void failedAbilityRefundLeavesLearnedStateAndPointsUnchangedForOneRetry() throws Exception {
var repository = new YamlCharacterRepository(data);
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var learned = new CharacterRecord(base.id(), base.name(), base.revision(), base.stats(), base.level(),
base.experience(), BigInteger.valueOf(39), base.health(), java.util.Set.of("fireball"),
java.util.List.of("fireball", "", "", "", "", "", ""),
java.util.Map.of("fireball", java.time.Instant.parse("2026-09-22T00:00:00Z")),
base.restrictedKills(), false, null, null);
repository.create(learned);
var failing = new YamlCharacterRepository(data, (source, target) -> { throw new IOException("simulated refund failure"); });
try (var storage = new CharacterStorage(failing, Clock.systemUTC())) {
assertThrows(CompletionException.class,
() -> storage.unlearnAbility(learned.id(), learned.revision(), "fireball").join());
}
assertEquals(learned, repository.load(learned.id()).orElseThrow());
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
var refund = storage.unlearnAbility(learned.id(), learned.revision(), "fireball").join();
assertEquals(CharacterStorage.AbilityMutationStatus.APPLIED, refund.status());
assertEquals(BigInteger.valueOf(40), refund.record().orElseThrow().unspentPoints());
assertEquals(CharacterStorage.StatPurchaseStatus.STALE,
storage.purchaseStat(learned.id(), learned.revision(), games.dmg.fantasystick.progression.Stat.STR,
learned.health(), new java.math.BigDecimal("1024")).join().status());
}
}
@Test void failedAbilityUnlockSaveLeavesPointsAndLearnedStateUnchangedForRetry() throws Exception {
var repository = new YamlCharacterRepository(data);
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
repository.create(base);
var failing = new YamlCharacterRepository(data, (source, target) -> { throw new IOException("simulated unlock failure"); });
try (var storage = new CharacterStorage(failing, Clock.systemUTC())) {
assertThrows(CompletionException.class, () -> storage.unlockAbility(base.id(), base.revision(), "test_focus",
java.util.Map.of(), BigInteger.ONE).join());
}
assertEquals(base, repository.load(base.id()).orElseThrow());
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
var retry = storage.unlockAbility(base.id(), base.revision(), "test_focus", java.util.Map.of(), BigInteger.ONE).join();
assertEquals(CharacterStorage.AbilityMutationStatus.APPLIED, retry.status());
assertTrue(retry.record().orElseThrow().learned().contains("test_focus"));
assertEquals(base.unspentPoints().subtract(BigInteger.ONE), retry.record().orElseThrow().unspentPoints());
}
}
@Test void nativeReviveBoundsFullHealthWithoutCappingTransferableCon() throws Exception {
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var stats = new java.util.EnumMap<>(base.stats());
stats.put(games.dmg.fantasystick.progression.Stat.CON, BigInteger.valueOf(5000));
var death = UUID.randomUUID(); var died = java.time.Instant.parse("2026-09-20T08:00:00Z");
var dead = new CharacterRecord(base.id(), base.name(), base.revision(), stats, base.level(), base.experience(),
base.unspentPoints(), java.math.BigDecimal.ZERO, base.learned(), base.loadout(), base.cooldowns(),
base.restrictedKills(), true, death, died);
var repository = new YamlCharacterRepository(data); repository.create(dead);
try (var storage = new CharacterStorage(repository,
Clock.fixed(java.time.Instant.parse("2026-09-20T09:00:00Z"), ZoneOffset.UTC))) {
var restored = storage.restoreDeathRecord(dead.id(), death, new java.math.BigDecimal("1024")).join().orElseThrow();
assertEquals(new java.math.BigDecimal("1024"), restored.health());
assertEquals(BigInteger.valueOf(5000), restored.stats().get(games.dmg.fantasystick.progression.Stat.CON));
}
}
@Test
void failedRevivalSaveLeavesDeadStateRetryableAndShutdownRejectsNewWork() throws Exception {
var saved = YamlCharacterRepositoryTest.sample(UUID.randomUUID());
@@ -0,0 +1,100 @@
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.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class FrostShardSettingsTest {
@TempDir Path data;
@Test void serializesConcurrentAcceptedKeysWithoutLosingUnrelatedConfiguration() throws Exception {
Files.writeString(data.resolve("config.yml"), "adventure-mode: true\nunrelated: retained\n");
try (var settings = new FrostShardSettings(data, defaults())) {
var damage = settings.set(FrostShardSettings.BASE_DAMAGE, "9").toCompletableFuture();
var range = settings.set(FrostShardSettings.RANGE, "40.5").toCompletableFuture();
var savedDamage = damage.get(5, TimeUnit.SECONDS);
var savedRange = range.get(5, TimeUnit.SECONDS);
assertEquals("9", savedDamage.value()); assertEquals("40.5", savedRange.value());
assertEquals(new java.math.BigInteger("4"), settings.settings().baseDamage(),
"Durability acknowledgment alone must not publish runtime settings");
settings.publish(savedRange); settings.publish(savedDamage); // stale callback cannot roll back the newer snapshot.
assertEquals(new java.math.BigInteger("9"), settings.settings().baseDamage());
assertEquals(40.5, settings.settings().range());
var saved = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
assertEquals(Boolean.TRUE, saved.get("adventure-mode"));
assertEquals("retained", saved.get("unrelated"));
assertEquals("9", String.valueOf(saved.get(FrostShardSettings.BASE_DAMAGE)));
assertEquals("40.5", String.valueOf(saved.get(FrostShardSettings.RANGE)));
}
}
@Test void rejectsMalformedAndOutOfRangeValuesBeforeQueueingOrChangingEffectiveState() {
try (var settings = new FrostShardSettings(data, defaults())) {
for (var entry : Map.of(
FrostShardSettings.BASE_DAMAGE, "-1",
FrostShardSettings.COOLDOWN, "31536001",
FrostShardSettings.RANGE, "0",
FrostShardSettings.LIFETIME, "0",
FrostShardSettings.SPEED, "Infinity",
FrostShardSettings.AMPLIFIER, "256",
FrostShardSettings.SLOWNESS, "0").entrySet()) {
assertThrows(IllegalArgumentException.class, () -> settings.set(entry.getKey(), entry.getValue()));
}
assertThrows(IllegalArgumentException.class, () -> settings.set(FrostShardSettings.BASE_DAMAGE, "1.5"));
assertThrows(IllegalArgumentException.class, () -> settings.set(FrostShardSettings.COOLDOWN, "0.0000000001"));
assertEquals("4", settings.value(FrostShardSettings.BASE_DAMAGE));
assertFalse(Files.exists(data.resolve("config.yml")));
}
}
@Test void failedAtomicReplacementRetainsEffectiveAndFileStateAndAllowsOneExplicitRetry() throws Exception {
Files.writeString(data.resolve("config.yml"), "frost-shard-base-damage: 4\n");
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
try (var settings = new FrostShardSettings(data, defaults(), (from, to) -> {
if (fail.getAndSet(false)) throw new java.io.IOException("simulated replacement failure");
Files.move(from, to, StandardCopyOption.REPLACE_EXISTING);
})) {
assertThrows(java.util.concurrent.ExecutionException.class, () ->
settings.set(FrostShardSettings.BASE_DAMAGE, "9").toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals("4", settings.value(FrostShardSettings.BASE_DAMAGE));
assertTrue(Files.readString(data.resolve("config.yml")).contains("4"));
var retry = settings.set(FrostShardSettings.BASE_DAMAGE, "9").toCompletableFuture()
.get(5, TimeUnit.SECONDS);
assertEquals("9", retry.value());
assertEquals("4", settings.value(FrostShardSettings.BASE_DAMAGE));
settings.publish(retry);
assertEquals("9", settings.value(FrostShardSettings.BASE_DAMAGE));
}
}
@Test void rejectsUnsafeConfigurationAndLockFilesAndPostCloseWrites() throws Exception {
if (!System.getProperty("os.name", "").toLowerCase().contains("win")) {
var external = data.resolveSibling(data.getFileName() + "-outside");
Files.writeString(external, "safe\n");
Files.createSymbolicLink(data.resolve("config.yml"), external);
try (var settings = new FrostShardSettings(data, defaults())) {
assertThrows(java.util.concurrent.ExecutionException.class, () ->
settings.set(FrostShardSettings.BASE_DAMAGE, "9").toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals("safe\n", Files.readString(external));
}
Files.delete(data.resolve("config.yml")); Files.deleteIfExists(external);
}
var settings = new FrostShardSettings(data, defaults()); settings.close();
assertThrows(java.util.concurrent.ExecutionException.class, () ->
settings.set(FrostShardSettings.BASE_DAMAGE, "9").toCompletableFuture().get(5, TimeUnit.SECONDS));
settings.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
}
private static Map<String, String> defaults() {
var result = new LinkedHashMap<String, String>();
for (String key : FrostShardSettings.keys()) result.put(key, FrostShardSettings.defaultValue(key));
return result;
}
}
@@ -0,0 +1,101 @@
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.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class LongbowSettingsTest {
@TempDir Path data;
@Test void serializesConcurrentAcceptedKeysWithoutLosingUnrelatedConfiguration() throws Exception {
Files.writeString(data.resolve("config.yml"), "adventure-mode: true\nunrelated: retained\n");
try (var settings = new LongbowSettings(data, defaults())) {
var damage = settings.set(LongbowSettings.BASE_DAMAGE, "9").toCompletableFuture();
var range = settings.set(LongbowSettings.RANGE, "40.5").toCompletableFuture();
var savedDamage = damage.get(5, TimeUnit.SECONDS);
var savedRange = range.get(5, TimeUnit.SECONDS);
assertEquals("9", savedDamage.value()); assertEquals("40.5", savedRange.value());
assertEquals(new java.math.BigInteger("5"), settings.settings().baseDamage(),
"Durability acknowledgment alone must not publish runtime settings");
settings.publish(savedRange); settings.publish(savedDamage); // stale callback cannot roll back the newer snapshot.
assertEquals(new java.math.BigInteger("9"), settings.settings().baseDamage());
assertEquals(40.5, settings.settings().range());
var saved = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
assertEquals(Boolean.TRUE, saved.get("adventure-mode"));
assertEquals("retained", saved.get("unrelated"));
assertEquals("9", String.valueOf(saved.get(LongbowSettings.BASE_DAMAGE)));
assertEquals("40.5", String.valueOf(saved.get(LongbowSettings.RANGE)));
}
}
@Test void rejectsMalformedAndOutOfRangeValuesBeforeQueueingOrChangingEffectiveState() {
try (var settings = new LongbowSettings(data, defaults())) {
for (var entry : Map.of(
LongbowSettings.BASE_DAMAGE, "-1",
LongbowSettings.RECOVERY, "0.999",
LongbowSettings.MINIMUM_DRAW, "0",
LongbowSettings.FULL_DRAW, "2",
LongbowSettings.RANGE, "0",
LongbowSettings.LIFETIME, "0",
LongbowSettings.FULL_SPEED, "Infinity").entrySet()) {
assertThrows(IllegalArgumentException.class, () -> settings.set(entry.getKey(), entry.getValue()));
}
assertThrows(IllegalArgumentException.class, () -> settings.set(LongbowSettings.BASE_DAMAGE, "1.5"));
assertThrows(IllegalArgumentException.class, () -> settings.set(LongbowSettings.BASE_DAMAGE, "1" + "0".repeat(309)));
assertThrows(IllegalArgumentException.class, () -> settings.set(LongbowSettings.RECOVERY, "1.0000000001"));
assertEquals("5", settings.value(LongbowSettings.BASE_DAMAGE));
assertFalse(Files.exists(data.resolve("config.yml")));
}
}
@Test void failedAtomicReplacementRetainsEffectiveAndFileStateAndAllowsOneExplicitRetry() throws Exception {
Files.writeString(data.resolve("config.yml"), "longbow-base-damage: 5\n");
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
try (var settings = new LongbowSettings(data, defaults(), (from, to) -> {
if (fail.getAndSet(false)) throw new java.io.IOException("simulated replacement failure");
Files.move(from, to, StandardCopyOption.REPLACE_EXISTING);
})) {
assertThrows(java.util.concurrent.ExecutionException.class, () ->
settings.set(LongbowSettings.BASE_DAMAGE, "9").toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals("5", settings.value(LongbowSettings.BASE_DAMAGE));
assertTrue(Files.readString(data.resolve("config.yml")).contains("5"));
var retry = settings.set(LongbowSettings.BASE_DAMAGE, "9").toCompletableFuture()
.get(5, TimeUnit.SECONDS);
assertEquals("9", retry.value());
assertEquals("5", settings.value(LongbowSettings.BASE_DAMAGE));
settings.publish(retry);
assertEquals("9", settings.value(LongbowSettings.BASE_DAMAGE));
}
}
@Test void rejectsUnsafeConfigurationAndLockFilesAndPostCloseWrites() throws Exception {
if (!System.getProperty("os.name", "").toLowerCase().contains("win")) {
var external = data.resolveSibling(data.getFileName() + "-outside");
Files.writeString(external, "safe\n");
Files.createSymbolicLink(data.resolve("config.yml"), external);
try (var settings = new LongbowSettings(data, defaults())) {
assertThrows(java.util.concurrent.ExecutionException.class, () ->
settings.set(LongbowSettings.BASE_DAMAGE, "9").toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals("safe\n", Files.readString(external));
}
Files.delete(data.resolve("config.yml")); Files.deleteIfExists(external);
}
var settings = new LongbowSettings(data, defaults()); settings.close();
assertThrows(java.util.concurrent.ExecutionException.class, () ->
settings.set(LongbowSettings.BASE_DAMAGE, "9").toCompletableFuture().get(5, TimeUnit.SECONDS));
settings.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
}
private static Map<String, String> defaults() {
var result = new LinkedHashMap<String, String>();
for (String key : LongbowSettings.keys()) result.put(key, LongbowSettings.defaultValue(key));
return result;
}
}
@@ -0,0 +1,82 @@
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.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class QuickstepSettingsTest {
@TempDir Path data;
@Test void serializesConcurrentKeysPreservesUnrelatedAndPublishesOnlyAcknowledgedSnapshots() throws Exception {
Files.writeString(data.resolve("config.yml"), "adventure-mode: true\nunrelated: retained\n");
try (var settings = new QuickstepSettings(data, defaults())) {
var distance = settings.set(QuickstepSettings.DISTANCE, "6").toCompletableFuture();
var speed = settings.set(QuickstepSettings.SPEED, "1.5").toCompletableFuture();
var savedDistance = distance.get(5, TimeUnit.SECONDS); var savedSpeed = speed.get(5, TimeUnit.SECONDS);
assertEquals(4, settings.settings().distance(), 0);
settings.publish(savedSpeed); settings.publish(savedDistance);
assertEquals(6, settings.settings().distance(), 0); assertEquals(1.5, settings.settings().speedPerTick(), 0);
var saved = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
assertEquals(Boolean.TRUE, saved.get("adventure-mode")); assertEquals("retained", saved.get("unrelated"));
assertEquals("6", String.valueOf(saved.get(QuickstepSettings.DISTANCE)));
assertEquals("1.5", String.valueOf(saved.get(QuickstepSettings.SPEED)));
}
}
@Test void rejectsMalformedOrOutOfRangeValuesBeforeQueueing() {
try (var settings = new QuickstepSettings(data, defaults())) {
for (var entry : Map.of(QuickstepSettings.COOLDOWN, "-1", QuickstepSettings.DISTANCE, "0",
QuickstepSettings.SPEED, "Infinity").entrySet()) {
assertThrows(IllegalArgumentException.class, () -> settings.set(entry.getKey(), entry.getValue()));
}
assertThrows(IllegalArgumentException.class, () -> settings.set(QuickstepSettings.COOLDOWN, "0.0000000001"));
assertEquals("4", settings.value(QuickstepSettings.DISTANCE));
assertFalse(Files.exists(data.resolve("config.yml")));
}
}
@Test void failedReplacementAndUnsafeFilesRetainEffectiveStateAndAllowRetry() throws Exception {
Files.writeString(data.resolve("config.yml"), "quickstep-distance-blocks: 4\n");
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
try (var settings = new QuickstepSettings(data, defaults(), (from, to) -> {
if (fail.getAndSet(false)) throw new java.io.IOException("simulated failure");
Files.move(from, to, StandardCopyOption.REPLACE_EXISTING);
})) {
assertThrows(java.util.concurrent.ExecutionException.class, () ->
settings.set(QuickstepSettings.DISTANCE, "6").toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals("4", settings.value(QuickstepSettings.DISTANCE));
var retry = settings.set(QuickstepSettings.DISTANCE, "6").toCompletableFuture().get(5, TimeUnit.SECONDS);
assertEquals("4", settings.value(QuickstepSettings.DISTANCE)); settings.publish(retry);
assertEquals("6", settings.value(QuickstepSettings.DISTANCE));
}
if (!System.getProperty("os.name", "").toLowerCase().contains("win")) {
var external = data.resolveSibling(data.getFileName() + "-outside"); Files.writeString(external, "safe\n");
Files.delete(data.resolve("config.yml")); Files.createSymbolicLink(data.resolve("config.yml"), external);
try (var settings = new QuickstepSettings(data, defaults())) {
assertThrows(java.util.concurrent.ExecutionException.class, () ->
settings.set(QuickstepSettings.DISTANCE, "7").toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals("safe\n", Files.readString(external));
}
}
}
@Test void closeRejectsNewWritesAndCompletesDrain() throws Exception {
var settings = new QuickstepSettings(data, defaults()); settings.close();
assertThrows(java.util.concurrent.ExecutionException.class, () ->
settings.set(QuickstepSettings.DISTANCE, "6").toCompletableFuture().get(5, TimeUnit.SECONDS));
settings.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
}
private static Map<String, String> defaults() {
var result = new LinkedHashMap<String, String>();
for (String key : QuickstepSettings.keys()) result.put(key, QuickstepSettings.defaultValue(key));
return result;
}
}
@@ -0,0 +1,58 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.UUID;
import java.util.concurrent.CompletionException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class RevivalStoreTest {
@TempDir Path data;
@Test void oneReservationRoundTripsAcrossReopenAndClearsOnlyWithItsToken() throws Exception {
var value = reservation();
try (var store = new RevivalStore(data)) {
assertEquals(value, store.reserve(value).join().orElseThrow());
assertTrue(store.reserve(new RevivalStore.Reservation(UUID.randomUUID(), value.casterCharacter(),
value.targetPlayer(), value.character(), value.death(), value.createdAt(), value.sourceSlot(),
value.sourceAmount(), value.destinationSlot())).join().isEmpty());
assertEquals(value, store.find(value.character()).join().orElseThrow());
assertEquals(java.util.List.of(value), store.target(value.targetPlayer()).join());
assertFalse(store.clear(value.character(), UUID.randomUUID()).join());
}
try (var reopened = new RevivalStore(data)) {
assertEquals(value, reopened.find(value.character()).join().orElseThrow());
assertTrue(reopened.clear(value.character(), value.id()).join());
assertTrue(reopened.find(value.character()).join().isEmpty());
}
}
@Test void malformedOversizedAndSymlinkedReservationStateIsRetainedAndRejected() throws Exception {
var value = reservation();
try (var store = new RevivalStore(data)) { store.reserve(value).join(); }
Path file = data.resolve("revivals").resolve(value.character() + ".yml");
String valid = Files.readString(file);
Files.writeString(file, valid + "unknown: value\n");
try (var store = new RevivalStore(data)) {
assertThrows(CompletionException.class, () -> store.find(value.character()).join());
}
assertTrue(Files.readString(file).endsWith("unknown: value\n"));
Files.delete(file);
Path outside = data.resolve("outside.yml"); Files.writeString(outside, valid);
try {
Files.createSymbolicLink(file, outside);
try (var store = new RevivalStore(data)) {
assertThrows(CompletionException.class, () -> store.find(value.character()).join());
}
} catch (UnsupportedOperationException ignored) { }
}
private static RevivalStore.Reservation reservation() {
return new RevivalStore.Reservation(UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(),
UUID.randomUUID(), Instant.parse("2026-09-20T10:00:00Z"), 10, 2, 12);
}
}
@@ -0,0 +1,70 @@
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.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class SecondWindSettingsTest {
@TempDir Path data;
@Test void serializesConcurrentWritesPreservesUnrelatedAndPublishesAcknowledgedSnapshots() throws Exception {
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
try (var settings = new SecondWindSettings(data, defaults())) {
var healing = settings.set(SecondWindSettings.BASE_HEALING, "9").toCompletableFuture();
var cooldown = settings.set(SecondWindSettings.COOLDOWN, "20").toCompletableFuture();
var h = healing.get(5, TimeUnit.SECONDS); var c = cooldown.get(5, TimeUnit.SECONDS);
assertEquals("6", settings.value(SecondWindSettings.BASE_HEALING)); settings.publish(c); settings.publish(h);
assertEquals("9", settings.value(SecondWindSettings.BASE_HEALING));
assertEquals(java.time.Duration.ofSeconds(20), settings.settings().cooldown());
var saved = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
assertEquals("retained", saved.get("unrelated")); assertEquals("9", String.valueOf(saved.get(SecondWindSettings.BASE_HEALING)));
}
}
@Test void rejectsInvalidValuesAndFailedReplacementRetainsState() throws Exception {
try (var settings = new SecondWindSettings(data, defaults())) {
assertThrows(IllegalArgumentException.class, () -> settings.set(SecondWindSettings.BASE_HEALING, "-1"));
assertThrows(IllegalArgumentException.class, () -> settings.set(SecondWindSettings.BASE_HEALING, "1.5"));
assertThrows(IllegalArgumentException.class, () -> settings.set(SecondWindSettings.COOLDOWN, "-1"));
assertThrows(IllegalArgumentException.class, () -> settings.set(SecondWindSettings.COOLDOWN, "0.0000000001"));
}
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
try (var settings = new SecondWindSettings(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(SecondWindSettings.BASE_HEALING, "9").toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals("6", settings.value(SecondWindSettings.BASE_HEALING));
}
}
@Test void rejectsUnsafeFileAndPostCloseWrites() throws Exception {
if (!System.getProperty("os.name", "").toLowerCase().contains("win")) {
var external = data.resolveSibling(data.getFileName() + "-outside"); Files.writeString(external, "safe\n");
Files.createSymbolicLink(data.resolve("config.yml"), external);
try (var settings = new SecondWindSettings(data, defaults())) {
assertThrows(java.util.concurrent.ExecutionException.class, () ->
settings.set(SecondWindSettings.BASE_HEALING, "9").toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals("safe\n", Files.readString(external));
}
}
var other = data.resolve("closed"); var settings = new SecondWindSettings(other, defaults()); settings.close();
assertThrows(java.util.concurrent.ExecutionException.class, () ->
settings.set(SecondWindSettings.BASE_HEALING, "9").toCompletableFuture().get(5, TimeUnit.SECONDS));
settings.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
}
private static Map<String, String> defaults() {
var result = new LinkedHashMap<String, String>();
for (String key : SecondWindSettings.keys()) result.put(key, SecondWindSettings.defaultValue(key));
return result;
}
}
@@ -0,0 +1,57 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ShieldBashSettingsTest {
@TempDir Path data;
@Test void serializesConcurrentKeysPreservesUnrelatedAndPublishesAcknowledgedSnapshots() throws Exception {
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
try (var settings = new ShieldBashSettings(data, defaults())) {
var range = settings.set(ShieldBashSettings.RANGE, "5").toCompletableFuture();
var horizontal = settings.set(ShieldBashSettings.HORIZONTAL, "1.2").toCompletableFuture();
var r = range.get(5, TimeUnit.SECONDS); var h = horizontal.get(5, TimeUnit.SECONDS);
assertEquals(3, settings.settings().range(), 0); settings.publish(h); settings.publish(r);
assertEquals(5, settings.settings().range(), 0); assertEquals(1.2, settings.settings().horizontalKnockback(), 0);
var saved = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
assertEquals("retained", saved.get("unrelated")); assertEquals("5", String.valueOf(saved.get(ShieldBashSettings.RANGE)));
assertEquals("1.2", String.valueOf(saved.get(ShieldBashSettings.HORIZONTAL)));
}
}
@Test void rejectsInvalidValuesAndFailedReplacementRetainsState() throws Exception {
try (var settings = new ShieldBashSettings(data, defaults())) {
for (var entry : Map.of(ShieldBashSettings.BASE_DAMAGE, "-1", ShieldBashSettings.COOLDOWN, "-1",
ShieldBashSettings.RANGE, "0", ShieldBashSettings.HORIZONTAL, "9",
ShieldBashSettings.VERTICAL, "Infinity").entrySet())
assertThrows(IllegalArgumentException.class, () -> settings.set(entry.getKey(), entry.getValue()));
assertThrows(IllegalArgumentException.class, () -> settings.set(ShieldBashSettings.COOLDOWN, "0.0000000001"));
assertThrows(IllegalArgumentException.class, () -> settings.set(ShieldBashSettings.BASE_DAMAGE, "1.5"));
}
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
try (var settings = new ShieldBashSettings(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(ShieldBashSettings.RANGE, "5").toCompletableFuture().get(5,TimeUnit.SECONDS));
assertEquals("3", settings.value(ShieldBashSettings.RANGE));
}
}
@Test void unsafeFileAndClosedStoreFailSafely() throws Exception {
if (!System.getProperty("os.name","").toLowerCase().contains("win")) {
var external=data.resolveSibling(data.getFileName()+"-outside"); Files.writeString(external,"safe\n");
Files.createSymbolicLink(data.resolve("config.yml"),external);
try(var settings=new ShieldBashSettings(data,defaults())) {
assertThrows(java.util.concurrent.ExecutionException.class,()->settings.set(ShieldBashSettings.RANGE,"5").toCompletableFuture().get(5,TimeUnit.SECONDS));
assertEquals("safe\n",Files.readString(external));
}
}
var settings=new ShieldBashSettings(data.resolve("closed"),defaults()); settings.close();
assertThrows(java.util.concurrent.ExecutionException.class,()->settings.set(ShieldBashSettings.RANGE,"5").toCompletableFuture().get(5,TimeUnit.SECONDS));
settings.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);
}
private static Map<String,String> defaults(){var result=new LinkedHashMap<String,String>();for(String key:ShieldBashSettings.keys())result.put(key,ShieldBashSettings.defaultValue(key));return result;}
}
@@ -0,0 +1,69 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class StoneSkinSettingsTest {
@TempDir Path data;
@Test void serializesConcurrentKeysPreservesUnrelatedAndPublishesAcknowledgedSnapshots() throws Exception {
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
try (var settings = new StoneSkinSettings(data, defaults())) {
var duration = settings.set(StoneSkinSettings.DURATION, "6").toCompletableFuture();
var reduction = settings.set(StoneSkinSettings.REDUCTION, "40").toCompletableFuture();
var d = duration.get(5, TimeUnit.SECONDS); var r = reduction.get(5, TimeUnit.SECONDS);
assertEquals(java.time.Duration.ofSeconds(4), settings.settings().duration());
settings.publish(r); settings.publish(d);
assertEquals(java.time.Duration.ofSeconds(6), settings.settings().duration());
assertEquals(0, new java.math.BigDecimal("40").compareTo(settings.settings().reductionPercent()));
var saved = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
assertEquals("retained", saved.get("unrelated"));
assertEquals("6", String.valueOf(saved.get(StoneSkinSettings.DURATION)));
assertEquals("40", String.valueOf(saved.get(StoneSkinSettings.REDUCTION)));
}
}
@Test void rejectsInvalidValuesAndFailedReplacementRetainsState() throws Exception {
try (var settings = new StoneSkinSettings(data, defaults())) {
for (var entry : Map.of(StoneSkinSettings.COOLDOWN, "-1", StoneSkinSettings.DURATION, "0",
StoneSkinSettings.REDUCTION, "101").entrySet())
assertThrows(IllegalArgumentException.class, () -> settings.set(entry.getKey(), entry.getValue()));
assertThrows(IllegalArgumentException.class, () -> settings.set(StoneSkinSettings.DURATION, "0.0000000001"));
}
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
try (var settings = new StoneSkinSettings(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(StoneSkinSettings.DURATION, "6").toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals("4", settings.value(StoneSkinSettings.DURATION));
}
}
@Test void unsafeFileAndClosedStoreFailSafely() throws Exception {
if (!System.getProperty("os.name", "").toLowerCase().contains("win")) {
var external = data.resolveSibling(data.getFileName() + "-outside"); Files.writeString(external, "safe\n");
Files.createSymbolicLink(data.resolve("config.yml"), external);
try (var settings = new StoneSkinSettings(data, defaults())) {
assertThrows(java.util.concurrent.ExecutionException.class, () ->
settings.set(StoneSkinSettings.DURATION, "6").toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals("safe\n", Files.readString(external));
}
}
var directory = data.resolve("closed"); var settings = new StoneSkinSettings(directory, defaults()); settings.close();
assertThrows(java.util.concurrent.ExecutionException.class, () ->
settings.set(StoneSkinSettings.DURATION, "6").toCompletableFuture().get(5, TimeUnit.SECONDS));
settings.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
}
private static Map<String,String> defaults() {
var result = new LinkedHashMap<String,String>();
for (String key : StoneSkinSettings.keys()) result.put(key, StoneSkinSettings.defaultValue(key));
return result;
}
}
@@ -43,6 +43,29 @@ class YamlCharacterRepositoryTest {
original.loadout(), original.cooldowns(), original.dead(), original.deathId(), original.diedAt());
}
@Test
void restrictedKillHistoryRoundTripsPerWorldAndOldRecordsRemainCompatible() throws Exception {
var original = sample(UUID.randomUUID());
var worldA = UUID.randomUUID(); var worldB = UUID.randomUUID();
var history = Map.of(
worldA, new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(worldA,
new BigDecimal("1.25"), new BigDecimal("-64"), new BigDecimal("3E+2")),
worldB, new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(worldB,
BigDecimal.ZERO, BigDecimal.ONE, BigDecimal.TEN));
var withHistory = new CharacterRecord(original.id(), original.name(), original.revision(), original.stats(),
original.level(), original.experience(), original.unspentPoints(), original.health(), original.learned(),
original.loadout(), original.cooldowns(), history, original.dead(), original.deathId(), original.diedAt());
var repository = new YamlCharacterRepository(data);
repository.create(withHistory);
assertEquals(withHistory, repository.load(withHistory.id()).orElseThrow());
String yaml = Files.readString(data.resolve("characters").resolve(withHistory.id() + ".yml"));
assertTrue(yaml.contains("restricted-kills:"));
assertTrue(yaml.contains(worldA.toString()));
String legacy = CharacterYaml.encode(original).replace("restricted-kills: []\n", "");
assertEquals(Map.of(), CharacterYaml.decode(legacy).restrictedKills());
}
@Test
void repeatedCreationAndAbandonmentDoNotAccumulatePerCharacterCoordinationFiles() throws Exception {
var repository = new YamlCharacterRepository(data);
@@ -0,0 +1,31 @@
package games.dmg.fantasystick.progression;
import static org.junit.jupiter.api.Assertions.*;
import java.time.Instant;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class CombatContributionsTest {
@Test void deduplicatesCharactersRefreshesTheirDeadlineAndConsumesATargetOnce() {
var ledger = new CombatContributions(CombatRewardRules.defaults());
var target = UUID.randomUUID(); var a = UUID.randomUUID(); var b = UUID.randomUUID();
var start = Instant.parse("2026-09-19T20:00:00Z");
ledger.accept(target, a, start);
ledger.accept(target, b, start);
ledger.accept(target, a, start.plusSeconds(60));
assertEquals(Set.of(a), ledger.consume(target, start.plusSeconds(301)), "Repeated accepted damage refreshes only its character");
assertEquals(Set.of(), ledger.consume(target, start.plusSeconds(301)), "One target is awarded once");
}
@Test void invalidationAndCloseBoundMemoryAndRefuseLaterDamage() {
var ledger = new CombatContributions(CombatRewardRules.defaults());
var target = UUID.randomUUID(); var character = UUID.randomUUID();
var now = Instant.parse("2026-09-19T20:00:00Z");
ledger.accept(target, character, now); ledger.invalidate(target);
assertEquals(Set.of(), ledger.consume(target, now));
ledger.accept(target, character, now); ledger.close(); ledger.accept(target, character, now);
assertEquals(Set.of(), ledger.consume(target, now));
}
}
@@ -0,0 +1,51 @@
package games.dmg.fantasystick.progression;
import static org.junit.jupiter.api.Assertions.*;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.time.Instant;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class CombatRewardRulesTest {
@Test void poolsAndRoundedSharesPreserveApprovedArithmetic() {
var rules = new CombatRewardRules(BigInteger.ONE, BigInteger.TEN, BigInteger.valueOf(100), new BigDecimal("3"));
assertEquals(BigInteger.ONE, rules.pool(CombatRewardRules.Target.PASSIVE, 99));
assertEquals(BigInteger.TEN, rules.pool(CombatRewardRules.Target.ORDINARY, 99));
assertEquals(BigInteger.valueOf(100), rules.pool(CombatRewardRules.Target.BOSS, 99));
assertEquals(BigInteger.valueOf(70), rules.pool(CombatRewardRules.Target.FANTASY_CHARACTER, 7));
assertEquals(BigInteger.valueOf(4), rules.share(BigInteger.TEN, 3));
assertEquals(BigInteger.ONE, rules.share(BigInteger.ONE, 3));
assertEquals(BigInteger.valueOf(34), rules.share(BigInteger.valueOf(100), 3));
assertEquals(BigInteger.valueOf(24), rules.share(BigInteger.valueOf(70), 3));
}
@Test void contributionWindowIncludesExactFiveMinuteBoundaryAndRejectsFutureTimes() {
var rules = CombatRewardRules.defaults();
var hit = Instant.parse("2026-09-19T20:00:00Z");
assertTrue(rules.contributes(hit, hit.plus(Duration.ofMinutes(5))));
assertFalse(rules.contributes(hit, hit.plus(Duration.ofMinutes(5)).plusNanos(1)));
assertFalse(rules.contributes(hit.plusSeconds(1), hit));
}
@Test void restrictedSpacingIsPerWorldAndUsesStraightLineThreeDimensionalDistance() {
var rules = CombatRewardRules.defaults();
var a = UUID.randomUUID(); var b = UUID.randomUUID();
var origin = new CombatRewardRules.KillPosition(a, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO);
assertFalse(rules.spaced(origin, new CombatRewardRules.KillPosition(a, new BigDecimal("2.999"), BigDecimal.ZERO, BigDecimal.ZERO)));
assertTrue(rules.spaced(origin, new CombatRewardRules.KillPosition(a, new BigDecimal("1"), new BigDecimal("2"), new BigDecimal("2"))));
assertTrue(rules.spaced(origin, new CombatRewardRules.KillPosition(b, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO)));
}
@Test void rejectsInvalidRewardConfigurationAndArithmeticInputs() {
assertAll(
() -> assertThrows(IllegalArgumentException.class, () -> new CombatRewardRules(BigInteger.valueOf(-1), BigInteger.TEN, BigInteger.TEN, BigDecimal.ONE)),
() -> assertThrows(IllegalArgumentException.class, () -> new CombatRewardRules(BigInteger.ONE, BigInteger.TEN, BigInteger.TEN, new BigDecimal("-0.1"))),
() -> assertThrows(IllegalArgumentException.class, () -> CombatRewardRules.defaults().share(BigInteger.ONE, 0)),
() -> assertThrows(IllegalArgumentException.class, () -> CombatRewardRules.defaults().pool(CombatRewardRules.Target.FANTASY_CHARACTER, 0)),
() -> assertThrows(IllegalArgumentException.class, () -> new CombatRewardRules.KillPosition(UUID.randomUUID(), new BigDecimal("1E1000000"), BigDecimal.ZERO, BigDecimal.ZERO))
);
}
}