Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40e41d6b7a |
No files matched your search
@@ -132,12 +132,13 @@ The genuine **Fantasy Storage Box** contains actual original items. Picking it u
|
||||
|
||||
Exit removes controls and restores original slots once, locating the genuine box by owner/session rather than original index. A new head uses the freed box slot. Native player phase markers and the persisted receipt must agree for recovery; ambiguous state is preserved/reported, never guessed into replacement gear. Shutdown is best-effort and nonblocking; accepted cleanup drains before its receipt worker closes. Equipment stays vanilla-readable without this plugin.
|
||||
|
||||
Optional `character-swap-cooldown-seconds` defaults to `900`; zero bypasses the player-bound restriction. Existing absolute deadlines survive configuration changes; only a new actual exit starts a new deadline, and repeated cleanup does not extend it. Settings are read on enable; there is no reload command or deployed configuration change.
|
||||
Optional `character-swap-cooldown-seconds` defaults to `900`; zero bypasses the player-bound restriction. Existing absolute deadlines survive configuration changes; only a new actual exit starts a new deadline, and repeated cleanup does not extend it. The switch-delay setting is read on enable; there is no general reload command or deployed configuration change.
|
||||
|
||||
Administration (permission `fantasystick.admin`, default operators):
|
||||
|
||||
- `/fantasystick inspect <player-uuid>` — asynchronous receipt/native-session inspection.
|
||||
- `/fantasystick recover <player-uuid>` — validated online equipment restoration only; no forced overwrite, progression grant or ambiguous replay.
|
||||
- `/fantasystick recover <player-uuid>` — validated online equipment/game-mode restoration only; no forced overwrite, progression grant or ambiguous replay.
|
||||
- `/fantasystick config adventure-mode [true|false]` — show or persist the policy for new sessions; no change to active sessions.
|
||||
|
||||
Tests use actual native items, inventories, serialization, Paper event dispatch and menu-close behavior, with explicit scheduler/player/world ports. Drag continuation ordering includes a model based on pinned bytecode. The packaged lifecycle fixture isolates the entrypoint but delegates helpers parent-first. Live clients, production PluginClassLoader execution and arbitrary-plugin/crash atomicity are not claimed verified. No deployment or existing-data migration is authorized by story completion.
|
||||
|
||||
@@ -151,7 +152,17 @@ Output resources belong to a player connection/session, not the sheet. Exit/defe
|
||||
|
||||
**Cross-plugin display compatibility is unsupported**, as explicitly requested. There is no Stealth/Leaf/Ignis bridge or nickname/concealment guarantee. Native/platform visibility and ordinary cancellations are covered; supplemental presentation-owned packets bypass ProtocolLib filters to avoid recursive projection. Names can therefore conflict with other plugins' presentation or packet-only concealment. This does not disable ordinary protection or inventory safeguards.
|
||||
|
||||
Tests exercise actual native codecs, renderer inputs and ProtocolLib dispatch with explicit transport/server/player ports. They do not prove live-client appearance or client delivery acknowledgments. Unknown transport outcomes are reported without speculative spawn replay; cleanup remains best-effort. Label reconciliation is tick-driven; live-client/network-load observation remains a follow-up. Optional Adventure mode is approved separately but not implemented in this source yet.
|
||||
Tests exercise actual native codecs, renderer inputs and ProtocolLib dispatch with explicit transport/server/player ports. They do not prove live-client appearance or client delivery acknowledgments. Unknown transport outcomes are reported without speculative spawn replay; cleanup remains best-effort. Label reconciliation is tick-driven; live-client/network-load observation remains a follow-up. Optional Adventure mode is described below; its verification does not complete the remaining fantasy health, nutrition or combat features.
|
||||
|
||||
## Optional Adventure mode
|
||||
|
||||
`adventure-mode: true` is the default. A new entry captures this policy; enabled entries save the holder's original game mode in the player/session receipt before changing it to Adventure. Disabled entries leave the mode untouched, as do holders already in Adventure. This state never transfers with the character.
|
||||
|
||||
The admin command saves on a worker, preserving unrelated configuration values, before publishing its effective value. Invalid values and failed writes leave that value unchanged. Already active sessions keep their captured policy. Accepted writes drain without blocking shutdown; immediate re-enable defers startup asynchronously until those writes finish, then reads the saved policy.
|
||||
|
||||
Exit, defeat and best-effort lifecycle/recovery cleanup restore only an owned Adventure change. Observed later admin changes relinquish ownership, including an observed change away and back. Native cancellation refuses entry without packing; a cancelled restoration retains its receipt for inspection/retry. Interrupted or mismatched player markers are not replay permission. Mode cleanup is independent of uncertain equipment cleanup.
|
||||
|
||||
A same-process retry can acknowledge gear already verified restored for that exact player while retrying a vetoed mode restoration; a serialized `RESTORED` marker alone cannot establish this after restart. Missing game-mode proof leaves the mode and receipt retained even when independently verified gear can be restored and synchronized. Player-data and YAML saves are not one transaction; unobserved out-of-band state edits are not detectable history. No forced player save, blanket Survival reset or deployment is introduced.
|
||||
|
||||
## CI and publication
|
||||
|
||||
|
||||
@@ -36,11 +36,30 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private CharacterPresentation presentation;
|
||||
private ProtocolPresentation protocolPresentation;
|
||||
private CharacterChatNames chatNames;
|
||||
private games.dmg.fantasystick.persistence.AdventureSettings adventureSettings;
|
||||
private games.dmg.fantasystick.session.AdventureModeLease gameModes;
|
||||
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
|
||||
|
||||
@Override public void onEnable() {
|
||||
try {
|
||||
Object generation = new Object();
|
||||
lifetime = generation;
|
||||
if (!configurationDrain.toCompletableFuture().isDone()) {
|
||||
configurationDrain.whenComplete((ignored, failure) -> {
|
||||
if (lifetime != generation) return;
|
||||
try {
|
||||
getServer().getScheduler().runTask(this, () -> {
|
||||
if (lifetime == generation && isEnabled()) onEnable();
|
||||
});
|
||||
} catch (RuntimeException rejected) {
|
||||
getLogger().log(Level.WARNING, "Configuration startup continuation unavailable", rejected);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
reloadConfig();
|
||||
var adventure = games.dmg.fantasystick.persistence.AdventureSettings.validate(getConfig().get("adventure-mode"));
|
||||
adventureSettings = new games.dmg.fantasystick.persistence.AdventureSettings(getDataFolder().toPath(), adventure);
|
||||
var switchDelay = switchDelay();
|
||||
var defaults = ProgressionRules.defaults();
|
||||
var rules = new ProgressionRules(new java.math.BigInteger(getConfig().getString("starting-points", "40")),
|
||||
@@ -80,12 +99,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
java.util.function.Consumer<EquipmentSessions.Failure> diagnostics = problem ->
|
||||
getLogger().log(Level.WARNING, "Equipment transition/recovery for player " + problem.player()
|
||||
+ "; session " + problem.session(), problem.cause());
|
||||
gameModes = new games.dmg.fantasystick.session.AdventureModeLease();
|
||||
sessions = new EquipmentSessions(characters, receipts, switchDelay, Clock.systemUTC(), nextTick,
|
||||
notice -> notifyPlayer(generation, notice), diagnostics, presentation);
|
||||
notice -> notifyPlayer(generation, notice), diagnostics, presentation, adventureSettings::enabled, gameModes);
|
||||
sessionEvents = new EquipmentSessionEvents(sessions, nextTick, diagnostics);
|
||||
admin = new EquipmentAdminCommand(receipts, sessions, getServer(), nextTick,
|
||||
problem -> getLogger().log(Level.WARNING, "Equipment admin " + problem.operation()
|
||||
+ " for player " + problem.player(), problem.cause()));
|
||||
+ " for player " + problem.player(), problem.cause()), adventureSettings);
|
||||
var command = java.util.Objects.requireNonNull(getCommand("fantasystick"), "Missing packaged fantasystick command");
|
||||
command.setExecutor(admin);
|
||||
command.setTabCompleter(admin);
|
||||
@@ -94,6 +114,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
getServer().getPluginManager().registerEvents(anvilNames, this);
|
||||
getServer().getPluginManager().registerEvents(sessionEvents, this);
|
||||
getServer().getPluginManager().registerEvents(chatNames, this);
|
||||
getServer().getPluginManager().registerEvents(gameModes, this);
|
||||
recipeRegistered = getServer().addRecipe(new CharacterSheets().recipe());
|
||||
if (!recipeRegistered) throw new IllegalStateException("Character-sheet recipe key is already registered");
|
||||
for (var player : getServer().getOnlinePlayers()) sessions.arrive(player);
|
||||
@@ -118,13 +139,20 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingSessions = sessions; sessions = null;
|
||||
var closingReceipts = receipts; receipts = null;
|
||||
var closingCharacters = characters; characters = null;
|
||||
var closingSettings = adventureSettings; adventureSettings = null;
|
||||
var closingModes = gameModes; gameModes = null;
|
||||
// Retire client resources independently of successful ordinary-equipment restoration.
|
||||
if (closingPresentation != null) cleanup("presentation", closingPresentation::close);
|
||||
if (closingProtocol != null) cleanup("protocol presentation", closingProtocol::close);
|
||||
if (closingAdmin != null) cleanup("admin command", closingAdmin::close);
|
||||
if (closingSettings != null) {
|
||||
configurationDrain = closingSettings.drained();
|
||||
cleanup("configuration", closingSettings::close);
|
||||
}
|
||||
if (closingEvents != null) cleanup("session events", closingEvents::close);
|
||||
if (closingCrafting != null) cleanup("craft attempts", closingCrafting::close);
|
||||
if (closingSessions != null) cleanup("equipment sessions", closingSessions::close);
|
||||
unregister(closingModes);
|
||||
unregister(closingChat);
|
||||
unregister(closingEvents);
|
||||
unregister(closingCrafting);
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Worker-owned config writes; publish a new-session policy only after durable file acknowledgment. */
|
||||
public final class AdventureSettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
private final Path directory;
|
||||
private final Replacement replacement;
|
||||
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-configuration-persistence"));
|
||||
private volatile boolean enabled;
|
||||
private boolean closed;
|
||||
private int pending;
|
||||
private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
|
||||
public AdventureSettings(Path directory, boolean initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
AdventureSettings(Path directory, boolean initial, Replacement replacement) {
|
||||
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
|
||||
this.replacement = Objects.requireNonNull(replacement);
|
||||
enabled = initial;
|
||||
}
|
||||
|
||||
public static boolean validate(Object configured) {
|
||||
if (configured == null) return true;
|
||||
if (configured instanceof Boolean value) return value;
|
||||
throw new IllegalArgumentException("adventure-mode must be true or false");
|
||||
}
|
||||
|
||||
public boolean enabled() { return enabled; }
|
||||
|
||||
public synchronized CompletionStage<Boolean> set(boolean value) {
|
||||
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
|
||||
pending++;
|
||||
try {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try { save(value); enabled = value; return value; }
|
||||
catch (IOException | RuntimeException failure) {
|
||||
// No parser contents, filesystem paths or configuration values in diagnostics.
|
||||
throw new java.util.concurrent.CompletionException(new IOException(
|
||||
"Adventure configuration save refused or failed; effective setting unchanged"));
|
||||
} finally { finished(); }
|
||||
}, worker).minimalCompletionStage();
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
finished();
|
||||
return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
|
||||
}
|
||||
}
|
||||
|
||||
private void save(boolean value) throws IOException {
|
||||
Files.createDirectories(directory);
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
|
||||
var file = directory.resolve("config.yml");
|
||||
var lockFile = directory.resolve(".config.lock");
|
||||
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS)
|
||||
&& !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe lock");
|
||||
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS);
|
||||
var lock = channel.tryLock()) {
|
||||
if (lock == null) throw new IOException("Configuration busy");
|
||||
var options = new LoaderOptions();
|
||||
options.setAllowDuplicateKeys(false);
|
||||
options.setMaxAliasesForCollections(20);
|
||||
options.setCodePointLimit(MAX_BYTES);
|
||||
var yaml = new Yaml(new SafeConstructor(options));
|
||||
Map<String, Object> values = new LinkedHashMap<>();
|
||||
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
|
||||
byte[] bytes;
|
||||
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
|
||||
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
|
||||
if (loaded != null) {
|
||||
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
|
||||
for (var entry : map.entrySet()) {
|
||||
if (!(entry.getKey() instanceof String key)) throw new IOException("Invalid configuration key");
|
||||
values.put(key, entry.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
values.put("adventure-mode", value);
|
||||
byte[] bytes = yaml.dump(values).getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
|
||||
Path temporary = Files.createTempFile(directory, ".config-", ".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);
|
||||
}
|
||||
replacement.move(temporary, file);
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
|
||||
}
|
||||
|
||||
private synchronized void finished() {
|
||||
pending--;
|
||||
if (closed && pending == 0) drained.complete(null);
|
||||
}
|
||||
|
||||
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
|
||||
|
||||
/** Drain accepted writes without waiting on the server thread. */
|
||||
@Override public synchronized void close() {
|
||||
closed = true;
|
||||
worker.shutdown();
|
||||
if (pending == 0) drained.complete(null);
|
||||
}
|
||||
}
|
||||
@@ -48,11 +48,15 @@ public final class EquipmentSessionStore implements AutoCloseable {
|
||||
private final ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-equipment-session-persistence"));
|
||||
|
||||
public record Receipt(UUID sessionId, UUID characterId, Instant preparedAt) {
|
||||
public record Receipt(UUID sessionId, UUID characterId, Instant preparedAt, String previousGameMode) {
|
||||
public Receipt(UUID sessionId, UUID characterId, Instant preparedAt) { this(sessionId, characterId, preparedAt, null); }
|
||||
public Receipt {
|
||||
Objects.requireNonNull(sessionId);
|
||||
Objects.requireNonNull(characterId);
|
||||
timestamp(preparedAt);
|
||||
if (previousGameMode != null && !Set.of("SURVIVAL", "CREATIVE", "ADVENTURE", "SPECTATOR").contains(previousGameMode)) {
|
||||
throw new IllegalArgumentException("Invalid previous game mode");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,16 +110,21 @@ public final class EquipmentSessionStore implements AutoCloseable {
|
||||
return submit(() -> withLock(() -> load(player)));
|
||||
}
|
||||
|
||||
/** A receipt acknowledgment is a prerequisite for packing, not evidence that packing happened. */
|
||||
public CompletionStage<State> prepare(UUID player, UUID session, UUID character) {
|
||||
return prepare(player, session, character, null);
|
||||
}
|
||||
|
||||
/** A receipt acknowledgment precedes mode/gear mutation; it is not proof either happened. */
|
||||
public CompletionStage<State> prepare(UUID player, UUID session, UUID character, String previousGameMode) {
|
||||
Objects.requireNonNull(player);
|
||||
var receipt = new Receipt(session, character, clock.instant());
|
||||
var receipt = new Receipt(session, character, clock.instant(), previousGameMode);
|
||||
return submit(() -> withLock(() -> {
|
||||
var found = load(player);
|
||||
if (found.isPresent()) {
|
||||
var old = found.orElseThrow();
|
||||
if (old.pending() != null) {
|
||||
if (session.equals(old.pending().sessionId()) && character.equals(old.pending().characterId())) {
|
||||
if (session.equals(old.pending().sessionId()) && character.equals(old.pending().characterId())
|
||||
&& Objects.equals(previousGameMode, old.pending().previousGameMode())) {
|
||||
return old;
|
||||
}
|
||||
throw new IOException("Conflicting pending equipment session");
|
||||
@@ -282,8 +291,12 @@ public final class EquipmentSessionStore implements AutoCloseable {
|
||||
root.put("revision", state.revision().toString());
|
||||
if (state.pending() != null) {
|
||||
var receipt = state.pending();
|
||||
root.put("pending", Map.of("session-id", receipt.sessionId().toString(),
|
||||
"character-id", receipt.characterId().toString(), "prepared-at", receipt.preparedAt().toString()));
|
||||
var pending = new LinkedHashMap<String, Object>();
|
||||
pending.put("session-id", receipt.sessionId().toString());
|
||||
pending.put("character-id", receipt.characterId().toString());
|
||||
pending.put("prepared-at", receipt.preparedAt().toString());
|
||||
if (receipt.previousGameMode() != null) pending.put("previous-game-mode", receipt.previousGameMode());
|
||||
root.put("pending", pending);
|
||||
}
|
||||
if (state.lastExit() != null) {
|
||||
var exit = state.lastExit();
|
||||
@@ -304,8 +317,13 @@ public final class EquipmentSessionStore implements AutoCloseable {
|
||||
Exit exit = null;
|
||||
if (root.containsKey("pending")) {
|
||||
var keys = Set.of("session-id", "character-id", "prepared-at");
|
||||
var row = mapping(root.get("pending"), keys, keys);
|
||||
pending = new Receipt(id(row.get("session-id")), id(row.get("character-id")), instant(row.get("prepared-at")));
|
||||
var row = mapping(root.get("pending"), keys, Set.of("session-id", "character-id", "prepared-at", "previous-game-mode"));
|
||||
String previousMode = null;
|
||||
if (row.containsKey("previous-game-mode")) {
|
||||
if (!(row.get("previous-game-mode") instanceof String value)) throw new IllegalArgumentException("Invalid previous mode");
|
||||
previousMode = value;
|
||||
}
|
||||
pending = new Receipt(id(row.get("session-id")), id(row.get("character-id")), instant(row.get("prepared-at")), previousMode);
|
||||
}
|
||||
if (root.containsKey("last-exit")) {
|
||||
var keys = Set.of("session-id", "character-id", "exited-at", "deadline");
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import java.util.UUID;
|
||||
import org.bukkit.GameMode;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
|
||||
/** Main-thread holder/session proof for optional Adventure mode; the receipt must already be saved. */
|
||||
public final class AdventureModeLease implements org.bukkit.event.Listener {
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void changed(org.bukkit.event.player.PlayerGameModeChangeEvent event) {
|
||||
if (event.isCancelled()) return;
|
||||
var player = event.getPlayer();
|
||||
var mark = read(player);
|
||||
// Events precede the write. A currently non-Adventure mode proves a previous
|
||||
// external change already happened, even when this new request returns to Adventure.
|
||||
// A lone external change is also detected directly by restore(). Never infer success
|
||||
// from this event's requested value, which a later listener may still cancel.
|
||||
if (mark != null && mark.phase() == Phase.APPLIED && player.getGameMode() != GameMode.ADVENTURE) {
|
||||
write(player, new Mark(mark.session(), mark.previous(), Phase.RELEASED));
|
||||
}
|
||||
}
|
||||
private static final NamespacedKey KEY = new NamespacedKey("fantasystick", "adventure_session");
|
||||
private enum Phase { APPLYING, APPLIED, RESTORING, RELEASED }
|
||||
private record Mark(UUID session, String previous, Phase phase) {
|
||||
boolean matches(UUID expected, String original) { return session.equals(expected) && previous.equals(original); }
|
||||
}
|
||||
|
||||
public boolean available(Player player) {
|
||||
var mark = read(player);
|
||||
return !player.getPersistentDataContainer().has(KEY) || mark != null && mark.phase() == Phase.RELEASED;
|
||||
}
|
||||
|
||||
public boolean enter(Player player, UUID session, String previous) {
|
||||
if (previous == null) return true;
|
||||
var existing = read(player);
|
||||
if (player.getPersistentDataContainer().has(KEY) && (existing == null || existing.phase() != Phase.RELEASED)) {
|
||||
return existing != null && existing.matches(session, previous) && existing.phase() == Phase.APPLIED
|
||||
&& player.getGameMode() == GameMode.ADVENTURE;
|
||||
}
|
||||
if (player.getGameMode() != GameMode.valueOf(previous)) return false;
|
||||
write(player, new Mark(session, previous, Phase.APPLYING));
|
||||
player.setGameMode(GameMode.ADVENTURE);
|
||||
if (player.getGameMode() != GameMode.ADVENTURE) {
|
||||
write(player, new Mark(session, previous, Phase.RELEASED));
|
||||
return false;
|
||||
}
|
||||
write(player, new Mark(session, previous, Phase.APPLIED));
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean restore(Player player, UUID session, String previous) {
|
||||
if (previous == null) return true;
|
||||
var mark = read(player);
|
||||
if (mark == null || !mark.matches(session, previous)) return false;
|
||||
if (mark.phase() == Phase.RELEASED) return true;
|
||||
if (mark.phase() != Phase.APPLIED) return false; // Interrupted setter is not restoration proof.
|
||||
if (player.getGameMode() != GameMode.ADVENTURE) {
|
||||
write(player, new Mark(session, previous, Phase.RELEASED));
|
||||
return true; // A later external/admin change is not ours to undo.
|
||||
}
|
||||
write(player, new Mark(session, previous, Phase.RESTORING));
|
||||
player.setGameMode(GameMode.valueOf(previous));
|
||||
if (player.getGameMode() == GameMode.ADVENTURE && !previous.equals("ADVENTURE")) {
|
||||
write(player, new Mark(session, previous, Phase.APPLIED));
|
||||
return false; // Native veto: unchanged and still owned, not successful restoration.
|
||||
}
|
||||
write(player, new Mark(session, previous, Phase.RELEASED));
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Mark read(Player player) {
|
||||
var data = player.getPersistentDataContainer();
|
||||
if (!data.has(KEY, PersistentDataType.STRING)) return null;
|
||||
try {
|
||||
String[] fields = data.get(KEY, PersistentDataType.STRING).split("\\|", -1);
|
||||
if (fields.length != 4 || !fields[0].equals("1")) return null;
|
||||
var session = UUID.fromString(fields[1]);
|
||||
if (!session.toString().equals(fields[1])) return null;
|
||||
GameMode.valueOf(fields[2]);
|
||||
return new Mark(session, fields[2], Phase.valueOf(fields[3]));
|
||||
} catch (IllegalArgumentException invalid) { return null; }
|
||||
}
|
||||
|
||||
private static void write(Player player, Mark mark) {
|
||||
player.getPersistentDataContainer().set(KEY, PersistentDataType.STRING,
|
||||
"1|" + mark.session() + "|" + mark.previous() + "|" + mark.phase());
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<Failure> diagnostics;
|
||||
private volatile boolean closed;
|
||||
private games.dmg.fantasystick.persistence.AdventureSettings settings;
|
||||
|
||||
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics) {
|
||||
@@ -30,10 +31,21 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
this.diagnostics = Objects.requireNonNull(diagnostics);
|
||||
}
|
||||
|
||||
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics,
|
||||
games.dmg.fantasystick.persistence.AdventureSettings settings) {
|
||||
this(receipts, sessions, server, nextTick, diagnostics);
|
||||
this.settings = Objects.requireNonNull(settings);
|
||||
}
|
||||
|
||||
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||
if (closed) return true;
|
||||
if (!sender.hasPermission(PERMISSION)) {
|
||||
sender.sendMessage("You need fantasystick.admin to inspect or recover equipment sessions.");
|
||||
sender.sendMessage("You need fantasystick.admin to administer Fantasy Stick.");
|
||||
return true;
|
||||
}
|
||||
if (args.length > 0 && args[0].equals("config")) {
|
||||
configuration(sender, args);
|
||||
return true;
|
||||
}
|
||||
UUID owner;
|
||||
@@ -96,6 +108,32 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
return true;
|
||||
}
|
||||
|
||||
private void configuration(CommandSender sender, String[] args) {
|
||||
if (args.length < 2 || args.length > 3 || !args[1].equals("adventure-mode")
|
||||
|| args.length == 3 && !args[2].equals("true") && !args[2].equals("false")) {
|
||||
sender.sendMessage("Usage: /fantasystick config adventure-mode [true|false]");
|
||||
return;
|
||||
}
|
||||
if (settings == null) { sender.sendMessage("Adventure configuration is unavailable."); return; }
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage("adventure-mode=" + settings.enabled() + " (applies to new sessions only)");
|
||||
return;
|
||||
}
|
||||
settings.set(Boolean.parseBoolean(args[2])).whenComplete((saved, failure) -> {
|
||||
if (failure != null) report(null, "config-adventure-mode", failure);
|
||||
if (closed) return;
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (closed || !sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
? "Saved adventure-mode=" + saved + " for new sessions only; active sessions are unchanged."
|
||||
: "Adventure configuration save failed; the effective setting is unchanged.");
|
||||
});
|
||||
} catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void report(UUID owner, String operation, Throwable cause) {
|
||||
try { diagnostics.accept(new Failure(owner, operation, cause)); }
|
||||
catch (RuntimeException ignored) { /* Diagnostics cannot authorize recovery. */ }
|
||||
|
||||
@@ -54,6 +54,8 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
private Consumer<UUID> cursorCustody = ignored -> { };
|
||||
private boolean cursorCustodyAttached;
|
||||
private boolean closed;
|
||||
private java.util.function.BooleanSupplier adventureMode = () -> false;
|
||||
private AdventureModeLease gameModes = new AdventureModeLease();
|
||||
private boolean closeSweepFinished;
|
||||
private int persistenceOperations;
|
||||
private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
@@ -71,6 +73,9 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
boolean acknowledged;
|
||||
boolean saving;
|
||||
boolean effectsStarted;
|
||||
String previousGameMode;
|
||||
boolean modeStarted;
|
||||
boolean gearRestoredForModeRecovery;
|
||||
boolean presentationInstalled;
|
||||
boolean cleaning;
|
||||
boolean cleanupAttempted;
|
||||
@@ -118,6 +123,15 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
this.presentation = Objects.requireNonNull(presentation);
|
||||
}
|
||||
|
||||
public EquipmentSessions(CharacterStorage characters, EquipmentSessionStore receipts, Duration switchDelay, Clock clock,
|
||||
Consumer<Runnable> nextTick, Consumer<Notice> notices, Consumer<Failure> diagnostics,
|
||||
games.dmg.fantasystick.presentation.SessionPresentation presentation,
|
||||
java.util.function.BooleanSupplier adventureMode, AdventureModeLease gameModes) {
|
||||
this(characters, receipts, switchDelay, clock, nextTick, notices, diagnostics, presentation);
|
||||
this.adventureMode = Objects.requireNonNull(adventureMode);
|
||||
this.gameModes = Objects.requireNonNull(gameModes);
|
||||
}
|
||||
|
||||
/** One server-thread custody bridge per coordinator; never called by persistence workers. */
|
||||
synchronized void attachCursorCustody(Consumer<UUID> invalidate) {
|
||||
if (cursorCustodyAttached) throw new IllegalStateException("Cursor custody already attached");
|
||||
@@ -182,6 +196,9 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
return;
|
||||
}
|
||||
var attempt = new Attempt(player, owner, character.orElseThrow());
|
||||
if (adventureMode.getAsBoolean() && player.getGameMode() != org.bukkit.GameMode.ADVENTURE) {
|
||||
attempt.previousGameMode = player.getGameMode().name();
|
||||
}
|
||||
attempt.snapshot = snapshot(player);
|
||||
attempt.cursor = copy(player.getItemOnCursor());
|
||||
pending.put(owner, attempt);
|
||||
@@ -192,7 +209,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
private record Loaded(Optional<CharacterRecord> character, Optional<EquipmentSessionStore.State> state) { }
|
||||
|
||||
private boolean entryNativeSafe(Attempt attempt, EquipmentSessionStore.State state) {
|
||||
if (hasRemnants(attempt.player)) return false;
|
||||
if (hasRemnants(attempt.player) || !gameModes.available(attempt.player)) return false;
|
||||
var marker = EquipmentSessionMarker.read(attempt.player).orElse(null);
|
||||
if (EquipmentSessionMarker.present(attempt.player)) {
|
||||
// A terminal marker is resolved only by its matching completed file acknowledgment.
|
||||
@@ -257,7 +274,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
owned.put(attempt.owner, attempt);
|
||||
controls.create(record, attempt.owner, attempt.session); // Validate all nine controls before preparation/packing.
|
||||
attempt.saving = true;
|
||||
persist(() -> receipts.prepare(attempt.owner, attempt.session, attempt.character), (saved, error) -> {
|
||||
persist(() -> receipts.prepare(attempt.owner, attempt.session, attempt.character, attempt.previousGameMode), (saved, error) -> {
|
||||
attempt.saving = false;
|
||||
if (error != null) { attempt.uncertainSave = true; fail(attempt, error); return; }
|
||||
attempt.acknowledged = true;
|
||||
@@ -281,6 +298,12 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
try {
|
||||
var items = controls.create(record, attempt.owner, attempt.session);
|
||||
attempt.entryLocation = attempt.player.getLocation().clone(); // Server thread, before activation.
|
||||
attempt.modeStarted = attempt.previousGameMode != null;
|
||||
if (!gameModes.enter(attempt.player, attempt.session, attempt.previousGameMode)) {
|
||||
attempt.modeStarted = false; // Returned refusal is not an uncertain setter exception.
|
||||
refuse(attempt, "GAME_MODE_REFUSED", "Adventure mode was refused; entry did not pack equipment."); return;
|
||||
}
|
||||
if (!entryCurrent(attempt) || attempt.exitRequested) return;
|
||||
attempt.effectsStarted = true;
|
||||
mark(attempt, EquipmentSessionMarker.Phase.PACKING);
|
||||
var result = equipment.pack(attempt.player.getInventory(), attempt.owner, attempt.session);
|
||||
@@ -308,6 +331,11 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
fail(attempt, error); // Effects may have started: never roll back or replay them.
|
||||
notice(attempt.owner, "NATIVE_UNCERTAIN", "Equipment mutation failed; items and receipt require inspection.");
|
||||
} finally {
|
||||
if (!active.containsKey(attempt.owner) && attempt.modeStarted) {
|
||||
restoreGameMode(attempt);
|
||||
if (!attempt.effectsStarted && attempt.exitRequested) pending.remove(attempt.owner, attempt);
|
||||
if (!attempt.effectsStarted && !current(attempt)) abandon(attempt);
|
||||
}
|
||||
transitioning.remove(attempt.owner, attempt);
|
||||
// The deferred exit must reserve persistence before drain can be signalled.
|
||||
try {
|
||||
@@ -471,11 +499,25 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
catch (RuntimeException | LinkageError failure) { diagnostic(attempt, failure); }
|
||||
}
|
||||
|
||||
private boolean restoreGameMode(Attempt attempt) {
|
||||
if (attempt.previousGameMode == null) return true;
|
||||
try {
|
||||
if (gameModes.restore(attempt.player, attempt.session, attempt.previousGameMode)) {
|
||||
attempt.modeStarted = false;
|
||||
return true;
|
||||
}
|
||||
} catch (RuntimeException failure) { diagnostic(attempt, failure); }
|
||||
notice(attempt.owner, "GAME_MODE_RECOVERY", "Game-mode restoration was refused or ambiguous; retained receipt requires inspection.");
|
||||
return false;
|
||||
}
|
||||
|
||||
private EquipmentStorage.RestoreResult restoreExit(Attempt attempt) {
|
||||
active.remove(attempt.owner, attempt);
|
||||
withdrawPresentation(attempt);
|
||||
boolean reserved = transitioning.putIfAbsent(attempt.owner, attempt) == null;
|
||||
try {
|
||||
invalidateCursor(attempt.owner);
|
||||
boolean modeRestored = restoreGameMode(attempt);
|
||||
EquipmentStorage.RestoreResult result;
|
||||
try {
|
||||
result = restoreMarked(attempt);
|
||||
@@ -486,7 +528,8 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
notice(attempt.owner, "NATIVE_UNCERTAIN", "Restoration failed; items and receipt require inspection.");
|
||||
return EquipmentStorage.RestoreResult.INVALID_BOX;
|
||||
}
|
||||
persistExit(attempt, result == EquipmentStorage.RestoreResult.RESTORED);
|
||||
if (result == EquipmentStorage.RestoreResult.RESTORED && !modeRestored) attempt.gearRestoredForModeRecovery = true;
|
||||
persistExit(attempt, result == EquipmentStorage.RestoreResult.RESTORED && modeRestored);
|
||||
if (result != EquipmentStorage.RestoreResult.RESTORED) {
|
||||
notice(attempt.owner, result.name(), "Equipment recovery refused: " + result);
|
||||
} else sync(attempt);
|
||||
@@ -504,6 +547,13 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
private EquipmentStorage.RestoreResult restoreMarked(Attempt attempt) {
|
||||
invalidateCursor(attempt.owner);
|
||||
var marker = EquipmentSessionMarker.read(attempt.player);
|
||||
// Only a successful restoration observed by this process for this exact Player
|
||||
// permits finishing a mode-only retry. Serialized RESTORED alone proves nothing.
|
||||
if (attempt.gearRestoredForModeRecovery && !hasRemnants(attempt.player)
|
||||
&& marker.filter(token -> token.matches(attempt.session, attempt.character)
|
||||
&& token.phase() == EquipmentSessionMarker.Phase.RESTORED).isPresent()) {
|
||||
return EquipmentStorage.RestoreResult.RESTORED;
|
||||
}
|
||||
if (marker.filter(token -> token.matches(attempt.session, attempt.character)
|
||||
&& token.phase() == EquipmentSessionMarker.Phase.PACKED).isEmpty()) {
|
||||
notice(attempt.owner, "NATIVE_MARKER", "Missing, malformed, mismatched or ambiguous native equipment marker; "
|
||||
@@ -544,7 +594,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
}
|
||||
|
||||
private void abandon(Attempt attempt) {
|
||||
if (attempt.effectsStarted || attempt.cleanupAttempted || attempt.uncertainSave) return;
|
||||
if (attempt.modeStarted || attempt.effectsStarted || attempt.cleanupAttempted || attempt.uncertainSave) return;
|
||||
if (attempt.acknowledged) {
|
||||
attempt.cleaning = true;
|
||||
attempt.cleanupAttempted = true;
|
||||
@@ -564,6 +614,8 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
private boolean entryCurrent(Attempt attempt) {
|
||||
if (!current(attempt)) { abandon(attempt); return false; }
|
||||
if (!attempt.player.isOnline()
|
||||
|| attempt.previousGameMode != null && !attempt.modeStarted
|
||||
&& !attempt.player.getGameMode().name().equals(attempt.previousGameMode)
|
||||
|| attempt.markerValidated && (EquipmentSessionMarker.present(attempt.player) != (attempt.entryMarker != null)
|
||||
|| !Objects.equals(attempt.entryMarker, EquipmentSessionMarker.read(attempt.player).orElse(null)))
|
||||
|| !Arrays.equals(attempt.snapshot, snapshot(attempt.player))
|
||||
@@ -666,6 +718,8 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
}
|
||||
attempt.character = receipt.characterId();
|
||||
attempt.session = receipt.sessionId();
|
||||
attempt.previousGameMode = receipt.previousGameMode();
|
||||
attempt.modeStarted = attempt.previousGameMode != null;
|
||||
var previous = owned.get(attempt.owner);
|
||||
if (previous != null && !attempt.session.equals(previous.session)) {
|
||||
notice(attempt.owner, "SESSION_MISMATCH", "Saved and process-local sessions disagree; items were left unchanged."); return;
|
||||
@@ -678,13 +732,18 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
restoreExit(previous);
|
||||
return;
|
||||
}
|
||||
if (previous != null) attempt.claim = previous.claim;
|
||||
if (previous != null) {
|
||||
attempt.claim = previous.claim;
|
||||
attempt.gearRestoredForModeRecovery = previous.player == attempt.player && previous.gearRestoredForModeRecovery;
|
||||
}
|
||||
owned.put(attempt.owner, attempt);
|
||||
transitioning.put(attempt.owner, attempt);
|
||||
try {
|
||||
boolean modeRestored = restoreGameMode(attempt);
|
||||
var result = restoreMarked(attempt);
|
||||
if (result == EquipmentStorage.RestoreResult.RESTORED) {
|
||||
persistExit(attempt, true);
|
||||
if (!modeRestored) attempt.gearRestoredForModeRecovery = true;
|
||||
persistExit(attempt, modeRestored);
|
||||
sync(attempt);
|
||||
} else notice(attempt.owner, result.name(), "Equipment recovery refused: " + result + "; receipt and items retained.");
|
||||
} catch (RuntimeException error) {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Captured when a new fantasy session starts; changing this does not alter active sessions.
|
||||
# Admin command: /fantasystick config adventure-mode [true|false]
|
||||
adventure-mode: true
|
||||
@@ -7,10 +7,10 @@ description: Transferable character sheets and recoverable equipment sessions; o
|
||||
author: dmg.games
|
||||
commands:
|
||||
fantasystick:
|
||||
description: Inspect equipment receipts or request validated online recovery.
|
||||
usage: /fantasystick <inspect|recover> <player-uuid>
|
||||
description: Inspect/recover equipment sessions or configure fantasy mode.
|
||||
usage: /fantasystick <inspect|recover> <player-uuid> | config adventure-mode [true|false]
|
||||
permission: fantasystick.admin
|
||||
permissions:
|
||||
fantasystick.admin:
|
||||
description: Inspect equipment sessions and request proof-based recovery.
|
||||
description: Inspect/recover equipment sessions and configure fantasy mode.
|
||||
default: op
|
||||
@@ -0,0 +1,111 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import games.dmg.fantasystick.session.AdventureModeLease;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.bukkit.GameMode;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class AdventureModeLeaseNativeTest {
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void changesAndRestoresOnlyTheAcknowledgedHoldersOriginalMode() {
|
||||
for (var original : new GameMode[]{GameMode.SURVIVAL, GameMode.CREATIVE, GameMode.SPECTATOR}) {
|
||||
var actor = new Actor(original);
|
||||
var lease = new AdventureModeLease();
|
||||
var session = UUID.randomUUID();
|
||||
assertTrue(lease.enter(actor.player, session, original.name()));
|
||||
assertEquals(GameMode.ADVENTURE, actor.mode.get());
|
||||
assertTrue(lease.restore(actor.player, session, original.name()));
|
||||
assertEquals(original, actor.mode.get());
|
||||
assertTrue(lease.restore(actor.player, session, original.name()));
|
||||
assertEquals(original, actor.mode.get());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void adminChangeAwayAndBackToAdventureRelinquishesOriginalRestoration() {
|
||||
var actor = new Actor(GameMode.CREATIVE);
|
||||
var lease = new AdventureModeLease(); var session = UUID.randomUUID();
|
||||
assertTrue(lease.enter(actor.player, session, "CREATIVE"));
|
||||
actor.mode.set(GameMode.SURVIVAL);
|
||||
// Native event is dispatched before the second external mode change takes effect.
|
||||
var event = new org.bukkit.event.player.PlayerGameModeChangeEvent(actor.player, GameMode.ADVENTURE);
|
||||
lease.changed(event);
|
||||
actor.mode.set(GameMode.ADVENTURE);
|
||||
assertTrue(lease.restore(actor.player, session, "CREATIVE"));
|
||||
assertEquals(GameMode.ADVENTURE, actor.mode.get(), "Do not override the administrator's final mode");
|
||||
}
|
||||
|
||||
@Test void nativeVetoesLeaveEntryUntouchedAndRestorationRetryableWithoutClaimingSuccess() {
|
||||
var actor = new Actor(GameMode.SURVIVAL); var lease = new AdventureModeLease(); var session = UUID.randomUUID();
|
||||
actor.change = ignored -> {};
|
||||
assertFalse(lease.enter(actor.player, session, "SURVIVAL"));
|
||||
assertEquals(GameMode.SURVIVAL, actor.mode.get()); assertTrue(lease.available(actor.player));
|
||||
actor.change = actor.mode::set;
|
||||
assertTrue(lease.enter(actor.player, session, "SURVIVAL"));
|
||||
actor.change = ignored -> {};
|
||||
assertFalse(lease.restore(actor.player, session, "SURVIVAL"));
|
||||
assertEquals(GameMode.ADVENTURE, actor.mode.get()); assertFalse(lease.available(actor.player));
|
||||
actor.change = actor.mode::set;
|
||||
assertTrue(lease.restore(actor.player, session, "SURVIVAL"));
|
||||
assertEquals(GameMode.SURVIVAL, actor.mode.get());
|
||||
}
|
||||
|
||||
@Test void serializedMatchingProofRecoversButMissingWrongOrInterruptedProofNeverGuesses() throws Exception {
|
||||
var actor = new Actor(GameMode.CREATIVE); var session = UUID.randomUUID();
|
||||
assertTrue(new AdventureModeLease().enter(actor.player, session, "CREATIVE"));
|
||||
var reloaded = new Actor(GameMode.ADVENTURE);
|
||||
reloaded.pdc.readFromBytes(actor.pdc.serializeToBytes(), true);
|
||||
var lease = new AdventureModeLease();
|
||||
assertFalse(lease.restore(reloaded.player, UUID.randomUUID(), "CREATIVE"));
|
||||
assertFalse(lease.restore(reloaded.player, session, "SURVIVAL"));
|
||||
assertEquals(GameMode.ADVENTURE, reloaded.mode.get());
|
||||
assertTrue(lease.restore(reloaded.player, session, "CREATIVE"));
|
||||
assertEquals(GameMode.CREATIVE, reloaded.mode.get());
|
||||
var key = new org.bukkit.NamespacedKey("fantasystick", "adventure_session");
|
||||
for (String phase : new String[]{"APPLYING", "RESTORING", "unknown"}) {
|
||||
reloaded.mode.set(GameMode.ADVENTURE);
|
||||
reloaded.pdc.set(key, org.bukkit.persistence.PersistentDataType.STRING, "1|" + session + "|CREATIVE|" + phase);
|
||||
var before = reloaded.pdc.serializeToBytes();
|
||||
assertFalse(lease.restore(reloaded.player, session, "CREATIVE"));
|
||||
assertArrayEquals(before, reloaded.pdc.serializeToBytes());
|
||||
assertEquals(GameMode.ADVENTURE, reloaded.mode.get());
|
||||
}
|
||||
reloaded.pdc.remove(key);
|
||||
assertFalse(lease.restore(reloaded.player, session, "CREATIVE"));
|
||||
}
|
||||
|
||||
@Test void setterExceptionsRetainAmbiguousProofWithoutAutomaticRetryAndDisabledNeverTouchesMode() {
|
||||
var actor = new Actor(GameMode.SURVIVAL); var lease = new AdventureModeLease(); var session = UUID.randomUUID();
|
||||
actor.change = ignored -> { throw new IllegalStateException("uncertain"); };
|
||||
assertTrue(lease.enter(actor.player, session, null)); assertTrue(lease.restore(actor.player, session, null));
|
||||
assertTrue(lease.available(actor.player));
|
||||
assertThrows(IllegalStateException.class, () -> lease.enter(actor.player, session, "SURVIVAL"));
|
||||
assertFalse(lease.restore(actor.player, session, "SURVIVAL"));
|
||||
assertFalse(lease.available(actor.player));
|
||||
}
|
||||
|
||||
static final class Actor {
|
||||
final UUID id = UUID.randomUUID();
|
||||
final AtomicReference<GameMode> mode;
|
||||
java.util.function.Consumer<GameMode> change;
|
||||
final org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer pdc =
|
||||
new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(
|
||||
new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry());
|
||||
final Player player;
|
||||
Actor(GameMode original) {
|
||||
mode = new AtomicReference<>(original);
|
||||
change = mode::set;
|
||||
player = FantasyStickLifecycleNativeTest.port(Player.class, (name, args) -> switch (name) {
|
||||
case "getUniqueId" -> id;
|
||||
case "getGameMode" -> mode.get();
|
||||
case "setGameMode" -> { change.accept((GameMode) args[0]); yield null; }
|
||||
case "getPersistentDataContainer" -> pdc;
|
||||
default -> throw new AssertionError(name);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,62 @@ class EquipmentAdminCommandNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void adventureConfigurationCommandReadsValidatesPersistsAndReopensWithoutTouchingPlayers() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var sender = new Sender();
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "adventure-mode"});
|
||||
assertTrue(sender.messages.getLast().contains("adventure-mode=true"));
|
||||
sender.messages.clear();
|
||||
for (String value : List.of("yes", "1", "TRUE", "")) {
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "adventure-mode", value});
|
||||
assertTrue(sender.messages.getLast().contains("true|false"));
|
||||
}
|
||||
assertTrue(f.tasks.isEmpty());
|
||||
assertFalse(Files.exists(data.resolve("config.yml")));
|
||||
sender.allowed = false;
|
||||
f.command.getExecutor().onCommand(sender.port, f.command, "fantasystick", new String[]{"config", "adventure-mode", "false"});
|
||||
assertFalse(Files.exists(data.resolve("config.yml")));
|
||||
sender.allowed = true; sender.messages.clear();
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "adventure-mode", "false"});
|
||||
assertTrue(sender.messages.isEmpty(), "Do not report success before acknowledgment");
|
||||
var reply = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(reply); reply.run();
|
||||
assertTrue(sender.messages.getLast().contains("adventure-mode=false"));
|
||||
assertTrue(sender.messages.getLast().contains("new sessions"));
|
||||
assertEquals(0, f.playerLookups);
|
||||
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "adventure-mode"});
|
||||
assertTrue(sender.messages.getLast().contains("adventure-mode=false"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void reenableWaitsOffThreadForAcceptedConfigurationWritesBeforeReadingNewPolicy() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var old = FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "adventureSettings");
|
||||
var worker = (java.util.concurrent.ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(old, "worker");
|
||||
var entered = new java.util.concurrent.CountDownLatch(1);
|
||||
var release = new java.util.concurrent.CountDownLatch(1);
|
||||
worker.execute(() -> {
|
||||
entered.countDown();
|
||||
try { release.await(5, java.util.concurrent.TimeUnit.SECONDS); }
|
||||
catch (InterruptedException error) { Thread.currentThread().interrupt(); }
|
||||
});
|
||||
try {
|
||||
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
var sender = new Sender();
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "adventure-mode", "false"});
|
||||
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
|
||||
assertNull(FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions"), "Do not activate from a stale config snapshot");
|
||||
release.countDown();
|
||||
f.tick();
|
||||
assertNotNull(FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions"));
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "adventure-mode"});
|
||||
assertTrue(sender.messages.getLast().contains("adventure-mode=false"));
|
||||
} finally { release.countDown(); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void permissionAndStrictShapeRejectBeforePersistenceOrNativeLookup() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
|
||||
@@ -62,6 +62,167 @@ class EquipmentSessionsNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void uncertainControlMutationWithReentrantExitStillRestoresOwnedGameMode() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
f.sessions = new EquipmentSessions(f.characters, f.receipts, Duration.ofSeconds(900), f.clock,
|
||||
f.tasks::add, f.notices::add, f.failures::add,
|
||||
games.dmg.fantasystick.presentation.SessionPresentation.NONE, () -> true,
|
||||
new games.dmg.fantasystick.session.AdventureModeLease());
|
||||
var a = f.actor(); a.gameMode = org.bukkit.GameMode.CREATIVE;
|
||||
var writes = new java.util.concurrent.atomic.AtomicInteger();
|
||||
a.mutation = () -> {
|
||||
if (writes.incrementAndGet() == 15) {
|
||||
a.mutation = () -> {};
|
||||
f.sessions.exit(a.player);
|
||||
throw new IllegalStateException("Uncertain control write after packing");
|
||||
}
|
||||
};
|
||||
f.sessions.enter(a.player); f.tick(); f.tick();
|
||||
assertTrue(f.sessions.active(a.id).isEmpty());
|
||||
assertEquals(org.bukkit.GameMode.CREATIVE, a.gameMode);
|
||||
assertNotNull(f.state(a).pending(), "Uncertain gear remains retained independently of safe mode cleanup");
|
||||
assertFailures(f, IllegalStateException.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedReceiptSaveNeverChangesModeOrPacksEquipment() throws Exception {
|
||||
try (var f = new Fixture((source, target) -> { throw new java.io.IOException("Injected receipt failure"); })) {
|
||||
f.sessions = new EquipmentSessions(f.characters, f.receipts, Duration.ofSeconds(900), f.clock,
|
||||
f.tasks::add, f.notices::add, f.failures::add,
|
||||
games.dmg.fantasystick.presentation.SessionPresentation.NONE, () -> true,
|
||||
new games.dmg.fantasystick.session.AdventureModeLease());
|
||||
var a = f.actor(); a.gameMode = org.bukkit.GameMode.CREATIVE;
|
||||
var original = a.snapshot(); var proof = a.pdc.serializeToBytes();
|
||||
a.beforeMode = () -> fail("Mode setter must not run before acknowledged receipt persistence");
|
||||
f.sessions.enter(a.player); f.tick();
|
||||
assertTrue(f.receipts.find(a.id).toCompletableFuture().get(5, TimeUnit.SECONDS).isEmpty());
|
||||
assertTrue(f.sessions.active(a.id).isEmpty());
|
||||
assertEquals(org.bukkit.GameMode.CREATIVE, a.gameMode);
|
||||
assertArrayEquals(original, a.snapshot()); assertArrayEquals(proof, a.pdc.serializeToBytes());
|
||||
assertFailures(f, java.io.IOException.class);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void transferredCharacterNeverCarriesPreviousHoldersGameModeAndAlreadyAdventureNeedsNoChange() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
f.sessions = new EquipmentSessions(f.characters, f.receipts, Duration.ofSeconds(900), f.clock,
|
||||
f.tasks::add, f.notices::add, f.failures::add,
|
||||
games.dmg.fantasystick.presentation.SessionPresentation.NONE, () -> true,
|
||||
new games.dmg.fantasystick.session.AdventureModeLease());
|
||||
var first = f.actor(); first.gameMode = org.bukkit.GameMode.CREATIVE;
|
||||
f.activate(first); f.sessions.exit(first.player); assertNull(f.state(first).pending());
|
||||
assertEquals(org.bukkit.GameMode.CREATIVE, first.gameMode);
|
||||
var second = f.actor(first.record); f.activate(second);
|
||||
assertEquals("SURVIVAL", f.state(second).pending().previousGameMode());
|
||||
f.sessions.exit(second.player); assertNull(f.state(second).pending());
|
||||
assertEquals(org.bukkit.GameMode.SURVIVAL, second.gameMode);
|
||||
var third = f.actor(first.record); third.gameMode = org.bukkit.GameMode.ADVENTURE;
|
||||
third.beforeMode = () -> fail("Already-Adventure sessions must not claim an unnecessary mode change");
|
||||
f.activate(third); assertNull(f.state(third).pending().previousGameMode());
|
||||
f.sessions.exit(third.player); assertNull(f.state(third).pending());
|
||||
assertEquals(org.bukkit.GameMode.ADVENTURE, third.gameMode);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void recoverySynchronizesRestoredEquipmentButRetainsMissingGameModeProof() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
f.sessions = new EquipmentSessions(f.characters, f.receipts, Duration.ofSeconds(900), f.clock,
|
||||
f.tasks::add, f.notices::add, f.failures::add,
|
||||
games.dmg.fantasystick.presentation.SessionPresentation.NONE, () -> true,
|
||||
new games.dmg.fantasystick.session.AdventureModeLease());
|
||||
var a = f.actor(); var original = a.snapshot(); f.activate(a);
|
||||
var reloaded = a.reload();
|
||||
reloaded.pdc.remove(new org.bukkit.NamespacedKey("fantasystick", "adventure_session"));
|
||||
var syncs = new java.util.concurrent.atomic.AtomicInteger(); reloaded.sync = syncs::incrementAndGet;
|
||||
f.sessions = f.coordinator(Duration.ofSeconds(900));
|
||||
f.sessions.recover(reloaded.player); f.tick();
|
||||
assertArrayEquals(original, reloaded.snapshot());
|
||||
assertEquals(org.bukkit.GameMode.ADVENTURE, reloaded.gameMode, "Missing proof cannot authorize a game-mode change");
|
||||
assertNotNull(f.state(reloaded).pending());
|
||||
assertEquals(1, syncs.get(), "Verified equipment restoration must still synchronize the client");
|
||||
assertNotNull(f.state(reloaded).lastExit());
|
||||
assertTrue(f.notices.stream().noneMatch(notice -> notice.message().contains("Equipment recovery refused: RESTORED")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void restartRecoveryRestoresSavedGameModeEvenWhenNewSessionPolicyIsDisabled() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
f.sessions = new EquipmentSessions(f.characters, f.receipts, Duration.ofSeconds(900), f.clock,
|
||||
f.tasks::add, f.notices::add, f.failures::add,
|
||||
games.dmg.fantasystick.presentation.SessionPresentation.NONE, () -> true,
|
||||
new games.dmg.fantasystick.session.AdventureModeLease());
|
||||
var a = f.actor(); a.gameMode = org.bukkit.GameMode.CREATIVE; var original = a.snapshot();
|
||||
f.activate(a);
|
||||
var reloaded = a.reload();
|
||||
// Fresh coordinator models loss of process-local claims at a restart; no orderly exit.
|
||||
f.sessions = f.coordinator(Duration.ofSeconds(900));
|
||||
f.sessions.recover(reloaded.player); f.tick();
|
||||
assertEquals(org.bukkit.GameMode.CREATIVE, reloaded.gameMode);
|
||||
assertArrayEquals(original, reloaded.snapshot());
|
||||
assertNull(f.state(reloaded).pending());
|
||||
f.sessions.recover(reloaded.player); f.tick();
|
||||
assertEquals(org.bukkit.GameMode.CREATIVE, reloaded.gameMode);
|
||||
assertArrayEquals(original, reloaded.snapshot());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void reentrantExitDuringAdventureApplicationRestoresModeWithoutPackingOrStartingDeadline() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
f.sessions = new EquipmentSessions(f.characters, f.receipts, Duration.ofSeconds(900), f.clock,
|
||||
f.tasks::add, f.notices::add, f.failures::add,
|
||||
games.dmg.fantasystick.presentation.SessionPresentation.NONE, () -> true,
|
||||
new games.dmg.fantasystick.session.AdventureModeLease());
|
||||
var a = f.actor(); var before = a.snapshot();
|
||||
a.beforeMode = () -> { a.beforeMode = () -> {}; f.sessions.exit(a.player); };
|
||||
f.sessions.enter(a.player); f.tick(); f.tick();
|
||||
assertArrayEquals(before, a.snapshot());
|
||||
assertEquals(org.bukkit.GameMode.SURVIVAL, a.gameMode);
|
||||
assertTrue(f.sessions.active(a.id).isEmpty());
|
||||
assertNull(f.state(a).pending()); assertNull(f.state(a).lastExit());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void orphanAdventureOwnershipCannotBeOverwrittenEvenWhenNewModePolicyIsDisabled() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
var a = f.actor();
|
||||
new games.dmg.fantasystick.session.AdventureModeLease().enter(a.player, UUID.randomUUID(), "SURVIVAL");
|
||||
var original = a.snapshot(); var proof = a.pdc.serializeToBytes();
|
||||
f.sessions.enter(a.player); f.tick();
|
||||
assertTrue(f.receipts.find(a.id).toCompletableFuture().get(5, TimeUnit.SECONDS).isEmpty());
|
||||
assertTrue(f.sessions.active(a.id).isEmpty());
|
||||
assertArrayEquals(original, a.snapshot()); assertArrayEquals(proof, a.pdc.serializeToBytes());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void adventurePolicyIsCapturedPerSessionAndSavedBeforeNativeModeChangeThenRestored() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
var enabled = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
f.sessions = new EquipmentSessions(f.characters, f.receipts, Duration.ofSeconds(900), f.clock,
|
||||
f.tasks::add, f.notices::add, f.failures::add,
|
||||
games.dmg.fantasystick.presentation.SessionPresentation.NONE, enabled::get,
|
||||
new games.dmg.fantasystick.session.AdventureModeLease());
|
||||
var a = f.actor(); a.gameMode = org.bukkit.GameMode.CREATIVE;
|
||||
a.beforeMode = () -> {
|
||||
try { assertEquals("CREATIVE", f.state(a).pending().previousGameMode()); }
|
||||
catch (Exception error) { throw new AssertionError(error); }
|
||||
};
|
||||
f.sessions.enter(a.player);
|
||||
enabled.set(false); // The entered request already captured its policy.
|
||||
f.tick(); f.tick();
|
||||
assertTrue(f.sessions.active(a.id).isPresent());
|
||||
assertEquals(org.bukkit.GameMode.ADVENTURE, a.gameMode);
|
||||
assertEquals(EquipmentStorage.RestoreResult.RESTORED, f.sessions.exit(a.player));
|
||||
assertEquals(org.bukkit.GameMode.CREATIVE, a.gameMode);
|
||||
assertNull(f.state(a).pending());
|
||||
a.beforeMode = () -> { throw new AssertionError("Disabled sessions must not change mode"); };
|
||||
f.activate(a);
|
||||
assertEquals(org.bukkit.GameMode.CREATIVE, a.gameMode);
|
||||
assertNull(f.state(a).pending().previousGameMode());
|
||||
f.sessions.exit(a.player);
|
||||
assertEquals(org.bukkit.GameMode.CREATIVE, a.gameMode);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void nativePlayerMarkerBracketsPackingAndRestorationAndSurvivesSerialization() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
var actor = f.actor();
|
||||
@@ -1521,6 +1682,8 @@ class EquipmentSessionsNativeTest {
|
||||
org.bukkit.Location teleported;
|
||||
boolean teleportAccepted = true;
|
||||
Runnable teleport = () -> {};
|
||||
org.bukkit.GameMode gameMode = org.bukkit.GameMode.SURVIVAL;
|
||||
Runnable beforeMode = () -> {};
|
||||
double health = 20;
|
||||
double normalMaximum = 28;
|
||||
int teleports;
|
||||
@@ -1548,6 +1711,8 @@ class EquipmentSessionsNativeTest {
|
||||
}
|
||||
case "getOpenInventory" -> view;
|
||||
case "getLocation" -> location.clone();
|
||||
case "getGameMode" -> gameMode;
|
||||
case "setGameMode" -> { beforeMode.run(); gameMode = (org.bukkit.GameMode) args[0]; yield null; }
|
||||
case "getHealth" -> health;
|
||||
case "setHealth" -> { health = (Double) args[0]; yield null; }
|
||||
case "getAttribute" -> { assertEquals(org.bukkit.attribute.Attribute.MAX_HEALTH, args[0]); yield maximum; }
|
||||
@@ -1576,6 +1741,7 @@ class EquipmentSessionsNativeTest {
|
||||
Actor reload() throws Exception {
|
||||
var reloaded = new Actor(record, id);
|
||||
reloaded.pdc.readFromBytes(pdc.serializeToBytes(), true);
|
||||
reloaded.gameMode = gameMode;
|
||||
var ops = net.minecraft.resources.RegistryOps.create(net.minecraft.nbt.NbtOps.INSTANCE,
|
||||
org.bukkit.craftbukkit.CraftRegistry.getMinecraftRegistry());
|
||||
for (int slot = 0; slot < inventory.getSize(); slot++) {
|
||||
|
||||
@@ -638,6 +638,110 @@ class FantasyStickLifecycleNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void nativeModeCancellationRefusesEntryAndRetainsRestorationForExplicitRetry() throws Exception {
|
||||
for (String route : List.of("entry", "retry", "restart")) {
|
||||
boolean entering = route.equals("entry");
|
||||
Path directory = data.resolve("veto-" + route); java.nio.file.Files.createDirectories(directory);
|
||||
try (var f = new Fixture(directory)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new Actor(directory); f.players.put(a.core.id, a.player);
|
||||
a.core.gameMode = org.bukkit.GameMode.CREATIVE; a.modeEvents = f.dispatcher::accept;
|
||||
var original = a.core.snapshot();
|
||||
var listener = new org.bukkit.event.Listener() {};
|
||||
org.bukkit.event.player.PlayerGameModeChangeEvent.getHandlerList().register(new org.bukkit.plugin.RegisteredListener(
|
||||
listener, (ignored, raw) -> {
|
||||
var event = (org.bukkit.event.player.PlayerGameModeChangeEvent) raw;
|
||||
if (event.getNewGameMode() == (entering ? org.bukkit.GameMode.ADVENTURE : org.bukkit.GameMode.CREATIVE)) event.setCancelled(true);
|
||||
}, org.bukkit.event.EventPriority.HIGHEST, f.plugin, false));
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions) Fixture.field(f.plugin, "sessions");
|
||||
if (entering) {
|
||||
f.changed(a); f.tick(); f.tick(); f.tick();
|
||||
assertTrue(sessions.active(a.core.id).isEmpty());
|
||||
assertEquals(org.bukkit.GameMode.CREATIVE, a.player.getGameMode());
|
||||
var saved = f.receipts().find(a.core.id).toCompletableFuture().get().orElseThrow();
|
||||
assertNull(saved.pending()); assertNull(saved.lastExit());
|
||||
} else {
|
||||
f.activate(a); sessions.exit(a.player);
|
||||
assertEquals(org.bukkit.GameMode.ADVENTURE, a.player.getGameMode());
|
||||
var saved = f.receipts().find(a.core.id).toCompletableFuture().get().orElseThrow();
|
||||
assertNotNull(saved.pending()); assertNotNull(saved.lastExit());
|
||||
org.bukkit.event.player.PlayerGameModeChangeEvent.getHandlerList().unregister(listener);
|
||||
if (route.equals("restart")) {
|
||||
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
|
||||
while (a.player.getGameMode() == org.bukkit.GameMode.ADVENTURE) f.tick();
|
||||
assertNotNull(f.receipts().find(a.core.id).toCompletableFuture().get().orElseThrow().pending(),
|
||||
"A serialized RESTORED marker cannot replace lost process-local gear proof");
|
||||
} else {
|
||||
sessions.recover(a.player); f.tick();
|
||||
assertNull(f.receipts().find(a.core.id).toCompletableFuture().get().orElseThrow().pending());
|
||||
}
|
||||
assertEquals(org.bukkit.GameMode.CREATIVE, a.player.getGameMode());
|
||||
}
|
||||
assertArrayEquals(original, a.core.snapshot());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void packagedAdventureRestoresOnQuitDefeatDisableAndPreservesLaterAdminModeChanges() throws Exception {
|
||||
for (String route : List.of("quit", "defeat", "disable", "admin")) {
|
||||
Path directory = data.resolve(route); java.nio.file.Files.createDirectories(directory);
|
||||
try (var f = new Fixture(directory)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new Actor(directory); f.players.put(a.core.id, a.player);
|
||||
a.core.gameMode = org.bukkit.GameMode.CREATIVE; a.modeEvents = f.dispatcher::accept;
|
||||
f.activate(a);
|
||||
assertEquals(org.bukkit.GameMode.ADVENTURE, a.player.getGameMode());
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions) Fixture.field(f.plugin, "sessions");
|
||||
switch (route) {
|
||||
case "quit" -> f.dispatcher.accept(new org.bukkit.event.player.PlayerQuitEvent(a.player, net.kyori.adventure.text.Component.empty()));
|
||||
case "disable" -> f.plugin.setEnabled(false);
|
||||
case "admin" -> {
|
||||
a.player.setGameMode(org.bukkit.GameMode.SURVIVAL);
|
||||
a.player.setGameMode(org.bukkit.GameMode.ADVENTURE);
|
||||
sessions.exit(a.player);
|
||||
}
|
||||
case "defeat" -> {
|
||||
var source = new org.bukkit.craftbukkit.damage.CraftDamageSource(new net.minecraft.world.damagesource.DamageSource(
|
||||
org.bukkit.craftbukkit.damage.CraftDamageType.bukkitToMinecraftHolder(org.bukkit.damage.DamageType.GENERIC)));
|
||||
var death = new org.bukkit.event.entity.PlayerDeathEvent(a.player, source, new ArrayList<>(), 0,
|
||||
net.kyori.adventure.text.Component.empty(), true);
|
||||
f.dispatcher.accept(death); assertTrue(death.isCancelled());
|
||||
}
|
||||
default -> throw new AssertionError(route);
|
||||
}
|
||||
assertEquals(route.equals("admin") ? org.bukkit.GameMode.ADVENTURE : org.bukkit.GameMode.CREATIVE, a.player.getGameMode());
|
||||
assertEquals(org.bukkit.Material.DIAMOND_SWORD, a.core.inventory.getItem(0).getType());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void packagedAdventureDefaultAndAdminChangesApplyOnlyToNewSessions() throws Exception {
|
||||
try (var f = new Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new Actor(data); f.players.put(a.core.id, a.player);
|
||||
a.core.gameMode = org.bukkit.GameMode.CREATIVE;
|
||||
a.modeEvents = f.dispatcher::accept;
|
||||
f.activate(a);
|
||||
assertEquals(org.bukkit.GameMode.ADVENTURE, a.player.getGameMode());
|
||||
assertEquals("CREATIVE", f.receipts().find(a.core.id).toCompletableFuture().get().orElseThrow().pending().previousGameMode());
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "adventure-mode", "false"});
|
||||
while (sender.messages.isEmpty()) f.tick();
|
||||
assertEquals(org.bukkit.GameMode.ADVENTURE, a.player.getGameMode());
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions) Fixture.field(f.plugin, "sessions");
|
||||
sessions.exit(a.player);
|
||||
assertEquals(org.bukkit.GameMode.CREATIVE, a.player.getGameMode());
|
||||
f.receipts().find(a.core.id).toCompletableFuture().get();
|
||||
var b = new Actor(data); f.players.put(b.core.id, b.player);
|
||||
b.core.gameMode = org.bukkit.GameMode.SURVIVAL;
|
||||
f.activate(b);
|
||||
assertEquals(org.bukkit.GameMode.SURVIVAL, b.player.getGameMode());
|
||||
assertNull(f.receipts().find(b.core.id).toCompletableFuture().get().orElseThrow().pending().previousGameMode());
|
||||
sessions.exit(b.player);
|
||||
assertEquals(org.bukkit.GameMode.SURVIVAL, b.player.getGameMode());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void configuredSwitchDelaysReachRealPersistedExits() throws Exception {
|
||||
for (String value : List.of("default", "0", "37")) {
|
||||
var directory = data.resolve(value); java.nio.file.Files.createDirectories(directory);
|
||||
@@ -775,7 +879,9 @@ class FantasyStickLifecycleNativeTest {
|
||||
|
||||
private static void assertNativeName(Fixture f, Actor a, Actor viewer, String name) {
|
||||
var tabs = f.packets(viewer.player, net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket.class);
|
||||
assertFalse(tabs.isEmpty(), "Assembled session must send native tab output, not merely catch a CraftPlayer failure");
|
||||
assertFalse(tabs.isEmpty(), () -> "Assembled session must send native tab output, not merely catch a CraftPlayer failure: "
|
||||
+ f.presentationFailures.stream().map(record -> record.getMessage() + ": " + record.getThrown()).toList()
|
||||
+ "; player notices=" + a.messages);
|
||||
var tab = tabs.getLast();
|
||||
assertEquals(java.util.EnumSet.of(net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket.Action.UPDATE_DISPLAY_NAME), tab.actions());
|
||||
assertEquals(a.core.id, tab.entries().getFirst().profileId());
|
||||
@@ -1003,7 +1109,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
assertTrue(actor.core.marker().endsWith("|PACKED"));
|
||||
}
|
||||
private void captureWorkers(JavaPlugin instance) {
|
||||
for (String name : List.of("characters", "receipts")) {
|
||||
for (String name : List.of("characters", "receipts", "adventureSettings")) {
|
||||
var store = field(instance, name);
|
||||
if (store != null) {
|
||||
var worker = (java.util.concurrent.ExecutorService) field(store, "worker");
|
||||
@@ -1011,7 +1117,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
}
|
||||
}
|
||||
}
|
||||
private static Object field(Object object, String name) {
|
||||
static Object field(Object object, String name) {
|
||||
try {
|
||||
var field = object.getClass().getDeclaredField(name); field.setAccessible(true); return field.get(object);
|
||||
} catch (ReflectiveOperationException error) { throw new AssertionError(error); }
|
||||
@@ -1034,8 +1140,11 @@ class FantasyStickLifecycleNativeTest {
|
||||
/** Real native inventory/PDC from the coordinator fixture; message/particle/network ports only. */
|
||||
static final class Actor {
|
||||
final EquipmentSessionsNativeTest.Actor core;
|
||||
// Location holds a WeakReference: the fixture must model the server's world ownership.
|
||||
final org.bukkit.World world = mock(org.bukkit.World.class);
|
||||
final List<String> messages = new ArrayList<>();
|
||||
final List<Integer> particles = new ArrayList<>();
|
||||
java.util.function.Consumer<org.bukkit.event.player.PlayerGameModeChangeEvent> modeEvents = ignored -> { };
|
||||
final org.bukkit.craftbukkit.entity.CraftPlayer player;
|
||||
final net.minecraft.server.level.ServerPlayer nativePlayer = mock(net.minecraft.server.level.ServerPlayer.class);
|
||||
final net.minecraft.world.entity.ai.attributes.AttributeInstance distance = new net.minecraft.world.entity.ai.attributes.AttributeInstance(
|
||||
@@ -1062,7 +1171,13 @@ class FantasyStickLifecycleNativeTest {
|
||||
case "getHeight" -> 1.8;
|
||||
case "getTrackedBy" -> tracked;
|
||||
case "canSee" -> true;
|
||||
case "getGameMode" -> org.bukkit.GameMode.SURVIVAL;
|
||||
case "getGameMode" -> core.gameMode;
|
||||
case "setGameMode" -> {
|
||||
var event = new org.bukkit.event.player.PlayerGameModeChangeEvent((org.bukkit.entity.Player) call.getMock(), (org.bukkit.GameMode) args[0]);
|
||||
modeEvents.accept(event);
|
||||
if (!event.isCancelled()) { core.beforeMode.run(); core.gameMode = event.getNewGameMode(); }
|
||||
yield null;
|
||||
}
|
||||
case "isSneaking", "isInvisible" -> false;
|
||||
case "getScoreboard" -> craftBoard;
|
||||
case "sendMessage" -> { messages.add((String) args[0]); yield null; }
|
||||
@@ -1070,7 +1185,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
default -> Proxy.getInvocationHandler(core.player).invoke(core.player, method, args);
|
||||
};
|
||||
});
|
||||
core.location.setWorld(mock(org.bukkit.World.class));
|
||||
core.location.setWorld(world);
|
||||
when(nativePlayer.getId()).thenReturn(42);
|
||||
when(nativePlayer.getUUID()).thenReturn(core.id);
|
||||
when(nativePlayer.getTabListDisplayName()).thenReturn(net.minecraft.network.chat.Component.literal("Ordinary tab"));
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class AdventureSettingsTest {
|
||||
@org.junit.jupiter.api.io.TempDir java.nio.file.Path directory;
|
||||
|
||||
@Test void acknowledgesDurableUpdateWithoutDiscardingOtherConfiguration() throws Exception {
|
||||
var file = directory.resolve("config.yml");
|
||||
java.nio.file.Files.writeString(file, "starting-points: 73\nother:\n enabled: true\n");
|
||||
try (var settings = new AdventureSettings(directory, true)) {
|
||||
assertFalse(settings.set(false).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
assertFalse(settings.enabled());
|
||||
var saved = new org.yaml.snakeyaml.Yaml().<java.util.Map<String, Object>>load(java.nio.file.Files.readString(file));
|
||||
assertEquals(false, saved.get("adventure-mode"));
|
||||
assertEquals(73, saved.get("starting-points"));
|
||||
assertEquals(java.util.Map.of("enabled", true), saved.get("other"));
|
||||
assertFalse(AdventureSettings.validate(saved.get("adventure-mode")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void acknowledgmentsAreWorkerOwnedOrderedAndSurviveObserverCancellationAndClose() throws Exception {
|
||||
var entered = new java.util.concurrent.CountDownLatch(1);
|
||||
var release = new java.util.concurrent.CountDownLatch(1);
|
||||
var caller = Thread.currentThread();
|
||||
var settings = new AdventureSettings(directory, true, (from, to) -> {
|
||||
assertNotSame(caller, Thread.currentThread());
|
||||
entered.countDown();
|
||||
try { assertTrue(release.await(5, java.util.concurrent.TimeUnit.SECONDS)); }
|
||||
catch (InterruptedException interrupted) { throw new java.io.IOException(interrupted); }
|
||||
java.nio.file.Files.move(from, to, java.nio.file.StandardCopyOption.ATOMIC_MOVE,
|
||||
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||
});
|
||||
try {
|
||||
var first = settings.set(false);
|
||||
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
assertTrue(settings.enabled());
|
||||
first.toCompletableFuture().cancel(false);
|
||||
var second = settings.set(true);
|
||||
settings.close();
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> settings.set(false).toCompletableFuture().get());
|
||||
release.countDown();
|
||||
assertFalse(first.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
assertTrue(second.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
assertTrue(settings.enabled());
|
||||
var saved = new org.yaml.snakeyaml.Yaml().<java.util.Map<String, Object>>load(
|
||||
java.nio.file.Files.readString(directory.resolve("config.yml")));
|
||||
assertEquals(true, saved.get("adventure-mode"));
|
||||
} finally { release.countDown(); settings.close(); }
|
||||
}
|
||||
|
||||
@Test void failedSavePreservesEffectiveValueAndUnrelatedOriginalBytes() throws Exception {
|
||||
var file = directory.resolve("config.yml");
|
||||
String original = "other: 17\nadventure-mode: true\n";
|
||||
java.nio.file.Files.writeString(file, original);
|
||||
try (var settings = new AdventureSettings(directory, true, (from, to) -> { throw new java.io.IOException("no write"); })) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> settings.set(false).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
assertTrue(settings.enabled());
|
||||
assertEquals(original, java.nio.file.Files.readString(file));
|
||||
}
|
||||
try (var files = java.nio.file.Files.list(directory)) {
|
||||
assertFalse(files.anyMatch(path -> path.toString().endsWith(".tmp")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void corruptDuplicateOversizedOrSymlinkedConfigurationIsRetained() throws Exception {
|
||||
var file = directory.resolve("config.yml");
|
||||
for (String invalid : java.util.List.of("a: [broken", "other: 1\nother: 2\n", "[1, 2]", "#".repeat(1_048_577))) {
|
||||
java.nio.file.Files.writeString(file, invalid);
|
||||
try (var settings = new AdventureSettings(directory, true)) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> settings.set(false).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
assertTrue(settings.enabled()); assertEquals(invalid, java.nio.file.Files.readString(file));
|
||||
}
|
||||
}
|
||||
java.nio.file.Files.delete(file);
|
||||
var outside = directory.resolve("outside.yml");
|
||||
java.nio.file.Files.writeString(outside, "keep: me\n");
|
||||
java.nio.file.Files.createSymbolicLink(file, outside);
|
||||
try (var settings = new AdventureSettings(directory, true)) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> settings.set(false).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
assertTrue(settings.enabled()); assertEquals("keep: me\n", java.nio.file.Files.readString(outside));
|
||||
assertTrue(java.nio.file.Files.isSymbolicLink(file));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void defaultsToTrueAndAcceptsOnlyActualBooleanValues() {
|
||||
assertTrue(AdventureSettings.validate(null));
|
||||
assertTrue(AdventureSettings.validate(Boolean.TRUE));
|
||||
assertFalse(AdventureSettings.validate(Boolean.FALSE));
|
||||
for (Object invalid : new Object[]{"true", "false", "yes", 0, 1, java.util.List.of(), java.util.Map.of()}) {
|
||||
assertThrows(IllegalArgumentException.class, () -> AdventureSettings.validate(invalid));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,19 @@ class EquipmentSessionStoreTest {
|
||||
return stage.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
@Test void previousGameModeIsAcknowledgedPersistedAndCannotBeReplacedByARepeatedPreparation() throws Exception {
|
||||
try (var store = new EquipmentSessionStore(data, CLOCK)) {
|
||||
var prepared = await(store.prepare(player, session, character, "CREATIVE"));
|
||||
assertEquals("CREATIVE", prepared.pending().previousGameMode());
|
||||
try (var reopened = new EquipmentSessionStore(data, CLOCK)) {
|
||||
assertEquals(prepared, await(reopened.find(player)).orElseThrow());
|
||||
assertEquals(prepared, await(reopened.prepare(player, session, character, "CREATIVE")));
|
||||
failure(reopened.prepare(player, session, character, "SURVIVAL"));
|
||||
assertEquals(prepared, await(reopened.find(player)).orElseThrow());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Path record() { return data.resolve("sessions").resolve(player + ".yml"); }
|
||||
|
||||
private static Throwable failure(CompletionStage<?> stage) {
|
||||
|
||||
Reference in new issue
Block a user