Compare commits

...
4 Commits
Author SHA1 Message Date
dmg 4abaeecf63 feat(ability): add purify
Release / release (push) Successful in 11m36s
2026-09-20 17:00:29 -04:00
dmg 5a22dc343a feat(ability): add mending light
Release / release (push) Successful in 7m41s
2026-09-20 16:42:45 -04:00
dmg db1c7b5906 test(release): verify versioned pack URL
Release / release (push) Successful in 7m50s
2026-09-20 15:56:46 -04:00
dmg 9ab5bffd2c feat(admin): grant fantasy character experience 2026-09-20 15:53:58 -04:00
28 changed files with 2146 additions and 16 deletions

No files matched your search

@@ -68,6 +68,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.ability.TemporaryHealth temporaryHealth;
private games.dmg.fantasystick.ability.Inspire inspire;
private games.dmg.fantasystick.persistence.InspireSettings inspireSettings;
private games.dmg.fantasystick.ability.MendingLight mendingLight;
private games.dmg.fantasystick.persistence.MendingLightSettings mendingLightSettings;
private games.dmg.fantasystick.ability.Purify purify;
private games.dmg.fantasystick.persistence.PurifySettings purifySettings;
private games.dmg.fantasystick.persistence.HexSettings hexSettings;
private games.dmg.fantasystick.persistence.ShieldBashSettings shieldBashSettings;
private games.dmg.fantasystick.persistence.StoneSkinSettings stoneSkinSettings;
@@ -134,6 +138,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
var hexInitial = games.dmg.fantasystick.persistence.HexSettings.parse(hexConfiguration);
var inspireConfiguration = inspireConfiguration();
var inspireInitial = games.dmg.fantasystick.persistence.InspireSettings.parse(inspireConfiguration);
var mendingLightConfiguration = mendingLightConfiguration();
var mendingLightInitial = games.dmg.fantasystick.persistence.MendingLightSettings.parse(mendingLightConfiguration);
var purifyConfiguration = purifyConfiguration();
var purifyInitial = games.dmg.fantasystick.persistence.PurifySettings.parse(purifyConfiguration);
var adminReviveTimeout = games.dmg.fantasystick.persistence.AdminReviveSettings.parse(
getConfig().get(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY));
var factory = new CharacterFactory();
@@ -223,6 +231,15 @@ public final class FantasyStickPlugin extends JavaPlugin {
support -> combatExperience.support(support.target(), support.supportingCharacter(), support.aided(), support.amount()));
inspireSettings = new games.dmg.fantasystick.persistence.InspireSettings(getDataFolder().toPath(), inspireConfiguration);
inspire = new games.dmg.fantasystick.ability.Inspire(inspireInitial, temporaryHealth);
mendingLightSettings = new games.dmg.fantasystick.persistence.MendingLightSettings(
getDataFolder().toPath(), mendingLightConfiguration);
mendingLight = new games.dmg.fantasystick.ability.MendingLight(mendingLightInitial, characters, sessions, parties,
nextTick, event -> getServer().getPluginManager().callEvent(event),
games.dmg.fantasystick.session.NativeHealthLimit::read,
support -> combatExperience.support(support.supportingCharacter(), support.aided(), support.amount()));
purifySettings = new games.dmg.fantasystick.persistence.PurifySettings(getDataFolder().toPath(), purifyConfiguration);
purify = new games.dmg.fantasystick.ability.Purify(purifyInitial, sessions, parties,
support -> combatExperience.support(support.supportingCharacter(), support.aided(), support.amount()));
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,
@@ -237,6 +254,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
abilityRegistry.register(shieldBash.definition());
abilityRegistry.register(hex.definition());
abilityRegistry.register(inspire.definition());
abilityRegistry.register(mendingLight.definition());
abilityRegistry.register(purify.definition());
abilityRegistry.register(greatSword.definition());
abilityRegistry.register(revive.definition());
sessions.attachAbilityRegistry(abilityRegistry);
@@ -279,7 +298,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
quickstepSettings, quickstep, secondWindSettings, secondWind,
stoneSkinSettings, stoneSkin, shieldBashSettings, shieldBash, hexSettings, hex,
inspireSettings, inspire, abilityRegistry);
admin.attachMendingLight(mendingLightSettings, mendingLight);
admin.attachPurify(purifySettings, purify);
admin.attachAdminRevival(adminRevival, adminReviveSettings);
admin.attachExperienceGrant(new games.dmg.fantasystick.session.AdminExperienceGrant(
characters, rules, sessions, nextTick, getServer()::getPlayer,
problem -> getLogger().log(Level.WARNING,
"Admin experience grant for character " + problem.character(), problem.cause())));
admin.attachSwitchDelay(switchDelaySettings);
admin.attachRoleplaySettings(roleplaySettings);
admin.attachPartySettings(partySettings);
@@ -374,6 +399,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingHex = hex; hex = null;
var closingInspire = inspire; inspire = null;
var closingInspireSettings = inspireSettings; inspireSettings = null;
mendingLight = null;
var closingMendingLightSettings = mendingLightSettings; mendingLightSettings = null;
purify = null;
var closingPurifySettings = purifySettings; purifySettings = null;
var closingAdminRevival = adminRevival; adminRevival = null;
var closingAdminReviveSettings = adminReviveSettings; adminReviveSettings = null;
var closingSwitchDelaySettings = switchDelaySettings; switchDelaySettings = null;
@@ -406,6 +435,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (closingShieldBashSettings != null) cleanup("Shield Bash configuration", closingShieldBashSettings::close);
if (closingHexSettings != null) cleanup("Hex configuration", closingHexSettings::close);
if (closingInspireSettings != null) cleanup("Inspire configuration", closingInspireSettings::close);
if (closingMendingLightSettings != null) cleanup("Mending Light configuration", closingMendingLightSettings::close);
if (closingPurifySettings != null) cleanup("Purify configuration", closingPurifySettings::close);
if (closingAdminReviveSettings != null) cleanup("admin revival configuration", closingAdminReviveSettings::close);
if (closingSwitchDelaySettings != null) cleanup("switch delay configuration", closingSwitchDelaySettings::close);
if (closingRoleplaySettings != null) cleanup("roleplay configuration", closingRoleplaySettings::close);
@@ -431,6 +462,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
: closingHexSettings.drained().toCompletableFuture(),
closingInspireSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingInspireSettings.drained().toCompletableFuture(),
closingMendingLightSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingMendingLightSettings.drained().toCompletableFuture(),
closingPurifySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingPurifySettings.drained().toCompletableFuture(),
closingAdminReviveSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingAdminReviveSettings.drained().toCompletableFuture(),
closingSwitchDelaySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
@@ -632,6 +667,25 @@ public final class FantasyStickPlugin extends JavaPlugin {
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> mendingLightConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.MendingLightSettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.MendingLightSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.MendingLightSettings.parse(values);
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> purifyConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.PurifySettings.keys()) {
values.put(key, getConfig().getString(key, games.dmg.fantasystick.persistence.PurifySettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.PurifySettings.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,183 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.party.PartyRoster;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.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.UUID;
import java.util.function.Consumer;
import java.util.function.Supplier;
import org.bukkit.Material;
import org.bukkit.attribute.Attribute;
import org.bukkit.entity.Player;
import org.bukkit.event.entity.EntityRegainHealthEvent;
/** Mending Light's aimed party healing with persist-before-publication support evidence. */
public final class MendingLight implements AbilityEffect {
public record Settings(BigInteger baseHealing, Duration cooldown, double range) {
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
|| !Double.isFinite(range) || range <= 0 || range > 64) {
throw new IllegalArgumentException("Invalid Mending Light settings");
}
}
public static Settings defaults() { return new Settings(BigInteger.valueOf(6), Duration.ofSeconds(8), 12); }
}
public record Support(UUID supportingCharacter, EquipmentSessions.Active aided, BigDecimal amount) {
public Support {
Objects.requireNonNull(supportingCharacter); Objects.requireNonNull(aided); Objects.requireNonNull(amount);
if (amount.signum() <= 0) throw new IllegalArgumentException("Support amount must be positive");
}
}
private record Target(Player player, EquipmentSessions.Active active, CharacterRecord record) { }
private final CharacterStorage characters;
private final EquipmentSessions sessions;
private final PartyRoster parties;
private final Consumer<Runnable> nextTick;
private final Consumer<EntityRegainHealthEvent> healingEvents;
private final Supplier<BigDecimal> nativeLimit;
private final Consumer<Support> supports;
private volatile Settings settings;
public MendingLight(Settings settings, CharacterStorage characters, EquipmentSessions sessions, PartyRoster parties,
Consumer<Runnable> nextTick, Consumer<EntityRegainHealthEvent> healingEvents,
Supplier<BigDecimal> nativeLimit, Consumer<Support> supports) {
this.settings = Objects.requireNonNull(settings); this.characters = Objects.requireNonNull(characters);
this.sessions = Objects.requireNonNull(sessions); this.parties = Objects.requireNonNull(parties);
this.nextTick = Objects.requireNonNull(nextTick); this.healingEvents = Objects.requireNonNull(healingEvents);
this.nativeLimit = Objects.requireNonNull(nativeLimit); this.supports = Objects.requireNonNull(supports);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
var current = settings;
return new AbilityDefinition("mending_light", "Mending Light", Material.GHAST_TEAR,
List.of("Restore a party ally's missing fantasy health.",
"Heals " + current.baseHealing() + " + WIS modifier within " + decimal(current.range()) + " blocks."),
"Use while aiming at an injured active party ally; self and full-health targets are refused.",
Map.of(Stat.WIS, BigInteger.valueOf(12), Stat.CHR, BigInteger.TEN), BigInteger.ONE,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("base-healing", new BigDecimal(current.baseHealing()),
"range", BigDecimal.valueOf(current.range()).stripTrailingZeros()),
AbilityDefinition.TargetPolicy.PARTY, this);
}
@Override public boolean accepts(ControlInput input) {
var target = target(input, settings.range());
if (target == null) return false;
var attribute = target.player().getAttribute(Attribute.MAX_HEALTH);
double health = target.player().getHealth();
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 Mending Light 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 configured = from(execution.definition());
var target = target(execution.input(), configured.range());
if (target == null) { execution.finish(); return; }
var attribute = target.player().getAttribute(Attribute.MAX_HEALTH);
if (attribute == null) { execution.finish(); return; }
double current = target.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(configured.baseHealing(), 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(target.player(), proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
healingEvents.accept(event);
double accepted = Math.min(proposed, Math.min(missing, event.getAmount()));
if (event.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) { execution.finish(); return; }
var expected = target.record().revision();
BigDecimal persistedHealth = BigDecimal.valueOf(current + accepted);
BigDecimal limit = nativeLimit.get();
characters.saveHealth(target.record().id(), expected, persistedHealth, limit).whenComplete((saved, failure) -> {
try {
nextTick.accept(() -> complete(execution, target, expected, accepted, saved, failure));
} catch (RuntimeException rejected) { execution.finish(); }
});
}
private void complete(AbilityExecution execution, Target target, BigInteger expected, double accepted,
java.util.Optional<CharacterRecord> saved, Throwable failure) {
try {
if (failure != null || saved == null || saved.isEmpty()) {
execution.input().player().sendMessage("Mending Light health could not be saved; no healing was applied.");
return;
}
if (!execution.stillCurrent() || !current(execution.input().player(), target, from(execution.definition()).range())) return;
var persisted = saved.orElseThrow();
if (!sessions.acknowledgeProgress(target.active(), expected, persisted)) return;
var attribute = target.player().getAttribute(Attribute.MAX_HEALTH);
if (attribute == null) return;
double current = target.player().getHealth(); double maximum = attribute.getValue();
double desired = Math.min(maximum, persisted.health().doubleValue());
double restored = Math.min(accepted, desired - current);
if (!Double.isFinite(current) || !Double.isFinite(maximum) || !Double.isFinite(restored) || restored <= 0) return;
target.player().setHealth(Math.min(maximum, current + restored));
var amount = BigDecimal.valueOf(restored).stripTrailingZeros();
supports.accept(new Support(execution.input().session().character(), target.active(), amount));
execution.input().player().sendMessage("Mending Light restored " + amount.toPlainString() + " health.");
target.player().sendMessage("Mending Light restored " + amount.toPlainString() + " health.");
} finally { execution.finish(); }
}
private Target target(ControlInput input, double range) {
if (!(input.target() instanceof Player player) || player == input.player() || !inRange(input.player(), player, range)
|| !sameParty(input.player(), player)) return null;
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
return record == null ? null : new Target(player, active, record);
}
private boolean current(Player source, Target target, double range) {
return sessions.current(target.active())
&& sessions.currentRecord(target.active()).filter(record -> record.id().equals(target.record().id())).isPresent()
&& inRange(source, target.player(), range) && sameParty(source, target.player());
}
private boolean sameParty(Player source, Player target) {
var party = parties.partyOf(source.getUniqueId()).orElse(null);
return party != null && party.members().contains(target.getUniqueId());
}
private static boolean inRange(Player source, Player target, double range) {
try {
var from = source.getLocation(); var to = target.getLocation();
return from.getWorld() == to.getWorld() && from.distanceSquared(to) <= range * range && source.hasLineOfSight(target);
} catch (RuntimeException invalid) { return false; }
}
private static Settings from(AbilityDefinition definition) {
var parameters = definition.parameters();
return new Settings(parameters.get("base-healing").toBigIntegerExact(), definition.cooldown(),
parameters.get("range").doubleValue());
}
private static String decimal(double value) { return BigDecimal.valueOf(value).stripTrailingZeros().toPlainString(); }
}
@@ -0,0 +1,128 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.party.PartyRoster;
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.UUID;
import java.util.function.Consumer;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.potion.PotionEffectType;
/** Purify's self-or-aimed-party native harmful-effect removal. */
public final class Purify implements AbilityEffect {
public record Settings(Duration cooldown, double range) {
public Settings {
Objects.requireNonNull(cooldown);
if (cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|| !Double.isFinite(range) || range <= 0 || range > 64) {
throw new IllegalArgumentException("Invalid Purify settings");
}
}
public static Settings defaults() { return new Settings(Duration.ofSeconds(15), 12); }
}
public record Support(UUID supportingCharacter, EquipmentSessions.Active aided, BigDecimal amount) {
public Support {
Objects.requireNonNull(supportingCharacter); Objects.requireNonNull(aided); Objects.requireNonNull(amount);
if (amount.signum() <= 0) throw new IllegalArgumentException("Support amount must be positive");
}
}
private record Target(Player player, EquipmentSessions.Active active) { }
private static final List<PotionEffectType> HARMFUL = List.of(
PotionEffectType.POISON, PotionEffectType.WITHER, PotionEffectType.SLOWNESS, PotionEffectType.WEAKNESS);
private final EquipmentSessions sessions;
private final PartyRoster parties;
private final Consumer<Support> supports;
private volatile Settings settings;
public Purify(Settings settings, EquipmentSessions sessions, PartyRoster parties, Consumer<Support> supports) {
this.settings = Objects.requireNonNull(settings); this.sessions = Objects.requireNonNull(sessions);
this.parties = Objects.requireNonNull(parties); this.supports = Objects.requireNonNull(supports);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
var current = settings;
return new AbilityDefinition("purify", "Purify", Material.HONEY_BOTTLE,
List.of("Remove Poison, Wither, Slowness and Weakness.",
"Aimed party-ally range: " + decimal(current.range()) + " blocks; beneficial effects remain."),
"Sneak+Use cleanses self; normal Use requires an aimed active party ally.",
Map.of(Stat.WIS, BigInteger.valueOf(14), Stat.CHR, BigInteger.TEN), BigInteger.ONE,
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, current.cooldown(),
Map.of("range", BigDecimal.valueOf(current.range()).stripTrailingZeros()),
AbilityDefinition.TargetPolicy.PARTY, this);
}
@Override public boolean accepts(ControlInput input) {
var target = target(input, settings.range());
return target != null && HARMFUL.stream().anyMatch(type -> target.player().getPotionEffect(type) != null);
}
@Override public void activate(AbilityExecution execution) {
if (!execution.stillCurrent()) return;
var configured = from(execution.definition()); var target = target(execution.input(), configured.range());
if (target == null) return;
int removed = 0;
for (var type : HARMFUL) {
if (!execution.stillCurrent() || !current(execution.input(), target, configured.range())) break;
if (target.player().getPotionEffect(type) == null) continue;
target.player().removePotionEffect(type);
if (target.player().getPotionEffect(type) == null) removed++;
}
if (removed == 0) return;
execution.input().player().sendMessage("Purify removed " + removed + " harmful effect" + (removed == 1 ? "." : "s."));
if (target.player() != execution.input().player()
&& execution.stillCurrent() && current(execution.input(), target, configured.range())) {
var amount = BigDecimal.valueOf(removed);
supports.accept(new Support(execution.input().session().character(), target.active(), amount));
target.player().sendMessage("Purify removed " + removed + " harmful effect" + (removed == 1 ? "." : "s."));
}
}
private Target target(ControlInput input, double range) {
if (!(input.target() instanceof Player player)) return null;
if (input.player().isSneaking()) {
if (player != input.player()) return null;
} else if (player == input.player() || !inRange(input.player(), player, range) || !sameParty(input.player(), player)) {
return null;
}
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
return active == null ? null : new Target(player, active);
}
private boolean current(ControlInput input, Target target, double range) {
if (!sessions.current(target.active())) return false;
if (target.player() == input.player()) return input.player().isSneaking();
return !input.player().isSneaking() && inRange(input.player(), target.player(), range)
&& sameParty(input.player(), target.player());
}
private boolean sameParty(Player source, Player target) {
var party = parties.partyOf(source.getUniqueId()).orElse(null);
return party != null && party.members().contains(target.getUniqueId());
}
private static boolean inRange(Player source, Player target, double range) {
try {
var from = source.getLocation(); var to = target.getLocation();
return from.getWorld() == to.getWorld() && from.distanceSquared(to) <= range * range && source.hasLineOfSight(target);
} catch (RuntimeException invalid) { return false; }
}
private static Settings from(AbilityDefinition definition) {
return new Settings(definition.cooldown(), definition.parameters().get("range").doubleValue());
}
private static String decimal(double value) { return BigDecimal.valueOf(value).stripTrailingZeros().toPlainString(); }
}
@@ -100,6 +100,13 @@ public final class CombatExperience implements Listener, AutoCloseable {
contributions.support(target, aided.character(), supportingCharacter, clock.instant());
}
/** Records effective healing; any directly contributed target can qualify it within ten seconds. */
public void support(UUID supportingCharacter, EquipmentSessions.Active aided, BigDecimal amount) {
Objects.requireNonNull(supportingCharacter); Objects.requireNonNull(aided); Objects.requireNonNull(amount);
if (closed || amount.signum() <= 0 || !sessions.current(aided) || supportingCharacter.equals(aided.character())) return;
contributions.support(aided.character(), supportingCharacter, clock.instant());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void damage(EntityDamageEvent event) {
if (closed || event.getFinalDamage() <= 0) return;
@@ -205,6 +212,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
public void retryPending() {
if (closed) return;
contributions.sweep(clock.instant());
for (int attempted = 0; attempted < 64; attempted++) {
UUID id = retries.poll();
if (id == null) break;
@@ -0,0 +1,172 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.MendingLight;
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.math.BigDecimal;
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 Mending Light settings; publication remains a server-thread caller decision. */
public final class MendingLightSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String BASE_HEALING = "mending-light-base-healing";
public static final String COOLDOWN = "mending-light-cooldown-seconds";
public static final String RANGE = "mending-light-range-blocks";
private static final List<String> KEYS = List.of(BASE_HEALING, COOLDOWN, RANGE);
private static final Map<String, String> DEFAULTS = Map.of(BASE_HEALING, "6", COOLDOWN, "8", RANGE, "12");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, MendingLight.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-mending-light-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 MendingLightSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
MendingLightSettings(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 MendingLight.Settings settings() { return parse(effectiveValues); }
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);
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("Mending Light 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 BigDecimal(raw).stripTrailingZeros().toPlainString();
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static MendingLight.Settings parse(Map<String, String> values) {
try {
return new MendingLight.Settings(new java.math.BigInteger(values.get(BASE_HEALING)),
seconds(values.get(COOLDOWN)), finite(values.get(RANGE)));
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Mending Light settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new 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,166 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.Purify;
import java.io.IOException;
import java.math.BigDecimal;
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 Purify settings; publication remains a server-thread caller decision. */
public final class PurifySettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String COOLDOWN = "purify-cooldown-seconds";
public static final String RANGE = "purify-range-blocks";
private static final List<String> KEYS = List.of(COOLDOWN, RANGE);
private static final Map<String, String> DEFAULTS = Map.of(COOLDOWN, "15", RANGE, "12");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, Purify.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-purify-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 PurifySettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
PurifySettings(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 Purify.Settings settings() { return parse(effectiveValues); }
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(value); var preview = new LinkedHashMap<>(effectiveValues);
preview.put(key, accepted); parse(preview);
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
var next = new LinkedHashMap<>(persistedValues); next.put(key, accepted);
var parsed = parse(next); save(key, accepted); persistedValues = Map.copyOf(next);
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("Purify 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 raw) {
try { return new BigDecimal(raw).stripTrailingZeros().toPlainString(); }
catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static Purify.Settings parse(Map<String, String> values) {
try { return new Purify.Settings(seconds(values.get(COOLDOWN)), finite(values.get(RANGE))); }
catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Purify settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new 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); }
}
@@ -14,6 +14,7 @@ public final class CombatContributions implements AutoCloseable {
private final CombatRewardRules rules;
private final Map<UUID, Map<UUID, Instant>> targets = new HashMap<>();
private final Map<UUID, Map<UUID, Map<UUID, Instant>>> supports = new HashMap<>();
private final Map<UUID, Map<UUID, Instant>> effectiveHealing = new HashMap<>();
private boolean closed;
public CombatContributions(CombatRewardRules rules) { this.rules = Objects.requireNonNull(rules); }
@@ -34,6 +35,14 @@ public final class CombatContributions implements AutoCloseable {
.merge(supportingCharacter, acceptedAt, (previous, next) -> previous.isAfter(next) ? previous : next);
}
/** Records effective healing that may qualify through any target the aided character directly contributes to. */
public void support(UUID aidedCharacter, UUID supportingCharacter, Instant acceptedAt) {
Objects.requireNonNull(aidedCharacter); Objects.requireNonNull(supportingCharacter); Objects.requireNonNull(acceptedAt);
if (closed) return;
effectiveHealing.computeIfAbsent(aidedCharacter, ignored -> new HashMap<>())
.merge(supportingCharacter, acceptedAt, (previous, next) -> previous.isAfter(next) ? previous : next);
}
public Set<UUID> consume(UUID target, Instant deathAt) {
Objects.requireNonNull(target); Objects.requireNonNull(deathAt);
if (closed) return Set.of();
@@ -45,8 +54,12 @@ public final class CombatContributions implements AutoCloseable {
if (rules.contributes(acceptedAt, deathAt)) eligible.add(character);
});
for (UUID aided : Set.copyOf(eligible)) {
var supporters = assistance == null ? null : assistance.get(aided);
if (supporters != null) supporters.forEach((supporter, acceptedAt) -> {
var targeted = assistance == null ? null : assistance.get(aided);
if (targeted != null) targeted.forEach((supporter, acceptedAt) -> {
if (supports(acceptedAt, deathAt)) eligible.add(supporter);
});
var healing = effectiveHealing.get(aided);
if (healing != null) healing.forEach((supporter, acceptedAt) -> {
if (supports(acceptedAt, deathAt)) eligible.add(supporter);
});
}
@@ -67,11 +80,14 @@ public final class CombatContributions implements AutoCloseable {
byAlly.values().removeIf(Map::isEmpty);
});
supports.values().removeIf(Map::isEmpty);
effectiveHealing.values().forEach(bySupporter ->
bySupporter.entrySet().removeIf(entry -> !supports(entry.getValue(), now)));
effectiveHealing.values().removeIf(Map::isEmpty);
}
private static boolean supports(Instant acceptedAt, Instant defeatAt) {
return !acceptedAt.isAfter(defeatAt) && !defeatAt.isAfter(acceptedAt.plus(SUPPORT_LIFETIME));
}
@Override public void close() { closed = true; targets.clear(); supports.clear(); }
@Override public void close() { closed = true; targets.clear(); supports.clear(); effectiveHealing.clear(); }
}
@@ -0,0 +1,114 @@
package games.dmg.fantasystick.session;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.ProgressionRules;
import java.math.BigInteger;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.Color;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
/** Acknowledged administrator XP mutation captured against one exact active character/session. */
public final class AdminExperienceGrant implements AutoCloseable {
public enum Status { APPLIED, AT_CAP, STALE, UNAVAILABLE, FAILED }
public record Result(Status status, String name, BigInteger requested, BigInteger applied,
int level, int levels) { }
public record Failure(UUID character, Throwable cause) { }
private final CharacterStorage characters;
private final ProgressionRules progression;
private final EquipmentSessions sessions;
private final Consumer<Runnable> nextTick;
private final Function<UUID, Player> players;
private final Consumer<Failure> failures;
private volatile boolean closed;
public AdminExperienceGrant(CharacterStorage characters, ProgressionRules progression, EquipmentSessions sessions,
Consumer<Runnable> nextTick, Function<UUID, Player> players, Consumer<Failure> failures) {
this.characters = Objects.requireNonNull(characters);
this.progression = Objects.requireNonNull(progression);
this.sessions = Objects.requireNonNull(sessions);
this.nextTick = Objects.requireNonNull(nextTick);
this.players = Objects.requireNonNull(players);
this.failures = Objects.requireNonNull(failures);
}
/** Returns false before persistence when the captured active identity is no longer authoritative. */
public boolean grant(EquipmentSessions.Active active, CharacterRecord captured, BigInteger amount,
Consumer<Result> completion) {
Objects.requireNonNull(active); Objects.requireNonNull(captured);
Objects.requireNonNull(amount); Objects.requireNonNull(completion);
if (amount.signum() <= 0) throw new IllegalArgumentException("Experience grant must be positive");
if (closed || !sessions.current(active)
|| sessions.currentRecord(active).filter(captured::equals).isEmpty()) return false;
characters.awardExperience(captured.id(), captured.revision(), amount, progression)
.whenComplete((award, failure) -> schedule(active, captured, amount, completion, award, failure));
return true;
}
private void schedule(EquipmentSessions.Active active, CharacterRecord captured, BigInteger amount,
Consumer<Result> completion, CharacterStorage.ExperienceAward award, Throwable failure) {
if (failure != null) report(captured.id(), failure);
if (closed) return;
try {
nextTick.accept(() -> complete(active, captured, amount, completion, award, failure));
} catch (RuntimeException rejected) { report(captured.id(), rejected); }
}
private void complete(EquipmentSessions.Active active, CharacterRecord captured, BigInteger amount,
Consumer<Result> completion, CharacterStorage.ExperienceAward award, Throwable failure) {
if (closed) return;
if (failure != null) {
completion.accept(new Result(Status.FAILED, captured.name(), amount, BigInteger.ZERO,
captured.level(), 0));
return;
}
var saved = award.record().orElse(captured);
Status status = switch (award.status()) {
case APPLIED -> Status.APPLIED;
case AT_CAP, UNCHANGED -> Status.AT_CAP;
case STALE -> Status.STALE;
case UNAVAILABLE -> Status.UNAVAILABLE;
};
BigInteger applied = status == Status.APPLIED
? saved.experience().subtract(captured.experience()) : BigInteger.ZERO;
if (status == Status.APPLIED && sessions.current(active)) {
boolean synchronizedSession = sessions.acknowledgeProgress(active, captured.revision(), saved);
if (!synchronizedSession && sessions.current(active)
&& sessions.currentRecord(active).filter(saved::equals).isEmpty()) {
report(captured.id(), new IllegalStateException(
"Saved administrator XP could not synchronize with its captured active session"));
}
if (synchronizedSession && sessions.current(active)) feedback(active, saved, applied, award.levels());
}
completion.accept(new Result(status, captured.name(), amount, applied, saved.level(), award.levels()));
}
private void feedback(EquipmentSessions.Active active, CharacterRecord saved, BigInteger applied, int levels) {
Player player;
try { player = players.apply(active.player()); }
catch (RuntimeException failure) { report(saved.id(), failure); return; }
if (player == null || !player.isOnline() || !sessions.current(active)
|| sessions.currentRecord(active).filter(saved::equals).isEmpty()) return;
try {
player.sendMessage("Received " + applied + " fantasy XP from an administrator.");
for (int gained = 1; gained <= levels; gained++) {
int level = saved.level() - levels + gained;
player.sendMessage("Level " + level + "! Gained 1 character point.");
SessionVisuals.burst(player, Color.BLUE, 12);
player.playSound(player.getLocation(), Sound.ENTITY_PLAYER_LEVELUP, 1, 1);
}
} catch (RuntimeException failure) { report(saved.id(), failure); }
}
private void report(UUID character, Throwable failure) {
try { failures.accept(new Failure(character, failure)); }
catch (RuntimeException ignored) { /* Diagnostics do not replay a grant. */ }
}
@Override public void close() { closed = true; }
}
@@ -1,6 +1,7 @@
package games.dmg.fantasystick.session;
import games.dmg.fantasystick.persistence.EquipmentSessionStore;
import java.math.BigInteger;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
@@ -38,8 +39,13 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private games.dmg.fantasystick.ability.Hex hex;
private games.dmg.fantasystick.persistence.InspireSettings inspireSettings;
private games.dmg.fantasystick.ability.Inspire inspire;
private games.dmg.fantasystick.persistence.MendingLightSettings mendingLightSettings;
private games.dmg.fantasystick.ability.MendingLight mendingLight;
private games.dmg.fantasystick.persistence.PurifySettings purifySettings;
private games.dmg.fantasystick.ability.Purify purify;
private AdminCharacterIssuer characterIssuer;
private AdminRevival adminRevival;
private AdminExperienceGrant experienceGrant;
private games.dmg.fantasystick.persistence.AdminReviveSettings adminReviveSettings;
private games.dmg.fantasystick.persistence.SwitchDelaySettings switchDelaySettings;
private games.dmg.fantasystick.persistence.RoleplaySettings roleplaySettings;
@@ -215,11 +221,28 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
this.inspireSettings=Objects.requireNonNull(inspireSettings);this.inspire=Objects.requireNonNull(inspire);
}
public void attachMendingLight(games.dmg.fantasystick.persistence.MendingLightSettings settings,
games.dmg.fantasystick.ability.MendingLight effect) {
if (closed || mendingLightSettings != null) throw new IllegalStateException("Mending Light already attached or command closed");
mendingLightSettings = Objects.requireNonNull(settings); mendingLight = Objects.requireNonNull(effect);
}
public void attachPurify(games.dmg.fantasystick.persistence.PurifySettings settings,
games.dmg.fantasystick.ability.Purify effect) {
if (closed || purifySettings != null) throw new IllegalStateException("Purify already attached or command closed");
purifySettings = Objects.requireNonNull(settings); purify = Objects.requireNonNull(effect);
}
public void attachSwitchDelay(games.dmg.fantasystick.persistence.SwitchDelaySettings settings){if(closed||switchDelaySettings!=null)throw new IllegalStateException("Switch delay already attached or command closed");switchDelaySettings=Objects.requireNonNull(settings);}
public void attachRoleplaySettings(games.dmg.fantasystick.persistence.RoleplaySettings settings){if(closed||roleplaySettings!=null)throw new IllegalStateException("Roleplay settings already attached or command closed");roleplaySettings=Objects.requireNonNull(settings);}
public void attachPartySettings(games.dmg.fantasystick.persistence.PartySettings settings){if(closed||partySettings!=null)throw new IllegalStateException("Party settings already attached or command closed");partySettings=Objects.requireNonNull(settings);}
public void attachSheetResourcePack(games.dmg.fantasystick.persistence.SheetResourcePackSettings settings,games.dmg.fantasystick.presentation.SheetResourcePack delivery){if(closed||sheetResourcePackSettings!=null)throw new IllegalStateException("Sheet resource pack already attached or command closed");sheetResourcePackSettings=Objects.requireNonNull(settings);sheetResourcePack=Objects.requireNonNull(delivery);}
public void attachExperienceGrant(AdminExperienceGrant grant) {
if (closed || experienceGrant != null) throw new IllegalStateException("Experience grant already attached or command closed");
experienceGrant = Objects.requireNonNull(grant);
}
public void attachAdminRevival(AdminRevival revival, games.dmg.fantasystick.persistence.AdminReviveSettings settings) {
if (closed || adminRevival != null) throw new IllegalStateException("Admin revival already attached or command closed");
adminRevival=Objects.requireNonNull(revival);adminReviveSettings=Objects.requireNonNull(settings);
@@ -247,6 +270,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (args.length > 0 && args[0].equals("revive")) {
adminRevive(sender,args);return true;
}
if (args.length > 0 && args[0].equals("experience")) {
experience(sender, args); return true;
}
UUID owner;
try {
if (args.length != 2 || !(args[0].equals("inspect") || args[0].equals("recover"))) {
@@ -307,6 +333,54 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
return true;
}
private void experience(CommandSender sender, String[] args) {
if (experienceGrant == null || args.length < 4 || !args[1].equals("give")) {
sender.sendMessage("Usage: /fantasystick experience give <active-character-name...> <positive-amount>"); return;
}
BigInteger amount;
try { amount = new BigInteger(args[args.length - 1]); }
catch (NumberFormatException invalid) {
sender.sendMessage("Experience amount is invalid; enter a positive whole number."); return;
}
if (amount.signum() <= 0) {
sender.sendMessage("Experience amount is invalid; enter a positive whole number."); return;
}
String name = String.join(" ", java.util.Arrays.copyOfRange(args, 2, args.length - 1));
if (name.isBlank()) {
sender.sendMessage("Usage: /fantasystick experience give <active-character-name...> <positive-amount>"); return;
}
var matches = sessions.activeCharacters().stream().filter(active -> active.record().name().equals(name)).toList();
if (matches.isEmpty()) {
sender.sendMessage("Experience grant refused: character '" + name + "' is not active. No fantasy XP changed."); return;
}
if (matches.size() != 1) {
sender.sendMessage("Experience grant refused: active character name '" + name
+ "' is ambiguous. No fantasy XP changed."); return;
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) {
sender.sendMessage("Experience grant refused before persistence. No fantasy XP changed."); return;
}
var target = matches.getFirst();
boolean accepted = experienceGrant.grant(target.session(), target.record(), amount, result -> {
if (closed || !sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(switch (result.status()) {
case APPLIED -> "Granted " + result.requested() + " fantasy XP to " + result.name()
+ "; applied " + result.applied() + " after cap clamping; resulting level " + result.level() + ".";
case AT_CAP -> "Granted 0 of requested " + result.requested() + " fantasy XP to " + result.name()
+ "; the character is already capped at level " + result.level() + ".";
case STALE -> "Experience grant refused: " + result.name()
+ " changed concurrently. No grant success was recorded.";
case UNAVAILABLE -> "Experience grant refused: " + result.name()
+ " is unavailable or dead. No fantasy XP changed.";
case FAILED -> "Experience grant failed before acknowledgment for " + result.name()
+ "; no success was reported. See server diagnostics.";
});
});
if (!accepted) sender.sendMessage("Experience grant refused: the captured character session changed before persistence. No fantasy XP changed.");
}
private void adminRevive(CommandSender sender,String[] args){if(args.length!=2||args[1].isBlank()||adminRevival==null||!(sender instanceof org.bukkit.entity.Player admin)){sender.sendMessage("Usage: /fantasystick revive <online-player> (an in-game administrator must select the Tail)");return;}var matches=server.getOnlinePlayers().stream().filter(player->player.isOnline()&&player.getName().equalsIgnoreCase(args[1])).toList();if(matches.size()!=1){sender.sendMessage("Admin revival refused: target must name one online player. No item changed.");return;}adminRevival.open(admin,matches.getFirst(),adminReviveSettings.timeout());}
private void give(CommandSender sender, String[] args) {
@@ -362,6 +436,12 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (inspireSettings != null && games.dmg.fantasystick.persistence.InspireSettings.keys().contains(args[1])) {
inspireConfiguration(sender, args); return;
}
if (mendingLightSettings != null && games.dmg.fantasystick.persistence.MendingLightSettings.keys().contains(args[1])) {
mendingLightConfiguration(sender, args); return;
}
if (purifySettings != null && games.dmg.fantasystick.persistence.PurifySettings.keys().contains(args[1])) {
purifyConfiguration(sender, args); return;
}
if (frostSettings == null || !games.dmg.fantasystick.persistence.FrostShardSettings.keys().contains(args[1])) {
configurationUsage(sender); return;
}
@@ -461,6 +541,61 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void inspireConfiguration(CommandSender sender,String[] args){String key=args[1];if(args.length==2){sender.sendMessage(key+"="+inspireSettings.value(key)+" (applies to future Inspire casts only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.InspireSettings.Update> operation;try{operation=inspireSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Inspire setting is invalid; the effective value is unchanged.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure==null){inspire.configure(saved.settings());abilities.replace(inspire.definition());inspireSettings.publish(saved);}if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;sender.sendMessage(failure==null?"Saved "+key+"="+saved.value()+" for future Inspire casts; active effects and deadlines are unchanged.":"Inspire configuration save failed; the effective setting is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void mendingLightConfiguration(CommandSender sender, String[] args) {
String key = args[1];
if (args.length == 2) {
sender.sendMessage(key + "=" + mendingLightSettings.value(key) + " (applies to future Mending Light casts only)");
return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.MendingLightSettings.Update> operation;
try { operation = mendingLightSettings.set(key, args[2]); }
catch (IllegalArgumentException invalid) {
sender.sendMessage("Mending Light 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) {
mendingLight.configure(saved.settings()); abilities.replace(mendingLight.definition()); mendingLightSettings.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 Mending Light casts; active work and deadlines are unchanged."
: "Mending Light configuration save failed; the effective setting is unchanged.");
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
private void purifyConfiguration(CommandSender sender, String[] args) {
String key = args[1];
if (args.length == 2) {
sender.sendMessage(key + "=" + purifySettings.value(key) + " (applies to future Purify casts only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.PurifySettings.Update> operation;
try { operation = purifySettings.set(key, args[2]); }
catch (IllegalArgumentException invalid) {
sender.sendMessage("Purify 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) {
purify.configure(saved.settings()); abilities.replace(purify.definition()); purifySettings.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 Purify casts; active work and deadlines are unchanged."
: "Purify configuration save failed; the effective setting is unchanged.");
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
private void hexConfiguration(CommandSender sender, String[] args) {
String key=args[1];if(args.length==2){sender.sendMessage(key+"="+hexSettings.value(key)+" (applies to future Hex casts only)");return;}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.HexSettings.Update> operation;
@@ -597,7 +732,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
@Override public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
if (closed || !sender.hasPermission(PERMISSION)) return List.of();
if (args.length == 1) return matching(sender instanceof org.bukkit.entity.Player
? List.of("config", "give", "inspect", "recover", "revive") : List.of("config", "give", "inspect", "recover"), args[0]);
? List.of("config", "experience", "give", "inspect", "recover", "revive") : List.of("config", "experience", "give", "inspect", "recover"), args[0]);
if (args.length == 2 && args[0].equals("experience")) return matching(List.of("give"), args[1]);
if (args.length >= 3 && args[0].equals("experience") && args[1].equals("give")) return experienceNames(args);
if (args.length == 2 && (args[0].equals("inspect") || args[0].equals("recover"))) return matching(
server.getOnlinePlayers().stream().filter(org.bukkit.entity.Player::isOnline)
.map(player -> player.getUniqueId().toString()).toList(), args[1]);
@@ -613,6 +750,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
keys.addAll(games.dmg.fantasystick.persistence.ShieldBashSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.HexSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.InspireSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.MendingLightSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.PurifySettings.keys());
return matching(keys, args[1]);
}
if (args.length == 3 && args[0].equals("config")) {
@@ -657,12 +796,32 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
}
if(inspireSettings!=null&&games.dmg.fantasystick.persistence.InspireSettings.keys().contains(args[1])){String current=inspireSettings.value(args[1]);String defaults=games.dmg.fantasystick.persistence.InspireSettings.defaultValue(args[1]);return matching(current.equals(defaults)?List.of(current):List.of(current,defaults),args[2]);}
if (mendingLightSettings != null && games.dmg.fantasystick.persistence.MendingLightSettings.keys().contains(args[1])) {
String current = mendingLightSettings.value(args[1]);
String defaults = games.dmg.fantasystick.persistence.MendingLightSettings.defaultValue(args[1]);
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
}
if (purifySettings != null && games.dmg.fantasystick.persistence.PurifySettings.keys().contains(args[1])) {
String current = purifySettings.value(args[1]);
String defaults = games.dmg.fantasystick.persistence.PurifySettings.defaultValue(args[1]);
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
}
}
return List.of();
}
private List<String> experienceNames(String[] args) {
int priorTokens = args.length - 3;
String prefix = args[args.length - 1];
return matching(sessions.activeCharacters().stream().map(active -> active.record().name())
.map(name -> name.split(" ")).filter(tokens -> tokens.length > priorTokens)
.filter(tokens -> java.util.stream.IntStream.range(0, priorTokens)
.allMatch(index -> tokens[index].equals(args[index + 2])))
.map(tokens -> tokens[priorTokens]).toList(), prefix);
}
private static List<String> matching(java.util.Collection<String> values, String prefix) {
return values.stream().filter(value -> value.startsWith(prefix)).distinct().sorted().toList();
}
@Override public void close() { closed = true; if (characterIssuer != null) characterIssuer.close(); if(adminRevival!=null)adminRevival.close(); }
@Override public void close() { closed = true; if (characterIssuer != null) characterIssuer.close(); if(adminRevival!=null)adminRevival.close(); if(experienceGrant!=null)experienceGrant.close(); }
}
@@ -345,8 +345,9 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
var selected = sessions.selectedControl(event.getPlayer(), event.getItem()).orElse(null);
if (selected != null && useGesture(selected)) {
org.bukkit.entity.Entity target = selected.ability().equals("hex")
|| selected.ability().equals("mending_light")
? event.getPlayer().getTargetEntity(64)
: selected.ability().equals("inspire")
: selected.ability().equals("inspire") || selected.ability().equals("purify")
? event.getPlayer().isSneaking() ? event.getPlayer() : event.getPlayer().getTargetEntity(64)
: null;
emit(event.getPlayer(), event.getItem(), selected, ControlInput.Gesture.USE, target, 0, 0);
@@ -12,6 +12,7 @@ import java.time.Duration;
import java.time.Instant;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
@@ -34,6 +35,7 @@ public final class EquipmentSessions implements AutoCloseable {
public record Notice(UUID player, String code, String message) { }
public record Failure(UUID player, UUID session, Throwable cause) { }
public record Active(UUID player, UUID session, UUID character) { }
public record ActiveCharacter(Active session, CharacterRecord record) { }
public record SelectedControl(Active session, int slot, String ability) { }
private final CharacterStorage characters;
@@ -224,6 +226,14 @@ public final class EquipmentSessions implements AutoCloseable {
&& sheets.identify(attempt.player.getInventory().getItemInOffHand()).filter(attempt.character::equals).isPresent();
}
/** Side-effect-free server-thread snapshots for exact active-character command resolution/autocomplete. */
public synchronized List<ActiveCharacter> activeCharacters() {
if (closed) return List.of();
return active.values().stream().filter(attempt -> attempt.record != null)
.map(attempt -> new ActiveCharacter(new Active(attempt.owner, attempt.session, attempt.character), attempt.record))
.filter(snapshot -> current(snapshot.session())).toList();
}
/** Current active holder for a transferable identity, used only to synchronize acknowledged character writes. */
public synchronized Optional<Active> activeCharacter(UUID character) {
Objects.requireNonNull(character);
+11
View File
@@ -89,3 +89,14 @@ inspire-base-temporary-health: 4
inspire-cooldown-seconds: 15
inspire-range-blocks: 12
inspire-duration-seconds: 6
# Mending Light defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
mending-light-base-healing: 6
mending-light-cooldown-seconds: 8
mending-light-range-blocks: 12
# Purify defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
purify-cooldown-seconds: 15
purify-range-blocks: 12
+3 -3
View File
@@ -7,10 +7,10 @@ description: Transferable characters, native fantasy health, recoverable equipme
author: dmg.games
commands:
fantasystick:
description: Issue or revive character sheets, inspect/recover sessions or configure Fantasy Stick.
usage: /fantasystick <give|revive> <online-player> | <inspect|recover> <player-uuid> | config <setting> [value]
description: Issue or revive sheets, grant fantasy experience, recover sessions or configure Fantasy Stick.
usage: /fantasystick <give|revive> <online-player> | experience give <active-character-name...> <amount> | <inspect|recover> <player-uuid> | config <setting> [value]
permission: fantasystick.admin
permissions:
fantasystick.admin:
description: Issue/revive sheets, inspect/recover sessions and configure fantasy mode.
description: Issue/revive sheets, grant fantasy experience, recover sessions and configure fantasy mode.
default: op
@@ -0,0 +1,73 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import games.dmg.fantasystick.crafting.CharacterFactory;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.ProgressionRules;
import games.dmg.fantasystick.session.AdminExperienceGrant;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.Test;
class AdminExperienceGrantNativeTest {
@Test void acceptedPersistenceCannotRedirectFeedbackAfterCharacterSwitch() {
var storage = mock(CharacterStorage.class); var sessions = mock(EquipmentSessions.class);
var rules = ProgressionRules.defaults(); CharacterRecord captured = new CharacterFactory().create(rules);
var active = new EquipmentSessions.Active(UUID.randomUUID(), UUID.randomUUID(), captured.id());
var current = new AtomicBoolean(true);
when(sessions.current(active)).thenAnswer(call -> current.get());
when(sessions.currentRecord(active)).thenReturn(Optional.of(captured));
var future = new CompletableFuture<CharacterStorage.ExperienceAward>();
when(storage.awardExperience(captured.id(), captured.revision(), BigInteger.TEN, rules)).thenReturn(future);
var tasks = new ArrayList<Runnable>(); var results = new ArrayList<AdminExperienceGrant.Result>();
var playerLookups = new java.util.concurrent.atomic.AtomicInteger();
var grant = new AdminExperienceGrant(storage, rules, sessions, tasks::add,
ignored -> { playerLookups.incrementAndGet(); return null; }, failure -> fail(failure.cause()));
assertTrue(grant.grant(active, captured, BigInteger.TEN, results::add));
current.set(false);
var saved = new CharacterRecord(captured.id(), captured.name(), BigInteger.ONE, captured.stats(), 1,
BigInteger.TEN, captured.unspentPoints(), captured.health(), captured.learned(), captured.loadout(),
captured.cooldowns(), captured.restrictedKills(), false, captured.deathId(), captured.diedAt());
future.complete(new CharacterStorage.ExperienceAward(CharacterStorage.ExperienceAwardStatus.APPLIED,
Optional.of(saved), 0));
assertEquals(1, tasks.size()); tasks.getFirst().run();
assertEquals(AdminExperienceGrant.Status.APPLIED, results.getFirst().status());
assertEquals(BigInteger.TEN, results.getFirst().applied());
verify(sessions, never()).acknowledgeProgress(any(), any(), any());
assertEquals(0, playerLookups.get(), "A replacement holder/session cannot receive captured feedback");
}
@Test void persistenceFailureReportsOnceAndClosedLifetimePublishesNoStaleCallback() {
var storage = mock(CharacterStorage.class); var sessions = mock(EquipmentSessions.class);
var rules = ProgressionRules.defaults(); CharacterRecord captured = new CharacterFactory().create(rules);
var active = new EquipmentSessions.Active(UUID.randomUUID(), UUID.randomUUID(), captured.id());
when(sessions.current(active)).thenReturn(true); when(sessions.currentRecord(active)).thenReturn(Optional.of(captured));
var failed = new CompletableFuture<CharacterStorage.ExperienceAward>();
when(storage.awardExperience(captured.id(), captured.revision(), BigInteger.ONE, rules)).thenReturn(failed);
var tasks = new ArrayList<Runnable>(); var results = new ArrayList<AdminExperienceGrant.Result>();
var failures = new ArrayList<AdminExperienceGrant.Failure>();
var grant = new AdminExperienceGrant(storage, rules, sessions, tasks::add, ignored -> null, failures::add);
assertTrue(grant.grant(active, captured, BigInteger.ONE, results::add));
failed.completeExceptionally(new java.io.IOException("save failed"));
assertEquals(1, failures.size()); assertEquals(1, tasks.size()); tasks.removeFirst().run();
assertEquals(List.of(AdminExperienceGrant.Status.FAILED), results.stream().map(AdminExperienceGrant.Result::status).toList());
var pending = new CompletableFuture<CharacterStorage.ExperienceAward>();
when(storage.awardExperience(captured.id(), captured.revision(), BigInteger.TWO, rules)).thenReturn(pending);
assertTrue(grant.grant(active, captured, BigInteger.TWO, results::add)); grant.close();
pending.complete(new CharacterStorage.ExperienceAward(CharacterStorage.ExperienceAwardStatus.AT_CAP,
Optional.of(captured), 0));
assertTrue(tasks.isEmpty()); assertEquals(1, results.size());
}
}
@@ -0,0 +1,212 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.items.CharacterSheets;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import java.math.BigInteger;
import java.nio.file.Path;
import java.util.List;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class AdminExperienceNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void registeredCommandResolvesExactActiveCharacterPersistsThenSynchronizesFeedbackAndBook() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var target = new FantasyStickLifecycleNativeTest.Actor(data);
var storage = storage(f);
storage.renameCharacter(target.core.record.id(), "Mira 雪").get();
f.players.put(target.core.id, target.player); f.activate(target);
var sender = new EquipmentAdminCommandNativeTest.Sender();
var before = storage.find(target.core.record.id()).get().orElseThrow();
assertEquals(List.of("experience"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"exp"}));
assertEquals(List.of("give"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"experience", "g"}));
assertEquals(List.of("Mira"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"experience", "give", "Mi"}));
assertEquals(List.of("雪"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"experience", "give", "Mira", ""}));
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"experience", "give", "Mira", "雪", ""}).isEmpty());
assertTrue(f.command.execute(sender.port, "fantasystick", new String[]{"experience", "give", "Mira", "雪", "250"}));
storage.find(target.core.record.id()).get(); f.tick();
var saved = storage.find(target.core.record.id()).get().orElseThrow();
assertEquals(BigInteger.valueOf(250), saved.experience());
assertEquals(3, saved.level());
assertEquals(before.unspentPoints().add(BigInteger.TWO), saved.unspentPoints());
assertEquals(before.stats(), saved.stats()); assertEquals(before.health(), saved.health());
assertEquals(before.learned(), saved.learned()); assertEquals(before.loadout(), saved.loadout());
assertEquals(before.cooldowns(), saved.cooldowns()); assertEquals(before.restrictedKills(), saved.restrictedKills());
assertTrue(sender.messages.getLast().contains("Mira 雪"));
assertTrue(sender.messages.getLast().contains("250")); assertTrue(sender.messages.getLast().contains("level 3"));
assertTrue(target.messages.stream().anyMatch(message -> message.contains("250 fantasy XP")));
assertTrue(target.messages.stream().anyMatch(message -> message.contains("Level 2")));
assertTrue(target.messages.stream().anyMatch(message -> message.contains("Level 3")));
assertEquals(2, target.sounds.size());
assertEquals(2, org.mockito.Mockito.mockingDetails(target.world).getInvocations().stream()
.filter(invocation -> invocation.getMethod().getName().equals("spawnParticle")).count());
PartyBookNativeTest.open(f, target);
String text = bookText(target.books.getLast());
assertTrue(text.contains("Level: 3"), text); assertTrue(text.contains("XP: 50/100"), text);
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
assertEquals(BigInteger.valueOf(250), storage(f).find(target.core.record.id()).get().orElseThrow().experience());
}
}
@Test void commandValidationAmbiguityExactCaseAndAutocompleteFailClosedWithoutMutation() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var first = active(f, data, "Twin Hero"); var second = active(f, data, "Twin Hero");
var unicode = active(f, data, "Árvíztűrő Tükörfúrógép");
var inactive = new FantasyStickLifecycleNativeTest.Actor(data);
storage(f).renameCharacter(inactive.core.record.id(), "Hidden Hero").get();
var sender = new EquipmentAdminCommandNativeTest.Sender();
var firstBefore = storage(f).find(first.core.record.id()).get().orElseThrow();
var secondBefore = storage(f).find(second.core.record.id()).get().orElseThrow();
assertEquals(List.of("Twin"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"experience", "give", "Tw"}));
assertEquals(List.of("Hero"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"experience", "give", "Twin", "H"}));
assertEquals(List.of("Árvíztűrő"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"experience", "give", "Á"}));
assertEquals(List.of("Tükörfúrógép"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"experience", "give", "Árvíztűrő", "T"}));
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"experience", "give", "Hidden", ""}).isEmpty());
int queued = f.tasks.size();
for (var args : List.of(
new String[]{"experience", "give", "Twin", "Hero", "5"},
new String[]{"experience", "give", "twin", "Hero", "5"},
new String[]{"experience", "give", "Missing", "5"},
new String[]{"experience", "give", "Hidden", "Hero", "5"},
new String[]{"experience", "give", "Account", "5"},
new String[]{"experience", "give", "Árvíztűrő", "Tükörfúrógép", "0"},
new String[]{"experience", "give", "Árvíztűrő", "Tükörfúrógép", "-1"},
new String[]{"experience", "give", "Árvíztűrő", "Tükörfúrógép", "1.5"},
new String[]{"experience", "give", "Árvíztűrő", "Tükörfúrógép", "5", "extra"},
new String[]{"experience", "give", "Árvíztűrő", "Tükörfúrógép"},
new String[]{"experience", "extra", "Árvíztűrő", "Tükörfúrógép", "5"})) {
f.command.execute(sender.port, "fantasystick", args);
}
assertEquals(queued, f.tasks.size());
assertEquals(firstBefore, storage(f).find(first.core.record.id()).get().orElseThrow());
assertEquals(secondBefore, storage(f).find(second.core.record.id()).get().orElseThrow());
assertTrue(sender.messages.stream().anyMatch(message -> message.contains("ambiguous") && message.contains("Twin Hero")));
assertTrue(sender.messages.stream().anyMatch(message -> message.contains("not active") && message.contains("Missing")));
sender.allowed = false;
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"exp"}).isEmpty());
f.command.execute(sender.port, "fantasystick", new String[]{"experience", "give", "Árvíztűrő", "Tükörfúrógép", "5"});
assertTrue(sender.messages.getLast().contains("Permission denied"));
assertEquals(BigInteger.ZERO, storage(f).find(unicode.core.record.id()).get().orElseThrow().experience());
}
}
@Test void arbitraryPrecisionGrantClampsAtCapPreservesCombatHistoryAndNeverTouchesNativeXp() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var target = new FantasyStickLifecycleNativeTest.Actor(data); var storage = storage(f);
var position = new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(java.util.UUID.randomUUID(),
java.math.BigDecimal.ONE, java.math.BigDecimal.TWO, java.math.BigDecimal.TEN);
var progressed = storage.awardCombatExperience(target.core.record.id(), target.core.record.revision(), BigInteger.TEN,
new games.dmg.fantasystick.progression.ProgressionRules(BigInteger.valueOf(40), BigInteger.valueOf(100), 30),
games.dmg.fantasystick.progression.CombatRewardRules.defaults(), position).get().record().orElseThrow();
storage.renameCharacter(target.core.record.id(), "Cap Hero").get();
f.players.put(target.core.id, target.player); f.activate(target);
var sender = new EquipmentAdminCommandNativeTest.Sender(); String huge = "9".repeat(1000);
f.command.execute(sender.port, "fantasystick", new String[]{"experience", "give", "Cap", "Hero", huge});
storage.find(target.core.record.id()).get(); f.tick();
var capped = storage.find(target.core.record.id()).get().orElseThrow();
assertEquals(30, capped.level()); assertEquals(BigInteger.valueOf(2900), capped.experience());
assertEquals(progressed.restrictedKills(), capped.restrictedKills());
assertTrue(sender.messages.getLast().contains("applied 2890"));
int feedback = target.messages.size(); int sounds = target.sounds.size();
f.command.execute(sender.port, "fantasystick", new String[]{"experience", "give", "Cap", "Hero", "1"});
storage.find(target.core.record.id()).get(); f.tick();
assertTrue(sender.messages.getLast().contains("Granted 0"));
assertEquals(feedback, target.messages.size()); assertEquals(sounds, target.sounds.size());
assertEquals(capped, storage.find(target.core.record.id()).get().orElseThrow());
}
}
@Test void revisionLogoutFailureAndRepeatedGrantRacesNeverRedirectDuplicateOrClaimFalseSuccess() throws Exception {
var staleDir = data.resolve("stale");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(staleDir)) {
f.plugin.setEnabled(true); var target = active(f, staleDir, "Before Rename"); var storage = storage(f);
var worker = (java.util.concurrent.ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(storage, "worker");
var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1);
var gate = worker.submit(() -> { entered.countDown(); try { assertTrue(release.await(5, java.util.concurrent.TimeUnit.SECONDS)); }
catch (InterruptedException interrupted) { throw new AssertionError(interrupted); } });
var sender = new EquipmentAdminCommandNativeTest.Sender(); assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
f.command.execute(sender.port, "fantasystick", new String[]{"experience", "give", "Before", "Rename", "75"});
var old = new games.dmg.fantasystick.persistence.YamlCharacterRepository(staleDir).load(target.core.record.id()).orElseThrow();
var renamed = copy(old, "After Rename", old.revision().add(BigInteger.ONE));
new games.dmg.fantasystick.persistence.YamlCharacterRepository(staleDir).replace(renamed, old.revision());
release.countDown(); gate.get(); storage.find(old.id()).get(); f.tick();
assertTrue(sender.messages.getLast().contains("changed concurrently"));
assertEquals(BigInteger.ZERO, storage.find(old.id()).get().orElseThrow().experience());
assertTrue(target.messages.stream().noneMatch(message -> message.contains("administrator")));
}
var logoutDir = data.resolve("logout");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(logoutDir)) {
f.plugin.setEnabled(true); var target = active(f, logoutDir, "Departing Hero"); var storage = storage(f);
var sender = new EquipmentAdminCommandNativeTest.Sender();
f.command.execute(sender.port, "fantasystick", new String[]{"experience", "give", "Departing", "Hero", "75"});
target.core.online = false;
storage.find(target.core.record.id()).get(); f.tick();
assertEquals(BigInteger.valueOf(75), storage.find(target.core.record.id()).get().orElseThrow().experience());
assertTrue(sender.messages.getLast().contains("applied 75"));
assertTrue(target.messages.stream().noneMatch(message -> message.contains("administrator")));
}
var repeatDir = data.resolve("repeat");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(repeatDir)) {
f.plugin.setEnabled(true); var target = active(f, repeatDir, "One Award"); var storage = storage(f);
var first = new EquipmentAdminCommandNativeTest.Sender(); var second = new EquipmentAdminCommandNativeTest.Sender();
f.command.execute(first.port, "fantasystick", new String[]{"experience", "give", "One", "Award", "25"});
f.command.execute(second.port, "fantasystick", new String[]{"experience", "give", "One", "Award", "25"});
storage.find(target.core.record.id()).get(); f.tick(); f.tick();
assertEquals(BigInteger.valueOf(25), storage.find(target.core.record.id()).get().orElseThrow().experience());
assertEquals(1, List.of(first.messages.getLast(), second.messages.getLast()).stream()
.filter(message -> message.contains("applied 25")).count());
assertEquals(1, List.of(first.messages.getLast(), second.messages.getLast()).stream()
.filter(message -> message.contains("changed concurrently")).count());
}
}
@Test void realFilesystemSaveFailureReportsNoSuccessOrPlayerFeedback() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var target = active(f, data, "Broken Save");
var sender = new EquipmentAdminCommandNativeTest.Sender();
var path = data.resolve("characters").resolve(target.core.record.id() + ".yml");
java.nio.file.Files.delete(path); java.nio.file.Files.createDirectory(path);
int feedback = target.messages.size();
f.command.execute(sender.port, "fantasystick", new String[]{"experience", "give", "Broken", "Save", "20"});
f.tick();
assertTrue(sender.messages.getLast().contains("failed before acknowledgment"));
assertEquals(feedback, target.messages.size());
assertTrue(sender.messages.stream().noneMatch(message -> message.startsWith("Granted 20")));
}
}
private static CharacterRecord copy(CharacterRecord old, String name, BigInteger revision) {
return new CharacterRecord(old.id(), name, revision, old.stats(), old.level(), old.experience(), old.unspentPoints(),
old.health(), old.learned(), old.loadout(), old.cooldowns(), old.restrictedKills(), old.dead(), old.deathId(), old.diedAt());
}
private static FantasyStickLifecycleNativeTest.Actor active(FantasyStickLifecycleNativeTest.Fixture f,
Path data, String name) throws Exception {
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
storage(f).renameCharacter(actor.core.record.id(), name).get();
f.players.put(actor.core.id, actor.player); f.activate(actor); return actor;
}
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture f) {
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
}
private static String bookText(org.bukkit.inventory.ItemStack book) {
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(book)
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
assertNotNull(content);
return content.pages().stream().map(page -> page.raw().getString()).collect(java.util.stream.Collectors.joining("\n"));
}
}
@@ -53,7 +53,7 @@ class EquipmentAdminCommandNativeTest {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var target = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(target.core.id,target.player);
var sender=new Sender();
assertEquals(List.of("config","give","inspect","recover"),f.command.tabComplete(sender.port,"fantasystick",new String[]{""}));
assertEquals(List.of("config","experience","give","inspect","recover"),f.command.tabComplete(sender.port,"fantasystick",new String[]{""}));
assertEquals(List.of("give"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"g"}));
assertEquals(List.of("Account"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"give",""}));
assertEquals(List.of("Account"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"give","Acc"}));
@@ -94,7 +94,7 @@ class EquipmentAdminCommandNativeTest {
f.players.put(first.core.id, first.player); f.players.put(second.core.id, second.player); f.players.put(offline.core.id, offline.player);
var sender = new Sender();
var expected = java.util.stream.Stream.of(first.core.id.toString(), second.core.id.toString()).sorted().toList();
assertEquals(List.of("config", "give", "inspect", "recover"), f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
assertEquals(List.of("config", "experience", "give", "inspect", "recover"), f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
assertEquals(List.of("inspect"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"in"}));
assertEquals(List.of("recover"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"rec"}));
assertEquals(expected, f.command.tabComplete(sender.port, "fantasystick", new String[]{"inspect", ""}));
@@ -1288,7 +1288,7 @@ class EquipmentSessionEventsNativeTest {
String[] abilities = {"fireball", "purify", "guardian_bond", "shield_bash", "rallying_cry", "war_banner", "revive"};
f.loadout(abilities);
var a = f.actor; f.core.activate(a);
var target = port(org.bukkit.entity.Player.class);
var target = port(org.bukkit.entity.Player.class); a.targetEntity = target;
for (int index = 0; index < abilities.length; index++) {
int slot = index + 2; a.inventory.getInventory().setSelectedSlot(slot);
var item = a.inventory.getItem(slot);
@@ -1688,6 +1688,8 @@ class EquipmentSessionsNativeTest {
Runnable beforeHealth = () -> {};
Runnable healthMutation = () -> {};
float attackStrength = 1;
boolean sneaking;
org.bukkit.entity.Entity targetEntity;
ItemStack activeItem = new ItemStack(Material.AIR);
int useStarts;
int useClears;
@@ -1723,6 +1725,8 @@ class EquipmentSessionsNativeTest {
case "setGameMode" -> { beforeMode.run(); gameMode = (org.bukkit.GameMode) args[0]; yield null; }
case "getHealth" -> health;
case "getAttackCooldown" -> attackStrength;
case "isSneaking" -> sneaking;
case "getTargetEntity" -> targetEntity;
case "getActiveItem" -> activeItem.clone();
case "hasActiveItem" -> !activeItem.isEmpty();
case "getActiveItemHand" -> org.bukkit.inventory.EquipmentSlot.HAND;
@@ -348,7 +348,7 @@ class LongbowNativeTest {
assertThrows(AssertionError.class, () -> abilityButton(unavailable, "[Learn]", "longbow"));
var sender = new EquipmentAdminCommandNativeTest.Sender();
assertEquals(java.util.List.of("config", "give", "inspect", "recover"),
assertEquals(java.util.List.of("config", "experience", "give", "inspect", "recover"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
assertEquals(java.util.List.of("config"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"c"}));
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"unknown"}).isEmpty());
@@ -0,0 +1,356 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import games.dmg.fantasystick.ability.AbilityExecution;
import games.dmg.fantasystick.ability.MendingLight;
import games.dmg.fantasystick.party.PartyRoster;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.persistence.MendingLightSettings;
import games.dmg.fantasystick.persistence.YamlCharacterRepository;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import net.minecraft.network.chat.ClickEvent;
import org.bukkit.Location;
import org.bukkit.attribute.Attribute;
import org.bukkit.attribute.AttributeInstance;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDeathEvent;
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 MendingLightNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void bookAcquisitionAimedPartyHealPersistsCapsAndKeepsCooldownAcrossReloadAndRelearn() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var caster = new FantasyStickLifecycleNativeTest.Actor(data);
var ally = new FantasyStickLifecycleNativeTest.Actor(data);
prepareCaster(data, caster, false);
setHealth(data, ally, BigDecimal.valueOf(4));
fixture.players.put(caster.core.id, caster.player);
fixture.players.put(ally.core.id, ally.player);
fixture.activate(caster); fixture.activate(ally); party(fixture, caster, ally); aim(caster, ally, 8, true);
caster.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, caster);
String text = PartyBookNativeTest.text(caster).replaceAll("\\s+", "");
assertTrue(text.contains("MendingLight")); assertTrue(text.contains("WIS12"));
assertTrue(text.contains("CHR10")); assertTrue(text.contains("Cost:1"));
assertTrue(text.contains("12")); assertTrue(text.contains("8"));
PartyBookNativeTest.click(fixture, caster, button(caster, "[Learn]", "mending_light"));
drain(fixture, 2);
PartyBookNativeTest.click(fixture, caster, button(caster, "[3]", "mending_light"));
drain(fixture, 2);
use(fixture, caster); drain(fixture, 3);
var storage = storage(fixture);
var healed = storage.find(ally.core.record.id()).get().orElseThrow();
assertEquals(0, BigDecimal.TEN.compareTo(healed.health()), "6 + WIS modifier caps at ally maximum");
assertEquals(10, ally.player.getHealth());
var deadline = storage.find(caster.core.record.id()).get().orElseThrow().cooldowns().get("mending_light");
assertNotNull(deadline);
caster.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, caster);
var unlearn = button(caster, "[Unlearn]", "mending_light");
PartyBookNativeTest.click(fixture, caster, unlearn); PartyBookNativeTest.click(fixture, caster, unlearn);
drain(fixture, 2);
assertEquals(deadline, storage.find(caster.core.record.id()).get().orElseThrow().cooldowns().get("mending_light"));
learnAndAssign(fixture, caster); use(fixture, caster);
assertTrue(caster.messages.stream().anyMatch(message -> message.contains("ready in")));
var allySheet = ally.core.inventory.getItemInOffHand().clone();
ally.core.inventory.setItemInOffHand(null); fixture.changed(ally); drain(fixture, 1); fixture.awaitCharacterAndReceipt(ally);
var resumed = new FantasyStickLifecycleNativeTest.Actor(data);
resumed.core.inventory.setItemInOffHand(allySheet); fixture.players.put(resumed.core.id, resumed.player); fixture.activate(resumed);
assertEquals(10, resumed.player.getHealth(), "Healed health survives exit, transfer and resume");
}
}
@Test void actualHealingCreatesOnePersistedSupportShareForAnAllyDirectContributor() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var caster = new FantasyStickLifecycleNativeTest.Actor(data);
var ally = new FantasyStickLifecycleNativeTest.Actor(data);
prepareCaster(data, caster, true); setHealth(data, ally, BigDecimal.valueOf(4));
fixture.players.put(caster.core.id, caster.player); fixture.players.put(ally.core.id, ally.player);
fixture.activate(caster); fixture.activate(ally); party(fixture, caster, ally); aim(caster, ally, 8, true);
use(fixture, caster); drain(fixture, 3);
var sourceSheet = caster.core.inventory.getItemInOffHand().clone();
caster.core.inventory.setItemInOffHand(null); fixture.changed(caster); drain(fixture, 1); fixture.awaitCharacterAndReceipt(caster);
var currentHolder = new FantasyStickLifecycleNativeTest.Actor(data);
currentHolder.core.inventory.setItemInOffHand(sourceSheet);
fixture.players.put(currentHolder.core.id, currentHolder.player); fixture.activate(currentHolder);
var enemy = mock(LivingEntity.class);
when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(caster.world.getUID()).thenReturn(UUID.randomUUID());
when(enemy.getLocation()).thenReturn(new Location(caster.world, 8, 64, 0));
var source = org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK)
.withCausingEntity(ally.player).withDirectEntity(ally.player).build();
fixture.dispatcher.accept(new EntityDamageByEntityEvent(ally.player, enemy,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 3));
fixture.dispatcher.accept(new EntityDeathEvent(enemy, source, new ArrayList<>(), 0));
drain(fixture, 4);
assertEquals(BigInteger.valueOf(5), storage(fixture).find(caster.core.record.id()).get().orElseThrow().experience());
assertEquals(BigInteger.valueOf(5), storage(fixture).find(ally.core.record.id()).get().orElseThrow().experience());
assertTrue(currentHolder.messages.stream().anyMatch(message -> message.contains("5") && message.contains("XP")),
"Effective healing follows the living source character through transfer");
}
}
@Test void selfOrdinaryNonPartyFullHealthRangeAndLineOfSightRefuseBeforeCooldown() throws Exception {
for (int route = 0; route < 6; route++) {
var directory = data.resolve("refusal-" + route);
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
fixture.plugin.setEnabled(true);
var caster = new FantasyStickLifecycleNativeTest.Actor(directory); prepareCaster(directory, caster, true);
fixture.players.put(caster.core.id, caster.player); fixture.activate(caster);
Player target;
if (route == 0) target = caster.player;
else if (route == 1) { target = mock(Player.class); when(target.getUniqueId()).thenReturn(UUID.randomUUID()); }
else {
var ally = new FantasyStickLifecycleNativeTest.Actor(directory);
setHealth(directory, ally, route == 3 ? BigDecimal.TEN : BigDecimal.valueOf(4));
fixture.players.put(ally.core.id, ally.player); fixture.activate(ally); target = ally.player;
if (route != 2) party(fixture, caster, ally);
}
aim(caster, target, route == 4 ? 13 : 8, route != 5);
use(fixture, caster); drain(fixture, 2);
assertFalse(storage(fixture).find(caster.core.record.id()).get().orElseThrow()
.cooldowns().containsKey("mending_light"), "route " + route + " must refuse before cooldown");
}
}
}
@Test void cancelledHealingConsumesCooldownButAppliesNoHealthOrSupport() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var caster = new FantasyStickLifecycleNativeTest.Actor(data);
var ally = new FantasyStickLifecycleNativeTest.Actor(data);
prepareCaster(data, caster, true); setHealth(data, ally, BigDecimal.valueOf(4));
fixture.players.put(caster.core.id, caster.player); fixture.players.put(ally.core.id, ally.player);
fixture.activate(caster); fixture.activate(ally); party(fixture, caster, ally); aim(caster, ally, 8, true);
class Veto implements org.bukkit.event.Listener {
@org.bukkit.event.EventHandler public void heal(EntityRegainHealthEvent event) {
if (event.getEntity() == ally.player) event.setCancelled(true);
}
}
fixture.dispatcher.register(new Veto(), fixture.plugin);
use(fixture, caster); drain(fixture, 3);
assertEquals(4, ally.player.getHealth());
assertNotNull(storage(fixture).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("mending_light"));
}
}
@Test void targetSaveFailureOrSessionReplacementPublishesNoNativeHealingOrSupport() {
var characters = mock(CharacterStorage.class); var sessions = mock(EquipmentSessions.class);
var parties = mock(PartyRoster.class); var source = mock(Player.class); var target = mock(Player.class);
var attribute = mock(AttributeInstance.class); when(target.getAttribute(Attribute.MAX_HEALTH)).thenReturn(attribute);
when(attribute.getValue()).thenReturn(10.0); when(target.getHealth()).thenReturn(4.0);
when(source.getUniqueId()).thenReturn(UUID.randomUUID()); when(target.getUniqueId()).thenReturn(UUID.randomUUID());
var world = mock(org.bukkit.World.class);
when(source.getLocation()).thenReturn(new Location(world, 0, 64, 0));
when(target.getLocation()).thenReturn(new Location(world, 8, 64, 0));
when(source.hasLineOfSight(target)).thenReturn(true);
var casterRecord = recordWith(UUID.randomUUID(), "mending_light", BigDecimal.TEN);
var targetRecord = recordWith(UUID.randomUUID(), null, BigDecimal.valueOf(4));
var casterActive = new EquipmentSessions.Active(source.getUniqueId(), UUID.randomUUID(), casterRecord.id());
var targetActive = new EquipmentSessions.Active(target.getUniqueId(), UUID.randomUUID(), targetRecord.id());
when(sessions.active(target.getUniqueId())).thenReturn(java.util.Optional.of(targetActive));
when(sessions.current(targetActive)).thenReturn(true); when(sessions.currentRecord(targetActive)).thenReturn(java.util.Optional.of(targetRecord));
var party = new PartyRoster.Party(UUID.randomUUID(), source.getUniqueId(), List.of(source.getUniqueId(), target.getUniqueId()));
when(parties.partyOf(source.getUniqueId())).thenReturn(java.util.Optional.of(party));
when(parties.partyOf(target.getUniqueId())).thenReturn(java.util.Optional.of(party));
var input = mock(ControlInput.class); when(input.player()).thenReturn(source); when(input.target()).thenReturn(target); when(input.session()).thenReturn(casterActive);
var execution = mock(AbilityExecution.class); when(execution.stillCurrent()).thenReturn(true);
when(execution.input()).thenReturn(input); when(execution.character()).thenReturn(casterRecord);
var supports = new ArrayList<MendingLight.Support>();
var effect = new MendingLight(MendingLight.Settings.defaults(), characters, sessions, parties, Runnable::run,
ignored -> {}, () -> BigDecimal.valueOf(1024), supports::add);
when(execution.definition()).thenReturn(effect.definition());
var failed = new CompletableFuture<java.util.Optional<CharacterRecord>>();
when(characters.saveHealth(eq(targetRecord.id()), eq(targetRecord.revision()), any(), any())).thenReturn(failed);
effect.activate(execution); failed.completeExceptionally(new java.io.IOException("simulated"));
verify(target, never()).setHealth(anyDouble()); assertTrue(supports.isEmpty()); verify(execution).finish();
var acknowledged = new CharacterRecord(targetRecord.id(), targetRecord.name(), targetRecord.revision().add(BigInteger.ONE),
targetRecord.stats(), targetRecord.level(), targetRecord.experience(), targetRecord.unspentPoints(), BigDecimal.TEN,
targetRecord.learned(), targetRecord.loadout(), targetRecord.cooldowns(), targetRecord.restrictedKills(), false, null, null);
var delayed = new CompletableFuture<java.util.Optional<CharacterRecord>>();
when(characters.saveHealth(eq(targetRecord.id()), eq(targetRecord.revision()), any(), any())).thenReturn(delayed);
when(sessions.current(targetActive)).thenReturn(true);
effect.activate(execution);
when(sessions.current(targetActive)).thenReturn(false);
delayed.complete(java.util.Optional.of(acknowledged));
verify(target, never()).setHealth(anyDouble()); assertTrue(supports.isEmpty());
verify(sessions, never()).acknowledgeProgress(eq(targetActive), any(), any());
verify(execution, times(2)).finish();
}
@Test void configuredDefinitionAdminRestartAndFutureCastCoverEverySetting() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"mending-light-base-healing: 8\nmending-light-cooldown-seconds: 0\nmending-light-range-blocks: 16\n");
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry");
var definition = registry.find("mending_light").orElseThrow();
assertEquals(java.time.Duration.ZERO, definition.cooldown());
assertEquals(new BigDecimal("8"), definition.parameters().get("base-healing"));
assertEquals(0, new BigDecimal("16").compareTo(definition.parameters().get("range")));
var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed = false;
assertTrue(fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "mending-light-"}).isEmpty());
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "mending-light-base-healing", "9"});
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
assertEquals(List.of("mending-light-base-healing", "mending-light-cooldown-seconds", "mending-light-range-blocks"),
fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "mending-light-"}));
assertEquals(List.of("6", "8"), fixture.command.tabComplete(sender.port, "fantasystick",
new String[]{"config", "mending-light-base-healing", ""}));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "mending-light-base-healing"});
assertTrue(sender.messages.getLast().contains("=8"));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "mending-light-base-healing", "9"});
var response = fixture.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(response); response.run();
assertEquals(new BigDecimal("9"), registry.find("mending_light").orElseThrow().parameters().get("base-healing"));
var caster = new FantasyStickLifecycleNativeTest.Actor(data); var ally = new FantasyStickLifecycleNativeTest.Actor(data);
prepareCaster(data, caster, true); setHealth(data, ally, BigDecimal.valueOf(1));
fixture.players.put(caster.core.id, caster.player); fixture.players.put(ally.core.id, ally.player);
fixture.activate(caster); fixture.activate(ally); party(fixture, caster, ally); aim(caster, ally, 15, true);
use(fixture, caster); drain(fixture, 3); assertEquals(10, ally.player.getHealth());
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "mending-light-base-healing", "-1"});
assertTrue(sender.messages.getLast().contains("invalid"));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "mending-light-base-healing", "9", "extra"});
assertTrue(sender.messages.getLast().contains("Usage"));
fixture.plugin.setEnabled(false); fixture.plugin.setEnabled(true);
var reloaded = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry");
assertEquals(new BigDecimal("9"), reloaded.find("mending_light").orElseThrow().parameters().get("base-healing"));
}
for (String invalid : List.of("mending-light-base-healing: -1\n", "mending-light-cooldown-seconds: -1\n",
"mending-light-range-blocks: 0\n")) {
var directory = data.resolve(Integer.toHexString(invalid.hashCode())); java.nio.file.Files.createDirectories(directory);
java.nio.file.Files.writeString(directory.resolve("config.yml"), invalid);
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
assertThrows(IllegalArgumentException.class, () -> fixture.plugin.setEnabled(true)); assertTrue(fixture.order.isEmpty());
}
}
}
@Test void exactFormulaSupportsUncappedWisdomAndFloorDivision() {
assertEquals(BigInteger.valueOf(7), MendingLight.healing(BigInteger.valueOf(12)));
assertEquals(BigInteger.valueOf(5), MendingLight.healing(BigInteger.valueOf(9)));
var huge = BigInteger.TEN.pow(500);
assertEquals(huge.subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(6)), MendingLight.healing(huge));
}
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, button(actor, "[Learn]", "mending_light")); drain(fixture, 2);
PartyBookNativeTest.click(fixture, actor, button(actor, "[3]", "mending_light")); drain(fixture, 2);
}
private static PartyRoster.Party party(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor caster,
FantasyStickLifecycleNativeTest.Actor ally) {
var parties = PartyLifecycleNativeTest.parties(fixture); var group = parties.create(caster.core.id).orElseThrow();
assertTrue(parties.accept(ally.core.id, parties.invite(caster.core.id, group.id(), ally.core.id).orElseThrow().id()));
return parties.partyOf(caster.core.id).orElseThrow();
}
private static void aim(FantasyStickLifecycleNativeTest.Actor caster,
FantasyStickLifecycleNativeTest.Actor target, double distance, boolean visible) {
aim(caster, target.player, distance, visible);
}
private static void aim(FantasyStickLifecycleNativeTest.Actor caster, Player target, double distance, boolean visible) {
doReturn(new Location(caster.world, 0, 64, 0)).when(caster.player).getLocation();
doReturn(new Location(caster.world, distance, 64, 0)).when(target).getLocation();
doReturn(visible).when(caster.player).hasLineOfSight(target);
doReturn(target).when(caster.player).getTargetEntity(64);
}
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 void drain(FantasyStickLifecycleNativeTest.Fixture fixture, int count) throws Exception {
for (int index = 0; index < count; index++) PartyBookNativeTest.drain(fixture);
}
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture fixture) {
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
}
private static ClickEvent.Custom button(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 = button(page.raw(), label, ability); if (found != null) return found; }
throw new AssertionError("Missing " + label + " for " + ability);
}
private static ClickEvent.Custom button(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 = button(child, label, ability); if (found != null) return found; }
return null;
}
private static void prepareCaster(Path directory, FantasyStickLifecycleNativeTest.Actor actor, boolean learned) throws Exception {
java.nio.file.Files.createDirectories(directory); var repository = new YamlCharacterRepository(directory);
var old = repository.load(actor.core.record.id()).orElseThrow(); var stats = new EnumMap<>(old.stats());
stats.put(Stat.WIS, BigInteger.valueOf(12)); stats.put(Stat.CHR, BigInteger.TEN);
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 ? Set.of("mending_light") : old.learned(),
learned ? List.of("mending_light", "", "", "", "", "", "") : old.loadout(),
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
}
private static void setHealth(Path directory, FantasyStickLifecycleNativeTest.Actor actor, BigDecimal health) throws Exception {
java.nio.file.Files.createDirectories(directory); var repository = new YamlCharacterRepository(directory);
var old = repository.load(actor.core.record.id()).orElseThrow();
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
old.level(), old.experience(), old.unspentPoints(), health, old.learned(), old.loadout(), old.cooldowns(),
old.restrictedKills(), false, null, null), old.revision());
}
private static CharacterRecord recordWith(UUID id, String ability, BigDecimal health) {
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var stats = new EnumMap<>(base.stats()); stats.put(Stat.WIS, BigInteger.valueOf(12)); stats.put(Stat.CHR, BigInteger.TEN);
return new CharacterRecord(id, base.name(), base.revision(), stats, base.level(), base.experience(), base.unspentPoints(),
health, ability == null ? Set.of() : Set.of(ability), ability == null ? base.loadout() : List.of(ability, "", "", "", "", "", ""),
base.cooldowns(), base.restrictedKills(), false, null, null);
}
}
@@ -0,0 +1,306 @@
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.persistence.YamlCharacterRepository;
import games.dmg.fantasystick.progression.Stat;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import net.minecraft.network.chat.ClickEvent;
import net.minecraft.world.effect.MobEffectInstance;
import org.bukkit.Location;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDeathEvent;
import org.bukkit.event.entity.EntityPotionEffectEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class PurifyNativeTest {
private static List<PotionEffectType> harmful;
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception {
NativeRuntime.bootstrap();
harmful = List.of(PotionEffectType.POISON, PotionEffectType.WITHER,
PotionEffectType.SLOWNESS, PotionEffectType.WEAKNESS);
}
@Test void bookAcquisitionSneakUseRemovesEveryHarmfulLayerAndPreservesBeneficialEffectsAndCooldown() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data); prepare(data, actor, false);
fixture.players.put(actor.core.id, actor.player); fixture.activate(actor);
var effects = effects(fixture, actor.player);
harmful.forEach(type -> effects.put(type, layered(type)));
effects.put(PotionEffectType.REGENERATION, instance(PotionEffectType.REGENERATION, 200, 1));
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
String book = PartyBookNativeTest.text(actor).replaceAll("\\s+", "");
assertTrue(book.contains("Purify")); assertTrue(book.contains("WIS14")); assertTrue(book.contains("CHR10"));
assertTrue(book.contains("Cost:1")); assertTrue(book.contains("15")); assertTrue(book.contains("12"));
PartyBookNativeTest.click(fixture, actor, button(actor, "[Learn]", "purify")); drain(fixture, 2);
PartyBookNativeTest.click(fixture, actor, button(actor, "[3]", "purify")); drain(fixture, 2);
doReturn(true).when(actor.player).isSneaking(); use(fixture, actor); drain(fixture, 2);
harmful.forEach(type -> assertFalse(effects.containsKey(type), type + " and its hidden chain must be gone"));
assertTrue(effects.containsKey(PotionEffectType.REGENERATION));
var storage = storage(fixture); var saved = storage.find(actor.core.record.id()).get().orElseThrow();
var deadline = saved.cooldowns().get("purify"); assertNotNull(deadline);
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
var unlearn = button(actor, "[Unlearn]", "purify");
PartyBookNativeTest.click(fixture, actor, unlearn); PartyBookNativeTest.click(fixture, actor, unlearn); drain(fixture, 2);
assertEquals(deadline, storage.find(actor.core.record.id()).get().orElseThrow().cooldowns().get("purify"));
learnAndAssign(fixture, actor); use(fixture, actor);
assertTrue(actor.messages.stream().anyMatch(message -> message.contains("ready in")));
}
}
@Test void aimedAllyActualCleanseCreatesPersistedSupportShareThatFollowsSourceCharacter() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var caster = new FantasyStickLifecycleNativeTest.Actor(data); var ally = new FantasyStickLifecycleNativeTest.Actor(data);
prepare(data, caster, true); fixture.players.put(caster.core.id, caster.player); fixture.players.put(ally.core.id, ally.player);
fixture.activate(caster); fixture.activate(ally); party(fixture, caster, ally); aim(caster, ally.player, 8, true);
var effects = effects(fixture, ally.player); effects.put(PotionEffectType.POISON, layered(PotionEffectType.POISON));
doReturn(false).when(caster.player).isSneaking(); use(fixture, caster); drain(fixture, 2);
assertFalse(effects.containsKey(PotionEffectType.POISON));
var sheet = caster.core.inventory.getItemInOffHand().clone(); caster.core.inventory.setItemInOffHand(null);
fixture.changed(caster); drain(fixture, 1); fixture.awaitCharacterAndReceipt(caster);
var holder = new FantasyStickLifecycleNativeTest.Actor(data); holder.core.inventory.setItemInOffHand(sheet);
fixture.players.put(holder.core.id, holder.player); fixture.activate(holder);
var enemy = mock(LivingEntity.class); when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(caster.world.getUID()).thenReturn(UUID.randomUUID()); when(enemy.getLocation()).thenReturn(new Location(caster.world, 8, 64, 0));
var source = org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK)
.withCausingEntity(ally.player).withDirectEntity(ally.player).build();
fixture.dispatcher.accept(new EntityDamageByEntityEvent(ally.player, enemy,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 3));
fixture.dispatcher.accept(new EntityDeathEvent(enemy, source, new ArrayList<>(), 0)); drain(fixture, 4);
assertEquals(BigInteger.valueOf(5), storage(fixture).find(caster.core.record.id()).get().orElseThrow().experience());
assertEquals(BigInteger.valueOf(5), storage(fixture).find(ally.core.record.id()).get().orElseThrow().experience());
assertTrue(holder.messages.stream().anyMatch(message -> message.contains("5") && message.contains("XP")));
}
}
@Test void normalSelfOrdinaryNonPartyOutOfRangeObstructedAndNoOpTargetsRefuseBeforeCooldown() throws Exception {
for (int route = 0; route < 6; route++) {
var directory = data.resolve("refusal-" + route);
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
fixture.plugin.setEnabled(true);
var caster = new FantasyStickLifecycleNativeTest.Actor(directory); prepare(directory, caster, true);
fixture.players.put(caster.core.id, caster.player); fixture.activate(caster);
Player target;
if (route == 0) { target = caster.player; doReturn(false).when(caster.player).isSneaking(); }
else if (route == 1) { target = mock(Player.class); when(target.getUniqueId()).thenReturn(UUID.randomUUID()); }
else {
var ally = new FantasyStickLifecycleNativeTest.Actor(directory); fixture.players.put(ally.core.id, ally.player);
fixture.activate(ally); target = ally.player; effects(fixture, ally.player);
if (route != 2) party(fixture, caster, ally);
}
if (route != 5 && route > 1) effectMap(target).put(PotionEffectType.WEAKNESS, instance(PotionEffectType.WEAKNESS, 200, 0));
aim(caster, target, route == 3 ? 13 : 8, route != 4); doReturn(false).when(caster.player).isSneaking();
use(fixture, caster); drain(fixture, 2);
assertFalse(storage(fixture).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("purify"),
"route " + route + " must refuse before cooldown");
}
}
}
@Test void cancelledRemovalAndSelfCleanseCreateNoSupportWhileAcknowledgedCooldownRemains() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var caster = new FantasyStickLifecycleNativeTest.Actor(data); var ally = new FantasyStickLifecycleNativeTest.Actor(data);
prepare(data, caster, true); fixture.players.put(caster.core.id, caster.player); fixture.players.put(ally.core.id, ally.player);
fixture.activate(caster); fixture.activate(ally); party(fixture, caster, ally); aim(caster, ally.player, 8, true);
var effects = effects(fixture, ally.player); effects.put(PotionEffectType.POISON, layered(PotionEffectType.POISON));
class Veto implements org.bukkit.event.Listener {
@org.bukkit.event.EventHandler public void removal(EntityPotionEffectEvent event) {
if (event.getEntity() == ally.player && event.getAction() == EntityPotionEffectEvent.Action.REMOVED) event.setCancelled(true);
}
}
fixture.dispatcher.register(new Veto(), fixture.plugin); doReturn(false).when(caster.player).isSneaking();
use(fixture, caster); drain(fixture, 2);
assertTrue(effects.containsKey(PotionEffectType.POISON));
assertNotNull(storage(fixture).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("purify"));
}
}
@Test void selfCleanseNeverEmitsSupportEvidence() {
var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class);
var parties = mock(games.dmg.fantasystick.party.PartyRoster.class); var player = mock(Player.class);
var active = new games.dmg.fantasystick.session.EquipmentSessions.Active(UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID());
when(player.getUniqueId()).thenReturn(active.player()); when(player.isSneaking()).thenReturn(true);
when(sessions.active(active.player())).thenReturn(java.util.Optional.of(active)); when(sessions.current(active)).thenReturn(true);
var poison = new java.util.concurrent.atomic.AtomicBoolean(true);
when(player.getPotionEffect(PotionEffectType.POISON)).thenAnswer(ignored -> poison.get()
? new PotionEffect(PotionEffectType.POISON, 200, 0) : null);
doAnswer(ignored -> { poison.set(false); return null; }).when(player).removePotionEffect(PotionEffectType.POISON);
var input = mock(games.dmg.fantasystick.session.ControlInput.class);
when(input.player()).thenReturn(player); when(input.target()).thenReturn(player); when(input.session()).thenReturn(active);
var supports = new ArrayList<games.dmg.fantasystick.ability.Purify.Support>();
var purify = new games.dmg.fantasystick.ability.Purify(
games.dmg.fantasystick.ability.Purify.Settings.defaults(), sessions, parties, supports::add);
var execution = mock(games.dmg.fantasystick.ability.AbilityExecution.class);
when(execution.stillCurrent()).thenReturn(true); when(execution.input()).thenReturn(input);
when(execution.definition()).thenReturn(purify.definition());
purify.activate(execution);
assertFalse(poison.get()); assertTrue(supports.isEmpty()); verify(player).removePotionEffect(PotionEffectType.POISON);
}
@Test void configuredDefinitionAdminRestartAndFutureCastCoverEverySetting() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "purify-cooldown-seconds: 0\npurify-range-blocks: 16\n");
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry");
var definition = registry.find("purify").orElseThrow(); assertEquals(java.time.Duration.ZERO, definition.cooldown());
assertEquals(0, new BigDecimal("16").compareTo(definition.parameters().get("range")));
var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed = false;
assertTrue(fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "purify-"}).isEmpty());
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "purify-range-blocks", "20"});
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
assertEquals(List.of("purify-cooldown-seconds", "purify-range-blocks"),
fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "purify-"}));
assertEquals(List.of("12", "16"), fixture.command.tabComplete(sender.port, "fantasystick",
new String[]{"config", "purify-range-blocks", ""}));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "purify-range-blocks"});
assertTrue(sender.messages.getLast().contains("=16"));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "purify-range-blocks", "20"});
var response = fixture.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(response); response.run();
assertEquals(0, new BigDecimal("20").compareTo(registry.find("purify").orElseThrow().parameters().get("range")));
var caster = new FantasyStickLifecycleNativeTest.Actor(data); var ally = new FantasyStickLifecycleNativeTest.Actor(data);
prepare(data, caster, true); fixture.players.put(caster.core.id, caster.player); fixture.players.put(ally.core.id, ally.player);
fixture.activate(caster); fixture.activate(ally); party(fixture, caster, ally); aim(caster, ally.player, 19, true);
effects(fixture, ally.player).put(PotionEffectType.SLOWNESS, instance(PotionEffectType.SLOWNESS, 200, 0));
doReturn(false).when(caster.player).isSneaking(); use(fixture, caster); drain(fixture, 2);
assertNull(ally.player.getPotionEffect(PotionEffectType.SLOWNESS));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "purify-range-blocks", "0"});
assertTrue(sender.messages.getLast().contains("invalid"));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "purify-range-blocks", "20", "extra"});
assertTrue(sender.messages.getLast().contains("Usage"));
fixture.plugin.setEnabled(false); fixture.plugin.setEnabled(true);
var reloaded = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry");
assertEquals(0, new BigDecimal("20").compareTo(reloaded.find("purify").orElseThrow().parameters().get("range")));
}
for (String invalid : List.of("purify-cooldown-seconds: -1\n", "purify-range-blocks: 0\n")) {
var directory = data.resolve(Integer.toHexString(invalid.hashCode())); java.nio.file.Files.createDirectories(directory);
java.nio.file.Files.writeString(directory.resolve("config.yml"), invalid);
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
assertThrows(IllegalArgumentException.class, () -> fixture.plugin.setEnabled(true)); assertTrue(fixture.order.isEmpty());
}
}
}
private static final Map<Player, Map<PotionEffectType, MobEffectInstance>> EFFECTS = new java.util.WeakHashMap<>();
private static Map<PotionEffectType, MobEffectInstance> effects(FantasyStickLifecycleNativeTest.Fixture fixture, Player player) {
var active = new HashMap<PotionEffectType, MobEffectInstance>(); EFFECTS.put(player, active);
doAnswer(call -> {
var effect = active.get(call.getArgument(0));
return effect == null ? null : new PotionEffect(call.getArgument(0), effect.getDuration(), effect.getAmplifier());
}).when(player).getPotionEffect(any());
doAnswer(call -> {
PotionEffectType type = call.getArgument(0); var current = active.get(type);
if (current == null) return null;
var old = new PotionEffect(type, current.getDuration(), current.getAmplifier());
var event = new EntityPotionEffectEvent(player, old, null, null, EntityPotionEffectEvent.Cause.MILK,
EntityPotionEffectEvent.Action.REMOVED, false);
fixture.dispatcher.accept(event); if (!event.isCancelled()) active.remove(type); return null;
}).when(player).removePotionEffect(any());
return active;
}
private static Map<PotionEffectType, MobEffectInstance> effectMap(Player player) {
return EFFECTS.computeIfAbsent(player, ignored -> new HashMap<>());
}
private static MobEffectInstance layered(PotionEffectType type) {
var current = instance(type, 200, 0); current.update(instance(type, 40, 1));
assertNotNull(current.hiddenEffect); return current;
}
private static MobEffectInstance instance(PotionEffectType type, int duration, int amplifier) {
return new MobEffectInstance(org.bukkit.craftbukkit.potion.CraftPotionEffectType.bukkitToMinecraftHolder(type), duration, amplifier);
}
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, button(actor, "[Learn]", "purify")); drain(fixture, 2);
PartyBookNativeTest.click(fixture, actor, button(actor, "[3]", "purify")); drain(fixture, 2);
}
private static void party(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor caster,
FantasyStickLifecycleNativeTest.Actor ally) {
var parties = PartyLifecycleNativeTest.parties(fixture); var group = parties.create(caster.core.id).orElseThrow();
assertTrue(parties.accept(ally.core.id, parties.invite(caster.core.id, group.id(), ally.core.id).orElseThrow().id()));
}
private static void aim(FantasyStickLifecycleNativeTest.Actor caster, Player target, double distance, boolean visible) {
doReturn(new Location(caster.world, 0, 64, 0)).when(caster.player).getLocation();
doReturn(new Location(caster.world, distance, 64, 0)).when(target).getLocation();
doReturn(visible).when(caster.player).hasLineOfSight(target); doReturn(target).when(caster.player).getTargetEntity(64);
}
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 void drain(FantasyStickLifecycleNativeTest.Fixture fixture, int count) throws Exception {
for (int index = 0; index < count; index++) PartyBookNativeTest.drain(fixture);
}
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture fixture) {
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
}
private static ClickEvent.Custom button(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 = button(page.raw(), label, ability); if (found != null) return found; }
throw new AssertionError("Missing " + label + " for " + ability);
}
private static ClickEvent.Custom button(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 = button(child, label, ability); if (found != null) return found; }
return null;
}
private static void prepare(Path directory, FantasyStickLifecycleNativeTest.Actor actor, boolean learned) throws Exception {
java.nio.file.Files.createDirectories(directory); var repository = new YamlCharacterRepository(directory);
var old = repository.load(actor.core.record.id()).orElseThrow(); var stats = new EnumMap<>(old.stats());
stats.put(Stat.WIS, BigInteger.valueOf(14)); stats.put(Stat.CHR, BigInteger.TEN);
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 ? Set.of("purify") : old.learned(), learned ? List.of("purify", "", "", "", "", "", "") : old.loadout(),
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
}
}
@@ -202,7 +202,7 @@ class QuickstepNativeTest {
assertEquals(0, new java.math.BigDecimal("6").compareTo(definition.parameters().get("distance")));
assertEquals(0, new java.math.BigDecimal("1.5").compareTo(definition.parameters().get("speed")));
var sender = new EquipmentAdminCommandNativeTest.Sender();
assertEquals(List.of("config", "give", "inspect", "recover"),
assertEquals(List.of("config", "experience", "give", "inspect", "recover"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
assertEquals(List.of("config"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"co"}));
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"unsupported"}).isEmpty());
@@ -130,7 +130,7 @@ class SecondWindNativeTest {
assertEquals(BigInteger.TEN.pow(500).subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(6)),
games.dmg.fantasystick.ability.SecondWind.healing(BigInteger.TEN.pow(500)));
var sender = new EquipmentAdminCommandNativeTest.Sender();
assertEquals(List.of("config", "give", "inspect", "recover"),
assertEquals(List.of("config", "experience", "give", "inspect", "recover"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
sender.allowed = false;
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "second"}).isEmpty());
@@ -2,7 +2,7 @@ package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;import static org.mockito.ArgumentMatchers.*;import static org.mockito.Mockito.*;import java.nio.file.Path;import java.util.*;import net.kyori.adventure.text.Component;import org.bukkit.event.player.PlayerResourcePackStatusEvent;import org.junit.jupiter.api.BeforeAll;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.io.TempDir;
class SheetResourcePackNativeTest{@TempDir Path data;@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
record Request(UUID id,String url,byte[] hash,String prompt,boolean required){}
@Test void packagedStartupJoinAndStatusesUseOptionalNativeDeliveryWithoutGameplayMutation()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){var a=new FantasyStickLifecycleNativeTest.Actor(data);a.core.inventory.setItemInOffHand(null);f.players.put(a.core.id,a.player);var before=a.core.snapshot();f.plugin.setEnabled(true);f.awaitCharacterAndReceipt(a);PartyBookNativeTest.drain(f);assertEquals(1,a.resourcePacks.size());var request=request(a.resourcePacks.getFirst());assertEquals(games.dmg.fantasystick.presentation.SheetResourcePack.ID,request.id());assertTrue(request.url().contains("/v0.1.0-SNAPSHOT/purpur-fantasy-stick-0.1.0-SNAPSHOT-resource-pack.zip"));assertEquals(20,request.hash().length);assertFalse(request.required());assertArrayEquals(before,a.core.snapshot());for(var status:List.of(PlayerResourcePackStatusEvent.Status.DECLINED,PlayerResourcePackStatusEvent.Status.FAILED_DOWNLOAD,PlayerResourcePackStatusEvent.Status.INVALID_URL,PlayerResourcePackStatusEvent.Status.FAILED_RELOAD,PlayerResourcePackStatusEvent.Status.DISCARDED)){f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),status));assertTrue(a.messages.getLast().toLowerCase().contains("cosmetic"));assertArrayEquals(before,a.core.snapshot());}int messages=a.messages.size();f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.ACCEPTED));f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.DOWNLOADED));f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.SUCCESSFULLY_LOADED));assertEquals(messages,a.messages.size());var active=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(active.core.id,active.player);var legacyMeta=active.core.inventory.getItemInOffHand().getItemMeta();legacyMeta.setItemModel(null);active.core.inventory.getItemInOffHand().setItemMeta(legacyMeta);var activeBefore=active.core.snapshot();var expectedMeta=activeBefore[40].getItemMeta();expectedMeta.setItemModel(games.dmg.fantasystick.items.CharacterSheets.MODEL);activeBefore[40].setItemMeta(expectedMeta);var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");sessions.enter(active.player);awaitPacked(f,active);assertEquals(games.dmg.fantasystick.items.CharacterSheets.MODEL,active.core.inventory.getItemInOffHand().getItemMeta().getItemModel());sessions.exit(active.player);f.awaitCharacterAndReceipt(active);assertArrayEquals(activeBefore,active.core.snapshot());var retained=(games.dmg.fantasystick.presentation.SheetResourcePack)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sheetResourcePack");f.plugin.setEnabled(false);retained.status(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.DECLINED));assertEquals(messages,a.messages.size());f.plugin.setEnabled(true);var late=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(late.core.id,late.player);f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(late.player,(Component)null));assertEquals(1,late.resourcePacks.size());}}
@Test void packagedStartupJoinAndStatusesUseOptionalNativeDeliveryWithoutGameplayMutation()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){var a=new FantasyStickLifecycleNativeTest.Actor(data);a.core.inventory.setItemInOffHand(null);f.players.put(a.core.id,a.player);var before=a.core.snapshot();f.plugin.setEnabled(true);f.awaitCharacterAndReceipt(a);PartyBookNativeTest.drain(f);assertEquals(1,a.resourcePacks.size());var request=request(a.resourcePacks.getFirst());assertEquals(games.dmg.fantasystick.presentation.SheetResourcePack.ID,request.id());assertEquals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.packagedDefaults().url(),request.url());assertEquals(20,request.hash().length);assertFalse(request.required());assertArrayEquals(before,a.core.snapshot());for(var status:List.of(PlayerResourcePackStatusEvent.Status.DECLINED,PlayerResourcePackStatusEvent.Status.FAILED_DOWNLOAD,PlayerResourcePackStatusEvent.Status.INVALID_URL,PlayerResourcePackStatusEvent.Status.FAILED_RELOAD,PlayerResourcePackStatusEvent.Status.DISCARDED)){f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),status));assertTrue(a.messages.getLast().toLowerCase().contains("cosmetic"));assertArrayEquals(before,a.core.snapshot());}int messages=a.messages.size();f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.ACCEPTED));f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.DOWNLOADED));f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.SUCCESSFULLY_LOADED));assertEquals(messages,a.messages.size());var active=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(active.core.id,active.player);var legacyMeta=active.core.inventory.getItemInOffHand().getItemMeta();legacyMeta.setItemModel(null);active.core.inventory.getItemInOffHand().setItemMeta(legacyMeta);var activeBefore=active.core.snapshot();var expectedMeta=activeBefore[40].getItemMeta();expectedMeta.setItemModel(games.dmg.fantasystick.items.CharacterSheets.MODEL);activeBefore[40].setItemMeta(expectedMeta);var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");sessions.enter(active.player);awaitPacked(f,active);assertEquals(games.dmg.fantasystick.items.CharacterSheets.MODEL,active.core.inventory.getItemInOffHand().getItemMeta().getItemModel());sessions.exit(active.player);f.awaitCharacterAndReceipt(active);assertArrayEquals(activeBefore,active.core.snapshot());var retained=(games.dmg.fantasystick.presentation.SheetResourcePack)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sheetResourcePack");f.plugin.setEnabled(false);retained.status(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.DECLINED));assertEquals(messages,a.messages.size());f.plugin.setEnabled(true);var late=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(late.core.id,late.player);f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(late.player,(Component)null));assertEquals(1,late.resourcePacks.size());}}
@Test void invalidStartupAndFailedRegisteredWriteNeverDeliverOrChangeEffectiveDefaults()throws Exception{int route=0;for(String value:List.of("sheet-resource-pack-url: 'http://bad.test/x.zip'\n","sheet-resource-pack-sha1: bad\n")){var dir=data.resolve("invalid-"+route++);java.nio.file.Files.createDirectories(dir);java.nio.file.Files.writeString(dir.resolve("config.yml"),value);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){assertThrows(IllegalArgumentException.class,()->f.plugin.setEnabled(true));assertTrue(f.order.isEmpty(),"native registration order "+f.order);assertTrue(f.tasks.isEmpty(),"queued tasks");}}var failed=data.resolve("failed");java.nio.file.Files.createDirectories(failed);try(var f=new FantasyStickLifecycleNativeTest.Fixture(failed)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(failed);f.players.put(a.core.id,a.player);java.nio.file.Files.createDirectory(failed.resolve("config.yml"));var sender=new EquipmentAdminCommandNativeTest.Sender();f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url","https://packs.example.test/x.zip"});var callback=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(callback);callback.run();assertTrue(sender.messages.getLast().contains("save failed"),sender.messages::toString);assertTrue(a.resourcePacks.isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url"});assertTrue(sender.messages.getLast().contains("git.garvis.dev"));}}
@Test void registeredConfigurationDurablyPublishesAndRedeliversWhileFailureRetainsPair()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(a.core.id,a.player);var sender=new EquipmentAdminCommandNativeTest.Sender();assertEquals(List.of("sheet-resource-pack-sha1","sheet-resource-pack-url"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","sheet-r"}));assertEquals(1,f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url",""}).size());sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","sheet-r"}).isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url","https://packs.example.test/sheet.zip"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url","https://packs.example.test/x.zip","extra"});assertTrue(sender.messages.getLast().contains("Usage"));for(var pair:List.of(new String[]{"sheet-resource-pack-url","http://bad.test/x.zip"},new String[]{"sheet-resource-pack-sha1","bad"})){f.command.execute(sender.port,"fantasystick",new String[]{"config",pair[0],pair[1]});assertTrue(sender.messages.getLast().contains("invalid"));}save(f,sender,"sheet-resource-pack-url","https://packs.example.test/sheet.zip");assertEquals(1,a.resourcePacks.size());assertEquals("https://packs.example.test/sheet.zip",request(a.resourcePacks.getLast()).url());save(f,sender,"sheet-resource-pack-sha1","a".repeat(40));assertEquals(2,a.resourcePacks.size());assertArrayEquals(java.util.HexFormat.of().parseHex("a".repeat(40)),request(a.resourcePacks.getLast()).hash());f.plugin.setEnabled(false);f.plugin.setEnabled(true);f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url"});assertTrue(sender.messages.getLast().contains("https://packs.example.test/sheet.zip"));}}
static void awaitPacked(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor actor)throws Exception{long deadline=System.nanoTime()+java.util.concurrent.TimeUnit.SECONDS.toNanos(10);while(System.nanoTime()<deadline){var marker=actor.core.marker();if(marker!=null&&marker.endsWith("|PACKED"))return;var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();}fail("Session did not activate; notices="+actor.messages);}
@@ -31,6 +31,7 @@ class DistributionTest {
assertEquals(java.util.List.of("ProtocolLib"), metadata.getDepend());
assertEquals(java.util.Set.of("fantasystick"), metadata.getCommands().keySet());
assertEquals("fantasystick.admin", metadata.getCommands().get("fantasystick").get("permission"));
assertTrue(metadata.getCommands().get("fantasystick").get("usage").toString().contains("experience give"));
assertEquals(1, metadata.getPermissions().size());
assertEquals("fantasystick.admin", metadata.getPermissions().getFirst().getName());
assertEquals(org.bukkit.permissions.PermissionDefault.OP, metadata.getPermissions().getFirst().getDefault());
@@ -0,0 +1,71 @@
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 MendingLightSettingsTest {
@TempDir Path data;
@Test void serializesPreservesUnrelatedKeysAndPublishesAcknowledgedSnapshotsInOrder() throws Exception {
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
try (var settings = new MendingLightSettings(data, defaults())) {
var range = settings.set(MendingLightSettings.RANGE, "16").toCompletableFuture();
var base = settings.set(MendingLightSettings.BASE_HEALING, "8").toCompletableFuture();
var savedRange = range.get(5, TimeUnit.SECONDS); var savedBase = base.get(5, TimeUnit.SECONDS);
assertEquals(12, settings.settings().range(), 0);
settings.publish(savedBase); settings.publish(savedRange);
assertEquals(16, settings.settings().range(), 0);
assertEquals(new java.math.BigInteger("8"), settings.settings().baseHealing());
var yaml = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
assertEquals("retained", yaml.get("unrelated")); assertEquals("16", String.valueOf(yaml.get(MendingLightSettings.RANGE)));
}
}
@Test void invalidFailedUnsafeAndClosedWritesRetainEffectiveState() throws Exception {
try (var settings = new MendingLightSettings(data, defaults())) {
for (var entry : Map.of(MendingLightSettings.COOLDOWN, "-1", MendingLightSettings.RANGE, "0",
MendingLightSettings.BASE_HEALING, "-1").entrySet()) {
assertThrows(IllegalArgumentException.class, () -> settings.set(entry.getKey(), entry.getValue()));
}
assertThrows(IllegalArgumentException.class, () -> settings.set(MendingLightSettings.COOLDOWN, "0.0000000001"));
assertThrows(IllegalArgumentException.class, () -> settings.set(MendingLightSettings.BASE_HEALING, "1.5"));
}
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
try (var settings = new MendingLightSettings(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(MendingLightSettings.RANGE, "16").toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals("12", settings.value(MendingLightSettings.RANGE));
}
if (!System.getProperty("os.name", "").toLowerCase().contains("win")) {
var unsafe = data.resolve("unsafe"); Files.createDirectories(unsafe);
var outside = data.resolve("outside.yml"); Files.writeString(outside, "safe\n");
Files.createSymbolicLink(unsafe.resolve("config.yml"), outside);
try (var settings = new MendingLightSettings(unsafe, defaults())) {
assertThrows(java.util.concurrent.ExecutionException.class,
() -> settings.set(MendingLightSettings.RANGE, "16").toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals("safe\n", Files.readString(outside));
}
}
var closed = new MendingLightSettings(data.resolve("closed"), defaults()); closed.close();
assertThrows(java.util.concurrent.ExecutionException.class,
() -> closed.set(MendingLightSettings.RANGE, "16").toCompletableFuture().get(5, TimeUnit.SECONDS));
closed.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
}
private static Map<String, String> defaults() {
var values = new LinkedHashMap<String, String>();
for (String key : MendingLightSettings.keys()) values.put(key, MendingLightSettings.defaultValue(key));
return values;
}
}
@@ -0,0 +1,69 @@
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 PurifySettingsTest {
@TempDir Path data;
@Test void serializesPreservesUnrelatedKeysAndPublishesAcknowledgedSnapshotsInOrder() throws Exception {
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
try (var settings = new PurifySettings(data, defaults())) {
var range = settings.set(PurifySettings.RANGE, "16").toCompletableFuture();
var cooldown = settings.set(PurifySettings.COOLDOWN, "20").toCompletableFuture();
var savedRange = range.get(5, TimeUnit.SECONDS); var savedCooldown = cooldown.get(5, TimeUnit.SECONDS);
assertEquals(12, settings.settings().range(), 0);
settings.publish(savedCooldown); settings.publish(savedRange);
assertEquals(16, settings.settings().range(), 0);
assertEquals(java.time.Duration.ofSeconds(20), settings.settings().cooldown());
var yaml = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
assertEquals("retained", yaml.get("unrelated")); assertEquals("16", String.valueOf(yaml.get(PurifySettings.RANGE)));
}
}
@Test void invalidFailedUnsafeAndClosedWritesRetainEffectiveState() throws Exception {
try (var settings = new PurifySettings(data, defaults())) {
for (var entry : Map.of(PurifySettings.COOLDOWN, "-1", PurifySettings.RANGE, "0").entrySet()) {
assertThrows(IllegalArgumentException.class, () -> settings.set(entry.getKey(), entry.getValue()));
}
assertThrows(IllegalArgumentException.class, () -> settings.set(PurifySettings.COOLDOWN, "0.0000000001"));
}
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
try (var settings = new PurifySettings(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(PurifySettings.RANGE, "16").toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals("12", settings.value(PurifySettings.RANGE));
}
if (!System.getProperty("os.name", "").toLowerCase().contains("win")) {
var unsafe = data.resolve("unsafe"); Files.createDirectories(unsafe);
var outside = data.resolve("outside.yml"); Files.writeString(outside, "safe\n");
Files.createSymbolicLink(unsafe.resolve("config.yml"), outside);
try (var settings = new PurifySettings(unsafe, defaults())) {
assertThrows(java.util.concurrent.ExecutionException.class,
() -> settings.set(PurifySettings.RANGE, "16").toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals("safe\n", Files.readString(outside));
}
}
var closed = new PurifySettings(data.resolve("closed"), defaults()); closed.close();
assertThrows(java.util.concurrent.ExecutionException.class,
() -> closed.set(PurifySettings.RANGE, "16").toCompletableFuture().get(5, TimeUnit.SECONDS));
closed.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
}
private static Map<String, String> defaults() {
var values = new LinkedHashMap<String, String>();
for (String key : PurifySettings.keys()) values.put(key, PurifySettings.defaultValue(key));
return values;
}
}
@@ -36,6 +36,22 @@ class CombatContributionsTest {
assertEquals(Set.of(ally), ledger.consume(target, now.plusSeconds(10).plusNanos(1)));
}
@Test void effectiveHealingSupportsEveryDirectTargetWithinTheExactTenSecondWindow() {
var now = Instant.parse("2026-09-20T20:30:00Z");
var first = UUID.randomUUID(); var second = UUID.randomUUID();
var ally = UUID.randomUUID(); var supporter = UUID.randomUUID(); var other = UUID.randomUUID();
var ledger = new CombatContributions(CombatRewardRules.defaults());
ledger.support(ally, supporter, now);
ledger.support(other, UUID.randomUUID(), now);
ledger.accept(first, ally, now.plusSeconds(1));
ledger.accept(first, supporter, now.plusSeconds(1));
ledger.accept(second, ally, now.plusSeconds(2));
assertEquals(Set.of(ally, supporter), ledger.consume(first, now.plusSeconds(10)),
"Healing is effective support for a directly contributing ally and deduplicates direct/support roles");
assertEquals(Set.of(ally), ledger.consume(second, now.plusSeconds(10).plusNanos(1)),
"Healing support expires immediately after ten seconds");
}
@Test void invalidationAndCloseBoundMemoryAndRefuseLaterDamage() {
var ledger = new CombatContributions(CombatRewardRules.defaults());
var target = UUID.randomUUID(); var character = UUID.randomUUID();