feat(roleplay): export truthful availability changes
Release / release (push) Failing after 10m2s

This commit is contained in:
dmg committed 2026-09-26 13:59:29 -04:00
1 parent 6ac4867821
commit 01fd2f10ff
5 files changed
+556 -5

No files matched your search

@@ -186,6 +186,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
private java.util.concurrent.CompletionStage<Void> switchAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.SwitchConfigEventBridge switchConfigEvents;
private java.util.concurrent.CompletionStage<Void> switchConfigAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.RoleplayAvailabilityEventBridge roleplayAvailabilityEvents;
private java.util.concurrent.CompletionStage<Void> roleplayAuditDrain = 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;
@@ -809,6 +811,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
failure -> getLogger().log(Level.WARNING, "Switch configuration event intent failed", failure));
admin.attachSwitchConfigEvents(switchConfigEvents);
roleplayAvailabilityEvents = new games.dmg.fantasystick.events.RoleplayAvailabilityEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
failure -> getLogger().log(Level.WARNING, "Roleplay availability event intent failed", failure));
admin.attachRoleplayAvailabilityEvents(roleplayAvailabilityEvents);
var identitySessions = sessions;
identityAuditEvents = new games.dmg.fantasystick.events.CharacterIdentityEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
@@ -836,7 +842,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; adminRevivalEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null; nutritionAuditEvents = null; switchAuditEvents = null; switchConfigEvents = null; identityAuditEvents = null; equipmentAuditEvents = null; sessionRecoveryAuditEvents = null; messageAuditEvents = null;
eventLog = null; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; reviveEvents = null; adminRevivalEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null; nutritionAuditEvents = null; switchAuditEvents = null; switchConfigEvents = null; roleplayAvailabilityEvents = 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);
@@ -922,6 +928,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (failure != null) getLogger().log(Level.WARNING, "Previous nutrition event intent drain failed", failure);
if (replayNutritionEvents != null) replayNutritionEvents.replay();
});
var replayRoleplayEvents = roleplayAvailabilityEvents;
roleplayAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous roleplay event intent drain failed", failure);
if (replayRoleplayEvents != null) replayRoleplayEvents.replay();
});
var replaySwitchConfigEvents = switchConfigEvents;
switchConfigAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous switch configuration intent drain failed", failure);
@@ -993,6 +1004,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingNutritionAuditEvents = nutritionAuditEvents; nutritionAuditEvents = null;
var closingSwitchAuditEvents = switchAuditEvents; switchAuditEvents = null;
var closingSwitchConfigEvents = switchConfigEvents; switchConfigEvents = null;
var closingRoleplayEvents = roleplayAvailabilityEvents; roleplayAvailabilityEvents = null;
var closingIdentityAuditEvents = identityAuditEvents; identityAuditEvents = null;
var closingAdminRevivalAuditEvents = adminRevivalEvents; adminRevivalEvents = null;
var closingEquipmentAuditEvents = equipmentAuditEvents; equipmentAuditEvents = null;
@@ -1314,6 +1326,16 @@ public final class FantasyStickPlugin extends JavaPlugin {
return null;
}).thenCompose(ignored -> closingNutritionAuditEvents.drained());
});
if (closingRoleplayEvents != null) cleanup("roleplay availability event intent drain", () -> {
var settingsDrained = closingRoleplaySettings == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null)
: closingRoleplaySettings.drained();
roleplayAuditDrain = settingsDrained.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Roleplay setting drain failed", failure);
closingRoleplayEvents.close();
return null;
}).thenCompose(ignored -> closingRoleplayEvents.drained());
});
if (closingSwitchConfigEvents != null) cleanup("switch configuration event intent drain", () -> {
var settingsDrained = closingSwitchDelaySettings == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null)
@@ -0,0 +1,127 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.RoleplaySettings;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
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.BlockCommandSender;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
/** A policy publication requests ordinary exits; it never attests their later receipt completion. */
public final class RoleplayAvailabilityEventBridge implements AutoCloseable {
public record Actor(String kind, EventInput.Context context) { }
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
private final Clock clock;
private final EquipmentSessions sessions;
private final FeatureEventOutbox outbox;
public RoleplayAvailabilityEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
Clock clock, Path data, EquipmentSessions sessions,
Consumer<Throwable> failures) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
this.sessions = Objects.requireNonNull(sessions);
outbox = new FeatureEventOutbox(data, "roleplay-availability-event-intents",
java.util.Set.of("config.changed", "game.availability_changed"), sink, failures);
}
public Actor actor(CommandSender sender) {
try {
var player = sender instanceof Player value ? value : null;
var kind = player != null ? "player" : sender instanceof BlockCommandSender ? "command_block" : "console";
var active = player == null ? null : sessions.active(player.getUniqueId()).orElse(null);
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
return new Actor(kind, player == null ? null : new EventInput.Context(player.getUniqueId(),
player.getName(), record == null ? null : record.id(),
record == null ? null : record.name(), record == null ? null : active.session(),
safeLocation(player)));
} catch (RuntimeException unavailable) { return null; }
}
public void read(Actor actor, boolean disabled) {
if (actor == null) return;
var values = common(actor);
values.put("effective", disabled);
values.put("available", !disabled);
values.put("outcome", "acknowledged_read");
var operation = UUID.randomUUID();
try { sink.apply(new EventInput(id(operation, "config.read"), operation, clock.instant(),
"config.read", actor.context(), values)); }
catch (RuntimeException ignored) { /* An observation cannot change availability. */ }
}
public void changed(Actor actor, UUID operation, boolean before, RoleplaySettings.Update saved,
EquipmentSessions.DisableSummary exits) {
if (actor == null) return;
try {
var config = common(actor);
config.put("operation_id", operation.toString());
config.put("policy_revision", Long.toString(saved.sequence()));
config.put("before", before);
config.put("after", saved.disabled());
config.put("effective", saved.disabled());
config.put("outcome", "acknowledged");
durable(operation, "config.changed", actor.context(), config);
var availability = common(actor);
availability.put("origin_admin_action_id", operation.toString());
availability.put("policy_revision", Long.toString(saved.sequence()));
availability.put("available_before", !before);
availability.put("available_after", !saved.disabled());
availability.put("exit_requested_count", exits == null ? 0 : exits.exitRequested());
availability.put("exit_request_accepted_count", exits == null ? 0 : exits.exitRequested());
availability.put("exit_native_restored_pending_receipt_count",
exits == null ? 0 : exits.nativeRestoredPendingReceipt());
availability.put("exit_confirmed_count", 0);
availability.put("exit_failed_count", exits == null ? 0 : exits.exitFailed());
availability.put("exit_pending_count", exits == null ? 0 : exits.exitPending());
availability.put("pending_entry_refused_count", exits == null ? 0 : exits.entryRefused());
availability.put("receipt_state", "not_yet_observed");
availability.put("outcome", "policy_published_exit_requests_only");
durable(operation, "game.availability_changed", actor.context(), availability);
} catch (RuntimeException ignored) { /* Logging failure cannot reverse policy or reissue exits. */ }
}
private static Map<String, Object> common(Actor actor) {
var values = new LinkedHashMap<String, Object>();
values.put("key", RoleplaySettings.KEY);
values.put("actor_kind", actor.kind());
values.put("application_scope", "global_and_active_sessions");
if (actor.context() == null) values.put("location_status", "not_applicable");
else if (actor.context().location() == null) values.put("location_status", "unavailable");
return values;
}
private EventInput.Location safeLocation(Player player) {
try {
Location location = player.getLocation();
if (location == null || location.getWorld() == null) return null;
var world = location.getWorld();
return new EventInput.Location(world.getUID(), world.getName(), location.getX(),
location.getY(), location.getZ(), clock.instant(), "exact");
} catch (RuntimeException unavailable) { return null; }
}
private void durable(UUID operation, String type, EventInput.Context actor, Map<String, Object> values) {
outbox.accepted(new EventInput(id(operation, type), operation, clock.instant(), type, actor, values));
}
private static UUID id(UUID operation, String type) {
return UUID.nameUUIDFromBytes(("fantasystick:roleplay-availability:" + operation + ":" + type)
.getBytes(StandardCharsets.UTF_8));
}
public void replay() { outbox.replay(); }
public CompletionStage<Void> drained() { return outbox.drained(); }
@Override public void close() { outbox.close(); }
}
@@ -74,6 +74,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private games.dmg.fantasystick.persistence.SwitchDelaySettings switchDelaySettings;
private games.dmg.fantasystick.events.SwitchConfigEventBridge switchConfigEvents;
private games.dmg.fantasystick.persistence.RoleplaySettings roleplaySettings;
private games.dmg.fantasystick.events.RoleplayAvailabilityEventBridge roleplayAvailabilityEvents;
private games.dmg.fantasystick.persistence.PartySettings partySettings;
private games.dmg.fantasystick.persistence.PartyEconomySettings partyEconomySettings;
private games.dmg.fantasystick.persistence.GearSettings gearSettings;
@@ -343,6 +344,10 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
switchConfigEvents=Objects.requireNonNull(events);
}
public void attachRoleplaySettings(games.dmg.fantasystick.persistence.RoleplaySettings settings){if(closed||roleplaySettings!=null)throw new IllegalStateException("Roleplay settings already attached or command closed");roleplaySettings=Objects.requireNonNull(settings);}
public void attachRoleplayAvailabilityEvents(games.dmg.fantasystick.events.RoleplayAvailabilityEventBridge events){
if(closed||roleplaySettings==null||roleplayAvailabilityEvents!=null)throw new IllegalStateException("Roleplay availability events unavailable");
roleplayAvailabilityEvents=Objects.requireNonNull(events);
}
public void attachPartySettings(games.dmg.fantasystick.persistence.PartySettings settings){if(closed||partySettings!=null)throw new IllegalStateException("Party settings already attached or command closed");partySettings=Objects.requireNonNull(settings);}
public void attachPartyEconomySettings(games.dmg.fantasystick.persistence.PartyEconomySettings settings){if(closed||partyEconomySettings!=null)throw new IllegalStateException("Party economy settings already attached or command closed");partyEconomySettings=Objects.requireNonNull(settings);}
public void attachGearSettings(games.dmg.fantasystick.persistence.GearSettings settings){if(closed||gearSettings!=null)throw new IllegalStateException("Gear settings already attached or command closed");gearSettings=Objects.requireNonNull(settings);}
@@ -1873,7 +1878,42 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void partyConfiguration(CommandSender sender,String[] args){if(partySettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.PartySettings.KEY+"="+partySettings.value()+" (existing parties are retained)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.PartySettings.Update> operation;try{operation=partySettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Party maximum is invalid; enter a positive whole number.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-party",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=false;if(failure==null)applied=partySettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Party configuration save failed; the effective limit and rosters are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+games.dmg.fantasystick.persistence.PartySettings.KEY+"="+partySettings.value()+".");else sender.sendMessage("Saved "+games.dmg.fantasystick.persistence.PartySettings.KEY+"="+saved.value()+"; existing members are retained.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void roleplayConfiguration(CommandSender sender,String[] args){if(roleplaySettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+roleplaySettings.value());return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.RoleplaySettings.Update> operation;try{operation=roleplaySettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Usage: /fstickadmin config roleplay-disabled [true|false]");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-roleplay",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=false;if(failure==null){applied=roleplaySettings.publish(saved);if(applied&&saved.disabled())sessions.disableRoleplay();}if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Roleplay configuration save failed; policy and sessions are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+roleplaySettings.value()+".");else sender.sendMessage("Saved "+games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+saved.disabled()+(saved.disabled()?"; ordinary exits were requested for active sessions.":"; normal sheet activation is available."));});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void roleplayConfiguration(CommandSender sender,String[] args){
if(roleplaySettings==null){configurationUsage(sender);return;}
var audit=roleplayAvailabilityEvents;
if(args.length==2){
var disabled=roleplaySettings.disabled();
if(audit!=null)audit.read(audit.actor(sender),disabled);
sender.sendMessage(games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+disabled);
return;
}
var actor=audit==null?null:audit.actor(sender);
var operationId=java.util.UUID.randomUUID();
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.RoleplaySettings.Update> operation;
try{operation=roleplaySettings.set(args[2]);}
catch(IllegalArgumentException invalid){sender.sendMessage("Usage: /fstickadmin config roleplay-disabled [true|false]");return;}
operation.whenComplete((saved,failure)->{
if(failure!=null)report(null,"config-roleplay",failure);
if(closed)return;
try{nextTick.accept(()->{
if(closed)return;
boolean applied=false;
if(failure==null){
var before=roleplaySettings.disabled();
applied=roleplaySettings.publish(saved);
EquipmentSessions.DisableSummary exits=null;
if(applied&&saved.disabled())exits=sessions.disableRoleplay();
if(audit!=null&&applied&&before!=saved.disabled())audit.changed(actor,operationId,before,saved,exits);
}
if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;
if(failure!=null)sender.sendMessage("Roleplay configuration save failed; policy and sessions are unchanged.");
else if(!applied)sender.sendMessage("Saved request was superseded; effective "+games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+roleplaySettings.value()+".");
else sender.sendMessage("Saved "+games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+saved.disabled()
+(saved.disabled()?"; ordinary exits were requested for active sessions."
:"; normal sheet activation is available."));
});}catch(RuntimeException rejected){report(null,"config-response",rejected);}
});
}
private void switchDelayConfiguration(CommandSender sender,String[] args){
if(switchDelaySettings==null){configurationUsage(sender);return;}
@@ -1345,12 +1345,30 @@ public final class EquipmentSessions implements AutoCloseable {
});
}
/** A synchronous exit request is not proof of an acknowledged character/receipt restoration. */
public record DisableSummary(int entryRefused, int exitRequested, int nativeRestoredPendingReceipt,
int exitFailed, int exitPending) { }
/** Cancel pending activation and route every active player through ordinary restoration. */
public synchronized void disableRoleplay() {
for (var attempt : java.util.List.copyOf(pending.values())) {
public synchronized DisableSummary disableRoleplay() {
var pendingEntries = java.util.List.copyOf(pending.values());
var activeSessions = java.util.List.copyOf(active.values());
for (var attempt : pendingEntries) {
refuse(attempt, "ROLEPLAY_DISABLED", "Fantasy roleplaying was disabled before activation completed.");
}
for (var attempt : java.util.List.copyOf(active.values())) exit(attempt.player);
int nativeRestored = 0, failed = 0;
for (var attempt : activeSessions) {
try {
var result = exit(attempt.player);
if (result == EquipmentStorage.RestoreResult.RESTORED) nativeRestored++;
else if (result != EquipmentStorage.RestoreResult.NOT_FOUND) failed++;
} catch (RuntimeException uncertain) {
diagnostic(attempt, uncertain);
failed++;
}
}
return new DisableSummary(pendingEntries.size(), activeSessions.size(), nativeRestored,
failed, activeSessions.size() - failed);
}
public synchronized void cancelEntry(UUID player) {
@@ -0,0 +1,344 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import games.dmg.fantasystick.persistence.RoleplaySettings;
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 org.bukkit.command.CommandSender;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Availability is a saved policy and exit request, not a claim that every restoration completed. */
class RoleplayAvailabilityEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void registeredReadAndDisableRequestTwoOrdinaryExitsWithoutClaimingCompletedRestoration() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", RoleplaySettings.KEY});
assertTrue(admin.messages.getLast().contains("=false"));
var one = new FantasyStickLifecycleNativeTest.Actor(data);
var two = new FantasyStickLifecycleNativeTest.Actor(data);
var ordinary = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(one.core.id, one.player); f.players.put(two.core.id, two.player);
f.players.put(ordinary.core.id, ordinary.player);
f.activate(one); f.activate(two);
var ordinaryBefore = ordinary.core.snapshot();
change(f, admin, "true");
assertArrayEquals(ordinaryBefore, ordinary.core.snapshot());
var policy = (RoleplaySettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "roleplaySettings");
assertTrue(policy.disabled());
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
assertTrue(sessions.active(one.core.id).isEmpty()); assertTrue(sessions.active(two.core.id).isEmpty());
f.awaitCharacterAndReceipt(one); f.awaitCharacterAndReceipt(two);
assertNotNull(f.receipts().find(one.core.id).toCompletableFuture()
.get(5, TimeUnit.SECONDS).orElseThrow().lastExit());
assertNotNull(f.receipts().find(two.core.id).toCompletableFuture()
.get(5, TimeUnit.SECONDS).orElseThrow().lastExit());
var blocked = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(blocked.core.id, blocked.player);
var original = blocked.core.snapshot();
f.changed(blocked); PartyBookNativeTest.drain(f);
assertTrue(sessions.active(blocked.core.id).isEmpty());
assertArrayEquals(original, blocked.core.snapshot());
for (int i = 0; i < 200 && config().size() < 3; i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var changes = config();
assertEquals(List.of("config.read", "config.changed", "game.availability_changed"), changes.stream()
.map(event -> event.get("type").getAsString().replace("games.dmg.minecraft.fantasystick.", ""))
.toList());
var config = changes.get(1).getAsJsonObject("data");
var availability = changes.get(2).getAsJsonObject("data");
assertEquals("false", config.get("before").getAsString());
assertEquals("true", config.get("effective").getAsString());
assertEquals(config.get("operation_id").getAsString(), availability.get("origin_admin_action_id").getAsString());
assertEquals(2, availability.get("exit_requested_count").getAsInt());
assertEquals(2, availability.get("exit_request_accepted_count").getAsInt());
assertEquals(0, availability.get("exit_confirmed_count").getAsInt(),
"The synchronous policy callback cannot attest later receipt completion");
assertEquals(2, availability.get("exit_pending_count").getAsInt());
assertEquals(0, availability.get("exit_failed_count").getAsInt());
assertTrue(availability.get("available_before").getAsBoolean());
assertFalse(availability.get("available_after").getAsBoolean());
}
@Test void failedNativeRestoreIsCountedSeparatelyFromPendingAndNeverConfirmedByToggle() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new EquipmentAdminCommandNativeTest.Sender();
var failed = new FantasyStickLifecycleNativeTest.Actor(data);
var intact = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(failed.core.id, failed.player); f.players.put(intact.core.id, intact.player);
f.activate(failed); f.activate(intact);
failed.core.mutation = () -> { throw new IllegalStateException("native restoration refused"); };
change(f, admin, "true");
failed.core.mutation = () -> {};
var failedReceipt = f.receipts().find(failed.core.id).toCompletableFuture()
.get(5, TimeUnit.SECONDS).orElseThrow();
assertNotNull(failedReceipt.pending(), "Failed native custody retains proof for recovery");
f.awaitCharacterAndReceipt(intact);
assertNotNull(f.receipts().find(intact.core.id).toCompletableFuture()
.get(5, TimeUnit.SECONDS).orElseThrow().lastExit());
for (int i = 0; i < 200 && config().size() < 2; i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var result = config().stream().filter(event -> event.get("type").getAsString()
.endsWith("game.availability_changed")).findFirst().orElseThrow().getAsJsonObject("data");
assertEquals(2, result.get("exit_requested_count").getAsInt());
assertEquals(1, result.get("exit_failed_count").getAsInt());
assertEquals(1, result.get("exit_pending_count").getAsInt());
assertEquals(1, result.get("exit_native_restored_pending_receipt_count").getAsInt());
assertEquals(0, result.get("exit_confirmed_count").getAsInt());
}
@Test void deniedInvalidOrFailedSaveCannotExportReadChangeOrAvailability() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new EquipmentAdminCommandNativeTest.Sender();
var active = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(active.core.id, active.player); f.activate(active);
admin.allowed = false;
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", RoleplaySettings.KEY});
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", RoleplaySettings.KEY, "true"});
admin.allowed = true;
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", RoleplaySettings.KEY, "TRUE"});
assertTrue(admin.messages.getLast().contains("true|false"));
Files.createDirectory(data.resolve("config.yml"));
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", RoleplaySettings.KEY, "true"});
for (int i = 0; i < 20 && !admin.messages.getLast().contains("save failed"); i++) {
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
}
assertTrue(admin.messages.getLast().contains("save failed"));
assertFalse(((RoleplaySettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "roleplaySettings")).disabled());
assertTrue(((EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "sessions")).active(active.core.id).isPresent());
f.plugin.setEnabled(false);
}
assertTrue(config().isEmpty());
}
@Test void repeatedSettingDoesNotDuplicateAvailabilityAndReenableHasNoExitRequest() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new EquipmentAdminCommandNativeTest.Sender();
var active = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(active.core.id, active.player); f.activate(active);
change(f, admin, "true");
f.awaitCharacterAndReceipt(active);
change(f, admin, "true");
change(f, admin, "false");
for (int i = 0; i < 200 && config().size() < 4; i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var entries = config();
assertEquals(4, entries.size(), "Read-free true/false changes have one config and one availability record each");
var availability = entries.stream().filter(e -> e.get("type").getAsString()
.endsWith("game.availability_changed")).map(e -> e.getAsJsonObject("data")).toList();
assertEquals(2, availability.size());
assertEquals(1, availability.getFirst().get("exit_requested_count").getAsInt());
assertEquals(0, availability.getLast().get("exit_requested_count").getAsInt());
assertFalse(availability.getFirst().get("available_after").getAsBoolean());
assertTrue(availability.getLast().get("available_after").getAsBoolean());
}
@Test void unavailableEventWriterRetainsOnePolicyTransitionForRestartReplayWithoutNewExits() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new EquipmentAdminCommandNativeTest.Sender();
var log = (games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog");
log.close();
change(f, admin, "true");
f.plugin.setEnabled(false);
assertTrue(config().isEmpty(), "Pending intents are not exported day-segment evidence");
f.plugin.setEnabled(true);
for (int i = 0; i < 200 && config().size() < 2; i++) Thread.sleep(10);
assertTrue(((RoleplaySettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "roleplaySettings")).disabled());
f.plugin.setEnabled(false);
}
var entries = config(); assertEquals(2, entries.size());
var changed = entries.stream().filter(e -> e.get("type").getAsString().endsWith("config.changed"))
.findFirst().orElseThrow().getAsJsonObject("data");
var availability = entries.stream().filter(e -> e.get("type").getAsString()
.endsWith("game.availability_changed")).findFirst().orElseThrow().getAsJsonObject("data");
assertEquals(changed.get("operation_id").getAsString(),
availability.get("origin_admin_action_id").getAsString());
assertEquals(0, availability.get("exit_requested_count").getAsInt());
}
@Test void reversedSavedCallbacksCannotPublishSupersededDisableOrInventAnAvailabilityChange() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
for (int i = 0; i < 30 && !f.tasks.isEmpty(); i++) f.tasks.remove().run();
var admin = new EquipmentAdminCommandNativeTest.Sender();
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(actor.core.id, actor.player); f.activate(actor);
f.awaitCharacterAndReceipt(actor); PartyBookNativeTest.drain(f);
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", RoleplaySettings.KEY, "true"});
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", RoleplaySettings.KEY, "false"});
var policy = (RoleplaySettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "roleplaySettings");
var worker = (java.util.concurrent.ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(
policy, "worker");
java.util.concurrent.CompletableFuture.runAsync(() -> {}, worker).get(5, TimeUnit.SECONDS);
var first = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(first);
var second = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(second);
second.run(); first.run();
assertFalse(policy.disabled());
assertTrue(((EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "sessions")).active(actor.core.id).isPresent());
f.plugin.setEnabled(false);
}
assertTrue(config().isEmpty(), "No effective availability transition was published");
}
@Test void administratorReadCapturesRealPlayerAndLocationWithoutPublishingPolicy() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(data);
doReturn("Administrator").when(admin.player).getName();
doReturn(true).when(admin.player).hasPermission("fantasystick.admin");
f.players.put(admin.core.id, admin.player);
f.adminCommand.execute(admin.player, "fstickadmin", new String[]{"config", RoleplaySettings.KEY});
for (int i = 0; i < 200 && config().isEmpty(); i++) Thread.sleep(10);
f.plugin.setEnabled(false);
var events = config(); assertEquals(1, events.size());
var read = events.getFirst().getAsJsonObject("data");
assertEquals(admin.core.id.toString(), read.get("player_id").getAsString());
assertEquals("Administrator", read.get("player_name").getAsString());
assertEquals("player", read.get("actor_kind").getAsString());
assertFalse(read.get("effective").getAsBoolean());
assertEquals(admin.core.location.getX(), read.getAsJsonObject("location").get("x").getAsDouble());
}
assertFalse(Files.exists(data.resolve("config.yml")));
}
@Test void closedLifetimeCannotPublishOrReplayAnOldAvailabilitySuccess() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var old = (RoleplaySettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "roleplaySettings");
var worker = (java.util.concurrent.ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(
old, "worker");
var entered = new java.util.concurrent.CountDownLatch(1);
var release = new java.util.concurrent.CountDownLatch(1);
worker.execute(() -> {
entered.countDown();
try { release.await(); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); }
});
assertTrue(entered.await(5, TimeUnit.SECONDS));
var admin = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", RoleplaySettings.KEY, "true"});
try { f.plugin.setEnabled(false); } finally { release.countDown(); }
old.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
while (!f.tasks.isEmpty()) f.tasks.remove().run();
assertFalse(old.disabled(), "The disabled old command did not publish a late callback");
assertTrue(config().isEmpty());
f.plugin.setEnabled(true);
for (int i = 0; i < 40 && FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions") == null; i++)
f.tick();
assertTrue(((RoleplaySettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "roleplaySettings")).disabled(), "The new boot loads the saved policy");
f.plugin.setEnabled(false);
}
assertTrue(config().isEmpty(), "Startup must not invent an old-lifetime admin action");
}
@Test void feedbackFailureCannotRollBackAcknowledgedPolicyOrItsAvailabilityRecords() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(actor.core.id, actor.player); f.activate(actor);
CommandSender broken = FantasyStickLifecycleNativeTest.port(CommandSender.class, (name, args) -> {
return switch (name) {
case "hasPermission" -> true;
case "sendMessage" -> throw new IllegalStateException("feedback delivery failed");
default -> throw new AssertionError("Unexpected sender call " + name);
};
});
f.adminCommand.execute(broken, "fstickadmin", new String[]{"config", RoleplaySettings.KEY, "true"});
for (int i = 0; i < 30 && !((RoleplaySettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "roleplaySettings")).disabled(); i++) {
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback);
try { callback.run(); } catch (IllegalStateException feedback) {
assertEquals("feedback delivery failed", feedback.getMessage());
}
}
f.awaitCharacterAndReceipt(actor);
for (int i = 0; i < 200 && config().size() < 2; i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var entries = config(); assertEquals(2, entries.size());
assertEquals(1, entries.stream().filter(e -> e.get("type").getAsString()
.endsWith("game.availability_changed")).count());
assertTrue(entries.stream().anyMatch(e -> e.get("type").getAsString().endsWith("config.changed")));
}
@Test void auditStoreFailureDoesNotPreventSavedDisableAndNativeExit() throws Exception {
Files.writeString(data.resolve("roleplay-availability-event-intents"), "unavailable");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(actor.core.id, actor.player); f.activate(actor);
var admin = new EquipmentAdminCommandNativeTest.Sender();
change(f, admin, "true");
f.awaitCharacterAndReceipt(actor);
assertTrue(((RoleplaySettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "roleplaySettings")).disabled());
assertTrue(((EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "sessions")).active(actor.core.id).isEmpty());
f.plugin.setEnabled(false);
}
assertTrue(config().isEmpty(), "An unavailable audit store cannot undo or claim the real transition");
}
private static void change(FantasyStickLifecycleNativeTest.Fixture f,
EquipmentAdminCommandNativeTest.Sender admin, String value) throws Exception {
int responses = admin.messages.size();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", RoleplaySettings.KEY, value});
for (int i = 0; i < 30 && admin.messages.size() == responses; i++) {
var task = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(task); task.run();
}
assertTrue(admin.messages.getLast().startsWith("Saved "), admin.messages.toString());
}
private List<JsonObject> config() throws Exception {
var events = new ArrayList<JsonObject>();
var root = data.resolve("events");
if (!Files.isDirectory(root)) return events;
try (var days = Files.list(root)) {
for (var day : days.filter(path -> path.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}")
&& Files.isDirectory(path, java.nio.file.LinkOption.NOFOLLOW_LINKS)).toList()) {
try (var segments = Files.list(day)) {
for (var path : segments.filter(file -> file.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()) {
var event = JsonParser.parseString(line).getAsJsonObject();
var type = event.get("type").getAsString();
if (type.endsWith("game.availability_changed")
|| (type.endsWith("config.read") || type.endsWith("config.changed"))
&& event.getAsJsonObject("data").has("key")
&& event.getAsJsonObject("data").get("key").getAsString().equals(RoleplaySettings.KEY))
events.add(event);
}
}
}
}
}
return events;
}
}