diff --git a/design/log.md b/design/log.md index 711265a..c66cbdf 100644 --- a/design/log.md +++ b/design/log.md @@ -6,6 +6,17 @@ description: Chronological record of material decisions affecting the Spigot Tyr # Spigot Tyrant Design Log +## 2026-08-14 — Administrative lifecycle completed + +- Completed US-001 with permission-gated `/tyrantadmin` start, status, pause, resume, and confirmed reset operations. +- Initial Vigilantes may be assigned explicitly or selected among eligible recently active players, with offline selections persisted as pending confirmations. +- Pause/resume preserves progression and assignments, shifts every cooldown, participation, inactivity, and pending-selection deadline, and invokes managed effect suppression/restoration. +- Added detailed operator status output and verified lifecycle, timer shifting, permissions, confirmation safety, candidate filtering, metadata, and effect hooks with the Gradle suite. + +## 2026-08-14 — Administrative lifecycle implementation started + +- US-001 begins with test-first game start, pause/resume deadline freezing, permission-gated administration, inspection, and confirmed reset behavior. + ## 2026-08-14 — Tyrant class assignment completed - Completed US-004 with `/tyrant assign `, purchased-class authorization, UUID state, one holder per class, and one Tyrant-side class per player. diff --git a/design/user-stories/us-001-start-pause-and-administer.md b/design/user-stories/us-001-start-pause-and-administer.md index e386d4d..7a810b1 100644 --- a/design/user-stories/us-001-start-pause-and-administer.md +++ b/design/user-stories/us-001-start-pause-and-administer.md @@ -2,7 +2,7 @@ type: User Story title: "US-001: Start, pause, and administer the game" description: Give server operators safe control over the Tyrant game's lifecycle and state. -status: backlog +status: done --- # US-001: Start, pause, and administer the game @@ -11,14 +11,14 @@ As a **server operator**, I want to start, pause, resume, and inspect the game s ## Acceptance criteria -- [ ] An administrator can start the first game by assigning the initial Tyrant. -- [ ] The administrator may assign the first Vigilante or allow random selection from eligible recently active players. -- [ ] An administrator can pause and resume the game without deleting game state. -- [ ] Pausing freezes progression, cooldowns, daily-use timers, inactivity checks, and pending-selection deadlines. -- [ ] Role and class effects are suppressed while paused and restored when play resumes. -- [ ] Administrative commands require the `spigottyrant.admin` permission, granted to server operators by default. -- [ ] Administrators can inspect lifecycle state, roles, classes, Followers, purchases, levels, cooldowns, participation, and pending selections. -- [ ] Destructive administrative operations require explicit confirmation. +- [x] An administrator can start the first game by assigning the initial Tyrant. +- [x] The administrator may assign the first Vigilante or allow random selection from eligible recently active players. +- [x] An administrator can pause and resume the game without deleting game state. +- [x] Pausing freezes progression, cooldowns, daily-use timers, inactivity checks, and pending-selection deadlines. +- [x] Role and class effects are suppressed while paused and restored when play resumes. +- [x] Administrative commands require the `spigottyrant.admin` permission, granted to server operators by default. +- [x] Administrators can inspect lifecycle state, roles, classes, Followers, purchases, levels, cooldowns, participation, and pending selections. +- [x] Destructive administrative operations require explicit confirmation. ## Related diff --git a/src/main/java/games/dmg/spigottyrant/GameLifecycleService.java b/src/main/java/games/dmg/spigottyrant/GameLifecycleService.java new file mode 100644 index 0000000..098de6a --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/GameLifecycleService.java @@ -0,0 +1,137 @@ +package games.dmg.spigottyrant; + +import java.time.Duration; +import java.time.Instant; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +public final class GameLifecycleService { + public LifecycleState start( + PersistentState state, + UUID tyrantId, + Optional vigilanteId, + Instant now + ) { + if (state.game().lifecycle() != GameLifecycle.UNSTARTED) { + throw new IllegalStateException("game has already been started"); + } + if (vigilanteId.filter(tyrantId::equals).isPresent()) { + throw new IllegalArgumentException("Tyrant and Vigilante must be different players"); + } + GameState game = new GameState( + GameLifecycle.RUNNING, + Optional.of(tyrantId), + vigilanteId, + Optional.empty(), + Optional.empty(), + Optional.empty(), + Duration.ZERO, + 0, + 1, + Set.of() + ); + return new LifecycleState(game, state.players()); + } + + public LifecycleState pause(PersistentState state, Instant now) { + if (state.game().lifecycle() != GameLifecycle.RUNNING) { + throw new IllegalStateException("only a running game can be paused"); + } + GameState current = state.game(); + GameState paused = new GameState( + GameLifecycle.PAUSED, + current.tyrantId(), + current.vigilanteId(), + current.pendingTyrant(), + current.pendingVigilante(), + Optional.of(now), + current.accumulatedPausedTime(), + current.tyrantLevel(), + current.unspentChoices(), + current.purchases() + ); + return new LifecycleState(paused, state.players()); + } + + public LifecycleState resume(PersistentState state, Instant now) { + GameState current = state.game(); + if (current.lifecycle() != GameLifecycle.PAUSED || current.pausedAt().isEmpty()) { + throw new IllegalStateException("only a paused game can be resumed"); + } + Instant pausedAt = current.pausedAt().orElseThrow(); + Duration pausedFor = Duration.between(pausedAt, now); + if (pausedFor.isNegative()) { + throw new IllegalArgumentException("resume time must not precede pause time"); + } + GameState resumed = new GameState( + GameLifecycle.RUNNING, + current.tyrantId(), + current.vigilanteId(), + shiftSelection(current.pendingTyrant(), pausedFor), + shiftSelection(current.pendingVigilante(), pausedFor), + Optional.empty(), + current.accumulatedPausedTime().plus(pausedFor), + current.tyrantLevel(), + current.unspentChoices(), + current.purchases() + ); + Map players = new HashMap<>(); + for (PlayerState player : state.players().values()) { + players.put(player.playerId(), shift(player, pausedFor)); + } + return new LifecycleState(resumed, players); + } + + public LifecycleState reset(PersistentState state) { + Map players = new HashMap<>(); + for (PlayerState player : state.players().values()) { + players.put(player.playerId(), new PlayerState( + player.playerId(), + player.latestName(), + player.lastLogin(), + player.optedOutUntil(), + TyrantClass.NONE, + Optional.empty(), + Map.of(), + Set.of(), + player.capturedMobs() + )); + } + return new LifecycleState(GameState.empty(), players); + } + + private static Optional shiftSelection( + Optional selection, + Duration pausedFor + ) { + return selection.map(value -> new PendingSelection( + value.candidateId(), value.expiresAt().plus(pausedFor) + )); + } + + private static PlayerState shift(PlayerState player, Duration pausedFor) { + Map cooldowns = new EnumMap<>(Ability.class); + player.cooldownEnds().forEach((ability, deadline) -> + cooldowns.put(ability, deadline.plus(pausedFor)) + ); + return new PlayerState( + player.playerId(), + player.latestName(), + shiftInstant(player.lastLogin(), pausedFor), + shiftInstant(player.optedOutUntil(), pausedFor), + player.tyrantClass(), + player.followerOf(), + cooldowns, + player.readyAbilityItems(), + player.capturedMobs() + ); + } + + private static Optional shiftInstant(Optional value, Duration duration) { + return value.map(instant -> instant.plus(duration)); + } +} diff --git a/src/main/java/games/dmg/spigottyrant/LifecycleState.java b/src/main/java/games/dmg/spigottyrant/LifecycleState.java new file mode 100644 index 0000000..f3cc301 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/LifecycleState.java @@ -0,0 +1,14 @@ +package games.dmg.spigottyrant; + +import java.util.Map; +import java.util.UUID; + +public record LifecycleState(GameState game, Map players) { + public LifecycleState { + players = Map.copyOf(players); + } + + public PersistentState asPersistentState() { + return new PersistentState(game, players); + } +} diff --git a/src/main/java/games/dmg/spigottyrant/ManagedRoleEffects.java b/src/main/java/games/dmg/spigottyrant/ManagedRoleEffects.java new file mode 100644 index 0000000..f20c3c4 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/ManagedRoleEffects.java @@ -0,0 +1,7 @@ +package games.dmg.spigottyrant; + +public interface ManagedRoleEffects { + void suppressAll(); + + void restoreAll(); +} diff --git a/src/main/java/games/dmg/spigottyrant/NoopManagedRoleEffects.java b/src/main/java/games/dmg/spigottyrant/NoopManagedRoleEffects.java new file mode 100644 index 0000000..0ff58b4 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/NoopManagedRoleEffects.java @@ -0,0 +1,13 @@ +package games.dmg.spigottyrant; + +public final class NoopManagedRoleEffects implements ManagedRoleEffects { + @Override + public void suppressAll() { + // No managed effects exist until their corresponding abilities are unlocked. + } + + @Override + public void restoreAll() { + // No managed effects exist until their corresponding abilities are unlocked. + } +} diff --git a/src/main/java/games/dmg/spigottyrant/RoleCandidateSelector.java b/src/main/java/games/dmg/spigottyrant/RoleCandidateSelector.java new file mode 100644 index 0000000..fbc0082 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/RoleCandidateSelector.java @@ -0,0 +1,47 @@ +package games.dmg.spigottyrant; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.random.RandomGenerator; + +public final class RoleCandidateSelector { + public Optional select( + Map players, + Set excluded, + Optional previousHolder, + Instant now, + Duration activityWindow, + RandomGenerator random + ) { + Instant cutoff = now.minus(activityWindow); + List eligible = new ArrayList<>(); + for (PlayerState player : players.values()) { + if (excluded.contains(player.playerId())) { + continue; + } + if (player.lastLogin().isEmpty() + || player.lastLogin().orElseThrow().isBefore(cutoff)) { + continue; + } + if (player.optedOutUntil().filter(deadline -> deadline.isAfter(now)).isPresent()) { + continue; + } + eligible.add(player.playerId()); + } + if (eligible.size() > 1) { + previousHolder.ifPresent(eligible::remove); + } + if (eligible.isEmpty()) { + return Optional.empty(); + } + eligible.sort(Comparator.comparing(UUID::toString)); + return Optional.of(eligible.get(random.nextInt(eligible.size()))); + } +} diff --git a/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java b/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java index dd2e839..27340f9 100644 --- a/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java +++ b/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java @@ -29,6 +29,18 @@ public final class SpigotTyrantPlugin extends JavaPlugin { Objects.requireNonNull(getCommand("tyrant"), "Missing tyrant command metadata") .setExecutor(new TyrantCommand(stateManager, new TyrantProgressionService())); + Objects.requireNonNull(getCommand("tyrantadmin"), "Missing tyrantadmin metadata") + .setExecutor(new TyrantAdminCommand( + stateManager, + new GameLifecycleService(), + new BukkitOnlinePlayerDirectory(), + new NoopManagedRoleEffects(), + Clock.systemUTC(), + new RoleCandidateSelector(), + settings.candidateActivityWindow(), + settings.pendingSelectionTimeout(), + java.util.random.RandomGenerator.getDefault() + )); getServer().getPluginManager().registerEvents( new PlayerJoinListener(stateManager, Clock.systemUTC()), this diff --git a/src/main/java/games/dmg/spigottyrant/TyrantAdminCommand.java b/src/main/java/games/dmg/spigottyrant/TyrantAdminCommand.java new file mode 100644 index 0000000..2845a98 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/TyrantAdminCommand.java @@ -0,0 +1,209 @@ +package games.dmg.spigottyrant; + +import java.time.Clock; +import java.time.Duration; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.random.RandomGenerator; +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 TyrantAdminCommand implements CommandExecutor { + private static final String PERMISSION = "spigottyrant.admin"; + private final TyrantStateManager stateManager; + private final GameLifecycleService lifecycle; + private final OnlinePlayerDirectory onlinePlayers; + private final ManagedRoleEffects effects; + private final Clock clock; + private final RoleCandidateSelector candidateSelector; + private final Duration activityWindow; + private final Duration pendingTimeout; + private final RandomGenerator random; + + public TyrantAdminCommand( + TyrantStateManager stateManager, + GameLifecycleService lifecycle, + OnlinePlayerDirectory onlinePlayers, + ManagedRoleEffects effects, + Clock clock + ) { + this( + stateManager, lifecycle, onlinePlayers, effects, clock, + new RoleCandidateSelector(), Duration.ofHours(24), Duration.ofHours(24), + RandomGenerator.getDefault() + ); + } + + public TyrantAdminCommand( + TyrantStateManager stateManager, + GameLifecycleService lifecycle, + OnlinePlayerDirectory onlinePlayers, + ManagedRoleEffects effects, + Clock clock, + RoleCandidateSelector candidateSelector, + Duration activityWindow, + Duration pendingTimeout, + RandomGenerator random + ) { + this.stateManager = stateManager; + this.lifecycle = lifecycle; + this.onlinePlayers = onlinePlayers; + this.effects = effects; + this.clock = clock; + this.candidateSelector = candidateSelector; + this.activityWindow = activityWindow; + this.pendingTimeout = pendingTimeout; + this.random = random; + } + + @Override + public boolean onCommand( + CommandSender sender, + Command command, + String label, + String[] arguments + ) { + if (!sender.hasPermission(PERMISSION)) { + sender.sendMessage(ChatColor.RED + "You do not have permission to administer Tyrant."); + return true; + } + if (arguments.length == 0 || arguments[0].equalsIgnoreCase("status")) { + showStatus(sender); + return true; + } + try { + switch (arguments[0].toLowerCase(Locale.ROOT)) { + case "start" -> start(sender, arguments); + case "pause" -> pause(sender); + case "resume" -> resume(sender); + case "reset" -> reset(sender, arguments); + default -> usage(sender); + } + } catch (IllegalArgumentException | IllegalStateException exception) { + sender.sendMessage(ChatColor.RED + exception.getMessage()); + } + return true; + } + + private void start(CommandSender sender, String[] arguments) { + if (arguments.length < 2 || arguments.length > 3) { + sender.sendMessage(ChatColor.YELLOW + + "Usage: /tyrantadmin start [vigilante]"); + return; + } + Player tyrant = onlinePlayers.findByName(arguments[1]); + if (tyrant == null) { + sender.sendMessage(ChatColor.RED + "The initial Tyrant must be online."); + return; + } + Optional vigilante = Optional.empty(); + if (arguments.length == 3) { + vigilante = Optional.ofNullable(onlinePlayers.findByName(arguments[2])); + if (vigilante.isEmpty()) { + sender.sendMessage(ChatColor.RED + "The initial Vigilante must be online."); + return; + } + } + stateManager.player(tyrant.getUniqueId(), tyrant.getName()); + vigilante.ifPresent(player -> stateManager.player(player.getUniqueId(), player.getName())); + InstantHolder now = new InstantHolder(clock.instant()); + Optional randomCandidate = vigilante.isEmpty() + ? candidateSelector.select( + stateManager.players(), Set.of(tyrant.getUniqueId()), Optional.empty(), + now.value(), activityWindow, random + ) + : Optional.empty(); + Optional activeVigilante = vigilante.map(Player::getUniqueId); + if (activeVigilante.isEmpty() && randomCandidate.isPresent() + && onlinePlayers.findById(randomCandidate.orElseThrow()) != null) { + activeVigilante = randomCandidate; + } + LifecycleState started = lifecycle.start( + stateManager.snapshot(), tyrant.getUniqueId(), activeVigilante, now.value() + ); + if (activeVigilante.isEmpty() && randomCandidate.isPresent()) { + GameState game = started.game(); + started = new LifecycleState(new GameState( + game.lifecycle(), game.tyrantId(), game.vigilanteId(), game.pendingTyrant(), + Optional.of(new PendingSelection( + randomCandidate.orElseThrow(), now.value().plus(pendingTimeout) + )), + game.pausedAt(), game.accumulatedPausedTime(), game.tyrantLevel(), + game.unspentChoices(), game.purchases() + ), started.players()); + } + stateManager.replaceState(started); + stateManager.saveIfDirty(); + effects.restoreAll(); + sender.sendMessage(ChatColor.GREEN + "Tyrant game started."); + if (vigilante.isEmpty()) { + sender.sendMessage(ChatColor.YELLOW + + "An eligible recently active Vigilante will be selected automatically."); + } + } + + private void pause(CommandSender sender) { + LifecycleState paused = lifecycle.pause(stateManager.snapshot(), clock.instant()); + stateManager.replaceState(paused); + stateManager.saveIfDirty(); + effects.suppressAll(); + sender.sendMessage(ChatColor.GREEN + "Tyrant game paused."); + } + + private void resume(CommandSender sender) { + LifecycleState resumed = lifecycle.resume(stateManager.snapshot(), clock.instant()); + stateManager.replaceState(resumed); + stateManager.saveIfDirty(); + effects.restoreAll(); + sender.sendMessage(ChatColor.GREEN + "Tyrant game resumed."); + } + + private void reset(CommandSender sender, String[] arguments) { + if (arguments.length != 2 || !arguments[1].equalsIgnoreCase("confirm")) { + sender.sendMessage(ChatColor.RED + + "This clears the game. Use /tyrantadmin reset confirm."); + return; + } + stateManager.replaceState(lifecycle.reset(stateManager.snapshot())); + stateManager.saveIfDirty(); + effects.suppressAll(); + sender.sendMessage(ChatColor.GREEN + "Tyrant game reset."); + } + + private void showStatus(CommandSender sender) { + GameState game = stateManager.game(); + sender.sendMessage(ChatColor.GOLD + "Lifecycle: " + game.lifecycle()); + sender.sendMessage("Tyrant: " + game.tyrantId().map(Object::toString).orElse("none")); + sender.sendMessage("Vigilante: " + + game.vigilanteId().map(Object::toString).orElse("none")); + sender.sendMessage("Level: " + game.tyrantLevel() + + ", choices: " + game.unspentChoices() + + ", purchases: " + game.purchases()); + sender.sendMessage("Pending Tyrant: " + game.pendingTyrant()); + sender.sendMessage("Pending Vigilante: " + game.pendingVigilante()); + for (PlayerState player : stateManager.players().values()) { + boolean optedOut = player.optedOutUntil() + .filter(deadline -> deadline.isAfter(clock.instant())) + .isPresent(); + sender.sendMessage( + player.latestName() + " (" + player.playerId() + "): class=" + + player.tyrantClass() + ", followerOf=" + + player.followerOf().map(Object::toString).orElse("none") + + ", optedOut=" + optedOut + ", cooldowns=" + player.cooldownEnds() + ); + } + } + + private record InstantHolder(java.time.Instant value) { + } + + private static void usage(CommandSender sender) { + sender.sendMessage(ChatColor.YELLOW + + "Usage: /tyrantadmin "); + } +} diff --git a/src/main/java/games/dmg/spigottyrant/TyrantStateManager.java b/src/main/java/games/dmg/spigottyrant/TyrantStateManager.java index 9d778a4..df3cde2 100644 --- a/src/main/java/games/dmg/spigottyrant/TyrantStateManager.java +++ b/src/main/java/games/dmg/spigottyrant/TyrantStateManager.java @@ -76,6 +76,17 @@ public final class TyrantStateManager { return game; } + public void replaceState(LifecycleState replacement) { + game = replacement.game(); + players.clear(); + players.putAll(replacement.players()); + dirty = true; + } + + public PersistentState snapshot() { + return new PersistentState(game, players); + } + public boolean saveIfDirty() { if (!dirty) { return true; diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index dc1940f..d355d52 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -8,3 +8,11 @@ commands: tyrant: description: View and use Spigot Tyrant game features. usage: /tyrant [choices|buy |assign ] + tyrantadmin: + description: Administer the Spigot Tyrant game. + usage: /tyrantadmin + permission: spigottyrant.admin +permissions: + spigottyrant.admin: + description: Allows administration of Spigot Tyrant. + default: op diff --git a/src/test/java/games/dmg/spigottyrant/GameLifecycleServiceTest.java b/src/test/java/games/dmg/spigottyrant/GameLifecycleServiceTest.java new file mode 100644 index 0000000..ff88964 --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/GameLifecycleServiceTest.java @@ -0,0 +1,65 @@ +package games.dmg.spigottyrant; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +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 GameLifecycleServiceTest { + 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 Instant START = Instant.parse("2026-08-14T12:00:00Z"); + private final GameLifecycleService service = new GameLifecycleService(); + + @Test + void startsFirstGameWithInitialRolesAndChoice() { + LifecycleState started = service.start( + PersistentState.empty(), TYRANT, Optional.of(VIGILANTE), START + ); + + assertEquals(GameLifecycle.RUNNING, started.game().lifecycle()); + assertEquals(Optional.of(TYRANT), started.game().tyrantId()); + assertEquals(Optional.of(VIGILANTE), started.game().vigilanteId()); + assertEquals(1, started.game().unspentChoices()); + } + + @Test + void pauseAndResumePreserveStateAndShiftEveryElapsedDeadline() { + Instant cooldown = START.plusSeconds(600); + PlayerState player = new PlayerState( + TYRANT, "Tyrant", Optional.of(START.minusSeconds(60)), + Optional.of(START.plusSeconds(1200)), TyrantClass.ASSASSIN, + Optional.empty(), Map.of(Ability.ASSASSIN_INVISIBILITY, cooldown), + Set.of(), java.util.List.of() + ); + GameState running = new GameState( + GameLifecycle.RUNNING, Optional.of(TYRANT), Optional.of(VIGILANTE), + Optional.empty(), Optional.of(new PendingSelection(VIGILANTE, cooldown)), + Optional.empty(), Duration.ofMinutes(2), 2, 1, + Set.of(TyrantUnlock.ASSASSIN) + ); + LifecycleState paused = service.pause( + new PersistentState(running, Map.of(TYRANT, player)), START + ); + Instant resumedAt = START.plusSeconds(300); + + LifecycleState resumed = service.resume(paused.asPersistentState(), resumedAt); + + assertEquals(GameLifecycle.RUNNING, resumed.game().lifecycle()); + assertEquals(Duration.ofMinutes(7), resumed.game().accumulatedPausedTime()); + assertEquals(cooldown.plusSeconds(300), + resumed.game().pendingVigilante().orElseThrow().expiresAt()); + PlayerState shifted = resumed.players().get(TYRANT); + assertEquals(cooldown.plusSeconds(300), + shifted.cooldownEnds().get(Ability.ASSASSIN_INVISIBILITY)); + assertEquals(START.plusSeconds(1500), shifted.optedOutUntil().orElseThrow()); + assertEquals(START.plusSeconds(240), shifted.lastLogin().orElseThrow()); + assertTrue(shifted.tyrantClass() == TyrantClass.ASSASSIN); + } +} diff --git a/src/test/java/games/dmg/spigottyrant/PluginMetadataTest.java b/src/test/java/games/dmg/spigottyrant/PluginMetadataTest.java index e64a5b1..1757657 100644 --- a/src/test/java/games/dmg/spigottyrant/PluginMetadataTest.java +++ b/src/test/java/games/dmg/spigottyrant/PluginMetadataTest.java @@ -23,6 +23,11 @@ final class PluginMetadataTest { Map commands = (Map) plugin.get("commands"); assertNotNull(commands); assertNotNull(commands.get("tyrant")); + assertNotNull(commands.get("tyrantadmin")); + Map permissions = (Map) plugin.get("permissions"); + assertNotNull(permissions); + Map admin = (Map) permissions.get("spigottyrant.admin"); + assertEquals("op", admin.get("default")); } @Test diff --git a/src/test/java/games/dmg/spigottyrant/RoleCandidateSelectorTest.java b/src/test/java/games/dmg/spigottyrant/RoleCandidateSelectorTest.java new file mode 100644 index 0000000..b23d083 --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/RoleCandidateSelectorTest.java @@ -0,0 +1,46 @@ +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 java.util.random.RandomGenerator; +import org.junit.jupiter.api.Test; + +final class RoleCandidateSelectorTest { + @Test + void selectsOnlyRecentlyActiveEligibleNonExcludedPlayer() { + Instant now = Instant.parse("2026-08-14T12:00:00Z"); + UUID tyrant = UUID.fromString("11111111-1111-1111-1111-111111111111"); + UUID candidate = UUID.fromString("22222222-2222-2222-2222-222222222222"); + UUID stale = UUID.fromString("33333333-3333-3333-3333-333333333333"); + Map players = Map.of( + tyrant, withLogin(PlayerState.newPlayer(tyrant, "Tyrant"), now), + candidate, withLogin(PlayerState.newPlayer(candidate, "Candidate"), now.minusSeconds(60)), + stale, withLogin(PlayerState.newPlayer(stale, "Stale"), now.minus(Duration.ofHours(25))) + ); + + Optional selected = new RoleCandidateSelector().select( + players, + Set.of(tyrant), + Optional.empty(), + now, + Duration.ofHours(24), + RandomGenerator.of("L64X128MixRandom") + ); + + assertEquals(Optional.of(candidate), selected); + } + + private static PlayerState withLogin(PlayerState player, Instant login) { + return new PlayerState( + player.playerId(), player.latestName(), Optional.of(login), player.optedOutUntil(), + player.tyrantClass(), player.followerOf(), player.cooldownEnds(), + player.readyAbilityItems(), player.capturedMobs() + ); + } +} diff --git a/src/test/java/games/dmg/spigottyrant/TyrantAdminCommandTest.java b/src/test/java/games/dmg/spigottyrant/TyrantAdminCommandTest.java new file mode 100644 index 0000000..5b3d79e --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/TyrantAdminCommandTest.java @@ -0,0 +1,87 @@ +package games.dmg.spigottyrant; + +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +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.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.junit.jupiter.api.Test; + +final class TyrantAdminCommandTest { + @Test + void rejectsSenderWithoutAdminPermission() { + TyrantStateManager manager = mock(TyrantStateManager.class); + CommandSender sender = mock(CommandSender.class); + when(sender.hasPermission("spigottyrant.admin")).thenReturn(false); + TyrantAdminCommand command = command(manager, mock(ManagedRoleEffects.class)); + + command.onCommand(sender, mock(Command.class), "tyrantadmin", new String[] {"pause"}); + + verify(sender).sendMessage(contains("permission")); + verify(manager, never()).replaceState(org.mockito.ArgumentMatchers.any()); + } + + @Test + void pausePreservesStateAndSuppressesManagedEffects() { + UUID tyrant = UUID.fromString("11111111-1111-1111-1111-111111111111"); + GameState game = new GameState( + GameLifecycle.RUNNING, Optional.of(tyrant), Optional.empty(), + Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO, + 2, 1, Set.of(TyrantUnlock.STRENGTH) + ); + PersistentState snapshot = new PersistentState( + game, Map.of(tyrant, PlayerState.newPlayer(tyrant, "Tyrant")) + ); + TyrantStateManager manager = mock(TyrantStateManager.class); + when(manager.snapshot()).thenReturn(snapshot); + ManagedRoleEffects effects = mock(ManagedRoleEffects.class); + CommandSender sender = mock(CommandSender.class); + when(sender.hasPermission("spigottyrant.admin")).thenReturn(true); + TyrantAdminCommand command = command(manager, effects); + + command.onCommand(sender, mock(Command.class), "tyrantadmin", new String[] {"pause"}); + + verify(manager).replaceState(org.mockito.ArgumentMatchers.argThat( + state -> state.game().lifecycle() == GameLifecycle.PAUSED + && state.game().tyrantLevel() == 2 + )); + verify(effects).suppressAll(); + } + + @Test + void resetRequiresExplicitConfirmation() { + TyrantStateManager manager = mock(TyrantStateManager.class); + CommandSender sender = mock(CommandSender.class); + when(sender.hasPermission("spigottyrant.admin")).thenReturn(true); + TyrantAdminCommand command = command(manager, mock(ManagedRoleEffects.class)); + + command.onCommand(sender, mock(Command.class), "tyrantadmin", new String[] {"reset"}); + + verify(sender).sendMessage(contains("reset confirm")); + verify(manager, never()).replaceState(org.mockito.ArgumentMatchers.any()); + } + + private static TyrantAdminCommand command( + TyrantStateManager manager, + ManagedRoleEffects effects + ) { + return new TyrantAdminCommand( + manager, + new GameLifecycleService(), + mock(OnlinePlayerDirectory.class), + effects, + Clock.fixed(Instant.parse("2026-08-14T12:00:00Z"), ZoneOffset.UTC) + ); + } +}