From ec7d50c2b511f6d14b0337d0d24c6a7e37a7d5f7 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Fri, 21 Aug 2026 21:19:29 -0400 Subject: [PATCH] feat(vigilante): add follower control panel --- design/log.md | 19 + design/user-stories/index.md | 1 + .../us-008-support-vigilante-and-followers.md | 5 + .../us-016-manage-vigilante-control-panel.md | 46 ++ .../BukkitFollowerPresenceProvider.java | 38 ++ .../BukkitTyrantControlPanelRenderer.java | 4 +- .../BukkitVigilanteControlPanelRenderer.java | 409 ++++++++++++++++++ .../DefaultVigilanteControlPanel.java | 73 ++++ .../dmg/spigottyrant/FollowerPresence.java | 12 + .../FollowerPresenceProvider.java | 10 + .../dmg/spigottyrant/FollowerService.java | 11 + .../dmg/spigottyrant/FollowerStatus.java | 1 + .../dmg/spigottyrant/SpigotTyrantPlugin.java | 26 +- .../dmg/spigottyrant/VigilanteCommand.java | 22 +- .../spigottyrant/VigilanteControlPanel.java | 19 + .../VigilanteControlPanelJoinListener.java | 48 ++ .../VigilanteControlPanelModel.java | 59 +++ .../VigilanteControlPanelRenderer.java | 8 + .../spigottyrant/VigilanteFollowerView.java | 14 + src/main/resources/plugin.yml | 2 +- .../DefaultVigilanteControlPanelTest.java | 67 +++ .../dmg/spigottyrant/FollowerServiceTest.java | 27 ++ .../spigottyrant/VigilanteCommandTest.java | 32 ++ ...VigilanteControlPanelJoinListenerTest.java | 80 ++++ .../VigilanteControlPanelModelTest.java | 58 +++ 25 files changed, 1087 insertions(+), 4 deletions(-) create mode 100644 design/user-stories/us-016-manage-vigilante-control-panel.md create mode 100644 src/main/java/games/dmg/spigottyrant/BukkitFollowerPresenceProvider.java create mode 100644 src/main/java/games/dmg/spigottyrant/BukkitVigilanteControlPanelRenderer.java create mode 100644 src/main/java/games/dmg/spigottyrant/DefaultVigilanteControlPanel.java create mode 100644 src/main/java/games/dmg/spigottyrant/FollowerPresence.java create mode 100644 src/main/java/games/dmg/spigottyrant/FollowerPresenceProvider.java create mode 100644 src/main/java/games/dmg/spigottyrant/VigilanteControlPanel.java create mode 100644 src/main/java/games/dmg/spigottyrant/VigilanteControlPanelJoinListener.java create mode 100644 src/main/java/games/dmg/spigottyrant/VigilanteControlPanelModel.java create mode 100644 src/main/java/games/dmg/spigottyrant/VigilanteControlPanelRenderer.java create mode 100644 src/main/java/games/dmg/spigottyrant/VigilanteFollowerView.java create mode 100644 src/test/java/games/dmg/spigottyrant/DefaultVigilanteControlPanelTest.java create mode 100644 src/test/java/games/dmg/spigottyrant/VigilanteCommandTest.java create mode 100644 src/test/java/games/dmg/spigottyrant/VigilanteControlPanelJoinListenerTest.java create mode 100644 src/test/java/games/dmg/spigottyrant/VigilanteControlPanelModelTest.java diff --git a/design/log.md b/design/log.md index d242132..720a206 100644 --- a/design/log.md +++ b/design/log.md @@ -6,6 +6,25 @@ description: Chronological record of material decisions affecting the Spigot Tyr # Spigot Tyrant Design Log +## 2026-08-21 — Vigilante control panel completed + +- Completed US-016 and the reopened US-008 criteria with resistance overview, recruitment, Follower management, and confirmed role-relinquishment screens. +- `/vigilante` and `/vigilante menu` open the panel, while existing invitation, acceptance, dismissal, leave, and relinquishment commands remain available. +- Added a 100-tick login delay with online, lifecycle, role, and already-opened session checks so only the current Vigilante receives one automatic panel per login. +- Pending invitations are visible and idempotent, existing Followers cannot be reinvited, and all menu mutations delegate to authoritative command and domain rules. +- Verified command routing, authorization, overview state, invitation rules, delayed opening safeguards, compiler warnings, tests, and packaging with `./gradlew clean check jar`. + +## 2026-08-21 — Vigilante control panel implementation started + +- US-008 and US-016 begin a test-first implementation of the Vigilante control panel, delayed login opening, resistance overview, recruitment, dismissal, and role relinquishment. +- The panel will reuse authoritative Follower and relinquishment rules while preserving all existing command alternatives. + +## 2026-08-21 — Vigilante control panel planned + +- Added US-016 for a graphical Vigilante control panel covering resistance overview, recruitment, Follower management, and confirmed role relinquishment while preserving command alternatives. +- Planned automatic control-panel opening five seconds after the active Vigilante joins a running event, with lifecycle, role, disconnect, and duplicate-opening safeguards. +- Reopened US-008 in the backlog to track the approved control-panel interactions and nearby-Follower combat contribution display. + ## 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. diff --git a/design/user-stories/index.md b/design/user-stories/index.md index 937ef78..caded5a 100644 --- a/design/user-stories/index.md +++ b/design/user-stories/index.md @@ -15,3 +15,4 @@ 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) +16. [US-016: Manage Followers through the Vigilante control panel](us-016-manage-vigilante-control-panel.md) diff --git a/design/user-stories/us-008-support-vigilante-and-followers.md b/design/user-stories/us-008-support-vigilante-and-followers.md index 9fa2574..1ec7f0f 100644 --- a/design/user-stories/us-008-support-vigilante-and-followers.md +++ b/design/user-stories/us-008-support-vigilante-and-followers.md @@ -23,8 +23,13 @@ As the **Vigilante**, I want nearby living Followers to strengthen me against th - [x] The deceased Vigilante is excluded when another eligible non-Tyrant candidate exists and is reused only when no such candidate exists. - [x] Followers are cleared rather than transferred when their Vigilante dies or loses the position. - [x] The Tyrant is never eligible to be selected as Vigilante. +- [x] The Vigilante can review, invite, and dismiss Followers through the Vigilante control panel. +- [x] Control-panel and command-based Follower actions enforce identical eligibility and membership rules. +- [x] The control panel communicates the current nearby-Follower combat contribution and configured effect caps. +- [x] Relinquishing through the control panel follows normal Vigilante succession and Follower cleanup. ## Related - [Role and class model](../concepts/role-and-class-model.md) - [Handle inactivity and pending selections](us-010-handle-inactivity-and-selection.md) +- [Manage Followers through the Vigilante control panel](us-016-manage-vigilante-control-panel.md) diff --git a/design/user-stories/us-016-manage-vigilante-control-panel.md b/design/user-stories/us-016-manage-vigilante-control-panel.md new file mode 100644 index 0000000..a56c282 --- /dev/null +++ b/design/user-stories/us-016-manage-vigilante-control-panel.md @@ -0,0 +1,46 @@ +--- +type: User Story +title: "US-016: Manage Followers through the Vigilante control panel" +description: Give the active Vigilante a graphical control panel for reviewing and managing the resistance. +status: done +--- + +# US-016: Manage Followers through the Vigilante control panel + +As the **Vigilante**, I want a control panel for managing Followers so that I can coordinate the resistance without memorizing commands. + +## Acceptance criteria + +- [x] The active Vigilante can open the control panel with `/vigilante menu`, and `/vigilante` with no arguments opens the same panel. +- [x] A player who is not the active Vigilante cannot open or operate the control panel. +- [x] The control panel opens automatically five seconds after the active Vigilante joins a running event. +- [x] Automatic opening is cancelled if the player disconnects, loses the Vigilante role, or the event pauses or stops during the five-second delay. +- [x] Pending Vigilante candidates and other players do not receive the automatically opened control panel. +- [x] Manually opening the control panel during the delay prevents a duplicate automatic opening. +- [x] Closing the automatically opened control panel does not cause it to reopen during the same login session. +- [x] The overview shows current Followers, pending invitations, and the number of nearby Followers currently eligible to contribute combat effects. +- [x] Each Follower's online, living, same-world, proximity, and participation status is communicated without exposing information unavailable to the Vigilante. +- [x] The control panel shows the current Strength and Resistance contribution and configured caps. +- [x] The Vigilante can navigate between overview, recruitment, Follower-management, and role-control screens. +- [x] The recruitment screen lists eligible online players and explains why opted-out, Tyrant, Vigilante, or otherwise ineligible players cannot be invited. +- [x] The Vigilante can invite an eligible player after confirmation. +- [x] Pending outgoing invitations are identified and cannot be duplicated through repeated or concurrent interaction. +- [x] Invited players receive the existing invitation notice and may continue using `/vigilante accept`. +- [x] The Follower-management screen identifies current Followers and requires confirmation before dismissal. +- [x] Successful invitations and dismissals refresh the control panel from authoritative current state and notify affected players. +- [x] The role-control screen allows the Vigilante to relinquish the role only after explicit confirmation. +- [x] Relinquishing through the control panel uses normal Vigilante replacement and Follower-cleanup rules. +- [x] Every action revalidates the running lifecycle and active Vigilante before changing state. +- [x] Stale or repeated control-panel interactions cannot invite an ineligible player, duplicate an invitation, or dismiss the wrong Follower. +- [x] Existing `/vigilante invite`, `/vigilante accept`, `/vigilante dismiss`, `/vigilante leave`, and `/tyrant relinquish confirm` commands remain available. + +## Validation + +Automated tests verify default and explicit command entry points, active-Vigilante authorization, overview state, invitation deduplication and eligibility, the exact 100-tick login delay, lifecycle cancellation, and duplicate-opening prevention. The complete `./gradlew clean check jar` lifecycle passes. + +## Related + +- [Support the Vigilante and Followers](us-008-support-vigilante-and-followers.md) +- [Handle inactivity and pending selections](us-010-handle-inactivity-and-selection.md) +- [Manage a reign through the Tyrant control panel](us-015-manage-tyrant-control-panel.md) +- [Role and class model](../concepts/role-and-class-model.md) diff --git a/src/main/java/games/dmg/spigottyrant/BukkitFollowerPresenceProvider.java b/src/main/java/games/dmg/spigottyrant/BukkitFollowerPresenceProvider.java new file mode 100644 index 0000000..e319054 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/BukkitFollowerPresenceProvider.java @@ -0,0 +1,38 @@ +package games.dmg.spigottyrant; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import org.bukkit.Server; +import org.bukkit.entity.Player; + +public final class BukkitFollowerPresenceProvider implements FollowerPresenceProvider { + private final Server server; + private final double rangeSquared; + + public BukkitFollowerPresenceProvider(Server server, double rangeBlocks) { + this.server = server; + this.rangeSquared = rangeBlocks * rangeBlocks; + } + + @Override + public Map inspect( + Player vigilante, + Map players + ) { + Map result = new HashMap<>(); + for (PlayerState state : players.values()) { + Player follower = server.getPlayer(state.playerId()); + boolean online = follower != null && follower.isOnline(); + boolean living = online && !follower.isDead(); + boolean sameWorld = living && follower.getWorld().equals(vigilante.getWorld()); + boolean withinRange = sameWorld && follower.getLocation() + .distanceSquared(vigilante.getLocation()) <= rangeSquared; + result.put( + state.playerId(), + new FollowerPresence(online, living, sameWorld, withinRange) + ); + } + return Map.copyOf(result); + } +} diff --git a/src/main/java/games/dmg/spigottyrant/BukkitTyrantControlPanelRenderer.java b/src/main/java/games/dmg/spigottyrant/BukkitTyrantControlPanelRenderer.java index c238268..64bd187 100644 --- a/src/main/java/games/dmg/spigottyrant/BukkitTyrantControlPanelRenderer.java +++ b/src/main/java/games/dmg/spigottyrant/BukkitTyrantControlPanelRenderer.java @@ -255,7 +255,9 @@ public final class BukkitTyrantControlPanelRenderer return; } try { - openAssignmentConfirmation(player, tyrantClass, UUID.fromString(lore.get(1))); + openAssignmentConfirmation( + player, tyrantClass, UUID.fromString(ChatColor.stripColor(lore.get(1))) + ); } catch (IllegalArgumentException ignored) { player.sendMessage(ChatColor.RED + "That player selection is no longer valid."); } diff --git a/src/main/java/games/dmg/spigottyrant/BukkitVigilanteControlPanelRenderer.java b/src/main/java/games/dmg/spigottyrant/BukkitVigilanteControlPanelRenderer.java new file mode 100644 index 0000000..7a9dae4 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/BukkitVigilanteControlPanelRenderer.java @@ -0,0 +1,409 @@ +package games.dmg.spigottyrant; + +import java.util.List; +import java.util.Map; +import java.util.Set; +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 BukkitVigilanteControlPanelRenderer + implements VigilanteControlPanelRenderer, Listener { + private static final String TITLE = ChatColor.DARK_BLUE + "Vigilante Control Panel"; + private final Server server; + private final TyrantStateManager stateManager; + private final FollowerService followers; + private final FollowerPresenceProvider presenceProvider; + private final int strengthCap; + private final int resistanceCap; + + public BukkitVigilanteControlPanelRenderer( + Server server, + TyrantStateManager stateManager, + FollowerService followers, + FollowerPresenceProvider presenceProvider, + int strengthCap, + int resistanceCap + ) { + this.server = server; + this.stateManager = stateManager; + this.followers = followers; + this.presenceProvider = presenceProvider; + this.strengthCap = strengthCap; + this.resistanceCap = resistanceCap; + } + + @Override + public void openOverview(Player player, VigilanteControlPanelModel model) { + MenuHolder holder = new MenuHolder(MenuView.OVERVIEW, null); + Inventory inventory = create(holder, 27, TITLE); + inventory.setItem(10, item(Material.SHIELD, ChatColor.BLUE + "Resistance Overview", + List.of( + "Followers: " + model.followers().size(), + "Pending invitations: " + model.pendingInvitations().size(), + "Nearby contributors: " + model.nearbyEligibleFollowers(), + "Strength: " + model.strengthLevel() + "/" + model.strengthCap(), + "Resistance: " + model.resistanceLevel() + "/" + model.resistanceCap() + ))); + inventory.setItem(12, item(Material.WRITABLE_BOOK, ChatColor.GREEN + "Recruitment", + List.of("Invite eligible online players."))); + inventory.setItem(14, item(Material.PLAYER_HEAD, ChatColor.AQUA + "Manage Followers", + followerSummary(model))); + inventory.setItem(16, item(Material.REDSTONE_TORCH, ChatColor.RED + "Role Control", + List.of("Relinquish the Vigilante role."))); + 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 (!isActiveVigilante(player)) { + player.closeInventory(); + player.sendMessage(ChatColor.RED + + "This Vigilante control panel is no longer active."); + return; + } + switch (holder.view()) { + case OVERVIEW -> clickOverview(player, event.getRawSlot()); + case RECRUITMENT -> clickRecruitment(player, event.getRawSlot()); + case INVITE_CONFIRMATION -> clickInviteConfirmation( + player, event.getRawSlot(), holder.targetId() + ); + case FOLLOWERS -> clickFollowers(player, event.getRawSlot()); + case DISMISS_CONFIRMATION -> clickDismissConfirmation( + player, event.getRawSlot(), holder.targetId() + ); + case ROLE -> clickRole(player, event.getRawSlot()); + case RELINQUISH_CONFIRMATION -> clickRelinquishConfirmation( + player, event.getRawSlot() + ); + } + } + + private void clickOverview(Player player, int slot) { + if (slot == 12) { + openRecruitment(player); + } else if (slot == 14) { + openFollowers(player); + } else if (slot == 16) { + openRole(player); + } + } + + private void openRecruitment(Player player) { + MenuHolder holder = new MenuHolder(MenuView.RECRUITMENT, null); + Inventory inventory = create(holder, 54, TITLE + " — Recruitment"); + Set pending = followers.invitedPlayerIds(player.getUniqueId()); + 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 = recruitmentEligibility(candidate.getUniqueId(), state, pending); + inventory.setItem(slot, playerItem(candidate.getName(), candidate.getUniqueId(), + eligibility)); + slot++; + } + inventory.setItem(49, backItem()); + player.openInventory(inventory); + } + + private String recruitmentEligibility( + UUID candidateId, + PlayerState state, + Set pending + ) { + GameState game = stateManager.game(); + if (pending.contains(candidateId)) { + return ChatColor.YELLOW + "Pending invitation"; + } + if (state.optedOutUntil().isPresent()) { + return ChatColor.RED + "Ineligible: opted out"; + } + if (game.tyrantId().filter(candidateId::equals).isPresent()) { + return ChatColor.RED + "Ineligible: Tyrant"; + } + if (game.vigilanteId().filter(candidateId::equals).isPresent()) { + return ChatColor.RED + "Ineligible: Vigilante"; + } + if (state.followerOf().isPresent()) { + return ChatColor.RED + "Ineligible: already a Follower"; + } + return ChatColor.GREEN + "Eligible"; + } + + private void clickRecruitment(Player player, int slot) { + if (slot == 49) { + refresh(player); + return; + } + selectedPlayer(player, slot, "Eligible") + .ifPresent(targetId -> openInviteConfirmation(player, targetId)); + } + + private void openInviteConfirmation(Player player, UUID targetId) { + Player target = server.getPlayer(targetId); + if (target == null) { + player.sendMessage(ChatColor.RED + "That player is no longer online."); + openRecruitment(player); + return; + } + MenuHolder holder = new MenuHolder(MenuView.INVITE_CONFIRMATION, targetId); + Inventory inventory = create(holder, 27, TITLE + " — Confirm Invitation"); + inventory.setItem(11, cancelItem()); + inventory.setItem(13, item(Material.WRITABLE_BOOK, ChatColor.YELLOW + target.getName(), + List.of("Invite this player to become a Follower?"))); + inventory.setItem(15, confirmItem()); + player.openInventory(inventory); + } + + private void clickInviteConfirmation(Player player, int slot, UUID targetId) { + if (slot == 11) { + openRecruitment(player); + } else if (slot == 15 && targetId != null) { + Player target = server.getPlayer(targetId); + if (target == null) { + player.sendMessage(ChatColor.RED + "That player is no longer online."); + } else { + player.performCommand("vigilante invite " + target.getName()); + } + openRecruitment(player); + } + } + + private void openFollowers(Player player) { + VigilanteControlPanelModel model = model(player); + MenuHolder holder = new MenuHolder(MenuView.FOLLOWERS, null); + Inventory inventory = create(holder, 54, TITLE + " — Followers"); + int slot = 0; + for (VigilanteFollowerView follower : model.followers()) { + if (slot >= 45) { + break; + } + FollowerPresence presence = follower.presence(); + List status = List.of( + presence.online() ? "Online" : "Offline", + presence.living() ? "Living" : "Dead or unavailable", + presence.sameWorld() ? "Same world" : "Different world or unavailable", + presence.withinRange() ? "Within range" : "Outside range or unavailable", + follower.optedOut() ? "Opted out" : "Participating", + presence.online() ? ChatColor.GREEN + "Click to dismiss." + : ChatColor.RED + "Must be online to dismiss." + ); + inventory.setItem(slot, playerItem( + follower.name(), follower.playerId(), status.toString() + )); + slot++; + } + inventory.setItem(49, backItem()); + player.openInventory(inventory); + } + + private void clickFollowers(Player player, int slot) { + if (slot == 49) { + refresh(player); + return; + } + selectedPlayer(player, slot, "Online") + .filter(targetId -> server.getPlayer(targetId) != null) + .ifPresent(targetId -> openDismissConfirmation(player, targetId)); + } + + private void openDismissConfirmation(Player player, UUID targetId) { + Player target = server.getPlayer(targetId); + if (target == null) { + player.sendMessage(ChatColor.RED + "That Follower is no longer online."); + openFollowers(player); + return; + } + MenuHolder holder = new MenuHolder(MenuView.DISMISS_CONFIRMATION, targetId); + Inventory inventory = create(holder, 27, TITLE + " — Confirm Dismissal"); + inventory.setItem(11, cancelItem()); + inventory.setItem(13, item(Material.PLAYER_HEAD, ChatColor.YELLOW + target.getName(), + List.of("Dismiss this Follower?"))); + inventory.setItem(15, confirmItem()); + player.openInventory(inventory); + } + + private void clickDismissConfirmation(Player player, int slot, UUID targetId) { + if (slot == 11) { + openFollowers(player); + } else if (slot == 15 && targetId != null) { + Player target = server.getPlayer(targetId); + if (target == null) { + player.sendMessage(ChatColor.RED + "That Follower is no longer online."); + } else { + player.performCommand("vigilante dismiss " + target.getName()); + } + openFollowers(player); + } + } + + private void openRole(Player player) { + MenuHolder holder = new MenuHolder(MenuView.ROLE, null); + Inventory inventory = create(holder, 27, TITLE + " — Role Control"); + inventory.setItem(13, item(Material.REDSTONE_TORCH, + ChatColor.RED + "Relinquish Vigilante Role", + List.of("Followers will be cleared and a replacement selected.", + "Click to review confirmation."))); + inventory.setItem(22, backItem()); + player.openInventory(inventory); + } + + private void clickRole(Player player, int slot) { + if (slot == 22) { + refresh(player); + } else if (slot == 13) { + MenuHolder holder = new MenuHolder(MenuView.RELINQUISH_CONFIRMATION, null); + Inventory inventory = create(holder, 27, TITLE + " — Confirm Relinquishment"); + inventory.setItem(11, cancelItem()); + inventory.setItem(13, item(Material.BARRIER, ChatColor.RED + "Relinquish Role", + List.of("This clears all Followers and rerolls the Vigilante."))); + inventory.setItem(15, confirmItem()); + player.openInventory(inventory); + } + } + + private void clickRelinquishConfirmation(Player player, int slot) { + if (slot == 11) { + openRole(player); + } else if (slot == 15) { + player.closeInventory(); + player.performCommand("tyrant relinquish confirm"); + } + } + + private java.util.Optional selectedPlayer( + Player player, + int slot, + String requiredStatus + ) { + ItemStack clicked = player.getOpenInventory().getTopInventory().getItem(slot); + if (clicked == null || clicked.getType() != Material.PLAYER_HEAD) { + return java.util.Optional.empty(); + } + List lore = clicked.getItemMeta().getLore(); + if (lore == null || lore.size() < 2 + || !ChatColor.stripColor(lore.get(0)).contains(requiredStatus)) { + return java.util.Optional.empty(); + } + try { + return java.util.Optional.of(UUID.fromString( + ChatColor.stripColor(lore.get(lore.size() - 1)) + )); + } catch (IllegalArgumentException ignored) { + player.sendMessage(ChatColor.RED + "That player selection is no longer valid."); + return java.util.Optional.empty(); + } + } + + private boolean isActiveVigilante(Player player) { + GameState game = stateManager.game(); + return game.lifecycle() == GameLifecycle.RUNNING + && game.vigilanteId().filter(player.getUniqueId()::equals).isPresent(); + } + + private VigilanteControlPanelModel model(Player player) { + Map players = stateManager.players(); + return VigilanteControlPanelModel.create( + stateManager.game(), players, followers.invitedPlayerIds(player.getUniqueId()), + presenceProvider.inspect(player, players), strengthCap, resistanceCap + ); + } + + 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 playerItem(String name, UUID playerId, String status) { + return item(Material.PLAYER_HEAD, name, List.of(status, playerId.toString())); + } + + 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 ItemStack cancelItem() { + return item(Material.RED_WOOL, ChatColor.RED + "Cancel", List.of()); + } + + private static ItemStack confirmItem() { + return item(Material.LIME_WOOL, ChatColor.GREEN + "Confirm", List.of()); + } + + private static List followerSummary(VigilanteControlPanelModel model) { + if (model.followers().isEmpty()) { + return List.of("No Followers assigned."); + } + return model.followers().stream() + .map(follower -> follower.name() + (follower.contributes() ? " — contributing" : "")) + .toList(); + } + + private enum MenuView { + OVERVIEW, + RECRUITMENT, + INVITE_CONFIRMATION, + FOLLOWERS, + DISMISS_CONFIRMATION, + ROLE, + RELINQUISH_CONFIRMATION + } + + private static final class MenuHolder implements InventoryHolder { + private final MenuView view; + private final UUID targetId; + private Inventory inventory; + + private MenuHolder(MenuView view, UUID targetId) { + this.view = view; + this.targetId = targetId; + } + + private MenuView view() { + return view; + } + + private UUID targetId() { + return targetId; + } + + @Override + public Inventory getInventory() { + return inventory; + } + } +} diff --git a/src/main/java/games/dmg/spigottyrant/DefaultVigilanteControlPanel.java b/src/main/java/games/dmg/spigottyrant/DefaultVigilanteControlPanel.java new file mode 100644 index 0000000..ddd00a4 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/DefaultVigilanteControlPanel.java @@ -0,0 +1,73 @@ +package games.dmg.spigottyrant; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import org.bukkit.ChatColor; +import org.bukkit.entity.Player; + +public final class DefaultVigilanteControlPanel implements VigilanteControlPanel { + private final TyrantStateManager stateManager; + private final FollowerService followers; + private final VigilanteControlPanelRenderer renderer; + private final FollowerPresenceProvider presenceProvider; + private final int strengthCap; + private final int resistanceCap; + private final Set openedSessions = new HashSet<>(); + + public DefaultVigilanteControlPanel( + TyrantStateManager stateManager, + FollowerService followers, + VigilanteControlPanelRenderer renderer, + FollowerPresenceProvider presenceProvider, + int strengthCap, + int resistanceCap + ) { + this.stateManager = stateManager; + this.followers = followers; + this.renderer = renderer; + this.presenceProvider = presenceProvider; + this.strengthCap = strengthCap; + this.resistanceCap = resistanceCap; + } + + @Override + public void open(Player player) { + GameState game = stateManager.game(); + if (game.lifecycle() != GameLifecycle.RUNNING + || game.vigilanteId().filter(player.getUniqueId()::equals).isEmpty()) { + player.sendMessage(ChatColor.RED + + "Only the active Vigilante can open the control panel while the event is running."); + return; + } + Map players = stateManager.players(); + renderer.openOverview( + player, + VigilanteControlPanelModel.create( + game, + players, + followers.invitedPlayerIds(player.getUniqueId()), + presenceProvider.inspect(player, players), + strengthCap, + resistanceCap + ) + ); + 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/FollowerPresence.java b/src/main/java/games/dmg/spigottyrant/FollowerPresence.java new file mode 100644 index 0000000..f4fce58 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/FollowerPresence.java @@ -0,0 +1,12 @@ +package games.dmg.spigottyrant; + +public record FollowerPresence( + boolean online, + boolean living, + boolean sameWorld, + boolean withinRange +) { + public boolean contributes() { + return online && living && sameWorld && withinRange; + } +} diff --git a/src/main/java/games/dmg/spigottyrant/FollowerPresenceProvider.java b/src/main/java/games/dmg/spigottyrant/FollowerPresenceProvider.java new file mode 100644 index 0000000..8d70883 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/FollowerPresenceProvider.java @@ -0,0 +1,10 @@ +package games.dmg.spigottyrant; + +import java.util.Map; +import java.util.UUID; +import org.bukkit.entity.Player; + +@FunctionalInterface +public interface FollowerPresenceProvider { + Map inspect(Player vigilante, Map players); +} diff --git a/src/main/java/games/dmg/spigottyrant/FollowerService.java b/src/main/java/games/dmg/spigottyrant/FollowerService.java index e2ac503..1235ed3 100644 --- a/src/main/java/games/dmg/spigottyrant/FollowerService.java +++ b/src/main/java/games/dmg/spigottyrant/FollowerService.java @@ -22,10 +22,14 @@ public final class FollowerService { } PlayerState target = players.get(targetId); if (target == null || target.optedOutUntil().isPresent() + || target.followerOf().isPresent() || game.tyrantId().filter(targetId::equals).isPresent() || game.vigilanteId().filter(targetId::equals).isPresent()) { return result(players, FollowerStatus.INELIGIBLE); } + if (vigilanteId.equals(invitations.get(targetId))) { + return result(players, FollowerStatus.ALREADY_INVITED); + } invitations.put(targetId, vigilanteId); return result(players, FollowerStatus.INVITED); } @@ -77,6 +81,13 @@ public final class FollowerService { return updated(players, withFollower(target, Optional.empty()), FollowerStatus.LEFT); } + public java.util.Set invitedPlayerIds(UUID vigilanteId) { + return invitations.entrySet().stream() + .filter(entry -> entry.getValue().equals(vigilanteId)) + .map(Map.Entry::getKey) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); + } + public void clearInvitations() { invitations.clear(); } diff --git a/src/main/java/games/dmg/spigottyrant/FollowerStatus.java b/src/main/java/games/dmg/spigottyrant/FollowerStatus.java index 715b660..8d31364 100644 --- a/src/main/java/games/dmg/spigottyrant/FollowerStatus.java +++ b/src/main/java/games/dmg/spigottyrant/FollowerStatus.java @@ -2,6 +2,7 @@ package games.dmg.spigottyrant; public enum FollowerStatus { INVITED, + ALREADY_INVITED, JOINED, DISMISSED, LEFT, diff --git a/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java b/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java index 543d40a..c8740f9 100644 --- a/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java +++ b/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java @@ -85,6 +85,18 @@ public final class SpigotTyrantPlugin extends JavaPlugin { TyrantControlPanel controlPanel = new DefaultTyrantControlPanel( stateManager, controlPanelRenderer, clock ); + FollowerPresenceProvider followerPresence = new BukkitFollowerPresenceProvider( + getServer(), settings.followerRangeBlocks() + ); + BukkitVigilanteControlPanelRenderer vigilanteControlPanelRenderer = + new BukkitVigilanteControlPanelRenderer( + getServer(), stateManager, followers, followerPresence, + settings.followerStrengthCap(), settings.followerResistanceCap() + ); + VigilanteControlPanel vigilanteControlPanel = new DefaultVigilanteControlPanel( + stateManager, followers, vigilanteControlPanelRenderer, followerPresence, + settings.followerStrengthCap(), settings.followerResistanceCap() + ); Objects.requireNonNull(getCommand("tyrant"), "Missing tyrant command metadata") .setExecutor(new TyrantCommand( stateManager, @@ -100,7 +112,9 @@ public final class SpigotTyrantPlugin extends JavaPlugin { controlPanel )); Objects.requireNonNull(getCommand("vigilante"), "Missing vigilante metadata") - .setExecutor(new VigilanteCommand(stateManager, followers, onlinePlayers)); + .setExecutor(new VigilanteCommand( + stateManager, followers, onlinePlayers, vigilanteControlPanel + )); Objects.requireNonNull(getCommand("tyrantadmin"), "Missing tyrantadmin metadata") .setExecutor(new TyrantAdminCommand( stateManager, @@ -118,6 +132,7 @@ public final class SpigotTyrantPlugin extends JavaPlugin { this ); getServer().getPluginManager().registerEvents(controlPanelRenderer, this); + getServer().getPluginManager().registerEvents(vigilanteControlPanelRenderer, this); getServer().getPluginManager().registerEvents( new TyrantControlPanelJoinListener( stateManager, @@ -127,6 +142,15 @@ public final class SpigotTyrantPlugin extends JavaPlugin { ), this ); + getServer().getPluginManager().registerEvents( + new VigilanteControlPanelJoinListener( + stateManager, + vigilanteControlPanel, + (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/VigilanteCommand.java b/src/main/java/games/dmg/spigottyrant/VigilanteCommand.java index 90a44e2..bea7aaa 100644 --- a/src/main/java/games/dmg/spigottyrant/VigilanteCommand.java +++ b/src/main/java/games/dmg/spigottyrant/VigilanteCommand.java @@ -11,15 +11,30 @@ public final class VigilanteCommand implements CommandExecutor { private final TyrantStateManager stateManager; private final FollowerService followers; private final OnlinePlayerDirectory onlinePlayers; + private final VigilanteControlPanel controlPanel; public VigilanteCommand( TyrantStateManager stateManager, FollowerService followers, OnlinePlayerDirectory onlinePlayers + ) { + this( + stateManager, followers, onlinePlayers, + player -> player.sendMessage(ChatColor.RED + + "The Vigilante control panel is unavailable.") + ); + } + + public VigilanteCommand( + TyrantStateManager stateManager, + FollowerService followers, + OnlinePlayerDirectory onlinePlayers, + VigilanteControlPanel controlPanel ) { this.stateManager = stateManager; this.followers = followers; this.onlinePlayers = onlinePlayers; + this.controlPanel = controlPanel; } @Override @@ -33,6 +48,11 @@ public final class VigilanteCommand implements CommandExecutor { sender.sendMessage("This command must be used by a player."); return true; } + if (arguments.length == 0 + || arguments.length == 1 && arguments[0].equalsIgnoreCase("menu")) { + controlPanel.open(player); + return true; + } FollowerResult result; Player target = null; if (arguments.length == 1 && arguments[0].equalsIgnoreCase("accept")) { @@ -61,7 +81,7 @@ public final class VigilanteCommand implements CommandExecutor { ); } else { player.sendMessage(ChatColor.YELLOW - + "Usage: /vigilante |accept|dismiss |leave>"); + + "Usage: /vigilante |accept|dismiss |leave>"); return true; } if (result.status() == FollowerStatus.JOINED diff --git a/src/main/java/games/dmg/spigottyrant/VigilanteControlPanel.java b/src/main/java/games/dmg/spigottyrant/VigilanteControlPanel.java new file mode 100644 index 0000000..b4b227e --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/VigilanteControlPanel.java @@ -0,0 +1,19 @@ +package games.dmg.spigottyrant; + +import java.util.UUID; +import org.bukkit.entity.Player; + +@FunctionalInterface +public interface VigilanteControlPanel { + 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/VigilanteControlPanelJoinListener.java b/src/main/java/games/dmg/spigottyrant/VigilanteControlPanelJoinListener.java new file mode 100644 index 0000000..245100e --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/VigilanteControlPanelJoinListener.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 VigilanteControlPanelJoinListener implements Listener { + private static final long OPEN_DELAY_TICKS = 100L; + private final TyrantStateManager stateManager; + private final VigilanteControlPanel controlPanel; + private final DelayedTaskScheduler scheduler; + + public VigilanteControlPanelJoinListener( + TyrantStateManager stateManager, + VigilanteControlPanel 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(() -> openIfStillVigilante(player), OPEN_DELAY_TICKS); + } + + @EventHandler + public void onQuit(PlayerQuitEvent event) { + controlPanel.endSession(event.getPlayer().getUniqueId()); + } + + private void openIfStillVigilante(Player player) { + GameState game = stateManager.game(); + if (player.isOnline() + && game.lifecycle() == GameLifecycle.RUNNING + && game.vigilanteId().filter(player.getUniqueId()::equals).isPresent() + && !controlPanel.openedThisSession(player.getUniqueId())) { + controlPanel.open(player); + } + } +} diff --git a/src/main/java/games/dmg/spigottyrant/VigilanteControlPanelModel.java b/src/main/java/games/dmg/spigottyrant/VigilanteControlPanelModel.java new file mode 100644 index 0000000..baba850 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/VigilanteControlPanelModel.java @@ -0,0 +1,59 @@ +package games.dmg.spigottyrant; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +public record VigilanteControlPanelModel( + List followers, + Set pendingInvitations, + int nearbyEligibleFollowers, + int strengthLevel, + int resistanceLevel, + int strengthCap, + int resistanceCap +) { + public VigilanteControlPanelModel { + followers = List.copyOf(followers); + pendingInvitations = Set.copyOf(pendingInvitations); + } + + public static VigilanteControlPanelModel create( + GameState game, + Map players, + Set pendingInvitationIds, + Map presence, + int strengthCap, + int resistanceCap + ) { + UUID vigilanteId = game.vigilanteId().orElseThrow(); + List followers = players.values().stream() + .filter(player -> player.followerOf().filter(vigilanteId::equals).isPresent()) + .map(player -> new VigilanteFollowerView( + player.playerId(), player.latestName(), player.optedOutUntil().isPresent(), + presence.getOrDefault( + player.playerId(), new FollowerPresence(false, false, false, false) + ) + )) + .sorted(Comparator.comparing(VigilanteFollowerView::name)) + .toList(); + Set pending = pendingInvitationIds.stream() + .map(players::get) + .filter(java.util.Objects::nonNull) + .map(PlayerState::latestName) + .collect(Collectors.toUnmodifiableSet()); + int nearby = (int) followers.stream() + .filter(VigilanteFollowerView::contributes) + .count(); + VigilantePower.Levels levels = VigilantePower.forFollowers( + nearby, strengthCap, resistanceCap + ); + return new VigilanteControlPanelModel( + followers, pending, nearby, levels.strength(), levels.resistance(), + strengthCap, resistanceCap + ); + } +} diff --git a/src/main/java/games/dmg/spigottyrant/VigilanteControlPanelRenderer.java b/src/main/java/games/dmg/spigottyrant/VigilanteControlPanelRenderer.java new file mode 100644 index 0000000..05266b9 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/VigilanteControlPanelRenderer.java @@ -0,0 +1,8 @@ +package games.dmg.spigottyrant; + +import org.bukkit.entity.Player; + +@FunctionalInterface +public interface VigilanteControlPanelRenderer { + void openOverview(Player player, VigilanteControlPanelModel model); +} diff --git a/src/main/java/games/dmg/spigottyrant/VigilanteFollowerView.java b/src/main/java/games/dmg/spigottyrant/VigilanteFollowerView.java new file mode 100644 index 0000000..407deab --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/VigilanteFollowerView.java @@ -0,0 +1,14 @@ +package games.dmg.spigottyrant; + +import java.util.UUID; + +public record VigilanteFollowerView( + UUID playerId, + String name, + boolean optedOut, + FollowerPresence presence +) { + public boolean contributes() { + return !optedOut && presence.contributes(); + } +} diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 338a3a5..67e2785 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -10,7 +10,7 @@ commands: usage: /tyrant vigilante: description: Manage Vigilante Followers. - usage: /vigilante |accept|dismiss |leave> + usage: /vigilante |accept|dismiss |leave> tyrantadmin: description: Administer the Spigot Tyrant game. usage: /tyrantadmin diff --git a/src/test/java/games/dmg/spigottyrant/DefaultVigilanteControlPanelTest.java b/src/test/java/games/dmg/spigottyrant/DefaultVigilanteControlPanelTest.java new file mode 100644 index 0000000..f572e32 --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/DefaultVigilanteControlPanelTest.java @@ -0,0 +1,67 @@ +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.Duration; +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 DefaultVigilanteControlPanelTest { + @Test + void activeVigilanteOpensAuthoritativeOverview() { + UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111"); + UUID vigilanteId = UUID.fromString("22222222-2222-2222-2222-222222222222"); + GameState game = game(tyrantId, vigilanteId); + PlayerState vigilanteState = PlayerState.newPlayer(vigilanteId, "Vigilante"); + TyrantStateManager manager = mock(TyrantStateManager.class); + when(manager.game()).thenReturn(game); + when(manager.players()).thenReturn(Map.of(vigilanteId, vigilanteState)); + Player vigilante = mock(Player.class); + when(vigilante.getUniqueId()).thenReturn(vigilanteId); + VigilanteControlPanelRenderer renderer = mock(VigilanteControlPanelRenderer.class); + FollowerPresenceProvider presence = mock(FollowerPresenceProvider.class); + when(presence.inspect(any(), any())).thenReturn(Map.of()); + DefaultVigilanteControlPanel panel = new DefaultVigilanteControlPanel( + manager, new FollowerService(), renderer, presence, 5, 4 + ); + + panel.open(vigilante); + + verify(renderer).openOverview(org.mockito.ArgumentMatchers.eq(vigilante), any()); + } + + @Test + void nonVigilanteCannotRenderControlPanel() { + UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111"); + UUID vigilanteId = UUID.fromString("22222222-2222-2222-2222-222222222222"); + Player other = mock(Player.class); + when(other.getUniqueId()).thenReturn(UUID.randomUUID()); + TyrantStateManager manager = mock(TyrantStateManager.class); + when(manager.game()).thenReturn(game(tyrantId, vigilanteId)); + VigilanteControlPanelRenderer renderer = mock(VigilanteControlPanelRenderer.class); + DefaultVigilanteControlPanel panel = new DefaultVigilanteControlPanel( + manager, new FollowerService(), renderer, mock(FollowerPresenceProvider.class), 5, 4 + ); + + panel.open(other); + + verify(renderer, never()).openOverview(any(), any()); + verify(other).sendMessage(org.mockito.ArgumentMatchers.contains("active Vigilante")); + } + + private static GameState game(UUID tyrantId, UUID vigilanteId) { + return new GameState( + GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.of(vigilanteId), + Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO, + 0, 0, Set.of() + ); + } +} diff --git a/src/test/java/games/dmg/spigottyrant/FollowerServiceTest.java b/src/test/java/games/dmg/spigottyrant/FollowerServiceTest.java index a764f5d..ea2c402 100644 --- a/src/test/java/games/dmg/spigottyrant/FollowerServiceTest.java +++ b/src/test/java/games/dmg/spigottyrant/FollowerServiceTest.java @@ -1,6 +1,7 @@ package games.dmg.spigottyrant; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.time.Duration; import java.util.Map; @@ -34,6 +35,32 @@ final class FollowerServiceTest { assertEquals(TyrantClass.FIXER, accepted.players().get(FOLLOWER).tyrantClass()); } + @Test + void repeatedInvitationIsRejectedAndPendingInvitationCanBeInspected() { + GameState game = game(); + PlayerState target = PlayerState.newPlayer(FOLLOWER, "Follower"); + Map players = Map.of(FOLLOWER, target); + + FollowerResult first = service.invite(game, players, VIGILANTE, FOLLOWER); + FollowerResult repeated = service.invite(game, players, VIGILANTE, FOLLOWER); + + assertEquals(FollowerStatus.INVITED, first.status()); + assertEquals(FollowerStatus.ALREADY_INVITED, repeated.status()); + assertTrue(service.invitedPlayerIds(VIGILANTE).contains(FOLLOWER)); + } + + @Test + void existingFollowerCannotReceiveAnotherInvitation() { + GameState game = game(); + PlayerState follower = withFollower(PlayerState.newPlayer(FOLLOWER, "Follower")); + + FollowerResult result = service.invite( + game, Map.of(FOLLOWER, follower), VIGILANTE, FOLLOWER + ); + + assertEquals(FollowerStatus.INELIGIBLE, result.status()); + } + @Test void vigilanteCanDismissAndFollowerCanLeave() { GameState game = game(); diff --git a/src/test/java/games/dmg/spigottyrant/VigilanteCommandTest.java b/src/test/java/games/dmg/spigottyrant/VigilanteCommandTest.java new file mode 100644 index 0000000..a985cde --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/VigilanteCommandTest.java @@ -0,0 +1,32 @@ +package games.dmg.spigottyrant; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.UUID; +import org.bukkit.command.Command; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +final class VigilanteCommandTest { + @Test + void playerCanOpenControlPanelWithNoArgumentsOrMenuSubcommand() { + UUID playerId = UUID.fromString("22222222-2222-2222-2222-222222222222"); + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(playerId); + VigilanteControlPanel controlPanel = mock(VigilanteControlPanel.class); + VigilanteCommand command = new VigilanteCommand( + mock(TyrantStateManager.class), + new FollowerService(), + mock(OnlinePlayerDirectory.class), + controlPanel + ); + + command.onCommand(player, mock(Command.class), "vigilante", new String[0]); + command.onCommand(player, mock(Command.class), "vigilante", new String[] {"menu"}); + + verify(controlPanel, times(2)).open(player); + } +} diff --git a/src/test/java/games/dmg/spigottyrant/VigilanteControlPanelJoinListenerTest.java b/src/test/java/games/dmg/spigottyrant/VigilanteControlPanelJoinListenerTest.java new file mode 100644 index 0000000..902fb7f --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/VigilanteControlPanelJoinListenerTest.java @@ -0,0 +1,80 @@ +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 VigilanteControlPanelJoinListenerTest { + @Test + void opensPanelOneHundredTicksAfterActiveVigilanteJoins() { + UUID vigilanteId = UUID.fromString("22222222-2222-2222-2222-222222222222"); + TyrantStateManager manager = mock(TyrantStateManager.class); + when(manager.game()).thenReturn(game(GameLifecycle.RUNNING, vigilanteId)); + Player vigilante = player(vigilanteId); + PlayerJoinEvent event = mock(PlayerJoinEvent.class); + when(event.getPlayer()).thenReturn(vigilante); + DelayedTaskScheduler scheduler = mock(DelayedTaskScheduler.class); + VigilanteControlPanel panel = mock(VigilanteControlPanel.class); + VigilanteControlPanelJoinListener listener = new VigilanteControlPanelJoinListener( + 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(vigilante); + } + + @Test + void doesNotOpenAfterPauseOrWhenAlreadyOpenedManually() { + UUID vigilanteId = UUID.fromString("22222222-2222-2222-2222-222222222222"); + Player vigilante = player(vigilanteId); + PlayerJoinEvent event = mock(PlayerJoinEvent.class); + when(event.getPlayer()).thenReturn(vigilante); + DelayedTaskScheduler scheduler = mock(DelayedTaskScheduler.class); + VigilanteControlPanel panel = mock(VigilanteControlPanel.class); + when(panel.openedThisSession(vigilanteId)).thenReturn(true); + TyrantStateManager manager = mock(TyrantStateManager.class); + when(manager.game()).thenReturn(game(GameLifecycle.PAUSED, vigilanteId)); + VigilanteControlPanelJoinListener listener = new VigilanteControlPanelJoinListener( + 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(vigilante); + } + + private static Player player(UUID id) { + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(id); + when(player.isOnline()).thenReturn(true); + return player; + } + + private static GameState game(GameLifecycle lifecycle, UUID vigilanteId) { + return new GameState( + lifecycle, + Optional.of(UUID.fromString("11111111-1111-1111-1111-111111111111")), + Optional.of(vigilanteId), Optional.empty(), Optional.empty(), + lifecycle == GameLifecycle.PAUSED + ? Optional.of(java.time.Instant.EPOCH) : Optional.empty(), + Duration.ZERO, 0, 0, Set.of() + ); + } +} diff --git a/src/test/java/games/dmg/spigottyrant/VigilanteControlPanelModelTest.java b/src/test/java/games/dmg/spigottyrant/VigilanteControlPanelModelTest.java new file mode 100644 index 0000000..904f276 --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/VigilanteControlPanelModelTest.java @@ -0,0 +1,58 @@ +package games.dmg.spigottyrant; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.time.Duration; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +final class VigilanteControlPanelModelTest { + @Test + void overviewReportsFollowersInvitationsAndCappedNearbyPower() { + UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111"); + UUID vigilanteId = UUID.fromString("22222222-2222-2222-2222-222222222222"); + UUID nearbyId = UUID.fromString("33333333-3333-3333-3333-333333333333"); + UUID offlineId = UUID.fromString("44444444-4444-4444-4444-444444444444"); + UUID invitedId = UUID.fromString("55555555-5555-5555-5555-555555555555"); + GameState game = new GameState( + GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.of(vigilanteId), + Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO, + 0, 0, Set.of() + ); + PlayerState nearby = follower(nearbyId, "Nearby", vigilanteId); + PlayerState offline = follower(offlineId, "Offline", vigilanteId); + PlayerState invited = PlayerState.newPlayer(invitedId, "Invited"); + + VigilanteControlPanelModel model = VigilanteControlPanelModel.create( + game, + Map.of(nearbyId, nearby, offlineId, offline, invitedId, invited), + Set.of(invitedId), + Map.of( + nearbyId, new FollowerPresence(true, true, true, true), + offlineId, new FollowerPresence(false, false, false, false) + ), + 5, + 4 + ); + + assertEquals(2, model.followers().size()); + assertEquals(Set.of("Invited"), model.pendingInvitations()); + assertEquals(1, model.nearbyEligibleFollowers()); + assertEquals(1, model.strengthLevel()); + assertEquals(1, model.resistanceLevel()); + assertEquals(5, model.strengthCap()); + assertEquals(4, model.resistanceCap()); + } + + private static PlayerState follower(UUID id, String name, UUID vigilanteId) { + PlayerState player = PlayerState.newPlayer(id, name); + return new PlayerState( + player.playerId(), player.latestName(), player.lastLogin(), player.optedOutUntil(), + player.tyrantClass(), Optional.of(vigilanteId), player.cooldownEnds(), + player.readyAbilityItems(), player.capturedMobs() + ); + } +}