feat(state): add validated persistent game state
Release / release (push) Successful in 2m13s
CI / build (push) Successful in 53s

This commit is contained in:
dmg
2026-08-14 22:29:49 -04:00
parent 7bfdf01d10
commit d604dca129
24 changed files with 1366 additions and 10 deletions
@@ -0,0 +1,39 @@
package games.dmg.spigottyrant;
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.Map;
import org.bukkit.configuration.file.YamlConfiguration;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
final class DefaultConfigurationTest {
@Test
void bundledConfigurationIsCompleteAndValid() {
InputStream stream = getClass().getClassLoader().getResourceAsStream("config.yml");
assertNotNull(stream);
Map<String, ?> values = new Yaml().load(stream);
PluginSettings settings = PluginSettings.from(values);
assertEquals(50.0, settings.tyrantRangeBlocks());
assertEquals(Duration.ofDays(7), settings.optOutDuration());
assertEquals("FISHING_ROD", settings.tamerItem().material());
}
@Test
void acceptsNestedSectionsAsBukkitProvidesThem() throws Exception {
InputStream stream = getClass().getClassLoader().getResourceAsStream("config.yml");
assertNotNull(stream);
String yamlText = new String(stream.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
YamlConfiguration yaml = new YamlConfiguration();
yaml.loadFromString(yamlText);
PluginSettings settings = PluginSettings.from(yaml.getValues(false));
assertEquals("Assassin Cloak", settings.assassinItem().name());
assertEquals("Fixer's Wrench", settings.fixerItem().name());
}
}
@@ -0,0 +1,20 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.time.Duration;
import java.time.Instant;
import org.junit.jupiter.api.Test;
final class PauseAwareTimeTest {
@Test
void shiftsDeadlineByTimeSpentPaused() {
Instant pausedAt = Instant.parse("2026-08-14T12:00:00Z");
Instant resumedAt = pausedAt.plus(Duration.ofHours(3));
Instant originalDeadline = pausedAt.plus(Duration.ofMinutes(30));
Instant shifted = PauseAwareTime.shiftDeadline(originalDeadline, pausedAt, resumedAt);
assertEquals(originalDeadline.plus(Duration.ofHours(3)), shifted);
}
}
@@ -0,0 +1,67 @@
package games.dmg.spigottyrant;
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 java.util.Set;
import org.junit.jupiter.api.Test;
final class PluginSettingsTest {
@Test
void approvedDefaultsCoverCoreGameTimingAndRanges() {
PluginSettings settings = PluginSettings.from(Map.of());
assertEquals(50.0, settings.tyrantRangeBlocks());
assertEquals(50.0, settings.followerRangeBlocks());
assertEquals(Duration.ofDays(7), settings.optOutDuration());
assertEquals(Duration.ofHours(48), settings.roleInactivity());
assertEquals(Duration.ofHours(24), settings.candidateActivityWindow());
assertEquals(Duration.ofHours(24), settings.pendingSelectionTimeout());
assertEquals(Duration.ofHours(1), settings.assassinCooldown());
assertEquals(Duration.ofMinutes(10), settings.assassinInvisibilityDuration());
assertEquals(Duration.ofHours(1), settings.fixerCooldown());
assertEquals(Duration.ofMinutes(10), settings.fixerEffectDuration());
assertEquals(Duration.ofHours(24), settings.rosterIntelligenceCooldown());
}
@Test
void approvedDefaultsCoverAbilitiesItemsMobsAndMessages() {
PluginSettings settings = PluginSettings.from(Map.of());
assertEquals(Duration.ofMinutes(1), settings.selectionRetryInterval());
assertEquals(Duration.ofSeconds(60), settings.assassinDoubleJumpCooldown());
assertEquals(Duration.ofSeconds(15), settings.assassinSpeedDuration());
assertEquals(Duration.ofSeconds(20), settings.assassinWeaknessDuration());
assertEquals(3, settings.assassinWeaknessLevel());
assertEquals(5, settings.followerStrengthCap());
assertEquals(4, settings.followerResistanceCap());
assertEquals(2, settings.fixerNormalHeartRows());
assertEquals(3, settings.fixerNearTyrantHeartRows());
assertEquals(Set.of("ENDER_DRAGON", "WITHER"), settings.deniedMobTypes());
assertEquals("STICK", settings.assassinItem().material());
assertEquals("Assassin Cloak", settings.assassinItem().name());
assertEquals("FISHING_ROD", settings.tamerItem().material());
assertEquals("/tyrant item", settings.recoveryCommand());
assertEquals("Your inventory is full. Make room and use /tyrant item.",
settings.inventoryFullMessage());
assertEquals(true, settings.freezeTimersWhilePaused());
}
@Test
void rejectsUnsafeRangesAndDurations() {
assertThrows(
IllegalArgumentException.class,
() -> PluginSettings.from(Map.of("tyrant-range-blocks", 0))
);
assertThrows(
IllegalArgumentException.class,
() -> PluginSettings.from(Map.of("follower-range-blocks", Double.NaN))
);
assertThrows(
IllegalArgumentException.class,
() -> PluginSettings.from(Map.of("role-inactivity-seconds", -1))
);
}
}
@@ -0,0 +1,32 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.Map;
import org.junit.jupiter.api.Test;
final class PluginSettingsValidatorTest {
@Test
void acceptsBundledItemMaterialsAndKnownEntityTypes() {
PluginSettings settings = PluginSettings.from(Map.of());
assertSame(settings, PluginSettingsValidator.validate(settings));
}
@Test
void rejectsInvalidItemMaterialAndEntityType() {
assertThrows(
IllegalArgumentException.class,
() -> PluginSettingsValidator.validate(PluginSettings.from(Map.of(
"assassin-item", Map.of("material", "NOT_REAL", "name", "Cloak")
)))
);
assertThrows(
IllegalArgumentException.class,
() -> PluginSettingsValidator.validate(PluginSettings.from(Map.of(
"denied-mob-types", java.util.List.of("NOT_REAL")
)))
);
}
}
@@ -0,0 +1,45 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.nio.file.Path;
import java.util.UUID;
import java.util.logging.Logger;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
final class TyrantStateManagerTest {
@TempDir
Path temporaryDirectory;
@Test
void persistsPlayerNamesAndGameUpdatesWhenDirty() throws Exception {
Path stateFile = temporaryDirectory.resolve("state.yml");
YamlTyrantStateRepository repository = new YamlTyrantStateRepository(stateFile);
TyrantStateManager manager = new TyrantStateManager(
repository,
Logger.getLogger("test")
);
UUID playerId = UUID.fromString("11111111-1111-1111-1111-111111111111");
manager.player(playerId, "FirstName");
manager.player(playerId, "LatestName");
manager.updateGame(current -> new GameState(
GameLifecycle.RUNNING,
java.util.Optional.of(playerId),
java.util.Optional.empty(),
java.util.Optional.empty(),
java.util.Optional.empty(),
java.util.Optional.empty(),
java.time.Duration.ZERO,
0,
1,
java.util.Set.of()
));
manager.saveIfDirty();
PersistentState restored = repository.load();
assertEquals("LatestName", restored.players().get(playerId).latestName());
assertEquals(GameLifecycle.RUNNING, restored.game().lifecycle());
}
}
@@ -0,0 +1,102 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
final class YamlTyrantStateRepositoryTest {
@TempDir
Path temporaryDirectory;
@Test
void roundTripsLifecycleRolesProgressionPlayersCooldownsItemsAndMobs() throws Exception {
UUID tyrant = UUID.fromString("11111111-1111-1111-1111-111111111111");
UUID vigilante = UUID.fromString("22222222-2222-2222-2222-222222222222");
Instant now = Instant.parse("2026-08-14T12:00:00Z");
GameState game = new GameState(
GameLifecycle.PAUSED,
Optional.of(tyrant),
Optional.of(vigilante),
Optional.empty(),
Optional.of(new PendingSelection(vigilante, now.plusSeconds(3600))),
Optional.of(now),
Duration.ofMinutes(15),
3,
2,
Set.of(TyrantUnlock.ASSASSIN, TyrantUnlock.STRENGTH)
);
PlayerState player = new PlayerState(
tyrant,
"TyrantPlayer",
Optional.of(now.minusSeconds(10)),
Optional.of(now.plusSeconds(600)),
TyrantClass.ASSASSIN,
Optional.of(vigilante),
Map.of(Ability.ASSASSIN_INVISIBILITY, now.plusSeconds(3600)),
Set.of(Ability.ASSASSIN_INVISIBILITY),
List.of(new CapturedMob("ZOMBIE", Map.of("custom-name", "Bob")))
);
PersistentState expected = new PersistentState(game, Map.of(tyrant, player));
YamlTyrantStateRepository repository = new YamlTyrantStateRepository(
temporaryDirectory.resolve("state.yml")
);
repository.save(expected);
assertEquals(expected, repository.load());
}
@Test
void preservesUnknownFieldsForRetainedState() throws Exception {
UUID playerId = UUID.fromString("11111111-1111-1111-1111-111111111111");
Path stateFile = temporaryDirectory.resolve("state.yml");
Files.writeString(stateFile, """
future-root: retained
players:
11111111-1111-1111-1111-111111111111:
name: Player
future-player-field: retained-too
""");
YamlTyrantStateRepository repository = new YamlTyrantStateRepository(stateFile);
PersistentState loaded = repository.load();
repository.save(loaded);
String saved = Files.readString(stateFile);
assertEquals(true, saved.contains("future-root: retained"));
assertEquals(true, saved.contains("future-player-field: retained-too"));
assertEquals(playerId, loaded.players().get(playerId).playerId());
}
@Test
void invalidRecordsCannotRestoreProgressOrCapturedDragons() throws Exception {
Path stateFile = temporaryDirectory.resolve("state.yml");
Files.writeString(stateFile, """
game:
lifecycle: RUNNING
tyrant-level: -10
players:
11111111-1111-1111-1111-111111111111:
name: Player
captured-mobs:
- entity-type: ENDER_DRAGON
- entity-type: NOT_REAL
""");
YamlTyrantStateRepository repository = new YamlTyrantStateRepository(stateFile);
PersistentState loaded = repository.load();
assertEquals(GameState.empty(), loaded.game());
assertEquals(List.of(), loaded.players().values().iterator().next().capturedMobs());
}
}