feat(experience): export acknowledged admin grants
Release / release (push) Failing after 17m43s

This commit is contained in:
dmg committed 2026-09-26 15:07:03 -04:00
1 parent 8f2501f1c0
commit 9425103745
5 files changed
+472 -18

No files matched your search

@@ -192,6 +192,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
private java.util.concurrent.CompletionStage<Void> partyCapacityAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.SheetResourcePackEventBridge sheetResourcePackEvents;
private java.util.concurrent.CompletionStage<Void> sheetResourcePackAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.AdminExperienceEventBridge adminExperienceEvents;
private java.util.concurrent.CompletionStage<Void> adminExperienceAuditDrain = 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;
@@ -694,10 +696,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
admin.attachWarBanner(warBannerSettings, warBanner);
admin.attachMeteor(meteorSettings, meteor);
admin.attachAdminRevival(adminRevival, adminReviveSettings);
admin.attachExperienceGrant(new games.dmg.fantasystick.session.AdminExperienceGrant(
var grantService = new games.dmg.fantasystick.session.AdminExperienceGrant(
characters, rules, sessions, nextTick, getServer()::getPlayer,
problem -> getLogger().log(Level.WARNING,
"Admin experience grant for character " + problem.character(), problem.cause()), particleDelivery));
"Admin experience grant for character " + problem.character(), problem.cause()), particleDelivery);
admin.attachExperienceGrant(grantService);
admin.attachSwitchDelay(switchDelaySettings);
admin.attachRoleplaySettings(roleplaySettings);
admin.attachPartySettings(partySettings);
@@ -828,6 +831,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
failure -> getLogger().log(Level.WARNING, "Sheet resource-pack event intent failed", failure));
admin.attachSheetResourcePackEvents(sheetResourcePackEvents);
sheetResourcePack.attachEvents(sheetResourcePackEvents);
adminExperienceEvents = new games.dmg.fantasystick.events.AdminExperienceEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
failure -> getLogger().log(Level.WARNING, "Admin experience event intent failed", failure));
grantService.attachEvents(adminExperienceEvents);
var identitySessions = sessions;
identityAuditEvents = new games.dmg.fantasystick.events.CharacterIdentityEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
@@ -855,7 +862,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
id -> presentation.snapshot().get(id),sessionAuditEvents,eventLog::emit,Clock.systemUTC());
getServer().getPluginManager().registerEvents(messageAuditEvents,this);
} catch (java.io.IOException failure) {
eventLog = null; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; reviveEvents = null; adminRevivalEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null; nutritionAuditEvents = null; switchAuditEvents = null; switchConfigEvents = null; roleplayAvailabilityEvents = null; partyCapacityEvents = null; sheetResourcePackEvents = null; identityAuditEvents = null; equipmentAuditEvents = null; sessionRecoveryAuditEvents = null; messageAuditEvents = null;
eventLog = null; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; reviveEvents = null; adminRevivalEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null; nutritionAuditEvents = null; switchAuditEvents = null; switchConfigEvents = null; roleplayAvailabilityEvents = null; partyCapacityEvents = null; sheetResourcePackEvents = null; adminExperienceEvents = 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);
@@ -956,6 +963,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (failure != null) getLogger().log(Level.WARNING, "Previous sheet pack event intent drain failed", failure);
if (replaySheetPackEvents != null) replaySheetPackEvents.replay();
});
var replayGrantEvents = adminExperienceEvents;
adminExperienceAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous admin experience intent drain failed", failure);
if (replayGrantEvents != null) replayGrantEvents.replay();
});
var replaySwitchConfigEvents = switchConfigEvents;
switchConfigAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous switch configuration intent drain failed", failure);
@@ -1030,6 +1042,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingRoleplayEvents = roleplayAvailabilityEvents; roleplayAvailabilityEvents = null;
var closingPartyCapacityEvents = partyCapacityEvents; partyCapacityEvents = null;
var closingSheetPackEvents = sheetResourcePackEvents; sheetResourcePackEvents = null;
var closingGrantEvents = adminExperienceEvents; adminExperienceEvents = null;
var closingIdentityAuditEvents = identityAuditEvents; identityAuditEvents = null;
var closingAdminRevivalAuditEvents = adminRevivalEvents; adminRevivalEvents = null;
var closingEquipmentAuditEvents = equipmentAuditEvents; equipmentAuditEvents = null;
@@ -1381,6 +1394,16 @@ public final class FantasyStickPlugin extends JavaPlugin {
return null;
}).thenCompose(ignored -> closingSheetPackEvents.drained());
});
if (closingGrantEvents != null) cleanup("admin experience event intent drain", () -> {
var storageDrained = closingCharacters == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null)
: closingCharacters.drained();
adminExperienceAuditDrain = storageDrained.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Character storage drain failed", failure);
closingGrantEvents.close();
return null;
}).thenCompose(ignored -> closingGrantEvents.drained());
});
if (closingSwitchConfigEvents != null) cleanup("switch configuration event intent drain", () -> {
var settingsDrained = closingSwitchDelaySettings == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null)
@@ -0,0 +1,105 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigInteger;
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 persisted exact-character grant is the only authorization for this event, not command feedback. */
public final class AdminExperienceEventBridge implements AutoCloseable {
public record Intent(UUID operation, String actorKind, EventInput.Context actor,
UUID targetPlayer, UUID targetSession, UUID targetCharacter,
String targetName, EventInput.Location targetLocation) { }
private final Clock clock;
private final FeatureEventOutbox outbox;
public AdminExperienceEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
Clock clock, Path data, Consumer<Throwable> failures) {
this.clock = Objects.requireNonNull(clock);
outbox = new FeatureEventOutbox(data, "admin-experience-event-intents",
java.util.Set.of("experience.granted"), Objects.requireNonNull(sink), failures);
}
public Intent capture(CommandSender sender, EquipmentSessions.Active target, CharacterRecord record,
Player targetPlayer) {
var player = sender instanceof Player value ? value : null;
var actorKind = player != null ? "player" : sender instanceof BlockCommandSender ? "command_block" : "console";
EventInput.Context actor = null;
if (player != null) {
try { actor = new EventInput.Context(player.getUniqueId(), player.getName(), null, null, null,
location(player)); } catch (RuntimeException unavailable) { /* Keep the policy operation; location is optional. */ }
}
return new Intent(UUID.randomUUID(), actorKind, actor, target.player(), target.session(), record.id(),
record.name(), targetPlayer == null ? null : location(targetPlayer));
}
public void granted(Intent intent, CharacterRecord before, CharacterRecord after, BigInteger requested,
int levelsGained, boolean feedbackDelivered) {
if (intent == null) return;
try {
var values = new LinkedHashMap<String, Object>();
values.put("operation_id", intent.operation().toString());
values.put("actor_kind", intent.actorKind());
values.put("target_player_id", intent.targetPlayer().toString());
values.put("target_session_id", intent.targetSession().toString());
values.put("target_character_id", intent.targetCharacter().toString());
values.put("target_character_name", intent.targetName());
if (intent.actor() == null || intent.actor().location() == null)
values.put("admin_location_status", intent.actor() == null ? "not_applicable" : "unavailable");
if (intent.targetLocation() != null) values.put("target_acceptance_location", locationData(intent.targetLocation()));
else values.put("target_location_status", "unavailable");
values.put("requested_xp", requested.toString());
values.put("applied_xp", after.experience().subtract(before.experience()).toString());
values.put("before_xp", before.experience().toString());
values.put("after_xp", after.experience().toString());
values.put("before_level", Integer.toString(before.level()));
values.put("after_level", Integer.toString(after.level()));
values.put("before_unspent_points", before.unspentPoints().toString());
values.put("after_unspent_points", after.unspentPoints().toString());
values.put("before_revision", before.revision().toString());
values.put("after_revision", after.revision().toString());
values.put("levels_gained", Integer.toString(levelsGained));
values.put("cap_clamped", after.experience().subtract(before.experience()).compareTo(requested) < 0);
values.put("same_session_feedback_delivered", feedbackDelivered);
values.put("outcome", "durably_applied");
var event = new EventInput(id(intent.operation()), intent.operation(), clock.instant(),
"experience.granted", intent.actor(), values);
outbox.accepted(event);
} catch (RuntimeException ignored) { /* Audit failure cannot replay, redirect or undo a grant. */ }
}
private EventInput.Location location(Player player) {
try {
Location value = player.getLocation();
if (value == null || value.getWorld() == null) return null;
var world = value.getWorld();
return new EventInput.Location(world.getUID(), world.getName(), value.getX(), value.getY(),
value.getZ(), clock.instant(), "exact");
} catch (RuntimeException unavailable) { return null; }
}
private static Map<String, Object> locationData(EventInput.Location place) {
return Map.of("world_id", place.worldId().toString(), "world_name", place.worldName(),
"x", place.x(), "y", place.y(), "z", place.z(), "captured_at", place.capturedAt().toString(),
"quality", place.quality());
}
private static UUID id(UUID operation) {
return UUID.nameUUIDFromBytes(("fantasystick:admin-xp:" + operation).getBytes(StandardCharsets.UTF_8));
}
public void replay() { outbox.replay(); }
public CompletionStage<Void> drained() { return outbox.drained(); }
@Override public void close() { outbox.close(); }
}
@@ -1,5 +1,6 @@
package games.dmg.fantasystick.session;
import games.dmg.fantasystick.events.AdminExperienceEventBridge;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.ProgressionRules;
@@ -11,6 +12,7 @@ import java.util.function.Function;
import org.bukkit.Color;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
import org.bukkit.command.CommandSender;
/** Acknowledged administrator XP mutation captured against one exact active character/session. */
public final class AdminExperienceGrant implements AutoCloseable {
@@ -27,6 +29,20 @@ public final class AdminExperienceGrant implements AutoCloseable {
private final Consumer<Failure> failures;
private final games.dmg.fantasystick.presentation.ParticleDelivery particles;
private volatile boolean closed;
private AdminExperienceEventBridge events;
private final java.util.concurrent.ConcurrentMap<UUID, Pending> pending = new java.util.concurrent.ConcurrentHashMap<>();
private static final class Pending {
final AdminExperienceEventBridge.Intent intent;
final CharacterRecord captured;
final BigInteger amount;
CharacterStorage.ExperienceAward award;
boolean resolved;
boolean emitted;
Pending(AdminExperienceEventBridge.Intent intent, CharacterRecord captured, BigInteger amount) {
this.intent = intent; this.captured = captured; this.amount = amount;
}
}
public AdminExperienceGrant(CharacterStorage characters, ProgressionRules progression, EquipmentSessions sessions,
Consumer<Runnable> nextTick, Function<UUID, Player> players, Consumer<Failure> failures) {
@@ -45,32 +61,59 @@ public final class AdminExperienceGrant implements AutoCloseable {
this.particles = Objects.requireNonNull(particles);
}
public void attachEvents(AdminExperienceEventBridge bridge) {
if (closed || events != null) throw new IllegalStateException("Experience grant events unavailable");
events = Objects.requireNonNull(bridge);
}
/** Returns false before persistence when the captured active identity is no longer authoritative. */
public boolean grant(EquipmentSessions.Active active, CharacterRecord captured, BigInteger amount,
Consumer<Result> completion) {
return grant(active, captured, amount, null, completion);
}
public boolean grant(EquipmentSessions.Active active, CharacterRecord captured, BigInteger amount,
CommandSender administrator, Consumer<Result> completion) {
Objects.requireNonNull(active); Objects.requireNonNull(captured);
Objects.requireNonNull(amount); Objects.requireNonNull(completion);
if (amount.signum() <= 0) throw new IllegalArgumentException("Experience grant must be positive");
if (closed || !sessions.current(active)
|| sessions.currentRecord(active).filter(captured::equals).isEmpty()) return false;
AdminExperienceEventBridge.Intent intent = null;
if (events != null && administrator != null) {
Player target = null;
try { target = players.apply(active.player()); }
catch (RuntimeException failure) { report(captured.id(), failure); }
try { intent = events.capture(administrator, active, captured, target); }
catch (RuntimeException failure) { report(captured.id(), failure); }
}
var operation = new Pending(intent, captured, amount);
if (intent != null) pending.put(intent.operation(), operation);
characters.awardExperience(captured.id(), captured.revision(), amount, progression)
.whenComplete((award, failure) -> schedule(active, captured, amount, completion, award, failure));
.whenComplete((award, failure) -> schedule(active, operation, completion, award, failure));
return true;
}
private void schedule(EquipmentSessions.Active active, CharacterRecord captured, BigInteger amount,
private void schedule(EquipmentSessions.Active active, Pending operation,
Consumer<Result> completion, CharacterStorage.ExperienceAward award, Throwable failure) {
if (failure != null) report(captured.id(), failure);
if (closed) return;
if (failure != null) report(operation.captured.id(), failure);
synchronized (operation) { operation.award = award; operation.resolved = true; }
if (closed) { publish(operation, false); return; }
try {
nextTick.accept(() -> complete(active, captured, amount, completion, award, failure));
} catch (RuntimeException rejected) { report(captured.id(), rejected); }
nextTick.accept(() -> complete(active, operation, completion, award, failure));
} catch (RuntimeException rejected) {
report(operation.captured.id(), rejected);
publish(operation, false);
}
}
private void complete(EquipmentSessions.Active active, CharacterRecord captured, BigInteger amount,
private void complete(EquipmentSessions.Active active, Pending operation,
Consumer<Result> completion, CharacterStorage.ExperienceAward award, Throwable failure) {
if (closed) return;
if (closed) { publish(operation, false); return; }
var captured = operation.captured;
var amount = operation.amount;
if (failure != null) {
publish(operation, false);
completion.accept(new Result(Status.FAILED, captured.name(), amount, BigInteger.ZERO,
captured.level(), 0));
return;
@@ -84,6 +127,7 @@ public final class AdminExperienceGrant implements AutoCloseable {
};
BigInteger applied = status == Status.APPLIED
? saved.experience().subtract(captured.experience()) : BigInteger.ZERO;
boolean feedbackDelivered = false;
if (status == Status.APPLIED && sessions.current(active)) {
boolean synchronizedSession = sessions.acknowledgeProgress(active, captured.revision(), saved);
if (!synchronizedSession && sessions.current(active)
@@ -91,17 +135,34 @@ public final class AdminExperienceGrant implements AutoCloseable {
report(captured.id(), new IllegalStateException(
"Saved administrator XP could not synchronize with its captured active session"));
}
if (synchronizedSession && sessions.current(active)) feedback(active, saved, applied, award.levels());
if (synchronizedSession && sessions.current(active))
feedbackDelivered = feedback(active, saved, applied, award.levels());
}
publish(operation, feedbackDelivered);
completion.accept(new Result(status, captured.name(), amount, applied, saved.level(), award.levels()));
}
private void feedback(EquipmentSessions.Active active, CharacterRecord saved, BigInteger applied, int levels) {
private void publish(Pending operation, boolean feedbackDelivered) {
synchronized (operation) {
if (operation.emitted) return;
var award = operation.award;
if (!operation.resolved) return; // A later worker completion will observe close and retain the intent.
operation.emitted = true;
if (award != null && award.status() == CharacterStorage.ExperienceAwardStatus.APPLIED
&& award.record().isPresent() && events != null) {
events.granted(operation.intent, operation.captured, award.record().orElseThrow(),
operation.amount, award.levels(), feedbackDelivered);
}
}
if (operation.intent != null) pending.remove(operation.intent.operation(), operation);
}
private boolean feedback(EquipmentSessions.Active active, CharacterRecord saved, BigInteger applied, int levels) {
Player player;
try { player = players.apply(active.player()); }
catch (RuntimeException failure) { report(saved.id(), failure); return; }
catch (RuntimeException failure) { report(saved.id(), failure); return false; }
if (player == null || !player.isOnline() || !sessions.current(active)
|| sessions.currentRecord(active).filter(saved::equals).isEmpty()) return;
|| sessions.currentRecord(active).filter(saved::equals).isEmpty()) return false;
try {
player.sendMessage("Received " + applied + " fantasy XP from an administrator.");
for (int gained = 1; gained <= levels; gained++) {
@@ -111,7 +172,8 @@ public final class AdminExperienceGrant implements AutoCloseable {
0.3, 0.4, 0.3, 0, new org.bukkit.Particle.DustOptions(Color.BLUE, 1));
player.playSound(player.getLocation(), Sound.ENTITY_PLAYER_LEVELUP, 1, 1);
}
} catch (RuntimeException failure) { report(saved.id(), failure); }
return true;
} catch (RuntimeException failure) { report(saved.id(), failure); return false; }
}
private void report(UUID character, Throwable failure) {
@@ -119,5 +181,8 @@ public final class AdminExperienceGrant implements AutoCloseable {
catch (RuntimeException ignored) { /* Diagnostics do not replay a grant. */ }
}
@Override public void close() { closed = true; }
@Override public void close() {
closed = true;
for (var operation : pending.values()) publish(operation, false);
}
}
@@ -822,7 +822,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
sender.sendMessage("Experience grant refused before persistence. No fantasy XP changed."); return;
}
var target = matches.getFirst();
boolean accepted = experienceGrant.grant(target.session(), target.record(), amount, result -> {
boolean accepted = experienceGrant.grant(target.session(), target.record(), amount, sender, result -> {
if (closed || !sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(switch (result.status()) {
@@ -0,0 +1,261 @@
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.CharacterStorage;
import java.math.BigInteger;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** The registered grant and authoritative saved revision, not command feedback, own this event. */
class AdminExperienceEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void exactUnicodeActiveCharacterGrantPersistsAndExportsOnlyAcknowledgedProgress() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var target = new FantasyStickLifecycleNativeTest.Actor(data);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
storage.renameCharacter(target.core.record.id(), "Mira 雪").get();
f.players.put(target.core.id, target.player); f.activate(target);
var before = storage.find(target.core.record.id()).get().orElseThrow();
var admin = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"experience", "give", "Mira", "雪", "250"});
storage.find(target.core.record.id()).get(); f.tick();
var after = storage.find(target.core.record.id()).get().orElseThrow();
assertEquals(before.experience().add(BigInteger.valueOf(250)), after.experience());
assertEquals(3, after.level());
for (int i = 0; i < 200 && grants().isEmpty(); i++) Thread.sleep(10);
var exported = grants(); assertEquals(1, exported.size());
var values = exported.getFirst().getAsJsonObject("data");
assertEquals(target.core.id.toString(), values.get("target_player_id").getAsString());
assertEquals(target.core.record.id().toString(), values.get("target_character_id").getAsString());
assertEquals(before.revision().toString(), values.get("before_revision").getAsString());
assertEquals(after.revision().toString(), values.get("after_revision").getAsString());
assertEquals(before.unspentPoints().toString(), values.get("before_unspent_points").getAsString());
assertEquals(after.unspentPoints().toString(), values.get("after_unspent_points").getAsString());
assertTrue(values.get("same_session_feedback_delivered").getAsBoolean());
assertTrue(values.has("target_acceptance_location"));
f.plugin.setEnabled(false);
}
var entries = grants(); assertEquals(1, entries.size(), "Only exported JSONL proves a grant event");
var data = entries.getFirst().getAsJsonObject("data");
assertEquals("Mira 雪", data.get("target_character_name").getAsString());
assertEquals("250", data.get("requested_xp").getAsString());
assertEquals("250", data.get("applied_xp").getAsString());
assertEquals("2", data.get("levels_gained").getAsString());
}
@Test void refusedInvalidAndAlreadyCappedRequestsCannotInventASecondGrant() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var target = new FantasyStickLifecycleNativeTest.Actor(data);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
storage.renameCharacter(target.core.record.id(), "Árvíztűrő Tükörfúrógép").get();
f.players.put(target.core.id, target.player); f.activate(target);
var admin = new EquipmentAdminCommandNativeTest.Sender(); admin.allowed = false;
var command = new String[]{"experience", "give", "Árvíztűrő", "Tükörfúrógép", "9"};
f.adminCommand.execute(admin.port, "fstickadmin", command);
admin.allowed = true;
for (var invalid : List.of("0", "-1", "1.5", "oops")) {
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"experience", "give", "Árvíztűrő", "Tükörfúrógép", invalid});
}
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"experience", "give", "árvíztűrő", "Tükörfúrógép", "9"});
assertTrue(grants().isEmpty());
var huge = "9".repeat(90);
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"experience", "give", "Árvíztűrő", "Tükörfúrógép", huge});
storage.find(target.core.record.id()).get(); f.tick();
for (int i = 0; i < 200 && grants().isEmpty(); i++) Thread.sleep(10);
var entries = grants(); assertEquals(1, entries.size());
var values = entries.getFirst().getAsJsonObject("data");
assertEquals(huge, values.get("requested_xp").getAsString());
assertTrue(values.get("cap_clamped").getAsBoolean());
assertTrue(new BigInteger(values.get("applied_xp").getAsString()).compareTo(new BigInteger(huge)) < 0);
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"experience", "give", "Árvíztűrő", "Tükörfúrógép", "20"});
storage.find(target.core.record.id()).get(); f.tick();
f.plugin.setEnabled(false);
}
assertEquals(1, grants().size(), "A capped no-op, denial or invalid amount is not a grant");
}
@Test void unavailableWriterReplaysOnlyOriginalSavedGrantWithoutRegrantingOnRestart() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var target = new FantasyStickLifecycleNativeTest.Actor(data);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
storage.renameCharacter(target.core.record.id(), "Replay Hero").get();
f.players.put(target.core.id, target.player); f.activate(target);
var log = (games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog");
log.close();
var admin = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"experience", "give", "Replay", "Hero", "50"});
storage.find(target.core.record.id()).get(); f.tick();
assertEquals(BigInteger.valueOf(50), storage.find(target.core.record.id()).get()
.orElseThrow().experience());
f.plugin.setEnabled(false);
assertTrue(grants().isEmpty(), "Pending intents are not exported evidence");
f.plugin.setEnabled(true);
for (int i = 0; i < 200 && grants().isEmpty(); i++) Thread.sleep(10);
assertEquals(BigInteger.valueOf(50), ((CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "characters")).find(target.core.record.id()).get().orElseThrow().experience());
f.plugin.setEnabled(false);
}
var entries = grants(); assertEquals(1, entries.size());
assertEquals("50", entries.getFirst().getAsJsonObject("data").get("applied_xp").getAsString());
}
@Test void physicalAdministratorIdentityAndAcceptanceLocationsAreCapturedBeforeDurableGrant() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var target = new FantasyStickLifecycleNativeTest.Actor(data);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
storage.renameCharacter(target.core.record.id(), "Scout 雪").get();
f.players.put(target.core.id, target.player); f.activate(target);
var admin = new FantasyStickLifecycleNativeTest.Actor(data);
doReturn(true).when(admin.player).hasPermission("fantasystick.admin");
doReturn("Administrator").when(admin.player).getName();
f.players.put(admin.core.id, admin.player);
f.adminCommand.execute(admin.player, "fstickadmin", new String[]{
"experience", "give", "Scout", "雪", "7"});
storage.find(target.core.record.id()).get(); f.tick();
for (int i = 0; i < 200 && grants().isEmpty(); i++) Thread.sleep(10);
var entries = grants(); assertEquals(1, entries.size());
var values = entries.getFirst().getAsJsonObject("data");
assertEquals(admin.core.id.toString(), values.get("player_id").getAsString());
assertEquals("Administrator", values.get("player_name").getAsString());
assertEquals(admin.core.location.getX(), values.getAsJsonObject("location").get("x").getAsDouble());
assertEquals(target.core.location.getX(), values.getAsJsonObject("target_acceptance_location")
.get("x").getAsDouble());
f.plugin.setEnabled(false);
}
}
@Test void closedLifetimeKeepsDurableGrantButCannotInventNativeFeedback() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var target = new FantasyStickLifecycleNativeTest.Actor(data);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
storage.renameCharacter(target.core.record.id(), "Shutdown Hero").get();
f.players.put(target.core.id, target.player); f.activate(target);
var admin = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"experience", "give", "Shutdown", "Hero", "25"});
storage.find(target.core.record.id()).get();
assertEquals(BigInteger.valueOf(25), storage.find(target.core.record.id()).get()
.orElseThrow().experience());
f.plugin.setEnabled(false);
f.plugin.setEnabled(true);
for (int i = 0; i < 200 && grants().isEmpty(); i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var entries = grants(); assertEquals(1, entries.size());
assertFalse(entries.getFirst().getAsJsonObject("data").get("same_session_feedback_delivered").getAsBoolean());
}
@Test void staleRevisionAndAuditStoreFailureCannotFabricateOrUndoAnExperienceGrant() throws Exception {
Files.writeString(data.resolve("admin-experience-event-intents"), "blocked");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var target = new FantasyStickLifecycleNativeTest.Actor(data);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
storage.renameCharacter(target.core.record.id(), "Old Hero").get();
f.players.put(target.core.id, target.player); f.activate(target);
storage.renameCharacter(target.core.record.id(), "New Hero").get();
var admin = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"experience", "give", "Old", "Hero", "5"});
storage.find(target.core.record.id()).get(); f.tick();
assertEquals(BigInteger.ZERO, storage.find(target.core.record.id()).get().orElseThrow().experience());
assertTrue(grants().isEmpty());
var fresh = new FantasyStickLifecycleNativeTest.Actor(data);
storage.renameCharacter(fresh.core.record.id(), "Fresh Hero").get();
f.players.put(fresh.core.id, fresh.player); f.activate(fresh);
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"experience", "give", "Fresh", "Hero", "5"});
storage.find(fresh.core.record.id()).get(); f.tick();
assertEquals(BigInteger.valueOf(5), storage.find(fresh.core.record.id()).get()
.orElseThrow().experience());
f.plugin.setEnabled(false);
}
assertTrue(grants().isEmpty(), "The rejected audit store cannot invent exported success");
}
@Test void failedTargetFeedbackCannotUndoDurableGrantOrClaimDeliveredSessionFeedback() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var target = new FantasyStickLifecycleNativeTest.Actor(data);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
storage.renameCharacter(target.core.record.id(), "Feedback Hero").get();
f.players.put(target.core.id, target.player); f.activate(target);
doThrow(new IllegalStateException("feedback failed")).when(target.player)
.sendMessage(org.mockito.ArgumentMatchers.anyString());
var admin = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"experience", "give", "Feedback", "Hero", "33"});
storage.find(target.core.record.id()).get(); f.tick();
assertEquals(BigInteger.valueOf(33), storage.find(target.core.record.id()).get()
.orElseThrow().experience());
for (int i = 0; i < 200 && grants().isEmpty(); i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var entries = grants(); assertEquals(1, entries.size());
assertFalse(entries.getFirst().getAsJsonObject("data").get("same_session_feedback_delivered").getAsBoolean());
}
@Test void ambiguousExactActiveNameCannotChooseAHolderOrPublishGrant() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var first = new FantasyStickLifecycleNativeTest.Actor(data);
var second = new FantasyStickLifecycleNativeTest.Actor(data);
storage.renameCharacter(first.core.record.id(), "Twin 雪").get();
storage.renameCharacter(second.core.record.id(), "Twin 雪").get();
f.players.put(first.core.id, first.player); f.players.put(second.core.id, second.player);
f.activate(first); f.activate(second);
var admin = new EquipmentAdminCommandNativeTest.Sender();
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"experience", "give", "Twin", "雪", "100"});
assertTrue(admin.messages.getLast().contains("ambiguous"));
assertEquals(BigInteger.ZERO, storage.find(first.core.record.id()).get().orElseThrow().experience());
assertEquals(BigInteger.ZERO, storage.find(second.core.record.id()).get().orElseThrow().experience());
f.plugin.setEnabled(false);
}
assertTrue(grants().isEmpty());
}
private List<JsonObject> grants() throws Exception {
var entries = new ArrayList<JsonObject>(); var root = data.resolve("events");
if (!Files.isDirectory(root)) return entries;
try (var days = Files.list(root)) {
for (var day : days.filter(p -> p.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}")
&& Files.isDirectory(p, java.nio.file.LinkOption.NOFOLLOW_LINKS)).toList()) {
try (var segments = Files.list(day)) {
for (var path : segments.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList()) {
var content = Files.readString(path); int end = content.lastIndexOf('\n');
if (end < 0) continue;
for (var line : content.substring(0, end).lines().toList()) if (!line.isBlank()) {
var event = JsonParser.parseString(line).getAsJsonObject();
if (event.get("type").getAsString().endsWith("experience.granted")) entries.add(event);
}
}
}
}
}
return entries;
}
}