This commit is contained in:
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"});
|
||||
|
||||
+225
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
+468
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user