Compare commits

...
3 Commits
Author SHA1 Message Date
dmg e054a78762 feat(ability): add Shield Bash
Release / release (push) Successful in 7m4s
2026-09-20 12:19:19 -04:00
dmg 63da22e37d feat(ability): add Stone Skin
Release / release (push) Successful in 7m23s
2026-09-20 12:01:16 -04:00
dmg b2e8da536a feat(ability): add Second Wind
Release / release (push) Successful in 6m54s
2026-09-20 11:41:23 -04:00
19 changed files with 2319 additions and 3 deletions

No files matched your search

@@ -55,6 +55,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.persistence.LongbowSettings longbowSettings;
private games.dmg.fantasystick.ability.Quickstep quickstep;
private games.dmg.fantasystick.persistence.QuickstepSettings quickstepSettings;
private games.dmg.fantasystick.ability.SecondWind secondWind;
private games.dmg.fantasystick.persistence.SecondWindSettings secondWindSettings;
private games.dmg.fantasystick.ability.CombatMitigation combatMitigation;
private games.dmg.fantasystick.ability.StoneSkin stoneSkin;
private games.dmg.fantasystick.ability.ShieldBash shieldBash;
private games.dmg.fantasystick.persistence.ShieldBashSettings shieldBashSettings;
private games.dmg.fantasystick.persistence.StoneSkinSettings stoneSkinSettings;
private games.dmg.fantasystick.ability.GreatSword greatSword;
private games.dmg.fantasystick.ability.Revive revive;
private games.dmg.fantasystick.persistence.RevivalStore revivals;
@@ -92,6 +99,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
var longbowInitial = games.dmg.fantasystick.persistence.LongbowSettings.parse(longbowConfiguration);
var quickstepConfiguration = quickstepConfiguration();
var quickstepInitial = games.dmg.fantasystick.persistence.QuickstepSettings.parse(quickstepConfiguration);
var secondWindConfiguration = secondWindConfiguration();
var secondWindInitial = games.dmg.fantasystick.persistence.SecondWindSettings.parse(secondWindConfiguration);
var stoneSkinConfiguration = stoneSkinConfiguration();
var stoneSkinInitial = games.dmg.fantasystick.persistence.StoneSkinSettings.parse(stoneSkinConfiguration);
var shieldBashConfiguration = shieldBashConfiguration();
var shieldBashInitial = games.dmg.fantasystick.persistence.ShieldBashSettings.parse(shieldBashConfiguration);
var factory = new CharacterFactory();
characters = new CharacterStorage(new YamlCharacterRepository(getDataFolder().toPath()), Clock.systemUTC());
crafting = new ManualCraftingController(characters, () -> factory.create(rules),
@@ -142,6 +155,19 @@ public final class FantasyStickPlugin extends JavaPlugin {
getDataFolder().toPath(), quickstepConfiguration);
quickstep = new games.dmg.fantasystick.ability.Quickstep(quickstepInitial,
event -> getServer().getPluginManager().callEvent(event));
secondWindSettings = new games.dmg.fantasystick.persistence.SecondWindSettings(
getDataFolder().toPath(), secondWindConfiguration);
secondWind = new games.dmg.fantasystick.ability.SecondWind(secondWindInitial, characters, sessions, nextTick,
event -> getServer().getPluginManager().callEvent(event), games.dmg.fantasystick.session.NativeHealthLimit::read);
stoneSkinSettings = new games.dmg.fantasystick.persistence.StoneSkinSettings(
getDataFolder().toPath(), stoneSkinConfiguration);
combatMitigation = new games.dmg.fantasystick.ability.CombatMitigation();
stoneSkin = new games.dmg.fantasystick.ability.StoneSkin(stoneSkinInitial, combatMitigation);
shieldBashSettings = new games.dmg.fantasystick.persistence.ShieldBashSettings(
getDataFolder().toPath(), shieldBashConfiguration);
shieldBash = new games.dmg.fantasystick.ability.ShieldBash(
shieldBashInitial,
event -> getServer().getPluginManager().callEvent(event));
greatSword = new games.dmg.fantasystick.ability.GreatSword(greatSwordSettings);
revivals = new games.dmg.fantasystick.persistence.RevivalStore(getDataFolder().toPath());
revive = new games.dmg.fantasystick.ability.Revive(characters, revivals, Clock.systemUTC(), nextTick,
@@ -151,6 +177,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
abilityRegistry.register(frostShard.definition());
abilityRegistry.register(longbow.definition());
abilityRegistry.register(quickstep.definition());
abilityRegistry.register(secondWind.definition());
abilityRegistry.register(stoneSkin.definition());
abilityRegistry.register(shieldBash.definition());
abilityRegistry.register(greatSword.definition());
abilityRegistry.register(revive.definition());
sessions.attachAbilityRegistry(abilityRegistry);
@@ -195,7 +224,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
problem -> getLogger().log(Level.WARNING, "Equipment admin " + problem.operation()
+ " for player " + problem.player(), problem.cause()), adventureSettings,
frostShardSettings, frostShard, longbowSettings, longbow,
quickstepSettings, quickstep, abilityRegistry);
quickstepSettings, quickstep, secondWindSettings, secondWind,
stoneSkinSettings, stoneSkin, shieldBashSettings, shieldBash, abilityRegistry);
var command = java.util.Objects.requireNonNull(getCommand("fantasystick"), "Missing packaged fantasystick command");
command.setExecutor(admin);
command.setTabCompleter(admin);
@@ -213,6 +243,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
getServer().getPluginManager().registerEvents(frostShard, this);
getServer().getPluginManager().registerEvents(longbow, this);
getServer().getPluginManager().registerEvents(quickstep, this);
getServer().getPluginManager().registerEvents(combatMitigation, this);
getServer().getPluginManager().registerEvents(shieldBash, this);
getServer().getPluginManager().registerEvents(greatSword, this);
getServer().getPluginManager().registerEvents(revive, this);
getServer().getPluginManager().registerEvents(localBasicAttack, this);
@@ -264,6 +296,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingLongbowSettings = longbowSettings; longbowSettings = null;
var closingQuickstep = quickstep; quickstep = null;
var closingQuickstepSettings = quickstepSettings; quickstepSettings = null;
secondWind = null;
var closingStoneSkin = stoneSkin; stoneSkin = null;
var closingShieldBash = shieldBash; shieldBash = null;
var closingShieldBashSettings = shieldBashSettings; shieldBashSettings = null;
var closingCombatMitigation = combatMitigation; combatMitigation = null;
var closingStoneSkinSettings = stoneSkinSettings; stoneSkinSettings = null;
var closingSecondWindSettings = secondWindSettings; secondWindSettings = null;
var closingGreatSword = greatSword; greatSword = null;
var closingRevive = revive; revive = null;
var closingRevivals = revivals; revivals = null;
@@ -277,6 +316,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (closingFrostSettings != null) cleanup("Frost Shard configuration", closingFrostSettings::close);
if (closingLongbowSettings != null) cleanup("Longbow configuration", closingLongbowSettings::close);
if (closingQuickstepSettings != null) cleanup("Quickstep configuration", closingQuickstepSettings::close);
if (closingSecondWindSettings != null) cleanup("Second Wind configuration", closingSecondWindSettings::close);
if (closingStoneSkinSettings != null) cleanup("Stone Skin configuration", closingStoneSkinSettings::close);
if (closingShieldBashSettings != null) cleanup("Shield Bash configuration", closingShieldBashSettings::close);
configurationDrain = java.util.concurrent.CompletableFuture.allOf(
closingSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingSettings.drained().toCompletableFuture(),
@@ -285,7 +327,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
closingLongbowSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingLongbowSettings.drained().toCompletableFuture(),
closingQuickstepSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingQuickstepSettings.drained().toCompletableFuture());
: closingQuickstepSettings.drained().toCompletableFuture(),
closingSecondWindSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingSecondWindSettings.drained().toCompletableFuture(),
closingStoneSkinSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingStoneSkinSettings.drained().toCompletableFuture(),
closingShieldBashSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingShieldBashSettings.drained().toCompletableFuture());
if (closingBooks != null) cleanup("character book", closingBooks::close);
if (closingBasicAttack != null) cleanup("basic attack", closingBasicAttack::close);
if (closingCombatExperience != null) cleanup("combat experience", closingCombatExperience::close);
@@ -293,6 +341,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (closingFrostShard != null) cleanup("Frost Shard", closingFrostShard::close);
if (closingLongbow != null) cleanup("Longbow", closingLongbow::close);
if (closingQuickstep != null) cleanup("Quickstep", closingQuickstep::close);
if (closingStoneSkin != null) cleanup("Stone Skin", closingStoneSkin::close);
if (closingShieldBash != null) cleanup("Shield Bash", closingShieldBash::close);
if (closingCombatMitigation != null) cleanup("combat mitigation", closingCombatMitigation::close);
if (closingGreatSword != null) cleanup("Great Sword", closingGreatSword::close);
if (closingRevive != null) cleanup("Revive", closingRevive::close);
if (closingAbilityEngine != null) cleanup("ability engine", closingAbilityEngine::close);
@@ -313,6 +364,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
unregister(closingFrostShard);
unregister(closingLongbow);
unregister(closingQuickstep);
unregister(closingCombatMitigation);
unregister(closingShieldBash);
unregister(closingGreatSword);
unregister(closingRevive);
unregister(closingBasicAttack);
@@ -415,6 +468,36 @@ public final class FantasyStickPlugin extends JavaPlugin {
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> secondWindConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.SecondWindSettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.SecondWindSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.SecondWindSettings.parse(values);
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> stoneSkinConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.StoneSkinSettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.StoneSkinSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.StoneSkinSettings.parse(values);
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> shieldBashConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.ShieldBashSettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.ShieldBashSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.ShieldBashSettings.parse(values);
return java.util.Map.copyOf(values);
}
private games.dmg.fantasystick.ability.GreatSword.Settings greatSwordSettings() {
try {
var base = new java.math.BigInteger(getConfig().getString("great-sword-base-damage", "7"));
@@ -0,0 +1,77 @@
package games.dmg.fantasystick.ability;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
/** Internal strongest-reduction stage shared by Fantasy Stick protection effects. */
public final class CombatMitigation implements Listener, AutoCloseable {
private final class Grant implements AutoCloseable {
final AbilityExecution execution; final Player player; final BigDecimal percent; boolean closed;
Grant(AbilityExecution execution, Player player, BigDecimal percent) {
this.execution = execution; this.player = player; this.percent = percent;
}
@Override public void close() {
if (closed) return; closed = true;
var owned = grants.get(player.getUniqueId());
if (owned != null) { owned.remove(this); if (owned.isEmpty()) grants.remove(player.getUniqueId()); }
}
}
private record Prevention(Grant grant, BigDecimal amount) { }
private final Map<UUID, List<Grant>> grants = new java.util.HashMap<>();
private final Map<EntityDamageEvent, Prevention> pending = new IdentityHashMap<>();
private boolean closed;
public AutoCloseable grant(AbilityExecution execution, Player player, BigDecimal percent) {
Objects.requireNonNull(execution); Objects.requireNonNull(player); Objects.requireNonNull(percent);
if (closed || percent.signum() < 0 || percent.compareTo(BigDecimal.valueOf(100)) > 0)
throw new IllegalArgumentException("Invalid combat mitigation grant");
var grant = new Grant(execution, player, percent);
grants.computeIfAbsent(player.getUniqueId(), ignored -> new ArrayList<>()).add(grant);
return grant;
}
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
public void reduce(EntityDamageByEntityEvent event) {
if (closed || !(event.getEntity() instanceof Player player)) return;
Grant strongest = null;
var owned = grants.get(player.getUniqueId());
if (owned == null) return;
for (var grant : List.copyOf(owned)) {
if (grant.closed || grant.player != player || !grant.execution.stillCurrent()) continue;
if (strongest == null || grant.percent.compareTo(strongest.percent) > 0) strongest = grant;
}
double original = event.getDamage();
if (strongest == null || !Double.isFinite(original) || original <= 0) return;
double reduced = BigDecimal.valueOf(original)
.multiply(BigDecimal.ONE.subtract(strongest.percent.movePointLeft(2))).doubleValue();
if (!Double.isFinite(reduced) || reduced < 0) return;
event.setDamage(reduced);
BigDecimal prevented = BigDecimal.valueOf(original - reduced);
if (prevented.signum() > 0) pending.put(event, new Prevention(strongest, prevented));
}
@EventHandler(priority = EventPriority.MONITOR)
public void accepted(EntityDamageEvent event) {
var prevention = pending.remove(event);
if (prevention == null || event.isCancelled() || event.getFinalDamage() <= 0
|| !prevention.grant.execution.stillCurrent()) return;
prevention.grant.execution.report(AbilityReport.Type.PREVENTION, event.getEntity(), prevention.amount);
}
@Override public void close() {
if (closed) return; closed = true;
for (var owned : List.copyOf(grants.values())) for (var grant : List.copyOf(owned)) grant.close();
grants.clear(); pending.clear();
}
}
@@ -0,0 +1,115 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Consumer;
import java.util.function.Supplier;
import org.bukkit.Material;
import org.bukkit.attribute.Attribute;
import org.bukkit.event.entity.EntityRegainHealthEvent;
/** Second Wind's validated persist-before-publication self-heal. */
public final class SecondWind implements AbilityEffect {
public record Settings(BigInteger baseHealing, Duration cooldown) {
public Settings {
Objects.requireNonNull(baseHealing); Objects.requireNonNull(cooldown);
if (baseHealing.signum() < 0 || baseHealing.compareTo(BigDecimal.valueOf(Double.MAX_VALUE).toBigInteger()) > 0
|| cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0) {
throw new IllegalArgumentException("Invalid Second Wind settings");
}
}
public static Settings defaults() { return new Settings(BigInteger.valueOf(6), Duration.ofSeconds(15)); }
}
private final CharacterStorage characters;
private final EquipmentSessions sessions;
private final Consumer<Runnable> nextTick;
private final Consumer<EntityRegainHealthEvent> healingEvents;
private final Supplier<BigDecimal> nativeLimit;
private volatile Settings settings;
public SecondWind(Settings settings, CharacterStorage characters, EquipmentSessions sessions,
Consumer<Runnable> nextTick, Consumer<EntityRegainHealthEvent> healingEvents,
Supplier<BigDecimal> nativeLimit) {
this.settings = Objects.requireNonNull(settings); this.characters = Objects.requireNonNull(characters);
this.sessions = Objects.requireNonNull(sessions); this.nextTick = Objects.requireNonNull(nextTick);
this.healingEvents = Objects.requireNonNull(healingEvents); this.nativeLimit = Objects.requireNonNull(nativeLimit);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
var current = settings;
return new AbilityDefinition("second_wind", "Second Wind", Material.GHAST_TEAR,
List.of("Restore missing fantasy health.",
"Heals " + current.baseHealing() + " + WIS modifier, capped at maximum health."),
"Use while injured; full health refuses without spending cooldown.",
Map.of(Stat.CON, BigInteger.valueOf(12), Stat.WIS, BigInteger.valueOf(12)), BigInteger.ONE,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("base-healing", new BigDecimal(current.baseHealing())),
AbilityDefinition.TargetPolicy.SELF, this);
}
@Override public boolean accepts(ControlInput input) {
double health = input.player().getHealth();
var attribute = input.player().getAttribute(Attribute.MAX_HEALTH);
return attribute != null && Double.isFinite(health) && health >= 0
&& Double.isFinite(attribute.getValue()) && health < attribute.getValue();
}
public static BigInteger healing(BigInteger wisdom) { return healing(BigInteger.valueOf(6), wisdom); }
static BigInteger healing(BigInteger base, BigInteger wisdom) {
Objects.requireNonNull(base); Objects.requireNonNull(wisdom);
if (base.signum() < 0 || wisdom.signum() < 0) throw new IllegalArgumentException("Invalid Second Wind healing input");
BigInteger delta = wisdom.subtract(BigInteger.TEN); BigInteger[] divided = delta.divideAndRemainder(BigInteger.TWO);
BigInteger modifier = divided[0];
if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
return base.add(modifier);
}
@Override public void activate(AbilityExecution execution) {
if (!execution.stillCurrent()) return;
var definition = execution.definition();
BigInteger base = definition.parameters().get("base-healing").toBigIntegerExact();
var player = execution.input().player();
var attribute = player.getAttribute(Attribute.MAX_HEALTH);
if (attribute == null) { execution.finish(); return; }
double current = player.getHealth(); double maximum = attribute.getValue();
double missing = maximum - current;
if (!Double.isFinite(current) || !Double.isFinite(maximum) || missing <= 0) { execution.finish(); return; }
BigInteger exact = healing(base, execution.character().stats().get(Stat.WIS));
double proposed = exact.bitLength() > 1023 ? missing : Math.min(missing, exact.doubleValue());
if (!Double.isFinite(proposed) || proposed <= 0) { execution.finish(); return; }
var event = new EntityRegainHealthEvent(player, proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
healingEvents.accept(event);
double accepted = Math.min(missing, event.getAmount());
if (event.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) { execution.finish(); return; }
var record = execution.character(); var expected = record.revision();
BigDecimal target = BigDecimal.valueOf(current + accepted);
BigDecimal limit = nativeLimit.get();
characters.saveHealth(record.id(), expected, target, limit).whenComplete((saved, failure) -> {
try {
nextTick.accept(() -> {
try {
if (failure != null || saved == null || saved.isEmpty() || !execution.stillCurrent()) {
player.sendMessage("Second Wind health could not be saved; no healing was applied."); return;
}
var persisted = saved.orElseThrow();
if (!sessions.acknowledgeProgress(execution.input().session(), expected, persisted)) {
player.sendMessage("Second Wind was saved, but the session changed; no native healing was applied."); return;
}
double publish = Math.min(attribute.getValue(), Math.max(player.getHealth(), persisted.health().doubleValue()));
player.setHealth(publish);
} finally { execution.finish(); }
});
} catch (RuntimeException rejected) { execution.finish(); }
});
}
}
@@ -0,0 +1,144 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Consumer;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.damage.DamageSource;
import org.bukkit.damage.DamageType;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.player.PlayerVelocityEvent;
import org.bukkit.util.Vector;
/** Shield Bash's contained short-range custom strike and post-damage knockback. */
public final class ShieldBash implements AbilityEffect, Listener, AutoCloseable {
public record Settings(BigInteger baseDamage, Duration cooldown, double range,
double horizontalKnockback, double minimumVerticalKnockback) {
public Settings {
Objects.requireNonNull(baseDamage); Objects.requireNonNull(cooldown);
if (baseDamage.signum() < 0 || cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|| !Double.isFinite(range) || range <= 0 || range > 64
|| !Double.isFinite(horizontalKnockback) || horizontalKnockback < 0 || horizontalKnockback > 8
|| !Double.isFinite(minimumVerticalKnockback) || minimumVerticalKnockback < 0
|| minimumVerticalKnockback > 8) throw new IllegalArgumentException("Invalid Shield Bash settings");
}
public static Settings defaults() {
return new Settings(BigInteger.valueOf(4), Duration.ofSeconds(6), 3, 0.8, 0.35);
}
}
private static final class Attempt {
final AbilityExecution execution; final LivingEntity target; final Settings settings; boolean accepted;
Attempt(AbilityExecution execution, LivingEntity target, Settings settings) {
this.execution = execution; this.target = target; this.settings = settings;
}
}
private volatile Settings settings;
private final Consumer<PlayerVelocityEvent> velocityEvents;
private Attempt damaging;
private boolean closed;
public ShieldBash(Settings settings, Consumer<PlayerVelocityEvent> velocityEvents) {
this.settings = Objects.requireNonNull(settings); this.velocityEvents = Objects.requireNonNull(velocityEvents);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
var current = settings;
return new AbilityDefinition("shield_bash", "Shield Bash", Material.SHIELD,
List.of("Strike within " + decimal(current.range()) + " blocks for " + current.baseDamage() + " + STR modifier damage.",
"Accepted damage adds knockback; this shield never enables ordinary blocking."),
"Attack a visible hostile at short range.", Map.of(Stat.STR, BigInteger.valueOf(12), Stat.CON, BigInteger.valueOf(12)),
BigInteger.ONE, AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.ATTACK, current.cooldown(),
Map.of("base-damage", new BigDecimal(current.baseDamage()), "range", decimalValue(current.range()),
"horizontal-knockback", decimalValue(current.horizontalKnockback()),
"minimum-vertical-knockback", decimalValue(current.minimumVerticalKnockback())),
AbilityDefinition.TargetPolicy.HOSTILE, this);
}
@Override public boolean accepts(ControlInput input) {
return !closed && input.target() instanceof LivingEntity target && inRange(input.player(), target, settings.range());
}
public static BigInteger damage(BigInteger strength) { return damage(BigInteger.valueOf(4), strength); }
static BigInteger damage(BigInteger base, BigInteger strength) {
Objects.requireNonNull(base); Objects.requireNonNull(strength);
if (base.signum() < 0 || strength.signum() < 0) throw new IllegalArgumentException("Invalid Shield Bash damage input");
var delta = strength.subtract(BigInteger.TEN); var divided = delta.divideAndRemainder(BigInteger.TWO);
var modifier = divided[0];
if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
return base.add(modifier);
}
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent() || !(execution.input().target() instanceof LivingEntity target)) return;
var current = from(execution.definition()); var player = execution.input().player();
if (!inRange(player, target, current.range())) { execution.finish(); return; }
var exact = damage(current.baseDamage(), execution.character().stats().get(Stat.STR));
double amount = exact.bitLength() > 1023 ? Double.MAX_VALUE : exact.doubleValue();
if (!Double.isFinite(amount) || amount <= 0) { execution.finish(); return; }
var source = DamageSource.builder(DamageType.PLAYER_ATTACK).withCausingEntity(player).withDirectEntity(player).build();
var attempt = new Attempt(execution, target, current); damaging = attempt;
try { target.damage(amount, source); }
finally { if (damaging == attempt) damaging = null; execution.finish(); }
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void acceptedDamage(EntityDamageByEntityEvent event) {
var attempt = damaging;
if (closed || attempt == null || attempt.accepted || event.isCancelled() || event.getFinalDamage() <= 0
|| !Double.isFinite(event.getFinalDamage()) || event.getDamager() != attempt.execution.input().player()
|| event.getEntity() != attempt.target || !attempt.execution.stillCurrent()) return;
attempt.accepted = true;
attempt.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(),
BigDecimal.valueOf(event.getFinalDamage()).stripTrailingZeros());
knockback(attempt);
}
private void knockback(Attempt attempt) {
var player = attempt.execution.input().player(); var target = attempt.target;
Vector away = target.getLocation().toVector().subtract(player.getLocation().toVector()); away.setY(0);
if (!finite(away) || away.lengthSquared() < 1.0e-12) return;
Vector current = target.getVelocity();
Vector proposed = current.clone().add(away.normalize().multiply(attempt.settings.horizontalKnockback()));
proposed.setY(Math.max(current.getY(), attempt.settings.minimumVerticalKnockback()));
if (!finite(proposed)) return;
if (target instanceof Player targetPlayer) {
var event = new PlayerVelocityEvent(targetPlayer, proposed.clone()); velocityEvents.accept(event);
if (event.isCancelled() || !finite(event.getVelocity())) return;
proposed = event.getVelocity().clone();
}
target.setVelocity(proposed);
}
private static boolean inRange(Player player, LivingEntity target, double range) {
try {
Location source = player.getLocation(); Location destination = target.getLocation();
return source.getWorld() == destination.getWorld() && source.distanceSquared(destination) <= range * range
&& player.hasLineOfSight(target);
} catch (RuntimeException invalid) { return false; }
}
private static boolean finite(Vector value) {
return Double.isFinite(value.getX()) && Double.isFinite(value.getY()) && Double.isFinite(value.getZ());
}
private static Settings from(AbilityDefinition definition) {
var p = definition.parameters();
return new Settings(p.get("base-damage").toBigIntegerExact(), definition.cooldown(), p.get("range").doubleValue(),
p.get("horizontal-knockback").doubleValue(), p.get("minimum-vertical-knockback").doubleValue());
}
private static BigDecimal decimalValue(double value) { return BigDecimal.valueOf(value).stripTrailingZeros(); }
private static String decimal(double value) { return decimalValue(value).toPlainString(); }
@Override public void close() { closed = true; damaging = null; }
}
@@ -0,0 +1,84 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import org.bukkit.Material;
import org.bukkit.entity.Player;
/** Stone Skin's captured, owned grant into the shared strongest-reduction stage. */
public final class StoneSkin implements AbilityEffect, AutoCloseable {
public record Settings(Duration cooldown, Duration duration, BigDecimal reductionPercent) {
public Settings {
Objects.requireNonNull(cooldown); Objects.requireNonNull(duration); Objects.requireNonNull(reductionPercent);
if (cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|| duration.isNegative() || duration.isZero() || duration.compareTo(Duration.ofDays(1)) > 0
|| reductionPercent.signum() < 0 || reductionPercent.compareTo(BigDecimal.valueOf(100)) > 0)
throw new IllegalArgumentException("Invalid Stone Skin settings");
}
public static Settings defaults() {
return new Settings(Duration.ofSeconds(14), Duration.ofSeconds(4), BigDecimal.valueOf(25));
}
}
private final class State implements AutoCloseable {
final AbilityExecution execution; final Player player; final AutoCloseable grant; boolean closed;
State(AbilityExecution execution, Player player, AutoCloseable grant) {
this.execution = execution; this.player = player; this.grant = grant;
}
@Override public void close() {
if (closed) return; closed = true; protections.remove(player.getUniqueId(), this);
try { grant.close(); } catch (Exception impossible) { throw new IllegalStateException(impossible); }
}
}
private volatile Settings settings;
private final CombatMitigation mitigation;
private final Map<UUID, State> protections = new java.util.HashMap<>();
private boolean closed;
public StoneSkin(Settings settings, CombatMitigation mitigation) {
this.settings = Objects.requireNonNull(settings); this.mitigation = Objects.requireNonNull(mitigation);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
var current = settings;
return new AbilityDefinition("stone_skin", "Stone Skin", Material.BRICK,
List.of("Reduce entity and projectile combat damage by " + decimal(current.reductionPercent()) + "%.",
"Lasts " + decimal(BigDecimal.valueOf(current.duration().toMillis()).movePointLeft(3)) + " seconds without stacking."),
"Use to protect yourself; environmental damage is unchanged.", Map.of(Stat.CON, BigInteger.valueOf(14)), BigInteger.ONE,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("duration-seconds", BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9),
"damage-reduction-percent", current.reductionPercent()), AbilityDefinition.TargetPolicy.SELF, this);
}
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) return;
var current = from(execution.definition()); var player = execution.input().player();
var grant = mitigation.grant(execution, player, current.reductionPercent());
var state = new State(execution, player, grant);
var previous = protections.put(player.getUniqueId(), state);
if (previous != null) previous.execution.finish();
execution.own(state); execution.later(current.duration(), execution::finish);
}
private static Settings from(AbilityDefinition definition) {
return new Settings(definition.cooldown(),
Duration.ofNanos(definition.parameters().get("duration-seconds").movePointRight(9).longValueExact()),
definition.parameters().get("damage-reduction-percent"));
}
private static String decimal(BigDecimal value) { return value.stripTrailingZeros().toPlainString(); }
@Override public void close() {
if (closed) return; closed = true;
for (var state : List.copyOf(protections.values())) state.execution.finish();
protections.clear();
}
}
@@ -441,12 +441,19 @@ public final class CharacterStorage implements AutoCloseable {
/** Session-owned remaining health; retain all other current fields and reject concurrent revision changes. */
public CompletableFuture<Optional<CharacterRecord>> saveHealth(UUID characterId, java.math.BigDecimal health,
java.math.BigDecimal nativeLimit) {
return saveHealth(characterId, null, health, nativeLimit);
}
/** Ability-owned health mutation that refuses a changed character revision rather than healing stale state. */
public CompletableFuture<Optional<CharacterRecord>> saveHealth(UUID characterId, BigInteger expectedRevision,
java.math.BigDecimal health, java.math.BigDecimal nativeLimit) {
Objects.requireNonNull(characterId);
if (health.signum() < 0 || nativeLimit.signum() <= 0) throw new IllegalArgumentException("Invalid health bound");
return submit(() -> {
var found = repository.load(characterId);
if (found.isEmpty() || found.orElseThrow().dead()) return Optional.empty();
var old = found.orElseThrow();
if (expectedRevision != null && !old.revision().equals(expectedRevision)) return Optional.empty();
var bounded = health.min(old.maximumHealth()).min(nativeLimit);
if (old.health().compareTo(bounded) == 0) return Optional.of(old);
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
@@ -0,0 +1,187 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.SecondWind;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Serialized durable SecondWind settings; publication remains a server-thread caller decision. */
public final class SecondWindSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String BASE_HEALING = "second-wind-base-healing";
public static final String COOLDOWN = "second-wind-cooldown-seconds";
private static final List<String> KEYS = List.of(BASE_HEALING, COOLDOWN);
private static final Map<String, String> DEFAULTS = Map.of(BASE_HEALING, "6", COOLDOWN, "15");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, SecondWind.Settings settings,
Map<String, String> values, long sequence) {
public Update { values = Map.copyOf(values); }
}
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-second-wind-configuration"));
private volatile Map<String, String> effectiveValues;
private Map<String, String> persistedValues;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public SecondWindSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
SecondWindSettings(Path directory, Map<String, String> initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
this.replacement = Objects.requireNonNull(replacement);
var copy = new LinkedHashMap<String, String>();
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
parse(copy);
effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
}
public static List<String> keys() { return KEYS; }
public static String defaultValue(String key) { return DEFAULTS.get(key); }
public String value(String key) { return effectiveValues.get(key); }
public SecondWind.Settings settings() { return parse(effectiveValues); }
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
public synchronized void publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
parse(update.values());
effectiveValues = update.values(); publishedSequence = update.sequence();
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(key, value);
var preview = new LinkedHashMap<>(effectiveValues); preview.put(key, accepted);
parse(preview); // Fail malformed/out-of-range command input before queueing accepted persistence.
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
var next = new LinkedHashMap<>(persistedValues);
next.put(key, accepted);
var parsed = parse(next);
save(key, accepted);
persistedValues = Map.copyOf(next);
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("SecondWind configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished();
return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String key, String raw) {
try {
if (key.equals(BASE_HEALING)) return new java.math.BigInteger(raw).toString();
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static SecondWind.Settings parse(Map<String, String> values) {
try {
var base = new java.math.BigInteger(values.get(BASE_HEALING));
var cooldown = seconds(values.get(COOLDOWN));
return new SecondWind.Settings(base, cooldown);
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("SecondWind settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new java.math.BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new java.math.BigDecimal(value).doubleValue();
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
return result;
}
private void save(String key, String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml");
var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS)
&& !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe lock");
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options));
Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
document.put(savedKey, entry.getValue());
}
}
}
document.put(key, value);
byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
private synchronized void finished() {
pending--;
if (closed && pending == 0) drained.complete(null);
}
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() {
closed = true; worker.shutdown(); if (pending == 0) drained.complete(null);
}
}
@@ -0,0 +1,192 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.ShieldBash;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Serialized durable Shield Bash settings; publication remains a server-thread caller decision. */
public final class ShieldBashSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String BASE_DAMAGE = "shield-bash-base-damage";
public static final String COOLDOWN = "shield-bash-cooldown-seconds";
public static final String RANGE = "shield-bash-range-blocks";
public static final String HORIZONTAL = "shield-bash-horizontal-knockback";
public static final String VERTICAL = "shield-bash-minimum-vertical-knockback";
private static final List<String> KEYS = List.of(BASE_DAMAGE, COOLDOWN, RANGE, HORIZONTAL, VERTICAL);
private static final Map<String, String> DEFAULTS = Map.of(BASE_DAMAGE, "4", COOLDOWN, "6", RANGE, "3",
HORIZONTAL, "0.8", VERTICAL, "0.35");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, ShieldBash.Settings settings,
Map<String, String> values, long sequence) {
public Update { values = Map.copyOf(values); }
}
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-shield-bash-configuration"));
private volatile Map<String, String> effectiveValues;
private Map<String, String> persistedValues;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public ShieldBashSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
ShieldBashSettings(Path directory, Map<String, String> initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
this.replacement = Objects.requireNonNull(replacement);
var copy = new LinkedHashMap<String, String>();
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
parse(copy);
effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
}
public static List<String> keys() { return KEYS; }
public static String defaultValue(String key) { return DEFAULTS.get(key); }
public String value(String key) { return effectiveValues.get(key); }
public ShieldBash.Settings settings() { return parse(effectiveValues); }
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
public synchronized void publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
parse(update.values());
effectiveValues = update.values(); publishedSequence = update.sequence();
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(key, value);
var preview = new LinkedHashMap<>(effectiveValues); preview.put(key, accepted);
parse(preview); // Fail malformed/out-of-range command input before queueing accepted persistence.
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
var next = new LinkedHashMap<>(persistedValues);
next.put(key, accepted);
var parsed = parse(next);
save(key, accepted);
persistedValues = Map.copyOf(next);
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("Shield Bash configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished();
return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String key, String raw) {
try {
if (key.equals(BASE_DAMAGE)) return new java.math.BigInteger(raw).toString();
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static ShieldBash.Settings parse(Map<String, String> values) {
try {
var base = new java.math.BigInteger(values.get(BASE_DAMAGE));
var cooldown = seconds(values.get(COOLDOWN));
return new ShieldBash.Settings(base, cooldown, finite(values.get(RANGE)),
finite(values.get(HORIZONTAL)), finite(values.get(VERTICAL)));
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Shield Bash settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new java.math.BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new java.math.BigDecimal(value).doubleValue();
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
return result;
}
private void save(String key, String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml");
var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS)
&& !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe lock");
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options));
Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
document.put(savedKey, entry.getValue());
}
}
}
document.put(key, value);
byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
private synchronized void finished() {
pending--;
if (closed && pending == 0) drained.complete(null);
}
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() {
closed = true; worker.shutdown(); if (pending == 0) drained.complete(null);
}
}
@@ -0,0 +1,188 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.StoneSkin;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Serialized durable Stone Skin settings; publication remains a server-thread caller decision. */
public final class StoneSkinSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String COOLDOWN = "stone-skin-cooldown-seconds";
public static final String DURATION = "stone-skin-duration-seconds";
public static final String REDUCTION = "stone-skin-damage-reduction-percent";
private static final List<String> KEYS = List.of(COOLDOWN, DURATION, REDUCTION);
private static final Map<String, String> DEFAULTS = Map.of(COOLDOWN, "14", DURATION, "4", REDUCTION, "25");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, StoneSkin.Settings settings,
Map<String, String> values, long sequence) {
public Update { values = Map.copyOf(values); }
}
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-stone-skin-configuration"));
private volatile Map<String, String> effectiveValues;
private Map<String, String> persistedValues;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public StoneSkinSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
StoneSkinSettings(Path directory, Map<String, String> initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
this.replacement = Objects.requireNonNull(replacement);
var copy = new LinkedHashMap<String, String>();
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
parse(copy);
effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
}
public static List<String> keys() { return KEYS; }
public static String defaultValue(String key) { return DEFAULTS.get(key); }
public String value(String key) { return effectiveValues.get(key); }
public StoneSkin.Settings settings() { return parse(effectiveValues); }
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
public synchronized void publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
parse(update.values());
effectiveValues = update.values(); publishedSequence = update.sequence();
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(key, value);
var preview = new LinkedHashMap<>(effectiveValues); preview.put(key, accepted);
parse(preview); // Fail malformed/out-of-range command input before queueing accepted persistence.
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
var next = new LinkedHashMap<>(persistedValues);
next.put(key, accepted);
var parsed = parse(next);
save(key, accepted);
persistedValues = Map.copyOf(next);
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("Stone Skin configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished();
return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String key, String raw) {
try {
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static StoneSkin.Settings parse(Map<String, String> values) {
try {
var cooldown = seconds(values.get(COOLDOWN));
var duration = seconds(values.get(DURATION));
var reduction = new java.math.BigDecimal(values.get(REDUCTION));
return new StoneSkin.Settings(cooldown, duration, reduction);
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Stone Skin settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new java.math.BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new java.math.BigDecimal(value).doubleValue();
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
return result;
}
private void save(String key, String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml");
var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS)
&& !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe lock");
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options));
Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
document.put(savedKey, entry.getValue());
}
}
}
document.put(key, value);
byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
private synchronized void finished() {
pending--;
if (closed && pending == 0) drained.complete(null);
}
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() {
closed = true; worker.shutdown(); if (pending == 0) drained.complete(null);
}
}
@@ -28,6 +28,12 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private games.dmg.fantasystick.ability.Longbow longbow;
private games.dmg.fantasystick.persistence.QuickstepSettings quickstepSettings;
private games.dmg.fantasystick.ability.Quickstep quickstep;
private games.dmg.fantasystick.persistence.SecondWindSettings secondWindSettings;
private games.dmg.fantasystick.ability.SecondWind secondWind;
private games.dmg.fantasystick.persistence.StoneSkinSettings stoneSkinSettings;
private games.dmg.fantasystick.ability.StoneSkin stoneSkin;
private games.dmg.fantasystick.persistence.ShieldBashSettings shieldBashSettings;
private games.dmg.fantasystick.ability.ShieldBash shieldBash;
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics) {
@@ -86,6 +92,67 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
this.quickstep = Objects.requireNonNull(quickstep);
}
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics,
games.dmg.fantasystick.persistence.AdventureSettings settings,
games.dmg.fantasystick.persistence.FrostShardSettings frostSettings,
games.dmg.fantasystick.ability.FrostShard frostShard,
games.dmg.fantasystick.persistence.LongbowSettings longbowSettings,
games.dmg.fantasystick.ability.Longbow longbow,
games.dmg.fantasystick.persistence.QuickstepSettings quickstepSettings,
games.dmg.fantasystick.ability.Quickstep quickstep,
games.dmg.fantasystick.persistence.SecondWindSettings secondWindSettings,
games.dmg.fantasystick.ability.SecondWind secondWind,
games.dmg.fantasystick.ability.AbilityRegistry abilities) {
this(receipts, sessions, server, nextTick, diagnostics, settings, frostSettings, frostShard,
longbowSettings, longbow, quickstepSettings, quickstep, abilities);
this.secondWindSettings = Objects.requireNonNull(secondWindSettings);
this.secondWind = Objects.requireNonNull(secondWind);
}
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics,
games.dmg.fantasystick.persistence.AdventureSettings settings,
games.dmg.fantasystick.persistence.FrostShardSettings frostSettings,
games.dmg.fantasystick.ability.FrostShard frostShard,
games.dmg.fantasystick.persistence.LongbowSettings longbowSettings,
games.dmg.fantasystick.ability.Longbow longbow,
games.dmg.fantasystick.persistence.QuickstepSettings quickstepSettings,
games.dmg.fantasystick.ability.Quickstep quickstep,
games.dmg.fantasystick.persistence.SecondWindSettings secondWindSettings,
games.dmg.fantasystick.ability.SecondWind secondWind,
games.dmg.fantasystick.persistence.StoneSkinSettings stoneSkinSettings,
games.dmg.fantasystick.ability.StoneSkin stoneSkin,
games.dmg.fantasystick.ability.AbilityRegistry abilities) {
this(receipts, sessions, server, nextTick, diagnostics, settings, frostSettings, frostShard,
longbowSettings, longbow, quickstepSettings, quickstep, secondWindSettings, secondWind, abilities);
this.stoneSkinSettings = Objects.requireNonNull(stoneSkinSettings);
this.stoneSkin = Objects.requireNonNull(stoneSkin);
}
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics,
games.dmg.fantasystick.persistence.AdventureSettings settings,
games.dmg.fantasystick.persistence.FrostShardSettings frostSettings,
games.dmg.fantasystick.ability.FrostShard frostShard,
games.dmg.fantasystick.persistence.LongbowSettings longbowSettings,
games.dmg.fantasystick.ability.Longbow longbow,
games.dmg.fantasystick.persistence.QuickstepSettings quickstepSettings,
games.dmg.fantasystick.ability.Quickstep quickstep,
games.dmg.fantasystick.persistence.SecondWindSettings secondWindSettings,
games.dmg.fantasystick.ability.SecondWind secondWind,
games.dmg.fantasystick.persistence.StoneSkinSettings stoneSkinSettings,
games.dmg.fantasystick.ability.StoneSkin stoneSkin,
games.dmg.fantasystick.persistence.ShieldBashSettings shieldBashSettings,
games.dmg.fantasystick.ability.ShieldBash shieldBash,
games.dmg.fantasystick.ability.AbilityRegistry abilities) {
this(receipts, sessions, server, nextTick, diagnostics, settings, frostSettings, frostShard,
longbowSettings, longbow, quickstepSettings, quickstep, secondWindSettings, secondWind,
stoneSkinSettings, stoneSkin, abilities);
this.shieldBashSettings = Objects.requireNonNull(shieldBashSettings);
this.shieldBash = Objects.requireNonNull(shieldBash);
}
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (closed) return true;
if (!sender.hasPermission(PERMISSION)) {
@@ -165,6 +232,15 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (quickstepSettings != null && games.dmg.fantasystick.persistence.QuickstepSettings.keys().contains(args[1])) {
quickstepConfiguration(sender, args); return;
}
if (secondWindSettings != null && games.dmg.fantasystick.persistence.SecondWindSettings.keys().contains(args[1])) {
secondWindConfiguration(sender, args); return;
}
if (stoneSkinSettings != null && games.dmg.fantasystick.persistence.StoneSkinSettings.keys().contains(args[1])) {
stoneSkinConfiguration(sender, args); return;
}
if (shieldBashSettings != null && games.dmg.fantasystick.persistence.ShieldBashSettings.keys().contains(args[1])) {
shieldBashConfiguration(sender, args); return;
}
if (frostSettings == null || !games.dmg.fantasystick.persistence.FrostShardSettings.keys().contains(args[1])) {
configurationUsage(sender); return;
}
@@ -262,6 +338,85 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
});
}
private void shieldBashConfiguration(CommandSender sender, String[] args) {
String key = args[1];
if (args.length == 2) { sender.sendMessage(key + "=" + shieldBashSettings.value(key) + " (applies to future Shield Bash strikes only)"); return; }
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.ShieldBashSettings.Update> operation;
try { operation = shieldBashSettings.set(key, args[2]); }
catch (IllegalArgumentException invalid) { sender.sendMessage("Shield Bash setting is invalid; the effective value is unchanged."); return; }
operation.whenComplete((saved, failure) -> {
if (failure != null) report(null, "config-" + key, failure);
if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
if (failure == null) {
shieldBash.configure(saved.settings()); abilities.replace(shieldBash.definition()); shieldBashSettings.publish(saved);
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(failure == null
? "Saved " + key + "=" + saved.value() + " for future Shield Bash strikes; active work and deadlines are unchanged."
: "Shield Bash configuration save failed; the effective setting is unchanged.");
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
private void stoneSkinConfiguration(CommandSender sender, String[] args) {
String key = args[1];
if (args.length == 2) {
sender.sendMessage(key + "=" + stoneSkinSettings.value(key) + " (applies to future Stone Skin casts only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.StoneSkinSettings.Update> operation;
try { operation = stoneSkinSettings.set(key, args[2]); }
catch (IllegalArgumentException invalid) { sender.sendMessage("Stone Skin setting is invalid; the effective value is unchanged."); return; }
operation.whenComplete((saved, failure) -> {
if (failure != null) report(null, "config-" + key, failure);
if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
if (failure == null) {
stoneSkin.configure(saved.settings()); abilities.replace(stoneSkin.definition()); stoneSkinSettings.publish(saved);
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(failure == null
? "Saved " + key + "=" + saved.value() + " for future Stone Skin casts; active effects and deadlines are unchanged."
: "Stone Skin configuration save failed; the effective setting is unchanged.");
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
private void secondWindConfiguration(CommandSender sender, String[] args) {
String key = args[1];
if (args.length == 2) {
sender.sendMessage(key + "=" + secondWindSettings.value(key) + " (applies to future Second Wind casts only)");
return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SecondWindSettings.Update> operation;
try { operation = secondWindSettings.set(key, args[2]); }
catch (IllegalArgumentException invalid) {
sender.sendMessage("Second Wind setting is invalid; the effective value is unchanged."); return;
}
operation.whenComplete((saved, failure) -> {
if (failure != null) report(null, "config-" + key, failure);
if (closed) return;
try {
nextTick.accept(() -> {
if (closed) return;
if (failure == null) {
secondWind.configure(saved.settings()); abilities.replace(secondWind.definition());
secondWindSettings.publish(saved);
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(failure == null
? "Saved " + key + "=" + saved.value() + " for future Second Wind casts; active work and deadlines are unchanged."
: "Second Wind configuration save failed; the effective setting is unchanged.");
});
} catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
private void adventureConfiguration(CommandSender sender, String[] args) {
if (args.length == 3 && !args[2].equals("true") && !args[2].equals("false")) {
sender.sendMessage("Usage: /fantasystick config adventure-mode [true|false]"); return;
@@ -303,6 +458,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
keys.addAll(games.dmg.fantasystick.persistence.FrostShardSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.LongbowSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.QuickstepSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.SecondWindSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.StoneSkinSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.ShieldBashSettings.keys());
return matching(keys, args[1]);
}
if (args.length == 3 && args[0].equals("config")) {
@@ -322,6 +480,21 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
String defaults = games.dmg.fantasystick.persistence.QuickstepSettings.defaultValue(args[1]);
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
}
if (secondWindSettings != null && games.dmg.fantasystick.persistence.SecondWindSettings.keys().contains(args[1])) {
String current = secondWindSettings.value(args[1]);
String defaults = games.dmg.fantasystick.persistence.SecondWindSettings.defaultValue(args[1]);
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
}
if (stoneSkinSettings != null && games.dmg.fantasystick.persistence.StoneSkinSettings.keys().contains(args[1])) {
String current = stoneSkinSettings.value(args[1]);
String defaults = games.dmg.fantasystick.persistence.StoneSkinSettings.defaultValue(args[1]);
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
}
if (shieldBashSettings != null && games.dmg.fantasystick.persistence.ShieldBashSettings.keys().contains(args[1])) {
String current = shieldBashSettings.value(args[1]);
String defaults = games.dmg.fantasystick.persistence.ShieldBashSettings.defaultValue(args[1]);
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
}
}
return List.of();
}
@@ -449,7 +449,8 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
if (special(item)) event.setCancelled(true);
if (willAttack) {
var selected = sessions.selectedControl(player, item).orElse(null);
if (selected != null && (selected.slot() == 1 || selected.ability().equals("great_sword"))) {
if (selected != null && (selected.slot() == 1 || selected.ability().equals("great_sword")
|| selected.ability().equals("shield_bash"))) {
emit(player, item, selected, ControlInput.Gesture.ATTACK, event.getAttacked(), 0, player.getAttackCooldown());
}
}
+19
View File
@@ -51,3 +51,22 @@ longbow-full-projectile-speed-blocks-per-tick: 3
quickstep-cooldown-seconds: 5
quickstep-distance-blocks: 4
quickstep-speed-blocks-per-tick: 1
# Second Wind defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
second-wind-base-healing: 6
second-wind-cooldown-seconds: 15
# Stone Skin defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
stone-skin-cooldown-seconds: 14
stone-skin-duration-seconds: 4
stone-skin-damage-reduction-percent: 25
# Shield Bash defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
shield-bash-base-damage: 4
shield-bash-cooldown-seconds: 6
shield-bash-range-blocks: 3
shield-bash-horizontal-knockback: 0.8
shield-bash-minimum-vertical-knockback: 0.35
@@ -0,0 +1,214 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.Stat;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.file.Path;
import java.util.EnumMap;
import java.util.List;
import net.minecraft.network.chat.ClickEvent;
import org.bukkit.event.entity.EntityRegainHealthEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class SecondWindNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void bookAcquisitionActualNativeHealPersistsBeforePublicationAndRetainsCooldown() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withSecondWind(data, a, false, BigDecimal.valueOf(4));
f.players.put(a.core.id, a.player); f.activate(a);
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
String text = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
assertTrue(text.contains("SecondWind")); assertTrue(text.contains("CON12")); assertTrue(text.contains("WIS12"));
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "second_wind"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "second_wind"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(4, a.player.getHealth());
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var saved = storage.find(a.core.record.id()).get().orElseThrow();
assertEquals(0, BigDecimal.valueOf(11).compareTo(saved.health())); assertEquals(11, a.player.getHealth());
assertNotNull(saved.cooldowns().get("second_wind"));
assertFalse(a.messages.stream().anyMatch(message -> message.contains("support")));
var sheet = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
a.core.inventory.setItemInOffHand(sheet); f.activate(a);
assertEquals(11, a.player.getHealth(), "Persisted healing survives exit and resume");
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
var unlearn = abilityButton(a, "[Unlearn]", "second_wind");
PartyBookNativeTest.click(f, a, unlearn); PartyBookNativeTest.click(f, a, unlearn);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var refunded = storage.find(a.core.record.id()).get().orElseThrow();
assertEquals(BigInteger.valueOf(40), refunded.unspentPoints());
assertEquals(saved.cooldowns().get("second_wind"), refunded.cooldowns().get("second_wind"));
learnAndAssign(f, a); use(f, a);
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
}
}
@Test void fullHealthAndCancelledNativeHealingApplyNoHealth() throws Exception {
for (boolean full : new boolean[]{true, false}) try (var f = new FantasyStickLifecycleNativeTest.Fixture(data.resolve(Boolean.toString(full)))) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data.resolve(Boolean.toString(full)));
withSecondWind(data.resolve(Boolean.toString(full)), a, true, BigDecimal.valueOf(full ? 22 : 4));
f.players.put(a.core.id, a.player); f.activate(a);
if (!full) {
class Veto implements org.bukkit.event.Listener {
@org.bukkit.event.EventHandler public void heal(EntityRegainHealthEvent event) {
if (event.getEntity() == a.player && event.getRegainReason() == EntityRegainHealthEvent.RegainReason.CUSTOM) {
event.setCancelled(true);
}
}
}
f.dispatcher.register(new Veto(), f.plugin);
}
double before = a.player.getHealth(); use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(before, a.player.getHealth());
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var saved = storage.find(a.core.record.id()).get().orElseThrow();
if (full) assertFalse(saved.cooldowns().containsKey("second_wind"));
else assertNotNull(saved.cooldowns().get("second_wind"));
}
}
@Test void failedHealthPersistencePublishesNoNativeHealing() {
var characters = mock(CharacterStorage.class);
var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class);
var player = mock(org.bukkit.entity.Player.class);
var attribute = mock(org.bukkit.attribute.AttributeInstance.class);
when(player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).thenReturn(attribute);
when(attribute.getValue()).thenReturn(22.0); when(player.getHealth()).thenReturn(4.0);
var stats = new EnumMap<Stat, BigInteger>(Stat.class);
for (Stat stat : Stat.values()) stats.put(stat, BigInteger.ZERO);
stats.put(Stat.CON, BigInteger.valueOf(12)); stats.put(Stat.WIS, BigInteger.valueOf(12));
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var record = new CharacterRecord(base.id(), base.name(), BigInteger.ONE, stats, base.level(), base.experience(),
base.unspentPoints(), BigDecimal.valueOf(4), java.util.Set.of("second_wind"),
List.of("second_wind", "", "", "", "", "", ""), base.cooldowns(), base.restrictedKills(), false, null, null);
var input = mock(games.dmg.fantasystick.session.ControlInput.class);
var active = new games.dmg.fantasystick.session.EquipmentSessions.Active(java.util.UUID.randomUUID(),
java.util.UUID.randomUUID(), record.id());
when(input.player()).thenReturn(player); when(input.session()).thenReturn(active);
var execution = mock(games.dmg.fantasystick.ability.AbilityExecution.class);
var effect = new games.dmg.fantasystick.ability.SecondWind(
games.dmg.fantasystick.ability.SecondWind.Settings.defaults(), characters, sessions, Runnable::run,
ignored -> {}, () -> BigDecimal.valueOf(1024));
when(execution.stillCurrent()).thenReturn(true); when(execution.input()).thenReturn(input);
when(execution.character()).thenReturn(record); when(execution.definition()).thenReturn(effect.definition());
var failure = new java.util.concurrent.CompletableFuture<java.util.Optional<CharacterRecord>>();
when(characters.saveHealth(eq(record.id()), eq(record.revision()), any(), any())).thenReturn(failure);
effect.activate(execution); verify(player, never()).setHealth(anyDouble());
failure.completeExceptionally(new java.io.IOException("simulated health save failure"));
verify(player, never()).setHealth(anyDouble()); verify(execution).finish();
}
@Test void configuredDefinitionFormulaAndAdminRoutesCoverBothSettings() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"second-wind-base-healing: 8\nsecond-wind-cooldown-seconds: 20\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
var definition = registry.find("second_wind").orElseThrow();
assertEquals(java.time.Duration.ofSeconds(20), definition.cooldown());
assertEquals(new BigDecimal("8"), definition.parameters().get("base-healing"));
assertEquals(BigInteger.valueOf(7), games.dmg.fantasystick.ability.SecondWind.healing(BigInteger.valueOf(12)));
assertEquals(BigInteger.TEN.pow(500).subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(6)),
games.dmg.fantasystick.ability.SecondWind.healing(BigInteger.TEN.pow(500)));
var sender = new EquipmentAdminCommandNativeTest.Sender();
assertEquals(List.of("config", "inspect", "recover"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
sender.allowed = false;
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "second"}).isEmpty());
f.command.execute(sender.port, "fantasystick", new String[]{"config", "second-wind-base-healing", "9"});
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
assertEquals(List.of("second-wind-base-healing", "second-wind-cooldown-seconds"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "second-wind-"}));
assertEquals(List.of("6", "8"), f.command.tabComplete(sender.port, "fantasystick",
new String[]{"config", "second-wind-base-healing", ""}));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "second-wind-base-healing"});
assertTrue(sender.messages.getLast().contains("=8"));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "second-wind-base-healing", "9"});
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
assertEquals(new BigDecimal("9"), registry.find("second_wind").orElseThrow().parameters().get("base-healing"));
var a = new FantasyStickLifecycleNativeTest.Actor(data); withSecondWind(data, a, true, BigDecimal.valueOf(4));
f.players.put(a.core.id, a.player); f.activate(a); use(f, a);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(14, a.player.getHealth(), "Future cast uses acknowledged base healing");
f.command.execute(sender.port, "fantasystick", new String[]{"config", "second-wind-base-healing", "-1"});
assertTrue(sender.messages.getLast().contains("invalid"));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "second-wind-base-healing", "9", "extra"});
assertTrue(sender.messages.getLast().contains("Usage"));
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
var reloaded = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
assertEquals(new BigDecimal("9"), reloaded.find("second_wind").orElseThrow().parameters().get("base-healing"));
}
for (String invalid : List.of("second-wind-base-healing: -1\n", "second-wind-cooldown-seconds: -1\n")) {
var directory = data.resolve(Integer.toHexString(invalid.hashCode()));
java.nio.file.Files.createDirectories(directory); java.nio.file.Files.writeString(directory.resolve("config.yml"), invalid);
try (var f = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
assertThrows(IllegalArgumentException.class, () -> f.plugin.setEnabled(true));
assertTrue(f.order.isEmpty());
}
}
}
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[Learn]", "second_wind"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "second_wind"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
}
private static void use(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor) {
actor.core.inventory.getInventory().setSelectedSlot(2);
fixture.dispatcher.accept(new PlayerInteractEvent(actor.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
actor.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND));
}
private static ClickEvent.Custom abilityButton(FantasyStickLifecycleNativeTest.Actor actor, String label, String ability) {
var book = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
assertNotNull(book);
for (var page : book.pages()) { var found = abilityButton(page.raw(), label, ability); if (found != null) return found; }
throw new AssertionError("Missing " + label + " action for " + ability);
}
private static ClickEvent.Custom abilityButton(net.minecraft.network.chat.Component component, String label, String ability) {
if (component.getString().equals(label) && component.getStyle().getClickEvent() instanceof ClickEvent.Custom custom
&& custom.payload().orElse(null) instanceof net.minecraft.nbt.StringTag payload
&& payload.value().contains(":" + ability)) return custom;
for (var child : component.getSiblings()) { var found = abilityButton(child, label, ability); if (found != null) return found; }
return null;
}
private static void withSecondWind(Path data, FantasyStickLifecycleNativeTest.Actor actor,
boolean learned, BigDecimal health) throws Exception {
java.nio.file.Files.createDirectories(data);
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(actor.core.record.id()).orElseThrow();
var stats = new EnumMap<>(old.stats()); stats.put(Stat.CON, BigInteger.valueOf(12)); stats.put(Stat.WIS, BigInteger.valueOf(12));
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
old.level(), old.experience(), BigInteger.valueOf(learned ? 39 : 40), health,
learned ? java.util.Set.of("second_wind") : old.learned(),
learned ? List.of("second_wind", "", "", "", "", "", "") : old.loadout(),
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
}
}
@@ -0,0 +1,329 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.Stat;
import java.math.BigInteger;
import java.nio.file.Path;
import java.util.EnumMap;
import java.util.List;
import net.minecraft.network.chat.ClickEvent;
import org.bukkit.Location;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.util.Vector;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ShieldBashNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void bookAcquisitionAndEquippedAttackReplaceVanillaDamageThenKnockBack() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withShieldBash(data, a, false);
f.players.put(a.core.id, a.player); f.activate(a);
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
String book = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
assertTrue(book.contains("ShieldBash")); assertTrue(book.contains("STR12")); assertTrue(book.contains("CON12"));
assertTrue(book.contains("Cost:1")); assertTrue(book.contains("3")); assertTrue(book.contains("6s"));
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "shield_bash"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "shield_bash"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertEquals(BigInteger.valueOf(39), storage.find(a.core.record.id()).get().orElseThrow().unspentPoints());
assertEquals(org.bukkit.Material.SHIELD, a.core.inventory.getItem(2).getType());
a.core.inventory.getInventory().setSelectedSlot(2);
var block = new org.bukkit.event.player.PlayerInteractEvent(a.player,
org.bukkit.event.block.Action.RIGHT_CLICK_AIR, a.core.inventory.getItem(2), null,
org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
f.dispatcher.accept(block); assertTrue(block.isCancelled(), "Contained shield cannot start ordinary blocking");
assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("shield_bash"));
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID()); when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
var origin = new Location(a.world, 0, 64, 0); var destination = new Location(a.world, 2, 64, 0);
doReturn(origin).when(a.player).getLocation(); when(target.getLocation()).thenReturn(destination);
doReturn(true).when(a.player).hasLineOfSight(target); when(target.getVelocity()).thenReturn(new Vector(0.1, 0.2, 0));
var applied = new java.util.concurrent.atomic.AtomicReference<Vector>();
doAnswer(call -> { applied.set(call.getArgument(0)); return null; }).when(target).setVelocity(any(Vector.class));
var damages = new java.util.ArrayList<EntityDamageByEntityEvent>();
doAnswer(call -> {
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
var event = new EntityDamageByEntityEvent(a.player, target, EntityDamageEvent.DamageCause.ENTITY_ATTACK,
source, (Double) call.getArgument(0)); damages.add(event); f.dispatcher.accept(event); return null;
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
a.core.inventory.getInventory().setSelectedSlot(2);
var attack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
f.dispatcher.accept(attack); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertTrue(attack.isCancelled(), "Contained shield cannot continue into vanilla damage/blocking");
assertEquals(1, damages.size()); assertEquals(5, damages.getFirst().getDamage(), 1.0e-9);
assertEquals(new Vector(0.9, 0.35, 0), applied.get());
assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("shield_bash"));
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(target, damages.getFirst().getDamageSource(),
new java.util.ArrayList<>(), 0)); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(BigInteger.TEN, storage.find(a.core.record.id()).get().orElseThrow().experience());
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
PartyBookNativeTest.drain(f); assertEquals(1, damages.size());
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
}
}
@Test void outOfRangeOrObstructedTargetRefusesBeforeCooldownWhileStillContainingShield() throws Exception {
for (int route = 0; route < 2; route++) {
var directory = data.resolve("refused-" + route);
try (var f = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(directory);
withShieldBash(directory, a, true); f.players.put(a.core.id, a.player); f.activate(a);
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID()); when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
doReturn(new Location(a.world, 0, 64, 0)).when(a.player).getLocation();
when(target.getLocation()).thenReturn(new Location(a.world, route == 0 ? 4 : 2, 64, 0));
doReturn(route == 0).when(a.player).hasLineOfSight(target);
a.core.inventory.getInventory().setSelectedSlot(2);
var attack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
f.dispatcher.accept(attack); assertTrue(attack.isCancelled());
verify(target, never()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("shield_bash"));
}
}
}
@Test void partyAndMixedModePlayerTargetsAreRefusedWithoutFreeVanillaHits() throws Exception {
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);var b=new FantasyStickLifecycleNativeTest.Actor(data);
withShieldBash(data,a,true);f.players.put(a.core.id,a.player);f.players.put(b.core.id,b.player);f.activate(a);f.activate(b);
var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(a.core.id).orElseThrow();
assertTrue(parties.accept(b.core.id,parties.invite(a.core.id,party.id(),b.core.id).orElseThrow().id()));
a.core.inventory.getInventory().setSelectedSlot(2);
var friendly=new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player,b.player,true);f.dispatcher.accept(friendly);
assertTrue(friendly.isCancelled());verify(b.player,never()).damage(anyDouble(),any(org.bukkit.damage.DamageSource.class));
var ordinary=mock(org.bukkit.entity.Player.class);when(ordinary.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
var mixed=new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player,ordinary,true);f.dispatcher.accept(mixed);
assertTrue(mixed.isCancelled());verify(ordinary,never()).damage(anyDouble(),any(org.bukkit.damage.DamageSource.class));
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");
assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("shield_bash"));
}
}
@Test void cancelledDamageAppliesNoKnockbackAndNeverRefundsAcknowledgedCooldown() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
withShieldBash(data, a, true); f.players.put(a.core.id, a.player); f.activate(a);
var target = mock(org.bukkit.entity.LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
doReturn(new Location(a.world, 0, 64, 0)).when(a.player).getLocation();
when(target.getLocation()).thenReturn(new Location(a.world, 2, 64, 0)); doReturn(true).when(a.player).hasLineOfSight(target);
doAnswer(call -> {
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
var event = new EntityDamageByEntityEvent(a.player, target, EntityDamageEvent.DamageCause.ENTITY_ATTACK,
source, (Double) call.getArgument(0)); event.setCancelled(true); f.dispatcher.accept(event); return null;
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
a.core.inventory.getInventory().setSelectedSlot(2);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); verify(target, never()).setVelocity(any(Vector.class));
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("shield_bash"));
}
}
@Test void cancelledPlayerVelocityPreservesMovementAfterAcceptedDamage() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
var b = new FantasyStickLifecycleNativeTest.Actor(data); withShieldBash(data, a, true);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player); f.activate(a); f.activate(b);
doReturn(new Location(a.world, 0, 64, 0)).when(a.player).getLocation();
doReturn(new Location(a.world, 2, 64, 0)).when(b.player).getLocation();
doReturn(true).when(a.player).hasLineOfSight(b.player); doReturn(new Vector(0.1, 0.2, 0)).when(b.player).getVelocity();
class Veto implements org.bukkit.event.Listener {
@org.bukkit.event.EventHandler public void velocity(org.bukkit.event.player.PlayerVelocityEvent event) {
if (event.getPlayer() == b.player) event.setCancelled(true);
}
}
f.dispatcher.register(new Veto(), f.plugin);
doAnswer(call -> {
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
f.dispatcher.accept(new EntityDamageByEntityEvent(a.player, b.player, EntityDamageEvent.DamageCause.ENTITY_ATTACK,
source, (Double) call.getArgument(0))); return null;
}).when(b.player).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
a.core.inventory.getInventory().setSelectedSlot(2);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, b.player, true));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); verify(b.player, never()).setVelocity(any(Vector.class));
}
}
@Test void unlearnRelearnAndCharacterTransferRetainCooldown() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "shield-bash-cooldown-seconds: 60\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
withShieldBash(data, a, true); f.players.put(a.core.id, a.player); f.activate(a);
var target = mock(org.bukkit.entity.LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
doReturn(new Location(a.world, 0, 64, 0)).when(a.player).getLocation();
when(target.getLocation()).thenReturn(new Location(a.world, 2, 64, 0)); doReturn(true).when(a.player).hasLineOfSight(target);
when(target.getVelocity()).thenReturn(new Vector()); var hits = new java.util.concurrent.atomic.AtomicInteger();
doAnswer(call -> { hits.incrementAndGet(); var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
f.dispatcher.accept(new EntityDamageByEntityEvent(a.player, target, EntityDamageEvent.DamageCause.ENTITY_ATTACK,
source, (Double) call.getArgument(0))); return null; }).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
a.core.inventory.getInventory().setSelectedSlot(2);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); assertEquals(1, hits.get());
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var deadline = storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("shield_bash");
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
var unlearn = abilityButton(a, "[Unlearn]", "shield_bash");
PartyBookNativeTest.click(f, a, unlearn); PartyBookNativeTest.click(f, a, unlearn);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var refunded = storage.find(a.core.record.id()).get().orElseThrow();
assertEquals(BigInteger.valueOf(40), refunded.unspentPoints()); assertEquals(deadline, refunded.cooldowns().get("shield_bash"));
learnAndAssign(f, a); a.core.inventory.getInventory().setSelectedSlot(2);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
PartyBookNativeTest.drain(f); assertEquals(1, hits.get());
var sheet = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
var b = new FantasyStickLifecycleNativeTest.Actor(data); b.core.inventory.setItemInOffHand(sheet);
f.players.put(b.core.id, b.player); f.activate(b); b.core.inventory.getInventory().setSelectedSlot(2);
doReturn(new Location(a.world, 0, 64, 0)).when(b.player).getLocation(); doReturn(true).when(b.player).hasLineOfSight(target);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(b.player, target, true));
PartyBookNativeTest.drain(f); assertEquals(1, hits.get()); assertEquals(deadline,
storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("shield_bash"));
}
}
@Test void exactDamageFormulaSupportsFiniteUncappedStats() {
assertEquals(BigInteger.valueOf(5), games.dmg.fantasystick.ability.ShieldBash.damage(BigInteger.valueOf(12)));
assertEquals(BigInteger.valueOf(3), games.dmg.fantasystick.ability.ShieldBash.damage(BigInteger.valueOf(9)));
var huge = BigInteger.TEN.pow(500);
assertEquals(huge.subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(4)),
games.dmg.fantasystick.ability.ShieldBash.damage(huge));
}
@Test void failedCooldownPersistenceDealsNoDamageOrKnockback() {
var storage=mock(CharacterStorage.class);var sessions=mock(games.dmg.fantasystick.session.EquipmentSessions.class);
var parties=new games.dmg.fantasystick.party.PartyRoster(java.time.Clock.systemUTC(),ignored->true);
var registry=new games.dmg.fantasystick.ability.AbilityRegistry();var bash=new games.dmg.fantasystick.ability.ShieldBash(
games.dmg.fantasystick.ability.ShieldBash.Settings.defaults(),ignored->{});registry.register(bash.definition());
var base=new games.dmg.fantasystick.crafting.CharacterFactory().create(games.dmg.fantasystick.progression.ProgressionRules.defaults());
var stats=new EnumMap<>(base.stats());stats.put(Stat.STR,BigInteger.valueOf(12));stats.put(Stat.CON,BigInteger.valueOf(12));
var record=new CharacterRecord(base.id(),base.name(),base.revision(),stats,base.level(),base.experience(),base.unspentPoints(),base.health(),
java.util.Set.of("shield_bash"),List.of("shield_bash","","","","","",""),base.cooldowns(),base.restrictedKills(),false,null,null);
var active=new games.dmg.fantasystick.session.EquipmentSessions.Active(java.util.UUID.randomUUID(),java.util.UUID.randomUUID(),record.id());
var player=mock(org.bukkit.entity.Player.class);when(player.getUniqueId()).thenReturn(active.player());
var world=mock(org.bukkit.World.class);doReturn(new Location(world,0,64,0)).when(player).getLocation();
var target=mock(org.bukkit.entity.LivingEntity.class);when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(target.getLocation()).thenReturn(new Location(world,2,64,0));
doReturn(true).when(player).hasLineOfSight(target);when(sessions.current(active)).thenReturn(true);
when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record));
var failed=new java.util.concurrent.CompletableFuture<CharacterStorage.AbilityMutation>();
failed.completeExceptionally(new java.io.IOException("simulated cooldown failure"));
when(storage.commitAbilityCooldown(eq(record.id()),eq(record.revision()),eq("shield_bash"),any())).thenReturn(failed);
var engine=new games.dmg.fantasystick.ability.AbilityEngine(registry,storage,sessions,parties,java.time.Clock.systemUTC(),Runnable::run,
(delay,action)->()->{},ignored->{},ignored->{});
engine.accept(new games.dmg.fantasystick.session.ControlInput(player,active,2,"shield_bash",
games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,games.dmg.fantasystick.session.ControlInput.Gesture.ATTACK,
target,0,1,()->true));
verify(storage).commitAbilityCooldown(eq(record.id()),eq(record.revision()),eq("shield_bash"),any());
verify(target,never()).damage(anyDouble(),any(org.bukkit.damage.DamageSource.class));verify(target,never()).setVelocity(any(Vector.class));
engine.close();bash.close();parties.close();
}
@Test void configuredDefinitionAdminRoutesRestartAndFutureStrikeCoverEverySetting() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"shield-bash-base-damage: 8\nshield-bash-cooldown-seconds: 0\nshield-bash-range-blocks: 5\n" +
"shield-bash-horizontal-knockback: 1.2\nshield-bash-minimum-vertical-knockback: 0.5\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
var definition = registry.find("shield_bash").orElseThrow(); assertEquals(java.time.Duration.ZERO, definition.cooldown());
assertEquals(0, new java.math.BigDecimal("8").compareTo(definition.parameters().get("base-damage")));
assertEquals(0, new java.math.BigDecimal("5").compareTo(definition.parameters().get("range")));
assertEquals(0, new java.math.BigDecimal("1.2").compareTo(definition.parameters().get("horizontal-knockback")));
assertEquals(0, new java.math.BigDecimal("0.5").compareTo(definition.parameters().get("minimum-vertical-knockback")));
var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed=false;
assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","shield-bash"}).isEmpty());
f.command.execute(sender.port,"fantasystick",new String[]{"config","shield-bash-base-damage","9"});
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed=true;
assertEquals(List.of("shield-bash-base-damage","shield-bash-cooldown-seconds","shield-bash-horizontal-knockback",
"shield-bash-minimum-vertical-knockback","shield-bash-range-blocks"),
f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","shield-bash-"}));
assertEquals(List.of("4","8"),f.command.tabComplete(sender.port,"fantasystick",
new String[]{"config","shield-bash-base-damage",""}));
f.command.execute(sender.port,"fantasystick",new String[]{"config","shield-bash-base-damage"});
assertTrue(sender.messages.getLast().contains("=8"));
f.command.execute(sender.port,"fantasystick",new String[]{"config","shield-bash-base-damage","9"});
var response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();
assertEquals(0,new java.math.BigDecimal("9").compareTo(registry.find("shield_bash").orElseThrow().parameters().get("base-damage")));
var a=new FantasyStickLifecycleNativeTest.Actor(data);withShieldBash(data,a,true);f.players.put(a.core.id,a.player);f.activate(a);
var target=mock(org.bukkit.entity.LivingEntity.class);when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
doReturn(new Location(a.world,0,64,0)).when(a.player).getLocation();when(target.getLocation()).thenReturn(new Location(a.world,4,64,0));
doReturn(true).when(a.player).hasLineOfSight(target);when(target.getVelocity()).thenReturn(new Vector());
var amount=new java.util.concurrent.atomic.AtomicReference<Double>();var movement=new java.util.concurrent.atomic.AtomicReference<Vector>();
doAnswer(call->{amount.set(call.getArgument(0));var source=(org.bukkit.damage.DamageSource)call.getArgument(1);
f.dispatcher.accept(new EntityDamageByEntityEvent(a.player,target,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,(Double)call.getArgument(0)));return null;
}).when(target).damage(anyDouble(),any(org.bukkit.damage.DamageSource.class));
doAnswer(call->{movement.set(call.getArgument(0));return null;}).when(target).setVelocity(any(Vector.class));
a.core.inventory.getInventory().setSelectedSlot(2);f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player,target,true));
PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(10,amount.get(),1.0e-9);assertEquals(new Vector(1.2,0.5,0),movement.get());
f.command.execute(sender.port,"fantasystick",new String[]{"config","shield-bash-base-damage","-1"});
assertTrue(sender.messages.getLast().contains("invalid"));
f.command.execute(sender.port,"fantasystick",new String[]{"config","shield-bash-base-damage","9","extra"});
assertTrue(sender.messages.getLast().contains("Usage"));
f.plugin.setEnabled(false);f.plugin.setEnabled(true);var reloaded=(games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");
assertEquals(0,new java.math.BigDecimal("9").compareTo(reloaded.find("shield_bash").orElseThrow().parameters().get("base-damage")));
}
for(String invalid:List.of("shield-bash-base-damage: -1\n","shield-bash-cooldown-seconds: -1\n",
"shield-bash-range-blocks: 0\n","shield-bash-horizontal-knockback: 9\n",
"shield-bash-minimum-vertical-knockback: 9\n")){
var directory=data.resolve(Integer.toHexString(invalid.hashCode()));java.nio.file.Files.createDirectories(directory);
java.nio.file.Files.writeString(directory.resolve("config.yml"),invalid);
try(var f=new FantasyStickLifecycleNativeTest.Fixture(directory)){
assertThrows(IllegalArgumentException.class,()->f.plugin.setEnabled(true));assertTrue(f.order.isEmpty());
}
}
}
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[Learn]", "shield_bash"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "shield_bash"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
}
private static ClickEvent.Custom abilityButton(FantasyStickLifecycleNativeTest.Actor actor, String label, String ability) {
var book = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT); assertNotNull(book);
for (var page : book.pages()) { var found = abilityButton(page.raw(), label, ability); if (found != null) return found; }
throw new AssertionError("Missing " + label + " for " + ability);
}
private static ClickEvent.Custom abilityButton(net.minecraft.network.chat.Component component, String label, String ability) {
if (component.getString().equals(label) && component.getStyle().getClickEvent() instanceof ClickEvent.Custom custom
&& custom.payload().orElse(null) instanceof net.minecraft.nbt.StringTag payload
&& payload.value().contains(":" + ability)) return custom;
for (var child : component.getSiblings()) { var found = abilityButton(child, label, ability); if (found != null) return found; }
return null;
}
private static void withShieldBash(Path data, FantasyStickLifecycleNativeTest.Actor actor, boolean learned) throws Exception {
java.nio.file.Files.createDirectories(data);
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(actor.core.record.id()).orElseThrow(); var stats = new EnumMap<>(old.stats());
stats.put(Stat.STR, BigInteger.valueOf(12)); stats.put(Stat.CON, BigInteger.valueOf(12));
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
old.level(), old.experience(), BigInteger.valueOf(learned ? 39 : 40), old.health(),
learned ? java.util.Set.of("shield_bash") : old.learned(),
learned ? List.of("shield_bash", "", "", "", "", "", "") : old.loadout(),
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
}
}
@@ -0,0 +1,294 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.Stat;
import java.math.BigInteger;
import java.nio.file.Path;
import java.util.EnumMap;
import java.util.List;
import net.minecraft.network.chat.ClickEvent;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class StoneSkinNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void bookAcquisitionEquippedUseReducesNativeEntityDamageForFourSeconds() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStoneSkin(data, a, false);
f.players.put(a.core.id, a.player); f.activate(a);
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
String text = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
assertTrue(text.contains("StoneSkin")); assertTrue(text.contains("CON14"));
assertTrue(text.contains("Cost:1")); assertTrue(text.contains("25%")); assertTrue(text.contains("4s"));
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "stone_skin"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "stone_skin"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertEquals(BigInteger.valueOf(39), storage.find(a.core.record.id()).get().orElseThrow().unspentPoints());
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("stone_skin"));
var attacker = mock(org.bukkit.entity.Zombie.class);
var hit = new EntityDamageByEntityEvent(attacker, a.player,
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(org.bukkit.damage.DamageSource.class), 8);
f.dispatcher.accept(hit); assertEquals(6, hit.getDamage(), 1.0e-9);
assertEquals(80L, f.delayedTicks.getLast());
f.delayed.getLast().run();
var expired = new EntityDamageByEntityEvent(attacker, a.player,
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(org.bukkit.damage.DamageSource.class), 8);
f.dispatcher.accept(expired); assertEquals(8, expired.getDamage(), 1.0e-9);
var deadline = storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("stone_skin");
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
var unlearn = abilityButton(a, "[Unlearn]", "stone_skin");
PartyBookNativeTest.click(f, a, unlearn); PartyBookNativeTest.click(f, a, unlearn);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var refunded = storage.find(a.core.record.id()).get().orElseThrow();
assertEquals(BigInteger.valueOf(40), refunded.unspentPoints()); assertEquals(deadline, refunded.cooldowns().get("stone_skin"));
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "stone_skin"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "stone_skin"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); use(f, a);
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
}
}
@Test void environmentalAndCancelledDamageRemainUntouchedWhileProjectileCombatIsReduced() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStoneSkin(data, a, true);
f.players.put(a.core.id, a.player); f.activate(a); use(f, a);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var fall = new EntityDamageEvent(a.player, EntityDamageEvent.DamageCause.FALL,
mock(org.bukkit.damage.DamageSource.class), 8);
f.dispatcher.accept(fall); assertEquals(8, fall.getDamage(), 1.0e-9);
var arrow = mock(org.bukkit.entity.Arrow.class);
var projectile = new EntityDamageByEntityEvent(arrow, a.player,
EntityDamageEvent.DamageCause.PROJECTILE, mock(org.bukkit.damage.DamageSource.class), 8);
f.dispatcher.accept(projectile); assertEquals(6, projectile.getDamage(), 1.0e-9);
class Veto implements org.bukkit.event.Listener {
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.LOWEST)
public void veto(EntityDamageByEntityEvent event) { event.setCancelled(true); }
}
f.dispatcher.register(new Veto(), f.plugin);
var cancelled = new EntityDamageByEntityEvent(mock(org.bukkit.entity.Zombie.class), a.player,
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(org.bukkit.damage.DamageSource.class), 8);
f.dispatcher.accept(cancelled); assertTrue(cancelled.isCancelled()); assertEquals(8, cancelled.getDamage(), 1.0e-9);
}
}
@Test void replacementExpirySessionExitAndTransferRemoveOnlyOwnedReductionAndRetainDeadline() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"stone-skin-cooldown-seconds: 0\nstone-skin-duration-seconds: 4\nstone-skin-damage-reduction-percent: 25\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStoneSkin(data, a, true);
f.players.put(a.core.id, a.player); f.activate(a); use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(2, f.delayed.size());
f.delayed.getFirst().run();
assertEquals(6, hit(f, a, 8).getDamage(), 1.0e-9, "Old expiry cannot remove replacement protection");
f.delayed.getLast().run();
assertEquals(8, hit(f, a, 8).getDamage(), 1.0e-9);
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var deadline = storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("stone_skin");
var sheet = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
assertEquals(8, hit(f, a, 8).getDamage(), 1.0e-9, "Exit clears owned reduction");
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("stone_skin"));
var b = new FantasyStickLifecycleNativeTest.Actor(data); b.core.inventory.setItemInOffHand(sheet);
f.players.put(b.core.id, b.player); f.activate(b);
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("stone_skin"));
}
}
@Test void acknowledgedConfigurationAffectsFutureCastsWhileActiveProtectionKeepsSnapshot() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"stone-skin-cooldown-seconds: 0\nstone-skin-duration-seconds: 4\nstone-skin-damage-reduction-percent: 25\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStoneSkin(data, a, true);
f.players.put(a.core.id, a.player); f.activate(a); use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var sender = new EquipmentAdminCommandNativeTest.Sender();
f.command.execute(sender.port, "fantasystick", new String[]{"config", "stone-skin-duration-seconds", "6"});
f.command.execute(sender.port, "fantasystick", new String[]{"config", "stone-skin-damage-reduction-percent", "50"});
for (int i = 0; i < 2; i++) { var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run(); }
assertEquals(6, hit(f, a, 8).getDamage(), 1.0e-9, "Active cast retains captured 25 percent");
assertEquals(80L, f.delayedTicks.getFirst());
var b = new FantasyStickLifecycleNativeTest.Actor(data); withStoneSkin(data, b, true);
f.players.put(b.core.id, b.player); f.activate(b); use(f, b); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(4, hit(f, b, 8).getDamage(), 1.0e-9); assertEquals(120L, f.delayedTicks.getLast());
}
}
@Test void acceptedDamageReportsExactPreventionWhileCancelledDamageReportsNothing() {
var mitigation = new games.dmg.fantasystick.ability.CombatMitigation();
var stone = new games.dmg.fantasystick.ability.StoneSkin(
games.dmg.fantasystick.ability.StoneSkin.Settings.defaults(), mitigation);
var execution = mock(games.dmg.fantasystick.ability.AbilityExecution.class);
var input = mock(games.dmg.fantasystick.session.ControlInput.class);
var player = mock(org.bukkit.entity.Player.class); var id = java.util.UUID.randomUUID();
when(player.getUniqueId()).thenReturn(id); when(input.player()).thenReturn(player);
when(execution.input()).thenReturn(input); when(execution.definition()).thenReturn(stone.definition());
when(execution.stillCurrent()).thenReturn(true);
stone.activate(execution);
var weaker = mock(games.dmg.fantasystick.ability.AbilityExecution.class);
when(weaker.stillCurrent()).thenReturn(true);
var unrelated = mitigation.grant(weaker, player, new java.math.BigDecimal("20"));
var accepted = new EntityDamageByEntityEvent(mock(org.bukkit.entity.Zombie.class), player,
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(org.bukkit.damage.DamageSource.class), 8);
mitigation.reduce(accepted); mitigation.accepted(accepted);
verify(execution).report(games.dmg.fantasystick.ability.AbilityReport.Type.PREVENTION,
player, new java.math.BigDecimal("2.0"));
var cancelled = new EntityDamageByEntityEvent(mock(org.bukkit.entity.Zombie.class), player,
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(org.bukkit.damage.DamageSource.class), 8);
mitigation.reduce(cancelled); cancelled.setCancelled(true); mitigation.accepted(cancelled);
verify(execution, times(1)).report(any(), any(), any());
verify(weaker, never()).report(any(), any(), any());
try { unrelated.close(); } catch (Exception failure) { fail(failure); }
stone.close(); mitigation.close();
}
@Test void failedCooldownPersistenceCreatesNoReduction() {
var storage = mock(CharacterStorage.class);
var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class);
var parties = new games.dmg.fantasystick.party.PartyRoster(java.time.Clock.systemUTC(), ignored -> true);
var registry = new games.dmg.fantasystick.ability.AbilityRegistry();
var mitigation = new games.dmg.fantasystick.ability.CombatMitigation();
var stone = new games.dmg.fantasystick.ability.StoneSkin(
games.dmg.fantasystick.ability.StoneSkin.Settings.defaults(), mitigation); registry.register(stone.definition());
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var stats = new EnumMap<>(base.stats()); stats.put(Stat.CON, BigInteger.valueOf(14));
var record = new CharacterRecord(base.id(), base.name(), base.revision(), stats, base.level(), base.experience(),
base.unspentPoints(), base.health(), java.util.Set.of("stone_skin"),
List.of("stone_skin", "", "", "", "", "", ""), base.cooldowns(), base.restrictedKills(), false, null, null);
var active = new games.dmg.fantasystick.session.EquipmentSessions.Active(java.util.UUID.randomUUID(),
java.util.UUID.randomUUID(), record.id());
var player = mock(org.bukkit.entity.Player.class); when(player.getUniqueId()).thenReturn(active.player());
when(sessions.current(active)).thenReturn(true); when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record));
var failed = new java.util.concurrent.CompletableFuture<CharacterStorage.AbilityMutation>();
failed.completeExceptionally(new java.io.IOException("simulated cooldown save failure"));
when(storage.commitAbilityCooldown(eq(record.id()), eq(record.revision()), eq("stone_skin"), any())).thenReturn(failed);
var failures = new java.util.ArrayList<games.dmg.fantasystick.ability.AbilityEngine.Failure>();
var engine = new games.dmg.fantasystick.ability.AbilityEngine(registry, storage, sessions, parties,
java.time.Clock.systemUTC(), Runnable::run, (delay, action) -> () -> {}, ignored -> {}, failures::add);
engine.accept(new games.dmg.fantasystick.session.ControlInput(player, active, 2, "stone_skin",
games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,
games.dmg.fantasystick.session.ControlInput.Gesture.USE, null, 0, 0, () -> true));
var hit = new EntityDamageByEntityEvent(mock(org.bukkit.entity.Zombie.class), player,
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(org.bukkit.damage.DamageSource.class), 8);
mitigation.reduce(hit); assertEquals(8, hit.getDamage(), 1.0e-9);
verify(storage).commitAbilityCooldown(eq(record.id()), eq(record.revision()), eq("stone_skin"), any());
engine.close(); stone.close(); mitigation.close(); parties.close();
}
@Test void configuredDefinitionAdminRoutesAndStartupValidationCoverEverySetting() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"stone-skin-cooldown-seconds: 2\nstone-skin-duration-seconds: 6\nstone-skin-damage-reduction-percent: 40\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
var definition = registry.find("stone_skin").orElseThrow();
assertEquals(java.time.Duration.ofSeconds(2), definition.cooldown());
assertEquals(0, new java.math.BigDecimal("6").compareTo(definition.parameters().get("duration-seconds")));
assertEquals(0, new java.math.BigDecimal("40").compareTo(definition.parameters().get("damage-reduction-percent")));
var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed = false;
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "stone-skin"}).isEmpty());
f.command.execute(sender.port, "fantasystick", new String[]{"config", "stone-skin-duration-seconds", "7"});
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
assertEquals(List.of("stone-skin-cooldown-seconds", "stone-skin-damage-reduction-percent", "stone-skin-duration-seconds"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "stone-skin-"}));
assertEquals(List.of("4", "6"), f.command.tabComplete(sender.port, "fantasystick",
new String[]{"config", "stone-skin-duration-seconds", ""}));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "stone-skin-duration-seconds"});
assertTrue(sender.messages.getLast().contains("=6"));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "stone-skin-duration-seconds", "7"});
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
assertEquals(0, new java.math.BigDecimal("7").compareTo(
registry.find("stone_skin").orElseThrow().parameters().get("duration-seconds")));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "stone-skin-duration-seconds", "-1"});
assertTrue(sender.messages.getLast().contains("invalid"));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "stone-skin-duration-seconds", "7", "extra"});
assertTrue(sender.messages.getLast().contains("Usage"));
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
var reloaded = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
assertEquals(0, new java.math.BigDecimal("7").compareTo(
reloaded.find("stone_skin").orElseThrow().parameters().get("duration-seconds")));
}
for (String invalid : List.of("stone-skin-cooldown-seconds: -1\n",
"stone-skin-duration-seconds: 0\n", "stone-skin-damage-reduction-percent: 101\n")) {
var directory = data.resolve(Integer.toHexString(invalid.hashCode())); java.nio.file.Files.createDirectories(directory);
java.nio.file.Files.writeString(directory.resolve("config.yml"), invalid);
try (var f = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
assertThrows(IllegalArgumentException.class, () -> f.plugin.setEnabled(true)); assertTrue(f.order.isEmpty());
}
}
}
private static EntityDamageByEntityEvent hit(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor target, double amount) {
var event = new EntityDamageByEntityEvent(mock(org.bukkit.entity.Zombie.class), target.player,
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(org.bukkit.damage.DamageSource.class), amount);
fixture.dispatcher.accept(event); return event;
}
private static void use(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor) {
actor.core.inventory.getInventory().setSelectedSlot(2);
fixture.dispatcher.accept(new PlayerInteractEvent(actor.player,
org.bukkit.event.block.Action.RIGHT_CLICK_AIR, actor.core.inventory.getItem(2), null,
org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND));
}
private static ClickEvent.Custom abilityButton(FantasyStickLifecycleNativeTest.Actor actor,
String label, String ability) {
var book = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
assertNotNull(book);
for (var page : book.pages()) {
var found = abilityButton(page.raw(), label, ability);
if (found != null) return found;
}
fail("Missing " + label + " for " + ability); return null;
}
private static ClickEvent.Custom abilityButton(net.minecraft.network.chat.Component component,
String label, String ability) {
if (component.getString().equals(label)
&& component.getStyle().getClickEvent() instanceof ClickEvent.Custom custom
&& custom.payload().orElse(null) instanceof net.minecraft.nbt.StringTag payload
&& payload.value().contains(":" + ability)) return custom;
for (var child : component.getSiblings()) {
var found = abilityButton(child, label, ability); if (found != null) return found;
}
return null;
}
private static void withStoneSkin(Path data, FantasyStickLifecycleNativeTest.Actor actor,
boolean learned) throws Exception {
java.nio.file.Files.createDirectories(data);
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(actor.core.record.id()).orElseThrow();
var stats = new EnumMap<>(old.stats()); stats.put(Stat.CON, BigInteger.valueOf(14));
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
old.level(), old.experience(), BigInteger.valueOf(learned ? 39 : 40), old.health(),
learned ? java.util.Set.of("stone_skin") : old.learned(),
learned ? List.of("stone_skin", "", "", "", "", "", "") : old.loadout(),
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
}
}
@@ -60,6 +60,19 @@ class DistributionTest {
"quickstep-distance-blocks: 4", "quickstep-speed-blocks-per-tick: 1")) {
assertTrue(defaults.contains(setting), "Missing packaged Quickstep default: " + setting);
}
for (String setting : java.util.List.of("second-wind-base-healing: 6",
"second-wind-cooldown-seconds: 15")) {
assertTrue(defaults.contains(setting), "Missing packaged Second Wind default: " + setting);
}
for (String setting : java.util.List.of("stone-skin-cooldown-seconds: 14",
"stone-skin-duration-seconds: 4", "stone-skin-damage-reduction-percent: 25")) {
assertTrue(defaults.contains(setting), "Missing packaged Stone Skin default: " + setting);
}
for (String setting : java.util.List.of("shield-bash-base-damage: 4", "shield-bash-cooldown-seconds: 6",
"shield-bash-range-blocks: 3", "shield-bash-horizontal-knockback: 0.8",
"shield-bash-minimum-vertical-knockback: 0.35")) {
assertTrue(defaults.contains(setting), "Missing packaged Shield Bash default: " + setting);
}
assertTrue(JavaPlugin.class.isAssignableFrom(FantasyStickPlugin.class));
var entrypoint = jar.getJarEntry(metadata.getMain().replace('.', '/') + ".class");
assertNotNull(entrypoint);
@@ -0,0 +1,70 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class SecondWindSettingsTest {
@TempDir Path data;
@Test void serializesConcurrentWritesPreservesUnrelatedAndPublishesAcknowledgedSnapshots() throws Exception {
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
try (var settings = new SecondWindSettings(data, defaults())) {
var healing = settings.set(SecondWindSettings.BASE_HEALING, "9").toCompletableFuture();
var cooldown = settings.set(SecondWindSettings.COOLDOWN, "20").toCompletableFuture();
var h = healing.get(5, TimeUnit.SECONDS); var c = cooldown.get(5, TimeUnit.SECONDS);
assertEquals("6", settings.value(SecondWindSettings.BASE_HEALING)); settings.publish(c); settings.publish(h);
assertEquals("9", settings.value(SecondWindSettings.BASE_HEALING));
assertEquals(java.time.Duration.ofSeconds(20), settings.settings().cooldown());
var saved = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
assertEquals("retained", saved.get("unrelated")); assertEquals("9", String.valueOf(saved.get(SecondWindSettings.BASE_HEALING)));
}
}
@Test void rejectsInvalidValuesAndFailedReplacementRetainsState() throws Exception {
try (var settings = new SecondWindSettings(data, defaults())) {
assertThrows(IllegalArgumentException.class, () -> settings.set(SecondWindSettings.BASE_HEALING, "-1"));
assertThrows(IllegalArgumentException.class, () -> settings.set(SecondWindSettings.BASE_HEALING, "1.5"));
assertThrows(IllegalArgumentException.class, () -> settings.set(SecondWindSettings.COOLDOWN, "-1"));
assertThrows(IllegalArgumentException.class, () -> settings.set(SecondWindSettings.COOLDOWN, "0.0000000001"));
}
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
try (var settings = new SecondWindSettings(data, defaults(), (from, to) -> {
if (fail.getAndSet(false)) throw new java.io.IOException("simulated");
Files.move(from, to, StandardCopyOption.REPLACE_EXISTING);
})) {
assertThrows(java.util.concurrent.ExecutionException.class, () ->
settings.set(SecondWindSettings.BASE_HEALING, "9").toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals("6", settings.value(SecondWindSettings.BASE_HEALING));
}
}
@Test void rejectsUnsafeFileAndPostCloseWrites() throws Exception {
if (!System.getProperty("os.name", "").toLowerCase().contains("win")) {
var external = data.resolveSibling(data.getFileName() + "-outside"); Files.writeString(external, "safe\n");
Files.createSymbolicLink(data.resolve("config.yml"), external);
try (var settings = new SecondWindSettings(data, defaults())) {
assertThrows(java.util.concurrent.ExecutionException.class, () ->
settings.set(SecondWindSettings.BASE_HEALING, "9").toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals("safe\n", Files.readString(external));
}
}
var other = data.resolve("closed"); var settings = new SecondWindSettings(other, defaults()); settings.close();
assertThrows(java.util.concurrent.ExecutionException.class, () ->
settings.set(SecondWindSettings.BASE_HEALING, "9").toCompletableFuture().get(5, TimeUnit.SECONDS));
settings.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
}
private static Map<String, String> defaults() {
var result = new LinkedHashMap<String, String>();
for (String key : SecondWindSettings.keys()) result.put(key, SecondWindSettings.defaultValue(key));
return result;
}
}
@@ -0,0 +1,57 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ShieldBashSettingsTest {
@TempDir Path data;
@Test void serializesConcurrentKeysPreservesUnrelatedAndPublishesAcknowledgedSnapshots() throws Exception {
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
try (var settings = new ShieldBashSettings(data, defaults())) {
var range = settings.set(ShieldBashSettings.RANGE, "5").toCompletableFuture();
var horizontal = settings.set(ShieldBashSettings.HORIZONTAL, "1.2").toCompletableFuture();
var r = range.get(5, TimeUnit.SECONDS); var h = horizontal.get(5, TimeUnit.SECONDS);
assertEquals(3, settings.settings().range(), 0); settings.publish(h); settings.publish(r);
assertEquals(5, settings.settings().range(), 0); assertEquals(1.2, settings.settings().horizontalKnockback(), 0);
var saved = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
assertEquals("retained", saved.get("unrelated")); assertEquals("5", String.valueOf(saved.get(ShieldBashSettings.RANGE)));
assertEquals("1.2", String.valueOf(saved.get(ShieldBashSettings.HORIZONTAL)));
}
}
@Test void rejectsInvalidValuesAndFailedReplacementRetainsState() throws Exception {
try (var settings = new ShieldBashSettings(data, defaults())) {
for (var entry : Map.of(ShieldBashSettings.BASE_DAMAGE, "-1", ShieldBashSettings.COOLDOWN, "-1",
ShieldBashSettings.RANGE, "0", ShieldBashSettings.HORIZONTAL, "9",
ShieldBashSettings.VERTICAL, "Infinity").entrySet())
assertThrows(IllegalArgumentException.class, () -> settings.set(entry.getKey(), entry.getValue()));
assertThrows(IllegalArgumentException.class, () -> settings.set(ShieldBashSettings.COOLDOWN, "0.0000000001"));
assertThrows(IllegalArgumentException.class, () -> settings.set(ShieldBashSettings.BASE_DAMAGE, "1.5"));
}
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
try (var settings = new ShieldBashSettings(data, defaults(), (from,to) -> {
if (fail.getAndSet(false)) throw new java.io.IOException("simulated"); Files.move(from,to,StandardCopyOption.REPLACE_EXISTING);
})) {
assertThrows(java.util.concurrent.ExecutionException.class, () ->
settings.set(ShieldBashSettings.RANGE, "5").toCompletableFuture().get(5,TimeUnit.SECONDS));
assertEquals("3", settings.value(ShieldBashSettings.RANGE));
}
}
@Test void unsafeFileAndClosedStoreFailSafely() throws Exception {
if (!System.getProperty("os.name","").toLowerCase().contains("win")) {
var external=data.resolveSibling(data.getFileName()+"-outside"); Files.writeString(external,"safe\n");
Files.createSymbolicLink(data.resolve("config.yml"),external);
try(var settings=new ShieldBashSettings(data,defaults())) {
assertThrows(java.util.concurrent.ExecutionException.class,()->settings.set(ShieldBashSettings.RANGE,"5").toCompletableFuture().get(5,TimeUnit.SECONDS));
assertEquals("safe\n",Files.readString(external));
}
}
var settings=new ShieldBashSettings(data.resolve("closed"),defaults()); settings.close();
assertThrows(java.util.concurrent.ExecutionException.class,()->settings.set(ShieldBashSettings.RANGE,"5").toCompletableFuture().get(5,TimeUnit.SECONDS));
settings.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);
}
private static Map<String,String> defaults(){var result=new LinkedHashMap<String,String>();for(String key:ShieldBashSettings.keys())result.put(key,ShieldBashSettings.defaultValue(key));return result;}
}
@@ -0,0 +1,69 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class StoneSkinSettingsTest {
@TempDir Path data;
@Test void serializesConcurrentKeysPreservesUnrelatedAndPublishesAcknowledgedSnapshots() throws Exception {
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
try (var settings = new StoneSkinSettings(data, defaults())) {
var duration = settings.set(StoneSkinSettings.DURATION, "6").toCompletableFuture();
var reduction = settings.set(StoneSkinSettings.REDUCTION, "40").toCompletableFuture();
var d = duration.get(5, TimeUnit.SECONDS); var r = reduction.get(5, TimeUnit.SECONDS);
assertEquals(java.time.Duration.ofSeconds(4), settings.settings().duration());
settings.publish(r); settings.publish(d);
assertEquals(java.time.Duration.ofSeconds(6), settings.settings().duration());
assertEquals(0, new java.math.BigDecimal("40").compareTo(settings.settings().reductionPercent()));
var saved = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
assertEquals("retained", saved.get("unrelated"));
assertEquals("6", String.valueOf(saved.get(StoneSkinSettings.DURATION)));
assertEquals("40", String.valueOf(saved.get(StoneSkinSettings.REDUCTION)));
}
}
@Test void rejectsInvalidValuesAndFailedReplacementRetainsState() throws Exception {
try (var settings = new StoneSkinSettings(data, defaults())) {
for (var entry : Map.of(StoneSkinSettings.COOLDOWN, "-1", StoneSkinSettings.DURATION, "0",
StoneSkinSettings.REDUCTION, "101").entrySet())
assertThrows(IllegalArgumentException.class, () -> settings.set(entry.getKey(), entry.getValue()));
assertThrows(IllegalArgumentException.class, () -> settings.set(StoneSkinSettings.DURATION, "0.0000000001"));
}
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
try (var settings = new StoneSkinSettings(data, defaults(), (from, to) -> {
if (fail.getAndSet(false)) throw new java.io.IOException("simulated");
Files.move(from, to, StandardCopyOption.REPLACE_EXISTING);
})) {
assertThrows(java.util.concurrent.ExecutionException.class, () ->
settings.set(StoneSkinSettings.DURATION, "6").toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals("4", settings.value(StoneSkinSettings.DURATION));
}
}
@Test void unsafeFileAndClosedStoreFailSafely() throws Exception {
if (!System.getProperty("os.name", "").toLowerCase().contains("win")) {
var external = data.resolveSibling(data.getFileName() + "-outside"); Files.writeString(external, "safe\n");
Files.createSymbolicLink(data.resolve("config.yml"), external);
try (var settings = new StoneSkinSettings(data, defaults())) {
assertThrows(java.util.concurrent.ExecutionException.class, () ->
settings.set(StoneSkinSettings.DURATION, "6").toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals("safe\n", Files.readString(external));
}
}
var directory = data.resolve("closed"); var settings = new StoneSkinSettings(directory, defaults()); settings.close();
assertThrows(java.util.concurrent.ExecutionException.class, () ->
settings.set(StoneSkinSettings.DURATION, "6").toCompletableFuture().get(5, TimeUnit.SECONDS));
settings.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
}
private static Map<String,String> defaults() {
var result = new LinkedHashMap<String,String>();
for (String key : StoneSkinSettings.keys()) result.put(key, StoneSkinSettings.defaultValue(key));
return result;
}
}