diff --git a/design/log.md b/design/log.md index bed7064..d384dd0 100644 --- a/design/log.md +++ b/design/log.md @@ -51,6 +51,14 @@ description: Chronological record of material decisions affecting the Spigot Tyr - US-005 begins with test-first bound-item invisibility, near-Tyrant duration doubling, and cooldown-gated double-jump effects. +## 2026-08-14 — Bound ability items completed + +- Completed US-014 with configurable named standard items carrying hidden owner and ability metadata, interaction cancellation, and owner-validated Assassin, Fixer, and Tamer activation. +- Cooldown items are consumed only on successful use, restored automatically when ready or by `/tyrant item`, deduplicated, and withheld with clear guidance when inventory space is unavailable. +- Drop, pickup, placement, crafting/storage movement, item-frame, dispenser, death, class loss, reign reset, login, and restart paths prevent transfer and duplication. +- Captured-mob custody items use independent IDs, reconcile missing or duplicate inventory items against durable snapshots, and spawn rather than vanish on drop or death. +- Verified readiness consumption and refresh, assignment cleanup, durable metadata state, captured custody, and the full Gradle build. + ## 2026-08-14 — Bound ability item implementation started - US-014 begins with test-first item readiness and recovery, owner metadata, transfer prevention, cooldown redelivery, and Tamer item permanence. diff --git a/design/user-stories/us-014-use-class-ability-items.md b/design/user-stories/us-014-use-class-ability-items.md index 7fae31d..77f0629 100644 --- a/design/user-stories/us-014-use-class-ability-items.md +++ b/design/user-stories/us-014-use-class-ability-items.md @@ -2,7 +2,7 @@ type: User Story title: "US-014: Use class ability items" description: Give class holders named bound items that activate abilities without enabling transfer or cooldown bypasses. -status: in-progress +status: done --- # US-014: Use class ability items @@ -11,17 +11,17 @@ As a **class holder**, I want a recognizable inventory item for my ability so th ## Acceptance criteria -- [ ] Each item-triggered class ability uses a uniquely named standard Minecraft item with hidden persistent ownership and ability metadata. -- [ ] Right-clicking or swinging the appropriate item activates the ability without also performing an unsafe vanilla action. -- [ ] Another player cannot activate, retain, craft with, place, store, frame, dispense, or otherwise transfer a bound ability item. -- [ ] A cooldown ability item disappears only after successful activation and is restored when the cooldown finishes. -- [ ] The Tamer's named fishing rod has no cooldown and remains available after successful capture or placement. -- [ ] A dropped class ability item vanishes immediately; a dropped captured-mob item instead attempts to spawn its mob safely. -- [ ] `/tyrant item` restores currently available missing class items but cannot bypass cooldowns or create duplicates. -- [ ] Missing ready items are restored on login and cooldown completion when inventory space is available. -- [ ] When delivery fails because the inventory is full, no item is dropped and the player is told both why and how to use `/tyrant item` after making room. -- [ ] Bound class items are removed when a player loses the class or the Tyrant's reign ends. -- [ ] Death, inventory movement, server restart, repeated commands, and concurrent events cannot duplicate a bound item or its captured mob. +- [x] Each item-triggered class ability uses a uniquely named standard Minecraft item with hidden persistent ownership and ability metadata. +- [x] Right-clicking or swinging the appropriate item activates the ability without also performing an unsafe vanilla action. +- [x] Another player cannot activate, retain, craft with, place, store, frame, dispense, or otherwise transfer a bound ability item. +- [x] A cooldown ability item disappears only after successful activation and is restored when the cooldown finishes. +- [x] The Tamer's named fishing rod has no cooldown and remains available after successful capture or placement. +- [x] A dropped class ability item vanishes immediately; a dropped captured-mob item instead attempts to spawn its mob safely. +- [x] `/tyrant item` restores currently available missing class items but cannot bypass cooldowns or create duplicates. +- [x] Missing ready items are restored on login and cooldown completion when inventory space is available. +- [x] When delivery fails because the inventory is full, no item is dropped and the player is told both why and how to use `/tyrant item` after making room. +- [x] Bound class items are removed when a player loses the class or the Tyrant's reign ends. +- [x] Death, inventory movement, server restart, repeated commands, and concurrent events cannot duplicate a bound item or its captured mob. ## Related diff --git a/src/main/java/games/dmg/spigottyrant/AbilityItemRefreshTask.java b/src/main/java/games/dmg/spigottyrant/AbilityItemRefreshTask.java index fdf3811..761bf7a 100644 --- a/src/main/java/games/dmg/spigottyrant/AbilityItemRefreshTask.java +++ b/src/main/java/games/dmg/spigottyrant/AbilityItemRefreshTask.java @@ -10,17 +10,20 @@ public final class AbilityItemRefreshTask implements Runnable { private final AbilityItemService items; private final Server server; private final Clock clock; + private final CapturedMobInventoryService capturedMobInventory; public AbilityItemRefreshTask( TyrantStateManager stateManager, AbilityReadinessService readiness, AbilityItemService items, + CapturedMobInventoryService capturedMobInventory, Server server, Clock clock ) { this.stateManager = stateManager; this.readiness = readiness; this.items = items; + this.capturedMobInventory = capturedMobInventory; this.server = server; this.clock = clock; } @@ -40,6 +43,7 @@ public final class AbilityItemRefreshTask implements Runnable { } items.removeInvalid(player, after); items.recover(player, after); + capturedMobInventory.reconcile(player, after); } } } diff --git a/src/main/java/games/dmg/spigottyrant/BoundItemListener.java b/src/main/java/games/dmg/spigottyrant/BoundItemListener.java index 2288e06..5f9d0d7 100644 --- a/src/main/java/games/dmg/spigottyrant/BoundItemListener.java +++ b/src/main/java/games/dmg/spigottyrant/BoundItemListener.java @@ -45,6 +45,7 @@ public final class BoundItemListener implements Listener { if (items.isBoundAbilityItem(event.getCurrentItem()) || items.isBoundAbilityItem(event.getCursor())) { if (!(event.getWhoClicked() instanceof Player player) + || event.isShiftClick() || event.getClickedInventory() == null || !event.getClickedInventory().equals(player.getInventory())) { event.setCancelled(true); diff --git a/src/main/java/games/dmg/spigottyrant/BukkitAbilityItemService.java b/src/main/java/games/dmg/spigottyrant/BukkitAbilityItemService.java index 02f5f0d..e1006ea 100644 --- a/src/main/java/games/dmg/spigottyrant/BukkitAbilityItemService.java +++ b/src/main/java/games/dmg/spigottyrant/BukkitAbilityItemService.java @@ -81,11 +81,17 @@ public final class BukkitAbilityItemService implements AbilityItemService { @Override public void removeInvalid(Player player, PlayerState state) { ItemStack[] contents = player.getInventory().getContents(); + java.util.Set retained = java.util.EnumSet.noneOf(Ability.class); for (int index = 0; index < contents.length; index++) { ItemStack item = contents[index]; - if (isBoundAbilityItem(item) - && (owner(item).filter(state.playerId()::equals).isEmpty() - || ability(item).filter(state.readyAbilityItems()::contains).isEmpty())) { + if (!isBoundAbilityItem(item)) { + continue; + } + Optional itemAbility = ability(item); + boolean valid = owner(item).filter(state.playerId()::equals).isPresent() + && itemAbility.filter(state.readyAbilityItems()::contains).isPresent() + && itemAbility.filter(retained::add).isPresent(); + if (!valid) { player.getInventory().setItem(index, null); } } diff --git a/src/main/java/games/dmg/spigottyrant/CapturedMobInventoryService.java b/src/main/java/games/dmg/spigottyrant/CapturedMobInventoryService.java new file mode 100644 index 0000000..300f6b1 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/CapturedMobInventoryService.java @@ -0,0 +1,61 @@ +package games.dmg.spigottyrant; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import org.bukkit.ChatColor; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +public final class CapturedMobInventoryService { + private final CapturedMobItemService items; + private final PluginSettings settings; + + public CapturedMobInventoryService( + CapturedMobItemService items, + PluginSettings settings + ) { + this.items = items; + this.settings = settings; + } + + public void reconcile(Player player, PlayerState state) { + Map stored = new java.util.HashMap<>(); + for (CapturedMob mob : state.capturedMobs()) { + try { + UUID captureId = UUID.fromString(mob.data().get("capture-id")); + stored.putIfAbsent(captureId, mob); + } catch (IllegalArgumentException | NullPointerException ignored) { + // Invalid custody records are not materialized as items. + } + } + Set retained = new HashSet<>(); + ItemStack[] contents = player.getInventory().getContents(); + for (int index = 0; index < contents.length; index++) { + ItemStack item = contents[index]; + if (!items.isCapturedMob(item)) { + continue; + } + UUID captureId = items.captureId(item).orElseThrow(); + boolean valid = items.owner(item).filter(state.playerId()::equals).isPresent() + && stored.containsKey(captureId) && retained.add(captureId); + if (!valid) { + player.getInventory().setItem(index, null); + } + } + for (Map.Entry entry : stored.entrySet()) { + if (retained.contains(entry.getKey())) { + continue; + } + Map leftovers = player.getInventory().addItem( + items.create( + state.playerId(), entry.getKey(), entry.getValue().entityType() + ) + ); + if (!leftovers.isEmpty()) { + player.sendMessage(ChatColor.RED + settings.inventoryFullMessage()); + } + } + } +} diff --git a/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java b/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java index 462bd77..9f7d630 100644 --- a/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java +++ b/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java @@ -57,6 +57,9 @@ public final class SpigotTyrantPlugin extends JavaPlugin { this, settings ); CapturedMobService capturedMobs = new CapturedMobService(); + CapturedMobInventoryService capturedMobInventory = new CapturedMobInventoryService( + capturedMobItems, settings + ); AssassinAbilityService assassinAbilities = new AssassinAbilityService( readiness, settings ); @@ -170,7 +173,8 @@ public final class SpigotTyrantPlugin extends JavaPlugin { getServer().getScheduler().runTaskTimer( this, new AbilityItemRefreshTask( - stateManager, readiness, abilityItems, getServer(), clock + stateManager, readiness, abilityItems, capturedMobInventory, + getServer(), clock ), 20L, 20L diff --git a/src/main/java/games/dmg/spigottyrant/TamerListener.java b/src/main/java/games/dmg/spigottyrant/TamerListener.java index 4e378db..eeb573c 100644 --- a/src/main/java/games/dmg/spigottyrant/TamerListener.java +++ b/src/main/java/games/dmg/spigottyrant/TamerListener.java @@ -9,6 +9,7 @@ import org.bukkit.Location; import org.bukkit.Server; import org.bukkit.entity.Entity; import org.bukkit.entity.EntitySnapshot; +import org.bukkit.entity.ItemFrame; import org.bukkit.entity.LivingEntity; import org.bukkit.entity.Player; import org.bukkit.entity.Tameable; @@ -159,6 +160,7 @@ public final class TamerListener implements Listener { if (capturedItems.isCapturedMob(event.getCurrentItem()) || capturedItems.isCapturedMob(event.getCursor())) { if (!(event.getWhoClicked() instanceof Player player) + || event.isShiftClick() || event.getClickedInventory() == null || !event.getClickedInventory().equals(player.getInventory())) { event.setCancelled(true); @@ -166,6 +168,16 @@ public final class TamerListener implements Listener { } } + @EventHandler + public void onFrame(PlayerInteractEntityEvent event) { + if (event.getRightClicked() instanceof ItemFrame + && capturedItems.isCapturedMob( + event.getPlayer().getInventory().getItem(event.getHand()) + )) { + event.setCancelled(true); + } + } + @EventHandler public void onDispense(BlockDispenseEvent event) { if (capturedItems.isCapturedMob(event.getItem())) {