Compare commits

...
6 Commits
Author SHA1 Message Date
dmg 9828590009 fix(sheets): wrap character instructions into three lore lines
Release / release (push) Successful in 8m5s
CI / build (push) Successful in 4m36s
2026-09-19 14:11:14 -04:00
dmg c85b5e91cc fix(sessions): preserve client life and group Tail pages
Release / release (push) Successful in 8m2s
CI / build (push) Successful in 4m49s
2026-09-19 14:04:57 -04:00
dmg bda7419b8b test(sessions): await health persistence in lifecycle assertions
Release / release (push) Successful in 8m33s
CI / build (push) Successful in 4m33s
2026-09-19 12:51:26 -04:00
dmg 6ac4c4bfde feat(sessions): complete native health and fantasy defeat
CI / build (push) Failing after 4m43s
Release / release (push) Failing after 4m45s
2026-09-19 12:37:20 -04:00
dmg 6f42082383 feat(book): complete paginated character inspection
Release / release (push) Successful in 8m20s
CI / build (push) Successful in 4m24s
2026-09-19 11:12:19 -04:00
dmg 9ddc139544 test(inventory): verify complete guarded control journey
CI / build (push) Successful in 5m52s
Release / release (push) Successful in 5m54s
2026-09-19 11:00:03 -04:00
26 changed files with 1510 additions and 88 deletions

No files matched your search

