From d604dca1297450cd741d7538d47d299abb05dadc Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Fri, 14 Aug 2026 22:29:49 -0400 Subject: [PATCH] feat(state): add validated persistent game state --- design/log.md | 11 + .../us-012-configure-and-persist-state.md | 20 +- .../java/games/dmg/spigottyrant/Ability.java | 9 + .../games/dmg/spigottyrant/CapturedMob.java | 14 + .../games/dmg/spigottyrant/GameLifecycle.java | 7 + .../games/dmg/spigottyrant/GameState.java | 59 +++ .../dmg/spigottyrant/PauseAwareTime.java | 20 ++ .../dmg/spigottyrant/PendingSelection.java | 12 + .../dmg/spigottyrant/PersistentState.java | 20 ++ .../games/dmg/spigottyrant/PlayerState.java | 51 +++ .../dmg/spigottyrant/PluginSettings.java | 266 ++++++++++++++ .../spigottyrant/PluginSettingsValidator.java | 36 ++ .../dmg/spigottyrant/SpigotTyrantPlugin.java | 42 +++ .../games/dmg/spigottyrant/TyrantClass.java | 8 + .../dmg/spigottyrant/TyrantStateManager.java | 100 ++++++ .../games/dmg/spigottyrant/TyrantUnlock.java | 10 + .../YamlTyrantStateRepository.java | 335 ++++++++++++++++++ src/main/resources/config.yml | 51 +++ .../DefaultConfigurationTest.java | 39 ++ .../dmg/spigottyrant/PauseAwareTimeTest.java | 20 ++ .../dmg/spigottyrant/PluginSettingsTest.java | 67 ++++ .../PluginSettingsValidatorTest.java | 32 ++ .../spigottyrant/TyrantStateManagerTest.java | 45 +++ .../YamlTyrantStateRepositoryTest.java | 102 ++++++ 24 files changed, 1366 insertions(+), 10 deletions(-) create mode 100644 src/main/java/games/dmg/spigottyrant/Ability.java create mode 100644 src/main/java/games/dmg/spigottyrant/CapturedMob.java create mode 100644 src/main/java/games/dmg/spigottyrant/GameLifecycle.java create mode 100644 src/main/java/games/dmg/spigottyrant/GameState.java create mode 100644 src/main/java/games/dmg/spigottyrant/PauseAwareTime.java create mode 100644 src/main/java/games/dmg/spigottyrant/PendingSelection.java create mode 100644 src/main/java/games/dmg/spigottyrant/PersistentState.java create mode 100644 src/main/java/games/dmg/spigottyrant/PlayerState.java create mode 100644 src/main/java/games/dmg/spigottyrant/PluginSettings.java create mode 100644 src/main/java/games/dmg/spigottyrant/PluginSettingsValidator.java create mode 100644 src/main/java/games/dmg/spigottyrant/TyrantClass.java create mode 100644 src/main/java/games/dmg/spigottyrant/TyrantStateManager.java create mode 100644 src/main/java/games/dmg/spigottyrant/TyrantUnlock.java create mode 100644 src/main/java/games/dmg/spigottyrant/YamlTyrantStateRepository.java create mode 100644 src/main/resources/config.yml create mode 100644 src/test/java/games/dmg/spigottyrant/DefaultConfigurationTest.java create mode 100644 src/test/java/games/dmg/spigottyrant/PauseAwareTimeTest.java create mode 100644 src/test/java/games/dmg/spigottyrant/PluginSettingsTest.java create mode 100644 src/test/java/games/dmg/spigottyrant/PluginSettingsValidatorTest.java create mode 100644 src/test/java/games/dmg/spigottyrant/TyrantStateManagerTest.java create mode 100644 src/test/java/games/dmg/spigottyrant/YamlTyrantStateRepositoryTest.java diff --git a/design/log.md b/design/log.md index 28282c1..626df3c 100644 --- a/design/log.md +++ b/design/log.md @@ -6,6 +6,17 @@ description: Chronological record of material decisions affecting the Spigot Tyr # Spigot Tyrant Design Log +## 2026-08-14 — Configuration and persistence completed + +- Completed US-012 with validated defaults for ranges, timers, effects, items, messages, and mob restrictions, including runtime Bukkit section parsing. +- Added immutable lifecycle and player state for roles, dual-alignment classes, progression, participation, cooldowns, bound items, and captured mobs. +- Added RFC 3339 UTC timestamp persistence, paused deadline shifting, atomic YAML replacement, invalid-record rejection, unknown-field preservation, periodic saves, and shutdown saves. +- Verified all state, settings, bundled configuration, corruption, forward-compatibility, and pause-time tests with `./gradlew clean check jar`. + +## 2026-08-14 — Configuration and persistence implementation started + +- US-012 begins with test-first validated defaults, immutable UUID-keyed domain state, paused-time-aware clocks, and defensive atomic YAML persistence. + ## 2026-08-14 — Build and release foundation completed - Completed US-013 with a Java 17 Gradle build, Spigot 26.2 API, strict compiler linting, JUnit 5 and Mockito dependencies, plugin metadata, and a minimal plugin entrypoint. diff --git a/design/user-stories/us-012-configure-and-persist-state.md b/design/user-stories/us-012-configure-and-persist-state.md index 08709df..0800db7 100644 --- a/design/user-stories/us-012-configure-and-persist-state.md +++ b/design/user-stories/us-012-configure-and-persist-state.md @@ -2,7 +2,7 @@ type: User Story title: "US-012: Configure and persist game state" description: Give operators validated configuration and durable defensive storage for all Tyrant behavior. -status: backlog +status: done --- # US-012: Configure and persist game state @@ -11,15 +11,15 @@ As a **server operator**, I want configurable and durable game behavior so that ## Acceptance criteria -- [ ] Configuration covers ranges, durations, cooldowns, inactivity periods, candidate windows, retry intervals, effect levels and caps, mob restrictions, item materials and names, messages, and timer behavior. -- [ ] Defaults match the approved user stories, including a 50-block Tyrant range and Follower range, seven-day opt-out, 48-hour inactivity, and 24-hour candidate windows. -- [ ] Invalid required configuration prevents partial plugin initialization and produces a clear server log message. -- [ ] UUID-keyed state stores lifecycle, current and pending roles, assignments, login history, participation, reign progression, purchases, choices, cooldowns, paused time, item delivery, and captured mobs. -- [ ] Cooldowns and deadlines use UTC instants and exclude administratively paused time. -- [ ] State is saved using atomic replacement where supported so failed writes do not replace valid state with partial data. -- [ ] Corrupt, unknown, or invalid records cannot silently grant progression, roles, powers, items, or duplicated mobs. -- [ ] Unknown forward-compatible configuration and state fields are preserved where practical. -- [ ] Plugin disable removes transient effects and presentation safely while preserving durable state. +- [x] Configuration covers ranges, durations, cooldowns, inactivity periods, candidate windows, retry intervals, effect levels and caps, mob restrictions, item materials and names, messages, and timer behavior. +- [x] Defaults match the approved user stories, including a 50-block Tyrant range and Follower range, seven-day opt-out, 48-hour inactivity, and 24-hour candidate windows. +- [x] Invalid required configuration prevents partial plugin initialization and produces a clear server log message. +- [x] UUID-keyed state stores lifecycle, current and pending roles, assignments, login history, participation, reign progression, purchases, choices, cooldowns, paused time, item delivery, and captured mobs. +- [x] Cooldowns and deadlines use UTC instants and exclude administratively paused time. +- [x] State is saved using atomic replacement where supported so failed writes do not replace valid state with partial data. +- [x] Corrupt, unknown, or invalid records cannot silently grant progression, roles, powers, items, or duplicated mobs. +- [x] Unknown forward-compatible configuration and state fields are preserved where practical. +- [x] Plugin disable removes transient effects and presentation safely while preserving durable state. ## Related diff --git a/src/main/java/games/dmg/spigottyrant/Ability.java b/src/main/java/games/dmg/spigottyrant/Ability.java new file mode 100644 index 0000000..fb46467 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/Ability.java @@ -0,0 +1,9 @@ +package games.dmg.spigottyrant; + +public enum Ability { + ASSASSIN_INVISIBILITY, + ASSASSIN_DOUBLE_JUMP, + FIXER_BOOST, + TAMER_CAPTURE, + ROSTER_INTELLIGENCE +} diff --git a/src/main/java/games/dmg/spigottyrant/CapturedMob.java b/src/main/java/games/dmg/spigottyrant/CapturedMob.java new file mode 100644 index 0000000..51599e7 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/CapturedMob.java @@ -0,0 +1,14 @@ +package games.dmg.spigottyrant; + +import java.util.Locale; +import java.util.Map; + +public record CapturedMob(String entityType, Map data) { + public CapturedMob { + if (entityType == null || entityType.isBlank()) { + throw new IllegalArgumentException("entityType must not be blank"); + } + entityType = entityType.trim().toUpperCase(Locale.ROOT); + data = data == null ? Map.of() : Map.copyOf(data); + } +} diff --git a/src/main/java/games/dmg/spigottyrant/GameLifecycle.java b/src/main/java/games/dmg/spigottyrant/GameLifecycle.java new file mode 100644 index 0000000..2e4fe69 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/GameLifecycle.java @@ -0,0 +1,7 @@ +package games.dmg.spigottyrant; + +public enum GameLifecycle { + UNSTARTED, + RUNNING, + PAUSED +} diff --git a/src/main/java/games/dmg/spigottyrant/GameState.java b/src/main/java/games/dmg/spigottyrant/GameState.java new file mode 100644 index 0000000..e027d7e --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/GameState.java @@ -0,0 +1,59 @@ +package games.dmg.spigottyrant; + +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +public record GameState( + GameLifecycle lifecycle, + Optional tyrantId, + Optional vigilanteId, + Optional pendingTyrant, + Optional pendingVigilante, + Optional pausedAt, + Duration accumulatedPausedTime, + int tyrantLevel, + int unspentChoices, + Set purchases +) { + public GameState { + lifecycle = lifecycle == null ? GameLifecycle.UNSTARTED : lifecycle; + tyrantId = tyrantId == null ? Optional.empty() : tyrantId; + vigilanteId = vigilanteId == null ? Optional.empty() : vigilanteId; + pendingTyrant = pendingTyrant == null ? Optional.empty() : pendingTyrant; + pendingVigilante = pendingVigilante == null ? Optional.empty() : pendingVigilante; + pausedAt = pausedAt == null ? Optional.empty() : pausedAt; + accumulatedPausedTime = accumulatedPausedTime == null + ? Duration.ZERO : accumulatedPausedTime; + purchases = purchases == null ? Set.of() : Set.copyOf(purchases); + if (tyrantId.isPresent() && tyrantId.equals(vigilanteId)) { + throw new IllegalArgumentException("Tyrant and Vigilante must be different players"); + } + if (tyrantLevel < 0 || unspentChoices < 0) { + throw new IllegalArgumentException("progression values must not be negative"); + } + if (accumulatedPausedTime.isNegative()) { + throw new IllegalArgumentException("accumulated paused time must not be negative"); + } + if ((lifecycle == GameLifecycle.PAUSED) != pausedAt.isPresent()) { + throw new IllegalArgumentException("pausedAt must be present exactly while paused"); + } + } + + public static GameState empty() { + return new GameState( + GameLifecycle.UNSTARTED, + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Duration.ZERO, + 0, + 0, + Set.of() + ); + } +} diff --git a/src/main/java/games/dmg/spigottyrant/PauseAwareTime.java b/src/main/java/games/dmg/spigottyrant/PauseAwareTime.java new file mode 100644 index 0000000..080b6b7 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/PauseAwareTime.java @@ -0,0 +1,20 @@ +package games.dmg.spigottyrant; + +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; + +public final class PauseAwareTime { + private PauseAwareTime() { + } + + public static Instant shiftDeadline(Instant deadline, Instant pausedAt, Instant resumedAt) { + Objects.requireNonNull(deadline, "deadline"); + Objects.requireNonNull(pausedAt, "pausedAt"); + Objects.requireNonNull(resumedAt, "resumedAt"); + if (resumedAt.isBefore(pausedAt)) { + throw new IllegalArgumentException("resumedAt must not precede pausedAt"); + } + return deadline.plus(Duration.between(pausedAt, resumedAt)); + } +} diff --git a/src/main/java/games/dmg/spigottyrant/PendingSelection.java b/src/main/java/games/dmg/spigottyrant/PendingSelection.java new file mode 100644 index 0000000..0d5eafa --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/PendingSelection.java @@ -0,0 +1,12 @@ +package games.dmg.spigottyrant; + +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +public record PendingSelection(UUID candidateId, Instant expiresAt) { + public PendingSelection { + Objects.requireNonNull(candidateId, "candidateId"); + Objects.requireNonNull(expiresAt, "expiresAt"); + } +} diff --git a/src/main/java/games/dmg/spigottyrant/PersistentState.java b/src/main/java/games/dmg/spigottyrant/PersistentState.java new file mode 100644 index 0000000..ec47845 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/PersistentState.java @@ -0,0 +1,20 @@ +package games.dmg.spigottyrant; + +import java.util.Map; +import java.util.UUID; + +public record PersistentState(GameState game, Map players) { + public PersistentState { + game = game == null ? GameState.empty() : game; + players = players == null ? Map.of() : Map.copyOf(players); + for (Map.Entry entry : players.entrySet()) { + if (!entry.getKey().equals(entry.getValue().playerId())) { + throw new IllegalArgumentException("player map key must match player state ID"); + } + } + } + + public static PersistentState empty() { + return new PersistentState(GameState.empty(), Map.of()); + } +} diff --git a/src/main/java/games/dmg/spigottyrant/PlayerState.java b/src/main/java/games/dmg/spigottyrant/PlayerState.java new file mode 100644 index 0000000..6cfaad5 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/PlayerState.java @@ -0,0 +1,51 @@ +package games.dmg.spigottyrant; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +public record PlayerState( + UUID playerId, + String latestName, + Optional lastLogin, + Optional optedOutUntil, + TyrantClass tyrantClass, + Optional followerOf, + Map cooldownEnds, + Set readyAbilityItems, + List capturedMobs +) { + public PlayerState { + if (playerId == null) { + throw new IllegalArgumentException("playerId must not be null"); + } + if (latestName == null || latestName.isBlank()) { + throw new IllegalArgumentException("latestName must not be blank"); + } + latestName = latestName.trim(); + lastLogin = lastLogin == null ? Optional.empty() : lastLogin; + optedOutUntil = optedOutUntil == null ? Optional.empty() : optedOutUntil; + tyrantClass = tyrantClass == null ? TyrantClass.NONE : tyrantClass; + followerOf = followerOf == null ? Optional.empty() : followerOf; + cooldownEnds = cooldownEnds == null ? Map.of() : Map.copyOf(cooldownEnds); + readyAbilityItems = readyAbilityItems == null ? Set.of() : Set.copyOf(readyAbilityItems); + capturedMobs = capturedMobs == null ? List.of() : List.copyOf(capturedMobs); + } + + public static PlayerState newPlayer(UUID playerId, String latestName) { + return new PlayerState( + playerId, + latestName, + Optional.empty(), + Optional.empty(), + TyrantClass.NONE, + Optional.empty(), + Map.of(), + Set.of(), + List.of() + ); + } +} diff --git a/src/main/java/games/dmg/spigottyrant/PluginSettings.java b/src/main/java/games/dmg/spigottyrant/PluginSettings.java new file mode 100644 index 0000000..d5529ba --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/PluginSettings.java @@ -0,0 +1,266 @@ +package games.dmg.spigottyrant; + +import java.time.Duration; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; +import org.bukkit.configuration.ConfigurationSection; + +public record PluginSettings( + double tyrantRangeBlocks, + double followerRangeBlocks, + Duration optOutDuration, + Duration roleInactivity, + Duration candidateActivityWindow, + Duration pendingSelectionTimeout, + Duration selectionRetryInterval, + Duration assassinCooldown, + Duration assassinInvisibilityDuration, + Duration assassinDoubleJumpCooldown, + Duration assassinSpeedDuration, + Duration assassinWeaknessDuration, + int assassinWeaknessLevel, + Duration fixerCooldown, + Duration fixerEffectDuration, + int fixerStrengthLevel, + int fixerNormalHeartRows, + int fixerNearTyrantHeartRows, + Duration rosterIntelligenceCooldown, + int tyrantStrengthLevel, + int tyrantResistanceLevel, + int followerStrengthCap, + int followerResistanceCap, + Set deniedMobTypes, + AbilityItemSettings assassinItem, + AbilityItemSettings fixerItem, + AbilityItemSettings tamerItem, + String recoveryCommand, + String inventoryFullMessage, + boolean freezeTimersWhilePaused +) { + public PluginSettings { + requirePositiveFinite(tyrantRangeBlocks, "tyrant-range-blocks"); + requirePositiveFinite(followerRangeBlocks, "follower-range-blocks"); + requirePositive(optOutDuration, "opt-out-seconds"); + requirePositive(roleInactivity, "role-inactivity-seconds"); + requirePositive(candidateActivityWindow, "candidate-activity-window-seconds"); + requirePositive(pendingSelectionTimeout, "pending-selection-timeout-seconds"); + requirePositive(selectionRetryInterval, "selection-retry-seconds"); + requirePositive(assassinCooldown, "assassin-cooldown-seconds"); + requirePositive(assassinInvisibilityDuration, "assassin-invisibility-seconds"); + requirePositive(assassinDoubleJumpCooldown, "assassin-double-jump-cooldown-seconds"); + requirePositive(assassinSpeedDuration, "assassin-speed-seconds"); + requirePositive(assassinWeaknessDuration, "assassin-weakness-seconds"); + requireEffectLevel(assassinWeaknessLevel, "assassin-weakness-level"); + requirePositive(fixerCooldown, "fixer-cooldown-seconds"); + requirePositive(fixerEffectDuration, "fixer-effect-seconds"); + requireEffectLevel(fixerStrengthLevel, "fixer-strength-level"); + requireHeartRows(fixerNormalHeartRows, "fixer-normal-heart-rows"); + requireHeartRows(fixerNearTyrantHeartRows, "fixer-near-tyrant-heart-rows"); + if (fixerNearTyrantHeartRows < fixerNormalHeartRows) { + throw new IllegalArgumentException( + "fixer-near-tyrant-heart-rows must be at least fixer-normal-heart-rows" + ); + } + requirePositive(rosterIntelligenceCooldown, "roster-intelligence-cooldown-seconds"); + requireEffectLevel(tyrantStrengthLevel, "tyrant-strength-level"); + requireEffectLevel(tyrantResistanceLevel, "tyrant-resistance-level"); + requireEffectLevel(followerStrengthCap, "follower-strength-cap"); + requireEffectLevel(followerResistanceCap, "follower-resistance-cap"); + deniedMobTypes = normalizedSet(deniedMobTypes, "denied-mob-types"); + assassinItem = Objects.requireNonNull(assassinItem, "assassinItem"); + fixerItem = Objects.requireNonNull(fixerItem, "fixerItem"); + tamerItem = Objects.requireNonNull(tamerItem, "tamerItem"); + recoveryCommand = requireText(recoveryCommand, "recovery-command"); + inventoryFullMessage = requireText(inventoryFullMessage, "inventory-full-message"); + } + + public static PluginSettings from(Map values) { + Objects.requireNonNull(values, "values"); + return new PluginSettings( + decimal(values, "tyrant-range-blocks", 50.0), + decimal(values, "follower-range-blocks", 50.0), + duration(values, "opt-out-seconds", Duration.ofDays(7)), + duration(values, "role-inactivity-seconds", Duration.ofHours(48)), + duration(values, "candidate-activity-window-seconds", Duration.ofHours(24)), + duration(values, "pending-selection-timeout-seconds", Duration.ofHours(24)), + duration(values, "selection-retry-seconds", Duration.ofMinutes(1)), + duration(values, "assassin-cooldown-seconds", Duration.ofHours(1)), + duration(values, "assassin-invisibility-seconds", Duration.ofMinutes(10)), + duration(values, "assassin-double-jump-cooldown-seconds", Duration.ofSeconds(60)), + duration(values, "assassin-speed-seconds", Duration.ofSeconds(15)), + duration(values, "assassin-weakness-seconds", Duration.ofSeconds(20)), + integer(values, "assassin-weakness-level", 3), + duration(values, "fixer-cooldown-seconds", Duration.ofHours(1)), + duration(values, "fixer-effect-seconds", Duration.ofMinutes(10)), + integer(values, "fixer-strength-level", 1), + integer(values, "fixer-normal-heart-rows", 2), + integer(values, "fixer-near-tyrant-heart-rows", 3), + duration(values, "roster-intelligence-cooldown-seconds", Duration.ofHours(24)), + integer(values, "tyrant-strength-level", 1), + integer(values, "tyrant-resistance-level", 1), + integer(values, "follower-strength-cap", 5), + integer(values, "follower-resistance-cap", 4), + stringSet(values, "denied-mob-types", Set.of("ENDER_DRAGON", "WITHER")), + item(values, "assassin-item", new AbilityItemSettings("STICK", "Assassin Cloak")), + item(values, "fixer-item", new AbilityItemSettings("STICK", "Fixer's Wrench")), + item(values, "tamer-item", new AbilityItemSettings("FISHING_ROD", "Tamer's Lead")), + string(values, "recovery-command", "/tyrant item"), + string( + values, + "inventory-full-message", + "Your inventory is full. Make room and use /tyrant item." + ), + bool(values, "freeze-timers-while-paused", true) + ); + } + + private static AbilityItemSettings item( + Map values, + String key, + AbilityItemSettings defaultValue + ) { + Object raw = values.get(key); + if (raw == null) { + return defaultValue; + } + Map map; + if (raw instanceof ConfigurationSection section) { + map = section.getValues(false); + } else if (raw instanceof Map itemValues) { + map = itemValues; + } else { + throw new IllegalArgumentException(key + " must be a section"); + } + Object material = map.get("material"); + Object name = map.get("name"); + return new AbilityItemSettings( + material == null ? defaultValue.material() : material.toString(), + name == null ? defaultValue.name() : name.toString() + ); + } + + private static double decimal(Map values, String key, double defaultValue) { + Object raw = values.get(key); + if (raw == null) { + return defaultValue; + } + if (!(raw instanceof Number number)) { + throw new IllegalArgumentException(key + " must be numeric"); + } + return number.doubleValue(); + } + + private static int integer(Map values, String key, int defaultValue) { + Object raw = values.get(key); + if (raw == null) { + return defaultValue; + } + if (!(raw instanceof Number number)) { + throw new IllegalArgumentException(key + " must be an integer"); + } + double value = number.doubleValue(); + if (!Double.isFinite(value) || value != Math.rint(value) + || value < Integer.MIN_VALUE || value > Integer.MAX_VALUE) { + throw new IllegalArgumentException(key + " must be a 32-bit integer"); + } + return number.intValue(); + } + + private static String string(Map values, String key, String defaultValue) { + Object raw = values.get(key); + return raw == null ? defaultValue : raw.toString(); + } + + private static boolean bool(Map values, String key, boolean defaultValue) { + Object raw = values.get(key); + if (raw == null) { + return defaultValue; + } + if (!(raw instanceof Boolean value)) { + throw new IllegalArgumentException(key + " must be true or false"); + } + return value; + } + + private static Set stringSet( + Map values, + String key, + Set defaultValue + ) { + Object raw = values.get(key); + if (raw == null) { + return defaultValue; + } + if (!(raw instanceof List list)) { + throw new IllegalArgumentException(key + " must be a list"); + } + return list.stream().map(Object::toString).collect(Collectors.toUnmodifiableSet()); + } + + private static void requirePositiveFinite(double value, String key) { + if (!Double.isFinite(value) || value <= 0.0) { + throw new IllegalArgumentException(key + " must be positive and finite"); + } + } + + private static void requirePositive(Duration value, String key) { + Objects.requireNonNull(value, key); + if (value.isZero() || value.isNegative()) { + throw new IllegalArgumentException(key + " must be positive"); + } + } + + private static void requireEffectLevel(int value, String key) { + if (value < 1 || value > 255) { + throw new IllegalArgumentException(key + " must be between 1 and 255"); + } + } + + private static void requireHeartRows(int value, String key) { + if (value < 1 || value > 100) { + throw new IllegalArgumentException(key + " must be between 1 and 100"); + } + } + + private static Set normalizedSet(Set values, String key) { + if (values == null || values.isEmpty()) { + throw new IllegalArgumentException(key + " must not be empty"); + } + return values.stream() + .map(value -> requireText(value, key).toUpperCase(Locale.ROOT)) + .collect(Collectors.toUnmodifiableSet()); + } + + private static String requireText(String value, String key) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(key + " must not be blank"); + } + return value.trim(); + } + + private static Duration duration(Map values, String key, Duration defaultValue) { + Object raw = values.get(key); + if (raw == null) { + return defaultValue; + } + if (!(raw instanceof Number number)) { + throw new IllegalArgumentException(key + " must be an integer number of seconds"); + } + double seconds = number.doubleValue(); + if (!Double.isFinite(seconds) || seconds != Math.rint(seconds)) { + throw new IllegalArgumentException(key + " must be an integer number of seconds"); + } + return Duration.ofSeconds(number.longValue()); + } + + public record AbilityItemSettings(String material, String name) { + public AbilityItemSettings { + material = requireText(material, "item material").toUpperCase(Locale.ROOT); + name = requireText(name, "item name"); + } + } +} diff --git a/src/main/java/games/dmg/spigottyrant/PluginSettingsValidator.java b/src/main/java/games/dmg/spigottyrant/PluginSettingsValidator.java new file mode 100644 index 0000000..90a49d4 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/PluginSettingsValidator.java @@ -0,0 +1,36 @@ +package games.dmg.spigottyrant; + +import org.bukkit.Material; +import org.bukkit.entity.EntityType; + +public final class PluginSettingsValidator { + private PluginSettingsValidator() { + } + + public static PluginSettings validate(PluginSettings settings) { + validateItem(settings.assassinItem(), "assassin-item"); + validateItem(settings.fixerItem(), "fixer-item"); + validateItem(settings.tamerItem(), "tamer-item"); + for (String entityType : settings.deniedMobTypes()) { + try { + EntityType.valueOf(entityType); + } catch (IllegalArgumentException exception) { + throw new IllegalArgumentException( + entityType + " in denied-mob-types is not a known entity type", + exception + ); + } + } + if (!settings.deniedMobTypes().contains("ENDER_DRAGON")) { + throw new IllegalArgumentException("denied-mob-types must include ENDER_DRAGON"); + } + return settings; + } + + private static void validateItem(PluginSettings.AbilityItemSettings item, String key) { + Material material = Material.matchMaterial(item.material()); + if (material == null || material == Material.AIR) { + throw new IllegalArgumentException(key + " material must identify an item"); + } + } +} diff --git a/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java b/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java index f61c685..0d43787 100644 --- a/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java +++ b/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java @@ -1,10 +1,52 @@ package games.dmg.spigottyrant; +import java.io.IOException; +import java.util.Map; +import java.util.logging.Level; import org.bukkit.plugin.java.JavaPlugin; public final class SpigotTyrantPlugin extends JavaPlugin { + private PluginSettings settings; + private TyrantStateManager stateManager; + @Override public void onEnable() { + saveDefaultConfig(); + try { + Map values = getConfig().getValues(false); + settings = PluginSettingsValidator.validate(PluginSettings.from(values)); + stateManager = new TyrantStateManager( + new YamlTyrantStateRepository(getDataFolder().toPath().resolve("state.yml")), + getLogger() + ); + } catch (IllegalArgumentException | IOException exception) { + getLogger().log(Level.SEVERE, "Could not initialize Spigot Tyrant", exception); + getServer().getPluginManager().disablePlugin(this); + return; + } + + getServer().getScheduler().runTaskTimer(this, stateManager::saveIfDirty, 600L, 600L); getLogger().info("Spigot Tyrant enabled."); } + + @Override + public void onDisable() { + if (stateManager != null) { + stateManager.saveIfDirty(); + } + } + + PluginSettings settings() { + if (settings == null) { + throw new IllegalStateException("Plugin settings are unavailable"); + } + return settings; + } + + TyrantStateManager stateManager() { + if (stateManager == null) { + throw new IllegalStateException("State manager is unavailable"); + } + return stateManager; + } } diff --git a/src/main/java/games/dmg/spigottyrant/TyrantClass.java b/src/main/java/games/dmg/spigottyrant/TyrantClass.java new file mode 100644 index 0000000..920d1bd --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/TyrantClass.java @@ -0,0 +1,8 @@ +package games.dmg.spigottyrant; + +public enum TyrantClass { + NONE, + ASSASSIN, + FIXER, + TAMER +} diff --git a/src/main/java/games/dmg/spigottyrant/TyrantStateManager.java b/src/main/java/games/dmg/spigottyrant/TyrantStateManager.java new file mode 100644 index 0000000..864a200 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/TyrantStateManager.java @@ -0,0 +1,100 @@ +package games.dmg.spigottyrant; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.function.UnaryOperator; +import java.util.logging.Level; +import java.util.logging.Logger; + +public final class TyrantStateManager { + private final YamlTyrantStateRepository repository; + private final Logger logger; + private final Map players; + private GameState game; + private boolean dirty; + + public TyrantStateManager( + YamlTyrantStateRepository repository, + Logger logger + ) throws IOException { + this.repository = repository; + this.logger = logger; + PersistentState state = repository.load(); + game = state.game(); + players = new HashMap<>(state.players()); + } + + public GameState game() { + return game; + } + + public Map players() { + return Map.copyOf(players); + } + + public PlayerState player(UUID playerId, String latestName) { + PlayerState current = players.get(playerId); + if (current == null) { + current = PlayerState.newPlayer(playerId, latestName); + players.put(playerId, current); + dirty = true; + return current; + } + if (!current.latestName().equals(latestName)) { + current = copyWithName(current, latestName); + players.put(playerId, current); + dirty = true; + } + return current; + } + + public PlayerState updatePlayer( + UUID playerId, + String latestName, + UnaryOperator update + ) { + PlayerState changed = update.apply(player(playerId, latestName)); + if (!playerId.equals(changed.playerId())) { + throw new IllegalArgumentException("updated player ID cannot change"); + } + players.put(playerId, changed); + dirty = true; + return changed; + } + + public GameState updateGame(UnaryOperator update) { + game = update.apply(game); + dirty = true; + return game; + } + + public boolean saveIfDirty() { + if (!dirty) { + return true; + } + try { + repository.save(new PersistentState(game, players)); + dirty = false; + return true; + } catch (IOException exception) { + logger.log(Level.SEVERE, "Could not save Spigot Tyrant state", exception); + return false; + } + } + + private static PlayerState copyWithName(PlayerState player, String latestName) { + return new PlayerState( + player.playerId(), + latestName, + player.lastLogin(), + player.optedOutUntil(), + player.tyrantClass(), + player.followerOf(), + player.cooldownEnds(), + player.readyAbilityItems(), + player.capturedMobs() + ); + } +} diff --git a/src/main/java/games/dmg/spigottyrant/TyrantUnlock.java b/src/main/java/games/dmg/spigottyrant/TyrantUnlock.java new file mode 100644 index 0000000..de0a802 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/TyrantUnlock.java @@ -0,0 +1,10 @@ +package games.dmg.spigottyrant; + +public enum TyrantUnlock { + ASSASSIN, + FIXER, + TAMER, + ROSTER_INTELLIGENCE, + RESISTANCE, + STRENGTH +} diff --git a/src/main/java/games/dmg/spigottyrant/YamlTyrantStateRepository.java b/src/main/java/games/dmg/spigottyrant/YamlTyrantStateRepository.java new file mode 100644 index 0000000..74842d9 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/YamlTyrantStateRepository.java @@ -0,0 +1,335 @@ +package games.dmg.spigottyrant; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Duration; +import java.time.Instant; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.InvalidConfigurationException; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.EntityType; + +public final class YamlTyrantStateRepository { + private static final String PLAYERS = "players"; + private final Path stateFile; + + public YamlTyrantStateRepository(Path stateFile) { + this.stateFile = stateFile; + } + + public PersistentState load() throws IOException { + if (!Files.exists(stateFile)) { + return PersistentState.empty(); + } + YamlConfiguration yaml = loadYaml(); + return new PersistentState(loadGameSafely(yaml), loadPlayers(yaml)); + } + + public void save(PersistentState state) throws IOException { + Path parent = stateFile.toAbsolutePath().getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + YamlConfiguration yaml = Files.exists(stateFile) ? loadYaml() : new YamlConfiguration(); + saveGame(yaml, state.game()); + removeAbsentPlayers(yaml, state.players().keySet()); + for (PlayerState player : state.players().values()) { + savePlayer(yaml, player); + } + + Path temporary = Files.createTempFile(parent, "spigot-tyrant-state-", ".yml"); + try { + yaml.save(temporary.toFile()); + try { + Files.move( + temporary, + stateFile, + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE + ); + } catch (IOException atomicMoveFailure) { + Files.move(temporary, stateFile, StandardCopyOption.REPLACE_EXISTING); + } + } finally { + Files.deleteIfExists(temporary); + } + } + + private YamlConfiguration loadYaml() throws IOException { + YamlConfiguration yaml = new YamlConfiguration(); + try { + yaml.load(stateFile.toFile()); + return yaml; + } catch (InvalidConfigurationException exception) { + throw new IOException("state file is not valid YAML", exception); + } + } + + private static GameState loadGameSafely(YamlConfiguration yaml) { + try { + GameLifecycle lifecycle = enumValue( + GameLifecycle.class, + yaml.getString("game.lifecycle"), + GameLifecycle.UNSTARTED + ); + return new GameState( + lifecycle, + uuid(yaml.getString("game.tyrant-id")), + uuid(yaml.getString("game.vigilante-id")), + pending(yaml, "game.pending-tyrant"), + pending(yaml, "game.pending-vigilante"), + instant(yaml.getString("game.paused-at")), + Duration.ofSeconds(nonNegativeLong(yaml, "game.accumulated-paused-seconds")), + nonNegativeInt(yaml, "game.tyrant-level"), + nonNegativeInt(yaml, "game.unspent-choices"), + enumSet(TyrantUnlock.class, yaml.getStringList("game.purchases")) + ); + } catch (IllegalArgumentException exception) { + return GameState.empty(); + } + } + + private static Map loadPlayers(YamlConfiguration yaml) { + Map players = new HashMap<>(); + ConfigurationSection section = yaml.getConfigurationSection(PLAYERS); + if (section == null) { + return players; + } + for (String key : section.getKeys(false)) { + try { + UUID playerId = UUID.fromString(key); + String path = PLAYERS + "." + key; + String name = requireText(yaml.getString(path + ".name"), "player name"); + PlayerState player = new PlayerState( + playerId, + name, + instant(yaml.getString(path + ".last-login")), + instant(yaml.getString(path + ".opted-out-until")), + enumValue( + TyrantClass.class, + yaml.getString(path + ".tyrant-class"), + TyrantClass.NONE + ), + uuid(yaml.getString(path + ".follower-of")), + loadCooldowns(yaml, path + ".cooldowns"), + enumSet(Ability.class, yaml.getStringList(path + ".ready-ability-items")), + loadCapturedMobs(yaml.getMapList(path + ".captured-mobs")) + ); + players.put(playerId, player); + } catch (IllegalArgumentException ignored) { + // Invalid player records grant no state. + } + } + return players; + } + + private static Map loadCooldowns(YamlConfiguration yaml, String path) { + Map cooldowns = new EnumMap<>(Ability.class); + ConfigurationSection section = yaml.getConfigurationSection(path); + if (section == null) { + return cooldowns; + } + for (String key : section.getKeys(false)) { + Ability ability = enumValue(Ability.class, key, null); + Optional end = instant(yaml.getString(path + "." + key)); + if (ability != null && end.isPresent()) { + cooldowns.put(ability, end.orElseThrow()); + } + } + return cooldowns; + } + + private static List loadCapturedMobs(List> rawMobs) { + List mobs = new ArrayList<>(); + for (Map raw : rawMobs) { + try { + String type = requireText( + raw.get("entity-type") == null ? null : raw.get("entity-type").toString(), + "captured mob entity type" + ); + EntityType entityType = EntityType.valueOf(type.toUpperCase(java.util.Locale.ROOT)); + if (entityType == EntityType.ENDER_DRAGON) { + continue; + } + Map data = new HashMap<>(); + Object rawData = raw.get("data"); + if (rawData instanceof Map values) { + for (Map.Entry entry : values.entrySet()) { + if (entry.getKey() != null && entry.getValue() != null) { + data.put(entry.getKey().toString(), entry.getValue().toString()); + } + } + } + mobs.add(new CapturedMob(entityType.name(), data)); + } catch (IllegalArgumentException ignored) { + // Unknown or malformed entities are not restored. + } + } + return List.copyOf(mobs); + } + + private static void saveGame(YamlConfiguration yaml, GameState game) { + yaml.set("game.lifecycle", game.lifecycle().name()); + yaml.set("game.tyrant-id", text(game.tyrantId())); + yaml.set("game.vigilante-id", text(game.vigilanteId())); + savePending(yaml, "game.pending-tyrant", game.pendingTyrant()); + savePending(yaml, "game.pending-vigilante", game.pendingVigilante()); + yaml.set("game.paused-at", instantText(game.pausedAt())); + yaml.set("game.accumulated-paused-seconds", game.accumulatedPausedTime().toSeconds()); + yaml.set("game.tyrant-level", game.tyrantLevel()); + yaml.set("game.unspent-choices", game.unspentChoices()); + yaml.set("game.purchases", game.purchases().stream().map(Enum::name).sorted().toList()); + } + + private static void savePlayer(YamlConfiguration yaml, PlayerState player) { + String path = PLAYERS + "." + player.playerId(); + yaml.set(path + ".name", player.latestName()); + yaml.set(path + ".last-login", instantText(player.lastLogin())); + yaml.set(path + ".opted-out-until", instantText(player.optedOutUntil())); + yaml.set(path + ".tyrant-class", player.tyrantClass().name()); + yaml.set(path + ".follower-of", text(player.followerOf())); + yaml.set(path + ".cooldowns", null); + for (Map.Entry cooldown : player.cooldownEnds().entrySet()) { + yaml.set(path + ".cooldowns." + cooldown.getKey().name(), cooldown.getValue().toString()); + } + yaml.set( + path + ".ready-ability-items", + player.readyAbilityItems().stream().map(Enum::name).sorted().toList() + ); + List> mobs = new ArrayList<>(); + for (CapturedMob mob : player.capturedMobs()) { + Map serialized = new LinkedHashMap<>(); + serialized.put("entity-type", mob.entityType()); + serialized.put("data", mob.data()); + mobs.add(serialized); + } + yaml.set(path + ".captured-mobs", mobs); + } + + private static void removeAbsentPlayers(YamlConfiguration yaml, Set retained) { + ConfigurationSection section = yaml.getConfigurationSection(PLAYERS); + if (section == null) { + return; + } + for (String key : section.getKeys(false)) { + try { + if (!retained.contains(UUID.fromString(key))) { + yaml.set(PLAYERS + "." + key, null); + } + } catch (IllegalArgumentException exception) { + yaml.set(PLAYERS + "." + key, null); + } + } + } + + private static Optional pending(YamlConfiguration yaml, String path) { + Optional candidate = uuid(yaml.getString(path + ".candidate-id")); + Optional expiresAt = instant(yaml.getString(path + ".expires-at")); + if (candidate.isEmpty() && expiresAt.isEmpty()) { + return Optional.empty(); + } + if (candidate.isEmpty() || expiresAt.isEmpty()) { + throw new IllegalArgumentException("incomplete pending selection"); + } + return Optional.of(new PendingSelection(candidate.orElseThrow(), expiresAt.orElseThrow())); + } + + private static void savePending( + YamlConfiguration yaml, + String path, + Optional pending + ) { + yaml.set(path, null); + pending.ifPresent(selection -> { + yaml.set(path + ".candidate-id", selection.candidateId().toString()); + yaml.set(path + ".expires-at", selection.expiresAt().toString()); + }); + } + + private static Optional uuid(String value) { + if (value == null || value.isBlank()) { + return Optional.empty(); + } + return Optional.of(UUID.fromString(value)); + } + + private static Optional instant(String value) { + if (value == null || value.isBlank()) { + return Optional.empty(); + } + try { + return Optional.of(Instant.parse(value)); + } catch (DateTimeParseException exception) { + throw new IllegalArgumentException("invalid RFC 3339 instant", exception); + } + } + + private static String instantText(Optional value) { + return value.map(Instant::toString).orElse(null); + } + + private static String text(Optional value) { + return value.map(UUID::toString).orElse(null); + } + + private static int nonNegativeInt(YamlConfiguration yaml, String path) { + if (!yaml.isInt(path)) { + return 0; + } + int value = yaml.getInt(path); + if (value < 0) { + throw new IllegalArgumentException(path + " must not be negative"); + } + return value; + } + + private static long nonNegativeLong(YamlConfiguration yaml, String path) { + if (!yaml.isLong(path) && !yaml.isInt(path)) { + return 0L; + } + long value = yaml.getLong(path); + if (value < 0L) { + throw new IllegalArgumentException(path + " must not be negative"); + } + return value; + } + + private static > E enumValue( + Class type, + String value, + E defaultValue + ) { + if (value == null || value.isBlank()) { + return defaultValue; + } + return Enum.valueOf(type, value.trim().toUpperCase(java.util.Locale.ROOT)); + } + + private static > Set enumSet(Class type, List values) { + Set result = EnumSet.noneOf(type); + for (String value : values) { + result.add(enumValue(type, value, null)); + } + return Set.copyOf(result); + } + + private static String requireText(String value, String description) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(description + " must not be blank"); + } + return value.trim(); + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml new file mode 100644 index 0000000..6c4a657 --- /dev/null +++ b/src/main/resources/config.yml @@ -0,0 +1,51 @@ +# Proximity ranges +tyrant-range-blocks: 50 +follower-range-blocks: 50 + +# Participation, inactivity, and selection +opt-out-seconds: 604800 +role-inactivity-seconds: 172800 +candidate-activity-window-seconds: 86400 +pending-selection-timeout-seconds: 86400 +selection-retry-seconds: 60 +freeze-timers-while-paused: true + +# Assassin +assassin-cooldown-seconds: 3600 +assassin-invisibility-seconds: 600 +assassin-double-jump-cooldown-seconds: 60 +assassin-speed-seconds: 15 +assassin-weakness-seconds: 20 +assassin-weakness-level: 3 +assassin-item: + material: STICK + name: Assassin Cloak + +# Fixer +fixer-cooldown-seconds: 3600 +fixer-effect-seconds: 600 +fixer-strength-level: 1 +fixer-normal-heart-rows: 2 +fixer-near-tyrant-heart-rows: 3 +fixer-item: + material: STICK + name: Fixer's Wrench + +# Tamer +tamer-item: + material: FISHING_ROD + name: Tamer's Lead +denied-mob-types: + - ENDER_DRAGON + - WITHER + +# Tyrant and Vigilante effects +roster-intelligence-cooldown-seconds: 86400 +tyrant-strength-level: 1 +tyrant-resistance-level: 1 +follower-strength-cap: 5 +follower-resistance-cap: 4 + +# Bound item recovery and delivery messages +recovery-command: /tyrant item +inventory-full-message: Your inventory is full. Make room and use /tyrant item. diff --git a/src/test/java/games/dmg/spigottyrant/DefaultConfigurationTest.java b/src/test/java/games/dmg/spigottyrant/DefaultConfigurationTest.java new file mode 100644 index 0000000..c7d6484 --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/DefaultConfigurationTest.java @@ -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 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()); + } +} diff --git a/src/test/java/games/dmg/spigottyrant/PauseAwareTimeTest.java b/src/test/java/games/dmg/spigottyrant/PauseAwareTimeTest.java new file mode 100644 index 0000000..c0463de --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/PauseAwareTimeTest.java @@ -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); + } +} diff --git a/src/test/java/games/dmg/spigottyrant/PluginSettingsTest.java b/src/test/java/games/dmg/spigottyrant/PluginSettingsTest.java new file mode 100644 index 0000000..e9ff215 --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/PluginSettingsTest.java @@ -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)) + ); + } +} diff --git a/src/test/java/games/dmg/spigottyrant/PluginSettingsValidatorTest.java b/src/test/java/games/dmg/spigottyrant/PluginSettingsValidatorTest.java new file mode 100644 index 0000000..09cd2fc --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/PluginSettingsValidatorTest.java @@ -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") + ))) + ); + } +} diff --git a/src/test/java/games/dmg/spigottyrant/TyrantStateManagerTest.java b/src/test/java/games/dmg/spigottyrant/TyrantStateManagerTest.java new file mode 100644 index 0000000..110a474 --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/TyrantStateManagerTest.java @@ -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()); + } +} diff --git a/src/test/java/games/dmg/spigottyrant/YamlTyrantStateRepositoryTest.java b/src/test/java/games/dmg/spigottyrant/YamlTyrantStateRepositoryTest.java new file mode 100644 index 0000000..8a9435f --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/YamlTyrantStateRepositoryTest.java @@ -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()); + } +}