diff --git a/design/log.md b/design/log.md index da511bc..356c3be 100644 --- a/design/log.md +++ b/design/log.md @@ -6,6 +6,12 @@ description: Chronological record of material decisions affecting the Spigot Tyr # Spigot Tyrant Design Log +## 2026-09-04 — Administrative Tyrant point grant completed + +- Added `/tyrantadmin grantpoint` so a permitted administrator can grant the current Tyrant exactly one level and one unspent unlock choice during a running game. +- Grants preserve purchases and unrelated state, persist immediately, report updated progression, reject vacancies and non-running games safely, and appear in contextual command completion. +- Verified progression and command behavior, permission enforcement, completion, 127 automated tests, compiler warnings, and packaging with `./gradlew clean check jar`. + ## 2026-09-04 — Vigilante arena victory kept private - Removed the server-wide Vigilante arena victory announcement that exposed the new Vigilante's identity. 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 434ec09..ac41646 100644 --- a/design/user-stories/us-001-start-pause-and-administer.md +++ b/design/user-stories/us-001-start-pause-and-administer.md @@ -18,6 +18,9 @@ As a **server operator**, I want to start, pause, resume, and inspect the game s - [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, legacy pending selections, and the configured shared role arena location. +- [x] `/tyrantadmin grantpoint` requires administrative permission and grants the current Tyrant exactly one level and one unspent unlock choice. +- [x] A successful administrative point grant persists immediately and confirms the updated level and choice count to the administrator. +- [x] If the game is not running or has no current Tyrant, an administrative point grant changes nothing and reports a clear error. - [x] Destructive administrative operations require explicit confirmation. ## Related diff --git a/design/user-stories/us-003-progress-and-purchase-unlocks.md b/design/user-stories/us-003-progress-and-purchase-unlocks.md index 0b791be..9d63a7b 100644 --- a/design/user-stories/us-003-progress-and-purchase-unlocks.md +++ b/design/user-stories/us-003-progress-and-purchase-unlocks.md @@ -12,6 +12,8 @@ As the **Tyrant**, I want to earn one meaningful choice for defeating the Vigila ## Acceptance criteria - [x] The Tyrant gains one level and one unlock choice only when personally credited with killing the current Vigilante. +- [x] An administrator can grant the current Tyrant one level and one unlock choice without changing purchases or unrelated game state. +- [x] Each successful administrative grant adds exactly one level and one choice, including across repeated invocations. - [x] A Vigilante death caused by another player, the environment, or the Vigilante does not level the Tyrant. - [x] Available purchases are Assassin, Fixer, Tamer, roster intelligence, permanent Resistance, and permanent Strength. - [x] Each class or ability can be purchased at most once during a reign. diff --git a/design/user-stories/us-018-complete-commands-contextually.md b/design/user-stories/us-018-complete-commands-contextually.md index 1f833c2..a7410e9 100644 --- a/design/user-stories/us-018-complete-commands-contextually.md +++ b/design/user-stories/us-018-complete-commands-contextually.md @@ -22,6 +22,7 @@ As a **player or administrator**, I want contextual command suggestions so that - [x] `/tyrantadmin start` suggests online Tyrant and Vigilante candidates. - [x] `/tyrantadmin reset` suggests `confirm`. - [x] `/tyrantadmin arena` suggests `set` and `status`. +- [x] `/tyrantadmin` suggests `grantpoint`, which accepts no additional arguments. - [x] Suggestions are filtered case-insensitively by the partially typed argument. - [x] Suggestions never include syntactically invalid options for the current argument position. - [x] Console completion works for administrative commands without exposing player-only commands as executable console actions. @@ -29,7 +30,7 @@ As a **player or administrator**, I want contextual command suggestions so that ## Validation -Automated tests verify root syntax, unlocks, classes, confirmations, eligible recruits, current Followers, permission gating, online administrative candidates, argument positions, and case-insensitive prefix filtering. The complete `./gradlew clean check jar` lifecycle passes. +Automated tests verify root syntax, unlocks, classes, confirmations, eligible recruits, current Followers, permission gating, online administrative candidates, the argument-free `grantpoint` command, argument positions, and case-insensitive prefix filtering. The complete `./gradlew clean check jar` lifecycle passes. ## Related diff --git a/src/main/java/games/dmg/spigottyrant/TyrantAdminCommand.java b/src/main/java/games/dmg/spigottyrant/TyrantAdminCommand.java index 49c9b8d..4d5969a 100644 --- a/src/main/java/games/dmg/spigottyrant/TyrantAdminCommand.java +++ b/src/main/java/games/dmg/spigottyrant/TyrantAdminCommand.java @@ -17,6 +17,7 @@ public final class TyrantAdminCommand implements CommandExecutor { private final ManagedRoleEffects effects; private final Clock clock; private final ArenaLocationStore arenaLocations; + private final TyrantProgressionService progression = new TyrantProgressionService(); public TyrantAdminCommand( TyrantStateManager stateManager, @@ -69,6 +70,7 @@ public final class TyrantAdminCommand implements CommandExecutor { case "resume" -> resume(sender); case "reset" -> reset(sender, arguments); case "arena" -> arena(sender, arguments); + case "grantpoint" -> grantPoint(sender, arguments); default -> usage(sender); } } catch (IllegalArgumentException | IllegalStateException exception) { @@ -141,6 +143,20 @@ public final class TyrantAdminCommand implements CommandExecutor { sender.sendMessage(ChatColor.GREEN + "Tyrant game reset."); } + private void grantPoint(CommandSender sender, String[] arguments) { + if (arguments.length != 1) { + sender.sendMessage(ChatColor.YELLOW + "Usage: /tyrantadmin grantpoint"); + return; + } + PersistentState snapshot = stateManager.snapshot(); + GameState updated = progression.grantPoint(snapshot.game()); + stateManager.replaceState(new LifecycleState(updated, snapshot.players())); + stateManager.saveIfDirty(); + sender.sendMessage(ChatColor.GREEN + "Granted the Tyrant one point; now level " + + updated.tyrantLevel() + " and " + updated.unspentChoices() + + " unspent choices."); + } + private void arena(CommandSender sender, String[] arguments) { if (arguments.length == 1 || arguments[1].equalsIgnoreCase("status")) { sender.sendMessage("Shared role arena: " + arenaLocations.location() @@ -194,6 +210,6 @@ public final class TyrantAdminCommand implements CommandExecutor { private static void usage(CommandSender sender) { sender.sendMessage(ChatColor.YELLOW - + "Usage: /tyrantadmin "); + + "Usage: /tyrantadmin "); } } diff --git a/src/main/java/games/dmg/spigottyrant/TyrantAdminTabCompleter.java b/src/main/java/games/dmg/spigottyrant/TyrantAdminTabCompleter.java index 82ffd9f..e28fc3f 100644 --- a/src/main/java/games/dmg/spigottyrant/TyrantAdminTabCompleter.java +++ b/src/main/java/games/dmg/spigottyrant/TyrantAdminTabCompleter.java @@ -11,7 +11,7 @@ import org.bukkit.entity.Player; public final class TyrantAdminTabCompleter implements TabCompleter { private static final String PERMISSION = "spigottyrant.admin"; private static final List SUBCOMMANDS = List.of( - "status", "start", "pause", "resume", "reset", "arena" + "status", "start", "pause", "resume", "reset", "arena", "grantpoint" ); private final OnlinePlayerDirectory onlinePlayers; diff --git a/src/main/java/games/dmg/spigottyrant/TyrantProgressionService.java b/src/main/java/games/dmg/spigottyrant/TyrantProgressionService.java index a749fe7..a63317f 100644 --- a/src/main/java/games/dmg/spigottyrant/TyrantProgressionService.java +++ b/src/main/java/games/dmg/spigottyrant/TyrantProgressionService.java @@ -20,6 +20,25 @@ public final class TyrantProgressionService { ); } + public GameState grantPoint(GameState state) { + if (state.lifecycle() != GameLifecycle.RUNNING) { + throw new IllegalStateException("Tyrant game must be running."); + } + if (state.tyrantId().isEmpty()) { + throw new IllegalStateException("There is no current Tyrant."); + } + if (state.tyrantLevel() == Integer.MAX_VALUE + || state.unspentChoices() == Integer.MAX_VALUE) { + throw new IllegalStateException("Tyrant progression cannot be increased further."); + } + return copyProgress( + state, + state.tyrantLevel() + 1, + state.unspentChoices() + 1, + state.purchases() + ); + } + public PurchaseResult purchase( GameState state, UUID buyerId, diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 4bda63e..999137a 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -13,7 +13,7 @@ commands: usage: /vigilante |accept|dismiss |leave> tyrantadmin: description: Administer the Spigot Tyrant game. - usage: /tyrantadmin > + usage: /tyrantadmin |grantpoint> permission: spigottyrant.admin permissions: spigottyrant.admin: diff --git a/src/test/java/games/dmg/spigottyrant/TyrantAdminCommandTest.java b/src/test/java/games/dmg/spigottyrant/TyrantAdminCommandTest.java index 0c04c16..dc679a8 100644 --- a/src/test/java/games/dmg/spigottyrant/TyrantAdminCommandTest.java +++ b/src/test/java/games/dmg/spigottyrant/TyrantAdminCommandTest.java @@ -62,6 +62,93 @@ final class TyrantAdminCommandTest { verify(effects).suppressAll(); } + @Test + void grantPointAddsAndPersistsOneLevelAndChoice() { + 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.ASSASSIN) + ); + TyrantStateManager manager = mock(TyrantStateManager.class); + when(manager.snapshot()).thenReturn(new PersistentState( + game, Map.of(tyrant, PlayerState.newPlayer(tyrant, "Tyrant")) + )); + CommandSender sender = mock(CommandSender.class); + when(sender.hasPermission("spigottyrant.admin")).thenReturn(true); + + command(manager, mock(ManagedRoleEffects.class)).onCommand( + sender, mock(Command.class), "tyrantadmin", new String[] {"grantpoint"} + ); + + verify(manager).replaceState(org.mockito.ArgumentMatchers.argThat(state -> + state.game().tyrantLevel() == 3 + && state.game().unspentChoices() == 2 + && state.game().purchases().equals(Set.of(TyrantUnlock.ASSASSIN)) + )); + verify(manager).saveIfDirty(); + verify(sender).sendMessage(contains("level 3 and 2 unspent choices")); + } + + @Test + void grantPointRejectsPausedGameWithoutChangingState() { + UUID tyrant = UUID.fromString("11111111-1111-1111-1111-111111111111"); + GameState paused = new GameState( + GameLifecycle.PAUSED, Optional.of(tyrant), Optional.empty(), + Optional.empty(), Optional.empty(), + Optional.of(Instant.parse("2026-08-14T12:00:00Z")), Duration.ZERO, + 2, 1, Set.of() + ); + TyrantStateManager manager = mock(TyrantStateManager.class); + when(manager.snapshot()).thenReturn(new PersistentState( + paused, Map.of(tyrant, PlayerState.newPlayer(tyrant, "Tyrant")) + )); + CommandSender sender = mock(CommandSender.class); + when(sender.hasPermission("spigottyrant.admin")).thenReturn(true); + + command(manager, mock(ManagedRoleEffects.class)).onCommand( + sender, mock(Command.class), "tyrantadmin", new String[] {"grantpoint"} + ); + + verify(manager, never()).replaceState(org.mockito.ArgumentMatchers.any()); + verify(sender).sendMessage(contains("must be running")); + } + + @Test + void grantPointRejectsAdditionalArgumentsWithoutChangingState() { + TyrantStateManager manager = mock(TyrantStateManager.class); + CommandSender sender = mock(CommandSender.class); + when(sender.hasPermission("spigottyrant.admin")).thenReturn(true); + + command(manager, mock(ManagedRoleEffects.class)).onCommand( + sender, mock(Command.class), "tyrantadmin", + new String[] {"grantpoint", "unexpected"} + ); + + verify(manager, never()).replaceState(org.mockito.ArgumentMatchers.any()); + verify(sender).sendMessage(contains("Usage: /tyrantadmin grantpoint")); + } + + @Test + void grantPointRejectsTyrantVacancyWithoutChangingState() { + GameState vacant = new GameState( + GameLifecycle.RUNNING, Optional.empty(), Optional.empty(), + Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO, + 0, 0, Set.of() + ); + TyrantStateManager manager = mock(TyrantStateManager.class); + when(manager.snapshot()).thenReturn(new PersistentState(vacant, Map.of())); + CommandSender sender = mock(CommandSender.class); + when(sender.hasPermission("spigottyrant.admin")).thenReturn(true); + + command(manager, mock(ManagedRoleEffects.class)).onCommand( + sender, mock(Command.class), "tyrantadmin", new String[] {"grantpoint"} + ); + + verify(manager, never()).replaceState(org.mockito.ArgumentMatchers.any()); + verify(sender).sendMessage(contains("no current Tyrant")); + } + @Test void playerCanPersistArenaAtCurrentLocation() { TyrantStateManager manager = mock(TyrantStateManager.class); diff --git a/src/test/java/games/dmg/spigottyrant/TyrantAdminTabCompleterTest.java b/src/test/java/games/dmg/spigottyrant/TyrantAdminTabCompleterTest.java index 4db7fcf..70f76f8 100644 --- a/src/test/java/games/dmg/spigottyrant/TyrantAdminTabCompleterTest.java +++ b/src/test/java/games/dmg/spigottyrant/TyrantAdminTabCompleterTest.java @@ -35,7 +35,7 @@ final class TyrantAdminTabCompleterTest { assertTrue(completer.onTabComplete( admin, command, "tyrantadmin", new String[] {""} ).containsAll(java.util.List.of( - "status", "start", "pause", "resume", "reset", "arena" + "status", "start", "pause", "resume", "reset", "arena", "grantpoint" ))); assertEquals(java.util.List.of("Alpha", "Beta"), completer.onTabComplete( admin, command, "tyrantadmin", new String[] {"start", ""} @@ -49,6 +49,9 @@ final class TyrantAdminTabCompleterTest { assertEquals(java.util.List.of("set", "status"), completer.onTabComplete( admin, command, "tyrantadmin", new String[] {"arena", "s"} )); + assertEquals(java.util.List.of(), completer.onTabComplete( + admin, command, "tyrantadmin", new String[] {"grantpoint", ""} + )); } private static Player player(UUID id, String name) { diff --git a/src/test/java/games/dmg/spigottyrant/TyrantProgressionServiceTest.java b/src/test/java/games/dmg/spigottyrant/TyrantProgressionServiceTest.java index 79ccd8c..78eb785 100644 --- a/src/test/java/games/dmg/spigottyrant/TyrantProgressionServiceTest.java +++ b/src/test/java/games/dmg/spigottyrant/TyrantProgressionServiceTest.java @@ -2,6 +2,7 @@ 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.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.time.Duration; @@ -47,6 +48,38 @@ final class TyrantProgressionServiceTest { assertEquals(paused, service.recordVigilanteDeath(paused, Optional.of(TYRANT))); } + @Test + void administrativePointGrantAddsOneLevelAndChoicePerInvocation() { + GameState before = runningState(2, 1, Set.of(TyrantUnlock.ASSASSIN)); + + GameState first = service.grantPoint(before); + GameState second = service.grantPoint(first); + + assertEquals(4, second.tyrantLevel()); + assertEquals(3, second.unspentChoices()); + assertEquals(before.purchases(), second.purchases()); + assertEquals(before.tyrantId(), second.tyrantId()); + assertEquals(before.vigilanteId(), second.vigilanteId()); + } + + @Test + void administrativePointGrantRequiresRunningGameAndCurrentTyrant() { + GameState running = runningState(0, 1, 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, 0, 1, Set.of() + ); + GameState vacant = new GameState( + GameLifecycle.RUNNING, Optional.empty(), Optional.empty(), Optional.empty(), + Optional.empty(), Optional.empty(), Duration.ZERO, 0, 0, Set.of() + ); + + assertThrows(IllegalStateException.class, () -> service.grantPoint(paused)); + assertThrows(IllegalStateException.class, () -> service.grantPoint(vacant)); + } + @Test void purchaseConsumesOneChoiceAndCannotBeBoughtTwice() { GameState before = runningState(1, 2, Set.of());