feat(fantasy-stick): add administrator coin adjustments
Release / release (push) Canceled after 0s

This commit is contained in:
dmg committed 2026-10-06 18:33:38 -04:00
1 parent 715f3cc6ef
commit 7dc23161a3
21 files changed
+1812 -27

No files matched your search

@@ -225,6 +225,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
private java.util.concurrent.CompletionStage<Void> partyCapacityAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.SheetResourcePackEventBridge sheetResourcePackEvents;
private java.util.concurrent.CompletionStage<Void> sheetResourcePackAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.AdminCoinsEventBridge adminCoinsEvents;
private java.util.concurrent.CompletionStage<Void> adminCoinsAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.AdminExperienceEventBridge adminExperienceEvents;
private java.util.concurrent.CompletionStage<Void> adminExperienceAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.CharacterIdentityEventBridge identityAuditEvents;
@@ -740,6 +742,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
problem -> getLogger().log(Level.WARNING,
"Admin experience grant for character " + problem.character(), problem.cause()), particleDelivery);
admin.attachExperienceGrant(grantService);
var coinsService = new games.dmg.fantasystick.session.AdminCharacterCoins(characters, sessions, nextTick);
admin.attachCharacterCoins(coinsService);
admin.attachSwitchDelay(switchDelaySettings);
admin.attachRoleplaySettings(roleplaySettings);
admin.attachPartySettings(partySettings);
@@ -1015,6 +1019,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
failure -> getLogger().log(Level.WARNING, "Admin experience event intent failed", failure));
grantService.attachEvents(adminExperienceEvents);
adminCoinsEvents = new games.dmg.fantasystick.events.AdminCoinsEventBridge(eventLog::emit,
Clock.systemUTC(), getDataFolder().toPath(), sessions,
failure -> getLogger().log(Level.WARNING, "Admin coins event intent failed", failure));
coinsService.attachEvents(adminCoinsEvents);
var identitySessions = sessions;
identityAuditEvents = new games.dmg.fantasystick.events.CharacterIdentityEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
@@ -1158,6 +1166,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (failure != null) getLogger().log(Level.WARNING, "Previous sheet pack event intent drain failed", failure);
if (replaySheetPackEvents != null) replaySheetPackEvents.replay();
});
var replayCoinsEvents = adminCoinsEvents;
adminCoinsAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous admin coins intent drain failed", failure);
if (replayCoinsEvents != null) replayCoinsEvents.replay();
});
var replayGrantEvents = adminExperienceEvents;
adminExperienceAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous admin experience intent drain failed", failure);
@@ -1322,6 +1335,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingRoleplayEvents = roleplayAvailabilityEvents; roleplayAvailabilityEvents = null;
var closingPartyCapacityEvents = partyCapacityEvents; partyCapacityEvents = null;
var closingSheetPackEvents = sheetResourcePackEvents; sheetResourcePackEvents = null;
var closingCoinsEvents = adminCoinsEvents; adminCoinsEvents = null;
var closingGrantEvents = adminExperienceEvents; adminExperienceEvents = null;
var closingKnockbackEvents = stickKnockbackEvents; stickKnockbackEvents = null;
var closingHardMobXpEvents = hardMobXpAuditEvents; hardMobXpAuditEvents = null;
@@ -1688,6 +1702,14 @@ public final class FantasyStickPlugin extends JavaPlugin {
return null;
}).thenCompose(ignored -> closingSheetPackEvents.drained());
});
if (closingCoinsEvents != null) cleanup("admin coins event intent drain", () -> {
var storageDrained = closingCharacters == null ? java.util.concurrent.CompletableFuture.<Void>completedFuture(null)
: closingCharacters.drained();
adminCoinsAuditDrain = storageDrained.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Character drain before coins events failed", failure);
closingCoinsEvents.close(); return null;
}).thenCompose(ignored -> closingCoinsEvents.drained());
});
if (closingGrantEvents != null) cleanup("admin experience event intent drain", () -> {
var storageDrained = closingCharacters == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null)
@@ -169,6 +169,9 @@ public final class AdminActionEventBridge implements AutoCloseable {
Map<String, Object> safeArgs = Map.of();
if (permitted && valid && (route.equals("inspect") || route.equals("recover"))) {
safeArgs = Map.of("player_id", UUID.fromString(args[1]).toString());
} else if (permitted && valid && route.equals("character") && args[1].equals("coins")) {
safeArgs = Map.of("character_id", UUID.fromString(args[2]).toString(),
"delta", new java.math.BigInteger(args[3]).toString());
} else if (permitted && valid && route.equals("config")
&& configurationKeys.contains(args[1])) {
var typed = new LinkedHashMap<String, Object>();
@@ -200,6 +203,12 @@ public final class AdminActionEventBridge implements AutoCloseable {
if (route.equals("adventure")) return (length == 2 || length == 3)
&& args[1].equals("reroll") && (length == 2 || args[2].equals("confirm"));
if (length < 2) return false;
if (route.equals("character") && args[1].equals("coins")) {
if (length != 4 || !args[3].matches("[+-]?[0-9]+")) return false;
try { return UUID.fromString(args[2]).toString().equalsIgnoreCase(args[2])
&& new java.math.BigInteger(args[3]).signum() != 0; }
catch (IllegalArgumentException invalid) { return false; }
}
return switch (route) {
case "character" -> Set.of("find", "show", "recover", "view").contains(args[1]);
case "adventure-site" -> Set.of("list", "place", "fishing", "mode", "remove", "teleport").contains(args[1]);
@@ -0,0 +1,72 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletionStage;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
/** Independent feature outbox: only acknowledged administrator balance adjustments. */
public final class AdminCoinsEventBridge implements AutoCloseable {
public record Intent(UUID operation, UUID action, Instant time, String actorKind, EventInput.Context actor) { }
private final Clock clock;
private final EquipmentSessions sessions;
private final FeatureEventOutbox outbox;
public AdminCoinsEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
Clock clock, Path data, EquipmentSessions sessions, Consumer<Throwable> failures) {
this.clock = clock; this.sessions = sessions;
outbox = new FeatureEventOutbox(data, "admin-coins-event-intents", Set.of("character.coins_adjusted"), sink, failures);
}
public Intent capture(CommandSender sender, UUID action) {
EventInput.Context actor = null;
if (sender instanceof Player player) {
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
EventInput.Location location = null;
try {
var place = player.getLocation();
if (place != null && place.getWorld() != null) location = new EventInput.Location(place.getWorld().getUID(),
place.getWorld().getName(), place.getX(), place.getY(), place.getZ(), clock.instant(), "exact");
} catch (RuntimeException unavailable) { /* Optional actor location is never mutation authority. */ }
actor = new EventInput.Context(player.getUniqueId(), player.getName(), record == null ? null : record.id(),
record == null ? null : record.name(), active == null ? null : active.session(), location);
}
return new Intent(UUID.randomUUID(), action, clock.instant(), sender instanceof Player ? "player" : "console", actor);
}
public void adjusted(Intent intent, CharacterRecord before, CharacterRecord after, BigInteger delta,
String targetState, String sync) {
var values = new LinkedHashMap<String, Object>();
values.put("operation_id", intent.operation().toString());
if (intent.action() != null) values.put("admin_action_id", intent.action().toString());
values.put("actor_kind", intent.actorKind());
values.put("target_character_id", after.id().toString()); values.put("target_character_name", after.name());
values.put("delta", delta.toString());
values.put("before_coins", before.coins().toString()); values.put("after_coins", after.coins().toString());
values.put("before_revision", before.revision().toString()); values.put("after_revision", after.revision().toString());
values.put("target_state", targetState); values.put("session_sync", sync);
var location = intent.actor() == null ? null : intent.actor().location();
values.put("location_status", intent.actor() == null ? "not_applicable" : location == null ? "unavailable" : "captured");
if (location != null) values.put("location", Map.of("world_id", location.worldId().toString(),
"world_name", location.worldName(), "x", location.x(), "y", location.y(), "z", location.z(),
"captured_at", location.capturedAt().toString(), "quality", location.quality()));
outbox.accepted(new EventInput(eventId(intent.operation()), intent.operation(), intent.time(),
"character.coins_adjusted", intent.actor(), values));
}
public static UUID eventId(UUID operation) {
return UUID.nameUUIDFromBytes(("fantasystick:admin-coins:" + operation).getBytes(StandardCharsets.UTF_8));
}
public void replay() { outbox.replay(); }
public CompletionStage<Void> drained() { return outbox.drained(); }
@Override public void close() { outbox.close(); }
}
@@ -0,0 +1,75 @@
package games.dmg.fantasystick.persistence;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.math.BigInteger;
import java.util.Optional;
import java.util.Properties;
import java.util.UUID;
/** Frozen operation proofs, independent of the bounded gameplay reward ledger. All access owns the records lock. */
final class AdminCoinOperationStore {
enum Phase { PREPARED, APPLIED, FAILED, UNCERTAIN }
record Proof(UUID operation, BigInteger delta, CharacterRecord before, CharacterRecord after, Phase phase) {
Proof phase(Phase value) { return new Proof(operation, delta, before, after, value); }
}
private final Path directory;
AdminCoinOperationStore(Path data) { directory = data.resolve("admin-coin-operations"); }
Optional<Proof> find(UUID operation) throws IOException {
var path = directory.resolve(operation + ".proof");
if (Files.notExists(path, LinkOption.NOFOLLOW_LINKS)) return Optional.empty();
if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe coin operation proof");
var fields = new Properties();
try (var input = Files.newInputStream(path, LinkOption.NOFOLLOW_LINKS)) {
var bytes = input.readNBytes(3_145_729);
if (bytes.length > 3_145_728) throw new IOException("Coin operation proof too large");
fields.load(new ByteArrayInputStream(bytes));
}
try {
if (!fields.getProperty("schema").equals("1")) throw new IllegalArgumentException();
var proof = new Proof(UUID.fromString(fields.getProperty("operation")), new BigInteger(fields.getProperty("delta")),
CharacterYaml.decode(fields.getProperty("before")), CharacterYaml.decode(fields.getProperty("after")),
Phase.valueOf(fields.getProperty("phase")));
var before = proof.before();
var receipts = new java.util.HashSet<>(before.rewardReceipts());
if (receipts.size() < 4096) receipts.add(operation);
var expected = new CharacterRecord(before.id(), before.name(), before.revision().add(BigInteger.ONE), before.stats(),
before.level(), before.experience(), before.unspentPoints(), before.health(), before.learned(), before.loadout(),
before.passives(), before.cooldowns(), before.restrictedKills(), before.dead(), before.deathId(), before.diedAt(),
before.coins().add(proof.delta()), before.coinsInitialized(), receipts, before.pvpCoinEscrow(), before.gear(), before.sheetGeneration());
if (!operation.equals(proof.operation()) || proof.delta().signum() == 0 || before.dead()
|| !before.coinsInitialized() || before.rewardReceipts().contains(operation)
|| !expected.equals(proof.after())) throw new IllegalArgumentException();
return Optional.of(proof);
} catch (RuntimeException invalid) { throw new IOException("Invalid coin operation proof"); }
}
void write(Proof proof) throws IOException {
if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS) && !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS))
throw new IOException("Unsafe coin operation directory");
Files.createDirectories(directory);
var fields = new Properties();
fields.setProperty("schema", "1"); fields.setProperty("operation", proof.operation().toString());
fields.setProperty("delta", proof.delta().toString()); fields.setProperty("phase", proof.phase().name());
fields.setProperty("before", CharacterYaml.encode(proof.before())); fields.setProperty("after", CharacterYaml.encode(proof.after()));
var bytes = new ByteArrayOutputStream(); fields.store(bytes, "frozen administrator coin operation");
var temporary = Files.createTempFile(directory, ".coins-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE)) {
var buffer = ByteBuffer.wrap(bytes.toByteArray());
while (buffer.hasRemaining()) output.write(buffer);
output.force(true);
}
Files.move(temporary, directory.resolve(proof.operation() + ".proof"), StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING);
DurableFiles.forceDirectory(directory);
} finally { Files.deleteIfExists(temporary); }
}
}
@@ -227,6 +227,94 @@ public final class CharacterStorage implements AutoCloseable {
}
/** Pure authoritative read for administration; unlike gameplay loading, this never performs legacy migration. */
public enum CoinAdjustmentStatus { APPLIED, DUPLICATE, STALE, UNAVAILABLE, BELOW_ZERO, FAILED, UNCERTAIN }
public record CoinAdjustment(CoinAdjustmentStatus status, Optional<CharacterRecord> before,
Optional<CharacterRecord> record) { }
/** Administrator arithmetic, not a combat reward; uses the same serialized write authority. */
public CompletableFuture<CoinAdjustment> adjustCoins(UUID id, BigInteger revision, UUID operation, BigInteger delta) {
return adjustCoins(id, revision, operation, delta, () -> true);
}
/** Acceptance guard reads only process authority, never native player state on the disk worker. */
public CompletableFuture<CoinAdjustment> adjustCoins(UUID id, BigInteger revision, UUID operation, BigInteger delta,
java.util.function.BooleanSupplier eligible) {
Objects.requireNonNull(id); Objects.requireNonNull(revision); Objects.requireNonNull(operation); Objects.requireNonNull(eligible);
if (Objects.requireNonNull(delta).signum() == 0) throw new IllegalArgumentException("Nonzero delta required");
return submit(() -> {
var replacementStarted = new java.util.concurrent.atomic.AtomicBoolean();
try { return repository.transaction(() -> {
var proofs = new AdminCoinOperationStore(repository.pluginDataDirectory());
var existing = proofs.find(operation);
if (existing.isPresent()) {
var proof = existing.orElseThrow();
if (!proof.before().id().equals(id) || !proof.delta().equals(delta))
return new CoinAdjustment(CoinAdjustmentStatus.UNAVAILABLE, Optional.empty(), Optional.empty());
if (proof.phase() == AdminCoinOperationStore.Phase.APPLIED) {
var current = repository.load(id).orElse(null);
if (current == null || current.revision().compareTo(proof.after().revision()) < 0
|| current.revision().equals(proof.after().revision()) && !current.equals(proof.after()))
return new CoinAdjustment(CoinAdjustmentStatus.FAILED, Optional.empty(), Optional.empty());
}
var status = switch (proof.phase()) {
case APPLIED -> CoinAdjustmentStatus.DUPLICATE;
case FAILED -> CoinAdjustmentStatus.FAILED;
case PREPARED, UNCERTAIN -> CoinAdjustmentStatus.UNCERTAIN;
};
return new CoinAdjustment(status, Optional.of(proof.before()), Optional.of(proof.after()));
}
var found = repository.load(id);
if (found.isEmpty() || found.orElseThrow().dead() || !found.orElseThrow().coinsInitialized()
|| !repository.mutationAvailable(id) || !eligible.getAsBoolean())
return new CoinAdjustment(CoinAdjustmentStatus.UNAVAILABLE, found, found);
var old = found.orElseThrow();
// A receipt without a frozen proof cannot authorize replay or fabricate an acknowledgment.
if (old.rewardReceipts().contains(operation))
return new CoinAdjustment(CoinAdjustmentStatus.UNAVAILABLE, found, found);
if (!old.revision().equals(revision)) return new CoinAdjustment(CoinAdjustmentStatus.STALE, found, found);
if (old.coins().add(delta).signum() < 0) return new CoinAdjustment(CoinAdjustmentStatus.BELOW_ZERO, found, found);
var receipts = new HashSet<>(old.rewardReceipts());
// The permanent proof is authoritative; never evict someone else's receipt or freeze gameplay at ledger capacity.
if (receipts.size() < 4096) receipts.add(operation);
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
old.level(), old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(),
old.passives(), old.cooldowns(), old.restrictedKills(), old.dead(), old.deathId(), old.diedAt(),
old.coins().add(delta), old.coinsInitialized(), receipts, old.pvpCoinEscrow(), old.gear(), old.sheetGeneration());
var proof = new AdminCoinOperationStore.Proof(operation, delta, old, saved, AdminCoinOperationStore.Phase.PREPARED);
try { proofs.write(proof); }
catch (IOException precommit) { return new CoinAdjustment(CoinAdjustmentStatus.FAILED, found, found); }
try {
// Repeat custody/session authority after durable preparation, immediately before replacement.
if (!repository.mutationAvailable(id) || !eligible.getAsBoolean()) {
proofs.write(proof.phase(AdminCoinOperationStore.Phase.FAILED));
return new CoinAdjustment(CoinAdjustmentStatus.UNAVAILABLE, found, found);
}
replacementStarted.set(true);
repository.replaceLocked(saved, old.revision(), true);
proofs.write(proof.phase(AdminCoinOperationStore.Phase.APPLIED));
return new CoinAdjustment(CoinAdjustmentStatus.APPLIED, found, Optional.of(saved));
} catch (IOException failure) {
boolean unchanged = false;
try { unchanged = repository.load(id).filter(old::equals).isPresent(); }
catch (IOException unreadable) { /* Cannot establish a definite noncommit. */ }
var status = unchanged ? CoinAdjustmentStatus.FAILED : CoinAdjustmentStatus.UNCERTAIN;
try { proofs.write(proof.phase(unchanged ? AdminCoinOperationStore.Phase.FAILED : AdminCoinOperationStore.Phase.UNCERTAIN)); }
catch (IOException retainedPreparation) { /* PREPARED remains fail-closed, never replayable. */ }
return new CoinAdjustment(status, found, unchanged ? found : Optional.empty());
}
}); } catch (IOException | RuntimeException failure) {
// Lookup/proof/lock failure before replacement is a proved noncommit, never a speculative uncertain write.
return new CoinAdjustment(replacementStarted.get() ? CoinAdjustmentStatus.UNCERTAIN : CoinAdjustmentStatus.FAILED,
Optional.empty(), Optional.empty());
}
}).exceptionally(failure -> {
var cause = failure instanceof CompletionException ? failure.getCause() : failure;
if (cause instanceof RejectedExecutionException)
return new CoinAdjustment(CoinAdjustmentStatus.FAILED, Optional.empty(), Optional.empty());
throw new CompletionException(failure); // Unexpected accepted-work failure remains conservative for its owner.
});
}
public CompletableFuture<Optional<CharacterRecord>> inspect(UUID id) {
return submit(() -> repository.load(Objects.requireNonNull(id)));
}
@@ -94,14 +94,26 @@ public final class YamlCharacterRepository {
if (!character.revision().equals(expectedRevision.add(BigInteger.ONE))) {
throw new IOException("Character update must advance exactly one revision");
}
withLock(() -> {
if (unavailable.test(character.id())) throw new IOException("Character is in dungeon boss custody");
if (enforceMutationAvailability && mutationUnavailable.test(character.id()))
throw new IOException("Character has unresolved recovery custody");
CharacterRecord current = load(character.id()).orElseThrow(() -> new IOException("Character not found"));
if (!current.revision().equals(expectedRevision)) throw new IOException("Stale character revision");
write(character);
});
withLock(() -> replaceLocked(character, expectedRevision, enforceMutationAvailability));
}
boolean mutationAvailable(UUID id) { return !unavailable.test(id) && !mutationUnavailable.test(id); }
/** Caller owns the same directory lock used by every character writer. */
void replaceLocked(CharacterRecord character, BigInteger expectedRevision, boolean enforceAvailability) throws IOException {
if (unavailable.test(character.id()) || enforceAvailability && mutationUnavailable.test(character.id()))
throw new IOException("Character custody is unresolved");
CharacterRecord current = load(character.id()).orElseThrow(() -> new IOException("Character not found"));
if (!current.revision().equals(expectedRevision)
|| !character.revision().equals(expectedRevision.add(BigInteger.ONE))) throw new IOException("Stale character revision");
write(character);
}
@FunctionalInterface interface Transaction<T> { T run() throws IOException; }
<T> T transaction(Transaction<T> operation) throws IOException {
var result = new java.util.concurrent.atomic.AtomicReference<T>();
withLock(() -> result.set(operation.run()));
return result.get();
}
/** Only the in-memory creator of a never-issued batch may request this cleanup. */
@@ -0,0 +1,141 @@
package games.dmg.fantasystick.session;
import games.dmg.fantasystick.events.AdminActionEventBridge;
import games.dmg.fantasystick.events.AdminCoinsEventBridge;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import java.math.BigInteger;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
/** Full-UUID request owner. Disk work is serialized; native permission/session/feedback stays on the tick. */
public final class AdminCharacterCoins implements AutoCloseable {
private final CharacterStorage characters;
private final EquipmentSessions sessions;
private final Consumer<Runnable> nextTick;
private volatile boolean closed;
private final Set<Completion> pending = ConcurrentHashMap.newKeySet();
private AdminActionEventBridge audit;
private AdminCoinsEventBridge events;
public AdminCharacterCoins(CharacterStorage characters, EquipmentSessions sessions, Consumer<Runnable> nextTick) {
this.characters = characters; this.sessions = sessions; this.nextTick = nextTick;
}
public void attachAdminAudit(AdminActionEventBridge bridge) { audit = bridge; }
public void attachEvents(AdminCoinsEventBridge bridge) { events = bridge; }
public void adjust(CommandSender sender, UUID target, BigInteger delta) {
var action = audit == null ? null : audit.current();
var intent = events == null ? new AdminCoinsEventBridge.Intent(UUID.randomUUID(), action == null ? null : action.id(),
java.time.Instant.now(), sender instanceof Player ? "player" : "console", null)
: events.capture(sender, action == null ? null : action.id());
result(action, AdminActionEventBridge.Outcome.PENDING, "owner_pending", Map.of());
var acceptedLookup = new AtomicBoolean();
characters.inspect(target).whenComplete((found, failure) -> {
if (closed) { result(action, AdminActionEventBridge.Outcome.REFUSED, "owner_refused", Map.of()); return; }
try { nextTick.accept(() -> {
if (!acceptedLookup.compareAndSet(false, true)) return;
if (!valid(sender)) {
result(action, AdminActionEventBridge.Outcome.REFUSED, "permission_changed", Map.of()); return;
}
if (failure != null || found == null || found.isEmpty() || found.orElseThrow().dead()
|| !sessions.acceptsCoinAdjustment(target)) {
result(action, AdminActionEventBridge.Outcome.REFUSED, "owner_refused", Map.of());
feedback(sender, "Coin adjustment refused: saved character is unavailable. Nothing changed."); return;
}
var before = found.orElseThrow();
var active = sessions.activeCharacter(target).orElse(null);
var completion = new Completion(sender, action, intent, before, active, delta);
pending.add(completion);
characters.adjustCoins(target, before.revision(), intent.operation(), delta,
() -> !closed && sessions.acceptsCoinAdjustment(target)).whenComplete(completion::saved);
}); } catch (RuntimeException rejected) {
result(action, AdminActionEventBridge.Outcome.REFUSED, "owner_refused", Map.of());
}
});
}
private boolean valid(CommandSender sender) {
return !closed && sender.hasPermission("fantasystick.admin") && (!(sender instanceof Player player) || player.isOnline());
}
/** The disk acknowledgment is owned even when shutdown cancels every queued native task. */
private final class Completion {
final CommandSender sender;
final AdminActionEventBridge.Action action;
final AdminCoinsEventBridge.Intent intent;
final CharacterRecord captured;
final EquipmentSessions.Active active;
final BigInteger delta;
final AtomicBoolean finished = new AtomicBoolean();
CharacterStorage.CoinAdjustment outcome;
Throwable failure;
boolean ready;
Completion(CommandSender sender, AdminActionEventBridge.Action action, AdminCoinsEventBridge.Intent intent,
CharacterRecord captured, EquipmentSessions.Active active, BigInteger delta) {
this.sender = sender; this.action = action; this.intent = intent; this.captured = captured;
this.active = active; this.delta = delta;
}
synchronized void saved(CharacterStorage.CoinAdjustment saved, Throwable error) {
if (ready) return;
outcome = saved; failure = error; ready = true;
if (closed) { finish(false); return; }
try { nextTick.accept(() -> finish(true)); }
catch (RuntimeException rejected) { finish(false); }
}
synchronized void finish(boolean nativeAllowed) {
if (!ready || !finished.compareAndSet(false, true)) return;
pending.remove(this);
boolean nativeFeedback = nativeAllowed && !closed;
if (failure != null || outcome == null || outcome.status() == CharacterStorage.CoinAdjustmentStatus.UNCERTAIN) {
result(action, AdminActionEventBridge.Outcome.UNCERTAIN, "operation_uncertain", Map.of());
if (nativeFeedback) feedback(sender, "Coin adjustment outcome uncertain; do not blindly repeat the command.");
return;
}
if (outcome.status() != CharacterStorage.CoinAdjustmentStatus.APPLIED) {
result(action, outcome.status() == CharacterStorage.CoinAdjustmentStatus.FAILED
? AdminActionEventBridge.Outcome.FAILED : AdminActionEventBridge.Outcome.REFUSED,
outcome.status() == CharacterStorage.CoinAdjustmentStatus.STALE ? "stale" : "owner_refused", Map.of());
if (nativeFeedback) {
var old = outcome.before().orElse(captured);
feedback(sender, outcome.status() == CharacterStorage.CoinAdjustmentStatus.BELOW_ZERO
? "Balance " + old.coins() + "; adjustment " + delta + " would produce " + old.coins().add(delta) + ". Nothing changed."
: "Coin adjustment refused: character changed or is unavailable. Nothing changed.");
}
return;
}
var before = outcome.before().orElseThrow(); var after = outcome.record().orElseThrow();
String sync = active == null ? "not_applicable" : "not_current";
if (nativeFeedback && active != null) {
try { if (sessions.acknowledgeProgress(active, before.revision(), after)) sync = "acknowledged"; }
catch (RuntimeException nativeFailure) { sync = "uncertain"; diagnostic("session acknowledgment", nativeFailure); }
}
try {
if (events != null) events.adjusted(intent, before, after, delta, active == null ? "inactive" : "active", sync);
} catch (RuntimeException eventFailure) { diagnostic("domain audit submission", eventFailure); }
result(action, AdminActionEventBridge.Outcome.COMPLETED, "acknowledged", Map.of("domain_operation_id", intent.operation(),
"domain_event_id", AdminCoinsEventBridge.eventId(intent.operation()), "subject_id", after.id()));
if (nativeFeedback) feedback(sender, "Adjusted " + after.name() + " (" + after.id() + "): balance " + before.coins()
+ "; adjustment " + delta + "; resulting balance " + after.coins() + ".");
}
}
private void result(AdminActionEventBridge.Action action, AdminActionEventBridge.Outcome outcome, String reason, Map<String,Object> facts) {
try { if (audit != null) audit.result(action, outcome, reason, facts); }
catch (RuntimeException failure) { diagnostic("admin audit submission", failure); }
}
private void feedback(CommandSender sender, String message) {
try { if (valid(sender)) sender.sendMessage(message); }
catch (RuntimeException failure) { diagnostic("administrator feedback", failure); }
}
private static void diagnostic(String phase, Throwable failure) {
java.util.logging.Logger.getLogger(AdminCharacterCoins.class.getName()).log(java.util.logging.Level.WARNING,
"Administrator coins " + phase + " failed; this cannot change or replay the saved operation", failure);
}
@Override public void close() {
closed = true;
for (var completion : pending) completion.finish(false);
}
}
@@ -70,6 +70,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private AdminRevival adminRevival;
private games.dmg.fantasystick.events.AdminRevivalEventBridge adminRevivalEvents;
private AdminExperienceGrant experienceGrant;
private AdminCharacterCoins characterCoins;
public void attachCharacterCoins(AdminCharacterCoins owner) {
characterCoins = Objects.requireNonNull(owner);
if (adminAudit != null) owner.attachAdminAudit(adminAudit);
}
private games.dmg.fantasystick.persistence.AdminReviveSettings adminReviveSettings;
private games.dmg.fantasystick.persistence.SwitchDelaySettings switchDelaySettings;
private games.dmg.fantasystick.events.SwitchConfigEventBridge switchConfigEvents;
@@ -137,6 +142,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (characterRecovery != null) characterRecovery.attachAdminAudit(audit);
if (characterIssuer != null) characterIssuer.attachAdminAudit(audit);
if (experienceGrant != null) experienceGrant.attachAdminAudit(audit);
if (characterCoins != null) characterCoins.attachAdminAudit(audit);
}
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
@@ -728,6 +734,23 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
}
private void characterCommand(CommandSender sender, String[] args) {
if (args.length >= 2 && args[1].equals("coins")) {
if (!(sender instanceof org.bukkit.entity.Player) && !(sender instanceof org.bukkit.command.ConsoleCommandSender)) {
adminRefused(); sender.sendMessage("Coin adjustment refused: use an authorized player or console."); return;
}
UUID target; java.math.BigInteger delta;
try {
if (args.length != 4 || characterCoins == null || !args[3].matches("[+-]?[0-9]+"))
throw new IllegalArgumentException("Invalid coin command");
target = UUID.fromString(args[2]);
if (!target.toString().equalsIgnoreCase(args[2])) throw new IllegalArgumentException("Full UUID required");
delta = new java.math.BigInteger(args[3]);
if (delta.signum() == 0) throw new IllegalArgumentException("Nonzero delta required");
} catch (IllegalArgumentException invalid) {
adminInvalid(); sender.sendMessage("Usage: /fstickadmin character coins <full-character-uuid> <nonzero-signed-amount>"); return;
}
characterCoins.adjust(sender, target, delta); return;
}
if (args.length >= 2 && args[1].equals("find")) {
characterFind(sender, args);
return;
@@ -2731,12 +2754,14 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if(dungeonCommands!=null&&args.length>1){var dungeon=dungeonCommands.complete(sender,args);if(!dungeon.isEmpty())return dungeon;if(args[0].equals("dungeon"))return List.of();}
if (args.length == 1) return matching(sender instanceof org.bukkit.entity.Player
? List.of("adventure", "adventure-site", "character", "config", "dungeon", "experience", "give", "inspect", "party-registrar", "quest-giver", "rare-shop", "recover", "revive", "shop", "training-dummy")
: List.of("adventure-site", "config", "dungeon", "experience", "give", "inspect", "party-registrar", "quest-giver", "rare-shop", "recover", "shop", "training-dummy"), args[0]);
: List.of("adventure-site", "character", "config", "dungeon", "experience", "give", "inspect", "party-registrar", "quest-giver", "rare-shop", "recover", "shop", "training-dummy"), args[0]);
if (args.length == 2 && args[0].equals("character")) {
var routes = new java.util.ArrayList<>(List.of("find"));
var routes = new java.util.ArrayList<>(List.of("coins", "find"));
if (sender instanceof org.bukkit.entity.Player) routes.addAll(List.of("recover", "show", "view"));
return matching(routes, args[1]);
}
if (args.length >= 3 && args[0].equals("character") && args[1].equals("coins"))
return args.length == 3 ? matching(savedCharacterSuggestions().keySet().stream().map(UUID::toString).sorted().toList(), args[2]) : List.of();
if (args.length >= 3 && args[0].equals("character") && args[1].equals("find")) {
String prefix = String.join(" ", java.util.Arrays.copyOfRange(args, 2, args.length));
return matchingIgnoreCase(savedCharacterSuggestions().values(), prefix);
@@ -2935,5 +2960,5 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
.distinct().sorted(java.util.Comparator.comparing((String value) -> value.toLowerCase(java.util.Locale.ROOT))
.thenComparing(java.util.Comparator.naturalOrder())).toList();
}
@Override public void close() { closed = true; savedSearches.clear(); if (characterIssuer != null) characterIssuer.close(); if(adminRevival!=null)adminRevival.close(); if(characterRecovery!=null)characterRecovery.close(); if(experienceGrant!=null)experienceGrant.close(); }
@Override public void close() { closed = true; savedSearches.clear(); if (characterIssuer != null) characterIssuer.close(); if(adminRevival!=null)adminRevival.close(); if(characterRecovery!=null)characterRecovery.close(); if(experienceGrant!=null)experienceGrant.close(); if(characterCoins!=null)characterCoins.close(); }
}
@@ -387,6 +387,19 @@ public final class EquipmentSessions implements AutoCloseable {
.map(attempt -> new Active(attempt.owner, attempt.session, attempt.character)).findFirst();
}
/** Process-authority eligibility: safe on the serialized write worker; no native inventory/player calls. */
public synchronized boolean acceptsCoinAdjustment(UUID character) {
if (closed || characterRecoveries.containsKey(character) || unavailableCharacter.test(character)
|| downedDirectory != null && downedDirectory.all().stream().anyMatch(value -> value.character().equals(character)
&& (value.phase() == DownedEpisode.Phase.PENDING || !value.published()))) return false;
if (java.util.stream.Stream.of(pending.values(), transitioning.values()).flatMap(java.util.Collection::stream)
.anyMatch(attempt -> character.equals(attempt.character))) return false;
return owned.values().stream().filter(attempt -> character.equals(attempt.character))
.allMatch(attempt -> active.get(attempt.owner) == attempt && attempt.acknowledged && !attempt.downing
&& attempt.downed == null && !attempt.exitRequested && !attempt.defeated && !attempt.cleaning
&& !attempt.receiptUncertain && !attempt.uncertainSave && !attempt.nativeUncertain);
}
/** Current native holder for a still-authorized server-thread action. */
public synchronized Optional<Player> currentPlayer(Active session) {
var attempt = active.get(session.player());
@@ -275,7 +275,7 @@ class AdminCharacterBookNativeTest {
assertEquals(java.util.List.of("character", "config"),
fixture.command.tabComplete(admin.player, "fantasystick", new String[]{"c"}));
assertEquals(java.util.List.of("find", "recover", "show", "view"),
assertEquals(java.util.List.of("coins", "find", "recover", "show", "view"),
fixture.command.tabComplete(admin.player, "fstickadmin", new String[]{"character", ""}));
assertEquals(java.util.List.of("ActiveTarget"),
fixture.command.tabComplete(admin.player, "fantasystick", new String[]{"character", "view", ""}));
@@ -293,7 +293,7 @@ class AdminCharacterBookNativeTest {
admin.allowed = true;
var console = new EquipmentAdminCommandNativeTest.Sender();
assertEquals(java.util.List.of("find"), fixture.command.tabComplete(console.port, "fstickadmin",
assertEquals(java.util.List.of("coins", "find"), fixture.command.tabComplete(console.port, "fstickadmin",
new String[]{"character", ""}));
fixture.command.execute(console.port, "fantasystick",
new String[]{"character", "view", "ActiveTarget"});
@@ -0,0 +1,225 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static games.dmg.fantasystick.AdminCharacterCoinsNativeTest.*;
import static games.dmg.fantasystick.AdminCharacterCoinsSafetyNativeTest.*;
import static games.dmg.fantasystick.AdminCharacterCoinsIntegratedNativeTest.*;
import games.dmg.fantasystick.adventure.AdventureQuestState;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.persistence.YamlCharacterRepository;
import java.math.BigInteger;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
/** Remaining direct AC controls; no alternate command, storage, lifecycle or gameplay authority. */
class AdminCharacterCoinsCoverageNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void actualExitAwaitingReceiptRefusesCoinsWithoutAdvancingCustody() throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f; var a = fixture.actor; f.activate(a);
var entry = new AdminActionEntryNativeTest.Entry(fixture, "console", true);
var id = a.core.record.id();
var worker = (ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(f.receipts(), "worker");
var entered = new CountDownLatch(1); var release = new CountDownLatch(1);
var gate = worker.submit(() -> {
entered.countDown();
try { release.await(); } catch (InterruptedException error) { Thread.currentThread().interrupt(); }
});
assertTrue(entered.await(5, TimeUnit.SECONDS));
try {
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.RESTORED, sessions(f).exit(a.player));
assertTrue(sessions(f).active(a.core.id).isEmpty());
assertFalse(sessions(f).acceptsCoinAdjustment(id), "real exit retains claim while receipt acknowledgment is held");
var before = storage(f).inspect(id).get().orElseThrow();
var bytes = Files.readAllBytes(data.resolve("characters").resolve(id + ".yml"));
var inventory = a.core.snapshot(); var marker = a.core.marker();
execute(fixture, entry, id.toString(), "100");
assertTrue(entry.messages().getLast().contains("refused"));
assertEquals(before, storage(f).inspect(id).get().orElseThrow());
assertArrayEquals(bytes, Files.readAllBytes(data.resolve("characters").resolve(id + ".yml")));
assertArrayEquals(inventory, a.core.snapshot()); assertEquals(marker, a.core.marker());
assertFalse(sessions(f).acceptsCoinAdjustment(id), "refusal cannot settle the held exit");
assertTrue(adjustments(data).isEmpty());
} finally { release.countDown(); gate.get(5, TimeUnit.SECONDS); }
f.awaitCharacterAndReceipt(a);
ReskillEventsNativeTest.await(f, () -> sessions(f).acceptsCoinAdjustment(id));
assertNull(f.receipts().find(a.core.id).toCompletableFuture().get().orElseThrow().pending());
}
}
@Test void realLookupAndReplacementRunOnWorkerWhileSenderAndSessionPublicationStayOnTick() throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f; var a = fixture.actor; f.activate(a);
var entry = new AdminActionEntryNativeTest.Entry(fixture, "console", true); // Strict sender asserts f.main on EVERY call.
var characters = storage(f); var id = a.core.record.id();
var repositoryField = CharacterStorage.class.getDeclaredField("repository"); repositoryField.setAccessible(true);
var original = (YamlCharacterRepository) repositoryField.get(characters);
var traced = spy(original); var reads = new AtomicInteger(); var writes = new AtomicInteger(); var publications = new AtomicInteger();
doAnswer(call -> {
assertNotSame(f.main, Thread.currentThread(), "lookup must not read YAML on tick");
assertEquals("fantasy-character-persistence", Thread.currentThread().getName()); reads.incrementAndGet();
return call.callRealMethod();
}).when(traced).load(id);
var replacementField = YamlCharacterRepository.class.getDeclaredField("replacement"); replacementField.setAccessible(true);
var replacement = replacementField.get(traced);
replacementField.set(traced, java.lang.reflect.Proxy.newProxyInstance(replacementField.getType().getClassLoader(),
new Class<?>[]{replacementField.getType()}, (proxy, method, args) -> {
assertNotSame(f.main, Thread.currentThread(), "durable replacement must not block tick");
assertEquals("fantasy-character-persistence", Thread.currentThread().getName()); writes.incrementAndGet();
try { method.setAccessible(true); return method.invoke(replacement, args); }
catch (java.lang.reflect.InvocationTargetException error) { throw error.getCause(); }
}));
var realSessions = sessions(f); var tracedSessions = spy(realSessions);
doAnswer(call -> {
assertSame(f.main, Thread.currentThread(), "current native session publication must stay on tick");
publications.incrementAndGet(); return call.callRealMethod();
}).when(tracedSessions).acknowledgeProgress(any(), any(), any());
var sessionField = games.dmg.fantasystick.session.AdminCharacterCoins.class.getDeclaredField("sessions"); sessionField.setAccessible(true);
repositoryField.set(characters, traced); sessionField.set(owner(f), tracedSessions);
try {
execute(fixture, entry, id.toString(), "100");
assertTrue(entry.messages().getLast().contains("resulting balance"));
assertTrue(reads.get() >= 2, "actual lookup AND locked authoritative reread observed");
assertEquals(1, writes.get()); assertEquals(1, publications.get());
var active = realSessions.active(a.core.id).orElseThrow();
assertEquals(characters.inspect(id).get().orElseThrow(), realSessions.currentRecord(active).orElseThrow());
} finally { repositoryField.set(characters, original); sessionField.set(owner(f), realSessions); }
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f); assertEquals(1, adjustments(data).size());
}
}
@ParameterizedTest @ValueSource(strings = {"fee_first", "adjustment_first"})
void actualFeeDebitAndAdministrativeDebitSerializeWithoutLostUpdate(String order) throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f; var entry = new AdminActionEntryNativeTest.Entry(fixture, "console", true);
var before = storage(f).inspect(fixture.actor.core.record.id()).get().orElseThrow();
var operation = UUID.randomUUID();
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin character coins " + before.id() + " -" + before.coins()));
var lookup = callback(f);
CharacterRecord winner;
if (order.equals("fee_first")) {
var spent = storage(f).spendCoins(before.id(), before.revision(), operation, BigInteger.ONE).get();
assertEquals(CharacterStorage.CoinSpendStatus.APPLIED, spent.status()); winner = spent.record().orElseThrow();
lookup.run(); ReskillEventsNativeTest.await(f, () -> !entry.messages().isEmpty());
assertTrue(entry.messages().getLast().contains("refused"));
assertEquals(before.coins().subtract(BigInteger.ONE), winner.coins());
assertTrue(winner.rewardReceipts().contains(operation));
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f); assertTrue(adjustments(data).isEmpty());
} else {
lookup.run(); winner = storage(f).inspect(before.id()).get().orElseThrow();
assertEquals(BigInteger.ZERO, winner.coins()); assertUnrelated(before, winner);
var rejected = storage(f).spendCoins(before.id(), before.revision(), operation, BigInteger.ONE).get();
assertEquals(CharacterStorage.CoinSpendStatus.STALE, rejected.status());
assertEquals(winner, rejected.record().orElseThrow());
callback(f).run(); FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
assertEquals(1, adjustments(data).size());
}
assertEquals(winner, storage(f).inspect(before.id()).get().orElseThrow());
assertEquals(winner, new YamlCharacterRepository(data).load(before.id()).orElseThrow());
}
}
@Test void populatedActualPartyQuestAndBothNativeInventoriesSurviveCreditAndDebit() throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f; var admin = fixture.actor; var target = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(target.core.id, target.player); f.activate(admin); f.activate(target);
var parties = PartyLifecycleNativeTest.parties(f);
var party = parties.create(admin.core.id).orElseThrow();
assertTrue(parties.accept(target.core.id, parties.invite(admin.core.id, party.id(), target.core.id).orElseThrow().id()));
var directory = PartyLifecycleNativeTest.directory(f);
assertEquals(2, directory.partyById(party.id()).orElseThrow().members().size());
var site = fixture.directory.placeSite(AdventureQuestState.SiteType.CAMP, "Coins preservation", admin.world.getUID(), 500, 64, 500).get();
var catalog = fixture.directory.ensureToday().get();
var quest = fixture.directory.accept(catalog.revision(), site.id(), AdventureQuestState.UnitType.PARTY, party.id(),
Set.of(admin.core.record.id(), target.core.record.id())).get();
assertEquals(games.dmg.fantasystick.adventure.AdventureQuestDirectory.Acceptance.APPLIED, quest.status());
assertTrue(fixture.directory.bank(quest.quest().orElseThrow().id(), UUID.randomUUID(),
java.util.Map.of(target.core.record.id(), new games.dmg.fantasystick.adventure.AdventureQuestDirectory.Reward(BigInteger.TEN, BigInteger.ONE))).get());
var partyBefore = directory.all(); var questBefore = fixture.directory.snapshot();
var partyBytes = Files.readAllBytes(data.resolve("parties.yml")); var questBytes = Files.readAllBytes(data.resolve("adventure-quests.yml"));
var adminInventory = admin.core.snapshot(); var targetInventory = target.core.snapshot();
var adminRecord = storage(f).inspect(admin.core.record.id()).get().orElseThrow();
var before = storage(f).inspect(target.core.record.id()).get().orElseThrow();
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
for (var delta : List.of("+100", "-100")) {
execute(fixture, entry, before.id().toString(), delta);
var after = storage(f).inspect(before.id()).get().orElseThrow(); assertUnrelated(before, after); before = after;
assertEquals(partyBefore, directory.all()); assertEquals(questBefore, fixture.directory.snapshot());
assertArrayEquals(partyBytes, Files.readAllBytes(data.resolve("parties.yml")));
assertArrayEquals(questBytes, Files.readAllBytes(data.resolve("adventure-quests.yml")));
assertArrayEquals(adminInventory, admin.core.snapshot()); assertArrayEquals(targetInventory, target.core.snapshot());
assertEquals(adminRecord, storage(f).inspect(adminRecord.id()).get().orElseThrow());
}
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f); assertEquals(2, adjustments(data).size());
}
}
@Test void offlineAdjustedZeroSurvivesLegitimateActivationActualResetBookPurchaseAndRestart() throws Exception {
UUID id; CharacterRecord retained;
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f; var a = fixture.actor; id = a.core.record.id();
var old = storage(f).inspect(id).get().orElseThrow(); var stats = new java.util.EnumMap<>(old.stats());
stats.replaceAll((key, value) -> BigInteger.ONE);
var invested = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats, old.level(), old.experience(),
old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(), old.cooldowns(), old.restrictedKills(), false,
old.deathId(), old.diedAt(), old.coins(), true, old.rewardReceipts(), old.pvpCoinEscrow(), old.gear(), old.sheetGeneration());
new YamlCharacterRepository(data).replace(invested, old.revision());
a.core.online = false;
var entry = new AdminActionEntryNativeTest.Entry(fixture, "console", true);
execute(fixture, entry, id.toString(), "+100");
var credited = storage(f).inspect(id).get().orElseThrow(); assertUnrelated(invested, credited);
execute(fixture, entry, id.toString(), "-" + credited.coins());
var zero = storage(f).inspect(id).get().orElseThrow(); assertEquals(BigInteger.ZERO, zero.coins()); assertTrue(zero.coinsInitialized());
assertTrue(sessions(f).active(a.core.id).isEmpty());
a.core.online = true; f.activate(a); // Real native sheet observation, disk load and packed activation.
var active = sessions(f).active(a.core.id).orElseThrow();
assertEquals(zero, sessions(f).currentRecord(active).orElseThrow(), "activation cannot reroll legitimate zero");
PartyBookNativeTest.open(f, a); assertTrue(PartyBookNativeTest.text(a).contains("Coins: 0"));
var rare = (games.dmg.fantasystick.shop.RareInventoryDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "rareInventory");
rare.add(id, games.dmg.fantasystick.shop.RareProduct.RESKILL, UUID.randomUUID()).join();
PartyBookNativeTest.open(f, a); PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[Use]"));
ReskillEventsNativeTest.await(f, () -> PartyBookNativeTest.text(a).contains("Confirm Reskill"));
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[Confirm Reskill]"));
ReskillEventsNativeTest.await(f, () -> rare.quantity(id, games.dmg.fantasystick.shop.RareProduct.RESKILL).signum() == 0
&& sessions(f).currentRecord(active).orElseThrow().stats().values().stream().allMatch(BigInteger.ZERO::equals));
var reset = sessions(f).currentRecord(active).orElseThrow();
assertEquals(BigInteger.ZERO, reset.coins()); assertTrue(reset.coinsInitialized());
assertEquals(zero.sheetGeneration(), reset.sheetGeneration()); assertEquals(zero.cooldowns(), reset.cooldowns());
PartyBookNativeTest.open(f, a); assertTrue(PartyBookNativeTest.text(a).contains("Coins: 0"));
var denied = storage(f).purchaseRare(id, reset.revision(), UUID.randomUUID(), BigInteger.ONE).get();
assertEquals(CharacterStorage.RarePurchaseStatus.INSUFFICIENT, denied.status());
assertEquals(reset, storage(f).inspect(id).get().orElseThrow());
execute(fixture, entry, id.toString(), "+100");
var funded = sessions(f).currentRecord(active).orElseThrow();
var bought = storage(f).purchaseRare(id, funded.revision(), UUID.randomUUID(), BigInteger.valueOf(50)).get();
assertEquals(CharacterStorage.RarePurchaseStatus.APPLIED, bought.status()); retained = bought.record().orElseThrow();
assertTrue(sessions(f).acknowledgeProgress(active, funded.revision(), retained));
PartyBookNativeTest.open(f, a); assertTrue(PartyBookNativeTest.text(a).contains("Coins: 50"));
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f); assertEquals(3, adjustments(data).size());
}
try (var restarted = new QuestTrackingAuditNativeFixture(data)) {
var record = storage(restarted.f).inspect(id).get().orElseThrow();
assertEquals(retained.coins(), record.coins()); assertEquals(retained.stats(), record.stats());
assertEquals(retained.sheetGeneration(), record.sheetGeneration()); assertTrue(record.coinsInitialized());
assertTrue(record.rewardReceipts().containsAll(retained.rewardReceipts()));
}
}
}
@@ -0,0 +1,468 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static games.dmg.fantasystick.AdminCharacterCoinsNativeTest.*;
import static games.dmg.fantasystick.AdminCharacterCoinsSafetyNativeTest.*;
import games.dmg.fantasystick.events.AdminActionEventBridge;
import games.dmg.fantasystick.events.AdminCoinsEventBridge;
import games.dmg.fantasystick.events.EventInput;
import games.dmg.fantasystick.events.LocalEventLog;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.session.AdminCharacterCoins;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigInteger;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Clock;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
/** Feature-local fault seams; command map, serialized records, native sessions and JSONL remain real. */
class AdminCharacterCoinsIntegratedNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
static EquipmentSessions sessions(FantasyStickLifecycleNativeTest.Fixture f) {
return (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
}
static AdminCharacterCoins owner(FantasyStickLifecycleNativeTest.Fixture f) {
return (AdminCharacterCoins) FantasyStickLifecycleNativeTest.Fixture.field(
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "admin"), "characterCoins");
}
@Test void commandBlockIsNotAConsoleAndNoLegacyAliasCanAdjust() throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var entry = new AdminActionEntryNativeTest.Entry(fixture, "command_block", true);
var before = storage(fixture.f).inspect(fixture.actor.core.record.id()).get().orElseThrow();
execute(fixture, entry, before.id().toString(), "100");
assertTrue(entry.messages().getLast().contains("refused"));
assertEquals(before, storage(fixture.f).inspect(before.id()).get().orElseThrow());
for (var alias : List.of("fantasystick", "fantasyfeedback", "fantasyparticles")) {
assertNull(entry.map.getCommand(alias)); assertNull(entry.map.getCommand("fantasystick:" + alias));
}
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(fixture.f);
assertTrue(adjustments(data).isEmpty());
}
}
@Test void activeAdministratorAndActiveTargetAreDistinctIdentitiesAndNativeStateIsReadOnly() throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f; var admin = fixture.actor;
var target = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(target.core.id, target.player); f.activate(admin); f.activate(target);
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
var adminSession = sessions(f).active(admin.core.id).orElseThrow();
var targetSession = sessions(f).active(target.core.id).orElseThrow();
var adminBefore = sessions(f).currentRecord(adminSession).orElseThrow();
var before = sessions(f).currentRecord(targetSession).orElseThrow();
var adminInventory = admin.core.snapshot(); var targetInventory = target.core.snapshot();
double health = target.player.getHealth(); int xp = target.nativePlayer.totalExperience;
var mode = target.player.getGameMode(); var marker = target.core.marker();
execute(fixture, entry, before.id().toString(), "+100");
var after = storage(f).inspect(before.id()).get().orElseThrow(); assertUnrelated(before, after);
assertEquals(after, sessions(f).currentRecord(targetSession).orElseThrow());
assertEquals(adminBefore, sessions(f).currentRecord(adminSession).orElseThrow());
assertArrayEquals(adminInventory, admin.core.snapshot()); assertArrayEquals(targetInventory, target.core.snapshot());
assertEquals(health, target.player.getHealth()); assertEquals(xp, target.nativePlayer.totalExperience);
assertEquals(mode, target.player.getGameMode()); assertEquals(marker, target.core.marker());
PartyBookNativeTest.open(f, target); assertTrue(PartyBookNativeTest.text(target).contains("Coins: " + after.coins()));
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
var row = adjustments(data).getFirst();
assertEquals(admin.core.id.toString(), row.get("playerid").getAsString());
assertEquals(adminBefore.id().toString(), row.get("characterid").getAsString());
assertEquals(before.id().toString(), row.getAsJsonObject("data").get("target_character_id").getAsString());
}
}
@Test void populatedSavedFieldsAndFinalDeathEscrowAreNeverAdministrativeSideEffects() throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f; var entry = new AdminActionEntryNativeTest.Entry(fixture, "console", true);
var base = storage(f).inspect(fixture.actor.core.record.id()).get().orElseThrow();
var stats = new java.util.EnumMap<>(base.stats());
for (var stat : stats.keySet()) stats.put(stat, BigInteger.valueOf(7));
var world = fixture.actor.world.getUID(); var now = java.time.Instant.now();
var rich = new games.dmg.fantasystick.persistence.CharacterRecord(base.id(), "Populated revived identity 雪", base.revision().add(BigInteger.ONE),
stats, 12, BigInteger.valueOf(987), BigInteger.valueOf(31), new java.math.BigDecimal("4.25"),
java.util.Set.of("fireball", "retained_saved_skill"), List.of("fireball", "retained_saved_skill", "", "", "", "", ""),
List.of("speed_i", "retained_saved_passive", ""), java.util.Map.of("revive", now.plusSeconds(300)),
java.util.Map.of(world, new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(world,
java.math.BigDecimal.ONE, java.math.BigDecimal.TEN, java.math.BigDecimal.ZERO)),
false, UUID.randomUUID(), now.minusSeconds(60), BigInteger.valueOf(77), true, java.util.Set.of(UUID.randomUUID()), BigInteger.ZERO,
new games.dmg.fantasystick.persistence.CharacterGear(games.dmg.fantasystick.persistence.GearTier.IRON,
games.dmg.fantasystick.persistence.GearTier.COPPER, games.dmg.fantasystick.persistence.GearTier.LEATHER), BigInteger.valueOf(4));
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(rich, base.revision());
var nativeBefore = fixture.actor.core.snapshot();
execute(fixture, entry, rich.id().toString(), "+10");
var credited = storage(f).inspect(rich.id()).get().orElseThrow(); assertUnrelated(rich, credited);
execute(fixture, entry, rich.id().toString(), "-10");
var debited = storage(f).inspect(rich.id()).get().orElseThrow(); assertUnrelated(credited, debited);
assertArrayEquals(nativeBefore, fixture.actor.core.snapshot());
assertEquals(rich.coins(), debited.coins());
var dead = storage(f).recordFinalDeath(rich.id(), debited.revision(), UUID.randomUUID(), now, true).get().orElseThrow();
assertEquals(debited.coins(), dead.pvpCoinEscrow()); assertEquals(BigInteger.ZERO, dead.coins());
execute(fixture, entry, rich.id().toString(), "+100");
assertTrue(entry.messages().getLast().contains("refused"));
assertEquals(dead, storage(f).inspect(rich.id()).get().orElseThrow(), "dead target retains every field, escrow and death proof");
assertArrayEquals(nativeBefore, fixture.actor.core.snapshot());
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f); assertEquals(2, adjustments(data).size());
}
}
@ParameterizedTest @ValueSource(strings = {"permission", "logout"})
void authorizationLossAfterCommitCannotUndoOrRepeatAndDoesNotDiscloseResult(String invalidation) throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f; var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
var before = storage(f).inspect(fixture.actor.core.record.id()).get().orElseThrow();
dispatch(entry, before.id()); callback(f).run(); storage(f).inspect(before.id()).get();
var complete = callback(f);
if (invalidation.equals("permission")) fixture.actor.allowed = false; else fixture.actor.core.online = false;
complete.run(); complete.run();
assertTrue(entry.messages().isEmpty());
var after = storage(f).inspect(before.id()).get().orElseThrow(); assertUnrelated(before, after);
assertEquals(before.coins().add(BigInteger.valueOf(100)), after.coins());
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f); assertEquals(1, adjustments(data).size());
}
}
@ParameterizedTest @ValueSource(strings = {"rename", "logout", "switch", "transfer"})
void capturedTargetCannotRedirectOrOverwriteReplacementSession(String transition) throws Exception {
UUID id; BigInteger retained;
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f; var a = fixture.actor; f.activate(a);
var entry = new AdminActionEntryNativeTest.Entry(fixture, "console", true);
var active = sessions(f).active(a.core.id).orElseThrow();
var before = sessions(f).currentRecord(active).orElseThrow(); id = before.id();
dispatch(entry, id); callback(f).run();
var applied = storage(f).inspect(id).get().orElseThrow(); retained = applied.coins();
var complete = callback(f);
if (transition.equals("rename")) {
var renamed = storage(f).renameCharacter(id, "Renamed after commitment 雪").get().orElseThrow();
assertTrue(sessions(f).acknowledgeProgress(active, before.revision(), applied));
assertTrue(sessions(f).acknowledgeProgress(active, applied.revision(), renamed));
} else {
// Make the legitimate exit save start from the acknowledged revision, not a stale fixture snapshot.
assertTrue(sessions(f).acknowledgeProgress(active, before.revision(), applied));
org.mockito.Mockito.doReturn(null).when(a.player).getFishHook(); // Exact ordinary native port: no hook to clean up.
f.dispatcher.accept(new org.bukkit.event.player.PlayerQuitEvent(a.player, net.kyori.adventure.text.Component.empty()));
storage(f).inspect(id).get(); f.awaitCharacterAndReceipt(a);
while (!f.tasks.isEmpty()) f.tasks.remove().run();
if (transition.equals("logout")) a.core.online = false;
else {
var holder = transition.equals("transfer") ? new FantasyStickLifecycleNativeTest.Actor(data) : a;
if (holder != a) f.players.put(holder.core.id, holder.player);
var selected = transition.equals("transfer") ? storage(f).inspect(id).get().orElseThrow()
: new games.dmg.fantasystick.crafting.CharacterFactory().create(games.dmg.fantasystick.progression.ProgressionRules.defaults());
if (transition.equals("switch")) {
storage(f).create(selected).get();
// Keep the real switch policy. Advance this fixture's session clock beyond the saved deadline.
var exit = f.receipts().find(a.core.id).toCompletableFuture().get().orElseThrow().lastExit();
var clock = EquipmentSessions.class.getDeclaredField("clock"); clock.setAccessible(true);
clock.set(sessions(f), Clock.fixed(exit.deadline().plusSeconds(1), java.time.ZoneOffset.UTC));
}
holder.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(selected));
f.reenter(holder);
var replacement = sessions(f).active(holder.core.id).orElseThrow();
var replacementRecord = sessions(f).currentRecord(replacement).orElseThrow();
complete.run(); complete.run();
assertEquals(replacementRecord, sessions(f).currentRecord(replacement).orElseThrow());
if (transition.equals("transfer")) assertEquals(retained, replacementRecord.coins());
}
}
complete.run(); complete.run();
assertEquals(retained, storage(f).inspect(id).get().orElseThrow().coins());
assertTrue(entry.messages().getLast().contains(before.name()), "feedback uses the frozen acknowledgment identity");
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
var values = adjustments(data).getFirst().getAsJsonObject("data");
assertEquals(before.name(), values.get("target_character_name").getAsString());
assertEquals(id.toString(), values.get("target_character_id").getAsString());
assertEquals("not_current", values.get("session_sync").getAsString());
}
assertEquals(retained, new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(id).orElseThrow().coins());
}
@Test void oldCompletionCannotOverwriteNewerPurchaseAndRewardAcknowledgment() throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f; f.activate(fixture.actor);
var entry = new AdminActionEntryNativeTest.Entry(fixture, "console", true);
var active = sessions(f).active(fixture.actor.core.id).orElseThrow();
var before = sessions(f).currentRecord(active).orElseThrow();
dispatch(entry, before.id()); callback(f).run();
var applied = storage(f).inspect(before.id()).get().orElseThrow(); var old = callback(f);
assertTrue(sessions(f).acknowledgeProgress(active, before.revision(), applied));
var purchased = storage(f).purchaseRare(before.id(), applied.revision(), UUID.randomUUID(), BigInteger.TEN).get().record().orElseThrow();
assertTrue(sessions(f).acknowledgeProgress(active, applied.revision(), purchased));
var rewarded = storage(f).awardCombatReward(before.id(), purchased.revision(), UUID.randomUUID(), BigInteger.ONE, BigInteger.TEN,
games.dmg.fantasystick.progression.ProgressionRules.defaults(), games.dmg.fantasystick.progression.CombatRewardRules.defaults(), null)
.get().record().orElseThrow();
assertTrue(sessions(f).acknowledgeProgress(active, purchased.revision(), rewarded));
old.run(); old.run();
assertEquals(rewarded, sessions(f).currentRecord(active).orElseThrow());
assertEquals(rewarded, storage(f).inspect(before.id()).get().orElseThrow());
PartyBookNativeTest.open(f, fixture.actor); assertTrue(PartyBookNativeTest.text(fixture.actor).contains("Coins: " + rewarded.coins()));
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
assertEquals("not_current", adjustments(data).getFirst().getAsJsonObject("data").get("session_sync").getAsString());
}
}
@Test void rejectedWriteAdmissionIsConfirmedFailedNotUncertain() throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f; var entry = new AdminActionEntryNativeTest.Entry(fixture, "console", true);
var before = storage(f).inspect(fixture.actor.core.record.id()).get().orElseThrow();
dispatch(entry, before.id()); var lookup = callback(f);
storage(f).close(); // The read completed, but no coin write can be admitted to this stopped owner.
lookup.run(); ReskillEventsNativeTest.await(f, () -> !entry.messages().isEmpty());
assertTrue(entry.messages().getLast().contains("Nothing changed."));
assertFalse(entry.messages().getLast().contains("uncertain"));
assertEquals(before, new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(before.id()).orElseThrow());
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f); assertTrue(adjustments(data).isEmpty());
var result = AdminActionEntryNativeTest.rows(data).stream().filter(row -> row.getAsJsonObject("data").get("stage").getAsString().equals("result")).toList().getLast();
assertEquals("failed", result.getAsJsonObject("data").get("outcome").getAsString());
}
}
@Test void negativeDebitCannotUseOldBalanceAfterInterveningPurchase() throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f; var entry = new AdminActionEntryNativeTest.Entry(fixture, "console", true);
var before = storage(f).inspect(fixture.actor.core.record.id()).get().orElseThrow();
assertTrue(before.coins().signum() > 0);
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin character coins " + before.id() + " -" + before.coins()));
var lookup = callback(f);
var purchase = storage(f).purchaseRare(before.id(), before.revision(), UUID.randomUUID(), before.coins()).get();
assertEquals(CharacterStorage.RarePurchaseStatus.APPLIED, purchase.status());
var acknowledged = purchase.record().orElseThrow(); assertEquals(BigInteger.ZERO, acknowledged.coins());
lookup.run(); ReskillEventsNativeTest.await(f, () -> !entry.messages().isEmpty());
assertTrue(entry.messages().getLast().contains("refused"));
assertEquals(acknowledged, storage(f).inspect(before.id()).get().orElseThrow());
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f); assertTrue(adjustments(data).isEmpty());
}
}
@ParameterizedTest @ValueSource(strings = {"custody", "downed", "shutdown"})
void queuedWriteRechecksEligibilityAfterNativeValidation(String invalidation) throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f; var entry = new AdminActionEntryNativeTest.Entry(fixture, "console", true);
var characters = storage(f); var before = characters.inspect(fixture.actor.core.record.id()).get().orElseThrow();
dispatch(entry, before.id()); var lookup = callback(f);
var events = (AdminCoinsEventBridge) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "adminCoinsEvents");
var worker = (java.util.concurrent.ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(characters, "worker");
var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1);
worker.submit(() -> { entered.countDown(); try { release.await(); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } });
assertTrue(entered.await(5, TimeUnit.SECONDS));
try {
lookup.run(); // Valid on tick; adjustment waits behind only this fixture's owned worker gate.
switch (invalidation) {
case "custody" -> characters.attachUnavailableCharacter(before.id()::equals);
case "downed" -> {
var downed = (games.dmg.fantasystick.session.DownedDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "downed");
downed.begin(before.id(), fixture.actor.core.id, UUID.randomUUID(), fixture.actor.world.getUID(), 1, 64, 2, 0, 0,
java.time.Duration.ofMinutes(5), new games.dmg.fantasystick.session.DownedEpisode.Attribution(null, "minecraft:fall")).get();
}
case "shutdown" -> f.plugin.setEnabled(false);
default -> throw new AssertionError(invalidation);
}
} finally { release.countDown(); }
if (invalidation.equals("shutdown")) {
characters.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
events.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
assertTrue(entry.messages().isEmpty());
} else {
ReskillEventsNativeTest.await(f, () -> !entry.messages().isEmpty());
assertTrue(entry.messages().getLast().contains("refused"));
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
}
assertEquals(before, new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(before.id()).orElseThrow());
assertTrue(adjustments(data).isEmpty());
}
}
@Test void sameTargetConcurrentCommandsHaveOneAcknowledgmentAndOneStaleRefusal() throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f; var entry = new AdminActionEntryNativeTest.Entry(fixture, "console", true);
var before = storage(f).inspect(fixture.actor.core.record.id()).get().orElseThrow();
dispatch(entry, before.id()); var first = callback(f);
dispatch(entry, before.id()); var second = callback(f);
first.run(); second.run();
ReskillEventsNativeTest.await(f, () -> entry.messages().size() == 2);
var after = storage(f).inspect(before.id()).get().orElseThrow(); assertUnrelated(before, after);
assertEquals(before.coins().add(BigInteger.valueOf(100)), after.coins());
assertEquals(1, entry.messages().stream().filter(text -> text.contains("resulting balance")).count());
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f); assertEquals(1, adjustments(data).size());
}
}
@ParameterizedTest @ValueSource(strings = {"failed", "uncertain", "unreadable_after_lookup"})
void storageFailureHasTruthfulWarningAndNoSuccessEventOrBlindReplay(String failure) throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f; var entry = new AdminActionEntryNativeTest.Entry(fixture, "console", true);
var before = storage(f).inspect(fixture.actor.core.record.id()).get().orElseThrow();
dispatch(entry, before.id()); var lookup = callback(f);
if (failure.equals("unreadable_after_lookup")) {
Files.writeString(data.resolve("characters").resolve(before.id() + ".yml"), "invalid: [");
} else {
var repository = FantasyStickLifecycleNativeTest.Fixture.field(storage(f), "repository");
var field = repository.getClass().getDeclaredField("replacement"); field.setAccessible(true);
field.set(repository, java.lang.reflect.Proxy.newProxyInstance(field.getType().getClassLoader(), new Class<?>[]{field.getType()},
(proxy, method, args) -> {
assertEquals("move", method.getName());
if (failure.equals("uncertain")) Files.move((Path)args[0], (Path)args[1],
java.nio.file.StandardCopyOption.ATOMIC_MOVE, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
throw new java.io.IOException("controlled coins replacement " + failure);
}));
}
lookup.run(); ReskillEventsNativeTest.await(f, () -> !entry.messages().isEmpty());
assertTrue(entry.messages().getLast().contains(failure.equals("uncertain") ? "uncertain" : "Nothing changed."));
if (!failure.equals("unreadable_after_lookup")) {
var after = storage(f).inspect(before.id()).get().orElseThrow();
assertEquals(failure.equals("uncertain") ? before.coins().add(BigInteger.valueOf(100)) : before.coins(), after.coins());
if (failure.equals("failed")) assertEquals(before, after); else assertUnrelated(before, after);
}
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
assertTrue(adjustments(data).isEmpty());
var terminal = AdminActionEntryNativeTest.rows(data).stream().filter(row -> row.getAsJsonObject("data").get("stage").getAsString().equals("result")).toList().getLast();
assertEquals(failure.equals("uncertain") ? "uncertain" : "failed", terminal.getAsJsonObject("data").get("outcome").getAsString());
}
}
@ParameterizedTest @ValueSource(strings = {"submission", "feedback", "audit", "store", "sink"})
void eventAndFeedbackFailuresNeverAlterAcknowledgedMutation(String fault) throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f; var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
var before = storage(f).inspect(fixture.actor.core.record.id()).get().orElseThrow();
dispatch(entry, before.id()); callback(f).run();
var after = storage(f).inspect(before.id()).get().orElseThrow(); var complete = callback(f);
var events = (AdminCoinsEventBridge) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "adminCoinsEvents");
var failures = new java.util.concurrent.CopyOnWriteArrayList<Throwable>();
AdminCoinsEventBridge replacement = null;
if (fault.equals("submission")) {
owner(f).attachEvents(f.own(org.mockito.Mockito.mock(AdminCoinsEventBridge.class, call -> {
if (call.getMethod().getName().equals("adjusted")) throw new java.util.concurrent.RejectedExecutionException("controlled coins submission rejection");
throw new AssertionError("Unexpected bridge port " + call.getMethod());
})));
} else if (fault.equals("feedback")) {
org.mockito.Mockito.doAnswer(call -> { throw new IllegalStateException("controlled coins feedback failure"); })
.when(fixture.actor.player).sendMessage(org.mockito.ArgumentMatchers.anyString());
} else if (fault.equals("audit")) {
owner(f).attachAdminAudit(f.own(org.mockito.Mockito.mock(AdminActionEventBridge.class, call -> {
assertEquals("result", call.getMethod().getName());
throw new java.util.concurrent.RejectedExecutionException("controlled coins admin audit rejection");
})));
} else {
Path root = data.resolve("fault-outbox"); Files.createDirectories(root);
if (fault.equals("store")) Files.writeString(root.resolve("admin-coins-event-intents"), "not a directory");
replacement = new AdminCoinsEventBridge(event -> CompletableFuture.completedFuture(LocalEventLog.Delivery.REJECTED),
Clock.systemUTC(), root, sessions(f), failures::add);
owner(f).attachEvents(replacement);
}
try {
assertDoesNotThrow(complete::run, "postcommit side effects may not escape the operation owner");
assertDoesNotThrow(complete::run);
assertEquals(after, storage(f).inspect(before.id()).get().orElseThrow()); assertUnrelated(before, after);
assertEquals(before.coins().add(BigInteger.valueOf(100)), after.coins());
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
if (!fault.equals("audit")) {
var terminal = AdminActionEntryNativeTest.rows(data).stream().filter(row -> row.getAsJsonObject("data").get("stage").getAsString().equals("result")).toList().getLast();
assertEquals("completed", terminal.getAsJsonObject("data").get("outcome").getAsString());
}
if (fault.equals("feedback") || fault.equals("audit")) assertEquals(1, adjustments(data).size());
} finally {
owner(f).attachEvents(events);
if (fault.equals("feedback")) org.mockito.Mockito.doAnswer(fixture.actor).when(fixture.actor.player)
.sendMessage(org.mockito.ArgumentMatchers.anyString());
if (replacement != null) { replacement.close(); replacement.drained().toCompletableFuture().get(5, TimeUnit.SECONDS); }
}
if (fault.equals("store")) assertEquals(1, failures.size(), "real outbox store failure is diagnosed, not suppressed");
if (fault.equals("sink")) try (var paths = Files.list(data.resolve("fault-outbox/admin-coins-event-intents"))) {
assertEquals(1, paths.filter(path -> path.toString().endsWith(".pending")).count());
}
}
}
@Test void actualCachedAutocompleteAllPositionsDedupAndNoIoEvenWithUnreadableDisk() throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f; var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
var other = new FantasyStickLifecycleNativeTest.Actor(data);
storage(f).renameCharacter(other.core.record.id(), fixture.actor.core.record.name()).get();
for (int repeat = 0; repeat < 2; repeat++) {
int count = entry.messages().size();
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin character find " + fixture.actor.core.record.name()));
ReskillEventsNativeTest.await(f, () -> entry.messages().size() > count);
}
var ids = List.of(fixture.actor.core.record.id().toString(), other.core.record.id().toString()).stream().sorted().toList();
var originals = new java.util.HashMap<Path, String>();
try (var paths = Files.list(data.resolve("characters"))) {
for (var path : paths.filter(path -> path.toString().endsWith(".yml")).toList()) {
originals.put(path, Files.readString(path)); Files.writeString(path, "invalid: [");
}
}
var worker = (java.util.concurrent.ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(storage(f), "worker");
var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1);
worker.submit(() -> { entered.countDown(); try { release.await(); } catch (InterruptedException error) { Thread.currentThread().interrupt(); } });
assertTrue(entered.await(5, TimeUnit.SECONDS));
try {
assertTrue(entry.map.tabComplete(entry.sender, "fstickadmin ").contains("character"));
assertEquals(List.of("character"), entry.map.tabComplete(entry.sender, "fstickadmin char"));
assertTrue(entry.map.tabComplete(entry.sender, "fstickadmin character ").containsAll(List.of("coins", "find")));
assertEquals(List.of("coins"), entry.map.tabComplete(entry.sender, "fstickadmin character coi"));
assertEquals(ids, entry.map.tabComplete(entry.sender, "fstickadmin character coins "));
for (var id : ids) assertEquals(List.of(id), entry.map.tabComplete(entry.sender, "fstickadmin character coins " + id.substring(0, 12)));
for (var line : List.of("fstickadmin character coins xyz", "fstickadmin character coins " + ids.getFirst() + " ",
"fstickadmin character coins " + ids.getFirst() + " -999 ", "fstickadmin character unsupported "))
assertEquals(List.of(), entry.map.tabComplete(entry.sender, line));
fixture.actor.allowed = false;
assertNull(entry.map.tabComplete(entry.sender, "fstickadmin character coins "));
assertTrue(adjustments(data).isEmpty());
assertTrue(f.tasks.isEmpty(), "completion must not enqueue lookup or native mutation");
for (var path : originals.keySet()) assertEquals("invalid: [", Files.readString(path), "no read-side repair");
} finally {
release.countDown(); for (var saved : originals.entrySet()) Files.writeString(saved.getKey(), saved.getValue());
}
}
}
@Test void realEventOutboxRestartReplaysExactCapturedPayloadWithoutGameplayReapplication() throws Exception {
EventInput original; UUID character; games.dmg.fantasystick.persistence.CharacterRecord applied;
var received = new java.util.concurrent.CopyOnWriteArrayList<EventInput>();
var failures = new java.util.concurrent.CopyOnWriteArrayList<Throwable>();
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f; var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
var before = storage(f).inspect(fixture.actor.core.record.id()).get().orElseThrow(); character = before.id();
var real = (AdminCoinsEventBridge) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "adminCoinsEvents");
var outbox = new AdminCoinsEventBridge(event -> { received.add(event); return CompletableFuture.completedFuture(LocalEventLog.Delivery.REJECTED); },
Clock.systemUTC(), data, sessions(f), failures::add);
try {
owner(f).attachEvents(outbox); execute(fixture, entry, character.toString(), "100");
applied = storage(f).inspect(character).get().orElseThrow();
} finally {
outbox.close(); outbox.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
owner(f).attachEvents(real);
}
assertEquals(1, received.size()); original = received.getFirst();
assertEquals(AdminCoinsEventBridge.eventId(original.actionId()), original.id());
storage(f).renameCharacter(character, "Later name must not rewrite old event").get();
}
received.clear();
try (var restarted = new QuestTrackingAuditNativeFixture(data)) {
var current = storage(restarted.f).inspect(character).get().orElseThrow();
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(restarted.f);
var row = adjustments(data).getFirst();
assertEquals(original.id().toString(), row.get("id").getAsString());
assertEquals(original.context().playerId().toString(), row.get("playerid").getAsString());
assertEquals(original.time().toString(), row.get("time").getAsString());
for (var value : original.data().entrySet()) {
var expected = new com.google.gson.Gson().toJsonTree(value.getValue());
assertEquals(expected, row.getAsJsonObject("data").get(value.getKey()), "frozen field " + value.getKey());
}
assertEquals(current, storage(restarted.f).inspect(character).get().orElseThrow());
assertEquals(applied.coins(), current.coins()); assertEquals(applied.revision().add(BigInteger.ONE), current.revision());
assertEquals(1, adjustments(data).size()); assertTrue(failures.isEmpty());
}
}
}
@@ -0,0 +1,169 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import com.google.gson.JsonObject;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigInteger;
import java.nio.file.Path;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
/** Real packaged command-map -> serialized YAML -> native session/book -> local JSONL. */
class AdminCharacterCoinsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@ParameterizedTest @ValueSource(strings = {"player", "console"})
void registeredExactInactiveUuidSignedArithmeticAndRestart(String kind) throws Exception {
UUID character; BigInteger result;
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var entry = new AdminActionEntryNativeTest.Entry(fixture, kind, true);
var storage = storage(fixture.f);
var before = storage.inspect(fixture.actor.core.record.id()).get().orElseThrow();
character = before.id();
var sameName = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.f.players.put(sameName.core.id, sameName.player);
storage.renameCharacter(sameName.core.record.id(), before.name()).get();
var untouched = storage.inspect(sameName.core.record.id()).get().orElseThrow();
String huge = "9".repeat(90);
execute(fixture, entry, character.toString(), "+" + huge);
var after = storage.inspect(character).get().orElseThrow();
result = before.coins().add(new BigInteger(huge));
assertEquals(result, after.coins(), "registered coins route must adjust the exact saved UUID");
assertUnrelated(before, after);
assertEquals(untouched, storage.inspect(untouched.id()).get().orElseThrow());
assertTrue(entry.messages().getLast().contains(before.name()));
assertTrue(entry.messages().getLast().contains(character.toString()));
assertTrue(entry.messages().getLast().contains(before.coins().toString()));
execute(fixture, entry, character.toString(), "-" + result);
assertEquals(BigInteger.ZERO, storage.inspect(character).get().orElseThrow().coins());
execute(fixture, entry, character.toString(), "100");
execute(fixture, entry, character.toString(), "+100");
result = BigInteger.valueOf(200);
assertEquals(result, storage.inspect(character).get().orElseThrow().coins(), "deliberate new commands are separate operations");
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(fixture.f);
var rows = adjustments(data); assertEquals(4, rows.size());
assertEquals(4, rows.stream().map(row -> row.get("id").getAsString()).distinct().count());
var values = rows.getFirst().getAsJsonObject("data");
assertEquals(character.toString(), values.get("target_character_id").getAsString());
assertEquals(huge, values.get("delta").getAsString());
assertEquals(before.coins().toString(), values.get("before_coins").getAsString());
assertEquals(after.coins().toString(), values.get("after_coins").getAsString());
assertEquals(before.revision().toString(), values.get("before_revision").getAsString());
assertEquals(after.revision().toString(), values.get("after_revision").getAsString());
assertEquals(kind, values.get("actor_kind").getAsString());
assertEquals("inactive", values.get("target_state").getAsString());
assertEquals("not_applicable", values.get("session_sync").getAsString());
if (kind.equals("console")) {
assertFalse(rows.getFirst().has("playerid")); assertFalse(rows.getFirst().has("characterid"));
assertEquals("not_applicable", values.get("location_status").getAsString());
} else {
assertEquals(fixture.actor.core.id.toString(), rows.getFirst().get("playerid").getAsString());
assertFalse(rows.getFirst().has("characterid"), "an inactive admin has no acting character");
assertEquals(fixture.actor.world.getUID().toString(), values.getAsJsonObject("location").get("world_id").getAsString());
}
var terminal = AdminActionEntryNativeTest.rows(data).stream().filter(row -> {
var v = row.getAsJsonObject("data");
return v.get("stage").getAsString().equals("result") && v.get("outcome").getAsString().equals("completed");
}).toList();
assertEquals(4, terminal.size());
var audit = terminal.getFirst().getAsJsonObject("data");
assertEquals(values.get("operation_id").getAsString(), audit.get("domain_operation_id").getAsString());
assertEquals(values.get("admin_action_id").getAsString(), audit.get("action_id").getAsString());
assertEquals(character.toString(), audit.getAsJsonObject("arguments").get("character_id").getAsString());
assertEquals(huge, audit.getAsJsonObject("arguments").get("delta").getAsString());
}
try (var restarted = new FantasyStickLifecycleNativeTest.Fixture(data)) {
restarted.plugin.setEnabled(true);
assertEquals(result, storage(restarted).inspect(character).get().orElseThrow().coins());
}
assertEquals(4, adjustments(data).stream().map(row -> row.get("id").getAsString()).distinct().count());
}
@Test void belowZeroWarnsWithExactAuthoritativeCalculationAndCompleteRefusal() throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var entry = new AdminActionEntryNativeTest.Entry(fixture, "console", true);
var before = storage(fixture.f).inspect(fixture.actor.core.record.id()).get().orElseThrow();
var delta = before.coins().add(BigInteger.TEN).negate();
execute(fixture, entry, before.id().toString(), delta.toString());
assertTrue(entry.messages().getLast().contains("Balance " + before.coins() + "; adjustment " + delta
+ " would produce -10. Nothing changed."), "underflow is an explicit warning, not usage or partial debit");
assertEquals(before, storage(fixture.f).inspect(before.id()).get().orElseThrow());
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(fixture.f);
assertTrue(adjustments(data).isEmpty());
}
}
@Test void activeSessionBookAndRealCoinPurchaseSeeAcknowledgedBalance() throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f; var a = fixture.actor; f.activate(a);
var entry = new AdminActionEntryNativeTest.Entry(fixture, "console", true);
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
var active = sessions.active(a.core.id).orElseThrow();
var before = sessions.currentRecord(active).orElseThrow();
execute(fixture, entry, before.id().toString(), "+100");
var saved = storage(f).inspect(before.id()).get().orElseThrow();
assertEquals(before.coins().add(BigInteger.valueOf(100)), sessions.currentRecord(active).orElseThrow().coins());
PartyBookNativeTest.open(f, a);
assertTrue(PartyBookNativeTest.text(a).contains("Coins: " + saved.coins()));
var purchase = storage(f).purchaseRare(saved.id(), sessions.currentRecord(active).orElseThrow().revision(),
UUID.randomUUID(), BigInteger.valueOf(50)).get();
assertEquals(CharacterStorage.RarePurchaseStatus.APPLIED, purchase.status());
assertEquals(saved.coins().subtract(BigInteger.valueOf(50)), purchase.record().orElseThrow().coins());
assertTrue(sessions.acknowledgeProgress(active, saved.revision(), purchase.record().orElseThrow()));
PartyBookNativeTest.open(f, a);
assertTrue(PartyBookNativeTest.text(a).contains("Coins: " + purchase.record().orElseThrow().coins()));
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
assertEquals("acknowledged", adjustments(data).getFirst().getAsJsonObject("data").get("session_sync").getAsString());
}
}
@Test void registeredCompletionIsPermissionAwareCachedUuidOnlyAndNonmutating() throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
var id = fixture.actor.core.record.id().toString();
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin character find " + fixture.actor.core.record.name()));
ReskillEventsNativeTest.await(fixture.f, () -> !fixture.actor.messages.isEmpty());
assertEquals(List.of("coins"), entry.map.tabComplete(entry.sender, "fstickadmin character coi"));
assertEquals(List.of(id), entry.map.tabComplete(entry.sender, "fstickadmin character coins " + id.substring(0, 8)));
assertTrue(entry.map.tabComplete(entry.sender, "fstickadmin character coins " + id + " ").isEmpty());
assertTrue(entry.map.tabComplete(entry.sender, "fstickadmin character coins " + id + " 1 ").isEmpty());
assertTrue(entry.map.tabComplete(entry.sender, "fstickadmin character unsupported ").isEmpty());
fixture.actor.allowed = false;
assertNull(entry.map.tabComplete(entry.sender, "fstickadmin character coins "),
"native command-map denies before invoking the completer; no target suggestions disclosed");
assertTrue(adjustments(data).isEmpty());
}
}
static void execute(QuestTrackingAuditNativeFixture fixture, AdminActionEntryNativeTest.Entry entry,
String character, String delta) throws Exception {
int count = entry.messages().size();
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin character coins " + character + " " + delta));
ReskillEventsNativeTest.await(fixture.f, () -> entry.messages().size() > count);
}
static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture f) {
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
}
static List<JsonObject> adjustments(Path root) throws Exception {
return ReskillEventsNativeTest.records(root).stream().filter(row -> row.get("type").getAsString()
.equals("games.dmg.minecraft.fantasystick.character.coins_adjusted")).toList();
}
static void assertUnrelated(CharacterRecord before, CharacterRecord after) {
assertTrue(after.rewardReceipts().containsAll(before.rewardReceipts()), "all prior receipts retained");
assertEquals(before.rewardReceipts().size() + 1, after.rewardReceipts().size(), "only own operation receipt added");
var expected = new CharacterRecord(before.id(), before.name(), after.revision(), before.stats(), before.level(),
before.experience(), before.unspentPoints(), before.health(), before.learned(), before.loadout(), before.passives(),
before.cooldowns(), before.restrictedKills(), before.dead(), before.deathId(), before.diedAt(), after.coins(),
before.coinsInitialized(), after.rewardReceipts(), before.pvpCoinEscrow(), before.gear(), before.sheetGeneration());
assertEquals(expected, after, "only balance, revision and operation bookkeeping may change");
}
}
@@ -0,0 +1,205 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static games.dmg.fantasystick.AdminCharacterCoinsNativeTest.*;
import games.dmg.fantasystick.session.DownedDirectory;
import games.dmg.fantasystick.session.DownedEpisode;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigInteger;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
class AdminCharacterCoinsSafetyNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void malformedUnauthorizedAndPlayerRootCannotMutateOrDisclose() throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
var before = storage(fixture.f).inspect(fixture.actor.core.record.id()).get().orElseThrow();
var nativeBefore = fixture.actor.core.snapshot();
for (String suffix : List.of("", " " + before.id(), " " + before.id() + " 0", " " + before.id() + " +0",
" " + before.id() + " -0", " " + before.id() + " 1.5", " " + before.id() + " 1e2",
" " + before.id() + " --1", " " + before.id() + " +-1", " " + before.id() + " NaN",
" " + before.id() + " 1 extra", " " + before.name() + " 1", " " + fixture.actor.core.id + " 1",
" " + before.id().toString().substring(0, 8) + " 1")) {
int count = entry.messages().size();
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin character coins" + suffix));
ReskillEventsNativeTest.await(fixture.f, () -> entry.messages().size() > count);
assertFalse(entry.messages().getLast().contains("resulting balance"));
}
fixture.actor.allowed = false;
execute(fixture, entry, before.id().toString(), "100");
assertFalse(entry.messages().getLast().contains(before.name()));
assertFalse(entry.messages().getLast().contains(before.id().toString()));
assertTrue(entry.map.dispatch(entry.sender, "fstick character coins " + before.id() + " 100"));
assertEquals(before, storage(fixture.f).inspect(before.id()).get().orElseThrow());
assertArrayEquals(nativeBefore, fixture.actor.core.snapshot());
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(fixture.f);
assertTrue(adjustments(data).isEmpty());
}
}
@ParameterizedTest @ValueSource(strings = {"permission", "logout", "shutdown"})
void lookupCallbackRevalidatesPermissionAndRequestLifetime(String invalidation) throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
var before = storage(fixture.f).inspect(fixture.actor.core.record.id()).get().orElseThrow();
dispatch(entry, before.id()); var lookup = callback(fixture.f);
switch (invalidation) {
case "permission" -> fixture.actor.allowed = false;
case "logout" -> fixture.actor.core.online = false;
case "shutdown" -> fixture.f.plugin.setEnabled(false);
default -> throw new AssertionError(invalidation);
}
lookup.run();
assertEquals(before, new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(before.id()).orElseThrow());
assertTrue(adjustments(data).isEmpty());
}
}
@ParameterizedTest @ValueSource(strings = {"lookup", "completion"})
void duplicateCallbacksCannotRepeatMutationFeedbackOrSuccessEvent(String phase) throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var entry = new AdminActionEntryNativeTest.Entry(fixture, "console", true);
var before = storage(fixture.f).inspect(fixture.actor.core.record.id()).get().orElseThrow();
int count = entry.messages().size();
dispatch(entry, before.id()); var lookup = callback(fixture.f); lookup.run();
if (phase.equals("lookup")) lookup.run();
storage(fixture.f).inspect(before.id()).get();
var completion = callback(fixture.f); completion.run();
if (phase.equals("completion")) completion.run();
while (!fixture.f.tasks.isEmpty()) fixture.f.tasks.remove().run();
assertEquals(before.coins().add(BigInteger.valueOf(100)), storage(fixture.f).inspect(before.id()).get().orElseThrow().coins());
assertEquals(count + 1, entry.messages().size(), "one accepted operation has exactly one result, including duplicate callbacks");
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(fixture.f);
assertEquals(1, adjustments(data).size());
}
}
@ParameterizedTest @ValueSource(strings = {"rejected", "disable_before_callback"})
void acknowledgedWriteFinalizesAuditWithoutNativeCompletion(String interruption) throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f;
var entry = new AdminActionEntryNativeTest.Entry(fixture, "console", true);
var before = storage(f).inspect(fixture.actor.core.record.id()).get().orElseThrow();
dispatch(entry, before.id()); var lookup = callback(f);
if (interruption.equals("rejected")) f.rejectNextMainTask.set(true);
lookup.run();
assertEquals(before.coins().add(BigInteger.valueOf(100)), storage(f).inspect(before.id()).get().orElseThrow().coins());
Runnable completion = interruption.equals("disable_before_callback") ? callback(f) : null;
var events = (games.dmg.fantasystick.events.AdminCoinsEventBridge)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "adminCoinsEvents");
f.plugin.setEnabled(false);
events.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
long retained = 0;
var pending = data.resolve("admin-coins-event-intents");
if (Files.isDirectory(pending)) try (var files = Files.list(pending)) {
retained = files.filter(path -> path.toString().endsWith(".pending")).count();
}
assertEquals(1, adjustments(data).size() + retained,
"acknowledged success audit must be exported or durably retained independently of native completion");
assertTrue(entry.messages().isEmpty(), "interrupted native feedback is not a new success or a replay");
if (completion != null) completion.run();
assertEquals(before.coins().add(BigInteger.valueOf(100)),
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(before.id()).orElseThrow().coins());
}
}
@ParameterizedTest @ValueSource(strings = {"dead", "missing", "corrupt", "recovery", "custody", "downed", "resolved_unpublished", "entering"})
void exactSavedRecordMustBeLivingAndSettledBeforeMutation(String state) throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f; var a = fixture.actor;
var entry = new AdminActionEntryNativeTest.Entry(fixture, "console", true);
var storage = storage(f); var record = storage.inspect(a.core.record.id()).get().orElseThrow();
UUID target = record.id();
switch (state) {
case "dead" -> storage.recordFinalDeath(target, record.revision(), UUID.randomUUID(), java.time.Instant.now(), false).get();
case "missing" -> target = UUID.randomUUID();
case "corrupt" -> Files.writeString(data.resolve("characters").resolve(target + ".yml"), "invalid: [");
case "recovery" -> { Files.createDirectories(data.resolve("character-recoveries"));
Files.writeString(data.resolve("character-recoveries").resolve(target + ".yml"), "retained unreadable proof"); }
case "custody" -> storage.attachUnavailableCharacter(target::equals);
case "downed", "resolved_unpublished" -> {
var downed = (DownedDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "downed");
var episode = downed.begin(target, a.core.id, UUID.randomUUID(), a.world.getUID(), 1, 64, 2, 0, 0,
Duration.ofMinutes(5), new DownedEpisode.Attribution(null, "minecraft:fall")).get();
if (state.equals("resolved_unpublished")) downed.claim(episode.id(), DownedEpisode.Outcome.REVIVED,
UUID.randomUUID(), Duration.ZERO).get();
}
case "entering" -> {
f.changed(a); f.tick(); // Reconciliation starts enter(); the next callback owns the actual saved load.
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
assertTrue(sessions.active(a.core.id).isEmpty());
assertFalse(sessions.acceptsCoinAdjustment(target), "the fixture must establish a real pending entry");
}
default -> throw new AssertionError(state);
}
var path = data.resolve("characters").resolve(target + ".yml");
byte[] savedBytes = Files.exists(path) ? Files.readAllBytes(path) : null;
var nativeBefore = a.core.snapshot();
if (state.equals("entering")) {
var entryLoad = callback(f); // Keep the legitimate activation pending while coins lookup completes.
dispatch(entry, target); callback(f).run();
storage.inspect(target).get();
if (!f.tasks.isEmpty()) callback(f).run();
assertArrayEquals(nativeBefore, a.core.snapshot(), "administration cannot advance the pending activation");
entryLoad.run();
} else {
execute(fixture, entry, target.toString(), "100");
assertArrayEquals(nativeBefore, a.core.snapshot(), "refusal preserves native Inventory");
}
if (savedBytes == null) assertFalse(Files.exists(path), "missing target must not be created");
else assertArrayEquals(savedBytes, Files.readAllBytes(path), "refusal cannot repair, migrate or change unrelated saved fields");
assertTrue(entry.messages().getLast().contains("refused"), "known guard must refuse, not claim uncertainty: " + entry.messages());
if (!state.equals("corrupt") && !state.equals("missing")) {
var after = storage.inspect(target).get().orElseThrow();
assertEquals(record.coins(), after.coins());
}
assertTrue(adjustments(data).isEmpty());
}
}
@ParameterizedTest @ValueSource(strings = {"purchase", "reward", "rename", "death"})
void interveningRevisionCannotLoseAnAcknowledgedWriteOrBypassUnderflow(String competing) throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f; var storage = storage(f);
var entry = new AdminActionEntryNativeTest.Entry(fixture, "console", true);
var before = storage.inspect(fixture.actor.core.record.id()).get().orElseThrow();
// Capture the old authoritative lookup, then commit a competing operation before native validation.
dispatch(entry, before.id()); var lookup = callback(f);
switch (competing) {
case "purchase" -> storage.purchaseRare(before.id(), before.revision(), UUID.randomUUID(), BigInteger.ONE).get();
case "reward" -> storage.awardCombatReward(before.id(), before.revision(), UUID.randomUUID(), BigInteger.ONE,
BigInteger.TEN, games.dmg.fantasystick.progression.ProgressionRules.defaults(),
games.dmg.fantasystick.progression.CombatRewardRules.defaults(), null).get();
case "rename" -> storage.renameCharacter(before.id(), "Renamed 雪").get();
case "death" -> storage.recordFinalDeath(before.id(), before.revision(), UUID.randomUUID(), java.time.Instant.now(), false).get();
default -> throw new AssertionError(competing);
}
var acknowledged = storage.inspect(before.id()).get().orElseThrow();
lookup.run(); ReskillEventsNativeTest.await(f, () -> !entry.messages().isEmpty());
assertEquals(acknowledged, storage.inspect(before.id()).get().orElseThrow());
assertTrue(entry.messages().getLast().contains("refused"));
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
assertTrue(adjustments(data).isEmpty());
}
}
static void dispatch(AdminActionEntryNativeTest.Entry entry, UUID id) {
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin character coins " + id + " 100"));
}
static Runnable callback(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
var result = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(result, "real owner callback required"); return result;
}
}
@@ -81,7 +81,7 @@ class EquipmentAdminCommandNativeTest {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var target = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(target.core.id,target.player);
var sender=new Sender();
assertEquals(List.of("adventure-site","config","dungeon","experience","give","inspect","party-registrar","quest-giver","rare-shop","recover","shop","training-dummy"),f.command.tabComplete(sender.port,"fantasystick",new String[]{""}));
assertEquals(List.of("adventure-site","character","config","dungeon","experience","give","inspect","party-registrar","quest-giver","rare-shop","recover","shop","training-dummy"),f.command.tabComplete(sender.port,"fantasystick",new String[]{""}));
assertEquals(List.of("give"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"g"}));
assertEquals(List.of("Account"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"give",""}));
assertEquals(List.of("Account"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"give","Acc"}));
@@ -122,7 +122,7 @@ class EquipmentAdminCommandNativeTest {
f.players.put(first.core.id, first.player); f.players.put(second.core.id, second.player); f.players.put(offline.core.id, offline.player);
var sender = new Sender();
var expected = java.util.stream.Stream.of(first.core.id.toString(), second.core.id.toString()).sorted().toList();
assertEquals(List.of("adventure-site", "config", "dungeon", "experience", "give", "inspect", "party-registrar", "quest-giver", "rare-shop", "recover", "shop", "training-dummy"), f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
assertEquals(List.of("adventure-site", "character", "config", "dungeon", "experience", "give", "inspect", "party-registrar", "quest-giver", "rare-shop", "recover", "shop", "training-dummy"), f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
assertEquals(List.of("inspect"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"in"}));
assertEquals(List.of("recover"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"rec"}));
assertEquals(expected, f.command.tabComplete(sender.port, "fantasystick", new String[]{"inspect", ""}));
@@ -570,9 +570,9 @@ class LongbowNativeTest {
assertThrows(AssertionError.class, () -> abilityButton(unavailable, "[Learn]", "longbow"));
var sender = new EquipmentAdminCommandNativeTest.Sender();
assertEquals(java.util.List.of("adventure-site", "config", "dungeon", "experience", "give", "inspect", "party-registrar", "quest-giver", "rare-shop", "recover", "shop", "training-dummy"),
assertEquals(java.util.List.of("adventure-site", "character", "config", "dungeon", "experience", "give", "inspect", "party-registrar", "quest-giver", "rare-shop", "recover", "shop", "training-dummy"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
assertEquals(java.util.List.of("config"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"c"}));
assertEquals(java.util.List.of("character", "config"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"c"}));
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"unknown"}).isEmpty());
assertEquals(java.util.List.of("longbow-base-damage"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "longbow-b"}));
@@ -17,6 +17,25 @@ class PartyProtectionEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void publishedJsonlReaderIgnoresPendingArtifactsAndIncompleteFinalLines() throws Exception {
var directory = Files.createDirectories(data.resolve("events")
.resolve(java.time.LocalDate.ofEpochDay(0).toString()));
var published = "{\"type\":\"action.resolved\",\"data\":{\"reason\":\"same_party\"}}";
Files.writeString(directory.resolve("events-exported.jsonl"), published + "\n\n{\"partial\":");
var pending = Files.createDirectories(data.resolve("events/pending"));
Files.writeString(pending.resolve("not-an-export.jsonl"), "{broken\n");
assertEquals(List.of(JsonParser.parseString(published).getAsJsonObject()), records(),
"Only complete published daily JSONL records may enter the observation");
}
@Test void publishedJsonlReaderPropagatesMalformedCompleteRecords() throws Exception {
var directory = Files.createDirectories(data.resolve("events")
.resolve(java.time.LocalDate.ofEpochDay(0).toString()));
Files.writeString(directory.resolve("events-exported.jsonl"), "{broken\n");
assertThrows(com.google.gson.JsonParseException.class, this::records,
"A malformed published record remains an error, not an empty observation");
}
@Test void currentCharacterPartyNativeDirectDamageRefusalHasOneCorrelatedFinalDecision() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
@@ -379,13 +398,19 @@ class PartyProtectionEventsNativeTest {
var result = new ArrayList<JsonObject>();
var directory = data.resolve("events");
if (!Files.exists(directory)) return result;
try (var paths = Files.walk(directory)) {
for (var path : paths.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList()) {
var content = Files.readString(path);
int end = content.lastIndexOf('\n');
if (end < 0) continue;
for (var line : content.substring(0, end).lines().toList())
if (!line.isBlank()) result.add(JsonParser.parseString(line).getAsJsonObject());
// Match the declared exported layout; pending intents are concurrently deleted by the writer.
try (var dates = Files.list(directory)) {
for (var date : dates.filter(path -> path.getFileName().toString()
.matches("[0-9]{4}-[0-9]{2}-[0-9]{2}")).toList()) {
try (var paths = Files.list(date)) {
for (var path : paths.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList()) {
var content = Files.readString(path);
int end = content.lastIndexOf('\n');
if (end < 0) continue;
for (var line : content.substring(0, end).lines().toList())
if (!line.isBlank()) result.add(JsonParser.parseString(line).getAsJsonObject());
}
}
}
}
return result;
@@ -389,7 +389,7 @@ class QuickstepNativeTest {
assertEquals(0, new java.math.BigDecimal("6").compareTo(definition.parameters().get("distance")));
assertEquals(0, new java.math.BigDecimal("1.5").compareTo(definition.parameters().get("speed")));
var sender = new EquipmentAdminCommandNativeTest.Sender();
assertEquals(List.of("adventure-site", "config", "dungeon", "experience", "give", "inspect", "party-registrar", "quest-giver", "rare-shop", "recover", "shop", "training-dummy"),
assertEquals(List.of("adventure-site", "character", "config", "dungeon", "experience", "give", "inspect", "party-registrar", "quest-giver", "rare-shop", "recover", "shop", "training-dummy"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
assertEquals(List.of("config"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"co"}));
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"unsupported"}).isEmpty());
@@ -304,7 +304,7 @@ class SecondWindNativeTest {
assertEquals(BigInteger.TEN.pow(500).subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(6)),
games.dmg.fantasystick.ability.SecondWind.healing(BigInteger.TEN.pow(500)));
var sender = new EquipmentAdminCommandNativeTest.Sender();
assertEquals(List.of("adventure-site", "config", "dungeon", "experience", "give", "inspect", "party-registrar", "quest-giver", "rare-shop", "recover", "shop", "training-dummy"),
assertEquals(List.of("adventure-site", "character", "config", "dungeon", "experience", "give", "inspect", "party-registrar", "quest-giver", "rare-shop", "recover", "shop", "training-dummy"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
sender.allowed = false;
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "second"}).isEmpty());
@@ -126,7 +126,17 @@ class FeatureEventOutboxCapacityTest {
var reader = new FeatureEventOutbox(data, "held-intents", Set.of(TYPE), input -> {
replayed.add(input); return CompletableFuture.completedFuture(LocalEventLog.Delivery.EXPORTED);
}, replayFailures::add);
reader.replay(); reader.close(); awaitOwnedWorker(reader);
reader.replay();
try {
var field = FeatureEventOutbox.class.getDeclaredField("worker");
field.setAccessible(true);
var worker = (java.util.concurrent.ExecutorService) field.get(reader);
// This sink settles synchronously on the owned worker. Its queue fence proves
// replay plus intent deletion completed, rather than testing the five-second close deadline.
worker.submit(() -> { }).get(30, TimeUnit.SECONDS);
} finally {
reader.close(); awaitOwnedWorker(reader);
}
assertEquals(Set.copyOf(inputs.subList(0, CAPACITY)), Set.copyOf(replayed));
assertEquals(CAPACITY, replayed.size());
assertTrue(replayFailures.isEmpty(), replayFailures.toString());
@@ -0,0 +1,226 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.progression.ProgressionRules;
import java.io.IOException;
import java.math.BigInteger;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Clock;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Real serialized disk authority and injected atomic replacement, never a fake currency wallet. */
class AdminCoinAdjustmentStorageTest {
@TempDir Path data;
@Test void stableOperationRetainsOriginalAcknowledgmentAcrossNewWritesAndRestart() throws Exception {
var repository = new YamlCharacterRepository(data);
var before = new games.dmg.fantasystick.crafting.CharacterFactory().create(ProgressionRules.defaults());
UUID operation = UUID.randomUUID(); CharacterRecord applied;
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
storage.create(before).join();
var result = storage.adjustCoins(before.id(), before.revision(), operation, BigInteger.TEN).join();
assertEquals("APPLIED", result.status().name()); applied = result.record().orElseThrow();
storage.renameCharacter(before.id(), "Later unrelated rename").join();
}
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
var current = storage.inspect(before.id()).join().orElseThrow();
var duplicate = storage.adjustCoins(before.id(), before.revision(), operation, BigInteger.TEN).join();
assertEquals("DUPLICATE", duplicate.status().name());
assertEquals(before, duplicate.before().orElseThrow(), "recovery retains the original before proof, not current state");
assertEquals(applied, duplicate.record().orElseThrow(), "recovery retains the original acknowledged revision");
assertEquals(current, storage.inspect(before.id()).join().orElseThrow());
}
}
@Test void receiptEvictionAndChangedArgumentsCannotReplayOrRebindStableOperation() throws Exception {
var repository = new YamlCharacterRepository(data);
var before = new games.dmg.fantasystick.crafting.CharacterFactory().create(ProgressionRules.defaults());
var other = new games.dmg.fantasystick.crafting.CharacterFactory().create(ProgressionRules.defaults());
var operation = UUID.randomUUID();
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
storage.create(before).join(); storage.create(other).join();
var applied = storage.adjustCoins(before.id(), before.revision(), operation, BigInteger.TEN).join().record().orElseThrow();
// Model the existing bounded receipt ledger's legitimate later eviction without changing any balances.
var evicted = new CharacterRecord(applied.id(), applied.name(), applied.revision().add(BigInteger.ONE), applied.stats(),
applied.level(), applied.experience(), applied.unspentPoints(), applied.health(), applied.learned(), applied.loadout(),
applied.passives(), applied.cooldowns(), applied.restrictedKills(), applied.dead(), applied.deathId(), applied.diedAt(),
applied.coins(), applied.coinsInitialized(), Set.of(), applied.pvpCoinEscrow(), applied.gear(), applied.sheetGeneration());
repository.replace(evicted, applied.revision());
}
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
var current = storage.inspect(before.id()).join().orElseThrow();
var duplicate = storage.adjustCoins(before.id(), current.revision(), operation, BigInteger.TEN).join();
assertEquals("DUPLICATE", duplicate.status().name(), "durable operation authority must outlive bounded receipts");
assertEquals(current, storage.inspect(before.id()).join().orElseThrow());
assertEquals("UNAVAILABLE", storage.adjustCoins(other.id(), other.revision(), operation, BigInteger.TEN).join().status().name());
assertEquals(other, storage.inspect(other.id()).join().orElseThrow());
assertEquals("UNAVAILABLE", storage.adjustCoins(before.id(), current.revision(), operation, BigInteger.ONE).join().status().name());
assertEquals(current, storage.inspect(before.id()).join().orElseThrow());
}
}
@Test void unreadableOperationProofIsAConfirmedPrecommitFailure() throws Exception {
var repository = new YamlCharacterRepository(data);
var before = new games.dmg.fantasystick.crafting.CharacterFactory().create(ProgressionRules.defaults());
var operation = UUID.randomUUID();
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
storage.create(before).join();
Files.createDirectories(data.resolve("admin-coin-operations"));
var proof = data.resolve("admin-coin-operations").resolve(operation + ".proof");
Files.writeString(proof, "not an operation proof");
var original = Files.readString(proof);
var outcome = assertDoesNotThrow(() -> storage.adjustCoins(before.id(), before.revision(), operation, BigInteger.TEN).join());
assertEquals(CharacterStorage.CoinAdjustmentStatus.FAILED, outcome.status());
assertEquals(before, storage.inspect(before.id()).join().orElseThrow());
assertEquals(original, Files.readString(proof), "invalid evidence is retained, not repaired or rebound");
}
}
@Test void appliedProofCannotAcknowledgeTamperedUnrelatedSnapshots() throws Exception {
var repository = new YamlCharacterRepository(data);
var before = new games.dmg.fantasystick.crafting.CharacterFactory().create(ProgressionRules.defaults());
var operation = UUID.randomUUID(); CharacterRecord after;
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
storage.create(before).join();
after = storage.adjustCoins(before.id(), before.revision(), operation, BigInteger.TEN).join().record().orElseThrow();
}
var proof = data.resolve("admin-coin-operations").resolve(operation + ".proof");
var fields = new java.util.Properties();
try (var input = Files.newInputStream(proof)) { fields.load(input); }
fields.setProperty("after", CharacterYaml.encode(new CharacterRecord(after.id(), "Forged unrelated name", after.revision(),
after.stats(), after.level(), after.experience(), after.unspentPoints(), after.health(), after.learned(), after.loadout(),
after.passives(), after.cooldowns(), after.restrictedKills(), after.dead(), after.deathId(), after.diedAt(), after.coins(),
after.coinsInitialized(), after.rewardReceipts(), after.pvpCoinEscrow(), after.gear(), after.sheetGeneration())));
try (var output = Files.newOutputStream(proof)) { fields.store(output, "controlled invalid snapshot"); }
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
var outcome = assertDoesNotThrow(() -> storage.adjustCoins(before.id(), before.revision(), operation, BigInteger.TEN).join());
assertEquals(CharacterStorage.CoinAdjustmentStatus.FAILED, outcome.status(), "invalid proof cannot fabricate an APPLIED acknowledgment");
assertEquals(after, storage.inspect(before.id()).join().orElseThrow());
}
}
@Test void internallyConsistentProofCannotRebindAcknowledgmentToAnotherUnchangedRecord() throws Exception {
var repository = new YamlCharacterRepository(data);
var factory = new games.dmg.fantasystick.crafting.CharacterFactory();
var before = factory.create(ProgressionRules.defaults()); var other = factory.create(ProgressionRules.defaults());
var operation = UUID.randomUUID();
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
storage.create(before).join(); storage.create(other).join();
storage.adjustCoins(before.id(), before.revision(), operation, BigInteger.TEN).join();
}
var forged = new CharacterRecord(other.id(), other.name(), other.revision().add(BigInteger.ONE), other.stats(), other.level(),
other.experience(), other.unspentPoints(), other.health(), other.learned(), other.loadout(), other.passives(), other.cooldowns(),
other.restrictedKills(), other.dead(), other.deathId(), other.diedAt(), other.coins().add(BigInteger.TEN), other.coinsInitialized(),
Set.of(operation), other.pvpCoinEscrow(), other.gear(), other.sheetGeneration());
new AdminCoinOperationStore(data).write(new AdminCoinOperationStore.Proof(operation, BigInteger.TEN, other, forged,
AdminCoinOperationStore.Phase.APPLIED));
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
var outcome = assertDoesNotThrow(() -> storage.adjustCoins(other.id(), other.revision(), operation, BigInteger.TEN).join());
assertEquals(CharacterStorage.CoinAdjustmentStatus.FAILED, outcome.status(), "a proof cannot acknowledge a replacement absent from saved truth");
assertEquals(other, storage.inspect(other.id()).join().orElseThrow());
}
}
@Test void legacyUninitializedZeroMustNotBeMigratedOrMintedByAdministration() throws Exception {
var repository = new YamlCharacterRepository(data);
var fresh = new games.dmg.fantasystick.crafting.CharacterFactory().create(ProgressionRules.defaults());
var before = new CharacterRecord(fresh.id(), fresh.name(), fresh.revision(), fresh.stats(), fresh.level(), fresh.experience(),
fresh.unspentPoints(), fresh.health(), fresh.learned(), fresh.loadout(), fresh.passives(), fresh.cooldowns(), fresh.restrictedKills(),
fresh.dead(), fresh.deathId(), fresh.diedAt(), BigInteger.ZERO, false, fresh.rewardReceipts(), fresh.pvpCoinEscrow(), fresh.gear(), fresh.sheetGeneration());
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
storage.create(before).join();
var file = data.resolve("characters").resolve(before.id() + ".yml"); var bytes = Files.readAllBytes(file);
var outcome = assertDoesNotThrow(() -> storage.adjustCoins(before.id(), before.revision(), UUID.randomUUID(), BigInteger.TEN).join());
assertEquals(CharacterStorage.CoinAdjustmentStatus.UNAVAILABLE, outcome.status());
assertArrayEquals(bytes, Files.readAllBytes(file)); assertEquals(before, storage.inspect(before.id()).join().orElseThrow());
}
}
@Test void preparedAndUncertainCrashProofsNeverMintOrReapplyCoins() throws Exception {
for (var phase : java.util.List.of(AdminCoinOperationStore.Phase.PREPARED, AdminCoinOperationStore.Phase.UNCERTAIN)) {
for (boolean committed : new boolean[]{false, true}) {
var root = data.resolve(phase + "-" + committed);
var repository = new YamlCharacterRepository(root);
var before = new games.dmg.fantasystick.crafting.CharacterFactory().create(ProgressionRules.defaults());
var operation = UUID.randomUUID(); CharacterRecord after;
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
storage.create(before).join();
after = storage.adjustCoins(before.id(), before.revision(), operation, BigInteger.TEN).join().record().orElseThrow();
}
if (!committed) repository.replace(new CharacterRecord(before.id(), before.name(), after.revision().add(BigInteger.ONE),
before.stats(), before.level(), before.experience(), before.unspentPoints(), before.health(), before.learned(),
before.loadout(), before.passives(), before.cooldowns(), before.restrictedKills(), before.dead(), before.deathId(),
before.diedAt(), before.coins(), before.coinsInitialized(), before.rewardReceipts(), before.pvpCoinEscrow(), before.gear(),
before.sheetGeneration()), after.revision());
new AdminCoinOperationStore(root).write(new AdminCoinOperationStore.Proof(operation, BigInteger.TEN, before, after, phase));
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
var current = storage.inspect(before.id()).join().orElseThrow();
assertEquals(CharacterStorage.CoinAdjustmentStatus.UNCERTAIN,
storage.adjustCoins(before.id(), current.revision(), operation, BigInteger.TEN).join().status());
assertEquals(current, storage.inspect(before.id()).join().orElseThrow());
}
}
}
}
@Test void stoppedWriteOwnerReturnsConfirmedFailedAdmissionWithoutTouchingSavedRecord() throws Exception {
var repository = new YamlCharacterRepository(data);
var before = new games.dmg.fantasystick.crafting.CharacterFactory().create(ProgressionRules.defaults());
var storage = new CharacterStorage(repository, Clock.systemUTC());
storage.create(before).join(); storage.close(); storage.drained().toCompletableFuture().join();
var outcome = assertDoesNotThrow(() -> storage.adjustCoins(before.id(), before.revision(), UUID.randomUUID(), BigInteger.TEN).join());
assertEquals(CharacterStorage.CoinAdjustmentStatus.FAILED, outcome.status());
assertEquals(before, repository.load(before.id()).orElseThrow());
assertFalse(Files.exists(data.resolve("admin-coin-operations")), "rejected admission cannot create an operation proof");
}
@Test void fullReceiptLedgerIsPreservedAndNeverBecomesANewAdministrativeBalanceCap() throws Exception {
var repository = new YamlCharacterRepository(data);
var fresh = new games.dmg.fantasystick.crafting.CharacterFactory().create(ProgressionRules.defaults());
var receipts = new java.util.HashSet<UUID>(); for (int i = 0; i < 4096; i++) receipts.add(UUID.randomUUID());
var before = new CharacterRecord(fresh.id(), fresh.name(), fresh.revision(), fresh.stats(), fresh.level(), fresh.experience(),
fresh.unspentPoints(), fresh.health(), fresh.learned(), fresh.loadout(), fresh.passives(), fresh.cooldowns(), fresh.restrictedKills(),
fresh.dead(), fresh.deathId(), fresh.diedAt(), fresh.coins(), fresh.coinsInitialized(), receipts, fresh.pvpCoinEscrow(), fresh.gear(), fresh.sheetGeneration());
var operation = UUID.randomUUID();
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
storage.create(before).join();
var outcome = storage.adjustCoins(before.id(), before.revision(), operation, BigInteger.TEN).join();
assertEquals(CharacterStorage.CoinAdjustmentStatus.APPLIED, outcome.status());
var after = outcome.record().orElseThrow(); assertEquals(receipts, after.rewardReceipts());
assertEquals(before.coins().add(BigInteger.TEN), after.coins());
assertEquals(CharacterStorage.CoinAdjustmentStatus.DUPLICATE,
storage.adjustCoins(before.id(), after.revision(), operation, BigInteger.TEN).join().status());
assertEquals(after, storage.inspect(before.id()).join().orElseThrow());
}
}
@Test void confirmedPrecommitFailureAndUncertainReplacementAreDistinctAndNeverBlindlyReapplied() throws Exception {
for (boolean moved : new boolean[]{false, true}) {
Path root = data.resolve(moved ? "uncertain" : "failed");
var fail = new AtomicBoolean();
var repository = new YamlCharacterRepository(root, (source, target) -> {
if (!fail.get() || moved) Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
if (fail.get()) throw new IOException("controlled replacement outcome");
});
var before = new games.dmg.fantasystick.crafting.CharacterFactory().create(ProgressionRules.defaults());
UUID operation = UUID.randomUUID();
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
storage.create(before).join(); fail.set(true);
var outcome = assertDoesNotThrow(() -> storage.adjustCoins(before.id(), before.revision(), operation, BigInteger.TEN).join());
assertEquals(moved ? "UNCERTAIN" : "FAILED", outcome.status().name());
var current = storage.inspect(before.id()).join().orElseThrow();
assertEquals(moved ? before.coins().add(BigInteger.TEN) : before.coins(), current.coins());
fail.set(false);
var retry = storage.adjustCoins(before.id(), current.revision(), operation, BigInteger.TEN).join();
assertNotEquals("APPLIED", retry.status().name(), "uncertain or failed old operation is not a new command");
assertEquals(current, storage.inspect(before.id()).join().orElseThrow());
}
}
}
}