Compare commits

...
3 Commits
Author SHA1 Message Date
dmg 13828054e5 feat(events): audit combat feedback preferences
Release / release (push) Failing after 14m7s
2026-09-25 09:48:27 -04:00
dmg 39c58ef4d9 feat(events): audit account aura preferences
Release / release (push) Successful in 14m40s
2026-09-25 09:37:45 -04:00
dmg ec73018f93 feat(events): audit account particle preferences
Release / release (push) Successful in 14m30s
2026-09-25 09:21:18 -04:00
8 changed files with 1171 additions and 15 deletions

No files matched your search

@@ -163,6 +163,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.passive.PassiveRegistry passiveRegistry;
private games.dmg.fantasystick.passive.PassiveEngine passiveEngine;
private games.dmg.fantasystick.events.LocalEventLog eventLog;
private games.dmg.fantasystick.events.PreferenceEventBridge preferenceEvents;
private games.dmg.fantasystick.events.EventCaptureListener eventCapture;
private games.dmg.fantasystick.events.DownedEventBridge downedEvents;
private games.dmg.fantasystick.events.RareEventBridge rareEvents;
@@ -712,6 +713,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
getServer().getVersion(), getServer().getMinecraftVersion(), getDescription().getVersion()), 1024, 134_217_728L,
message -> getLogger().warning(message));
eventCapture = new games.dmg.fantasystick.events.EventCaptureListener(eventLog, sessions, Clock.systemUTC());
preferenceEvents = new games.dmg.fantasystick.events.PreferenceEventBridge(
eventLog::emit, Clock.systemUTC(), sessions, feedbackPreferences,
failure -> getLogger().log(Level.WARNING, "Preference event publication proof failed", failure));
particlesCommand.attachEvents(preferenceEvents);
feedbackCommand.attachEvents(preferenceEvents);
eventCapture.excludeOwnedDamage(fireball::ownsAcceptedDamage);
eventCapture.excludeOwnedDamage(guardianBond::ownsAcceptedRedirect);
eventCapture.excludeOwnedDamage(frostNova::ownsAcceptedDamage);
@@ -830,7 +836,15 @@ public final class FantasyStickPlugin extends JavaPlugin {
recipeDiscovery.discoverAll(onlinePlayers);
for (var player : onlinePlayers) { sessions.arrive(player); revive.reconcile(player); }
sheetResourcePack.deliverAll(onlinePlayers);
if(eventLog!=null)eventLog.ready();
if (eventLog != null) {
var preferenceReplay = preferenceEvents;
var savedPreferences = feedbackPreferences;
eventLog.ready().whenComplete((delivery, failure) -> {
if (failure == null && delivery != games.dmg.fantasystick.events.LocalEventLog.Delivery.REJECTED
&& preferenceReplay != null && savedPreferences != null)
for (var pendingPreference : savedPreferences.pendingEvents()) preferenceReplay.replay(pendingPreference);
});
}
} catch (RuntimeException | LinkageError failure) {
try { onDisable(); }
catch (RuntimeException | LinkageError cleanupFailure) { failure.addSuppressed(cleanupFailure); }
@@ -852,7 +866,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
particleDelivery = null;
combatFeedback = null;
var closingEvents = sessionEvents; sessionEvents = null;
var closingEventLog = eventLog; eventLog = null;
var closingEventLog = eventLog; eventLog = null; preferenceEvents = null;
var closingEventCapture = eventCapture; eventCapture = null; var closingMessageAuditEvents = messageAuditEvents; messageAuditEvents = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; abilityEvents = null; passiveEvents = null; recoveryEvents = null; reviveEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null;
var closingBooks = books; books = null;
var closingPartyEvents = partyEvents; partyEvents = null;
@@ -0,0 +1,198 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.PlayerFeedbackPreferences;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CompletionStage;
import java.util.function.Function;
import java.util.function.Consumer;
import org.bukkit.Location;
import org.bukkit.entity.Player;
/** Captured account preferences remain independent of gameplay and feedback delivery. */
public final class PreferenceEventBridge {
public record Action(UUID id, Instant time, EventInput.Context context, String key,
boolean before, boolean savedBefore, Boolean requested, String actorKind) { }
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
private final Clock clock;
private final EquipmentSessions sessions;
private final PlayerFeedbackPreferences preferences;
private final Consumer<Throwable> failures;
public PreferenceEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink, Clock clock,
EquipmentSessions sessions, PlayerFeedbackPreferences preferences,
Consumer<Throwable> failures) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
this.sessions = Objects.requireNonNull(sessions);
this.preferences = Objects.requireNonNull(preferences);
this.failures = Objects.requireNonNull(failures);
}
public Action begin(Player player, String key, boolean before, Boolean requested) {
Objects.requireNonNull(player);
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
var where = player.getLocation();
EventInput.Location location = where == null || where.getWorld() == null ? null
: new EventInput.Location(where.getWorld().getUID(), where.getWorld().getName(), where.getX(),
where.getY(), where.getZ(), clock.instant(), "exact");
var action = new Action(UUID.randomUUID(), clock.instant(), new EventInput.Context(player.getUniqueId(),
player.getName(), record == null ? null : record.id(), record == null ? null : record.name(),
record == null ? null : active.session(), location), key, before,
key.equals("player-aura-particles") ? preferences.savedPlayerAurasEnabled(player.getUniqueId())
: key.equals("combat_feedback") ? preferences.savedEnabled(player.getUniqueId())
: preferences.savedAllParticlesEnabled(player.getUniqueId()), requested, "player");
emit("action.observed", action, "observed", null, null, before, false);
return action;
}
public void refuseNonPlayer(org.bukkit.command.CommandSender sender, String key) {
var kind = sender instanceof org.bukkit.command.ConsoleCommandSender ? "console"
: sender instanceof org.bukkit.command.BlockCommandSender ? "command_block" : "system";
boolean initial = !key.equals("player-aura-particles");
var action = new Action(UUID.randomUUID(), clock.instant(), null, key, initial, initial, null, kind);
emit("action.observed", action, "observed", null, null, true, false);
emit("action.resolved", action, "refused", "player_required", null, true, false);
}
/** Written with the preference value in the same acknowledged file revision. */
public PlayerFeedbackPreferences.EventIntent intent(Action action) {
var fields = new LinkedHashMap<String,String>();
fields.put("time", action.time().toString());
fields.put("account", action.context().playerId().toString());
fields.put("account_name", action.context().playerName());
fields.put("key", action.key());
fields.put("before", Boolean.toString(action.before()));
fields.put("saved_before", Boolean.toString(action.savedBefore()));
fields.put("requested", Boolean.toString(action.requested()));
fields.put("all_enabled", Boolean.toString(preferences.allParticlesEnabled(action.context().playerId())));
if (action.context().characterId() != null) {
fields.put("character", action.context().characterId().toString());
fields.put("character_name", action.context().characterName());
fields.put("session", action.context().sessionId().toString());
}
var location = action.context().location();
if (location != null) {
fields.put("world", location.worldId().toString());
fields.put("world_name", location.worldName());
fields.put("x", Double.toString(location.x()));
fields.put("y", Double.toString(location.y()));
fields.put("z", Double.toString(location.z()));
fields.put("location_time", location.capturedAt().toString());
}
return new PlayerFeedbackPreferences.EventIntent(action.id(), fields);
}
public void replay(PlayerFeedbackPreferences.EventIntent intent) {
try {
var row = intent.captured();
UUID account = UUID.fromString(row.get("account"));
if (!"true".equals(row.get("published"))
|| Boolean.parseBoolean(row.getOrDefault("saved_before", row.get("before")))
== Boolean.parseBoolean(row.get("requested"))) return;
UUID character = row.containsKey("character") ? UUID.fromString(row.get("character")) : null;
EventInput.Location location = row.containsKey("world")
? new EventInput.Location(UUID.fromString(row.get("world")), row.get("world_name"),
Double.parseDouble(row.get("x")), Double.parseDouble(row.get("y")), Double.parseDouble(row.get("z")),
Instant.parse(row.get("location_time")), "exact") : null;
var context = new EventInput.Context(account, row.get("account_name"), character,
row.get("character_name"), character == null ? null : UUID.fromString(row.get("session")), location);
var action = new Action(intent.actionId(), Instant.parse(row.get("time")), context, row.get("key"),
Boolean.parseBoolean(row.get("before")), Boolean.parseBoolean(
row.getOrDefault("saved_before", row.get("before"))),
Boolean.parseBoolean(row.get("requested")), "player");
boolean after = Boolean.parseBoolean(row.get("requested"));
boolean effective = row.get("key").equals("player-aura-particles")
? after && Boolean.parseBoolean(row.get("all_enabled")) : after;
emit("preference.changed", action, "accepted", "acknowledged", intent.sequence(), effective, true);
} catch (RuntimeException ignored) { /* Invalid pending evidence cannot authorize a preference change. */ }
}
public void read(Action action) {
emit("action.resolved", action, "accepted", "read", null, action.before(), false);
emit("preference.read", action, "accepted", "acknowledged", null, action.before(), false);
}
public void saved(Action action, PlayerFeedbackPreferences.Update update, boolean published, boolean effective) {
if (!published) {
if (update.intent() != null) preferences.acknowledgeEvent(action.id());
emit("action.resolved", action, "refused", "superseded", update.sequence(), effective, false);
return;
}
if (update.intent() == null || action.savedBefore() == action.requested()) {
if (update.intent() != null) preferences.acknowledgeEvent(action.id());
emit("action.resolved", action, "refused", "unchanged", update.sequence(), effective, false);
return;
}
try {
preferences.markPublished(action.id()).whenComplete((ignored, failure) -> {
if (failure != null) try { failures.accept(failure); } catch (RuntimeException ignoredFailure) { }
});
} catch (RuntimeException failure) {
try { failures.accept(failure); } catch (RuntimeException ignoredFailure) { }
}
emit("action.resolved", action, "accepted", "acknowledged", update.sequence(), effective, false);
emit("preference.changed", action, "accepted", "acknowledged", update.sequence(), effective, true);
}
public void failed(Action action, String reason) {
emit("action.resolved", action, "failed", reason, null, action.before(), false);
}
public void refused(Action action, String reason) {
emit("action.resolved", action, "refused", reason, null, action.before(), false);
}
private void emit(String type, Action action, String outcome, String reason, Long preferenceSequence,
boolean effective, boolean acknowledgedIntent) {
if (action == null) return;
var data = new LinkedHashMap<String, Object>();
data.put("action_kind", "preference_" + (action.requested() == null ? "read" : "change"));
data.put("entry_point", action.key().equals("combat_feedback") ? "/fstick feedback"
: "/fstick particles " + (action.key().equals("player-aura-particles") ? "players" : "all"));
data.put("preference_key", action.key());
if (action.context() == null) { data.put("actor_kind", action.actorKind()); data.put("location_status", "not_applicable"); }
data.put("scope", "account");
data.put("default_value", Boolean.toString(!action.key().equals("player-aura-particles")));
data.put("before", Boolean.toString(action.before()));
data.put("saved_before", Boolean.toString(action.savedBefore()));
data.put("effective_before", Boolean.toString(action.before()));
if (action.requested() != null) data.put("requested", Boolean.toString(action.requested()));
if (action.context() != null) {
boolean latestSaved = type.equals("preference.changed") ? action.requested()
: preferenceSequence == null ? action.savedBefore()
: action.key().equals("player-aura-particles")
? preferences.savedPlayerAurasEnabled(action.context().playerId())
: action.key().equals("combat_feedback")
? preferences.savedEnabled(action.context().playerId())
: preferences.savedAllParticlesEnabled(action.context().playerId());
data.put("saved_value", Boolean.toString(latestSaved));
data.put("effective_value", Boolean.toString(effective));
data.put("effective_after", Boolean.toString(effective));
}
if (preferenceSequence != null) data.put("preference_sequence", Long.toString(preferenceSequence));
data.put("outcome", outcome);
if (reason != null) data.put(type.startsWith("preference.") ? "acknowledgment" : "reason", reason);
if (type.equals("preference.changed")) {
data.put("after", Boolean.toString(action.requested()));
data.put("preference_revision", Long.toString(preferenceSequence));
}
try {
var input = new EventInput(UUID.nameUUIDFromBytes(("fantasystick:preference:"+action.id()+":"+type)
.getBytes(StandardCharsets.UTF_8)), action.id(), type.equals("preference.changed") ? action.time()
: clock.instant(), type, action.context(), Map.copyOf(data));
var result = sink.apply(input);
if (acknowledgedIntent) result.whenComplete((delivery, failure) -> {
if (failure == null && delivery != LocalEventLog.Delivery.REJECTED)
preferences.acknowledgeEvent(action.id());
});
} catch (RuntimeException ignored) { /* Logging cannot affect the command or persistent state. */ }
}
}
@@ -42,29 +42,81 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
private AccountPreference { Objects.requireNonNull(account); Objects.requireNonNull(preference); }
}
public record Update(UUID account, Preference preference, boolean enabled, long sequence) {
public record EventIntent(UUID actionId, Map<String, String> captured, long sequence) {
public EventIntent { Objects.requireNonNull(actionId); captured = Map.copyOf(captured); }
public EventIntent(UUID actionId, Map<String, String> captured) { this(actionId, captured, 0); }
}
public record Update(UUID account, Preference preference, boolean enabled, long sequence, EventIntent intent) {
public Update { Objects.requireNonNull(account); Objects.requireNonNull(preference); }
public Update(UUID account, Preference preference, boolean enabled, long sequence) {
this(account, preference, enabled, sequence, null);
}
}
private final Path directory;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-player-feedback-preferences"));
private final Map<AccountPreference, Boolean> effective = new java.util.HashMap<>();
private final Map<AccountPreference, Boolean> saved = new java.util.HashMap<>();
private final Map<AccountPreference, Long> published = new java.util.HashMap<>();
private long persistedSequence;
private final Map<UUID, EventIntent> pendingEvents = new LinkedHashMap<>();
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public PlayerFeedbackPreferences(Path directory) throws IOException {
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
effective.putAll(loadEnabled());
var loaded = loadEnabled();
effective.putAll(loaded);
saved.putAll(loaded);
var root = readRoot();
var persisted = root.get("preference-sequence");
if (persisted != null) {
try { persistedSequence = Long.parseLong(String.valueOf(persisted)); }
catch (NumberFormatException invalid) { throw new IOException("Invalid preference sequence", invalid); }
if (persistedSequence < 0) throw new IOException("Invalid preference sequence");
}
var pending = stringMap(root.get("pending-preference-events"), "pending preference events");
for (var entry : pending.entrySet()) {
UUID id;
try { id = UUID.fromString(entry.getKey()); }
catch (IllegalArgumentException invalid) { throw new IOException("Invalid pending preference event identity", invalid); }
var row = stringMap(entry.getValue(), "pending preference event");
var captured = new LinkedHashMap<String, String>();
for (var field : row.entrySet()) {
if (!(field.getValue() instanceof String text)) throw new IOException("Invalid pending preference field");
captured.put(field.getKey(), text);
}
long sequence;
try { sequence = Long.parseLong(captured.remove("preference_sequence")); }
catch (RuntimeException invalid) { throw new IOException("Invalid pending preference sequence", invalid); }
if (sequence <= 0) throw new IOException("Invalid pending preference sequence");
pendingEvents.put(id, new EventIntent(id, captured, sequence));
persistedSequence = Math.max(persistedSequence, sequence);
}
}
/** Existing routine combat/effect feedback preference. */
public synchronized boolean enabled(UUID account) { return value(account, Preference.COMBAT_FEEDBACK); }
public synchronized boolean savedEnabled(UUID account) {
Objects.requireNonNull(account);
return saved.getOrDefault(new AccountPreference(account, Preference.COMBAT_FEEDBACK),
Preference.COMBAT_FEEDBACK.defaultValue);
}
public synchronized boolean playerAurasEnabled(UUID account) { return value(account, Preference.PLAYER_AURAS); }
public synchronized boolean allParticlesEnabled(UUID account) { return value(account, Preference.ALL_PARTICLES); }
public synchronized boolean savedPlayerAurasEnabled(UUID account) {
Objects.requireNonNull(account);
return saved.getOrDefault(new AccountPreference(account, Preference.PLAYER_AURAS),
Preference.PLAYER_AURAS.defaultValue);
}
public synchronized boolean savedAllParticlesEnabled(UUID account) {
Objects.requireNonNull(account);
return saved.getOrDefault(new AccountPreference(account, Preference.ALL_PARTICLES),
Preference.ALL_PARTICLES.defaultValue);
}
public synchronized boolean auraParticlesEnabled(UUID account) {
return value(account, Preference.ALL_PARTICLES) && value(account, Preference.PLAYER_AURAS);
}
@@ -78,16 +130,94 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
return set(account, Preference.COMBAT_FEEDBACK, enabled);
}
public CompletionStage<Update> set(UUID account, boolean enabled, EventIntent intent) {
return set(account, Preference.COMBAT_FEEDBACK, enabled, intent);
}
public CompletionStage<Update> setPlayerAuras(UUID account, boolean enabled) {
return set(account, Preference.PLAYER_AURAS, enabled);
}
public CompletionStage<Update> setPlayerAuras(UUID account, boolean enabled, EventIntent intent) {
return set(account, Preference.PLAYER_AURAS, enabled, intent);
}
/** Shared persisted precedence for the separately delivered all-particles command. */
public CompletionStage<Update> setAllParticles(UUID account, boolean enabled) {
return set(account, Preference.ALL_PARTICLES, enabled);
}
public CompletionStage<Update> setAllParticles(UUID account, boolean enabled, EventIntent intent) {
return set(account, Preference.ALL_PARTICLES, enabled, intent);
}
public synchronized java.util.List<EventIntent> pendingEvents() { return java.util.List.copyOf(pendingEvents.values()); }
/** Persist native publication proof independently of the event writer before a recovered success is claimed. */
public CompletionStage<Void> markPublished(UUID action) {
Objects.requireNonNull(action);
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Player preferences closed"));
pending++;
}
try {
return CompletableFuture.runAsync(() -> {
try (var channel = FileChannel.open(directory.resolve(".player-preferences.lock"),
StandardOpenOption.CREATE, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS);
var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Player preferences busy");
var root = readRoot();
var all = stringMap(root.get("pending-preference-events"), "pending preference events");
var found = stringMap(all.get(action.toString()), "pending preference event");
if (found.isEmpty()) return; // Already accepted by the event writer.
found.put("published", "true");
all.put(action.toString(), found);
root.put("pending-preference-events", all);
writeRoot(root);
synchronized (this) {
var previous = pendingEvents.get(action);
if (previous != null) {
var snapshot = new LinkedHashMap<>(previous.captured());
snapshot.put("published", "true");
pendingEvents.put(action, new EventIntent(action, snapshot, previous.sequence()));
}
}
} catch (IOException | OverlappingFileLockException failure) {
throw new java.util.concurrent.CompletionException(failure);
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished();
return CompletableFuture.failedStage(rejected);
}
}
public CompletionStage<Void> acknowledgeEvent(UUID action) {
Objects.requireNonNull(action);
try {
return CompletableFuture.runAsync(() -> {
try (var channel = FileChannel.open(directory.resolve(".player-preferences.lock"),
StandardOpenOption.CREATE, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS);
var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Player preferences busy");
Map<String,Object> root = readRoot();
var pending = stringMap(root.get("pending-preference-events"), "pending preference events");
if (pending.remove(action.toString()) == null) return;
root.put("pending-preference-events", pending);
writeRoot(root);
synchronized (this) { pendingEvents.remove(action); }
} catch (IOException | OverlappingFileLockException failure) {
throw new java.util.concurrent.CompletionException(failure);
}
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException closedWorker) { return CompletableFuture.failedStage(closedWorker); }
}
private CompletionStage<Update> set(UUID account, Preference preference, boolean enabled) {
return set(account, preference, enabled, null);
}
private CompletionStage<Update> set(UUID account, Preference preference, boolean enabled, EventIntent intent) {
Objects.requireNonNull(account); Objects.requireNonNull(preference);
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Player preference store is closed"));
@@ -96,8 +226,9 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
try {
return CompletableFuture.supplyAsync(() -> {
try {
save(account, preference, enabled);
return new Update(account, preference, enabled, ++persistedSequence);
long sequence = ++persistedSequence;
var accepted = save(account, preference, enabled, intent, sequence);
return new Update(account, preference, enabled, sequence, accepted);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(new IOException(
"Player preference save refused or failed; effective setting unchanged", failure));
@@ -142,7 +273,8 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
return result;
}
private void save(UUID account, Preference preference, boolean enabled) throws IOException {
private EventIntent save(UUID account, Preference preference, boolean enabled, EventIntent intent,
long sequence) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe data directory");
Path lockFile = directory.resolve(".player-preferences.lock");
@@ -155,9 +287,19 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
Map<String, Object> root = readRoot();
Map<String, Object> players = stringMap(root.get(PLAYERS), "player preferences");
Map<String, Object> values = stringMap(players.get(account.toString()), "player preference entry");
boolean before = values.get(preference.key) instanceof Boolean stored ? stored : preference.defaultValue;
values.put(preference.key, enabled);
players.put(account.toString(), values);
root.put(PLAYERS, players);
root.put("preference-sequence", Long.toString(sequence));
EventIntent accepted = intent != null && before != enabled ? new EventIntent(intent.actionId(), intent.captured(), sequence) : null;
if (accepted != null) {
var pending = stringMap(root.get("pending-preference-events"), "pending preference events");
var captured = new LinkedHashMap<String,Object>(accepted.captured());
captured.put("preference_sequence", Long.toString(sequence));
pending.put(accepted.actionId().toString(), captured);
root.put("pending-preference-events", pending);
}
byte[] bytes = yaml().dump(root).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Player preferences too large");
Path temporary = Files.createTempFile(directory, ".player-preferences-", ".tmp");
@@ -171,11 +313,32 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
StandardCopyOption.REPLACE_EXISTING);
DurableFiles.forceDirectory(directory);
} finally { Files.deleteIfExists(temporary); }
synchronized (this) {
saved.put(new AccountPreference(account, preference), enabled);
if (accepted != null) pendingEvents.put(accepted.actionId(), accepted);
}
return accepted;
} catch (OverlappingFileLockException busy) {
throw new IOException("Player preferences busy", busy);
}
}
private void writeRoot(Map<String,Object> root) throws IOException {
byte[] bytes = yaml().dump(root).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Player preferences too large");
Path temporary = Files.createTempFile(directory, ".player-preferences-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes);
while (buffer.hasRemaining()) output.write(buffer);
output.force(true);
}
Files.move(temporary, directory.resolve(FILE), StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING);
DurableFiles.forceDirectory(directory);
} finally { Files.deleteIfExists(temporary); }
}
private Map<String, Object> readRoot() throws IOException {
Path file = directory.resolve(FILE);
if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) return new LinkedHashMap<>();
@@ -15,8 +15,13 @@ public final class FantasyFeedbackCommand implements TabExecutor, AutoCloseable
private final PlayerFeedbackPreferences preferences;
private final Consumer<Runnable> nextTick;
private final Consumer<Throwable> failures;
private games.dmg.fantasystick.events.PreferenceEventBridge events;
private boolean closed;
public void attachEvents(games.dmg.fantasystick.events.PreferenceEventBridge bridge) {
this.events = Objects.requireNonNull(bridge);
}
public FantasyFeedbackCommand(PlayerFeedbackPreferences preferences, Consumer<Runnable> nextTick,
Consumer<Throwable> failures) {
this.preferences = Objects.requireNonNull(preferences);
@@ -26,32 +31,51 @@ public final class FantasyFeedbackCommand implements TabExecutor, AutoCloseable
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (!(sender instanceof Player player)) {
if (events != null) try { events.refuseNonPlayer(sender, "combat_feedback"); }
catch (RuntimeException ignored) { }
sender.sendMessage("Fantasy feedback is a player-account preference.");
return true;
}
if (args.length == 0) {
var action = observe(player, null);
player.sendMessage("Fantasy feedback is " + state(preferences.enabled(player.getUniqueId())) + ".");
if (action != null) events.read(action);
return true;
}
if (args.length != 1 || !(args[0].equals("on") || args[0].equals("off"))) return false;
if (args.length != 1 || !(args[0].equals("on") || args[0].equals("off"))) {
var action = observe(player, null);
if (action != null) events.refused(action, "invalid_arguments");
return false;
}
boolean enabled = args[0].equals("on");
var account = player.getUniqueId();
preferences.set(account, enabled).whenComplete((saved, failure) -> {
var action = observe(player, enabled);
var save = action == null ? preferences.set(account, enabled)
: preferences.set(account, enabled, events.intent(action));
save.whenComplete((saved, failure) -> {
if (failure != null) report(failure);
try {
nextTick.accept(() -> {
if (closed) return;
if (closed) {
if (action != null) events.failed(action, "closed");
return;
}
if (failure != null) {
if (action != null) events.failed(action, "save_failed");
if (player.isOnline()) player.sendMessage(
"Fantasy feedback preference could not be saved; it remains "
+ state(preferences.enabled(account)) + ".");
return;
}
boolean published = preferences.publish(saved);
if (action != null) events.saved(action, saved, published, preferences.enabled(account));
if (player.isOnline()) player.sendMessage("Fantasy feedback is "
+ (published ? "now " : "already ") + state(preferences.enabled(account)) + ".");
});
} catch (RuntimeException rejected) { report(rejected); }
} catch (RuntimeException rejected) {
if (action != null) events.failed(action, "schedule_failed");
report(rejected);
}
});
return true;
}
@@ -62,6 +86,13 @@ public final class FantasyFeedbackCommand implements TabExecutor, AutoCloseable
return List.of("off", "on").stream().filter(value -> value.startsWith(prefix)).toList();
}
private games.dmg.fantasystick.events.PreferenceEventBridge.Action observe(Player player, Boolean requested) {
var bridge = events;
if (bridge == null) return null;
try { return bridge.begin(player, "combat_feedback", preferences.enabled(player.getUniqueId()), requested); }
catch (RuntimeException ignored) { return null; } // Auditing cannot refuse a player preference.
}
private static String state(boolean enabled) { return enabled ? "on" : "off"; }
private void report(Throwable failure) { try { failures.accept(failure); } catch (RuntimeException ignored) { } }
@Override public void close() { closed = true; }
@@ -16,8 +16,13 @@ public final class FantasyParticlesCommand implements TabExecutor, AutoCloseable
private final PlayerFeedbackPreferences preferences;
private final Consumer<Runnable> nextTick;
private final Consumer<Throwable> failures;
private games.dmg.fantasystick.events.PreferenceEventBridge events;
private boolean closed;
public void attachEvents(games.dmg.fantasystick.events.PreferenceEventBridge events) {
this.events = Objects.requireNonNull(events);
}
public FantasyParticlesCommand(PlayerFeedbackPreferences preferences, Consumer<Runnable> nextTick,
Consumer<Throwable> failures) {
this.preferences = Objects.requireNonNull(preferences);
@@ -27,35 +32,60 @@ public final class FantasyParticlesCommand implements TabExecutor, AutoCloseable
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (!(sender instanceof Player player)) {
if (args.length > 0 && (args[0].equals("all") || args[0].equals("players")) && events != null) {
try { events.refuseNonPlayer(sender, key(args[0])); } catch (RuntimeException ignored) { }
}
sender.sendMessage("Fantasy particles are a player-account preference.");
return true;
}
if (args.length == 1 && (args[0].equals("players") || args[0].equals("all"))) {
var action = observe(player, args[0], null);
player.sendMessage(status(player.getUniqueId(), args[0]));
if (action != null) events.read(action);
return true;
}
if (args.length != 2 || !(args[0].equals("players") || args[0].equals("all"))
|| !(args[1].equals("on") || args[1].equals("off"))) return false;
|| !(args[1].equals("on") || args[1].equals("off"))) {
if (args.length > 0 && (args[0].equals("all") || args[0].equals("players"))) {
var action = observe(player, args[0], null);
if (action != null) events.refused(action, "invalid_arguments");
}
return false;
}
String route = args[0];
boolean enabled = args[1].equals("on");
UUID account = player.getUniqueId();
var action = observe(player, route, enabled);
var save = route.equals("players")
? preferences.setPlayerAuras(account, enabled) : preferences.setAllParticles(account, enabled);
? action == null ? preferences.setPlayerAuras(account, enabled)
: preferences.setPlayerAuras(account, enabled, events.intent(action))
: action == null ? preferences.setAllParticles(account, enabled)
: preferences.setAllParticles(account, enabled, events.intent(action));
save.whenComplete((saved, failure) -> {
if (failure != null) report(failure);
try {
nextTick.accept(() -> {
if (closed) return;
if (closed) {
if (action != null) events.failed(action, "closed");
return;
}
if (failure != null) {
if (action != null) events.failed(action, "save_failed");
if (player.isOnline()) player.sendMessage(
(route.equals("players") ? "Active-character aura" : "All-particles")
+ " preference could not be saved; " + status(account, route));
return;
}
boolean published = preferences.publish(saved);
if (action != null) events.saved(action, saved, published,
route.equals("players") ? preferences.auraParticlesEnabled(account)
: preferences.allParticlesEnabled(account));
if (player.isOnline()) player.sendMessage(acknowledgement(account, route, published));
});
} catch (RuntimeException rejected) { report(rejected); }
} catch (RuntimeException rejected) {
if (action != null) events.failed(action, "schedule_failed");
report(rejected);
}
});
return true;
}
@@ -68,6 +98,18 @@ public final class FantasyParticlesCommand implements TabExecutor, AutoCloseable
return List.of();
}
private games.dmg.fantasystick.events.PreferenceEventBridge.Action observe(
Player player, String route, Boolean requested) {
var bridge = events;
if (bridge == null) return null;
try { return bridge.begin(player, key(route), route.equals("players")
? preferences.auraParticlesEnabled(player.getUniqueId())
: preferences.allParticlesEnabled(player.getUniqueId()), requested); }
catch (RuntimeException ignored) { return null; } // Capture failure cannot refuse gameplay.
}
private static String key(String route) { return route.equals("players") ? "player-aura-particles" : "all-particles"; }
private String status(UUID account, String route) {
if (route.equals("all"))
return "All Fantasy Stick particles are " + state(preferences.allParticlesEnabled(account)) + ".";
@@ -38,6 +38,11 @@ class FantasyFeedbackCommandNativeTest {
assertEquals("Fantasy feedback is off.", player.messages.getLast());
assertTrue(player.messageComponents.isEmpty(), "preference/status notices keep their existing presentation");
}
var records = feedbackRecords();
assertEquals(2, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.read")
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("combat_feedback")).count());
assertEquals(1, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("combat_feedback")).count());
}
@Test void consoleMalformedAndExtraArgumentsCannotChangeAnAccountPreference() throws Exception {
@@ -60,6 +65,188 @@ class FantasyFeedbackCommandNativeTest {
}
}
@Test void registeredFeedbackReadAndChangeReachAccountOwnedJsonl() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
var root = fixture.playerCommand.getExecutor();
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick", new String[]{"feedback"}));
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick", new String[]{"feedback", "off"}));
fixture.tick();
assertEquals("Fantasy feedback is now off.", actor.messages.getLast());
fixture.plugin.setEnabled(false);
var records = feedbackRecords();
var read = records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.read"))
.findFirst().orElseThrow().getAsJsonObject("data");
assertEquals("combat_feedback", read.get("preference_key").getAsString());
assertEquals(actor.core.id.toString(), read.get("player_id").getAsString());
assertEquals("/fstick feedback", read.get("entry_point").getAsString());
assertEquals("true", read.get("default_value").getAsString());
assertEquals("true", read.get("effective_value").getAsString());
var change = records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed"))
.findFirst().orElseThrow().getAsJsonObject("data");
assertEquals(actor.core.id.toString(), change.get("player_id").getAsString());
assertEquals("true", change.get("before").getAsString());
assertEquals("false", change.get("after").getAsString());
assertTrue(change.get("preference_sequence").getAsLong() > 0);
assertEquals(actor.world.getUID().toString(), change.getAsJsonObject("location").get("world_id").getAsString());
}
}
@Test void feedbackRefusalsFailedAndSupersededWritesCannotClaimSuccess() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
var root = fixture.playerCommand.getExecutor();
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick", new String[]{"feedback", "off"}));
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick", new String[]{"feedback", "on"}));
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
preferences.set(java.util.UUID.randomUUID(), true).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS);
var callbacks = new java.util.ArrayList<Runnable>();
fixture.tasks.drainTo(callbacks);
assertEquals(2, callbacks.size());
java.util.Collections.reverse(callbacks); callbacks.forEach(Runnable::run);
assertFalse(root.onCommand(actor.player, fixture.playerCommand, "fstick", new String[]{"feedback", "OFF"}));
assertTrue(root.onCommand(org.mockito.Mockito.mock(org.bukkit.command.ConsoleCommandSender.class),
fixture.playerCommand, "fstick", new String[]{"feedback", "off"}));
assertTrue(root.onCommand(org.mockito.Mockito.mock(org.bukkit.command.BlockCommandSender.class),
fixture.playerCommand, "fstick", new String[]{"feedback", "off"}));
fixture.plugin.setEnabled(false);
var records = feedbackRecords().stream().filter(e -> e.getAsJsonObject("data").has("preference_key")
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("combat_feedback")).toList();
assertEquals(0, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
assertEquals(java.util.Set.of("superseded", "unchanged", "invalid_arguments", "player_required"),
records.stream().filter(e -> e.get("type").getAsString().endsWith("action.resolved"))
.map(e -> e.getAsJsonObject("data").get("reason").getAsString())
.collect(java.util.stream.Collectors.toSet()));
fixture.plugin.setEnabled(true);
fixture.plugin.setEnabled(false);
assertEquals(0, feedbackRecords().stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
}
}
@Test void failedFeedbackSaveIsReportedWithoutChangingEffectiveAccountState() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
java.nio.file.Files.createDirectory(data.resolve("player-preferences.yml"));
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
"fstick", new String[]{"feedback", "off"}));
fixture.tick();
assertTrue(actor.messages.getLast().contains("could not be saved"));
fixture.plugin.setEnabled(false);
assertEquals(0, feedbackRecords().stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
assertTrue(feedbackRecords().stream().anyMatch(e -> e.get("type").getAsString().endsWith("action.resolved")
&& e.getAsJsonObject("data").has("reason")
&& e.getAsJsonObject("data").get("reason").getAsString().equals("save_failed")));
}
}
@Test void closedFeedbackCommandCannotPublishUnacknowledgedPreferenceSuccess() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
"fstick", new String[]{"feedback", "off"}));
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
preferences.set(java.util.UUID.randomUUID(), true).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS);
((games.dmg.fantasystick.session.FantasyFeedbackCommand)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackCommand")).close();
fixture.tick();
assertTrue(preferences.enabled(actor.core.id), "Closed callback leaves the current effective value unchanged");
fixture.plugin.setEnabled(false);
fixture.plugin.setEnabled(true);
fixture.plugin.setEnabled(false);
var records = feedbackRecords();
assertEquals(0, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
assertTrue(records.stream().anyMatch(e -> e.get("type").getAsString().endsWith("action.resolved")
&& e.getAsJsonObject("data").has("reason")
&& e.getAsJsonObject("data").get("reason").getAsString().equals("closed")));
}
}
@Test void feedbackOutboxRecoversOriginalAcceptedChangeAfterSinkOutage() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
((games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog")).close();
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
"fstick", new String[]{"feedback", "off"}));
fixture.tick();
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
for (int i = 0; i < 80 && preferences.pendingEvents().stream()
.noneMatch(p -> "true".equals(p.captured().get("published"))); i++) Thread.sleep(10);
assertTrue(preferences.pendingEvents().stream()
.anyMatch(p -> "true".equals(p.captured().get("published"))));
fixture.plugin.setEnabled(false);
fixture.plugin.setEnabled(true);
for (int i = 0; i < 80 && feedbackRecords().stream()
.noneMatch(e -> e.get("type").getAsString().endsWith("preference.changed")); i++) Thread.sleep(10);
fixture.plugin.setEnabled(false);
var changes = feedbackRecords().stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("combat_feedback")).toList();
assertEquals(1, changes.stream().map(e -> e.get("id").getAsString()).distinct().count());
assertEquals("false", changes.getFirst().getAsJsonObject("data").get("after").getAsString());
}
}
@Test void feedbackPreferenceIsAccountOwnedAcrossCharacterTransfer() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var owner = new FantasyStickLifecycleNativeTest.Actor(data);
var recipient = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(owner.core.id, owner.player); fixture.players.put(recipient.core.id, recipient.player);
var root = fixture.playerCommand.getExecutor();
assertTrue(root.onCommand(owner.player, fixture.playerCommand, "fstick", new String[]{"feedback", "off"}));
fixture.tick();
fixture.activate(owner);
var sheet = owner.core.inventory.getItemInOffHand().clone();
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
sessions.exit(owner.player);
fixture.awaitCharacterAndReceipt(owner);
recipient.core.inventory.setItem(8, recipient.core.inventory.getItemInOffHand());
recipient.core.inventory.setItemInOffHand(sheet);
fixture.activate(recipient);
assertTrue(root.onCommand(owner.player, fixture.playerCommand, "fstick", new String[]{"feedback"}));
assertEquals("Fantasy feedback is off.", owner.messages.getLast());
assertTrue(root.onCommand(recipient.player, fixture.playerCommand, "fstick", new String[]{"feedback"}));
assertEquals("Fantasy feedback is on.", recipient.messages.getLast());
fixture.plugin.setEnabled(false);
var reads = feedbackRecords().stream().filter(e -> e.get("type").getAsString().endsWith("preference.read"))
.map(e -> e.getAsJsonObject("data"))
.filter(d -> d.get("preference_key").getAsString().equals("combat_feedback")).toList();
assertTrue(reads.stream().anyMatch(d -> d.get("player_id").getAsString().equals(owner.core.id.toString())
&& d.get("effective_value").getAsString().equals("false") && !d.has("character_id")));
assertTrue(reads.stream().anyMatch(d -> d.get("player_id").getAsString().equals(recipient.core.id.toString())
&& d.get("effective_value").getAsString().equals("true")
&& d.get("character_id").getAsString().equals(owner.core.record.id().toString())));
assertEquals(1, feedbackRecords().stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("combat_feedback")).count());
}
}
private List<com.google.gson.JsonObject> feedbackRecords() throws Exception {
var records = new java.util.ArrayList<com.google.gson.JsonObject>();
try (var files = java.nio.file.Files.walk(data.resolve("events"))) {
for (var path : files.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList())
for (var line : java.nio.file.Files.readAllLines(path))
if (!line.isBlank()) records.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
return records;
}
@Test void sourceAndAffectedAccountPreferencesIndependentlyFilterPerTickMessages() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
@@ -287,6 +287,503 @@ class FantasyParticlesNativeTest {
}
}
@Test void registeredAllParticlesReadAndSavedChangeProduceAccountEvents() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
"fstick", new String[]{"particles", "all"}));
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
"fstick", new String[]{"particles", "all", "off"}));
fixture.tick();
assertEquals("All Fantasy Stick particles are now off.", actor.messages.getLast());
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
for (int attempt = 0; attempt < 80 && !preferences.pendingEvents().isEmpty(); attempt++)
Thread.sleep(10);
assertTrue(preferences.pendingEvents().isEmpty(), "First acknowledged event was not drained");
fixture.plugin.setEnabled(false);
var records = new java.util.ArrayList<com.google.gson.JsonObject>();
try (var files = java.nio.file.Files.walk(data.resolve("events"))) {
for (var path : files.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList())
for (var line : java.nio.file.Files.readAllLines(path))
if (!line.isBlank()) records.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
var read = records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.read"))
.map(e -> e.getAsJsonObject("data")).filter(d -> d.get("preference_key").getAsString().equals("all-particles"))
.findFirst().orElseThrow();
assertEquals(actor.core.id.toString(), read.get("player_id").getAsString());
assertEquals("true", read.get("effective_value").getAsString());
var changed = records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed"))
.map(e -> e.getAsJsonObject("data")).filter(d -> d.get("preference_key").getAsString().equals("all-particles"))
.findFirst().orElseThrow();
assertEquals("true", changed.get("before").getAsString());
assertEquals("false", changed.get("after").getAsString());
assertTrue(changed.get("preference_sequence").getAsLong() > 0);
assertEquals(read.get("player_id"), changed.get("player_id"));
fixture.plugin.setEnabled(true);
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
"fstick", new String[]{"particles", "all", "on"}));
for (int attempt = 0; attempt < 80 && !actor.messages.getLast().contains("now on"); attempt++) {
var callback = fixture.tasks.poll(50, java.util.concurrent.TimeUnit.MILLISECONDS);
if (callback != null) callback.run();
}
assertEquals("All Fantasy Stick particles are now on.", actor.messages.getLast());
fixture.plugin.setEnabled(false);
var changes = preferenceRecords().stream()
.filter(e -> e.get("type").getAsString().endsWith("preference.changed"))
.map(e -> e.getAsJsonObject("data"))
.filter(d -> d.get("preference_key").getAsString().equals("all-particles"))
.toList();
var logical = changes.stream().collect(java.util.stream.Collectors.toMap(
d -> d.get("action_id").getAsString(), d -> d, (first, duplicate) -> first,
java.util.LinkedHashMap::new));
assertEquals(2, logical.size(), "At-least-once export may duplicate a physical line, not a logical change");
assertTrue(logical.values().stream().mapToLong(d -> d.get("preference_sequence").getAsLong()).max().orElseThrow()
> changed.get("preference_sequence").getAsLong(),
"The acknowledged preference revision must stay ordered across restart");
}
}
@Test void refusedAndSupersededAllParticleWritesNeverClaimAnAcceptedChange() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
var root = fixture.playerCommand.getExecutor();
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
new String[]{"particles", "all", "off"}));
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
new String[]{"particles", "all", "on"}));
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
preferences.setAllParticles(java.util.UUID.randomUUID(), true).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS);
var callbacks = new java.util.ArrayList<Runnable>();
fixture.tasks.drainTo(callbacks);
assertEquals(2, callbacks.size());
java.util.Collections.reverse(callbacks); callbacks.forEach(Runnable::run);
assertTrue(preferences.allParticlesEnabled(actor.core.id));
assertFalse(root.onCommand(actor.player, fixture.playerCommand, "fstick",
new String[]{"particles", "all", "invalid"}));
fixture.plugin.setEnabled(false);
var records = preferenceRecords();
assertEquals(0, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
var resolved = records.stream().filter(e -> e.get("type").getAsString().endsWith("action.resolved"))
.map(e -> e.getAsJsonObject("data"))
.filter(d -> d.has("preference_key") && d.get("preference_key").getAsString().equals("all-particles"))
.toList();
assertEquals(java.util.Set.of("superseded", "unchanged", "invalid_arguments"), resolved.stream()
.map(d -> d.get("reason").getAsString()).collect(java.util.stream.Collectors.toSet()));
assertEquals(3, resolved.size());
fixture.plugin.setEnabled(true);
fixture.plugin.setEnabled(false);
assertEquals(0, preferenceRecords().stream()
.filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count(),
"Superseded and unchanged saves cannot become successes during restart recovery");
}
}
@Test void failedAllParticleSaveRemainsObservableWithoutChangingGameplay() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
java.nio.file.Files.createDirectory(data.resolve("player-preferences.yml"));
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
"fstick", new String[]{"particles", "all", "off"}));
fixture.tick();
assertTrue(actor.messages.getLast().contains("could not be saved"));
fixture.plugin.setEnabled(false);
var records = preferenceRecords();
assertEquals(0, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
assertEquals(1, records.stream().filter(e -> e.get("type").getAsString().endsWith("action.resolved")
&& e.getAsJsonObject("data").has("reason")
&& e.getAsJsonObject("data").get("reason").getAsString().equals("save_failed")).count());
}
}
@Test void accountOptOutRetainsAuditAndCapturesCurrentCharacterWithoutBorrowingAnotherAccount() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
var other = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
fixture.players.put(other.core.id, other.player);
fixture.activate(actor);
var root = fixture.playerCommand.getExecutor();
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
new String[]{"particles", "all", "off"}));
fixture.tick();
assertTrue(root.onCommand(other.player, fixture.playerCommand, "fstick",
new String[]{"particles", "all"}));
assertEquals("All Fantasy Stick particles are on.", other.messages.getLast());
var sourceSheet = actor.core.inventory.getItemInOffHand().clone();
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
sessions.exit(actor.player);
fixture.awaitCharacterAndReceipt(actor);
other.core.inventory.setItem(8, other.core.inventory.getItemInOffHand());
other.core.inventory.setItemInOffHand(sourceSheet);
fixture.activate(other);
assertTrue(root.onCommand(other.player, fixture.playerCommand, "fstick",
new String[]{"particles", "all"}));
assertEquals("All Fantasy Stick particles are on.", other.messages.getLast());
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
new String[]{"particles", "all"}));
assertEquals("All Fantasy Stick particles are off.", actor.messages.getLast());
assertEquals(List.of("off"), fixture.playerCommand.getTabCompleter().onTabComplete(actor.player,
fixture.playerCommand, "fstick", new String[]{"particles", "all", "of"}));
fixture.plugin.setEnabled(false);
var records = preferenceRecords();
var changed = records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed"))
.map(e -> e.getAsJsonObject("data")).toList();
assertEquals(1, changed.size(), "Particle delivery and autocomplete do not create audit events");
assertEquals(actor.core.id.toString(), changed.getFirst().get("player_id").getAsString());
assertEquals(actor.core.record.id().toString(), changed.getFirst().get("character_id").getAsString());
assertEquals(actor.world.getUID().toString(), changed.getFirst().getAsJsonObject("location")
.get("world_id").getAsString());
var read = records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.read"))
.map(e -> e.getAsJsonObject("data"))
.filter(d -> d.get("player_id").getAsString().equals(other.core.id.toString()))
.findFirst().orElseThrow();
assertFalse(read.has("character_id"), "An ordinary viewer cannot borrow a playing character identity");
assertEquals("true", read.get("effective_value").getAsString());
assertTrue(records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.read"))
.map(e -> e.getAsJsonObject("data"))
.anyMatch(d -> d.get("player_id").getAsString().equals(other.core.id.toString())
&& d.has("character_id")
&& d.get("character_id").getAsString().equals(actor.core.record.id().toString())
&& d.get("effective_value").getAsString().equals("true")),
"A transferred sheet carries its character, not the previous account's particle preference");
}
}
@Test void nonPlayerAllParticleRequestsAreTruthfullyRefusedWithoutInventingPreferenceSuccess() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var console = org.mockito.Mockito.mock(org.bukkit.command.ConsoleCommandSender.class);
var block = org.mockito.Mockito.mock(org.bukkit.command.BlockCommandSender.class);
var root = fixture.playerCommand.getExecutor();
assertTrue(root.onCommand(console, fixture.playerCommand, "fstick",
new String[]{"particles", "all", "off"}));
assertTrue(root.onCommand(block, fixture.playerCommand, "fstick",
new String[]{"particles", "all", "off"}));
fixture.plugin.setEnabled(false);
var records = preferenceRecords();
assertEquals(0, records.stream().filter(e -> e.get("type").getAsString().contains(".preference.")).count());
var refused = records.stream().filter(e -> e.get("type").getAsString().endsWith("action.resolved"))
.map(e -> e.getAsJsonObject("data"))
.filter(d -> d.has("reason") && d.get("reason").getAsString().equals("player_required"))
.toList();
assertEquals(java.util.Set.of("console", "command_block"), refused.stream()
.map(d -> d.get("actor_kind").getAsString()).collect(java.util.stream.Collectors.toSet()));
assertEquals(2, refused.size());
assertTrue(refused.stream().allMatch(d -> d.get("location_status").getAsString().equals("not_applicable")));
}
}
@Test void acceptedPreferenceOutlivesUnavailableEventSinkWithoutLosingItsOriginalEvent() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
var log = (games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog");
log.close(); // Logging fails after gameplay has started; the saved preference remains authoritative.
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
"fstick", new String[]{"particles", "all", "off"}));
fixture.tick();
assertEquals("All Fantasy Stick particles are now off.", actor.messages.getLast());
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
for (int attempt = 0; attempt < 80 && preferences.pendingEvents().stream()
.noneMatch(intent -> "true".equals(intent.captured().get("published"))); attempt++)
Thread.sleep(10);
assertTrue(preferences.pendingEvents().stream()
.anyMatch(intent -> "true".equals(intent.captured().get("published"))),
"Committed publication proof must be durable before orderly restart");
fixture.plugin.setEnabled(false);
fixture.plugin.setEnabled(true);
for (int attempt = 0; attempt < 80 && preferenceRecords().stream()
.noneMatch(e -> e.get("type").getAsString().endsWith("preference.changed")); attempt++)
Thread.sleep(10);
fixture.plugin.setEnabled(false);
var events = preferenceRecords();
var changes = events.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed"))
.filter(e -> e.getAsJsonObject("data").get("preference_key").getAsString().equals("all-particles"))
.toList();
assertEquals(1, changes.stream().map(e -> e.get("id").getAsString()).distinct().count(),
"An acknowledged account change must retain one recoverable event identity");
assertEquals("true", changes.getFirst().getAsJsonObject("data").get("before").getAsString());
assertEquals("false", changes.getFirst().getAsJsonObject("data").get("after").getAsString());
}
}
@Test void readDuringDeferredPublicationDistinguishesSavedFromEffectivePreference() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
var root = fixture.playerCommand.getExecutor();
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
new String[]{"particles", "all", "off"}));
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
preferences.setAllParticles(java.util.UUID.randomUUID(), true).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS); // worker barrier; do not execute deferred main callback
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
new String[]{"particles", "all"}));
assertEquals("All Fantasy Stick particles are on.", actor.messages.getLast());
fixture.plugin.setEnabled(false);
var read = preferenceRecords().stream()
.filter(e -> e.get("type").getAsString().endsWith("preference.read"))
.map(e -> e.getAsJsonObject("data"))
.filter(d -> d.get("player_id").getAsString().equals(actor.core.id.toString()))
.findFirst().orElseThrow();
assertEquals("false", read.get("saved_value").getAsString());
assertEquals("true", read.get("effective_value").getAsString());
assertEquals(0, preferenceRecords().stream()
.filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count(),
"A queued native callback has not yet published a changed outcome");
}
}
@Test void twoAcknowledgedChangesSurviveEventSinkFailureWithTheirOriginalIdentities() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
((games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog")).close();
var root = fixture.playerCommand.getExecutor();
for (String value : List.of("off", "on")) {
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
new String[]{"particles", "all", value}));
for (int attempt = 0; attempt < 80 && (actor.messages.isEmpty()
|| !actor.messages.getLast().contains("now " + value)); attempt++) {
var callback = fixture.tasks.poll(50, java.util.concurrent.TimeUnit.MILLISECONDS);
if (callback != null) callback.run();
}
assertTrue(actor.messages.getLast().contains("now " + value));
}
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
for (int attempt = 0; attempt < 80 && preferences.pendingEvents().stream()
.filter(intent -> "true".equals(intent.captured().get("published"))).count() < 2; attempt++)
Thread.sleep(10);
assertEquals(2, preferences.pendingEvents().stream()
.filter(intent -> "true".equals(intent.captured().get("published"))).count());
fixture.plugin.setEnabled(false);
fixture.plugin.setEnabled(true);
for (int attempt = 0; attempt < 80 && preferenceRecords().stream()
.filter(e -> e.get("type").getAsString().endsWith("preference.changed"))
.map(e -> e.get("id").getAsString()).distinct().count() < 2; attempt++)
Thread.sleep(10);
fixture.plugin.setEnabled(false);
var changes = preferenceRecords().stream()
.filter(e -> e.get("type").getAsString().endsWith("preference.changed"))
.collect(java.util.stream.Collectors.toMap(e -> e.get("id").getAsString(), e -> e,
(first, duplicate) -> first, java.util.LinkedHashMap::new));
assertEquals(2, changes.size(), "Both acknowledged changes need distinct stable recovered outcomes");
assertEquals(java.util.Set.of("false", "true"), changes.values().stream()
.map(e -> e.getAsJsonObject("data").get("after").getAsString())
.collect(java.util.stream.Collectors.toSet()));
}
}
@Test void registeredAuraPreferenceRecordsDefaultOffAndSavedOptInUnderAllOff() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
var root = fixture.playerCommand.getExecutor();
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
new String[]{"particles", "players"}));
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
new String[]{"particles", "all", "off"}));
fixture.tick();
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
new String[]{"particles", "players", "on"}));
fixture.tick();
assertTrue(actor.messages.getLast().contains("effectively off"));
fixture.plugin.setEnabled(false);
var records = preferenceRecords();
var aura = records.stream().filter(e -> e.getAsJsonObject("data").has("preference_key")
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("player-aura-particles"))
.toList();
var read = aura.stream().filter(e -> e.get("type").getAsString().endsWith("preference.read"))
.findFirst().orElseThrow().getAsJsonObject("data");
assertEquals("false", read.get("default_value").getAsString());
assertEquals("false", read.get("saved_value").getAsString());
assertEquals("false", read.get("effective_value").getAsString());
assertEquals("/fstick particles players", read.get("entry_point").getAsString());
var change = aura.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed"))
.findFirst().orElseThrow().getAsJsonObject("data");
assertEquals(actor.core.id.toString(), change.get("player_id").getAsString());
assertEquals("false", change.get("saved_before").getAsString());
assertEquals("true", change.get("saved_value").getAsString());
assertEquals("false", change.get("effective_value").getAsString());
assertEquals("true", change.get("after").getAsString());
assertTrue(change.get("preference_sequence").getAsLong() > 0);
}
}
@Test void auraRefusalsAndSupersededWritesNeverClaimPreferenceSuccess() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
var root = fixture.playerCommand.getExecutor();
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
new String[]{"particles", "players", "on"}));
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
new String[]{"particles", "players", "off"}));
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
preferences.setPlayerAuras(java.util.UUID.randomUUID(), true).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS);
var callbacks = new java.util.ArrayList<Runnable>();
fixture.tasks.drainTo(callbacks);
assertEquals(2, callbacks.size());
java.util.Collections.reverse(callbacks); callbacks.forEach(Runnable::run);
assertFalse(root.onCommand(actor.player, fixture.playerCommand, "fstick",
new String[]{"particles", "players", "invalid"}));
assertTrue(root.onCommand(org.mockito.Mockito.mock(org.bukkit.command.ConsoleCommandSender.class),
fixture.playerCommand, "fstick", new String[]{"particles", "players", "on"}));
assertTrue(root.onCommand(org.mockito.Mockito.mock(org.bukkit.command.BlockCommandSender.class),
fixture.playerCommand, "fstick", new String[]{"particles", "players", "on"}));
fixture.plugin.setEnabled(false);
var records = preferenceRecords().stream().filter(e -> e.getAsJsonObject("data").has("preference_key")
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("player-aura-particles")).toList();
assertEquals(0, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
var reasons = records.stream().filter(e -> e.get("type").getAsString().endsWith("action.resolved"))
.map(e -> e.getAsJsonObject("data").get("reason").getAsString()).toList();
assertEquals(5, reasons.size());
assertEquals(java.util.Set.of("superseded", "unchanged", "invalid_arguments", "player_required"),
java.util.Set.copyOf(reasons));
fixture.plugin.setEnabled(true);
fixture.plugin.setEnabled(false);
assertEquals(0, preferenceRecords().stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
}
}
@Test void auraSaveFailureLeavesDefaultOffAndReportsFailure() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
java.nio.file.Files.createDirectory(data.resolve("player-preferences.yml"));
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
"fstick", new String[]{"particles", "players", "on"}));
fixture.tick();
assertTrue(actor.messages.getLast().contains("could not be saved"));
fixture.plugin.setEnabled(false);
var records = preferenceRecords().stream().filter(e -> e.getAsJsonObject("data").has("preference_key")
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("player-aura-particles")).toList();
assertEquals(0, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
assertEquals("save_failed", records.stream().filter(e -> e.get("type").getAsString().endsWith("action.resolved"))
.findFirst().orElseThrow().getAsJsonObject("data").get("reason").getAsString());
}
}
@Test void auraOptInSurvivesRestartAndCharacterTransferWithoutLeakingAccounts() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var owner = new FantasyStickLifecycleNativeTest.Actor(data);
var receiver = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(owner.core.id, owner.player);
fixture.players.put(receiver.core.id, receiver.player);
var root = fixture.playerCommand.getExecutor();
assertTrue(root.onCommand(owner.player, fixture.playerCommand, "fstick",
new String[]{"particles", "players", "on"}));
fixture.tick();
fixture.activate(owner);
var sheet = owner.core.inventory.getItemInOffHand().clone();
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
sessions.exit(owner.player);
fixture.awaitCharacterAndReceipt(owner);
receiver.core.inventory.setItem(8, receiver.core.inventory.getItemInOffHand());
receiver.core.inventory.setItemInOffHand(sheet);
fixture.activate(receiver);
assertTrue(root.onCommand(receiver.player, fixture.playerCommand, "fstick",
new String[]{"particles", "players"}));
assertEquals("Active-character aura particles are off.", receiver.messages.getLast());
assertTrue(root.onCommand(owner.player, fixture.playerCommand, "fstick",
new String[]{"particles", "players"}));
assertEquals("Active-character aura particles are on.", owner.messages.getLast());
fixture.plugin.setEnabled(false);
fixture.plugin.setEnabled(true);
assertTrue(root.onCommand(receiver.player, fixture.playerCommand, "fstick",
new String[]{"particles", "players"}));
assertEquals("Active-character aura particles are off.", receiver.messages.getLast());
assertTrue(root.onCommand(owner.player, fixture.playerCommand, "fstick",
new String[]{"particles", "players"}));
assertEquals("Active-character aura particles are on.", owner.messages.getLast());
assertEquals(List.of("off"), fixture.playerCommand.getTabCompleter().onTabComplete(owner.player,
fixture.playerCommand, "fstick", new String[]{"particles", "players", "of"}));
fixture.plugin.setEnabled(false);
var records = preferenceRecords().stream()
.filter(e -> e.getAsJsonObject("data").has("preference_key")
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("player-aura-particles"))
.toList();
assertEquals(1, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
var receiverRead = records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.read"))
.map(e -> e.getAsJsonObject("data"))
.filter(d -> d.get("player_id").getAsString().equals(receiver.core.id.toString()))
.findFirst().orElseThrow();
assertEquals("false", receiverRead.get("saved_value").getAsString());
assertEquals(owner.core.record.id().toString(), receiverRead.get("character_id").getAsString());
assertTrue(records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.read"))
.map(e -> e.getAsJsonObject("data"))
.anyMatch(d -> d.get("player_id").getAsString().equals(owner.core.id.toString())
&& d.get("saved_value").getAsString().equals("true")));
}
}
@Test void acceptedAuraOptInRecoversSameEventAfterSinkOutage() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
((games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog")).close();
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
"fstick", new String[]{"particles", "players", "on"}));
fixture.tick();
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
for (int i = 0; i < 80 && preferences.pendingEvents().stream()
.noneMatch(p -> "true".equals(p.captured().get("published"))); i++) Thread.sleep(10);
assertTrue(preferences.pendingEvents().stream()
.anyMatch(p -> "true".equals(p.captured().get("published"))));
fixture.plugin.setEnabled(false);
fixture.plugin.setEnabled(true);
for (int i = 0; i < 80 && preferenceRecords().stream()
.noneMatch(e -> e.get("type").getAsString().endsWith("preference.changed")); i++) Thread.sleep(10);
fixture.plugin.setEnabled(false);
var changes = preferenceRecords().stream()
.filter(e -> e.get("type").getAsString().endsWith("preference.changed")
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("player-aura-particles"))
.toList();
assertEquals(1, changes.stream().map(e -> e.get("id").getAsString()).distinct().count());
assertEquals("false", changes.getFirst().getAsJsonObject("data").get("saved_before").getAsString());
assertEquals("true", changes.getFirst().getAsJsonObject("data").get("saved_value").getAsString());
}
}
private java.util.List<com.google.gson.JsonObject> preferenceRecords() throws Exception {
var records = new java.util.ArrayList<com.google.gson.JsonObject>();
try (var files = java.nio.file.Files.walk(data.resolve("events"))) {
for (var path : files.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList())
for (var line : java.nio.file.Files.readAllLines(path))
if (!line.isBlank()) records.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
return records;
}
private static void optIn(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
fixture.particlesCommand.execute(actor.player, "fantasyparticles", new String[]{"players", "on"});
@@ -105,6 +105,30 @@ class MendingLightNativeTest {
assertEquals("10", healed.getAsJsonObject("data").get("health_after").getAsString());
}
@Test void accountFeedbackOptOutCannotHideAnAcceptedNativeHealEvent() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var caster = new FantasyStickLifecycleNativeTest.Actor(data);
var ally = new FantasyStickLifecycleNativeTest.Actor(data);
prepareCaster(data, caster, true); setHealth(data, ally, BigDecimal.valueOf(4));
fixture.players.put(caster.core.id, caster.player); fixture.players.put(ally.core.id, ally.player);
fixture.activate(caster); fixture.activate(ally); party(fixture, caster, ally); aim(caster, ally, 8, true);
assertTrue(fixture.playerCommand.getExecutor().onCommand(caster.player, fixture.playerCommand,
"fstick", new String[]{"feedback", "off"}));
fixture.tick();
use(fixture, caster); drain(fixture, 3);
assertFalse(caster.messages.contains("Mending Light healed " + ally.core.record.name() + " for 6 health."));
assertTrue(ally.messages.contains("Mending Light healed " + ally.core.record.name() + " for 6 health."));
fixture.plugin.setEnabled(false);
}
var records = events(data);
assertEquals(1, records.stream().filter(e -> e.get("type").getAsString().endsWith("combat.healed")
&& e.getAsJsonObject("data").has("ability_id")
&& e.getAsJsonObject("data").get("ability_id").getAsString().equals("mending_light")).count());
assertEquals(1, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("combat_feedback")).count());
}
@Test void actualHealingCreatesOnePersistedSupportShareForAnAllyDirectContributor() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);