feat(admin): add Tyrant point grant command
Release / release (push) Successful in 2m27s
CI / build (push) Successful in 1m4s

This commit is contained in:
dmg
2026-09-04 22:12:47 -04:00
parent 4b6c709157
commit e5de6b76e7
11 changed files with 175 additions and 5 deletions
+6
View File
@@ -6,6 +6,12 @@ description: Chronological record of material decisions affecting the Spigot Tyr
# Spigot Tyrant Design Log # 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 ## 2026-09-04 — Vigilante arena victory kept private
- Removed the server-wide Vigilante arena victory announcement that exposed the new Vigilante's identity. - Removed the server-wide Vigilante arena victory announcement that exposed the new Vigilante's identity.
@@ -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] 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] 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] 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. - [x] Destructive administrative operations require explicit confirmation.
## Related ## Related
@@ -12,6 +12,8 @@ As the **Tyrant**, I want to earn one meaningful choice for defeating the Vigila
## Acceptance criteria ## Acceptance criteria
- [x] 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.
- [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] 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] Available purchases are Assassin, Fixer, Tamer, roster intelligence, permanent Resistance, and permanent Strength.
- [x] Each class or ability can be purchased at most once during a reign. - [x] Each class or ability can be purchased at most once during a reign.
@@ -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 start` suggests online Tyrant and Vigilante candidates.
- [x] `/tyrantadmin reset` suggests `confirm`. - [x] `/tyrantadmin reset` suggests `confirm`.
- [x] `/tyrantadmin arena` suggests `set` and `status`. - [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 are filtered case-insensitively by the partially typed argument.
- [x] Suggestions never include syntactically invalid options for the current argument position. - [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. - [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 ## 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 ## Related
@@ -17,6 +17,7 @@ public final class TyrantAdminCommand implements CommandExecutor {
private final ManagedRoleEffects effects; private final ManagedRoleEffects effects;
private final Clock clock; private final Clock clock;
private final ArenaLocationStore arenaLocations; private final ArenaLocationStore arenaLocations;
private final TyrantProgressionService progression = new TyrantProgressionService();
public TyrantAdminCommand( public TyrantAdminCommand(
TyrantStateManager stateManager, TyrantStateManager stateManager,
@@ -69,6 +70,7 @@ public final class TyrantAdminCommand implements CommandExecutor {
case "resume" -> resume(sender); case "resume" -> resume(sender);
case "reset" -> reset(sender, arguments); case "reset" -> reset(sender, arguments);
case "arena" -> arena(sender, arguments); case "arena" -> arena(sender, arguments);
case "grantpoint" -> grantPoint(sender, arguments);
default -> usage(sender); default -> usage(sender);
} }
} catch (IllegalArgumentException | IllegalStateException exception) { } catch (IllegalArgumentException | IllegalStateException exception) {
@@ -141,6 +143,20 @@ public final class TyrantAdminCommand implements CommandExecutor {
sender.sendMessage(ChatColor.GREEN + "Tyrant game reset."); 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) { private void arena(CommandSender sender, String[] arguments) {
if (arguments.length == 1 || arguments[1].equalsIgnoreCase("status")) { if (arguments.length == 1 || arguments[1].equalsIgnoreCase("status")) {
sender.sendMessage("Shared role arena: " + arenaLocations.location() sender.sendMessage("Shared role arena: " + arenaLocations.location()
@@ -194,6 +210,6 @@ public final class TyrantAdminCommand implements CommandExecutor {
private static void usage(CommandSender sender) { private static void usage(CommandSender sender) {
sender.sendMessage(ChatColor.YELLOW sender.sendMessage(ChatColor.YELLOW
+ "Usage: /tyrantadmin <status|start|pause|resume|reset confirm|arena set>"); + "Usage: /tyrantadmin <status|start|pause|resume|reset confirm|arena set|grantpoint>");
} }
} }
@@ -11,7 +11,7 @@ import org.bukkit.entity.Player;
public final class TyrantAdminTabCompleter implements TabCompleter { public final class TyrantAdminTabCompleter implements TabCompleter {
private static final String PERMISSION = "spigottyrant.admin"; private static final String PERMISSION = "spigottyrant.admin";
private static final List<String> SUBCOMMANDS = List.of( private static final List<String> SUBCOMMANDS = List.of(
"status", "start", "pause", "resume", "reset", "arena" "status", "start", "pause", "resume", "reset", "arena", "grantpoint"
); );
private final OnlinePlayerDirectory onlinePlayers; private final OnlinePlayerDirectory onlinePlayers;
@@ -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( public PurchaseResult purchase(
GameState state, GameState state,
UUID buyerId, UUID buyerId,
+1 -1
View File
@@ -13,7 +13,7 @@ commands:
usage: /vigilante <menu|item|invite <player>|accept|dismiss <player>|leave> usage: /vigilante <menu|item|invite <player>|accept|dismiss <player>|leave>
tyrantadmin: tyrantadmin:
description: Administer the Spigot Tyrant game. description: Administer the Spigot Tyrant game.
usage: /tyrantadmin <status|start|pause|resume|reset confirm|arena <set|status>> usage: /tyrantadmin <status|start|pause|resume|reset confirm|arena <set|status>|grantpoint>
permission: spigottyrant.admin permission: spigottyrant.admin
permissions: permissions:
spigottyrant.admin: spigottyrant.admin:
@@ -62,6 +62,93 @@ final class TyrantAdminCommandTest {
verify(effects).suppressAll(); 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 @Test
void playerCanPersistArenaAtCurrentLocation() { void playerCanPersistArenaAtCurrentLocation() {
TyrantStateManager manager = mock(TyrantStateManager.class); TyrantStateManager manager = mock(TyrantStateManager.class);
@@ -35,7 +35,7 @@ final class TyrantAdminTabCompleterTest {
assertTrue(completer.onTabComplete( assertTrue(completer.onTabComplete(
admin, command, "tyrantadmin", new String[] {""} admin, command, "tyrantadmin", new String[] {""}
).containsAll(java.util.List.of( ).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( assertEquals(java.util.List.of("Alpha", "Beta"), completer.onTabComplete(
admin, command, "tyrantadmin", new String[] {"start", ""} admin, command, "tyrantadmin", new String[] {"start", ""}
@@ -49,6 +49,9 @@ final class TyrantAdminTabCompleterTest {
assertEquals(java.util.List.of("set", "status"), completer.onTabComplete( assertEquals(java.util.List.of("set", "status"), completer.onTabComplete(
admin, command, "tyrantadmin", new String[] {"arena", "s"} 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) { private static Player player(UUID id, String name) {
@@ -2,6 +2,7 @@ package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; 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 static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration; import java.time.Duration;
@@ -47,6 +48,38 @@ final class TyrantProgressionServiceTest {
assertEquals(paused, service.recordVigilanteDeath(paused, Optional.of(TYRANT))); 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 @Test
void purchaseConsumesOneChoiceAndCannotBeBoughtTwice() { void purchaseConsumesOneChoiceAndCannotBeBoughtTwice() {
GameState before = runningState(1, 2, Set.of()); GameState before = runningState(1, 2, Set.of());