feat(issuance): export acknowledged admin sheet events
Release / release (push) Successful in 18m0s

This commit is contained in:
dmg committed 2026-09-26 12:25:24 -04:00
1 parent 2ae8f1ddf9
commit 439c1b9cdd
6 files changed
+400 -18

No files matched your search

@@ -802,6 +802,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
playerId -> identitySessions.active(playerId).map(games.dmg.fantasystick.session.EquipmentSessions.Active::session),
failure -> getLogger().log(Level.WARNING, "Character identity event intent failed", failure));
crafting.attachIdentityEvents(identityAuditEvents);
admin.attachCharacterIssueEvents(identityAuditEvents);
anvilNames.attachIdentityEvents(identityAuditEvents);
sessions.attachIdentityEvents(identityAuditEvents);
equipmentAuditEvents = new games.dmg.fantasystick.events.EquipmentEventBridge(
@@ -14,6 +14,8 @@ import java.util.concurrent.CompletionStage;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.Location;
import org.bukkit.command.BlockCommandSender;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
/** Captures actual identity-creating and identity-changing native actions, never previews. */
@@ -37,7 +39,7 @@ public final class CharacterIdentityEventBridge implements AutoCloseable {
this.clock = Objects.requireNonNull(clock);
this.activeSession = Objects.requireNonNull(activeSession);
this.outbox = new FeatureEventOutbox(Objects.requireNonNull(dataFolder),
"character-identity-event-intents", java.util.Set.of("character.created", "character.renamed"),
"character-identity-event-intents", java.util.Set.of("character.created", "character.renamed", "character.issued"),
sink, Objects.requireNonNull(failures));
}
@@ -74,6 +76,57 @@ public final class CharacterIdentityEventBridge implements AutoCloseable {
} catch (RuntimeException ignored) { /* Observation never authorizes a character session. */ }
}
public record Issue(UUID operation, String actorKind, EventInput.Context actor,
UUID targetPlayer, String targetName) { }
/** Capture the administrator before asynchronous saving; no second admin.action is produced. */
public Issue issue(CommandSender sender, Player target) {
try {
var player = sender instanceof Player actor ? actor : null;
var kind = player != null ? "player" : sender instanceof BlockCommandSender ? "command_block" : "console";
EventInput.Context context = player == null ? null : new EventInput.Context(player.getUniqueId(),
player.getName(), null, null, null, safeLocation(player));
return new Issue(UUID.randomUUID(), kind, context, target.getUniqueId(), target.getName());
} catch (RuntimeException ignored) { return null; }
}
/** The saved character and exact native inventory slot were acknowledged before this call. */
public void issued(Issue issue, Player target, CharacterRecord record, int slot) {
if (issue == null) return;
try {
var data = new LinkedHashMap<String, Object>();
data.put("actor_kind", issue.actorKind());
data.put("issue_operation_id", issue.operation().toString());
data.put("target_player_id", issue.targetPlayer().toString());
data.put("target_player_name", issue.targetName());
data.put("character_id", record.id().toString());
data.put("character_name", record.name());
data.put("revision", record.revision().toString());
data.put("generation", record.sheetGeneration().toString());
data.put("starting_level", Integer.toString(record.level()));
data.put("starting_unspent_points", record.unspentPoints().toString());
data.put("starting_health", record.health().toPlainString());
data.put("starting_coins", record.coins().toString());
var stats = new LinkedHashMap<String, Object>();
record.stats().forEach((stat, value) -> stats.put(stat.name().toLowerCase(java.util.Locale.ROOT), value.toString()));
data.put("starting_stats", stats);
data.put("delivered_slot", slot);
data.put("delivery_outcome", "native_inventory_acknowledged");
data.put("outcome", "acknowledged_delivered");
if (issue.actor() == null) data.put("actor_location_status", "not_applicable");
else if (issue.actor().location() == null) data.put("actor_location_status", "unavailable");
var targetLocation = safeLocation(target);
if (targetLocation != null) data.put("target_location", locationMap(targetLocation, "exact"));
else data.put("target_location_status", "unavailable");
durable("character.issued", issue.operation(), clock.instant(), issue.actor(), data, "issued");
} catch (RuntimeException ignored) { /* Audit cannot change acknowledged native issuance. */ }
}
private EventInput.Location safeLocation(Player player) {
try { return location(player.getLocation()); }
catch (RuntimeException unavailable) { return null; }
}
public record Craft(UUID action, Instant time, EventInput.Context actor, String route, int quantity,
UUID holderSession) { }
@@ -100,15 +100,14 @@ final class FeatureEventOutbox implements AutoCloseable {
fields.setProperty("quality", location.quality());
}
event.data().forEach((key, value) -> {
if (value instanceof String text) fields.setProperty("data." + key, "s" + text);
else if (value instanceof Boolean bool) fields.setProperty("data." + key, "b" + bool);
else if (value instanceof Integer number) fields.setProperty("data." + key, "i" + number);
else if (value instanceof Double number && Double.isFinite(number))
fields.setProperty("data." + key, "d" + number);
else if (value instanceof java.util.List<?> list && list.stream().allMatch(item -> item instanceof String text
&& (text.equals("hotbar") || text.equals("armor"))))
fields.setProperty("data." + key, "l" + String.join(",", list.stream().map(String.class::cast).toList()));
else throw new IllegalArgumentException("Unsupported feature intent value");
if (value instanceof java.util.Map<?, ?> nested) {
if (key.contains(".")) throw new IllegalArgumentException("Unsafe nested feature intent key");
nested.forEach((name, part) -> {
if (!(name instanceof String child) || child.contains("."))
throw new IllegalArgumentException("Unsafe nested feature intent key");
encode(fields, "data." + key + "." + child, part);
});
} else encode(fields, "data." + key, value);
});
var output = new ByteArrayOutputStream();
fields.store(output, "acknowledged character identity event");
@@ -127,6 +126,17 @@ final class FeatureEventOutbox implements AutoCloseable {
return event;
}
private static void encode(Properties fields, String key, Object value) {
if (value instanceof String text) fields.setProperty(key, "s" + text);
else if (value instanceof Boolean bool) fields.setProperty(key, "b" + bool);
else if (value instanceof Integer number) fields.setProperty(key, "i" + number);
else if (value instanceof Double number && Double.isFinite(number)) fields.setProperty(key, "d" + number);
else if (value instanceof java.util.List<?> list && list.stream().allMatch(item -> item instanceof String text
&& (text.equals("hotbar") || text.equals("armor"))))
fields.setProperty(key, "l" + String.join(",", list.stream().map(String.class::cast).toList()));
else throw new IllegalArgumentException("Unsupported feature intent value");
}
private EventInput read(Path path) throws IOException {
if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe character event intent");
var fields = new Properties();
@@ -155,10 +165,11 @@ final class FeatureEventOutbox implements AutoCloseable {
fields.containsKey("session") ? UUID.fromString(fields.getProperty("session")) : null, location)
: null;
var data = new LinkedHashMap<String, Object>();
var nested = new LinkedHashMap<String, java.util.Map<String, Object>>();
for (var key : fields.stringPropertyNames()) if (key.startsWith("data.")) {
var value = fields.getProperty(key);
if (value.isEmpty()) throw new IOException("Invalid character event value");
data.put(key.substring(5), switch (value.charAt(0)) {
var decoded = switch (value.charAt(0)) {
case 's' -> value.substring(1);
case 'b' -> {
if (!value.equals("btrue") && !value.equals("bfalse")) throw new IOException("Invalid boolean");
@@ -178,8 +189,20 @@ final class FeatureEventOutbox implements AutoCloseable {
yield list;
}
default -> throw new IOException("Invalid feature event value type");
});
};
var name = key.substring(5);
var separator = name.indexOf('.');
if (separator < 0) data.put(name, decoded);
else {
if (separator == 0 || separator == name.length() - 1 || name.indexOf('.', separator + 1) >= 0)
throw new IOException("Invalid nested feature intent key");
nested.computeIfAbsent(name.substring(0, separator), ignored -> new LinkedHashMap<>())
.put(name.substring(separator + 1), decoded);
}
}
nested.forEach((key, value) -> {
if (data.putIfAbsent(key, value) != null) throw new IllegalArgumentException("Conflicting feature intent key");
});
return new EventInput(id, action, Instant.parse(fields.getProperty("time")), type, context, data);
} catch (RuntimeException failure) { throw new IOException("Invalid character event intent", failure); }
}
@@ -9,6 +9,7 @@ import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import java.util.function.Supplier;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
/** Persists one fresh character before handing its authentic sheet to a still-valid native inventory slot. */
@@ -22,6 +23,11 @@ public final class AdminCharacterIssuer implements AutoCloseable {
private final Consumer<Failure> failures;
private final ConcurrentHashMap<UUID, Attempt> pending = new ConcurrentHashMap<>();
private volatile boolean closed;
private games.dmg.fantasystick.events.CharacterIdentityEventBridge identityEvents;
public void attachIdentityEvents(games.dmg.fantasystick.events.CharacterIdentityEventBridge events) {
identityEvents = Objects.requireNonNull(events);
}
public AdminCharacterIssuer(CharacterStorage storage, Supplier<CharacterRecord> allocate,
Consumer<Runnable> nextTick, Consumer<Failure> failures) {
@@ -32,12 +38,13 @@ public final class AdminCharacterIssuer implements AutoCloseable {
}
/** Must be entered on the server thread. Completion is delivered on that thread. */
public void issue(Player player, Consumer<Outcome> response) {
Objects.requireNonNull(player); Objects.requireNonNull(response);
public void issue(CommandSender sender, Player player, Consumer<Outcome> response) {
Objects.requireNonNull(sender); Objects.requireNonNull(player); Objects.requireNonNull(response);
if (closed || !player.isOnline()) { response.accept(Outcome.STALE); return; }
int slot = player.getInventory().firstEmpty();
if (slot < 0) { response.accept(Outcome.FULL); return; }
var attempt = new Attempt(player, slot, response);
var audit = identityEvents;
var attempt = new Attempt(player, slot, response, audit == null ? null : audit.issue(sender, player));
if (pending.putIfAbsent(attempt.playerId, attempt) != null) { response.accept(Outcome.BUSY); return; }
try {
attempt.record = allocate.get();
@@ -74,9 +81,15 @@ public final class AdminCharacterIssuer implements AutoCloseable {
|| !claimed.orElseThrow().getFirst().id().equals(attempt.record.id())) {
abandon(attempt); respond(attempt, Outcome.RETAINED); return;
}
var savedRecord = claimed.orElseThrow().getFirst();
try {
var sheet = new CharacterSheets().create(attempt.record);
var sheets = new CharacterSheets();
var sheet = sheets.create(savedRecord);
attempt.player.getInventory().setItem(attempt.slot, sheet);
if (!sheets.identify(attempt.player.getInventory().getItem(attempt.slot))
.filter(savedRecord.id()::equals).isPresent()) {
respond(attempt, Outcome.RETAINED); return;
}
attempt.delivered = true;
} catch (RuntimeException uncertain) {
report(attempt, "delivery", uncertain);
@@ -85,6 +98,8 @@ public final class AdminCharacterIssuer implements AutoCloseable {
}
try { attempt.player.updateInventory(); }
catch (RuntimeException syncFailure) { report(attempt, "sync", syncFailure); }
var events = identityEvents;
if (events != null) events.issued(attempt.issue, attempt.player, savedRecord, attempt.slot);
respond(attempt, Outcome.DELIVERED);
}
@@ -114,8 +129,12 @@ public final class AdminCharacterIssuer implements AutoCloseable {
private static final class Attempt {
final Player player; final UUID playerId; final int slot; final Consumer<Outcome> response;
final games.dmg.fantasystick.events.CharacterIdentityEventBridge.Issue issue;
final java.util.concurrent.atomic.AtomicBoolean abandoned = new java.util.concurrent.atomic.AtomicBoolean();
CharacterRecord record; CharacterStorage.CraftPreparation preparation; volatile boolean delivered;
Attempt(Player player, int slot, Consumer<Outcome> response) { this.player=player; this.playerId=player.getUniqueId(); this.slot=slot; this.response=response; }
Attempt(Player player, int slot, Consumer<Outcome> response,
games.dmg.fantasystick.events.CharacterIdentityEventBridge.Issue issue) {
this.player=player; this.playerId=player.getUniqueId(); this.slot=slot; this.response=response; this.issue=issue;
}
}
}
@@ -403,6 +403,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
characterIssuer = Objects.requireNonNull(issuer);
}
public void attachCharacterIssueEvents(games.dmg.fantasystick.events.CharacterIdentityEventBridge events) {
if (closed || characterIssuer == null) throw new IllegalStateException("Character issuer unavailable");
characterIssuer.attachIdentityEvents(events);
}
public void attachCharacterBooks(games.dmg.fantasystick.book.CharacterBookController books,
games.dmg.fantasystick.persistence.CharacterStorage storage) {
if (closed || characterBooks != null) throw new IllegalStateException("Character books already attached or command closed");
@@ -959,7 +964,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
sender.sendMessage("Character issue refused: target must name one online player. No character or item was created."); return;
}
var target = matches.getFirst();
characterIssuer.issue(target, outcome -> {
characterIssuer.issue(sender, target, outcome -> {
if (closed || !sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(switch (outcome) {
@@ -0,0 +1,281 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import games.dmg.fantasystick.items.CharacterSheets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
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;
/** Registered administrator issuance is not an event success until native delivery is acknowledged. */
class AdminCharacterIssueEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void registeredConsoleIssueExportsOnlyAcknowledgedSavedAndDeliveredSheet() throws Exception {
UUID issued;
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 EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"give", target.accountName});
assertTrue(sender.messages.isEmpty(), "A queued save is not delivery");
assertTrue(issuedEvents().isEmpty(), "A queued save cannot export issuance");
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
storage.find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
var completion = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(completion);
completion.run(); completion.run();
assertTrue(sender.messages.getLast().contains("Issued one new character sheet"));
var sheets = new CharacterSheets();
var delivered = java.util.stream.IntStream.range(0, 36)
.filter(slot -> sheets.identify(target.core.inventory.getItem(slot)).isPresent()
&& !sheets.identify(target.core.inventory.getItem(slot)).orElseThrow().equals(target.core.record.id()))
.toArray();
assertEquals(1, delivered.length);
issued = sheets.identify(target.core.inventory.getItem(delivered[0])).orElseThrow();
var saved = storage.find(issued).get(5, TimeUnit.SECONDS).orElseThrow();
for (int i = 0; i < 200 && issuedEvents().isEmpty(); i++) Thread.sleep(10);
var matching = issuedEvents().stream().filter(event -> event.getAsJsonObject("data")
.get("character_id").getAsString().equals(issued.toString())).toList();
assertEquals(1, matching.size(), "One native delivery has one stable success record");
var event = matching.getFirst(); var payload = event.getAsJsonObject("data");
assertEquals("console", payload.get("actor_kind").getAsString());
assertEquals(target.core.id.toString(), payload.get("target_player_id").getAsString());
assertEquals(target.accountName, payload.get("target_player_name").getAsString());
assertEquals(issued.toString(), payload.get("character_id").getAsString());
assertEquals(saved.name(), payload.get("character_name").getAsString());
assertEquals(saved.revision().toString(), payload.get("revision").getAsString());
assertEquals(saved.unspentPoints().toString(), payload.get("starting_unspent_points").getAsString());
assertEquals(saved.stats().size(), payload.getAsJsonObject("starting_stats").size());
assertEquals(delivered[0], payload.get("delivered_slot").getAsInt());
assertEquals("acknowledged_delivered", payload.get("outcome").getAsString());
assertEquals("not_applicable", payload.get("actor_location_status").getAsString());
assertEquals(target.world.getUID().toString(),
payload.getAsJsonObject("target_location").get("world_id").getAsString());
assertEquals(payload.get("issue_operation_id").getAsString(), payload.get("action_id").getAsString());
assertEquals(1, matching.stream().map(item -> item.get("id").getAsString()).distinct().count());
}
var beforeRestart = issuedEvents().stream().filter(event -> event.getAsJsonObject("data")
.get("character_id").getAsString().equals(issued.toString())).toList();
assertEquals(1, beforeRestart.size());
try (var restarted = new FantasyStickLifecycleNativeTest.Fixture(data)) {
restarted.plugin.setEnabled(true);
restarted.plugin.setEnabled(false);
}
var afterRestart = issuedEvents().stream().filter(event -> event.getAsJsonObject("data")
.get("character_id").getAsString().equals(issued.toString())).toList();
assertEquals(1, afterRestart.stream().map(item -> item.get("id").getAsString()).distinct().count());
}
@Test void registeredPlayerAdministratorCapturesActualActorAndTargetLocations() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(data);
admin.accountName = "Administrator";
var target = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(admin.core.id, admin.player); f.players.put(target.core.id, target.player);
f.adminCommand.execute(admin.player, "fstickadmin", new String[]{"give", target.accountName});
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
storage.find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
var completion = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(completion); completion.run();
for (int i = 0; i < 200 && issuedEvents().isEmpty(); i++) Thread.sleep(10);
var events = issuedEvents(); assertEquals(1, events.size());
var data = events.getFirst().getAsJsonObject("data");
assertEquals(admin.core.id.toString(), data.get("player_id").getAsString());
assertEquals("player", data.get("actor_kind").getAsString());
assertEquals(admin.world.getUID().toString(), data.getAsJsonObject("location").get("world_id").getAsString());
assertEquals(target.world.getUID().toString(), data.getAsJsonObject("target_location").get("world_id").getAsString());
}
}
@Test void fullInventoryAndDeniedCommandCannotExportIssuance() throws Exception {
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 EquipmentAdminCommandNativeTest.Sender();
sender.allowed = false;
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"give", target.accountName});
sender.allowed = true;
for (int slot = 0; slot < 36; slot++)
target.core.inventory.setItem(slot, new org.bukkit.inventory.ItemStack(org.bukkit.Material.STONE));
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"give", target.accountName});
assertTrue(sender.messages.getLast().contains("no inventory space"));
assertTrue(f.tasks.isEmpty());
f.plugin.setEnabled(false);
}
assertTrue(issuedEvents().isEmpty());
}
@Test void failedSaveDisconnectAndCloseCannotExportIssuance() throws Exception {
for (var route : List.of("save", "disconnect", "close")) {
var dir = data.resolve(route); Files.createDirectories(dir);
try (var f = new FantasyStickLifecycleNativeTest.Fixture(dir)) {
f.plugin.setEnabled(true);
var target = new FantasyStickLifecycleNativeTest.Actor(dir);
f.players.put(target.core.id, target.player);
var sender = new EquipmentAdminCommandNativeTest.Sender();
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var worker = (java.util.concurrent.ExecutorService)
FantasyStickLifecycleNativeTest.Fixture.field(storage, "worker");
var entered = new java.util.concurrent.CountDownLatch(1);
var release = new java.util.concurrent.CountDownLatch(1);
worker.execute(() -> {
entered.countDown();
try { release.await(5, TimeUnit.SECONDS); }
catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); }
});
try {
assertTrue(entered.await(5, TimeUnit.SECONDS));
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"give", target.accountName});
if (route.equals("save")) {
var issuer = FantasyStickLifecycleNativeTest.Fixture.field(f.command.getExecutor(), "characterIssuer");
var pending = (java.util.Map<?, ?>) FantasyStickLifecycleNativeTest.Fixture.field(issuer, "pending");
var attempt = pending.values().iterator().next();
var record = (games.dmg.fantasystick.persistence.CharacterRecord)
FantasyStickLifecycleNativeTest.Fixture.field(attempt, "record");
Files.createDirectory(dir.resolve("characters").resolve(record.id() + ".yml"));
}
} finally { release.countDown(); }
storage.find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback);
if (route.equals("disconnect")) target.core.online = false;
if (route.equals("close")) f.plugin.setEnabled(false);
callback.run();
assertFalse(sender.messages.stream().anyMatch(message -> message.startsWith("Issued one")));
if (!route.equals("close")) f.plugin.setEnabled(false);
}
assertTrue(issuedEvents(dir).isEmpty(), route + " is not acknowledged delivery");
}
}
@Test void unavailableEventStoreCannotUndoAcknowledgedNativeIssue() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
Files.writeString(data.resolve("character-identity-event-intents"), "obstructed");
var target = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(target.core.id, target.player);
var sender = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"give", target.accountName});
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
storage.find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
assertTrue(sender.messages.getLast().contains("Issued one new character sheet"));
var sheets = new CharacterSheets();
assertEquals(1, java.util.stream.IntStream.range(0, 36).filter(slot ->
sheets.identify(target.core.inventory.getItem(slot)).filter(id -> !id.equals(target.core.record.id()))
.isPresent()).count());
f.plugin.setEnabled(false);
}
assertTrue(issuedEvents().isEmpty(), "A failed audit store never fabricates an exported record");
}
@Test void staleReservedSlotAndFailedNativeSetterCannotClaimAnIssuedSheet() throws Exception {
for (var route : List.of("slot", "native")) {
var dir = data.resolve(route); Files.createDirectories(dir);
try (var f = new FantasyStickLifecycleNativeTest.Fixture(dir)) {
f.plugin.setEnabled(true);
var target = new FantasyStickLifecycleNativeTest.Actor(dir);
f.players.put(target.core.id, target.player);
var sender = new EquipmentAdminCommandNativeTest.Sender();
if (route.equals("native")) {
var inventory = mock(org.bukkit.inventory.PlayerInventory.class);
when(inventory.firstEmpty()).thenReturn(1);
doThrow(new IllegalStateException("native setter rejected"))
.when(inventory).setItem(eq(1), any(org.bukkit.inventory.ItemStack.class));
doReturn(inventory).when(target.player).getInventory();
}
f.adminCommand.execute(sender.port, "fstickadmin", new String[]{"give", target.accountName});
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
storage.find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback);
if (route.equals("slot"))
target.core.inventory.setItem(1, new org.bukkit.inventory.ItemStack(org.bukkit.Material.DIAMOND));
callback.run();
assertFalse(sender.messages.stream().anyMatch(message -> message.startsWith("Issued one")));
f.plugin.setEnabled(false);
}
assertTrue(issuedEvents(dir).isEmpty(), route + " must not claim confirmed delivery");
}
}
@Test void rejectedEventStoreRetainsTheSameIssuanceIdentityForReplayWithoutRedelivery() throws Exception {
var target = mock(org.bukkit.entity.Player.class);
var playerId = UUID.randomUUID();
when(target.getUniqueId()).thenReturn(playerId); when(target.getName()).thenReturn("Target");
var sender = mock(org.bukkit.command.CommandSender.class);
var record = new games.dmg.fantasystick.crafting.CharacterFactory()
.create(games.dmg.fantasystick.progression.ProgressionRules.defaults());
var initial = new games.dmg.fantasystick.events.CharacterIdentityEventBridge(
ignored -> java.util.concurrent.CompletableFuture.completedFuture(
games.dmg.fantasystick.events.LocalEventLog.Delivery.REJECTED),
java.time.Clock.systemUTC(), data, ignored -> java.util.Optional.empty(), failure -> fail(failure));
var issue = initial.issue(sender, target);
assertNotNull(issue);
initial.issued(issue, target, record, 1);
initial.close(); initial.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
try (var pending = Files.list(data.resolve("character-identity-event-intents"))) {
assertEquals(1, pending.filter(path -> path.toString().endsWith(".pending")).count());
}
var exported = new java.util.concurrent.CopyOnWriteArrayList<games.dmg.fantasystick.events.EventInput>();
var replay = new games.dmg.fantasystick.events.CharacterIdentityEventBridge(event -> {
exported.add(event);
return java.util.concurrent.CompletableFuture.completedFuture(
games.dmg.fantasystick.events.LocalEventLog.Delivery.EXPORTED);
}, java.time.Clock.systemUTC(), data, ignored -> java.util.Optional.empty(), failure -> fail(failure));
replay.replay(); replay.close(); replay.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
assertEquals(1, exported.size());
assertEquals(issue.operation(), exported.getFirst().actionId());
assertEquals(issue.operation().toString(), exported.getFirst().data().get("issue_operation_id"));
assertEquals(playerId.toString(), exported.getFirst().data().get("target_player_id"));
assertEquals(1, exported.getFirst().data().get("delivered_slot"));
assertEquals(record.stats().size(), ((java.util.Map<?, ?>) exported.getFirst().data().get("starting_stats")).size());
assertEquals("unavailable", exported.getFirst().data().get("target_location_status"));
var again = new games.dmg.fantasystick.events.CharacterIdentityEventBridge(event -> {
exported.add(event);
return java.util.concurrent.CompletableFuture.completedFuture(
games.dmg.fantasystick.events.LocalEventLog.Delivery.EXPORTED);
}, java.time.Clock.systemUTC(), data, ignored -> java.util.Optional.empty(), failure -> fail(failure));
again.replay(); again.close(); again.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
assertEquals(1, exported.size(), "Acknowledged export removes the recoverable intent");
}
private List<JsonObject> issuedEvents() throws Exception { return issuedEvents(data); }
private List<JsonObject> issuedEvents(Path root) throws Exception {
var results = new ArrayList<JsonObject>();
if (!Files.isDirectory(root.resolve("events"))) return results;
try (var days = Files.list(root.resolve("events"))) {
for (var day : days.filter(path -> path.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}"))
.toList()) {
try (var segments = Files.list(day)) {
for (var path : segments.filter(file -> file.getFileName().toString().startsWith("events-")
&& file.getFileName().toString().endsWith(".jsonl")).toList()) {
for (var line : Files.readAllLines(path)) {
if (!line.isBlank()) {
var event = JsonParser.parseString(line).getAsJsonObject();
if (event.get("type").getAsString().endsWith("character.issued")) results.add(event);
}
}
}
}
}
}
return results;
}
}