From 805efda8bbaa1691b2bc7f987558fc168851d7e2 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Fri, 14 Aug 2026 22:33:11 -0400 Subject: [PATCH] feat(progression): add tyrant unlock choices --- design/log.md | 10 ++ .../us-003-progress-and-purchase-unlocks.md | 18 +-- .../dmg/spigottyrant/PurchaseResult.java | 10 ++ .../dmg/spigottyrant/PurchaseStatus.java | 9 ++ .../dmg/spigottyrant/SpigotTyrantPlugin.java | 3 + .../games/dmg/spigottyrant/TyrantCommand.java | 86 ++++++++++++ .../TyrantProgressionService.java | 75 +++++++++++ src/main/resources/plugin.yml | 4 + .../dmg/spigottyrant/PluginMetadataTest.java | 3 + .../dmg/spigottyrant/TyrantCommandTest.java | 50 +++++++ .../TyrantProgressionServiceTest.java | 124 ++++++++++++++++++ 11 files changed, 383 insertions(+), 9 deletions(-) create mode 100644 src/main/java/games/dmg/spigottyrant/PurchaseResult.java create mode 100644 src/main/java/games/dmg/spigottyrant/PurchaseStatus.java create mode 100644 src/main/java/games/dmg/spigottyrant/TyrantCommand.java create mode 100644 src/main/java/games/dmg/spigottyrant/TyrantProgressionService.java create mode 100644 src/test/java/games/dmg/spigottyrant/TyrantCommandTest.java create mode 100644 src/test/java/games/dmg/spigottyrant/TyrantProgressionServiceTest.java diff --git a/design/log.md b/design/log.md index 626df3c..28e1786 100644 --- a/design/log.md +++ b/design/log.md @@ -6,6 +6,16 @@ description: Chronological record of material decisions affecting the Spigot Tyr # Spigot Tyrant Design Log +## 2026-08-14 — Tyrant progression completed + +- Completed US-003 with exact Tyrant-on-Vigilante kill attribution, level and choice rewards, pause guards, one-time purchases, and reign reset behavior. +- Added `/tyrant choices` status output and `/tyrant buy ` 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. 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 4ed7b1f..e5bb453 100644 --- a/design/user-stories/us-003-progress-and-purchase-unlocks.md +++ b/design/user-stories/us-003-progress-and-purchase-unlocks.md @@ -2,7 +2,7 @@ type: User Story title: "US-003: Progress and purchase Tyrant unlocks" description: Let the Tyrant earn and spend choices by personally defeating the Vigilante. -status: backlog +status: done --- # US-003: Progress and purchase Tyrant unlocks @@ -11,14 +11,14 @@ As the **Tyrant**, I want to earn one meaningful choice for defeating the Vigila ## Acceptance criteria -- [ ] The Tyrant gains one level and one unlock choice only when personally credited with killing the current Vigilante. -- [ ] A Vigilante death caused by another player, the environment, or the Vigilante does not level the Tyrant. -- [ ] Available purchases are Assassin, Fixer, Tamer, roster intelligence, permanent Resistance, and permanent Strength. -- [ ] Each class or ability can be purchased at most once during a reign. -- [ ] A menu or command clearly identifies purchased, available, and unavailable choices. -- [ ] A purchase consumes exactly one unspent choice and cannot be duplicated by retries or concurrent input. -- [ ] Progress cannot be earned or spent while the game is paused. -- [ ] Levels, purchases, and unspent choices survive logout and restart but reset when the reign ends. +- [x] The Tyrant gains one level and one unlock choice only when personally credited with killing the current Vigilante. +- [x] A Vigilante death caused by another player, the environment, or the Vigilante does not level the Tyrant. +- [x] Available purchases are Assassin, Fixer, Tamer, roster intelligence, permanent Resistance, and permanent Strength. +- [x] Each class or ability can be purchased at most once during a reign. +- [x] A menu or command clearly identifies purchased, available, and unavailable choices. +- [x] A purchase consumes exactly one unspent choice and cannot be duplicated by retries or concurrent input. +- [x] Progress cannot be earned or spent while the game is paused. +- [x] Levels, purchases, and unspent choices survive logout and restart but reset when the reign ends. ## Related diff --git a/src/main/java/games/dmg/spigottyrant/PurchaseResult.java b/src/main/java/games/dmg/spigottyrant/PurchaseResult.java new file mode 100644 index 0000000..d1733ac --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/PurchaseResult.java @@ -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"); + } +} diff --git a/src/main/java/games/dmg/spigottyrant/PurchaseStatus.java b/src/main/java/games/dmg/spigottyrant/PurchaseStatus.java new file mode 100644 index 0000000..da0278c --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/PurchaseStatus.java @@ -0,0 +1,9 @@ +package games.dmg.spigottyrant; + +public enum PurchaseStatus { + PURCHASED, + GAME_NOT_RUNNING, + NOT_TYRANT, + NO_CHOICES, + ALREADY_PURCHASED +} diff --git a/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java b/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java index 0d43787..bdf603b 100644 --- a/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java +++ b/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java @@ -2,6 +2,7 @@ package games.dmg.spigottyrant; import java.io.IOException; import java.util.Map; +import java.util.Objects; import java.util.logging.Level; import org.bukkit.plugin.java.JavaPlugin; @@ -25,6 +26,8 @@ public final class SpigotTyrantPlugin extends JavaPlugin { return; } + Objects.requireNonNull(getCommand("tyrant"), "Missing tyrant command metadata") + .setExecutor(new TyrantCommand(stateManager, new TyrantProgressionService())); getServer().getScheduler().runTaskTimer(this, stateManager::saveIfDirty, 600L, 600L); getLogger().info("Spigot Tyrant enabled."); } diff --git a/src/main/java/games/dmg/spigottyrant/TyrantCommand.java b/src/main/java/games/dmg/spigottyrant/TyrantCommand.java new file mode 100644 index 0000000..628d660 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/TyrantCommand.java @@ -0,0 +1,86 @@ +package games.dmg.spigottyrant; + +import java.util.Locale; +import org.bukkit.ChatColor; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +public final class TyrantCommand implements CommandExecutor { + private final TyrantStateManager stateManager; + private final TyrantProgressionService progression; + + public TyrantCommand( + TyrantStateManager stateManager, + TyrantProgressionService progression + ) { + this.stateManager = stateManager; + this.progression = progression; + } + + @Override + public boolean onCommand( + CommandSender sender, + Command command, + String label, + String[] arguments + ) { + if (!(sender instanceof Player player)) { + sender.sendMessage("This command must be used by a player."); + return true; + } + if (arguments.length == 0 || arguments[0].equalsIgnoreCase("choices")) { + showChoices(player); + return true; + } + if (arguments.length == 2 && arguments[0].equalsIgnoreCase("buy")) { + buy(player, arguments[1]); + return true; + } + player.sendMessage(ChatColor.YELLOW + "Usage: /tyrant choices | /tyrant buy "); + return true; + } + + private void showChoices(Player player) { + GameState state = stateManager.game(); + player.sendMessage(ChatColor.GOLD + "Tyrant unlock choices: " + state.unspentChoices()); + for (TyrantUnlock unlock : TyrantUnlock.values()) { + String status; + if (state.purchases().contains(unlock)) { + status = "PURCHASED"; + } else if (state.lifecycle() == GameLifecycle.RUNNING + && state.tyrantId().filter(player.getUniqueId()::equals).isPresent() + && state.unspentChoices() > 0) { + status = "AVAILABLE"; + } else { + status = "LOCKED"; + } + player.sendMessage(ChatColor.YELLOW + "[" + status + "] " + unlock.name()); + } + } + + private void buy(Player player, String requestedUnlock) { + TyrantUnlock unlock; + try { + unlock = TyrantUnlock.valueOf(requestedUnlock.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException exception) { + player.sendMessage(ChatColor.RED + "Unknown unlock. Use /tyrant choices."); + return; + } + PurchaseResult[] result = new PurchaseResult[1]; + stateManager.updateGame(current -> { + result[0] = progression.purchase(current, player.getUniqueId(), unlock); + return result[0].state(); + }); + if (result[0].status() == PurchaseStatus.PURCHASED) { + stateManager.saveIfDirty(); + player.sendMessage(ChatColor.GREEN + "Purchased " + unlock.name() + "."); + } else { + player.sendMessage( + ChatColor.RED + "Could not purchase " + unlock.name() + ": " + + result[0].status().name().toLowerCase(Locale.ROOT).replace('_', ' ') + ); + } + } +} diff --git a/src/main/java/games/dmg/spigottyrant/TyrantProgressionService.java b/src/main/java/games/dmg/spigottyrant/TyrantProgressionService.java new file mode 100644 index 0000000..a749fe7 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/TyrantProgressionService.java @@ -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 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 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 purchases + ) { + return new GameState( + state.lifecycle(), + state.tyrantId(), + state.vigilanteId(), + state.pendingTyrant(), + state.pendingVigilante(), + state.pausedAt(), + state.accumulatedPausedTime(), + level, + choices, + purchases + ); + } +} diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 29e055c..22c85ec 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -4,3 +4,7 @@ main: games.dmg.spigottyrant.SpigotTyrantPlugin api-version: "1.20" description: Asymmetric Tyrant, Vigilante, Follower, and specialist class gameplay. author: dmg.games +commands: + tyrant: + description: View and use Spigot Tyrant game features. + usage: /tyrant [choices|buy ] diff --git a/src/test/java/games/dmg/spigottyrant/PluginMetadataTest.java b/src/test/java/games/dmg/spigottyrant/PluginMetadataTest.java index 025ed2f..e64a5b1 100644 --- a/src/test/java/games/dmg/spigottyrant/PluginMetadataTest.java +++ b/src/test/java/games/dmg/spigottyrant/PluginMetadataTest.java @@ -20,6 +20,9 @@ final class PluginMetadataTest { assertEquals("SpigotTyrant", plugin.get("name")); assertEquals("games.dmg.spigottyrant.SpigotTyrantPlugin", plugin.get("main")); assertEquals("1.20", plugin.get("api-version")); + Map commands = (Map) plugin.get("commands"); + assertNotNull(commands); + assertNotNull(commands.get("tyrant")); } @Test diff --git a/src/test/java/games/dmg/spigottyrant/TyrantCommandTest.java b/src/test/java/games/dmg/spigottyrant/TyrantCommandTest.java new file mode 100644 index 0000000..36625f0 --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/TyrantCommandTest.java @@ -0,0 +1,50 @@ +package games.dmg.spigottyrant; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.bukkit.command.Command; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +final class TyrantCommandTest { + @Test + void choicesClearlyIdentifyPurchasedAvailableAndUnavailableUnlocks() { + UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111"); + GameState state = new GameState( + GameLifecycle.RUNNING, + Optional.of(tyrantId), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Duration.ZERO, + 1, + 1, + Set.of(TyrantUnlock.STRENGTH) + ); + TyrantStateManager manager = mock(TyrantStateManager.class); + when(manager.game()).thenReturn(state); + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(tyrantId); + TyrantCommand command = new TyrantCommand(manager, new TyrantProgressionService()); + + command.onCommand(player, mock(Command.class), "tyrant", new String[] {"choices"}); + + ArgumentCaptor messages = ArgumentCaptor.forClass(String.class); + verify(player, org.mockito.Mockito.atLeastOnce()).sendMessage(messages.capture()); + List 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"))); + } +} diff --git a/src/test/java/games/dmg/spigottyrant/TyrantProgressionServiceTest.java b/src/test/java/games/dmg/spigottyrant/TyrantProgressionServiceTest.java new file mode 100644 index 0000000..79ccd8c --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/TyrantProgressionServiceTest.java @@ -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 purchases + ) { + return new GameState( + GameLifecycle.RUNNING, + Optional.of(TYRANT), + Optional.of(VIGILANTE), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Duration.ZERO, + level, + choices, + purchases + ); + } +}