This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user