feat(persistence): add durable stealth state
Release / release (push) Successful in 2m47s
CI / build (push) Successful in 1m10s

This commit is contained in:
dmg
2026-08-14 23:56:38 -04:00
parent f28e47782e
commit 248c5e72c1
14 changed files with 627 additions and 13 deletions
@@ -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;
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<StealthStateManager> 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();
}
}
@@ -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;
}
}
+8
View File
@@ -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."