feat(party): export acknowledged capacity changes
Release / release (push) Successful in 17m51s

This commit is contained in:
dmg committed 2026-09-26 14:18:28 -04:00
1 parent 01fd2f10ff
commit 2f7a72355d
4 files changed
+438 -2

No files matched your search

@@ -188,6 +188,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
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.PartyCapacityEventBridge partyCapacityEvents;
private java.util.concurrent.CompletionStage<Void> partyCapacityAuditDrain = 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;
@@ -815,6 +817,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
failure -> getLogger().log(Level.WARNING, "Roleplay availability event intent failed", failure));
admin.attachRoleplayAvailabilityEvents(roleplayAvailabilityEvents);
partyCapacityEvents = new games.dmg.fantasystick.events.PartyCapacityEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
failure -> getLogger().log(Level.WARNING, "Party capacity event intent failed", failure));
admin.attachPartyCapacityEvents(partyCapacityEvents);
var identitySessions = sessions;
identityAuditEvents = new games.dmg.fantasystick.events.CharacterIdentityEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
@@ -842,7 +848,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; roleplayAvailabilityEvents = 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; partyCapacityEvents = 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);
@@ -933,6 +939,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (failure != null) getLogger().log(Level.WARNING, "Previous roleplay event intent drain failed", failure);
if (replayRoleplayEvents != null) replayRoleplayEvents.replay();
});
var replayPartyCapacityEvents = partyCapacityEvents;
partyCapacityAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous party capacity event intent drain failed", failure);
if (replayPartyCapacityEvents != null) replayPartyCapacityEvents.replay();
});
var replaySwitchConfigEvents = switchConfigEvents;
switchConfigAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous switch configuration intent drain failed", failure);
@@ -1005,6 +1016,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingSwitchAuditEvents = switchAuditEvents; switchAuditEvents = null;
var closingSwitchConfigEvents = switchConfigEvents; switchConfigEvents = null;
var closingRoleplayEvents = roleplayAvailabilityEvents; roleplayAvailabilityEvents = null;
var closingPartyCapacityEvents = partyCapacityEvents; partyCapacityEvents = null;
var closingIdentityAuditEvents = identityAuditEvents; identityAuditEvents = null;
var closingAdminRevivalAuditEvents = adminRevivalEvents; adminRevivalEvents = null;
var closingEquipmentAuditEvents = equipmentAuditEvents; equipmentAuditEvents = null;
@@ -1336,6 +1348,16 @@ public final class FantasyStickPlugin extends JavaPlugin {
return null;
}).thenCompose(ignored -> closingRoleplayEvents.drained());
});
if (closingPartyCapacityEvents != null) cleanup("party capacity event intent drain", () -> {
var settingsDrained = closingPartySettings == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null)
: closingPartySettings.drained();
partyCapacityAuditDrain = settingsDrained.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Party setting drain failed", failure);
closingPartyCapacityEvents.close();
return null;
}).thenCompose(ignored -> closingPartyCapacityEvents.drained());
});
if (closingSwitchConfigEvents != null) cleanup("switch configuration event intent drain", () -> {
var settingsDrained = closingSwitchDelaySettings == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null)
@@ -0,0 +1,109 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.PartySettings;
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;
/** Observes effective party join capacity without reading or mutating protected rosters. */
public final class PartyCapacityEventBridge 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 PartyCapacityEventBridge(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, "party-capacity-event-intents",
java.util.Set.of("config.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(), active == null ? null : active.session(),
safeLocation(player)));
} catch (RuntimeException unavailable) { return null; }
}
public void read(Actor actor, String effective) {
if (actor == null) return;
var values = common(actor);
values.put("effective", effective);
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) { /* Observation cannot change party capacity. */ }
}
public void changed(Actor actor, UUID operation, String before, String effective,
PartySettings.Update saved) {
if (actor == null) return;
try {
var values = common(actor);
values.put("operation_id", operation.toString());
values.put("policy_revision", Long.toString(saved.sequence()));
values.put("before", before);
values.put("after", saved.value());
values.put("effective", effective);
values.put("existing_rosters_grandfathered", true);
values.put("outcome", "acknowledged");
outbox.accepted(new EventInput(id(operation, "config.changed"), operation, clock.instant(),
"config.changed", actor.context(), values));
} catch (RuntimeException ignored) { /* Audit failure cannot roll back a published join limit. */ }
}
private static Map<String, Object> common(Actor actor) {
var values = new LinkedHashMap<String, Object>();
values.put("key", PartySettings.KEY);
values.put("default", PartySettings.DEFAULT);
values.put("actor_kind", actor.kind());
values.put("application_scope", "future_join_checks");
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 static UUID id(UUID operation, String type) {
return UUID.nameUUIDFromBytes(("fantasystick:party-capacity:" + operation + ":" + type)
.getBytes(StandardCharsets.UTF_8));
}
public void replay() { outbox.replay(); }
public CompletionStage<Void> drained() { return outbox.drained(); }
@Override public void close() { outbox.close(); }
}
@@ -76,6 +76,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
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.events.PartyCapacityEventBridge partyCapacityEvents;
private games.dmg.fantasystick.persistence.PartyEconomySettings partyEconomySettings;
private games.dmg.fantasystick.persistence.GearSettings gearSettings;
private games.dmg.fantasystick.persistence.CoinSettings coinSettings;
@@ -349,6 +350,10 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
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 attachPartyCapacityEvents(games.dmg.fantasystick.events.PartyCapacityEventBridge events){
if(closed||partySettings==null||partyCapacityEvents!=null)throw new IllegalStateException("Party capacity events unavailable");
partyCapacityEvents=Objects.requireNonNull(events);
}
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);}
public void attachCoinSettings(games.dmg.fantasystick.persistence.CoinSettings settings){if(closed||coinSettings!=null)throw new IllegalStateException("Coin settings already attached or command closed");coinSettings=Objects.requireNonNull(settings);}
@@ -1876,7 +1881,40 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=failure==null&&adventureQuestSettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Adventure quest configuration save failed; effective values are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+adventureQuestSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+saved.value()+" for later catalogs, acceptances or attempts; active state is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});
}
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 partyConfiguration(CommandSender sender,String[] args){
if(partySettings==null){configurationUsage(sender);return;}
var audit=partyCapacityEvents;
if(args.length==2){
var effective=partySettings.value();
if(audit!=null)audit.read(audit.actor(sender),effective);
sender.sendMessage(games.dmg.fantasystick.persistence.PartySettings.KEY+"="+effective+" (existing parties are retained)");
return;
}
var actor=audit==null?null:audit.actor(sender);
var operationId=java.util.UUID.randomUUID();
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){
var before=partySettings.value();
applied=partySettings.publish(saved);
var effective=partySettings.value();
if(audit!=null&&applied&&!before.equals(effective))
audit.changed(actor,operationId,before,effective,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;}
@@ -0,0 +1,267 @@
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.PartySettings;
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;
import org.bukkit.command.CommandSender;
/** Evidence must join the registered policy path to actual roster enforcement and exported JSONL. */
class PartyCapacityEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void registeredReadAndAcknowledgedReductionAndIncreaseGovernActualJoinsAndExportPolicy() 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", PartySettings.KEY});
assertTrue(admin.messages.getLast().contains("=6"));
var leader = PartyCapacityNativeTest.actor(f, data);
var member = PartyCapacityNativeTest.actor(f, data);
var third = PartyCapacityNativeTest.actor(f, data);
var roster = PartyLifecycleNativeTest.parties(f);
var party = roster.create(leader.core.id).orElseThrow();
var first = roster.invite(leader.core.id, party.id(), member.core.id).orElseThrow();
assertTrue(roster.accept(member.core.id, first.id()));
var second = roster.invite(leader.core.id, party.id(), third.core.id).orElseThrow();
change(f, admin, "2");
assertFalse(roster.accept(third.core.id, second.id()));
change(f, admin, "3");
assertTrue(roster.accept(third.core.id, second.id()));
change(f, admin, "2");
assertEquals(3, roster.partyOf(leader.core.id).orElseThrow().members().size());
assertTrue(roster.invite(leader.core.id, party.id(), java.util.UUID.randomUUID()).isEmpty());
for (int i = 0; i < 200 && config().size() < 4; i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var entries = config(); assertEquals(4, entries.size(), "Only exported JSONL proves event delivery");
assertEquals(List.of("6", "2", "3", "2"), entries.stream()
.map(e -> e.getAsJsonObject("data").get("effective").getAsString()).toList());
assertEquals(List.of("config.read", "config.changed", "config.changed", "config.changed"), entries.stream()
.map(e -> e.get("type").getAsString().substring(e.get("type").getAsString().lastIndexOf(".",
e.get("type").getAsString().lastIndexOf(".") - 1) + 1)).toList());
assertTrue(entries.stream().skip(1).allMatch(e -> e.getAsJsonObject("data")
.get("existing_rosters_grandfathered").getAsBoolean()));
}
@Test void deniedInvalidAndFailedSaveNeverExportAChangeOrModifyCapacity() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new EquipmentAdminCommandNativeTest.Sender(); admin.allowed = false;
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", PartySettings.KEY});
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", PartySettings.KEY, "2"});
admin.allowed = true;
for (var invalid : List.of("0", "-2", "2.5", "2147483648", "not-a-number")) {
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", PartySettings.KEY, invalid});
assertTrue(admin.messages.getLast().contains("invalid"));
}
Files.createDirectory(data.resolve("config.yml"));
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", PartySettings.KEY, "2"});
for (int i = 0; i < 30 && !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"));
assertEquals(PartySettings.DEFAULT, ((PartySettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "partySettings")).value());
f.plugin.setEnabled(false);
}
assertTrue(config().isEmpty(), "Refusals and failed writes have no feature-owned success");
}
@Test void outOfOrderSavedCallbacksAndRepeatedValueNeverPublishFalseChanges() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
while (!f.tasks.isEmpty()) f.tasks.remove().run();
var admin = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", PartySettings.KEY, "2"});
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", PartySettings.KEY, "3"});
var settings = (PartySettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "partySettings");
var worker = (java.util.concurrent.ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(
settings, "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();
assertEquals("3", settings.value());
change(f, admin, "3");
for (int i = 0; i < 200 && config().isEmpty(); i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var entries = config(); assertEquals(1, entries.size());
var values = entries.getFirst().getAsJsonObject("data");
assertEquals("6", values.get("before").getAsString());
assertEquals("3", values.get("effective").getAsString());
assertEquals("future_join_checks", values.get("application_scope").getAsString());
assertNotNull(values.get("operation_id")); assertNotNull(values.get("policy_revision"));
}
@Test void physicalAdministratorReadReportsExactIdentityLocationAndDefaultWithoutRosters() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new FantasyStickLifecycleNativeTest.Actor(data);
doReturn(true).when(admin.player).hasPermission("fantasystick.admin");
doReturn("Administrator").when(admin.player).getName();
f.players.put(admin.core.id, admin.player);
f.adminCommand.execute(admin.player, "fstickadmin", new String[]{"config", PartySettings.KEY});
for (int i = 0; i < 200 && config().isEmpty(); i++) Thread.sleep(10);
var observed = config(); assertEquals(1, observed.size());
var data = observed.getFirst().getAsJsonObject("data");
assertEquals(admin.core.id.toString(), data.get("player_id").getAsString());
assertEquals(admin.core.location.getX(), data.getAsJsonObject("location").get("x").getAsDouble());
f.plugin.setEnabled(false);
}
var entries = config(); assertEquals(1, entries.size());
var values = entries.getFirst().getAsJsonObject("data");
assertEquals("6", values.get("default").getAsString());
assertEquals("6", values.get("effective").getAsString());
assertEquals("player", values.get("actor_kind").getAsString());
assertEquals("Administrator", values.get("player_name").getAsString());
assertEquals("future_join_checks", values.get("application_scope").getAsString());
assertNotNull(values.getAsJsonObject("location").get("x"));
}
@Test void unavailableWriterRetainsSingleAcceptedPolicyIntentAcrossRestartWithoutAnotherWrite() 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, "2");
assertEquals("2", ((PartySettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "partySettings")).value());
f.plugin.setEnabled(false);
assertTrue(config().isEmpty(), "Only exported segments count as evidence");
f.plugin.setEnabled(true);
for (int i = 0; i < 200 && config().isEmpty(); i++) Thread.sleep(10);
assertEquals("2", ((PartySettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "partySettings")).value());
f.plugin.setEnabled(false);
}
var entries = config(); assertEquals(1, entries.size());
assertEquals("2", entries.getFirst().getAsJsonObject("data").get("effective").getAsString());
}
@Test void auditStoreFailureCannotRollBackAnAcknowledgedLimitOrMutateGrandfatheredRoster() throws Exception {
Files.writeString(data.resolve("party-capacity-event-intents"), "blocked");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var leader = PartyCapacityNativeTest.actor(f, data);
var member = PartyCapacityNativeTest.actor(f, data);
var third = PartyCapacityNativeTest.actor(f, data);
var roster = PartyLifecycleNativeTest.parties(f);
var party = roster.create(leader.core.id).orElseThrow();
assertTrue(roster.accept(member.core.id, roster.invite(leader.core.id, party.id(), member.core.id)
.orElseThrow().id()));
assertTrue(roster.accept(third.core.id, roster.invite(leader.core.id, party.id(), third.core.id)
.orElseThrow().id()));
var admin = new EquipmentAdminCommandNativeTest.Sender();
change(f, admin, "2");
assertEquals("2", ((PartySettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "partySettings")).value());
assertEquals(3, roster.partyOf(leader.core.id).orElseThrow().members().size());
assertTrue(roster.invite(leader.core.id, party.id(), java.util.UUID.randomUUID()).isEmpty());
f.plugin.setEnabled(false);
}
assertTrue(config().isEmpty(), "Intent-store failure cannot fabricate an exported success");
}
@Test void callbackAfterCloseDoesNotInventAnUnpublishedRuntimeChange() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var settings = (PartySettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "partySettings");
var worker = (java.util.concurrent.ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(
settings, "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", PartySettings.KEY, "2"});
try { f.plugin.setEnabled(false); } finally { release.countDown(); }
settings.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
while (!f.tasks.isEmpty()) f.tasks.remove().run();
assertEquals("6", settings.value()); assertTrue(config().isEmpty());
f.plugin.setEnabled(true);
assertEquals("2", ((PartySettings) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "partySettings")).value());
f.plugin.setEnabled(false);
}
assertTrue(config().isEmpty(), "Startup must not fabricate an earlier runtime publication");
}
@Test void failedFeedbackCannotReverseAcknowledgedLimitOrOwnedEvent() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
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", PartySettings.KEY, "2"});
var settings = (PartySettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "partySettings");
for (int i = 0; i < 30 && !"2".equals(settings.value()); i++) {
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback);
try { callback.run(); } catch (IllegalStateException feedback) {
assertEquals("feedback delivery failed", feedback.getMessage());
}
}
assertEquals("2", settings.value());
for (int i = 0; i < 200 && config().isEmpty(); i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
assertEquals(1, config().size());
assertEquals("2", config().getFirst().getAsJsonObject("data").get("effective").getAsString());
}
private void change(FantasyStickLifecycleNativeTest.Fixture f,
EquipmentAdminCommandNativeTest.Sender admin, String value) throws Exception {
int before = admin.messages.size();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", PartySettings.KEY, value});
for (int i = 0; i < 30 && admin.messages.size() == before; i++) {
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
}
assertTrue(admin.messages.getLast().startsWith("Saved "), admin.messages.toString());
}
private List<JsonObject> config() throws Exception {
var entries = new ArrayList<JsonObject>(); var root = data.resolve("events");
if (!Files.isDirectory(root)) return entries;
try (var days = Files.list(root)) {
for (var day : days.filter(p -> p.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}")
&& Files.isDirectory(p, java.nio.file.LinkOption.NOFOLLOW_LINKS)).toList()) {
try (var segments = Files.list(day)) {
for (var path : segments.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()) {
var event = JsonParser.parseString(line).getAsJsonObject();
if (event.get("type").getAsString().endsWith("config.read")
|| event.get("type").getAsString().endsWith("config.changed")) {
var values = event.getAsJsonObject("data");
if (values.has("key") && PartySettings.KEY.equals(values.get("key").getAsString()))
entries.add(event);
}
}
}
}
}
}
return entries;
}
}