feat(persistence): add durable stealth state
This commit is contained in:
@@ -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