+31 -9
View File
@@ -1,15 +1,15 @@
# Fantasy Stick
**Transferable characters and standalone names (US-001), equipment restoration (US-002), and switch delay (US-023) have automated implementation coverage. The full RPG remains in development and is not deployment-ready.**
**Transferable characters and standalone names (US-001), equipment restoration (US-002), inventory safety (US-003), paginated character inspection (US-005), native session health/defeat (US-006), book parties/native damage protection (US-012), and switch delay (US-023) have automated implementation coverage. The full RPG remains in development and is not deployment-ready.**
Fantasy Stick is the proposed transferable-character RPG plugin for Social Minecraft. Its approved design uses Paper character sheets, native equipment storage and a book/basic-stick/seven-ability hotbar. Crafting, equipment-only offhand sessions, read-only character books and book-managed parties with native damage protection are assembled; fantasy health/nutrition, stat/ability book actions, combat progression and spells remain incomplete. The server-independent progression core now tests initial stats/points, stat purchases, CON's equal maximum/current-health gain, capped XP and `MAX` presentation. YAML persistence, backend death/revival transitions and a native signed-book factory are also present. Saved-sheet crafting is wired; anvil-name persistence now observes native accepted takes; fantasy health and spell interactions remain incomplete.
Fantasy Stick is the proposed transferable-character RPG plugin for Social Minecraft. Its approved design uses Paper character sheets, native equipment storage and a book/basic-stick/seven-ability hotbar. Crafting, native-health offhand sessions, read-only character books and book-managed parties with native damage protection are assembled; active food overrides, stat/ability book actions, combat progression and spells remain incomplete. The server-independent progression core now tests initial stats/points, stat purchases, CON's equal maximum/current-health gain, capped XP and `MAX` presentation. YAML persistence, backend death/revival transitions and a native signed-book factory are also present. Saved-sheet crafting and accepted anvil-name persistence are wired. Stat purchases and spell execution remain incomplete.
## Build and verify
- Java 25 toolchain (Gradle can resolve it), Gradle wrapper 9.6.1.
- Purpur API/runtime `26.2.build.2618-stable`; `plugin.yml` declares API `26.2`.
- Node.js 20 or later and npm for the TypeScript release-tool tests.
- ProtocolLib **5.4.0** is required at runtime and declared as a compile-only dependency; it is not bundled.
- ProtocolLib **5.4.0** is required at runtime and declared as a compile-only dependency; it is not bundled. Install the official [server distribution](https://github.com/dmulloy2/ProtocolLib/releases/tag/5.4.0), not the Maven library JAR (which lacks bundled runtime dependencies).
```sh
./gradlew clean check jar
@@ -28,7 +28,7 @@ The file adapter uses safe YAML construction, duplicate-key rejection, bounded r
`CharacterStorage` queues disk operations on its persistence worker and acknowledges successful writes only after commit. Its backend restoration checks the saved dead flag, matching current death ID and death age strictly under 24 hours at the request/selection time. It preserves progress/cooldowns and restores full character health; old books do not become eligible after a later death. This method is **not** a complete Revive spell: the engine must still validate the caster and native dialog, spend its cooldown, recheck item possession and perform physical conversion safely.
`TailBooks` creates vanilla `WRITTEN_BOOK` items with static pages. The full character name remains in display/pages; the native title is bounded to vanilla's 32 UTF-16 units without splitting a surrogate pair. `fantasystick:tail_version`, `character_id` and `death_id` identify the saved record. Pages/title are never read as authoritative stats or revival eligibility. Plugin absence does not remove the vanilla written-book contents. The equipment coordinator now intercepts active-session native death, restores gear and normal health, attempts the entry-location return, and saves authoritative death before guarded one-book conversion. Stale/offline/uncertain delivery is reported without reissue; this is not the complete Revive spell or fantasy-health integration.
`TailBooks` creates vanilla `WRITTEN_BOOK` items with static pages. The full character name remains in display/pages; the native title is bounded to vanilla's 32 UTF-16 units without splitting a surrogate pair. `fantasystick:tail_version`, `character_id` and `death_id` identify the saved record. Pages/title are never read as authoritative stats or revival eligibility. Plugin absence does not remove the vanilla written-book contents. The equipment coordinator now intercepts active-session native death, restores gear and normal health, attempts the entry-location return, and saves authoritative death before guarded one-book conversion. Stale/offline/uncertain delivery is reported without reissue; this is not the complete Revive spell. Native health/session integration is described below.
File-level atomic replacement is **not** atomicity with Minecraft inventory/player saves. Player/session receipts and proof-based equipment reconciliation are implemented below, not an administrator lock, automatic overflow drops or guaranteed cross-save recovery.
@@ -44,7 +44,7 @@ Thirteen craft-persistence tests cover these backend boundaries, including worke
## Paper sheet and recipe definition
`CharacterSheets` renders one Paper sheet from a saved live record, with its current name and the exact offhand instruction. `fantasystick:sheet_version` (integer 1) and `character_id` (UUID string) identify the character, with no owner lock or duplicated progress snapshot. Identification is only a reference: it does not authorize entry or replace saved-state/session validation. Rendering from a dead record is refused.
`CharacterSheets` renders one Paper sheet from a saved live record, with its current name and three separate lore lines: `Character sheet:`, `Place in off hand`, `to enter fantasy world.` Recipe previews and acknowledged crafted sheets use the same layout; existing items are not bulk-rewritten. `fantasystick:sheet_version` (integer 1) and `character_id` (UUID string) identify the character, with no owner lock or duplicated progress snapshot. Identification is only a reference: it does not authorize entry or replace saved-state/session validation. Rendering from a dead record is refused.
`CharacterFactory` allocates a fresh UUID and randomized two-part character name when explicitly called. It reuses the progression initializer for zero stats, level 1/zero XP, configured unspent points and full base health, with empty abilities/loadout/cooldowns and no prior death. Name collisions are allowed; identity is independent. Construction and recipe previews do not invoke allocation. Unit tests check fresh defaults/configured arbitrary-precision points, request-time allocation, seeded name variation and same-name/different-ID characters. A native composition test prepares a planned batch through YAML and verifies individually named sheets/IDs after native serialization without consuming or delivering inventory items. The assembled craft-event controller now invokes this allocator only for accepted current attempts.
@@ -102,13 +102,13 @@ Vanilla retains responsibility for item movement, XP charging and anvil damage.
Six backend tests, seven native observer tests and an added packaged-entrypoint registration test cover preservation, sequencing, failures, shutdown, preview/take separation, name fallbacks and later serialized sheet/Tail rendering. Native XP/damage execution and live clients are not exercised by these fixtures. Active overhead/chat/tab presentation now refreshes after acknowledged saves, including renaming a copied reference held by someone else; stale session/revision callbacks cannot rename a replacement holder. This slice passes 176 tests, clean/incremental packaging, three additional native-suite repeats, audit and static analysis. A controller test's leftover retry was explicitly drained before fixture reuse (12 focused stress runs pass); production shutdown remains nonblocking. Remote publication is tracked separately.
## Native health ceiling (activation still unwired)
## Native health ceiling (historical core checkpoint)
`CharacterProgress` now limits base maximum health to `min(10 + CON, supplied native limit)` without limiting CON purchases. A purchase adds only the actual maximum-health gain to current health: fractional gain when crossing the ceiling, zero gain once capped. Injury and revision/idempotency protections remain intact. The two-argument initializer uses the pinned runtime's default 1,024 HP; runtime adapters must pass their actual ceiling explicitly.
`NativeHealthLimit.read()` reads the current native ranged attribute through the pinned runtime's public Craft/native accessors, without setting attributes or changing configuration. It must run on the server thread after registry initialization. Incompatible accessors or nonpositive/nonfinite/unrepresentable native ranges fail explicitly instead of guessing. This is not a refusal based on CON. Tests cover core purchases and actual native attribute compatibility at 10.5, 1,024 and 2,048 HP; native tests restore their temporary in-process range changes in `finally`.
No virtual health, global configuration change, stored-data migration or active-player health adapter is introduced. The persisted schema's raw `10 + CON` validation envelope remains unchanged for compatibility; bounded reload/revival, native floating-point round trips, unrelated modifiers and lifecycle application still need integration. Current-health gain is calculated against the core instance's fixed supplied limit; callers must obtain/revalidate the applicable limit at their native operation boundary. This slice passes 186 tests, clean/incremental packaging, audit and static analysis. Remote publication is tracked separately.
At this historical checkpoint, no virtual health, global configuration change, stored-data migration or active-player health adapter was introduced. The persisted schema's raw `10 + CON` validation envelope remains unchanged for compatibility. The later native session integration is described below; a complete Revive spell remains unfinished. Current-health gain is calculated against the core instance's fixed supplied limit; callers must obtain/revalidate the applicable limit at their native operation boundary. This slice passes 186 tests, clean/incremental packaging, audit and static analysis. Remote publication is tracked separately.
## Session claims
@@ -122,11 +122,11 @@ Six unit tests cover exclusion, transfer/switch release, stale callbacks, invali
Version-1 box metadata uses `fantasystick:storage_version`, `storage_owner_id`, `storage_session_id` and `storage_slots`. Box positions 0–8 contain original hotbar slots 0–8; positions 9–12 contain boots, leggings, chest and head from player slots 36–39. These metadata fields are for identity/restoration, not the only copy of the items. Restoration searches main slots 9–35, so sorting relocation does not lose the box. Repeated requests, wrong identity, duplicate/malformed boxes, unexpected extra contents or occupied restoration slots do not overwrite/delete items. These are adapter error results, not a persistent administrator lock or completed recovery policy.
The coordinator supplies authenticated control ownership to the adapter, which validates all destinations before replacing controls. One new head stack uses the slot freed by removing the box, even with a full main inventory; there is no drop fallback. The equipment coordinator/event bridge now routes sheet/cursor events, emits entry warnings and registers equipment-only sessions. Fantasy-health and nutrition overrides remain separate unfinished features; standalone display projection is implemented below. Native tests use real player-slot storage with only the absent network recipient stubbed; client packets and live gameplay remain unverified.
The coordinator supplies authenticated control ownership to the adapter, which validates all destinations before replacing controls. One new head stack uses the slot freed by removing the box, even with a full main inventory; there is no drop fallback. The equipment coordinator/event bridge now routes sheet/cursor events, emits entry warnings and registers sessions. Native health/vitals integration is described below; active nutrition overrides remain unfinished. Standalone display projection is implemented below. Native tests use real player-slot storage with only the absent network recipient stubbed; client packets and live gameplay remain unverified.
## Equipment sessions and safe box movement
US-002 passes all acceptance criteria and **359 automated tests** (315 Java/native and 44 release-tool tests), clean/incremental packaging, dependency audit and static analysis. The new equipment path loads authoritative characters, claims player/character identity, and acknowledges `<plugin data>/sessions/<player UUID>.yml` before packing. Preparation failure leaves gear unchanged. Nine typed bound controls occupy the hotbar; their book/ability actions are not implemented yet.
The historical US-002 completion checkpoint passed all acceptance criteria and **359 automated tests** (315 Java/native and 44 release-tool tests), clean/incremental packaging, dependency audit and static analysis. The new equipment path loads authoritative characters, claims player/character identity, and acknowledges `<plugin data>/sessions/<player UUID>.yml` before packing. Preparation failure leaves gear unchanged. Nine typed bound controls occupy the hotbar; book/ability actions were not yet implemented at that checkpoint. Current inspection and party actions are described below; basic/learned attacks remain unfinished.
The genuine **Fantasy Storage Box** contains actual original items. Picking it up for a move leaves it in main storage and issues a harmless bound cursor move-control. Confirming a valid main-storage destination exchanges the real box and that destination's contents; closing the inventory cancels the move. Sorting, stale callbacks and lifecycle changes cannot confer movement authority on a copied control. Full storage needs no extra space or overflow drops. Ordinary sheet movements, including Q/Ctrl-Q, remain supported.
@@ -164,6 +164,28 @@ Exit, defeat and best-effort lifecycle/recovery cleanup restore only an owned Ad
A same-process retry can acknowledge gear already verified restored for that exact player while retrying a vetoed mode restoration; a serialized `RESTORED` marker alone cannot establish this after restart. Missing game-mode proof leaves the mode and receipt retained even when independently verified gear can be restored and synchronized. Player-data and YAML saves are not one transaction; unobserved out-of-band state edits are not detectable history. No forced player save, blanket Survival reset or deployment is introduced.
## Native fantasy health, exit and defeat
Offhand entry applies actual native maximum health `min(10 + CON, native health limit)`, resumes saved remaining character health, and shows public blue particles. A fresh character's saved health starts full. CON has no gameplay cap; high values do not reject entry. Native damage/healing rules remain in effect, with the existing party/mixed-mode protection. The book reports current native health rather than waiting for an exit save.
`NativeMaximumHealth` owns only the effective-value cache/dirty callback of that player's existing native maximum-health instance. Base values, modifiers and native potion expiry remain owned by their original sources; nothing is copied back from an old modifier snapshot. The native player health methods and client attribute snapshot are tested. This adapter uses **pinned Purpur 2618 internals**, including private fields; a detached startup probe exercises installation, recalculation and removal before gameplay is registered. Runtime upgrades require renewed native compatibility verification, not a guessed fallback or global attribute-limit changes. Matching native health scaling displays the real fantasy maximum, not a separate virtual health pool; a later unrelated display-scale change is preserved on exit.
Ordinary health, food, saturation, exhaustion and health-display state belong to the holder/session, not the character sheet. The session receipt acknowledges this snapshot before native mutation; versioned player PDC records its application/restoration phase. Voluntary exit restores ordinary gear/vitals at the current location, with ordinary health capped by the current legitimate maximum. Character damage is saved on the worker, and the character claim/receipt cannot complete ahead of that acknowledgment. Quit and disable perform the same best-effort cleanup. Failed or uncertain saves retain the receipt and acknowledged character record instead of permitting a stale-health reentry. Missing/ambiguous native proof never authorizes a snapshot heal; process-owned maximum overrides are still retired independently of equipment/client-write failures.
An intercepted fantasy defeat records authoritative death once, removes session state, restores ordinary health to its current maximum and attempts return to the entry location. Ordinary health is restored before health-scale setters can send or queue client health packets: cancelled defeat must not deliver zero health and strand an otherwise alive player on a death screen. Refused/failed return leaves the player at the current location with a warning, not an alternate destination or invulnerability. A public red burst marks the defeat location. After the death acknowledgment, guarded conversion replaces the unchanged sheet stack with one signed Tail book. New Tails group ordinary-sized name/level/XP/points/stats on a summary page, then give each learned ability its own section with saved ID, assigned ability slots and cooldown deadline. Unknown IDs, saved unlearned cooldowns, empty abilities and death time stay explicit; long content continues onto additional pages without truncation. No ability descriptions are invented and existing books are not rewritten. Static Unicode-safe Tail pagination survives native serialization without a plugin; signed books use a different codec from the 100-page writable-book format. Stale or ambiguous conversion never overwrites another item or automatically reissues a book.
Interrupted recovery requires matching player/session receipt and native proof. It preserves current native modifier ownership and does not resurrect expired effects. Repeated/stale recovery cannot replay ordinary health or recreate an already released claim. This is **not crash-atomic** across player, inventory and plugin files; unresolved native setter phases and unacknowledged progress may require inspection. Legacy equipment-only receipts remain readable but do not grant health-restoration authority. Older binaries do not understand the new optional ordinary-vitals receipt field; do not downgrade across unresolved newer receipts.
Active hunger/exhaustion suppression and food-regeneration discrimination remain US-026 work. Book stat purchases, XP delivery, basic/learned attacks and Revive remain separate unfinished stories. The preceding equipment/progression checkpoint sections are historical evidence, not the current health scope.
## Character inspection and inventory safety
The first-hotbar-slot book reads the active character's authoritative name, six stats, health, level, XP/MAX, points and learned identities. Inspection does not change character data or inventory. Plain-text pages wrap conservatively for the vanilla font without splitting Unicode code points. Large supported records use volumes of at most 80 inspection pages, leaving room for navigation and party controls while bounding each opened view's payload. This is a plugin presentation bound, not Minecraft's 100-page writable-book cap; signed books use a different codec. `[Next pages]` / `[Previous pages]` reread current data; unknown ability IDs and arbitrarily large saved numbers are not silently discarded.
Deferred reads and single-use navigation validate the player, session, character and held control. Superseded requests, ended sessions, quit/disable and invalid client tokens cannot open a stale view. Read failures preserve data and allow reopening. Native tests cover overflow, multiple volumes, rename, transfer, reload and unchanged inventory/data; actual client rendering and custom resource-pack fonts are not claimed as tested.
All nine hotbar controls remain bound, with seven selectable placeholders on a new character. Native containment tests cover export/consumption, hidden swaps, recipe autofill, armor/cosmetics, cursor custody and sorting relocation. The packaged inventory journey proves that these guards still permit the real book action and that removing the transferable offhand sheet restores ordinary equipment. Future attack/ability consumers must retain these same safeguards.
## Character-book parties and combat protection
Use the slot-1 book to create a party, invite online players, accept invitations, leave, kick members or disband. Parties hold up to six consenting players. Only the creator/leader may invite, kick or disband; a leader leaving disbands rather than transferring leadership. Invitations last 60 real seconds and reserve no capacity. Book actions are single-use and bound to the current player/session/view, with authority and capacity rechecked on execution. Membership belongs to the player, not a traded character sheet, and grants no XP by itself.
@@ -21,7 +21,7 @@ import java.time.Clock;
import java.util.logging.Level;
import org.bukkit.plugin.java.JavaPlugin;
/** Crafting, equipment sessions and standalone names; health, nutrition and spells remain inactive. */
/** Crafting, native fantasy sessions, books, parties and names; progression actions and spells remain separate. */
public final class FantasyStickPlugin extends JavaPlugin {
private CharacterStorage characters;
private ManualCraftingController crafting;
@@ -42,6 +42,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
private CharacterChatNames chatNames;
private games.dmg.fantasystick.persistence.AdventureSettings adventureSettings;
private games.dmg.fantasystick.session.AdventureModeLease gameModes;
private games.dmg.fantasystick.session.SessionVitals vitals;
private games.dmg.fantasystick.session.SessionVisuals sessionVisuals;
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
@Override public void onEnable() {
@@ -106,6 +108,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
gameModes = new games.dmg.fantasystick.session.AdventureModeLease();
sessions = new EquipmentSessions(characters, receipts, switchDelay, Clock.systemUTC(), nextTick,
notice -> notifyPlayer(generation, notice), diagnostics, presentation, adventureSettings::enabled, gameModes);
vitals = new games.dmg.fantasystick.session.SessionVitals();
sessions.attachVitals(vitals);
parties = new games.dmg.fantasystick.party.PartyRoster(Clock.systemUTC(), id -> {
var player = getServer().getPlayer(id);
return player != null && player.isOnline();
@@ -136,6 +140,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
partyCombat.install(this);
getServer().getPluginManager().registerEvents(books, this);
getServer().getPluginManager().registerEvents(gameModes, this);
sessionVisuals = new games.dmg.fantasystick.session.SessionVisuals(sessions,
failure -> getLogger().log(Level.WARNING, "Fantasy session particle delivery failed", failure));
sessionVisuals.install(this);
recipeRegistered = getServer().addRecipe(new CharacterSheets().recipe());
if (!recipeRegistered) throw new IllegalStateException("Character-sheet recipe key is already registered");
for (var player : getServer().getOnlinePlayers()) sessions.arrive(player);
@@ -166,6 +173,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingCharacters = characters; characters = null;
var closingSettings = adventureSettings; adventureSettings = null;
var closingModes = gameModes; gameModes = null;
var closingVitals = vitals; vitals = null;
var closingVisuals = sessionVisuals; sessionVisuals = 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);
@@ -175,11 +184,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
cleanup("configuration", closingSettings::close);
}
if (closingBooks != null) cleanup("character book", closingBooks::close);
if (closingVisuals != null) cleanup("session particles", closingVisuals::close);
if (closingParties != null) cleanup("parties", closingParties::close);
if (closingPartyCombat != null) cleanup("party combat", closingPartyCombat::close);
if (closingEvents != null) cleanup("session events", closingEvents::close);
if (closingCrafting != null) cleanup("craft attempts", closingCrafting::close);
if (closingSessions != null) cleanup("equipment sessions", closingSessions::close);
if (closingVitals != null) cleanup("native health hooks", closingVitals::close);
unregister(closingModes);
unregister(closingChat);
unregister(closingPartyEvents);
@@ -243,7 +254,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
default -> notice.message();
};
player.sendMessage(message);
player.spawnParticle(org.bukkit.Particle.ANGRY_VILLAGER, player.getLocation().add(0, 1, 0), 8, 0.3, 0.4, 0.3, 0);
if (notice.code().equals("DEFEAT")) games.dmg.fantasystick.session.SessionVisuals.burst(player, org.bukkit.Color.RED, 12);
else player.spawnParticle(org.bukkit.Particle.ANGRY_VILLAGER, player.getLocation().add(0, 1, 0), 8, 0.3, 0.4, 0.3, 0);
};
if (lifetime != generation) return;
if (getServer().isPrimaryThread()) delivery.run();
@@ -0,0 +1,37 @@
package games.dmg.fantasystick.book;
import java.util.ArrayList;
import java.util.List;
import net.kyori.adventure.text.Component;
/** Plain native book pages, conservatively bounded for the vanilla font (no styled/bold text). */
public final class BookPagination {
private BookPagination() { }
public static List<Component> pages(String text) {
var lines = new ArrayList<String>();
var line = new StringBuilder();
int width = 0;
for (int offset = 0; offset < text.length();) {
int codePoint = text.codePointAt(offset);
offset += Character.charCount(codePoint);
if (codePoint == '\n') {
lines.add(line.toString()); line.setLength(0); width = 0;
continue;
}
// ASCII is at most six pixels; vanilla's Unicode fallback is at most nine.
// Leave six pixels of horizontal slack and one line of vertical slack.
int advance = codePoint < 128 ? 6 : 9;
if (width + advance > 108) {
lines.add(line.toString()); line.setLength(0); width = 0;
}
line.appendCodePoint(codePoint); width += advance;
}
lines.add(line.toString());
var pages = new ArrayList<Component>();
for (int start = 0; start < lines.size(); start += 13) {
pages.add(Component.text(String.join("\n", lines.subList(start, Math.min(start + 13, lines.size())))));
}
return List.copyOf(pages);
}
}
@@ -6,7 +6,6 @@ import games.dmg.fantasystick.progression.ProgressionRules;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.NativeHealthLimit;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
@@ -27,6 +26,11 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
private final Consumer<Failure> failures;
private final Map<UUID, Object> pending = new ConcurrentHashMap<>();
private volatile boolean closed;
private static final int VOLUME_SIZE = 80; // Leave room for navigation and the bounded party pages.
private static final net.kyori.adventure.key.Key PAGES = net.kyori.adventure.key.Key.key("fantasystick", "character_pages");
private static final java.util.regex.Pattern PAGE_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([01])\"");
private record View(String nonce, ControlInput input, int previous, int next) { }
private final Map<UUID, View> views = new ConcurrentHashMap<>();
/** Scheduler must always defer to the server thread. Diagnostics must not access players or throw. */
public CharacterBookController(CharacterStorage characters, ProgressionRules rules,
@@ -39,11 +43,14 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
this.partyControls = new PartyBookControls(parties, server, names, this::accept, this::report);
}
@Override public void accept(ControlInput input) {
@Override public void accept(ControlInput input) { load(input, 0); }
private void load(ControlInput input, int offset) {
if (closed || input.kind() != ControlInput.Kind.BOOK || input.gesture() != ControlInput.Gesture.USE
|| !input.stillCurrent()) return;
var owner = input.session().player();
partyControls.forget(owner);
views.remove(owner);
Object ticket = new Object();
pending.put(owner, ticket);
characters.find(input.session().character()).whenComplete((record, failure) -> {
@@ -60,8 +67,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
input.player().sendMessage("This character is no longer available.");
return;
}
try { input.player().openBook(render(record.orElseThrow(), input)); }
catch (RuntimeException error) { partyControls.forget(owner); report(input, error); }
try { input.player().openBook(render(record.orElseThrow(), input, offset)); }
catch (RuntimeException error) { partyControls.forget(owner); views.remove(owner); report(input, error); }
});
} catch (RuntimeException rejected) {
pending.remove(owner, ticket);
@@ -70,7 +77,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
});
}
private ItemStack render(CharacterRecord record, ControlInput input) {
private ItemStack render(CharacterRecord record, ControlInput input, int offset) {
var first = new StringBuilder(record.name()).append("\n\n");
for (var stat : Stat.values()) first.append(stat).append(": ").append(record.stats().get(stat)).append('\n');
var maximum = record.maximumHealth().min(NativeHealthLimit.read());
@@ -78,19 +85,55 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
: record.experience().remainder(rules.xpPerLevel()) + "/" + rules.xpPerLevel();
String learned = record.learned().isEmpty() ? "none" : String.join(", ", record.learned().stream().sorted().toList());
String second = "Level: " + record.level() + "\nXP: " + xp + "\nUnspent points: " + record.unspentPoints()
+ "\nHealth: " + record.health().min(maximum).stripTrailingZeros().toPlainString() + "/" + maximum.stripTrailingZeros().toPlainString()
+ "\nHealth: " + java.math.BigDecimal.valueOf(input.player().getHealth()).min(maximum).stripTrailingZeros().toPlainString()
+ "/" + maximum.stripTrailingZeros().toPlainString()
+ "\n\nAbilities: " + learned;
var item = new ItemStack(Material.WRITTEN_BOOK);
var meta = (BookMeta) item.getItemMeta();
meta.setTitle("Character");
meta.setAuthor("Fantasy Stick");
var pages = new java.util.ArrayList<Component>(List.of(Component.text(first.toString()), Component.text(second)));
var all = BookPagination.pages(first + "\n" + second);
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));
if (all.size() > VOLUME_SIZE) {
var view = new View(UUID.randomUUID().toString(), input, start > 0 ? start - VOLUME_SIZE : -1,
end < all.size() ? end : -1);
views.put(input.session().player(), view);
Component navigation = Component.text("Character pages\n\n" + (start + 1) + "–" + end + "/" + all.size());
if (view.previous() >= 0) navigation = navigation.append(Component.newline()).append(pageButton("Previous pages", view, 0));
if (view.next() >= 0) navigation = navigation.append(Component.newline()).append(pageButton("Next pages", view, 1));
pages.add(navigation);
}
pages.addAll(partyControls.pages(input));
meta.pages(pages);
item.setItemMeta(meta);
return item;
}
private static Component pageButton(String label, View view, int direction) {
return Component.text("[" + label + "]", net.kyori.adventure.text.format.NamedTextColor.DARK_BLUE)
.clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(PAGES,
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\"" + view.nonce() + ":" + direction + "\"")));
}
@org.bukkit.event.EventHandler
public void pageAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
if (closed || !PAGES.equals(event.getIdentifier())
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)) return;
var player = connection.getPlayer();
var view = views.get(player.getUniqueId());
if (view == null || view.input().player() != player || event.getTag() == null) return;
String raw = event.getTag().string();
if (raw.length() > 64) return;
var match = PAGE_PAYLOAD.matcher(raw);
if (!match.matches() || !view.nonce().equals(match.group(1))) return;
int offset = match.group(2).equals("0") ? view.previous() : view.next();
if (offset < 0 || !views.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
// Reread authoritative data, rather than retaining a stale giant character snapshot.
load(view.input(), offset);
}
private void report(ControlInput input, Throwable failure) {
try { failures.accept(new Failure(input.session().player(), input.session().character(), failure)); }
catch (RuntimeException ignored) { /* A diagnostic failure must not replay a book request. */ }
@@ -103,8 +146,9 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
public void quit(org.bukkit.event.player.PlayerQuitEvent event) {
var owner = event.getPlayer().getUniqueId();
pending.remove(owner);
views.remove(owner);
partyControls.forget(owner);
}
@Override public void close() { closed = true; pending.clear(); partyControls.close(); }
@Override public void close() { closed = true; pending.clear(); views.clear(); partyControls.close(); }
}
@@ -16,7 +16,8 @@ public final class CharacterSheets {
public static final NamespacedKey RECIPE = new NamespacedKey("fantasystick", "character_sheet");
private static final NamespacedKey VERSION = new NamespacedKey("fantasystick", "sheet_version");
private static final NamespacedKey CHARACTER = new NamespacedKey("fantasystick", "character_id");
private static final String INSTRUCTION = "Character sheet: place in off hand to enter fantasy world.";
private static final List<Component> INSTRUCTION = List.of(Component.text("Character sheet:"),
Component.text("Place in off hand"), Component.text("to enter fantasy world."));
public ItemStack create(CharacterRecord character) {
if (character.dead()) throw new IllegalArgumentException("Dead characters require authoritative revival before sheet creation");
@@ -40,7 +41,7 @@ public final class CharacterSheets {
var item = new ItemStack(Material.PAPER);
var meta = item.getItemMeta();
meta.displayName(Component.text(name));
meta.lore(List.of(Component.text(INSTRUCTION)));
meta.lore(INSTRUCTION);
item.setItemMeta(meta);
return item;
}
@@ -1,5 +1,6 @@
package games.dmg.fantasystick.items;
import games.dmg.fantasystick.book.BookPagination;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.progression.Stat;
import java.util.ArrayList;
@@ -35,14 +36,26 @@ public final class TailBooks {
meta.setGeneration(BookMeta.Generation.ORIGINAL);
meta.displayName(Component.text(fullTitle));
List<Component> pages = new ArrayList<>();
pages.add(Component.text(fullTitle));
pages.add(Component.text("Level: " + character.level()));
pages.add(Component.text("XP: " + character.experience()));
pages.add(Component.text("Unspent points: " + character.unspentPoints()));
for (Stat stat : Stat.values()) pages.add(Component.text(stat + ": " + character.stats().get(stat)));
pages.add(Component.text("Abilities: " + (character.learned().isEmpty() ? "None" : "")));
character.learned().stream().sorted().forEach(ability -> pages.add(Component.text(ability)));
pages.add(Component.text("Fell: " + character.diedAt()));
var summary = new StringBuilder(fullTitle).append("\nLevel: ").append(character.level())
.append("\nXP: ").append(character.experience()).append("\nUnspent points: ").append(character.unspentPoints());
for (Stat stat : Stat.values()) summary.append('\n').append(stat).append(": ").append(character.stats().get(stat));
pages.addAll(BookPagination.pages(summary.toString()));
if (character.learned().isEmpty()) pages.addAll(BookPagination.pages("Abilities: None"));
character.learned().stream().sorted().forEach(ability -> {
var slots = new ArrayList<String>();
for (int slot = 0; slot < character.loadout().size(); slot++) {
if (character.loadout().get(slot).equals(ability)) slots.add(Integer.toString(slot + 1));
}
var section = new StringBuilder("Ability: ").append(ability)
.append("\nSlots: ").append(slots.isEmpty() ? "none" : String.join(", ", slots));
var deadline = character.cooldowns().get(ability);
section.append(deadline == null ? "\nCooldown: none" : "\nCooldown until:\n" + deadline);
pages.addAll(BookPagination.pages(section.toString()));
});
// Unlearned/unknown saved cooldowns remain readable without pretending they are learned abilities.
character.cooldowns().keySet().stream().filter(id -> !character.learned().contains(id)).sorted().forEach(id ->
pages.addAll(BookPagination.pages("Saved cooldown\n" + id + "\nUntil:\n" + character.cooldowns().get(id))));
pages.addAll(BookPagination.pages("Fell:\n" + character.diedAt()));
meta.pages(pages);
var data = meta.getPersistentDataContainer();
data.set(VERSION, PersistentDataType.INTEGER, 1);
@@ -210,6 +210,25 @@ public final class CharacterStorage implements AutoCloseable {
});
}
/** Session-owned remaining health; retain all other current fields and reject concurrent revision changes. */
public CompletableFuture<Optional<CharacterRecord>> saveHealth(UUID characterId, java.math.BigDecimal health,
java.math.BigDecimal nativeLimit) {
Objects.requireNonNull(characterId);
if (health.signum() < 0 || nativeLimit.signum() <= 0) throw new IllegalArgumentException("Invalid health bound");
return submit(() -> {
var found = repository.load(characterId);
if (found.isEmpty() || found.orElseThrow().dead()) return Optional.empty();
var old = found.orElseThrow();
var bounded = health.min(old.maximumHealth()).min(nativeLimit);
if (old.health().compareTo(bounded) == 0) return Optional.of(old);
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints(), bounded, old.learned(), old.loadout(), old.cooldowns(),
false, old.deathId(), old.diedAt());
repository.replace(saved, old.revision());
return Optional.of(saved);
});
}
/**
* One accepted defeat against the latest live record on this worker, including queued names.
* The repository's locked revision compare-and-replace prevents overwriting a concurrent
@@ -48,8 +48,11 @@ public final class EquipmentSessionStore implements AutoCloseable {
private final ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-equipment-session-persistence"));
public record Receipt(UUID sessionId, UUID characterId, Instant preparedAt, String previousGameMode) {
public Receipt(UUID sessionId, UUID characterId, Instant preparedAt) { this(sessionId, characterId, preparedAt, null); }
public record Receipt(UUID sessionId, UUID characterId, Instant preparedAt, String previousGameMode, OrdinaryVitals ordinaryVitals) {
public Receipt(UUID sessionId, UUID characterId, Instant preparedAt) { this(sessionId, characterId, preparedAt, null, null); }
public Receipt(UUID sessionId, UUID characterId, Instant preparedAt, String previousGameMode) {
this(sessionId, characterId, preparedAt, previousGameMode, null);
}
public Receipt {
Objects.requireNonNull(sessionId);
Objects.requireNonNull(characterId);
@@ -116,15 +119,20 @@ public final class EquipmentSessionStore implements AutoCloseable {
/** A receipt acknowledgment precedes mode/gear mutation; it is not proof either happened. */
public CompletionStage<State> prepare(UUID player, UUID session, UUID character, String previousGameMode) {
return prepare(player, session, character, previousGameMode, null);
}
public CompletionStage<State> prepare(UUID player, UUID session, UUID character, String previousGameMode, OrdinaryVitals ordinaryVitals) {
Objects.requireNonNull(player);
var receipt = new Receipt(session, character, clock.instant(), previousGameMode);
var receipt = new Receipt(session, character, clock.instant(), previousGameMode, ordinaryVitals);
return submit(() -> withLock(() -> {
var found = load(player);
if (found.isPresent()) {
var old = found.orElseThrow();
if (old.pending() != null) {
if (session.equals(old.pending().sessionId()) && character.equals(old.pending().characterId())
&& Objects.equals(previousGameMode, old.pending().previousGameMode())) {
&& Objects.equals(previousGameMode, old.pending().previousGameMode())
&& Objects.equals(ordinaryVitals, old.pending().ordinaryVitals())) {
return old;
}
throw new IOException("Conflicting pending equipment session");
@@ -296,6 +304,7 @@ public final class EquipmentSessionStore implements AutoCloseable {
pending.put("character-id", receipt.characterId().toString());
pending.put("prepared-at", receipt.preparedAt().toString());
if (receipt.previousGameMode() != null) pending.put("previous-game-mode", receipt.previousGameMode());
if (receipt.ordinaryVitals() != null) pending.put("ordinary-vitals", receipt.ordinaryVitals().encode());
root.put("pending", pending);
}
if (state.lastExit() != null) {
@@ -317,13 +326,14 @@ public final class EquipmentSessionStore implements AutoCloseable {
Exit exit = null;
if (root.containsKey("pending")) {
var keys = Set.of("session-id", "character-id", "prepared-at");
var row = mapping(root.get("pending"), keys, Set.of("session-id", "character-id", "prepared-at", "previous-game-mode"));
var row = mapping(root.get("pending"), keys, Set.of("session-id", "character-id", "prepared-at", "previous-game-mode", "ordinary-vitals"));
String previousMode = null;
if (row.containsKey("previous-game-mode")) {
if (!(row.get("previous-game-mode") instanceof String value)) throw new IllegalArgumentException("Invalid previous mode");
previousMode = value;
}
pending = new Receipt(id(row.get("session-id")), id(row.get("character-id")), instant(row.get("prepared-at")), previousMode);
pending = new Receipt(id(row.get("session-id")), id(row.get("character-id")), instant(row.get("prepared-at")), previousMode,
row.containsKey("ordinary-vitals") ? OrdinaryVitals.decode(text(row.get("ordinary-vitals"))) : null);
}
if (root.containsKey("last-exit")) {
var keys = Set.of("session-id", "character-id", "exited-at", "deadline");
@@ -0,0 +1,27 @@
package games.dmg.fantasystick.persistence;
/** Holder-owned ordinary state, never transferable character progress or an attribute snapshot. */
public record OrdinaryVitals(double health, int food, float saturation, float exhaustion,
boolean scaled, double scale) {
public OrdinaryVitals {
if (!Double.isFinite(health) || health < 0 || food < 0 || food > 20
|| !Float.isFinite(saturation) || saturation < 0 || !Float.isFinite(exhaustion) || exhaustion < 0
|| !Double.isFinite(scale) || scale <= 0) throw new IllegalArgumentException("Invalid ordinary vitals");
}
public String encode() {
return health + ";" + food + ";" + saturation + ";" + exhaustion + ";" + scaled + ";" + scale;
}
public static OrdinaryVitals decode(String encoded) {
if (encoded.length() > 256) throw new IllegalArgumentException("Oversized ordinary vitals");
String[] fields = encoded.split(";", -1);
if (fields.length != 6 || !fields[4].equals("true") && !fields[4].equals("false")) {
throw new IllegalArgumentException("Invalid ordinary vitals fields");
}
var value = new OrdinaryVitals(Double.parseDouble(fields[0]), Integer.parseInt(fields[1]),
Float.parseFloat(fields[2]), Float.parseFloat(fields[3]), Boolean.parseBoolean(fields[4]), Double.parseDouble(fields[5]));
if (!encoded.equals(value.encode())) throw new IllegalArgumentException("Noncanonical ordinary vitals");
return value;
}
}
@@ -24,8 +24,8 @@ import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
/**
* Server-thread equipment custody and minimal defeat handling. Fantasy health attributes,
* nutrition and rewards are not applied here. Presentation is an independently cleaned output port. No inventory/file transaction,
* Server-thread equipment custody, optional native vitals overlay and intercepted defeat.
* Rewards and active food overrides remain separate. Presentation is independently cleaned. No inventory/file transaction,
* automatic retry or snapshot rollback is promised. The scheduler must defer onto
* the server thread; diagnostics must be thread-safe and must not use Player APIs.
*/
@@ -57,6 +57,7 @@ public final class EquipmentSessions implements AutoCloseable {
private boolean closed;
private java.util.function.BooleanSupplier adventureMode = () -> false;
private AdventureModeLease gameModes = new AdventureModeLease();
private SessionVitals vitals;
private boolean closeSweepFinished;
private int persistenceOperations;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
@@ -77,6 +78,11 @@ public final class EquipmentSessions implements AutoCloseable {
boolean effectsStarted;
String previousGameMode;
boolean modeStarted;
games.dmg.fantasystick.persistence.OrdinaryVitals ordinaryVitals;
boolean vitalsStarted;
boolean healthSaveRequested;
boolean healthAcknowledged = true;
boolean nativeRestored;
boolean gearRestoredForModeRecovery;
boolean presentationInstalled;
boolean cleaning;
@@ -85,6 +91,7 @@ public final class EquipmentSessions implements AutoCloseable {
boolean nativeUncertain;
boolean exitRequested;
boolean arrival;
Attempt recoveryOwner;
org.bukkit.Location entryLocation;
boolean defeated;
boolean deathResolved;
@@ -134,6 +141,12 @@ public final class EquipmentSessions implements AutoCloseable {
this.gameModes = Objects.requireNonNull(gameModes);
}
/** Install before exposing entry; equipment-only adapter tests can omit the health overlay. */
public synchronized void attachVitals(SessionVitals value) {
if (vitals != null || !owned.isEmpty()) throw new IllegalStateException("Vitals already attached or sessions started");
vitals = Objects.requireNonNull(value);
}
/** One server-thread custody bridge per coordinator; never called by persistence workers. */
synchronized void attachCursorCustody(Consumer<UUID> invalidate) {
if (cursorCustodyAttached) throw new IllegalStateException("Cursor custody already attached");
@@ -239,7 +252,8 @@ public final class EquipmentSessions implements AutoCloseable {
private record Loaded(Optional<CharacterRecord> character, Optional<EquipmentSessionStore.State> state) { }
private boolean entryNativeSafe(Attempt attempt, EquipmentSessionStore.State state) {
if (hasRemnants(attempt.player) || !gameModes.available(attempt.player)) return false;
if (hasRemnants(attempt.player) || !gameModes.available(attempt.player)
|| vitals != null && !vitals.available(attempt.player)) return false;
var marker = EquipmentSessionMarker.read(attempt.player).orElse(null);
if (EquipmentSessionMarker.present(attempt.player)) {
// A terminal marker is resolved only by its matching completed file acknowledgment.
@@ -303,8 +317,9 @@ public final class EquipmentSessions implements AutoCloseable {
attempt.session = attempt.claim.sessionId();
owned.put(attempt.owner, attempt);
controls.create(record, attempt.owner, attempt.session); // Validate all nine controls before preparation/packing.
if (vitals != null) attempt.ordinaryVitals = vitals.capture(attempt.player);
attempt.saving = true;
persist(() -> receipts.prepare(attempt.owner, attempt.session, attempt.character, attempt.previousGameMode), (saved, error) -> {
persist(() -> receipts.prepare(attempt.owner, attempt.session, attempt.character, attempt.previousGameMode, attempt.ordinaryVitals), (saved, error) -> {
attempt.saving = false;
if (error != null) { attempt.uncertainSave = true; fail(attempt, error); return; }
attempt.acknowledged = true;
@@ -349,6 +364,12 @@ public final class EquipmentSessions implements AutoCloseable {
if (interrupted(attempt)) return;
}
mark(attempt, EquipmentSessionMarker.Phase.PACKED);
if (vitals != null) {
attempt.vitalsStarted = true;
attempt.healthAcknowledged = false;
vitals.enter(attempt.player, attempt.session, attempt.ordinaryVitals, record);
if (attempt.exitRequested || !current(attempt)) return;
}
pending.remove(attempt.owner, attempt);
if (!attempt.exitRequested) {
attempt.record = record;
@@ -443,8 +464,8 @@ public final class EquipmentSessions implements AutoCloseable {
attempt.deathReference = copy(attempt.player.getInventory().getItemInOffHand());
attempt.deathView = attempt.player.getOpenInventory();
restoreExit(attempt);
// Equipment-only sessions have not changed attributes. Read the current ordinary
// maximum, including unrelated modifiers; never set a base value or modifiers.
// Read the current maximum after owned overlay cleanup, including legitimate
// ordinary modifiers. Never reconstruct attributes from an entry snapshot.
double maximum = Objects.requireNonNull(attempt.player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).getValue();
event.setReviveHealth(maximum);
attempt.player.setHealth(maximum); // Native cancellation only fills health when <= 0.
@@ -548,19 +569,24 @@ public final class EquipmentSessions implements AutoCloseable {
boolean reserved = transitioning.putIfAbsent(attempt.owner, attempt) == null;
try {
invalidateCursor(attempt.owner);
saveRemainingHealth(attempt);
boolean modeRestored = restoreGameMode(attempt);
EquipmentStorage.RestoreResult result;
try {
result = restoreMarked(attempt);
} catch (RuntimeException failure) {
attempt.nativeUncertain = true;
restoreVitals(attempt); // Proven health ownership is independent of uncertain equipment writes.
persistExit(attempt, false);
diagnostic(attempt, failure);
notice(attempt.owner, "NATIVE_UNCERTAIN", "Restoration failed; items and receipt require inspection.");
return EquipmentStorage.RestoreResult.INVALID_BOX;
}
if (result == EquipmentStorage.RestoreResult.RESTORED && !modeRestored) attempt.gearRestoredForModeRecovery = true;
persistExit(attempt, result == EquipmentStorage.RestoreResult.RESTORED && modeRestored);
boolean vitalsRestored = restoreVitals(attempt);
if (result == EquipmentStorage.RestoreResult.RESTORED && (!modeRestored || !vitalsRestored || !attempt.healthAcknowledged)) {
attempt.gearRestoredForModeRecovery = true;
}
persistExit(attempt, result == EquipmentStorage.RestoreResult.RESTORED && modeRestored && vitalsRestored);
if (result != EquipmentStorage.RestoreResult.RESTORED) {
notice(attempt.owner, result.name(), "Equipment recovery refused: " + result);
} else sync(attempt);
@@ -608,14 +634,42 @@ public final class EquipmentSessions implements AutoCloseable {
catch (RuntimeException failure) { diagnostic(attempt, failure); }
}
private void saveRemainingHealth(Attempt attempt) {
if (!attempt.vitalsStarted || attempt.defeated || attempt.healthSaveRequested) return;
attempt.healthSaveRequested = true; // Failed/uncertain writes are not authority for an automatic replay.
try {
var remaining = vitals.remaining(attempt.player, attempt.session, attempt.ordinaryVitals);
var limit = NativeHealthLimit.read();
persist(() -> characters.saveHealth(attempt.character, remaining, limit), (saved, failure) -> {
if (failure != null || saved.isEmpty()) {
diagnostic(attempt, failure != null ? failure : new IllegalStateException("Character unavailable for health save"));
return;
}
attempt.healthAcknowledged = true;
if (attempt.nativeRestored) persistExit(attempt, true);
});
} catch (RuntimeException failure) { diagnostic(attempt, failure); }
}
private boolean restoreVitals(Attempt attempt) {
if (vitals == null || attempt.ordinaryVitals == null) return true;
try {
if (vitals.restore(attempt.player, attempt.session, attempt.ordinaryVitals)) return true;
} catch (RuntimeException failure) { diagnostic(attempt, failure); }
notice(attempt.owner, "VITALS_RECOVERY", "Health/nutrition restoration was refused or ambiguous; retained receipt requires inspection.");
return false;
}
private void persistExit(Attempt attempt, boolean restored) {
attempt.nativeRestored = restored;
boolean complete = restored && (attempt.healthAcknowledged || attempt.defeated);
attempt.saving = true;
persist(() -> restored
persist(() -> complete
? receipts.completeExit(attempt.owner, attempt.session, switchDelay)
: receipts.recordExit(attempt.owner, attempt.session, switchDelay), (state, failure) -> {
attempt.saving = false;
if (failure != null) diagnostic(attempt, failure);
else if (restored) { attempt.gearAcknowledged = true; release(attempt); }
else if (complete) { attempt.gearAcknowledged = true; release(attempt); }
});
}
@@ -647,6 +701,7 @@ public final class EquipmentSessions implements AutoCloseable {
if (!attempt.player.isOnline()
|| attempt.previousGameMode != null && !attempt.modeStarted
&& !attempt.player.getGameMode().name().equals(attempt.previousGameMode)
|| attempt.ordinaryVitals != null && !attempt.vitalsStarted && !vitals.unchanged(attempt.player, attempt.ordinaryVitals)
|| attempt.markerValidated && (EquipmentSessionMarker.present(attempt.player) != (attempt.entryMarker != null)
|| !Objects.equals(attempt.entryMarker, EquipmentSessionMarker.read(attempt.player).orElse(null)))
|| !Arrays.equals(attempt.snapshot, snapshot(attempt.player))
@@ -667,6 +722,12 @@ public final class EquipmentSessions implements AutoCloseable {
pending.remove(attempt.owner, attempt);
active.remove(attempt.owner, attempt);
withdrawPresentation(attempt);
if (attempt.vitalsStarted) {
// Only native callbacks can reach this phase. Preserve observed character damage
// before independent vitals teardown; the uncertain equipment receipt stays owned.
saveRemainingHealth(attempt);
restoreVitals(attempt);
}
abandon(attempt);
diagnostic(attempt, failure);
}
@@ -723,6 +784,7 @@ public final class EquipmentSessions implements AutoCloseable {
}
cancelEntry(owner);
var attempt = new Attempt(player, owner, null);
attempt.recoveryOwner = previous;
// A recovered receipt has no process-local never-packed proof.
attempt.effectsStarted = true;
attempt.arrival = arrival;
@@ -735,6 +797,10 @@ public final class EquipmentSessions implements AutoCloseable {
if (!current(attempt)) return;
pending.remove(attempt.owner, attempt);
if (!attempt.player.isOnline()) return;
if (owned.get(attempt.owner) != attempt.recoveryOwner) {
notice(attempt.owner, "STALE_RECOVERY", "Session ownership changed after the recovery read; no old receipt was replayed.");
return;
}
if (failure != null) { diagnostic(attempt, failure); return; }
var receipt = state.map(EquipmentSessionStore.State::pending).orElse(null);
if (receipt == null) {
@@ -750,12 +816,15 @@ public final class EquipmentSessions implements AutoCloseable {
attempt.character = receipt.characterId();
attempt.session = receipt.sessionId();
attempt.previousGameMode = receipt.previousGameMode();
attempt.ordinaryVitals = receipt.ordinaryVitals();
attempt.vitalsStarted = attempt.ordinaryVitals != null;
attempt.healthAcknowledged = !attempt.vitalsStarted;
attempt.modeStarted = attempt.previousGameMode != null;
var previous = owned.get(attempt.owner);
if (previous != null && !attempt.session.equals(previous.session)) {
notice(attempt.owner, "SESSION_MISMATCH", "Saved and process-local sessions disagree; items were left unchanged."); return;
}
if (previous != null && previous.defeated) {
if (previous != null && (previous.defeated || previous.vitalsStarted)) {
// Explicit same-lifetime equipment recovery still uses matching receipt/PACKED
// proof. Keep the original death authority and its pending worker completion;
// do not replace it with a recovery Attempt that could release its claim early.
@@ -770,11 +839,13 @@ public final class EquipmentSessions implements AutoCloseable {
owned.put(attempt.owner, attempt);
transitioning.put(attempt.owner, attempt);
try {
saveRemainingHealth(attempt);
boolean modeRestored = restoreGameMode(attempt);
var result = restoreMarked(attempt);
boolean vitalsRestored = restoreVitals(attempt);
if (result == EquipmentStorage.RestoreResult.RESTORED) {
if (!modeRestored) attempt.gearRestoredForModeRecovery = true;
persistExit(attempt, modeRestored);
if (!modeRestored || !vitalsRestored || !attempt.healthAcknowledged) attempt.gearRestoredForModeRecovery = true;
persistExit(attempt, modeRestored && vitalsRestored);
sync(attempt);
} else notice(attempt.owner, result.name(), "Equipment recovery refused: " + result + "; receipt and items retained.");
} catch (RuntimeException error) {
@@ -0,0 +1,88 @@
package games.dmg.fantasystick.session;
import java.lang.reflect.Field;
import java.util.Objects;
import java.util.function.Consumer;
import net.minecraft.world.entity.ai.attributes.AttributeInstance;
/**
* Purpur 2618 instance-local effective-value lease. Base/modifiers remain in the native
* instance, so additions, removals, potion expiry and serialization keep their normal owners.
* The pinned dirty callback is synchronous: no polling interval can expose an external maximum.
* No global attribute/range is changed. Native compatibility tests must gate runtime upgrades.
*/
public final class NativeMaximumHealth implements AutoCloseable {
private static final Field DIRTY = field("dirty");
private static final Field VALUE = field("cachedValue");
private static final Field CALLBACK = field("onDirty");
private final AttributeInstance attribute;
private final Consumer<AttributeInstance> previous;
private final Consumer<AttributeInstance> callback = this::changed;
private final double maximum;
private boolean closed;
/** Exercise the pinned hook on a detached instance before exposing any player entry. */
public static void prepare() {
var probe = new AttributeInstance(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH, ignored -> {});
double bound = Math.min(10, NativeHealthLimit.read().doubleValue());
try (var lease = new NativeMaximumHealth(probe, bound)) {
Objects.requireNonNull(lease);
if (probe.getValue() != bound) throw new IllegalStateException("Native maximum hook did not apply");
probe.setBaseValue(probe.getBaseValue() + 1);
if (probe.getValue() != bound) throw new IllegalStateException("Native dirty hook did not retain the maximum");
}
if (probe.getValue() != probe.getAttribute().value().sanitizeValue(probe.getBaseValue())) {
throw new IllegalStateException("Native maximum hook did not restore ordinary calculation");
}
}
@SuppressWarnings("unchecked")
public NativeMaximumHealth(AttributeInstance attribute, double maximum) {
this.attribute = Objects.requireNonNull(attribute);
if (!Double.isFinite(maximum) || maximum <= 0) throw new IllegalArgumentException("Invalid fantasy maximum");
this.maximum = maximum;
try { previous = (Consumer<AttributeInstance>) CALLBACK.get(attribute); }
catch (ReflectiveOperationException failure) { throw incompatible(failure); }
try {
CALLBACK.set(attribute, callback);
changed(attribute);
} catch (ReflectiveOperationException | RuntimeException failure) {
try { close(); }
catch (RuntimeException cleanup) { failure.addSuppressed(cleanup); }
throw incompatible(failure);
}
}
private void changed(AttributeInstance changed) {
if (!closed) {
try {
VALUE.setDouble(attribute, maximum);
DIRTY.setBoolean(attribute, false);
} catch (ReflectiveOperationException failure) { throw incompatible(failure); }
}
previous.accept(changed);
}
@Override public void close() {
if (closed) return;
closed = true;
try {
// Never overwrite a later callback installed by someone else. If it wraps ours,
// our now-closed callback simply passes through and no longer overrides values.
if (CALLBACK.get(attribute) == callback) CALLBACK.set(attribute, previous);
DIRTY.setBoolean(attribute, true);
previous.accept(attribute);
} catch (ReflectiveOperationException failure) { throw incompatible(failure); }
}
private static Field field(String name) {
try {
var field = AttributeInstance.class.getDeclaredField(name);
field.setAccessible(true);
return field;
} catch (ReflectiveOperationException failure) { throw incompatible(failure); }
}
private static IllegalStateException incompatible(Exception failure) {
return new IllegalStateException("Unsupported native maximum-health internals; no fallback health policy", failure);
}
}
@@ -0,0 +1,41 @@
package games.dmg.fantasystick.session;
import java.util.Objects;
import java.util.function.Consumer;
import org.bukkit.Color;
import org.bukkit.Particle;
import org.bukkit.entity.Player;
import org.bukkit.plugin.Plugin;
import org.bukkit.scheduler.BukkitTask;
/** Public particles only; never grants gameplay state, protection, health or persistence authority. */
public final class SessionVisuals implements AutoCloseable {
private final EquipmentSessions sessions;
private final Consumer<Throwable> failures;
private BukkitTask task;
private boolean closed;
public SessionVisuals(EquipmentSessions sessions, Consumer<Throwable> failures) {
this.sessions = Objects.requireNonNull(sessions); this.failures = Objects.requireNonNull(failures);
}
public void install(Plugin plugin) {
if (closed || task != null) throw new IllegalStateException("Visual lifetime already used");
task = plugin.getServer().getScheduler().runTaskTimer(plugin, () -> {
if (closed) return;
for (var player : plugin.getServer().getOnlinePlayers()) {
try {
var active = sessions.active(player.getUniqueId());
if (active.isPresent() && sessions.current(active.orElseThrow())) burst(player, Color.BLUE, 3);
} catch (RuntimeException failure) { failures.accept(failure); }
}
}, 1L, 10L);
}
public static void burst(Player player, Color color, int count) {
player.getWorld().spawnParticle(Particle.DUST, player.getLocation().add(0, 1, 0), count,
0.3, 0.4, 0.3, 0, new Particle.DustOptions(color, 1));
}
@Override public void close() {
closed = true;
if (task != null) { task.cancel(); task = null; }
}
}
@@ -0,0 +1,154 @@
package games.dmg.fantasystick.session;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.OrdinaryVitals;
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import org.bukkit.NamespacedKey;
import org.bukkit.attribute.Attribute;
import org.bukkit.craftbukkit.entity.CraftPlayer;
import org.bukkit.entity.Player;
import org.bukkit.persistence.PersistentDataType;
/** Server-thread native overlay; native attributes themselves are never snapshotted/replayed. */
public final class SessionVitals implements AutoCloseable {
private static final NamespacedKey KEY = new NamespacedKey("fantasystick", "vitals_session");
private enum Phase { APPLYING, APPLIED, RESTORING, RELEASED }
private record Mark(UUID session, Phase phase, double maximum, OrdinaryVitals ordinary) { }
private record Lease(UUID session, NativeMaximumHealth maximum) { }
private final Map<UUID, Lease> leases = new HashMap<>();
private boolean closed;
public SessionVitals() { NativeMaximumHealth.prepare(); }
public OrdinaryVitals capture(Player player) {
return new OrdinaryVitals(player.getHealth(), player.getFoodLevel(), player.getSaturation(), player.getExhaustion(),
player.isHealthScaled(), player.getHealthScale());
}
public boolean available(Player player) {
var mark = read(player);
return !closed && (!player.getPersistentDataContainer().has(KEY) || mark != null && mark.phase() == Phase.RELEASED);
}
public boolean unchanged(Player player, OrdinaryVitals ordinary) {
return ordinary != null && ordinary.health() > 0 && ordinary.equals(capture(player));
}
/** Caller already acknowledged this ordinary snapshot in its player/session receipt. */
public void enter(Player player, UUID session, OrdinaryVitals ordinary, CharacterRecord record) {
if (!available(player) || !unchanged(player, ordinary) || leases.containsKey(player.getUniqueId())) {
throw new IllegalStateException("Stale native vitals entry");
}
double maximum = record.maximumHealth().min(NativeHealthLimit.read()).doubleValue();
write(player, new Mark(session, Phase.APPLYING, maximum, ordinary));
var nativeAttribute = Objects.requireNonNull(((CraftPlayer) player).getHandle()
.getAttribute(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH));
try {
var lease = new NativeMaximumHealth(nativeAttribute, maximum);
leases.put(player.getUniqueId(), new Lease(session, lease));
// Equal to the actual native maximum, not virtual health. Native attribute packets
// use CraftPlayer's scaled snapshot, without exposing ordinary modifier arithmetic.
// Initialize once BEFORE display delivery can reenter damage/healing handlers.
player.setHealth(Math.min(record.health().doubleValue(), maximum));
if (closed) throw new IllegalStateException("Vitals lifetime ended during entry");
player.setHealthScale(maximum);
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 { restoreScale(player, maximum, ordinary); }
catch (RuntimeException | LinkageError cleanup) { failure.addSuppressed(cleanup); }
// Leave APPLYING proof and the acknowledged receipt: an uncertain health setter
// is not permission to heal from a stale snapshot or replay the character save.
throw failure;
}
}
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) {
throw new IllegalStateException("No matching applied fantasy vitals proof");
}
return BigDecimal.valueOf(Math.max(0, Math.min(player.getHealth(), mark.maximum())));
}
public boolean restore(Player player, UUID session, OrdinaryVitals ordinary) {
if (ordinary == null) return true; // Legacy equipment-only receipt, not health restoration authority.
var mark = read(player);
if (mark == null || !mark.session().equals(session) || !mark.ordinary().equals(ordinary)
|| mark.phase() != Phase.APPLIED) {
// Process-local hook ownership is sufficient to retire our maximum override,
// never to replay ordinary health/nutrition when native setter proof is missing.
releaseMaximum(player, session);
return mark != null && mark.session().equals(session) && mark.ordinary().equals(ordinary) && mark.phase() == Phase.RELEASED;
}
write(player, new Mark(session, Phase.RESTORING, mark.maximum(), ordinary));
// Reconcile restored equipment's native modifiers before clamping ordinary health.
try { ((CraftPlayer) player).getHandle().detectEquipmentUpdates(); }
finally { releaseMaximum(player, session); }
// On restart there is no hook: native attribute serialization retained ordinary
// base/permanent modifiers, and the native effect lifecycle owns potion expiry/removal.
// CraftPlayer scale setters send health packets immediately (or queue one for damage
// 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());
restoreScale(player, mark.maximum(), ordinary);
write(player, new Mark(session, Phase.RELEASED, mark.maximum(), ordinary));
return true;
}
private void releaseMaximum(Player player, UUID session) {
var lease = leases.get(player.getUniqueId());
if (lease != null && lease.session().equals(session)) {
leases.remove(player.getUniqueId(), lease);
lease.maximum().close();
}
}
private static void restoreScale(Player player, double maximum, OrdinaryVitals ordinary) {
if (player.isHealthScaled() && player.getHealthScale() == maximum) {
player.setHealthScale(ordinary.scale());
player.setHealthScaled(ordinary.scaled());
}
}
/** Only removes this process's effective-value hooks; never replays unknown ordinary snapshots. */
@Override public void close() {
closed = true;
RuntimeException errors = null;
for (var lease : java.util.List.copyOf(leases.values())) {
try { lease.maximum().close(); }
catch (RuntimeException | LinkageError failure) {
if (errors == null) errors = new IllegalStateException("Native maximum cleanup failed");
errors.addSuppressed(failure);
}
}
leases.clear();
if (errors != null) throw errors;
}
private static Mark read(Player player) {
var data = player.getPersistentDataContainer();
if (!data.has(KEY, PersistentDataType.STRING)) return null;
try {
String encoded = data.get(KEY, PersistentDataType.STRING);
if (encoded.length() > 400) return null;
String[] fields = encoded.split("\\|", -1);
if (fields.length != 5 || !fields[0].equals("1")) return null;
UUID session = UUID.fromString(fields[1]);
double maximum = Double.parseDouble(fields[3]);
if (!session.toString().equals(fields[1]) || !Double.isFinite(maximum) || maximum <= 0) return null;
return new Mark(session, Phase.valueOf(fields[2]), maximum, OrdinaryVitals.decode(fields[4]));
} catch (IllegalArgumentException invalid) { return null; }
}
private static void write(Player player, Mark mark) {
player.getPersistentDataContainer().set(KEY, PersistentDataType.STRING,
"1|" + mark.session() + "|" + mark.phase() + "|" + mark.maximum() + "|" + mark.ordinary().encode());
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ version: ${version}
main: games.dmg.fantasystick.FantasyStickPlugin
api-version: "26.2"
depend: [ProtocolLib]
description: Transferable character sheets and recoverable equipment sessions; other RPG gameplay is not activated.
description: Transferable characters, native fantasy health, recoverable equipment, character books and parties.
author: dmg.games
commands:
fantasystick:
@@ -101,7 +101,8 @@ class CharacterAnvilNamesNativeTest {
assertEquals("Tail of Mira of Dawn — 雪", book.getTitle());
var text = net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText();
assertTrue(text.serialize(book.customName()).contains("Mira of Dawn — 雪"));
assertTrue(book.pages().stream().map(text::serialize).anyMatch(page -> page.contains("Mira of Dawn — 雪")));
assertTrue(book.pages().stream().map(text::serialize).collect(java.util.stream.Collectors.joining())
.replace("\n", "").contains("Mira of Dawn — 雪"), "Readable wrapping must retain the full acknowledged name");
assertTrue(problems.isEmpty());
// Actor rejects all XP/cursor/inventory mutation calls and all worker-side player access.
}
@@ -70,13 +70,127 @@ class CharacterBookNativeTest {
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(saved, old.revision());
click(f, a); drainReads(f); runQueued(f);
assertEquals(1, a.books.size());
String rendered = text(a.books.getFirst());
String rendered = text(a.books.getFirst()).replace("\n", "");
for (String expected : java.util.List.of("Level: 30", "XP: MAX", "Unspent points: 9", "STR: " + strength,
"revive", "unavailable_saved_id")) assertTrue(rendered.contains(expected), expected);
assertEquals(saved, characters(f).find(old.id()).get(5, TimeUnit.SECONDS).orElseThrow());
}
}
@Test void nativePagesKeepLargeNumbersUnicodeNamesAndUnknownAbilitiesReadableAndComplete() 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 old = a.core.record;
var stats = new java.util.EnumMap<games.dmg.fantasystick.progression.Stat, java.math.BigInteger>(games.dmg.fantasystick.progression.Stat.class);
stats.putAll(old.stats());
String digits = "1234567890".repeat(400);
stats.put(games.dmg.fantasystick.progression.Stat.STR, new java.math.BigInteger(digits));
String name = "雪🐈".repeat(80);
var learned = java.util.stream.IntStream.range(0, 50).mapToObj(i -> "unknown_ability_" + i).collect(java.util.stream.Collectors.toSet());
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), name, old.revision().add(java.math.BigInteger.ONE),
stats, old.level(), old.experience(), old.unspentPoints(), old.health(), learned, old.loadout(), old.cooldowns(), false, null, null);
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(saved, old.revision());
var before = a.core.snapshot();
click(f, a); drainReads(f); runQueued(f);
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(a.books.getLast())
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
assertNotNull(content);
for (var page : content.pages()) {
String text = page.raw().getString();
assertTrue(text.split("\\n", -1).length <= 13, "Native page exceeds visible line count");
for (String line : text.split("\\n", -1)) assertTrue(line.codePoints().map(cp -> cp < 128 ? 6 : 9).sum() <= 108,
"Native line exceeds conservative visible width: " + line.length());
}
String complete = text(a.books.getLast()).replace("\n", "");
assertTrue(complete.contains(name)); assertTrue(complete.contains(digits));
for (String ability : learned) assertTrue(complete.contains(ability), ability);
assertArrayEquals(before, a.core.snapshot());
assertEquals(saved, characters(f).find(saved.id()).get(5, TimeUnit.SECONDS).orElseThrow());
}
}
@Test void largeRecordsRemainCompleteThroughBoundedSingleUseNavigation() 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);
String digits = "1234567890".repeat(4000);
var old = a.core.record;
var stats = new java.util.EnumMap<games.dmg.fantasystick.progression.Stat, java.math.BigInteger>(games.dmg.fantasystick.progression.Stat.class);
stats.putAll(old.stats()); stats.put(games.dmg.fantasystick.progression.Stat.STR, new java.math.BigInteger(digits));
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE),
stats, old.level(), old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.cooldowns(), false, null, null);
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(saved, old.revision());
byte[] bytes = Files.readAllBytes(data.resolve("characters").resolve(saved.id() + ".yml"));
click(f, a); drainReads(f); runQueued(f);
assertEquals(1, a.books.size(), "Large but supported records still open a native book");
StringBuilder all = new StringBuilder();
int volumes = 0;
while (true) {
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(a.books.getLast())
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
assertNotNull(content); assertTrue(content.pages().size() <= 100, "Keep the chosen per-view payload bound (not a signed-book codec page limit)");
for (var page : content.pages()) if (!hasClick(page.raw())) all.append(page.raw().getString().replace("\n", ""));
if (!text(a.books.getLast()).contains("[Next pages]")) break;
assertTrue(++volumes < 10, "Navigation must terminate, not repeat a truncated volume");
var next = PartyBookNativeTest.button(a, "[Next pages]");
if (volumes == 1) {
var stranger = new FantasyStickLifecycleNativeTest.Actor(data);
PartyBookNativeTest.click(f, stranger, next);
for (var payload : java.util.List.of(net.minecraft.nbt.StringTag.valueOf("x".repeat(1000)),
net.minecraft.nbt.StringTag.valueOf("not-a-token"), new net.minecraft.nbt.CompoundTag())) {
PartyBookNativeTest.click(f, a, new net.minecraft.network.chat.ClickEvent.Custom(next.id(), java.util.Optional.of(payload)));
}
assertTrue(stranger.books.isEmpty());
}
PartyBookNativeTest.click(f, a, next); PartyBookNativeTest.click(f, a, next);
drainReads(f); runQueued(f);
assertEquals(volumes + 1, a.books.size(), "One native action cannot open two volumes");
}
assertTrue(volumes > 0); assertTrue(all.toString().contains(digits), "Every digit remains reachable in order");
var previous = PartyBookNativeTest.button(a, "[Previous pages]");
a.core.inventory.getInventory().setSelectedSlot(1);
PartyBookNativeTest.click(f, a, previous); drainReads(f); runQueued(f);
assertEquals(volumes + 1, a.books.size(), "Stale held-slot navigation has no authority");
assertArrayEquals(bytes, Files.readAllBytes(data.resolve("characters").resolve(saved.id() + ".yml")));
}
}
@Test void acknowledgedRenameTransferAndReloadInspectTheCurrentRecordWithoutCarryingTheFormerHoldersView() 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);
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);
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));
f.activate(b); click(f, b); drainReads(f); runQueued(f);
assertTrue(text(b.books.getLast()).replace("\n", "").contains(renamed.name()));
assertTrue(a.books.isEmpty());
f.plugin.setEnabled(false); f.awaitWorkers();
f.players.clear(); f.plugin.setEnabled(true);
f.players.put(b.core.id, b.player); f.reenter(b);
var before = b.core.snapshot();
var path = data.resolve("characters").resolve(renamed.id() + ".yml");
byte[] saved = Files.readAllBytes(path);
click(f, b); drainReads(f); runQueued(f);
assertTrue(text(b.books.getLast()).replace("\n", "").contains(renamed.name()));
var meta = (org.bukkit.inventory.meta.BookMeta) b.books.getLast().getItemMeta();
assertEquals("Character", meta.getTitle()); assertEquals("Fantasy Stick", meta.getAuthor());
assertArrayEquals(saved, Files.readAllBytes(path)); assertArrayEquals(before, b.core.snapshot());
}
}
private static boolean hasClick(net.minecraft.network.chat.Component component) {
return component.getStyle().getClickEvent() != null || component.getSiblings().stream().anyMatch(CharacterBookNativeTest::hasClick);
}
@Test void malformedRecordReadLeavesDataAndInventoryUntouchedAndCanBeRetriedAfterRepair() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
@@ -84,6 +84,8 @@ class CharacterSheetsNativeTest {
assertEquals(1, preview.getAmount());
assertTrue(sheets.identify(preview).isEmpty());
assertTrue(preview.getItemMeta().getPersistentDataContainer().isEmpty());
assertEquals(List.of(Component.text("Character sheet:"), Component.text("Place in off hand"),
Component.text("to enter fantasy world.")), preview.getItemMeta().lore());
}
}
@@ -104,7 +106,8 @@ class CharacterSheetsNativeTest {
assertEquals(Material.PAPER, sheet.getType());
assertEquals(1, sheet.getAmount());
assertEquals(Component.text(record.name()), sheet.getItemMeta().displayName());
assertEquals(List.of(Component.text("Character sheet: place in off hand to enter fantasy world.")), sheet.getItemMeta().lore());
assertEquals(List.of(Component.text("Character sheet:"), Component.text("Place in off hand"),
Component.text("to enter fantasy world.")), sheet.getItemMeta().lore());
assertEquals(record.id(), sheets.identify(sheet).orElseThrow());
assertEquals(Set.of(new NamespacedKey("fantasystick", "sheet_version"), new NamespacedKey("fantasystick", "character_id")),
sheet.getItemMeta().getPersistentDataContainer().getKeys(), "No player owner, equipment or progress snapshot belongs on the sheet");
@@ -0,0 +1,86 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.ClientboundSetHealthPacket;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class DefeatHealthDeliveryNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void zeroHealthDefeatNeverDeliversADeathScreenHealthUpdateAndTailArrivesWithoutReconnect() throws Exception {
for (boolean queued : List.of(false, true)) try (var f = new FantasyStickLifecycleNativeTest.Fixture(data.resolve("queued-" + queued))) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data.resolve("queued-" + queued));
f.players.put(a.core.id, a.player);
var sent = nativeHealthDelivery(a);
var entry = a.player.getLocation();
f.activate(a);
a.core.location.add(20, 0, 20);
a.core.health = 0; // The real death callback begins after native health reaches zero.
sent.clear();
a.nativePlayer.queueHealthUpdatePacket = queued;
var death = death(a);
f.dispatcher.accept(death);
if (a.nativePlayer.queuedHealthUpdatePacket != null) sent.add(a.nativePlayer.queuedHealthUpdatePacket);
a.nativePlayer.queueHealthUpdatePacket = false;
PartyBookNativeTest.drain(f);
assertTrue(death.isCancelled());
assertEquals(a.maximumHealth.getValue(), a.player.getHealth());
assertEquals(entry, a.player.getLocation());
assertFalse(a.player.isHealthScaled());
var updates = sent.stream().filter(ClientboundSetHealthPacket.class::isInstance)
.map(ClientboundSetHealthPacket.class::cast).toList();
assertFalse(updates.isEmpty(), "Exercise actual CraftPlayer scale/health delivery, not a mocked setter");
assertTrue(updates.stream().allMatch(p -> p.getHealth() > 0),
() -> "Cancelled fantasy defeat must never deliver zero health: " + updates.stream().map(ClientboundSetHealthPacket::getHealth).toList());
var tail = a.core.inventory.getItemInOffHand().clone();
assertEquals(org.bukkit.Material.WRITTEN_BOOK, tail.getType()); assertEquals(1, tail.getAmount());
f.dispatcher.accept(death); PartyBookNativeTest.drain(f);
assertEquals(tail, a.core.inventory.getItemInOffHand());
// A subsequent ordinary death is not protected by a blanket zero-health packet filter.
a.core.health = 0;
var ordinaryDeath = death(a); f.dispatcher.accept(ordinaryDeath);
assertFalse(ordinaryDeath.isCancelled());
a.player.sendHealthUpdate();
assertEquals(0, ((ClientboundSetHealthPacket) sent.getLast()).getHealth());
}
}
private static org.bukkit.event.entity.PlayerDeathEvent death(FantasyStickLifecycleNativeTest.Actor a) {
var source = new org.bukkit.craftbukkit.damage.CraftDamageSource(new net.minecraft.world.damagesource.DamageSource(
org.bukkit.craftbukkit.damage.CraftDamageType.bukkitToMinecraftHolder(org.bukkit.damage.DamageType.GENERIC)));
return new org.bukkit.event.entity.PlayerDeathEvent(a.player, source, new ArrayList<>(), 0,
net.kyori.adventure.text.Component.empty(), true);
}
private static List<Packet<?>> nativeHealthDelivery(FantasyStickLifecycleNativeTest.Actor a) {
var sent = new ArrayList<Packet<?>>();
var connection = mock(net.minecraft.server.network.ServerGamePacketListenerImpl.class);
doAnswer(call -> { sent.add(call.getArgument(0)); return null; }).when(connection).send(any(Packet.class));
a.nativePlayer.connection = connection;
when(a.nativePlayer.getFoodData()).thenReturn(a.food);
// Transport and tracked entity storage are explicit ports; scaling and packet creation run natively.
when(a.nativePlayer.getEntityData()).thenReturn(mock(net.minecraft.network.syncher.SynchedEntityData.class));
doCallRealMethod().when(a.player).setHealthScale(anyDouble());
doCallRealMethod().when(a.player).setHealthScaled(anyBoolean());
doCallRealMethod().when(a.player).getHealthScale();
doCallRealMethod().when(a.player).isHealthScaled();
doCallRealMethod().when(a.player).getScaledHealth();
doCallRealMethod().when(a.player).getScaledMaxHealth();
doCallRealMethod().when(a.player).updateScaledHealth();
doCallRealMethod().when(a.player).updateScaledHealth(anyBoolean());
doCallRealMethod().when(a.player).sendHealthUpdate();
a.player.setHealthScale(20); a.player.setHealthScaled(false);
sent.clear(); return sent;
}
}
@@ -1685,6 +1685,8 @@ class EquipmentSessionsNativeTest {
org.bukkit.GameMode gameMode = org.bukkit.GameMode.SURVIVAL;
Runnable beforeMode = () -> {};
double health = 20;
Runnable beforeHealth = () -> {};
Runnable healthMutation = () -> {};
float attackStrength = 1;
ItemStack activeItem = new ItemStack(Material.AIR);
int useStarts;
@@ -1729,7 +1731,7 @@ class EquipmentSessionsNativeTest {
useStarts++; activeItem = inventory.getItemInMainHand().clone(); startUse.run(); yield null;
}
case "clearActiveItem" -> { useClears++; activeItem = new ItemStack(Material.AIR); clearUse.run(); yield null; }
case "setHealth" -> { health = (Double) args[0]; yield null; }
case "setHealth" -> { beforeHealth.run(); health = (Double) args[0]; healthMutation.run(); yield null; }
case "getAttribute" -> { assertEquals(org.bukkit.attribute.Attribute.MAX_HEALTH, args[0]); yield maximum; }
case "teleport" -> {
teleports++; teleported = ((org.bukkit.Location) args[0]).clone(); teleport.run();
@@ -0,0 +1,384 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.persistence.CharacterStorage;
import java.nio.file.Path;
import java.util.concurrent.TimeUnit;
import org.bukkit.attribute.Attribute;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class FantasyHealthNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void nativeMaximumIgnoresBonusesPenaltiesAndHealthBoostWhileTheirOrdinaryOwnershipAndExpiryRemainIntact() 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 bonus = modifier("bonus", 5, net.minecraft.world.entity.ai.attributes.AttributeModifier.Operation.ADD_VALUE);
var penalty = modifier("penalty", -0.5, net.minecraft.world.entity.ai.attributes.AttributeModifier.Operation.ADD_MULTIPLIED_TOTAL);
a.maximumHealth.addTransientModifier(bonus); a.maximumHealth.addTransientModifier(penalty);
var boost = net.minecraft.world.effect.MobEffects.HEALTH_BOOST.value();
boost.addAttributeModifiers(a.healthAttributes, 1);
a.player.setHealth(12); a.player.setFoodLevel(7); a.player.setSaturation(2); a.player.setExhaustion(3);
f.activate(a);
assertEquals(10, a.maximumHealth.getValue());
a.player.setHealth(6);
a.maximumHealth.setBaseValue(30); // A later legitimate ordinary base change must survive exit.
var later = modifier("later", 4, net.minecraft.world.entity.ai.attributes.AttributeModifier.Operation.ADD_VALUE);
a.maximumHealth.addPermanentModifier(later);
boost.addAttributeModifiers(a.healthAttributes, 2);
assertEquals(10, a.maximumHealth.getValue(), "New native potion modifiers cannot change fantasy maximum");
boost.removeAttributeModifiers(a.healthAttributes);
a.maximumHealth.removeModifier(bonus); a.maximumHealth.removeModifier(penalty);
var zero = modifier("zero", -1, net.minecraft.world.entity.ai.attributes.AttributeModifier.Operation.ADD_MULTIPLIED_TOTAL);
a.maximumHealth.addTransientModifier(zero);
assertEquals(10, a.maximumHealth.getValue(), "Even a zero multiplier cannot bypass the native effective-value lease");
a.maximumHealth.removeModifier(zero);
assertEquals(6, a.player.getHealth());
var serialized = a.maximumHealth.pack();
assertEquals(30, serialized.baseValue()); assertEquals(java.util.List.of(later), serialized.modifiers());
var loaded = new net.minecraft.world.entity.ai.attributes.AttributeInstance(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH, ignored -> {});
loaded.apply(serialized); assertEquals(34, loaded.getValue(), "Player saves retain ordinary attribute ownership, not the fantasy maximum");
a.player.setFoodLevel(2); a.player.setSaturation(0); a.player.setExhaustion(1);
a.core.inventory.setItemInOffHand(null); f.changed(a);
assertEquals(34, a.maximumHealth.getValue()); assertEquals(12, a.player.getHealth());
assertEquals(java.util.Set.of(later), a.maximumHealth.getModifiers(), "Expired/removed modifiers are not replayed from a snapshot");
assertEquals(7, a.player.getFoodLevel()); assertEquals(2, a.player.getSaturation()); assertEquals(3, a.player.getExhaustion());
assertFalse(a.player.isHealthScaled()); assertEquals(20, a.player.getHealthScale());
}
}
@Test void activeCharacterBookShowsCurrentNativeDamageRatherThanTheLastSavedHealth() 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(4);
PartyBookNativeTest.open(f, a);
assertTrue(PartyBookNativeTest.text(a).contains("Health: 4/10"));
}
}
@Test void activeBlueMistAndOneRedDefeatBurstUseWorldLocationsAndStopWithTheSession() 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);
var entry = a.player.getLocation(); f.activate(a);
f.pulses.forEach(Runnable::run);
assertTrue(dust(a, org.bukkit.Color.BLUE) > 0, "Active fantasy mode must have visible public blue particles");
assertEquals(0, dust(ordinary, org.bukkit.Color.BLUE));
a.core.location.add(20, 0, 20);
var defeatLocation = a.player.getLocation().add(0, 1, 0);
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); assertTrue(death.isCancelled());
PartyBookNativeTest.drain(f);
assertEquals(1, dust(a, org.bukkit.Color.RED));
assertTrue(org.mockito.Mockito.mockingDetails(a.world).getInvocations().stream().anyMatch(call ->
call.getMethod().getName().equals("spawnParticle") && java.util.Arrays.asList(call.getArguments()).contains(defeatLocation)));
assertEquals(entry, a.player.getLocation()); assertEquals(28, a.player.getHealth());
var tail = a.core.inventory.getItemInOffHand().clone();
assertEquals(org.bukkit.Material.WRITTEN_BOOK, tail.getType()); assertEquals(1, tail.getAmount());
long blue = dust(a, org.bukkit.Color.BLUE);
f.pulses.forEach(Runnable::run); f.dispatcher.accept(death);
assertEquals(blue, dust(a, org.bukkit.Color.BLUE)); assertEquals(1, dust(a, org.bukkit.Color.RED));
assertEquals(tail, a.core.inventory.getItemInOffHand());
f.plugin.setEnabled(false); f.awaitWorkers();
f.pulses.forEach(Runnable::run);
assertEquals(blue, dust(a, org.bukkit.Color.BLUE), "Retired timers cannot emit fantasy effects");
}
}
@Test void uncappedConstitutionEntersAtTheNativeCeilingAndPersistsOnlyBoundedRemainingHealth() 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 old = a.core.record;
var con = java.math.BigInteger.TEN.pow(1000);
var stats = new java.util.EnumMap<games.dmg.fantasystick.progression.Stat, java.math.BigInteger>(games.dmg.fantasystick.progression.Stat.class);
stats.putAll(old.stats()); stats.put(games.dmg.fantasystick.progression.Stat.CON, con);
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE),
stats, old.level(), old.experience(), old.unspentPoints(), new java.math.BigDecimal(con.add(java.math.BigInteger.TEN)),
old.learned(), old.loadout(), old.cooldowns(), false, null, null);
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(saved, old.revision());
var ceiling = games.dmg.fantasystick.session.NativeHealthLimit.read();
f.activate(a);
assertEquals(ceiling.doubleValue(), a.maximumHealth.getValue()); assertEquals(ceiling.doubleValue(), a.player.getHealth());
a.player.setHealth(3.25);
a.core.inventory.setItemInOffHand(null); f.changed(a);
var characters = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var suspended = characters.find(old.id()).get(5, TimeUnit.SECONDS).orElseThrow();
assertEquals(con, suspended.stats().get(games.dmg.fantasystick.progression.Stat.CON));
assertEquals(0, suspended.health().compareTo(new java.math.BigDecimal("3.25")));
assertEquals(ceiling, games.dmg.fantasystick.session.NativeHealthLimit.read(), "Never mutate global attribute limits");
}
}
@Test void serializedInterruptedSessionRecoversOrdinaryVitalsAndRemainingHealthWithoutReplayingExpiredModifiers() throws Exception {
FantasyStickLifecycleNativeTest.Actor a;
byte[] nativeProof, characterFile, receiptFile;
byte[][] items;
net.minecraft.world.entity.ai.attributes.AttributeInstance.Packed ordinaryAttributes;
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
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);
net.minecraft.world.effect.MobEffects.HEALTH_BOOST.value().addAttributeModifiers(a.healthAttributes, 1);
f.activate(a); a.player.setHealth(4); a.player.setFoodLevel(2);
nativeProof = a.core.pdc.serializeToBytes();
items = java.util.Arrays.stream(a.core.snapshot()).map(item -> item == null ? null : item.serializeAsBytes()).toArray(byte[][]::new);
ordinaryAttributes = a.maximumHealth.pack();
characterFile = java.nio.file.Files.readAllBytes(data.resolve("characters").resolve(a.core.record.id() + ".yml"));
receiptFile = java.nio.file.Files.readAllBytes(data.resolve("sessions").resolve(a.core.id + ".yml"));
}
// Recreate one concrete crash window from native item/PDC/attribute and YAML saves.
// Orderly fixture disposal above is not mistaken for an actual server crash.
java.nio.file.Files.write(data.resolve("characters").resolve(a.core.record.id() + ".yml"), characterFile);
java.nio.file.Files.write(data.resolve("sessions").resolve(a.core.id + ".yml"), receiptFile);
a.core.inventory.setContents(java.util.Arrays.stream(items).map(bytes -> bytes == null ? null : org.bukkit.inventory.ItemStack.deserializeBytes(bytes))
.toArray(org.bukkit.inventory.ItemStack[]::new));
a.core.pdc.readFromBytes(nativeProof, true);
a.maximumHealth.removeModifiers(); a.maximumHealth.apply(ordinaryAttributes);
// Native potion attributes use permanent storage. Exercise the real expiry/removal
// hook after load; plugin recovery must not reintroduce that removed modifier.
net.minecraft.world.effect.MobEffects.HEALTH_BOOST.value().removeAttributeModifiers(a.healthAttributes);
a.core.health = 4; a.food.setFoodLevel(2); a.healthScaled = true; a.healthScale = 10;
a.core.gameMode = org.bukkit.GameMode.ADVENTURE;
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.players.put(a.core.id, a.player); f.plugin.setEnabled(true); f.tick();
PartyBookNativeTest.drain(f);
var receipt = f.receipts().find(a.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS).orElseThrow();
assertNull(receipt.pending());
assertEquals(org.bukkit.Material.DIAMOND_SWORD, a.core.inventory.getItem(0).getType());
assertEquals(28, a.maximumHealth.getValue()); assertTrue(a.maximumHealth.getModifiers().isEmpty());
assertEquals(12, a.player.getHealth()); assertEquals(7, a.player.getFoodLevel());
assertEquals(2, a.player.getSaturation()); assertEquals(3, a.player.getExhaustion());
assertFalse(a.player.isHealthScaled()); assertEquals(0, a.core.teleports);
var characters = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertEquals(0, characters.find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow().health().compareTo(java.math.BigDecimal.valueOf(4)));
// An unrelated later ordinary change cannot be overwritten by a repeated recovery.
a.player.setHealth(5); a.player.setFoodLevel(9);
((games.dmg.fantasystick.session.EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions")).recover(a.player);
f.tick(); assertEquals(5, a.player.getHealth()); assertEquals(9, a.player.getFoodLevel());
}
}
@Test void failedEntrySettersAndMissingExitProofNeverLeaveTheNativeMaximumOverridden() throws Exception {
for (int route = 0; route < 6; route++) try (var f = new FantasyStickLifecycleNativeTest.Fixture(data.resolve("failure-" + route))) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data.resolve("failure-" + route));
f.players.put(a.core.id, a.player);
Runnable fail = () -> { a.core.beforeHealth = () -> {}; a.core.healthMutation = () -> {}; throw new IllegalStateException("Native health setter failed"); };
if (route == 0) a.core.beforeHealth = fail;
if (route == 1) a.core.healthMutation = fail;
if (route == 5) a.core.sync = () -> {
a.core.sync = () -> {}; a.player.setHealth(3);
throw new IllegalStateException("Activation client synchronization failed after native damage");
};
f.activate(a);
if (route >= 2 && route < 5) {
a.player.setHealth(4); a.player.setFoodLevel(2);
if (route == 2) a.core.pdc.remove(new org.bukkit.NamespacedKey("fantasystick", "vitals_session"));
else if (route == 3) org.mockito.Mockito.doThrow(new IllegalStateException("Native equipment update failed")).when(a.nativePlayer).detectEquipmentUpdates();
else a.core.beforeMutation = () -> { throw new IllegalStateException("Equipment restoration write failed"); };
a.core.inventory.setItemInOffHand(null); f.changed(a);
if (route < 4) {
assertEquals(4, a.player.getHealth(), "Missing/uncertain proof cannot authorize a stale ordinary-health replay");
assertEquals(2, a.player.getFoodLevel());
}
}
var sessions = (games.dmg.fantasystick.session.EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
assertTrue(sessions.active(a.core.id).isEmpty());
assertEquals(28, a.maximumHealth.getValue(), "Only the owned maximum hook is safe to remove despite an uncertain setter/proof");
assertNotNull(f.receipts().find(a.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS).orElseThrow().pending());
if (route < 2) {
assertEquals(route == 0 ? 20 : 10, a.player.getHealth(), "Do not blindly heal an uncertain native write");
assertFalse(a.player.isHealthScaled()); assertEquals(20, a.player.getHealthScale());
}
if (route >= 4) {
assertEquals(20, a.player.getHealth(), "Proven vitals cleanup is independent of uncertain equipment/client state");
var characters = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertEquals(0, characters.find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow().health()
.compareTo(java.math.BigDecimal.valueOf(route == 4 ? 4 : 3)));
}
}
}
@Test void failedHealthWriteRestoresOrdinaryGearButRetainsTheReceiptAndCannotResumeStaleHealth() 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 sheet = a.core.inventory.getItemInOffHand().clone(); a.player.setHealth(4);
var path = data.resolve("characters").resolve(a.core.record.id() + ".yml");
byte[] acknowledged = java.nio.file.Files.readAllBytes(path);
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());
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f);
assertNotNull(f.receipts().find(a.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS).orElseThrow().pending());
assertArrayEquals(acknowledged, java.nio.file.Files.readAllBytes(path));
}
assertEquals(org.bukkit.Material.DIAMOND_SWORD, a.core.inventory.getItem(0).getType());
assertEquals(28, a.maximumHealth.getValue()); assertEquals(20, a.player.getHealth());
a.core.inventory.setItemInOffHand(sheet); f.changed(a); f.tick();
var sessions = (games.dmg.fantasystick.session.EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
assertTrue(sessions.active(a.core.id).isEmpty(), "An unacknowledged health save cannot become a free heal on reentry");
assertArrayEquals(acknowledged, java.nio.file.Files.readAllBytes(path));
assertTrue(a.messages.stream().anyMatch(message -> message.contains("persistence or recovery is pending")));
}
}
@Test void delayedRecoveryReadCannotRecreateOwnershipAfterTheHealthSaveAndExitAlreadyCompleted() 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 sheet = a.core.inventory.getItemInOffHand().clone(); a.player.setHealth(4);
var characters = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var worker = (java.util.concurrent.ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(characters, "worker");
var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1);
var gate = worker.submit(() -> {
entered.countDown();
try { assertTrue(release.await(5, TimeUnit.SECONDS)); }
catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new AssertionError(interrupted); }
});
try {
assertTrue(entered.await(5, TimeUnit.SECONDS));
a.core.inventory.setItemInOffHand(null); f.changed(a);
assertNotNull(f.receipts().find(a.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS).orElseThrow().pending());
var sessions = (games.dmg.fantasystick.session.EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
sessions.recover(a.player);
var stale = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(stale);
release.countDown(); gate.get(5, TimeUnit.SECONDS);
characters.find(a.core.record.id()).get(5, TimeUnit.SECONDS);
assertNull(f.receipts().find(a.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS).orElseThrow().pending());
stale.run();
a.core.inventory.setItemInOffHand(sheet); f.changed(a); f.tick();
assertFalse(a.messages.stream().anyMatch(message -> message.contains("persistence or recovery is pending")),
"A delayed old receipt must not manufacture a new ownership blocker after acknowledged exit");
f.tick(); f.tick(); assertTrue(sessions.active(a.core.id).isPresent()); assertEquals(4, a.player.getHealth());
} finally { release.countDown(); }
}
}
@Test void nativeHealthChangesDuringClientScaleDeliveryAreNotOverwrittenByInitialActivation() 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.scaleMutation = () -> {
a.scaleMutation = () -> {};
a.player.setHealth(Math.min(a.player.getHealth(), a.maximumHealth.getValue()) - 1);
};
f.activate(a);
assertEquals(9, a.player.getHealth(), "Native damage during a reentrant client update must not be replaced with another full-health initialization");
}
}
@Test void quitAndDisablePersistDamageAndRestoreOnlyOwnedOrdinaryVitalsAndDisplay() throws Exception {
for (boolean quit : new boolean[]{true, false}) try (var f = new FantasyStickLifecycleNativeTest.Fixture(data.resolve("lifecycle-" + quit))) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data.resolve("lifecycle-" + quit)); 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); a.player.setFoodLevel(1);
a.maximumHealth.setBaseValue(8); // A legitimate later ordinary maximum, below the pre-entry health.
a.player.setHealthScale(44); // A later display owner's value is not ours to undo.
if (quit) {
a.core.online = false;
f.dispatcher.accept(new org.bukkit.event.player.PlayerQuitEvent(a.player, net.kyori.adventure.text.Component.empty()));
PartyBookNativeTest.drain(f);
f.receipts().find(a.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS);
} else { f.plugin.setEnabled(false); f.awaitWorkers(); }
assertEquals(8, a.maximumHealth.getValue()); assertEquals(8, a.player.getHealth());
assertEquals(7, a.player.getFoodLevel()); assertEquals(2, a.player.getSaturation()); assertEquals(3, a.player.getExhaustion());
assertEquals(44, a.player.getHealthScale()); assertTrue(a.player.isHealthScaled());
assertEquals(org.bukkit.Material.DIAMOND_SWORD, a.core.inventory.getItem(0).getType());
var saved = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data.resolve("lifecycle-" + quit)).load(a.core.record.id()).orElseThrow();
assertFalse(saved.dead()); assertEquals(0, saved.health().compareTo(java.math.BigDecimal.valueOf(4))); assertEquals(0, a.core.teleports);
}
}
@Test void aDamagedTransferredCharacterNeverTransfersItsFormerHoldersOrdinaryVitals() 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);
a.player.setHealth(12); a.player.setFoodLevel(7);
b.player.setHealth(19); b.player.setFoodLevel(3);
var sheet = a.core.inventory.getItemInOffHand().clone(); f.activate(a); a.player.setHealth(3);
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f);
f.receipts().find(a.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS);
b.core.inventory.setItemInOffHand(sheet); f.activate(b);
assertEquals(3, b.player.getHealth()); assertEquals(12, a.player.getHealth()); assertEquals(7, a.player.getFoodLevel());
b.player.setHealth(2); b.core.inventory.setItemInOffHand(null); f.changed(b); PartyBookNativeTest.drain(f);
assertEquals(19, b.player.getHealth()); assertEquals(3, b.player.getFoodLevel());
var characters = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertEquals(0, characters.find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow().health().compareTo(java.math.BigDecimal.valueOf(2)));
assertEquals(b.core.record, characters.find(b.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow());
}
}
@Test void shuttingDownMaximumHooksContinuesAfterAnOriginalNativeObserverThrows() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var throwNow = new java.util.concurrent.atomic.AtomicBoolean();
var attributes = new java.util.ArrayList<net.minecraft.world.entity.ai.attributes.AttributeInstance>();
for (int i = 0; i < 2; i++) {
var a = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(a.core.id, a.player);
var attribute = new net.minecraft.world.entity.ai.attributes.AttributeInstance(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH,
ignored -> { if (throwNow.get()) throw new IllegalStateException("Native dirty observer failed during cleanup"); });
attributes.add(attribute);
org.mockito.Mockito.when(a.nativePlayer.getAttribute(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH)).thenReturn(attribute);
f.activate(a); assertEquals(10, attribute.getValue());
}
throwNow.set(true);
var vitals = (games.dmg.fantasystick.session.SessionVitals) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "vitals");
var failure = assertThrows(IllegalStateException.class, vitals::close);
assertEquals(2, failure.getSuppressed().length);
for (var attribute : attributes) assertEquals(20, attribute.getValue(), "Every player's hook must be retired despite another cleanup failure");
}
}
private static long dust(FantasyStickLifecycleNativeTest.Actor a, org.bukkit.Color color) {
return org.mockito.Mockito.mockingDetails(a.world).getInvocations().stream().filter(call ->
call.getMethod().getName().equals("spawnParticle") && java.util.Arrays.stream(call.getArguments())
.anyMatch(argument -> argument instanceof org.bukkit.Particle.DustOptions dust && dust.getColor().equals(color))).count();
}
private static net.minecraft.world.entity.ai.attributes.AttributeModifier modifier(String name, double amount,
net.minecraft.world.entity.ai.attributes.AttributeModifier.Operation operation) {
return new net.minecraft.world.entity.ai.attributes.AttributeModifier(net.minecraft.resources.Identifier.fromNamespaceAndPath("test", name), amount, operation);
}
@Test void actualOffhandEntryAppliesFantasyHealthAndExitRestoresOrdinaryHealthWithoutHealingTheCharacter() 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);
double ordinaryHealth = a.player.getHealth();
double ordinaryMaximum = a.player.getAttribute(Attribute.MAX_HEALTH).getValue();
var sheet = a.core.inventory.getItemInOffHand().clone();
f.activate(a);
assertEquals(10, a.player.getAttribute(Attribute.MAX_HEALTH).getValue());
assertEquals(10, a.player.getHealth(), "A fresh character starts full, independently of ordinary health");
a.player.setHealth(4);
a.core.inventory.setItemInOffHand(null); f.changed(a);
assertEquals(ordinaryMaximum, a.player.getAttribute(Attribute.MAX_HEALTH).getValue());
assertEquals(ordinaryHealth, a.player.getHealth());
var characters = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var saved = characters.find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
assertEquals(0, saved.health().compareTo(java.math.BigDecimal.valueOf(4)));
f.receipts().find(a.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS);
a.core.inventory.setItemInOffHand(sheet); f.activate(a);
assertEquals(4, a.player.getHealth(), "Resuming does not replay a first-activation heal");
}
}
}
@@ -60,7 +60,7 @@ class FantasyStickLifecycleNativeTest {
assertTrue(fixture.order.indexOf("CharacterChatNames") >= 0);
assertTrue(fixture.order.indexOf("CharacterChatNames") < recipe);
assertNotNull(fixture.packetListener);
assertEquals(2, fixture.timers.size(), "Presentation and bounded combat-provenance cleanup");
assertEquals(3, fixture.timers.size(), "Presentation, combat-provenance cleanup and session particles");
assertTrue(fixture.order.indexOf("PartyCombat") >= 0);
assertTrue(fixture.order.indexOf("PartyCombat") < recipe);
assertNotNull(fixture.command);
@@ -178,6 +178,9 @@ class FantasyStickLifecycleNativeTest {
assertTrue(actor.fixture.view.getCursor().isEmpty());
var complete = fixture.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(complete);
complete.run();
assertEquals(List.of(net.kyori.adventure.text.Component.text("Character sheet:"),
net.kyori.adventure.text.Component.text("Place in off hand"),
net.kyori.adventure.text.Component.text("to enter fantasy world.")), actor.fixture.view.getCursor().getItemMeta().lore());
var id = new CharacterSheets().identify(actor.fixture.view.getCursor()).orElseThrow();
var saved = new games.dmg.fantasystick.persistence.YamlCharacterRepository(directory).load(id).orElseThrow();
assertEquals(new java.math.BigInteger(points), saved.unspentPoints());
@@ -433,38 +436,61 @@ class FantasyStickLifecycleNativeTest {
assertNativeName(f, a, viewer, a.core.record.name()); assertChat(f, a, a.core.record.name());
var receipts = f.receipts();
var session = receipts.find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().pending();
var requested = java.time.Instant.now();
if (route.equals("removal")) {
a.core.cursor = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a);
} else if (route.equals("swap")) {
var event = new org.bukkit.event.player.PlayerSwapHandItemsEvent(a.player,
a.core.inventory.getItemInOffHand(), a.core.inventory.getItem(0));
f.dispatcher.accept(event); assertTrue(event.isCancelled()); f.tick();
} else if (route.equals("quit")) {
f.dispatcher.accept(new org.bukkit.event.player.PlayerQuitEvent(a.player, (net.kyori.adventure.text.Component) null));
} else { f.plugin.setEnabled(false); f.awaitWorkers(); }
assertRestored(f, a, viewer);
assertTrue(a.core.marker().endsWith("|RESTORED"));
assertEquals(org.bukkit.Material.DIAMOND_HELMET, a.core.inventory.getHelmet().getType());
assertEquals(org.bukkit.Material.DIAMOND_SWORD, route.equals("swap")
? a.core.inventory.getItemInOffHand().getType() : a.core.inventory.getItem(0).getType());
if (!route.equals("disable")) assertNull(receipts.find(a.core.id).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().pending());
var restored = a.core.snapshot();
f.plugin.setEnabled(false); f.awaitWorkers(); f.plugin.onDisable();
while (!f.tasks.isEmpty()) f.tasks.remove().run();
assertArrayEquals(restored, a.core.snapshot());
try (var reopened = new games.dmg.fantasystick.persistence.EquipmentSessionStore(data.resolve(route), java.time.Clock.systemUTC())) {
var state = reopened.find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow();
assertNull(state.pending());
var exit = state.lastExit();
assertEquals(session.sessionId(), exit.sessionId()); assertEquals(a.core.record.id(), exit.characterId());
assertFalse(exit.exitedAt().isBefore(requested)); assertFalse(exit.exitedAt().isAfter(java.time.Instant.now()));
assertEquals(java.time.Duration.ofSeconds(900), java.time.Duration.between(exit.exitedAt(), exit.deadline()));
assertEquals(state, reopened.completeExit(a.core.id, session.sessionId(), java.time.Duration.ofHours(1))
.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS), "Repeated lifecycle acknowledgment must not extend the delay");
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);
if (!route.equals("disable")) {
worker.execute(() -> {
entered.countDown();
try { release.await(); }
catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); }
});
}
try {
if (!route.equals("disable")) assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
var requested = java.time.Instant.now();
if (route.equals("removal")) {
a.core.cursor = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a);
} else if (route.equals("swap")) {
var event = new org.bukkit.event.player.PlayerSwapHandItemsEvent(a.player,
a.core.inventory.getItemInOffHand(), a.core.inventory.getItem(0));
f.dispatcher.accept(event); assertTrue(event.isCancelled()); f.tick();
} else if (route.equals("quit")) {
f.dispatcher.accept(new org.bukkit.event.player.PlayerQuitEvent(a.player, (net.kyori.adventure.text.Component) null));
} else { f.plugin.setEnabled(false); f.awaitWorkers(); }
assertRestored(f, a, viewer);
assertTrue(a.core.marker().endsWith("|RESTORED"));
assertEquals(org.bukkit.Material.DIAMOND_HELMET, a.core.inventory.getHelmet().getType());
assertEquals(org.bukkit.Material.DIAMOND_SWORD, route.equals("swap")
? a.core.inventory.getItemInOffHand().getType() : a.core.inventory.getItem(0).getType());
if (!route.equals("disable")) {
assertNotNull(receipts.find(a.core.id).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().pending(),
"Native exit must not release its receipt ahead of blocked character-health persistence");
release.countDown();
// The real character worker completes its save callback before this queued read.
// That callback schedules receipt completion on the separate receipt worker.
characters.find(a.core.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS);
assertNull(receipts.find(a.core.id).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().pending());
}
var restored = a.core.snapshot();
f.plugin.setEnabled(false); f.awaitWorkers(); f.plugin.onDisable();
while (!f.tasks.isEmpty()) f.tasks.remove().run();
assertArrayEquals(restored, a.core.snapshot());
try (var reopened = new games.dmg.fantasystick.persistence.EquipmentSessionStore(data.resolve(route), java.time.Clock.systemUTC())) {
var state = reopened.find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow();
assertNull(state.pending());
var exit = state.lastExit();
assertEquals(session.sessionId(), exit.sessionId()); assertEquals(a.core.record.id(), exit.characterId());
assertFalse(exit.exitedAt().isBefore(requested)); assertFalse(exit.exitedAt().isAfter(java.time.Instant.now()));
assertEquals(java.time.Duration.ofSeconds(900), java.time.Duration.between(exit.exitedAt(), exit.deadline()));
assertEquals(state, reopened.completeExit(a.core.id, session.sessionId(), java.time.Duration.ofHours(1))
.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS), "Repeated lifecycle acknowledgment must not extend the delay");
}
} finally { release.countDown(); }
}
}
}
@@ -874,6 +900,14 @@ class FantasyStickLifecycleNativeTest {
var oldSessions = (games.dmg.fantasystick.session.EquipmentSessions) Fixture.field(f.plugin, "sessions");
var worker = (java.util.concurrent.ExecutorService) Fixture.field(oldCharacters, "worker");
var receiptWorker = (java.util.concurrent.ExecutorService) Fixture.field(f.receipts(), "worker");
// Finish owned health/receipt durability before deliberately overlapping ONLY a
// rename with a new plugin lifetime. A pending health save now correctly retains
// the character claim; it is not safe authority to start a replacement session.
var transferredSheet = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a);
oldCharacters.find(a.core.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS);
f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
a.core.inventory.setItemInOffHand(transferredSheet);
var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1);
var gate = worker.submit(() -> {
entered.countDown();
@@ -1178,6 +1212,14 @@ class FantasyStickLifecycleNativeTest {
final List<String> messages = new ArrayList<>();
final List<org.bukkit.inventory.ItemStack> books = new ArrayList<>();
final List<Integer> particles = new ArrayList<>();
final net.minecraft.world.entity.ai.attributes.AttributeMap healthAttributes = new net.minecraft.world.entity.ai.attributes.AttributeMap(
net.minecraft.world.entity.ai.attributes.AttributeSupplier.builder().add(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH).build());
final net.minecraft.world.entity.ai.attributes.AttributeInstance maximumHealth = java.util.Objects.requireNonNull(
healthAttributes.getInstance(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH));
final net.minecraft.world.food.FoodData food = new net.minecraft.world.food.FoodData();
boolean healthScaled;
double healthScale = 20;
Runnable scaleMutation = () -> {};
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);
@@ -1205,6 +1247,21 @@ class FantasyStickLifecycleNativeTest {
case "getHeight" -> 1.8;
case "getTrackedBy" -> tracked;
case "canSee" -> true;
case "getAttribute" -> {
assertEquals(org.bukkit.attribute.Attribute.MAX_HEALTH, args[0]);
yield new org.bukkit.craftbukkit.attribute.CraftAttributeInstance(maximumHealth, org.bukkit.attribute.Attribute.MAX_HEALTH);
}
case "getMaxHealth" -> maximumHealth.getValue();
case "getFoodLevel" -> food.getFoodLevel();
case "setFoodLevel" -> { food.setFoodLevel((Integer) args[0]); yield null; }
case "getSaturation" -> food.getSaturationLevel();
case "setSaturation" -> { food.setSaturation((Float) args[0]); yield null; }
case "getExhaustion" -> food.exhaustionLevel;
case "setExhaustion" -> { food.exhaustionLevel = (Float) args[0]; yield null; }
case "isHealthScaled" -> healthScaled;
case "getHealthScale" -> healthScale;
case "setHealthScale" -> { healthScale = (Double) args[0]; healthScaled = true; scaleMutation.run(); yield null; }
case "setHealthScaled" -> { healthScaled = (Boolean) args[0]; yield null; }
case "getGameMode" -> core.gameMode;
case "setGameMode" -> {
var event = new org.bukkit.event.player.PlayerGameModeChangeEvent((org.bukkit.entity.Player) call.getMock(), (org.bukkit.GameMode) args[0]);
@@ -1224,6 +1281,9 @@ class FantasyStickLifecycleNativeTest {
when(nativePlayer.getId()).thenReturn(42);
when(nativePlayer.getUUID()).thenReturn(core.id);
when(nativePlayer.getTabListDisplayName()).thenReturn(net.minecraft.network.chat.Component.literal("Ordinary tab"));
maximumHealth.setBaseValue(core.normalMaximum);
when(nativePlayer.getAttributes()).thenReturn(healthAttributes);
when(nativePlayer.getAttribute(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH)).thenReturn(maximumHealth);
distance.setBaseValue(64);
when(nativePlayer.getAttribute(net.minecraft.world.entity.ai.attributes.Attributes.NAME_TAG_DISTANCE)).thenReturn(distance);
var level = mock(net.minecraft.server.level.ServerLevel.class);
@@ -0,0 +1,72 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import games.dmg.fantasystick.items.SessionControls;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.nio.file.Path;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import org.bukkit.Material;
import org.bukkit.block.BlockFace;
import org.bukkit.entity.Item;
import org.bukkit.event.Event;
import org.bukkit.event.block.Action;
import org.bukkit.event.inventory.InventoryMoveItemEvent;
import org.bukkit.event.player.PlayerDropItemEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerItemConsumeEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Supported offhand entry -> guarded delivered controls -> actual book -> ordinary exit. */
class InventoryJourneyNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void allNineDeliveredControlsStayContainedWhileTheActualBookWorksAndTheSheetCanLeave() 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 ordinary = a.core.snapshot();
var sheet = a.core.inventory.getItemInOffHand().clone();
f.activate(a);
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
var active = sessions.active(a.core.id).orElseThrow();
var delivered = a.core.snapshot();
for (int slot = 0; slot < 9; slot++) {
var item = a.core.inventory.getItem(slot);
var identity = new SessionControls().identify(item).orElseThrow();
assertEquals(slot, identity.slot());
assertEquals(active.player(), identity.owner());
assertEquals(active.session(), identity.session());
assertEquals(slot == 0 ? Material.BOOK : slot == 1 ? Material.STICK : Material.GRAY_DYE, item.getType());
var entity = mock(Item.class); when(entity.getItemStack()).thenReturn(item);
var drop = new PlayerDropItemEvent(a.player, entity);
f.dispatcher.accept(drop); assertTrue(drop.isCancelled());
var consume = new PlayerItemConsumeEvent(a.player, item, EquipmentSlot.HAND);
f.dispatcher.accept(consume); assertTrue(consume.isCancelled());
var transfer = new InventoryMoveItemEvent(a.core.inventory, item, a.core.view.getTopInventory(), true);
f.dispatcher.accept(transfer); assertTrue(transfer.isCancelled());
a.core.inventory.getInventory().setSelectedSlot(slot);
var use = new PlayerInteractEvent(a.player, Action.RIGHT_CLICK_AIR, item, null, BlockFace.SELF, EquipmentSlot.HAND);
f.dispatcher.accept(use); assertEquals(Event.Result.DENY, use.useItemInHand());
var characters = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
characters.find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
Runnable task; while ((task = f.tasks.poll()) != null) task.run();
assertEquals(1, a.books.size(), "Only slot 1 opens the actual native book; other delivered controls are inert");
}
assertArrayEquals(delivered, a.core.snapshot(), "Containment and inspection never consume/export inventory");
assertEquals(sheet, a.core.inventory.getItemInOffHand());
a.core.inventory.setItemInOffHand(null); f.changed(a);
assertTrue(sessions.active(a.core.id).isEmpty(), "Transferable sheet removal exits without a combat lock");
ordinary[40] = null;
assertArrayEquals(ordinary, a.core.snapshot(), "Ordinary gear is restored without replacing the removed sheet");
}
}
}
@@ -0,0 +1,57 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.session.NativeMaximumHealth;
import net.minecraft.world.entity.ai.attributes.AttributeInstance;
import net.minecraft.world.entity.ai.attributes.Attributes;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
class NativeMaximumHealthNativeTest {
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void startupProbeExercisesPinnedInternalsWithoutChangingTheGlobalRangeOrOrdinaryInstances() {
var maximum = games.dmg.fantasystick.session.NativeHealthLimit.read();
var ordinary = new AttributeInstance(Attributes.MAX_HEALTH, ignored -> {});
double before = ordinary.getValue();
NativeMaximumHealth.prepare();
assertEquals(maximum, games.dmg.fantasystick.session.NativeHealthLimit.read());
assertEquals(before, ordinary.getValue()); assertTrue(ordinary.getModifiers().isEmpty());
}
@Test void nativePlayerHealthClampingAndClientAttributeSnapshotsUseTheLeasedMaximum() {
var attributes = new net.minecraft.world.entity.ai.attributes.AttributeMap(
net.minecraft.world.entity.ai.attributes.AttributeSupplier.builder().add(Attributes.MAX_HEALTH).build());
var maximum = java.util.Objects.requireNonNull(attributes.getInstance(Attributes.MAX_HEALTH));
var player = org.mockito.Mockito.mock(org.bukkit.craftbukkit.entity.CraftPlayer.class, org.mockito.Mockito.CALLS_REAL_METHODS);
var nativePlayer = org.mockito.Mockito.mock(net.minecraft.server.level.ServerPlayer.class, org.mockito.Mockito.CALLS_REAL_METHODS);
org.mockito.Mockito.doReturn(attributes).when(nativePlayer).getAttributes();
org.mockito.Mockito.doReturn(player).when(nativePlayer).getBukkitEntity();
org.mockito.Mockito.doReturn(nativePlayer).when(player).getHandle();
// Network/entity-data delivery is the boundary; run the actual native health methods.
org.mockito.Mockito.doNothing().when(player).updateScaledHealth();
org.mockito.Mockito.doNothing().when(player).updateScaledHealth(org.mockito.ArgumentMatchers.anyBoolean());
try (var lease = new NativeMaximumHealth(maximum, 10)) {
assertNotNull(lease);
player.setHealthScale(10);
nativePlayer.setHealth(100);
assertEquals(10, nativePlayer.getMaxHealth()); assertEquals(10, nativePlayer.getHealth());
nativePlayer.setHealth(6.25f); assertEquals(6.25, player.getHealth());
var packet = new net.minecraft.network.protocol.game.ClientboundUpdateAttributesPacket(42, java.util.List.of(player.getScaledMaxHealth()));
assertEquals(10, packet.getValues().getFirst().base()); assertTrue(packet.getValues().getFirst().modifiers().isEmpty());
assertEquals(20, maximum.getBaseValue(), "Ordinary attribute data is not replaced by the client presentation snapshot");
}
assertEquals(20, nativePlayer.getMaxHealth());
assertEquals(6.25, nativePlayer.getHealth(), "Removing the maximum lease does not itself heal");
}
@Test void failingOriginalNativeDirtyCallbackCannotLeaveAnUnownedMaximumOverride() {
var attribute = new AttributeInstance(Attributes.MAX_HEALTH, ignored -> { throw new IllegalStateException("Native observer failed"); });
assertEquals(20, attribute.getValue());
assertThrows(IllegalStateException.class, () -> new NativeMaximumHealth(attribute, 10));
assertEquals(20, attribute.getValue(), "Failed construction must not leave an unreachable effective-value lease");
assertThrows(IllegalStateException.class, () -> attribute.setBaseValue(24));
assertEquals(24, attribute.getValue(), "Subsequent legitimate native writes must not call a leaked lease");
}
}
@@ -390,8 +390,8 @@ class PartyCombatNativeTest {
static void exit(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor a) throws Exception {
a.core.inventory.setItemInOffHand(null); f.changed(a);
PartyBookNativeTest.drain(f); // Health acknowledgment precedes clearing the equipment receipt.
f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
PartyBookNativeTest.drain(f);
}
private FantasyStickLifecycleNativeTest.Actor actor(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
@@ -54,7 +54,86 @@ class TailBooksNativeTest {
assertTrue(meta.getTitle().length() <= 32);
assertFalse(Character.isHighSurrogate(meta.getTitle().charAt(meta.getTitle().length() - 1)));
assertEquals(Component.text("Tail of " + name), meta.displayName());
assertTrue(meta.pages().contains(Component.text("Tail of " + name)));
String text = meta.pages().stream().map(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText()::serialize)
.collect(java.util.stream.Collectors.joining()).replace("\n", "");
assertTrue(text.contains("Tail of " + name), "Readable wrapping must retain every character of the full name");
}
@Test void giantStatsRemainReadableInOneSignedKeepsakeThroughTheActualNativeCodec() {
var old = fallen();
var stats = new EnumMap<Stat, BigInteger>(Stat.class); stats.putAll(old.stats());
String digits = "1234567890".repeat(4000);
stats.put(Stat.STR, new BigInteger(digits));
var saved = new CharacterRecord(old.id(), "雪🐈".repeat(80), old.revision(), stats, old.level(), old.experience(),
old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.cooldowns(), true, old.deathId(), old.diedAt());
var book = new TailBooks().create(saved);
var ops = RegistryOps.create(NbtOps.INSTANCE, CraftRegistry.getMinecraftRegistry());
var encoded = net.minecraft.world.item.ItemStack.CODEC.encodeStart(ops, CraftItemStack.asNMSCopy(book)).getOrThrow();
var decoded = net.minecraft.world.item.ItemStack.CODEC.parse(ops, encoded).getOrThrow();
var content = decoded.get(DataComponents.WRITTEN_BOOK_CONTENT); assertNotNull(content);
assertTrue(content.pages().size() > 100, "Signed books are not subject to the writable-book 100-page cap");
var text = new StringBuilder();
for (var page : content.pages()) {
String value = page.raw().getString();
assertTrue(value.split("\\n", -1).length <= 13);
for (String line : value.split("\\n", -1)) assertTrue(line.codePoints().map(cp -> cp < 128 ? 6 : 9).sum() <= 108);
text.append(value.replace("\n", ""));
}
assertTrue(text.toString().contains(saved.name())); assertTrue(text.toString().contains(digits));
assertEquals(1, book.getAmount());
assertEquals(new TailBooks.Identity(saved.id(), saved.deathId()), new TailBooks().identify(CraftItemStack.asBukkitCopy(decoded)).orElseThrow());
}
@Test void ordinaryStatsShareOneSummaryAndEachSavedAbilityHasItsOwnReadableSection() {
var old = fallen();
var expiry = old.diedAt().plusSeconds(3600);
var saved = new CharacterRecord(old.id(), old.name(), old.revision(), old.stats(), old.level(), old.experience(),
old.unspentPoints(), old.health(), Set.of("second_wind", "unknown:雪"),
List.of("second_wind", "unknown:雪", "second_wind", "", "", "", ""),
Map.of("second_wind", expiry, "retired_ability", expiry.plusSeconds(60)), true, old.deathId(), old.diedAt());
var book = new TailBooks().create(saved);
var pages = nativePages(book);
String summary = pages.getFirst().replace("\n", "");
for (String expected : List.of("Tail of Ari", "Level: 2", "XP: 100", "Unspent points: 30",
"STR: 0", "DEX: 0", "CON: 10", "INT: 0", "WIS: 0", "CHR: 0")) assertTrue(summary.contains(expected), expected);
String wind = pages.stream().map(p -> p.replace("\n", "")).filter(p -> p.contains("Ability: second_wind")).findFirst().orElseThrow();
assertTrue(wind.contains("Slots: 1, 3")); assertTrue(wind.contains(expiry.toString()));
assertFalse(wind.contains("unknown:雪"), "Each learned ability starts its own page/section");
String unknown = pages.stream().map(p -> p.replace("\n", "")).filter(p -> p.contains("Ability: unknown:雪")).findFirst().orElseThrow();
assertTrue(unknown.contains("Slots: 2")); assertTrue(unknown.contains("Cooldown: none"));
String all = String.join("", pages).replace("\n", "");
assertTrue(all.contains("Saved cooldown")); assertTrue(all.contains("retired_ability"));
assertTrue(all.contains(expiry.plusSeconds(60).toString())); assertTrue(all.contains(old.diedAt().toString()));
assertEquals(new TailBooks.Identity(saved.id(), saved.deathId()), new TailBooks().identify(book).orElseThrow());
}
@Test void emptyAbilitiesAreExplicitAndLongUnknownAbilityPagesRetainEveryCodePoint() {
var old = fallen();
var empty = new CharacterRecord(old.id(), old.name(), old.revision(), old.stats(), old.level(), old.experience(),
old.unspentPoints(), old.health(), Set.of(), List.of("", "", "", "", "", "", ""), Map.of(), true, old.deathId(), old.diedAt());
var legacyBook = new TailBooks().create(empty);
var original = legacyBook.clone();
assertTrue(nativePages(legacyBook).stream().anyMatch(p -> p.replace("\n", "").contains("Abilities: None")));
String id = "unknown:雪🐈".repeat(4000);
var longAbility = new CharacterRecord(old.id(), old.name(), old.revision(), old.stats(), old.level(), old.experience(),
old.unspentPoints(), old.health(), Set.of(id), List.of("", "", "", "", "", "", ""), Map.of(), true, old.deathId(), old.diedAt());
var pages = nativePages(new TailBooks().create(longAbility));
assertTrue(pages.size() > 100);
assertTrue(String.join("", pages).replace("\n", "").contains("Ability: " + id));
assertTrue(String.join("", pages).replace("\n", "").contains("Slots: none"));
for (String page : pages) {
assertTrue(page.split("\n", -1).length <= 13);
for (String line : page.split("\n", -1)) assertTrue(line.codePoints().map(cp -> cp < 128 ? 6 : 9).sum() <= 108);
}
assertEquals(original, legacyBook, "Rendering another record must not rewrite any existing signed keepsake");
}
private static List<String> nativePages(ItemStack book) {
var ops = RegistryOps.create(NbtOps.INSTANCE, CraftRegistry.getMinecraftRegistry());
var encoded = net.minecraft.world.item.ItemStack.CODEC.encodeStart(ops, CraftItemStack.asNMSCopy(book)).getOrThrow();
var decoded = net.minecraft.world.item.ItemStack.CODEC.parse(ops, encoded).getOrThrow();
return java.util.Objects.requireNonNull(decoded.get(DataComponents.WRITTEN_BOOK_CONTENT)).pages().stream()
.map(p -> p.raw().getString()).toList();
}
@Test
@@ -95,7 +174,7 @@ class TailBooksNativeTest {
assertTrue(pages.contains("Level: 2"));
assertTrue(pages.contains("XP: 100"));
assertTrue(pages.contains("CON: 10"));
assertTrue(pages.contains("second_wind"));
assertTrue(pages.replace("\n", "").contains("second_wind"), "Labeled ability IDs remain complete across readable line wrapping");
assertEquals(new TailBooks.Identity(saved.id(), saved.deathId()), books.identify(CraftItemStack.asBukkitCopy(restored)).orElseThrow());
}
}
@@ -0,0 +1,25 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import java.util.List;
import org.junit.jupiter.api.Test;
class OrdinaryVitalsTest {
@Test void canonicalOrdinarySnapshotRetainsExactHealthNutritionAndDisplayValues() {
var value = new OrdinaryVitals(12.25, 7, 2.5f, 3.75f, false, 20);
assertEquals(value, OrdinaryVitals.decode(value.encode()));
var scaled = new OrdinaryVitals(1, 0, 0, 0, true, 44);
assertEquals(scaled, OrdinaryVitals.decode(scaled.encode()));
}
@Test void malformedNonfiniteNegativeAndNoncanonicalSnapshotsCannotAuthorizeRestoration() {
for (String input : List.of("", "garbage", "x".repeat(300), "NaN;7;2.0;3.0;false;20.0",
"Infinity;7;2.0;3.0;false;20.0", "-1.0;7;2.0;3.0;false;20.0", "12.0;-1;2.0;3.0;false;20.0",
"12.0;21;2.0;3.0;false;20.0", "12.0;7;NaN;3.0;false;20.0", "12.0;7;2.0;-1.0;false;20.0",
"12.0;7;2.0;Infinity;false;20.0", "12.0;7;2.0;3.0;yes;20.0", "12.0;7;2.0;3.0;false;0.0",
"12;7;2.0;3.0;false;20.0", "12.0;7;2.0;3.0;false;20.0;extra")) {
assertThrows(IllegalArgumentException.class, () -> OrdinaryVitals.decode(input), input);
}
}
}