Compare commits

...
2 Commits
Author SHA1 Message Date
dmg 805efda8bb feat(progression): add tyrant unlock choices
Release / release (push) Successful in 2m9s
CI / build (push) Successful in 55s
2026-08-14 22:33:11 -04:00
dmg d604dca129 feat(state): add validated persistent game state
Release / release (push) Successful in 2m13s
CI / build (push) Successful in 53s
2026-08-14 22:29:49 -04:00
33 changed files with 1749 additions and 19 deletions
+21
View File
@@ -6,6 +6,27 @@ description: Chronological record of material decisions affecting the Spigot Tyr
# Spigot Tyrant Design Log
## 2026-08-14 — Tyrant progression completed
- Completed US-003 with exact Tyrant-on-Vigilante kill attribution, level and choice rewards, pause guards, one-time purchases, and reign reset behavior.
- Added `/tyrant choices` status output and `/tyrant buy <unlock>` with durable atomic state updates and clear failure feedback.
- Verified progression, duplicate purchase, authorization, paused-state, reset, command presentation, metadata, and persistence behavior with the Gradle test suite.
## 2026-08-14 — Tyrant progression implementation started
- US-003 begins with test-first kill attribution, one-time unlock purchases, and player-visible choice status.
## 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.
@@ -2,7 +2,7 @@
type: User Story
title: "US-003: Progress and purchase Tyrant unlocks"
description: Let the Tyrant earn and spend choices by personally defeating the Vigilante.
status: backlog
status: done
---
# US-003: Progress and purchase Tyrant unlocks
@@ -11,14 +11,14 @@ As the **Tyrant**, I want to earn one meaningful choice for defeating the Vigila
## Acceptance criteria
- [ ] The Tyrant gains one level and one unlock choice only when personally credited with killing the current Vigilante.
- [ ] A Vigilante death caused by another player, the environment, or the Vigilante does not level the Tyrant.
- [ ] Available purchases are Assassin, Fixer, Tamer, roster intelligence, permanent Resistance, and permanent Strength.
- [ ] Each class or ability can be purchased at most once during a reign.
- [ ] A menu or command clearly identifies purchased, available, and unavailable choices.
- [ ] A purchase consumes exactly one unspent choice and cannot be duplicated by retries or concurrent input.
- [ ] Progress cannot be earned or spent while the game is paused.
- [ ] Levels, purchases, and unspent choices survive logout and restart but reset when the reign ends.
- [x] The Tyrant gains one level and one unlock choice only when personally credited with killing the current Vigilante.
- [x] A Vigilante death caused by another player, the environment, or the Vigilante does not level the Tyrant.
- [x] Available purchases are Assassin, Fixer, Tamer, roster intelligence, permanent Resistance, and permanent Strength.
- [x] Each class or ability can be purchased at most once during a reign.
- [x] A menu or command clearly identifies purchased, available, and unavailable choices.
- [x] A purchase consumes exactly one unspent choice and cannot be duplicated by retries or concurrent input.
- [x] Progress cannot be earned or spent while the game is paused.
- [x] Levels, purchases, and unspent choices survive logout and restart but reset when the reign ends.
## Related
@@ -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
@@ -0,0 +1,9 @@
package games.dmg.spigottyrant;
public enum Ability {
ASSASSIN_INVISIBILITY,
ASSASSIN_DOUBLE_JUMP,
FIXER_BOOST,
TAMER_CAPTURE,
ROSTER_INTELLIGENCE
}
@@ -0,0 +1,14 @@
package games.dmg.spigottyrant;
import java.util.Locale;
import java.util.Map;
public record CapturedMob(String entityType, Map<String, String> 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);
}
}
@@ -0,0 +1,7 @@
package games.dmg.spigottyrant;
public enum GameLifecycle {
UNSTARTED,
RUNNING,
PAUSED
}
@@ -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<UUID> tyrantId,
Optional<UUID> vigilanteId,
Optional<PendingSelection> pendingTyrant,
Optional<PendingSelection> pendingVigilante,
Optional<Instant> pausedAt,
Duration accumulatedPausedTime,
int tyrantLevel,
int unspentChoices,
Set<TyrantUnlock> 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()
);
}
}
@@ -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));
}
}
@@ -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");
}
}
@@ -0,0 +1,20 @@
package games.dmg.spigottyrant;
import java.util.Map;
import java.util.UUID;
public record PersistentState(GameState game, Map<UUID, PlayerState> players) {
public PersistentState {
game = game == null ? GameState.empty() : game;
players = players == null ? Map.of() : Map.copyOf(players);
for (Map.Entry<UUID, PlayerState> 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());
}
}
@@ -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<Instant> lastLogin,
Optional<Instant> optedOutUntil,
TyrantClass tyrantClass,
Optional<UUID> followerOf,
Map<Ability, Instant> cooldownEnds,
Set<Ability> readyAbilityItems,
List<CapturedMob> 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()
);
}
}
@@ -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<String> 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<String, ?> 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<String, ?> 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<String, ?> 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<String, ?> 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<String, ?> values, String key, String defaultValue) {
Object raw = values.get(key);
return raw == null ? defaultValue : raw.toString();
}
private static boolean bool(Map<String, ?> 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<String> stringSet(
Map<String, ?> values,
String key,
Set<String> 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<String> normalizedSet(Set<String> 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<String, ?> 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");
}
}
}
@@ -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");
}
}
}
@@ -0,0 +1,10 @@
package games.dmg.spigottyrant;
import java.util.Objects;
public record PurchaseResult(GameState state, PurchaseStatus status) {
public PurchaseResult {
Objects.requireNonNull(state, "state");
Objects.requireNonNull(status, "status");
}
}
@@ -0,0 +1,9 @@
package games.dmg.spigottyrant;
public enum PurchaseStatus {
PURCHASED,
GAME_NOT_RUNNING,
NOT_TYRANT,
NO_CHOICES,
ALREADY_PURCHASED
}
@@ -1,10 +1,55 @@
package games.dmg.spigottyrant;
import java.io.IOException;
import java.util.Map;
import java.util.Objects;
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<String, Object> 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;
}
Objects.requireNonNull(getCommand("tyrant"), "Missing tyrant command metadata")
.setExecutor(new TyrantCommand(stateManager, new TyrantProgressionService()));
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;
}
}
@@ -0,0 +1,8 @@
package games.dmg.spigottyrant;
public enum TyrantClass {
NONE,
ASSASSIN,
FIXER,
TAMER
}
@@ -0,0 +1,86 @@
package games.dmg.spigottyrant;
import java.util.Locale;
import org.bukkit.ChatColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
public final class TyrantCommand implements CommandExecutor {
private final TyrantStateManager stateManager;
private final TyrantProgressionService progression;
public TyrantCommand(
TyrantStateManager stateManager,
TyrantProgressionService progression
) {
this.stateManager = stateManager;
this.progression = progression;
}
@Override
public boolean onCommand(
CommandSender sender,
Command command,
String label,
String[] arguments
) {
if (!(sender instanceof Player player)) {
sender.sendMessage("This command must be used by a player.");
return true;
}
if (arguments.length == 0 || arguments[0].equalsIgnoreCase("choices")) {
showChoices(player);
return true;
}
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("buy")) {
buy(player, arguments[1]);
return true;
}
player.sendMessage(ChatColor.YELLOW + "Usage: /tyrant choices | /tyrant buy <unlock>");
return true;
}
private void showChoices(Player player) {
GameState state = stateManager.game();
player.sendMessage(ChatColor.GOLD + "Tyrant unlock choices: " + state.unspentChoices());
for (TyrantUnlock unlock : TyrantUnlock.values()) {
String status;
if (state.purchases().contains(unlock)) {
status = "PURCHASED";
} else if (state.lifecycle() == GameLifecycle.RUNNING
&& state.tyrantId().filter(player.getUniqueId()::equals).isPresent()
&& state.unspentChoices() > 0) {
status = "AVAILABLE";
} else {
status = "LOCKED";
}
player.sendMessage(ChatColor.YELLOW + "[" + status + "] " + unlock.name());
}
}
private void buy(Player player, String requestedUnlock) {
TyrantUnlock unlock;
try {
unlock = TyrantUnlock.valueOf(requestedUnlock.toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException exception) {
player.sendMessage(ChatColor.RED + "Unknown unlock. Use /tyrant choices.");
return;
}
PurchaseResult[] result = new PurchaseResult[1];
stateManager.updateGame(current -> {
result[0] = progression.purchase(current, player.getUniqueId(), unlock);
return result[0].state();
});
if (result[0].status() == PurchaseStatus.PURCHASED) {
stateManager.saveIfDirty();
player.sendMessage(ChatColor.GREEN + "Purchased " + unlock.name() + ".");
} else {
player.sendMessage(
ChatColor.RED + "Could not purchase " + unlock.name() + ": "
+ result[0].status().name().toLowerCase(Locale.ROOT).replace('_', ' ')
);
}
}
}
@@ -0,0 +1,75 @@
package games.dmg.spigottyrant;
import java.util.Optional;
import java.util.UUID;
public final class TyrantProgressionService {
public GameState recordVigilanteDeath(GameState state, Optional<UUID> killerId) {
if (state.lifecycle() != GameLifecycle.RUNNING
|| state.tyrantId().isEmpty()
|| state.vigilanteId().isEmpty()
|| killerId.isEmpty()
|| !state.tyrantId().orElseThrow().equals(killerId.orElseThrow())) {
return state;
}
return copyProgress(
state,
state.tyrantLevel() + 1,
state.unspentChoices() + 1,
state.purchases()
);
}
public PurchaseResult purchase(
GameState state,
UUID buyerId,
TyrantUnlock unlock
) {
if (state.lifecycle() != GameLifecycle.RUNNING) {
return new PurchaseResult(state, PurchaseStatus.GAME_NOT_RUNNING);
}
if (state.tyrantId().isEmpty() || !state.tyrantId().orElseThrow().equals(buyerId)) {
return new PurchaseResult(state, PurchaseStatus.NOT_TYRANT);
}
if (state.purchases().contains(unlock)) {
return new PurchaseResult(state, PurchaseStatus.ALREADY_PURCHASED);
}
if (state.unspentChoices() == 0) {
return new PurchaseResult(state, PurchaseStatus.NO_CHOICES);
}
java.util.Set<TyrantUnlock> purchases = java.util.EnumSet.noneOf(TyrantUnlock.class);
purchases.addAll(state.purchases());
purchases.add(unlock);
GameState updated = copyProgress(
state,
state.tyrantLevel(),
state.unspentChoices() - 1,
purchases
);
return new PurchaseResult(updated, PurchaseStatus.PURCHASED);
}
public GameState resetReignProgression(GameState state) {
return copyProgress(state, 0, 0, java.util.Set.of());
}
private static GameState copyProgress(
GameState state,
int level,
int choices,
java.util.Set<TyrantUnlock> purchases
) {
return new GameState(
state.lifecycle(),
state.tyrantId(),
state.vigilanteId(),
state.pendingTyrant(),
state.pendingVigilante(),
state.pausedAt(),
state.accumulatedPausedTime(),
level,
choices,
purchases
);
}
}
@@ -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<UUID, PlayerState> 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<UUID, PlayerState> 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<PlayerState> 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<GameState> 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()
);
}
}
@@ -0,0 +1,10 @@
package games.dmg.spigottyrant;
public enum TyrantUnlock {
ASSASSIN,
FIXER,
TAMER,
ROSTER_INTELLIGENCE,
RESISTANCE,
STRENGTH
}
@@ -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<UUID, PlayerState> loadPlayers(YamlConfiguration yaml) {
Map<UUID, PlayerState> 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<Ability, Instant> loadCooldowns(YamlConfiguration yaml, String path) {
Map<Ability, Instant> 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<Instant> end = instant(yaml.getString(path + "." + key));
if (ability != null && end.isPresent()) {
cooldowns.put(ability, end.orElseThrow());
}
}
return cooldowns;
}
private static List<CapturedMob> loadCapturedMobs(List<Map<?, ?>> rawMobs) {
List<CapturedMob> 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<String, String> 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<Ability, Instant> 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<Map<String, Object>> mobs = new ArrayList<>();
for (CapturedMob mob : player.capturedMobs()) {
Map<String, Object> 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<UUID> 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<PendingSelection> pending(YamlConfiguration yaml, String path) {
Optional<UUID> candidate = uuid(yaml.getString(path + ".candidate-id"));
Optional<Instant> 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<PendingSelection> 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> uuid(String value) {
if (value == null || value.isBlank()) {
return Optional.empty();
}
return Optional.of(UUID.fromString(value));
}
private static Optional<Instant> 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<Instant> value) {
return value.map(Instant::toString).orElse(null);
}
private static String text(Optional<UUID> 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 extends Enum<E>> E enumValue(
Class<E> 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 <E extends Enum<E>> Set<E> enumSet(Class<E> type, List<String> values) {
Set<E> 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();
}
}
+51
View File
@@ -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.
+4
View File
@@ -4,3 +4,7 @@ main: games.dmg.spigottyrant.SpigotTyrantPlugin
api-version: "1.20"
description: Asymmetric Tyrant, Vigilante, Follower, and specialist class gameplay.
author: dmg.games
commands:
tyrant:
description: View and use Spigot Tyrant game features.
usage: /tyrant [choices|buy <unlock>]
@@ -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);
}
}
@@ -20,6 +20,9 @@ final class PluginMetadataTest {
assertEquals("SpigotTyrant", plugin.get("name"));
assertEquals("games.dmg.spigottyrant.SpigotTyrantPlugin", plugin.get("main"));
assertEquals("1.20", plugin.get("api-version"));
Map<?, ?> commands = (Map<?, ?>) plugin.get("commands");
assertNotNull(commands);
assertNotNull(commands.get("tyrant"));
}
@Test
@@ -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,50 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Duration;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.bukkit.command.Command;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
final class TyrantCommandTest {
@Test
void choicesClearlyIdentifyPurchasedAvailableAndUnavailableUnlocks() {
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
GameState state = new GameState(
GameLifecycle.RUNNING,
Optional.of(tyrantId),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Duration.ZERO,
1,
1,
Set.of(TyrantUnlock.STRENGTH)
);
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.game()).thenReturn(state);
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(tyrantId);
TyrantCommand command = new TyrantCommand(manager, new TyrantProgressionService());
command.onCommand(player, mock(Command.class), "tyrant", new String[] {"choices"});
ArgumentCaptor<String> messages = ArgumentCaptor.forClass(String.class);
verify(player, org.mockito.Mockito.atLeastOnce()).sendMessage(messages.capture());
List<String> all = messages.getAllValues();
assertTrue(all.stream().anyMatch(message -> message.contains("PURCHASED")
&& message.contains("STRENGTH")));
assertTrue(all.stream().anyMatch(message -> message.contains("AVAILABLE")
&& message.contains("ASSASSIN")));
}
}
@@ -0,0 +1,124 @@
package games.dmg.spigottyrant;
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.time.Duration;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class TyrantProgressionServiceTest {
private static final UUID TYRANT = UUID.fromString("11111111-1111-1111-1111-111111111111");
private static final UUID VIGILANTE = UUID.fromString("22222222-2222-2222-2222-222222222222");
private static final UUID OTHER = UUID.fromString("33333333-3333-3333-3333-333333333333");
private final TyrantProgressionService service = new TyrantProgressionService();
@Test
void tyrantPersonallyKillingCurrentVigilanteEarnsOneLevelAndChoice() {
GameState before = runningState(0, 1, Set.of());
GameState after = service.recordVigilanteDeath(before, Optional.of(TYRANT));
assertEquals(1, after.tyrantLevel());
assertEquals(2, after.unspentChoices());
}
@Test
void otherEnvironmentalAndPausedDeathsDoNotEarnProgress() {
GameState running = runningState(2, 0, Set.of());
GameState paused = new GameState(
GameLifecycle.PAUSED,
running.tyrantId(),
running.vigilanteId(),
running.pendingTyrant(),
running.pendingVigilante(),
Optional.of(java.time.Instant.parse("2026-08-14T12:00:00Z")),
Duration.ZERO,
2,
0,
Set.of()
);
assertEquals(running, service.recordVigilanteDeath(running, Optional.of(OTHER)));
assertEquals(running, service.recordVigilanteDeath(running, Optional.empty()));
assertEquals(paused, service.recordVigilanteDeath(paused, Optional.of(TYRANT)));
}
@Test
void purchaseConsumesOneChoiceAndCannotBeBoughtTwice() {
GameState before = runningState(1, 2, Set.of());
PurchaseResult first = service.purchase(before, TYRANT, TyrantUnlock.ASSASSIN);
PurchaseResult duplicate = service.purchase(
first.state(), TYRANT, TyrantUnlock.ASSASSIN
);
assertEquals(PurchaseStatus.PURCHASED, first.status());
assertEquals(1, first.state().unspentChoices());
assertTrue(first.state().purchases().contains(TyrantUnlock.ASSASSIN));
assertEquals(PurchaseStatus.ALREADY_PURCHASED, duplicate.status());
assertEquals(first.state(), duplicate.state());
}
@Test
void nonTyrantPausedAndNoChoicePurchasesAreRejected() {
GameState noChoices = runningState(0, 0, Set.of());
GameState paused = new GameState(
GameLifecycle.PAUSED,
noChoices.tyrantId(),
noChoices.vigilanteId(),
noChoices.pendingTyrant(),
noChoices.pendingVigilante(),
Optional.of(java.time.Instant.parse("2026-08-14T12:00:00Z")),
Duration.ZERO,
0,
1,
Set.of()
);
assertEquals(PurchaseStatus.NO_CHOICES,
service.purchase(noChoices, TYRANT, TyrantUnlock.FIXER).status());
assertEquals(PurchaseStatus.NOT_TYRANT,
service.purchase(runningState(0, 1, Set.of()), OTHER, TyrantUnlock.FIXER).status());
PurchaseResult pausedResult = service.purchase(paused, TYRANT, TyrantUnlock.FIXER);
assertEquals(PurchaseStatus.GAME_NOT_RUNNING, pausedResult.status());
assertFalse(pausedResult.state().purchases().contains(TyrantUnlock.FIXER));
}
@Test
void endingReignClearsLevelChoicesAndPurchases() {
GameState progressed = runningState(
4,
2,
Set.of(TyrantUnlock.ASSASSIN, TyrantUnlock.STRENGTH)
);
GameState reset = service.resetReignProgression(progressed);
assertEquals(0, reset.tyrantLevel());
assertEquals(0, reset.unspentChoices());
assertEquals(Set.of(), reset.purchases());
}
private static GameState runningState(
int level,
int choices,
Set<TyrantUnlock> purchases
) {
return new GameState(
GameLifecycle.RUNNING,
Optional.of(TYRANT),
Optional.of(VIGILANTE),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Duration.ZERO,
level,
choices,
purchases
);
}
}
@@ -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());
}
}