feat(items): add bound role control items

This commit is contained in:
dmg
2026-08-21 21:28:32 -04:00
parent ec7d50c2b5
commit 0b202f588d
24 changed files with 777 additions and 13 deletions
+20
View File
@@ -6,6 +6,26 @@ description: Chronological record of material decisions affecting the Spigot Tyr
# Spigot Tyrant Design Log # 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 ## 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. - Completed US-016 and the reopened US-008 criteria with resistance overview, recruitment, Follower management, and confirmed role-relinquishment screens.
+1
View File
@@ -16,3 +16,4 @@
14. [US-014: Use class ability items](us-014-use-class-ability-items.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) 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) 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)
@@ -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] 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] 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] 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 ## Validation
@@ -42,3 +43,4 @@ Automated tests verify command entry points, active-Tyrant authorization, author
- [Assign unlocked classes](us-004-assign-unlocked-classes.md) - [Assign unlocked classes](us-004-assign-unlocked-classes.md)
- [Purchase and use Tyrant abilities](us-009-use-tyrant-abilities.md) - [Purchase and use Tyrant abilities](us-009-use-tyrant-abilities.md)
- [Inform players and manage participation](us-011-inform-and-manage-participation.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)
@@ -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] 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] 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] 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 ## 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) - [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) - [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) - [Role and class model](../concepts/role-and-class-model.md)
- [Use bound role control items](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)
@@ -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<RoleControl> required = RoleControlItemPolicy.requiredRole(
game, player.getUniqueId()
);
if (required.isEmpty() || contains(player, required.orElseThrow())) {
return;
}
Map<Integer, ItemStack> 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<RoleControl> 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<UUID> 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<RoleControl> 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);
}
}
@@ -39,6 +39,8 @@ public record PluginSettings(
AbilityItemSettings fixerItem, AbilityItemSettings fixerItem,
AbilityItemSettings tamerItem, AbilityItemSettings tamerItem,
AbilityItemSettings capturedMobItem, AbilityItemSettings capturedMobItem,
AbilityItemSettings tyrantControlItem,
AbilityItemSettings vigilanteControlItem,
String recoveryCommand, String recoveryCommand,
String inventoryFullMessage, String inventoryFullMessage,
boolean freezeTimersWhilePaused boolean freezeTimersWhilePaused
@@ -78,6 +80,10 @@ public record PluginSettings(
fixerItem = Objects.requireNonNull(fixerItem, "fixerItem"); fixerItem = Objects.requireNonNull(fixerItem, "fixerItem");
tamerItem = Objects.requireNonNull(tamerItem, "tamerItem"); tamerItem = Objects.requireNonNull(tamerItem, "tamerItem");
capturedMobItem = Objects.requireNonNull(capturedMobItem, "capturedMobItem"); capturedMobItem = Objects.requireNonNull(capturedMobItem, "capturedMobItem");
tyrantControlItem = Objects.requireNonNull(tyrantControlItem, "tyrantControlItem");
vigilanteControlItem = Objects.requireNonNull(
vigilanteControlItem, "vigilanteControlItem"
);
recoveryCommand = requireText(recoveryCommand, "recovery-command"); recoveryCommand = requireText(recoveryCommand, "recovery-command");
inventoryFullMessage = requireText(inventoryFullMessage, "inventory-full-message"); 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, "fixer-item", new AbilityItemSettings("STICK", "Fixer's Wrench")),
item(values, "tamer-item", new AbilityItemSettings("FISHING_ROD", "Tamer's Lead")), item(values, "tamer-item", new AbilityItemSettings("FISHING_ROD", "Tamer's Lead")),
item(values, "captured-mob-item", new AbilityItemSettings("PAPER", "Captured Mob")), 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, "recovery-command", "/tyrant item"),
string( string(
values, values,
@@ -12,6 +12,8 @@ public final class PluginSettingsValidator {
validateItem(settings.fixerItem(), "fixer-item"); validateItem(settings.fixerItem(), "fixer-item");
validateItem(settings.tamerItem(), "tamer-item"); validateItem(settings.tamerItem(), "tamer-item");
validateItem(settings.capturedMobItem(), "captured-mob-item"); validateItem(settings.capturedMobItem(), "captured-mob-item");
validateItem(settings.tyrantControlItem(), "tyrant-control-item");
validateItem(settings.vigilanteControlItem(), "vigilante-control-item");
for (String entityType : settings.deniedMobTypes()) { for (String entityType : settings.deniedMobTypes()) {
try { try {
EntityType.valueOf(entityType); EntityType.valueOf(entityType);
@@ -0,0 +1,6 @@
package games.dmg.spigottyrant;
public enum RoleControl {
TYRANT,
VIGILANTE
}
@@ -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<RoleControl> 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);
}
}
@@ -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<RoleControl> 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();
}
}
@@ -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);
}
}
}
}
@@ -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<RoleControl> role(ItemStack item);
Optional<UUID> owner(ItemStack item);
void removeInvalid(Player player, GameState game);
void removeAll(Player player);
}
@@ -54,6 +54,9 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
FollowerService followers = new FollowerService(); FollowerService followers = new FollowerService();
AbilityReadinessService readiness = new AbilityReadinessService(); AbilityReadinessService readiness = new AbilityReadinessService();
AbilityItemService abilityItems = new BukkitAbilityItemService(this, settings); AbilityItemService abilityItems = new BukkitAbilityItemService(this, settings);
RoleControlItemService roleControlItems = new BukkitRoleControlItemService(
this, settings
);
CapturedMobItemService capturedMobItems = new BukkitCapturedMobItemService( CapturedMobItemService capturedMobItems = new BukkitCapturedMobItemService(
this, settings this, settings
); );
@@ -109,11 +112,13 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
tyrantPresentation, tyrantPresentation,
abilityItems, abilityItems,
new TyrantAbilityService(settings.rosterIntelligenceCooldown()), new TyrantAbilityService(settings.rosterIntelligenceCooldown()),
controlPanel controlPanel,
roleControlItems
)); ));
Objects.requireNonNull(getCommand("vigilante"), "Missing vigilante metadata") Objects.requireNonNull(getCommand("vigilante"), "Missing vigilante metadata")
.setExecutor(new VigilanteCommand( .setExecutor(new VigilanteCommand(
stateManager, followers, onlinePlayers, vigilanteControlPanel stateManager, followers, onlinePlayers, vigilanteControlPanel,
roleControlItems
)); ));
Objects.requireNonNull(getCommand("tyrantadmin"), "Missing tyrantadmin metadata") Objects.requireNonNull(getCommand("tyrantadmin"), "Missing tyrantadmin metadata")
.setExecutor(new TyrantAdminCommand( .setExecutor(new TyrantAdminCommand(
@@ -181,6 +186,12 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
new BoundItemListener(abilityItems), new BoundItemListener(abilityItems),
this this
); );
getServer().getPluginManager().registerEvents(
new RoleControlItemListener(
stateManager, roleControlItems, controlPanel, vigilanteControlPanel
),
this
);
getServer().getPluginManager().registerEvents( getServer().getPluginManager().registerEvents(
new TamerListener( new TamerListener(
stateManager, abilityItems, capturedMobItems, capturedMobs, stateManager, abilityItems, capturedMobItems, capturedMobs,
@@ -229,6 +240,12 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
20L, 20L,
20L 20L
); );
getServer().getScheduler().runTaskTimer(
this,
new RoleControlItemRefreshTask(stateManager, roleControlItems, getServer()),
1L,
10L
);
getServer().getScheduler().runTaskTimer(this, stateManager::saveIfDirty, 600L, 600L); getServer().getScheduler().runTaskTimer(this, stateManager::saveIfDirty, 600L, 600L);
getLogger().info("Spigot Tyrant enabled."); getLogger().info("Spigot Tyrant enabled.");
} }
@@ -21,6 +21,7 @@ public final class TyrantCommand implements CommandExecutor {
private final AbilityItemService abilityItems; private final AbilityItemService abilityItems;
private final TyrantAbilityService tyrantAbilities; private final TyrantAbilityService tyrantAbilities;
private final TyrantControlPanel controlPanel; private final TyrantControlPanel controlPanel;
private final RoleControlItemService roleControlItems;
public TyrantCommand( public TyrantCommand(
TyrantStateManager stateManager, TyrantStateManager stateManager,
@@ -44,7 +45,21 @@ public final class TyrantCommand implements CommandExecutor {
stateManager, progression, new ClassAssignmentService(), stateManager, progression, new ClassAssignmentService(),
new BukkitOnlinePlayerDirectory(), Clock.systemUTC(), new BukkitOnlinePlayerDirectory(), Clock.systemUTC(),
new ParticipationService(Duration.ofDays(7)), null, null, null, 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( this(
stateManager, progression, assignments, onlinePlayers, clock, stateManager, progression, assignments, onlinePlayers, clock,
new ParticipationService(Duration.ofDays(7)), null, null, null, 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( this(
stateManager, progression, assignments, onlinePlayers, clock, participation, stateManager, progression, assignments, onlinePlayers, clock, participation,
relinquishment, presentation, abilityItems, tyrantAbilities, relinquishment, presentation, abilityItems, tyrantAbilities,
TyrantCommand::unavailablePanel TyrantCommand::unavailablePanel, null
); );
} }
@@ -93,6 +109,26 @@ public final class TyrantCommand implements CommandExecutor {
AbilityItemService abilityItems, AbilityItemService abilityItems,
TyrantAbilityService tyrantAbilities, TyrantAbilityService tyrantAbilities,
TyrantControlPanel controlPanel 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.stateManager = stateManager;
this.progression = progression; this.progression = progression;
@@ -105,6 +141,7 @@ public final class TyrantCommand implements CommandExecutor {
this.abilityItems = abilityItems; this.abilityItems = abilityItems;
this.tyrantAbilities = tyrantAbilities; this.tyrantAbilities = tyrantAbilities;
this.controlPanel = controlPanel; this.controlPanel = controlPanel;
this.roleControlItems = roleControlItems;
} }
@Override @Override
@@ -308,14 +345,21 @@ public final class TyrantCommand implements CommandExecutor {
} }
private void recoverItems(Player player) { private void recoverItems(Player player) {
if (abilityItems == null) { if (abilityItems == null && roleControlItems == null) {
player.sendMessage(ChatColor.RED + "Ability item recovery is unavailable."); player.sendMessage(ChatColor.RED + "Item recovery is unavailable.");
return; return;
} }
if (abilityItems != null) {
PlayerState state = stateManager.player(player.getUniqueId(), player.getName()); PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
abilityItems.recover(player, state); 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 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) { private void relinquish(Player player, String[] arguments) {
@@ -12,6 +12,7 @@ public final class VigilanteCommand implements CommandExecutor {
private final FollowerService followers; private final FollowerService followers;
private final OnlinePlayerDirectory onlinePlayers; private final OnlinePlayerDirectory onlinePlayers;
private final VigilanteControlPanel controlPanel; private final VigilanteControlPanel controlPanel;
private final RoleControlItemService roleControlItems;
public VigilanteCommand( public VigilanteCommand(
TyrantStateManager stateManager, TyrantStateManager stateManager,
@@ -21,7 +22,8 @@ public final class VigilanteCommand implements CommandExecutor {
this( this(
stateManager, followers, onlinePlayers, stateManager, followers, onlinePlayers,
player -> player.sendMessage(ChatColor.RED 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, FollowerService followers,
OnlinePlayerDirectory onlinePlayers, OnlinePlayerDirectory onlinePlayers,
VigilanteControlPanel controlPanel VigilanteControlPanel controlPanel
) {
this(stateManager, followers, onlinePlayers, controlPanel, null);
}
public VigilanteCommand(
TyrantStateManager stateManager,
FollowerService followers,
OnlinePlayerDirectory onlinePlayers,
VigilanteControlPanel controlPanel,
RoleControlItemService roleControlItems
) { ) {
this.stateManager = stateManager; this.stateManager = stateManager;
this.followers = followers; this.followers = followers;
this.onlinePlayers = onlinePlayers; this.onlinePlayers = onlinePlayers;
this.controlPanel = controlPanel; this.controlPanel = controlPanel;
this.roleControlItems = roleControlItems;
} }
@Override @Override
@@ -53,6 +66,16 @@ public final class VigilanteCommand implements CommandExecutor {
controlPanel.open(player); controlPanel.open(player);
return true; 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; FollowerResult result;
Player target = null; Player target = null;
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("accept")) { if (arguments.length == 1 && arguments[0].equalsIgnoreCase("accept")) {
@@ -81,7 +104,7 @@ public final class VigilanteCommand implements CommandExecutor {
); );
} else { } else {
player.sendMessage(ChatColor.YELLOW player.sendMessage(ChatColor.YELLOW
+ "Usage: /vigilante <menu|invite <player>|accept|dismiss <player>|leave>"); + "Usage: /vigilante <menu|item|invite <player>|accept|dismiss <player>|leave>");
return true; return true;
} }
if (result.status() == FollowerStatus.JOINED if (result.status() == FollowerStatus.JOINED
+8
View File
@@ -50,6 +50,14 @@ tyrant-resistance-level: 1
follower-strength-cap: 5 follower-strength-cap: 5
follower-resistance-cap: 4 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 # Bound item recovery and delivery messages
recovery-command: /tyrant item recovery-command: /tyrant item
inventory-full-message: Your inventory is full. Make room and use /tyrant item. inventory-full-message: Your inventory is full. Make room and use /tyrant item.
+1 -1
View File
@@ -10,7 +10,7 @@ commands:
usage: /tyrant <menu|status|choices|buy|assign|item|intelligence|optout|optin|relinquish confirm> usage: /tyrant <menu|status|choices|buy|assign|item|intelligence|optout|optin|relinquish confirm>
vigilante: vigilante:
description: Manage Vigilante Followers. description: Manage Vigilante Followers.
usage: /vigilante <menu|invite <player>|accept|dismiss <player>|leave> usage: /vigilante <menu|item|invite <player>|accept|dismiss <player>|leave>
tyrantadmin: tyrantadmin:
description: Administer the Spigot Tyrant game. description: Administer the Spigot Tyrant game.
usage: /tyrantadmin <status|start|pause|resume|reset confirm> usage: /tyrantadmin <status|start|pause|resume|reset confirm>
@@ -43,6 +43,10 @@ final class PluginSettingsTest {
assertEquals("STICK", settings.assassinItem().material()); assertEquals("STICK", settings.assassinItem().material());
assertEquals("Assassin Cloak", settings.assassinItem().name()); assertEquals("Assassin Cloak", settings.assassinItem().name());
assertEquals("FISHING_ROD", settings.tamerItem().material()); 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("/tyrant item", settings.recoveryCommand());
assertEquals("Your inventory is full. Make room and use /tyrant item.", assertEquals("Your inventory is full. Make room and use /tyrant item.",
settings.inventoryFullMessage()); settings.inventoryFullMessage());
@@ -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)
);
}
}
@@ -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));
}
}
@@ -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);
}
}
@@ -43,6 +43,28 @@ final class TyrantCommandTest {
verify(controlPanel, org.mockito.Mockito.times(2)).open(tyrant); 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 @Test
void choicesClearlyIdentifyPurchasedAvailableAndUnavailableUnlocks() { void choicesClearlyIdentifyPurchasedAvailableAndUnavailableUnlocks() {
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111"); UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
@@ -5,12 +5,39 @@ import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import java.time.Duration;
import java.util.Optional;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
import org.bukkit.command.Command; import org.bukkit.command.Command;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
final class VigilanteCommandTest { 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 @Test
void playerCanOpenControlPanelWithNoArgumentsOrMenuSubcommand() { void playerCanOpenControlPanelWithNoArgumentsOrMenuSubcommand() {
UUID playerId = UUID.fromString("22222222-2222-2222-2222-222222222222"); UUID playerId = UUID.fromString("22222222-2222-2222-2222-222222222222");