This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user