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