Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9771d1e7c3 |
No files matched your search
@@ -101,6 +101,12 @@ 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 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.
|
||||
|
||||
## 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
|
||||
|
||||
`EquipmentStorage` preflights and moves nine hotbar/four armor slots into an actual named shulker in one free main-inventory slot. Item counts, metadata/damage/enchantments and legal nested contents remain native item data, accessible without this plugin. Eligibility is tested against the real vanilla shulker slot for every nonlegacy native item material; a main-inventory shulker does not prevent entry. Oversized or ineligible equipment and missing space produce unchanged refusals.
|
||||
|
||||
@@ -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,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