Compare commits

...
3 Commits
Author SHA1 Message Date
dmg af7c75b6ed feat(parties): audit native protection decisions
Release / release (push) Successful in 15m37s
2026-09-25 14:11:48 -04:00
dmg 39881ad6fc feat(recovery): audit session and configuration outcomes
Release / release (push) Successful in 15m50s
2026-09-25 13:36:58 -04:00
dmg 9737ed0ddb feat(equipment): audit native packing and recovery
Release / release (push) Successful in 15m47s
2026-09-25 12:53:32 -04:00
15 changed files with 2380 additions and 54 deletions

No files matched your search

@@ -181,6 +181,10 @@ 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.SessionRecoveryEventBridge sessionRecoveryAuditEvents;
private java.util.concurrent.CompletionStage<Void> sessionRecoveryAuditDrain = 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);
@@ -748,6 +752,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
recoveryEvents = new games.dmg.fantasystick.events.RecoveryEventBridge(eventLog::emit, Clock.systemUTC());outOfCombatRecovery.attachEvents(recoveryEvents,record->record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationI.ID)?quickerRegeneration.bonus():java.math.BigInteger.ZERO);
reviveEvents = new games.dmg.fantasystick.events.ReviveEventBridge(eventLog::emit, Clock.systemUTC());revive.attachEvents(reviveEvents);
partyAuditEvents = new games.dmg.fantasystick.events.PartyEventBridge(eventLog::emit, Clock.systemUTC());partyManagement.attachEvents(partyAuditEvents);admin.attachPartyEvents(partyAuditEvents);
partyCombat.attachProtectionEvents(new games.dmg.fantasystick.events.PartyProtectionEventBridge(
eventLog::emit, Clock.systemUTC()));
combatRewardEvents = new games.dmg.fantasystick.events.CombatRewardEventBridge(eventLog::emit,Clock.systemUTC());combatExperience.attachEvents(combatRewardEvents);
coinEvents = new games.dmg.fantasystick.events.CoinEventBridge(eventLog::emit,Clock.systemUTC());characters.attachCoinEvents(coinEvents);admin.attachCoinEvents(coinEvents);
sessionAuditEvents = new games.dmg.fantasystick.events.SessionEventBridge(eventLog::emit,Clock.systemUTC());sessions.attachSessionEvents(sessionAuditEvents);
@@ -763,6 +769,15 @@ 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);
sessionRecoveryAuditEvents = new games.dmg.fantasystick.events.SessionRecoveryEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
failure -> getLogger().log(Level.WARNING, "Session recovery event intent failed", failure));
admin.attachSessionRecoveryEvents(sessionRecoveryAuditEvents);
sessions.attachSessionRecoveryEvents(sessionRecoveryAuditEvents);
sessions.attachDownedEvents(downedEvents);
downedController.attachEvents(downedEvents);
revive.attachDownedEvents(downedEvents);
@@ -772,7 +787,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; sessionRecoveryAuditEvents = null; messageAuditEvents = null;
getLogger().log(Level.WARNING,"Fantasy Stick local events are unavailable; gameplay remains enabled",failure);
}
getServer().getPluginManager().registerEvents(crafting, this);
@@ -863,6 +878,16 @@ 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();
});
var replaySessionRecoveryEvents = sessionRecoveryAuditEvents;
sessionRecoveryAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous recovery event intent drain failed", failure);
if (replaySessionRecoveryEvents != null) replaySessionRecoveryEvents.replay();
});
recipeEvents.replay(); // Serial worker replays old intents before new discoveries are captured.
}
getServer().getPluginManager().registerEvents(recipeDiscovery, this);
@@ -903,6 +928,8 @@ 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 closingSessionRecoveryAuditEvents = sessionRecoveryAuditEvents; sessionRecoveryAuditEvents = 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 +1243,26 @@ 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 (closingSessionRecoveryAuditEvents != null) cleanup("recovery event intent drain", () -> {
var sessionsDrained = closingSessions == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingSessions.drained();
sessionRecoveryAuditDrain = java.util.concurrent.CompletableFuture.allOf(
sessionsDrained.toCompletableFuture(), configurationDrain.toCompletableFuture())
.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Recovery owner drain failed", failure);
closingSessionRecoveryAuditEvents.close();
return null;
}).thenCompose(ignored -> closingSessionRecoveryAuditEvents.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));
}
}
@@ -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);
@@ -77,14 +80,16 @@ final class CharacterIdentityEventOutbox implements AutoCloseable {
fields.setProperty("time", event.time().toString());
fields.setProperty("type", event.type());
var context = event.context();
fields.setProperty("player", context.playerId().toString());
fields.setProperty("player_name", context.playerName());
if (context.characterId() != null) {
if (context != null) {
fields.setProperty("player", context.playerId().toString());
fields.setProperty("player_name", context.playerName());
}
if (context != null && context.characterId() != null) {
fields.setProperty("character", context.characterId().toString());
fields.setProperty("character_name", context.characterName());
fields.setProperty("session", context.sessionId().toString());
}
if (context.location() != null) {
if (context != null && context.location() != null) {
var location = context.location();
fields.setProperty("world", location.worldId().toString());
fields.setProperty("world_name", location.worldName());
@@ -98,7 +103,12 @@ 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 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");
});
var output = new ByteArrayOutputStream();
fields.store(output, "acknowledged character identity event");
@@ -129,7 +139,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"),
@@ -137,9 +147,13 @@ final class CharacterIdentityEventOutbox implements AutoCloseable {
Double.parseDouble(fields.getProperty("z")), Instant.parse(fields.getProperty("captured_at")),
fields.getProperty("quality")) : null;
var character = fields.containsKey("character") ? UUID.fromString(fields.getProperty("character")) : null;
var context = new EventInput.Context(UUID.fromString(fields.getProperty("player")),
fields.getProperty("player_name"), character, fields.getProperty("character_name"),
fields.containsKey("session") ? UUID.fromString(fields.getProperty("session")) : null, location);
if (!fields.containsKey("player") && (character != null || fields.containsKey("session") || location != null))
throw new IOException("Unbound event context");
var context = fields.containsKey("player")
? new EventInput.Context(UUID.fromString(fields.getProperty("player")),
fields.getProperty("player_name"), character, fields.getProperty("character_name"),
fields.containsKey("session") ? UUID.fromString(fields.getProperty("session")) : null, location)
: null;
var data = new LinkedHashMap<String, Object>();
for (var key : fields.stringPropertyNames()) if (key.startsWith("data.")) {
var value = fields.getProperty(key);
@@ -151,7 +165,19 @@ 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 'd' -> {
var number = Double.valueOf(value.substring(1));
if (!Double.isFinite(number)) throw new IOException("Non-finite event coordinate");
yield number;
}
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);
@@ -0,0 +1,150 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.party.CombatSources;
import games.dmg.fantasystick.party.PartyRoster;
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.util.function.Consumer;
import org.bukkit.Location;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.bukkit.event.entity.EntityDamageEvent;
/** Captures native protection decisions without changing the native damage/event authority. */
public final class PartyProtectionEventBridge {
public record Action(UUID id, Instant observedAt, EventInput.Context actor, Map<String, Object> data) { }
private final Consumer<EventInput> sink;
private final Clock clock;
public PartyProtectionEventBridge(Consumer<EventInput> sink, Clock clock) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
}
public Action damage(EntityDamageEvent event, CombatSources.Origin origin,
CombatSources.Origin recipient, Player target,
PartyRoster.Party sourceParty, PartyRoster.Party targetParty, String reason) {
try {
var id = UUID.randomUUID();
var time = clock.instant();
Entity causing = event.getDamageSource().getCausingEntity();
Entity direct = event.getDamageSource().getDirectEntity();
var source = causing instanceof Player player && player.getUniqueId().equals(origin.player()) ? player : null;
var context = source == null ? null : new EventInput.Context(source.getUniqueId(), source.getName(),
null, null, null, location(source.getLocation()));
var data = new LinkedHashMap<String, Object>();
data.put("action_kind", "party_damage_protection");
if (context == null) data.put("actor_kind", "system");
data.put("damage_cause", event.getCause().name().toLowerCase(java.util.Locale.ROOT));
data.put("source_kind", causing == null ? "provenance" : causing instanceof Player ? "player"
: causing.getType() == null ? "unavailable" : causing.getType().name().toLowerCase(java.util.Locale.ROOT));
data.put("source_player_id", origin.player().toString());
if (causing != null && causing.getUniqueId() != null)
data.put("causing_entity_id", causing.getUniqueId().toString());
if (direct != null && direct.getUniqueId() != null)
data.put("direct_entity_id", direct.getUniqueId().toString());
if (origin.character() != null) data.put("source_character_id", origin.character().toString());
if (origin.session() != null) data.put("source_session_id", origin.session().toString());
data.put("target_player_id", target.getUniqueId().toString());
if (recipient.character() != null) data.put("target_character_id", recipient.character().toString());
if (recipient.session() != null) data.put("target_session_id", recipient.session().toString());
if (sourceParty != null) data.put("source_party_id", sourceParty.id().toString());
if (targetParty != null) data.put("target_party_id", targetParty.id().toString());
data.put("provenance_quality", origin.session() == null ? "ordinary_or_unproved"
: reason.equals("stale_source") ? "claimed_session_unverified" : "matching_session_id");
data.put("provenance_age_status", "unavailable");
data.put("requested_amount", event.getDamage());
data.put("prior_cancelled", event.isCancelled());
data.put("proposed_reason", reason);
var sourceLocation = causing == null ? null : location(causing.getLocation());
var targetLocation = location(target.getLocation());
if (sourceLocation != null) data.put("source_location", coordinates(sourceLocation));
else data.put("source_location_status", "unavailable");
if (targetLocation != null) data.put("target_location", coordinates(targetLocation));
else data.put("target_location_status", "unavailable");
var action = new Action(id, time, context, Map.copyOf(data));
var observed = new LinkedHashMap<String, Object>(data);
observed.put("outcome", "observed");
emit(action, "action.observed", "observed", observed);
return action;
} catch (RuntimeException ignored) { return null; }
}
public Action nativeAttempt(String kind, Entity causing, Player target, CombatSources.Origin origin,
CombatSources.Origin recipient, PartyRoster.Party sourceParty,
PartyRoster.Party targetParty, String reason, String nativeKind) {
try {
var id = UUID.randomUUID();
var now = clock.instant();
var source = causing instanceof Player player && player.getUniqueId().equals(origin.player()) ? player : null;
var context = source == null ? null : new EventInput.Context(source.getUniqueId(), source.getName(),
null, null, null, location(source.getLocation()));
var data = new LinkedHashMap<String, Object>();
data.put("action_kind", kind);
if (context == null) data.put("actor_kind", "system");
data.put("native_kind", nativeKind);
data.put("source_player_id", origin.player().toString());
if (causing != null && causing.getUniqueId() != null)
data.put("direct_entity_id", causing.getUniqueId().toString());
if (origin.character() != null) data.put("source_character_id", origin.character().toString());
if (origin.session() != null) data.put("source_session_id", origin.session().toString());
if (recipient != null) {
data.put("target_player_id", recipient.player().toString());
if (recipient.character() != null) data.put("target_character_id", recipient.character().toString());
if (recipient.session() != null) data.put("target_session_id", recipient.session().toString());
} else data.put("target_status", "not_single_proved_player");
if (sourceParty != null) data.put("source_party_id", sourceParty.id().toString());
if (targetParty != null) data.put("target_party_id", targetParty.id().toString());
data.put("provenance_quality", origin.session() == null ? "ordinary_or_unproved"
: reason.equals("stale_source") ? "claimed_session_unverified" : "matching_session_id");
data.put("provenance_age_status", "unavailable");
data.put("requested_amount_status", "not_applicable");
data.put("prior_cancelled", false);
var sourceLocation = causing == null ? null : location(causing.getLocation());
var targetLocation = target == null ? null : location(target.getLocation());
if (sourceLocation != null) data.put("source_location", coordinates(sourceLocation));
else data.put("source_location_status", "unavailable");
if (targetLocation != null) data.put("target_location", coordinates(targetLocation));
else data.put("target_location_status", "unavailable");
var action = new Action(id, now, context, Map.copyOf(data));
var observed = new LinkedHashMap<String, Object>(data);
observed.put("outcome", "observed");
emit(action, "action.observed", "observed", observed);
return action;
} catch (RuntimeException ignored) { return null; }
}
public void resolved(Action action, String outcome, String reason, boolean cancelled, Double finalAmount) {
if (action == null) return;
var values = new LinkedHashMap<String, Object>(action.data());
values.put("outcome", outcome);
values.put("reason", reason);
values.put("final_cancelled", cancelled);
if (finalAmount != null) values.put("final_amount", finalAmount);
else values.put("final_amount_status", "not_applicable");
emit(action, "action.resolved", "resolved", values);
}
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 Map<String, Object> coordinates(EventInput.Location value) {
return Map.of("world_id", value.worldId().toString(), "world_name", value.worldName(),
"x", value.x(), "y", value.y(), "z", value.z(), "captured_at", value.capturedAt().toString());
}
private void emit(Action action, String type, String stage, Map<String, Object> data) {
try { sink.accept(new EventInput(UUID.nameUUIDFromBytes(("fantasystick:party-protection:" + action.id()
+ ':' + stage).getBytes(StandardCharsets.UTF_8)), action.id(),
stage.equals("observed") ? action.observedAt() : clock.instant(), type, action.actor(), data)); }
catch (RuntimeException ignored) { }
}
}
@@ -0,0 +1,200 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.AdventureSettings;
import games.dmg.fantasystick.persistence.EquipmentSessionStore;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletionStage;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.Location;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
/** Captured administrative/session recovery observations; never authorizes native recovery. */
public final class SessionRecoveryEventBridge implements AutoCloseable {
public record Action(UUID id, Instant observedAt, EventInput.Context context, String actorKind,
String locationStatus, UUID target) { }
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
private final FeatureEventOutbox outbox;
private final Consumer<Throwable> failures;
private final Clock clock;
public SessionRecoveryEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
Clock clock, Path data, Consumer<Throwable> failures) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
this.failures = Objects.requireNonNull(failures);
outbox = new FeatureEventOutbox(data, "session-recovery-event-intents",
Set.of("recovery.resolved", "config.changed"), sink, failures);
}
public void replay() { outbox.replay(); }
public CompletionStage<Void> drained() { return outbox.drained(); }
@Override public void close() { outbox.close(); }
public Action capture(CommandSender sender, UUID target) {
try {
var now = clock.instant();
if (sender instanceof Player player) {
var location = player.getLocation();
EventInput.Location origin = location == null || location.getWorld() == null ? null
: new EventInput.Location(location.getWorld().getUID(), location.getWorld().getName(),
location.getX(), location.getY(), location.getZ(), now, "exact");
return new Action(UUID.randomUUID(), now,
new EventInput.Context(player.getUniqueId(), player.getName(), null, null, null, origin),
"player", origin == null ? "unavailable" : null, target);
}
return new Action(UUID.randomUUID(), now, null,
sender instanceof org.bukkit.command.BlockCommandSender ? "command_block" : "console",
"not_applicable", target);
} catch (RuntimeException ignored) { return null; }
}
public void observed(Action action, String kind) {
if (action == null) return;
var data = fields(action);
data.put("action_kind", kind);
data.put("outcome", "observed");
emit(action, "action.observed", "observed", data);
}
public void resolved(Action action, String kind, String outcome, String reason) {
if (action == null) return;
var data = fields(action);
if (outcome.equals("refused")) data.remove("target_player_id");
data.put("action_kind", kind);
data.put("outcome", outcome);
data.put("reason", reason);
emit(action, "action.resolved", "resolved:" + outcome + ':' + reason, data);
}
public void inspected(Action action, Optional<EquipmentSessionStore.State> state,
String nativePhase, boolean online) {
if (action == null) return;
var data = fields(action);
data.put("action_kind", "equipment_inspect");
data.put("outcome", "authorized");
var saved = state.orElse(null);
var receipt = saved == null ? null : saved.pending();
data.put("receipt_phase", receipt != null ? "pending" : saved == null ? "absent" : "completed");
if (saved != null) data.put("receipt_revision", saved.revision().toString());
if (receipt != null) {
data.put("session_id", receipt.sessionId().toString());
data.put("character_id", receipt.characterId().toString());
}
data.put("native_phase", online ? nativePhase : "offline_unavailable");
data.put("custody_quality", online ? "receipt_and_native_observation" : "receipt_only");
emit(action, "recovery.inspected", "inspected", data);
}
/** One result per proof-based reconciliation attempt, distinct from native equipment custody. */
public void reconciled(Action action, UUID session, UUID character, String priorPhase,
String currentPhase, String outcome, String quality, String reason,
java.math.BigInteger priorRevision, java.math.BigInteger afterRevision,
UUID equipmentOperation, EventInput.Location original, EventInput.Location current) {
if (action == null) return;
var data = fields(action);
data.put("recovery_operation_id", action.id().toString());
if (session != null) data.put("session_id", session.toString());
if (character != null) data.put("character_id", character.toString());
data.put("prior_phase", priorPhase);
data.put("current_phase", currentPhase);
data.put("custody_quality", quality);
data.put("outcome", outcome);
data.put("reason", reason);
if (priorRevision != null) data.put("prior_receipt_revision", priorRevision.toString());
if (afterRevision != null) data.put("receipt_revision", afterRevision.toString());
if (equipmentOperation != null) data.put("equipment_recovery_operation_id", equipmentOperation.toString());
nativeLocation(data, "original", original);
nativeLocation(data, "current", current);
data.put("changed_components", outcome.equals("restored") && quality.equals("verified_box_and_marker")
? "verified_native_equipment" : quality.equals("native_restored_receipt_pending")
? "native_equipment_unacknowledged" : "not_independently_verified");
emit(action, "recovery.resolved", "reconciled", data);
if (!action.actorKind().equals("system")) resolved(action, "equipment_recovery", outcome, reason);
}
/** Must be called on the native thread before any asynchronous receipt acknowledgment. */
public EventInput.Location captureLocation(Location value) {
if (value == null || value.getWorld() == null) return null;
return new EventInput.Location(value.getWorld().getUID(), value.getWorld().getName(),
value.getX(), value.getY(), value.getZ(), clock.instant(), "exact");
}
private void nativeLocation(LinkedHashMap<String, Object> data, String name, EventInput.Location location) {
if (location == null) {
data.put(name + "_location_status", "unavailable");
return;
}
data.put(name + "_world_id", location.worldId().toString());
data.put(name + "_world_name", location.worldName());
data.put(name + "_x", location.x());
data.put(name + "_y", location.y());
data.put(name + "_z", location.z());
data.put(name + "_captured_at", location.capturedAt().toString());
data.put(name + "_location_status", name.equals("original") ? "captured_at_entry" : "captured_at_reconciliation");
}
public Action system(Player player) {
try {
var now = clock.instant();
var location = player.getLocation();
var action = new Action(UUID.randomUUID(), now, null, "system",
location == null || location.getWorld() == null ? "unavailable" : "captured_current_only",
player.getUniqueId());
return action;
} catch (RuntimeException ignored) { return null; }
}
public void configRead(Action action, boolean effective, Optional<String> revision) {
if (action == null) return;
var data = fields(action);
data.put("key", "adventure-mode");
data.put("effective_value", Boolean.toString(effective));
if (revision.isPresent()) data.put("persistence_revision", revision.orElseThrow());
else data.put("persistence_revision_status", "not_verified_at_read");
data.put("outcome", "authorized");
emit(action, "config.read", "read", data);
}
public void configChanged(Action action, AdventureSettings.Saved saved) {
if (action == null || saved == null) return;
var data = fields(action);
data.put("key", "adventure-mode");
data.put("before_value", Boolean.toString(saved.beforeValue()));
data.put("after_value", Boolean.toString(saved.value()));
data.put("effective_value", Boolean.toString(saved.value()));
data.put("persistence_revision", saved.revision());
data.put("application", "future_sessions");
data.put("outcome", "acknowledged");
emit(action, "config.changed", "changed", data);
}
private LinkedHashMap<String, Object> fields(Action action) {
var data = new LinkedHashMap<String, Object>();
data.put("actor_kind", action.actorKind());
if (action.locationStatus() != null) data.put("location_status", action.locationStatus());
if (action.target() != null) data.put("target_player_id", action.target().toString());
return data;
}
private void emit(Action action, String type, String stage, LinkedHashMap<String, Object> data) {
try {
var event = new EventInput(UUID.nameUUIDFromBytes(("fantasystick:session-recovery:" + action.id()
+ ':' + stage).getBytes(StandardCharsets.UTF_8)), action.id(),
stage.equals("observed") ? action.observedAt() : clock.instant(), type, action.context(), data);
if (type.equals("config.changed") || type.equals("recovery.resolved")) outbox.accepted(event);
else sink.apply(event);
}
catch (RuntimeException failure) { try { failures.accept(failure); } catch (RuntimeException ignored) { } }
}
}
@@ -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. */
@@ -5,6 +5,7 @@ import java.util.Objects;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import java.util.WeakHashMap;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
@@ -17,12 +18,24 @@ public final class PartyCombat implements Listener, AutoCloseable {
private boolean closed;
private final PartyRoster parties;
private final CombatSources sources;
private games.dmg.fantasystick.events.PartyProtectionEventBridge protectionEvents;
private final WeakHashMap<EntityDamageEvent, DamageAudit> damageAudits = new WeakHashMap<>();
private final WeakHashMap<org.bukkit.event.Event, NativeAudit> nativeAudits = new WeakHashMap<>();
private record NativeAudit(games.dmg.fantasystick.events.PartyProtectionEventBridge.Action action,
String reason) { }
private record DamageAudit(games.dmg.fantasystick.events.PartyProtectionEventBridge.Action action,
String decision) { }
public PartyCombat(PartyRoster parties, EquipmentSessions sessions) {
this.parties = Objects.requireNonNull(parties);
this.sources = new CombatSources(sessions);
}
public void attachProtectionEvents(games.dmg.fantasystick.events.PartyProtectionEventBridge events) {
if (protectionEvents != null || closed) throw new IllegalStateException("Party protection events already attached");
protectionEvents = Objects.requireNonNull(events);
}
public void install(org.bukkit.plugin.Plugin plugin) {
cleanup = plugin.getServer().getScheduler().runTaskTimer(plugin, () -> {
if (!closed) for (var player : plugin.getServer().getOnlinePlayers()) OngoingDamage.sweep(player);
@@ -31,6 +44,8 @@ public final class PartyCombat implements Listener, AutoCloseable {
@Override public void close() {
closed = true;
damageAudits.clear();
nativeAudits.clear();
if (cleanup != null) cleanup.cancel();
}
@@ -40,8 +55,21 @@ public final class PartyCombat implements Listener, AutoCloseable {
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void impact(org.bukkit.event.entity.ProjectileHitEvent event) {
var origin = sources.read(event.getEntity());
if (origin != null && (!sources.current(origin) || !(event.getEntity() instanceof org.bukkit.entity.ThrownPotion)
&& event.getHitEntity() instanceof Player target && blocked(origin, target))) event.setCancelled(true);
if (origin == null) return;
var target = event.getHitEntity() instanceof Player player ? player : null;
var reason = !sources.current(origin) ? "stale_source"
: target != null && !(event.getEntity() instanceof org.bukkit.entity.ThrownPotion)
? protectionReason(origin, target) : null;
if (reason != null) {
captureNative(event, "party_projectile_impact_protection", event.getEntity(), target, origin,
reason, "projectile_impact");
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void resolvedImpact(org.bukkit.event.entity.ProjectileHitEvent event) {
resolveNative(event, event);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
@@ -50,13 +78,33 @@ public final class PartyCombat implements Listener, AutoCloseable {
// Revoke stale continuation here. Valid healing and unrelated recipients are untouched;
// actual harmful applications and instantaneous damage have their own guards below.
var origin = sources.read(event.getEntity());
if (origin != null && !sources.current(origin)) event.setCancelled(true);
if (origin != null && !sources.current(origin)) {
var recipients = event.getAffectedEntities().stream().filter(Player.class::isInstance)
.map(Player.class::cast).toList();
captureNative(event, "party_splash_protection", event.getEntity(),
recipients.size() == 1 ? recipients.getFirst() : null, origin, "stale_source", "potion_splash");
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void resolvedSplash(org.bukkit.event.entity.PotionSplashEvent event) {
resolveNative(event, event);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void staleLingering(LingeringPotionSplashEvent event) {
var origin = sources.read(event.getEntity());
if (origin != null && !sources.current(origin)) event.setCancelled(true);
if (origin != null && !sources.current(origin)) {
captureNative(event, "party_lingering_protection", event.getEntity(), null,
origin, "stale_source", "lingering_splash");
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void resolvedLingering(LingeringPotionSplashEvent event) {
resolveNative(event, event);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
@@ -64,12 +112,49 @@ public final class PartyCombat implements Listener, AutoCloseable {
sources.store(event.getAreaEffectCloud(), sources.read(event.getEntity()));
}
@EventHandler(priority = EventPriority.LOWEST)
public void observeDamage(EntityDamageEvent event) {
if (protectionEvents == null || !(event.getEntity() instanceof Player target)) return;
// OngoingDamage.read reconciles native PDC. Only the original HIGHEST guard may do so;
// the LOWEST audit must not change its timing or mutate an earlier protection's state.
if (event.getCause() == EntityDamageEvent.DamageCause.POISON
|| event.getCause() == EntityDamageEvent.DamageCause.WITHER
|| event.getCause() == EntityDamageEvent.DamageCause.FIRE_TICK) return;
var origin = sources.source(event);
if (origin == null) return;
var ongoing = OngoingDamage.read(target, event.getCause());
String reason = ongoing != null && !sources.current(ongoing.target()) ? "stale_target"
: protectionReason(origin, target);
if (reason == null) return;
captureDamage(event, target, origin, reason);
}
@EventHandler(priority = EventPriority.MONITOR)
public void resolvedDamage(EntityDamageEvent event) {
if (protectionEvents == null) return;
var audit = damageAudits.remove(event);
if (audit == null) return;
boolean prior = Boolean.TRUE.equals(audit.action().data().get("prior_cancelled"));
String reason = prior && event.isCancelled() ? "prior_cancelled"
: !event.isCancelled() && audit.decision() != null ? "protection_overridden"
: event.isCancelled() && audit.decision() != null ? audit.decision()
: event.isCancelled() ? "external_cancelled" : "protection_not_applied";
String outcome = !event.isCancelled() ? "accepted" : prior || audit.decision() == null
? "cancelled" : "refused";
protectionEvents.resolved(audit.action(), outcome, reason, event.isCancelled(),
event.isCancelled() ? 0 : event.getFinalDamage());
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void damage(EntityDamageEvent event) {
if (games.dmg.fantasystick.ability.GuardianBond.redirectedDamage()) return;
if (event.getEntity() instanceof Player target) {
var ongoing = OngoingDamage.read(target, event.getCause());
if (ongoing != null && (!sources.current(ongoing.target()) || blocked(ongoing.source(), target))) {
var reason = !sources.current(ongoing.target()) ? "stale_target"
: protectionReason(ongoing.source(), target);
captureDamage(event, target, ongoing.source(), reason);
markDamageDecision(event, reason);
event.setCancelled(true); return;
}
}
@@ -77,7 +162,14 @@ public final class PartyCombat implements Listener, AutoCloseable {
if (origin == null && event instanceof EntityDamageByEntityEvent entity) origin = sources.read(entity.getDamager());
if (origin == null) origin = sources.read(event.getDamageSource().getCausingEntity());
if (origin != null && (!sources.current(origin)
|| event.getEntity() instanceof Player target && blocked(origin, target))) event.setCancelled(true);
|| event.getEntity() instanceof Player target && blocked(origin, target))) {
if (event.getEntity() instanceof Player target) {
var reason = protectionReason(origin, target);
captureDamage(event, target, origin, reason);
markDamageDecision(event, reason);
}
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
@@ -85,7 +177,16 @@ public final class PartyCombat implements Listener, AutoCloseable {
if (!(event.getEntity() instanceof Player target) || event.getNewEffect() == null
|| OngoingDamage.cause(event.getModifiedType()) == null) return;
var origin = sources.read(event.getSource());
if (origin != null && blocked(origin, target)) event.setCancelled(true);
if (origin != null && blocked(origin, target)) {
captureNative(event, "party_effect_application_protection", event.getSource(), target,
origin, protectionReason(origin, target), event.getModifiedType().getKey().getKey());
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void resolvedPotion(org.bukkit.event.entity.EntityPotionEffectEvent event) {
resolveNative(event, event);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
@@ -105,7 +206,16 @@ public final class PartyCombat implements Listener, AutoCloseable {
public void combust(org.bukkit.event.entity.EntityCombustByEntityEvent event) {
if (!(event.getEntity() instanceof Player target)) return;
var origin = sources.read(event.getCombuster());
if (origin != null && blocked(origin, target)) event.setCancelled(true);
if (origin != null && blocked(origin, target)) {
captureNative(event, "party_ignite_protection", event.getCombuster(), target,
origin, protectionReason(origin, target), "combust");
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void resolvedCombust(org.bukkit.event.entity.EntityCombustByEntityEvent event) {
resolveNative(event, event);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
@@ -118,11 +228,49 @@ public final class PartyCombat implements Listener, AutoCloseable {
: new OngoingDamage.Application(origin, sources.player(target.getUniqueId())));
}
private boolean blocked(CombatSources.Origin origin, Player target) {
private void captureNative(org.bukkit.event.Event event, String kind, org.bukkit.entity.Entity causing,
Player target, CombatSources.Origin origin, String reason, String nativeKind) {
if (protectionEvents == null || reason == null) return;
var recipient = target == null ? null : sources.player(target.getUniqueId());
var action = protectionEvents.nativeAttempt(kind, causing, target, origin, recipient,
parties.partyOf(origin.player()).orElse(null),
recipient == null ? null : parties.partyOf(recipient.player()).orElse(null),
reason, nativeKind);
if (action != null) nativeAudits.put(event, new NativeAudit(action, reason));
}
private void resolveNative(org.bukkit.event.Event event, org.bukkit.event.Cancellable cancellable) {
if (protectionEvents == null) return;
var audit = nativeAudits.remove(event);
if (audit != null) protectionEvents.resolved(audit.action(), cancellable.isCancelled() ? "refused" : "accepted",
cancellable.isCancelled() ? audit.reason() : "external_uncancelled", cancellable.isCancelled(), null);
}
private void captureDamage(EntityDamageEvent event, Player target, CombatSources.Origin origin, String reason) {
if (protectionEvents == null || damageAudits.containsKey(event) || reason == null) return;
var recipient = sources.player(target.getUniqueId());
var action = protectionEvents.damage(event, origin, recipient, target,
parties.partyOf(origin.player()).orElse(null), parties.partyOf(recipient.player()).orElse(null), reason);
if (action != null) damageAudits.put(event, new DamageAudit(action, null));
}
private void markDamageDecision(EntityDamageEvent event, String reason) {
var audit = damageAudits.get(event);
if (audit != null && reason != null) damageAudits.put(event, new DamageAudit(audit.action(), reason));
}
private String protectionReason(CombatSources.Origin origin, Player target) {
if (!sources.current(origin)) return "stale_source";
var recipient = sources.player(target.getUniqueId());
boolean fantasy = origin.session() != null;
return !sources.current(origin) || fantasy != (recipient.session() != null) || fantasy
&& !origin.player().equals(recipient.player())
&& parties.partyOf(origin.player()).filter(group -> group.members().contains(recipient.player())).isPresent();
if (fantasy != (recipient.session() != null)) return "mixed_mode";
if (fantasy && !origin.player().equals(recipient.player())
&& parties.partyOf(origin.player()).filter(group -> group.members().contains(recipient.player())).isPresent())
return "same_party";
return null;
}
private boolean blocked(CombatSources.Origin origin, Player target) {
return protectionReason(origin, target) != null;
}
}
@@ -30,6 +30,9 @@ public final class AdventureSettings implements AutoCloseable {
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-configuration-persistence"));
private volatile boolean enabled;
private volatile String revision;
public java.util.Optional<String> revision() { return java.util.Optional.ofNullable(revision); }
public record Saved(boolean beforeValue, boolean value, String revision) { }
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
@@ -53,13 +56,32 @@ public final class AdventureSettings implements AutoCloseable {
public boolean enabled() { return enabled; }
public synchronized CompletionStage<Boolean> set(boolean value) {
public CompletionStage<Boolean> set(boolean value) {
return setRecorded(value).thenApply(Saved::value);
}
/** The digest identifies the exact acknowledged config bytes, not a cross-file transaction. */
public CompletionStage<Saved> setRecorded(boolean value) {
return setRecorded(value, ignored -> { });
}
/** Capture an acknowledged audit intent before the configuration worker finishes draining. */
public synchronized CompletionStage<Saved> setRecorded(boolean value, java.util.function.Consumer<Saved> onSaved) {
Objects.requireNonNull(onSaved);
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
try {
return CompletableFuture.supplyAsync(() -> {
try { save(value); enabled = value; return value; }
catch (IOException | RuntimeException failure) {
try {
boolean before = enabled;
var revision = save(value);
enabled = value;
this.revision = revision;
var saved = new Saved(before, value, revision);
try { onSaved.accept(saved); }
catch (RuntimeException ignored) { /* An audit observer cannot roll back an acknowledged policy. */ }
return saved;
} catch (IOException | RuntimeException failure) {
// No parser contents, filesystem paths or configuration values in diagnostics.
throw new java.util.concurrent.CompletionException(new IOException(
"Adventure configuration save refused or failed; effective setting unchanged"));
@@ -71,7 +93,7 @@ public final class AdventureSettings implements AutoCloseable {
}
}
private void save(boolean value) throws IOException {
private String save(boolean value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml");
@@ -111,7 +133,15 @@ public final class AdventureSettings implements AutoCloseable {
while (buffer.hasRemaining()) output.write(buffer);
output.force(true);
}
String revision;
try {
revision = "sha256:" + java.util.HexFormat.of().formatHex(
java.security.MessageDigest.getInstance("SHA-256").digest(bytes));
} catch (java.security.NoSuchAlgorithmException missing) {
throw new IllegalStateException("SHA-256 unavailable", missing);
}
replacement.move(temporary, file);
return revision;
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
@@ -109,6 +109,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private games.dmg.fantasystick.book.CharacterBookController characterBooks;
private games.dmg.fantasystick.persistence.CharacterStorage characters;
private AdminCharacterRecovery characterRecovery;
private games.dmg.fantasystick.events.SessionRecoveryEventBridge sessionRecoveryEvents;
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics) {
@@ -412,6 +413,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
characterRecovery = Objects.requireNonNull(recovery);
}
public void attachSessionRecoveryEvents(games.dmg.fantasystick.events.SessionRecoveryEventBridge events) {
if (closed || sessionRecoveryEvents != null) throw new IllegalStateException("Session recovery events already attached or command closed");
sessionRecoveryEvents = Objects.requireNonNull(events);
}
public void attachPartyEvents(games.dmg.fantasystick.events.PartyEventBridge events){if(closed||partyEvents!=null)throw new IllegalStateException("Party events already attached or command closed");partyEvents=Objects.requireNonNull(events);}
public void attachPartyRegistrars(games.dmg.fantasystick.party.PartyRegistrarDirectory directory,
@@ -441,6 +447,13 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (closed) return true;
if (!sender.hasPermission(PERMISSION)) {
if (sessionRecoveryEvents != null && args.length > 0
&& (args[0].equals("inspect") || args[0].equals("recover"))) {
var action = sessionRecoveryEvents.capture(sender, null);
var kind = args[0].equals("inspect") ? "equipment_inspect" : "equipment_recovery";
sessionRecoveryEvents.observed(action, kind);
sessionRecoveryEvents.resolved(action, kind, "refused", "unauthorized");
}
sender.sendMessage("Permission denied: you need fantasystick.admin to administer Fantasy Stick.");
return true;
}
@@ -497,24 +510,41 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
return true;
}
if (args[0].equals("recover")) {
var request = sessionRecoveryEvents == null ? null : sessionRecoveryEvents.capture(sender, owner);
if (sessionRecoveryEvents != null) sessionRecoveryEvents.observed(request, "equipment_recovery");
var player = server.getPlayer(owner);
if (player == null || !player.isOnline()) {
if (sessionRecoveryEvents != null) sessionRecoveryEvents.resolved(request,
"equipment_recovery", "refused", "target_offline");
sender.sendMessage("Recovery refused: target " + owner + " must be online. No items changed.");
return true;
}
sessions.recover(player);
sessions.recover(player, request);
sender.sendMessage("Validated equipment recovery requested for " + owner
+ ". This is not confirmation of restoration; ambiguous items and receipts are retained.");
return true;
}
var inspection = sessionRecoveryEvents == null ? null : sessionRecoveryEvents.capture(sender, owner);
if (sessionRecoveryEvents != null) sessionRecoveryEvents.observed(inspection, "equipment_inspect");
receipts.find(owner).whenComplete((state, failure) -> {
if (failure != null) report(owner, "inspect", failure);
if (closed) return;
try {
nextTick.accept(() -> {
if (closed || !sender.hasPermission(PERMISSION)) return;
if (sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
if (closed) return;
if (!sender.hasPermission(PERMISSION)) {
if (sessionRecoveryEvents != null) sessionRecoveryEvents.resolved(inspection,
"equipment_inspect", "refused", "permission_changed");
return;
}
if (sender instanceof org.bukkit.entity.Player player && !player.isOnline()) {
if (sessionRecoveryEvents != null) sessionRecoveryEvents.resolved(inspection,
"equipment_inspect", "cancelled", "sender_offline");
return;
}
if (failure != null) {
if (sessionRecoveryEvents != null) sessionRecoveryEvents.resolved(inspection,
"equipment_inspect", "failed", "receipt_unavailable");
sender.sendMessage("Equipment inspection failed for " + owner + "; retained state requires inspection. See server diagnostics.");
return;
}
@@ -530,9 +560,18 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
+ " character=" + exit.characterId() + " exited=" + exit.exitedAt() + " deadline=" + exit.deadline());
}
var player = server.getPlayer(owner);
var marker = player == null || !player.isOnline()
? java.util.Optional.<EquipmentSessionMarker.Token>empty() : EquipmentSessionMarker.read(player);
if (sessionRecoveryEvents != null) {
sessionRecoveryEvents.inspected(inspection, state,
marker.map(token -> token.phase().name().toLowerCase(java.util.Locale.ROOT))
.orElse(player == null || !player.isOnline() ? "offline_unavailable" :
EquipmentSessionMarker.present(player) ? "malformed" : "absent"),
player != null && player.isOnline());
sessionRecoveryEvents.resolved(inspection, "equipment_inspect", "accepted", "read_completed");
}
if (player == null || !player.isOnline()) sender.sendMessage("Native state: offline; not inspected.");
else {
var marker = EquipmentSessionMarker.read(player);
sender.sendMessage("Online native marker=" + marker.map(token -> "session=" + token.session()
+ " character=" + token.character() + " phase=" + token.phase()).orElseGet(() ->
EquipmentSessionMarker.present(player) ? "malformed (retained)" : "none"));
@@ -1686,22 +1725,39 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void adminReviveConfiguration(CommandSender sender,String[] args){if(adminReviveSettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY+"="+adminReviveSettings.value()+" (applies to later admin revival selections only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.AdminReviveSettings.Update> operation;try{operation=adminReviveSettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Admin revival timeout is invalid; the effective value is unchanged.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-admin-revive",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure==null)adminReviveSettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;sender.sendMessage(failure==null?"Saved "+games.dmg.fantasystick.persistence.AdminReviveSettings.KEY+"="+saved.value()+" for later selections; open selections retain their deadline.":"Admin revival configuration save failed; the effective setting is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void adventureConfiguration(CommandSender sender, String[] args) {
var action = sessionRecoveryEvents == null ? null : sessionRecoveryEvents.capture(sender, null);
if (sessionRecoveryEvents != null) sessionRecoveryEvents.observed(action, "adventure_mode_config");
if (args.length == 3 && !args[2].equals("true") && !args[2].equals("false")) {
if (sessionRecoveryEvents != null) sessionRecoveryEvents.resolved(action,
"adventure_mode_config", "refused", "invalid_value");
sender.sendMessage("Usage: /fstickadmin config adventure-mode [true|false]"); return;
}
if (settings == null) { sender.sendMessage("Adventure configuration is unavailable."); return; }
if (settings == null) {
if (sessionRecoveryEvents != null) sessionRecoveryEvents.resolved(action,
"adventure_mode_config", "failed", "settings_unavailable");
sender.sendMessage("Adventure configuration is unavailable."); return;
}
if (args.length == 2) {
if (sessionRecoveryEvents != null) {
sessionRecoveryEvents.configRead(action, settings.enabled(), settings.revision());
sessionRecoveryEvents.resolved(action, "adventure_mode_config", "accepted", "read_completed");
}
sender.sendMessage("adventure-mode=" + settings.enabled() + " (applies to new sessions only)"); return;
}
settings.set(Boolean.parseBoolean(args[2])).whenComplete((saved, failure) -> {
settings.setRecorded(Boolean.parseBoolean(args[2]), saved -> {
if (sessionRecoveryEvents != null) sessionRecoveryEvents.configChanged(action, saved);
}).whenComplete((saved, failure) -> {
if (failure != null) report(null, "config-adventure-mode", failure);
if (sessionRecoveryEvents != null) sessionRecoveryEvents.resolved(action, "adventure_mode_config",
failure == null ? "accepted" : "failed", failure == null ? "save_acknowledged" : "save_failed");
if (closed) return;
try {
nextTick.accept(() -> {
if (closed || !sender.hasPermission(PERMISSION)
if (closed) return;
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(failure == null
? "Saved adventure-mode=" + saved + " for new sessions only; active sessions are unchanged."
? "Saved adventure-mode=" + saved.value() + " for new sessions only; active sessions are unchanged."
: "Adventure configuration save failed; the effective setting is unchanged.");
});
} catch (RuntimeException rejected) { report(null, "config-response", rejected); }
@@ -50,6 +50,8 @@ 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 games.dmg.fantasystick.events.SessionRecoveryEventBridge sessionRecoveryEvents;
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 +110,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 +125,18 @@ 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;
games.dmg.fantasystick.events.SessionRecoveryEventBridge.Action sessionRecoveryAction;
games.dmg.fantasystick.events.EventInput.Location recoveryOriginalLocation;
games.dmg.fantasystick.events.EventInput.Location recoveryCurrentLocation;
String recoveryPriorPhase = "unavailable";
java.math.BigInteger recoveryReceiptRevision;
boolean recoveryResolved;
boolean sessionRecoveryResolved;
boolean restoreEventWritten;
Duration exitDelay;
boolean arrival;
Attempt recoveryOwner;
@@ -217,6 +232,8 @@ 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 attachSessionRecoveryEvents(games.dmg.fantasystick.events.SessionRecoveryEventBridge events){if(!owned.isEmpty()||sessionRecoveryEvents!=null)throw new IllegalStateException("Recovery events already attached or sessions started");sessionRecoveryEvents=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 +650,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 +673,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 +722,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,12 +1068,27 @@ 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);
}
}
captureRecoveryLocations(attempt);
sessionReconciled(attempt, "restoring", "ambiguous", "native_uncertain",
"native_exception", attempt.recoveryReceiptRevision);
restoreVitals(attempt); // Proven health ownership is independent of uncertain equipment writes.
captureRecoveryLocations(attempt);
persistExit(attempt, false);
diagnostic(attempt, failure);
notice(attempt.owner, "NATIVE_UNCERTAIN", "Restoration failed; items and receipt require inspection.");
@@ -1054,10 +1098,28 @@ public final class EquipmentSessions implements AutoCloseable {
if (result == EquipmentStorage.RestoreResult.RESTORED && (!modeRestored || !vitalsRestored || !attempt.healthAcknowledged)) {
attempt.gearRestoredForModeRecovery = true;
}
captureRecoveryLocations(attempt);
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);
}
sessionReconciled(attempt, "pending", "retained", "receipt_and_marker_only",
result.name().toLowerCase(java.util.Locale.ROOT), 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 +1149,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,11 +1186,46 @@ 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) {
attempt.healthSaveFailed = true;
diagnostic(attempt, failure);
}
}
private void recoveryIncomplete(Attempt attempt, String reason) {
if (attempt.recoveryResolved && attempt.sessionRecoveryResolved) return;
if (equipmentEvents != null && attempt.recoveryAction != null && !attempt.recoveryResolved) {
attempt.recoveryResolved = true;
equipmentEvents.recovery(attempt.recoveryAction, attempt.session, attempt.character,
attempt.recoveryPriorPhase, "restored", "native_restored_receipt_pending",
"ambiguous", reason, attempt.recoveryReceiptRevision);
}
sessionReconciled(attempt, "restored", "ambiguous", "native_restored_receipt_pending",
reason, attempt.recoveryReceiptRevision);
}
private void captureRecoveryLocations(Attempt attempt) {
if (sessionRecoveryEvents == null || attempt.sessionRecoveryAction == null) return;
attempt.recoveryOriginalLocation = sessionRecoveryEvents.captureLocation(attempt.entryLocation);
attempt.recoveryCurrentLocation = sessionRecoveryEvents.captureLocation(attempt.player.getLocation());
}
private void sessionReconciled(Attempt attempt, String phase, String outcome, String quality,
String reason, java.math.BigInteger revision) {
if (sessionRecoveryEvents == null || attempt.sessionRecoveryAction == null || attempt.sessionRecoveryResolved) return;
attempt.sessionRecoveryResolved = true;
try {
sessionRecoveryEvents.reconciled(attempt.sessionRecoveryAction, attempt.session, attempt.character,
attempt.recoveryPriorPhase, phase, outcome, quality, reason, attempt.recoveryReceiptRevision,
revision, attempt.recoveryAction == null ? null : attempt.recoveryAction.id(),
attempt.recoveryOriginalLocation, attempt.recoveryCurrentLocation);
} catch (RuntimeException failure) { diagnostic(attempt, failure); }
}
@@ -1146,11 +1255,34 @@ public final class EquipmentSessions implements AutoCloseable {
: receipts.recordExit(attempt.owner, attempt.session, attempt.exitDelay), (state, failure) -> {
attempt.saving = false;
if (failure != null) {
sessionReconciled(attempt,
attempt.nativeRestored ? "restored" : "pending",
attempt.nativeRestored ? "ambiguous" : "failed",
attempt.nativeRestored ? "native_restored_receipt_pending" : "receipt_unavailable",
"receipt_save_failed", attempt.recoveryReceiptRevision);
diagnostic(attempt, failure);
if (attempt.defeated) conversionUncertain(attempt,
"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());
}
}
sessionReconciled(attempt, "restored", "restored",
attempt.restoreEvent == null ? "receipt_and_marker_only" : "verified_box_and_marker",
"receipt_completed", state.revision());
if (!attempt.switchEventWritten && switchEvents != null) {
attempt.switchEventWritten = true;
switchEvents.started(attempt.switchExit, state);
@@ -1268,16 +1400,21 @@ public final class EquipmentSessions implements AutoCloseable {
}
/** Explicit proof-based reconciliation; never infer restoration from an absent box. */
public synchronized void recover(Player player) { recover(player, false); }
public synchronized void recover(Player player) { recover(player, false, null); }
public synchronized void recover(Player player, games.dmg.fantasystick.events.SessionRecoveryEventBridge.Action action) {
recover(player, false, action);
}
/** Join reconciliation: recover a pending receipt, otherwise request normal entry. */
public synchronized void arrive(Player player) {
var directory=downedDirectory;
if(directory!=null){var death=directory.all().stream().filter(value->value.player().equals(player.getUniqueId())&&value.phase()==DownedEpisode.Phase.RESOLVED&&value.outcome()==DownedEpisode.Outcome.FINAL_DEATH&&!value.published()).findFirst().orElse(null);if(death!=null){reconcileOfflineFinalDeath(player,death);return;}}
recover(player,true);
recover(player,true,null);
}
private void recover(Player player, boolean arrival) {
private void recover(Player player, boolean arrival,
games.dmg.fantasystick.events.SessionRecoveryEventBridge.Action action) {
UUID owner = player.getUniqueId();
invalidateCursor(owner);
if (closed || !player.isOnline()) return;
@@ -1296,6 +1433,7 @@ public final class EquipmentSessions implements AutoCloseable {
cancelEntry(owner);
var attempt = new Attempt(player, owner, null);
attempt.recoveryOwner = previous;
attempt.sessionRecoveryAction = action;
// A recovered receipt has no process-local never-packed proof.
attempt.effectsStarted = true;
attempt.arrival = arrival;
@@ -1312,7 +1450,16 @@ public final class EquipmentSessions implements AutoCloseable {
notice(attempt.owner, "STALE_RECOVERY", "Session ownership changed after the recovery read; no old receipt was replayed.");
return;
}
if (failure != null) { diagnostic(attempt, failure); return; }
if (failure != null) {
if (sessionRecoveryEvents != null) {
var action = attempt.sessionRecoveryAction == null
? sessionRecoveryEvents.system(attempt.player) : attempt.sessionRecoveryAction;
sessionRecoveryEvents.reconciled(action, null, null, "unavailable", "unavailable",
"failed", "receipt_unreadable", "receipt_read_failed", null, null, null, null,
sessionRecoveryEvents.captureLocation(attempt.player.getLocation()));
}
diagnostic(attempt, failure); return;
}
var receipt = state.map(EquipmentSessionStore.State::pending).orElse(null);
if (receipt == null) {
if (attempt.arrival) {
@@ -1323,10 +1470,25 @@ 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");
if (sessionRecoveryEvents != null) {
var action = attempt.sessionRecoveryAction == null
? sessionRecoveryEvents.system(attempt.player) : attempt.sessionRecoveryAction;
sessionRecoveryEvents.reconciled(action, null, null, "absent", "absent", "refused",
"receipt_missing", "no_receipt", null, null, null, null,
sessionRecoveryEvents.captureLocation(attempt.player.getLocation()));
}
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 (sessionRecoveryEvents != null && attempt.sessionRecoveryAction == null)
attempt.sessionRecoveryAction = sessionRecoveryEvents.system(attempt.player);
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 +1496,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 +1504,11 @@ 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.sessionRecoveryAction = attempt.sessionRecoveryAction;
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 +1518,47 @@ 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);
captureRecoveryLocations(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());
}
sessionReconciled(attempt, "pending", "retained", "receipt_and_marker_only",
result.name().toLowerCase(java.util.Locale.ROOT), attempt.recoveryReceiptRevision);
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);
}
captureRecoveryLocations(attempt);
sessionReconciled(attempt, "restoring", "ambiguous", "native_uncertain",
"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,10 @@ 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);
((java.util.concurrent.CompletionStage<?>) field(plugin, "sessionRecoveryAuditDrain"))
.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); }
@@ -0,0 +1,393 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Current persistent-party native damage decision -> generic local JSONL action. */
class PartyProtectionEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void currentCharacterPartyNativeDirectDamageRefusalHasOneCorrelatedFinalDecision() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var attacker = actor(fixture); var target = actor(fixture);
fixture.activate(attacker); fixture.activate(target);
var parties = PartyLifecycleNativeTest.parties(fixture);
var group = parties.create(attacker.core.id).orElseThrow();
var invitation = parties.invite(attacker.core.id, group.id(), target.core.id).orElseThrow();
assertTrue(parties.accept(target.core.id, invitation.id()));
var damage = PartyCombatNativeTest.hit(fixture, attacker.player, target.player);
assertTrue(damage.isCancelled());
for (int i = 0; i < 250 && records().stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("reason")
&& entry.getAsJsonObject("data").get("reason").getAsString().equals("same_party")); i++)
Thread.sleep(20);
assertTrue(records().stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("combat.damage")), "A protected hit cannot become an accepted damage record");
assertTrue(parties.leave(target.core.id, group.id()));
assertFalse(PartyCombatNativeTest.hit(fixture, attacker.player, target.player).isCancelled());
fixture.plugin.setEnabled(false);
var entries = records();
var observed = entries.stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.observed") && entry.getAsJsonObject("data").has("action_kind")
&& entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_damage_protection"))
.toList();
var resolved = entries.stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("reason")
&& entry.getAsJsonObject("data").get("reason").getAsString().equals("same_party"))
.toList();
assertEquals(1, observed.size()); assertEquals(1, resolved.size());
assertEquals(observed.getFirst().getAsJsonObject("data").get("action_id").getAsString(),
resolved.getFirst().getAsJsonObject("data").get("action_id").getAsString());
var value = resolved.getFirst().getAsJsonObject("data");
assertEquals(group.id().toString(), value.get("source_party_id").getAsString());
assertEquals(group.id().toString(), value.get("target_party_id").getAsString());
assertEquals("refused", value.get("outcome").getAsString());
assertTrue(value.has("source_location")); assertTrue(value.has("target_location"));
assertTrue(entries.stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("party.created")), "Protection cannot resurrect transient lifecycle records");
}
}
@Test void observationDoesNotReconcileNativeOngoingProvenanceBeforeProtectionPriority() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var attacker = actor(fixture); var target = actor(fixture);
fixture.activate(attacker); fixture.activate(target);
var effect = new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.POISON, 200, 0);
org.mockito.Mockito.doReturn(effect).when(target.player).getPotionEffect(effect.getType());
fixture.dispatcher.accept(new org.bukkit.event.entity.EntityPotionEffectEvent(target.player,
null, effect, attacker.player, org.bukkit.event.entity.EntityPotionEffectEvent.Cause.POTION_SPLASH,
org.bukkit.event.entity.EntityPotionEffectEvent.Action.ADDED, true));
var before = target.core.pdc.serializeToBytes();
org.mockito.Mockito.doReturn(null).when(target.player).getPotionEffect(effect.getType());
var atNormalPriority = new org.bukkit.event.Listener() {
byte[] observed;
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.NORMAL)
public void inspect(org.bukkit.event.entity.EntityDamageEvent ignored) {
observed = assertDoesNotThrow(() -> target.core.pdc.serializeToBytes());
}
};
fixture.dispatcher.register(atNormalPriority, fixture.plugin);
var tick = new org.bukkit.event.entity.EntityDamageEvent(target.player,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.POISON,
org.mockito.Mockito.mock(org.bukkit.damage.DamageSource.class), 1);
fixture.dispatcher.accept(tick);
assertNotNull(atNormalPriority.observed, "NORMAL-priority observer must execute");
assertArrayEquals(before, atNormalPriority.observed,
"Audit must not reconcile native effect PDC earlier than its protection guard");
assertFalse(tick.isCancelled());
assertFalse(java.util.Arrays.equals(before, target.core.pdc.serializeToBytes()),
"Original HIGHEST protection read still reconciles an ended effect");
}
}
@Test void poisonWitherAndFireTickRecheckBoundSourceWithoutInventedNativeAttacker() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var attacker = actor(fixture); var target = actor(fixture);
fixture.activate(attacker); fixture.activate(target);
for (var type : List.of(org.bukkit.potion.PotionEffectType.POISON,
org.bukkit.potion.PotionEffectType.WITHER)) {
var effect = new org.bukkit.potion.PotionEffect(type, 200, 0);
org.mockito.Mockito.doReturn(effect).when(target.player).getPotionEffect(type);
var apply = new org.bukkit.event.entity.EntityPotionEffectEvent(target.player, null, effect,
attacker.player, org.bukkit.event.entity.EntityPotionEffectEvent.Cause.POTION_SPLASH,
org.bukkit.event.entity.EntityPotionEffectEvent.Action.ADDED, true);
fixture.dispatcher.accept(apply);
assertFalse(apply.isCancelled());
}
org.mockito.Mockito.doReturn(200).when(target.player).getFireTicks();
var ignite = new org.bukkit.event.entity.EntityCombustByEntityEvent(attacker.player, target.player, 10.0f);
fixture.dispatcher.accept(ignite); assertFalse(ignite.isCancelled());
var parties = PartyLifecycleNativeTest.parties(fixture);
var group = parties.create(attacker.core.id).orElseThrow();
var invitation = parties.invite(attacker.core.id, group.id(), target.core.id).orElseThrow();
assertTrue(parties.accept(target.core.id, invitation.id()));
var beforeVeto = target.core.pdc.serializeToBytes();
org.mockito.Mockito.doReturn(null).when(target.player).getPotionEffect(org.bukkit.potion.PotionEffectType.POISON);
var vetoedTick = new org.bukkit.event.entity.EntityDamageEvent(target.player,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.POISON,
org.mockito.Mockito.mock(org.bukkit.damage.DamageSource.class), 1);
vetoedTick.setCancelled(true); fixture.dispatcher.accept(vetoedTick);
assertArrayEquals(beforeVeto, target.core.pdc.serializeToBytes(),
"A prior veto must not reconcile or clear owned native provenance");
org.mockito.Mockito.doReturn(new org.bukkit.potion.PotionEffect(
org.bukkit.potion.PotionEffectType.POISON, 200, 0)).when(target.player)
.getPotionEffect(org.bukkit.potion.PotionEffectType.POISON);
for (var cause : List.of(org.bukkit.event.entity.EntityDamageEvent.DamageCause.POISON,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.WITHER,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.FIRE_TICK)) {
var tick = new org.bukkit.event.entity.EntityDamageEvent(target.player, cause,
org.mockito.Mockito.mock(org.bukkit.damage.DamageSource.class), 1);
fixture.dispatcher.accept(tick);
assertTrue(tick.isCancelled(), cause.toString());
}
for (var type : List.of(org.bukkit.potion.PotionEffectType.POISON,
org.bukkit.potion.PotionEffectType.WITHER)) {
var remove = new org.bukkit.event.entity.EntityPotionEffectEvent(target.player,
new org.bukkit.potion.PotionEffect(type, 200, 0), null, null,
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.MILK,
org.bukkit.event.entity.EntityPotionEffectEvent.Action.REMOVED, false);
fixture.dispatcher.accept(remove);
org.mockito.Mockito.doReturn(null).when(target.player).getPotionEffect(type);
}
org.mockito.Mockito.doReturn(0).when(target.player).getFireTicks();
for (var cause : List.of(org.bukkit.event.entity.EntityDamageEvent.DamageCause.POISON,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.WITHER,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.FIRE_TICK)) {
var expired = new org.bukkit.event.entity.EntityDamageEvent(target.player, cause,
org.mockito.Mockito.mock(org.bukkit.damage.DamageSource.class), 1);
fixture.dispatcher.accept(expired);
assertFalse(expired.isCancelled(), "Expired provenance cannot create party protection: " + cause);
}
for (int i = 0; i < 250 && records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
&& entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_damage_protection"))
.count() < 3; i++) Thread.sleep(20);
fixture.plugin.setEnabled(false);
var decisions = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
&& entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_damage_protection"))
.map(entry -> entry.getAsJsonObject("data")).toList();
assertEquals(3, decisions.size());
assertEquals(java.util.Set.of("poison", "wither", "fire_tick"), decisions.stream()
.map(value -> value.get("damage_cause").getAsString()).collect(java.util.stream.Collectors.toSet()));
assertTrue(decisions.stream().allMatch(value -> value.get("reason").getAsString().equals("same_party")
&& value.get("source_location_status").getAsString().equals("unavailable")
&& value.has("target_location")));
}
}
@Test void mixedModeAndPriorCancelledHitsReportTruthfulDistinctOutcomes() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var attacker = actor(fixture); var target = actor(fixture);
fixture.activate(attacker);
var mixed = PartyCombatNativeTest.hit(fixture, attacker.player, target.player);
assertTrue(mixed.isCancelled());
fixture.activate(target);
var parties = PartyLifecycleNativeTest.parties(fixture);
var group = parties.create(attacker.core.id).orElseThrow();
var invitation = parties.invite(attacker.core.id, group.id(), target.core.id).orElseThrow();
assertTrue(parties.accept(target.core.id, invitation.id()));
var source = org.mockito.Mockito.mock(org.bukkit.damage.DamageSource.class);
org.mockito.Mockito.when(source.getCausingEntity()).thenReturn(attacker.player);
org.mockito.Mockito.when(source.getDirectEntity()).thenReturn(attacker.player);
var cancelled = new org.bukkit.event.entity.EntityDamageByEntityEvent(attacker.player, target.player,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 4);
cancelled.setCancelled(true); fixture.dispatcher.accept(cancelled);
assertTrue(cancelled.isCancelled());
for (int i = 0; i < 250 && records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
&& entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_damage_protection"))
.count() < 2; i++) Thread.sleep(20);
fixture.plugin.setEnabled(false);
var results = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
&& entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_damage_protection"))
.map(entry -> entry.getAsJsonObject("data")).toList();
assertEquals(2, results.size());
assertTrue(results.stream().anyMatch(value -> value.get("reason").getAsString().equals("mixed_mode")
&& value.get("outcome").getAsString().equals("refused")));
assertTrue(results.stream().anyMatch(value -> value.get("reason").getAsString().equals("prior_cancelled")
&& value.get("outcome").getAsString().equals("cancelled")
&& value.get("prior_cancelled").getAsBoolean()));
}
}
@Test void stalePotionAndLingeringSplashCancellationHaveBoundedNativeResolutions() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var attacker = actor(fixture); var target = actor(fixture);
fixture.activate(attacker); fixture.activate(target);
var potion = PartyCombatNativeTest.entity(org.bukkit.entity.ThrownPotion.class);
org.mockito.Mockito.when(potion.getShooter()).thenReturn(attacker.player);
fixture.dispatcher.accept(new org.bukkit.event.entity.ProjectileLaunchEvent(potion));
PartyCombatNativeTest.exit(fixture, attacker);
var splash = new org.bukkit.event.entity.PotionSplashEvent(potion,
new java.util.HashMap<>(java.util.Map.of(target.player, 1.0)));
fixture.dispatcher.accept(splash); assertTrue(splash.isCancelled());
var cloud = PartyCombatNativeTest.entity(org.bukkit.entity.AreaEffectCloud.class);
var lingering = new org.bukkit.event.entity.LingeringPotionSplashEvent(potion, cloud);
fixture.dispatcher.accept(lingering); assertTrue(lingering.isCancelled());
for (int i = 0; i < 250 && records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
&& (entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_splash_protection")
|| entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_lingering_protection")))
.count() < 2; i++) Thread.sleep(20);
fixture.plugin.setEnabled(false);
var decisions = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
&& (entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_splash_protection")
|| entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_lingering_protection")))
.map(entry -> entry.getAsJsonObject("data")).toList();
assertEquals(java.util.Set.of("party_splash_protection", "party_lingering_protection"), decisions.stream()
.map(value -> value.get("action_kind").getAsString()).collect(java.util.stream.Collectors.toSet()));
assertTrue(decisions.stream().allMatch(value -> value.get("reason").getAsString().equals("stale_source")
&& value.get("outcome").getAsString().equals("refused")));
assertEquals("not_single_proved_player", decisions.stream().filter(value -> value.get("action_kind")
.getAsString().equals("party_lingering_protection")).findFirst().orElseThrow()
.get("target_status").getAsString());
}
}
@Test void protectedImpactPotionAndIgnitionDecisionsWithoutDamageEventsAreAudited() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var attacker = actor(fixture); var target = actor(fixture);
fixture.activate(attacker); fixture.activate(target);
var parties = PartyLifecycleNativeTest.parties(fixture);
var group = parties.create(attacker.core.id).orElseThrow();
var invitation = parties.invite(attacker.core.id, group.id(), target.core.id).orElseThrow();
assertTrue(parties.accept(target.core.id, invitation.id()));
var arrow = PartyCombatNativeTest.projectile(fixture, attacker.player);
var impact = new org.bukkit.event.entity.ProjectileHitEvent(arrow, target.player);
fixture.dispatcher.accept(impact); assertTrue(impact.isCancelled());
var effect = new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.POISON, 200, 0);
var poison = new org.bukkit.event.entity.EntityPotionEffectEvent(target.player, null, effect,
attacker.player, org.bukkit.event.entity.EntityPotionEffectEvent.Cause.POTION_SPLASH,
org.bukkit.event.entity.EntityPotionEffectEvent.Action.ADDED, true);
fixture.dispatcher.accept(poison); assertTrue(poison.isCancelled());
var ignition = new org.bukkit.event.entity.EntityCombustByEntityEvent(arrow, target.player, 10.0f);
fixture.dispatcher.accept(ignition); assertTrue(ignition.isCancelled());
for (int i = 0; i < 250 && records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
&& entry.getAsJsonObject("data").get("action_kind").getAsString().startsWith("party_"))
.count() < 3; i++) Thread.sleep(20);
fixture.plugin.setEnabled(false);
var outcomes = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
&& entry.getAsJsonObject("data").get("action_kind").getAsString().startsWith("party_"))
.map(entry -> entry.getAsJsonObject("data")).toList();
assertEquals(java.util.Set.of("party_projectile_impact_protection", "party_effect_application_protection",
"party_ignite_protection"), outcomes.stream().map(value -> value.get("action_kind").getAsString())
.collect(java.util.stream.Collectors.toSet()));
assertTrue(outcomes.stream().allMatch(value -> value.get("reason").getAsString().equals("same_party")
&& value.get("final_cancelled").getAsBoolean()));
assertTrue(records().stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("combat.damage")));
}
}
@Test void serializedOldProjectileAfterRestartRetainsOldSessionAndNeverClaimsNewHolder() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var attacker = actor(fixture); var target = actor(fixture);
fixture.activate(attacker); fixture.activate(target);
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
var originalSession = sessions.active(attacker.core.id).orElseThrow().session();
var arrow = PartyCombatNativeTest.projectile(fixture, attacker.player);
var bytes = arrow.getPersistentDataContainer().serializeToBytes();
fixture.plugin.setEnabled(false);
fixture.awaitWorkers();
var serialized = PartyCombatNativeTest.entity(org.bukkit.entity.Arrow.class);
org.mockito.Mockito.when(serialized.getShooter()).thenReturn(attacker.player);
serialized.getPersistentDataContainer().readFromBytes(bytes, true);
fixture.plugin.setEnabled(true);
assertTrue(PartyCombatNativeTest.hit(fixture, serialized, target.player).isCancelled());
for (int i = 0; i < 250 && records().stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("reason")
&& entry.getAsJsonObject("data").get("reason").getAsString().equals("stale_source")); i++)
Thread.sleep(20);
fixture.plugin.setEnabled(false);
var result = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("reason")
&& entry.getAsJsonObject("data").get("reason").getAsString().equals("stale_source"))
.findFirst().orElseThrow().getAsJsonObject("data");
assertEquals(originalSession.toString(), result.get("source_session_id").getAsString());
assertEquals("unavailable", result.get("provenance_age_status").getAsString());
assertTrue(result.has("target_location"));
}
}
@Test void malformedAndForgedProjectileOriginsNeverBorrowCurrentCharacterOrParty() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var attacker = actor(fixture); var target = actor(fixture);
fixture.activate(attacker); fixture.activate(target);
var arrow = PartyCombatNativeTest.projectile(fixture, attacker.player);
var key = arrow.getPersistentDataContainer().getKeys().stream()
.filter(value -> value.getKey().equals("combat_origin_v1")).findFirst().orElseThrow();
arrow.getPersistentDataContainer().set(key, org.bukkit.persistence.PersistentDataType.STRING, "malformed");
assertTrue(PartyCombatNativeTest.hit(fixture, arrow, target.player).isCancelled());
arrow.getPersistentDataContainer().set(key, org.bukkit.persistence.PersistentDataType.STRING,
attacker.core.id + "/" + java.util.UUID.randomUUID() + "/" + attacker.core.record.id());
assertTrue(PartyCombatNativeTest.hit(fixture, arrow, target.player).isCancelled());
for (int i = 0; i < 250 && records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
&& entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_damage_protection"))
.count() < 2; i++) Thread.sleep(20);
fixture.plugin.setEnabled(false);
var decisions = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
&& entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_damage_protection"))
.map(entry -> entry.getAsJsonObject("data")).toList();
assertEquals(List.of("stale_source", "stale_source"), decisions.stream()
.map(value -> value.get("reason").getAsString()).toList());
assertEquals("ordinary_or_unproved", decisions.getFirst().get("provenance_quality").getAsString());
assertFalse(decisions.getFirst().has("source_session_id"));
assertEquals("claimed_session_unverified", decisions.get(1).get("provenance_quality").getAsString());
assertFalse(decisions.stream().anyMatch(value -> value.has("source_party_id")));
}
}
@Test void attributedProjectileRechecksCurrentPartyAndExpiresWithOriginalSession() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var attacker = actor(fixture); var target = actor(fixture);
fixture.activate(attacker); fixture.activate(target);
var projectile = PartyCombatNativeTest.projectile(fixture, attacker.player);
var parties = PartyLifecycleNativeTest.parties(fixture);
var group = parties.create(attacker.core.id).orElseThrow();
var invitation = parties.invite(attacker.core.id, group.id(), target.core.id).orElseThrow();
assertTrue(parties.accept(target.core.id, invitation.id()));
assertTrue(PartyCombatNativeTest.hit(fixture, projectile, target.player).isCancelled());
PartyCombatNativeTest.exit(fixture, attacker);
assertTrue(PartyCombatNativeTest.hit(fixture, projectile, target.player).isCancelled());
for (int i = 0; i < 250 && records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
&& entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_damage_protection"))
.count() < 2; i++) Thread.sleep(20);
fixture.plugin.setEnabled(false);
var reasons = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
&& entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_damage_protection"))
.map(entry -> entry.getAsJsonObject("data").get("reason").getAsString()).toList();
assertEquals(List.of("same_party", "stale_source"), reasons);
}
}
private FantasyStickLifecycleNativeTest.Actor actor(FantasyStickLifecycleNativeTest.Fixture fixture) throws Exception {
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
return actor;
}
private List<JsonObject> records() throws Exception {
var result = new ArrayList<JsonObject>();
var directory = data.resolve("events");
if (!Files.exists(directory)) return result;
try (var paths = Files.walk(directory)) {
for (var path : paths.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList()) {
var content = Files.readString(path);
int end = content.lastIndexOf('\n');
if (end < 0) continue;
for (var line : content.substring(0, end).lines().toList())
if (!line.isBlank()) result.add(JsonParser.parseString(line).getAsJsonObject());
}
}
return result;
}
}
@@ -0,0 +1,516 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
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;
/** Registered admin/lifecycle outcome -> local JSONL, not isolated bridge invocation. */
class SessionRecoveryEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void registeredAuthorizedInspectionOfPendingReceiptIsRecordedWithoutEquipmentContents() 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 administrator = new EquipmentAdminCommandNativeTest.Sender();
fixture.adminCommand.execute(administrator.port, "fstickadmin",
new String[]{"inspect", actor.core.id.toString()});
fixture.tick();
assertTrue(administrator.messages.stream().anyMatch(message -> message.contains("pending session=")));
awaitType(fixture, "recovery.inspected");
fixture.plugin.setEnabled(false);
var entry = records().stream().filter(value -> value.get("type").getAsString()
.endsWith("recovery.inspected")).findFirst().orElseThrow();
var fields = entry.getAsJsonObject("data");
assertEquals(actor.core.id.toString(), fields.get("target_player_id").getAsString());
assertEquals("pending", fields.get("receipt_phase").getAsString());
assertEquals("authorized", fields.get("outcome").getAsString());
assertFalse(fields.toString().contains("DIAMOND_SWORD"));
assertFalse(fields.toString().contains("item_meta"));
}
}
@Test void failedAdventureSaveRetainsEffectivePolicyAndEmitsNoChange() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var collision = data.resolve("config.yml");
Files.createDirectory(collision);
Files.writeString(collision.resolve("block"), "not a config");
var administrator = new EquipmentAdminCommandNativeTest.Sender();
fixture.adminCommand.execute(administrator.port, "fstickadmin",
new String[]{"config", "adventure-mode", "false"});
fixture.tick();
assertTrue(administrator.messages.getLast().contains("save failed"));
fixture.adminCommand.execute(administrator.port, "fstickadmin", new String[]{"config", "adventure-mode"});
assertTrue(administrator.messages.getLast().contains("adventure-mode=true"));
for (int i = 0; i < 250 && records().stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("reason")
&& entry.getAsJsonObject("data").get("reason").getAsString().equals("save_failed")); i++)
Thread.sleep(20);
fixture.plugin.setEnabled(false);
assertTrue(records().stream().noneMatch(entry -> entry.get("type").getAsString().endsWith("config.changed")));
Files.delete(collision.resolve("block"));
Files.delete(collision);
}
}
@Test void acknowledgedAdventureChangeKeepsStableEventIdAcrossWriterOutageAndRestart() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
((games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog")).close();
var administrator = new EquipmentAdminCommandNativeTest.Sender();
fixture.adminCommand.execute(administrator.port, "fstickadmin",
new String[]{"config", "adventure-mode", "false"});
fixture.tick();
fixture.plugin.setEnabled(false);
fixture.awaitWorkers();
java.util.Set<String> pending;
try (var paths = Files.list(data.resolve("session-recovery-event-intents"))) {
pending = paths.filter(path -> path.getFileName().toString().endsWith(".pending"))
.map(path -> path.getFileName().toString().replace(".pending", ""))
.collect(java.util.stream.Collectors.toSet());
}
assertEquals(1, pending.size());
fixture.plugin.setEnabled(true);
awaitType(fixture, "config.changed");
fixture.plugin.setEnabled(false);
var saved = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("config.changed")).toList();
assertEquals(pending, saved.stream().map(entry -> entry.get("id").getAsString())
.collect(java.util.stream.Collectors.toSet()));
}
}
@Test void explicitRecoveryWithoutReceiptIsRefusedWithoutInventingSession() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
actor.core.inventory.setItemInOffHand(null);
fixture.players.put(actor.core.id, actor.player);
var administrator = new EquipmentAdminCommandNativeTest.Sender();
fixture.adminCommand.execute(administrator.port, "fstickadmin",
new String[]{"recover", actor.core.id.toString()});
fixture.tick();
awaitType(fixture, "recovery.resolved");
fixture.plugin.setEnabled(false);
var result = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("recovery.resolved")).findFirst().orElseThrow().getAsJsonObject("data");
assertEquals("refused", result.get("outcome").getAsString());
assertEquals("no_receipt", result.get("reason").getAsString());
assertFalse(result.has("character_id"));
assertTrue(records().stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("equipment.restored")));
}
}
@Test void failedRecoveryReceiptCompletionRemainsAmbiguousWithoutSuccessEvent() 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 = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
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));
awaitRefusedExitReady(fixture, sessions, actor);
actor.core.inventory.setItem(0, null);
var administrator = new EquipmentAdminCommandNativeTest.Sender();
fixture.adminCommand.execute(administrator.port, "fstickadmin",
new String[]{"recover", actor.core.id.toString()});
fixture.receipts().find(actor.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS);
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");
fixture.tick();
awaitType(fixture, "recovery.resolved");
var result = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("recovery.resolved")).findFirst().orElseThrow().getAsJsonObject("data");
assertEquals("ambiguous", result.get("outcome").getAsString());
assertEquals("receipt_save_failed", result.get("reason").getAsString());
assertEquals("restored", result.get("current_phase").getAsString());
assertTrue(records().stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("equipment.restored")));
Files.delete(receipt.resolve("block"));
Files.delete(receipt);
Files.move(backup, receipt);
fixture.plugin.setEnabled(false);
}
}
@Test void unreadableReceiptRefusesRegisteredRecoveryWithoutTouchingNativeItems() 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 = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
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));
awaitRefusedExitReady(fixture, sessions, actor);
var nativeBefore = actor.core.snapshot();
var receipt = data.resolve("sessions").resolve(actor.core.id + ".yml");
var backup = data.resolve("saved-receipt.yml");
Files.move(receipt, backup);
Files.createDirectory(receipt);
Files.writeString(receipt.resolve("block"), "not a receipt");
var administrator = new EquipmentAdminCommandNativeTest.Sender();
fixture.adminCommand.execute(administrator.port, "fstickadmin",
new String[]{"recover", actor.core.id.toString()});
fixture.tick();
awaitType(fixture, "recovery.resolved");
assertArrayEquals(nativeBefore, actor.core.snapshot());
var result = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("recovery.resolved")).findFirst().orElseThrow().getAsJsonObject("data");
assertEquals("failed", result.get("outcome").getAsString());
assertEquals("receipt_read_failed", result.get("reason").getAsString());
assertFalse(result.has("session_id"));
Files.delete(receipt.resolve("block"));
Files.delete(receipt);
Files.move(backup, receipt);
fixture.plugin.setEnabled(false);
}
}
@Test void ambiguousCopiedBoxThroughRegisteredRecoveryRetainsReceiptAndItems() 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 = (games.dmg.fantasystick.session.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);
awaitRefusedExitReady(fixture, sessions, actor);
var administrator = new EquipmentAdminCommandNativeTest.Sender();
fixture.adminCommand.execute(administrator.port, "fstickadmin",
new String[]{"recover", actor.core.id.toString()});
fixture.tick();
awaitType(fixture, "recovery.resolved");
assertNotNull(fixture.receipts().find(actor.core.id).toCompletableFuture()
.get(5, TimeUnit.SECONDS).orElseThrow().pending());
fixture.plugin.setEnabled(false);
var result = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("recovery.resolved")).findFirst().orElseThrow().getAsJsonObject("data");
assertEquals("retained", result.get("outcome").getAsString());
assertEquals("ambiguous", result.get("reason").getAsString());
assertTrue(records().stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("equipment.restored")));
}
}
@Test void uncertainNativeRecoveryReportsAmbiguousWithoutInventingEquipmentSuccess() 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 = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
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);
awaitRefusedExitReady(fixture, sessions, actor);
actor.core.inventory.setItem(0, null);
actor.core.cursor = actor.core.inventory.getItem(9).clone();
actor.core.inventory.setItem(9, null);
actor.core.beforeCursorMutation = () -> { throw new IllegalStateException("Uncertain cursor write"); };
var administrator = new EquipmentAdminCommandNativeTest.Sender();
fixture.adminCommand.execute(administrator.port, "fstickadmin",
new String[]{"recover", actor.core.id.toString()});
fixture.tick();
fixture.awaitCharacterAndReceipt(actor);
awaitType(fixture, "recovery.resolved");
actor.core.beforeCursorMutation = () -> {};
fixture.plugin.setEnabled(false);
var results = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("recovery.resolved")).toList();
assertEquals(1, results.size());
assertEquals("ambiguous", results.getFirst().getAsJsonObject("data").get("outcome").getAsString());
assertEquals("native_exception", results.getFirst().getAsJsonObject("data").get("reason").getAsString());
assertTrue(records().stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("equipment.restored")));
}
}
@Test void acknowledgedCommandRecoveryReplaysStableResultAfterClosedWriter() 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 = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
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);
awaitRefusedExitReady(fixture, sessions, actor);
actor.core.inventory.setItem(0, null);
((games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog")).close();
var administrator = new EquipmentAdminCommandNativeTest.Sender();
fixture.adminCommand.execute(administrator.port, "fstickadmin",
new String[]{"recover", actor.core.id.toString()});
fixture.tick();
fixture.awaitCharacterAndReceipt(actor);
fixture.plugin.setEnabled(false);
fixture.awaitWorkers();
java.util.Set<String> pending;
try (var paths = Files.list(data.resolve("session-recovery-event-intents"))) {
pending = paths.filter(path -> path.getFileName().toString().endsWith(".pending"))
.map(path -> path.getFileName().toString().replace(".pending", ""))
.collect(java.util.stream.Collectors.toSet());
}
assertEquals(1, pending.size());
fixture.plugin.setEnabled(true);
awaitType(fixture, "recovery.resolved");
fixture.plugin.setEnabled(false);
var outcome = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("recovery.resolved")).toList();
assertEquals(pending, outcome.stream().map(entry -> entry.get("id").getAsString())
.collect(java.util.stream.Collectors.toSet()));
assertEquals(1, outcome.size());
}
}
@Test void restartedAutomaticReconciliationRetainsUnverifiedHealthAndOriginalLocationStatus() 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 = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
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);
fixture.plugin.setEnabled(false);
fixture.awaitWorkers();
actor.core.inventory.setItem(0, null);
fixture.plugin.setEnabled(true);
fixture.tick();
fixture.awaitCharacterAndReceipt(actor);
awaitType(fixture, "recovery.resolved");
fixture.plugin.setEnabled(false);
var results = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("recovery.resolved")).toList();
assertEquals(1, results.size());
var value = results.getFirst().getAsJsonObject("data");
assertEquals("ambiguous", value.get("outcome").getAsString());
assertEquals("health_unverified", value.get("reason").getAsString());
assertEquals("packed", value.get("prior_phase").getAsString());
assertEquals("restored", value.get("current_phase").getAsString());
assertEquals("captured_current_only", value.get("location_status").getAsString());
assertEquals("unavailable", value.get("original_location_status").getAsString());
assertEquals("captured_at_reconciliation", value.get("current_location_status").getAsString());
assertTrue(records().stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("equipment.restored")));
}
}
@Test void registeredProofBasedRecoveryCorrelatesEquipmentWithoutAnotherSessionExit() 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 = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
var session = sessions.active(actor.core.id).orElseThrow().session();
var originalAttempt = ((java.util.Map<?, ?>) FantasyStickLifecycleNativeTest.Fixture.field(
sessions, "owned")).get(actor.core.id);
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);
awaitRefusedExitReady(fixture, sessions, actor);
actor.core.inventory.setItem(0, null);
var administrator = new EquipmentAdminCommandNativeTest.Sender();
fixture.adminCommand.execute(administrator.port, "fstickadmin",
new String[]{"recover", actor.core.id.toString()});
fixture.tick();
fixture.awaitCharacterAndReceipt(actor);
assertNotNull(FantasyStickLifecycleNativeTest.Fixture.field(originalAttempt, "sessionRecoveryAction"));
assertTrue((boolean) FantasyStickLifecycleNativeTest.Fixture.field(originalAttempt, "sessionRecoveryResolved"),
"Completed receipt should trigger one session recovery outcome");
awaitType(fixture, "recovery.resolved");
fixture.plugin.setEnabled(false);
var entries = records();
var recovered = entries.stream().filter(entry -> entry.get("type").getAsString()
.endsWith("recovery.resolved")).toList();
assertEquals(1, recovered.size());
var value = recovered.getFirst().getAsJsonObject("data");
assertEquals("restored", value.get("outcome").getAsString());
assertEquals(session.toString(), value.get("session_id").getAsString());
assertEquals("packed", value.get("prior_phase").getAsString());
assertTrue(value.has("equipment_recovery_operation_id"));
assertEquals("captured_at_entry", value.get("original_location_status").getAsString());
assertEquals("captured_at_reconciliation", value.get("current_location_status").getAsString());
assertTrue(value.has("original_x"));
assertTrue(value.has("current_x"));
assertEquals(1, entries.stream().filter(entry -> entry.get("type").getAsString()
.endsWith("character.exited")).count());
}
}
@Test void registeredAdventureReadAndAcknowledgedChangeHaveSafeRevisionAndNoInvalidChange() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var administrator = new EquipmentAdminCommandNativeTest.Sender();
fixture.adminCommand.execute(administrator.port, "fstickadmin", new String[]{"config", "adventure-mode"});
fixture.adminCommand.execute(administrator.port, "fstickadmin", new String[]{"config", "adventure-mode", "false"});
fixture.tick();
fixture.adminCommand.execute(administrator.port, "fstickadmin", new String[]{"config", "adventure-mode", "wrong"});
awaitType(fixture, "config.changed");
fixture.plugin.setEnabled(false);
var entries = records();
var read = entries.stream().filter(entry -> entry.get("type").getAsString()
.endsWith("config.read") && entry.getAsJsonObject("data").has("key")
&& entry.getAsJsonObject("data").get("key").getAsString().equals("adventure-mode")).toList();
var changed = entries.stream().filter(entry -> entry.get("type").getAsString()
.endsWith("config.changed") && entry.getAsJsonObject("data").has("key")
&& entry.getAsJsonObject("data").get("key").getAsString().equals("adventure-mode")).toList();
assertEquals(1, read.size());
assertEquals("true", read.getFirst().getAsJsonObject("data").get("effective_value").getAsString());
assertEquals(1, changed.size(), "Invalid input must not produce an acknowledged config change");
var fields = changed.getFirst().getAsJsonObject("data");
assertEquals("true", fields.get("before_value").getAsString());
assertEquals("false", fields.get("after_value").getAsString());
assertTrue(fields.get("persistence_revision").getAsString().matches("sha256:[0-9a-f]{64}"));
}
}
@Test void inspectionRevokedBeforeReadResponseDoesNotPublishProtectedTarget() 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 administrator = new EquipmentAdminCommandNativeTest.Sender();
fixture.adminCommand.execute(administrator.port, "fstickadmin",
new String[]{"inspect", actor.core.id.toString()});
administrator.allowed = false;
fixture.tick();
assertTrue(administrator.messages.isEmpty());
for (int i = 0; i < 200 && records().stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("reason")
&& entry.getAsJsonObject("data").get("reason").getAsString().equals("permission_changed")); i++)
Thread.sleep(20);
fixture.plugin.setEnabled(false);
var entries = records();
assertTrue(entries.stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("recovery.inspected")));
assertTrue(entries.stream().filter(entry -> entry.get("type").getAsString().endsWith("action.resolved"))
.map(entry -> entry.getAsJsonObject("data"))
.anyMatch(value -> value.has("reason") && value.get("reason").getAsString()
.equals("permission_changed") && !value.has("target_player_id")));
}
}
@Test void authorizedOfflineInspectionReportsOnlySavedReceiptAndUnknownNativeLocation() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var administrator = new EquipmentAdminCommandNativeTest.Sender();
var target = java.util.UUID.randomUUID();
fixture.adminCommand.execute(administrator.port, "fstickadmin",
new String[]{"inspect", target.toString()});
fixture.tick();
awaitType(fixture, "recovery.inspected");
fixture.plugin.setEnabled(false);
var result = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("recovery.inspected")).findFirst().orElseThrow().getAsJsonObject("data");
assertEquals(target.toString(), result.get("target_player_id").getAsString());
assertEquals("absent", result.get("receipt_phase").getAsString());
assertEquals("offline_unavailable", result.get("native_phase").getAsString());
assertEquals("receipt_only", result.get("custody_quality").getAsString());
assertEquals("not_applicable", result.get("location_status").getAsString());
assertFalse(result.has("session_id"));
}
}
@Test void registeredPermissionGateCannotExposeProtectedInspectionOrTarget() 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 denied = new EquipmentAdminCommandNativeTest.Sender();
denied.allowed = false;
fixture.adminCommand.execute(denied.port, "fstickadmin",
new String[]{"inspect", actor.core.id.toString()});
assertTrue(denied.messages.getLast().toLowerCase(java.util.Locale.ROOT).contains("permission"));
fixture.plugin.setEnabled(false);
var entries = records();
assertTrue(entries.stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("recovery.inspected")));
assertTrue(entries.stream().filter(entry -> entry.get("type").getAsString().endsWith("action.resolved"))
.map(entry -> entry.getAsJsonObject("data"))
.noneMatch(fields -> fields.has("action_kind") && fields.get("action_kind").getAsString()
.equals("equipment_inspect") && fields.has("target_player_id")));
}
}
private void awaitRefusedExitReady(FantasyStickLifecycleNativeTest.Fixture fixture,
games.dmg.fantasystick.session.EquipmentSessions sessions,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
var owned = (java.util.Map<?, ?>) FantasyStickLifecycleNativeTest.Fixture.field(sessions, "owned");
var pending = owned.get(actor.core.id);
assertNotNull(pending);
for (int i = 0; i < 8; i++) {
fixture.awaitCharacterAndReceipt(actor);
if (!(boolean) FantasyStickLifecycleNativeTest.Fixture.field(pending, "saving")) return;
fixture.tick();
}
fail("Refused exit persistence callback did not settle before recovery request");
}
private void awaitType(FantasyStickLifecycleNativeTest.Fixture fixture, String type) 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().anyMatch(entry -> entry.get("type").getAsString().endsWith(type))) return;
}
fail("Registered recovery outcome did not reach JSONL: " + type + "; observed types="
+ records().stream().map(entry -> entry.get("type").getAsString()).toList());
}
private List<JsonObject> records() throws Exception {
var result = new ArrayList<JsonObject>();
Path directory = data.resolve("events");
if (!Files.exists(directory)) return result;
try (var files = Files.walk(directory)) {
for (var path : files.filter(item -> item.getFileName().toString().endsWith(".jsonl")).toList()) {
var contents = Files.readString(path);
int end = contents.lastIndexOf('\n');
if (end < 0) continue;
for (var line : contents.substring(0, end).lines().toList())
if (!line.isBlank()) result.add(JsonParser.parseString(line).getAsJsonObject());
}
}
return result;
}
}