Compare commits

...
6 Commits
Author SHA1 Message Date
dmg 8119673c9f feat(admin): control roleplay availability
Release / release (push) Successful in 7m8s
2026-09-20 14:08:39 -04:00
dmg cc5300bb03 feat(admin): configure character switch cooldown
Release / release (push) Successful in 8m5s
2026-09-20 13:56:29 -04:00
dmg 293d28ca29 feat(admin): revive characters from Tails
Release / release (push) Successful in 7m10s
2026-09-20 13:38:30 -04:00
dmg c530576262 feat(admin): issue new character sheets
Release / release (push) Successful in 7m5s
2026-09-20 13:17:27 -04:00
dmg e850e33975 feat(ability): add Inspire
Release / release (push) Successful in 7m18s
2026-09-20 13:02:56 -04:00
dmg 20abd2fa6e feat(ability): add Hex
Release / release (push) Successful in 7m17s
2026-09-20 12:43:14 -04:00
39 changed files with 1834 additions and 65 deletions

No files matched your search

@@ -60,11 +60,21 @@ public final class FantasyStickPlugin extends JavaPlugin {
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.ability.OutgoingDamageMitigation outgoingDamageMitigation;
private games.dmg.fantasystick.ability.Hex hex;
private games.dmg.fantasystick.ability.TemporaryHealth temporaryHealth;
private games.dmg.fantasystick.ability.Inspire inspire;
private games.dmg.fantasystick.persistence.InspireSettings inspireSettings;
private games.dmg.fantasystick.persistence.HexSettings hexSettings;
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.AdminRevival adminRevival;
private games.dmg.fantasystick.persistence.AdminReviveSettings adminReviveSettings;
private games.dmg.fantasystick.persistence.SwitchDelaySettings switchDelaySettings;
private games.dmg.fantasystick.persistence.RoleplaySettings roleplaySettings;
private games.dmg.fantasystick.session.NutritionPolicy nutritionPolicy;
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
@@ -88,7 +98,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
reloadConfig();
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 switchDelay = games.dmg.fantasystick.persistence.SwitchDelaySettings.parse(
getConfig().get(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY));
var roleplayDisabled = games.dmg.fantasystick.persistence.RoleplaySettings.parse(
getConfig().get(games.dmg.fantasystick.persistence.RoleplaySettings.KEY));
var rules = progressionRules();
var rewardRules = combatRewardRules();
var fireballSettings = fireballSettings();
@@ -105,6 +118,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
var stoneSkinInitial = games.dmg.fantasystick.persistence.StoneSkinSettings.parse(stoneSkinConfiguration);
var shieldBashConfiguration = shieldBashConfiguration();
var shieldBashInitial = games.dmg.fantasystick.persistence.ShieldBashSettings.parse(shieldBashConfiguration);
var hexConfiguration = hexConfiguration();
var hexInitial = games.dmg.fantasystick.persistence.HexSettings.parse(hexConfiguration);
var inspireConfiguration = inspireConfiguration();
var inspireInitial = games.dmg.fantasystick.persistence.InspireSettings.parse(inspireConfiguration);
var adminReviveTimeout = games.dmg.fantasystick.persistence.AdminReviveSettings.parse(
getConfig().get(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY));
var factory = new CharacterFactory();
characters = new CharacterStorage(new YamlCharacterRepository(getDataFolder().toPath()), Clock.systemUTC());
crafting = new ManualCraftingController(characters, () -> factory.create(rules),
@@ -141,8 +160,21 @@ public final class FantasyStickPlugin extends JavaPlugin {
getLogger().log(Level.WARNING, "Equipment transition/recovery for player " + problem.player()
+ "; session " + problem.session(), problem.cause());
gameModes = new games.dmg.fantasystick.session.AdventureModeLease();
switchDelaySettings = new games.dmg.fantasystick.persistence.SwitchDelaySettings(getDataFolder().toPath(), switchDelay);
roleplaySettings = new games.dmg.fantasystick.persistence.RoleplaySettings(getDataFolder().toPath(), roleplayDisabled);
sessions = new EquipmentSessions(characters, receipts, switchDelay, Clock.systemUTC(), nextTick,
notice -> notifyPlayer(generation, notice), diagnostics, presentation, adventureSettings::enabled, gameModes);
sessions.attachSwitchDelay(switchDelaySettings::delay);
sessions.attachRoleplayDisabled(roleplaySettings::disabled);
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);
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()));
abilityRegistry = new games.dmg.fantasystick.ability.AbilityRegistry();
fireball = new games.dmg.fantasystick.ability.Fireball(fireballSettings);
frostShardSettings = new games.dmg.fantasystick.persistence.FrostShardSettings(
@@ -168,6 +200,14 @@ public final class FantasyStickPlugin extends JavaPlugin {
shieldBash = new games.dmg.fantasystick.ability.ShieldBash(
shieldBashInitial,
event -> getServer().getPluginManager().callEvent(event));
outgoingDamageMitigation = new games.dmg.fantasystick.ability.OutgoingDamageMitigation(sessions, parties,
support -> combatExperience.support(support.target(), support.supportingCharacter(), support.aided(), support.amount()));
hexSettings = new games.dmg.fantasystick.persistence.HexSettings(getDataFolder().toPath(), hexConfiguration);
hex = new games.dmg.fantasystick.ability.Hex(hexInitial, outgoingDamageMitigation);
temporaryHealth = new games.dmg.fantasystick.ability.TemporaryHealth(sessions, parties, nextTick,
support -> combatExperience.support(support.target(), support.supportingCharacter(), support.aided(), support.amount()));
inspireSettings = new games.dmg.fantasystick.persistence.InspireSettings(getDataFolder().toPath(), inspireConfiguration);
inspire = new games.dmg.fantasystick.ability.Inspire(inspireInitial, temporaryHealth);
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,
@@ -180,26 +220,23 @@ public final class FantasyStickPlugin extends JavaPlugin {
abilityRegistry.register(secondWind.definition());
abilityRegistry.register(stoneSkin.definition());
abilityRegistry.register(shieldBash.definition());
abilityRegistry.register(hex.definition());
abilityRegistry.register(inspire.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);
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;
};
adminReviveSettings = new games.dmg.fantasystick.persistence.AdminReviveSettings(getDataFolder().toPath(), adminReviveTimeout);
adminRevival = new games.dmg.fantasystick.session.AdminRevival(characters, revivals, Clock.systemUTC(), nextTick,
abilityScheduler::later, (size, title) -> getServer().createInventory(null, size, title),
problem -> getLogger().log(Level.WARNING, "Admin revival failed", problem));
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()
@@ -225,7 +262,15 @@ public final class FantasyStickPlugin extends JavaPlugin {
+ " for player " + problem.player(), problem.cause()), adventureSettings,
frostShardSettings, frostShard, longbowSettings, longbow,
quickstepSettings, quickstep, secondWindSettings, secondWind,
stoneSkinSettings, stoneSkin, shieldBashSettings, shieldBash, abilityRegistry);
stoneSkinSettings, stoneSkin, shieldBashSettings, shieldBash, hexSettings, hex,
inspireSettings, inspire, abilityRegistry);
admin.attachAdminRevival(adminRevival, adminReviveSettings);
admin.attachSwitchDelay(switchDelaySettings);
admin.attachRoleplaySettings(roleplaySettings);
admin.attachCharacterIssuer(new games.dmg.fantasystick.session.AdminCharacterIssuer(characters,
() -> factory.create(rules), nextTick, problem -> getLogger().log(Level.WARNING,
"Admin character issue " + problem.operation() + " for player " + problem.player()
+ "; character " + problem.character(), problem.cause())));
var command = java.util.Objects.requireNonNull(getCommand("fantasystick"), "Missing packaged fantasystick command");
command.setExecutor(admin);
command.setTabCompleter(admin);
@@ -245,8 +290,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
getServer().getPluginManager().registerEvents(quickstep, this);
getServer().getPluginManager().registerEvents(combatMitigation, this);
getServer().getPluginManager().registerEvents(shieldBash, this);
getServer().getPluginManager().registerEvents(outgoingDamageMitigation, this);
getServer().getPluginManager().registerEvents(temporaryHealth, this);
getServer().getPluginManager().registerEvents(greatSword, this);
getServer().getPluginManager().registerEvents(revive, this);
getServer().getPluginManager().registerEvents(adminRevival, this);
getServer().getPluginManager().registerEvents(localBasicAttack, this);
getServer().getPluginManager().registerEvents(nutritionPolicy, this);
getServer().getPluginManager().registerEvents(books, this);
@@ -299,6 +347,16 @@ public final class FantasyStickPlugin extends JavaPlugin {
secondWind = null;
var closingStoneSkin = stoneSkin; stoneSkin = null;
var closingShieldBash = shieldBash; shieldBash = null;
var closingHex = hex; hex = null;
var closingInspire = inspire; inspire = null;
var closingInspireSettings = inspireSettings; inspireSettings = null;
var closingAdminRevival = adminRevival; adminRevival = null;
var closingAdminReviveSettings = adminReviveSettings; adminReviveSettings = null;
var closingSwitchDelaySettings = switchDelaySettings; switchDelaySettings = null;
var closingRoleplaySettings = roleplaySettings; roleplaySettings = null;
var closingTemporaryHealth = temporaryHealth; temporaryHealth = null;
var closingHexSettings = hexSettings; hexSettings = null;
var closingOutgoingDamageMitigation = outgoingDamageMitigation; outgoingDamageMitigation = null;
var closingShieldBashSettings = shieldBashSettings; shieldBashSettings = null;
var closingCombatMitigation = combatMitigation; combatMitigation = null;
var closingStoneSkinSettings = stoneSkinSettings; stoneSkinSettings = null;
@@ -319,6 +377,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
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);
if (closingHexSettings != null) cleanup("Hex configuration", closingHexSettings::close);
if (closingInspireSettings != null) cleanup("Inspire configuration", closingInspireSettings::close);
if (closingAdminReviveSettings != null) cleanup("admin revival configuration", closingAdminReviveSettings::close);
if (closingSwitchDelaySettings != null) cleanup("switch delay configuration", closingSwitchDelaySettings::close);
if (closingRoleplaySettings != null) cleanup("roleplay configuration", closingRoleplaySettings::close);
configurationDrain = java.util.concurrent.CompletableFuture.allOf(
closingSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingSettings.drained().toCompletableFuture(),
@@ -333,8 +396,19 @@ public final class FantasyStickPlugin extends JavaPlugin {
closingStoneSkinSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingStoneSkinSettings.drained().toCompletableFuture(),
closingShieldBashSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingShieldBashSettings.drained().toCompletableFuture());
: closingShieldBashSettings.drained().toCompletableFuture(),
closingHexSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingHexSettings.drained().toCompletableFuture(),
closingInspireSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingInspireSettings.drained().toCompletableFuture(),
closingAdminReviveSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingAdminReviveSettings.drained().toCompletableFuture(),
closingSwitchDelaySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingSwitchDelaySettings.drained().toCompletableFuture(),
closingRoleplaySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingRoleplaySettings.drained().toCompletableFuture());
if (closingBooks != null) cleanup("character book", closingBooks::close);
if (closingAdminRevival != null) cleanup("admin revival", closingAdminRevival::close);
if (closingBasicAttack != null) cleanup("basic attack", closingBasicAttack::close);
if (closingCombatExperience != null) cleanup("combat experience", closingCombatExperience::close);
if (closingFireball != null) cleanup("Fireball", closingFireball::close);
@@ -343,6 +417,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (closingQuickstep != null) cleanup("Quickstep", closingQuickstep::close);
if (closingStoneSkin != null) cleanup("Stone Skin", closingStoneSkin::close);
if (closingShieldBash != null) cleanup("Shield Bash", closingShieldBash::close);
if (closingHex != null) cleanup("Hex", closingHex::close);
if (closingInspire != null) cleanup("Inspire", closingInspire::close);
if (closingTemporaryHealth != null) cleanup("temporary health", closingTemporaryHealth::close);
if (closingOutgoingDamageMitigation != null) cleanup("outgoing damage mitigation", closingOutgoingDamageMitigation::close);
if (closingCombatMitigation != null) cleanup("combat mitigation", closingCombatMitigation::close);
if (closingGreatSword != null) cleanup("Great Sword", closingGreatSword::close);
if (closingRevive != null) cleanup("Revive", closingRevive::close);
@@ -366,8 +444,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
unregister(closingQuickstep);
unregister(closingCombatMitigation);
unregister(closingShieldBash);
unregister(closingOutgoingDamageMitigation);
unregister(closingTemporaryHealth);
unregister(closingGreatSword);
unregister(closingRevive);
unregister(closingAdminRevival);
unregister(closingBasicAttack);
unregister(closingNutrition);
unregister(closingBooks);
@@ -498,6 +579,24 @@ public final class FantasyStickPlugin extends JavaPlugin {
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> hexConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.HexSettings.keys()) {
values.put(key, getConfig().getString(key, games.dmg.fantasystick.persistence.HexSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.HexSettings.parse(values);
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> inspireConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.InspireSettings.keys()) {
values.put(key, getConfig().getString(key, games.dmg.fantasystick.persistence.InspireSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.InspireSettings.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"));
@@ -509,24 +608,6 @@ public final class FantasyStickPlugin extends JavaPlugin {
}
}
private java.time.Duration switchDelay() {
Object configured = getConfig().get("character-swap-cooldown-seconds", 900);
String value = String.valueOf(configured);
if (!value.matches("[0-9]+")) {
throw new IllegalArgumentException("character-swap-cooldown-seconds must be a nonnegative integer");
}
try {
var delay = java.time.Duration.ofSeconds(new java.math.BigInteger(value).longValueExact());
var deadline = java.time.Instant.now().plus(delay);
if (deadline.atOffset(java.time.ZoneOffset.UTC).getYear() > 9999) {
throw new IllegalArgumentException("character-swap-cooldown-seconds exceeds the persisted UTC timestamp range");
}
return delay;
} catch (ArithmeticException | java.time.DateTimeException invalid) {
throw new IllegalArgumentException("character-swap-cooldown-seconds exceeds the supported duration range", invalid);
}
}
private void notifyPlayer(Object generation, EquipmentSessions.Notice notice) {
Runnable delivery = () -> {
if (lifetime != generation) return;
@@ -0,0 +1,49 @@
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.Location;
import org.bukkit.Material;
import org.bukkit.entity.LivingEntity;
/** Hex's aimed, owned outgoing combat-damage penalty. */
public final class Hex implements AbilityEffect,AutoCloseable{
public record Settings(Duration cooldown,double range,Duration duration,BigDecimal reductionPercent){
public Settings{Objects.requireNonNull(cooldown);Objects.requireNonNull(duration);Objects.requireNonNull(reductionPercent);
if(cooldown.isNegative()||cooldown.compareTo(Duration.ofDays(365))>0||!Double.isFinite(range)||range<=0||range>64
||duration.isZero()||duration.isNegative()||duration.compareTo(Duration.ofDays(1))>0
||reductionPercent.signum()<0||reductionPercent.compareTo(BigDecimal.valueOf(100))>0)throw new IllegalArgumentException("Invalid Hex settings");}
public static Settings defaults(){return new Settings(Duration.ofSeconds(10),12,Duration.ofSeconds(4),BigDecimal.valueOf(20));}}
private final class State implements AutoCloseable{final AbilityExecution execution;final LivingEntity target;final AutoCloseable grant;boolean closed;
State(AbilityExecution execution,LivingEntity target,AutoCloseable grant){this.execution=execution;this.target=target;this.grant=grant;}
@Override public void close(){if(closed)return;closed=true;hexes.remove(target.getUniqueId(),this);try{grant.close();}catch(Exception failure){throw new IllegalStateException(failure);}}}
private volatile Settings settings;private final OutgoingDamageMitigation mitigation;private final Map<UUID,State> hexes=new java.util.HashMap<>();private boolean closed;
public Hex(Settings settings,OutgoingDamageMitigation 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("hex","Hex",Material.SPIDER_EYE,
List.of("Reduce an aimed hostile's combat damage by "+current.reductionPercent().stripTrailingZeros().toPlainString()+"%.",
"Range "+BigDecimal.valueOf(current.range()).stripTrailingZeros().toPlainString()+" blocks; lasts "+seconds(current.duration())+" seconds."),
"Use while aiming at the first visible hostile; there is no fallback target.",Map.of(Stat.CHR,BigInteger.valueOf(14),Stat.WIS,BigInteger.valueOf(12)),BigInteger.ONE,
AbilityDefinition.Activation.DELAYED,ControlInput.Gesture.USE,current.cooldown(),Map.of("range",BigDecimal.valueOf(current.range()).stripTrailingZeros(),
"duration-seconds",BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9),"damage-reduction-percent",current.reductionPercent()),
AbilityDefinition.TargetPolicy.HOSTILE,this);}
@Override public boolean accepts(ControlInput input){return !closed&&input.target() instanceof LivingEntity target&&inRange(input,target,settings.range());}
@Override public void activate(AbilityExecution execution){if(closed||!execution.stillCurrent()||!(execution.input().target() instanceof LivingEntity target))return;
var current=from(execution.definition());if(!inRange(execution.input(),target,current.range())){execution.finish();return;}
var state=new State(execution,target,mitigation.grant(execution,target,current.reductionPercent()));var previous=hexes.put(target.getUniqueId(),state);
if(previous!=null)previous.execution.finish();execution.own(state);execution.later(current.duration(),execution::finish);}
private static boolean inRange(ControlInput input,LivingEntity target,double range){try{Location source=input.player().getLocation(),destination=target.getLocation();
return source.getWorld()==destination.getWorld()&&source.distanceSquared(destination)<=range*range&&input.player().hasLineOfSight(target);
}catch(RuntimeException invalid){return false;}}
private static Settings from(AbilityDefinition definition){var p=definition.parameters();return new Settings(definition.cooldown(),p.get("range").doubleValue(),
Duration.ofNanos(p.get("duration-seconds").movePointRight(9).longValueExact()),p.get("damage-reduction-percent"));}
private static String seconds(Duration value){return BigDecimal.valueOf(value.toNanos()).movePointLeft(9).stripTrailingZeros().toPlainString();}
@Override public void close(){if(closed)return;closed=true;for(var state:List.copyOf(hexes.values()))state.execution.finish();hexes.clear();}
}
@@ -0,0 +1,16 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.progression.Stat;import games.dmg.fantasystick.session.ControlInput;import java.math.*;import java.time.Duration;import java.util.*;import org.bukkit.*;import org.bukkit.entity.Player;
/** Inspire's self-or-aimed fantasy-player temporary health grant. */
public final class Inspire implements AbilityEffect,AutoCloseable{
public record Settings(BigInteger baseTemporaryHealth,Duration cooldown,double range,Duration duration){public Settings{Objects.requireNonNull(baseTemporaryHealth);Objects.requireNonNull(cooldown);Objects.requireNonNull(duration);if(baseTemporaryHealth.signum()<0||baseTemporaryHealth.compareTo(BigDecimal.valueOf(Double.MAX_VALUE).toBigInteger())>0||cooldown.isNegative()||cooldown.compareTo(Duration.ofDays(365))>0||!Double.isFinite(range)||range<=0||range>64||duration.isZero()||duration.isNegative()||duration.compareTo(Duration.ofDays(1))>0)throw new IllegalArgumentException("Invalid Inspire settings");}public static Settings defaults(){return new Settings(BigInteger.valueOf(4),Duration.ofSeconds(15),12,Duration.ofSeconds(6));}}
private volatile Settings settings;private final TemporaryHealth temporaryHealth;private boolean closed;
public Inspire(Settings settings,TemporaryHealth temporaryHealth){this.settings=Objects.requireNonNull(settings);this.temporaryHealth=Objects.requireNonNull(temporaryHealth);}public void configure(Settings settings){this.settings=Objects.requireNonNull(settings);}
public AbilityDefinition definition(){var s=settings;return new AbilityDefinition("inspire","Inspire",Material.AMETHYST_SHARD,List.of("Grant "+s.baseTemporaryHealth()+" + CHR modifier temporary health for "+seconds(s.duration())+" seconds.","Range "+BigDecimal.valueOf(s.range()).stripTrailingZeros().toPlainString()+" blocks for an aimed fantasy player."),"Sneak+Use selects self; normal Use requires an aimed fantasy player.",Map.of(Stat.CHR,BigInteger.valueOf(12),Stat.WIS,BigInteger.TEN),BigInteger.ONE,AbilityDefinition.Activation.DELAYED,ControlInput.Gesture.USE,s.cooldown(),Map.of("base-temporary-health",new BigDecimal(s.baseTemporaryHealth()),"range",BigDecimal.valueOf(s.range()).stripTrailingZeros(),"duration-seconds",BigDecimal.valueOf(s.duration().toNanos()).movePointLeft(9)),AbilityDefinition.TargetPolicy.ALLY,this);}
@Override public boolean accepts(ControlInput input){if(closed||!(input.target() instanceof Player target))return false;if(input.player().isSneaking())return target==input.player();return target!=input.player()&&inRange(input.player(),target,settings.range());}
public static BigInteger amount(BigInteger charisma){return amount(BigInteger.valueOf(4),charisma);}static BigInteger amount(BigInteger base,BigInteger charisma){Objects.requireNonNull(base);Objects.requireNonNull(charisma);if(base.signum()<0||charisma.signum()<0)throw new IllegalArgumentException("Invalid Inspire amount");var delta=charisma.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 Player target))return;var s=from(execution.definition());if(execution.input().player().isSneaking()?target!=execution.input().player():target==execution.input().player()||!inRange(execution.input().player(),target,s.range())){execution.finish();return;}var exact=amount(s.baseTemporaryHealth(),execution.character().stats().get(Stat.CHR));double nativeAmount=exact.bitLength()>1023?Double.MAX_VALUE:exact.doubleValue();if(!Double.isFinite(nativeAmount)||nativeAmount<=0){execution.finish();return;}execution.own(temporaryHealth.grant(execution,target,nativeAmount));execution.later(s.duration(),execution::finish);}
private static boolean inRange(Player source,Player target,double range){try{var a=source.getLocation();var b=target.getLocation();return a.getWorld()==b.getWorld()&&a.distanceSquared(b)<=range*range&&source.hasLineOfSight(target);}catch(RuntimeException invalid){return false;}}
private static Settings from(AbilityDefinition d){var p=d.parameters();return new Settings(p.get("base-temporary-health").toBigIntegerExact(),d.cooldown(),p.get("range").doubleValue(),Duration.ofNanos(p.get("duration-seconds").movePointRight(9).longValueExact()));}
private static String seconds(Duration d){return BigDecimal.valueOf(d.toNanos()).movePointLeft(9).stripTrailingZeros().toPlainString();}
@Override public void close(){closed=true;}
}
@@ -0,0 +1,78 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.party.PartyRoster;
import games.dmg.fantasystick.session.EquipmentSessions;
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 java.util.function.Consumer;
import org.bukkit.entity.Entity;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import org.bukkit.entity.Projectile;
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.projectiles.ProjectileSource;
/** Internal pre-recipient strongest outgoing-damage reduction stage. */
public final class OutgoingDamageMitigation implements Listener, AutoCloseable {
public record Support(UUID target, UUID supportingCharacter, EquipmentSessions.Active aided, BigDecimal amount) { }
private final class Grant implements AutoCloseable {
final long sequence; final AbilityExecution execution; final LivingEntity target; final BigDecimal percent; boolean closed;
Grant(AbilityExecution execution, LivingEntity target, BigDecimal percent) {
this.sequence=++sequences;this.execution=execution;this.target=target;this.percent=percent;
}
@Override public void close(){if(closed)return;closed=true;var owned=grants.get(target.getUniqueId());
if(owned!=null){owned.remove(this);if(owned.isEmpty())grants.remove(target.getUniqueId());}}
}
private record Prevention(Grant grant, Entity recipient, BigDecimal amount) { }
private final EquipmentSessions sessions; private final PartyRoster parties; private final Consumer<Support> supports;
private final Map<UUID,List<Grant>> grants=new java.util.HashMap<>();
private final Map<EntityDamageEvent,Prevention> pending=new IdentityHashMap<>();
private long sequences; private boolean closed;
public OutgoingDamageMitigation(EquipmentSessions sessions,PartyRoster parties,Consumer<Support> supports){
this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.supports=Objects.requireNonNull(supports);}
public AutoCloseable grant(AbilityExecution execution,LivingEntity target,BigDecimal percent){
Objects.requireNonNull(execution);Objects.requireNonNull(target);Objects.requireNonNull(percent);
if(closed||percent.signum()<0||percent.compareTo(BigDecimal.valueOf(100))>0)throw new IllegalArgumentException("Invalid outgoing mitigation grant");
var grant=new Grant(execution,target,percent);grants.computeIfAbsent(target.getUniqueId(),ignored->new ArrayList<>()).add(grant);return grant;}
@EventHandler(priority=EventPriority.NORMAL,ignoreCancelled=true)
public void reduce(EntityDamageByEntityEvent event){
if(closed)return;Entity attacker=attacker(event);if(attacker==null)return;var owned=grants.get(attacker.getUniqueId());if(owned==null)return;
Grant strongest=null;for(var grant:List.copyOf(owned)){if(grant.closed||grant.target!=attacker||!grant.execution.stillCurrent())continue;
if(strongest==null||grant.percent.compareTo(strongest.percent)>0
||grant.percent.compareTo(strongest.percent)==0&&grant.sequence>strongest.sequence)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);var prevented=BigDecimal.valueOf(original-reduced);
if(prevented.signum()>0)pending.put(event,new Prevention(strongest,event.getEntity(),prevented));
}
@EventHandler(priority=EventPriority.MONITOR)
public void accepted(EntityDamageEvent event){
var prevention=pending.remove(event);if(prevention==null||event.isCancelled()||!prevention.grant.execution.stillCurrent())return;
prevention.grant.execution.report(AbilityReport.Type.PREVENTION,prevention.grant.target,prevention.amount);
if(!(prevention.recipient instanceof Player player))return;
var aided=sessions.active(player.getUniqueId()).orElse(null);if(aided==null||!sessions.current(aided))return;
var input=prevention.grant.execution.input();if(aided.character().equals(input.session().character()))return;
var sourceParty=parties.partyOf(input.player().getUniqueId()).orElse(null);
var aidedParty=parties.partyOf(player.getUniqueId()).orElse(null);
if(sourceParty==null||aidedParty==null||!sourceParty.id().equals(aidedParty.id()))return;
supports.accept(new Support(prevention.grant.target.getUniqueId(),input.session().character(),aided,prevention.amount));
}
private static Entity attacker(EntityDamageByEntityEvent event){
Entity causing=event.getDamageSource().getCausingEntity();if(causing!=null)return causing;
if(event.getDamager() instanceof Projectile projectile){ProjectileSource shooter=projectile.getShooter();if(shooter instanceof Entity entity)return entity;}
return event.getDamager();
}
@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();}
}
@@ -351,7 +351,9 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
target.getInventory().setItem(reservation.sourceSlot(), remaining); target.updateInventory();
}
var nativeLimit = games.dmg.fantasystick.session.NativeHealthLimit.read();
characters.restoreDeathRecord(reservation.character(), reservation.death(), nativeLimit)
(reservation.administrative()
? characters.restoreDeathRecordAdministratively(reservation.character(), reservation.death(), nativeLimit)
: characters.restoreDeathRecord(reservation.character(), reservation.death(), nativeLimit))
.whenComplete((restored, failure) -> schedule(() -> {
if (failure != null) { report(failure); return; }
if (restored.isPresent() && installFinalSheet(target, restored.orElseThrow())) {
@@ -0,0 +1,33 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.party.PartyRoster;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigDecimal;
import java.util.*;
import java.util.function.Consumer;
import org.bukkit.entity.*;
import org.bukkit.event.*;
import org.bukkit.event.entity.*;
import org.bukkit.projectiles.ProjectileSource;
/** Plugin-owned temporary-health ledger layered over unrelated native absorption. */
public final class TemporaryHealth implements Listener,AutoCloseable{
public record Support(UUID target,UUID supportingCharacter,EquipmentSessions.Active aided,BigDecimal amount){}
private final class Grant implements AutoCloseable{final long sequence;final AbilityExecution execution;final Player target;double remaining;boolean closed;
Grant(AbilityExecution e,Player t,double amount){sequence=++sequences;execution=e;target=t;remaining=amount;}
@Override public void close(){if(closed)return;double before=owned(target.getUniqueId());closed=true;var list=grants.get(target.getUniqueId());if(list!=null){list.remove(this);if(list.isEmpty())grants.remove(target.getUniqueId());}double after=owned(target.getUniqueId());removeNative(target,Math.max(0,before-after));}}
private final EquipmentSessions sessions;private final PartyRoster parties;private final Consumer<Runnable> nextTick;private final Consumer<Support> supports;
private final Map<UUID,List<Grant>> grants=new HashMap<>();private long sequences;private boolean closed;
public TemporaryHealth(EquipmentSessions sessions,PartyRoster parties,Consumer<Runnable> nextTick,Consumer<Support> supports){this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.nextTick=Objects.requireNonNull(nextTick);this.supports=Objects.requireNonNull(supports);}
public AutoCloseable grant(AbilityExecution execution,Player target,double amount){Objects.requireNonNull(execution);Objects.requireNonNull(target);if(closed||!Double.isFinite(amount)||amount<=0)throw new IllegalArgumentException("Invalid temporary health grant");double before=owned(target.getUniqueId());var grant=new Grant(execution,target,amount);grants.computeIfAbsent(target.getUniqueId(),ignored->new ArrayList<>()).add(grant);double after=owned(target.getUniqueId());addNative(target,Math.max(0,after-before));return grant;}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void damage(EntityDamageEvent event){if(closed||!(event.getEntity() instanceof Player target))return;var owner=strongest(target.getUniqueId());if(owner==null||!owner.execution.stillCurrent())return;double before=target.getAbsorptionAmount(),owned=owned(target.getUniqueId());if(!Double.isFinite(before)||before<=0||owned<=0)return;Entity attacker=event instanceof EntityDamageByEntityEvent by?attacker(by):null;var aided=sessions.active(target.getUniqueId()).orElse(null);boolean qualifies=aided!=null&&sessions.current(aided)&&!aided.character().equals(owner.execution.input().session().character())&&sameParty(owner.execution.input().player(),target);UUID enemy=attacker==null?null:attacker.getUniqueId();long ownerSequence=owner.sequence;UUID source=owner.execution.input().session().character();
try{nextTick.accept(()->consume(target,before,owned,ownerSequence,source,qualifies?aided:null,enemy));}catch(RuntimeException ignored){}}
private void consume(Player target,double before,double ownedBefore,long ownerSequence,UUID source,EquipmentSessions.Active aided,UUID enemy){if(closed)return;double after=target.getAbsorptionAmount();double consumed=Math.min(ownedBefore,Math.max(0,before-after));if(!Double.isFinite(consumed)||consumed<=0)return;var list=grants.get(target.getUniqueId());if(list==null)return;for(var grant:List.copyOf(list))grant.remaining=Math.max(0,grant.remaining-consumed);list.removeIf(g->g.closed||g.remaining<=1e-9);if(list.isEmpty())grants.remove(target.getUniqueId());if(aided!=null&&enemy!=null)supports.accept(new Support(enemy,source,aided,BigDecimal.valueOf(consumed).stripTrailingZeros()));}
private boolean sameParty(Player source,Player target){var a=parties.partyOf(source.getUniqueId()).orElse(null);var b=parties.partyOf(target.getUniqueId()).orElse(null);return a!=null&&b!=null&&a.id().equals(b.id());}
private Grant strongest(UUID target){Grant result=null;var list=grants.get(target);if(list!=null)for(var grant:List.copyOf(list))if(!grant.closed&&grant.remaining>0&&grant.execution.stillCurrent()&&(result==null||grant.remaining>result.remaining||grant.remaining==result.remaining&&grant.sequence>result.sequence))result=grant;return result;}
private double owned(UUID target){double result=0;var list=grants.get(target);if(list!=null)for(var grant:List.copyOf(list))if(!grant.closed&&grant.remaining>result)result=grant.remaining;return result;}
private static Entity attacker(EntityDamageByEntityEvent event){Entity causing=event.getDamageSource().getCausingEntity();if(causing!=null)return causing;if(event.getDamager() instanceof Projectile p){ProjectileSource shooter=p.getShooter();if(shooter instanceof Entity e)return e;}return event.getDamager();}
private static void addNative(Player target,double amount){if(amount<=0)return;double current=target.getAbsorptionAmount(),next=current+amount;if(!Double.isFinite(current)||current<0||!Double.isFinite(next))throw new IllegalStateException("Temporary health exceeds native range");target.setAbsorptionAmount(next);}
private static void removeNative(Player target,double amount){if(amount<=0)return;double current=target.getAbsorptionAmount();if(Double.isFinite(current)&&current>=0)target.setAbsorptionAmount(Math.max(0,current-amount));}
@Override public void close(){if(closed)return;for(var list:List.copyOf(grants.values()))for(var grant:List.copyOf(list))grant.close();closed=true;grants.clear();}
}
@@ -93,6 +93,13 @@ public final class CombatExperience implements Listener, AutoCloseable {
retryTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::retryPending, 20, 20);
}
/** Records target-specific, effective support; only a direct contributor can activate it at defeat. */
public void support(UUID target, UUID supportingCharacter, EquipmentSessions.Active aided, BigDecimal amount) {
Objects.requireNonNull(target); Objects.requireNonNull(supportingCharacter); Objects.requireNonNull(aided); Objects.requireNonNull(amount);
if (closed || amount.signum() <= 0 || !sessions.current(aided) || supportingCharacter.equals(aided.character())) return;
contributions.support(target, aided.character(), supportingCharacter, clock.instant());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void damage(EntityDamageEvent event) {
if (closed || event.getFinalDamage() <= 0) return;
@@ -0,0 +1,42 @@
package games.dmg.fantasystick.persistence;
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.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Durable save-before-publish policy for later administrator revival selections. */
public final class AdminReviveSettings implements AutoCloseable {
public static final String KEY="admin-revive-selection-timeout-seconds";public static final String DEFAULT="60";
private static final int MAX_BYTES=1_048_576;private static final long MAX_SECONDS=365L*24*60*60;
@FunctionalInterface interface Replacement{void move(Path from,Path to)throws IOException;}
public record Update(String value,Duration timeout,long sequence){}
private final Path directory;private final Replacement replacement;private final java.util.concurrent.ExecutorService worker=Executors.newSingleThreadExecutor(task->new Thread(task,"fantasy-admin-revive-configuration"));
private volatile Duration effective;private Duration persisted;private long persistedSequence,publishedSequence;private boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
public AdminReviveSettings(Path directory,Duration initial){this(directory,initial,(from,to)->Files.move(from,to,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING));}
AdminReviveSettings(Path directory,Duration initial,Replacement replacement){this.directory=Objects.requireNonNull(directory).toAbsolutePath();this.replacement=Objects.requireNonNull(replacement);effective=validate(initial);persisted=effective;}
public static Duration parse(Object value){if(value==null)return Duration.ofSeconds(60);try{return validate(Duration.ofSeconds(value instanceof Number n?new java.math.BigDecimal(n.toString()).longValueExact():Long.parseLong(value.toString())));}catch(ArithmeticException|NumberFormatException invalid){throw new IllegalArgumentException(KEY+" must be positive whole seconds",invalid);}}
private static Duration validate(Duration value){long seconds;try{seconds=value.toSeconds();}catch(ArithmeticException invalid){throw new IllegalArgumentException("Invalid admin revival timeout",invalid);}if(value.isZero()||value.isNegative()||value.getNano()!=0||seconds>MAX_SECONDS)throw new IllegalArgumentException(KEY+" must be positive whole seconds through 365 days");return value;}
public Duration timeout(){return effective;}public String value(){return Long.toString(effective.toSeconds());}
public CompletionStage<Update> set(String raw){Duration accepted=parse(raw);synchronized(this){if(closed)return CompletableFuture.failedStage(new IOException("Configuration store is closed"));pending++;}try{return CompletableFuture.supplyAsync(()->{try{save(accepted);persisted=accepted;return new Update(Long.toString(accepted.toSeconds()),accepted,++persistedSequence);}catch(IOException|RuntimeException failure){throw new java.util.concurrent.CompletionException(new IOException("Admin revival configuration save refused or failed; effective setting unchanged"));}finally{finished();}},worker).minimalCompletionStage();}catch(RejectedExecutionException rejected){finished();return CompletableFuture.failedStage(new IOException("Configuration store is closed"));}}
public synchronized void publish(Update update){if(update.sequence()<=publishedSequence)return;effective=validate(update.timeout());publishedSequence=update.sequence();}
private void save(Duration 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> values=new LinkedHashMap<>();if(Files.exists(file,LinkOption.NOFOLLOW_LINKS)){if(!Files.isRegularFile(file,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe configuration");byte[] bytes;try(var input=Files.newInputStream(file,LinkOption.NOFOLLOW_LINKS)){bytes=input.readNBytes(MAX_BYTES+1);}if(bytes.length>MAX_BYTES)throw new IOException("Configuration too large");Object loaded=yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());if(loaded!=null){if(!(loaded instanceof Map<?,?> map))throw new IOException("Invalid configuration root");for(var entry:map.entrySet()){if(!(entry.getKey() instanceof String key))throw new IOException("Invalid configuration key");values.put(key,entry.getValue());}}}values.put(KEY,Long.toString(value.toSeconds()));byte[] bytes=yaml.dump(values).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);}
}
@@ -502,8 +502,18 @@ public final class CharacterStorage implements AutoCloseable {
/** 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);
return restore(characterId, deathId, nativeLimit, true);
}
/** Administrator revival retains death identity/consume-once checks but deliberately ignores death age. */
public CompletableFuture<Optional<CharacterRecord>> restoreDeathRecordAdministratively(
UUID characterId, UUID deathId, java.math.BigDecimal nativeLimit) {
return restore(characterId, deathId, nativeLimit, false);
}
private CompletableFuture<Optional<CharacterRecord>> restore(UUID characterId, UUID deathId,
java.math.BigDecimal nativeLimit, boolean ageLimited) {
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(() -> {
@@ -511,9 +521,8 @@ public final class CharacterStorage implements AutoCloseable {
if (found.isEmpty() || !found.orElseThrow().dead()) return Optional.empty();
CharacterRecord old = found.orElseThrow();
Duration age = Duration.between(old.diedAt(), selectedAt);
if (!deathId.equals(old.deathId()) || age.isNegative() || age.compareTo(Duration.ofHours(24)) >= 0) {
return Optional.empty();
}
if (!deathId.equals(old.deathId()) || age.isNegative()
|| ageLimited && age.compareTo(Duration.ofHours(24)) >= 0) return Optional.empty();
var health = nativeLimit == null ? old.maximumHealth() : old.maximumHealth().min(nativeLimit);
var restored = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
old.level(), old.experience(), old.unspentPoints(), health, old.learned(), old.loadout(), old.cooldowns(),
@@ -0,0 +1,190 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.Hex;
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 Hex settings; publication remains a server-thread caller decision. */
public final class HexSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String COOLDOWN = "hex-cooldown-seconds";
public static final String RANGE = "hex-range-blocks";
public static final String DURATION = "hex-duration-seconds";
public static final String REDUCTION = "hex-damage-reduction-percent";
private static final List<String> KEYS = List.of(COOLDOWN, RANGE, DURATION, REDUCTION);
private static final Map<String, String> DEFAULTS = Map.of(COOLDOWN, "10", RANGE, "12", DURATION, "4", REDUCTION, "20");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, Hex.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-hex-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 HexSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
HexSettings(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 Hex.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("Hex 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 Hex.Settings parse(Map<String, String> values) {
try {
var cooldown = seconds(values.get(COOLDOWN));
var range = finite(values.get(RANGE));
var duration = seconds(values.get(DURATION));
var reduction = new java.math.BigDecimal(values.get(REDUCTION));
return new Hex.Settings(cooldown, range, duration, reduction);
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Hex 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,191 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.Inspire;
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 Inspire settings; publication remains a server-thread caller decision. */
public final class InspireSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String BASE = "inspire-base-temporary-health";
public static final String COOLDOWN = "inspire-cooldown-seconds";
public static final String RANGE = "inspire-range-blocks";
public static final String DURATION = "inspire-duration-seconds";
private static final List<String> KEYS = List.of(BASE, COOLDOWN, RANGE, DURATION);
private static final Map<String, String> DEFAULTS = Map.of(BASE, "4", COOLDOWN, "15", RANGE, "12", DURATION, "6");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, Inspire.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-inspire-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 InspireSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
InspireSettings(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 Inspire.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("Inspire 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)) 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 Inspire.Settings parse(Map<String, String> values) {
try {
var base = new java.math.BigInteger(values.get(BASE));
var cooldown = seconds(values.get(COOLDOWN));
var range = finite(values.get(RANGE));
var duration = seconds(values.get(DURATION));
return new Inspire.Settings(base, cooldown, range, duration);
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Inspire 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);
}
}
@@ -29,10 +29,16 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
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",
private static final Set<String> KEYS_V1 = Set.of("schema-version", "reservation-id", "caster-character-id",
"target-player-id", "character-id", "death-id", "created-at", "source-slot", "source-amount", "destination-slot");
private static final Set<String> KEYS_V2 = java.util.stream.Stream.concat(KEYS_V1.stream(), java.util.stream.Stream.of("administrative"))
.collect(java.util.stream.Collectors.toUnmodifiableSet());
public record Reservation(UUID id, UUID casterCharacter, UUID targetPlayer, UUID character, UUID death,
Instant createdAt, int sourceSlot, int sourceAmount, int destinationSlot) {
Instant createdAt, int sourceSlot, int sourceAmount, int destinationSlot, boolean administrative) {
public Reservation(UUID id, UUID casterCharacter, UUID targetPlayer, UUID character, UUID death,
Instant createdAt, int sourceSlot, int sourceAmount, int destinationSlot) {
this(id, casterCharacter, targetPlayer, character, death, createdAt, sourceSlot, sourceAmount, destinationSlot, false);
}
public Reservation {
Objects.requireNonNull(id); Objects.requireNonNull(casterCharacter); Objects.requireNonNull(targetPlayer);
Objects.requireNonNull(character); Objects.requireNonNull(death); Objects.requireNonNull(createdAt);
@@ -121,13 +127,13 @@ public final class RevivalStore implements AutoCloseable {
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");
if (!(decoded instanceof Map<?, ?> raw) || !(raw.keySet().equals(KEYS_V1) || raw.keySet().equals(KEYS_V2))) throw new IOException("Invalid revival reservation keys");
try {
if (!"1".equals(text(raw, "schema-version"))) throw new IllegalArgumentException("schema");
String schema=text(raw,"schema-version");if(!(schema.equals("1")&&raw.keySet().equals(KEYS_V1)||schema.equals("2")&&raw.keySet().equals(KEYS_V2)))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"));
integer(raw, "destination-slot"), schema.equals("2") && bool(raw,"administrative"));
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); }
@@ -135,11 +141,12 @@ public final class RevivalStore implements AutoCloseable {
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("schema-version", "2"); 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()));
map.put("administrative", Boolean.toString(value.administrative()));
return new Yaml().dump(map);
}
@@ -148,6 +155,7 @@ public final class RevivalStore implements AutoCloseable {
}
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)); }
private static boolean bool(Map<?,?> map,String key){String value=text(map,key);if(!value.equals("true")&&!value.equals("false"))throw new IllegalArgumentException(key);return Boolean.parseBoolean(value);}
@FunctionalInterface private interface Work<T> { T run() throws IOException; }
private <T> CompletableFuture<T> submit(Work<T> work) {
@@ -0,0 +1,152 @@
package games.dmg.fantasystick.persistence;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Durable save-before-publish policy for roleplay availability and coordinated exits. */
public final class RoleplaySettings implements AutoCloseable {
public static final String KEY = "roleplay-disabled";
private static final int MAX_BYTES = 1_048_576;
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(boolean disabled, long sequence) { }
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-roleplay-configuration"));
private volatile boolean disabled;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public RoleplaySettings(Path directory, boolean initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
RoleplaySettings(Path directory, boolean initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
this.replacement = Objects.requireNonNull(replacement);
disabled = initial;
}
public static boolean parse(Object value) {
if (value == null) return false;
if (value instanceof Boolean parsed) return parsed;
throw new IllegalArgumentException(KEY + " must be true or false");
}
public boolean disabled() { return disabled; }
public String value() { return Boolean.toString(disabled); }
public CompletionStage<Update> set(String raw) {
if (!"true".equals(raw) && !"false".equals(raw)) {
throw new IllegalArgumentException(KEY + " must be true or false");
}
boolean accepted = Boolean.parseBoolean(raw);
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
save(accepted);
return new Update(accepted, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(new IOException(
"Roleplay configuration save refused or failed; effective setting unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished();
return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
public synchronized boolean publish(Update update) {
if (update.sequence() <= publishedSequence) return false;
disabled = update.disabled();
publishedSequence = update.sequence();
return true;
}
private void save(boolean 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> values = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) {
bytes = input.readNBytes(MAX_BYTES + 1);
}
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String key)) throw new IOException("Invalid configuration key");
values.put(key, entry.getValue());
}
}
}
values.put(KEY, value);
byte[] bytes = yaml.dump(values).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,42 @@
package games.dmg.fantasystick.persistence;
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.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Durable save-before-publish policy for character-switch delay checks and later exits. */
public final class SwitchDelaySettings implements AutoCloseable {
public static final String KEY="character-swap-cooldown-seconds";public static final String DEFAULT="900";
private static final int MAX_BYTES=1_048_576;
@FunctionalInterface interface Replacement{void move(Path from,Path to)throws IOException;}
public record Update(String value,Duration delay,long sequence){}
private final Path directory;private final Replacement replacement;private final java.util.concurrent.ExecutorService worker=Executors.newSingleThreadExecutor(task->new Thread(task,"fantasy-switch-delay-configuration"));
private volatile Duration effective;private long persistedSequence,publishedSequence;private boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
public SwitchDelaySettings(Path directory,Duration initial){this(directory,initial,(from,to)->Files.move(from,to,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING));}
SwitchDelaySettings(Path directory,Duration initial,Replacement replacement){this.directory=Objects.requireNonNull(directory).toAbsolutePath();this.replacement=Objects.requireNonNull(replacement);effective=validate(initial);}
public static Duration parse(Object value){if(value==null)return Duration.ofSeconds(900);try{return validate(Duration.ofSeconds(value instanceof Number n?new java.math.BigDecimal(n.toString()).longValueExact():Long.parseLong(value.toString())));}catch(ArithmeticException|NumberFormatException invalid){throw new IllegalArgumentException(KEY+" must be nonnegative whole seconds",invalid);}}
private static Duration validate(Duration value){if(value.isNegative()||value.getNano()!=0)throw new IllegalArgumentException(KEY+" must be nonnegative whole seconds");try{var deadline=java.time.Instant.now().plus(value);if(deadline.atOffset(java.time.ZoneOffset.UTC).getYear()>9999)throw new IllegalArgumentException(KEY+" exceeds the persisted timestamp range");return value;}catch(java.time.DateTimeException|ArithmeticException invalid){throw new IllegalArgumentException(KEY+" exceeds the persisted timestamp range",invalid);}}
public Duration delay(){return effective;}public String value(){return Long.toString(effective.toSeconds());}
public CompletionStage<Update> set(String raw){Duration accepted=parse(raw);synchronized(this){if(closed)return CompletableFuture.failedStage(new IOException("Configuration store is closed"));pending++;}try{return CompletableFuture.supplyAsync(()->{try{save(accepted);return new Update(Long.toString(accepted.toSeconds()),accepted,++persistedSequence);}catch(IOException|RuntimeException failure){throw new java.util.concurrent.CompletionException(new IOException("Character switch configuration save refused or failed; effective setting unchanged"));}finally{finished();}},worker).minimalCompletionStage();}catch(RejectedExecutionException rejected){finished();return CompletableFuture.failedStage(new IOException("Configuration store is closed"));}}
public synchronized void publish(Update update){if(update.sequence()<=publishedSequence)return;effective=validate(update.delay());publishedSequence=update.sequence();}
private void save(Duration 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> values=new LinkedHashMap<>();if(Files.exists(file,LinkOption.NOFOLLOW_LINKS)){if(!Files.isRegularFile(file,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe configuration");byte[] bytes;try(var input=Files.newInputStream(file,LinkOption.NOFOLLOW_LINKS)){bytes=input.readNBytes(MAX_BYTES+1);}if(bytes.length>MAX_BYTES)throw new IOException("Configuration too large");Object loaded=yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());if(loaded!=null){if(!(loaded instanceof Map<?,?> map))throw new IOException("Invalid configuration root");for(var entry:map.entrySet()){if(!(entry.getKey() instanceof String key))throw new IOException("Invalid configuration key");values.put(key,entry.getValue());}}}values.put(KEY,Long.toString(value.toSeconds()));byte[] bytes=yaml.dump(values).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);}
}
@@ -10,8 +10,10 @@ import java.util.UUID;
/** Server-thread bounded contribution ledger keyed by target and originating character. */
public final class CombatContributions implements AutoCloseable {
private static final java.time.Duration SUPPORT_LIFETIME = java.time.Duration.ofSeconds(10);
private final CombatRewardRules rules;
private final Map<UUID, Map<UUID, Instant>> targets = new HashMap<>();
private final Map<UUID, Map<UUID, Map<UUID, Instant>>> supports = new HashMap<>();
private boolean closed;
public CombatContributions(CombatRewardRules rules) { this.rules = Objects.requireNonNull(rules); }
@@ -23,26 +25,53 @@ public final class CombatContributions implements AutoCloseable {
(previous, next) -> previous.isAfter(next) ? previous : next);
}
public void support(UUID target, UUID aidedCharacter, UUID supportingCharacter, Instant acceptedAt) {
Objects.requireNonNull(target); Objects.requireNonNull(aidedCharacter);
Objects.requireNonNull(supportingCharacter); Objects.requireNonNull(acceptedAt);
if (closed) return;
supports.computeIfAbsent(target, ignored -> new HashMap<>())
.computeIfAbsent(aidedCharacter, ignored -> new HashMap<>())
.merge(supportingCharacter, 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);
var assistance = supports.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);
});
for (UUID aided : Set.copyOf(eligible)) {
var supporters = assistance == null ? null : assistance.get(aided);
if (supporters != null) supporters.forEach((supporter, acceptedAt) -> {
if (supports(acceptedAt, deathAt)) eligible.add(supporter);
});
}
return Set.copyOf(eligible);
}
public void invalidate(UUID target) { if (!closed) targets.remove(Objects.requireNonNull(target)); }
public void invalidate(UUID target) {
if (!closed) { target = Objects.requireNonNull(target); targets.remove(target); supports.remove(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);
supports.values().forEach(byAlly -> {
byAlly.values().forEach(bySupporter -> bySupporter.entrySet().removeIf(entry -> !supports(entry.getValue(), now)));
byAlly.values().removeIf(Map::isEmpty);
});
supports.values().removeIf(Map::isEmpty);
}
@Override public void close() { closed = true; targets.clear(); }
private static boolean supports(Instant acceptedAt, Instant defeatAt) {
return !acceptedAt.isAfter(defeatAt) && !defeatAt.isAfter(acceptedAt.plus(SUPPORT_LIFETIME));
}
@Override public void close() { closed = true; targets.clear(); supports.clear(); }
}
@@ -0,0 +1,121 @@
package games.dmg.fantasystick.session;
import games.dmg.fantasystick.items.CharacterSheets;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import java.util.function.Supplier;
import org.bukkit.entity.Player;
/** Persists one fresh character before handing its authentic sheet to a still-valid native inventory slot. */
public final class AdminCharacterIssuer implements AutoCloseable {
public enum Outcome { DELIVERED, FULL, BUSY, STALE, SAVE_FAILED, RETAINED }
public record Failure(UUID player, UUID character, String operation, Throwable cause) { }
private final CharacterStorage storage;
private final Supplier<CharacterRecord> allocate;
private final Consumer<Runnable> nextTick;
private final Consumer<Failure> failures;
private final ConcurrentHashMap<UUID, Attempt> pending = new ConcurrentHashMap<>();
private volatile boolean closed;
public AdminCharacterIssuer(CharacterStorage storage, Supplier<CharacterRecord> allocate,
Consumer<Runnable> nextTick, Consumer<Failure> failures) {
this.storage = Objects.requireNonNull(storage);
this.allocate = Objects.requireNonNull(allocate);
this.nextTick = Objects.requireNonNull(nextTick);
this.failures = Objects.requireNonNull(failures);
}
/** Must be entered on the server thread. Completion is delivered on that thread. */
public void issue(Player player, Consumer<Outcome> response) {
Objects.requireNonNull(player); Objects.requireNonNull(response);
if (closed || !player.isOnline()) { response.accept(Outcome.STALE); return; }
int slot = player.getInventory().firstEmpty();
if (slot < 0) { response.accept(Outcome.FULL); return; }
var attempt = new Attempt(player, slot, response);
if (pending.putIfAbsent(attempt.playerId, attempt) != null) { response.accept(Outcome.BUSY); return; }
try {
attempt.record = allocate.get();
attempt.preparation = storage.prepareCraft(List.of(attempt.record));
attempt.preparation.ready().whenComplete((batch, failure) -> schedule(attempt, batch, failure));
} catch (RuntimeException failure) {
pending.remove(attempt.playerId, attempt);
abandon(attempt);
report(attempt, "prepare", failure);
response.accept(Outcome.SAVE_FAILED);
}
}
private void schedule(Attempt attempt, CharacterStorage.CraftBatch batch, Throwable failure) {
if (closed || pending.get(attempt.playerId) != attempt) { abandon(attempt); return; }
try { nextTick.accept(() -> complete(attempt, batch, failure)); }
catch (RuntimeException rejected) {
pending.remove(attempt.playerId, attempt); abandon(attempt); report(attempt, "schedule", rejected);
}
}
private void complete(Attempt attempt, CharacterStorage.CraftBatch batch, Throwable failure) {
if (closed || !pending.remove(attempt.playerId, attempt)) { abandon(attempt); return; }
if (failure != null) {
abandon(attempt); report(attempt, "save", failure); respond(attempt, Outcome.SAVE_FAILED); return;
}
var occupying = attempt.player.getInventory().getItem(attempt.slot);
if (!attempt.player.isOnline() || attempt.slot < 0 || attempt.slot >= 36
|| occupying != null && !occupying.getType().isAir()) {
abandon(attempt); respond(attempt, Outcome.STALE); return;
}
var claimed = batch.claimForDelivery();
if (claimed.isEmpty() || claimed.orElseThrow().size() != 1
|| !claimed.orElseThrow().getFirst().id().equals(attempt.record.id())) {
abandon(attempt); respond(attempt, Outcome.RETAINED); return;
}
try {
var sheet = new CharacterSheets().create(attempt.record);
attempt.player.getInventory().setItem(attempt.slot, sheet);
attempt.delivered = true;
} catch (RuntimeException uncertain) {
report(attempt, "delivery", uncertain);
respond(attempt, Outcome.RETAINED);
return;
}
try { attempt.player.updateInventory(); }
catch (RuntimeException syncFailure) { report(attempt, "sync", syncFailure); }
respond(attempt, Outcome.DELIVERED);
}
private void respond(Attempt attempt, Outcome outcome) {
try { attempt.response.accept(outcome); }
catch (RuntimeException failure) { report(attempt, "response", failure); }
}
private void abandon(Attempt attempt) {
if (attempt.preparation == null || attempt.delivered || !attempt.abandoned.compareAndSet(false, true)) return;
attempt.preparation.abandon().thenAccept(cleanup -> {
if (!cleanup.retained().isEmpty()) report(attempt, "cleanup-retained", null);
});
}
private void report(Attempt attempt, String operation, Throwable cause) {
try { failures.accept(new Failure(attempt.playerId, attempt.record == null ? null : attempt.record.id(), operation, cause)); }
catch (RuntimeException ignored) { }
}
@Override public void close() {
closed = true;
for (var attempt : List.copyOf(pending.values())) {
if (pending.remove(attempt.playerId, attempt)) abandon(attempt);
}
}
private static final class Attempt {
final Player player; final UUID playerId; final int slot; final Consumer<Outcome> response;
final java.util.concurrent.atomic.AtomicBoolean abandoned = new java.util.concurrent.atomic.AtomicBoolean();
CharacterRecord record; CharacterStorage.CraftPreparation preparation; volatile boolean delivered;
Attempt(Player player, int slot, Consumer<Outcome> response) { this.player=player; this.playerId=player.getUniqueId(); this.slot=slot; this.response=response; }
}
}
@@ -0,0 +1,68 @@
package games.dmg.fantasystick.session;
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 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.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;
/** Admin-owned, expiring Tail selection with authoritative consume-once restoration. */
public final class AdminRevival implements Listener, AutoCloseable {
public interface PopupFactory { Inventory create(int size, Component title); }
public interface Scheduler { AutoCloseable later(Duration delay, Runnable action); }
private record Candidate(int slot, ItemStack item, 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 Scheduler scheduler; 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> views=new IdentityHashMap<>();private boolean closed;
private final class View implements AutoCloseable {
final Player admin,target;final Inventory inventory;final Map<Integer,Choice> choices;final Instant deadline;final AutoCloseable expiry;boolean selecting,finished;
View(Player admin,Player target,Inventory inventory,Map<Integer,Choice> choices,Instant deadline){this.admin=admin;this.target=target;this.inventory=inventory;this.choices=choices;this.deadline=deadline;this.expiry=scheduler.later(Duration.between(clock.instant(),deadline),this::close);}
boolean current(){return !closed&&!finished&&admin.isOnline()&&admin.hasPermission(EquipmentAdminCommand.PERMISSION)&&target.isOnline()&&clock.instant().isBefore(deadline);}
@Override public void close(){if(finished)return;finished=true;views.remove(inventory,this);inventory.clear();try{expiry.close();}catch(Exception e){report(e);}try{admin.closeInventory();}catch(RuntimeException e){report(e);}}
}
public AdminRevival(CharacterStorage characters,RevivalStore reservations,Clock clock,Consumer<Runnable> nextTick,Scheduler scheduler,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.scheduler=Objects.requireNonNull(scheduler);this.popups=Objects.requireNonNull(popups);this.failures=Objects.requireNonNull(failures);}
public void open(Player admin,Player target,Duration timeout){Objects.requireNonNull(admin);Objects.requireNonNull(target);if(closed||!admin.isOnline()||!admin.hasPermission(EquipmentAdminCommand.PERMISSION)||!target.isOnline()){admin.sendMessage("Admin revival refused: authorization or target is no longer available.");return;}var candidates=candidates(target);if(candidates.isEmpty()){admin.sendMessage("That player has no authentic direct-inventory Tails.");return;}var loads=candidates.stream().map(c->characters.find(c.identity.characterId())).toList();CompletableFuture.allOf(loads.toArray(CompletableFuture[]::new)).whenComplete((ignored,failure)->schedule(()->{if(failure!=null){report(failure);admin.sendMessage("Admin revival could not inspect those Tails safely.");return;}if(closed||!admin.isOnline()||!admin.hasPermission(EquipmentAdminCommand.PERMISSION)||!target.isOnline())return;var choices=new ArrayList<Choice>();for(int i=0;i<candidates.size();i++){var found=loads.get(i).join();if(found.isPresent()&&eligible(found.orElseThrow(),candidates.get(i).identity)&&unchanged(target,candidates.get(i)))choices.add(new Choice(candidates.get(i),found.orElseThrow()));}if(choices.isEmpty()){admin.sendMessage("That player has no current unconsumed Tail.");return;}show(admin,target,choices,timeout);}));}
private List<Candidate> candidates(Player target){var result=new ArrayList<Candidate>();for(int slot=0;slot<=40;slot++){if(slot>35&&slot!=40)continue;var 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 id){return record.dead()&&record.id().equals(id.characterId())&&Objects.equals(record.deathId(),id.deathId())&&!record.diedAt().isAfter(clock.instant());}
private static boolean unchanged(Player target,Candidate candidate){var item=target.getInventory().getItem(candidate.slot);return item!=null&&item.equals(candidate.item);}
private void show(Player admin,Player target,List<Choice> eligible,Duration timeout){int size=Math.min(54,Math.max(9,((eligible.size()+8)/9)*9));var inventory=popups.create(size,Component.text("Choose a Tail for "+target.getName()));var choices=new LinkedHashMap<Integer,Choice>();for(int i=0;i<eligible.size()&&i<size;i++){var choice=eligible.get(i);var item=choice.candidate.item.clone();item.setAmount(1);var meta=item.getItemMeta();meta.lore(List.of(Component.text("Character: "+choice.record.name()),Component.text("Level "+choice.record.level()+" · target slot "+choice.candidate.slot),Component.text("Click to restore this exact death")));item.setItemMeta(meta);inventory.setItem(i,item);choices.put(i,choice);}var view=new View(admin,target,inventory,Map.copyOf(choices),clock.instant().plus(timeout));views.put(inventory,view);admin.openInventory(inventory);}
@EventHandler(priority=EventPriority.HIGHEST) public void click(org.bukkit.event.inventory.InventoryClickEvent event){var view=views.get(event.getView().getTopInventory());if(view==null)return;event.setCancelled(true);if(event.getWhoClicked()!=view.admin||event.getRawSlot()<0||event.getRawSlot()>=view.inventory.getSize())return;var choice=view.choices.get(event.getRawSlot());if(choice==null||view.selecting)return;view.selecting=true;views.remove(view.inventory,view);view.inventory.clear();view.admin.closeInventory();confirm(view,choice);}
@EventHandler(priority=EventPriority.HIGHEST) public void drag(org.bukkit.event.inventory.InventoryDragEvent event){var view=views.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=views.get(event.getView().getTopInventory());if(view!=null&&!view.selecting)view.close();}
@EventHandler(priority=EventPriority.MONITOR) public void quit(org.bukkit.event.player.PlayerQuitEvent event){for(var view:List.copyOf(views.values()))if(view.admin==event.getPlayer()||view.target==event.getPlayer())view.close();}
private void confirm(View view,Choice choice){if(!view.current()||!unchanged(view.target,choice.candidate)){view.close();return;}characters.find(choice.record.id()).whenComplete((found,failure)->schedule(()->{if(failure!=null){report(failure);view.admin.sendMessage("Admin revival could not recheck that Tail; no item changed.");view.close();return;}if(!view.current()||found.isEmpty()||!eligible(found.orElseThrow(),choice.candidate.identity)||!unchanged(view.target,choice.candidate)){view.admin.sendMessage("That Tail selection is stale; no item changed.");view.close();return;}reserve(view,choice.candidate,found.orElseThrow());}));}
private void reserve(View view,Candidate candidate,CharacterRecord dead){var source=view.target.getInventory().getItem(candidate.slot);int destination=source.getAmount()==1?-1:emptySlot(view.target,candidate.slot);if(source.getAmount()>1&&destination<0){view.admin.sendMessage("Admin revival needs one empty direct inventory slot to preserve copied Tails.");view.close();return;}var reservation=new RevivalStore.Reservation(UUID.randomUUID(),view.admin.getUniqueId(),view.target.getUniqueId(),dead.id(),dead.deathId(),clock.instant().truncatedTo(ChronoUnit.SECONDS),candidate.slot,source.getAmount(),destination,true);reservations.reserve(reservation).whenComplete((saved,failure)->schedule(()->{if(failure!=null){report(failure);view.admin.sendMessage("Admin revival reservation failed; no item changed.");view.close();return;}if(saved.isEmpty()||!view.current()||!unchanged(view.target,candidate)){view.admin.sendMessage("Admin revival became stale after reservation; no item changed.");view.close();return;}deliver(view,dead,reservation);}));}
private static int emptySlot(Player target,int source){for(int slot=0;slot<=40;slot++){if(slot>35&&slot!=40||slot==source)continue;var item=target.getInventory().getItem(slot);if(item==null||item.isEmpty())return slot;}return-1;}
private void deliver(View view,CharacterRecord dead,RevivalStore.Reservation reservation){try{var provisional=new CharacterRecord(dead.id(),dead.name(),dead.revision(),dead.stats(),dead.level(),dead.experience(),dead.unspentPoints(),dead.maximumHealth(),dead.learned(),dead.loadout(),dead.cooldowns(),dead.restrictedKills(),false,dead.deathId(),dead.diedAt());var sheet=sheets.create(provisional);var source=view.target.getInventory().getItem(reservation.sourceSlot());if(reservation.sourceAmount()==1)view.target.getInventory().setItem(reservation.sourceSlot(),sheet);else{view.target.getInventory().setItem(reservation.destinationSlot(),sheet);var remaining=source.clone();remaining.setAmount(reservation.sourceAmount()-1);view.target.getInventory().setItem(reservation.sourceSlot(),remaining);}view.target.updateInventory();}catch(RuntimeException uncertain){report(uncertain);view.admin.sendMessage("Admin revival delivery is uncertain; retained proof requires recovery.");view.close();return;}characters.restoreDeathRecordAdministratively(reservation.character(),reservation.death(),NativeHealthLimit.read()).whenComplete((restored,failure)->schedule(()->{if(failure!=null||restored.isEmpty()){if(failure!=null)report(failure);view.admin.sendMessage("The Tail changed, but restoration is pending proof-based recovery.");view.close();return;}if(installFinal(view.target,restored.orElseThrow())){reservations.clear(reservation.character(),reservation.id()).exceptionally(error->{report(error);return false;});view.admin.sendMessage("Administratively restored "+restored.orElseThrow().name()+" for "+view.target.getName()+".");view.target.sendMessage("An administrator restored character sheet: "+restored.orElseThrow().name()+".");}else view.admin.sendMessage("Character restoration was saved, but sheet custody is ambiguous; retained proof requires recovery.");view.close();}));}
private boolean installFinal(Player target,CharacterRecord record){int found=-1;for(int slot=0;slot<=40;slot++){if(slot>35&&slot!=40)continue;if(sheets.identify(target.getInventory().getItem(slot)).filter(record.id()::equals).isEmpty())continue;if(found>=0)return false;found=slot;}if(found<0)return false;target.getInventory().setItem(found,sheets.create(record));target.updateInventory();return true;}
private void schedule(Runnable action){try{nextTick.accept(action);}catch(RuntimeException rejected){report(rejected);}}
private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}
@Override public void close(){if(closed)return;closed=true;for(var view:List.copyOf(views.values()))view.close();views.clear();}
}
@@ -34,6 +34,15 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private games.dmg.fantasystick.ability.StoneSkin stoneSkin;
private games.dmg.fantasystick.persistence.ShieldBashSettings shieldBashSettings;
private games.dmg.fantasystick.ability.ShieldBash shieldBash;
private games.dmg.fantasystick.persistence.HexSettings hexSettings;
private games.dmg.fantasystick.ability.Hex hex;
private games.dmg.fantasystick.persistence.InspireSettings inspireSettings;
private games.dmg.fantasystick.ability.Inspire inspire;
private AdminCharacterIssuer characterIssuer;
private AdminRevival adminRevival;
private games.dmg.fantasystick.persistence.AdminReviveSettings adminReviveSettings;
private games.dmg.fantasystick.persistence.SwitchDelaySettings switchDelaySettings;
private games.dmg.fantasystick.persistence.RoleplaySettings roleplaySettings;
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics) {
@@ -153,6 +162,69 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
this.shieldBash = Objects.requireNonNull(shieldBash);
}
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.persistence.HexSettings hexSettings,
games.dmg.fantasystick.ability.Hex hex,
games.dmg.fantasystick.ability.AbilityRegistry abilities) {
this(receipts, sessions, server, nextTick, diagnostics, settings, frostSettings, frostShard,
longbowSettings, longbow, quickstepSettings, quickstep, secondWindSettings, secondWind,
stoneSkinSettings, stoneSkin, shieldBashSettings, shieldBash, abilities);
this.hexSettings = Objects.requireNonNull(hexSettings); this.hex = Objects.requireNonNull(hex);
}
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.persistence.HexSettings hexSettings,
games.dmg.fantasystick.ability.Hex hex,
games.dmg.fantasystick.persistence.InspireSettings inspireSettings,
games.dmg.fantasystick.ability.Inspire inspire,
games.dmg.fantasystick.ability.AbilityRegistry abilities) {
this(receipts, sessions, server, nextTick, diagnostics, settings, frostSettings, frostShard,
longbowSettings, longbow, quickstepSettings, quickstep, secondWindSettings, secondWind,
stoneSkinSettings, stoneSkin, shieldBashSettings, shieldBash, hexSettings, hex, abilities);
this.inspireSettings=Objects.requireNonNull(inspireSettings);this.inspire=Objects.requireNonNull(inspire);
}
public void attachSwitchDelay(games.dmg.fantasystick.persistence.SwitchDelaySettings settings){if(closed||switchDelaySettings!=null)throw new IllegalStateException("Switch delay already attached or command closed");switchDelaySettings=Objects.requireNonNull(settings);}
public void attachRoleplaySettings(games.dmg.fantasystick.persistence.RoleplaySettings settings){if(closed||roleplaySettings!=null)throw new IllegalStateException("Roleplay settings already attached or command closed");roleplaySettings=Objects.requireNonNull(settings);}
public void attachAdminRevival(AdminRevival revival, games.dmg.fantasystick.persistence.AdminReviveSettings settings) {
if (closed || adminRevival != null) throw new IllegalStateException("Admin revival already attached or command closed");
adminRevival=Objects.requireNonNull(revival);adminReviveSettings=Objects.requireNonNull(settings);
}
public void attachCharacterIssuer(AdminCharacterIssuer issuer) {
if (closed || characterIssuer != null) throw new IllegalStateException("Character issuer already attached or command closed");
characterIssuer = Objects.requireNonNull(issuer);
}
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (closed) return true;
if (!sender.hasPermission(PERMISSION)) {
@@ -163,6 +235,13 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
configuration(sender, args);
return true;
}
if (args.length > 0 && args[0].equals("give")) {
give(sender, args);
return true;
}
if (args.length > 0 && args[0].equals("revive")) {
adminRevive(sender,args);return true;
}
UUID owner;
try {
if (args.length != 2 || !(args[0].equals("inspect") || args[0].equals("recover"))) {
@@ -223,9 +302,38 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
return true;
}
private void adminRevive(CommandSender sender,String[] args){if(args.length!=2||args[1].isBlank()||adminRevival==null||!(sender instanceof org.bukkit.entity.Player admin)){sender.sendMessage("Usage: /fantasystick revive <online-player> (an in-game administrator must select the Tail)");return;}var matches=server.getOnlinePlayers().stream().filter(player->player.isOnline()&&player.getName().equalsIgnoreCase(args[1])).toList();if(matches.size()!=1){sender.sendMessage("Admin revival refused: target must name one online player. No item changed.");return;}adminRevival.open(admin,matches.getFirst(),adminReviveSettings.timeout());}
private void give(CommandSender sender, String[] args) {
if (args.length != 2 || args[1].isBlank() || characterIssuer == null) {
sender.sendMessage("Usage: /fantasystick give <online-player>"); return;
}
var matches = server.getOnlinePlayers().stream().filter(player -> player.isOnline()
&& player.getName().equalsIgnoreCase(args[1])).toList();
if (matches.size() != 1) {
sender.sendMessage("Character issue refused: target must name one online player. No character or item was created."); return;
}
var target = matches.getFirst();
characterIssuer.issue(target, outcome -> {
if (closed || !sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(switch (outcome) {
case DELIVERED -> "Issued one new character sheet to " + target.getName() + ".";
case FULL -> "Character issue refused: " + target.getName() + " has no inventory space. No character was created.";
case BUSY -> "Character issue refused: another delivery to " + target.getName() + " is pending.";
case STALE -> "Character issue refused: target disconnected or the reserved inventory slot changed. No sheet was delivered.";
case SAVE_FAILED -> "Character issue failed before delivery; no sheet was delivered. See server diagnostics.";
case RETAINED -> "Character issue has ambiguous retained state; no retry was performed. See server diagnostics.";
});
});
}
private void configuration(CommandSender sender, String[] args) {
if (args.length < 2 || args.length > 3) { configurationUsage(sender); return; }
if (args[1].equals("adventure-mode")) { adventureConfiguration(sender, args); return; }
if (args[1].equals(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY)) { switchDelayConfiguration(sender,args); return; }
if (args[1].equals(games.dmg.fantasystick.persistence.RoleplaySettings.KEY)) { roleplayConfiguration(sender,args); return; }
if (args[1].equals(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY)) { adminReviveConfiguration(sender,args); return; }
if (longbowSettings != null && games.dmg.fantasystick.persistence.LongbowSettings.keys().contains(args[1])) {
longbowConfiguration(sender, args); return;
}
@@ -241,6 +349,12 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (shieldBashSettings != null && games.dmg.fantasystick.persistence.ShieldBashSettings.keys().contains(args[1])) {
shieldBashConfiguration(sender, args); return;
}
if (hexSettings != null && games.dmg.fantasystick.persistence.HexSettings.keys().contains(args[1])) {
hexConfiguration(sender, args); return;
}
if (inspireSettings != null && games.dmg.fantasystick.persistence.InspireSettings.keys().contains(args[1])) {
inspireConfiguration(sender, args); return;
}
if (frostSettings == null || !games.dmg.fantasystick.persistence.FrostShardSettings.keys().contains(args[1])) {
configurationUsage(sender); return;
}
@@ -338,6 +452,19 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
});
}
private void inspireConfiguration(CommandSender sender,String[] args){String key=args[1];if(args.length==2){sender.sendMessage(key+"="+inspireSettings.value(key)+" (applies to future Inspire casts only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.InspireSettings.Update> operation;try{operation=inspireSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Inspire 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){inspire.configure(saved.settings());abilities.replace(inspire.definition());inspireSettings.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 Inspire casts; active effects and deadlines are unchanged.":"Inspire configuration save failed; the effective setting is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void hexConfiguration(CommandSender sender, String[] args) {
String key=args[1];if(args.length==2){sender.sendMessage(key+"="+hexSettings.value(key)+" (applies to future Hex casts only)");return;}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.HexSettings.Update> operation;
try{operation=hexSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Hex 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){hex.configure(saved.settings());abilities.replace(hex.definition());hexSettings.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 Hex casts; active effects and deadlines are unchanged."
:"Hex 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; }
@@ -417,6 +544,12 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
});
}
private void roleplayConfiguration(CommandSender sender,String[] args){if(roleplaySettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+roleplaySettings.value());return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.RoleplaySettings.Update> operation;try{operation=roleplaySettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Usage: /fantasystick config roleplay-disabled [true|false]");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-roleplay",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=false;if(failure==null){applied=roleplaySettings.publish(saved);if(applied&&saved.disabled())sessions.disableRoleplay();}if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Roleplay configuration save failed; policy and sessions are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+roleplaySettings.value()+".");else sender.sendMessage("Saved "+games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+saved.disabled()+(saved.disabled()?"; ordinary exits were requested for active sessions.":"; normal sheet activation is available."));});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void switchDelayConfiguration(CommandSender sender,String[] args){if(switchDelaySettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY+"="+switchDelaySettings.value()+" (new exits use this delay; existing deadlines are unchanged)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SwitchDelaySettings.Update> operation;try{operation=switchDelaySettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Character switch delay is invalid; the effective value is unchanged.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-switch-delay",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure==null)switchDelaySettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;sender.sendMessage(failure==null?"Saved "+games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY+"="+saved.value()+"; new exits use it and existing deadlines are unchanged.":"Character switch delay save failed; the effective value is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void adminReviveConfiguration(CommandSender sender,String[] args){if(adminReviveSettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY+"="+adminReviveSettings.value()+" (applies to later admin revival selections only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.AdminReviveSettings.Update> operation;try{operation=adminReviveSettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Admin revival timeout is invalid; the effective value is unchanged.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-admin-revive",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure==null)adminReviveSettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;sender.sendMessage(failure==null?"Saved "+games.dmg.fantasystick.persistence.AdminReviveSettings.KEY+"="+saved.value()+" for later selections; open selections retain their deadline.":"Admin revival 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;
@@ -449,22 +582,30 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
catch (RuntimeException ignored) { /* Diagnostics cannot authorize recovery. */ }
}
// Never fall back to Bukkit's online-player-name suggestions for a UUID-only admin interface.
// Return only explicit implemented routes; never fall back to Bukkit's broad default suggestions.
@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 == 1) return matching(sender instanceof org.bukkit.entity.Player
? List.of("config", "give", "inspect", "recover", "revive") : List.of("config", "give", "inspect", "recover"), args[0]);
if (args.length == 2 && (args[0].equals("give") || args[0].equals("revive") && sender instanceof org.bukkit.entity.Player)) return matching(server.getOnlinePlayers().stream()
.filter(org.bukkit.entity.Player::isOnline).map(org.bukkit.entity.Player::getName).toList(), args[1]);
if (args.length == 2 && args[0].equals("config")) {
var keys = new java.util.ArrayList<String>(); keys.add("adventure-mode");
var keys = new java.util.ArrayList<String>(); keys.add("adventure-mode");keys.add(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY);keys.add(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY);keys.add(games.dmg.fantasystick.persistence.RoleplaySettings.KEY);
keys.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());
keys.addAll(games.dmg.fantasystick.persistence.HexSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.InspireSettings.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(args[1].equals(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY)&&adminReviveSettings!=null){String current=adminReviveSettings.value();return matching(current.equals(games.dmg.fantasystick.persistence.AdminReviveSettings.DEFAULT)?List.of(current):List.of(current,games.dmg.fantasystick.persistence.AdminReviveSettings.DEFAULT),args[2]);}
if(args[1].equals(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY)&&switchDelaySettings!=null){String current=switchDelaySettings.value();return matching(current.equals(games.dmg.fantasystick.persistence.SwitchDelaySettings.DEFAULT)?List.of(current):List.of(current,games.dmg.fantasystick.persistence.SwitchDelaySettings.DEFAULT),args[2]);}
if(args[1].equals(games.dmg.fantasystick.persistence.RoleplaySettings.KEY)&&roleplaySettings!=null)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]);
@@ -495,6 +636,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
String defaults = games.dmg.fantasystick.persistence.ShieldBashSettings.defaultValue(args[1]);
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
}
if (hexSettings != null && games.dmg.fantasystick.persistence.HexSettings.keys().contains(args[1])) {
String current = hexSettings.value(args[1]); String defaults = games.dmg.fantasystick.persistence.HexSettings.defaultValue(args[1]);
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
}
if(inspireSettings!=null&&games.dmg.fantasystick.persistence.InspireSettings.keys().contains(args[1])){String current=inspireSettings.value(args[1]);String defaults=games.dmg.fantasystick.persistence.InspireSettings.defaultValue(args[1]);return matching(current.equals(defaults)?List.of(current):List.of(current,defaults),args[2]);}
}
return List.of();
}
@@ -502,5 +648,5 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
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; }
@Override public void close() { closed = true; if (characterIssuer != null) characterIssuer.close(); if(adminRevival!=null)adminRevival.close(); }
}
@@ -343,8 +343,14 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
if (allowed && event.getHand() == EquipmentSlot.HAND && (event.getAction() == org.bukkit.event.block.Action.RIGHT_CLICK_AIR
|| event.getAction() == org.bukkit.event.block.Action.RIGHT_CLICK_BLOCK)) {
var selected = sessions.selectedControl(event.getPlayer(), event.getItem()).orElse(null);
if (selected != null && useGesture(selected)) emit(event.getPlayer(), event.getItem(), selected,
ControlInput.Gesture.USE, null, 0, 0);
if (selected != null && useGesture(selected)) {
org.bukkit.entity.Entity target = selected.ability().equals("hex")
? event.getPlayer().getTargetEntity(64)
: selected.ability().equals("inspire")
? event.getPlayer().isSneaking() ? event.getPlayer() : event.getPlayer().getTargetEntity(64)
: null;
emit(event.getPlayer(), event.getItem(), selected, ControlInput.Gesture.USE, target, 0, 0);
}
else if (selected != null && selected.ability().equals("longbow")) beginDraw(event.getPlayer(), event.getItem(), selected);
}
}
@@ -434,8 +440,11 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
if (special(item)) event.setCancelled(true);
if (event.getHand() == EquipmentSlot.HAND) {
var selected = sessions.selectedControl(player, item).orElse(null);
if (selected != null && useGesture(selected)) emit(player, item, selected,
ControlInput.Gesture.USE, event.getRightClicked(), 0, 0);
if (selected != null && useGesture(selected)) {
org.bukkit.entity.Entity target = selected.ability().equals("inspire") && player.isSneaking()
? player : event.getRightClicked();
emit(player, item, selected, ControlInput.Gesture.USE, target, 0, 0);
}
}
}
@@ -38,7 +38,8 @@ public final class EquipmentSessions implements AutoCloseable {
private final CharacterStorage characters;
private final EquipmentSessionStore receipts;
private final Duration switchDelay;
private Supplier<Duration> switchDelay;
private java.util.function.BooleanSupplier roleplayDisabled = () -> false;
private final Clock clock;
private final Consumer<Runnable> nextTick;
private final Consumer<Notice> notices;
@@ -91,6 +92,7 @@ public final class EquipmentSessions implements AutoCloseable {
boolean uncertainSave;
boolean nativeUncertain;
boolean exitRequested;
Duration exitDelay;
boolean arrival;
Attempt recoveryOwner;
org.bukkit.Location entryLocation;
@@ -118,7 +120,8 @@ public final class EquipmentSessions implements AutoCloseable {
Consumer<Runnable> nextTick, Consumer<Notice> notices, Consumer<Failure> diagnostics) {
this.characters = Objects.requireNonNull(characters);
this.receipts = Objects.requireNonNull(receipts);
this.switchDelay = Objects.requireNonNull(switchDelay);
var initialSwitchDelay = Objects.requireNonNull(switchDelay);
this.switchDelay = () -> initialSwitchDelay;
this.clock = Objects.requireNonNull(clock);
if (switchDelay.isNegative()) throw new IllegalArgumentException("Negative switch delay");
this.nextTick = Objects.requireNonNull(nextTick);
@@ -142,6 +145,19 @@ public final class EquipmentSessions implements AutoCloseable {
this.gameModes = Objects.requireNonNull(gameModes);
}
/** Install before exposing entry so later checks/exits capture the acknowledged live policy. */
public synchronized void attachSwitchDelay(Supplier<Duration> policy) {
if (!owned.isEmpty()) throw new IllegalStateException("Sessions already started");
switchDelay = Objects.requireNonNull(policy);
if (switchDelay.get().isNegative()) throw new IllegalArgumentException("Negative switch delay");
}
/** Install before exposing entry so pending and new activation work revalidates availability. */
public synchronized void attachRoleplayDisabled(java.util.function.BooleanSupplier policy) {
if (!owned.isEmpty()) throw new IllegalStateException("Sessions already started");
roleplayDisabled = Objects.requireNonNull(policy);
}
/** 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");
@@ -267,6 +283,10 @@ public final class EquipmentSessions implements AutoCloseable {
public synchronized void enter(Player player) {
UUID owner = player.getUniqueId();
if (closed) return;
if (roleplayDisabled.getAsBoolean()) {
notice(owner, "ROLEPLAY_DISABLED", "Fantasy roleplaying is disabled; the character sheet was not activated.");
return;
}
if (transitioning.containsKey(owner)) {
notice(owner, "BUSY", "An equipment transition is still completing; retry afterwards."); return;
}
@@ -326,6 +346,10 @@ public final class EquipmentSessions implements AutoCloseable {
private synchronized void loaded(Attempt attempt, Loaded loaded, Throwable failure) {
if (!entryCurrent(attempt) || attempt.claim != null) return;
if (roleplayDisabled.getAsBoolean()) {
refuse(attempt, "ROLEPLAY_DISABLED", "Fantasy roleplaying was disabled before activation completed.");
return;
}
if (failure != null) { fail(attempt, failure); return; }
var record = loaded.character().orElse(null);
if (record == null || record.dead() || record.health().signum() <= 0) {
@@ -337,7 +361,7 @@ public final class EquipmentSessions implements AutoCloseable {
refuse(attempt, "RECOVERY_REQUIRED", "An unresolved equipment receipt requires validated recovery."); return;
}
Instant now = clock.instant();
if (!state.canEnter(attempt.character, switchDelay, now)) {
if (!state.canEnter(attempt.character, switchDelay.get(), now)) {
Duration remaining = Duration.between(now, state.lastExit().deadline());
long seconds = remaining.getSeconds() + (remaining.getNano() == 0 ? 0 : 1);
refuse(attempt, "COOLDOWN", "Wait " + seconds + " seconds to switch characters (until "
@@ -376,6 +400,10 @@ public final class EquipmentSessions implements AutoCloseable {
private synchronized void complete(Attempt attempt, Optional<CharacterRecord> fresh, Throwable failure) {
if (!entryCurrent(attempt)) return;
if (roleplayDisabled.getAsBoolean()) {
refuse(attempt, "ROLEPLAY_DISABLED", "Fantasy roleplaying was disabled before activation completed.");
return;
}
if (failure != null) { fail(attempt, failure); return; }
var record = fresh.orElse(null);
if (record == null || record.dead() || record.health().signum() <= 0) {
@@ -454,6 +482,7 @@ public final class EquipmentSessions implements AutoCloseable {
UUID owner = player.getUniqueId();
var transition = transitioning.get(owner);
if (transition != null) {
captureExitDelay(transition);
active.remove(owner, transition);
withdrawPresentation(transition);
transition.exitRequested = true;
@@ -463,6 +492,7 @@ public final class EquipmentSessions implements AutoCloseable {
cancelEntry(owner);
var attempt = active.remove(owner);
if (attempt == null) { invalidateCursor(owner); return EquipmentStorage.RestoreResult.NOT_FOUND; }
captureExitDelay(attempt);
return restoreExit(attempt);
}
@@ -607,6 +637,7 @@ public final class EquipmentSessions implements AutoCloseable {
}
private EquipmentStorage.RestoreResult restoreExit(Attempt attempt) {
captureExitDelay(attempt);
active.remove(attempt.owner, attempt);
withdrawPresentation(attempt);
boolean reserved = transitioning.putIfAbsent(attempt.owner, attempt) == null;
@@ -703,19 +734,35 @@ public final class EquipmentSessions implements AutoCloseable {
return false;
}
private void captureExitDelay(Attempt attempt) {
if (attempt.exitDelay != null) return;
var delay = Objects.requireNonNull(switchDelay.get());
if (delay.isNegative()) throw new IllegalStateException("Negative switch delay policy");
attempt.exitDelay = delay;
}
private void persistExit(Attempt attempt, boolean restored) {
captureExitDelay(attempt); // Recovery attempts have no process-local exit request to capture earlier.
attempt.nativeRestored = restored;
boolean complete = restored && (attempt.healthAcknowledged || attempt.defeated);
attempt.saving = true;
persist(() -> complete
? receipts.completeExit(attempt.owner, attempt.session, switchDelay)
: receipts.recordExit(attempt.owner, attempt.session, switchDelay), (state, failure) -> {
? receipts.completeExit(attempt.owner, attempt.session, attempt.exitDelay)
: receipts.recordExit(attempt.owner, attempt.session, attempt.exitDelay), (state, failure) -> {
attempt.saving = false;
if (failure != null) diagnostic(attempt, failure);
else if (complete) { attempt.gearAcknowledged = true; release(attempt); }
});
}
/** Cancel pending activation and route every active player through ordinary restoration. */
public synchronized void disableRoleplay() {
for (var attempt : java.util.List.copyOf(pending.values())) {
refuse(attempt, "ROLEPLAY_DISABLED", "Fantasy roleplaying was disabled before activation completed.");
}
for (var attempt : java.util.List.copyOf(active.values())) exit(attempt.player);
}
public synchronized void cancelEntry(UUID player) {
var attempt = pending.remove(player);
if (attempt != null) abandon(attempt);
+16
View File
@@ -1,9 +1,11 @@
# 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
admin-revive-selection-timeout-seconds: 60
# Real-time delay before this player may enter a different character after exit.
character-swap-cooldown-seconds: 900
roleplay-disabled: false
# New-character and transferable fantasy progression defaults.
starting-points: 40
@@ -70,3 +72,17 @@ shield-bash-cooldown-seconds: 6
shield-bash-range-blocks: 3
shield-bash-horizontal-knockback: 0.8
shield-bash-minimum-vertical-knockback: 0.35
# Hex defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
hex-cooldown-seconds: 10
hex-range-blocks: 12
hex-duration-seconds: 4
hex-damage-reduction-percent: 20
# Inspire defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
inspire-base-temporary-health: 4
inspire-cooldown-seconds: 15
inspire-range-blocks: 12
inspire-duration-seconds: 6
+3 -3
View File
@@ -7,10 +7,10 @@ description: Transferable characters, native fantasy health, recoverable equipme
author: dmg.games
commands:
fantasystick:
description: Inspect/recover equipment sessions or configure Fantasy Stick.
usage: /fantasystick <inspect|recover> <player-uuid> | config <setting> [value]
description: Issue or revive character sheets, inspect/recover sessions or configure Fantasy Stick.
usage: /fantasystick <give|revive> <online-player> | <inspect|recover> <player-uuid> | config <setting> [value]
permission: fantasystick.admin
permissions:
fantasystick.admin:
description: Inspect/recover equipment sessions and configure fantasy mode.
description: Issue/revive sheets, inspect/recover sessions and configure fantasy mode.
default: op
@@ -0,0 +1,28 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.math.BigInteger;
import java.nio.file.Path;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class AdminRevivalNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void commandCompletionConfigurationAndValidationArePermissionAwareAndDurable()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var admin=new FantasyStickLifecycleNativeTest.Actor(data);var target=new FantasyStickLifecycleNativeTest.Actor(data);doReturn("Admin").when(admin.player).getName();doReturn("Target").when(target.player).getName();doReturn(true).when(admin.player).hasPermission("fantasystick.admin");f.players.put(admin.core.id,admin.player);f.players.put(target.core.id,target.player);assertEquals(java.util.List.of("revive"),f.command.tabComplete(admin.player,"fantasystick",new String[]{"rev"}));assertEquals(java.util.List.of("Target"),f.command.tabComplete(admin.player,"fantasystick",new String[]{"revive","Tar"}));assertTrue(f.command.tabComplete(admin.player,"fantasystick",new String[]{"revive","Target",""}).isEmpty());doReturn(false).when(admin.player).hasPermission("fantasystick.admin");assertTrue(f.command.tabComplete(admin.player,"fantasystick",new String[]{"revive",""}).isEmpty());doReturn(true).when(admin.player).hasPermission("fantasystick.admin");f.command.execute(admin.player,"fantasystick",new String[]{"revive"});assertTrue(admin.messages.getLast().contains("Usage"));f.command.execute(admin.player,"fantasystick",new String[]{"revive","Missing"});assertTrue(admin.messages.getLast().contains("target must name"));var sender=new EquipmentAdminCommandNativeTest.Sender();sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","admin-rev"}).isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config","admin-revive-selection-timeout-seconds","90"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;f.command.execute(sender.port,"fantasystick",new String[]{"config","admin-revive-selection-timeout-seconds","90","extra"});assertTrue(sender.messages.getLast().contains("Usage"));assertEquals(java.util.List.of("admin-revive-selection-timeout-seconds"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","admin-rev"}));assertEquals(java.util.List.of("60"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","admin-revive-selection-timeout-seconds",""}));f.command.execute(sender.port,"fantasystick",new String[]{"config","admin-revive-selection-timeout-seconds"});assertTrue(sender.messages.getLast().contains("=60"));for(String invalid:java.util.List.of("0","-1","1.5","bad")){f.command.execute(sender.port,"fantasystick",new String[]{"config","admin-revive-selection-timeout-seconds",invalid});assertTrue(sender.messages.getLast().contains("invalid"));}var pendingDead=dead(data,Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.SECONDS));target.core.inventory.setItem(10,new games.dmg.fantasystick.items.TailBooks().create(pendingDead));f.command.execute(admin.player,"fantasystick",new String[]{"revive","Target"});((games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters")).find(pendingDead.id()).get();f.tick();assertEquals(1200L,f.delayedTicks.getLast());int before=sender.messages.size();f.command.execute(sender.port,"fantasystick",new String[]{"config","admin-revive-selection-timeout-seconds","90"});assertEquals(before,sender.messages.size());var response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertTrue(sender.messages.getLast().contains("=90"));f.command.execute(admin.player,"fantasystick",new String[]{"revive","Target"});((games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters")).find(pendingDead.id()).get();f.tick();assertEquals(1800L,f.delayedTicks.getLast());assertTrue(f.delayedTicks.contains(1200L));f.plugin.setEnabled(false);f.plugin.setEnabled(true);f.command.execute(sender.port,"fantasystick",new String[]{"config","admin-revive-selection-timeout-seconds"});assertTrue(sender.messages.getLast().contains("=90"));}
var failed=data.resolve("failed-write");java.nio.file.Files.createDirectories(failed);try(var f=new FantasyStickLifecycleNativeTest.Fixture(failed)){f.plugin.setEnabled(true);java.nio.file.Files.createDirectory(failed.resolve("config.yml"));var sender=new EquipmentAdminCommandNativeTest.Sender();f.command.execute(sender.port,"fantasystick",new String[]{"config","admin-revive-selection-timeout-seconds","90"});var response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertTrue(sender.messages.getLast().contains("save failed"));f.command.execute(sender.port,"fantasystick",new String[]{"config","admin-revive-selection-timeout-seconds"});assertTrue(sender.messages.getLast().contains("=60"));}
for(String invalid:java.util.List.of("0","-1","1.5","bad")){var dir=data.resolve(invalid.replace('.','_'));java.nio.file.Files.createDirectories(dir);java.nio.file.Files.writeString(dir.resolve("config.yml"),"admin-revive-selection-timeout-seconds: '"+invalid+"'\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){assertThrows(IllegalArgumentException.class,()->f.plugin.setEnabled(true));assertTrue(f.order.isEmpty());}}}
@Test void registeredAdminSelectsAnExpiredDirectTailAndRestoresSameCharacterWithoutChangingTargetState()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var admin=new FantasyStickLifecycleNativeTest.Actor(data);var target=new FantasyStickLifecycleNativeTest.Actor(data);doReturn("Admin").when(admin.player).getName();doReturn("Target").when(target.player).getName();doReturn(true).when(admin.player).hasPermission("fantasystick.admin");f.players.put(admin.core.id,admin.player);f.players.put(target.core.id,target.player);f.activate(target);var dead=dead(data,Instant.now().minusSeconds(172800).truncatedTo(ChronoUnit.SECONDS));var tail=new games.dmg.fantasystick.items.TailBooks().create(dead);tail.setAmount(2);target.core.inventory.setItem(10,tail);var beforeHealth=target.core.health;var beforeMode=target.core.gameMode;var beforeLocation=target.core.location.clone();
assertTrue(f.command.execute(admin.player,"fantasystick",new String[]{"revive","Target"}));var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");storage.find(dead.id()).get();f.tick();assertEquals(1,admin.inventories.size());assertTrue(target.inventories.isEmpty());var popup=admin.inventories.getFirst();assertEquals(1,java.util.stream.IntStream.range(0,popup.getSize()).filter(i->popup.getItem(i)!=null).count());var view=mock(org.bukkit.inventory.InventoryView.class);when(view.getTopInventory()).thenReturn(popup);when(view.getBottomInventory()).thenReturn(admin.core.inventory);when(view.getPlayer()).thenReturn(admin.player);var click=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);f.dispatcher.accept(click);assertTrue(click.isCancelled());PartyBookNativeTest.drain(f);((games.dmg.fantasystick.persistence.RevivalStore)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"revivals")).find(dead.id()).join();for(int i=0;i<4;i++)PartyBookNativeTest.drain(f);var restored=storage.find(dead.id()).get().orElseThrow();assertFalse(restored.dead());var sheets=new games.dmg.fantasystick.items.CharacterSheets();assertEquals(1,java.util.stream.IntStream.range(0,41).filter(slot->(slot<=35||slot==40)&&sheets.identify(target.core.inventory.getItem(slot)).filter(dead.id()::equals).isPresent()).count());assertEquals(1,target.core.inventory.getItem(10).getAmount());assertEquals(dead.deathId(),new games.dmg.fantasystick.items.TailBooks().identify(target.core.inventory.getItem(10)).orElseThrow().deathId());assertEquals(restored.maximumHealth(),restored.health());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());f.dispatcher.accept(click);assertEquals(1,java.util.stream.IntStream.range(0,41).filter(slot->(slot<=35||slot==40)&&sheets.identify(target.core.inventory.getItem(slot)).filter(dead.id()::equals).isPresent()).count());assertEquals(1,target.core.inventory.getItem(10).getAmount());assertEquals(beforeHealth,target.core.health);assertEquals(beforeMode,target.core.gameMode);assertEquals(beforeLocation,target.core.location);assertEquals(target.core.record.id(),((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions")).active(target.core.id).orElseThrow().character());}}
@Test void failedCharacterRestoreRetainsOneSheetAndReservationWithoutFalseSuccess()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var admin=new FantasyStickLifecycleNativeTest.Actor(data);var target=new FantasyStickLifecycleNativeTest.Actor(data);doReturn("Admin").when(admin.player).getName();doReturn("Target").when(target.player).getName();doReturn(true).when(admin.player).hasPermission("fantasystick.admin");f.players.put(admin.core.id,admin.player);f.players.put(target.core.id,target.player);var dead=dead(data,Instant.now().minusSeconds(172800).truncatedTo(ChronoUnit.SECONDS));target.core.inventory.setItem(10,new games.dmg.fantasystick.items.TailBooks().create(dead));f.command.execute(admin.player,"fantasystick",new String[]{"revive","Target"});PartyBookNativeTest.drain(f);var popup=admin.inventories.getFirst();var corrupted=new java.util.concurrent.atomic.AtomicBoolean();target.core.mutation=()->{if(!corrupted.compareAndSet(false,true))return;try{var path=data.resolve("characters").resolve(dead.id()+".yml");java.nio.file.Files.delete(path);java.nio.file.Files.createDirectory(path);}catch(Exception e){throw new RuntimeException(e);}};var view=mock(org.bukkit.inventory.InventoryView.class);when(view.getTopInventory()).thenReturn(popup);when(view.getBottomInventory()).thenReturn(admin.core.inventory);when(view.getPlayer()).thenReturn(admin.player);f.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(f);var revival=(games.dmg.fantasystick.persistence.RevivalStore)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"revivals");revival.find(dead.id()).join();for(int i=0;i<3;i++)PartyBookNativeTest.drain(f);assertEquals(dead.id(),new games.dmg.fantasystick.items.CharacterSheets().identify(target.core.inventory.getItem(10)).orElseThrow());assertTrue(revival.find(dead.id()).join().isPresent());assertFalse(admin.messages.stream().anyMatch(message->message.startsWith("Administratively restored")));target.core.mutation=()->{};}}
@Test void competingAdministratorsCanConsumeOneDeathOnlyOnce()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 target=new FantasyStickLifecycleNativeTest.Actor(data);doReturn("AdminA").when(a.player).getName();doReturn("AdminB").when(b.player).getName();doReturn("Target").when(target.player).getName();doReturn(true).when(a.player).hasPermission("fantasystick.admin");doReturn(true).when(b.player).hasPermission("fantasystick.admin");f.players.put(a.core.id,a.player);f.players.put(b.core.id,b.player);f.players.put(target.core.id,target.player);var dead=dead(data,Instant.now().minusSeconds(172800).truncatedTo(ChronoUnit.SECONDS));target.core.inventory.setItem(10,new games.dmg.fantasystick.items.TailBooks().create(dead));f.command.execute(a.player,"fantasystick",new String[]{"revive","Target"});f.command.execute(b.player,"fantasystick",new String[]{"revive","Target"});PartyBookNativeTest.drain(f);assertEquals(1,a.inventories.size());assertEquals(1,b.inventories.size());for(var admin:java.util.List.of(a,b)){var popup=admin.inventories.getFirst();var view=mock(org.bukkit.inventory.InventoryView.class);when(view.getTopInventory()).thenReturn(popup);when(view.getBottomInventory()).thenReturn(admin.core.inventory);when(view.getPlayer()).thenReturn(admin.player);f.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(f);var revival=(games.dmg.fantasystick.persistence.RevivalStore)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"revivals");revival.find(dead.id()).join();for(int i=0;i<4;i++)PartyBookNativeTest.drain(f);var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertFalse(storage.find(dead.id()).get().orElseThrow().dead());var sheets=new games.dmg.fantasystick.items.CharacterSheets();assertEquals(1,java.util.stream.IntStream.range(0,41).filter(slot->(slot<=35||slot==40)&&sheets.identify(target.core.inventory.getItem(slot)).filter(dead.id()::equals).isPresent()).count());}}
@Test void counterfeitLivingAndLifecycleCancelledSelectionsNeverChangeItems()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var admin=new FantasyStickLifecycleNativeTest.Actor(data);var target=new FantasyStickLifecycleNativeTest.Actor(data);doReturn("Admin").when(admin.player).getName();doReturn("Target").when(target.player).getName();doReturn(true).when(admin.player).hasPermission("fantasystick.admin");f.players.put(admin.core.id,admin.player);f.players.put(target.core.id,target.player);target.core.inventory.setItem(10,new org.bukkit.inventory.ItemStack(org.bukkit.Material.WRITTEN_BOOK));f.command.execute(admin.player,"fantasystick",new String[]{"revive","Target"});assertTrue(admin.messages.getLast().contains("no authentic"));var livingTailRecord=dead(data,Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.SECONDS));var livingTail=new games.dmg.fantasystick.items.TailBooks().create(livingTailRecord);var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");storage.restoreDeathRecordAdministratively(livingTailRecord.id(),livingTailRecord.deathId(),new java.math.BigDecimal("1024")).get();target.core.inventory.setItem(10,livingTail);f.command.execute(admin.player,"fantasystick",new String[]{"revive","Target"});storage.find(livingTailRecord.id()).get();f.tick();assertTrue(admin.messages.getLast().contains("no current"));var dead=dead(data,Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.SECONDS));var tail=new games.dmg.fantasystick.items.TailBooks().create(dead);target.core.inventory.setItem(10,tail);f.command.execute(admin.player,"fantasystick",new String[]{"revive","Target"});storage.find(dead.id()).get();f.tick();var popup=admin.inventories.getFirst();f.plugin.setEnabled(false);assertNull(popup.getItem(0));assertEquals(tail,target.core.inventory.getItem(10));}}
@Test void administrativeReservationResumesAfterRestartWithoutWeakeningDeathIdentity()throws Exception{var target=new FantasyStickLifecycleNativeTest.Actor(data);doReturn("Target").when(target.player).getName();var dead=dead(data,Instant.now().minusSeconds(172800).truncatedTo(ChronoUnit.SECONDS));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(),dead.deathId(),Instant.now().truncatedTo(ChronoUnit.SECONDS),10,1,-1,true);try(var store=new games.dmg.fantasystick.persistence.RevivalStore(data)){assertTrue(store.reserve(reservation).join().isPresent());}try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.players.put(target.core.id,target.player);f.plugin.setEnabled(true);var revivals=(games.dmg.fantasystick.persistence.RevivalStore)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"revivals");revivals.target(target.core.id).join();for(int i=0;i<4;i++)PartyBookNativeTest.drain(f);var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertFalse(storage.find(dead.id()).get().orElseThrow().dead());assertTrue(revivals.find(dead.id()).join().isEmpty());assertEquals(dead.id(),new games.dmg.fantasystick.items.CharacterSheets().identify(target.core.inventory.getItem(10)).orElseThrow());}}
@Test void multipleChoicesSelectExactItemWhilePermissionLossMovementAndTimeoutCancelWithoutMutation()throws Exception{for(String route:java.util.List.of("select-recent","select-second","permission","movement","timeout")){var dir=data.resolve(route);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){f.plugin.setEnabled(true);var admin=new FantasyStickLifecycleNativeTest.Actor(dir);var target=new FantasyStickLifecycleNativeTest.Actor(dir);doReturn("Admin").when(admin.player).getName();doReturn("Target").when(target.player).getName();doReturn(true).when(admin.player).hasPermission("fantasystick.admin");f.players.put(admin.core.id,admin.player);f.players.put(target.core.id,target.player);var first=dead(dir,Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.SECONDS));var second=dead(dir,Instant.now().minusSeconds(86400).truncatedTo(ChronoUnit.SECONDS));var firstTail=new games.dmg.fantasystick.items.TailBooks().create(first);var secondTail=new games.dmg.fantasystick.items.TailBooks().create(second);target.core.inventory.setItem(10,firstTail);target.core.inventory.setItem(11,secondTail);var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");f.command.execute(admin.player,"fantasystick",new String[]{"revive","Target"});storage.find(first.id()).get();f.tick();var popup=admin.inventories.getFirst();assertEquals(2,java.util.stream.IntStream.range(0,popup.getSize()).filter(i->popup.getItem(i)!=null).count());assertTrue(f.delayedTicks.contains(1200L));if(route.equals("timeout")){f.delayed.getLast().run();assertEquals(firstTail,target.core.inventory.getItem(10));assertEquals(secondTail,target.core.inventory.getItem(11));continue;}var view=mock(org.bukkit.inventory.InventoryView.class);when(view.getTopInventory()).thenReturn(popup);when(view.getBottomInventory()).thenReturn(admin.core.inventory);when(view.getPlayer()).thenReturn(admin.player);if(route.equals("permission"))doReturn(false).when(admin.player).hasPermission("fantasystick.admin");if(route.equals("movement"))target.core.inventory.setItem(11,new org.bukkit.inventory.ItemStack(org.bukkit.Material.DIAMOND));int selected=route.equals("select-recent")?0:1;f.dispatcher.accept(new org.bukkit.event.inventory.InventoryClickEvent(view,org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER,selected,org.bukkit.event.inventory.ClickType.LEFT,org.bukkit.event.inventory.InventoryAction.PICKUP_ALL));PartyBookNativeTest.drain(f);if(route.startsWith("select-")){var selectedRecord=route.equals("select-recent")?first:second;var revival=(games.dmg.fantasystick.persistence.RevivalStore)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"revivals");revival.find(selectedRecord.id()).join();for(int i=0;i<3;i++)PartyBookNativeTest.drain(f);assertEquals(!route.equals("select-recent"),storage.find(first.id()).get().orElseThrow().dead());assertEquals(route.equals("select-recent"),storage.find(second.id()).get().orElseThrow().dead());if(route.equals("select-recent")){assertEquals(first.id(),new games.dmg.fantasystick.items.CharacterSheets().identify(target.core.inventory.getItem(10)).orElseThrow());assertEquals(secondTail,target.core.inventory.getItem(11));}else{assertEquals(firstTail,target.core.inventory.getItem(10));assertEquals(second.id(),new games.dmg.fantasystick.items.CharacterSheets().identify(target.core.inventory.getItem(11)).orElseThrow());}}else{assertTrue(storage.find(first.id()).get().orElseThrow().dead());assertTrue(storage.find(second.id()).get().orElseThrow().dead());assertEquals(firstTail,target.core.inventory.getItem(10));assertEquals(route.equals("movement")?org.bukkit.Material.DIAMOND:org.bukkit.Material.WRITTEN_BOOK,target.core.inventory.getItem(11).getType());}}}}
static games.dmg.fantasystick.persistence.CharacterRecord dead(Path data,Instant died)throws Exception{var base=new games.dmg.fantasystick.crafting.CharacterFactory().create(games.dmg.fantasystick.progression.ProgressionRules.defaults());var record=new games.dmg.fantasystick.persistence.CharacterRecord(base.id(),"Ancient Mira",base.revision(),base.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",Instant.now().plusSeconds(30)),base.restrictedKills(),true,java.util.UUID.randomUUID(),died);new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).create(record);return record;}
}
@@ -17,6 +17,73 @@ class EquipmentAdminCommandNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void registeredGivePersistsOneFreshSheetThenDeliversActivatesAndReloads() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var target = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(target.core.id, target.player);
var before = target.core.snapshot(); var sender = new Sender();
assertTrue(f.command.execute(sender.port, "fantasystick", new String[]{"give", "Account"}));
assertTrue(sender.messages.isEmpty(), "Queued persistence is not successful delivery");
((games.dmg.fantasystick.persistence.CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters"))
.find(java.util.UUID.randomUUID()).get(5, java.util.concurrent.TimeUnit.SECONDS);
var callback = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(callback); callback.run(); callback.run();
assertTrue(sender.messages.getLast().contains("Issued one new character sheet"));
assertFalse(java.util.Arrays.equals(before, target.core.snapshot()));
var sheets = new games.dmg.fantasystick.items.CharacterSheets();
var delivered = java.util.stream.IntStream.range(0, 36).mapToObj(target.core.inventory::getItem)
.filter(item -> sheets.identify(item).isPresent()).filter(item -> !sheets.identify(item).orElseThrow().equals(target.core.record.id())).toList();
assertEquals(1, delivered.size(), "Repeated callback must not duplicate delivery");
assertEquals(target.core.record.id(),sheets.identify(target.core.inventory.getItemInOffHand()).orElseThrow());
assertTrue(((games.dmg.fantasystick.session.EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions")).active(target.core.id).isEmpty());
var id = sheets.identify(delivered.getFirst()).orElseThrow();
var storage = (games.dmg.fantasystick.persistence.CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var record = storage.find(id).get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow();
assertEquals(java.math.BigInteger.valueOf(40), record.unspentPoints()); assertEquals(1, record.level());
assertEquals(java.math.BigInteger.ZERO, record.experience()); assertFalse(record.dead()); assertTrue(record.learned().isEmpty());
assertTrue(record.stats().values().stream().allMatch(java.math.BigInteger.ZERO::equals));assertEquals(new java.math.BigDecimal("10"),record.health());
assertEquals(java.util.Collections.nCopies(7,""),record.loadout());assertTrue(record.cooldowns().isEmpty());assertFalse(record.name().isBlank());
target.core.inventory.remove(delivered.getFirst()); target.core.inventory.setItemInOffHand(delivered.getFirst());
f.activate(target); assertEquals(id, ((games.dmg.fantasystick.session.EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions")).active(target.core.id).orElseThrow().character());
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
assertEquals(record, ((games.dmg.fantasystick.persistence.CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters")).find(id).get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow());
}
}
@Test void giveCompletionAndRejectedExecutionArePermissionAwareStrictAndReadOnly() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var target = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(target.core.id,target.player);
var sender=new Sender();
assertEquals(List.of("config","give","inspect","recover"),f.command.tabComplete(sender.port,"fantasystick",new String[]{""}));
assertEquals(List.of("give"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"g"}));
assertEquals(List.of("Account"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"give",""}));
assertEquals(List.of("Account"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"give","Acc"}));
assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"give","Account",""}).isEmpty());
sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"give",""}).isEmpty());
var original=target.core.snapshot();f.command.getExecutor().onCommand(sender.port,f.command,"fantasystick",new String[]{"give","Account"});
sender.allowed=true;for(String[] args:List.of(new String[]{"give"},new String[]{"give","Missing"},new String[]{"give","Account","extra"}))f.command.execute(sender.port,"fantasystick",args);
assertArrayEquals(original,target.core.snapshot());assertTrue(f.tasks.isEmpty());
assertEquals(1,java.nio.file.Files.list(data.resolve("characters")).filter(path->path.toString().endsWith(".yml")).count());
for(int slot=0;slot<36;slot++)target.core.inventory.setItem(slot,new org.bukkit.inventory.ItemStack(org.bukkit.Material.STONE));
var full=target.core.snapshot();f.command.execute(sender.port,"fantasystick",new String[]{"give","Account"});assertTrue(sender.messages.getLast().contains("no inventory space"));
assertArrayEquals(full,target.core.snapshot());assertTrue(f.tasks.isEmpty());assertEquals(1,java.nio.file.Files.list(data.resolve("characters")).filter(path->path.toString().endsWith(".yml")).count());
}
}
@Test void giveSaveFailureDisconnectSlotRaceAndLifecycleInterruptionNeverDuplicateOrOverwrite() throws Exception {
for(String route:List.of("save","disconnect","slot","close")){var dir=data.resolve(route);java.nio.file.Files.createDirectories(dir);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){
f.plugin.setEnabled(true);var target=new FantasyStickLifecycleNativeTest.Actor(dir);f.players.put(target.core.id,target.player);var sender=new Sender();var original=target.core.snapshot();
var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");
var worker=(java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(storage,"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 e){Thread.currentThread().interrupt();}});assertTrue(entered.await(5,java.util.concurrent.TimeUnit.SECONDS));
f.command.execute(sender.port,"fantasystick",new String[]{"give","Account"});
if(route.equals("save")){var issuer=FantasyStickLifecycleNativeTest.Fixture.field(f.command.getExecutor(),"characterIssuer");var pending=(java.util.Map<?,?>)FantasyStickLifecycleNativeTest.Fixture.field(issuer,"pending");var attempt=pending.values().iterator().next();var record=(games.dmg.fantasystick.persistence.CharacterRecord)FantasyStickLifecycleNativeTest.Fixture.field(attempt,"record");java.nio.file.Files.createDirectory(dir.resolve("characters").resolve(record.id()+".yml"));}
release.countDown();storage.find(java.util.UUID.randomUUID()).get(5,java.util.concurrent.TimeUnit.SECONDS);var callback=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(callback);
if(route.equals("disconnect"))target.core.online=false;if(route.equals("slot"))target.core.inventory.setItem(1,new org.bukkit.inventory.ItemStack(org.bukkit.Material.DIAMOND));if(route.equals("close"))f.plugin.setEnabled(false);callback.run();
if(!route.equals("close"))storage.find(java.util.UUID.randomUUID()).get(5,java.util.concurrent.TimeUnit.SECONDS);
assertFalse(sender.messages.stream().anyMatch(message->message.startsWith("Issued one")));assertEquals(route.equals("slot")?org.bukkit.Material.DIAMOND:original[1]==null?null:original[1].getType(),target.core.inventory.getItem(1)==null?null:target.core.inventory.getItem(1).getType());
if(!route.equals("save"))assertEquals(1,java.nio.file.Files.list(dir.resolve("characters")).filter(path->path.toString().endsWith(".yml")).count());
release.countDown();}}
}
@Test void adventureConfigurationCommandReadsValidatesPersistsAndReopensWithoutTouchingPlayers() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
@@ -0,0 +1,196 @@
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.damage.DamageSource;
import org.bukkit.damage.DamageType;
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 HexNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void bookCastActualPreventionAndQualifyingAllyDamagePersistTwoRewardShares() 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);
withHex(data, a, false); 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(0); PartyBookNativeTest.open(f, a);
String book = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
assertTrue(book.contains("Hex")); assertTrue(book.contains("CHR14")); assertTrue(book.contains("WIS12"));
assertTrue(book.contains("Cost:1")); assertTrue(book.contains("12")); assertTrue(book.contains("20%")); assertTrue(book.contains("4s"));
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "hex")); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "hex")); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var enemy = mock(org.bukkit.entity.LivingEntity.class); var enemyId = java.util.UUID.randomUUID();
when(enemy.getUniqueId()).thenReturn(enemyId); when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
doReturn(new Location(a.world, 0, 64, 0)).when(a.player).getLocation();
when(enemy.getLocation()).thenReturn(new Location(a.world, 8, 64, 0)); doReturn(true).when(a.player).hasLineOfSight(enemy);
doReturn(enemy).when(a.player).getTargetEntity(64);
a.core.inventory.getInventory().setSelectedSlot(2);
f.dispatcher.accept(new PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
a.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("hex"));
var allySource = DamageSource.builder(DamageType.PLAYER_ATTACK).withCausingEntity(b.player).withDirectEntity(b.player).build();
f.dispatcher.accept(new EntityDamageByEntityEvent(b.player, enemy, EntityDamageEvent.DamageCause.ENTITY_ATTACK, allySource, 3));
var enemySource = DamageSource.builder(DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();
var protectedHit = new EntityDamageByEntityEvent(enemy, b.player, EntityDamageEvent.DamageCause.ENTITY_ATTACK, enemySource, 10);
f.dispatcher.accept(protectedHit); assertEquals(8, protectedHit.getDamage(), 1.0e-9);
var sourceSheet=a.core.inventory.getItemInOffHand().clone();a.core.inventory.setItemInOffHand(null);f.changed(a);PartyBookNativeTest.drain(f);f.awaitCharacterAndReceipt(a);
var holder=new FantasyStickLifecycleNativeTest.Actor(data);holder.core.inventory.setItemInOffHand(sourceSheet);f.players.put(holder.core.id,holder.player);f.activate(holder);
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(enemy, allySource, new java.util.ArrayList<>(), 0));
for (int i = 0; i < 4; i++) PartyBookNativeTest.drain(f);
assertEquals(BigInteger.valueOf(5), storage.find(a.core.record.id()).get().orElseThrow().experience());
assertEquals(BigInteger.valueOf(5), storage.find(b.core.record.id()).get().orElseThrow().experience());
}
}
@Test void replacementOldExpiryAndSourceExitPreserveOnlyCurrentOwnedPenalty() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),"hex-cooldown-seconds: 0\n");
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withHex(data,a,true);f.players.put(a.core.id,a.player);f.activate(a);
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
doReturn(new Location(a.world,0,64,0)).when(a.player).getLocation();when(enemy.getLocation()).thenReturn(new Location(a.world,8,64,0));doReturn(true).when(a.player).hasLineOfSight(enemy);doReturn(enemy).when(a.player).getTargetEntity(64);
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();
var hit=new EntityDamageByEntityEvent(enemy,mock(org.bukkit.entity.Zombie.class),EntityDamageEvent.DamageCause.ENTITY_ATTACK,mock(DamageSource.class),10);
f.dispatcher.accept(hit);assertEquals(8,hit.getDamage(),1.0e-9);f.delayed.getLast().run();
var expired=new EntityDamageByEntityEvent(enemy,mock(org.bukkit.entity.Zombie.class),EntityDamageEvent.DamageCause.ENTITY_ATTACK,mock(DamageSource.class),10);
f.dispatcher.accept(expired);assertEquals(10,expired.getDamage(),1.0e-9);
use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);a.core.inventory.setItemInOffHand(null);f.changed(a);PartyBookNativeTest.drain(f);f.awaitCharacterAndReceipt(a);
var exited=new EntityDamageByEntityEvent(enemy,mock(org.bukkit.entity.Zombie.class),EntityDamageEvent.DamageCause.ENTITY_ATTACK,mock(DamageSource.class),10);
f.dispatcher.accept(exited);assertEquals(10,exited.getDamage(),1.0e-9);
}
}
@Test void latestReplacementOwnsEffectivePreventionSupport() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),"hex-cooldown-seconds: 0\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);var c=new FantasyStickLifecycleNativeTest.Actor(data);var b=new FantasyStickLifecycleNativeTest.Actor(data);withHex(data,a,true);withHex(data,c,true);f.players.put(a.core.id,a.player);f.players.put(c.core.id,c.player);f.players.put(b.core.id,b.player);f.activate(a);f.activate(c);f.activate(b);
var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(a.core.id).orElseThrow();for(var member:List.of(c,b))assertTrue(parties.accept(member.core.id,parties.invite(a.core.id,party.id(),member.core.id).orElseThrow().id()));
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());when(enemy.getLocation()).thenReturn(new Location(a.world,8,64,0));
for(var caster:List.of(a,c)){doReturn(new Location(a.world,0,64,0)).when(caster.player).getLocation();doReturn(true).when(caster.player).hasLineOfSight(enemy);doReturn(enemy).when(caster.player).getTargetEntity(64);use(f,caster);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);}
var allySource=DamageSource.builder(DamageType.PLAYER_ATTACK).withCausingEntity(b.player).withDirectEntity(b.player).build();f.dispatcher.accept(new EntityDamageByEntityEvent(b.player,enemy,EntityDamageEvent.DamageCause.ENTITY_ATTACK,allySource,3));var enemySource=DamageSource.builder(DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();var hit=new EntityDamageByEntityEvent(enemy,b.player,EntityDamageEvent.DamageCause.ENTITY_ATTACK,enemySource,10);f.dispatcher.accept(hit);assertEquals(8,hit.getDamage(),1.0e-9);f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(enemy,allySource,new java.util.ArrayList<>(),0));for(int i=0;i<4;i++)PartyBookNativeTest.drain(f);
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(BigInteger.ZERO,storage.find(a.core.record.id()).get().orElseThrow().experience());assertEquals(BigInteger.valueOf(5),storage.find(c.core.record.id()).get().orElseThrow().experience());assertEquals(BigInteger.valueOf(5),storage.find(b.core.record.id()).get().orElseThrow().experience());
}}
@Test void unusedProtectionCannotManufactureSupportShare() 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);withHex(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()));
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
doReturn(new Location(a.world,0,64,0)).when(a.player).getLocation();when(enemy.getLocation()).thenReturn(new Location(a.world,8,64,0));doReturn(true).when(a.player).hasLineOfSight(enemy);doReturn(enemy).when(a.player).getTargetEntity(64);use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
var allySource=DamageSource.builder(DamageType.PLAYER_ATTACK).withCausingEntity(b.player).withDirectEntity(b.player).build();
f.dispatcher.accept(new EntityDamageByEntityEvent(b.player,enemy,EntityDamageEvent.DamageCause.ENTITY_ATTACK,allySource,3));
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(enemy,allySource,new java.util.ArrayList<>(),0));for(int i=0;i<4;i++)PartyBookNativeTest.drain(f);
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(BigInteger.ZERO,storage.find(a.core.record.id()).get().orElseThrow().experience());
assertEquals(BigInteger.TEN,storage.find(b.core.record.id()).get().orElseThrow().experience());
}
}
@Test void failedCooldownPersistenceAppliesNoHex() {
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 mitigation=new games.dmg.fantasystick.ability.OutgoingDamageMitigation(sessions,parties,ignored->{});var hex=new games.dmg.fantasystick.ability.Hex(games.dmg.fantasystick.ability.Hex.Settings.defaults(),mitigation);var registry=new games.dmg.fantasystick.ability.AbilityRegistry();registry.register(hex.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.CHR,BigInteger.valueOf(14));stats.put(Stat.WIS,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("hex"),List.of("hex","","","","","",""),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 enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(enemy.getLocation()).thenReturn(new Location(world,8,64,0));doReturn(true).when(player).hasLineOfSight(enemy);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"));when(storage.commitAbilityCooldown(eq(record.id()),eq(record.revision()),eq("hex"),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,"hex",games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,games.dmg.fantasystick.session.ControlInput.Gesture.USE,enemy,0,0,()->true));
verify(storage).commitAbilityCooldown(eq(record.id()),eq(record.revision()),eq("hex"),any());var hit=new EntityDamageByEntityEvent(enemy,mock(org.bukkit.entity.Zombie.class),EntityDamageEvent.DamageCause.ENTITY_ATTACK,mock(DamageSource.class),10);mitigation.reduce(hit);assertEquals(10,hit.getDamage(),1.0e-9);engine.close();hex.close();mitigation.close();parties.close();
}
@Test void cancelledDamageCreatesNoReductionOrSupportButRetainsAcknowledgedCooldown() 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);withHex(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()));
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);doReturn(new Location(a.world,0,64,0)).when(a.player).getLocation();when(enemy.getLocation()).thenReturn(new Location(a.world,8,64,0));doReturn(true).when(a.player).hasLineOfSight(enemy);doReturn(enemy).when(a.player).getTargetEntity(64);use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
class Veto implements org.bukkit.event.Listener{@org.bukkit.event.EventHandler(priority=org.bukkit.event.EventPriority.LOWEST)public void damage(EntityDamageByEntityEvent event){if(event.getDamager()==enemy)event.setCancelled(true);}}
f.dispatcher.register(new Veto(),f.plugin);var source=DamageSource.builder(DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();var hit=new EntityDamageByEntityEvent(enemy,b.player,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);f.dispatcher.accept(hit);assertTrue(hit.isCancelled());assertEquals(10,hit.getDamage(),1.0e-9);
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("hex"));
}
}
@Test void unlearnRelearnAndTransferRetainCooldownAndClearOwnedPenalty() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),"hex-cooldown-seconds: 60\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withHex(data,a,true);f.players.put(a.core.id,a.player);f.activate(a);
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);doReturn(new Location(a.world,0,64,0)).when(a.player).getLocation();when(enemy.getLocation()).thenReturn(new Location(a.world,8,64,0));doReturn(true).when(a.player).hasLineOfSight(enemy);doReturn(enemy).when(a.player).getTargetEntity(64);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("hex");
a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);var unlearn=abilityButton(a,"[Unlearn]","hex");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("hex"));
var hit=new EntityDamageByEntityEvent(enemy,mock(org.bukkit.entity.Zombie.class),EntityDamageEvent.DamageCause.ENTITY_ATTACK,mock(DamageSource.class),10);f.dispatcher.accept(hit);assertEquals(10,hit.getDamage(),1.0e-9);
learnAndAssign(f,a);use(f,a);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);doReturn(new Location(a.world,0,64,0)).when(b.player).getLocation();doReturn(true).when(b.player).hasLineOfSight(enemy);doReturn(enemy).when(b.player).getTargetEntity(64);use(f,b);assertTrue(b.messages.stream().anyMatch(message->message.contains("ready in")));assertEquals(deadline,storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("hex"));
}}
@Test void configuredDefinitionAdminRoutesRestartAndStartupValidationCoverEverySetting() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),"hex-cooldown-seconds: 2\nhex-range-blocks: 16\nhex-duration-seconds: 6\nhex-damage-reduction-percent: 30\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 d=registry.find("hex").orElseThrow();
assertEquals(java.time.Duration.ofSeconds(2),d.cooldown());assertEquals(0,new java.math.BigDecimal("16").compareTo(d.parameters().get("range")));assertEquals(0,new java.math.BigDecimal("6").compareTo(d.parameters().get("duration-seconds")));assertEquals(0,new java.math.BigDecimal("30").compareTo(d.parameters().get("damage-reduction-percent")));
var sender=new EquipmentAdminCommandNativeTest.Sender();sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","hex-"}).isEmpty());
f.command.execute(sender.port,"fantasystick",new String[]{"config","hex-range-blocks","18"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;
assertEquals(List.of("hex-cooldown-seconds","hex-damage-reduction-percent","hex-duration-seconds","hex-range-blocks"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","hex-"}));
assertEquals(List.of("12","16"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","hex-range-blocks",""}));
f.command.execute(sender.port,"fantasystick",new String[]{"config","hex-range-blocks"});assertTrue(sender.messages.getLast().contains("=16"));
f.command.execute(sender.port,"fantasystick",new String[]{"config","hex-range-blocks","18"});var response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();
assertEquals(0,new java.math.BigDecimal("18").compareTo(registry.find("hex").orElseThrow().parameters().get("range")));
var a=new FantasyStickLifecycleNativeTest.Actor(data);withHex(data,a,true);f.players.put(a.core.id,a.player);f.activate(a);var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);doReturn(new Location(a.world,0,64,0)).when(a.player).getLocation();when(enemy.getLocation()).thenReturn(new Location(a.world,17,64,0));doReturn(true).when(a.player).hasLineOfSight(enemy);doReturn(enemy).when(a.player).getTargetEntity(64);use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);var configured=new EntityDamageByEntityEvent(enemy,mock(org.bukkit.entity.Zombie.class),EntityDamageEvent.DamageCause.ENTITY_ATTACK,mock(DamageSource.class),10);f.dispatcher.accept(configured);assertEquals(7,configured.getDamage(),1.0e-9);
f.command.execute(sender.port,"fantasystick",new String[]{"config","hex-range-blocks","0"});assertTrue(sender.messages.getLast().contains("invalid"));
f.command.execute(sender.port,"fantasystick",new String[]{"config","hex-range-blocks","18","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("18").compareTo(reloaded.find("hex").orElseThrow().parameters().get("range")));
}
for(String invalid:List.of("hex-cooldown-seconds: -1\n","hex-range-blocks: 0\n","hex-duration-seconds: 0\n","hex-damage-reduction-percent: 101\n")){var dir=data.resolve(Integer.toHexString(invalid.hashCode()));java.nio.file.Files.createDirectories(dir);java.nio.file.Files.writeString(dir.resolve("config.yml"),invalid);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){assertThrows(IllegalArgumentException.class,()->f.plugin.setEnabled(true));assertTrue(f.order.isEmpty());}}
}
@Test void partyAndMixedModeAimedPlayersRefuseWithoutCooldown() throws Exception {
for(int route=0;route<2;route++){var dir=data.resolve("player-refused-"+route);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(dir);withHex(dir,a,true);f.players.put(a.core.id,a.player);f.activate(a);org.bukkit.entity.Player target;
if(route==0){var b=new FantasyStickLifecycleNativeTest.Actor(dir);target=b.player;f.players.put(b.core.id,b.player);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()));}else{target=mock(org.bukkit.entity.Player.class);when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());}
doReturn(new Location(a.world,0,64,0)).when(a.player).getLocation();doReturn(new Location(a.world,8,64,0)).when(target).getLocation();doReturn(true).when(a.player).hasLineOfSight(target);doReturn(target).when(a.player).getTargetEntity(64);use(f,a);PartyBookNativeTest.drain(f);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("hex"));}}
}
@Test void firstAimedOutOfRangeOrObstructedTargetRefusesWithoutFallbackOrCooldown() throws Exception {
for(int route=0;route<2;route++){var dir=data.resolve("refused-"+route);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(dir);withHex(dir,a,true);f.players.put(a.core.id,a.player);f.activate(a);
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);doReturn(new Location(a.world,0,64,0)).when(a.player).getLocation();when(enemy.getLocation()).thenReturn(new Location(a.world,route==0?13:8,64,0));doReturn(route==0).when(a.player).hasLineOfSight(enemy);doReturn(enemy).when(a.player).getTargetEntity(64);use(f,a);PartyBookNativeTest.drain(f);
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("hex"));}}
}
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a)throws Exception{a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);PartyBookNativeTest.click(f,a,abilityButton(a,"[Learn]","hex"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);PartyBookNativeTest.click(f,a,abilityButton(a,"[3]","hex"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);}
private static void use(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a){a.core.inventory.getInventory().setSelectedSlot(2);
f.dispatcher.accept(new PlayerInteractEvent(a.player,org.bukkit.event.block.Action.RIGHT_CLICK_AIR,a.core.inventory.getItem(2),null,org.bukkit.block.BlockFace.SELF,EquipmentSlot.HAND));}
private static ClickEvent.Custom 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 action");}
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 withHex(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.CHR,BigInteger.valueOf(14));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),old.health(),learned?java.util.Set.of("hex"):old.learned(),
learned?List.of("hex","","","","","",""):old.loadout(),old.cooldowns(),old.restrictedKills(),false,null,null),old.revision());}
}
@@ -0,0 +1,27 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;import static org.mockito.Mockito.*;
import games.dmg.fantasystick.persistence.*;import games.dmg.fantasystick.progression.Stat;import java.math.BigInteger;import java.nio.file.Path;import java.util.*;import net.minecraft.network.chat.ClickEvent;import org.bukkit.event.player.PlayerInteractEvent;import org.bukkit.inventory.EquipmentSlot;import org.junit.jupiter.api.*;import org.junit.jupiter.api.io.TempDir;
class InspireNativeTest{@TempDir Path data;@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void bookAcquisitionSneakUseAddsOwnedTemporaryHealthAndExpiryPreservesUnrelatedAbsorption()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,false);f.players.put(a.core.id,a.player);f.activate(a);a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);String book=PartyBookNativeTest.text(a).replaceAll("\\s+","");assertTrue(book.contains("Inspire"));assertTrue(book.contains("CHR12"));assertTrue(book.contains("WIS10"));assertTrue(book.contains("Cost:1"));assertTrue(book.contains("12"));assertTrue(book.contains("6s"));PartyBookNativeTest.click(f,a,button(a,"[Learn]","inspire"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);PartyBookNativeTest.click(f,a,button(a,"[3]","inspire"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
double maximum=a.maximumHealth.getBaseValue();var absorption=new java.util.concurrent.atomic.AtomicReference<>(2.0);doAnswer(c->absorption.get()).when(a.player).getAbsorptionAmount();doAnswer(c->{absorption.set(c.getArgument(0));return null;}).when(a.player).setAbsorptionAmount(anyDouble());doReturn(true).when(a.player).isSneaking();a.core.inventory.getInventory().setSelectedSlot(2);f.dispatcher.accept(new PlayerInteractEvent(a.player,org.bukkit.event.block.Action.RIGHT_CLICK_AIR,a.core.inventory.getItem(2),null,org.bukkit.block.BlockFace.SELF,EquipmentSlot.HAND));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(7,absorption.get(),1e-9);assertEquals(maximum,a.maximumHealth.getBaseValue(),1e-9);assertEquals(120L,f.delayedTicks.getLast());f.delayed.getLast().run();assertEquals(2,absorption.get(),1e-9);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("inspire"));}}
@Test void aimedPartyAllyActualAbsorptionCreatesPersistedSupportShare()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);var b=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);f.players.put(a.core.id,a.player);f.players.put(b.core.id,b.player);f.activate(a);f.activate(b);var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(a.core.id).orElseThrow();assertTrue(parties.accept(b.core.id,parties.invite(a.core.id,party.id(),b.core.id).orElseThrow().id()));
doReturn(false).when(a.player).isSneaking();doReturn(new org.bukkit.Location(a.world,0,64,0)).when(a.player).getLocation();doReturn(new org.bukkit.Location(a.world,8,64,0)).when(b.player).getLocation();doReturn(true).when(a.player).hasLineOfSight(b.player);doReturn(b.player).when(a.player).getTargetEntity(64);var absorption=new java.util.concurrent.atomic.AtomicReference<>(0.0);doAnswer(c->absorption.get()).when(b.player).getAbsorptionAmount();doAnswer(c->{absorption.set(c.getArgument(0));return null;}).when(b.player).setAbsorptionAmount(anyDouble());use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(5,absorption.get(),1e-9);
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());when(enemy.getLocation()).thenReturn(new org.bukkit.Location(a.world,8,64,0));var enemySource=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,b.player,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,enemySource,3));absorption.set(2.0);PartyBookNativeTest.drain(f);var sourceSheet=a.core.inventory.getItemInOffHand().clone();a.core.inventory.setItemInOffHand(null);f.changed(a);PartyBookNativeTest.drain(f);f.awaitCharacterAndReceipt(a);var holder=new FantasyStickLifecycleNativeTest.Actor(data);holder.core.inventory.setItemInOffHand(sourceSheet);f.players.put(holder.core.id,holder.player);f.activate(holder);
var allySource=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK).withCausingEntity(b.player).withDirectEntity(b.player).build();f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(b.player,enemy,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,allySource,3));f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(enemy,allySource,new java.util.ArrayList<>(),0));for(int i=0;i<4;i++)PartyBookNativeTest.drain(f);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(BigInteger.valueOf(5),storage.find(a.core.record.id()).get().orElseThrow().experience());assertEquals(BigInteger.valueOf(5),storage.find(b.core.record.id()).get().orElseThrow().experience());}}
@Test void normalSelfOrdinaryOutOfRangeAndObstructedTargetsRefuseBeforeCooldown()throws Exception{for(int route=0;route<4;route++){var dir=data.resolve("refuse-"+route);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(dir);withInspire(dir,a,true);f.players.put(a.core.id,a.player);f.activate(a);org.bukkit.entity.Player target;if(route==0)target=a.player;else if(route==1){target=mock(org.bukkit.entity.Player.class);when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());}else{var b=new FantasyStickLifecycleNativeTest.Actor(dir);target=b.player;f.players.put(b.core.id,b.player);f.activate(b);}doReturn(false).when(a.player).isSneaking();doReturn(new org.bukkit.Location(a.world,0,64,0)).when(a.player).getLocation();doReturn(new org.bukkit.Location(a.world,route==2?13:8,64,0)).when(target).getLocation();doReturn(route!=3).when(a.player).hasLineOfSight(target);doReturn(target).when(a.player).getTargetEntity(64);use(f,a);PartyBookNativeTest.drain(f);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("inspire"));}}}
@Test void nonPartyTargetIsAllowedAndStrongestRemainingCopiesPreserveUnrelatedAbsorption()throws Exception{java.nio.file.Files.writeString(data.resolve("config.yml"),"inspire-cooldown-seconds: 0\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);var c=new FantasyStickLifecycleNativeTest.Actor(data);var b=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);withInspire(data,c,true);f.players.put(a.core.id,a.player);f.players.put(c.core.id,c.player);f.players.put(b.core.id,b.player);f.activate(a);f.activate(c);f.activate(b);var absorption=new java.util.concurrent.atomic.AtomicReference<>(2.0);doAnswer(x->absorption.get()).when(b.player).getAbsorptionAmount();doAnswer(x->{absorption.set(x.getArgument(0));return null;}).when(b.player).setAbsorptionAmount(anyDouble());for(var caster:List.of(a,c)){doReturn(false).when(caster.player).isSneaking();doReturn(new org.bukkit.Location(a.world,0,64,0)).when(caster.player).getLocation();doReturn(true).when(caster.player).hasLineOfSight(b.player);doReturn(b.player).when(caster.player).getTargetEntity(64);}doReturn(new org.bukkit.Location(a.world,8,64,0)).when(b.player).getLocation();use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(7,absorption.get(),1e-9);var sender=new EquipmentAdminCommandNativeTest.Sender();f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health","6"});var response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();use(f,c);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(9,absorption.get(),1e-9);
var enemy=mock(org.bukkit.entity.Zombie.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());var source=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,b.player,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3));absorption.set(6.0);PartyBookNativeTest.drain(f);f.delayed.getLast().run();assertEquals(4,absorption.get(),1e-9);f.delayed.getFirst().run();assertEquals(2,absorption.get(),1e-9);}}
@Test void cancelledOrSelfOnlyProtectionCannotManufactureSupportShare()throws Exception{for(int route=0;route<2;route++){var dir=data.resolve("no-support-"+route);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(dir);var b=new FantasyStickLifecycleNativeTest.Actor(dir);withInspire(dir,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()));var target=route==0?b.player:a.player;var absorption=new java.util.concurrent.atomic.AtomicReference<>(0.0);doAnswer(x->absorption.get()).when(target).getAbsorptionAmount();doAnswer(x->{absorption.set(x.getArgument(0));return null;}).when(target).setAbsorptionAmount(anyDouble());doReturn(route==1).when(a.player).isSneaking();doReturn(new org.bukkit.Location(a.world,0,64,0)).when(a.player).getLocation();doReturn(new org.bukkit.Location(a.world,8,64,0)).when(b.player).getLocation();doReturn(true).when(a.player).hasLineOfSight(b.player);doReturn(b.player).when(a.player).getTargetEntity(64);use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(enemy.getLocation()).thenReturn(new org.bukkit.Location(a.world,8,64,0));when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());var enemySource=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,target,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,enemySource,3);if(route==0)hit.setCancelled(true);f.dispatcher.accept(hit);if(route==1){absorption.set(2.0);PartyBookNativeTest.drain(f);}var allySource=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK).withCausingEntity(b.player).withDirectEntity(b.player).build();f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(b.player,enemy,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,allySource,3));f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(enemy,allySource,new java.util.ArrayList<>(),0));for(int i=0;i<4;i++)PartyBookNativeTest.drain(f);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(BigInteger.ZERO,storage.find(a.core.record.id()).get().orElseThrow().experience());assertEquals(BigInteger.TEN,storage.find(b.core.record.id()).get().orElseThrow().experience());}}}
@Test void exactFormulaSupportsFiniteUncappedCharisma(){assertEquals(BigInteger.valueOf(5),games.dmg.fantasystick.ability.Inspire.amount(BigInteger.valueOf(12)));assertEquals(BigInteger.valueOf(3),games.dmg.fantasystick.ability.Inspire.amount(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.Inspire.amount(huge));}
@Test void failedCooldownPersistencePublishesNoTemporaryHealth(){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 temporary=new games.dmg.fantasystick.ability.TemporaryHealth(sessions,parties,Runnable::run,ignored->{});var inspire=new games.dmg.fantasystick.ability.Inspire(games.dmg.fantasystick.ability.Inspire.Settings.defaults(),temporary);var registry=new games.dmg.fantasystick.ability.AbilityRegistry();registry.register(inspire.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.CHR,BigInteger.valueOf(12));stats.put(Stat.WIS,BigInteger.TEN);var record=new CharacterRecord(base.id(),base.name(),base.revision(),stats,base.level(),base.experience(),base.unspentPoints(),base.health(),Set.of("inspire"),List.of("inspire","","","","","",""),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(player.isSneaking()).thenReturn(true);when(sessions.active(active.player())).thenReturn(java.util.Optional.of(active));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"));when(storage.commitAbilityCooldown(eq(record.id()),eq(record.revision()),eq("inspire"),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,"inspire",games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,games.dmg.fantasystick.session.ControlInput.Gesture.USE,player,0,0,()->true));verify(storage).commitAbilityCooldown(eq(record.id()),eq(record.revision()),eq("inspire"),any());verify(player,never()).setAbsorptionAmount(anyDouble());engine.close();inspire.close();temporary.close();parties.close();}
@Test void unlearnRelearnAndTransferRetainCooldownAndClearOnlyOwnedTemporaryHealth()throws Exception{java.nio.file.Files.writeString(data.resolve("config.yml"),"inspire-cooldown-seconds: 60\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);f.players.put(a.core.id,a.player);f.activate(a);var absorption=new java.util.concurrent.atomic.AtomicReference<>(2.0);doAnswer(x->absorption.get()).when(a.player).getAbsorptionAmount();doAnswer(x->{absorption.set(x.getArgument(0));return null;}).when(a.player).setAbsorptionAmount(anyDouble());doReturn(true).when(a.player).isSneaking();use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(7,absorption.get(),1e-9);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var deadline=storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("inspire");a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);var unlearn=button(a,"[Unlearn]","inspire");PartyBookNativeTest.click(f,a,unlearn);PartyBookNativeTest.click(f,a,unlearn);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(2,absorption.get(),1e-9);var refunded=storage.find(a.core.record.id()).get().orElseThrow();assertEquals(BigInteger.valueOf(40),refunded.unspentPoints());assertEquals(deadline,refunded.cooldowns().get("inspire"));learnAndAssign(f,a);use(f,a);assertTrue(a.messages.stream().anyMatch(m->m.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);doReturn(true).when(b.player).isSneaking();use(f,b);assertTrue(b.messages.stream().anyMatch(m->m.contains("ready in")));assertEquals(deadline,storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("inspire"));}}
@Test void configuredDefinitionAdminRestartFutureCastAndStartupValidationCoverEverySetting()throws Exception{java.nio.file.Files.writeString(data.resolve("config.yml"),"inspire-base-temporary-health: 8\ninspire-cooldown-seconds: 0\ninspire-range-blocks: 16\ninspire-duration-seconds: 8\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 d=registry.find("inspire").orElseThrow();assertEquals(java.time.Duration.ZERO,d.cooldown());assertEquals(new java.math.BigDecimal("8"),d.parameters().get("base-temporary-health"));assertEquals(0,new java.math.BigDecimal("16").compareTo(d.parameters().get("range")));assertEquals(0,new java.math.BigDecimal("8").compareTo(d.parameters().get("duration-seconds")));
var sender=new EquipmentAdminCommandNativeTest.Sender();sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","inspire-"}).isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health","9"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;assertEquals(List.of("inspire-base-temporary-health","inspire-cooldown-seconds","inspire-duration-seconds","inspire-range-blocks"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","inspire-"}));assertEquals(List.of("4","8"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health",""}));f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health"});assertTrue(sender.messages.getLast().contains("=8"));f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health","9"});var response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertEquals(new java.math.BigDecimal("9"),registry.find("inspire").orElseThrow().parameters().get("base-temporary-health"));
var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);f.players.put(a.core.id,a.player);f.activate(a);var absorption=new java.util.concurrent.atomic.AtomicReference<>(0.0);doAnswer(c->absorption.get()).when(a.player).getAbsorptionAmount();doAnswer(c->{absorption.set(c.getArgument(0));return null;}).when(a.player).setAbsorptionAmount(anyDouble());doReturn(true).when(a.player).isSneaking();use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(10,absorption.get(),1e-9);assertEquals(160L,f.delayedTicks.getLast());
f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health","-1"});assertTrue(sender.messages.getLast().contains("invalid"));f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health","9","extra"});assertTrue(sender.messages.getLast().contains("Usage"));f.plugin.setEnabled(false);f.plugin.setEnabled(true);var reload=(games.dmg.fantasystick.ability.AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");assertEquals(new java.math.BigDecimal("9"),reload.find("inspire").orElseThrow().parameters().get("base-temporary-health"));}
for(String invalid:List.of("inspire-base-temporary-health: -1\n","inspire-cooldown-seconds: -1\n","inspire-range-blocks: 0\n","inspire-duration-seconds: 0\n")){var dir=data.resolve(Integer.toHexString(invalid.hashCode()));java.nio.file.Files.createDirectories(dir);java.nio.file.Files.writeString(dir.resolve("config.yml"),invalid);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){assertThrows(IllegalArgumentException.class,()->f.plugin.setEnabled(true));assertTrue(f.order.isEmpty());}}}
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a)throws Exception{a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);PartyBookNativeTest.click(f,a,button(a,"[Learn]","inspire"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);PartyBookNativeTest.click(f,a,button(a,"[3]","inspire"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);}
private static void use(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a){a.core.inventory.getInventory().setSelectedSlot(2);f.dispatcher.accept(new PlayerInteractEvent(a.player,org.bukkit.event.block.Action.RIGHT_CLICK_AIR,a.core.inventory.getItem(2),null,org.bukkit.block.BlockFace.SELF,EquipmentSlot.HAND));}
private static ClickEvent.Custom button(FantasyStickLifecycleNativeTest.Actor a,String label,String ability){var b=org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(a.books.getLast()).get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);assertNotNull(b);for(var p:b.pages()){var x=button(p.raw(),label,ability);if(x!=null)return x;}throw new AssertionError("missing action");}
private static ClickEvent.Custom button(net.minecraft.network.chat.Component c,String label,String ability){if(c.getString().equals(label)&&c.getStyle().getClickEvent() instanceof ClickEvent.Custom x&&x.payload().orElse(null) instanceof net.minecraft.nbt.StringTag p&&p.value().contains(":"+ability))return x;for(var k:c.getSiblings()){var x=button(k,label,ability);if(x!=null)return x;}return null;}
private static void withInspire(Path d,FantasyStickLifecycleNativeTest.Actor a,boolean learned)throws Exception{java.nio.file.Files.createDirectories(d);var r=new YamlCharacterRepository(d);var o=r.load(a.core.record.id()).orElseThrow();var s=new EnumMap<>(o.stats());s.put(Stat.CHR,BigInteger.valueOf(12));s.put(Stat.WIS,BigInteger.TEN);r.replace(new CharacterRecord(o.id(),o.name(),o.revision().add(BigInteger.ONE),s,o.level(),o.experience(),BigInteger.valueOf(learned?39:40),o.health(),learned?Set.of("inspire"):o.learned(),learned?List.of("inspire","","","","","",""):o.loadout(),o.cooldowns(),o.restrictedKills(),false,null,null),o.revision());}}
@@ -348,7 +348,7 @@ class LongbowNativeTest {
assertThrows(AssertionError.class, () -> abilityButton(unavailable, "[Learn]", "longbow"));
var sender = new EquipmentAdminCommandNativeTest.Sender();
assertEquals(java.util.List.of("config", "inspect", "recover"),
assertEquals(java.util.List.of("config", "give", "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());
@@ -202,7 +202,7 @@ class QuickstepNativeTest {
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"),
assertEquals(List.of("config", "give", "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());
@@ -36,9 +36,9 @@ class ReviveNativeTest {
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.click(fixture, actor, abilityButton(actor, "[Learn]", "revive"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
PartyBookNativeTest.click(fixture, actor, PartyBookNativeTest.button(actor, "[3]"));
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "revive"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
@@ -372,6 +372,29 @@ class ReviveNativeTest {
}
}
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 + " 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 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();
@@ -0,0 +1,11 @@
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 RoleplayAvailabilityNativeTest{@TempDir Path data;@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void registeredToggleExitsEveryActiveSessionBlocksEntryAndAllowsRecoveryThenLaterEntry()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var sender=new EquipmentAdminCommandNativeTest.Sender();assertEquals(java.util.List.of("roleplay-disabled"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","rolep"}));assertEquals(java.util.List.of("false","true"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","roleplay-disabled",""}));f.command.execute(sender.port,"fantasystick",new String[]{"config","roleplay-disabled"});assertTrue(sender.messages.getLast().contains("=false"));for(String bad:java.util.List.of("TRUE","1","yes","")){f.command.execute(sender.port,"fantasystick",new String[]{"config","roleplay-disabled",bad});assertTrue(sender.messages.getLast().contains("true|false"));}sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","rolep"}).isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config","roleplay-disabled","true"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;f.command.execute(sender.port,"fantasystick",new String[]{"config","roleplay-disabled","true","extra"});assertTrue(sender.messages.getLast().contains("Usage"));assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","roleplay-disabled","true",""}).isEmpty());var a=new FantasyStickLifecycleNativeTest.Actor(data);var b=new FantasyStickLifecycleNativeTest.Actor(data);var ordinary=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(a.core.id,a.player);f.players.put(b.core.id,b.player);f.players.put(ordinary.core.id,ordinary.player);var aBefore=a.core.snapshot();var bBefore=b.core.snapshot();var ordinaryBefore=ordinary.core.snapshot();assertDoesNotThrow(()->f.activate(a),"initial A activation");assertDoesNotThrow(()->f.activate(b),"initial B activation");a.core.health=4;save(f,sender,"true");f.awaitCharacterAndReceipt(a);f.awaitCharacterAndReceipt(b);var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");assertTrue(sessions.active(a.core.id).isEmpty());assertTrue(sessions.active(b.core.id).isEmpty());assertArrayEquals(aBefore,a.core.snapshot());assertArrayEquals(bBefore,b.core.snapshot());assertArrayEquals(ordinaryBefore,ordinary.core.snapshot());assertTrue(a.core.online&&b.core.online);var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(0,new java.math.BigDecimal("4").compareTo(storage.find(a.core.record.id()).toCompletableFuture().get().orElseThrow().health()));var c=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(c.core.id,c.player);var cBefore=c.core.snapshot();f.changed(c);PartyBookNativeTest.drain(f);assertTrue(sessions.active(c.core.id).isEmpty());assertArrayEquals(cBefore,c.core.snapshot());assertTrue(c.messages.stream().anyMatch(message->message.toLowerCase().contains("disabled")));
var recovery=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(recovery.core.id,recovery.player);var recoveryBefore=recovery.core.snapshot();var session=java.util.UUID.randomUUID();f.receipts().prepare(recovery.core.id,session,recovery.core.record.id()).toCompletableFuture().get();assertEquals(games.dmg.fantasystick.items.EquipmentStorage.PackResult.PACKED,new games.dmg.fantasystick.items.EquipmentStorage().pack(recovery.core.inventory,recovery.core.id,session));recovery.core.marker(session,"PACKED");f.command.execute(sender.port,"fantasystick",new String[]{"recover",recovery.core.id.toString()});f.tick();assertArrayEquals(recoveryBefore,recovery.core.snapshot());save(f,sender,"false");var policy=(games.dmg.fantasystick.persistence.RoleplaySettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"roleplaySettings");assertFalse(policy.disabled());f.reenter(c);assertEquals(c.core.record.id(),sessions.active(c.core.id).orElseThrow().character());f.plugin.setEnabled(false);f.plugin.setEnabled(true);f.command.execute(sender.port,"fantasystick",new String[]{"config","roleplay-disabled"});assertTrue(sender.messages.getLast().contains("=false"));}}
@Test void reenableWaitsForAcceptedRoleplayWriteAndOldLifetimeCannotReplayExit()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var old=(games.dmg.fantasystick.persistence.RoleplaySettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"roleplaySettings");var worker=(java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(old,"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 e){Thread.currentThread().interrupt();}});assertTrue(entered.await(5,java.util.concurrent.TimeUnit.SECONDS));var sender=new EquipmentAdminCommandNativeTest.Sender();f.command.execute(sender.port,"fantasystick",new String[]{"config","roleplay-disabled","true"});f.plugin.setEnabled(false);f.plugin.setEnabled(true);assertNull(FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions"));release.countDown();f.tick();assertNotNull(FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions"));assertTrue(((games.dmg.fantasystick.persistence.RoleplaySettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"roleplaySettings")).disabled());var a=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(a.core.id,a.player);var before=a.core.snapshot();f.changed(a);PartyBookNativeTest.drain(f);assertArrayEquals(before,a.core.snapshot());}}
@Test void startupLoadsDisabledPolicyAndInvalidTypedValuesFailBeforeRegistration()throws Exception{var enabled=data.resolve("enabled");java.nio.file.Files.createDirectories(enabled);java.nio.file.Files.writeString(enabled.resolve("config.yml"),"roleplay-disabled: true\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(enabled)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(enabled);f.players.put(a.core.id,a.player);var before=a.core.snapshot();f.changed(a);PartyBookNativeTest.drain(f);var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");assertTrue(sessions.active(a.core.id).isEmpty());assertArrayEquals(before,a.core.snapshot());var sender=new EquipmentAdminCommandNativeTest.Sender();f.command.execute(sender.port,"fantasystick",new String[]{"config","roleplay-disabled"});assertTrue(sender.messages.getLast().contains("=true"));}var invalid=data.resolve("invalid");java.nio.file.Files.createDirectories(invalid);java.nio.file.Files.writeString(invalid.resolve("config.yml"),"roleplay-disabled: 'true'\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(invalid)){assertThrows(IllegalArgumentException.class,()->f.plugin.setEnabled(true));assertTrue(f.order.isEmpty());assertTrue(f.tasks.isEmpty());}}
@Test void failedRestorationRetainsEvidenceWhileDisabled()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var sender=new EquipmentAdminCommandNativeTest.Sender();var a=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(a.core.id,a.player);f.activate(a);int box=java.util.stream.IntStream.range(9,36).filter(slot->a.core.inventory.getItem(slot)!=null&&a.core.inventory.getItem(slot).getType()==org.bukkit.Material.SHULKER_BOX).findFirst().orElseThrow();a.core.inventory.setItem(box,null);var before=a.core.snapshot();save(f,sender,"true");var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");assertTrue(sessions.active(a.core.id).isEmpty());assertArrayEquals(before,a.core.snapshot());var state=f.receipts().find(a.core.id).toCompletableFuture().get().orElseThrow();assertNotNull(state.pending());assertNotNull(state.lastExit());assertTrue(((games.dmg.fantasystick.persistence.RoleplaySettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"roleplaySettings")).disabled());assertTrue(a.core.online);}}
@Test void acknowledgedDisableCancelsPendingEntryAndFailedSaveChangesNothing()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var sender=new EquipmentAdminCommandNativeTest.Sender();var a=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(a.core.id,a.player);var before=a.core.snapshot();var characters=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var worker=(java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.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 e){Thread.currentThread().interrupt();}});assertTrue(entered.await(5,java.util.concurrent.TimeUnit.SECONDS));f.changed(a);save(f,sender,"true");release.countDown();characters.find(java.util.UUID.randomUUID()).toCompletableFuture().get();PartyBookNativeTest.drain(f);var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");assertTrue(sessions.active(a.core.id).isEmpty());assertArrayEquals(before,a.core.snapshot());f.plugin.setEnabled(false);}var failed=data.resolve("failed");java.nio.file.Files.createDirectories(failed);try(var f=new FantasyStickLifecycleNativeTest.Fixture(failed)){f.plugin.setEnabled(true);var sender=new EquipmentAdminCommandNativeTest.Sender();var a=new FantasyStickLifecycleNativeTest.Actor(failed);f.players.put(a.core.id,a.player);f.activate(a);java.nio.file.Files.createDirectory(failed.resolve("config.yml"));f.command.execute(sender.port,"fantasystick",new String[]{"config","roleplay-disabled","true"});var callback=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(callback);callback.run();assertTrue(sender.messages.getLast().contains("save failed"));var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");assertTrue(sessions.active(a.core.id).isPresent());}}
static void save(FantasyStickLifecycleNativeTest.Fixture f,EquipmentAdminCommandNativeTest.Sender sender,String value)throws Exception{int before=sender.messages.size();f.command.execute(sender.port,"fantasystick",new String[]{"config","roleplay-disabled",value});assertEquals(before,sender.messages.size());while(sender.messages.size()==before){var task=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(task);task.run();}assertTrue(sender.messages.getLast().contains("="+value));}
}
@@ -130,7 +130,7 @@ class SecondWindNativeTest {
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"),
assertEquals(List.of("config", "give", "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());
@@ -0,0 +1,9 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;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 SwitchDelayAdminNativeTest{@TempDir Path data;@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void registeredCommandDurablyChangesActualExitAndEntryPolicyWithoutRewritingDeadlines()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var sender=new EquipmentAdminCommandNativeTest.Sender();assertEquals(java.util.List.of("character-swap-cooldown-seconds"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","character-s"}));assertEquals(java.util.List.of("900"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","character-swap-cooldown-seconds",""}));f.command.execute(sender.port,"fantasystick",new String[]{"config","character-swap-cooldown-seconds"});assertTrue(sender.messages.getLast().contains("=900"));sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","character"}).isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config","character-swap-cooldown-seconds","37"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;for(String bad:java.util.List.of("-1","1.5","bad","9223372036854775807")){f.command.execute(sender.port,"fantasystick",new String[]{"config","character-swap-cooldown-seconds",bad});assertTrue(sender.messages.getLast().contains("invalid"),bad+" -> "+sender.messages);}f.command.execute(sender.port,"fantasystick",new String[]{"config","character-swap-cooldown-seconds","37","extra"});assertTrue(sender.messages.getLast().contains("Usage"));save(f,sender,"37");
var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");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);sessions.exit(a.player);sessions.exit(b.player);f.awaitCharacterAndReceipt(a);f.awaitCharacterAndReceipt(b);var aExit=f.receipts().find(a.core.id).toCompletableFuture().get().orElseThrow().lastExit();var bExit=f.receipts().find(b.core.id).toCompletableFuture().get().orElseThrow().lastExit();assertEquals(Duration.ofSeconds(37),Duration.between(aExit.exitedAt(),aExit.deadline()));assertEquals(Duration.ofSeconds(37),Duration.between(bExit.exitedAt(),bExit.deadline()));var otherA=new FantasyStickLifecycleNativeTest.Actor(data);var otherB=new FantasyStickLifecycleNativeTest.Actor(data);a.core.inventory.setItemInOffHand(otherA.core.inventory.getItemInOffHand().clone());b.core.inventory.setItemInOffHand(otherB.core.inventory.getItemInOffHand().clone());save(f,sender,"0");f.activate(a);assertEquals(otherA.core.record.id(),sessions.active(a.core.id).orElseThrow().character());assertEquals(aExit,f.receipts().find(a.core.id).toCompletableFuture().get().orElseThrow().lastExit());save(f,sender,"900");var policy=(games.dmg.fantasystick.persistence.SwitchDelaySettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"switchDelaySettings");assertEquals(Duration.ofSeconds(900),policy.delay());assertFalse(f.receipts().find(b.core.id).toCompletableFuture().get().orElseThrow().canEnter(otherB.core.record.id(),policy.delay(),java.time.Instant.now()));assertTrue(sessions.active(b.core.id).isEmpty(),"B remained active after exit");f.changed(b);f.receipts().find(b.core.id).toCompletableFuture().get();PartyBookNativeTest.drain(f);assertTrue(sessions.active(b.core.id).isEmpty(),b.messages::toString);assertEquals(bExit,f.receipts().find(b.core.id).toCompletableFuture().get().orElseThrow().lastExit());sessions.exit(a.player);f.awaitCharacterAndReceipt(a);var newExit=f.receipts().find(a.core.id).toCompletableFuture().get().orElseThrow().lastExit();assertEquals(Duration.ofSeconds(900),Duration.between(newExit.exitedAt(),newExit.deadline()));f.plugin.setEnabled(false);f.plugin.setEnabled(true);f.command.execute(sender.port,"fantasystick",new String[]{"config","character-swap-cooldown-seconds"});assertTrue(sender.messages.getLast().contains("=900"));}}
@Test void exitRetainsPolicyCapturedWhenItsNativeTransitionStarts()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var sender=new EquipmentAdminCommandNativeTest.Sender();save(f,sender,"37");var a=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(a.core.id,a.player);f.activate(a);var characters=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var worker=(java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.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();}});assertTrue(entered.await(5,java.util.concurrent.TimeUnit.SECONDS));var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");sessions.exit(a.player);save(f,sender,"0");release.countDown();f.awaitCharacterAndReceipt(a);var exit=f.receipts().find(a.core.id).toCompletableFuture().get().orElseThrow().lastExit();assertEquals(Duration.ofSeconds(37),Duration.between(exit.exitedAt(),exit.deadline()));}}
@Test void failedRegisteredWriteLeavesEffectivePolicyAndUnrelatedStateUntouched()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);java.nio.file.Files.createDirectory(data.resolve("config.yml"));var sender=new EquipmentAdminCommandNativeTest.Sender();f.command.execute(sender.port,"fantasystick",new String[]{"config","character-swap-cooldown-seconds","0"});var task=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(task);task.run();assertTrue(sender.messages.getLast().contains("save failed"));f.command.execute(sender.port,"fantasystick",new String[]{"config","character-swap-cooldown-seconds"});assertTrue(sender.messages.getLast().contains("=900"));}}
static void save(FantasyStickLifecycleNativeTest.Fixture f,EquipmentAdminCommandNativeTest.Sender sender,String value)throws Exception{int before=sender.messages.size();f.command.execute(sender.port,"fantasystick",new String[]{"config","character-swap-cooldown-seconds",value});assertEquals(before,sender.messages.size());var task=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(task);task.run();assertTrue(sender.messages.getLast().contains("="+value));}
}
@@ -73,6 +73,15 @@ class DistributionTest {
"shield-bash-minimum-vertical-knockback: 0.35")) {
assertTrue(defaults.contains(setting), "Missing packaged Shield Bash default: " + setting);
}
for (String setting : java.util.List.of("hex-cooldown-seconds: 10", "hex-range-blocks: 12",
"hex-duration-seconds: 4", "hex-damage-reduction-percent: 20")) {
assertTrue(defaults.contains(setting), "Missing packaged Hex default: " + setting);
}
for (String setting : java.util.List.of("inspire-base-temporary-health: 4",
"inspire-cooldown-seconds: 15", "inspire-range-blocks: 12", "inspire-duration-seconds: 6")) {
assertTrue(defaults.contains(setting), "Missing packaged Inspire default: " + setting);
}
assertTrue(defaults.contains("admin-revive-selection-timeout-seconds: 60"));
assertTrue(JavaPlugin.class.isAssignableFrom(FantasyStickPlugin.class));
var entrypoint = jar.getJarEntry(metadata.getMain().replace('.', '/') + ".class");
assertNotNull(entrypoint);
@@ -0,0 +1,7 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.*;import java.time.Duration;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.io.TempDir;
class AdminReviveSettingsTest{@TempDir Path data;
@Test void validatesPersistsPreservesUnrelatedAndPublishesOnlyAcknowledgedUpdates()throws Exception{assertEquals(Duration.ofSeconds(60),AdminReviveSettings.parse(null));for(Object bad:new Object[]{0,-1,"1.5","no",31536001L})assertThrows(IllegalArgumentException.class,()->AdminReviveSettings.parse(bad));Files.writeString(data.resolve("config.yml"),"unrelated: keep\nadmin-revive-selection-timeout-seconds: 60\n");try(var settings=new AdminReviveSettings(data,Duration.ofSeconds(60))){var update=settings.set("90").toCompletableFuture().get();assertEquals("60",settings.value());assertTrue(Files.readString(data.resolve("config.yml")).contains("unrelated: keep"));settings.publish(update);assertEquals("90",settings.value());assertEquals(Duration.ofSeconds(90),settings.timeout());settings.publish(update);assertEquals("90",settings.value());}}
@Test void failedWriteRetainsEffectiveValue()throws Exception{try(var settings=new AdminReviveSettings(data,Duration.ofSeconds(60))){Files.createDirectory(data.resolve("config.yml"));assertThrows(Exception.class,()->settings.set("90").toCompletableFuture().get());assertEquals("60",settings.value());}}
}
@@ -0,0 +1,13 @@
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 HexSettingsTest{@TempDir Path data;
@Test void serializesPreservesAndPublishesAcknowledgedSnapshots()throws Exception{Files.writeString(data.resolve("config.yml"),"unrelated: retained\n");try(var s=new HexSettings(data,defaults())){
var r=s.set(HexSettings.RANGE,"16").toCompletableFuture();var p=s.set(HexSettings.REDUCTION,"30").toCompletableFuture();var rr=r.get(5,TimeUnit.SECONDS);var pp=p.get(5,TimeUnit.SECONDS);
assertEquals(12,s.settings().range(),0);s.publish(pp);s.publish(rr);assertEquals(16,s.settings().range(),0);assertEquals(0,new java.math.BigDecimal("30").compareTo(s.settings().reductionPercent()));
var y=(Map<?,?>)new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));assertEquals("retained",y.get("unrelated"));assertEquals("16",String.valueOf(y.get(HexSettings.RANGE)));}}
@Test void invalidAndFailedWritesRetainState()throws Exception{try(var s=new HexSettings(data,defaults())){for(var e:Map.of(HexSettings.COOLDOWN,"-1",HexSettings.RANGE,"0",HexSettings.DURATION,"0",HexSettings.REDUCTION,"101").entrySet())assertThrows(IllegalArgumentException.class,()->s.set(e.getKey(),e.getValue()));assertThrows(IllegalArgumentException.class,()->s.set(HexSettings.COOLDOWN,"0.0000000001"));}
var fail=new java.util.concurrent.atomic.AtomicBoolean(true);try(var s=new HexSettings(data,defaults(),(a,b)->{if(fail.getAndSet(false))throw new java.io.IOException("simulated");Files.move(a,b,StandardCopyOption.REPLACE_EXISTING);})){assertThrows(java.util.concurrent.ExecutionException.class,()->s.set(HexSettings.RANGE,"16").toCompletableFuture().get(5,TimeUnit.SECONDS));assertEquals("12",s.value(HexSettings.RANGE));}}
@Test void unsafeAndClosedStoresFailSafely()throws Exception{if(!System.getProperty("os.name","").toLowerCase().contains("win")){var out=data.resolveSibling(data.getFileName()+"-outside");Files.writeString(out,"safe\n");Files.createSymbolicLink(data.resolve("config.yml"),out);try(var s=new HexSettings(data,defaults())){assertThrows(java.util.concurrent.ExecutionException.class,()->s.set(HexSettings.RANGE,"16").toCompletableFuture().get(5,TimeUnit.SECONDS));assertEquals("safe\n",Files.readString(out));}}
var s=new HexSettings(data.resolve("closed"),defaults());s.close();assertThrows(java.util.concurrent.ExecutionException.class,()->s.set(HexSettings.RANGE,"16").toCompletableFuture().get(5,TimeUnit.SECONDS));s.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);}
private static Map<String,String>defaults(){var m=new LinkedHashMap<String,String>();for(String k:HexSettings.keys())m.put(k,HexSettings.defaultValue(k));return m;}}
@@ -0,0 +1,13 @@
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 InspireSettingsTest{@TempDir Path data;
@Test void serializesPreservesAndPublishesAcknowledgedSnapshots()throws Exception{Files.writeString(data.resolve("config.yml"),"unrelated: retained\n");try(var s=new InspireSettings(data,defaults())){
var r=s.set(InspireSettings.RANGE,"16").toCompletableFuture();var p=s.set(InspireSettings.BASE,"8").toCompletableFuture();var rr=r.get(5,TimeUnit.SECONDS);var pp=p.get(5,TimeUnit.SECONDS);
assertEquals(12,s.settings().range(),0);s.publish(pp);s.publish(rr);assertEquals(16,s.settings().range(),0);assertEquals(new java.math.BigInteger("8"),s.settings().baseTemporaryHealth());
var y=(Map<?,?>)new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));assertEquals("retained",y.get("unrelated"));assertEquals("16",String.valueOf(y.get(InspireSettings.RANGE)));}}
@Test void invalidAndFailedWritesRetainState()throws Exception{try(var s=new InspireSettings(data,defaults())){for(var e:Map.of(InspireSettings.COOLDOWN,"-1",InspireSettings.RANGE,"0",InspireSettings.DURATION,"0",InspireSettings.BASE,"-1").entrySet())assertThrows(IllegalArgumentException.class,()->s.set(e.getKey(),e.getValue()));assertThrows(IllegalArgumentException.class,()->s.set(InspireSettings.COOLDOWN,"0.0000000001"));assertThrows(IllegalArgumentException.class,()->s.set(InspireSettings.BASE,"1.5"));}
var fail=new java.util.concurrent.atomic.AtomicBoolean(true);try(var s=new InspireSettings(data,defaults(),(a,b)->{if(fail.getAndSet(false))throw new java.io.IOException("simulated");Files.move(a,b,StandardCopyOption.REPLACE_EXISTING);})){assertThrows(java.util.concurrent.ExecutionException.class,()->s.set(InspireSettings.RANGE,"16").toCompletableFuture().get(5,TimeUnit.SECONDS));assertEquals("12",s.value(InspireSettings.RANGE));}}
@Test void unsafeAndClosedStoresFailSafely()throws Exception{if(!System.getProperty("os.name","").toLowerCase().contains("win")){var out=data.resolveSibling(data.getFileName()+"-outside");Files.writeString(out,"safe\n");Files.createSymbolicLink(data.resolve("config.yml"),out);try(var s=new InspireSettings(data,defaults())){assertThrows(java.util.concurrent.ExecutionException.class,()->s.set(InspireSettings.RANGE,"16").toCompletableFuture().get(5,TimeUnit.SECONDS));assertEquals("safe\n",Files.readString(out));}}
var s=new InspireSettings(data.resolve("closed"),defaults());s.close();assertThrows(java.util.concurrent.ExecutionException.class,()->s.set(InspireSettings.RANGE,"16").toCompletableFuture().get(5,TimeUnit.SECONDS));s.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);}
private static Map<String,String>defaults(){var m=new LinkedHashMap<String,String>();for(String k:InspireSettings.keys())m.put(k,InspireSettings.defaultValue(k));return m;}}
@@ -0,0 +1,6 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;import java.nio.file.*;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.io.TempDir;
class RoleplaySettingsTest{@TempDir Path data;
@Test void validatesPersistsPreservesUnrelatedAndPublishesOnlyAcknowledgedSequence()throws Exception{assertFalse(RoleplaySettings.parse(null));assertFalse(RoleplaySettings.parse(false));assertTrue(RoleplaySettings.parse(true));for(Object bad:new Object[]{"true","false",1,"yes"})assertThrows(IllegalArgumentException.class,()->RoleplaySettings.parse(bad));Files.writeString(data.resolve("config.yml"),"unrelated: keep\nroleplay-disabled: false\n");try(var settings=new RoleplaySettings(data,false)){var first=settings.set("true").toCompletableFuture().get();var second=settings.set("false").toCompletableFuture().get();assertFalse(settings.disabled());settings.publish(second);settings.publish(first);assertFalse(settings.disabled());assertTrue(Files.readString(data.resolve("config.yml")).contains("unrelated: keep"));}}
@Test void failedWriteRetainsEffectiveValue()throws Exception{try(var settings=new RoleplaySettings(data,false)){Files.createDirectory(data.resolve("config.yml"));assertThrows(Exception.class,()->settings.set("true").toCompletableFuture().get());assertFalse(settings.disabled());}}
}
@@ -0,0 +1,7 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;import java.nio.file.*;import java.time.Duration;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.io.TempDir;
class SwitchDelaySettingsTest{@TempDir Path data;
@Test void validatesZeroPersistsPreservesUnrelatedAndPublishesAfterAcknowledgment()throws Exception{assertEquals(Duration.ofSeconds(900),SwitchDelaySettings.parse(null));assertEquals(Duration.ZERO,SwitchDelaySettings.parse("0"));for(Object bad:new Object[]{-1,"1.5","bad","9223372036854775807","253402300800"})assertThrows(IllegalArgumentException.class,()->SwitchDelaySettings.parse(bad));Files.writeString(data.resolve("config.yml"),"unrelated: keep\ncharacter-swap-cooldown-seconds: 900\n");try(var settings=new SwitchDelaySettings(data,Duration.ofSeconds(900))){var update=settings.set("37").toCompletableFuture().get();assertEquals("900",settings.value());assertTrue(Files.readString(data.resolve("config.yml")).contains("unrelated: keep"));settings.publish(update);assertEquals("37",settings.value());assertEquals(Duration.ofSeconds(37),settings.delay());}}
@Test void concurrentWritesSerializeAndStalePublicationCannotRollBackNewerPolicy()throws Exception{try(var settings=new SwitchDelaySettings(data,Duration.ofSeconds(900))){var one=settings.set("1").toCompletableFuture();var two=settings.set("2").toCompletableFuture();var first=one.get();var second=two.get();settings.publish(second);settings.publish(first);assertEquals("2",settings.value());assertTrue(Files.readString(data.resolve("config.yml")).contains("character-swap-cooldown-seconds: '2'"));}}
@Test void failedWriteRetainsEffectiveValue()throws Exception{try(var settings=new SwitchDelaySettings(data,Duration.ofSeconds(900))){Files.createDirectory(data.resolve("config.yml"));assertThrows(Exception.class,()->settings.set("0").toCompletableFuture().get());assertEquals("900",settings.value());}}
}
@@ -19,6 +19,23 @@ class CombatContributionsTest {
assertEquals(Set.of(), ledger.consume(target, start.plusSeconds(301)), "One target is awarded once");
}
@Test void targetSpecificSupportRequiresDirectAllyAndTenSecondWindowWithoutRecursion() {
var now = Instant.parse("2026-09-19T20:00:00Z");
var target = UUID.randomUUID(); var otherTarget = UUID.randomUUID();
var ally = UUID.randomUUID(); var supporter = UUID.randomUUID(); var supportOnly = UUID.randomUUID();
var ledger = new CombatContributions(CombatRewardRules.defaults());
ledger.accept(target, ally, now);
ledger.support(target, ally, supporter, now);
ledger.support(target, supporter, supportOnly, now);
ledger.support(otherTarget, ally, supportOnly, now);
assertEquals(Set.of(ally, supporter), ledger.consume(target, now.plusSeconds(10)));
ledger.accept(target, ally, now); ledger.accept(target, supporter, now);
ledger.support(target, ally, supporter, now);
assertEquals(Set.of(ally, supporter), ledger.consume(target, now.plusSeconds(10)), "Direct and support roles deduplicate");
ledger.accept(target, ally, now); ledger.support(target, ally, supporter, now);
assertEquals(Set.of(ally), ledger.consume(target, now.plusSeconds(10).plusNanos(1)));
}
@Test void invalidationAndCloseBoundMemoryAndRefuseLaterDamage() {
var ledger = new CombatContributions(CombatRewardRules.defaults());
var target = UUID.randomUUID(); var character = UUID.randomUUID();