This commit is contained in:
1 parent
b0996b0c6e
commit
6ac4867821
4 files changed
+440
-2
No files matched your search
@@ -184,6 +184,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private java.util.concurrent.CompletionStage<Void> nutritionAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.SwitchDelayEventBridge switchAuditEvents;
|
||||
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.CharacterIdentityEventBridge identityAuditEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> identityAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.EquipmentEventBridge equipmentAuditEvents;
|
||||
@@ -803,6 +805,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
|
||||
failure -> getLogger().log(Level.WARNING, "Switch deadline event intent failed", failure));
|
||||
sessions.attachSwitchEvents(switchAuditEvents);
|
||||
switchConfigEvents = new games.dmg.fantasystick.events.SwitchConfigEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
|
||||
failure -> getLogger().log(Level.WARNING, "Switch configuration event intent failed", failure));
|
||||
admin.attachSwitchConfigEvents(switchConfigEvents);
|
||||
var identitySessions = sessions;
|
||||
identityAuditEvents = new games.dmg.fantasystick.events.CharacterIdentityEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
|
||||
@@ -830,7 +836,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; 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; 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);
|
||||
@@ -916,6 +922,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 replaySwitchConfigEvents = switchConfigEvents;
|
||||
switchConfigAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous switch configuration intent drain failed", failure);
|
||||
if (replaySwitchConfigEvents != null) replaySwitchConfigEvents.replay();
|
||||
});
|
||||
var replaySwitchEvents = switchAuditEvents;
|
||||
switchAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous switch event intent drain failed", failure);
|
||||
@@ -981,6 +992,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingEventLog = eventLog; eventLog = null; preferenceEvents = null;
|
||||
var closingNutritionAuditEvents = nutritionAuditEvents; nutritionAuditEvents = null;
|
||||
var closingSwitchAuditEvents = switchAuditEvents; switchAuditEvents = null;
|
||||
var closingSwitchConfigEvents = switchConfigEvents; switchConfigEvents = null;
|
||||
var closingIdentityAuditEvents = identityAuditEvents; identityAuditEvents = null;
|
||||
var closingAdminRevivalAuditEvents = adminRevivalEvents; adminRevivalEvents = null;
|
||||
var closingEquipmentAuditEvents = equipmentAuditEvents; equipmentAuditEvents = null;
|
||||
@@ -1302,6 +1314,16 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
return null;
|
||||
}).thenCompose(ignored -> closingNutritionAuditEvents.drained());
|
||||
});
|
||||
if (closingSwitchConfigEvents != null) cleanup("switch configuration event intent drain", () -> {
|
||||
var settingsDrained = closingSwitchDelaySettings == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null)
|
||||
: closingSwitchDelaySettings.drained();
|
||||
switchConfigAuditDrain = settingsDrained.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Switch setting drain failed", failure);
|
||||
closingSwitchConfigEvents.close();
|
||||
return null;
|
||||
}).thenCompose(ignored -> closingSwitchConfigEvents.drained());
|
||||
});
|
||||
if (closingSwitchAuditEvents != null) cleanup("switch event intent drain", () -> {
|
||||
var sessionsDrained = closingSessions == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingSessions.drained();
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.persistence.SwitchDelaySettings;
|
||||
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 the registered administrator's acknowledged switch policy; never changes deadlines. */
|
||||
public final class SwitchConfigEventBridge 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 SwitchConfigEventBridge(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, "switch-config-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(), record == null ? null : active.session(),
|
||||
safeLocation(player)));
|
||||
} catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
|
||||
public void read(Actor actor, String effective) {
|
||||
if (actor == null) return;
|
||||
var data = common(actor);
|
||||
data.put("effective", effective);
|
||||
data.put("outcome", "acknowledged_read");
|
||||
var action = UUID.randomUUID();
|
||||
try { sink.apply(new EventInput(id(action, "config.read"), action, clock.instant(),
|
||||
"config.read", actor.context(), data)); }
|
||||
catch (RuntimeException ignored) { /* Observation cannot change the policy. */ }
|
||||
}
|
||||
|
||||
public void changed(Actor actor, UUID operation, String before, String effective,
|
||||
SwitchDelaySettings.Update saved) {
|
||||
if (actor == null) return;
|
||||
try {
|
||||
var data = common(actor);
|
||||
data.put("operation_id", operation.toString());
|
||||
data.put("store_sequence", Long.toString(saved.sequence()));
|
||||
data.put("before", before);
|
||||
data.put("after", saved.value());
|
||||
data.put("effective", effective);
|
||||
data.put("zero_disabled", saved.delay().isZero());
|
||||
data.put("outcome", "acknowledged");
|
||||
outbox.accepted(new EventInput(id(operation, "config.changed"), operation, clock.instant(),
|
||||
"config.changed", actor.context(), data));
|
||||
} catch (RuntimeException ignored) { /* A failed audit cannot roll back an effective setting. */ }
|
||||
}
|
||||
|
||||
private static Map<String, Object> common(Actor actor) {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("key", SwitchDelaySettings.KEY);
|
||||
data.put("default", SwitchDelaySettings.DEFAULT);
|
||||
data.put("application_scope", "future_exit");
|
||||
data.put("existing_deadlines_unchanged", true);
|
||||
data.put("actor_kind", actor.kind());
|
||||
if (actor.context() == null) data.put("location_status", "not_applicable");
|
||||
else if (actor.context().location() == null) data.put("location_status", "unavailable");
|
||||
return data;
|
||||
}
|
||||
|
||||
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 action, String kind) {
|
||||
return UUID.nameUUIDFromBytes(("fantasystick:switch-config:" + action + ":" + kind)
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
public void replay() { outbox.replay(); }
|
||||
public CompletionStage<Void> drained() { return outbox.drained(); }
|
||||
@Override public void close() { outbox.close(); }
|
||||
}
|
||||
@@ -72,6 +72,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private AdminExperienceGrant experienceGrant;
|
||||
private games.dmg.fantasystick.persistence.AdminReviveSettings adminReviveSettings;
|
||||
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.persistence.PartySettings partySettings;
|
||||
private games.dmg.fantasystick.persistence.PartyEconomySettings partyEconomySettings;
|
||||
@@ -337,6 +338,10 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
}
|
||||
|
||||
public void attachSwitchDelay(games.dmg.fantasystick.persistence.SwitchDelaySettings settings){if(closed||switchDelaySettings!=null)throw new IllegalStateException("Switch delay already attached or command closed");switchDelaySettings=Objects.requireNonNull(settings);}
|
||||
public void attachSwitchConfigEvents(games.dmg.fantasystick.events.SwitchConfigEventBridge events){
|
||||
if(closed||switchDelaySettings==null||switchConfigEvents!=null)throw new IllegalStateException("Switch config events unavailable");
|
||||
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 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);}
|
||||
@@ -1870,7 +1875,39 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
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 switchDelayConfiguration(CommandSender sender,String[] args){if(switchDelaySettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY+"="+switchDelaySettings.value()+" (new exits use this delay; existing deadlines are unchanged)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SwitchDelaySettings.Update> operation;try{operation=switchDelaySettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Character switch delay is invalid; the effective value is unchanged.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-switch-delay",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure==null)switchDelaySettings.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.SwitchDelaySettings.KEY+"="+saved.value()+"; new exits use it and existing deadlines are unchanged.":"Character switch delay save failed; the effective value is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
private void switchDelayConfiguration(CommandSender sender,String[] args){
|
||||
if(switchDelaySettings==null){configurationUsage(sender);return;}
|
||||
var audit=switchConfigEvents;
|
||||
if(args.length==2){
|
||||
var effective=switchDelaySettings.value();
|
||||
if(audit!=null)audit.read(audit.actor(sender),effective);
|
||||
sender.sendMessage(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY+"="+effective
|
||||
+" (new exits use this delay; existing deadlines are unchanged)");
|
||||
return;
|
||||
}
|
||||
var actor=audit==null?null:audit.actor(sender);
|
||||
var operationId=java.util.UUID.randomUUID();
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SwitchDelaySettings.Update> operation;
|
||||
try{operation=switchDelaySettings.set(args[2]);}
|
||||
catch(IllegalArgumentException invalid){sender.sendMessage("Character switch delay is invalid; the effective value is unchanged.");return;}
|
||||
operation.whenComplete((saved,failure)->{
|
||||
if(failure!=null)report(null,"config-switch-delay",failure);
|
||||
if(closed)return;
|
||||
try{nextTick.accept(()->{
|
||||
if(closed)return;
|
||||
if(failure==null){
|
||||
var before=switchDelaySettings.value();
|
||||
switchDelaySettings.publish(saved);
|
||||
var effective=switchDelaySettings.value();
|
||||
if(audit!=null&&!before.equals(effective))audit.changed(actor,operationId,before,effective,saved);
|
||||
}
|
||||
if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;
|
||||
sender.sendMessage(failure==null?"Saved "+games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY+"="+saved.value()
|
||||
+"; new exits use it and existing deadlines are unchanged."
|
||||
:"Character switch delay save failed; the effective value is unchanged.");
|
||||
});}catch(RuntimeException rejected){report(null,"config-response",rejected);}
|
||||
});
|
||||
}
|
||||
|
||||
private void adminReviveConfiguration(CommandSender sender,String[] args){
|
||||
if(adminReviveSettings==null){configurationUsage(sender);return;}
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
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.SwitchDelaySettings;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
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 administrator policy changes must be independently visible in exported day segments. */
|
||||
class SwitchDelayAdminEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void registeredReadAndAcknowledgedZeroThenNonzeroChangesReachExportedJsonlAndActualExits() 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", SwitchDelaySettings.KEY});
|
||||
assertTrue(admin.messages.getLast().contains("=900"));
|
||||
var player = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(player.core.id, player.player); f.activate(player);
|
||||
change(f, admin, "0");
|
||||
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
|
||||
sessions.exit(player.player); f.awaitCharacterAndReceipt(player);
|
||||
var first = f.receipts().find(player.core.id).toCompletableFuture()
|
||||
.get(5, TimeUnit.SECONDS).orElseThrow().lastExit();
|
||||
assertEquals(Duration.ZERO, Duration.between(first.exitedAt(), first.deadline()));
|
||||
f.reenter(player);
|
||||
change(f, admin, "37");
|
||||
sessions.exit(player.player); f.awaitCharacterAndReceipt(player);
|
||||
var second = f.receipts().find(player.core.id).toCompletableFuture()
|
||||
.get(5, TimeUnit.SECONDS).orElseThrow().lastExit();
|
||||
assertEquals(Duration.ofSeconds(37), Duration.between(second.exitedAt(), second.deadline()));
|
||||
assertEquals(first.exitedAt(), first.deadline(), "Changing policy did not rewrite an existing deadline");
|
||||
for (int i = 0; i < 200 && config().size() < 3; i++) Thread.sleep(10);
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var entries = config();
|
||||
assertEquals(List.of("games.dmg.minecraft.fantasystick.config.read",
|
||||
"games.dmg.minecraft.fantasystick.config.changed",
|
||||
"games.dmg.minecraft.fantasystick.config.changed"), entries.stream()
|
||||
.map(event -> event.get("type").getAsString()).toList());
|
||||
assertEquals(List.of("900", "0", "37"), entries.stream()
|
||||
.map(event -> event.getAsJsonObject("data").get("effective").getAsString()).toList());
|
||||
assertEquals(List.of("900", "0"), entries.subList(1, 3).stream()
|
||||
.map(event -> event.getAsJsonObject("data").get("before").getAsString()).toList());
|
||||
assertTrue(entries.get(1).getAsJsonObject("data").get("zero_disabled").getAsBoolean());
|
||||
assertFalse(entries.get(2).getAsJsonObject("data").get("zero_disabled").getAsBoolean());
|
||||
}
|
||||
|
||||
@Test void deniedInvalidAndFailedWritesNeverClaimPolicyChange() 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", SwitchDelaySettings.KEY});
|
||||
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", SwitchDelaySettings.KEY, "0"});
|
||||
admin.allowed = true;
|
||||
for (var invalid : List.of("-1", "1.5", "not-a-number")) {
|
||||
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", SwitchDelaySettings.KEY, invalid});
|
||||
assertTrue(admin.messages.getLast().contains("invalid"));
|
||||
}
|
||||
Files.createDirectory(data.resolve("config.yml"));
|
||||
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", SwitchDelaySettings.KEY, "0"});
|
||||
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"));
|
||||
var settings = (SwitchDelaySettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "switchDelaySettings");
|
||||
assertEquals("900", settings.value());
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
assertTrue(config().isEmpty(), "A rejected request, read or save has no domain success");
|
||||
}
|
||||
|
||||
@Test void concurrentAcknowledgedWritesReportEffectiveBeforeAndAfterWithoutSupersededSuccess() 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", SwitchDelaySettings.KEY, "0"});
|
||||
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", SwitchDelaySettings.KEY, "37"});
|
||||
for (int i = 0; i < 30 && admin.messages.size() < 2; i++) {
|
||||
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
|
||||
}
|
||||
assertEquals(2, admin.messages.size(), admin.messages.toString());
|
||||
assertTrue(admin.messages.stream().allMatch(message -> message.startsWith("Saved ")));
|
||||
for (int i = 0; i < 200 && config().size() < 2; i++) Thread.sleep(10);
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var changes = config(); assertEquals(2, changes.size());
|
||||
assertEquals(List.of("900", "0"), changes.stream()
|
||||
.map(event -> event.getAsJsonObject("data").get("before").getAsString()).toList());
|
||||
assertEquals(List.of("0", "37"), changes.stream()
|
||||
.map(event -> event.getAsJsonObject("data").get("effective").getAsString()).toList());
|
||||
assertEquals(2, changes.stream().map(event -> event.getAsJsonObject("data")
|
||||
.get("operation_id").getAsString()).distinct().count());
|
||||
}
|
||||
|
||||
@Test void supersededSavedCallbackCannotExportASecondOrRollbackEffectiveChange() 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();
|
||||
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", SwitchDelaySettings.KEY, "0"});
|
||||
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", SwitchDelaySettings.KEY, "37"});
|
||||
var settings = (SwitchDelaySettings) FantasyStickLifecycleNativeTest.Fixture.field(
|
||||
f.plugin, "switchDelaySettings");
|
||||
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("37", settings.value());
|
||||
for (int i = 0; i < 200 && config().isEmpty(); i++) Thread.sleep(10);
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var changes = config();
|
||||
assertEquals(1, changes.size(), "The stale callback must not claim a policy publication");
|
||||
assertEquals("900", changes.getFirst().getAsJsonObject("data").get("before").getAsString());
|
||||
assertEquals("37", changes.getFirst().getAsJsonObject("data").get("effective").getAsString());
|
||||
}
|
||||
|
||||
@Test void playerAdministratorReadCapturesExactIdentityAndLocationWithoutSaving() 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", SwitchDelaySettings.KEY});
|
||||
for (int i = 0; i < 200 && config().isEmpty(); i++) Thread.sleep(10);
|
||||
f.plugin.setEnabled(false);
|
||||
var entries = config(); assertEquals(1, entries.size());
|
||||
var read = entries.getFirst(); var values = read.getAsJsonObject("data");
|
||||
assertTrue(read.get("type").getAsString().endsWith("config.read"));
|
||||
assertEquals(admin.core.id.toString(), values.get("player_id").getAsString());
|
||||
assertEquals("player", values.get("actor_kind").getAsString());
|
||||
assertEquals("future_exit", values.get("application_scope").getAsString());
|
||||
assertEquals("900", values.get("default").getAsString());
|
||||
assertEquals("900", values.get("effective").getAsString());
|
||||
assertEquals(admin.core.location.getX(), values.getAsJsonObject("location").get("x").getAsDouble());
|
||||
}
|
||||
assertFalse(Files.exists(data.resolve("config.yml")), "A read does not persist configuration");
|
||||
}
|
||||
|
||||
@Test void failedIntentHandoffCannotUndoSavedPolicyAndReplaysStableOperationAfterRestart() 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, "0");
|
||||
assertEquals("0", ((SwitchDelaySettings) FantasyStickLifecycleNativeTest.Fixture.field(
|
||||
f.plugin, "switchDelaySettings")).value());
|
||||
f.plugin.setEnabled(false);
|
||||
assertTrue(config().isEmpty(), "No pending file counts as an exported event");
|
||||
f.plugin.setEnabled(true);
|
||||
for (int i = 0; i < 200 && config().isEmpty(); i++) Thread.sleep(10);
|
||||
assertEquals("0", ((SwitchDelaySettings) FantasyStickLifecycleNativeTest.Fixture.field(
|
||||
f.plugin, "switchDelaySettings")).value());
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
assertEquals(1, config().size(), "One saved operation replays without another configuration request");
|
||||
assertEquals("0", config().getFirst().getAsJsonObject("data").get("effective").getAsString());
|
||||
}
|
||||
|
||||
@Test void auditIntentFailureCannotRollBackPolicyOrPreventActualZeroDurationExit() throws Exception {
|
||||
Files.writeString(data.resolve("switch-config-event-intents"), "blocked");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var admin = new EquipmentAdminCommandNativeTest.Sender();
|
||||
change(f, admin, "0");
|
||||
var player = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(player.core.id, player.player); f.activate(player);
|
||||
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
|
||||
sessions.exit(player.player); f.awaitCharacterAndReceipt(player);
|
||||
var exit = f.receipts().find(player.core.id).toCompletableFuture()
|
||||
.get(5, TimeUnit.SECONDS).orElseThrow().lastExit();
|
||||
assertEquals(Duration.ZERO, Duration.between(exit.exitedAt(), exit.deadline()));
|
||||
assertEquals("0", ((SwitchDelaySettings) FantasyStickLifecycleNativeTest.Fixture.field(
|
||||
f.plugin, "switchDelaySettings")).value());
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
assertTrue(config().isEmpty(), "Audit-store failure cannot invent an exported change");
|
||||
}
|
||||
|
||||
@Test void closingBeforeAcknowledgedCallbackNeverLogsUnpublishedRuntimeChange() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var settings = (SwitchDelaySettings) FantasyStickLifecycleNativeTest.Fixture.field(
|
||||
f.plugin, "switchDelaySettings");
|
||||
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", SwitchDelaySettings.KEY, "0"});
|
||||
try { f.plugin.setEnabled(false); } finally { release.countDown(); }
|
||||
settings.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
while (!f.tasks.isEmpty()) f.tasks.remove().run();
|
||||
assertEquals("900", settings.value(), "Closed command cannot publish a late setting");
|
||||
assertTrue(config().isEmpty(), "Persisted but unpublished work is not an acknowledged domain change");
|
||||
f.plugin.setEnabled(true);
|
||||
assertEquals("0", ((SwitchDelaySettings) FantasyStickLifecycleNativeTest.Fixture.field(
|
||||
f.plugin, "switchDelaySettings")).value(), "The later boot loads the committed setting");
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
assertTrue(config().isEmpty(), "Startup load does not invent a prior runtime config.changed");
|
||||
}
|
||||
|
||||
private void change(FantasyStickLifecycleNativeTest.Fixture f,
|
||||
EquipmentAdminCommandNativeTest.Sender admin, String value) throws Exception {
|
||||
int messages = admin.messages.size();
|
||||
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", SwitchDelaySettings.KEY, value});
|
||||
for (int i = 0; i < 30 && admin.messages.size() == messages; i++) {
|
||||
var callback = f.tasks.poll(5, TimeUnit.SECONDS);
|
||||
assertNotNull(callback, "Acknowledged setting needs a native 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(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();
|
||||
if (event.get("type").getAsString().endsWith("config.read")
|
||||
|| event.get("type").getAsString().endsWith("config.changed")) {
|
||||
var values = event.getAsJsonObject("data");
|
||||
if (values.has("key") && values.get("key").getAsString().equals(SwitchDelaySettings.KEY))
|
||||
entries.add(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user