From 62f63526335822411063c0e73819c6faf151602c Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Fri, 21 Aug 2026 20:47:33 -0400 Subject: [PATCH] feat(tyrant): add reign control panel --- design/log.md | 18 + design/user-stories/index.md | 1 + .../us-003-progress-and-purchase-unlocks.md | 4 + .../us-004-assign-unlocked-classes.md | 4 + .../us-009-use-tyrant-abilities.md | 3 + .../us-015-manage-tyrant-control-panel.md | 44 ++ .../BukkitTyrantControlPanelRenderer.java | 434 ++++++++++++++++++ .../DefaultTyrantControlPanel.java | 57 +++ .../spigottyrant/DelayedTaskScheduler.java | 6 + .../dmg/spigottyrant/SpigotTyrantPlugin.java | 18 +- .../games/dmg/spigottyrant/TyrantCommand.java | 50 +- .../dmg/spigottyrant/TyrantControlPanel.java | 19 + .../TyrantControlPanelJoinListener.java | 48 ++ .../spigottyrant/TyrantControlPanelModel.java | 67 +++ .../TyrantControlPanelRenderer.java | 8 + .../dmg/spigottyrant/UnlockAvailability.java | 7 + src/main/resources/plugin.yml | 2 +- .../DefaultTyrantControlPanelTest.java | 72 +++ .../dmg/spigottyrant/TyrantCommandTest.java | 23 + .../TyrantControlPanelJoinListenerTest.java | 105 +++++ .../TyrantControlPanelModelTest.java | 49 ++ 21 files changed, 1034 insertions(+), 5 deletions(-) create mode 100644 design/user-stories/us-015-manage-tyrant-control-panel.md create mode 100644 src/main/java/games/dmg/spigottyrant/BukkitTyrantControlPanelRenderer.java create mode 100644 src/main/java/games/dmg/spigottyrant/DefaultTyrantControlPanel.java create mode 100644 src/main/java/games/dmg/spigottyrant/DelayedTaskScheduler.java create mode 100644 src/main/java/games/dmg/spigottyrant/TyrantControlPanel.java create mode 100644 src/main/java/games/dmg/spigottyrant/TyrantControlPanelJoinListener.java create mode 100644 src/main/java/games/dmg/spigottyrant/TyrantControlPanelModel.java create mode 100644 src/main/java/games/dmg/spigottyrant/TyrantControlPanelRenderer.java create mode 100644 src/main/java/games/dmg/spigottyrant/UnlockAvailability.java create mode 100644 src/test/java/games/dmg/spigottyrant/DefaultTyrantControlPanelTest.java create mode 100644 src/test/java/games/dmg/spigottyrant/TyrantControlPanelJoinListenerTest.java create mode 100644 src/test/java/games/dmg/spigottyrant/TyrantControlPanelModelTest.java diff --git a/design/log.md b/design/log.md index dad6cee..d242132 100644 --- a/design/log.md +++ b/design/log.md @@ -6,6 +6,24 @@ description: Chronological record of material decisions affecting the Spigot Tyr # Spigot Tyrant Design Log +## 2026-08-21 — Tyrant control panel completed + +- Completed US-015 and the reopened US-003, US-004, and US-009 control-panel criteria with overview, unlock purchase confirmation, class assignment, and ability screens. +- The active Tyrant can open the panel with `/tyrant` or `/tyrant menu`; existing command alternatives remain available and provide the authoritative mutations behind menu actions. +- Added a 100-tick login delay with online, lifecycle, role, and already-opened session checks so only the current Tyrant receives one automatic panel per login. +- Verified command routing, authorization, overview state, delayed opening safeguards, compiler warnings, tests, and packaging with `./gradlew clean check jar`. + +## 2026-08-21 — Tyrant control panel implementation started + +- US-003, US-004, US-009, and US-015 begin a test-first implementation of the Tyrant control panel and its delayed login opening. +- The installed Spigot API does not expose a native dialog API, so the control panel will use a server-rendered inventory menu while preserving command alternatives. + +## 2026-08-21 — Tyrant control panel planned + +- Added US-015 for a graphical Tyrant control panel covering reign overview, unlock purchases, class assignments, and roster intelligence while retaining command alternatives. +- Planned automatic control-panel opening five seconds after the active Tyrant joins a running event, with lifecycle, role, disconnect, and duplicate-opening safeguards. +- Reopened US-003, US-004, and US-009 in the backlog to track their approved control-panel interactions. + ## 2026-08-18 — Preserve ordinary potion effects - Corrected Assassin effect maintenance so it refreshes active Tyrant abilities without removing invisibility, Speed, or Weakness supplied by potions, commands, or other plugins. diff --git a/design/user-stories/index.md b/design/user-stories/index.md index 0dd8f75..937ef78 100644 --- a/design/user-stories/index.md +++ b/design/user-stories/index.md @@ -14,3 +14,4 @@ 12. [US-012: Configure and persist game state](us-012-configure-and-persist-state.md) 13. [US-013: Build, test, and release the plugin](us-013-build-test-and-release.md) 14. [US-014: Use class ability items](us-014-use-class-ability-items.md) +15. [US-015: Manage a reign through the Tyrant control panel](us-015-manage-tyrant-control-panel.md) 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 e5bb453..0b791be 100644 --- a/design/user-stories/us-003-progress-and-purchase-unlocks.md +++ b/design/user-stories/us-003-progress-and-purchase-unlocks.md @@ -19,9 +19,13 @@ As the **Tyrant**, I want to earn one meaningful choice for defeating the Vigila - [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. +- [x] The Tyrant can review and purchase unlocks through the Tyrant control panel. +- [x] A confirmation displays the selected unlock and choice cost before purchase. +- [x] The control panel refreshes the available choices immediately after a successful purchase. ## Related - [Role and class model](../concepts/role-and-class-model.md) - [Assign unlocked classes](us-004-assign-unlocked-classes.md) - [Purchase and use Tyrant abilities](us-009-use-tyrant-abilities.md) +- [Manage a reign through the Tyrant control panel](us-015-manage-tyrant-control-panel.md) diff --git a/design/user-stories/us-004-assign-unlocked-classes.md b/design/user-stories/us-004-assign-unlocked-classes.md index d47dc99..1e9dfa5 100644 --- a/design/user-stories/us-004-assign-unlocked-classes.md +++ b/design/user-stories/us-004-assign-unlocked-classes.md @@ -19,8 +19,12 @@ As the **Tyrant**, I want to assign unlocked specialist classes so that I can bu - [x] Assignment and removal are stored by UUID and communicated clearly to affected players. - [x] A class holder is reminded of the assignment and its controls on login. - [x] All class assignments end when the Tyrant's reign ends. +- [x] The Tyrant can assign and reassign purchased classes through the Tyrant control panel. +- [x] The control panel's player selector communicates eligibility and identifies the current class holder before confirmation. +- [x] Control-panel and command-based assignments enforce identical rules. ## Related - [Role and class model](../concepts/role-and-class-model.md) - [Use class ability items](us-014-use-class-ability-items.md) +- [Manage a reign through the Tyrant control panel](us-015-manage-tyrant-control-panel.md) diff --git a/design/user-stories/us-009-use-tyrant-abilities.md b/design/user-stories/us-009-use-tyrant-abilities.md index 6c2675a..379cea6 100644 --- a/design/user-stories/us-009-use-tyrant-abilities.md +++ b/design/user-stories/us-009-use-tyrant-abilities.md @@ -19,8 +19,11 @@ As the **Tyrant**, I want permanent upgrades and limited roster intelligence so - [x] Intelligence counts the current Vigilante and living assigned Followers, whether online or offline, according to configurable counting rules. - [x] The Tyrant can inspect ability ownership and the remaining intelligence cooldown. - [x] Purchases and cooldowns survive restart and end with the Tyrant's reign. +- [x] The Tyrant control panel shows purchased passive upgrades and the roster-intelligence readiness or remaining cooldown. +- [x] The Tyrant can activate ready roster intelligence from the control panel and receive the result without revealing identities or locations. ## Related - [Progress and purchase Tyrant unlocks](us-003-progress-and-purchase-unlocks.md) - [Configure and persist game state](us-012-configure-and-persist-state.md) +- [Manage a reign through the Tyrant control panel](us-015-manage-tyrant-control-panel.md) diff --git a/design/user-stories/us-015-manage-tyrant-control-panel.md b/design/user-stories/us-015-manage-tyrant-control-panel.md new file mode 100644 index 0000000..844a224 --- /dev/null +++ b/design/user-stories/us-015-manage-tyrant-control-panel.md @@ -0,0 +1,44 @@ +--- +type: User Story +title: "US-015: Manage a reign through the Tyrant control panel" +description: Give the active Tyrant a graphical control panel for inspecting and managing the current reign. +status: done +--- + +# US-015: Manage a reign through the Tyrant control panel + +As the **Tyrant**, I want a control panel for my reign so that I can inspect progress, purchase unlocks, assign classes, and use abilities without memorizing commands. + +## Acceptance criteria + +- [x] The active Tyrant can open the control panel with `/tyrant menu`, and `/tyrant` with no arguments opens the same panel. +- [x] A player who is not the active Tyrant cannot open or operate the control panel. +- [x] When the active Tyrant joins while the event is running, the control panel opens automatically five seconds after login. +- [x] Automatic opening is cancelled if the Tyrant disconnects, loses the Tyrant role, or the event pauses or stops during the five-second delay. +- [x] Other players and pending Tyrant candidates do not receive the automatically opened control panel. +- [x] Closing an automatically opened control panel does not cause it to reopen during the same login session. +- [x] The Tyrant may open the control panel manually during the five-second delay without causing a second panel to open automatically. +- [x] The main screen shows the Tyrant's level, unspent choices, purchased unlocks, class assignments, and roster-intelligence cooldown. +- [x] Purchased, available, unavailable, and cooldown-gated actions are visually distinguishable and explain why they cannot currently be used. +- [x] The Tyrant can navigate between overview, unlock, class-assignment, and ability screens and can close the panel without changing gameplay state. +- [x] The Tyrant can review and purchase an available unlock after confirming the selected unlock and its choice cost. +- [x] The Tyrant can select a purchased class and assign or reassign it to an eligible online player. +- [x] The class player selector excludes the Tyrant and clearly identifies players who are opted out or otherwise ineligible. +- [x] Class reassignment identifies the current holder and requires confirmation before replacing that holder. +- [x] The Tyrant can activate roster intelligence from the panel when the ability is purchased and ready. +- [x] A successful or rejected action refreshes the panel from authoritative current state and communicates the result clearly. +- [x] Panel actions use the same domain rules and persistence behavior as their command equivalents. +- [x] Stale, repeated, or concurrent panel interactions cannot duplicate purchases, bypass cooldowns, or create conflicting assignments. +- [x] Panel actions become unavailable immediately if the game pauses, the reign ends, or the player is no longer the Tyrant. +- [x] Existing `/tyrant choices`, `/tyrant buy`, `/tyrant assign`, and `/tyrant intelligence` commands remain available as alternatives. + +## Validation + +Automated tests verify command entry points, active-Tyrant authorization, authoritative overview state, the exact 100-tick login delay, lifecycle cancellation, and duplicate-opening prevention. The complete `./gradlew clean check jar` lifecycle passes. + +## Related + +- [Progress and purchase Tyrant unlocks](us-003-progress-and-purchase-unlocks.md) +- [Assign unlocked classes](us-004-assign-unlocked-classes.md) +- [Purchase and use Tyrant abilities](us-009-use-tyrant-abilities.md) +- [Inform players and manage participation](us-011-inform-and-manage-participation.md) diff --git a/src/main/java/games/dmg/spigottyrant/BukkitTyrantControlPanelRenderer.java b/src/main/java/games/dmg/spigottyrant/BukkitTyrantControlPanelRenderer.java new file mode 100644 index 0000000..c238268 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/BukkitTyrantControlPanelRenderer.java @@ -0,0 +1,434 @@ +package games.dmg.spigottyrant; + +import java.time.Clock; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.UUID; +import org.bukkit.ChatColor; +import org.bukkit.Material; +import org.bukkit.Server; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; + +public final class BukkitTyrantControlPanelRenderer + implements TyrantControlPanelRenderer, Listener { + private static final String TITLE = ChatColor.DARK_RED + "Tyrant Control Panel"; + private final Server server; + private final TyrantStateManager stateManager; + private final Clock clock; + + public BukkitTyrantControlPanelRenderer( + Server server, + TyrantStateManager stateManager, + Clock clock + ) { + this.server = server; + this.stateManager = stateManager; + this.clock = clock; + } + + @Override + public void openOverview(Player player, TyrantControlPanelModel model) { + MenuHolder holder = new MenuHolder(MenuView.OVERVIEW, null, null); + Inventory inventory = create(holder, 27, TITLE); + inventory.setItem(10, item(Material.NETHER_STAR, ChatColor.GOLD + "Reign Overview", List.of( + "Level: " + model.level(), + "Unspent choices: " + model.unspentChoices(), + "Purchased: " + (model.purchasedUnlocks().isEmpty() + ? "none" : model.purchasedUnlocks()) + ))); + inventory.setItem(12, item(Material.ENCHANTED_BOOK, ChatColor.YELLOW + "Unlocks", + List.of("Review and purchase reign unlocks."))); + inventory.setItem(14, item(Material.ARMOR_STAND, ChatColor.AQUA + "Class Assignments", + classSummary(model))); + String intelligence = intelligenceText(model); + inventory.setItem(16, item(Material.SPYGLASS, ChatColor.LIGHT_PURPLE + "Roster Intelligence", + List.of(intelligence, "Click to activate when ready."))); + player.openInventory(inventory); + } + + @EventHandler + public void onClick(InventoryClickEvent event) { + if (!(event.getWhoClicked() instanceof Player player) + || !(event.getView().getTopInventory().getHolder() instanceof MenuHolder holder)) { + return; + } + event.setCancelled(true); + if (event.getRawSlot() < 0 + || event.getRawSlot() >= event.getView().getTopInventory().getSize()) { + return; + } + if (!isActiveTyrant(player)) { + player.closeInventory(); + player.sendMessage(ChatColor.RED + "This Tyrant control panel is no longer active."); + return; + } + switch (holder.view()) { + case OVERVIEW -> clickOverview(player, event.getRawSlot()); + case UNLOCKS -> clickUnlocks(player, event.getRawSlot()); + case PURCHASE_CONFIRMATION -> clickPurchaseConfirmation( + player, event.getRawSlot(), holder.unlock() + ); + case CLASSES -> clickClasses(player, event.getRawSlot()); + case ABILITIES -> clickAbilities(player, event.getRawSlot()); + case PLAYERS -> clickPlayers(player, event.getRawSlot(), holder.tyrantClass()); + case ASSIGN_CONFIRMATION -> clickAssignmentConfirmation( + player, event.getRawSlot(), holder.tyrantClass(), holder.targetId() + ); + } + } + + private void clickOverview(Player player, int slot) { + if (slot == 12) { + openUnlocks(player); + } else if (slot == 14) { + openClasses(player); + } else if (slot == 16) { + openAbilities(player); + } + } + + private void openAbilities(Player player) { + TyrantControlPanelModel model = model(player); + MenuHolder holder = new MenuHolder(MenuView.ABILITIES, null, null); + Inventory inventory = create(holder, 27, TITLE + " — Abilities"); + inventory.setItem(11, item(Material.BLAZE_POWDER, ChatColor.RED + "Permanent Strength", + List.of(model.purchasedUnlocks().contains(TyrantUnlock.STRENGTH) + ? "Purchased and active." : "Not purchased."))); + inventory.setItem(13, item(Material.IRON_CHESTPLATE, + ChatColor.BLUE + "Permanent Resistance", + List.of(model.purchasedUnlocks().contains(TyrantUnlock.RESISTANCE) + ? "Purchased and active." : "Not purchased."))); + inventory.setItem(15, item(Material.SPYGLASS, ChatColor.LIGHT_PURPLE + + "Roster Intelligence", List.of( + intelligenceText(model), "Click to activate when ready." + ))); + inventory.setItem(22, backItem()); + player.openInventory(inventory); + } + + private void clickAbilities(Player player, int slot) { + if (slot == 22) { + refresh(player); + } else if (slot == 15) { + player.performCommand("tyrant intelligence"); + openAbilities(player); + } + } + + private void openUnlocks(Player player) { + TyrantControlPanelModel model = model(player); + MenuHolder holder = new MenuHolder(MenuView.UNLOCKS, null, null); + Inventory inventory = create(holder, 27, TITLE + " — Unlocks"); + int[] slots = {10, 11, 12, 14, 15, 16}; + TyrantUnlock[] unlocks = TyrantUnlock.values(); + for (int index = 0; index < unlocks.length; index++) { + TyrantUnlock unlock = unlocks[index]; + UnlockAvailability availability = model.unlocks().get(unlock); + Material material = switch (availability) { + case PURCHASED -> Material.LIME_DYE; + case AVAILABLE -> Material.GOLD_INGOT; + case UNAVAILABLE -> Material.GRAY_DYE; + }; + inventory.setItem(slots[index], item(material, + ChatColor.YELLOW + readable(unlock.name()), + List.of("Status: " + availability, "Cost: 1 choice"))); + } + inventory.setItem(22, backItem()); + player.openInventory(inventory); + } + + private void clickUnlocks(Player player, int slot) { + if (slot == 22) { + refresh(player); + return; + } + int[] slots = {10, 11, 12, 14, 15, 16}; + TyrantUnlock[] unlocks = TyrantUnlock.values(); + for (int index = 0; index < slots.length; index++) { + if (slot == slots[index] + && model(player).unlocks().get(unlocks[index]) + == UnlockAvailability.AVAILABLE) { + openPurchaseConfirmation(player, unlocks[index]); + return; + } + } + } + + private void openPurchaseConfirmation(Player player, TyrantUnlock unlock) { + MenuHolder holder = new MenuHolder(MenuView.PURCHASE_CONFIRMATION, unlock, null); + Inventory inventory = create(holder, 27, TITLE + " — Confirm Purchase"); + inventory.setItem(11, item(Material.RED_WOOL, ChatColor.RED + "Cancel", List.of())); + inventory.setItem(13, item(Material.ENCHANTED_BOOK, ChatColor.YELLOW + readable(unlock.name()), + List.of("Cost: 1 choice"))); + inventory.setItem(15, item(Material.LIME_WOOL, ChatColor.GREEN + "Confirm", List.of())); + player.openInventory(inventory); + } + + private void clickPurchaseConfirmation(Player player, int slot, TyrantUnlock unlock) { + if (slot == 11) { + openUnlocks(player); + } else if (slot == 15 && unlock != null) { + player.performCommand("tyrant buy " + unlock.name().toLowerCase(Locale.ROOT)); + openUnlocks(player); + } + } + + private void openClasses(Player player) { + TyrantControlPanelModel model = model(player); + MenuHolder holder = new MenuHolder(MenuView.CLASSES, null, null); + Inventory inventory = create(holder, 27, TITLE + " — Classes"); + TyrantClass[] classes = {TyrantClass.ASSASSIN, TyrantClass.FIXER, TyrantClass.TAMER}; + int[] slots = {11, 13, 15}; + for (int index = 0; index < classes.length; index++) { + TyrantClass tyrantClass = classes[index]; + boolean purchased = stateManager.game().purchases().contains(unlockFor(tyrantClass)); + inventory.setItem(slots[index], item( + purchased ? Material.ARMOR_STAND : Material.BARRIER, + ChatColor.AQUA + readable(tyrantClass.name()), + List.of("Holder: " + model.classHolders().get(tyrantClass), + purchased ? "Click to assign." : "Unlock not purchased."))); + } + inventory.setItem(22, backItem()); + player.openInventory(inventory); + } + + private void clickClasses(Player player, int slot) { + if (slot == 22) { + refresh(player); + return; + } + TyrantClass tyrantClass = switch (slot) { + case 11 -> TyrantClass.ASSASSIN; + case 13 -> TyrantClass.FIXER; + case 15 -> TyrantClass.TAMER; + default -> null; + }; + if (tyrantClass != null + && stateManager.game().purchases().contains(unlockFor(tyrantClass))) { + openPlayers(player, tyrantClass); + } + } + + private void openPlayers(Player player, TyrantClass tyrantClass) { + MenuHolder holder = new MenuHolder(MenuView.PLAYERS, null, tyrantClass); + Inventory inventory = create(holder, 54, TITLE + " — Select Player"); + int slot = 0; + for (Player candidate : server.getOnlinePlayers()) { + if (slot >= 45 || candidate.getUniqueId().equals(player.getUniqueId())) { + continue; + } + PlayerState state = stateManager.player(candidate.getUniqueId(), candidate.getName()); + String eligibility = state.optedOutUntil().isPresent() + ? ChatColor.RED + "Ineligible: opted out" + : state.tyrantClass() != TyrantClass.NONE + && state.tyrantClass() != tyrantClass + ? ChatColor.RED + "Ineligible: already " + state.tyrantClass() + : ChatColor.GREEN + "Eligible"; + inventory.setItem(slot, item(Material.PLAYER_HEAD, candidate.getName(), + List.of(eligibility, candidate.getUniqueId().toString()))); + slot++; + } + inventory.setItem(49, backItem()); + player.openInventory(inventory); + } + + private void clickPlayers(Player player, int slot, TyrantClass tyrantClass) { + if (slot == 49) { + openClasses(player); + return; + } + ItemStack clicked = player.getOpenInventory().getTopInventory().getItem(slot); + if (clicked == null || clicked.getType() != Material.PLAYER_HEAD) { + return; + } + List lore = clicked.getItemMeta().getLore(); + if (lore == null || lore.size() < 2 || !ChatColor.stripColor(lore.get(0)).equals("Eligible")) { + return; + } + try { + openAssignmentConfirmation(player, tyrantClass, UUID.fromString(lore.get(1))); + } catch (IllegalArgumentException ignored) { + player.sendMessage(ChatColor.RED + "That player selection is no longer valid."); + } + } + + private void openAssignmentConfirmation( + Player player, + TyrantClass tyrantClass, + UUID targetId + ) { + Player target = server.getPlayer(targetId); + if (target == null) { + player.sendMessage(ChatColor.RED + "That player is no longer online."); + openPlayers(player, tyrantClass); + return; + } + MenuHolder holder = new MenuHolder( + MenuView.ASSIGN_CONFIRMATION, null, tyrantClass, targetId + ); + Inventory inventory = create(holder, 27, TITLE + " — Confirm Assignment"); + String current = model(player).classHolders().get(tyrantClass); + inventory.setItem(11, item(Material.RED_WOOL, ChatColor.RED + "Cancel", List.of())); + inventory.setItem(13, item(Material.ARMOR_STAND, + ChatColor.YELLOW + target.getName(), + List.of("Class: " + tyrantClass, "Current holder: " + current))); + inventory.setItem(15, item(Material.LIME_WOOL, ChatColor.GREEN + "Confirm", List.of())); + player.openInventory(inventory); + } + + private void clickAssignmentConfirmation( + Player player, + int slot, + TyrantClass tyrantClass, + UUID targetId + ) { + if (slot == 11) { + openPlayers(player, tyrantClass); + } else if (slot == 15 && tyrantClass != null && targetId != null) { + Player target = server.getPlayer(targetId); + if (target == null) { + player.sendMessage(ChatColor.RED + "That player is no longer online."); + } else { + player.performCommand("tyrant assign " + + tyrantClass.name().toLowerCase(Locale.ROOT) + " " + target.getName()); + } + openClasses(player); + } + } + + private boolean isActiveTyrant(Player player) { + GameState game = stateManager.game(); + return game.lifecycle() == GameLifecycle.RUNNING + && game.tyrantId().filter(player.getUniqueId()::equals).isPresent(); + } + + private TyrantControlPanelModel model(Player player) { + return TyrantControlPanelModel.create( + stateManager.game(), + stateManager.player(player.getUniqueId(), player.getName()), + stateManager.players(), + clock.instant() + ); + } + + private void refresh(Player player) { + openOverview(player, model(player)); + } + + private Inventory create(MenuHolder holder, int size, String title) { + Inventory inventory = server.createInventory(holder, size, title); + holder.inventory = inventory; + return inventory; + } + + private static ItemStack item(Material material, String name, List lore) { + ItemStack item = new ItemStack(material); + ItemMeta meta = item.getItemMeta(); + meta.setDisplayName(name); + meta.setLore(lore.stream().map(line -> ChatColor.GRAY + line).toList()); + item.setItemMeta(meta); + return item; + } + + private static ItemStack backItem() { + return item(Material.ARROW, ChatColor.YELLOW + "Back", List.of()); + } + + private static List classSummary(TyrantControlPanelModel model) { + List summary = new ArrayList<>(); + for (TyrantClass tyrantClass : new TyrantClass[] { + TyrantClass.ASSASSIN, TyrantClass.FIXER, TyrantClass.TAMER + }) { + summary.add(readable(tyrantClass.name()) + ": " + + model.classHolders().get(tyrantClass)); + } + return summary; + } + + private static String intelligenceText(TyrantControlPanelModel model) { + if (model.unlocks().get(TyrantUnlock.ROSTER_INTELLIGENCE) + != UnlockAvailability.PURCHASED) { + return "Unavailable: unlock not purchased."; + } + Duration cooldown = model.intelligenceCooldown(); + return cooldown.isZero() ? "Ready" : "Cooldown: " + cooldown.toSeconds() + "s"; + } + + 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 String readable(String value) { + String lower = value.toLowerCase(Locale.ROOT).replace('_', ' '); + return Character.toUpperCase(lower.charAt(0)) + lower.substring(1); + } + + private enum MenuView { + OVERVIEW, + UNLOCKS, + PURCHASE_CONFIRMATION, + CLASSES, + ABILITIES, + PLAYERS, + ASSIGN_CONFIRMATION + } + + private static final class MenuHolder implements InventoryHolder { + private final MenuView view; + private final TyrantUnlock unlock; + private final TyrantClass tyrantClass; + private final UUID targetId; + private Inventory inventory; + + private MenuHolder(MenuView view, TyrantUnlock unlock, TyrantClass tyrantClass) { + this(view, unlock, tyrantClass, null); + } + + private MenuHolder( + MenuView view, + TyrantUnlock unlock, + TyrantClass tyrantClass, + UUID targetId + ) { + this.view = view; + this.unlock = unlock; + this.tyrantClass = tyrantClass; + this.targetId = targetId; + } + + private MenuView view() { + return view; + } + + private TyrantUnlock unlock() { + return unlock; + } + + private TyrantClass tyrantClass() { + return tyrantClass; + } + + private UUID targetId() { + return targetId; + } + + @Override + public Inventory getInventory() { + return inventory; + } + } +} diff --git a/src/main/java/games/dmg/spigottyrant/DefaultTyrantControlPanel.java b/src/main/java/games/dmg/spigottyrant/DefaultTyrantControlPanel.java new file mode 100644 index 0000000..27ef965 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/DefaultTyrantControlPanel.java @@ -0,0 +1,57 @@ +package games.dmg.spigottyrant; + +import java.time.Clock; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; +import org.bukkit.ChatColor; +import org.bukkit.entity.Player; + +public final class DefaultTyrantControlPanel implements TyrantControlPanel { + private final TyrantStateManager stateManager; + private final TyrantControlPanelRenderer renderer; + private final Clock clock; + private final Set openedSessions = new HashSet<>(); + + public DefaultTyrantControlPanel( + TyrantStateManager stateManager, + TyrantControlPanelRenderer renderer, + Clock clock + ) { + this.stateManager = stateManager; + this.renderer = renderer; + this.clock = clock; + } + + @Override + public void open(Player player) { + GameState game = stateManager.game(); + if (game.lifecycle() != GameLifecycle.RUNNING + || game.tyrantId().filter(player.getUniqueId()::equals).isEmpty()) { + player.sendMessage(ChatColor.RED + + "Only the active Tyrant can open the control panel while the event is running."); + return; + } + PlayerState tyrant = stateManager.player(player.getUniqueId(), player.getName()); + renderer.openOverview( + player, + TyrantControlPanelModel.create(game, tyrant, stateManager.players(), clock.instant()) + ); + openedSessions.add(player.getUniqueId()); + } + + @Override + public void beginSession(UUID playerId) { + openedSessions.remove(playerId); + } + + @Override + public void endSession(UUID playerId) { + openedSessions.remove(playerId); + } + + @Override + public boolean openedThisSession(UUID playerId) { + return openedSessions.contains(playerId); + } +} diff --git a/src/main/java/games/dmg/spigottyrant/DelayedTaskScheduler.java b/src/main/java/games/dmg/spigottyrant/DelayedTaskScheduler.java new file mode 100644 index 0000000..162d695 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/DelayedTaskScheduler.java @@ -0,0 +1,6 @@ +package games.dmg.spigottyrant; + +@FunctionalInterface +public interface DelayedTaskScheduler { + void schedule(Runnable task, long delayTicks); +} diff --git a/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java b/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java index d537af6..543d40a 100644 --- a/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java +++ b/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java @@ -80,6 +80,11 @@ public final class SpigotTyrantPlugin extends JavaPlugin { stateManager, tyrantPresentation ); managedEffects.restoreAll(); + BukkitTyrantControlPanelRenderer controlPanelRenderer = + new BukkitTyrantControlPanelRenderer(getServer(), stateManager, clock); + TyrantControlPanel controlPanel = new DefaultTyrantControlPanel( + stateManager, controlPanelRenderer, clock + ); Objects.requireNonNull(getCommand("tyrant"), "Missing tyrant command metadata") .setExecutor(new TyrantCommand( stateManager, @@ -91,7 +96,8 @@ public final class SpigotTyrantPlugin extends JavaPlugin { new RoleRelinquishmentService(succession, maintenance), tyrantPresentation, abilityItems, - new TyrantAbilityService(settings.rosterIntelligenceCooldown()) + new TyrantAbilityService(settings.rosterIntelligenceCooldown()), + controlPanel )); Objects.requireNonNull(getCommand("vigilante"), "Missing vigilante metadata") .setExecutor(new VigilanteCommand(stateManager, followers, onlinePlayers)); @@ -111,6 +117,16 @@ public final class SpigotTyrantPlugin extends JavaPlugin { new PlayerJoinListener(stateManager, clock, tyrantPresentation), this ); + getServer().getPluginManager().registerEvents(controlPanelRenderer, this); + getServer().getPluginManager().registerEvents( + new TyrantControlPanelJoinListener( + stateManager, + controlPanel, + (task, delayTicks) -> getServer().getScheduler() + .runTaskLater(this, task, delayTicks) + ), + this + ); getServer().getPluginManager().registerEvents( new TyrantDeathListener( stateManager, succession, tyrantPresentation, getServer(), clock diff --git a/src/main/java/games/dmg/spigottyrant/TyrantCommand.java b/src/main/java/games/dmg/spigottyrant/TyrantCommand.java index 62b2d0e..e874e8a 100644 --- a/src/main/java/games/dmg/spigottyrant/TyrantCommand.java +++ b/src/main/java/games/dmg/spigottyrant/TyrantCommand.java @@ -20,6 +20,7 @@ public final class TyrantCommand implements CommandExecutor { private final TyrantPresentation presentation; private final AbilityItemService abilityItems; private final TyrantAbilityService tyrantAbilities; + private final TyrantControlPanel controlPanel; public TyrantCommand( TyrantStateManager stateManager, @@ -34,6 +35,19 @@ public final class TyrantCommand implements CommandExecutor { ); } + public TyrantCommand( + TyrantStateManager stateManager, + TyrantProgressionService progression, + TyrantControlPanel controlPanel + ) { + this( + stateManager, progression, new ClassAssignmentService(), + new BukkitOnlinePlayerDirectory(), Clock.systemUTC(), + new ParticipationService(Duration.ofDays(7)), null, null, null, + new TyrantAbilityService(Duration.ofHours(24)), controlPanel + ); + } + TyrantCommand( TyrantStateManager stateManager, TyrantProgressionService progression, @@ -44,7 +58,7 @@ public final class TyrantCommand implements CommandExecutor { this( stateManager, progression, assignments, onlinePlayers, clock, new ParticipationService(Duration.ofDays(7)), null, null, null, - new TyrantAbilityService(Duration.ofHours(24)) + new TyrantAbilityService(Duration.ofHours(24)), TyrantCommand::unavailablePanel ); } @@ -59,6 +73,26 @@ public final class TyrantCommand implements CommandExecutor { TyrantPresentation presentation, AbilityItemService abilityItems, TyrantAbilityService tyrantAbilities + ) { + this( + stateManager, progression, assignments, onlinePlayers, clock, participation, + relinquishment, presentation, abilityItems, tyrantAbilities, + TyrantCommand::unavailablePanel + ); + } + + public TyrantCommand( + TyrantStateManager stateManager, + TyrantProgressionService progression, + ClassAssignmentService assignments, + OnlinePlayerDirectory onlinePlayers, + Clock clock, + ParticipationService participation, + RoleRelinquishmentService relinquishment, + TyrantPresentation presentation, + AbilityItemService abilityItems, + TyrantAbilityService tyrantAbilities, + TyrantControlPanel controlPanel ) { this.stateManager = stateManager; this.progression = progression; @@ -70,6 +104,7 @@ public final class TyrantCommand implements CommandExecutor { this.presentation = presentation; this.abilityItems = abilityItems; this.tyrantAbilities = tyrantAbilities; + this.controlPanel = controlPanel; } @Override @@ -83,7 +118,12 @@ public final class TyrantCommand implements CommandExecutor { sender.sendMessage("This command must be used by a player."); return true; } - if (arguments.length == 0 || arguments[0].equalsIgnoreCase("choices")) { + if (arguments.length == 0 + || arguments.length == 1 && arguments[0].equalsIgnoreCase("menu")) { + controlPanel.open(player); + return true; + } + if (arguments.length == 1 && arguments[0].equalsIgnoreCase("choices")) { showChoices(player); return true; } @@ -121,7 +161,7 @@ public final class TyrantCommand implements CommandExecutor { } player.sendMessage( ChatColor.YELLOW - + "Usage: /tyrant " ); return true; @@ -303,6 +343,10 @@ public final class TyrantCommand implements CommandExecutor { + readable(result.status()) + "."); } + private static void unavailablePanel(Player player) { + player.sendMessage(ChatColor.RED + "The Tyrant control panel is unavailable."); + } + private static String readable(Enum status) { return status.name().toLowerCase(Locale.ROOT).replace('_', ' '); } diff --git a/src/main/java/games/dmg/spigottyrant/TyrantControlPanel.java b/src/main/java/games/dmg/spigottyrant/TyrantControlPanel.java new file mode 100644 index 0000000..d558646 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/TyrantControlPanel.java @@ -0,0 +1,19 @@ +package games.dmg.spigottyrant; + +import java.util.UUID; +import org.bukkit.entity.Player; + +@FunctionalInterface +public interface TyrantControlPanel { + void open(Player player); + + default void beginSession(UUID playerId) { + } + + default void endSession(UUID playerId) { + } + + default boolean openedThisSession(UUID playerId) { + return false; + } +} diff --git a/src/main/java/games/dmg/spigottyrant/TyrantControlPanelJoinListener.java b/src/main/java/games/dmg/spigottyrant/TyrantControlPanelJoinListener.java new file mode 100644 index 0000000..508d00e --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/TyrantControlPanelJoinListener.java @@ -0,0 +1,48 @@ +package games.dmg.spigottyrant; + +import java.util.UUID; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerQuitEvent; + +public final class TyrantControlPanelJoinListener implements Listener { + private static final long OPEN_DELAY_TICKS = 100L; + private final TyrantStateManager stateManager; + private final TyrantControlPanel controlPanel; + private final DelayedTaskScheduler scheduler; + + public TyrantControlPanelJoinListener( + TyrantStateManager stateManager, + TyrantControlPanel controlPanel, + DelayedTaskScheduler scheduler + ) { + this.stateManager = stateManager; + this.controlPanel = controlPanel; + this.scheduler = scheduler; + } + + @EventHandler + public void onJoin(PlayerJoinEvent event) { + Player player = event.getPlayer(); + UUID playerId = player.getUniqueId(); + controlPanel.beginSession(playerId); + scheduler.schedule(() -> openIfStillTyrant(player), OPEN_DELAY_TICKS); + } + + @EventHandler + public void onQuit(PlayerQuitEvent event) { + controlPanel.endSession(event.getPlayer().getUniqueId()); + } + + private void openIfStillTyrant(Player player) { + GameState game = stateManager.game(); + if (player.isOnline() + && game.lifecycle() == GameLifecycle.RUNNING + && game.tyrantId().filter(player.getUniqueId()::equals).isPresent() + && !controlPanel.openedThisSession(player.getUniqueId())) { + controlPanel.open(player); + } + } +} diff --git a/src/main/java/games/dmg/spigottyrant/TyrantControlPanelModel.java b/src/main/java/games/dmg/spigottyrant/TyrantControlPanelModel.java new file mode 100644 index 0000000..8927778 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/TyrantControlPanelModel.java @@ -0,0 +1,67 @@ +package games.dmg.spigottyrant; + +import java.time.Duration; +import java.time.Instant; +import java.util.EnumMap; +import java.util.Map; +import java.util.Set; + +public record TyrantControlPanelModel( + int level, + int unspentChoices, + Map unlocks, + Map classHolders, + Duration intelligenceCooldown +) { + public TyrantControlPanelModel { + unlocks = Map.copyOf(unlocks); + classHolders = Map.copyOf(classHolders); + } + + public Set purchasedUnlocks() { + return unlocks.entrySet().stream() + .filter(entry -> entry.getValue() == UnlockAvailability.PURCHASED) + .map(Map.Entry::getKey) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); + } + + public static TyrantControlPanelModel create( + GameState game, + PlayerState tyrant, + Map players, + Instant now + ) { + Map unlocks = new EnumMap<>(TyrantUnlock.class); + for (TyrantUnlock unlock : TyrantUnlock.values()) { + UnlockAvailability availability; + if (game.purchases().contains(unlock)) { + availability = UnlockAvailability.PURCHASED; + } else if (game.lifecycle() == GameLifecycle.RUNNING + && game.unspentChoices() > 0) { + availability = UnlockAvailability.AVAILABLE; + } else { + availability = UnlockAvailability.UNAVAILABLE; + } + unlocks.put(unlock, availability); + } + Map holders = new EnumMap<>(TyrantClass.class); + for (TyrantClass tyrantClass : new TyrantClass[] { + TyrantClass.ASSASSIN, TyrantClass.FIXER, TyrantClass.TAMER + }) { + String holder = players.values().stream() + .filter(player -> player.tyrantClass() == tyrantClass) + .map(PlayerState::latestName) + .findFirst() + .orElse("Unassigned"); + holders.put(tyrantClass, holder); + } + Duration cooldown = tyrant.cooldownEnds().getOrDefault( + Ability.ROSTER_INTELLIGENCE, now + ).isAfter(now) + ? Duration.between(now, tyrant.cooldownEnds().get(Ability.ROSTER_INTELLIGENCE)) + : Duration.ZERO; + return new TyrantControlPanelModel( + game.tyrantLevel(), game.unspentChoices(), unlocks, holders, cooldown + ); + } +} diff --git a/src/main/java/games/dmg/spigottyrant/TyrantControlPanelRenderer.java b/src/main/java/games/dmg/spigottyrant/TyrantControlPanelRenderer.java new file mode 100644 index 0000000..e12ee23 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/TyrantControlPanelRenderer.java @@ -0,0 +1,8 @@ +package games.dmg.spigottyrant; + +import org.bukkit.entity.Player; + +@FunctionalInterface +public interface TyrantControlPanelRenderer { + void openOverview(Player player, TyrantControlPanelModel model); +} diff --git a/src/main/java/games/dmg/spigottyrant/UnlockAvailability.java b/src/main/java/games/dmg/spigottyrant/UnlockAvailability.java new file mode 100644 index 0000000..8da4d9c --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/UnlockAvailability.java @@ -0,0 +1,7 @@ +package games.dmg.spigottyrant; + +public enum UnlockAvailability { + PURCHASED, + AVAILABLE, + UNAVAILABLE +} diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 5a45440..338a3a5 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -7,7 +7,7 @@ author: dmg.games commands: tyrant: description: View and use Spigot Tyrant game features. - usage: /tyrant + usage: /tyrant vigilante: description: Manage Vigilante Followers. usage: /vigilante |accept|dismiss |leave> diff --git a/src/test/java/games/dmg/spigottyrant/DefaultTyrantControlPanelTest.java b/src/test/java/games/dmg/spigottyrant/DefaultTyrantControlPanelTest.java new file mode 100644 index 0000000..6cb9b27 --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/DefaultTyrantControlPanelTest.java @@ -0,0 +1,72 @@ +package games.dmg.spigottyrant; + +import static org.mockito.ArgumentMatchers.any; +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.entity.Player; +import org.junit.jupiter.api.Test; + +final class DefaultTyrantControlPanelTest { + @Test + void nonTyrantCannotRenderControlPanel() { + UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111"); + UUID otherId = 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() + ); + TyrantStateManager manager = mock(TyrantStateManager.class); + when(manager.game()).thenReturn(game); + Player other = mock(Player.class); + when(other.getUniqueId()).thenReturn(otherId); + TyrantControlPanelRenderer renderer = mock(TyrantControlPanelRenderer.class); + DefaultTyrantControlPanel panel = new DefaultTyrantControlPanel( + manager, renderer, + Clock.fixed(Instant.parse("2026-08-21T12:00:00Z"), ZoneOffset.UTC) + ); + + panel.open(other); + + verify(renderer, never()).openOverview(any(), any()); + verify(other).sendMessage(org.mockito.ArgumentMatchers.contains("active Tyrant")); + } + + @Test + void openingAsActiveTyrantRendersAuthoritativeOverview() { + UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111"); + GameState game = new GameState( + GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.empty(), + Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO, + 2, 1, Set.of(TyrantUnlock.STRENGTH) + ); + PlayerState tyrantState = PlayerState.newPlayer(tyrantId, "Tyrant"); + TyrantStateManager manager = mock(TyrantStateManager.class); + when(manager.game()).thenReturn(game); + when(manager.player(tyrantId, "Tyrant")).thenReturn(tyrantState); + when(manager.players()).thenReturn(Map.of(tyrantId, tyrantState)); + Player tyrant = mock(Player.class); + when(tyrant.getUniqueId()).thenReturn(tyrantId); + when(tyrant.getName()).thenReturn("Tyrant"); + TyrantControlPanelRenderer renderer = mock(TyrantControlPanelRenderer.class); + DefaultTyrantControlPanel panel = new DefaultTyrantControlPanel( + manager, renderer, + Clock.fixed(Instant.parse("2026-08-21T12:00:00Z"), ZoneOffset.UTC) + ); + + panel.open(tyrant); + + verify(renderer).openOverview(org.mockito.ArgumentMatchers.eq(tyrant), any()); + } +} diff --git a/src/test/java/games/dmg/spigottyrant/TyrantCommandTest.java b/src/test/java/games/dmg/spigottyrant/TyrantCommandTest.java index 3485964..f3659cc 100644 --- a/src/test/java/games/dmg/spigottyrant/TyrantCommandTest.java +++ b/src/test/java/games/dmg/spigottyrant/TyrantCommandTest.java @@ -20,6 +20,29 @@ import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; final class TyrantCommandTest { + @Test + void activeTyrantCanOpenControlPanelWithNoArgumentsOrMenuSubcommand() { + UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111"); + GameState game = new GameState( + GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.empty(), + Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO, + 0, 1, Set.of() + ); + TyrantStateManager manager = mock(TyrantStateManager.class); + when(manager.game()).thenReturn(game); + Player tyrant = mock(Player.class); + when(tyrant.getUniqueId()).thenReturn(tyrantId); + TyrantControlPanel controlPanel = mock(TyrantControlPanel.class); + TyrantCommand command = new TyrantCommand( + manager, new TyrantProgressionService(), controlPanel + ); + + command.onCommand(tyrant, mock(Command.class), "tyrant", new String[0]); + command.onCommand(tyrant, mock(Command.class), "tyrant", new String[] {"menu"}); + + verify(controlPanel, org.mockito.Mockito.times(2)).open(tyrant); + } + @Test void choicesClearlyIdentifyPurchasedAvailableAndUnavailableUnlocks() { UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111"); diff --git a/src/test/java/games/dmg/spigottyrant/TyrantControlPanelJoinListenerTest.java b/src/test/java/games/dmg/spigottyrant/TyrantControlPanelJoinListenerTest.java new file mode 100644 index 0000000..8971362 --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/TyrantControlPanelJoinListenerTest.java @@ -0,0 +1,105 @@ +package games.dmg.spigottyrant; + +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.Duration; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.bukkit.entity.Player; +import org.bukkit.event.player.PlayerJoinEvent; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +final class TyrantControlPanelJoinListenerTest { + @Test + void delayedTaskDoesNotOpenAfterEventPauses() { + UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111"); + GameState paused = new GameState( + GameLifecycle.PAUSED, Optional.of(tyrantId), Optional.empty(), + Optional.empty(), Optional.empty(), Optional.of(java.time.Instant.EPOCH), + Duration.ZERO, 0, 1, Set.of() + ); + TyrantStateManager manager = mock(TyrantStateManager.class); + when(manager.game()).thenReturn(paused); + Player tyrant = mock(Player.class); + when(tyrant.getUniqueId()).thenReturn(tyrantId); + when(tyrant.isOnline()).thenReturn(true); + PlayerJoinEvent event = mock(PlayerJoinEvent.class); + when(event.getPlayer()).thenReturn(tyrant); + DelayedTaskScheduler scheduler = mock(DelayedTaskScheduler.class); + TyrantControlPanel panel = mock(TyrantControlPanel.class); + TyrantControlPanelJoinListener listener = new TyrantControlPanelJoinListener( + manager, panel, scheduler + ); + + listener.onJoin(event); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).schedule(task.capture(), org.mockito.ArgumentMatchers.eq(100L)); + task.getValue().run(); + + verify(panel, never()).open(tyrant); + } + + @Test + void delayedTaskDoesNotReopenPanelOpenedManually() { + UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111"); + GameState game = new GameState( + GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.empty(), + Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO, + 0, 1, Set.of() + ); + TyrantStateManager manager = mock(TyrantStateManager.class); + when(manager.game()).thenReturn(game); + Player tyrant = mock(Player.class); + when(tyrant.getUniqueId()).thenReturn(tyrantId); + when(tyrant.isOnline()).thenReturn(true); + PlayerJoinEvent event = mock(PlayerJoinEvent.class); + when(event.getPlayer()).thenReturn(tyrant); + DelayedTaskScheduler scheduler = mock(DelayedTaskScheduler.class); + TyrantControlPanel panel = mock(TyrantControlPanel.class); + when(panel.openedThisSession(tyrantId)).thenReturn(true); + TyrantControlPanelJoinListener listener = new TyrantControlPanelJoinListener( + manager, panel, scheduler + ); + + listener.onJoin(event); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).schedule(task.capture(), org.mockito.ArgumentMatchers.eq(100L)); + task.getValue().run(); + + verify(panel, never()).open(tyrant); + } + + @Test + void opensPanelOneHundredTicksAfterActiveTyrantJoins() { + UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111"); + GameState game = new GameState( + GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.empty(), + Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO, + 0, 1, Set.of() + ); + TyrantStateManager manager = mock(TyrantStateManager.class); + when(manager.game()).thenReturn(game); + Player tyrant = mock(Player.class); + when(tyrant.getUniqueId()).thenReturn(tyrantId); + when(tyrant.isOnline()).thenReturn(true); + PlayerJoinEvent event = mock(PlayerJoinEvent.class); + when(event.getPlayer()).thenReturn(tyrant); + DelayedTaskScheduler scheduler = mock(DelayedTaskScheduler.class); + TyrantControlPanel panel = mock(TyrantControlPanel.class); + TyrantControlPanelJoinListener listener = new TyrantControlPanelJoinListener( + manager, panel, scheduler + ); + + listener.onJoin(event); + + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).schedule(task.capture(), org.mockito.ArgumentMatchers.eq(100L)); + task.getValue().run(); + verify(panel).open(tyrant); + } +} diff --git a/src/test/java/games/dmg/spigottyrant/TyrantControlPanelModelTest.java b/src/test/java/games/dmg/spigottyrant/TyrantControlPanelModelTest.java new file mode 100644 index 0000000..77185cb --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/TyrantControlPanelModelTest.java @@ -0,0 +1,49 @@ +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 TyrantControlPanelModelTest { + @Test + void overviewShowsProgressPurchasesAssignmentsAndIntelligenceCooldown() { + UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111"); + UUID assassinId = UUID.fromString("22222222-2222-2222-2222-222222222222"); + Instant now = Instant.parse("2026-08-21T12:00:00Z"); + GameState game = new GameState( + GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.empty(), + Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO, + 3, 2, Set.of(TyrantUnlock.ASSASSIN, TyrantUnlock.ROSTER_INTELLIGENCE) + ); + PlayerState tyrant = new PlayerState( + tyrantId, "Tyrant", Optional.of(now), Optional.empty(), TyrantClass.NONE, + Optional.empty(), Map.of(Ability.ROSTER_INTELLIGENCE, now.plusSeconds(90)), + Set.of(), java.util.List.of() + ); + PlayerState assassin = new PlayerState( + assassinId, "Blade", Optional.of(now), Optional.empty(), TyrantClass.ASSASSIN, + Optional.empty(), Map.of(), Set.of(), java.util.List.of() + ); + + TyrantControlPanelModel model = TyrantControlPanelModel.create( + game, tyrant, Map.of(tyrantId, tyrant, assassinId, assassin), now + ); + + assertEquals(3, model.level()); + assertEquals(2, model.unspentChoices()); + assertEquals(UnlockAvailability.PURCHASED, + model.unlocks().get(TyrantUnlock.ASSASSIN)); + assertEquals( + Set.of(TyrantUnlock.ASSASSIN, TyrantUnlock.ROSTER_INTELLIGENCE), + model.purchasedUnlocks() + ); + assertEquals("Blade", model.classHolders().get(TyrantClass.ASSASSIN)); + assertEquals(Duration.ofSeconds(90), model.intelligenceCooldown()); + } +}