Compare commits

...
5 Commits
Author SHA1 Message Date
dmg db35c28f0b feat(nutrition): isolate fantasy hunger and healing
Release / release (push) Successful in 5m54s
2026-09-19 15:26:02 -04:00
dmg 636ff9f076 feat(combat): add built-in basic attack
Release / release (push) Successful in 5m48s
2026-09-19 15:17:53 -04:00
dmg 7158817726 feat(progression): add permanent stat purchases
Release / release (push) Successful in 5m55s
2026-09-19 15:08:54 -04:00
dmg 8ee0d98599 ci(fantasy-stick): avoid duplicate release builds
Release / release (push) Successful in 3m31s
2026-09-19 14:51:20 -04:00
dmg 539900eb14 fix(tests): synchronize health saves before reentry and transfer
Release / release (push) Successful in 8m26s
CI / build (push) Successful in 4m19s
2026-09-19 14:32:35 -04:00
16 changed files with 847 additions and 33 deletions

No files matched your search

+2
View File
@@ -2,6 +2,8 @@ name: CI
on:
push:
branches-ignore: [main]
tags-ignore: ['**']
pull_request:
jobs:
@@ -21,7 +21,7 @@ import java.time.Clock;
import java.util.logging.Level;
import org.bukkit.plugin.java.JavaPlugin;
/** Crafting, native fantasy sessions, books, parties and names; progression actions and spells remain separate. */
/** Crafting, native fantasy sessions, books, parties, names and permanent-stat progression. */
public final class FantasyStickPlugin extends JavaPlugin {
private CharacterStorage characters;
private ManualCraftingController crafting;
@@ -44,6 +44,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.session.AdventureModeLease gameModes;
private games.dmg.fantasystick.session.SessionVitals vitals;
private games.dmg.fantasystick.session.SessionVisuals sessionVisuals;
private games.dmg.fantasystick.combat.BasicAttack basicAttack;
private games.dmg.fantasystick.session.NutritionPolicy nutritionPolicy;
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
@Override public void onEnable() {
@@ -67,9 +69,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
var adventure = games.dmg.fantasystick.persistence.AdventureSettings.validate(getConfig().get("adventure-mode"));
adventureSettings = new games.dmg.fantasystick.persistence.AdventureSettings(getDataFolder().toPath(), adventure);
var switchDelay = switchDelay();
var defaults = ProgressionRules.defaults();
var rules = new ProgressionRules(new java.math.BigInteger(getConfig().getString("starting-points", "40")),
defaults.xpPerLevel(), defaults.maximumLevel());
var rules = progressionRules();
var factory = new CharacterFactory();
characters = new CharacterStorage(new YamlCharacterRepository(getDataFolder().toPath()), Clock.systemUTC());
crafting = new ManualCraftingController(characters, () -> factory.create(rules),
@@ -110,20 +110,26 @@ public final class FantasyStickPlugin extends JavaPlugin {
notice -> notifyPlayer(generation, notice), diagnostics, presentation, adventureSettings::enabled, gameModes);
vitals = new games.dmg.fantasystick.session.SessionVitals();
sessions.attachVitals(vitals);
nutritionPolicy = new games.dmg.fantasystick.session.NutritionPolicy(sessions);
parties = new games.dmg.fantasystick.party.PartyRoster(Clock.systemUTC(), id -> {
var player = getServer().getPlayer(id);
return player != null && player.isOnline();
});
partyEvents = new games.dmg.fantasystick.party.PartyEvents(parties);
partyCombat = new games.dmg.fantasystick.party.PartyCombat(parties, sessions);
books = new games.dmg.fantasystick.book.CharacterBookController(characters, rules, nextTick,
books = new games.dmg.fantasystick.book.CharacterBookController(characters, rules, sessions, nextTick,
problem -> getLogger().log(Level.WARNING, "Character book failed for player " + problem.player()
+ "; character " + problem.character(), problem.cause()), parties, getServer(), id -> {
String name = localPresentation.get().name(id);
var player = getServer().getPlayer(id);
return net.kyori.adventure.text.Component.text(name != null ? name : player == null ? id.toString() : player.getName());
});
sessionEvents = new EquipmentSessionEvents(sessions, nextTick, diagnostics, books);
var localBasicAttack = new games.dmg.fantasystick.combat.BasicAttack(sessions);
basicAttack = localBasicAttack;
sessionEvents = new EquipmentSessionEvents(sessions, nextTick, diagnostics, input -> {
books.accept(input);
localBasicAttack.accept(input);
});
admin = new EquipmentAdminCommand(receipts, sessions, getServer(), nextTick,
problem -> getLogger().log(Level.WARNING, "Equipment admin " + problem.operation()
+ " for player " + problem.player(), problem.cause()), adventureSettings);
@@ -138,6 +144,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
getServer().getPluginManager().registerEvents(partyEvents, this);
getServer().getPluginManager().registerEvents(partyCombat, this);
partyCombat.install(this);
getServer().getPluginManager().registerEvents(localBasicAttack, this);
getServer().getPluginManager().registerEvents(nutritionPolicy, this);
getServer().getPluginManager().registerEvents(books, this);
getServer().getPluginManager().registerEvents(gameModes, this);
sessionVisuals = new games.dmg.fantasystick.session.SessionVisuals(sessions,
@@ -175,6 +183,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingModes = gameModes; gameModes = null;
var closingVitals = vitals; vitals = null;
var closingVisuals = sessionVisuals; sessionVisuals = null;
var closingBasicAttack = basicAttack; basicAttack = null;
var closingNutrition = nutritionPolicy; nutritionPolicy = null;
// Retire client resources independently of successful ordinary-equipment restoration.
if (closingPresentation != null) cleanup("presentation", closingPresentation::close);
if (closingProtocol != null) cleanup("protocol presentation", closingProtocol::close);
@@ -184,6 +194,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
cleanup("configuration", closingSettings::close);
}
if (closingBooks != null) cleanup("character book", closingBooks::close);
if (closingBasicAttack != null) cleanup("basic attack", closingBasicAttack::close);
if (closingNutrition != null) cleanup("nutrition policy", closingNutrition::close);
if (closingVisuals != null) cleanup("session particles", closingVisuals::close);
if (closingParties != null) cleanup("parties", closingParties::close);
if (closingPartyCombat != null) cleanup("party combat", closingPartyCombat::close);
@@ -195,6 +207,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
unregister(closingChat);
unregister(closingPartyEvents);
unregister(closingPartyCombat);
unregister(closingBasicAttack);
unregister(closingNutrition);
unregister(closingBooks);
unregister(closingEvents);
unregister(closingCrafting);
@@ -225,6 +239,17 @@ public final class FantasyStickPlugin extends JavaPlugin {
}
}
private ProgressionRules progressionRules() {
try {
var starting = new java.math.BigInteger(getConfig().getString("starting-points", "40"));
var threshold = new java.math.BigInteger(getConfig().getString("xp-per-level", "100"));
var maximum = new java.math.BigInteger(getConfig().getString("maximum-level", "30")).intValueExact();
return new ProgressionRules(starting, threshold, maximum);
} catch (ArithmeticException invalid) {
throw new IllegalArgumentException("maximum-level exceeds the supported integer range", invalid);
}
}
private java.time.Duration switchDelay() {
Object configured = getConfig().get("character-swap-cooldown-seconds", 900);
String value = String.valueOf(configured);
@@ -16,11 +16,12 @@ import org.bukkit.Material;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.BookMeta;
/** Authoritative inspection and validated party controls; character mutation actions remain unfinished. */
/** Authoritative inspection, permanent-stat purchases and validated party controls. */
public final class CharacterBookController implements Consumer<ControlInput>, AutoCloseable, org.bukkit.event.Listener {
public record Failure(UUID player, UUID character, Throwable cause) { }
private final CharacterStorage characters;
private final ProgressionRules rules;
private final games.dmg.fantasystick.session.EquipmentSessions sessions;
private final PartyBookControls partyControls;
private final Consumer<Runnable> nextTick;
private final Consumer<Failure> failures;
@@ -28,16 +29,22 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
private volatile boolean closed;
private static final int VOLUME_SIZE = 80; // Leave room for navigation and the bounded party pages.
private static final net.kyori.adventure.key.Key PAGES = net.kyori.adventure.key.Key.key("fantasystick", "character_pages");
private static final net.kyori.adventure.key.Key STATS = net.kyori.adventure.key.Key.key("fantasystick", "character_stat");
private static final java.util.regex.Pattern PAGE_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([01])\"");
private static final java.util.regex.Pattern STAT_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):(STR|DEX|CON|INT|WIS|CHR)\"");
private record View(String nonce, ControlInput input, int previous, int next) { }
private record StatView(String nonce, ControlInput input, java.math.BigInteger revision) { }
private final Map<UUID, View> views = new ConcurrentHashMap<>();
private final Map<UUID, StatView> statViews = new ConcurrentHashMap<>();
/** Scheduler must always defer to the server thread. Diagnostics must not access players or throw. */
public CharacterBookController(CharacterStorage characters, ProgressionRules rules,
games.dmg.fantasystick.session.EquipmentSessions sessions,
Consumer<Runnable> nextTick, Consumer<Failure> failures, games.dmg.fantasystick.party.PartyRoster parties,
org.bukkit.Server server, java.util.function.Function<UUID, Component> names) {
this.characters = Objects.requireNonNull(characters);
this.rules = Objects.requireNonNull(rules);
this.sessions = Objects.requireNonNull(sessions);
this.nextTick = Objects.requireNonNull(nextTick);
this.failures = Objects.requireNonNull(failures);
this.partyControls = new PartyBookControls(parties, server, names, this::accept, this::report);
@@ -51,6 +58,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
var owner = input.session().player();
partyControls.forget(owner);
views.remove(owner);
statViews.remove(owner);
Object ticket = new Object();
pending.put(owner, ticket);
characters.find(input.session().character()).whenComplete((record, failure) -> {
@@ -96,6 +104,16 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
int start = Math.min(offset, (all.size() - 1) / VOLUME_SIZE * VOLUME_SIZE);
int end = Math.min(start + VOLUME_SIZE, all.size());
var pages = new java.util.ArrayList<Component>(all.subList(start, end));
var statView = new StatView(UUID.randomUUID().toString(), input, record.revision());
statViews.put(input.session().player(), statView);
Component purchases = Component.text("Spend 1 point\n\n");
for (var stat : Stat.values()) {
purchases = purchases.append(Component.text("[+1 " + stat + "]", net.kyori.adventure.text.format.NamedTextColor.DARK_GREEN)
.clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(STATS,
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\"" + statView.nonce() + ":" + stat + "\""))))
.append(Component.newline());
}
pages.add(purchases);
if (all.size() > VOLUME_SIZE) {
var view = new View(UUID.randomUUID().toString(), input, start > 0 ? start - VOLUME_SIZE : -1,
end < all.size() ? end : -1);
@@ -139,6 +157,63 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
catch (RuntimeException ignored) { /* A diagnostic failure must not replay a book request. */ }
}
@org.bukkit.event.EventHandler
public void statAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
if (closed || !STATS.equals(event.getIdentifier())
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)) return;
var player = connection.getPlayer();
var view = statViews.get(player.getUniqueId());
if (view == null || view.input().player() != player || event.getTag() == null) return;
String raw = event.getTag().string();
if (raw.length() > 80) return;
var match = STAT_PAYLOAD.matcher(raw);
if (!match.matches() || !view.nonce().equals(match.group(1))
|| !statViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
var stat = Stat.valueOf(match.group(2));
var currentHealth = java.math.BigDecimal.valueOf(player.getHealth());
var nativeLimit = NativeHealthLimit.read();
characters.purchaseStat(view.input().session().character(), view.revision(), stat, currentHealth, nativeLimit)
.whenComplete((purchase, failure) -> {
try {
nextTick.accept(() -> finishPurchase(view, stat, purchase, failure));
} catch (RuntimeException rejected) { report(view.input(), rejected); }
});
}
private void finishPurchase(StatView view, Stat stat, CharacterStorage.StatPurchase purchase, Throwable failure) {
var input = view.input();
if (closed || !input.stillCurrent()) return;
if (failure != null) {
report(input, failure);
input.player().sendMessage("Stat purchase could not be saved; no character state was changed.");
return;
}
if (purchase.status() == CharacterStorage.StatPurchaseStatus.APPLIED) {
var saved = purchase.record().orElseThrow();
try {
if (!sessions.acknowledgeProgress(input.session(), view.revision(), saved)) {
input.player().sendMessage("Stat purchase was saved; reopen the character after the session transition.");
return;
}
} catch (RuntimeException nativeFailure) {
report(input, nativeFailure);
input.player().sendMessage("Stat purchase was saved, but native health refresh failed; exit for recovery.");
return;
}
input.player().sendMessage(stat + " increased to " + saved.stats().get(stat) + ".");
load(input, 0);
return;
}
String message = switch (purchase.status()) {
case STALE -> "That character page is stale; reopen it before spending points.";
case NO_POINTS -> "This character has no unspent points.";
case UNAVAILABLE -> "This character is no longer available.";
case APPLIED -> throw new IllegalStateException("Handled applied purchase");
};
input.player().sendMessage(message);
load(input, 0);
}
@org.bukkit.event.EventHandler
public void partyAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) { partyControls.click(event); }
@@ -147,8 +222,9 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
var owner = event.getPlayer().getUniqueId();
pending.remove(owner);
views.remove(owner);
statViews.remove(owner);
partyControls.forget(owner);
}
@Override public void close() { closed = true; pending.clear(); views.clear(); partyControls.close(); }
@Override public void close() { closed = true; pending.clear(); views.clear(); statViews.clear(); partyControls.close(); }
}
@@ -0,0 +1,91 @@
package games.dmg.fantasystick.combat;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigInteger;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.entity.LivingEntity;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
/** Server-thread built-in attack consumer; the bound stick's vanilla attack is always contained upstream. */
public final class BasicAttack implements Consumer<ControlInput>, Listener, AutoCloseable {
private static final Duration CADENCE = Duration.ofSeconds(1);
private final EquipmentSessions sessions;
private final Clock clock;
private final Map<UUID, Instant> readyAt = new HashMap<>();
private Pending pending;
private boolean closed;
private static final class Pending {
final ControlInput input;
final LivingEntity target;
final Instant attackedAt;
boolean accepted;
Pending(ControlInput input, LivingEntity target, Instant attackedAt) {
this.input = input; this.target = target; this.attackedAt = attackedAt;
}
}
public BasicAttack(EquipmentSessions sessions) {
this(sessions, Clock.systemUTC());
}
public BasicAttack(EquipmentSessions sessions, Clock clock) {
this.sessions = Objects.requireNonNull(sessions);
this.clock = Objects.requireNonNull(clock);
}
@Override public void accept(ControlInput input) {
if (closed || pending != null || input.kind() != ControlInput.Kind.BASIC_ATTACK
|| input.gesture() != ControlInput.Gesture.ATTACK
|| !(input.target() instanceof LivingEntity target) || !input.stillCurrent()) return;
var record = sessions.currentRecord(input.session()).orElse(null);
if (record == null) return;
Instant now = clock.instant();
Instant ready = readyAt.get(input.session().character());
if (ready != null && now.isBefore(ready)) return;
BigInteger damage = damage(record.stats().get(Stat.STR));
double nativeDamage = damage.bitLength() > 1023 ? Double.MAX_VALUE : damage.doubleValue();
var attempt = new Pending(input, target, now);
pending = attempt;
try {
target.damage(nativeDamage, input.player());
} finally {
if (pending == attempt) pending = null;
if (attempt.accepted) readyAt.put(input.session().character(), attempt.attackedAt.plus(CADENCE));
}
}
static BigInteger damage(BigInteger strength) {
Objects.requireNonNull(strength);
if (strength.signum() < 0) throw new IllegalArgumentException("Strength must not be negative");
return strength.subtract(BigInteger.TEN).divide(BigInteger.TWO).max(BigInteger.ONE);
}
/** MONITOR observes the synchronous native result; cancelled/zero damage never spends cadence. */
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void acceptedDamage(EntityDamageByEntityEvent event) {
var attempt = pending;
if (closed || attempt == null || event.isCancelled() || event.getFinalDamage() <= 0
|| event.getDamager() != attempt.input.player() || event.getEntity() != attempt.target
|| !attempt.input.stillCurrent() || !sessions.current(attempt.input.session())) return;
attempt.accepted = true;
}
@Override public void close() {
closed = true;
pending = null;
readyAt.clear();
}
}
@@ -1,5 +1,7 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.progression.ProgressionRules;
import games.dmg.fantasystick.progression.Stat;
import java.io.IOException;
import java.math.BigInteger;
import java.time.Clock;
@@ -210,6 +212,91 @@ public final class CharacterStorage implements AutoCloseable {
});
}
public enum ExperienceAwardStatus { APPLIED, AT_CAP, UNCHANGED, STALE, UNAVAILABLE }
public record ExperienceAward(ExperienceAwardStatus status, Optional<CharacterRecord> record, int levels) { }
/** Persist configured transferable XP/level/point progression as one expected revision. */
public CompletableFuture<ExperienceAward> awardExperience(UUID characterId, BigInteger expectedRevision,
BigInteger reward, ProgressionRules rules) {
Objects.requireNonNull(characterId);
Objects.requireNonNull(expectedRevision);
Objects.requireNonNull(reward);
Objects.requireNonNull(rules);
if (reward.signum() < 0) throw new IllegalArgumentException("Experience reward must not be negative");
return submit(() -> {
var found = repository.load(characterId);
if (found.isEmpty() || found.orElseThrow().dead()) {
return new ExperienceAward(ExperienceAwardStatus.UNAVAILABLE, Optional.empty(), 0);
}
var old = found.orElseThrow();
if (!old.revision().equals(expectedRevision)) {
return new ExperienceAward(ExperienceAwardStatus.STALE, Optional.of(old), 0);
}
var cap = rules.xpPerLevel().multiply(BigInteger.valueOf(rules.maximumLevel() - 1L));
if (old.level() >= rules.maximumLevel() || old.experience().compareTo(cap) >= 0) {
return new ExperienceAward(ExperienceAwardStatus.AT_CAP, Optional.of(old), 0);
}
var experience = old.experience().add(reward).min(cap);
if (experience.equals(old.experience())) {
var status = old.level() >= rules.maximumLevel()
? ExperienceAwardStatus.AT_CAP : ExperienceAwardStatus.UNCHANGED;
return new ExperienceAward(status, Optional.of(old), 0);
}
int level = experience.divide(rules.xpPerLevel()).intValueExact() + 1;
int gained = Math.max(0, level - old.level());
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
level, experience, old.unspentPoints().add(BigInteger.valueOf(gained)), old.health(), old.learned(),
old.loadout(), old.cooldowns(), false, old.deathId(), old.diedAt());
repository.replace(saved, old.revision());
return new ExperienceAward(ExperienceAwardStatus.APPLIED, Optional.of(saved), gained);
});
}
public enum StatPurchaseStatus { APPLIED, STALE, NO_POINTS, UNAVAILABLE }
public record StatPurchase(StatPurchaseStatus status, Optional<CharacterRecord> record) { }
/** Persist one active-session stat purchase and its current native health as one revision. */
public CompletableFuture<StatPurchase> purchaseStat(UUID characterId, BigInteger expectedRevision, Stat stat,
java.math.BigDecimal currentHealth,
java.math.BigDecimal nativeLimit) {
Objects.requireNonNull(characterId);
Objects.requireNonNull(expectedRevision);
Objects.requireNonNull(stat);
Objects.requireNonNull(currentHealth);
Objects.requireNonNull(nativeLimit);
if (currentHealth.signum() < 0 || nativeLimit.signum() <= 0) {
throw new IllegalArgumentException("Invalid stat-purchase health bound");
}
return submit(() -> {
var found = repository.load(characterId);
if (found.isEmpty() || found.orElseThrow().dead()) {
return new StatPurchase(StatPurchaseStatus.UNAVAILABLE, Optional.empty());
}
var old = found.orElseThrow();
if (!old.revision().equals(expectedRevision)) {
return new StatPurchase(StatPurchaseStatus.STALE, Optional.of(old));
}
if (old.unspentPoints().signum() == 0) {
return new StatPurchase(StatPurchaseStatus.NO_POINTS, Optional.of(old));
}
var oldMaximum = old.maximumHealth().min(nativeLimit);
if (currentHealth.compareTo(oldMaximum) > 0) {
return new StatPurchase(StatPurchaseStatus.STALE, Optional.of(old));
}
var stats = new java.util.EnumMap<Stat, BigInteger>(Stat.class);
stats.putAll(old.stats());
stats.put(stat, stats.get(stat).add(BigInteger.ONE));
var rawMaximum = new java.math.BigDecimal(stats.get(Stat.CON).add(BigInteger.TEN));
var gain = rawMaximum.min(nativeLimit).subtract(oldMaximum);
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
old.level(), old.experience(), old.unspentPoints().subtract(BigInteger.ONE),
currentHealth.add(gain), old.learned(), old.loadout(), old.cooldowns(), false,
old.deathId(), old.diedAt());
repository.replace(saved, old.revision());
return new StatPurchase(StatPurchaseStatus.APPLIED, Optional.of(saved));
});
}
/** Session-owned remaining health; retain all other current fields and reject concurrent revision changes. */
public CompletableFuture<Optional<CharacterRecord>> saveHealth(UUID characterId, java.math.BigDecimal health,
java.math.BigDecimal nativeLimit) {
@@ -6,6 +6,7 @@ import games.dmg.fantasystick.items.SessionControls;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.persistence.EquipmentSessionStore;
import java.math.BigInteger;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
@@ -201,6 +202,26 @@ public final class EquipmentSessions implements AutoCloseable {
&& sheets.identify(attempt.player.getInventory().getItemInOffHand()).filter(attempt.character::equals).isPresent();
}
/** Current transferable character snapshot for a still-authorized server-thread action. */
public synchronized Optional<CharacterRecord> currentRecord(Active session) {
var attempt = active.get(session.player());
return current(session) && attempt != null && attempt.record != null
? Optional.of(attempt.record) : Optional.empty();
}
/** Apply a persisted progression revision to the still-current active session. */
public synchronized boolean acknowledgeProgress(Active session, BigInteger expectedRevision, CharacterRecord saved) {
var attempt = active.get(session.player());
if (closed || attempt == null || attempt.session == null || attempt.record == null
|| !attempt.session.equals(session.session()) || !attempt.character.equals(session.character())
|| !attempt.record.revision().equals(expectedRevision) || !saved.id().equals(attempt.character)
|| !saved.revision().equals(expectedRevision.add(BigInteger.ONE)) || !current(session)) return false;
var previous = attempt.record;
if (vitals != null) vitals.updateProgress(attempt.player, attempt.session, previous, saved);
attempt.record = saved;
return true;
}
/** Server-thread authorization for the currently held, issued control and saved loadout. */
public synchronized Optional<SelectedControl> selectedControl(Player player, ItemStack reported) {
var attempt = active.get(player.getUniqueId());
@@ -0,0 +1,48 @@
package games.dmg.fantasystick.session;
import java.util.Objects;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
/** Active-session-only hunger/exhaustion suppression; explicit non-food healing remains native. */
public final class NutritionPolicy implements Listener, AutoCloseable {
private final EquipmentSessions sessions;
private boolean closed;
public NutritionPolicy(EquipmentSessions sessions) {
this.sessions = Objects.requireNonNull(sessions);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void food(org.bukkit.event.entity.FoodLevelChangeEvent event) {
if (!active(event.getEntity())) return;
event.setCancelled(true);
var player = (Player) event.getEntity();
player.setFoodLevel(20);
player.setExhaustion(0);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void exhaustion(org.bukkit.event.entity.EntityExhaustionEvent event) {
if (!active(event.getEntity())) return;
event.setCancelled(true);
var player = (Player) event.getEntity();
player.setFoodLevel(20);
player.setExhaustion(0);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void healing(org.bukkit.event.entity.EntityRegainHealthEvent event) {
if (event.getRegainReason() == org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.SATIATED
&& active(event.getEntity())) event.setCancelled(true);
}
private boolean active(org.bukkit.entity.Entity entity) {
return !closed && entity instanceof Player player
&& sessions.active(player.getUniqueId()).isPresent();
}
@Override public void close() { closed = true; }
}
@@ -57,10 +57,15 @@ public final class SessionVitals implements AutoCloseable {
if (closed) throw new IllegalStateException("Vitals lifetime ended during entry");
player.setHealthScale(maximum);
if (closed) throw new IllegalStateException("Vitals lifetime ended during entry");
player.setFoodLevel(20);
player.setExhaustion(0);
if (closed) throw new IllegalStateException("Vitals lifetime ended during entry");
write(player, new Mark(session, Phase.APPLIED, maximum, ordinary));
} catch (RuntimeException | LinkageError failure) {
try { releaseMaximum(player, session); }
catch (RuntimeException | LinkageError cleanup) { failure.addSuppressed(cleanup); }
try { restoreNutrition(player, ordinary); }
catch (RuntimeException | LinkageError cleanup) { failure.addSuppressed(cleanup); }
try { restoreScale(player, maximum, ordinary); }
catch (RuntimeException | LinkageError cleanup) { failure.addSuppressed(cleanup); }
// Leave APPLYING proof and the acknowledged receipt: an uncertain health setter
@@ -69,6 +74,30 @@ public final class SessionVitals implements AutoCloseable {
}
}
/** Apply an acknowledged stat revision to the live native health pool exactly once. */
public void updateProgress(Player player, UUID session, CharacterRecord previous, CharacterRecord saved) {
Objects.requireNonNull(previous);
Objects.requireNonNull(saved);
var mark = read(player);
var lease = leases.get(player.getUniqueId());
if (closed || mark == null || mark.phase() != Phase.APPLIED || !mark.session().equals(session)
|| lease == null || !lease.session().equals(session) || !previous.id().equals(saved.id())
|| !previous.revision().add(java.math.BigInteger.ONE).equals(saved.revision())) {
throw new IllegalStateException("No matching active fantasy vitals revision");
}
double nextMaximum = saved.maximumHealth().min(NativeHealthLimit.read()).doubleValue();
double gain = Math.max(0, nextMaximum - mark.maximum());
if (gain == 0 && nextMaximum == mark.maximum()) return;
var nativeAttribute = Objects.requireNonNull(((CraftPlayer) player).getHandle()
.getAttribute(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH));
lease.maximum().close();
var replacement = new NativeMaximumHealth(nativeAttribute, nextMaximum);
leases.put(player.getUniqueId(), new Lease(session, replacement));
player.setHealth(Math.min(nextMaximum, player.getHealth() + gain));
player.setHealthScale(nextMaximum);
write(player, new Mark(session, Phase.APPLIED, nextMaximum, mark.ordinary()));
}
public BigDecimal remaining(Player player, UUID session, OrdinaryVitals ordinary) {
var mark = read(player);
if (mark == null || !mark.session().equals(session) || !mark.ordinary().equals(ordinary) || mark.phase() != Phase.APPLIED) {
@@ -97,7 +126,7 @@ public final class SessionVitals implements AutoCloseable {
// completion). Restore positive health first: a zero-health packet opens a client death
// screen even when Paper cancels the death and the server-side player survives.
player.setHealth(Math.min(ordinary.health(), Objects.requireNonNull(player.getAttribute(Attribute.MAX_HEALTH)).getValue()));
player.setFoodLevel(ordinary.food()); player.setSaturation(ordinary.saturation()); player.setExhaustion(ordinary.exhaustion());
restoreNutrition(player, ordinary);
restoreScale(player, mark.maximum(), ordinary);
write(player, new Mark(session, Phase.RELEASED, mark.maximum(), ordinary));
return true;
@@ -111,6 +140,12 @@ public final class SessionVitals implements AutoCloseable {
}
}
private static void restoreNutrition(Player player, OrdinaryVitals ordinary) {
player.setFoodLevel(ordinary.food());
player.setSaturation(ordinary.saturation());
player.setExhaustion(ordinary.exhaustion());
}
private static void restoreScale(Player player, double maximum, OrdinaryVitals ordinary) {
if (player.isHealthScaled() && player.getHealthScale() == maximum) {
player.setHealthScale(ordinary.scale());
+5
View File
@@ -1,3 +1,8 @@
# Captured when a new fantasy session starts; changing this does not alter active sessions.
# Admin command: /fantasystick config adventure-mode [true|false]
adventure-mode: true
# New-character and transferable fantasy progression defaults.
starting-points: 40
xp-per-level: 100
maximum-level: 30
@@ -0,0 +1,114 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class BasicAttackNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void successfulHitsStartAnExactOneSecondCadenceButCancelledDamageDoesNot() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.activate(a);
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
var active = sessions.active(a.core.id).orElseThrow();
var clock = new MutableClock(Instant.parse("2026-09-19T00:00:00Z"));
var basic = new games.dmg.fantasystick.combat.BasicAttack(sessions, clock);
var target = mock(org.bukkit.entity.LivingEntity.class);
var cancelled = new java.util.concurrent.atomic.AtomicBoolean(true);
doAnswer(call -> {
var source = mock(org.bukkit.damage.DamageSource.class);
when(source.getCausingEntity()).thenReturn(a.player);
when(source.getDirectEntity()).thenReturn(a.player);
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, target,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, (Double) call.getArgument(0));
event.setCancelled(cancelled.get());
basic.acceptedDamage(event);
return null;
}).when(target).damage(anyDouble(), same(a.player));
var input = new games.dmg.fantasystick.session.ControlInput(a.player, active, 1, "",
games.dmg.fantasystick.session.ControlInput.Kind.BASIC_ATTACK,
games.dmg.fantasystick.session.ControlInput.Gesture.ATTACK, target, 0, 1,
() -> sessions.current(active));
basic.accept(input); // Cancelled: no cadence spent.
cancelled.set(false);
basic.accept(input); // Accepted at t=0.
clock.advance(java.time.Duration.ofMillis(999)); basic.accept(input);
verify(target, times(2)).damage(1.0, a.player);
clock.advance(java.time.Duration.ofMillis(1)); basic.accept(input);
verify(target, times(3)).damage(1.0, a.player);
a.core.inventory.setItemInOffHand(null); f.changed(a);
clock.advance(java.time.Duration.ofSeconds(1)); basic.accept(input);
verifyNoMoreInteractions(target);
}
}
@Test void partyCancellationPreventsDamageAndDoesNotSpendThePackagedAttackCadence() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var b = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
f.activate(a); f.activate(b);
var parties = PartyLifecycleNativeTest.parties(f);
var party = parties.create(a.core.id).orElseThrow();
assertTrue(parties.accept(b.core.id, parties.invite(a.core.id, party.id(), b.core.id).orElseThrow().id()));
var events = new java.util.ArrayList<org.bukkit.event.entity.EntityDamageByEntityEvent>();
doAnswer(call -> {
var source = mock(org.bukkit.damage.DamageSource.class);
when(source.getCausingEntity()).thenReturn(a.player);
when(source.getDirectEntity()).thenReturn(a.player);
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, b.player,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, (Double) call.getArgument(0));
events.add(event); f.dispatcher.accept(event); return null;
}).when(b.player).damage(anyDouble(), same(a.player));
a.core.inventory.getInventory().setSelectedSlot(1);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, b.player, true));
assertEquals(1, events.size()); assertTrue(events.getFirst().isCancelled());
assertTrue(parties.leave(a.core.id, party.id()));
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, b.player, true));
assertEquals(2, events.size()); assertFalse(events.getLast().isCancelled());
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, b.player, true));
assertEquals(2, events.size(), "The accepted second hit starts cadence; the cancelled first hit did not");
}
}
@Test void packagedBasicStickInputDealsOneLowStrengthHitWithoutVanillaContinuation() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.activate(a);
a.core.inventory.getInventory().setSelectedSlot(1);
var target = mock(org.bukkit.entity.LivingEntity.class);
var attack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
f.dispatcher.accept(attack);
assertTrue(attack.isCancelled(), "The icon stick must never continue into vanilla damage");
verify(target).damage(1.0, a.player);
a.core.inventory.setItemInOffHand(null); f.changed(a); f.awaitCharacterAndReceipt(a);
assertEquals(org.bukkit.Material.DIAMOND_SWORD, a.core.inventory.getItem(0).getType());
assertTrue(a.core.inventory.getItem(1) == null || a.core.inventory.getItem(1).isEmpty(),
"Exit restores the ordinary item that occupied the basic-stick slot");
}
}
private static final class MutableClock extends Clock {
private Instant now;
MutableClock(Instant now) { this.now = now; }
void advance(java.time.Duration duration) { now = now.plus(duration); }
@Override public ZoneId getZone() { return ZoneOffset.UTC; }
@Override public Clock withZone(ZoneId zone) { return this; }
@Override public Instant instant() { return now; }
}
}
@@ -77,6 +77,103 @@ class CharacterBookNativeTest {
}
}
@Test void bookPurchasePersistsConAndPointsAndAppliesTheAcknowledgedNativeHealthGain() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.activate(a);
a.player.setHealth(5);
PartyBookNativeTest.open(f, a);
var purchase = PartyBookNativeTest.button(a, "[+1 CON]");
PartyBookNativeTest.click(f, a, purchase);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var saved = characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
assertEquals(java.math.BigInteger.ONE, saved.stats().get(games.dmg.fantasystick.progression.Stat.CON));
assertEquals(java.math.BigInteger.valueOf(39), saved.unspentPoints());
assertEquals(0, saved.health().compareTo(java.math.BigDecimal.valueOf(6)));
assertEquals(11, a.maximumHealth.getValue());
assertEquals(6, a.player.getHealth());
assertTrue(PartyBookNativeTest.text(a).contains("CON: 1"));
assertTrue(PartyBookNativeTest.text(a).contains("Unspent points: 39"));
PartyBookNativeTest.click(f, a, purchase);
PartyBookNativeTest.drain(f);
assertEquals(saved, characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow(),
"A rendered purchase action is single-use");
}
}
@Test void conPurchaseKeepsItsOwnMaximumAndOneTimeGainDespiteExternalModifiers() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player);
var before = new net.minecraft.world.entity.ai.attributes.AttributeModifier(
net.minecraft.resources.Identifier.fromNamespaceAndPath("test", "before_purchase"), 5,
net.minecraft.world.entity.ai.attributes.AttributeModifier.Operation.ADD_VALUE);
a.maximumHealth.addTransientModifier(before);
f.activate(a); assertEquals(10, a.maximumHealth.getValue());
var during = new net.minecraft.world.entity.ai.attributes.AttributeModifier(
net.minecraft.resources.Identifier.fromNamespaceAndPath("test", "during_purchase"), 100,
net.minecraft.world.entity.ai.attributes.AttributeModifier.Operation.ADD_VALUE);
a.maximumHealth.addPermanentModifier(during);
assertEquals(10, a.maximumHealth.getValue());
a.player.setHealth(5);
PartyBookNativeTest.open(f, a);
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[+1 CON]"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(11, a.maximumHealth.getValue()); assertEquals(6, a.player.getHealth());
assertEquals(java.util.Set.of(before, during), a.maximumHealth.getModifiers());
a.core.inventory.setItemInOffHand(null); f.changed(a); f.awaitCharacterAndReceipt(a);
assertEquals(java.util.Set.of(before, during), a.maximumHealth.getModifiers());
var saved = characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
assertEquals(java.math.BigInteger.ONE, saved.stats().get(games.dmg.fantasystick.progression.Stat.CON));
assertEquals(0, saved.health().compareTo(java.math.BigDecimal.valueOf(6)));
}
}
@Test void failedPurchaseChangesNothingAndAcknowledgedStatsSurviveReopenTransferAndReload() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "character-swap-cooldown-seconds: 0\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var b = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player); f.activate(a);
a.player.setHealth(5);
PartyBookNativeTest.open(f, a);
var rejected = PartyBookNativeTest.button(a, "[+1 STR]");
var before = characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
try (var channel = java.nio.channels.FileChannel.open(data.resolve("characters/.records.lock"), java.nio.file.StandardOpenOption.WRITE);
var lock = channel.lock()) {
assertTrue(lock.isValid());
PartyBookNativeTest.click(f, a, rejected);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
}
assertEquals(before, characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow());
assertEquals(5, a.player.getHealth()); assertEquals(10, a.maximumHealth.getValue());
assertTrue(a.messages.stream().anyMatch(message -> message.contains("could not be saved")));
PartyBookNativeTest.click(f, a, rejected); PartyBookNativeTest.drain(f);
assertEquals(before, characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow());
PartyBookNativeTest.open(f, a);
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[+1 STR]"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var saved = characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
assertEquals(java.math.BigInteger.ONE, saved.stats().get(games.dmg.fantasystick.progression.Stat.STR));
assertEquals(java.math.BigInteger.valueOf(39), saved.unspentPoints());
var sheet = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a); f.awaitCharacterAndReceipt(a);
b.core.inventory.setItemInOffHand(sheet); f.activate(b);
PartyBookNativeTest.open(f, b);
assertTrue(PartyBookNativeTest.text(b).contains("STR: 1"));
assertTrue(PartyBookNativeTest.text(b).contains("Unspent points: 39"));
f.plugin.setEnabled(false); f.awaitWorkers();
f.players.clear(); f.plugin.setEnabled(true); f.players.put(b.core.id, b.player); f.reenter(b);
PartyBookNativeTest.open(f, b);
assertTrue(PartyBookNativeTest.text(b).contains("STR: 1"));
assertEquals(saved.stats(), characters(f).find(saved.id()).get(5, TimeUnit.SECONDS).orElseThrow().stats());
}
}
@Test void nativePagesKeepLargeNumbersUnicodeNamesAndUnknownAbilitiesReadableAndComplete() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
@@ -166,7 +263,7 @@ class CharacterBookNativeTest {
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player); f.activate(a);
click(f, a); drainReads(f); // Old holder has a queued result, not a delivered book.
a.core.inventory.setItemInOffHand(null); f.changed(a);
f.receipts().find(a.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS);
f.awaitCharacterAndReceipt(a);
var renamed = characters(f).renameCharacter(a.core.record.id(), "Traded 雪 character").get(5, TimeUnit.SECONDS).orElseThrow();
runQueued(f); assertTrue(a.books.isEmpty());
b.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(renamed));
@@ -63,6 +63,10 @@ class FantasyStickLifecycleNativeTest {
assertEquals(3, fixture.timers.size(), "Presentation, combat-provenance cleanup and session particles");
assertTrue(fixture.order.indexOf("PartyCombat") >= 0);
assertTrue(fixture.order.indexOf("PartyCombat") < recipe);
assertTrue(fixture.order.indexOf("BasicAttack") >= 0);
assertTrue(fixture.order.indexOf("BasicAttack") < recipe);
assertTrue(fixture.order.indexOf("NutritionPolicy") >= 0);
assertTrue(fixture.order.indexOf("NutritionPolicy") < recipe);
assertNotNull(fixture.command);
assertEquals("EquipmentAdminCommand", fixture.command.getExecutor().getClass().getSimpleName());
assertTrue(fixture.tasks.isEmpty());
@@ -206,6 +210,21 @@ class FantasyStickLifecycleNativeTest {
}
}
@Test void invalidExperienceThresholdAndMaximumLevelFailBeforeInstallingGameplay() throws Exception {
for (String configuration : List.of("xp-per-level: 0\n", "xp-per-level: -1\n",
"maximum-level: 0\n", "maximum-level: 2147483648\n", "maximum-level: not-a-number\n")) {
var directory = data.resolve(Integer.toHexString(configuration.hashCode()));
java.nio.file.Files.createDirectories(directory);
java.nio.file.Files.writeString(directory.resolve("config.yml"), configuration);
try (var fixture = new Fixture(directory)) {
assertThrows(IllegalArgumentException.class, () -> fixture.plugin.setEnabled(true));
assertTrue(fixture.order.isEmpty());
assertTrue(fixture.recipes.isEmpty());
assertFalse(java.nio.file.Files.exists(directory.resolve("characters")));
}
}
}
@Test
void installedGuardRejectsOnlyTheCharacterRecipeThroughTheNativeDispatcher() throws Exception {
try (var fixture = new Fixture(data)) {
@@ -721,6 +740,7 @@ class FantasyStickLifecycleNativeTest {
assertNull(saved.pending()); assertNull(saved.lastExit());
} else {
f.activate(a); sessions.exit(a.player);
f.awaitCharacterAndReceipt(a);
assertEquals(org.bukkit.GameMode.ADVENTURE, a.player.getGameMode());
var saved = f.receipts().find(a.core.id).toCompletableFuture().get().orElseThrow();
assertNotNull(saved.pending()); assertNotNull(saved.lastExit());
@@ -790,7 +810,7 @@ class FantasyStickLifecycleNativeTest {
var sessions = (games.dmg.fantasystick.session.EquipmentSessions) Fixture.field(f.plugin, "sessions");
sessions.exit(a.player);
assertEquals(org.bukkit.GameMode.CREATIVE, a.player.getGameMode());
f.receipts().find(a.core.id).toCompletableFuture().get();
f.awaitCharacterAndReceipt(a);
var b = new Actor(data); f.players.put(b.core.id, b.player);
b.core.gameMode = org.bukkit.GameMode.SURVIVAL;
f.activate(b);
@@ -807,27 +827,43 @@ class FantasyStickLifecycleNativeTest {
if (!value.equals("default")) java.nio.file.Files.writeString(directory.resolve("config.yml"), "character-swap-cooldown-seconds: " + value + "\n");
try (var f = new Fixture(directory)) {
f.plugin.setEnabled(true); var a = new Actor(directory); f.players.put(a.core.id, a.player); f.activate(a);
f.dispatcher.accept(new org.bukkit.event.player.PlayerQuitEvent(a.player, (net.kyori.adventure.text.Component) null));
var exit = f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().lastExit();
assertEquals(java.time.Duration.ofSeconds(value.equals("default") ? 900 : Long.parseLong(value)),
java.time.Duration.between(exit.exitedAt(), exit.deadline()));
assertEquals(a.core.record.id(), exit.characterId());
var other = new Actor(directory);
a.core.inventory.setItemInOffHand(other.core.inventory.getItemInOffHand().clone());
var before = a.core.snapshot();
f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(a.player, (net.kyori.adventure.text.Component) null));
f.tick(); f.tick(); // Join receipt reconciliation, then actual coordinator eligibility.
if (value.equals("0")) {
f.tick(); assertTrue(a.core.marker().endsWith("|PACKED"), "Configured zero reaches entry, not only persisted arithmetic");
} else {
assertArrayEquals(before, a.core.snapshot()); assertTrue(a.core.marker().endsWith("|RESTORED"));
assertTrue(a.messages.getLast().matches("(?s).*Wait [1-9][0-9]* seconds.*same character may be resumed now.*"), a.messages::toString);
var state = f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow();
assertNull(state.pending()); assertEquals(exit, state.lastExit());
a.core.inventory.setItemInOffHand(new CharacterSheets().create(a.core.record));
var characters = (CharacterStorage) Fixture.field(f.plugin, "characters");
var worker = (java.util.concurrent.ExecutorService) Fixture.field(characters, "worker");
var entered = new java.util.concurrent.CountDownLatch(1);
var release = new java.util.concurrent.CountDownLatch(1);
worker.execute(() -> {
entered.countDown();
try { release.await(); }
catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); }
});
try {
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
f.dispatcher.accept(new org.bukkit.event.player.PlayerQuitEvent(a.player, (net.kyori.adventure.text.Component) null));
assertNotNull(f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().pending(),
"Quit restores native state but must retain ownership until health persistence completes");
release.countDown();
f.awaitCharacterAndReceipt(a);
var exit = f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().lastExit();
assertEquals(java.time.Duration.ofSeconds(value.equals("default") ? 900 : Long.parseLong(value)),
java.time.Duration.between(exit.exitedAt(), exit.deadline()));
assertEquals(a.core.record.id(), exit.characterId());
var other = new Actor(directory);
a.core.inventory.setItemInOffHand(other.core.inventory.getItemInOffHand().clone());
var before = a.core.snapshot();
f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(a.player, (net.kyori.adventure.text.Component) null));
f.tick(); f.tick(); f.tick(); assertTrue(a.core.marker().endsWith("|PACKED"));
}
f.tick(); f.tick(); // Join receipt reconciliation, then actual coordinator eligibility.
if (value.equals("0")) {
f.tick(); assertTrue(a.core.marker().endsWith("|PACKED"), "Configured zero reaches entry, not only persisted arithmetic");
} else {
assertArrayEquals(before, a.core.snapshot()); assertTrue(a.core.marker().endsWith("|RESTORED"));
assertTrue(a.messages.getLast().matches("(?s).*Wait [1-9][0-9]* seconds.*same character may be resumed now.*"), a.messages::toString);
var state = f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow();
assertNull(state.pending()); assertEquals(exit, state.lastExit());
a.core.inventory.setItemInOffHand(new CharacterSheets().create(a.core.record));
f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(a.player, (net.kyori.adventure.text.Component) null));
f.tick(); f.tick(); f.tick(); assertTrue(a.core.marker().endsWith("|PACKED"));
}
} finally { release.countDown(); }
}
}
}
@@ -1144,6 +1180,12 @@ class FantasyStickLifecycleNativeTest {
games.dmg.fantasystick.persistence.EquipmentSessionStore receipts() {
return (games.dmg.fantasystick.persistence.EquipmentSessionStore) field(plugin, "receipts");
}
void awaitCharacterAndReceipt(Actor actor) throws Exception {
// A receipt read alone cannot await work submitted later by the character-save callback.
((CharacterStorage) field(plugin, "characters")).find(actor.core.record.id())
.get(5, java.util.concurrent.TimeUnit.SECONDS);
receipts().find(actor.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
}
void tick() throws Exception {
var task = tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS);
assertNotNull(task, "Missing deferred main-thread callback"); task.run();
@@ -1220,6 +1262,7 @@ class FantasyStickLifecycleNativeTest {
boolean healthScaled;
double healthScale = 20;
Runnable scaleMutation = () -> {};
Runnable foodMutation = () -> {};
java.util.function.Consumer<org.bukkit.event.player.PlayerGameModeChangeEvent> modeEvents = ignored -> { };
final org.bukkit.craftbukkit.entity.CraftPlayer player;
final net.minecraft.server.level.ServerPlayer nativePlayer = mock(net.minecraft.server.level.ServerPlayer.class);
@@ -1253,7 +1296,7 @@ class FantasyStickLifecycleNativeTest {
}
case "getMaxHealth" -> maximumHealth.getValue();
case "getFoodLevel" -> food.getFoodLevel();
case "setFoodLevel" -> { food.setFoodLevel((Integer) args[0]); yield null; }
case "setFoodLevel" -> { food.setFoodLevel((Integer) args[0]); foodMutation.run(); yield null; }
case "getSaturation" -> food.getSaturationLevel();
case "setSaturation" -> { food.setSaturation((Float) args[0]); yield null; }
case "getExhaustion" -> food.exhaustionLevel;
@@ -0,0 +1,121 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Path;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class NutritionNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void exitEatingAndReentryCannotHealTheSavedCharacterOrLoseTheNewOrdinaryNutrition() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "character-swap-cooldown-seconds: 0\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player);
a.player.setHealth(12); a.player.setFoodLevel(7); a.player.setSaturation(2); a.player.setExhaustion(3);
f.activate(a); a.player.setHealth(4);
var sheet = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a); f.awaitCharacterAndReceipt(a);
assertEquals(12, a.player.getHealth()); assertEquals(7, a.player.getFoodLevel());
a.player.setHealth(a.maximumHealth.getValue());
a.player.setFoodLevel(20); a.player.setSaturation(12); a.player.setExhaustion(0.5f);
a.core.inventory.setItemInOffHand(sheet); f.activate(a);
assertEquals(4, a.player.getHealth(), "Ordinary eating/healing cannot refill transferable fantasy health");
assertEquals(20, a.player.getFoodLevel()); assertEquals(0, a.player.getExhaustion());
a.core.inventory.setItemInOffHand(null); f.changed(a); f.awaitCharacterAndReceipt(a);
assertEquals(a.maximumHealth.getValue(), a.player.getHealth());
assertEquals(20, a.player.getFoodLevel()); assertEquals(12, a.player.getSaturation());
assertEquals(0.5f, a.player.getExhaustion());
var characters = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertEquals(0, characters.find(a.core.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS)
.orElseThrow().health().compareTo(java.math.BigDecimal.valueOf(4)));
}
}
@Test void failedFantasyFoodSetterRestoresTheCapturedOrdinaryNutritionAndRetainsRecoveryProof() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player);
a.player.setFoodLevel(7); a.player.setSaturation(2); a.player.setExhaustion(3);
a.foodMutation = () -> {
a.foodMutation = () -> {};
throw new IllegalStateException("Injected food setter failure");
};
f.activate(a);
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
assertTrue(sessions.active(a.core.id).isEmpty());
assertEquals(7, a.player.getFoodLevel()); assertEquals(2, a.player.getSaturation());
assertEquals(3, a.player.getExhaustion());
assertNotNull(f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS)
.orElseThrow().pending());
}
}
@Test void fantasyDefeatRestoresOrdinaryNutritionInsteadOfLeavingTheFullFantasyState() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player);
a.player.setFoodLevel(7); a.player.setSaturation(2); a.player.setExhaustion(3);
f.activate(a);
assertEquals(20, a.player.getFoodLevel()); assertEquals(0, a.player.getExhaustion());
var source = new org.bukkit.craftbukkit.damage.CraftDamageSource(new net.minecraft.world.damagesource.DamageSource(
org.bukkit.craftbukkit.damage.CraftDamageType.bukkitToMinecraftHolder(org.bukkit.damage.DamageType.OUT_OF_WORLD)));
var death = new org.bukkit.event.entity.PlayerDeathEvent(a.player, source, new java.util.ArrayList<>(), 0,
net.kyori.adventure.text.Component.empty(), true);
f.dispatcher.accept(death); PartyBookNativeTest.drain(f);
assertTrue(death.isCancelled());
assertEquals(7, a.player.getFoodLevel()); assertEquals(2, a.player.getSaturation());
assertEquals(3, a.player.getExhaustion());
}
}
@Test void activeFantasyModeKeepsFoodFullBlocksFoodHealingAndRestoresOrdinaryNutrition() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var ordinary = new FantasyStickLifecycleNativeTest.Actor(data);
ordinary.core.inventory.setItemInOffHand(null);
f.players.put(a.core.id, a.player); f.players.put(ordinary.core.id, ordinary.player);
a.player.setFoodLevel(7); a.player.setSaturation(2); a.player.setExhaustion(3);
ordinary.player.setFoodLevel(8); ordinary.player.setExhaustion(4);
f.activate(a);
assertEquals(20, a.player.getFoodLevel()); assertEquals(0, a.player.getExhaustion());
var drain = new org.bukkit.event.entity.FoodLevelChangeEvent(a.player, 19);
var exhaustion = new org.bukkit.event.entity.EntityExhaustionEvent(a.player,
org.bukkit.event.entity.EntityExhaustionEvent.ExhaustionReason.SPRINT, 0.1f);
var foodHealing = new org.bukkit.event.entity.EntityRegainHealthEvent(a.player, 1,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.SATIATED);
var potionHealing = new org.bukkit.event.entity.EntityRegainHealthEvent(a.player, 1,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.MAGIC);
var abilityHealing = new org.bukkit.event.entity.EntityRegainHealthEvent(a.player, 1,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.CUSTOM);
f.dispatcher.accept(drain); f.dispatcher.accept(exhaustion);
f.dispatcher.accept(foodHealing); f.dispatcher.accept(potionHealing); f.dispatcher.accept(abilityHealing);
assertTrue(drain.isCancelled()); assertTrue(exhaustion.isCancelled()); assertTrue(foodHealing.isCancelled());
assertFalse(potionHealing.isCancelled(), "Explicit native magic healing remains available");
assertFalse(abilityHealing.isCancelled(), "Explicit ability healing remains available");
assertEquals(20, a.player.getFoodLevel()); assertEquals(0, a.player.getExhaustion());
var ordinaryDrain = new org.bukkit.event.entity.FoodLevelChangeEvent(ordinary.player, 7);
var ordinaryHealing = new org.bukkit.event.entity.EntityRegainHealthEvent(ordinary.player, 1,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.SATIATED);
f.dispatcher.accept(ordinaryDrain); f.dispatcher.accept(ordinaryHealing);
assertFalse(ordinaryDrain.isCancelled()); assertFalse(ordinaryHealing.isCancelled());
assertEquals(8, ordinary.player.getFoodLevel()); assertEquals(4, ordinary.player.getExhaustion());
a.core.inventory.setItemInOffHand(null); f.changed(a); f.awaitCharacterAndReceipt(a);
assertEquals(7, a.player.getFoodLevel()); assertEquals(2, a.player.getSaturation());
assertEquals(3, a.player.getExhaustion());
}
}
}
@@ -234,7 +234,7 @@ class PartyBookNativeTest {
open(f, a); var oldDisband = button(a, "[Disband party]");
var sheet = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a);
f.receipts().find(a.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS);
f.awaitCharacterAndReceipt(a);
b.core.inventory.setItemInOffHand(sheet); f.activate(b);
open(f, b);
assertTrue(text(b).contains(a.core.record.name()));
@@ -0,0 +1,20 @@
package games.dmg.fantasystick.combat;
import static org.junit.jupiter.api.Assertions.*;
import java.math.BigInteger;
import org.junit.jupiter.api.Test;
class BasicAttackTest {
@Test void followsTheApprovedStrengthFormulaWithoutAStatCap() {
assertEquals(BigInteger.ONE, BasicAttack.damage(BigInteger.ZERO));
assertEquals(BigInteger.ONE, BasicAttack.damage(BigInteger.TEN));
assertEquals(BigInteger.ONE, BasicAttack.damage(BigInteger.valueOf(11)));
assertEquals(BigInteger.ONE, BasicAttack.damage(BigInteger.valueOf(12)));
assertEquals(BigInteger.TWO, BasicAttack.damage(BigInteger.valueOf(14)));
var huge = BigInteger.TEN.pow(1000);
assertEquals(huge.subtract(BigInteger.TEN).divide(BigInteger.TWO), BasicAttack.damage(huge));
assertThrows(IllegalArgumentException.class, () -> BasicAttack.damage(BigInteger.valueOf(-1)));
assertThrows(NullPointerException.class, () -> BasicAttack.damage(null));
}
}
@@ -49,6 +49,35 @@ class CharacterStorageTest {
}
}
@Test void awardsConfiguredExperiencePersistentlyWithMultiLevelAndCapIdempotence() throws Exception {
var repository = new YamlCharacterRepository(data);
var rules = new games.dmg.fantasystick.progression.ProgressionRules(BigInteger.valueOf(2), BigInteger.valueOf(7), 3);
var live = new games.dmg.fantasystick.crafting.CharacterFactory().create(rules);
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
storage.create(live).join();
var awarded = storage.awardExperience(live.id(), live.revision(), BigInteger.valueOf(8), rules).join();
assertEquals(CharacterStorage.ExperienceAwardStatus.APPLIED, awarded.status());
assertEquals(1, awarded.levels());
var levelTwo = awarded.record().orElseThrow();
assertEquals(2, levelTwo.level());
assertEquals(BigInteger.valueOf(8), levelTwo.experience());
assertEquals(BigInteger.valueOf(3), levelTwo.unspentPoints());
var capped = storage.awardExperience(live.id(), levelTwo.revision(), BigInteger.valueOf(100), rules).join();
assertEquals(1, capped.levels());
assertEquals(3, capped.record().orElseThrow().level());
assertEquals(BigInteger.valueOf(14), capped.record().orElseThrow().experience());
assertEquals(CharacterStorage.ExperienceAwardStatus.AT_CAP,
storage.awardExperience(live.id(), capped.record().orElseThrow().revision(), BigInteger.ONE, rules).join().status());
assertEquals(CharacterStorage.ExperienceAwardStatus.STALE,
storage.awardExperience(live.id(), live.revision(), BigInteger.ONE, rules).join().status());
var lowered = new games.dmg.fantasystick.progression.ProgressionRules(BigInteger.ZERO, BigInteger.valueOf(7), 2);
assertEquals(CharacterStorage.ExperienceAwardStatus.AT_CAP,
storage.awardExperience(live.id(), capped.record().orElseThrow().revision(), BigInteger.ONE, lowered).join().status());
assertEquals(capped.record().orElseThrow(), repository.load(live.id()).orElseThrow(),
"Lowering configuration must not rewrite existing transferable progress");
}
}
@Test
void failedRevivalSaveLeavesDeadStateRetryableAndShutdownRejectsNewWork() throws Exception {
var saved = YamlCharacterRepositoryTest.sample(UUID.randomUUID());