From 0b202f588da35022088fd44845605d3d0ff8c527 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Fri, 21 Aug 2026 21:28:32 -0400 Subject: [PATCH] feat(items): add bound role control items --- design/log.md | 20 +++ design/user-stories/index.md | 1 + .../us-015-manage-tyrant-control-panel.md | 2 + .../us-016-manage-vigilante-control-panel.md | 2 + .../us-017-use-bound-role-control-items.md | 43 ++++++ .../BukkitRoleControlItemService.java | 146 ++++++++++++++++++ .../dmg/spigottyrant/PluginSettings.java | 14 ++ .../spigottyrant/PluginSettingsValidator.java | 2 + .../games/dmg/spigottyrant/RoleControl.java | 6 + .../spigottyrant/RoleControlItemListener.java | 146 ++++++++++++++++++ .../spigottyrant/RoleControlItemPolicy.java | 19 +++ .../RoleControlItemRefreshTask.java | 31 ++++ .../spigottyrant/RoleControlItemService.java | 20 +++ .../dmg/spigottyrant/SpigotTyrantPlugin.java | 21 ++- .../games/dmg/spigottyrant/TyrantCommand.java | 60 ++++++- .../dmg/spigottyrant/VigilanteCommand.java | 27 +++- src/main/resources/config.yml | 8 + src/main/resources/plugin.yml | 2 +- .../dmg/spigottyrant/PluginSettingsTest.java | 4 + .../RoleControlItemListenerTest.java | 89 +++++++++++ .../RoleControlItemPolicyTest.java | 41 +++++ .../RoleControlItemRefreshTaskTest.java | 37 +++++ .../dmg/spigottyrant/TyrantCommandTest.java | 22 +++ .../spigottyrant/VigilanteCommandTest.java | 27 ++++ 24 files changed, 777 insertions(+), 13 deletions(-) create mode 100644 design/user-stories/us-017-use-bound-role-control-items.md create mode 100644 src/main/java/games/dmg/spigottyrant/BukkitRoleControlItemService.java create mode 100644 src/main/java/games/dmg/spigottyrant/RoleControl.java create mode 100644 src/main/java/games/dmg/spigottyrant/RoleControlItemListener.java create mode 100644 src/main/java/games/dmg/spigottyrant/RoleControlItemPolicy.java create mode 100644 src/main/java/games/dmg/spigottyrant/RoleControlItemRefreshTask.java create mode 100644 src/main/java/games/dmg/spigottyrant/RoleControlItemService.java create mode 100644 src/test/java/games/dmg/spigottyrant/RoleControlItemListenerTest.java create mode 100644 src/test/java/games/dmg/spigottyrant/RoleControlItemPolicyTest.java create mode 100644 src/test/java/games/dmg/spigottyrant/RoleControlItemRefreshTaskTest.java diff --git a/design/log.md b/design/log.md index 720a206..6b1eaa2 100644 --- a/design/log.md +++ b/design/log.md @@ -6,6 +6,26 @@ description: Chronological record of material decisions affecting the Spigot Tyr # Spigot Tyrant Design Log +## 2026-08-21 — Bound role control items completed + +- Completed US-017 and the reopened US-015 and US-016 criteria with configurable owner-bound Tyrant and Vigilante control items that open their respective panels. +- Role items are reconciled on login and periodically after role transitions, deduplicated, removed after role loss, and recoverable through `/tyrant item` or `/vigilante item` without dropping overflow. +- Drop, pickup, container, drag, hopper, placement, dispenser, item-frame, death, stale-owner, and stale-role paths prevent transfer or remove invalid items. +- Added default `Tyrant Control` Nether Star and `Vigilante Control` Compass settings and validation. +- Verified role policy, delayed reconciliation, panel interaction, command recovery, settings defaults, compiler warnings, tests, and packaging with `./gradlew clean check jar`. + +## 2026-08-21 — Bound role control item implementation started + +- US-015, US-016, and US-017 begin a test-first implementation of configurable owner-bound Tyrant and Vigilante control items. +- Delivery, command recovery, interaction, role validation, transfer prevention, role-loss cleanup, and duplicate resistance will build on the existing bound-item patterns. + +## 2026-08-21 — Bound role control items planned + +- Added US-017 for secure owner-bound Tyrant and Vigilante inventory items that open the corresponding control panels. +- The Tyrant item defaults to a Nether Star named `Tyrant Control`, and the Vigilante item defaults to a Compass named `Vigilante Control`; both material and name are configurable. +- Planned automatic role-assignment and login delivery, command recovery, full transfer prevention, removal on role loss or inventory departure, and duplicate resistance. +- Reopened US-015 and US-016 in the backlog to track control-panel access through the new role items. + ## 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. diff --git a/design/user-stories/index.md b/design/user-stories/index.md index caded5a..4cc618d 100644 --- a/design/user-stories/index.md +++ b/design/user-stories/index.md @@ -16,3 +16,4 @@ 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) +17. [US-017: Use bound role control items](us-017-use-bound-role-control-items.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 index 844a224..c15144e 100644 --- a/design/user-stories/us-015-manage-tyrant-control-panel.md +++ b/design/user-stories/us-015-manage-tyrant-control-panel.md @@ -31,6 +31,7 @@ As the **Tyrant**, I want a control panel for my reign so that I can inspect pro - [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. +- [x] The active Tyrant can open the control panel by using the bound Tyrant control item. ## Validation @@ -42,3 +43,4 @@ Automated tests verify command entry points, active-Tyrant authorization, author - [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) +- [Use bound role control items](us-017-use-bound-role-control-items.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 index a56c282..8c63b53 100644 --- a/design/user-stories/us-016-manage-vigilante-control-panel.md +++ b/design/user-stories/us-016-manage-vigilante-control-panel.md @@ -33,6 +33,7 @@ As the **Vigilante**, I want a control panel for managing Followers so that I ca - [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. +- [x] The active Vigilante can open the control panel by using the bound Vigilante control item. ## Validation @@ -44,3 +45,4 @@ Automated tests verify default and explicit command entry points, active-Vigilan - [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) +- [Use bound role control items](us-017-use-bound-role-control-items.md) diff --git a/design/user-stories/us-017-use-bound-role-control-items.md b/design/user-stories/us-017-use-bound-role-control-items.md new file mode 100644 index 0000000..bf4de42 --- /dev/null +++ b/design/user-stories/us-017-use-bound-role-control-items.md @@ -0,0 +1,43 @@ +--- +type: User Story +title: "US-017: Use bound role control items" +description: Give the active Tyrant and Vigilante secure inventory items that open their control panels. +status: done +--- + +# US-017: Use bound role control items + +As a **Tyrant or Vigilante**, I want a bound role item that opens my control panel so that I can access role controls without typing a command. + +## Acceptance criteria + +- [x] A player receives the appropriate control item when becoming the active Tyrant or Vigilante. +- [x] The Tyrant control item opens the Tyrant control panel when used. +- [x] The Vigilante control item opens the Vigilante control panel when used. +- [x] The Tyrant control item defaults to a Nether Star named `Tyrant Control`. +- [x] The Vigilante control item defaults to a Compass named `Vigilante Control`. +- [x] Each item's material and name are configurable, and hidden metadata identifies its owner, associated role, and the plugin. +- [x] Only the current role holder identified by the item's metadata can use the item. +- [x] A former role holder or another player cannot use or retain the item. +- [x] `/tyrant item` restores a missing Tyrant control item in addition to existing eligible class items. +- [x] `/vigilante item` restores a missing Vigilante control item. +- [x] Recovery commands cannot create duplicate control items. +- [x] Using a recovery command while the inventory is full does not drop the item and explains how to retry. +- [x] A role control item cannot be placed, crafted with, stored, framed, dispensed, transferred, or moved into another inventory. +- [x] Dropping a role control item removes it immediately rather than creating a transferable dropped item. +- [x] If a role control item leaves its owner's inventory during death or another inventory event, it is removed and may be recovered after respawn. +- [x] A missing role control item is restored automatically on login or role assignment when inventory space is available. +- [x] Every copy is removed immediately when the player loses the associated role. +- [x] Repeated commands, role transitions, death, logout, restart, and concurrent inventory events cannot duplicate role control items. +- [x] Existing `/tyrant`, `/tyrant menu`, `/vigilante`, and `/vigilante menu` control-panel access remains available. + +## Validation + +Automated tests verify role eligibility, paused-role retention, reconciliation and recovery, panel activation, role-specific commands, and configuration defaults. Inventory listeners cover transfer and departure paths, and the complete `./gradlew clean check jar` lifecycle passes. + +## Related + +- [Manage a reign through the Tyrant control panel](us-015-manage-tyrant-control-panel.md) +- [Manage Followers through the Vigilante control panel](us-016-manage-vigilante-control-panel.md) +- [Use class ability items](us-014-use-class-ability-items.md) +- [Configure and persist game state](us-012-configure-and-persist-state.md) diff --git a/src/main/java/games/dmg/spigottyrant/BukkitRoleControlItemService.java b/src/main/java/games/dmg/spigottyrant/BukkitRoleControlItemService.java new file mode 100644 index 0000000..27207f5 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/BukkitRoleControlItemService.java @@ -0,0 +1,146 @@ +package games.dmg.spigottyrant; + +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.bukkit.ChatColor; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataType; +import org.bukkit.plugin.Plugin; + +public final class BukkitRoleControlItemService implements RoleControlItemService { + private final NamespacedKey roleKey; + private final NamespacedKey ownerKey; + private final PluginSettings settings; + + public BukkitRoleControlItemService(Plugin plugin, PluginSettings settings) { + roleKey = new NamespacedKey(plugin, "role-control"); + ownerKey = new NamespacedKey(plugin, "role-control-owner"); + this.settings = settings; + } + + @Override + public void recover(Player player, GameState game) { + Optional required = RoleControlItemPolicy.requiredRole( + game, player.getUniqueId() + ); + if (required.isEmpty() || contains(player, required.orElseThrow())) { + return; + } + Map leftovers = player.getInventory().addItem( + create(player.getUniqueId(), required.orElseThrow()) + ); + if (!leftovers.isEmpty()) { + String command = required.orElseThrow() == RoleControl.TYRANT + ? "/tyrant item" : "/vigilante item"; + player.sendMessage(ChatColor.RED + + "Your inventory is full. Make room and use " + command + "."); + } + } + + @Override + public boolean isBoundRoleControlItem(ItemStack item) { + return role(item).isPresent() && owner(item).isPresent(); + } + + @Override + public Optional role(ItemStack item) { + String value = metadata(item, roleKey); + if (value == null) { + return Optional.empty(); + } + try { + return Optional.of(RoleControl.valueOf(value)); + } catch (IllegalArgumentException exception) { + return Optional.empty(); + } + } + + @Override + public Optional owner(ItemStack item) { + String value = metadata(item, ownerKey); + if (value == null) { + return Optional.empty(); + } + try { + return Optional.of(UUID.fromString(value)); + } catch (IllegalArgumentException exception) { + return Optional.empty(); + } + } + + @Override + public void removeInvalid(Player player, GameState game) { + Optional required = RoleControlItemPolicy.requiredRole( + game, player.getUniqueId() + ); + boolean retained = false; + ItemStack[] contents = player.getInventory().getContents(); + for (int index = 0; index < contents.length; index++) { + ItemStack item = contents[index]; + if (!isBoundRoleControlItem(item)) { + continue; + } + boolean valid = !retained + && owner(item).filter(player.getUniqueId()::equals).isPresent() + && role(item).equals(required); + if (valid) { + retained = true; + } else { + player.getInventory().setItem(index, null); + } + } + } + + @Override + public void removeAll(Player player) { + ItemStack[] contents = player.getInventory().getContents(); + for (int index = 0; index < contents.length; index++) { + if (isBoundRoleControlItem(contents[index])) { + player.getInventory().setItem(index, null); + } + } + } + + private ItemStack create(UUID ownerId, RoleControl role) { + PluginSettings.AbilityItemSettings configured = role == RoleControl.TYRANT + ? settings.tyrantControlItem() : settings.vigilanteControlItem(); + Material material = Material.matchMaterial(configured.material()); + if (material == null) { + throw new IllegalStateException("Configured role control material is unavailable"); + } + ItemStack item = new ItemStack(material); + ItemMeta meta = item.getItemMeta(); + meta.setDisplayName(ChatColor.GOLD + configured.name()); + meta.getPersistentDataContainer().set( + roleKey, PersistentDataType.STRING, role.name() + ); + meta.getPersistentDataContainer().set( + ownerKey, PersistentDataType.STRING, ownerId.toString() + ); + item.setItemMeta(meta); + return item; + } + + private boolean contains(Player player, RoleControl role) { + for (ItemStack item : player.getInventory().getContents()) { + if (owner(item).filter(player.getUniqueId()::equals).isPresent() + && role(item).filter(role::equals).isPresent()) { + return true; + } + } + return false; + } + + private static String metadata(ItemStack item, NamespacedKey key) { + if (item == null || !item.hasItemMeta()) { + return null; + } + return item.getItemMeta().getPersistentDataContainer() + .get(key, PersistentDataType.STRING); + } +} diff --git a/src/main/java/games/dmg/spigottyrant/PluginSettings.java b/src/main/java/games/dmg/spigottyrant/PluginSettings.java index 5c2157e..e194fba 100644 --- a/src/main/java/games/dmg/spigottyrant/PluginSettings.java +++ b/src/main/java/games/dmg/spigottyrant/PluginSettings.java @@ -39,6 +39,8 @@ public record PluginSettings( AbilityItemSettings fixerItem, AbilityItemSettings tamerItem, AbilityItemSettings capturedMobItem, + AbilityItemSettings tyrantControlItem, + AbilityItemSettings vigilanteControlItem, String recoveryCommand, String inventoryFullMessage, boolean freezeTimersWhilePaused @@ -78,6 +80,10 @@ public record PluginSettings( fixerItem = Objects.requireNonNull(fixerItem, "fixerItem"); tamerItem = Objects.requireNonNull(tamerItem, "tamerItem"); capturedMobItem = Objects.requireNonNull(capturedMobItem, "capturedMobItem"); + tyrantControlItem = Objects.requireNonNull(tyrantControlItem, "tyrantControlItem"); + vigilanteControlItem = Objects.requireNonNull( + vigilanteControlItem, "vigilanteControlItem" + ); recoveryCommand = requireText(recoveryCommand, "recovery-command"); inventoryFullMessage = requireText(inventoryFullMessage, "inventory-full-message"); } @@ -114,6 +120,14 @@ public record PluginSettings( item(values, "fixer-item", new AbilityItemSettings("STICK", "Fixer's Wrench")), item(values, "tamer-item", new AbilityItemSettings("FISHING_ROD", "Tamer's Lead")), item(values, "captured-mob-item", new AbilityItemSettings("PAPER", "Captured Mob")), + item( + values, "tyrant-control-item", + new AbilityItemSettings("NETHER_STAR", "Tyrant Control") + ), + item( + values, "vigilante-control-item", + new AbilityItemSettings("COMPASS", "Vigilante Control") + ), string(values, "recovery-command", "/tyrant item"), string( values, diff --git a/src/main/java/games/dmg/spigottyrant/PluginSettingsValidator.java b/src/main/java/games/dmg/spigottyrant/PluginSettingsValidator.java index 561529c..8e663ca 100644 --- a/src/main/java/games/dmg/spigottyrant/PluginSettingsValidator.java +++ b/src/main/java/games/dmg/spigottyrant/PluginSettingsValidator.java @@ -12,6 +12,8 @@ public final class PluginSettingsValidator { validateItem(settings.fixerItem(), "fixer-item"); validateItem(settings.tamerItem(), "tamer-item"); validateItem(settings.capturedMobItem(), "captured-mob-item"); + validateItem(settings.tyrantControlItem(), "tyrant-control-item"); + validateItem(settings.vigilanteControlItem(), "vigilante-control-item"); for (String entityType : settings.deniedMobTypes()) { try { EntityType.valueOf(entityType); diff --git a/src/main/java/games/dmg/spigottyrant/RoleControl.java b/src/main/java/games/dmg/spigottyrant/RoleControl.java new file mode 100644 index 0000000..39f1225 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/RoleControl.java @@ -0,0 +1,6 @@ +package games.dmg.spigottyrant; + +public enum RoleControl { + TYRANT, + VIGILANTE +} diff --git a/src/main/java/games/dmg/spigottyrant/RoleControlItemListener.java b/src/main/java/games/dmg/spigottyrant/RoleControlItemListener.java new file mode 100644 index 0000000..a9b3f47 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/RoleControlItemListener.java @@ -0,0 +1,146 @@ +package games.dmg.spigottyrant; + +import org.bukkit.entity.ItemFrame; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.block.Action; +import org.bukkit.event.block.BlockDispenseEvent; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.entity.EntityPickupItemEvent; +import org.bukkit.event.entity.PlayerDeathEvent; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.event.inventory.InventoryMoveItemEvent; +import org.bukkit.event.player.PlayerDropItemEvent; +import org.bukkit.event.player.PlayerInteractEntityEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.inventory.ItemStack; + +public final class RoleControlItemListener implements Listener { + private final TyrantStateManager stateManager; + private final RoleControlItemService items; + private final TyrantControlPanel tyrantPanel; + private final VigilanteControlPanel vigilantePanel; + + public RoleControlItemListener( + TyrantStateManager stateManager, + RoleControlItemService items, + TyrantControlPanel tyrantPanel, + VigilanteControlPanel vigilantePanel + ) { + this.stateManager = stateManager; + this.items = items; + this.tyrantPanel = tyrantPanel; + this.vigilantePanel = vigilantePanel; + } + + @EventHandler + public void onInteract(PlayerInteractEvent event) { + ItemStack item = event.getItem(); + if (!items.isBoundRoleControlItem(item) + || event.getAction() != Action.RIGHT_CLICK_AIR + && event.getAction() != Action.RIGHT_CLICK_BLOCK) { + return; + } + event.setCancelled(true); + Player player = event.getPlayer(); + GameState game = stateManager.game(); + java.util.Optional required = RoleControlItemPolicy.requiredRole( + game, player.getUniqueId() + ); + boolean valid = items.owner(item).filter(player.getUniqueId()::equals).isPresent() + && items.role(item).equals(required) + && game.lifecycle() == GameLifecycle.RUNNING; + if (!valid) { + items.removeInvalid(player, game); + player.sendMessage(org.bukkit.ChatColor.RED + + "That role control item is no longer valid."); + return; + } + if (required.orElseThrow() == RoleControl.TYRANT) { + tyrantPanel.open(player); + } else { + vigilantePanel.open(player); + } + } + + @EventHandler + public void onJoin(PlayerJoinEvent event) { + items.removeInvalid(event.getPlayer(), stateManager.game()); + items.recover(event.getPlayer(), stateManager.game()); + } + + @EventHandler + public void onDrop(PlayerDropItemEvent event) { + if (items.isBoundRoleControlItem(event.getItemDrop().getItemStack())) { + event.getItemDrop().remove(); + } + } + + @EventHandler + public void onPickup(EntityPickupItemEvent event) { + if (items.isBoundRoleControlItem(event.getItem().getItemStack())) { + event.setCancelled(true); + event.getItem().remove(); + } + } + + @EventHandler + public void onInventoryClick(InventoryClickEvent event) { + if (items.isBoundRoleControlItem(event.getCurrentItem()) + || items.isBoundRoleControlItem(event.getCursor())) { + if (!(event.getWhoClicked() instanceof Player player) + || event.isShiftClick() + || event.getClickedInventory() == null + || !event.getClickedInventory().equals(player.getInventory())) { + event.setCancelled(true); + } + } + } + + @EventHandler + public void onInventoryDrag(InventoryDragEvent event) { + if (items.isBoundRoleControlItem(event.getOldCursor()) + || event.getNewItems().values().stream() + .anyMatch(items::isBoundRoleControlItem)) { + event.setCancelled(true); + } + } + + @EventHandler + public void onInventoryMove(InventoryMoveItemEvent event) { + if (items.isBoundRoleControlItem(event.getItem())) { + event.setCancelled(true); + } + } + + @EventHandler + public void onPlace(BlockPlaceEvent event) { + if (items.isBoundRoleControlItem(event.getItemInHand())) { + event.setCancelled(true); + } + } + + @EventHandler + public void onDispense(BlockDispenseEvent event) { + if (items.isBoundRoleControlItem(event.getItem())) { + event.setCancelled(true); + } + } + + @EventHandler + public void onFrame(PlayerInteractEntityEvent event) { + if (event.getRightClicked() instanceof ItemFrame + && items.isBoundRoleControlItem(event.getPlayer().getInventory() + .getItem(event.getHand()))) { + event.setCancelled(true); + } + } + + @EventHandler + public void onDeath(PlayerDeathEvent event) { + event.getDrops().removeIf(items::isBoundRoleControlItem); + } +} diff --git a/src/main/java/games/dmg/spigottyrant/RoleControlItemPolicy.java b/src/main/java/games/dmg/spigottyrant/RoleControlItemPolicy.java new file mode 100644 index 0000000..80fdad5 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/RoleControlItemPolicy.java @@ -0,0 +1,19 @@ +package games.dmg.spigottyrant; + +import java.util.Optional; +import java.util.UUID; + +public final class RoleControlItemPolicy { + private RoleControlItemPolicy() { + } + + public static Optional requiredRole(GameState game, UUID playerId) { + if (game.tyrantId().filter(playerId::equals).isPresent()) { + return Optional.of(RoleControl.TYRANT); + } + if (game.vigilanteId().filter(playerId::equals).isPresent()) { + return Optional.of(RoleControl.VIGILANTE); + } + return Optional.empty(); + } +} diff --git a/src/main/java/games/dmg/spigottyrant/RoleControlItemRefreshTask.java b/src/main/java/games/dmg/spigottyrant/RoleControlItemRefreshTask.java new file mode 100644 index 0000000..2b4d011 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/RoleControlItemRefreshTask.java @@ -0,0 +1,31 @@ +package games.dmg.spigottyrant; + +import org.bukkit.Server; +import org.bukkit.entity.Player; + +public final class RoleControlItemRefreshTask implements Runnable { + private final TyrantStateManager stateManager; + private final RoleControlItemService items; + private final Server server; + + public RoleControlItemRefreshTask( + TyrantStateManager stateManager, + RoleControlItemService items, + Server server + ) { + this.stateManager = stateManager; + this.items = items; + this.server = server; + } + + @Override + public void run() { + GameState game = stateManager.game(); + for (Player player : server.getOnlinePlayers()) { + items.removeInvalid(player, game); + if (!player.isDead()) { + items.recover(player, game); + } + } + } +} diff --git a/src/main/java/games/dmg/spigottyrant/RoleControlItemService.java b/src/main/java/games/dmg/spigottyrant/RoleControlItemService.java new file mode 100644 index 0000000..f4f061c --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/RoleControlItemService.java @@ -0,0 +1,20 @@ +package games.dmg.spigottyrant; + +import java.util.Optional; +import java.util.UUID; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +public interface RoleControlItemService { + void recover(Player player, GameState game); + + boolean isBoundRoleControlItem(ItemStack item); + + Optional role(ItemStack item); + + Optional owner(ItemStack item); + + void removeInvalid(Player player, GameState game); + + void removeAll(Player player); +} diff --git a/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java b/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java index c8740f9..789d20e 100644 --- a/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java +++ b/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java @@ -54,6 +54,9 @@ public final class SpigotTyrantPlugin extends JavaPlugin { FollowerService followers = new FollowerService(); AbilityReadinessService readiness = new AbilityReadinessService(); AbilityItemService abilityItems = new BukkitAbilityItemService(this, settings); + RoleControlItemService roleControlItems = new BukkitRoleControlItemService( + this, settings + ); CapturedMobItemService capturedMobItems = new BukkitCapturedMobItemService( this, settings ); @@ -109,11 +112,13 @@ public final class SpigotTyrantPlugin extends JavaPlugin { tyrantPresentation, abilityItems, new TyrantAbilityService(settings.rosterIntelligenceCooldown()), - controlPanel + controlPanel, + roleControlItems )); Objects.requireNonNull(getCommand("vigilante"), "Missing vigilante metadata") .setExecutor(new VigilanteCommand( - stateManager, followers, onlinePlayers, vigilanteControlPanel + stateManager, followers, onlinePlayers, vigilanteControlPanel, + roleControlItems )); Objects.requireNonNull(getCommand("tyrantadmin"), "Missing tyrantadmin metadata") .setExecutor(new TyrantAdminCommand( @@ -181,6 +186,12 @@ public final class SpigotTyrantPlugin extends JavaPlugin { new BoundItemListener(abilityItems), this ); + getServer().getPluginManager().registerEvents( + new RoleControlItemListener( + stateManager, roleControlItems, controlPanel, vigilanteControlPanel + ), + this + ); getServer().getPluginManager().registerEvents( new TamerListener( stateManager, abilityItems, capturedMobItems, capturedMobs, @@ -229,6 +240,12 @@ public final class SpigotTyrantPlugin extends JavaPlugin { 20L, 20L ); + getServer().getScheduler().runTaskTimer( + this, + new RoleControlItemRefreshTask(stateManager, roleControlItems, getServer()), + 1L, + 10L + ); getServer().getScheduler().runTaskTimer(this, stateManager::saveIfDirty, 600L, 600L); getLogger().info("Spigot Tyrant enabled."); } diff --git a/src/main/java/games/dmg/spigottyrant/TyrantCommand.java b/src/main/java/games/dmg/spigottyrant/TyrantCommand.java index e874e8a..432b77d 100644 --- a/src/main/java/games/dmg/spigottyrant/TyrantCommand.java +++ b/src/main/java/games/dmg/spigottyrant/TyrantCommand.java @@ -21,6 +21,7 @@ public final class TyrantCommand implements CommandExecutor { private final AbilityItemService abilityItems; private final TyrantAbilityService tyrantAbilities; private final TyrantControlPanel controlPanel; + private final RoleControlItemService roleControlItems; public TyrantCommand( TyrantStateManager stateManager, @@ -44,7 +45,21 @@ public final class TyrantCommand implements CommandExecutor { stateManager, progression, new ClassAssignmentService(), new BukkitOnlinePlayerDirectory(), Clock.systemUTC(), new ParticipationService(Duration.ofDays(7)), null, null, null, - new TyrantAbilityService(Duration.ofHours(24)), controlPanel + new TyrantAbilityService(Duration.ofHours(24)), controlPanel, null + ); + } + + public TyrantCommand( + TyrantStateManager stateManager, + TyrantProgressionService progression, + TyrantControlPanel controlPanel, + RoleControlItemService roleControlItems + ) { + this( + stateManager, progression, new ClassAssignmentService(), + new BukkitOnlinePlayerDirectory(), Clock.systemUTC(), + new ParticipationService(Duration.ofDays(7)), null, null, null, + new TyrantAbilityService(Duration.ofHours(24)), controlPanel, roleControlItems ); } @@ -58,7 +73,8 @@ 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)), TyrantCommand::unavailablePanel + new TyrantAbilityService(Duration.ofHours(24)), TyrantCommand::unavailablePanel, + null ); } @@ -77,7 +93,7 @@ public final class TyrantCommand implements CommandExecutor { this( stateManager, progression, assignments, onlinePlayers, clock, participation, relinquishment, presentation, abilityItems, tyrantAbilities, - TyrantCommand::unavailablePanel + TyrantCommand::unavailablePanel, null ); } @@ -93,6 +109,26 @@ public final class TyrantCommand implements CommandExecutor { AbilityItemService abilityItems, TyrantAbilityService tyrantAbilities, TyrantControlPanel controlPanel + ) { + this( + stateManager, progression, assignments, onlinePlayers, clock, participation, + relinquishment, presentation, abilityItems, tyrantAbilities, controlPanel, null + ); + } + + public TyrantCommand( + TyrantStateManager stateManager, + TyrantProgressionService progression, + ClassAssignmentService assignments, + OnlinePlayerDirectory onlinePlayers, + Clock clock, + ParticipationService participation, + RoleRelinquishmentService relinquishment, + TyrantPresentation presentation, + AbilityItemService abilityItems, + TyrantAbilityService tyrantAbilities, + TyrantControlPanel controlPanel, + RoleControlItemService roleControlItems ) { this.stateManager = stateManager; this.progression = progression; @@ -105,6 +141,7 @@ public final class TyrantCommand implements CommandExecutor { this.abilityItems = abilityItems; this.tyrantAbilities = tyrantAbilities; this.controlPanel = controlPanel; + this.roleControlItems = roleControlItems; } @Override @@ -308,14 +345,21 @@ public final class TyrantCommand implements CommandExecutor { } private void recoverItems(Player player) { - if (abilityItems == null) { - player.sendMessage(ChatColor.RED + "Ability item recovery is unavailable."); + if (abilityItems == null && roleControlItems == null) { + player.sendMessage(ChatColor.RED + "Item recovery is unavailable."); return; } - PlayerState state = stateManager.player(player.getUniqueId(), player.getName()); - abilityItems.recover(player, state); + if (abilityItems != null) { + PlayerState state = stateManager.player(player.getUniqueId(), player.getName()); + abilityItems.recover(player, state); + } + GameState game = stateManager.game(); + if (roleControlItems != null + && game.tyrantId().filter(player.getUniqueId()::equals).isPresent()) { + roleControlItems.recover(player, game); + } player.sendMessage(ChatColor.YELLOW - + "Recovered every currently ready missing class item that could fit."); + + "Recovered every currently available missing Tyrant item that could fit."); } private void relinquish(Player player, String[] arguments) { diff --git a/src/main/java/games/dmg/spigottyrant/VigilanteCommand.java b/src/main/java/games/dmg/spigottyrant/VigilanteCommand.java index bea7aaa..386dfd8 100644 --- a/src/main/java/games/dmg/spigottyrant/VigilanteCommand.java +++ b/src/main/java/games/dmg/spigottyrant/VigilanteCommand.java @@ -12,6 +12,7 @@ public final class VigilanteCommand implements CommandExecutor { private final FollowerService followers; private final OnlinePlayerDirectory onlinePlayers; private final VigilanteControlPanel controlPanel; + private final RoleControlItemService roleControlItems; public VigilanteCommand( TyrantStateManager stateManager, @@ -21,7 +22,8 @@ public final class VigilanteCommand implements CommandExecutor { this( stateManager, followers, onlinePlayers, player -> player.sendMessage(ChatColor.RED - + "The Vigilante control panel is unavailable.") + + "The Vigilante control panel is unavailable."), + null ); } @@ -30,11 +32,22 @@ public final class VigilanteCommand implements CommandExecutor { FollowerService followers, OnlinePlayerDirectory onlinePlayers, VigilanteControlPanel controlPanel + ) { + this(stateManager, followers, onlinePlayers, controlPanel, null); + } + + public VigilanteCommand( + TyrantStateManager stateManager, + FollowerService followers, + OnlinePlayerDirectory onlinePlayers, + VigilanteControlPanel controlPanel, + RoleControlItemService roleControlItems ) { this.stateManager = stateManager; this.followers = followers; this.onlinePlayers = onlinePlayers; this.controlPanel = controlPanel; + this.roleControlItems = roleControlItems; } @Override @@ -53,6 +66,16 @@ public final class VigilanteCommand implements CommandExecutor { controlPanel.open(player); return true; } + if (arguments.length == 1 && arguments[0].equalsIgnoreCase("item")) { + if (roleControlItems == null) { + player.sendMessage(ChatColor.RED + "Role item recovery is unavailable."); + } else { + roleControlItems.recover(player, stateManager.game()); + player.sendMessage(ChatColor.YELLOW + + "Recovered the missing Vigilante control item if it could fit."); + } + return true; + } FollowerResult result; Player target = null; if (arguments.length == 1 && arguments[0].equalsIgnoreCase("accept")) { @@ -81,7 +104,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/resources/config.yml b/src/main/resources/config.yml index 67c326d..73e14a9 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -50,6 +50,14 @@ tyrant-resistance-level: 1 follower-strength-cap: 5 follower-resistance-cap: 4 +# Role control items +tyrant-control-item: + material: NETHER_STAR + name: Tyrant Control +vigilante-control-item: + material: COMPASS + name: Vigilante Control + # Bound item recovery and delivery messages recovery-command: /tyrant item inventory-full-message: Your inventory is full. Make room and use /tyrant item. diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 67e2785..75cd5aa 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/PluginSettingsTest.java b/src/test/java/games/dmg/spigottyrant/PluginSettingsTest.java index e9ff215..de0dddb 100644 --- a/src/test/java/games/dmg/spigottyrant/PluginSettingsTest.java +++ b/src/test/java/games/dmg/spigottyrant/PluginSettingsTest.java @@ -43,6 +43,10 @@ final class PluginSettingsTest { assertEquals("STICK", settings.assassinItem().material()); assertEquals("Assassin Cloak", settings.assassinItem().name()); assertEquals("FISHING_ROD", settings.tamerItem().material()); + assertEquals("NETHER_STAR", settings.tyrantControlItem().material()); + assertEquals("Tyrant Control", settings.tyrantControlItem().name()); + assertEquals("COMPASS", settings.vigilanteControlItem().material()); + assertEquals("Vigilante Control", settings.vigilanteControlItem().name()); assertEquals("/tyrant item", settings.recoveryCommand()); assertEquals("Your inventory is full. Make room and use /tyrant item.", settings.inventoryFullMessage()); diff --git a/src/test/java/games/dmg/spigottyrant/RoleControlItemListenerTest.java b/src/test/java/games/dmg/spigottyrant/RoleControlItemListenerTest.java new file mode 100644 index 0000000..e541575 --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/RoleControlItemListenerTest.java @@ -0,0 +1,89 @@ +package games.dmg.spigottyrant; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.bukkit.entity.Item; +import org.bukkit.entity.Player; +import org.bukkit.event.block.Action; +import org.bukkit.event.inventory.InventoryMoveItemEvent; +import org.bukkit.event.player.PlayerDropItemEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.Test; + +final class RoleControlItemListenerTest { + @Test + void droppedRoleControlItemVanishes() { + ItemStack item = mock(ItemStack.class); + Item dropped = mock(Item.class); + when(dropped.getItemStack()).thenReturn(item); + PlayerDropItemEvent event = mock(PlayerDropItemEvent.class); + when(event.getItemDrop()).thenReturn(dropped); + RoleControlItemService items = mock(RoleControlItemService.class); + when(items.isBoundRoleControlItem(item)).thenReturn(true); + RoleControlItemListener listener = listener(items); + + listener.onDrop(event); + + verify(dropped).remove(); + } + + @Test + void automatedInventoryTransferIsBlocked() { + ItemStack item = mock(ItemStack.class); + InventoryMoveItemEvent event = mock(InventoryMoveItemEvent.class); + when(event.getItem()).thenReturn(item); + RoleControlItemService items = mock(RoleControlItemService.class); + when(items.isBoundRoleControlItem(item)).thenReturn(true); + RoleControlItemListener listener = listener(items); + + listener.onInventoryMove(event); + + verify(event).setCancelled(true); + } + + @Test + void currentTyrantUsesBoundItemToOpenControlPanel() { + 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, 0, Set.of() + ); + TyrantStateManager manager = mock(TyrantStateManager.class); + when(manager.game()).thenReturn(game); + Player tyrant = mock(Player.class); + when(tyrant.getUniqueId()).thenReturn(tyrantId); + ItemStack item = mock(ItemStack.class); + PlayerInteractEvent event = mock(PlayerInteractEvent.class); + when(event.getPlayer()).thenReturn(tyrant); + when(event.getItem()).thenReturn(item); + when(event.getAction()).thenReturn(Action.RIGHT_CLICK_AIR); + RoleControlItemService items = mock(RoleControlItemService.class); + when(items.isBoundRoleControlItem(item)).thenReturn(true); + when(items.owner(item)).thenReturn(Optional.of(tyrantId)); + when(items.role(item)).thenReturn(Optional.of(RoleControl.TYRANT)); + TyrantControlPanel tyrantPanel = mock(TyrantControlPanel.class); + RoleControlItemListener listener = new RoleControlItemListener( + manager, items, tyrantPanel, mock(VigilanteControlPanel.class) + ); + + listener.onInteract(event); + + verify(event).setCancelled(true); + verify(tyrantPanel).open(tyrant); + } + + private static RoleControlItemListener listener(RoleControlItemService items) { + return new RoleControlItemListener( + mock(TyrantStateManager.class), items, mock(TyrantControlPanel.class), + mock(VigilanteControlPanel.class) + ); + } +} diff --git a/src/test/java/games/dmg/spigottyrant/RoleControlItemPolicyTest.java b/src/test/java/games/dmg/spigottyrant/RoleControlItemPolicyTest.java new file mode 100644 index 0000000..7322d17 --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/RoleControlItemPolicyTest.java @@ -0,0 +1,41 @@ +package games.dmg.spigottyrant; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.time.Duration; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +final class RoleControlItemPolicyTest { + @Test + void runningRoleHoldersRequireOnlyTheirOwnControlItem() { + UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111"); + UUID vigilanteId = UUID.fromString("22222222-2222-2222-2222-222222222222"); + GameState game = new GameState( + GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.of(vigilanteId), + Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO, + 0, 0, Set.of() + ); + + assertEquals(Optional.of(RoleControl.TYRANT), + RoleControlItemPolicy.requiredRole(game, tyrantId)); + assertEquals(Optional.of(RoleControl.VIGILANTE), + RoleControlItemPolicy.requiredRole(game, vigilanteId)); + assertEquals(Optional.empty(), RoleControlItemPolicy.requiredRole(game, UUID.randomUUID())); + } + + @Test + void pausedRoleHolderRetainsControlItem() { + UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111"); + GameState game = new GameState( + GameLifecycle.PAUSED, Optional.of(tyrantId), Optional.empty(), + Optional.empty(), Optional.empty(), Optional.of(java.time.Instant.EPOCH), + Duration.ZERO, 0, 0, Set.of() + ); + + assertEquals(Optional.of(RoleControl.TYRANT), + RoleControlItemPolicy.requiredRole(game, tyrantId)); + } +} diff --git a/src/test/java/games/dmg/spigottyrant/RoleControlItemRefreshTaskTest.java b/src/test/java/games/dmg/spigottyrant/RoleControlItemRefreshTaskTest.java new file mode 100644 index 0000000..260d76d --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/RoleControlItemRefreshTaskTest.java @@ -0,0 +1,37 @@ +package games.dmg.spigottyrant; + +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.bukkit.Server; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +final class RoleControlItemRefreshTaskTest { + @Test + void reconciliationRemovesInvalidCopiesAndRecoversRequiredRoleItem() { + 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, 0, Set.of() + ); + TyrantStateManager manager = mock(TyrantStateManager.class); + when(manager.game()).thenReturn(game); + Player tyrant = mock(Player.class); + Server server = mock(Server.class); + doReturn(Set.of(tyrant)).when(server).getOnlinePlayers(); + RoleControlItemService items = mock(RoleControlItemService.class); + + new RoleControlItemRefreshTask(manager, items, server).run(); + + verify(items).removeInvalid(tyrant, game); + verify(items).recover(tyrant, game); + } +} diff --git a/src/test/java/games/dmg/spigottyrant/TyrantCommandTest.java b/src/test/java/games/dmg/spigottyrant/TyrantCommandTest.java index f3659cc..78a6fc2 100644 --- a/src/test/java/games/dmg/spigottyrant/TyrantCommandTest.java +++ b/src/test/java/games/dmg/spigottyrant/TyrantCommandTest.java @@ -43,6 +43,28 @@ final class TyrantCommandTest { verify(controlPanel, org.mockito.Mockito.times(2)).open(tyrant); } + @Test + void itemCommandRecoversTyrantRoleControlItem() { + 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, 0, Set.of() + ); + TyrantStateManager manager = mock(TyrantStateManager.class); + when(manager.game()).thenReturn(game); + Player tyrant = mock(Player.class); + when(tyrant.getUniqueId()).thenReturn(tyrantId); + RoleControlItemService roleItems = mock(RoleControlItemService.class); + TyrantCommand command = new TyrantCommand( + manager, new TyrantProgressionService(), mock(TyrantControlPanel.class), roleItems + ); + + command.onCommand(tyrant, mock(Command.class), "tyrant", new String[] {"item"}); + + verify(roleItems).recover(tyrant, game); + } + @Test void choicesClearlyIdentifyPurchasedAvailableAndUnavailableUnlocks() { UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111"); diff --git a/src/test/java/games/dmg/spigottyrant/VigilanteCommandTest.java b/src/test/java/games/dmg/spigottyrant/VigilanteCommandTest.java index a985cde..75c24c7 100644 --- a/src/test/java/games/dmg/spigottyrant/VigilanteCommandTest.java +++ b/src/test/java/games/dmg/spigottyrant/VigilanteCommandTest.java @@ -5,12 +5,39 @@ import static org.mockito.Mockito.times; 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.command.Command; import org.bukkit.entity.Player; import org.junit.jupiter.api.Test; final class VigilanteCommandTest { + @Test + void itemCommandRecoversVigilanteRoleControlItem() { + UUID vigilanteId = UUID.fromString("22222222-2222-2222-2222-222222222222"); + GameState game = new GameState( + GameLifecycle.RUNNING, + Optional.of(UUID.fromString("11111111-1111-1111-1111-111111111111")), + Optional.of(vigilanteId), Optional.empty(), Optional.empty(), Optional.empty(), + Duration.ZERO, 0, 0, Set.of() + ); + TyrantStateManager manager = mock(TyrantStateManager.class); + when(manager.game()).thenReturn(game); + Player vigilante = mock(Player.class); + when(vigilante.getUniqueId()).thenReturn(vigilanteId); + RoleControlItemService roleItems = mock(RoleControlItemService.class); + VigilanteCommand command = new VigilanteCommand( + manager, new FollowerService(), mock(OnlinePlayerDirectory.class), + mock(VigilanteControlPanel.class), roleItems + ); + + command.onCommand(vigilante, mock(Command.class), "vigilante", new String[] {"item"}); + + verify(roleItems).recover(vigilante, game); + } + @Test void playerCanOpenControlPanelWithNoArgumentsOrMenuSubcommand() { UUID playerId = UUID.fromString("22222222-2222-2222-2222-222222222222");