feat(items): secure bound ability inventory
This commit is contained in:
@@ -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.
|
- 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
|
## 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.
|
- US-014 begins with test-first item readiness and recovery, owner metadata, transfer prevention, cooldown redelivery, and Tamer item permanence.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: User Story
|
type: User Story
|
||||||
title: "US-014: Use class ability items"
|
title: "US-014: Use class ability items"
|
||||||
description: Give class holders named bound items that activate abilities without enabling transfer or cooldown bypasses.
|
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
|
# 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
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] Each item-triggered class ability uses a uniquely named standard Minecraft item with hidden persistent ownership and ability metadata.
|
- [x] 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.
|
- [x] 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.
|
- [x] 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.
|
- [x] 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.
|
- [x] 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.
|
- [x] 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.
|
- [x] `/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.
|
- [x] 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.
|
- [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.
|
||||||
- [ ] Bound class items are removed when a player loses the class or the Tyrant's reign ends.
|
- [x] 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] Death, inventory movement, server restart, repeated commands, and concurrent events cannot duplicate a bound item or its captured mob.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
|
|||||||
@@ -10,17 +10,20 @@ public final class AbilityItemRefreshTask implements Runnable {
|
|||||||
private final AbilityItemService items;
|
private final AbilityItemService items;
|
||||||
private final Server server;
|
private final Server server;
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
|
private final CapturedMobInventoryService capturedMobInventory;
|
||||||
|
|
||||||
public AbilityItemRefreshTask(
|
public AbilityItemRefreshTask(
|
||||||
TyrantStateManager stateManager,
|
TyrantStateManager stateManager,
|
||||||
AbilityReadinessService readiness,
|
AbilityReadinessService readiness,
|
||||||
AbilityItemService items,
|
AbilityItemService items,
|
||||||
|
CapturedMobInventoryService capturedMobInventory,
|
||||||
Server server,
|
Server server,
|
||||||
Clock clock
|
Clock clock
|
||||||
) {
|
) {
|
||||||
this.stateManager = stateManager;
|
this.stateManager = stateManager;
|
||||||
this.readiness = readiness;
|
this.readiness = readiness;
|
||||||
this.items = items;
|
this.items = items;
|
||||||
|
this.capturedMobInventory = capturedMobInventory;
|
||||||
this.server = server;
|
this.server = server;
|
||||||
this.clock = clock;
|
this.clock = clock;
|
||||||
}
|
}
|
||||||
@@ -40,6 +43,7 @@ public final class AbilityItemRefreshTask implements Runnable {
|
|||||||
}
|
}
|
||||||
items.removeInvalid(player, after);
|
items.removeInvalid(player, after);
|
||||||
items.recover(player, after);
|
items.recover(player, after);
|
||||||
|
capturedMobInventory.reconcile(player, after);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ public final class BoundItemListener implements Listener {
|
|||||||
if (items.isBoundAbilityItem(event.getCurrentItem())
|
if (items.isBoundAbilityItem(event.getCurrentItem())
|
||||||
|| items.isBoundAbilityItem(event.getCursor())) {
|
|| items.isBoundAbilityItem(event.getCursor())) {
|
||||||
if (!(event.getWhoClicked() instanceof Player player)
|
if (!(event.getWhoClicked() instanceof Player player)
|
||||||
|
|| event.isShiftClick()
|
||||||
|| event.getClickedInventory() == null
|
|| event.getClickedInventory() == null
|
||||||
|| !event.getClickedInventory().equals(player.getInventory())) {
|
|| !event.getClickedInventory().equals(player.getInventory())) {
|
||||||
event.setCancelled(true);
|
event.setCancelled(true);
|
||||||
|
|||||||
@@ -81,11 +81,17 @@ public final class BukkitAbilityItemService implements AbilityItemService {
|
|||||||
@Override
|
@Override
|
||||||
public void removeInvalid(Player player, PlayerState state) {
|
public void removeInvalid(Player player, PlayerState state) {
|
||||||
ItemStack[] contents = player.getInventory().getContents();
|
ItemStack[] contents = player.getInventory().getContents();
|
||||||
|
java.util.Set<Ability> retained = java.util.EnumSet.noneOf(Ability.class);
|
||||||
for (int index = 0; index < contents.length; index++) {
|
for (int index = 0; index < contents.length; index++) {
|
||||||
ItemStack item = contents[index];
|
ItemStack item = contents[index];
|
||||||
if (isBoundAbilityItem(item)
|
if (!isBoundAbilityItem(item)) {
|
||||||
&& (owner(item).filter(state.playerId()::equals).isEmpty()
|
continue;
|
||||||
|| ability(item).filter(state.readyAbilityItems()::contains).isEmpty())) {
|
}
|
||||||
|
Optional<Ability> 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);
|
player.getInventory().setItem(index, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<UUID, CapturedMob> 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<UUID> 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<UUID, CapturedMob> entry : stored.entrySet()) {
|
||||||
|
if (retained.contains(entry.getKey())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Map<Integer, ItemStack> leftovers = player.getInventory().addItem(
|
||||||
|
items.create(
|
||||||
|
state.playerId(), entry.getKey(), entry.getValue().entityType()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (!leftovers.isEmpty()) {
|
||||||
|
player.sendMessage(ChatColor.RED + settings.inventoryFullMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,9 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
|
|||||||
this, settings
|
this, settings
|
||||||
);
|
);
|
||||||
CapturedMobService capturedMobs = new CapturedMobService();
|
CapturedMobService capturedMobs = new CapturedMobService();
|
||||||
|
CapturedMobInventoryService capturedMobInventory = new CapturedMobInventoryService(
|
||||||
|
capturedMobItems, settings
|
||||||
|
);
|
||||||
AssassinAbilityService assassinAbilities = new AssassinAbilityService(
|
AssassinAbilityService assassinAbilities = new AssassinAbilityService(
|
||||||
readiness, settings
|
readiness, settings
|
||||||
);
|
);
|
||||||
@@ -170,7 +173,8 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
|
|||||||
getServer().getScheduler().runTaskTimer(
|
getServer().getScheduler().runTaskTimer(
|
||||||
this,
|
this,
|
||||||
new AbilityItemRefreshTask(
|
new AbilityItemRefreshTask(
|
||||||
stateManager, readiness, abilityItems, getServer(), clock
|
stateManager, readiness, abilityItems, capturedMobInventory,
|
||||||
|
getServer(), clock
|
||||||
),
|
),
|
||||||
20L,
|
20L,
|
||||||
20L
|
20L
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import org.bukkit.Location;
|
|||||||
import org.bukkit.Server;
|
import org.bukkit.Server;
|
||||||
import org.bukkit.entity.Entity;
|
import org.bukkit.entity.Entity;
|
||||||
import org.bukkit.entity.EntitySnapshot;
|
import org.bukkit.entity.EntitySnapshot;
|
||||||
|
import org.bukkit.entity.ItemFrame;
|
||||||
import org.bukkit.entity.LivingEntity;
|
import org.bukkit.entity.LivingEntity;
|
||||||
import org.bukkit.entity.Player;
|
import org.bukkit.entity.Player;
|
||||||
import org.bukkit.entity.Tameable;
|
import org.bukkit.entity.Tameable;
|
||||||
@@ -159,6 +160,7 @@ public final class TamerListener implements Listener {
|
|||||||
if (capturedItems.isCapturedMob(event.getCurrentItem())
|
if (capturedItems.isCapturedMob(event.getCurrentItem())
|
||||||
|| capturedItems.isCapturedMob(event.getCursor())) {
|
|| capturedItems.isCapturedMob(event.getCursor())) {
|
||||||
if (!(event.getWhoClicked() instanceof Player player)
|
if (!(event.getWhoClicked() instanceof Player player)
|
||||||
|
|| event.isShiftClick()
|
||||||
|| event.getClickedInventory() == null
|
|| event.getClickedInventory() == null
|
||||||
|| !event.getClickedInventory().equals(player.getInventory())) {
|
|| !event.getClickedInventory().equals(player.getInventory())) {
|
||||||
event.setCancelled(true);
|
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
|
@EventHandler
|
||||||
public void onDispense(BlockDispenseEvent event) {
|
public void onDispense(BlockDispenseEvent event) {
|
||||||
if (capturedItems.isCapturedMob(event.getItem())) {
|
if (capturedItems.isCapturedMob(event.getItem())) {
|
||||||
|
|||||||
Reference in New Issue
Block a user