Compare commits

..
3 Commits
Author SHA1 Message Date
dmg 63dc5ff736 feat(classes): add tyrant class assignment
Release / release (push) Successful in 2m14s
CI / build (push) Successful in 57s
2026-08-14 22:37:09 -04:00
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
43 changed files with 2292 additions and 28 deletions
+32
View File
@@ -6,6 +6,38 @@ description: Chronological record of material decisions affecting the Spigot Tyr
# Spigot Tyrant Design Log # Spigot Tyrant Design Log
## 2026-08-14 — Tyrant class assignment completed
- Completed US-004 with `/tyrant assign <class> <player>`, purchased-class authorization, UUID state, one holder per class, and one Tyrant-side class per player.
- Preserved dual alignment so Vigilantes and Followers can hold a Tyrant-side class, while rejecting the Tyrant and opted-out targets.
- Added reassignment and reign cleanup for class cooldown/item state, online assignment/removal notices, and login control reminders.
- Verified assignment constraints, dual alignment, cleanup, commands, notifications, and reminders with the Gradle test suite.
## 2026-08-14 — Tyrant class assignment implementation started
- US-004 begins with test-first purchased-class authorization, one-holder constraints, dual alignment, reassignment cleanup, and reign-wide removal.
## 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 ## 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. - 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 type: User Story
title: "US-003: Progress and purchase Tyrant unlocks" title: "US-003: Progress and purchase Tyrant unlocks"
description: Let the Tyrant earn and spend choices by personally defeating the Vigilante. description: Let the Tyrant earn and spend choices by personally defeating the Vigilante.
status: backlog status: done
--- ---
# US-003: Progress and purchase Tyrant unlocks # 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 ## Acceptance criteria
- [ ] The Tyrant gains one level and one unlock choice only when personally credited with killing the current Vigilante. - [x] 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. - [x] 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. - [x] 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. - [x] Each class or ability can be purchased at most once during a reign.
- [ ] A menu or command clearly identifies purchased, available, and unavailable choices. - [x] 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. - [x] 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. - [x] 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] Levels, purchases, and unspent choices survive logout and restart but reset when the reign ends.
## Related ## Related
@@ -2,7 +2,7 @@
type: User Story type: User Story
title: "US-004: Assign unlocked classes" title: "US-004: Assign unlocked classes"
description: Let the Tyrant assign each purchased class to one eligible participant. description: Let the Tyrant assign each purchased class to one eligible participant.
status: backlog status: done
--- ---
# US-004: Assign unlocked classes # US-004: Assign unlocked classes
@@ -11,14 +11,14 @@ As the **Tyrant**, I want to assign unlocked specialist classes so that I can bu
## Acceptance criteria ## Acceptance criteria
- [ ] The Tyrant can assign only Assassin, Fixer, and Tamer classes purchased during the current reign. - [x] The Tyrant can assign only Assassin, Fixer, and Tamer classes purchased during the current reign.
- [ ] Each class can be assigned to at most one player at a time, and a player can hold at most one Tyrant-side class. - [x] Each class can be assigned to at most one player at a time, and a player can hold at most one Tyrant-side class.
- [ ] Role alignment and Tyrant-side class are independent: the Vigilante or a Follower may simultaneously be the Assassin, Fixer, or Tamer. - [x] Role alignment and Tyrant-side class are independent: the Vigilante or a Follower may simultaneously be the Assassin, Fixer, or Tamer.
- [ ] The Tyrant cannot assign a class to the Tyrant, an opted-out player, or another ineligible player. - [x] The Tyrant cannot assign a class to the Tyrant, an opted-out player, or another ineligible player.
- [ ] Reassignment removes the class and its bound items and effects from the previous holder before granting it to the new holder. - [x] Reassignment removes the class and its bound items and effects from the previous holder before granting it to the new holder.
- [ ] Assignment and removal are stored by UUID and communicated clearly to affected players. - [x] Assignment and removal are stored by UUID and communicated clearly to affected players.
- [ ] A class holder is reminded of the assignment and its controls on login. - [x] A class holder is reminded of the assignment and its controls on login.
- [ ] All class assignments end when the Tyrant's reign ends. - [x] All class assignments end when the Tyrant's reign ends.
## Related ## Related
@@ -2,7 +2,7 @@
type: User Story type: User Story
title: "US-012: Configure and persist game state" title: "US-012: Configure and persist game state"
description: Give operators validated configuration and durable defensive storage for all Tyrant behavior. description: Give operators validated configuration and durable defensive storage for all Tyrant behavior.
status: backlog status: done
--- ---
# US-012: Configure and persist game state # 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 ## 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. - [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.
- [ ] 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] 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. - [x] 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. - [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.
- [ ] Cooldowns and deadlines use UTC instants and exclude administratively paused time. - [x] 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. - [x] 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. - [x] 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. - [x] Unknown forward-compatible configuration and state fields are preserved where practical.
- [ ] Plugin disable removes transient effects and presentation safely while preserving durable state. - [x] Plugin disable removes transient effects and presentation safely while preserving durable state.
## Related ## 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,17 @@
package games.dmg.spigottyrant;
import java.util.UUID;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
public final class BukkitOnlinePlayerDirectory implements OnlinePlayerDirectory {
@Override
public Player findByName(String name) {
return Bukkit.getPlayerExact(name);
}
@Override
public Player findById(UUID playerId) {
return Bukkit.getPlayer(playerId);
}
}
@@ -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,18 @@
package games.dmg.spigottyrant;
public final class ClassAssignmentMessages {
private ClassAssignmentMessages() {
}
public static String loginReminder(PlayerState player) {
return switch (player.tyrantClass()) {
case ASSASSIN -> "Class: ASSASSIN. Use your Assassin Cloak; double-jump while "
+ "airborne. Use /tyrant item to recover ready class items.";
case FIXER -> "Class: FIXER. Use your Fixer's Wrench to activate your boost. "
+ "Use /tyrant item to recover ready class items.";
case TAMER -> "Class: TAMER. Use your Tamer's Lead to capture mobs. "
+ "Use /tyrant item to recover ready class items.";
case NONE -> "";
};
}
}
@@ -0,0 +1,16 @@
package games.dmg.spigottyrant;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
public record ClassAssignmentResult(
Map<UUID, PlayerState> players,
ClassAssignmentStatus status,
Optional<UUID> previousHolderId
) {
public ClassAssignmentResult {
players = Map.copyOf(players);
previousHolderId = previousHolderId == null ? Optional.empty() : previousHolderId;
}
}
@@ -0,0 +1,129 @@
package games.dmg.spigottyrant;
import java.time.Instant;
import java.util.EnumMap;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
public final class ClassAssignmentService {
public ClassAssignmentResult assign(
GameState game,
Map<UUID, PlayerState> players,
UUID assignerId,
UUID targetId,
TyrantClass tyrantClass,
Instant now
) {
ClassAssignmentStatus rejected = rejection(
game, players, assignerId, targetId, tyrantClass, now
);
if (rejected != null) {
return new ClassAssignmentResult(players, rejected, Optional.empty());
}
Map<UUID, PlayerState> updated = new HashMap<>(players);
Optional<UUID> previousHolder = players.values().stream()
.filter(player -> player.tyrantClass() == tyrantClass)
.map(PlayerState::playerId)
.filter(playerId -> !playerId.equals(targetId))
.findFirst();
previousHolder.ifPresent(playerId -> updated.put(
playerId,
withClass(updated.get(playerId), TyrantClass.NONE)
));
updated.put(targetId, withClass(updated.get(targetId), tyrantClass));
return new ClassAssignmentResult(
updated,
ClassAssignmentStatus.ASSIGNED,
previousHolder
);
}
public Map<UUID, PlayerState> clearAll(Map<UUID, PlayerState> players) {
Map<UUID, PlayerState> updated = new HashMap<>();
for (PlayerState player : players.values()) {
updated.put(player.playerId(), withClass(player, TyrantClass.NONE));
}
return Map.copyOf(updated);
}
private static ClassAssignmentStatus rejection(
GameState game,
Map<UUID, PlayerState> players,
UUID assignerId,
UUID targetId,
TyrantClass tyrantClass,
Instant now
) {
if (game.lifecycle() != GameLifecycle.RUNNING) {
return ClassAssignmentStatus.GAME_NOT_RUNNING;
}
if (game.tyrantId().isEmpty() || !game.tyrantId().orElseThrow().equals(assignerId)) {
return ClassAssignmentStatus.NOT_TYRANT;
}
if (tyrantClass == null || tyrantClass == TyrantClass.NONE) {
return ClassAssignmentStatus.INVALID_CLASS;
}
if (!game.purchases().contains(unlockFor(tyrantClass))) {
return ClassAssignmentStatus.CLASS_NOT_UNLOCKED;
}
PlayerState target = players.get(targetId);
if (target == null) {
return ClassAssignmentStatus.UNKNOWN_PLAYER;
}
if (targetId.equals(assignerId)) {
return ClassAssignmentStatus.TARGET_IS_TYRANT;
}
if (target.optedOutUntil().filter(until -> until.isAfter(now)).isPresent()) {
return ClassAssignmentStatus.TARGET_OPTED_OUT;
}
return null;
}
private static TyrantUnlock unlockFor(TyrantClass tyrantClass) {
return switch (tyrantClass) {
case ASSASSIN -> TyrantUnlock.ASSASSIN;
case FIXER -> TyrantUnlock.FIXER;
case TAMER -> TyrantUnlock.TAMER;
case NONE -> throw new IllegalArgumentException("NONE is not assignable");
};
}
private static PlayerState withClass(PlayerState player, TyrantClass tyrantClass) {
Map<Ability, Instant> cooldowns = new EnumMap<>(Ability.class);
cooldowns.putAll(player.cooldownEnds());
Set<Ability> readyItems = EnumSet.noneOf(Ability.class);
readyItems.addAll(player.readyAbilityItems());
for (Ability ability : abilitiesFor(player.tyrantClass())) {
cooldowns.remove(ability);
readyItems.remove(ability);
}
return new PlayerState(
player.playerId(),
player.latestName(),
player.lastLogin(),
player.optedOutUntil(),
tyrantClass,
player.followerOf(),
cooldowns,
readyItems,
player.capturedMobs()
);
}
private static Set<Ability> abilitiesFor(TyrantClass tyrantClass) {
return switch (tyrantClass) {
case ASSASSIN -> Set.of(
Ability.ASSASSIN_INVISIBILITY,
Ability.ASSASSIN_DOUBLE_JUMP
);
case FIXER -> Set.of(Ability.FIXER_BOOST);
case TAMER -> Set.of(Ability.TAMER_CAPTURE);
case NONE -> Set.of();
};
}
}
@@ -0,0 +1,12 @@
package games.dmg.spigottyrant;
public enum ClassAssignmentStatus {
ASSIGNED,
GAME_NOT_RUNNING,
NOT_TYRANT,
CLASS_NOT_UNLOCKED,
INVALID_CLASS,
UNKNOWN_PLAYER,
TARGET_IS_TYRANT,
TARGET_OPTED_OUT
}
@@ -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,10 @@
package games.dmg.spigottyrant;
import java.util.UUID;
import org.bukkit.entity.Player;
public interface OnlinePlayerDirectory {
Player findByName(String name);
Player findById(UUID playerId);
}
@@ -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,33 @@
package games.dmg.spigottyrant;
import java.time.Clock;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
public final class PlayerJoinListener implements Listener {
private final TyrantStateManager stateManager;
private final Clock clock;
public PlayerJoinListener(TyrantStateManager stateManager, Clock clock) {
this.stateManager = stateManager;
this.clock = clock;
}
@EventHandler
public void onJoin(PlayerJoinEvent event) {
PlayerState player = stateManager.updatePlayer(
event.getPlayer().getUniqueId(),
event.getPlayer().getName(),
current -> new PlayerState(
current.playerId(), current.latestName(), java.util.Optional.of(clock.instant()),
current.optedOutUntil(), current.tyrantClass(), current.followerOf(),
current.cooldownEnds(), current.readyAbilityItems(), current.capturedMobs()
)
);
String reminder = ClassAssignmentMessages.loginReminder(player);
if (!reminder.isEmpty()) {
event.getPlayer().sendMessage(reminder);
}
}
}
@@ -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,60 @@
package games.dmg.spigottyrant; package games.dmg.spigottyrant;
import java.io.IOException;
import java.time.Clock;
import java.util.Map;
import java.util.Objects;
import java.util.logging.Level;
import org.bukkit.plugin.java.JavaPlugin; import org.bukkit.plugin.java.JavaPlugin;
public final class SpigotTyrantPlugin extends JavaPlugin { public final class SpigotTyrantPlugin extends JavaPlugin {
private PluginSettings settings;
private TyrantStateManager stateManager;
@Override @Override
public void onEnable() { 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().getPluginManager().registerEvents(
new PlayerJoinListener(stateManager, Clock.systemUTC()),
this
);
getServer().getScheduler().runTaskTimer(this, stateManager::saveIfDirty, 600L, 600L);
getLogger().info("Spigot Tyrant enabled."); 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,162 @@
package games.dmg.spigottyrant;
import java.time.Clock;
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;
private final ClassAssignmentService assignments;
private final OnlinePlayerDirectory onlinePlayers;
private final Clock clock;
public TyrantCommand(
TyrantStateManager stateManager,
TyrantProgressionService progression
) {
this(
stateManager,
progression,
new ClassAssignmentService(),
new BukkitOnlinePlayerDirectory(),
Clock.systemUTC()
);
}
TyrantCommand(
TyrantStateManager stateManager,
TyrantProgressionService progression,
ClassAssignmentService assignments,
OnlinePlayerDirectory onlinePlayers,
Clock clock
) {
this.stateManager = stateManager;
this.progression = progression;
this.assignments = assignments;
this.onlinePlayers = onlinePlayers;
this.clock = clock;
}
@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;
}
if (arguments.length == 3 && arguments[0].equalsIgnoreCase("assign")) {
assign(player, arguments[1], arguments[2]);
return true;
}
player.sendMessage(
ChatColor.YELLOW
+ "Usage: /tyrant choices | /tyrant buy <unlock> | "
+ "/tyrant assign <class> <player>"
);
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() + ": "
+ readable(result[0].status())
);
}
}
private void assign(Player tyrant, String requestedClass, String targetName) {
TyrantClass tyrantClass;
try {
tyrantClass = TyrantClass.valueOf(requestedClass.toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException exception) {
tyrant.sendMessage(ChatColor.RED + "Unknown class. Use assassin, fixer, or tamer.");
return;
}
Player target = onlinePlayers.findByName(targetName);
if (target == null) {
tyrant.sendMessage(ChatColor.RED + "That player must be online.");
return;
}
stateManager.player(target.getUniqueId(), target.getName());
ClassAssignmentResult result = assignments.assign(
stateManager.game(),
stateManager.players(),
tyrant.getUniqueId(),
target.getUniqueId(),
tyrantClass,
clock.instant()
);
if (result.status() != ClassAssignmentStatus.ASSIGNED) {
tyrant.sendMessage(
ChatColor.RED + "Could not assign " + tyrantClass.name() + ": "
+ readable(result.status())
);
return;
}
stateManager.replacePlayers(result.players());
stateManager.saveIfDirty();
result.previousHolderId().map(onlinePlayers::findById).ifPresent(previous ->
previous.sendMessage(ChatColor.YELLOW + "You are no longer the "
+ tyrantClass.name() + ".")
);
target.sendMessage(ChatColor.GREEN + "You are now the " + tyrantClass.name() + ".");
tyrant.sendMessage(
ChatColor.GREEN + "Assigned " + tyrantClass.name() + " to " + target.getName() + "."
);
}
private static String readable(Enum<?> status) {
return 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,106 @@
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 void replacePlayers(Map<UUID, PlayerState> replacement) {
players.clear();
players.putAll(replacement);
dirty = true;
}
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" api-version: "1.20"
description: Asymmetric Tyrant, Vigilante, Follower, and specialist class gameplay. description: Asymmetric Tyrant, Vigilante, Follower, and specialist class gameplay.
author: dmg.games author: dmg.games
commands:
tyrant:
description: View and use Spigot Tyrant game features.
usage: /tyrant [choices|buy <unlock>|assign <class> <player>]
@@ -0,0 +1,29 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class ClassAssignmentMessagesTest {
@Test
void loginReminderNamesClassAndControls() {
PlayerState assassin = new PlayerState(
UUID.fromString("11111111-1111-1111-1111-111111111111"),
"Player",
java.util.Optional.empty(),
java.util.Optional.empty(),
TyrantClass.ASSASSIN,
java.util.Optional.empty(),
java.util.Map.of(),
java.util.Set.of(),
java.util.List.of()
);
assertEquals(
"Class: ASSASSIN. Use your Assassin Cloak; double-jump while airborne. "
+ "Use /tyrant item to recover ready class items.",
ClassAssignmentMessages.loginReminder(assassin)
);
}
}
@@ -0,0 +1,127 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class ClassAssignmentServiceTest {
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 FOLLOWER = UUID.fromString("33333333-3333-3333-3333-333333333333");
private static final Instant NOW = Instant.parse("2026-08-14T12:00:00Z");
private final ClassAssignmentService service = new ClassAssignmentService();
@Test
void purchasedClassCanBeAssignedToVigilanteOrFollower() {
GameState game = gameWith(TyrantUnlock.ASSASSIN);
PlayerState vigilante = PlayerState.newPlayer(VIGILANTE, "Vigilante");
PlayerState follower = withFollower(
PlayerState.newPlayer(FOLLOWER, "Follower"),
VIGILANTE
);
ClassAssignmentResult vigilanteResult = service.assign(
game,
Map.of(VIGILANTE, vigilante, FOLLOWER, follower),
TYRANT,
VIGILANTE,
TyrantClass.ASSASSIN,
NOW
);
ClassAssignmentResult followerResult = service.assign(
game,
vigilanteResult.players(),
TYRANT,
FOLLOWER,
TyrantClass.ASSASSIN,
NOW
);
assertEquals(ClassAssignmentStatus.ASSIGNED, vigilanteResult.status());
assertEquals(TyrantClass.ASSASSIN,
vigilanteResult.players().get(VIGILANTE).tyrantClass());
assertEquals(ClassAssignmentStatus.ASSIGNED, followerResult.status());
assertEquals(TyrantClass.NONE,
followerResult.players().get(VIGILANTE).tyrantClass());
assertEquals(TyrantClass.ASSASSIN,
followerResult.players().get(FOLLOWER).tyrantClass());
assertEquals(Optional.of(VIGILANTE), followerResult.previousHolderId());
}
@Test
void rejectsLockedSelfAndOptedOutAssignments() {
PlayerState target = PlayerState.newPlayer(FOLLOWER, "Target");
PlayerState optedOut = new PlayerState(
target.playerId(), target.latestName(), target.lastLogin(),
Optional.of(NOW.plusSeconds(60)), target.tyrantClass(), target.followerOf(),
target.cooldownEnds(), target.readyAbilityItems(), target.capturedMobs()
);
assertEquals(ClassAssignmentStatus.CLASS_NOT_UNLOCKED, service.assign(
gameWith(TyrantUnlock.FIXER), Map.of(FOLLOWER, target), TYRANT, FOLLOWER,
TyrantClass.ASSASSIN, NOW
).status());
assertEquals(ClassAssignmentStatus.TARGET_IS_TYRANT, service.assign(
gameWith(TyrantUnlock.ASSASSIN), Map.of(TYRANT,
PlayerState.newPlayer(TYRANT, "Tyrant")), TYRANT, TYRANT,
TyrantClass.ASSASSIN, NOW
).status());
assertEquals(ClassAssignmentStatus.TARGET_OPTED_OUT, service.assign(
gameWith(TyrantUnlock.ASSASSIN), Map.of(FOLLOWER, optedOut), TYRANT,
FOLLOWER, TyrantClass.ASSASSIN, NOW
).status());
}
@Test
void reassignmentAndReignEndClearClassAbilityState() {
Instant cooldown = NOW.plusSeconds(3600);
PlayerState holder = new PlayerState(
VIGILANTE, "Holder", Optional.empty(), Optional.empty(),
TyrantClass.ASSASSIN, Optional.empty(),
Map.of(Ability.ASSASSIN_INVISIBILITY, cooldown),
Set.of(Ability.ASSASSIN_INVISIBILITY), java.util.List.of()
);
PlayerState replacement = PlayerState.newPlayer(FOLLOWER, "Replacement");
ClassAssignmentResult reassigned = service.assign(
gameWith(TyrantUnlock.ASSASSIN),
Map.of(VIGILANTE, holder, FOLLOWER, replacement),
TYRANT, FOLLOWER, TyrantClass.ASSASSIN, NOW
);
PlayerState former = reassigned.players().get(VIGILANTE);
Map<UUID, PlayerState> cleared = service.clearAll(reassigned.players());
assertEquals(Map.of(), former.cooldownEnds());
assertEquals(Set.of(), former.readyAbilityItems());
assertEquals(TyrantClass.NONE, cleared.get(FOLLOWER).tyrantClass());
}
private static GameState gameWith(TyrantUnlock unlock) {
return new GameState(
GameLifecycle.RUNNING,
Optional.of(TYRANT),
Optional.of(VIGILANTE),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Duration.ZERO,
1,
0,
Set.of(unlock)
);
}
private static PlayerState withFollower(PlayerState player, UUID vigilanteId) {
return new PlayerState(
player.playerId(), player.latestName(), player.lastLogin(), player.optedOutUntil(),
player.tyrantClass(), Optional.of(vigilanteId), player.cooldownEnds(),
player.readyAbilityItems(), player.capturedMobs()
);
}
}
@@ -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("SpigotTyrant", plugin.get("name"));
assertEquals("games.dmg.spigottyrant.SpigotTyrantPlugin", plugin.get("main")); assertEquals("games.dmg.spigottyrant.SpigotTyrantPlugin", plugin.get("main"));
assertEquals("1.20", plugin.get("api-version")); assertEquals("1.20", plugin.get("api-version"));
Map<?, ?> commands = (Map<?, ?>) plugin.get("commands");
assertNotNull(commands);
assertNotNull(commands.get("tyrant"));
} }
@Test @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,95 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
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")));
}
@Test
void assigningClassNotifiesTyrantAndNewHolder() {
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
UUID targetId = UUID.fromString("22222222-2222-2222-2222-222222222222");
GameState game = new GameState(
GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.empty(),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
0, 0, Set.of(TyrantUnlock.FIXER)
);
PlayerState targetState = PlayerState.newPlayer(targetId, "Target");
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.game()).thenReturn(game);
when(manager.player(targetId, "Target")).thenReturn(targetState);
when(manager.players()).thenReturn(java.util.Map.of(targetId, targetState));
Player tyrant = mock(Player.class);
when(tyrant.getUniqueId()).thenReturn(tyrantId);
Player target = mock(Player.class);
when(target.getUniqueId()).thenReturn(targetId);
when(target.getName()).thenReturn("Target");
OnlinePlayerDirectory directory = mock(OnlinePlayerDirectory.class);
when(directory.findByName("Target")).thenReturn(target);
TyrantCommand command = new TyrantCommand(
manager,
new TyrantProgressionService(),
new ClassAssignmentService(),
directory,
Clock.fixed(Instant.parse("2026-08-14T12:00:00Z"), ZoneOffset.UTC)
);
command.onCommand(
tyrant,
mock(Command.class),
"tyrant",
new String[] {"assign", "fixer", "Target"}
);
verify(manager).replacePlayers(any());
verify(target).sendMessage(org.mockito.ArgumentMatchers.contains("now the FIXER"));
verify(tyrant).sendMessage(org.mockito.ArgumentMatchers.contains("Assigned FIXER"));
}
}
@@ -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());
}
}