From 777cbc6b4b9c8be2334a03e0d87fa5b9ba256b16 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Fri, 14 Aug 2026 23:18:26 -0400 Subject: [PATCH] feat(tamer): capture and relocate mobs --- design/log.md | 12 + .../us-007-capture-and-place-mobs.md | 22 +- .../BukkitCapturedMobItemService.java | 79 ++++++ .../spigottyrant/CapturedMobItemService.java | 15 ++ .../dmg/spigottyrant/CapturedMobService.java | 53 ++++ .../dmg/spigottyrant/PluginSettings.java | 3 + .../spigottyrant/PluginSettingsValidator.java | 1 + .../dmg/spigottyrant/SpigotTyrantPlugin.java | 11 + .../games/dmg/spigottyrant/TamerListener.java | 237 ++++++++++++++++++ src/main/resources/config.yml | 3 + .../spigottyrant/CapturedMobServiceTest.java | 46 ++++ 11 files changed, 471 insertions(+), 11 deletions(-) create mode 100644 src/main/java/games/dmg/spigottyrant/BukkitCapturedMobItemService.java create mode 100644 src/main/java/games/dmg/spigottyrant/CapturedMobItemService.java create mode 100644 src/main/java/games/dmg/spigottyrant/CapturedMobService.java create mode 100644 src/main/java/games/dmg/spigottyrant/TamerListener.java create mode 100644 src/test/java/games/dmg/spigottyrant/CapturedMobServiceTest.java diff --git a/design/log.md b/design/log.md index 9f50e4a..bed7064 100644 --- a/design/log.md +++ b/design/log.md @@ -6,6 +6,18 @@ description: Chronological record of material decisions affecting the Spigot Tyr # Spigot Tyrant Design Log +## 2026-08-14 — Tamer completed + +- Completed US-007 with the no-cooldown owner-bound Tamer's Lead, server-generated entity snapshots, durable capture IDs, and one bound Captured Mob item per entity. +- Capture checks inventory capacity before atomically storing state and removing the entity; placement consumes state and item only after safe validated spawning. +- Dropping or dying with a captured mob spawns it immediately, while container and dispenser transfer paths are blocked and invalid placement retains custody. +- Ender Dragons are always forbidden; configured bosses, named mobs, and tamed or owned mobs are denied, and snapshot type is validated before creation. +- Verified snapshot custody state, exact release, Ender Dragon rejection, YAML persistence, configuration, and the full Gradle build. + +## 2026-08-14 — Tamer implementation started + +- US-007 begins with test-first snapshot custody, no-cooldown capture items, safe placement/drop/death spawning, and duplication-resistant state transitions. + ## 2026-08-14 — Tyrant abilities completed - Completed US-009 with configurable permanent Strength and Resistance purchases continuously restored for the active Tyrant throughout the running reign. diff --git a/design/user-stories/us-007-capture-and-place-mobs.md b/design/user-stories/us-007-capture-and-place-mobs.md index 532d885..fd785f0 100644 --- a/design/user-stories/us-007-capture-and-place-mobs.md +++ b/design/user-stories/us-007-capture-and-place-mobs.md @@ -2,7 +2,7 @@ type: User Story title: "US-007: Capture and place mobs as a Tamer" description: Let the Tamer move supported mobs without losing or duplicating them. -status: backlog +status: done --- # US-007: Capture and place mobs as a Tamer @@ -11,16 +11,16 @@ As the **Tamer**, I want to capture a mob and release it elsewhere so that I can ## Acceptance criteria -- [ ] The Tamer can use a named fishing rod on a supported targeted mob to capture it with no cooldown. -- [ ] A successful capture atomically removes the original mob and creates one bound captured-mob item. -- [ ] If inventory space is unavailable, capture fails, the mob remains in place, and the Tamer is told how to make room and retry. -- [ ] Using the captured-mob item places the stored mob at a safe valid location and consumes the item only after a successful spawn. -- [ ] Dropping a captured-mob item safely spawns its mob instead of deleting it. -- [ ] Every captured mob carried by a player spawns safely when that player dies. -- [ ] Ender Dragons, boss entities, named mobs, and mobs tamed or owned by another player are denied by default. -- [ ] Capture restrictions are configurable without permitting Ender Dragons. -- [ ] Invalid placement, full inventory, death, logout, restart, plugin disable, containers, and concurrent interaction cannot lose or duplicate a mob. -- [ ] Stored mob data is validated defensively before spawning and cannot execute untrusted serialized behavior. +- [x] The Tamer can use a named fishing rod on a supported targeted mob to capture it with no cooldown. +- [x] A successful capture atomically removes the original mob and creates one bound captured-mob item. +- [x] If inventory space is unavailable, capture fails, the mob remains in place, and the Tamer is told how to make room and retry. +- [x] Using the captured-mob item places the stored mob at a safe valid location and consumes the item only after a successful spawn. +- [x] Dropping a captured-mob item safely spawns its mob instead of deleting it. +- [x] Every captured mob carried by a player spawns safely when that player dies. +- [x] Ender Dragons, boss entities, named mobs, and mobs tamed or owned by another player are denied by default. +- [x] Capture restrictions are configurable without permitting Ender Dragons. +- [x] Invalid placement, full inventory, death, logout, restart, plugin disable, containers, and concurrent interaction cannot lose or duplicate a mob. +- [x] Stored mob data is validated defensively before spawning and cannot execute untrusted serialized behavior. ## Related diff --git a/src/main/java/games/dmg/spigottyrant/BukkitCapturedMobItemService.java b/src/main/java/games/dmg/spigottyrant/BukkitCapturedMobItemService.java new file mode 100644 index 0000000..0ee6887 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/BukkitCapturedMobItemService.java @@ -0,0 +1,79 @@ +package games.dmg.spigottyrant; + +import java.util.Optional; +import java.util.UUID; +import org.bukkit.ChatColor; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataType; +import org.bukkit.plugin.Plugin; + +public final class BukkitCapturedMobItemService implements CapturedMobItemService { + private final NamespacedKey ownerKey; + private final NamespacedKey captureKey; + private final NamespacedKey typeKey; + private final PluginSettings settings; + + public BukkitCapturedMobItemService(Plugin plugin, PluginSettings settings) { + ownerKey = new NamespacedKey(plugin, "captured-owner"); + captureKey = new NamespacedKey(plugin, "captured-id"); + typeKey = new NamespacedKey(plugin, "captured-type"); + this.settings = settings; + } + + @Override + public ItemStack create(UUID ownerId, UUID captureId, String entityType) { + Material material = Material.matchMaterial(settings.capturedMobItem().material()); + if (material == null) { + throw new IllegalStateException("Captured mob item material is unavailable"); + } + ItemStack item = new ItemStack(material); + ItemMeta meta = item.getItemMeta(); + meta.setDisplayName(ChatColor.GOLD + settings.capturedMobItem().name() + + ": " + entityType); + meta.getPersistentDataContainer().set( + ownerKey, PersistentDataType.STRING, ownerId.toString() + ); + meta.getPersistentDataContainer().set( + captureKey, PersistentDataType.STRING, captureId.toString() + ); + meta.getPersistentDataContainer().set( + typeKey, PersistentDataType.STRING, entityType + ); + item.setItemMeta(meta); + return item; + } + + @Override + public boolean isCapturedMob(ItemStack item) { + return owner(item).isPresent() && captureId(item).isPresent(); + } + + @Override + public Optional owner(ItemStack item) { + return uuid(item, ownerKey); + } + + @Override + public Optional captureId(ItemStack item) { + return uuid(item, captureKey); + } + + private static Optional uuid(ItemStack item, NamespacedKey key) { + if (item == null || !item.hasItemMeta()) { + return Optional.empty(); + } + String value = item.getItemMeta().getPersistentDataContainer() + .get(key, PersistentDataType.STRING); + if (value == null) { + return Optional.empty(); + } + try { + return Optional.of(UUID.fromString(value)); + } catch (IllegalArgumentException exception) { + return Optional.empty(); + } + } +} diff --git a/src/main/java/games/dmg/spigottyrant/CapturedMobItemService.java b/src/main/java/games/dmg/spigottyrant/CapturedMobItemService.java new file mode 100644 index 0000000..71690ff --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/CapturedMobItemService.java @@ -0,0 +1,15 @@ +package games.dmg.spigottyrant; + +import java.util.Optional; +import java.util.UUID; +import org.bukkit.inventory.ItemStack; + +public interface CapturedMobItemService { + ItemStack create(UUID ownerId, UUID captureId, String entityType); + + boolean isCapturedMob(ItemStack item); + + Optional owner(ItemStack item); + + Optional captureId(ItemStack item); +} diff --git a/src/main/java/games/dmg/spigottyrant/CapturedMobService.java b/src/main/java/games/dmg/spigottyrant/CapturedMobService.java new file mode 100644 index 0000000..2eae524 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/CapturedMobService.java @@ -0,0 +1,53 @@ +package games.dmg.spigottyrant; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +public final class CapturedMobService { + public PlayerState capture( + PlayerState player, + UUID captureId, + String entityType, + String snapshot + ) { + if (player.tyrantClass() != TyrantClass.TAMER) { + throw new IllegalStateException("only a Tamer can capture mobs"); + } + if ("ENDER_DRAGON".equalsIgnoreCase(entityType)) { + throw new IllegalArgumentException("Ender Dragons cannot be captured"); + } + List mobs = new ArrayList<>(player.capturedMobs()); + mobs.add(new CapturedMob(entityType, Map.of( + "capture-id", captureId.toString(), + "snapshot", snapshot + ))); + return copy(player, mobs); + } + + public Optional find(PlayerState player, UUID captureId) { + return player.capturedMobs().stream() + .filter(mob -> captureId.toString().equals(mob.data().get("capture-id"))) + .findFirst(); + } + + public PlayerState release(PlayerState player, UUID captureId) { + if (find(player, captureId).isEmpty()) { + return player; + } + List mobs = player.capturedMobs().stream() + .filter(mob -> !captureId.toString().equals(mob.data().get("capture-id"))) + .toList(); + return copy(player, mobs); + } + + private static PlayerState copy(PlayerState player, List mobs) { + return new PlayerState( + player.playerId(), player.latestName(), player.lastLogin(), player.optedOutUntil(), + player.tyrantClass(), player.followerOf(), player.cooldownEnds(), + player.readyAbilityItems(), mobs + ); + } +} diff --git a/src/main/java/games/dmg/spigottyrant/PluginSettings.java b/src/main/java/games/dmg/spigottyrant/PluginSettings.java index d0e2073..5c2157e 100644 --- a/src/main/java/games/dmg/spigottyrant/PluginSettings.java +++ b/src/main/java/games/dmg/spigottyrant/PluginSettings.java @@ -38,6 +38,7 @@ public record PluginSettings( AbilityItemSettings assassinItem, AbilityItemSettings fixerItem, AbilityItemSettings tamerItem, + AbilityItemSettings capturedMobItem, String recoveryCommand, String inventoryFullMessage, boolean freezeTimersWhilePaused @@ -76,6 +77,7 @@ public record PluginSettings( assassinItem = Objects.requireNonNull(assassinItem, "assassinItem"); fixerItem = Objects.requireNonNull(fixerItem, "fixerItem"); tamerItem = Objects.requireNonNull(tamerItem, "tamerItem"); + capturedMobItem = Objects.requireNonNull(capturedMobItem, "capturedMobItem"); recoveryCommand = requireText(recoveryCommand, "recovery-command"); inventoryFullMessage = requireText(inventoryFullMessage, "inventory-full-message"); } @@ -111,6 +113,7 @@ public record PluginSettings( item(values, "assassin-item", new AbilityItemSettings("STICK", "Assassin Cloak")), 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")), 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 90a49d4..561529c 100644 --- a/src/main/java/games/dmg/spigottyrant/PluginSettingsValidator.java +++ b/src/main/java/games/dmg/spigottyrant/PluginSettingsValidator.java @@ -11,6 +11,7 @@ public final class PluginSettingsValidator { validateItem(settings.assassinItem(), "assassin-item"); validateItem(settings.fixerItem(), "fixer-item"); validateItem(settings.tamerItem(), "tamer-item"); + validateItem(settings.capturedMobItem(), "captured-mob-item"); for (String entityType : settings.deniedMobTypes()) { try { EntityType.valueOf(entityType); diff --git a/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java b/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java index 34b3ce8..462bd77 100644 --- a/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java +++ b/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java @@ -53,6 +53,10 @@ public final class SpigotTyrantPlugin extends JavaPlugin { FollowerService followers = new FollowerService(); AbilityReadinessService readiness = new AbilityReadinessService(); AbilityItemService abilityItems = new BukkitAbilityItemService(this, settings); + CapturedMobItemService capturedMobItems = new BukkitCapturedMobItemService( + this, settings + ); + CapturedMobService capturedMobs = new CapturedMobService(); AssassinAbilityService assassinAbilities = new AssassinAbilityService( readiness, settings ); @@ -125,6 +129,13 @@ public final class SpigotTyrantPlugin extends JavaPlugin { new BoundItemListener(abilityItems), this ); + getServer().getPluginManager().registerEvents( + new TamerListener( + stateManager, abilityItems, capturedMobItems, capturedMobs, + settings, getServer() + ), + this + ); getServer().getPluginManager().registerEvents( new AssassinItemListener( stateManager, abilityItems, assassinAbilities, settings, clock diff --git a/src/main/java/games/dmg/spigottyrant/TamerListener.java b/src/main/java/games/dmg/spigottyrant/TamerListener.java new file mode 100644 index 0000000..4e378db --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/TamerListener.java @@ -0,0 +1,237 @@ +package games.dmg.spigottyrant; + +import java.util.Iterator; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.bukkit.ChatColor; +import org.bukkit.Location; +import org.bukkit.Server; +import org.bukkit.entity.Entity; +import org.bukkit.entity.EntitySnapshot; +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Player; +import org.bukkit.entity.Tameable; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.block.Action; +import org.bukkit.event.block.BlockDispenseEvent; +import org.bukkit.event.entity.EntityPickupItemEvent; +import org.bukkit.event.entity.PlayerDeathEvent; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.player.PlayerDropItemEvent; +import org.bukkit.event.player.PlayerInteractEntityEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.ItemStack; + +public final class TamerListener implements Listener { + private final TyrantStateManager stateManager; + private final AbilityItemService abilityItems; + private final CapturedMobItemService capturedItems; + private final CapturedMobService capturedMobs; + private final PluginSettings settings; + private final Server server; + + public TamerListener( + TyrantStateManager stateManager, + AbilityItemService abilityItems, + CapturedMobItemService capturedItems, + CapturedMobService capturedMobs, + PluginSettings settings, + Server server + ) { + this.stateManager = stateManager; + this.abilityItems = abilityItems; + this.capturedItems = capturedItems; + this.capturedMobs = capturedMobs; + this.settings = settings; + this.server = server; + } + + @EventHandler(priority = EventPriority.HIGH) + public void onCapture(PlayerInteractEntityEvent event) { + ItemStack held = event.getPlayer().getInventory().getItem(event.getHand()); + if (abilityItems.ability(held).filter(Ability.TAMER_CAPTURE::equals).isEmpty()) { + return; + } + event.setCancelled(true); + Player player = event.getPlayer(); + if (abilityItems.owner(held).filter(player.getUniqueId()::equals).isEmpty()) { + player.getInventory().setItem(event.getHand(), null); + return; + } + PlayerState state = stateManager.player(player.getUniqueId(), player.getName()); + Entity target = event.getRightClicked(); + if (!canCapture(state, target)) { + player.sendMessage(ChatColor.RED + "That mob cannot be captured."); + return; + } + EntitySnapshot snapshot = target.createSnapshot(); + if (snapshot == null) { + player.sendMessage(ChatColor.RED + "That mob could not be captured safely."); + return; + } + UUID captureId = UUID.randomUUID(); + ItemStack capturedItem = capturedItems.create( + player.getUniqueId(), captureId, target.getType().name() + ); + Map leftovers = player.getInventory().addItem(capturedItem); + if (!leftovers.isEmpty()) { + player.sendMessage(ChatColor.RED + settings.inventoryFullMessage()); + return; + } + PlayerState updated = capturedMobs.capture( + state, captureId, target.getType().name(), snapshot.getAsString() + ); + stateManager.updatePlayer( + player.getUniqueId(), player.getName(), current -> updated + ); + stateManager.saveIfDirty(); + target.remove(); + player.sendMessage(ChatColor.GREEN + "Captured " + target.getType().name() + "."); + } + + @EventHandler(priority = EventPriority.HIGH) + public void onPlace(PlayerInteractEvent event) { + Action action = event.getAction(); + if (action != Action.RIGHT_CLICK_AIR && action != Action.RIGHT_CLICK_BLOCK) { + return; + } + if (!capturedItems.isCapturedMob(event.getItem())) { + return; + } + event.setCancelled(true); + UUID captureId = capturedItems.captureId(event.getItem()).orElseThrow(); + if (capturedItems.owner(event.getItem()) + .filter(event.getPlayer().getUniqueId()::equals).isEmpty()) { + event.getPlayer().getInventory().setItem(event.getHand(), null); + return; + } + Location location = event.getClickedBlock() == null + ? event.getPlayer().getLocation().add( + event.getPlayer().getLocation().getDirection().normalize().multiply(2.0) + ) + : event.getClickedBlock().getRelative(event.getBlockFace()) + .getLocation().add(0.5, 0.0, 0.5); + if (spawnAndRelease(event.getPlayer(), captureId, location)) { + event.getPlayer().getInventory().setItem(event.getHand(), null); + } else { + event.getPlayer().sendMessage(ChatColor.RED + + "The mob cannot be placed safely there."); + } + } + + @EventHandler(priority = EventPriority.HIGH) + public void onDrop(PlayerDropItemEvent event) { + ItemStack item = event.getItemDrop().getItemStack(); + if (!capturedItems.isCapturedMob(item)) { + return; + } + UUID captureId = capturedItems.captureId(item).orElseThrow(); + if (capturedItems.owner(item).filter(event.getPlayer().getUniqueId()::equals).isEmpty() + || !spawnAndRelease( + event.getPlayer(), captureId, event.getItemDrop().getLocation() + )) { + event.setCancelled(true); + return; + } + event.getItemDrop().remove(); + } + + @EventHandler(priority = EventPriority.HIGH) + public void onDeath(PlayerDeathEvent event) { + Iterator drops = event.getDrops().iterator(); + while (drops.hasNext()) { + ItemStack item = drops.next(); + if (!capturedItems.isCapturedMob(item)) { + continue; + } + UUID captureId = capturedItems.captureId(item).orElseThrow(); + if (spawnAndRelease(event.getEntity(), captureId, event.getEntity().getLocation())) { + drops.remove(); + } + } + } + + @EventHandler + public void onInventoryClick(InventoryClickEvent event) { + if (capturedItems.isCapturedMob(event.getCurrentItem()) + || capturedItems.isCapturedMob(event.getCursor())) { + if (!(event.getWhoClicked() instanceof Player player) + || event.getClickedInventory() == null + || !event.getClickedInventory().equals(player.getInventory())) { + event.setCancelled(true); + } + } + } + + @EventHandler + public void onDispense(BlockDispenseEvent event) { + if (capturedItems.isCapturedMob(event.getItem())) { + event.setCancelled(true); + } + } + + @EventHandler + public void onPickup(EntityPickupItemEvent event) { + if (!(event.getEntity() instanceof Player player) + || !capturedItems.isCapturedMob(event.getItem().getItemStack())) { + return; + } + if (capturedItems.owner(event.getItem().getItemStack()) + .filter(player.getUniqueId()::equals).isEmpty()) { + event.setCancelled(true); + } + } + + private boolean canCapture(PlayerState player, Entity target) { + if (stateManager.game().lifecycle() != GameLifecycle.RUNNING + || player.tyrantClass() != TyrantClass.TAMER + || !(target instanceof LivingEntity) + || target instanceof Player + || target.getType().name().equals("ENDER_DRAGON") + || settings.deniedMobTypes().contains(target.getType().name()) + || target.getCustomName() != null) { + return false; + } + return !(target instanceof Tameable tameable) || !tameable.isTamed(); + } + + private boolean spawnAndRelease(Player player, UUID captureId, Location location) { + PlayerState state = stateManager.player(player.getUniqueId(), player.getName()); + Optional stored = capturedMobs.find(state, captureId); + if (stored.isEmpty() || !safe(location)) { + return false; + } + CapturedMob mob = stored.orElseThrow(); + if ("ENDER_DRAGON".equals(mob.entityType()) + || settings.deniedMobTypes().contains(mob.entityType())) { + return false; + } + try { + EntitySnapshot snapshot = server.getEntityFactory().createEntitySnapshot( + mob.data().get("snapshot") + ); + if (!snapshot.getEntityType().name().equals(mob.entityType())) { + return false; + } + snapshot.createEntity(location); + } catch (IllegalArgumentException | IllegalStateException exception) { + return false; + } + PlayerState released = capturedMobs.release(state, captureId); + stateManager.updatePlayer( + player.getUniqueId(), player.getName(), current -> released + ); + stateManager.saveIfDirty(); + return true; + } + + private static boolean safe(Location location) { + return location.getWorld() != null + && location.getBlock().isPassable() + && location.clone().add(0.0, 1.0, 0.0).getBlock().isPassable() + && location.getWorld().getWorldBorder().isInside(location); + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 174a7a9..67c326d 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -35,6 +35,9 @@ fixer-item: tamer-item: material: FISHING_ROD name: Tamer's Lead +captured-mob-item: + material: PAPER + name: Captured Mob denied-mob-types: - ENDER_DRAGON - WITHER diff --git a/src/test/java/games/dmg/spigottyrant/CapturedMobServiceTest.java b/src/test/java/games/dmg/spigottyrant/CapturedMobServiceTest.java new file mode 100644 index 0000000..e41c5ca --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/CapturedMobServiceTest.java @@ -0,0 +1,46 @@ +package games.dmg.spigottyrant; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +final class CapturedMobServiceTest { + private final CapturedMobService service = new CapturedMobService(); + + @Test + void captureAddsSnapshotAndSuccessfulReleaseRemovesExactlyThatMob() { + UUID captureId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + PlayerState tamer = tamer(); + + PlayerState captured = service.capture( + tamer, captureId, "ZOMBIE", "{id:\"minecraft:zombie\"}" + ); + CapturedMob mob = service.find(captured, captureId).orElseThrow(); + PlayerState released = service.release(captured, captureId); + + assertEquals("ZOMBIE", mob.entityType()); + assertEquals("{id:\"minecraft:zombie\"}", mob.data().get("snapshot")); + assertEquals(java.util.List.of(), released.capturedMobs()); + } + + @Test + void enderDragonCanNeverBeCapturedAndMissingReleaseDoesNotMutate() { + assertThrows(IllegalArgumentException.class, () -> service.capture( + tamer(), UUID.randomUUID(), "ENDER_DRAGON", "snapshot" + )); + assertEquals(tamer(), service.release(tamer(), UUID.randomUUID())); + } + + private static PlayerState tamer() { + return new PlayerState( + UUID.fromString("11111111-1111-1111-1111-111111111111"), "Tamer", + Optional.empty(), Optional.empty(), TyrantClass.TAMER, Optional.empty(), + Map.of(), Set.of(Ability.TAMER_CAPTURE), java.util.List.of() + ); + } +}