This commit is contained in:
1 parent
53366b3c50
commit
9737ed0ddb
8 files changed
+739
-21
No files matched your search
@@ -181,6 +181,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private java.util.concurrent.CompletionStage<Void> switchAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.CharacterIdentityEventBridge identityAuditEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> identityAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.EquipmentEventBridge equipmentAuditEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> equipmentAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.MessageEventBridge messageAuditEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
|
||||
|
||||
@@ -763,6 +765,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
crafting.attachIdentityEvents(identityAuditEvents);
|
||||
anvilNames.attachIdentityEvents(identityAuditEvents);
|
||||
sessions.attachIdentityEvents(identityAuditEvents);
|
||||
equipmentAuditEvents = new games.dmg.fantasystick.events.EquipmentEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
|
||||
failure -> getLogger().log(Level.WARNING, "Equipment event intent failed", failure));
|
||||
sessions.attachEquipmentEvents(equipmentAuditEvents);
|
||||
sessions.attachDownedEvents(downedEvents);
|
||||
downedController.attachEvents(downedEvents);
|
||||
revive.attachDownedEvents(downedEvents);
|
||||
@@ -772,7 +778,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
id -> presentation.snapshot().get(id),sessionAuditEvents,eventLog::emit,Clock.systemUTC());
|
||||
getServer().getPluginManager().registerEvents(messageAuditEvents,this);
|
||||
} catch (java.io.IOException failure) {
|
||||
eventLog = null; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; reviveEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null; switchAuditEvents = null; identityAuditEvents = null; messageAuditEvents = null;
|
||||
eventLog = null; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; reviveEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null; switchAuditEvents = null; identityAuditEvents = null; equipmentAuditEvents = null; messageAuditEvents = null;
|
||||
getLogger().log(Level.WARNING,"Fantasy Stick local events are unavailable; gameplay remains enabled",failure);
|
||||
}
|
||||
getServer().getPluginManager().registerEvents(crafting, this);
|
||||
@@ -863,6 +869,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous identity event intent drain failed", failure);
|
||||
if (replayIdentityEvents != null) replayIdentityEvents.replay();
|
||||
});
|
||||
var replayEquipmentEvents = equipmentAuditEvents;
|
||||
equipmentAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous equipment event intent drain failed", failure);
|
||||
if (replayEquipmentEvents != null) replayEquipmentEvents.replay();
|
||||
});
|
||||
recipeEvents.replay(); // Serial worker replays old intents before new discoveries are captured.
|
||||
}
|
||||
getServer().getPluginManager().registerEvents(recipeDiscovery, this);
|
||||
@@ -903,6 +914,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingEventLog = eventLog; eventLog = null; preferenceEvents = null;
|
||||
var closingSwitchAuditEvents = switchAuditEvents; switchAuditEvents = null;
|
||||
var closingIdentityAuditEvents = identityAuditEvents; identityAuditEvents = null;
|
||||
var closingEquipmentAuditEvents = equipmentAuditEvents; equipmentAuditEvents = null;
|
||||
var closingEventCapture = eventCapture; eventCapture = null; var closingMessageAuditEvents = messageAuditEvents; messageAuditEvents = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; abilityEvents = null; passiveEvents = null; recoveryEvents = null; reviveEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null;
|
||||
var closingBooks = books; books = null;
|
||||
var closingPartyEvents = partyEvents; partyEvents = null;
|
||||
@@ -1216,6 +1228,15 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
return null;
|
||||
}).thenCompose(ignored -> closingSwitchAuditEvents.drained());
|
||||
});
|
||||
if (closingEquipmentAuditEvents != null) cleanup("equipment event intent drain", () -> {
|
||||
var sessionsDrained = closingSessions == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingSessions.drained();
|
||||
equipmentAuditDrain = sessionsDrained.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Equipment session drain failed", failure);
|
||||
closingEquipmentAuditEvents.close();
|
||||
return null;
|
||||
}).thenCompose(ignored -> closingEquipmentAuditEvents.drained());
|
||||
});
|
||||
if (closingEventLog != null) cleanup("local events", closingEventLog::close);
|
||||
if (closingVitals != null) cleanup("native health hooks", closingVitals::close);
|
||||
unregister(closingModes);
|
||||
|
||||
@@ -19,7 +19,7 @@ import org.bukkit.entity.Player;
|
||||
/** Captures actual identity-creating and identity-changing native actions, never previews. */
|
||||
public final class CharacterIdentityEventBridge implements AutoCloseable {
|
||||
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
|
||||
private final CharacterIdentityEventOutbox outbox;
|
||||
private final FeatureEventOutbox outbox;
|
||||
private final Clock clock;
|
||||
private final Function<UUID, Optional<UUID>> activeSession;
|
||||
private static final int MAX_RECENT_HOLDERS = 8192;
|
||||
@@ -36,8 +36,9 @@ public final class CharacterIdentityEventBridge implements AutoCloseable {
|
||||
this.sink = Objects.requireNonNull(sink);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.activeSession = Objects.requireNonNull(activeSession);
|
||||
this.outbox = new CharacterIdentityEventOutbox(Objects.requireNonNull(dataFolder), sink,
|
||||
Objects.requireNonNull(failures));
|
||||
this.outbox = new FeatureEventOutbox(Objects.requireNonNull(dataFolder),
|
||||
"character-identity-event-intents", java.util.Set.of("character.created", "character.renamed"),
|
||||
sink, Objects.requireNonNull(failures));
|
||||
}
|
||||
|
||||
public void replay() { outbox.replay(); }
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.items.EquipmentStorage;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Observes only proved native box custody and the owning acknowledged receipt boundary. */
|
||||
public final class EquipmentEventBridge implements AutoCloseable {
|
||||
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
|
||||
private final FeatureEventOutbox outbox;
|
||||
private final Clock clock;
|
||||
|
||||
public EquipmentEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
|
||||
Clock clock, Path dataFolder, Consumer<Throwable> failures) {
|
||||
this.sink = Objects.requireNonNull(sink);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.outbox = new FeatureEventOutbox(Objects.requireNonNull(dataFolder), "equipment-event-intents",
|
||||
Set.of("equipment.packed", "equipment.restored", "equipment.recovery_resolved"), sink,
|
||||
Objects.requireNonNull(failures));
|
||||
}
|
||||
|
||||
public void replay() { outbox.replay(); }
|
||||
@Override public void close() { outbox.close(); }
|
||||
public CompletionStage<Void> drained() { return outbox.drained(); }
|
||||
|
||||
public record Capture(EventInput.Context context, EquipmentStorage.Snapshot box, Instant observedAt,
|
||||
UUID session, UUID character) { }
|
||||
|
||||
public record Action(UUID id, Instant time, EventInput.Context context, UUID session, UUID character,
|
||||
String kind) { }
|
||||
|
||||
public Action action(Player player, UUID session, UUID character, String kind) {
|
||||
try {
|
||||
// A receipt proves the character UUID, not its current display name. Keep that UUID
|
||||
// in the payload rather than inventing an incomplete character envelope context.
|
||||
var context = new EventInput.Context(player.getUniqueId(), player.getName(), null, null, null,
|
||||
location(player.getLocation()));
|
||||
var action = new Action(UUID.randomUUID(), clock.instant(), context, session, character, kind);
|
||||
var values = actionFields(action);
|
||||
values.put("outcome", "observed");
|
||||
emitAction(action, "action.observed", "observed", values);
|
||||
return action;
|
||||
} catch (RuntimeException ignored) { return null; }
|
||||
}
|
||||
|
||||
public void resolved(Action action, String outcome, String reason) {
|
||||
if (action == null) return;
|
||||
var values = actionFields(action);
|
||||
values.put("outcome", outcome);
|
||||
values.put("reason", reason);
|
||||
emitAction(action, "action.resolved", "resolved", values);
|
||||
}
|
||||
|
||||
public void recovery(Action action, UUID session, UUID character, String priorPhase, String currentPhase,
|
||||
String quality, String outcome, String reason, BigInteger receiptRevision) {
|
||||
if (action == null) return;
|
||||
var values = actionFields(action);
|
||||
values.put("recovery_operation_id", action.id().toString());
|
||||
values.put("session_id", session.toString());
|
||||
values.put("character_id", character.toString());
|
||||
values.put("prior_phase", priorPhase);
|
||||
values.put("current_phase", currentPhase);
|
||||
values.put("custody_quality", quality);
|
||||
values.put("outcome", outcome);
|
||||
values.put("reason", reason);
|
||||
if (receiptRevision != null) values.put("receipt_revision", receiptRevision.toString());
|
||||
try { outbox.accepted(new EventInput(id(action.id(), "recovery_resolved"), action.id(), clock.instant(),
|
||||
"equipment.recovery_resolved", action.context(), values)); }
|
||||
catch (RuntimeException ignored) { }
|
||||
}
|
||||
|
||||
private LinkedHashMap<String, Object> actionFields(Action action) {
|
||||
var values = new LinkedHashMap<String, Object>();
|
||||
values.put("action_kind", action.kind());
|
||||
if (action.session() != null) values.put("session_id", action.session().toString());
|
||||
if (action.character() != null) values.put("character_id", action.character().toString());
|
||||
return values;
|
||||
}
|
||||
|
||||
private void emitAction(Action action, String kind, String stage, Map<String, Object> values) {
|
||||
try { sink.apply(new EventInput(id(action.id(), stage + ':' + values.get("outcome") + ':' + values.getOrDefault("reason", "")), action.id(),
|
||||
stage.equals("observed") ? action.time() : clock.instant(), kind, action.context(), values)); }
|
||||
catch (RuntimeException ignored) { }
|
||||
}
|
||||
|
||||
public Capture capture(Player player, UUID session, CharacterRecord character, EquipmentStorage.Snapshot box) {
|
||||
return capture(player, session, character.id(), character.name(), box);
|
||||
}
|
||||
|
||||
/** Recovery can prove a receipt's character UUID without proving a current character name. */
|
||||
public Capture capture(Player player, UUID session, UUID character, String characterName,
|
||||
EquipmentStorage.Snapshot box) {
|
||||
try {
|
||||
return new Capture(new EventInput.Context(player.getUniqueId(), player.getName(),
|
||||
characterName == null ? null : character, characterName,
|
||||
characterName == null ? null : session, location(player.getLocation())),
|
||||
box, clock.instant(), session, character);
|
||||
} catch (RuntimeException ignored) { return null; }
|
||||
}
|
||||
|
||||
public void packed(Capture captured, BigInteger revision) {
|
||||
if (captured == null || revision == null) return;
|
||||
var values = fields(captured, revision);
|
||||
values.put("box_custody", "main_storage");
|
||||
values.put("outcome", "acknowledged_packed");
|
||||
emit(captured, "equipment.packed", "packed", values);
|
||||
}
|
||||
|
||||
public void restored(Capture captured, BigInteger revision) {
|
||||
if (captured == null || revision == null) return;
|
||||
var values = fields(captured, revision);
|
||||
values.put("displaced_head_handling", captured.box().displacedHead()
|
||||
? captured.box().boxSource().equals("cursor") ? "cursor" : "freed_box_slot" : "none");
|
||||
values.put("outcome", "acknowledged_restored");
|
||||
emit(captured, "equipment.restored", "restored", values);
|
||||
}
|
||||
|
||||
private LinkedHashMap<String, Object> fields(Capture captured, BigInteger revision) {
|
||||
var box = captured.box();
|
||||
var values = new LinkedHashMap<String, Object>();
|
||||
values.put("session_id", captured.session().toString());
|
||||
values.put("character_id", captured.character().toString());
|
||||
values.put("equipment_operation_id", captured.session().toString());
|
||||
values.put("receipt_revision", revision.toString());
|
||||
values.put("slot_count", box.packedSlots());
|
||||
values.put("slot_classes", box.slotClasses());
|
||||
values.put("hotbar_slot_count", box.hotbarSlots());
|
||||
values.put("armor_slot_count", box.armorSlots());
|
||||
values.put("box_source", box.boxSource());
|
||||
values.put("box_slot", box.boxSlot());
|
||||
return values;
|
||||
}
|
||||
|
||||
private void emit(Capture captured, String type, String phase, Map<String, Object> values) {
|
||||
try { outbox.accepted(new EventInput(id(captured.session(), phase), captured.session(), captured.observedAt(),
|
||||
type, captured.context(), values)); }
|
||||
catch (RuntimeException ignored) { /* Audit failures cannot change native equipment custody. */ }
|
||||
}
|
||||
|
||||
private EventInput.Location location(Location value) {
|
||||
return value == null || value.getWorld() == null ? null
|
||||
: new EventInput.Location(value.getWorld().getUID(), value.getWorld().getName(), value.getX(), value.getY(),
|
||||
value.getZ(), clock.instant(), "exact");
|
||||
}
|
||||
|
||||
private static UUID id(UUID session, String stage) {
|
||||
return UUID.nameUUIDFromBytes(("fantasystick:equipment:" + session + ':' + stage)
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
+24
-11
@@ -23,20 +23,23 @@ import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
/** Recoverable intent after acknowledged craft delivery or character-file rename. Never replays gameplay. */
|
||||
final class CharacterIdentityEventOutbox implements AutoCloseable {
|
||||
/** Per-feature recoverable intent for acknowledged domain outcomes. Never replays gameplay. */
|
||||
final class FeatureEventOutbox implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 16_384;
|
||||
private static final Set<String> TYPES = Set.of("character.created", "character.renamed");
|
||||
private final Set<String> types;
|
||||
private final Path directory;
|
||||
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
|
||||
private final Consumer<Throwable> failures;
|
||||
private final ExecutorService worker = Executors.newSingleThreadExecutor(task ->
|
||||
new Thread(task, "fantasy-character-identity-events"));
|
||||
new Thread(task, "fantasy-feature-event-intents"));
|
||||
private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
|
||||
CharacterIdentityEventOutbox(Path data, Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
|
||||
Consumer<Throwable> failures) {
|
||||
directory = data.toAbsolutePath().resolve("character-identity-event-intents");
|
||||
FeatureEventOutbox(Path data, String folder, Set<String> types,
|
||||
Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
|
||||
Consumer<Throwable> failures) {
|
||||
if (!folder.matches("[a-z-]+")) throw new IllegalArgumentException("Unsafe event intent folder");
|
||||
directory = data.toAbsolutePath().resolve(folder);
|
||||
this.types = Set.copyOf(types);
|
||||
this.sink = sink;
|
||||
this.failures = failures;
|
||||
}
|
||||
@@ -65,7 +68,7 @@ final class CharacterIdentityEventOutbox implements AutoCloseable {
|
||||
private Path path(EventInput event) { return directory.resolve(event.id() + ".pending"); }
|
||||
|
||||
private EventInput write(EventInput event) throws IOException {
|
||||
if (!TYPES.contains(event.type())) throw new IOException("Unsupported character intent type");
|
||||
if (!types.contains(event.type())) throw new IOException("Unsupported character intent type");
|
||||
if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS)
|
||||
&& !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe character event directory");
|
||||
Files.createDirectories(directory);
|
||||
@@ -98,7 +101,10 @@ final class CharacterIdentityEventOutbox implements AutoCloseable {
|
||||
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 throw new IllegalArgumentException("Unsupported character intent value");
|
||||
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");
|
||||
});
|
||||
var output = new ByteArrayOutputStream();
|
||||
fields.store(output, "acknowledged character identity event");
|
||||
@@ -129,7 +135,7 @@ final class CharacterIdentityEventOutbox implements AutoCloseable {
|
||||
var id = UUID.fromString(fields.getProperty("id"));
|
||||
var action = UUID.fromString(fields.getProperty("action"));
|
||||
var type = fields.getProperty("type");
|
||||
if (!path.equals(directory.resolve(id + ".pending")) || !TYPES.contains(type))
|
||||
if (!path.equals(directory.resolve(id + ".pending")) || !types.contains(type))
|
||||
throw new IOException("Invalid character event intent identity");
|
||||
EventInput.Location location = fields.containsKey("world")
|
||||
? new EventInput.Location(UUID.fromString(fields.getProperty("world")), fields.getProperty("world_name"),
|
||||
@@ -151,7 +157,14 @@ final class CharacterIdentityEventOutbox implements AutoCloseable {
|
||||
yield Boolean.valueOf(value.substring(1));
|
||||
}
|
||||
case 'i' -> Integer.valueOf(value.substring(1));
|
||||
default -> throw new IOException("Invalid character event value type");
|
||||
case 'l' -> {
|
||||
var encoded = value.substring(1);
|
||||
var list = encoded.isEmpty() ? java.util.List.<String>of() : java.util.List.of(encoded.split(","));
|
||||
if (!list.stream().allMatch(item -> item.equals("hotbar") || item.equals("armor")))
|
||||
throw new IOException("Invalid slot class");
|
||||
yield list;
|
||||
}
|
||||
default -> throw new IOException("Invalid feature event value type");
|
||||
});
|
||||
}
|
||||
return new EventInput(id, action, Instant.parse(fields.getProperty("time")), type, context, data);
|
||||
@@ -77,6 +77,41 @@ public final class EquipmentStorage {
|
||||
return new PackPlan(PackResult.PACKED, item, available);
|
||||
}
|
||||
|
||||
/** Bounded custody/slot-class proof; never exposes the box's items or metadata to event payloads. */
|
||||
public record Snapshot(String boxSource, int boxSlot, int hotbarSlots, int armorSlots, boolean displacedHead) {
|
||||
public int packedSlots() { return hotbarSlots + armorSlots; }
|
||||
public java.util.List<String> slotClasses() {
|
||||
var result = new java.util.ArrayList<String>(2);
|
||||
if (hotbarSlots > 0) result.add("hotbar");
|
||||
if (armorSlots > 0) result.add("armor");
|
||||
return java.util.List.copyOf(result);
|
||||
}
|
||||
}
|
||||
|
||||
/** Read-only native proof at the packing/restoration boundary, including moved or cursor custody. */
|
||||
public java.util.Optional<Snapshot> snapshot(PlayerInventory inventory, UUID owner, UUID session, ItemStack cursor) {
|
||||
Snapshot found = null;
|
||||
for (int slot = 9; slot <= 41; slot++) {
|
||||
if (slot >= 36 && slot < 40) continue;
|
||||
var item = slot == 41 ? cursor : inventory.getItem(slot);
|
||||
if (!matches(item, owner, session)) continue;
|
||||
if (found != null || item.getAmount() != 1 || !(item.getItemMeta() instanceof BlockStateMeta meta)
|
||||
|| !(meta.getBlockState() instanceof ShulkerBox box)) return java.util.Optional.empty();
|
||||
var fields = meta.getPersistentDataContainer();
|
||||
if (!fields.has(VERSION, PersistentDataType.INTEGER) || fields.get(VERSION, PersistentDataType.INTEGER) != 1
|
||||
|| !Arrays.equals(EQUIPMENT_SLOTS, fields.get(SLOTS, PersistentDataType.INTEGER_ARRAY)))
|
||||
return java.util.Optional.empty();
|
||||
var contents = box.getInventory();
|
||||
int hotbar = 0, armor = 0;
|
||||
for (int index = 0; index < EQUIPMENT_SLOTS.length; index++) {
|
||||
if (!empty(contents.getItem(index))) { if (index < 9) hotbar++; else armor++; }
|
||||
}
|
||||
found = new Snapshot(slot == 41 ? "cursor" : slot == 40 ? "offhand" : "main",
|
||||
slot, hotbar, armor, !empty(inventory.getItem(39)));
|
||||
}
|
||||
return java.util.Optional.ofNullable(found);
|
||||
}
|
||||
|
||||
public enum RestoreResult { RESTORED, NOT_FOUND, AMBIGUOUS, INVALID_BOX, OCCUPIED_SLOT }
|
||||
|
||||
/** Caller removes validated fantasy controls first. Unexpected slot occupants are never overwritten. */
|
||||
|
||||
@@ -50,6 +50,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
private games.dmg.fantasystick.events.SessionEventBridge sessionEvents;
|
||||
private games.dmg.fantasystick.events.SwitchDelayEventBridge switchEvents;
|
||||
private games.dmg.fantasystick.events.CharacterIdentityEventBridge identityEvents;
|
||||
private games.dmg.fantasystick.events.EquipmentEventBridge equipmentEvents;
|
||||
private java.util.function.Function<DownedEpisode,CompletionStage<?>> downedDefeatRewards=ignored->CompletableFuture.completedStage(null);
|
||||
private java.util.function.Predicate<UUID> unavailableCharacter = ignored -> false;
|
||||
private java.util.function.Function<UUID,java.util.concurrent.CompletionStage<?>> partyDefeat =
|
||||
@@ -108,6 +109,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
games.dmg.fantasystick.persistence.OrdinaryVitals ordinaryVitals;
|
||||
boolean vitalsStarted;
|
||||
boolean healthSaveRequested;
|
||||
boolean healthSaveFailed;
|
||||
boolean healthAcknowledged = true;
|
||||
boolean nativeRestored;
|
||||
boolean gearRestoredForModeRecovery;
|
||||
@@ -122,6 +124,14 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
games.dmg.fantasystick.events.SwitchDelayEventBridge.ExitContext switchExit;
|
||||
boolean switchEventWritten;
|
||||
String exitReason = "voluntary";
|
||||
java.math.BigInteger packReceiptRevision;
|
||||
games.dmg.fantasystick.events.EquipmentEventBridge.Capture restoreEvent;
|
||||
games.dmg.fantasystick.events.EquipmentEventBridge.Action restoreAction;
|
||||
games.dmg.fantasystick.events.EquipmentEventBridge.Action recoveryAction;
|
||||
String recoveryPriorPhase = "unavailable";
|
||||
java.math.BigInteger recoveryReceiptRevision;
|
||||
boolean recoveryResolved;
|
||||
boolean restoreEventWritten;
|
||||
Duration exitDelay;
|
||||
boolean arrival;
|
||||
Attempt recoveryOwner;
|
||||
@@ -217,6 +227,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
public synchronized void attachSessionEvents(games.dmg.fantasystick.events.SessionEventBridge events){if(!owned.isEmpty()||sessionEvents!=null)throw new IllegalStateException("Session events already attached or sessions started");sessionEvents=Objects.requireNonNull(events);}
|
||||
public synchronized void attachSwitchEvents(games.dmg.fantasystick.events.SwitchDelayEventBridge events){if(!owned.isEmpty()||switchEvents!=null)throw new IllegalStateException("Switch events already attached or sessions started");switchEvents=Objects.requireNonNull(events);}
|
||||
public synchronized void attachIdentityEvents(games.dmg.fantasystick.events.CharacterIdentityEventBridge events){if(!owned.isEmpty()||identityEvents!=null)throw new IllegalStateException("Identity events already attached or sessions started");identityEvents=Objects.requireNonNull(events);}
|
||||
public synchronized void attachEquipmentEvents(games.dmg.fantasystick.events.EquipmentEventBridge events){if(!owned.isEmpty()||equipmentEvents!=null)throw new IllegalStateException("Equipment events already attached or sessions started");equipmentEvents=Objects.requireNonNull(events);}
|
||||
public synchronized void attachDownedDefeatRewards(java.util.function.Function<DownedEpisode,CompletionStage<?>> settlement){if(!owned.isEmpty())throw new IllegalStateException("Sessions already started");downedDefeatRewards=Objects.requireNonNull(settlement);}
|
||||
public synchronized List<DownedEpisode.Reward> finalRewards(DownedEpisode episode){return List.copyOf(downedRewardAllocation.apply(Objects.requireNonNull(episode)));}
|
||||
|
||||
@@ -633,7 +644,13 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
return;
|
||||
}
|
||||
var refusal = equipment.refusal(attempt.player.getInventory(), attempt.owner, UUID.randomUUID());
|
||||
if (refusal.isPresent()) { refuse(attempt, refusal.orElseThrow().name(), "Equipment entry refused: " + refusal.orElseThrow()); return; }
|
||||
if (refusal.isPresent()) {
|
||||
if (equipmentEvents != null) {
|
||||
var action = equipmentEvents.action(attempt.player, null, attempt.character, "equipment_pack_preflight");
|
||||
equipmentEvents.resolved(action, "refused", refusal.orElseThrow().name().toLowerCase(java.util.Locale.ROOT));
|
||||
}
|
||||
refuse(attempt, refusal.orElseThrow().name(), "Equipment entry refused: " + refusal.orElseThrow()); return;
|
||||
}
|
||||
// Restart receipts can belong to dead/missing characters, so they cannot acquire a
|
||||
// live SessionClaims token. Their unresolved equipment outcome still excludes entry.
|
||||
if (owned.values().stream().anyMatch(other -> attempt.character.equals(other.character))) {
|
||||
@@ -650,6 +667,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
attempt.saving = false;
|
||||
if (error != null) { attempt.uncertainSave = true; fail(attempt, error); return; }
|
||||
attempt.acknowledged = true;
|
||||
attempt.packReceiptRevision = saved.revision();
|
||||
if (!current(attempt)) { abandon(attempt); return; }
|
||||
// Queue the cross-worker read under lifecycle ownership: close cannot finish
|
||||
// its drain boundary and then have this callback submit fresh character IO.
|
||||
@@ -698,6 +716,11 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
}
|
||||
if(gear!=null)gear.install(attempt.player.getInventory(),record,attempt.owner,attempt.session);
|
||||
mark(attempt, EquipmentSessionMarker.Phase.PACKED);
|
||||
if (equipmentEvents != null) try {
|
||||
equipment.snapshot(attempt.player.getInventory(), attempt.owner, attempt.session,
|
||||
attempt.player.getItemOnCursor()).ifPresent(box -> equipmentEvents.packed(
|
||||
equipmentEvents.capture(attempt.player, attempt.session, record, box), attempt.packReceiptRevision));
|
||||
} catch (RuntimeException observationFailure) { diagnostic(attempt, observationFailure); }
|
||||
if (vitals != null) {
|
||||
attempt.vitalsStarted = true;
|
||||
attempt.healthAcknowledged = false;
|
||||
@@ -1039,11 +1062,22 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
invalidateCursor(attempt.owner);
|
||||
saveRemainingHealth(attempt);
|
||||
boolean modeRestored = restoreGameMode(attempt);
|
||||
if (equipmentEvents != null) attempt.restoreAction = equipmentEvents.action(
|
||||
attempt.player, attempt.session, attempt.character, "equipment_restore");
|
||||
EquipmentStorage.RestoreResult result;
|
||||
try {
|
||||
result = restoreMarked(attempt);
|
||||
} catch (RuntimeException failure) {
|
||||
attempt.nativeUncertain = true;
|
||||
if (equipmentEvents != null) {
|
||||
equipmentEvents.resolved(attempt.restoreAction, "uncertain", "native_exception");
|
||||
if (attempt.recoveryAction != null) {
|
||||
equipmentEvents.resolved(attempt.recoveryAction, "uncertain", "native_exception");
|
||||
equipmentEvents.recovery(attempt.recoveryAction, attempt.session, attempt.character,
|
||||
attempt.recoveryPriorPhase, "restoring", "native_uncertain", "ambiguous",
|
||||
"native_exception", attempt.recoveryReceiptRevision);
|
||||
}
|
||||
}
|
||||
restoreVitals(attempt); // Proven health ownership is independent of uncertain equipment writes.
|
||||
persistExit(attempt, false);
|
||||
diagnostic(attempt, failure);
|
||||
@@ -1056,8 +1090,23 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
}
|
||||
persistExit(attempt, result == EquipmentStorage.RestoreResult.RESTORED && modeRestored && vitalsRestored);
|
||||
if (result != EquipmentStorage.RestoreResult.RESTORED) {
|
||||
if (equipmentEvents != null) {
|
||||
var reason = result.name().toLowerCase(java.util.Locale.ROOT);
|
||||
equipmentEvents.resolved(attempt.restoreAction, "refused", reason);
|
||||
if (attempt.recoveryAction != null) equipmentEvents.resolved(attempt.recoveryAction, "retained", reason);
|
||||
if (attempt.recoveryAction != null) equipmentEvents.recovery(attempt.recoveryAction,
|
||||
attempt.session, attempt.character, attempt.recoveryPriorPhase, "pending",
|
||||
attempt.restoreEvent == null ? "receipt_and_marker_only" : "verified_box_and_marker",
|
||||
"retained", reason, attempt.recoveryReceiptRevision);
|
||||
}
|
||||
notice(attempt.owner, result.name(), "Equipment recovery refused: " + result);
|
||||
} else sync(attempt);
|
||||
} else {
|
||||
if ((!modeRestored || !vitalsRestored) && equipmentEvents != null) {
|
||||
equipmentEvents.resolved(attempt.restoreAction, "pending", "receipt_incomplete");
|
||||
equipmentEvents.resolved(attempt.recoveryAction, "pending", "receipt_incomplete");
|
||||
}
|
||||
sync(attempt);
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
if (reserved) transitioning.remove(attempt.owner, attempt);
|
||||
@@ -1087,6 +1136,18 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
+ ". Items and receipt retained. RESTORED receipts require manual reconciliation too.");
|
||||
return EquipmentStorage.RestoreResult.INVALID_BOX;
|
||||
}
|
||||
if (equipmentEvents != null && attempt.restoreEvent == null) try {
|
||||
equipment.snapshot(attempt.player.getInventory(), attempt.owner, attempt.session,
|
||||
attempt.player.getItemOnCursor()).ifPresent(box -> {
|
||||
var head = attempt.player.getInventory().getItem(39);
|
||||
boolean displaced = box.displacedHead() && !controls.owned(head, attempt.owner, attempt.session)
|
||||
&& (gear == null || !gear.owned(head, attempt.owner, attempt.session));
|
||||
attempt.restoreEvent = equipmentEvents.capture(attempt.player, attempt.session,
|
||||
attempt.character, attempt.record == null ? null : attempt.record.name(),
|
||||
new EquipmentStorage.Snapshot(box.boxSource(), box.boxSlot(), box.hotbarSlots(),
|
||||
box.armorSlots(), displaced));
|
||||
});
|
||||
} catch (RuntimeException observationFailure) { diagnostic(attempt, observationFailure); }
|
||||
mark(attempt, EquipmentSessionMarker.Phase.RESTORING);
|
||||
var result = equipment.restore(attempt.player.getInventory(), attempt.owner, attempt.session,
|
||||
item -> controls.owned(item, attempt.owner, attempt.session)||(gear!=null&&gear.owned(item,attempt.owner,attempt.session)),
|
||||
@@ -1112,12 +1173,25 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
persist(() -> characters.saveHealth(attempt.character, remaining, limit), (saved, failure) -> {
|
||||
if (failure != null || saved.isEmpty()) {
|
||||
diagnostic(attempt, failure != null ? failure : new IllegalStateException("Character unavailable for health save"));
|
||||
attempt.healthSaveFailed = true;
|
||||
if (attempt.nativeRestored) recoveryIncomplete(attempt, "health_unverified");
|
||||
return;
|
||||
}
|
||||
attempt.healthAcknowledged = true;
|
||||
if (attempt.nativeRestored) persistExit(attempt, true);
|
||||
});
|
||||
} catch (RuntimeException failure) { diagnostic(attempt, failure); }
|
||||
} catch (RuntimeException failure) {
|
||||
attempt.healthSaveFailed = true;
|
||||
diagnostic(attempt, failure);
|
||||
}
|
||||
}
|
||||
|
||||
private void recoveryIncomplete(Attempt attempt, String reason) {
|
||||
if (equipmentEvents == null || attempt.recoveryAction == null || attempt.recoveryResolved) return;
|
||||
attempt.recoveryResolved = true;
|
||||
equipmentEvents.recovery(attempt.recoveryAction, attempt.session, attempt.character,
|
||||
attempt.recoveryPriorPhase, "restored", "native_restored_receipt_pending",
|
||||
"ambiguous", reason, attempt.recoveryReceiptRevision);
|
||||
}
|
||||
|
||||
private boolean restoreVitals(Attempt attempt) {
|
||||
@@ -1151,6 +1225,21 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
"Equipment restoration persistence failed before Tail conversion");
|
||||
} else if (complete) {
|
||||
attempt.gearAcknowledged = true;
|
||||
if (!attempt.restoreEventWritten && equipmentEvents != null && attempt.restoreEvent != null) {
|
||||
attempt.restoreEventWritten = true;
|
||||
equipmentEvents.restored(attempt.restoreEvent, state.revision());
|
||||
}
|
||||
if (equipmentEvents != null) {
|
||||
equipmentEvents.resolved(attempt.restoreAction, "accepted", "receipt_completed");
|
||||
if (attempt.recoveryAction != null && !attempt.recoveryResolved) {
|
||||
attempt.recoveryResolved = true;
|
||||
equipmentEvents.resolved(attempt.recoveryAction, "accepted", "receipt_completed");
|
||||
equipmentEvents.recovery(attempt.recoveryAction,
|
||||
attempt.session, attempt.character, attempt.recoveryPriorPhase, "restored",
|
||||
attempt.restoreEvent == null ? "receipt_and_marker_only" : "verified_box_and_marker",
|
||||
"restored", "receipt_completed", state.revision());
|
||||
}
|
||||
}
|
||||
if (!attempt.switchEventWritten && switchEvents != null) {
|
||||
attempt.switchEventWritten = true;
|
||||
switchEvents.started(attempt.switchExit, state);
|
||||
@@ -1323,10 +1412,16 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
}
|
||||
return; // Ordinary no-sheet/no-receipt arrivals are not rejected entry attempts.
|
||||
}
|
||||
if (equipmentEvents != null) equipmentEvents.resolved(
|
||||
equipmentEvents.action(attempt.player, null, null, "equipment_recovery"), "refused", "no_receipt");
|
||||
notice(attempt.owner, "NO_RECEIPT", "No saved equipment receipt is available; items were left unchanged."); return;
|
||||
}
|
||||
attempt.character = receipt.characterId();
|
||||
attempt.session = receipt.sessionId();
|
||||
attempt.recoveryReceiptRevision = state.orElseThrow().revision();
|
||||
if (equipmentEvents != null) attempt.recoveryAction = equipmentEvents.action(
|
||||
attempt.player, attempt.session, attempt.character,
|
||||
attempt.arrival ? "equipment_arrival_recovery" : "equipment_recovery");
|
||||
attempt.previousGameMode = receipt.previousGameMode();
|
||||
attempt.ordinaryVitals = receipt.ordinaryVitals();
|
||||
attempt.vitalsStarted = attempt.ordinaryVitals != null;
|
||||
@@ -1334,6 +1429,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
attempt.modeStarted = attempt.previousGameMode != null;
|
||||
var previous = owned.get(attempt.owner);
|
||||
if (previous != null && !attempt.session.equals(previous.session)) {
|
||||
if (equipmentEvents != null) equipmentEvents.resolved(attempt.recoveryAction, "refused", "session_mismatch");
|
||||
notice(attempt.owner, "SESSION_MISMATCH", "Saved and process-local sessions disagree; items were left unchanged."); return;
|
||||
}
|
||||
if (previous != null && (previous.defeated || previous.vitalsStarted)) {
|
||||
@@ -1341,6 +1437,10 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
// proof. Keep the original death authority and its pending worker completion;
|
||||
// do not replace it with a recovery Attempt that could release its claim early.
|
||||
previous.deliveryInvalidated = true;
|
||||
previous.recoveryAction = attempt.recoveryAction;
|
||||
previous.recoveryReceiptRevision = attempt.recoveryReceiptRevision;
|
||||
previous.recoveryPriorPhase = EquipmentSessionMarker.read(attempt.player)
|
||||
.map(token -> token.phase().name().toLowerCase(java.util.Locale.ROOT)).orElse("unavailable");
|
||||
restoreExit(previous);
|
||||
return;
|
||||
}
|
||||
@@ -1350,17 +1450,41 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
}
|
||||
owned.put(attempt.owner, attempt);
|
||||
transitioning.put(attempt.owner, attempt);
|
||||
attempt.recoveryPriorPhase = EquipmentSessionMarker.read(attempt.player)
|
||||
.map(token -> token.phase().name().toLowerCase(java.util.Locale.ROOT)).orElse("unavailable");
|
||||
try {
|
||||
saveRemainingHealth(attempt);
|
||||
boolean modeRestored = restoreGameMode(attempt);
|
||||
var result = restoreMarked(attempt);
|
||||
boolean vitalsRestored = restoreVitals(attempt);
|
||||
if (result == EquipmentStorage.RestoreResult.RESTORED) {
|
||||
if (!modeRestored || !vitalsRestored || !attempt.healthAcknowledged) attempt.gearRestoredForModeRecovery = true;
|
||||
if (!modeRestored || !vitalsRestored || !attempt.healthAcknowledged) {
|
||||
attempt.gearRestoredForModeRecovery = true;
|
||||
if (equipmentEvents != null) {
|
||||
var reason = !attempt.healthAcknowledged ? "health_unverified" : "native_restore_incomplete";
|
||||
equipmentEvents.resolved(attempt.recoveryAction, "pending", reason);
|
||||
if (attempt.healthSaveFailed || !modeRestored || !vitalsRestored)
|
||||
recoveryIncomplete(attempt, reason);
|
||||
}
|
||||
}
|
||||
persistExit(attempt, modeRestored && vitalsRestored);
|
||||
sync(attempt);
|
||||
} else notice(attempt.owner, result.name(), "Equipment recovery refused: " + result + "; receipt and items retained.");
|
||||
} else {
|
||||
if (equipmentEvents != null) {
|
||||
equipmentEvents.resolved(attempt.recoveryAction, "retained", result.name().toLowerCase(java.util.Locale.ROOT));
|
||||
equipmentEvents.recovery(attempt.recoveryAction, attempt.session, attempt.character,
|
||||
attempt.recoveryPriorPhase, "pending", "receipt_and_marker_only", "retained",
|
||||
result.name().toLowerCase(java.util.Locale.ROOT), state.orElseThrow().revision());
|
||||
}
|
||||
notice(attempt.owner, result.name(), "Equipment recovery refused: " + result + "; receipt and items retained.");
|
||||
}
|
||||
} catch (RuntimeException error) {
|
||||
if (equipmentEvents != null) {
|
||||
equipmentEvents.resolved(attempt.recoveryAction, "uncertain", "native_exception");
|
||||
equipmentEvents.recovery(attempt.recoveryAction, attempt.session, attempt.character,
|
||||
attempt.recoveryPriorPhase, "restoring", "native_uncertain", "ambiguous",
|
||||
"native_exception", attempt.recoveryReceiptRevision);
|
||||
}
|
||||
fail(attempt, error);
|
||||
notice(attempt.owner, "NATIVE_UNCERTAIN", "Recovery mutation failed; native phase, items and receipt retained for inspection.");
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class EquipmentEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void acknowledgedNativePackAndExactRestorationProduceOwnJsonlWithoutDuplicatingSessionEvents() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
fixture.activate(actor);
|
||||
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
var active = sessions.active(actor.core.id).orElseThrow();
|
||||
var pending = fixture.receipts().find(actor.core.id).toCompletableFuture()
|
||||
.get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
assertEquals(active.session(), pending.pending().sessionId());
|
||||
sessions.exit(actor.player);
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
var completed = fixture.receipts().find(actor.core.id).toCompletableFuture()
|
||||
.get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
assertNull(completed.pending());
|
||||
awaitDomain(fixture, "equipment.packed", "equipment.restored");
|
||||
fixture.plugin.setEnabled(false);
|
||||
var entries = records();
|
||||
var packed = entries.stream().filter(event -> event.get("type").getAsString()
|
||||
.endsWith("equipment.packed")).toList();
|
||||
var restored = entries.stream().filter(event -> event.get("type").getAsString()
|
||||
.endsWith("equipment.restored")).toList();
|
||||
assertEquals(1, packed.stream().map(event -> event.get("id").getAsString()).distinct().count());
|
||||
assertEquals(1, restored.stream().map(event -> event.get("id").getAsString()).distinct().count());
|
||||
assertEquals(active.session().toString(), packed.getFirst().getAsJsonObject("data")
|
||||
.get("session_id").getAsString());
|
||||
assertEquals(active.session().toString(), restored.getFirst().getAsJsonObject("data")
|
||||
.get("session_id").getAsString());
|
||||
assertEquals(pending.revision().toString(), packed.getFirst().getAsJsonObject("data")
|
||||
.get("receipt_revision").getAsString());
|
||||
assertEquals(completed.revision().toString(), restored.getFirst().getAsJsonObject("data")
|
||||
.get("receipt_revision").getAsString());
|
||||
assertEquals("main", restored.getFirst().getAsJsonObject("data").get("box_source").getAsString());
|
||||
assertEquals("none", restored.getFirst().getAsJsonObject("data")
|
||||
.get("displaced_head_handling").getAsString());
|
||||
assertTrue(packed.getFirst().getAsJsonObject("data").get("slot_classes").getAsJsonArray().size() >= 1);
|
||||
assertEquals(1, entries.stream().filter(event -> event.get("type").getAsString()
|
||||
.endsWith("character.entered")).map(event -> event.get("id").getAsString()).distinct().count());
|
||||
assertEquals(1, entries.stream().filter(event -> event.get("type").getAsString()
|
||||
.endsWith("character.exited")).map(event -> event.get("id").getAsString()).distinct().count());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedReceiptCompletionCannotTurnNativeRestoreIntoAcknowledgedEvent() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
fixture.activate(actor);
|
||||
awaitDomain(fixture, "equipment.packed");
|
||||
var receipt = data.resolve("sessions").resolve(actor.core.id + ".yml");
|
||||
var backup = data.resolve("receipt-backup.yml");
|
||||
Files.move(receipt, backup);
|
||||
Files.createDirectory(receipt);
|
||||
Files.writeString(receipt.resolve("block"), "not a receipt");
|
||||
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.RESTORED,
|
||||
sessions.exit(actor.player));
|
||||
((games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters"))
|
||||
.find(actor.core.record.id()).get(5, TimeUnit.SECONDS);
|
||||
for (int i = 0; i < 4; i++) {
|
||||
var callback = fixture.tasks.poll(300, TimeUnit.MILLISECONDS);
|
||||
if (callback == null) break;
|
||||
callback.run();
|
||||
}
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () -> fixture.receipts().find(actor.core.id)
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
Files.delete(receipt.resolve("block"));
|
||||
Files.delete(receipt);
|
||||
Files.move(backup, receipt);
|
||||
assertNotNull(fixture.receipts().find(actor.core.id).toCompletableFuture()
|
||||
.get(5, TimeUnit.SECONDS).orElseThrow().pending());
|
||||
fixture.plugin.setEnabled(false);
|
||||
fixture.awaitWorkers();
|
||||
assertTrue(records().stream().noneMatch(event -> event.get("type").getAsString()
|
||||
.endsWith("equipment.restored")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void acknowledgedRestorationKeepsOriginalIdAcrossClosedWriterAndRestart() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
fixture.activate(actor);
|
||||
awaitDomain(fixture, "equipment.packed");
|
||||
((games.dmg.fantasystick.events.LocalEventLog)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog")).close();
|
||||
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
var session = sessions.active(actor.core.id).orElseThrow().session();
|
||||
sessions.exit(actor.player);
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
fixture.plugin.setEnabled(false);
|
||||
fixture.awaitWorkers();
|
||||
java.util.Set<String> pendingIds;
|
||||
try (var files = Files.list(data.resolve("equipment-event-intents"))) {
|
||||
pendingIds = files.filter(path -> path.getFileName().toString().endsWith(".pending"))
|
||||
.map(path -> path.getFileName().toString().replace(".pending", ""))
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
}
|
||||
assertEquals(1, pendingIds.size());
|
||||
fixture.plugin.setEnabled(true);
|
||||
awaitDomain(fixture, "equipment.restored");
|
||||
fixture.plugin.setEnabled(false);
|
||||
var domain = records().stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("equipment.restored")).toList();
|
||||
assertEquals(pendingIds, domain.stream().map(entry -> entry.get("id").getAsString())
|
||||
.collect(java.util.stream.Collectors.toSet()));
|
||||
assertTrue(domain.stream().allMatch(entry -> entry.getAsJsonObject("data")
|
||||
.get("session_id").getAsString().equals(session.toString())));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void cursorHeldGenuineBoxAndDisplacedHeadAreCapturedWithoutItemContents() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
fixture.activate(actor);
|
||||
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
actor.core.cursor = actor.core.inventory.getItem(9).clone();
|
||||
actor.core.inventory.setItem(9, null);
|
||||
actor.core.inventory.setHelmet(new org.bukkit.inventory.ItemStack(org.bukkit.Material.CARVED_PUMPKIN));
|
||||
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.RESTORED,
|
||||
sessions.exit(actor.player));
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
assertEquals(org.bukkit.Material.CARVED_PUMPKIN, actor.core.cursor.getType());
|
||||
awaitDomain(fixture, "equipment.packed", "equipment.restored");
|
||||
fixture.plugin.setEnabled(false);
|
||||
var restored = records().stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("equipment.restored")).findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals("cursor", restored.get("box_source").getAsString());
|
||||
assertEquals("cursor", restored.get("displaced_head_handling").getAsString());
|
||||
assertFalse(restored.toString().contains("CARVED_PUMPKIN"));
|
||||
assertFalse(restored.toString().contains("DIAMOND_SWORD"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void restartedArrivalDoesNotInventHealthAcknowledgmentOrCompletedRestore() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
fixture.activate(actor);
|
||||
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
var session = sessions.active(actor.core.id).orElseThrow().session();
|
||||
actor.core.inventory.setItem(0, new org.bukkit.inventory.ItemStack(org.bukkit.Material.STONE));
|
||||
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.OCCUPIED_SLOT,
|
||||
sessions.exit(actor.player));
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
awaitDomain(fixture, "equipment.packed");
|
||||
fixture.plugin.setEnabled(false);
|
||||
fixture.awaitWorkers();
|
||||
actor.core.inventory.setItem(0, null);
|
||||
fixture.plugin.setEnabled(true);
|
||||
fixture.tick();
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
assertNotNull(fixture.receipts().find(actor.core.id).toCompletableFuture()
|
||||
.get(5, TimeUnit.SECONDS).orElseThrow().pending());
|
||||
awaitDomain(fixture, "equipment.recovery_resolved");
|
||||
fixture.plugin.setEnabled(false);
|
||||
var entries = records();
|
||||
assertEquals(0, entries.stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("equipment.restored")).count());
|
||||
var recovery = entries.stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("equipment.recovery_resolved")).findFirst().orElseThrow();
|
||||
var value = recovery.getAsJsonObject("data");
|
||||
assertEquals(session.toString(), value.get("session_id").getAsString());
|
||||
assertEquals("packed", value.get("prior_phase").getAsString());
|
||||
assertEquals("restored", value.get("current_phase").getAsString());
|
||||
assertEquals("native_restored_receipt_pending", value.get("custody_quality").getAsString());
|
||||
assertEquals("ambiguous", value.get("outcome").getAsString());
|
||||
assertEquals("health_unverified", value.get("reason").getAsString());
|
||||
assertTrue(value.has("location"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void uncertainCursorWriteReportsAmbiguousRecoveryWithoutFalseRestore() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
fixture.activate(actor);
|
||||
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
actor.core.cursor = actor.core.inventory.getItem(9).clone();
|
||||
actor.core.inventory.setItem(9, null);
|
||||
actor.core.beforeCursorMutation = () -> { throw new IllegalStateException("Native cursor write uncertain"); };
|
||||
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.INVALID_BOX,
|
||||
sessions.exit(actor.player));
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
assertNotNull(fixture.receipts().find(actor.core.id).toCompletableFuture()
|
||||
.get(5, TimeUnit.SECONDS).orElseThrow().pending());
|
||||
actor.core.beforeCursorMutation = () -> {};
|
||||
sessions.recover(actor.player);
|
||||
assertTrue(fixture.tasks.isEmpty(), "Uncertain native writes cannot be replayed automatically");
|
||||
awaitAction(fixture, "equipment_restore", "uncertain");
|
||||
fixture.plugin.setEnabled(false);
|
||||
var entries = records();
|
||||
assertTrue(entries.stream().noneMatch(entry -> entry.get("type").getAsString()
|
||||
.endsWith("equipment.restored")));
|
||||
assertTrue(entries.stream().filter(entry -> entry.get("type").getAsString().endsWith("action.resolved"))
|
||||
.map(entry -> entry.getAsJsonObject("data"))
|
||||
.anyMatch(value -> value.has("action_kind") && value.get("action_kind").getAsString()
|
||||
.equals("equipment_restore") && value.get("outcome").getAsString().equals("uncertain")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void ambiguousNativeBoxCopiesRetainReceiptWithoutInventingRestoration() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
fixture.activate(actor);
|
||||
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
actor.core.cursor = actor.core.inventory.getItem(9).clone();
|
||||
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.AMBIGUOUS,
|
||||
sessions.exit(actor.player));
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
sessions.recover(actor.player);
|
||||
fixture.tick();
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
assertNotNull(fixture.receipts().find(actor.core.id).toCompletableFuture()
|
||||
.get(5, TimeUnit.SECONDS).orElseThrow().pending());
|
||||
awaitDomain(fixture, "equipment.recovery_resolved");
|
||||
fixture.plugin.setEnabled(false);
|
||||
var entries = records();
|
||||
assertTrue(entries.stream().noneMatch(entry -> entry.get("type").getAsString()
|
||||
.endsWith("equipment.restored")));
|
||||
var recovery = entries.stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("equipment.recovery_resolved")).toList();
|
||||
assertEquals(1, recovery.stream().map(entry -> entry.get("id").getAsString()).distinct().count());
|
||||
assertEquals("retained", recovery.getFirst().getAsJsonObject("data").get("outcome").getAsString());
|
||||
assertEquals("ambiguous", recovery.getFirst().getAsJsonObject("data").get("reason").getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void refusedOccupiedSlotThenProofBasedRecoveryReportsRetainedAndRestoredOutcomes() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
fixture.activate(actor);
|
||||
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
var session = sessions.active(actor.core.id).orElseThrow().session();
|
||||
actor.core.inventory.setItem(0, new org.bukkit.inventory.ItemStack(org.bukkit.Material.STONE));
|
||||
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.OCCUPIED_SLOT,
|
||||
sessions.exit(actor.player));
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
assertNotNull(fixture.receipts().find(actor.core.id).toCompletableFuture()
|
||||
.get(5, TimeUnit.SECONDS).orElseThrow().pending());
|
||||
actor.core.inventory.setItem(0, null);
|
||||
sessions.recover(actor.player);
|
||||
fixture.tick();
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
assertNull(fixture.receipts().find(actor.core.id).toCompletableFuture()
|
||||
.get(5, TimeUnit.SECONDS).orElseThrow().pending());
|
||||
awaitDomain(fixture, "equipment.packed", "equipment.restored", "equipment.recovery_resolved");
|
||||
fixture.plugin.setEnabled(false);
|
||||
var entries = records();
|
||||
var recovery = entries.stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("equipment.recovery_resolved")).toList();
|
||||
assertTrue(entries.stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("action.resolved")).map(entry -> entry.getAsJsonObject("data"))
|
||||
.anyMatch(value -> value.has("action_kind") && value.get("action_kind").getAsString()
|
||||
.equals("equipment_restore") && value.get("reason").getAsString().equals("occupied_slot")));
|
||||
assertTrue(recovery.stream().noneMatch(entry -> entry.getAsJsonObject("data").get("outcome")
|
||||
.getAsString().equals("retained")), "Initial refusal is not a second recovery attempt");
|
||||
assertTrue(recovery.stream().anyMatch(entry -> entry.getAsJsonObject("data").get("outcome")
|
||||
.getAsString().equals("restored") && entry.getAsJsonObject("data").get("session_id")
|
||||
.getAsString().equals(session.toString())));
|
||||
assertEquals(1, entries.stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("equipment.restored")).map(entry -> entry.get("id").getAsString()).distinct().count());
|
||||
}
|
||||
}
|
||||
|
||||
private void awaitAction(FantasyStickLifecycleNativeTest.Fixture fixture, String kind, String outcome) throws Exception {
|
||||
for (int i = 0; i < 300; i++) {
|
||||
var callback = fixture.tasks.poll(20, TimeUnit.MILLISECONDS);
|
||||
if (callback != null) callback.run();
|
||||
if (records().stream().filter(entry -> entry.get("type").getAsString().endsWith("action.resolved"))
|
||||
.map(entry -> entry.getAsJsonObject("data"))
|
||||
.anyMatch(data -> data.has("action_kind") && data.get("action_kind").getAsString().equals(kind)
|
||||
&& data.get("outcome").getAsString().equals(outcome))) return;
|
||||
}
|
||||
fail("Action resolution did not reach JSONL before shutdown");
|
||||
}
|
||||
|
||||
private void awaitDomain(FantasyStickLifecycleNativeTest.Fixture fixture, String... types) throws Exception {
|
||||
for (int i = 0; i < 300; i++) {
|
||||
var callback = fixture.tasks.poll(20, TimeUnit.MILLISECONDS);
|
||||
if (callback != null) callback.run();
|
||||
var entries = records();
|
||||
if (java.util.Arrays.stream(types).allMatch(type -> entries.stream()
|
||||
.anyMatch(entry -> entry.get("type").getAsString().endsWith(type)))) return;
|
||||
}
|
||||
fail("Accepted equipment events did not reach JSONL before shutdown");
|
||||
}
|
||||
|
||||
private List<JsonObject> records() throws Exception {
|
||||
var result = new ArrayList<JsonObject>();
|
||||
try (var dates = Files.list(data.resolve("events"))) {
|
||||
for (var date : dates.filter(path -> path.getFileName().toString().matches("[0-9]{4}-[0-9]{2}-[0-9]{2}"))
|
||||
.toList()) try (var files = Files.list(date)) {
|
||||
for (var file : files.filter(path -> path.getFileName().toString().endsWith(".jsonl")).toList()) {
|
||||
var content = Files.readString(file);
|
||||
int complete = content.lastIndexOf('\n');
|
||||
if (complete < 0) continue;
|
||||
for (var line : content.substring(0, complete).lines().toList())
|
||||
if (!line.isBlank()) result.add(JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -706,7 +706,9 @@ class FantasyStickLifecycleNativeTest {
|
||||
}
|
||||
|
||||
@Test void packagedRefusalsExplainSpaceAndIneligibleEquipmentWithoutMutation() throws Exception {
|
||||
for (boolean full : new boolean[]{false, true}) try (var f = new Fixture(data.resolve("refusal-" + full))) {
|
||||
for (boolean full : new boolean[]{false, true}) {
|
||||
Path directory = data.resolve("refusal-" + full);
|
||||
try (var f = new Fixture(directory)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new Actor(data.resolve("refusal-" + full)); f.players.put(a.core.id, a.player);
|
||||
if (full) for (int slot = 9; slot < 36; slot++) a.core.inventory.setItem(slot, new org.bukkit.inventory.ItemStack(org.bukkit.Material.STONE, 64));
|
||||
@@ -717,6 +719,25 @@ class FantasyStickLifecycleNativeTest {
|
||||
assertTrue(a.messages.getFirst().contains(full ? "free one slot" : "vanilla shulker"), a.messages::toString);
|
||||
assertEquals(1, a.particles.size()); assertTrue(a.particles.getFirst() > 0 && a.particles.getFirst() <= 16);
|
||||
assertTrue(f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).isEmpty());
|
||||
boolean actionWritten = false;
|
||||
for (int i = 0; i < 300; i++) {
|
||||
if (localRecords(directory).stream().anyMatch(entry -> entry.get("type").getAsString().endsWith("action.resolved")
|
||||
&& entry.getAsJsonObject("data").has("action_kind")
|
||||
&& entry.getAsJsonObject("data").get("action_kind").getAsString().equals("equipment_pack_preflight"))) {
|
||||
actionWritten = true; break;
|
||||
}
|
||||
Thread.sleep(20);
|
||||
}
|
||||
assertTrue(actionWritten, "Preflight refusal must reach local JSONL");
|
||||
}
|
||||
var entries = localRecords(directory);
|
||||
assertTrue(entries.stream().noneMatch(entry -> entry.get("type").getAsString().endsWith("equipment.packed")));
|
||||
assertTrue(entries.stream().filter(entry -> entry.get("type").getAsString().endsWith("action.resolved"))
|
||||
.map(entry -> entry.getAsJsonObject("data"))
|
||||
.anyMatch(value -> value.has("action_kind") && value.get("action_kind").getAsString()
|
||||
.equals("equipment_pack_preflight") && value.get("reason").getAsString()
|
||||
.equals(full ? "no_space" : "ineligible_equipment") && value.get("outcome").getAsString()
|
||||
.equals("refused")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1561,6 +1582,8 @@ class FantasyStickLifecycleNativeTest {
|
||||
.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
((java.util.concurrent.CompletionStage<?>) field(plugin, "identityAuditDrain"))
|
||||
.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
((java.util.concurrent.CompletionStage<?>) field(plugin, "equipmentAuditDrain"))
|
||||
.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
} catch (java.util.concurrent.ExecutionException | java.util.concurrent.TimeoutException failure) {
|
||||
throw new IOException("Switch event intent worker did not drain before fixture disposal", failure);
|
||||
} catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new IOException(interrupted); }
|
||||
|
||||
Reference in new issue
Block a user