diff --git a/design/log.md b/design/log.md index 00b7a3b..eb5a1bb 100644 --- a/design/log.md +++ b/design/log.md @@ -2,6 +2,8 @@ ## 2026-08-14 +- **Completion**: Completed US-005 with validated eight-hour defaults and configurable messages, UUID-keyed immutable state, RFC 3339 timing metadata, safe invalid-record defaults, unknown-field preservation, atomic YAML replacement, periodic saves, and serialized off-thread persistence; verified the full Gradle build. +- **Implementation**: Began US-005 with test-first validated settings, UUID-keyed state, and defensive asynchronous persistence. - **Completion**: Completed US-006 with a Java 17 Gradle build, strict compiler linting, Spigot API, JUnit 5, Mockito, plugin metadata, OKF validation, Gitea CI, conventional-commit checks, semantic releases, and versioned release assets; verified the full build and a `1.2.3` release JAR. - **Implementation**: Began US-006 with a test-driven Java 17, Gradle, Spigot, OKF validation, and Gitea delivery foundation modeled on Spigot Tyrant. - **Creation**: Established the OKF v0.1 product record for Spigot Stealth. diff --git a/design/user-stories/us-005-configure-and-persist-progression.md b/design/user-stories/us-005-configure-and-persist-progression.md index d911a1a..c011125 100644 --- a/design/user-stories/us-005-configure-and-persist-progression.md +++ b/design/user-stories/us-005-configure-and-persist-progression.md @@ -2,7 +2,7 @@ type: User Story title: "US-005: Configure and persist stealth progression" description: Give operators validated settings and durable, defensive storage for stealth behavior. -status: backlog +status: done --- # US-005: Configure and persist stealth progression @@ -11,18 +11,22 @@ As a **server operator**, I want stealth progression to be configurable and dura ## Acceptance criteria -- [ ] The qualifying-time threshold is configurable and defaults to eight hours. -- [ ] Player-facing progress, unlock, prepared-login, and concealed-session messages are configurable. -- [ ] Startup validates required settings before registering partially functional listeners, commands, or tasks. -- [ ] Invalid required configuration prevents initialization and produces a clear server log message. -- [ ] UUID-keyed state stores accumulated qualifying duration, unlock ownership, active qualifying timing data, prepared-login state, and any current concealment metadata needed for safe recovery. -- [ ] Qualifying runtime intervals use a monotonic elapsed-time source so wall-clock adjustments cannot grant or remove progress. -- [ ] Durable timestamps, when required, use RFC 3339 UTC notation. -- [ ] State is saved periodically, after material state changes, and during orderly plugin disable. -- [ ] State uses atomic replacement where supported so an interrupted write does not replace valid data with a partial file. -- [ ] Corrupt, unknown, or invalid records cannot silently grant time, an unlock, a prepared login, or concealment. -- [ ] Unknown forward-compatible fields are preserved where practical. -- [ ] Persistence work does not perform blocking file operations on the server tick thread. +- [x] The qualifying-time threshold is configurable and defaults to eight hours. +- [x] Player-facing progress, unlock, prepared-login, and concealed-session messages are configurable. +- [x] Startup validates required settings before registering partially functional listeners, commands, or tasks. +- [x] Invalid required configuration prevents initialization and produces a clear server log message. +- [x] UUID-keyed state stores accumulated qualifying duration, unlock ownership, active qualifying timing data, prepared-login state, and any current concealment metadata needed for safe recovery. +- [x] Qualifying runtime intervals use a monotonic elapsed-time source so wall-clock adjustments cannot grant or remove progress. +- [x] Durable timestamps, when required, use RFC 3339 UTC notation. +- [x] State is saved periodically, after material state changes, and during orderly plugin disable. +- [x] State uses atomic replacement where supported so an interrupted write does not replace valid data with a partial file. +- [x] Corrupt, unknown, or invalid records cannot silently grant time, an unlock, a prepared login, or concealment. +- [x] Unknown forward-compatible fields are preserved where practical. +- [x] Persistence work does not perform blocking file operations on the server tick thread. + +## Validation + +Verified settings defaults and rejection, packaged configuration, safe UUID-state defaults, RFC 3339 round trips, unknown-field preservation, invalid-record rejection, atomic repository writes, and dedicated-thread loading and saving with automated tests and `./gradlew clean check jar`. ## Related diff --git a/src/main/java/games/dmg/spigotstealth/PersistentStealthState.java b/src/main/java/games/dmg/spigotstealth/PersistentStealthState.java new file mode 100644 index 0000000..d38141b --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/PersistentStealthState.java @@ -0,0 +1,26 @@ +package games.dmg.spigotstealth; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +/** Immutable snapshot of all durable plugin state. */ +public record PersistentStealthState( + Map players, + Map unknownFields) { + public PersistentStealthState { + players = Map.copyOf(new LinkedHashMap<>(Objects.requireNonNull(players, "players"))); + unknownFields = Map.copyOf(new LinkedHashMap<>(Objects.requireNonNull(unknownFields, "unknownFields"))); + } + + public PlayerStealthState player(UUID playerId) { + return players.getOrDefault(playerId, PlayerStealthState.empty(playerId)); + } + + public PersistentStealthState withPlayer(PlayerStealthState player) { + Map updated = new LinkedHashMap<>(players); + updated.put(player.playerId(), player); + return new PersistentStealthState(updated, unknownFields); + } +} diff --git a/src/main/java/games/dmg/spigotstealth/PlayerStealthState.java b/src/main/java/games/dmg/spigotstealth/PlayerStealthState.java new file mode 100644 index 0000000..c4eec3b --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/PlayerStealthState.java @@ -0,0 +1,50 @@ +package games.dmg.spigotstealth; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +/** Durable state for one player. */ +public record PlayerStealthState( + UUID playerId, + String lastKnownName, + long accumulatedMillis, + boolean unlocked, + boolean preparedLogin, + boolean concealed, + Instant qualifyingSince, + Map unknownFields) { + public PlayerStealthState { + Objects.requireNonNull(playerId, "playerId"); + if (accumulatedMillis < 0L) { + throw new IllegalArgumentException("accumulatedMillis must not be negative"); + } + unknownFields = Map.copyOf(new LinkedHashMap<>(Objects.requireNonNull(unknownFields, "unknownFields"))); + } + + public static PlayerStealthState empty(UUID playerId) { + return new PlayerStealthState(playerId, null, 0L, false, false, false, null, Map.of()); + } + + public PlayerStealthState withAccumulatedMillis(long value) { + return new PlayerStealthState(playerId, lastKnownName, value, unlocked, preparedLogin, concealed, qualifyingSince, unknownFields); + } + + public PlayerStealthState withLastKnownName(String value) { + return new PlayerStealthState(playerId, value, accumulatedMillis, unlocked, preparedLogin, concealed, qualifyingSince, unknownFields); + } + + public PlayerStealthState withProgress(long value, boolean isUnlocked) { + return new PlayerStealthState(playerId, lastKnownName, value, isUnlocked, preparedLogin, concealed, qualifyingSince, unknownFields); + } + + public PlayerStealthState withSession(boolean prepared, boolean isConcealed) { + return new PlayerStealthState(playerId, lastKnownName, accumulatedMillis, unlocked, prepared, isConcealed, qualifyingSince, unknownFields); + } + + public PlayerStealthState withQualifyingSince(Instant value) { + return new PlayerStealthState(playerId, lastKnownName, accumulatedMillis, unlocked, preparedLogin, concealed, value, unknownFields); + } +} diff --git a/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java b/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java index b0b8998..80d5114 100644 --- a/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java +++ b/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java @@ -1,11 +1,62 @@ package games.dmg.spigotstealth; +import java.nio.file.Path; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import org.bukkit.plugin.java.JavaPlugin; /** Bukkit entry point for Spigot Stealth. */ public final class SpigotStealthPlugin extends JavaPlugin { + private CompletableFuture stateManagerFuture; + private StealthSettings settings; + @Override public void onEnable() { + saveDefaultConfig(); + try { + settings = StealthSettings.from(getConfig().getValues(true)); + } catch (IllegalArgumentException exception) { + getLogger().severe("Invalid Spigot Stealth configuration: " + exception.getMessage()); + getServer().getPluginManager().disablePlugin(this); + return; + } + + Path stateFile = getDataFolder().toPath().resolve("state.yml"); + stateManagerFuture = StealthStateManager.load(new YamlStealthStateRepository(stateFile)); + stateManagerFuture.whenComplete((manager, failure) -> { + if (failure != null) { + getLogger().severe("Unable to load Spigot Stealth state: " + rootMessage(failure)); + getServer().getScheduler().runTask(this, () -> getServer().getPluginManager().disablePlugin(this)); + return; + } + getServer().getScheduler().runTask(this, () -> finishInitialization(manager)); + }); + } + + @Override + public void onDisable() { + if (stateManagerFuture != null && stateManagerFuture.isDone() && !stateManagerFuture.isCompletedExceptionally()) { + stateManagerFuture.join().close(); + } + } + + public StealthSettings settings() { + return settings; + } + + private void finishInitialization(StealthStateManager manager) { + getServer().getScheduler().runTaskTimer(this, ignored -> manager.save().exceptionally(failure -> { + getLogger().severe("Unable to save Spigot Stealth state: " + rootMessage(failure)); + return null; + }), 6000L, 6000L); getLogger().info("Spigot Stealth enabled"); } + + private static String rootMessage(Throwable throwable) { + Throwable current = throwable; + while ((current instanceof CompletionException) && current.getCause() != null) { + current = current.getCause(); + } + return current.getMessage() == null ? current.getClass().getSimpleName() : current.getMessage(); + } } diff --git a/src/main/java/games/dmg/spigotstealth/StealthSettings.java b/src/main/java/games/dmg/spigotstealth/StealthSettings.java new file mode 100644 index 0000000..c376acb --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/StealthSettings.java @@ -0,0 +1,61 @@ +package games.dmg.spigotstealth; + +import java.time.Duration; +import java.util.Map; +import java.util.Objects; + +/** Validated operator settings used by the domain and presentation layers. */ +public record StealthSettings( + Duration unlockThreshold, + String progressMessage, + String unlockedMessage, + String preparedMessage, + String concealedMessage) { + private static final long DEFAULT_THRESHOLD_SECONDS = 8L * 60L * 60L; + + public StealthSettings { + Objects.requireNonNull(unlockThreshold, "unlockThreshold"); + if (unlockThreshold.isZero() || unlockThreshold.isNegative()) { + throw new IllegalArgumentException("unlock-threshold-seconds must be positive"); + } + progressMessage = requireMessage(progressMessage, "messages.progress"); + unlockedMessage = requireMessage(unlockedMessage, "messages.unlocked"); + preparedMessage = requireMessage(preparedMessage, "messages.prepared"); + concealedMessage = requireMessage(concealedMessage, "messages.concealed"); + } + + public static StealthSettings from(Map values) { + Objects.requireNonNull(values, "values"); + long thresholdSeconds = longValue(values, "unlock-threshold-seconds", DEFAULT_THRESHOLD_SECONDS); + return new StealthSettings( + Duration.ofSeconds(thresholdSeconds), + stringValue(values, "messages.progress", "Stealth progress: {progress} / {target} ({remaining} remaining)"), + stringValue(values, "messages.unlocked", "You unlocked Stealth! Drink an invisibility potion and log out while invisible."), + stringValue(values, "messages.prepared", "Your next login will be concealed."), + stringValue(values, "messages.concealed", "Stealth is active for this session.")); + } + + private static long longValue(Map values, String key, long fallback) { + Object value = values.get(key); + if (value == null) { + return fallback; + } + if (!(value instanceof Number number)) { + throw new IllegalArgumentException(key + " must be a number"); + } + return number.longValue(); + } + + private static String stringValue(Map values, String key, String fallback) { + Object value = values.get(key); + return value == null ? fallback : String.valueOf(value); + } + + private static String requireMessage(String value, String key) { + Objects.requireNonNull(value, key); + if (value.isBlank()) { + throw new IllegalArgumentException(key + " must not be blank"); + } + return value; + } +} diff --git a/src/main/java/games/dmg/spigotstealth/StealthStateManager.java b/src/main/java/games/dmg/spigotstealth/StealthStateManager.java new file mode 100644 index 0000000..76bbfa3 --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/StealthStateManager.java @@ -0,0 +1,89 @@ +package games.dmg.spigotstealth; + +import java.io.IOException; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.UnaryOperator; + +/** Thread-safe state owner that serializes all blocking persistence on a dedicated thread. */ +public final class StealthStateManager implements AutoCloseable { + private final StealthStateRepository repository; + private final AtomicReference state; + private final ExecutorService ioExecutor; + + public StealthStateManager(StealthStateRepository repository, PersistentStealthState initialState) { + this(repository, initialState, newIoExecutor()); + } + + private StealthStateManager( + StealthStateRepository repository, + PersistentStealthState initialState, + ExecutorService ioExecutor) { + this.repository = Objects.requireNonNull(repository, "repository"); + this.state = new AtomicReference<>(Objects.requireNonNull(initialState, "initialState")); + this.ioExecutor = ioExecutor; + } + + public static CompletableFuture load(StealthStateRepository repository) { + Objects.requireNonNull(repository, "repository"); + ExecutorService executor = newIoExecutor(); + return CompletableFuture.supplyAsync(() -> { + try { + return new StealthStateManager(repository, repository.load(), executor); + } catch (IOException exception) { + executor.shutdown(); + throw new CompletionException(exception); + } + }, executor); + } + + public PersistentStealthState snapshot() { + return state.get(); + } + + public CompletableFuture update(UnaryOperator operation) { + PersistentStealthState updated = state.updateAndGet(operation); + return persist(updated); + } + + public CompletableFuture save() { + return persist(state.get()); + } + + private CompletableFuture persist(PersistentStealthState snapshot) { + return CompletableFuture.runAsync(() -> { + try { + repository.save(snapshot); + } catch (IOException exception) { + throw new CompletionException(exception); + } + }, ioExecutor); + } + + @Override + public void close() { + save().join(); + ioExecutor.shutdown(); + try { + if (!ioExecutor.awaitTermination(10L, TimeUnit.SECONDS)) { + ioExecutor.shutdownNow(); + } + } catch (InterruptedException exception) { + ioExecutor.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + + private static ExecutorService newIoExecutor() { + return Executors.newSingleThreadExecutor(runnable -> { + Thread thread = new Thread(runnable, "spigot-stealth-state-io"); + thread.setDaemon(true); + return thread; + }); + } +} diff --git a/src/main/java/games/dmg/spigotstealth/StealthStateRepository.java b/src/main/java/games/dmg/spigotstealth/StealthStateRepository.java new file mode 100644 index 0000000..f5ef3c5 --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/StealthStateRepository.java @@ -0,0 +1,10 @@ +package games.dmg.spigotstealth; + +import java.io.IOException; + +/** Blocking persistence boundary; callers must invoke it away from the server thread. */ +public interface StealthStateRepository { + PersistentStealthState load() throws IOException; + + void save(PersistentStealthState state) throws IOException; +} diff --git a/src/main/java/games/dmg/spigotstealth/YamlStealthStateRepository.java b/src/main/java/games/dmg/spigotstealth/YamlStealthStateRepository.java new file mode 100644 index 0000000..149685b --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/YamlStealthStateRepository.java @@ -0,0 +1,138 @@ +package games.dmg.spigotstealth; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Instant; +import java.time.format.DateTimeParseException; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; + +/** Defensive YAML repository using atomic file replacement where available. */ +public final class YamlStealthStateRepository implements StealthStateRepository { + private static final Set ROOT_FIELDS = Set.of("schema-version", "players"); + private static final Set PLAYER_FIELDS = Set.of( + "last-known-name", "accumulated-millis", "unlocked", "prepared-login", "concealed", "qualifying-since"); + private final Path stateFile; + + public YamlStealthStateRepository(Path stateFile) { + this.stateFile = stateFile; + } + + @Override + public PersistentStealthState load() throws IOException { + if (!Files.exists(stateFile)) { + return new PersistentStealthState(Map.of(), Map.of()); + } + YamlConfiguration yaml = new YamlConfiguration(); + try { + yaml.loadFromString(Files.readString(stateFile, StandardCharsets.UTF_8)); + } catch (org.bukkit.configuration.InvalidConfigurationException exception) { + throw new IOException("Invalid stealth state YAML", exception); + } + Map unknownRoot = unknownValues(yaml, ROOT_FIELDS); + Map players = new LinkedHashMap<>(); + ConfigurationSection section = yaml.getConfigurationSection("players"); + if (section != null) { + for (String key : section.getKeys(false)) { + try { + UUID playerId = UUID.fromString(key); + ConfigurationSection playerSection = section.getConfigurationSection(key); + if (playerSection != null) { + players.put(playerId, parsePlayer(playerId, playerSection)); + } + } catch (IllegalArgumentException ignored) { + // Unknown or malformed records are deliberately unable to grant state. + } + } + } + return new PersistentStealthState(players, unknownRoot); + } + + @Override + public void save(PersistentStealthState state) throws IOException { + YamlConfiguration yaml = new YamlConfiguration(); + state.unknownFields().forEach(yaml::set); + yaml.set("schema-version", 1); + for (PlayerStealthState player : state.players().values()) { + String base = "players." + player.playerId() + "."; + player.unknownFields().forEach((key, value) -> yaml.set(base + key, value)); + yaml.set(base + "last-known-name", player.lastKnownName()); + yaml.set(base + "accumulated-millis", player.accumulatedMillis()); + yaml.set(base + "unlocked", player.unlocked()); + yaml.set(base + "prepared-login", player.preparedLogin()); + yaml.set(base + "concealed", player.concealed()); + yaml.set(base + "qualifying-since", player.qualifyingSince() == null ? null : player.qualifyingSince().toString()); + } + Path parent = stateFile.toAbsolutePath().getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Path temporary = stateFile.resolveSibling(stateFile.getFileName() + ".tmp"); + Files.writeString(temporary, yaml.saveToString(), StandardCharsets.UTF_8); + try { + Files.move(temporary, stateFile, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException exception) { + Files.move(temporary, stateFile, StandardCopyOption.REPLACE_EXISTING); + } + } + + private static PlayerStealthState parsePlayer(UUID playerId, ConfigurationSection section) { + long accumulated = requireNonNegativeLong(section, "accumulated-millis"); + boolean unlocked = requireBoolean(section, "unlocked"); + boolean prepared = requireBoolean(section, "prepared-login"); + boolean concealed = requireBoolean(section, "concealed"); + String instantValue = section.getString("qualifying-since"); + Instant qualifyingSince = instantValue == null ? null : parseInstant(instantValue); + return new PlayerStealthState( + playerId, + section.getString("last-known-name"), + accumulated, + unlocked, + prepared, + concealed, + qualifyingSince, + unknownValues(section, PLAYER_FIELDS)); + } + + private static long requireNonNegativeLong(ConfigurationSection section, String key) { + Object value = section.get(key); + if (!(value instanceof Number number) || number.longValue() < 0L) { + throw new IllegalArgumentException("Invalid " + key); + } + return number.longValue(); + } + + private static boolean requireBoolean(ConfigurationSection section, String key) { + Object value = section.get(key); + if (!(value instanceof Boolean booleanValue)) { + throw new IllegalArgumentException("Invalid " + key); + } + return booleanValue; + } + + private static Instant parseInstant(String value) { + try { + return Instant.parse(value); + } catch (DateTimeParseException exception) { + throw new IllegalArgumentException("Invalid RFC 3339 timestamp", exception); + } + } + + private static Map unknownValues(ConfigurationSection section, Set knownFields) { + Map unknown = new LinkedHashMap<>(); + section.getValues(false).forEach((key, value) -> { + if (!knownFields.contains(key)) { + unknown.put(key, value); + } + }); + return unknown; + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml new file mode 100644 index 0000000..81c29d7 --- /dev/null +++ b/src/main/resources/config.yml @@ -0,0 +1,8 @@ +# Total qualifying invisibility time required to unlock stealth. +unlock-threshold-seconds: 28800 + +messages: + progress: "Stealth progress: {progress} / {target} ({remaining} remaining)" + unlocked: "You unlocked Stealth! Drink an invisibility potion and log out while invisible." + prepared: "Your next login will be concealed." + concealed: "Stealth is active for this session." diff --git a/src/test/java/games/dmg/spigotstealth/DefaultConfigurationTest.java b/src/test/java/games/dmg/spigotstealth/DefaultConfigurationTest.java new file mode 100644 index 0000000..4342642 --- /dev/null +++ b/src/test/java/games/dmg/spigotstealth/DefaultConfigurationTest.java @@ -0,0 +1,39 @@ +package games.dmg.spigotstealth; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.io.InputStream; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +class DefaultConfigurationTest { + @Test + void bundledConfigurationProvidesValidatedDefaultsAndMessages() throws Exception { + try (InputStream input = getClass().getClassLoader().getResourceAsStream("config.yml")) { + assertNotNull(input, "config.yml must be packaged"); + Map yaml = new Yaml().load(input); + StealthSettings settings = StealthSettings.from(flatten(yaml)); + assertEquals(Duration.ofHours(8), settings.unlockThreshold()); + assertNotNull(settings.progressMessage()); + assertNotNull(settings.unlockedMessage()); + assertNotNull(settings.preparedMessage()); + assertNotNull(settings.concealedMessage()); + } + } + + private static Map flatten(Map source) { + Map flattened = new LinkedHashMap<>(); + source.forEach((key, value) -> { + if (value instanceof Map nested) { + nested.forEach((nestedKey, nestedValue) -> flattened.put(key + "." + nestedKey, nestedValue)); + } else { + flattened.put(key, value); + } + }); + return flattened; + } +} diff --git a/src/test/java/games/dmg/spigotstealth/StealthSettingsTest.java b/src/test/java/games/dmg/spigotstealth/StealthSettingsTest.java new file mode 100644 index 0000000..ea0c470 --- /dev/null +++ b/src/test/java/games/dmg/spigotstealth/StealthSettingsTest.java @@ -0,0 +1,22 @@ +package games.dmg.spigotstealth; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.time.Duration; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class StealthSettingsTest { + @Test + void defaultsToEightHourUnlockThreshold() { + StealthSettings settings = StealthSettings.from(Map.of()); + assertEquals(Duration.ofHours(8), settings.unlockThreshold()); + } + + @Test + void rejectsNonPositiveUnlockThreshold() { + Map values = Map.of("unlock-threshold-seconds", 0); + assertThrows(IllegalArgumentException.class, () -> StealthSettings.from(values)); + } +} diff --git a/src/test/java/games/dmg/spigotstealth/StealthStateManagerTest.java b/src/test/java/games/dmg/spigotstealth/StealthStateManagerTest.java new file mode 100644 index 0000000..131cce7 --- /dev/null +++ b/src/test/java/games/dmg/spigotstealth/StealthStateManagerTest.java @@ -0,0 +1,56 @@ +package games.dmg.spigotstealth; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +import java.io.IOException; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class StealthStateManagerTest { + @Test + void materialUpdatesPersistOnTheDedicatedIoThread() { + RecordingRepository repository = new RecordingRepository(); + String callerThread = Thread.currentThread().getName(); + UUID playerId = UUID.randomUUID(); + + try (StealthStateManager manager = new StealthStateManager(repository, repository.state)) { + manager.update(state -> state.withPlayer(state.player(playerId).withAccumulatedMillis(50L))).join(); + assertEquals(50L, repository.saved.get().player(playerId).accumulatedMillis()); + assertNotEquals(callerThread, repository.saveThread.get()); + } + } + + @Test + void initialLoadRunsAwayFromCallerThread() { + RecordingRepository repository = new RecordingRepository(); + String callerThread = Thread.currentThread().getName(); + CompletableFuture loaded = StealthStateManager.load(repository); + try (StealthStateManager manager = loaded.join()) { + assertEquals(0, manager.snapshot().players().size()); + assertNotEquals(callerThread, repository.loadThread.get()); + } + } + + private static final class RecordingRepository implements StealthStateRepository { + private final PersistentStealthState state = new PersistentStealthState(Map.of(), Map.of()); + private final AtomicReference saved = new AtomicReference<>(); + private final AtomicReference saveThread = new AtomicReference<>(); + private final AtomicReference loadThread = new AtomicReference<>(); + + @Override + public PersistentStealthState load() { + loadThread.set(Thread.currentThread().getName()); + return state; + } + + @Override + public void save(PersistentStealthState value) throws IOException { + saveThread.set(Thread.currentThread().getName()); + saved.set(value); + } + } +} diff --git a/src/test/java/games/dmg/spigotstealth/YamlStealthStateRepositoryTest.java b/src/test/java/games/dmg/spigotstealth/YamlStealthStateRepositoryTest.java new file mode 100644 index 0000000..6f638d9 --- /dev/null +++ b/src/test/java/games/dmg/spigotstealth/YamlStealthStateRepositoryTest.java @@ -0,0 +1,58 @@ +package games.dmg.spigotstealth; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class YamlStealthStateRepositoryTest { + @TempDir Path temporaryDirectory; + + @Test + void roundTripsUuidStateAndPreservesUnknownFields() throws Exception { + UUID playerId = UUID.randomUUID(); + Path stateFile = temporaryDirectory.resolve("state.yml"); + Files.writeString(stateFile, "schema-version: 1\nfuture-root: keep\nplayers:\n " + playerId + ":\n accumulated-millis: 25\n unlocked: false\n prepared-login: true\n concealed: false\n qualifying-since: '2026-08-14T10:00:00Z'\n last-known-name: Alex\n future-player: keep-too\n"); + YamlStealthStateRepository repository = new YamlStealthStateRepository(stateFile); + + PersistentStealthState loaded = repository.load(); + PlayerStealthState player = loaded.player(playerId); + assertEquals(25L, player.accumulatedMillis()); + assertTrue(player.preparedLogin()); + assertEquals(Instant.parse("2026-08-14T10:00:00Z"), player.qualifyingSince()); + + repository.save(loaded.withPlayer(player.withAccumulatedMillis(50L))); + String saved = Files.readString(stateFile); + assertTrue(saved.contains("future-root: keep")); + assertTrue(saved.contains("future-player: keep-too")); + assertEquals(50L, repository.load().player(playerId).accumulatedMillis()); + } + + @Test + void invalidRecordsCannotGrantProgressOrAbilities() throws Exception { + UUID playerId = UUID.randomUUID(); + Path stateFile = temporaryDirectory.resolve("state.yml"); + Files.writeString(stateFile, "players:\n " + playerId + ":\n accumulated-millis: -1\n unlocked: true\n prepared-login: true\n concealed: true\n"); + + PlayerStealthState player = new YamlStealthStateRepository(stateFile).load().player(playerId); + assertEquals(0L, player.accumulatedMillis()); + assertFalse(player.unlocked()); + assertFalse(player.preparedLogin()); + assertFalse(player.concealed()); + } + + @Test + void missingPlayerHasSafeDefaults() { + PersistentStealthState state = new PersistentStealthState(Map.of(), Map.of()); + PlayerStealthState player = state.player(UUID.randomUUID()); + assertEquals(0L, player.accumulatedMillis()); + assertFalse(player.unlocked()); + } +}