Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db87505142 | ||
|
|
9771d1e7c3 | ||
|
|
259cd8cf29 |
No files matched your search
@@ -2,7 +2,7 @@
|
||||
|
||||
**Development in progress. Manual character crafting is registered; fantasy sessions and the full RPG are not ready for deployment.**
|
||||
|
||||
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 is now assembled; fantasy-mode activation and most gameplay integrations remain planned. 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; health, session, anvil rename 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 is now assembled; fantasy-mode activation and most gameplay integrations remain planned. 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; health, session and spell interactions remain incomplete.
|
||||
|
||||
## Build and verify
|
||||
|
||||
@@ -47,7 +47,7 @@ Thirteen craft-persistence tests cover these backend boundaries, including worke
|
||||
|
||||
`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.
|
||||
|
||||
The `fantasystick:character_sheet` shapeless recipe definition contains one stick, paper, feather and ink sac. Character creation is **manual player crafting only** (inventory grid or crafting table). `ManualCraftingGuard` cancels this recipe's automated `CrafterCraftEvent` before native consumption/ejection, without touching other recipes or reversing cancellations. Startup installs this guard and the manual controller before exposing the recipe. Its preview has no character metadata and allocates no identity/name. Manual completion, bulk crafting and cancellation/save handling are registered; anvil rename persistence remains unimplemented. Presentation edits preserving metadata are tested, not a claim that complete anvil transactions work.
|
||||
The `fantasystick:character_sheet` shapeless recipe definition contains one stick, paper, feather and ink sac. Character creation is **manual player crafting only** (inventory grid or crafting table). `ManualCraftingGuard` cancels this recipe's automated `CrafterCraftEvent` before native consumption/ejection, without touching other recipes or reversing cancellations. Startup installs this guard and the manual controller before exposing the recipe. Its preview has no character metadata and allocates no identity/name. Manual completion, bulk crafting, cancellation/save handling and accepted anvil-name persistence are registered. Presentation edits preserving metadata are tested, not a claim that complete anvil transactions work.
|
||||
|
||||
## Manual craft planning (not delivery)
|
||||
|
||||
@@ -91,7 +91,29 @@ starting-points: 40
|
||||
|
||||
The default is 40; nonnegative arbitrary-precision integers are supported. Invalid values fail before gameplay registration. Existing saved characters are not rewritten. Configuration is read on enable; no reload command or automatic data migration is supplied.
|
||||
|
||||
Nine native lifecycle tests load the actual packaged entrypoint through a test configured classloader and native `JavaPlugin.setEnabled`, then register/dispatch through Paper. They cover registration ordering, configured points, all craft routes, distinct persisted sheets, installed Crafter cancellation, failed startup and shutdown/queued-callback cleanup. Server registry/scheduler/player/world insertion are explicit test ports; this is not a listening world/client test. The assembled checkpoint passes clean/incremental packaging, all 162 tests, audit and static analysis; remote publication is tracked separately. Anvil rename, fantasy-mode entry and the remaining RPG are not implemented.
|
||||
Nine native lifecycle tests load the actual packaged entrypoint through a test configured classloader and native `JavaPlugin.setEnabled`, then register/dispatch through Paper. They cover registration ordering, configured points, all craft routes, distinct persisted sheets, installed Crafter cancellation, failed startup and shutdown/queued-callback cleanup. Server registry/scheduler/player/world insertion are explicit test ports; this is not a listening world/client test. The assembled checkpoint passes clean/incremental packaging, all 162 tests, audit and static analysis; remote publication is tracked separately. Fantasy-mode entry and the remaining RPG are not implemented.
|
||||
|
||||
## Accepted anvil names
|
||||
|
||||
`CharacterAnvilNames` observes Purpur's non-cancellable accepted-take event, not anvil previews. Matching input/result character identities and changed displayed text are captured on the server thread; `CharacterStorage.renameCharacter` then changes only name/revision on the persistence worker against the latest live record. It preserves stats, XP, levels, points, health, loadout, cooldowns and historical death identity. Same-name requests do not rewrite; missing/dead records cannot be created or revived by renamed items. Clearing the custom name uses the native item-factory name, including an explicit item-name component where present.
|
||||
|
||||
Vanilla retains responsibility for item movement, XP charging and anvil damage. The inspected event fires at the start of native accepted-take processing, before that continuation; the observer does not make native completion contingent on YAML acknowledgment. Failure/crash may leave item presentation ahead of the saved name. The original saved progress is retained and captured IDs are logged; there is no automatic item/XP rollback, reissue or guaranteed retry.
|
||||
|
||||
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 and fantasy sessions remain incomplete. 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)
|
||||
|
||||
`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.
|
||||
|
||||
## Session claims (not wired to activation)
|
||||
|
||||
`SessionClaims` supplies process-local exclusion for pending/active character sessions. A single shared registry atomically prevents simultaneous claims for either the same character identity or the same player's active mode. This does not limit how many sheets a player may own or permanently bind a character to a holder. Released characters can transfer; a stale token cannot release a replacement claim. Private claim objects carry distinct session UUIDs for future equipment/callback correlation. Dead/zero-health DTOs refuse unchanged; shutdown invalidates all claims and refuses new ones.
|
||||
|
||||
Six unit tests cover exclusion, transfer/switch release, stale callbacks, invalid input, shutdown/restart boundaries, same-name identities, uncapped values and 24 rounds of 16 competing callbacks. The registry does no persistence or native work and does not impose a native-health cap. It must be shared across entry routes; separate processes do not coordinate. Callers still must load current authoritative state, validate sheet possession/context/deadlines, preflight native equipment/health, retain/release claims across their lifecycle and reconcile interrupted state. `isCurrent()` is a lifetime check, not saved-state or effect authorization. No fantasy activation is registered yet. The foundation passes 182 tests, clean/incremental packaging, audit and static analysis; remote publication is tracked separately.
|
||||
|
||||
## Native equipment adapter
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import games.dmg.fantasystick.crafting.CharacterFactory;
|
||||
import games.dmg.fantasystick.crafting.CharacterAnvilNames;
|
||||
import games.dmg.fantasystick.crafting.ManualCraftingController;
|
||||
import games.dmg.fantasystick.crafting.ManualCraftingGuard;
|
||||
import games.dmg.fantasystick.crafting.PlayerSheetDrops;
|
||||
@@ -12,11 +13,12 @@ import java.time.Clock;
|
||||
import java.util.logging.Level;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
/** Manual character crafting; fantasy sessions and the remaining gameplay adapters are not registered yet. */
|
||||
/** Manual character crafting and anvil names; fantasy sessions and remaining gameplay are not registered yet. */
|
||||
public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private CharacterStorage characters;
|
||||
private ManualCraftingController crafting;
|
||||
private ManualCraftingGuard guard;
|
||||
private CharacterAnvilNames anvilNames;
|
||||
private boolean recipeRegistered;
|
||||
|
||||
@Override public void onEnable() {
|
||||
@@ -32,8 +34,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
problem -> getLogger().log(Level.WARNING, "Character craft " + problem.operation() + " for player "
|
||||
+ problem.player() + "; character records " + problem.characters(), problem.cause()));
|
||||
guard = new ManualCraftingGuard();
|
||||
anvilNames = new CharacterAnvilNames(characters,
|
||||
problem -> getLogger().log(Level.WARNING, "Character name update was refused or failed for player "
|
||||
+ problem.player() + "; character " + problem.character(), problem.cause()), getServer().getItemFactory());
|
||||
getServer().getPluginManager().registerEvents(crafting, this);
|
||||
getServer().getPluginManager().registerEvents(guard, this);
|
||||
getServer().getPluginManager().registerEvents(anvilNames, this);
|
||||
recipeRegistered = getServer().addRecipe(new CharacterSheets().recipe());
|
||||
if (!recipeRegistered) throw new IllegalStateException("Character-sheet recipe key is already registered");
|
||||
} catch (RuntimeException failure) {
|
||||
@@ -61,6 +67,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
org.bukkit.event.HandlerList.unregisterAll(guard);
|
||||
guard = null;
|
||||
}
|
||||
if (anvilNames != null) {
|
||||
org.bukkit.event.HandlerList.unregisterAll(anvilNames);
|
||||
anvilNames = null;
|
||||
}
|
||||
if (characters != null) {
|
||||
characters.close();
|
||||
characters = null;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package games.dmg.fantasystick.crafting;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.purpurmc.purpur.event.inventory.AnvilTakeResultEvent;
|
||||
|
||||
/** Observes the native accepted-take boundary; does not replace vanilla movement, XP cost or anvil damage. */
|
||||
public final class CharacterAnvilNames implements Listener {
|
||||
public record Failure(UUID player, UUID character, Throwable cause) { }
|
||||
private final CharacterStorage storage;
|
||||
private final Consumer<Failure> failures;
|
||||
private final org.bukkit.inventory.ItemFactory items;
|
||||
|
||||
/** Diagnostics may run on the persistence worker and must not access native player state. */
|
||||
public CharacterAnvilNames(CharacterStorage storage, Consumer<Failure> failures, org.bukkit.inventory.ItemFactory items) {
|
||||
this.items = java.util.Objects.requireNonNull(items);
|
||||
this.storage = java.util.Objects.requireNonNull(storage);
|
||||
this.failures = java.util.Objects.requireNonNull(failures);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onTaken(AnvilTakeResultEvent event) {
|
||||
var sheets = new CharacterSheets();
|
||||
var result = event.getResult();
|
||||
var id = sheets.identify(result);
|
||||
if (id.isEmpty()) return;
|
||||
var input = event.getInventory().getItem(0);
|
||||
if (!id.equals(sheets.identify(input))) return;
|
||||
String name = name(result);
|
||||
if (name.equals(name(input))) return;
|
||||
var player = event.getPlayer().getUniqueId();
|
||||
var character = id.orElseThrow();
|
||||
storage.renameCharacter(character, name).whenComplete((renamed, failure) -> {
|
||||
if (failure != null || renamed.isEmpty()) failures.accept(new Failure(player, character, failure));
|
||||
});
|
||||
}
|
||||
|
||||
private String name(ItemStack sheet) {
|
||||
var custom = sheet.getItemMeta().customName();
|
||||
return custom == null ? items.getI18NDisplayName(sheet)
|
||||
: net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(custom);
|
||||
}
|
||||
}
|
||||
@@ -193,6 +193,23 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
return submit(() -> repository.load(id));
|
||||
}
|
||||
|
||||
/** Persist an accepted native rename against the latest live record; never create a character from item metadata. */
|
||||
public CompletableFuture<Optional<CharacterRecord>> renameCharacter(UUID characterId, String name) {
|
||||
Objects.requireNonNull(characterId);
|
||||
Objects.requireNonNull(name);
|
||||
return submit(() -> {
|
||||
var found = repository.load(characterId);
|
||||
if (found.isEmpty() || found.orElseThrow().dead()) return Optional.empty();
|
||||
var old = found.orElseThrow();
|
||||
if (old.name().equals(name)) return Optional.of(old);
|
||||
var renamed = new CharacterRecord(old.id(), name, old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.cooldowns(),
|
||||
false, old.deathId(), old.diedAt());
|
||||
repository.replace(renamed, old.revision());
|
||||
return Optional.of(renamed);
|
||||
});
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<CharacterRecord>> recordDeath(UUID characterId, BigInteger expectedRevision) {
|
||||
Objects.requireNonNull(characterId);
|
||||
Objects.requireNonNull(expectedRevision);
|
||||
|
||||
@@ -19,6 +19,7 @@ public final class CharacterProgress {
|
||||
|
||||
private final UUID characterId;
|
||||
private final ProgressionRules rules;
|
||||
private final BigDecimal nativeHealthLimit;
|
||||
private final EnumMap<Stat, BigInteger> stats = new EnumMap<>(Stat.class);
|
||||
private int level = 1;
|
||||
private BigInteger experience = BigInteger.ZERO;
|
||||
@@ -26,11 +27,19 @@ public final class CharacterProgress {
|
||||
private BigInteger revision = BigInteger.ZERO;
|
||||
private BigDecimal health = BigDecimal.TEN;
|
||||
|
||||
/** Uses the pinned runtime's default ceiling. Runtime adapters must supply their actual limit. */
|
||||
public CharacterProgress(UUID characterId, ProgressionRules rules) {
|
||||
this(characterId, rules, new BigDecimal("1024"));
|
||||
}
|
||||
|
||||
public CharacterProgress(UUID characterId, ProgressionRules rules, BigDecimal nativeHealthLimit) {
|
||||
this.characterId = Objects.requireNonNull(characterId);
|
||||
this.rules = Objects.requireNonNull(rules);
|
||||
this.nativeHealthLimit = Objects.requireNonNull(nativeHealthLimit);
|
||||
if (nativeHealthLimit.signum() <= 0) throw new IllegalArgumentException("Native health limit must be positive");
|
||||
this.points = rules.startingPoints();
|
||||
for (Stat stat : Stat.values()) stats.put(stat, BigInteger.ZERO);
|
||||
this.health = maximumHealth();
|
||||
}
|
||||
|
||||
public synchronized Snapshot snapshot() {
|
||||
@@ -47,9 +56,10 @@ public final class CharacterProgress {
|
||||
if (!revision.equals(expectedRevision)) return Purchase.STALE_REVISION;
|
||||
if (health.signum() == 0) return Purchase.NOT_ALIVE;
|
||||
if (points.signum() == 0) return Purchase.NO_POINTS;
|
||||
BigDecimal previousMaximum = maximumHealth();
|
||||
stats.put(stat, stats.get(stat).add(BigInteger.ONE));
|
||||
points = points.subtract(BigInteger.ONE);
|
||||
if (stat == Stat.CON) health = health.add(BigDecimal.ONE);
|
||||
if (stat == Stat.CON) health = health.add(maximumHealth().subtract(previousMaximum));
|
||||
revision = revision.add(BigInteger.ONE);
|
||||
return Purchase.APPLIED;
|
||||
}
|
||||
@@ -80,6 +90,6 @@ public final class CharacterProgress {
|
||||
}
|
||||
|
||||
private BigDecimal maximumHealth() {
|
||||
return new BigDecimal(stats.get(Stat.CON).add(BigInteger.TEN));
|
||||
return new BigDecimal(stats.get(Stat.CON).add(BigInteger.TEN)).min(nativeHealthLimit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import org.bukkit.attribute.Attribute;
|
||||
|
||||
/**
|
||||
* Read-only access to the pinned runtime's health range; never changes server settings or attributes.
|
||||
* Call on the server thread after registry initialization. The API exposes no range getter, so use the
|
||||
* supported runtime's public Craft/native accessors and fail explicitly if they are incompatible.
|
||||
* This reads the current range on each call, not a guessed default or cached configuration value.
|
||||
* Native session/health application is separate and must revalidate its context before mutation.
|
||||
*/
|
||||
public final class NativeHealthLimit {
|
||||
private NativeHealthLimit() { }
|
||||
|
||||
public static BigDecimal read() {
|
||||
try {
|
||||
Object range = Attribute.MAX_HEALTH.getClass().getMethod("getHandle").invoke(Attribute.MAX_HEALTH);
|
||||
double maximum = ((Number) range.getClass().getMethod("getMaxValue").invoke(range)).doubleValue();
|
||||
if (!Double.isFinite(maximum) || maximum <= 0
|
||||
|| !Float.isFinite((float) maximum) || (float) maximum <= 0) {
|
||||
throw new IllegalStateException("Native maximum health must be positive and representable as Minecraft health");
|
||||
}
|
||||
return BigDecimal.valueOf(maximum);
|
||||
} catch (ReflectiveOperationException | ClassCastException failure) {
|
||||
throw new IllegalStateException("Cannot read the supported native maximum-health range", failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Process-local pending/active session exclusion, not persisted character ownership or native activation.
|
||||
* A player can own any number of sheets, but must finish one session before entering another.
|
||||
* The caller retains the claim across asynchronous entry/active callbacks and releases it on failure/exit.
|
||||
* Share one registry across entry routes in this plugin process; separate instances/processes do not coordinate.
|
||||
* No cooldown, equipment, saved-state reconciliation or native health policy is supplied here.
|
||||
*/
|
||||
public final class SessionClaims implements AutoCloseable {
|
||||
private final java.util.Map<UUID, Claim> players = new java.util.HashMap<>();
|
||||
private final java.util.Map<UUID, Claim> characters = new java.util.HashMap<>();
|
||||
private boolean closed;
|
||||
|
||||
/**
|
||||
* The caller must supply current authoritative saved data, not data copied from an item, and validate
|
||||
* sheet possession, lifecycle, switching deadline and native preflight. This method does no file I/O.
|
||||
*/
|
||||
public synchronized Optional<Claim> tryClaim(UUID player, CharacterRecord character) {
|
||||
java.util.Objects.requireNonNull(player);
|
||||
java.util.Objects.requireNonNull(character);
|
||||
if (closed || character.dead() || character.health().signum() == 0
|
||||
|| players.containsKey(player) || characters.containsKey(character.id())) return Optional.empty();
|
||||
var claim = new Claim(player, character.id());
|
||||
players.put(player, claim);
|
||||
characters.put(character.id(), claim);
|
||||
return Optional.of(claim);
|
||||
}
|
||||
|
||||
public final class Claim implements AutoCloseable {
|
||||
private final UUID player;
|
||||
private final UUID character;
|
||||
private final UUID session = UUID.randomUUID();
|
||||
private Claim(UUID player, UUID character) { this.player = player; this.character = character; }
|
||||
public UUID playerId() { return player; }
|
||||
public UUID characterId() { return character; }
|
||||
public UUID sessionId() { return session; }
|
||||
/** Lifetime/identity check only: does not re-read saved liveness or authorize native effects. */
|
||||
public boolean isCurrent() {
|
||||
synchronized (SessionClaims.this) {
|
||||
return players.get(player) == this && characters.get(character) == this;
|
||||
}
|
||||
}
|
||||
@Override public void close() {
|
||||
synchronized (SessionClaims.this) {
|
||||
players.remove(player, this);
|
||||
characters.remove(character, this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override public synchronized void close() {
|
||||
closed = true;
|
||||
players.clear();
|
||||
characters.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.crafting.CharacterAnvilNames;
|
||||
import games.dmg.fantasystick.crafting.CharacterFactory;
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.persistence.YamlCharacterRepository;
|
||||
import games.dmg.fantasystick.progression.ProgressionRules;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.minecraft.world.SimpleContainer;
|
||||
import net.minecraft.world.inventory.ResultContainer;
|
||||
import org.bukkit.craftbukkit.inventory.CraftAbstractInventoryView;
|
||||
import org.bukkit.craftbukkit.inventory.CraftInventoryAnvil;
|
||||
import org.bukkit.entity.HumanEntity;
|
||||
import org.bukkit.event.inventory.InventoryType;
|
||||
import org.bukkit.inventory.InventoryView;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.MenuType;
|
||||
import org.bukkit.inventory.PlayerInventory;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.purpurmc.purpur.event.inventory.AnvilTakeResultEvent;
|
||||
|
||||
class CharacterAnvilNamesNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test
|
||||
void anAcceptedNativeResultSavesItsNewNameWithoutTouchingInventoryOrPlayerXp() throws Exception {
|
||||
var original = new CharacterFactory().create(ProgressionRules.defaults());
|
||||
var fixture = new AnvilFixture();
|
||||
var sheet = new CharacterSheets().create(original);
|
||||
fixture.grid.setItem(0, sheet);
|
||||
var renamed = sheet.clone();
|
||||
var meta = renamed.getItemMeta();
|
||||
meta.customName(Component.text("Mira of Dawn — 雪"));
|
||||
renamed.setItemMeta(meta);
|
||||
fixture.grid.setItem(2, renamed);
|
||||
var problems = new CopyOnWriteArrayList<CharacterAnvilNames.Failure>();
|
||||
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
|
||||
storage.create(original).join();
|
||||
listener(storage, problems).onTaken(new AnvilTakeResultEvent(fixture.actor.player, fixture.view, renamed));
|
||||
var saved = storage.find(original.id()).join().orElseThrow();
|
||||
assertEquals("Mira of Dawn — 雪", saved.name());
|
||||
assertEquals(original.id(), saved.id());
|
||||
assertEquals(original.stats(), saved.stats());
|
||||
assertEquals(original.health(), saved.health());
|
||||
assertEquals(original.unspentPoints(), saved.unspentPoints());
|
||||
assertEquals(sheet, fixture.grid.getItem(0));
|
||||
assertEquals(renamed, fixture.grid.getItem(2));
|
||||
var dead = storage.recordDeath(saved.id(), saved.revision()).join().orElseThrow();
|
||||
var tail = new games.dmg.fantasystick.items.TailBooks().create(dead);
|
||||
var book = (org.bukkit.inventory.meta.BookMeta) ItemStack.deserializeBytes(tail.serializeAsBytes()).getItemMeta();
|
||||
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(problems.isEmpty());
|
||||
// Actor rejects all XP/cursor/inventory mutation calls and all worker-side player access.
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void clearingTheCustomNameUsesTheNativePaperNameAndLaterSheetsAndTailBooksUseTheSavedName() throws Exception {
|
||||
var original = new CharacterFactory().create(ProgressionRules.defaults());
|
||||
var fixture = new AnvilFixture();
|
||||
var sheet = new CharacterSheets().create(original);
|
||||
fixture.grid.setItem(0, sheet);
|
||||
var cleared = named(sheet, null);
|
||||
var problems = new CopyOnWriteArrayList<CharacterAnvilNames.Failure>();
|
||||
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
|
||||
storage.create(original).join();
|
||||
listener(storage, problems).onTaken(new AnvilTakeResultEvent(fixture.actor.player, fixture.view, cleared));
|
||||
var saved = storage.find(original.id()).join().orElseThrow();
|
||||
assertEquals("Paper", saved.name());
|
||||
var rendered = new CharacterSheets().create(saved);
|
||||
assertEquals(Component.text("Paper"), rendered.getItemMeta().customName());
|
||||
assertEquals(original.id(), new CharacterSheets().identify(ItemStack.deserializeBytes(rendered.serializeAsBytes())).orElseThrow());
|
||||
var dead = storage.recordDeath(saved.id(), saved.revision()).join().orElseThrow();
|
||||
var book = new games.dmg.fantasystick.items.TailBooks().create(dead);
|
||||
assertEquals("Tail of Paper", ((org.bukkit.inventory.meta.BookMeta) book.getItemMeta()).getTitle());
|
||||
assertTrue(problems.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoresOrdinaryItemsIdentitySubstitutionAndNonRenameTakesWithoutOverwritingANewerSavedName() throws Exception {
|
||||
var original = new CharacterFactory().create(ProgressionRules.defaults());
|
||||
var other = new CharacterFactory().create(ProgressionRules.defaults());
|
||||
var fixture = new AnvilFixture();
|
||||
var sheet = new CharacterSheets().create(original);
|
||||
var problems = new CopyOnWriteArrayList<CharacterAnvilNames.Failure>();
|
||||
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
|
||||
storage.create(original).join();
|
||||
storage.create(other).join();
|
||||
var latest = storage.renameCharacter(original.id(), "Saved elsewhere").join().orElseThrow();
|
||||
var listener = listener(storage, problems);
|
||||
fixture.grid.setItem(0, sheet);
|
||||
listener.onTaken(new AnvilTakeResultEvent(fixture.actor.player, fixture.view, sheet.clone()));
|
||||
listener.onTaken(new AnvilTakeResultEvent(fixture.actor.player, fixture.view, new ItemStack(org.bukkit.Material.PAPER)));
|
||||
listener.onTaken(new AnvilTakeResultEvent(fixture.actor.player, fixture.view,
|
||||
named(new CharacterSheets().create(other), Component.text("Different identity"))));
|
||||
fixture.grid.setItem(0, new ItemStack(org.bukkit.Material.PAPER));
|
||||
listener.onTaken(new AnvilTakeResultEvent(fixture.actor.player, fixture.view, named(sheet, Component.text("Counterfeit input"))));
|
||||
assertEquals(latest, storage.find(original.id()).join().orElseThrow());
|
||||
assertEquals(other, storage.find(other.id()).join().orElseThrow());
|
||||
assertTrue(problems.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingAndDeadSheetReferencesAreReportedWithoutCreatingOrRevivingACharacter() throws Exception {
|
||||
for (boolean dead : new boolean[] {false, true}) {
|
||||
var original = new CharacterFactory().create(ProgressionRules.defaults());
|
||||
var fixture = new AnvilFixture();
|
||||
var sheet = new CharacterSheets().create(original);
|
||||
fixture.grid.setItem(0, sheet);
|
||||
var problems = new CopyOnWriteArrayList<CharacterAnvilNames.Failure>();
|
||||
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
|
||||
if (dead) { storage.create(original).join(); storage.recordDeath(original.id(), original.revision()).join(); }
|
||||
listener(storage, problems).onTaken(new AnvilTakeResultEvent(fixture.actor.player, fixture.view,
|
||||
named(sheet, Component.text("Cannot create or restore"))));
|
||||
var saved = storage.find(original.id()).join();
|
||||
assertEquals(dead, saved.isPresent());
|
||||
if (dead) { assertTrue(saved.orElseThrow().dead()); assertEquals(original.name(), saved.orElseThrow().name()); }
|
||||
assertEquals(1, problems.size());
|
||||
assertEquals(original.id(), problems.getFirst().character());
|
||||
assertEquals(fixture.actor.id, problems.getFirst().player());
|
||||
assertEquals(sheet, fixture.grid.getItem(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveFailureReportsCapturedIdsAndPreservesSavedProgressWithoutReissuingOrMutatingItems() throws Exception {
|
||||
var original = new CharacterFactory().create(ProgressionRules.defaults());
|
||||
var fixture = new AnvilFixture();
|
||||
var sheet = new CharacterSheets().create(original);
|
||||
var output = named(sheet, Component.text("Taken but not yet saved"));
|
||||
fixture.grid.setItem(0, sheet);
|
||||
fixture.grid.setItem(2, output);
|
||||
var problems = new CopyOnWriteArrayList<CharacterAnvilNames.Failure>();
|
||||
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
|
||||
storage.create(original).join();
|
||||
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());
|
||||
listener(storage, problems).onTaken(new AnvilTakeResultEvent(fixture.actor.player, fixture.view, output));
|
||||
assertEquals(original, storage.find(original.id()).join().orElseThrow());
|
||||
assertEquals(1, problems.size());
|
||||
assertNotNull(problems.getFirst().cause());
|
||||
}
|
||||
assertEquals(sheet, fixture.grid.getItem(0));
|
||||
assertEquals(output, fixture.grid.getItem(2));
|
||||
assertEquals(0, fixture.actor.syncs);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void capturesTheTakenNameBeforeWorkerWorkAndRepeatsDoNotRewriteTheSameSavedName() throws Exception {
|
||||
var original = new CharacterFactory().create(ProgressionRules.defaults());
|
||||
var fixture = new AnvilFixture();
|
||||
var sheet = new CharacterSheets().create(original);
|
||||
fixture.grid.setItem(0, sheet);
|
||||
var output = named(sheet, Component.text("Captured name"));
|
||||
var problems = new CopyOnWriteArrayList<CharacterAnvilNames.Failure>();
|
||||
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
|
||||
storage.create(original).join();
|
||||
var listener = listener(storage, problems);
|
||||
var event = new AnvilTakeResultEvent(fixture.actor.player, fixture.view, output);
|
||||
listener.onTaken(event); listener.onTaken(event);
|
||||
var meta = output.getItemMeta(); meta.customName(Component.text("Later mutable output")); output.setItemMeta(meta);
|
||||
var saved = storage.find(original.id()).join().orElseThrow();
|
||||
assertEquals("Captured name", saved.name());
|
||||
assertEquals(java.math.BigInteger.ONE, saved.revision());
|
||||
assertTrue(problems.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void clearingACustomNameRespectsAnExplicitNativeItemNameComponent() throws Exception {
|
||||
var original = new CharacterFactory().create(ProgressionRules.defaults());
|
||||
var fixture = new AnvilFixture();
|
||||
var input = new CharacterSheets().create(original);
|
||||
var meta = input.getItemMeta(); meta.itemName(Component.text("Arcane Paper")); input.setItemMeta(meta);
|
||||
fixture.grid.setItem(0, input);
|
||||
var output = named(input, null);
|
||||
String nativeName = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(output).getHoverName().getString();
|
||||
assertEquals("Arcane Paper", nativeName);
|
||||
var problems = new CopyOnWriteArrayList<CharacterAnvilNames.Failure>();
|
||||
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
|
||||
storage.create(original).join();
|
||||
listener(storage, problems).onTaken(new AnvilTakeResultEvent(fixture.actor.player, fixture.view, output));
|
||||
assertEquals(nativeName, storage.find(original.id()).join().orElseThrow().name());
|
||||
assertTrue(problems.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
private static CharacterAnvilNames listener(CharacterStorage storage, CopyOnWriteArrayList<CharacterAnvilNames.Failure> problems) {
|
||||
return new CharacterAnvilNames(storage, problems::add, org.bukkit.craftbukkit.inventory.CraftItemFactory.instance());
|
||||
}
|
||||
|
||||
static ItemStack named(ItemStack source, Component name) {
|
||||
var result = source.clone();
|
||||
var meta = result.getItemMeta(); meta.customName(name); result.setItemMeta(meta);
|
||||
return result;
|
||||
}
|
||||
|
||||
static final class AnvilFixture {
|
||||
final ManualCraftingControllerNativeTest.Actor actor = new ManualCraftingControllerNativeTest.Actor(2, 1);
|
||||
final CraftInventoryAnvil grid = new CraftInventoryAnvil(null, new SimpleContainer(2), new ResultContainer());
|
||||
final InventoryView view = new CraftAbstractInventoryView() {
|
||||
@Override public CraftInventoryAnvil getTopInventory() { return grid; }
|
||||
@Override public PlayerInventory getBottomInventory() { return actor.fixture.inventory; }
|
||||
@Override public HumanEntity getPlayer() { return actor.player; }
|
||||
@Override public InventoryType getType() { return InventoryType.ANVIL; }
|
||||
@Override public String getTitle() { return "Anvil fixture"; }
|
||||
@Override public String getOriginalTitle() { return getTitle(); }
|
||||
@Override public void setTitle(String title) { throw new AssertionError("Unexpected title change"); }
|
||||
@Override public MenuType getMenuType() { return null; }
|
||||
@Override public ItemStack getCursor() { throw new AssertionError("Unexpected cursor access"); }
|
||||
@Override public void setCursor(ItemStack item) { throw new AssertionError("Unexpected cursor write"); }
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -77,12 +77,13 @@ class FantasyStickLifecycleNativeTest {
|
||||
|
||||
@Test
|
||||
void recipeCollisionAndPartialListenerRegistrationFailureRollbackOnlyThisEnableAttempt() throws Exception {
|
||||
for (boolean collision : new boolean[] {true, false}) {
|
||||
for (String stage : List.of("collision", "ManualCraftingGuard", "CharacterAnvilNames")) {
|
||||
boolean collision = stage.equals("collision");
|
||||
try (var fixture = new Fixture(data)) {
|
||||
var existing = new org.bukkit.inventory.ShapelessRecipe(CharacterSheets.RECIPE,
|
||||
new org.bukkit.inventory.ItemStack(org.bukkit.Material.DIAMOND)).addIngredient(org.bukkit.Material.STICK);
|
||||
if (collision) fixture.recipes.put(CharacterSheets.RECIPE, existing);
|
||||
else fixture.failRegistrationAt = "ManualCraftingGuard";
|
||||
else fixture.failRegistrationAt = stage;
|
||||
assertThrows(IllegalStateException.class, () -> fixture.plugin.setEnabled(true));
|
||||
if (collision) assertSame(existing, fixture.recipes.get(CharacterSheets.RECIPE));
|
||||
else assertFalse(fixture.recipes.containsKey(CharacterSheets.RECIPE));
|
||||
@@ -90,6 +91,8 @@ class FantasyStickLifecycleNativeTest {
|
||||
.anyMatch(listener -> listener.getPlugin() == fixture.plugin));
|
||||
assertFalse(java.util.Arrays.stream(org.bukkit.event.block.CrafterCraftEvent.getHandlerList().getRegisteredListeners())
|
||||
.anyMatch(listener -> listener.getPlugin() == fixture.plugin));
|
||||
assertFalse(java.util.Arrays.stream(org.purpurmc.purpur.event.inventory.AnvilTakeResultEvent.getHandlerList().getRegisteredListeners())
|
||||
.anyMatch(listener -> listener.getPlugin() == fixture.plugin));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,6 +182,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
assertEquals(2, actor.fixture.grid.getMatrix()[0].getAmount());
|
||||
assertEquals(0, actor.syncs);
|
||||
assertFalse(fixture.recipes.containsKey(CharacterSheets.RECIPE));
|
||||
awaitUnissuedRecordCleanup(data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,6 +256,54 @@ class FantasyStickLifecycleNativeTest {
|
||||
fixture.failRemoval = false;
|
||||
fixture.plugin.onDisable();
|
||||
assertFalse(fixture.recipes.containsKey(CharacterSheets.RECIPE));
|
||||
awaitUnissuedRecordCleanup(data);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void installedAnvilObserverPersistsTakenNamesButNeverPreviewsAndUnregistersOnDisable() throws Exception {
|
||||
try (var fixture = new Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var original = new games.dmg.fantasystick.crafting.CharacterFactory()
|
||||
.create(games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
repository.create(original);
|
||||
var anvil = new CharacterAnvilNamesNativeTest.AnvilFixture();
|
||||
var sheet = new CharacterSheets().create(original);
|
||||
var output = CharacterAnvilNamesNativeTest.named(sheet, net.kyori.adventure.text.Component.text("Registered anvil name"));
|
||||
anvil.grid.setItem(0, sheet);
|
||||
anvil.grid.setItem(2, output);
|
||||
var previewView = port(org.bukkit.inventory.view.AnvilView.class, (name, args) -> switch (name) {
|
||||
case "getTopInventory" -> anvil.grid;
|
||||
default -> throw new AssertionError("Unexpected preview call " + name);
|
||||
});
|
||||
fixture.dispatcher.accept(new org.bukkit.event.inventory.PrepareAnvilEvent(previewView, output));
|
||||
assertEquals(original, repository.load(original.id()).orElseThrow());
|
||||
fixture.dispatcher.accept(new org.purpurmc.purpur.event.inventory.AnvilTakeResultEvent(anvil.actor.player, anvil.view, output));
|
||||
var deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(5);
|
||||
while (!repository.load(original.id()).orElseThrow().name().equals("Registered anvil name") && System.nanoTime() < deadline) {
|
||||
Thread.sleep(5);
|
||||
}
|
||||
assertEquals("Registered anvil name", repository.load(original.id()).orElseThrow().name());
|
||||
assertEquals(original.stats(), repository.load(original.id()).orElseThrow().stats());
|
||||
assertEquals(sheet, anvil.grid.getItem(0));
|
||||
assertEquals(output, anvil.grid.getItem(2));
|
||||
fixture.plugin.setEnabled(false);
|
||||
assertFalse(java.util.Arrays.stream(org.purpurmc.purpur.event.inventory.AnvilTakeResultEvent.getHandlerList().getRegisteredListeners())
|
||||
.anyMatch(listener -> listener.getPlugin() == fixture.plugin));
|
||||
}
|
||||
}
|
||||
|
||||
private static void awaitUnissuedRecordCleanup(Path directory) throws Exception {
|
||||
// The batch was ready before disable, so its cleanup was queued before shutdown.
|
||||
// Production disable remains nonblocking; only the test waits before deleting its fixture directory.
|
||||
var deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(5);
|
||||
while (true) {
|
||||
try (var paths = java.nio.file.Files.list(directory.resolve("characters"))) {
|
||||
if (paths.noneMatch(path -> path.getFileName().toString().endsWith(".yml"))) return;
|
||||
}
|
||||
if (System.nanoTime() >= deadline) fail("Unissued fixture records were not cleaned up before temporary-directory disposal");
|
||||
Thread.sleep(5);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,6 +342,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
});
|
||||
server = port(Server.class, (name, args) -> switch (name) {
|
||||
case "getPluginManager" -> manager;
|
||||
case "getItemFactory" -> org.bukkit.craftbukkit.inventory.CraftItemFactory.instance();
|
||||
case "getScheduler" -> scheduler;
|
||||
case "getLogger" -> Logger.getLogger("FantasyStick lifecycle fixture");
|
||||
case "isPrimaryThread" -> true;
|
||||
|
||||
@@ -296,6 +296,13 @@ class ManualCraftingControllerNativeTest {
|
||||
actor.onSync = () -> { };
|
||||
controller.onCraft(actor.fixture.event(ClickType.DROP, InventoryAction.DROP_ONE_SLOT));
|
||||
assertEquals(2, records.size(), "An exception must release the reservation for a separate new intent");
|
||||
// close() intentionally does not wait for disk work. Drain this new attempt while the
|
||||
// store is open, before the next scenario reuses its directory or JUnit removes it.
|
||||
var retry = tasks.poll(5, TimeUnit.SECONDS); assertNotNull(retry);
|
||||
controller.onClose(new org.bukkit.event.inventory.InventoryCloseEvent(actor.fixture.view));
|
||||
assertTrue(storage.find(records.getLast().id()).join().isEmpty());
|
||||
retry.run();
|
||||
assertEquals(1, problems.size());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.progression.CharacterProgress;
|
||||
import games.dmg.fantasystick.progression.ProgressionRules;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.NativeHealthLimit;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.util.UUID;
|
||||
import net.minecraft.world.entity.ai.attributes.Attributes;
|
||||
import net.minecraft.world.entity.ai.attributes.RangedAttribute;
|
||||
import org.bukkit.attribute.Attribute;
|
||||
import org.bukkit.craftbukkit.attribute.CraftAttributeInstance;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class NativeHealthLimitTest {
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test
|
||||
void invalidNativeRangesFailExplicitlyRatherThanGuessingOrChangingTheRuntime() {
|
||||
var range = (RangedAttribute) Attributes.MAX_HEALTH.value();
|
||||
double original = range.getMaxValue();
|
||||
try {
|
||||
for (double invalid : new double[] {0, -1, Double.NaN, Double.POSITIVE_INFINITY,
|
||||
Double.NEGATIVE_INFINITY, Double.MAX_VALUE, Double.MIN_VALUE}) {
|
||||
range.maxValue = invalid;
|
||||
assertThrows(IllegalStateException.class, NativeHealthLimit::read);
|
||||
assertEquals(invalid, range.getMaxValue());
|
||||
}
|
||||
} finally { range.maxValue = original; }
|
||||
assertEquals(0, BigDecimal.valueOf(original).compareTo(NativeHealthLimit.read()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsTheActualNativeCeilingRatherThanGuessingTheDefaultAndFeedsCappedProgression() {
|
||||
var range = (RangedAttribute) Attributes.MAX_HEALTH.value();
|
||||
double original = range.getMaxValue();
|
||||
try {
|
||||
// Isolated non-listening test runtime only. Never write an operator's config file.
|
||||
for (double ceiling : new double[] {10.5, 1024, 2048}) {
|
||||
range.maxValue = ceiling;
|
||||
BigDecimal limit = NativeHealthLimit.read();
|
||||
assertEquals(0, BigDecimal.valueOf(ceiling).compareTo(limit));
|
||||
assertEquals(ceiling, range.getMaxValue(), "Reading does not modify the native range");
|
||||
var progress = new CharacterProgress(UUID.randomUUID(),
|
||||
new ProgressionRules(BigInteger.valueOf(3000), BigInteger.valueOf(100), 30), limit);
|
||||
progress.recordHealth(new BigDecimal("6.25"));
|
||||
var handle = new net.minecraft.world.entity.ai.attributes.AttributeInstance(Attributes.MAX_HEALTH, ignored -> { });
|
||||
var attribute = new CraftAttributeInstance(handle, Attribute.MAX_HEALTH);
|
||||
for (int index = 0; index < 2100; index++) {
|
||||
var before = progress.snapshot();
|
||||
assertEquals(CharacterProgress.Purchase.APPLIED,
|
||||
progress.buyStat(before.characterId(), before.revision(), Stat.CON));
|
||||
var after = progress.snapshot();
|
||||
attribute.setBaseValue(after.maximumHealth().doubleValue());
|
||||
assertEquals(after.maximumHealth().doubleValue(), attribute.getValue());
|
||||
assertEquals(0, new BigDecimal("3.75").compareTo(after.maximumHealth().subtract(after.currentHealth())));
|
||||
}
|
||||
assertEquals(BigInteger.valueOf(2100), progress.snapshot().stats().get(Stat.CON));
|
||||
assertEquals(0, limit.compareTo(progress.snapshot().maximumHealth()));
|
||||
}
|
||||
} finally { range.maxValue = original; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class CharacterRenameTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test
|
||||
void renamesAnExistingLiveCharacterWithoutChangingAnyProgressAndDoesNotRewriteTheSameName() throws Exception {
|
||||
var original = live();
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
repository.create(original);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
var result = storage.renameCharacter(original.id(), "Mira of Dawn — 雪").join().orElseThrow();
|
||||
var expected = YamlCharacterRepositoryTest.rename(original, "Mira of Dawn — 雪", BigInteger.ONE);
|
||||
assertEquals(expected, result);
|
||||
assertEquals(expected, storage.find(original.id()).join().orElseThrow());
|
||||
assertEquals(expected, storage.renameCharacter(original.id(), expected.name()).join().orElseThrow());
|
||||
}
|
||||
assertEquals("Mira of Dawn — 雪", new YamlCharacterRepository(data).load(original.id()).orElseThrow().name());
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingAndDeadReferencesCannotCreateRenameOrRestoreCharacters() throws Exception {
|
||||
var dead = YamlCharacterRepositoryTest.sample(UUID.randomUUID());
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
repository.create(dead);
|
||||
var file = data.resolve("characters").resolve(dead.id() + ".yml");
|
||||
var bytes = java.nio.file.Files.readAllBytes(file);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
var missing = UUID.randomUUID();
|
||||
assertTrue(storage.renameCharacter(missing, "Counterfeit").join().isEmpty());
|
||||
assertTrue(storage.find(missing).join().isEmpty());
|
||||
assertTrue(storage.renameCharacter(dead.id(), "Not a revival").join().isEmpty());
|
||||
assertTrue(storage.renameCharacter(dead.id(), dead.name()).join().isEmpty());
|
||||
assertEquals(dead, storage.find(dead.id()).join().orElseThrow());
|
||||
}
|
||||
assertArrayEquals(bytes, java.nio.file.Files.readAllBytes(file));
|
||||
}
|
||||
|
||||
@Test
|
||||
void renameRunsOnTheWorkerAndAcknowledgesOnlyAfterTheReplacement() throws Exception {
|
||||
var original = live();
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
repository.create(original);
|
||||
var entered = new java.util.concurrent.CountDownLatch(1);
|
||||
var release = new java.util.concurrent.CountDownLatch(1);
|
||||
var caller = Thread.currentThread();
|
||||
var gated = new YamlCharacterRepository(data, (source, target) -> {
|
||||
assertNotSame(caller, Thread.currentThread());
|
||||
entered.countDown();
|
||||
try {
|
||||
if (!release.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new java.io.IOException("Test gate timed out");
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt(); throw new java.io.IOException(exception);
|
||||
}
|
||||
java.nio.file.Files.move(source, target, java.nio.file.StandardCopyOption.ATOMIC_MOVE,
|
||||
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||
});
|
||||
try (var storage = new CharacterStorage(gated, Clock.systemUTC())) {
|
||||
var pending = storage.renameCharacter(original.id(), "After acknowledgment");
|
||||
try {
|
||||
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
assertFalse(pending.isDone());
|
||||
assertEquals(original, repository.load(original.id()).orElseThrow());
|
||||
} finally { release.countDown(); }
|
||||
assertEquals("After acknowledgment", pending.join().orElseThrow().name());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedReplacementLeavesTheOriginalRecordAndCanBeRetriedWithoutRecreatingIt() throws Exception {
|
||||
var original = live();
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
repository.create(original);
|
||||
var broken = new YamlCharacterRepository(data, (source, target) -> { throw new java.io.IOException("Simulated rename failure"); });
|
||||
try (var storage = new CharacterStorage(broken, Clock.systemUTC())) {
|
||||
assertThrows(java.util.concurrent.CompletionException.class, () -> storage.renameCharacter(original.id(), "Uncommitted").join());
|
||||
assertEquals(original, repository.load(original.id()).orElseThrow());
|
||||
}
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
assertEquals(YamlCharacterRepositoryTest.rename(original, "Saved later", BigInteger.ONE),
|
||||
storage.renameCharacter(original.id(), "Saved later").join().orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void orderedRenamesUseCurrentProgressAndADeathPreventsLaterStaleSheetRenames() throws Exception {
|
||||
var original = live();
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
repository.create(original);
|
||||
var current = new CharacterRecord(original.id(), original.name(), BigInteger.ONE, original.stats(), original.level(),
|
||||
original.experience(), original.unspentPoints(), new BigDecimal("7.25"), original.learned(), original.loadout(),
|
||||
original.cooldowns(), false, original.deathId(), original.diedAt());
|
||||
repository.replace(current, original.revision());
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
var first = storage.renameCharacter(original.id(), "First");
|
||||
var second = storage.renameCharacter(original.id(), "Second");
|
||||
var death = storage.recordDeath(original.id(), BigInteger.valueOf(3));
|
||||
var stale = storage.renameCharacter(original.id(), "Stale copy");
|
||||
assertEquals(YamlCharacterRepositoryTest.rename(current, "First", BigInteger.TWO), first.join().orElseThrow());
|
||||
assertEquals(YamlCharacterRepositoryTest.rename(current, "Second", BigInteger.valueOf(3)), second.join().orElseThrow());
|
||||
assertTrue(death.join().orElseThrow().dead());
|
||||
assertEquals("Second", death.join().orElseThrow().name());
|
||||
assertTrue(stale.join().isEmpty());
|
||||
assertEquals(death.join(), storage.find(original.id()).join());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void shutdownDrainsAnAcceptedRenameButRejectsNewRenameWork() throws Exception {
|
||||
var original = live();
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
repository.create(original);
|
||||
var storage = new CharacterStorage(repository, Clock.systemUTC());
|
||||
try {
|
||||
var accepted = storage.renameCharacter(original.id(), "Accepted before disable");
|
||||
storage.close();
|
||||
assertEquals("Accepted before disable", accepted.join().orElseThrow().name());
|
||||
assertThrows(java.util.concurrent.CompletionException.class,
|
||||
() -> storage.renameCharacter(original.id(), "After disable").join());
|
||||
assertEquals("Accepted before disable", repository.load(original.id()).orElseThrow().name());
|
||||
} finally { storage.close(); }
|
||||
}
|
||||
|
||||
private static CharacterRecord live() {
|
||||
var original = YamlCharacterRepositoryTest.sample(UUID.randomUUID());
|
||||
return new CharacterRecord(original.id(), original.name(), original.revision(), original.stats(), original.level(),
|
||||
original.experience(), original.unspentPoints(), new BigDecimal("12.25"), original.learned(), original.loadout(),
|
||||
original.cooldowns(), false, original.deathId(), original.diedAt());
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,55 @@ class CharacterProgressTest {
|
||||
character.buyStat(before.characterId(), before.revision(), stat));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nativeHealthCeilingDoesNotCapConOrTurnFurtherPurchasesIntoHealing() {
|
||||
var character = new CharacterProgress(UUID.randomUUID(),
|
||||
new ProgressionRules(integer(2000), integer(100), 30));
|
||||
for (int i = 0; i < 1013; i++) buy(character, Stat.CON);
|
||||
character.recordHealth(new BigDecimal("1019.25")); // 3.75 health points missing
|
||||
buy(character, Stat.CON);
|
||||
assertEquals(new BigDecimal("1024"), character.snapshot().maximumHealth());
|
||||
assertEquals(new BigDecimal("1020.25"), character.snapshot().currentHealth());
|
||||
var before = character.snapshot();
|
||||
buy(character, Stat.CON);
|
||||
var capped = character.snapshot();
|
||||
assertEquals(new BigDecimal("1024"), capped.maximumHealth());
|
||||
assertEquals(before.currentHealth(), capped.currentHealth());
|
||||
assertEquals(integer(1015), capped.stats().get(Stat.CON));
|
||||
assertEquals(before.unspentPoints().subtract(BigInteger.ONE), capped.unspentPoints());
|
||||
assertEquals(CharacterProgress.Purchase.STALE_REVISION,
|
||||
character.buyStat(before.characterId(), before.revision(), Stat.CON));
|
||||
assertEquals(capped, character.snapshot());
|
||||
assertThrows(IllegalArgumentException.class, () -> character.recordHealth(new BigDecimal("1024.01")));
|
||||
assertEquals(capped, character.snapshot());
|
||||
}
|
||||
|
||||
@Test
|
||||
void suppliedNativeLimitAllowsPartialConGainAndZeroGainOnceReached() {
|
||||
var character = new CharacterProgress(UUID.randomUUID(), ProgressionRules.defaults(), new BigDecimal("10.5"));
|
||||
character.recordHealth(new BigDecimal("6.25"));
|
||||
buy(character, Stat.CON);
|
||||
assertEquals(new BigDecimal("10.5"), character.snapshot().maximumHealth());
|
||||
assertEquals(new BigDecimal("6.75"), character.snapshot().currentHealth());
|
||||
buy(character, Stat.CON);
|
||||
assertEquals(new BigDecimal("6.75"), character.snapshot().currentHealth());
|
||||
assertEquals(integer(2), character.snapshot().stats().get(Stat.CON));
|
||||
var low = new CharacterProgress(UUID.randomUUID(), ProgressionRules.defaults(), new BigDecimal("5"));
|
||||
assertEquals(new BigDecimal("5"), low.snapshot().maximumHealth());
|
||||
assertEquals(new BigDecimal("5"), low.snapshot().currentHealth());
|
||||
var higher = new CharacterProgress(UUID.randomUUID(),
|
||||
new ProgressionRules(integer(2000), integer(100), 30), new BigDecimal("2048"));
|
||||
for (int i = 0; i < 1015; i++) buy(higher, Stat.CON);
|
||||
assertEquals(new BigDecimal("1025"), higher.snapshot().maximumHealth());
|
||||
assertEquals(new BigDecimal("1025"), higher.snapshot().currentHealth());
|
||||
for (String invalid : new String[] {"0", "-1"}) {
|
||||
assertThrows(IllegalArgumentException.class, () -> new CharacterProgress(UUID.randomUUID(),
|
||||
ProgressionRules.defaults(), new BigDecimal(invalid)));
|
||||
}
|
||||
assertThrows(NullPointerException.class, () -> new CharacterProgress(UUID.randomUUID(),
|
||||
ProgressionRules.defaults(), null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void conAddsItsGainToBothPoolsPreservingTheFiveOfSevenToSevenOfNineHeartExample() {
|
||||
var character = new CharacterProgress(UUID.randomUUID(), ProgressionRules.defaults());
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.crafting.CharacterFactory;
|
||||
import games.dmg.fantasystick.progression.ProgressionRules;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class SessionClaimsTest {
|
||||
@Test
|
||||
void releasingAllowsTransferAndSwitchingButAStaleReleaseCannotClearTheReplacement() {
|
||||
var factory = new CharacterFactory();
|
||||
var first = factory.create(ProgressionRules.defaults());
|
||||
var second = factory.create(ProgressionRules.defaults());
|
||||
var a = UUID.randomUUID();
|
||||
var b = UUID.randomUUID();
|
||||
try (var claims = new SessionClaims()) {
|
||||
var old = claims.tryClaim(a, first).orElseThrow();
|
||||
old.close();
|
||||
assertFalse(old.isCurrent());
|
||||
var transferred = claims.tryClaim(b, first).orElseThrow();
|
||||
assertNotEquals(old.sessionId(), transferred.sessionId());
|
||||
var switched = claims.tryClaim(a, second).orElseThrow();
|
||||
old.close(); old.close();
|
||||
assertTrue(transferred.isCurrent());
|
||||
assertTrue(switched.isCurrent());
|
||||
assertTrue(claims.tryClaim(a, first).isEmpty());
|
||||
transferred.close();
|
||||
switched.close();
|
||||
var resumed = claims.tryClaim(a, first).orElseThrow();
|
||||
transferred.close();
|
||||
assertTrue(resumed.isCurrent());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void deadOrZeroHealthRecordsAndInvalidArgumentsCannotReserveEitherIdentity() {
|
||||
var live = new CharacterFactory().create(ProgressionRules.defaults());
|
||||
var player = UUID.randomUUID();
|
||||
var dead = new games.dmg.fantasystick.persistence.CharacterRecord(live.id(), live.name(), live.revision(),
|
||||
live.stats(), live.level(), live.experience(), live.unspentPoints(), java.math.BigDecimal.TEN,
|
||||
live.learned(), live.loadout(), live.cooldowns(), true, UUID.randomUUID(), java.time.Instant.EPOCH);
|
||||
var empty = new games.dmg.fantasystick.persistence.CharacterRecord(live.id(), live.name(), live.revision(),
|
||||
live.stats(), live.level(), live.experience(), live.unspentPoints(), java.math.BigDecimal.ZERO,
|
||||
live.learned(), live.loadout(), live.cooldowns(), false, null, null);
|
||||
try (var claims = new SessionClaims()) {
|
||||
assertTrue(claims.tryClaim(player, dead).isEmpty());
|
||||
assertTrue(claims.tryClaim(player, empty).isEmpty());
|
||||
assertThrows(NullPointerException.class, () -> claims.tryClaim(null, live));
|
||||
assertThrows(NullPointerException.class, () -> claims.tryClaim(player, null));
|
||||
assertTrue(claims.tryClaim(player, live).orElseThrow().isCurrent());
|
||||
assertTrue(dead.dead());
|
||||
assertEquals(java.math.BigDecimal.ZERO, empty.health());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("try")
|
||||
void shutdownInvalidatesAllTokensAndDoesNotPretendToPersistOwnershipAcrossRestart() {
|
||||
var factory = new CharacterFactory();
|
||||
var first = factory.create(ProgressionRules.defaults());
|
||||
var second = factory.create(ProgressionRules.defaults());
|
||||
var a = UUID.randomUUID();
|
||||
var b = UUID.randomUUID();
|
||||
try (var claims = new SessionClaims()) {
|
||||
var old = claims.tryClaim(a, first).orElseThrow();
|
||||
var other = claims.tryClaim(b, second).orElseThrow();
|
||||
claims.close(); claims.close();
|
||||
assertFalse(old.isCurrent());
|
||||
assertFalse(other.isCurrent());
|
||||
assertTrue(claims.tryClaim(a, first).isEmpty());
|
||||
try (var restarted = new SessionClaims()) {
|
||||
var fresh = restarted.tryClaim(b, first).orElseThrow();
|
||||
old.close(); other.close();
|
||||
assertTrue(fresh.isCurrent());
|
||||
assertNotEquals(old.sessionId(), fresh.sessionId());
|
||||
// This only establishes an empty process-local registry, not recovery of native equipment/state.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void competingCallbacksHaveOneWinnerForEitherSharedCharacterOrSharedPlayer() throws Exception {
|
||||
var factory = new CharacterFactory();
|
||||
for (boolean sharedCharacter : new boolean[] {true, false}) {
|
||||
for (int round = 0; round < 12; round++) {
|
||||
var character = factory.create(ProgressionRules.defaults());
|
||||
var player = UUID.randomUUID();
|
||||
var ready = new java.util.concurrent.CountDownLatch(16);
|
||||
var start = new java.util.concurrent.CountDownLatch(1);
|
||||
try (var claims = new SessionClaims(); var workers = java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor()) {
|
||||
var pending = new java.util.ArrayList<java.util.concurrent.Future<java.util.Optional<SessionClaims.Claim>>>();
|
||||
for (int index = 0; index < 16; index++) pending.add(workers.submit(() -> {
|
||||
ready.countDown();
|
||||
if (!start.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new AssertionError("Start gate timed out");
|
||||
return claims.tryClaim(sharedCharacter ? UUID.randomUUID() : player,
|
||||
sharedCharacter ? character : factory.create(ProgressionRules.defaults()));
|
||||
}));
|
||||
assertTrue(ready.await(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
start.countDown();
|
||||
var won = new java.util.ArrayList<SessionClaims.Claim>();
|
||||
for (var future : pending) future.get(5, java.util.concurrent.TimeUnit.SECONDS).ifPresent(won::add);
|
||||
assertEquals(1, won.size());
|
||||
assertTrue(won.getFirst().isCurrent());
|
||||
won.getFirst().close();
|
||||
assertFalse(won.getFirst().isCurrent());
|
||||
} finally { start.countDown(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void identityNotNameControlsExclusionAndNoGameplayStatOrNativeHealthCapIsAdded() {
|
||||
var original = new CharacterFactory().create(ProgressionRules.defaults());
|
||||
var stats = new java.util.EnumMap<games.dmg.fantasystick.progression.Stat, java.math.BigInteger>(original.stats());
|
||||
stats.put(games.dmg.fantasystick.progression.Stat.CON, java.math.BigInteger.TEN.pow(100));
|
||||
var health = new java.math.BigDecimal(stats.get(games.dmg.fantasystick.progression.Stat.CON).add(java.math.BigInteger.TEN));
|
||||
var large = new games.dmg.fantasystick.persistence.CharacterRecord(original.id(), "Shared name", original.revision(),
|
||||
stats, original.level(), original.experience(), original.unspentPoints(), health,
|
||||
original.learned(), original.loadout(), original.cooldowns(), false, null, null);
|
||||
var independent = new games.dmg.fantasystick.persistence.CharacterRecord(UUID.randomUUID(), large.name(), large.revision(),
|
||||
large.stats(), large.level(), large.experience(), large.unspentPoints(), large.health(),
|
||||
large.learned(), large.loadout(), large.cooldowns(), false, null, null);
|
||||
var renamedCopy = new games.dmg.fantasystick.persistence.CharacterRecord(large.id(), "Different name", java.math.BigInteger.ONE,
|
||||
large.stats(), large.level(), large.experience(), large.unspentPoints(), large.health(),
|
||||
large.learned(), large.loadout(), large.cooldowns(), false, null, null);
|
||||
try (var claims = new SessionClaims()) {
|
||||
var held = claims.tryClaim(UUID.randomUUID(), large).orElseThrow();
|
||||
assertTrue(claims.tryClaim(UUID.randomUUID(), independent).isPresent());
|
||||
assertTrue(claims.tryClaim(UUID.randomUUID(), renamedCopy).isEmpty());
|
||||
assertTrue(held.isCurrent());
|
||||
assertEquals(health, large.health());
|
||||
assertEquals(java.math.BigInteger.TEN.pow(100), large.stats().get(games.dmg.fantasystick.progression.Stat.CON));
|
||||
// This registry does not project these values into native attributes or grant permission to activate them.
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void excludesOverlappingCharactersAndPlayerModesWithoutRestrictingStoredCharacterAcquisition() {
|
||||
var factory = new CharacterFactory();
|
||||
var first = factory.create(ProgressionRules.defaults());
|
||||
var second = factory.create(ProgressionRules.defaults());
|
||||
var a = UUID.randomUUID();
|
||||
var b = UUID.randomUUID();
|
||||
try (var claims = new SessionClaims()) {
|
||||
var held = claims.tryClaim(a, first).orElseThrow();
|
||||
assertEquals(first.id(), held.characterId());
|
||||
assertEquals(a, held.playerId());
|
||||
assertNotNull(held.sessionId());
|
||||
assertTrue(held.isCurrent());
|
||||
assertTrue(claims.tryClaim(a, first).isEmpty(), "Repeated entry is not a second session");
|
||||
assertTrue(claims.tryClaim(b, first).isEmpty(), "Copied references cannot activate the same character twice");
|
||||
assertTrue(claims.tryClaim(a, second).isEmpty(), "A sheet switch must finish exiting before claiming another active mode");
|
||||
var independent = claims.tryClaim(b, second).orElseThrow();
|
||||
assertTrue(independent.isCurrent());
|
||||
assertNotEquals(held.sessionId(), independent.sessionId());
|
||||
// Both records exist independently; this limits active/pending sessions, not sheets a player can own.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user