Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d10e804820 | ||
|
|
7cf91baca4 |
No files matched your search
@@ -37,19 +37,23 @@ File-level atomic replacement is **not** atomicity with Minecraft inventory/play
|
||||
|
||||
Abandonment skips remaining queued writes and schedules cleanup for acknowledged, never-issued creations. Cleanup checks the original file identity/creation and modification attributes and exact bytes under the repository lock. Changed, replaced, corrupt, symlinked, busy or unidentifiable files are retained. Partial-save failure cleans only acknowledged creations and reports uncertain targets without deleting them. Shutdown rejection preserves records rather than inventing successful cleanup. Repeated cleanup cannot delete a later file. Restart never reconstructs cleanup authority from revision zero or scans records for automatic deletion/reissue.
|
||||
|
||||
Thirteen craft-persistence tests cover these backend boundaries, including worker timing, partial/post-rename failures, ownership changes, restart, shutdown, claim/abandon races, and mutated/cancelled observer futures. This is **not native inventory delivery**: the controller still must contain vanilla consumption, validate current view/lifecycle/content and commit actual items. Filesystem locks coordinate cooperating writers; concurrent external filesystem mutation must be excluded/coordinated. File flush/atomic rename does not establish directory power-loss durability or Minecraft/plugin-save atomicity. Empty coordination `.lock` files remain after YAML cleanup; they must not be unlinked casually because that can split locking between writers. Their file-count growth still needs a bounded coordination design before gameplay registration.
|
||||
Thirteen craft-persistence tests cover these backend boundaries, including worker timing, partial/post-rename failures, ownership changes, restart, shutdown, claim/abandon races, and mutated/cancelled observer futures. This is **not native inventory delivery**: the controller still must contain vanilla consumption, validate current view/lifecycle/content and commit actual items. Filesystem locks coordinate cooperating writers; concurrent external filesystem mutation must be excluded/coordinated. File flush/atomic rename does not establish directory power-loss durability or Minecraft/plugin-save atomicity. All create/update/cleanup mutations now share one fail-fast `characters/.records.lock` coordination file, so abandoning new characters does not accumulate one lock file per ID. Read-only atomic-file loads remain unlocked. The file stays present but its OS lock is held only for an operation, not an administrator-unlock flag. Never unlink it while writers may use it. Old per-ID lock files are left intact; no new ones are created.
|
||||
|
||||
**Upgrade boundary:** stop all older per-ID-protocol writers before opening the same data directory with this shared-lock implementation. Mixing versions or hot-reloading while old worker writes remain queued is unsafe. YAML format and record filenames are unchanged; no deployment or existing-server data migration has been performed. Contention between cooperating repository instances fails without mutation and can be retried, rather than blocking a server tick or deleting coordination files.
|
||||
|
||||
## 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.
|
||||
|
||||
`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 actual craft-event controller is still absent.
|
||||
|
||||
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; register the guard with the eventual completed-crafting adapter before exposing the recipe. Its preview has no character metadata and allocates no identity/name. It is deliberately **not registered** yet: actual completion, bulk crafting, cancellation/save handling and anvil rename persistence still need their event adapters. Presentation edits preserving metadata are tested, not a claim that complete anvil transactions work.
|
||||
|
||||
## Manual craft planning (not delivery)
|
||||
|
||||
`ManualCraftPlan` reads real crafting inventories without mutation. It calculates one result for cursor/number/offhand clicks, distinct empty destination slots for shift-click, and single/bulk intent for Q/Ctrl-Q. It bounds quantity by ingredients and storage capacity, preserves native reverse storage order, rejects cancelled/non-craft/wrong-recipe/malformed input, and snapshots full item data rather than retaining mutable native stack mirrors. Returned leftovers are detached copies; `matches` detects changed inputs, result, recipe, cursor or planned destinations without overwriting intervening items.
|
||||
|
||||
This is **not a transaction commit**. Nine native planner tests use actual 2×2/3×3 crafting containers, player storage, menu-carried stacks and recipe holders, with the absent player/network boundary stubbed. They do not execute packet dispatch or vanilla result consumption. A controller still needs one-use lifecycle tokens (a native menu may be reused after close/reopen), name/ID allocation, asynchronous persistence, reentrant matrix-update handling and delivery. Drop intent is not proof that a cancellable native drop succeeds. No recipe/listener registration, recovery policy or cross-save atomicity is added by this planner.
|
||||
This is **not a transaction commit**. Ten native planner/composition tests use actual 2×2/3×3 crafting containers, player storage, menu-carried stacks and recipe holders, with the absent player/network boundary stubbed. They do not execute packet dispatch or vanilla result consumption. A controller still needs one-use lifecycle tokens (a native menu may be reused after close/reopen), wiring of name/ID allocation and asynchronous persistence, reentrant matrix-update handling and delivery. Drop intent is not proof that a cancellable native drop succeeds. No recipe/listener registration, recovery policy or cross-save atomicity is added by this planner.
|
||||
|
||||
## Native equipment adapter
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package games.dmg.fantasystick.crafting;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.progression.CharacterProgress;
|
||||
import games.dmg.fantasystick.progression.ProgressionRules;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.random.RandomGenerator;
|
||||
|
||||
/** Allocation only; invoke for an accepted craft, never for recipe previews or activation. */
|
||||
public final class CharacterFactory {
|
||||
private static final String[] GIVEN_NAMES = {"Ari", "Bryn", "Elin", "Finn", "Iris", "Kael", "Mira", "Neris",
|
||||
"Orin", "Rhea", "Soren", "Tala", "Vera", "Wren", "Yara", "Zev"};
|
||||
private static final String[] FAMILY_NAMES = {"Ash", "Birch", "Brook", "Dawn", "Ember", "Fern", "Flint", "Glen",
|
||||
"Hollow", "Lark", "Mist", "Reed", "Stone", "Thorn", "Vale", "Willow"};
|
||||
private final Supplier<UUID> identities;
|
||||
private final RandomGenerator names;
|
||||
|
||||
public CharacterFactory() { this(UUID::randomUUID, RandomGenerator.getDefault()); }
|
||||
|
||||
CharacterFactory(Supplier<UUID> identities, RandomGenerator names) {
|
||||
this.identities = Objects.requireNonNull(identities);
|
||||
this.names = Objects.requireNonNull(names);
|
||||
}
|
||||
|
||||
public synchronized CharacterRecord create(ProgressionRules rules) {
|
||||
Objects.requireNonNull(rules);
|
||||
var initial = new CharacterProgress(identities.get(), rules).snapshot();
|
||||
String name = GIVEN_NAMES[names.nextInt(GIVEN_NAMES.length)] + " "
|
||||
+ FAMILY_NAMES[names.nextInt(FAMILY_NAMES.length)];
|
||||
return new CharacterRecord(initial.characterId(), name, initial.revision(), initial.stats(),
|
||||
initial.level(), initial.experience(), initial.unspentPoints(), initial.currentHealth(),
|
||||
Set.of(), Collections.nCopies(7, ""), Map.of(), false, null, null);
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ public final class YamlCharacterRepository {
|
||||
Creation createTracked(CharacterRecord character) throws IOException {
|
||||
if (character.revision().signum() != 0) throw new IOException("New character revision must be zero");
|
||||
Creation[] created = new Creation[1];
|
||||
withLock(character.id(), () -> {
|
||||
withLock(() -> {
|
||||
if (Files.exists(file(character.id()), LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Character already exists: " + character.id());
|
||||
}
|
||||
@@ -71,7 +71,7 @@ public final class YamlCharacterRepository {
|
||||
if (!character.revision().equals(expectedRevision.add(BigInteger.ONE))) {
|
||||
throw new IOException("Character update must advance exactly one revision");
|
||||
}
|
||||
withLock(character.id(), () -> {
|
||||
withLock(() -> {
|
||||
CharacterRecord current = load(character.id()).orElseThrow(() -> new IOException("Character not found"));
|
||||
if (!current.revision().equals(expectedRevision)) throw new IOException("Stale character revision");
|
||||
write(character);
|
||||
@@ -81,7 +81,7 @@ public final class YamlCharacterRepository {
|
||||
/** Only the in-memory creator of a never-issued batch may request this cleanup. */
|
||||
boolean discardUnissued(Creation created) throws IOException {
|
||||
boolean[] removed = {false};
|
||||
withLock(created.record.id(), () -> {
|
||||
withLock(() -> {
|
||||
var path = file(created.record.id());
|
||||
if (Files.notExists(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
removed[0] = true;
|
||||
@@ -138,9 +138,10 @@ public final class YamlCharacterRepository {
|
||||
}
|
||||
}
|
||||
|
||||
private void withLock(UUID id, Operation operation) throws IOException {
|
||||
/** Bounded coordination for all mutations; never mix with older per-ID writers in this directory. */
|
||||
private void withLock(Operation operation) throws IOException {
|
||||
Files.createDirectories(directory);
|
||||
Path lockPath = directory.resolve(id + ".lock");
|
||||
Path lockPath = directory.resolve(".records.lock");
|
||||
try (var channel = FileChannel.open(lockPath, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
|
||||
LinkOption.NOFOLLOW_LINKS);
|
||||
var lock = channel.tryLock()) {
|
||||
|
||||
@@ -224,6 +224,43 @@ class ManualCraftPlanNativeTest {
|
||||
assertEquals(2, fixture.grid.getResult().getAmount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void plannedBatchCanPersistAndRenderDistinctNamedSheetsWithoutApplyingInventoryChanges(
|
||||
@org.junit.jupiter.api.io.TempDir java.nio.file.Path data) throws Exception {
|
||||
var fixture = new Fixture(3, 4);
|
||||
var plan = ManualCraftPlan.capture(fixture.event(ClickType.SHIFT_LEFT,
|
||||
InventoryAction.MOVE_TO_OTHER_INVENTORY)).orElseThrow();
|
||||
var factory = new games.dmg.fantasystick.crafting.CharacterFactory();
|
||||
var records = java.util.stream.IntStream.range(0, plan.quantity())
|
||||
.mapToObj(index -> factory.create(games.dmg.fantasystick.progression.ProgressionRules.defaults())).toList();
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
try (var storage = new games.dmg.fantasystick.persistence.CharacterStorage(repository, java.time.Clock.systemUTC())) {
|
||||
var batch = storage.prepareCraft(records).ready().join(); // Test thread, never the runtime tick thread.
|
||||
assertTrue(plan.matches(fixture.view));
|
||||
var saved = batch.claimForDelivery().orElseThrow();
|
||||
var identities = new java.util.HashSet<java.util.UUID>();
|
||||
var sheets = new CharacterSheets();
|
||||
var ops = net.minecraft.resources.RegistryOps.create(net.minecraft.nbt.NbtOps.INSTANCE,
|
||||
org.bukkit.craftbukkit.CraftRegistry.getMinecraftRegistry());
|
||||
for (var record : saved) {
|
||||
assertEquals(record, storage.find(record.id()).join().orElseThrow());
|
||||
var sheet = sheets.create(record);
|
||||
var encoded = net.minecraft.world.item.ItemStack.CODEC.encodeStart(ops,
|
||||
CraftItemStack.asNMSCopy(sheet)).getOrThrow();
|
||||
var decoded = CraftItemStack.asBukkitCopy(net.minecraft.world.item.ItemStack.CODEC.parse(ops, encoded).getOrThrow());
|
||||
assertTrue(identities.add(sheets.identify(decoded).orElseThrow()));
|
||||
assertEquals(net.kyori.adventure.text.Component.text(record.name()), decoded.getItemMeta().displayName());
|
||||
assertEquals(1, decoded.getAmount());
|
||||
assertEquals(record, repository.load(record.id()).orElseThrow());
|
||||
}
|
||||
assertEquals(plan.quantity(), identities.size());
|
||||
assertEquals(4, fixture.grid.getMatrix()[0].getAmount(), "This composition test does not commit a native craft");
|
||||
assertNull(fixture.inventory.getItem(8));
|
||||
assertTrue(sheets.identify(fixture.grid.getResult()).isEmpty());
|
||||
assertTrue(batch.claimForDelivery().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Fixture {
|
||||
final Menu menu = new Menu();
|
||||
final TransientCraftingContainer matrix;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package games.dmg.fantasystick.crafting;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.progression.ProgressionRules;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class CharacterFactoryTest {
|
||||
@Test
|
||||
void randomNamesVaryButNamesMayCollideWithoutSharingIdentity() {
|
||||
var first = new CharacterFactory(UUID::randomUUID, new Random(7));
|
||||
var replay = new CharacterFactory(UUID::randomUUID, new Random(7));
|
||||
var names = new java.util.HashSet<String>();
|
||||
var ids = new java.util.HashSet<UUID>();
|
||||
for (int index = 0; index < 64; index++) {
|
||||
var character = first.create(ProgressionRules.defaults());
|
||||
assertEquals(character.name(), replay.create(ProgressionRules.defaults()).name());
|
||||
assertTrue(ids.add(character.id()));
|
||||
assertEquals(BigInteger.valueOf(40), character.unspentPoints());
|
||||
names.add(character.name());
|
||||
}
|
||||
assertTrue(names.size() > 1, "Names must use the supplied randomness, not a constant placeholder");
|
||||
var collisions = new CharacterFactory(UUID::randomUUID, () -> 0L);
|
||||
var sameNameA = collisions.create(ProgressionRules.defaults());
|
||||
var sameNameB = collisions.create(ProgressionRules.defaults());
|
||||
assertEquals(sameNameA.name(), sameNameB.name(), "Name collisions do not require a retry or owner lock");
|
||||
assertNotEquals(sameNameA.id(), sameNameB.id());
|
||||
}
|
||||
|
||||
@Test
|
||||
void allocatesOnlyWhenRequestedAndUsesTheApprovedFreshStateAndConfiguredPoints() {
|
||||
var allocations = new AtomicInteger();
|
||||
var id = UUID.randomUUID();
|
||||
var factory = new CharacterFactory(() -> { allocations.incrementAndGet(); return id; }, new Random(17));
|
||||
assertEquals(0, allocations.get(), "Constructing a factory must not create a character");
|
||||
var rules = new ProgressionRules(BigInteger.TEN.pow(40), BigInteger.valueOf(100), 30);
|
||||
var record = factory.create(rules);
|
||||
assertNotNull(record, "An accepted allocation must produce a fresh named character");
|
||||
assertEquals(1, allocations.get());
|
||||
assertEquals(id, record.id());
|
||||
assertFalse(record.name().isBlank());
|
||||
assertEquals(BigInteger.ZERO, record.revision());
|
||||
assertEquals(6, record.stats().size());
|
||||
assertTrue(record.stats().values().stream().allMatch(BigInteger.ZERO::equals));
|
||||
assertEquals(1, record.level());
|
||||
assertEquals(BigInteger.ZERO, record.experience());
|
||||
assertEquals(rules.startingPoints(), record.unspentPoints());
|
||||
assertEquals(BigDecimal.TEN, record.health());
|
||||
assertEquals(Set.of(), record.learned());
|
||||
assertEquals(List.of("", "", "", "", "", "", ""), record.loadout());
|
||||
assertEquals(Map.of(), record.cooldowns());
|
||||
assertFalse(record.dead());
|
||||
assertNull(record.deathId());
|
||||
assertNull(record.diedAt());
|
||||
}
|
||||
}
|
||||
@@ -267,17 +267,18 @@ class CraftPersistenceTest {
|
||||
CharacterStorage.CraftCleanup cleanup;
|
||||
if (scenario == 3) {
|
||||
try (var channel = java.nio.channels.FileChannel.open(
|
||||
data.resolve("characters").resolve(protectedRecord.id() + ".lock"),
|
||||
data.resolve("characters").resolve(".records.lock"),
|
||||
java.nio.file.StandardOpenOption.WRITE);
|
||||
var lock = channel.lock()) {
|
||||
assertTrue(lock.isValid());
|
||||
cleanup = batch.abandon().join();
|
||||
}
|
||||
} else cleanup = batch.abandon().join();
|
||||
assertEquals(Set.of(protectedRecord.id()), cleanup.retained());
|
||||
assertEquals(Set.of(removable.id()), cleanup.removed());
|
||||
assertEquals(scenario == 3 ? Set.of(protectedRecord.id(), removable.id())
|
||||
: Set.of(protectedRecord.id()), cleanup.retained());
|
||||
assertEquals(scenario == 3 ? Set.of() : Set.of(removable.id()), cleanup.removed());
|
||||
assertTrue(java.nio.file.Files.exists(file, java.nio.file.LinkOption.NOFOLLOW_LINKS));
|
||||
assertTrue(storage.find(removable.id()).join().isEmpty());
|
||||
assertEquals(scenario != 3, storage.find(removable.id()).join().isEmpty());
|
||||
if (scenario == 2) {
|
||||
assertTrue(java.nio.file.Files.isSymbolicLink(file));
|
||||
assertTrue(java.nio.file.Files.isRegularFile(data.resolve("protected-target.yml")));
|
||||
|
||||
@@ -43,6 +43,67 @@ class YamlCharacterRepositoryTest {
|
||||
original.loadout(), original.cooldowns(), original.dead(), original.deathId(), original.diedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void repeatedCreationAndAbandonmentDoNotAccumulatePerCharacterCoordinationFiles() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
for (int index = 0; index < 96; index++) {
|
||||
var created = repository.createTracked(sample(UUID.randomUUID()));
|
||||
assertTrue(repository.discardUnissued(created));
|
||||
}
|
||||
try (var paths = Files.list(data.resolve("characters"))) {
|
||||
var remaining = paths.toList();
|
||||
assertTrue(remaining.size() <= 1, "Abandoned crafts must not leave one coordination file per generated ID");
|
||||
assertTrue(remaining.stream().allMatch(path -> path.getFileName().toString().endsWith(".lock")));
|
||||
}
|
||||
var legitimate = sample(UUID.randomUUID());
|
||||
repository.create(legitimate);
|
||||
assertEquals(legitimate, repository.load(legitimate.id()).orElseThrow());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sharedCoordinationRefusesEveryMutationWhileBusyAndPermitsRetryAfterRelease() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
var original = sample(UUID.randomUUID());
|
||||
var unissued = repository.createTracked(sample(UUID.randomUUID()));
|
||||
var candidate = sample(UUID.randomUUID());
|
||||
repository.create(original);
|
||||
var update = rename(original, "Retry after contention", BigInteger.ONE);
|
||||
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());
|
||||
assertThrows(IOException.class, () -> new YamlCharacterRepository(data).create(candidate));
|
||||
assertThrows(IOException.class, () -> repository.replace(update, BigInteger.ZERO));
|
||||
assertThrows(IOException.class, () -> repository.discardUnissued(unissued));
|
||||
assertEquals(original, repository.load(original.id()).orElseThrow());
|
||||
assertEquals(unissued.record, repository.load(unissued.record.id()).orElseThrow());
|
||||
assertTrue(repository.load(candidate.id()).isEmpty());
|
||||
}
|
||||
repository.create(candidate);
|
||||
repository.replace(update, BigInteger.ZERO);
|
||||
assertTrue(repository.discardUnissued(unissued));
|
||||
assertEquals(update, repository.load(original.id()).orElseThrow());
|
||||
}
|
||||
|
||||
@Test
|
||||
void leavesLegacyCoordinationFilesIntactAndRefusesASymlinkedSharedLock() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
var original = sample(UUID.randomUUID());
|
||||
repository.create(original);
|
||||
var legacy = data.resolve("characters").resolve(original.id() + ".lock");
|
||||
Files.writeString(legacy, "Legacy coordination fixture");
|
||||
repository.replace(rename(original, "Updated", BigInteger.ONE), BigInteger.ZERO);
|
||||
assertEquals("Legacy coordination fixture", Files.readString(legacy));
|
||||
var shared = data.resolve("characters/.records.lock");
|
||||
Files.delete(shared); // Test setup only: no writer is holding this fixture lock.
|
||||
var outside = data.resolve("outside-lock");
|
||||
Files.writeString(outside, "Protected outside fixture");
|
||||
Files.createSymbolicLink(shared, outside);
|
||||
assertThrows(IOException.class, () -> repository.create(sample(UUID.randomUUID())));
|
||||
assertEquals("Protected outside fixture", Files.readString(outside));
|
||||
assertTrue(Files.isSymbolicLink(shared));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatesExactlyTheExpectedRevisionAndNeverOverwritesWithAStaleCopy() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
|
||||
Reference in new issue
Block a user