feat(persistence): add durable stealth state
This commit is contained in:
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
## 2026-08-14
|
## 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.
|
- **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.
|
- **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.
|
- **Creation**: Established the OKF v0.1 product record for Spigot Stealth.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: User Story
|
type: User Story
|
||||||
title: "US-005: Configure and persist stealth progression"
|
title: "US-005: Configure and persist stealth progression"
|
||||||
description: Give operators validated settings and durable, defensive storage for stealth behavior.
|
description: Give operators validated settings and durable, defensive storage for stealth behavior.
|
||||||
status: backlog
|
status: done
|
||||||
---
|
---
|
||||||
|
|
||||||
# US-005: Configure and persist stealth progression
|
# 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
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] The qualifying-time threshold is configurable and defaults to eight hours.
|
- [x] The qualifying-time threshold is configurable and defaults to eight hours.
|
||||||
- [ ] Player-facing progress, unlock, prepared-login, and concealed-session messages are configurable.
|
- [x] Player-facing progress, unlock, prepared-login, and concealed-session messages are configurable.
|
||||||
- [ ] Startup validates required settings before registering partially functional listeners, commands, or tasks.
|
- [x] Startup validates required settings before registering partially functional listeners, commands, or tasks.
|
||||||
- [ ] Invalid required configuration prevents initialization and produces a clear server log message.
|
- [x] 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.
|
- [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.
|
||||||
- [ ] Qualifying runtime intervals use a monotonic elapsed-time source so wall-clock adjustments cannot grant or remove progress.
|
- [x] 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.
|
- [x] Durable timestamps, when required, use RFC 3339 UTC notation.
|
||||||
- [ ] State is saved periodically, after material state changes, and during orderly plugin disable.
|
- [x] 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.
|
- [x] 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.
|
- [x] Corrupt, unknown, or invalid records cannot silently grant time, an unlock, a prepared login, or concealment.
|
||||||
- [ ] Unknown forward-compatible fields are preserved where practical.
|
- [x] Unknown forward-compatible fields are preserved where practical.
|
||||||
- [ ] Persistence work does not perform blocking file operations on the server tick thread.
|
- [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
|
## Related
|
||||||
|
|
||||||
|
|||||||
@@ -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<UUID, PlayerStealthState> players,
|
||||||
|
Map<String, Object> 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<UUID, PlayerStealthState> updated = new LinkedHashMap<>(players);
|
||||||
|
updated.put(player.playerId(), player);
|
||||||
|
return new PersistentStealthState(updated, unknownFields);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, Object> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,62 @@
|
|||||||
package games.dmg.spigotstealth;
|
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;
|
import org.bukkit.plugin.java.JavaPlugin;
|
||||||
|
|
||||||
/** Bukkit entry point for Spigot Stealth. */
|
/** Bukkit entry point for Spigot Stealth. */
|
||||||
public final class SpigotStealthPlugin extends JavaPlugin {
|
public final class SpigotStealthPlugin extends JavaPlugin {
|
||||||
|
private CompletableFuture<StealthStateManager> stateManagerFuture;
|
||||||
|
private StealthSettings settings;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onEnable() {
|
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");
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<String, ?> 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<String, ?> 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<String, ?> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<PersistentStealthState> 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<StealthStateManager> 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<Void> update(UnaryOperator<PersistentStealthState> operation) {
|
||||||
|
PersistentStealthState updated = state.updateAndGet(operation);
|
||||||
|
return persist(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<Void> save() {
|
||||||
|
return persist(state.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
private CompletableFuture<Void> 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<String> ROOT_FIELDS = Set.of("schema-version", "players");
|
||||||
|
private static final Set<String> 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<String, Object> unknownRoot = unknownValues(yaml, ROOT_FIELDS);
|
||||||
|
Map<UUID, PlayerStealthState> 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<String, Object> unknownValues(ConfigurationSection section, Set<String> knownFields) {
|
||||||
|
Map<String, Object> unknown = new LinkedHashMap<>();
|
||||||
|
section.getValues(false).forEach((key, value) -> {
|
||||||
|
if (!knownFields.contains(key)) {
|
||||||
|
unknown.put(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
@@ -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<String, Object> 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<String, Object> flatten(Map<String, Object> source) {
|
||||||
|
Map<String, Object> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, Object> values = Map.of("unlock-threshold-seconds", 0);
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> StealthSettings.from(values));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<StealthStateManager> 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<PersistentStealthState> saved = new AtomicReference<>();
|
||||||
|
private final AtomicReference<String> saveThread = new AtomicReference<>();
|
||||||
|
private final AtomicReference<String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user