diff --git a/design/log.md b/design/log.md index 6b1eaa2..191f1aa 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-21 — Former Tamer automatic release completed + +- Completed US-004 and US-007 so every captured mob is released automatically after its holder loses the Tamer class, regardless of the class-removal path. +- Online former Tamers release at a validated safe location; offline, dead, unsafe, malformed, or otherwise unspawnable custody remains durable and retries periodically after login or movement. +- Custody records and captured-mob items are removed only for successful spawns, while ordinary inventory remains untouched. +- Verified former-Tamer filtering, offline deferral, partial success, custody preservation, compiler warnings, tests, and packaging with `./gradlew clean check jar`. + +## 2026-08-21 — Former Tamer automatic release started + +- US-004 and US-007 begin a test-first implementation that automatically releases captured mobs when a player loses the Tamer class. +- Release will preserve ordinary inventory, remove custody only after successful spawning, and defer safely for offline players or unsafe locations before retrying. + ## 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. diff --git a/design/user-stories/us-004-assign-unlocked-classes.md b/design/user-stories/us-004-assign-unlocked-classes.md index 1e9dfa5..f4d42b2 100644 --- a/design/user-stories/us-004-assign-unlocked-classes.md +++ b/design/user-stories/us-004-assign-unlocked-classes.md @@ -22,6 +22,7 @@ As the **Tyrant**, I want to assign unlocked specialist classes so that I can bu - [x] The Tyrant can assign and reassign purchased classes through the Tyrant control panel. - [x] The control panel's player selector communicates eligibility and identifies the current class holder before confirmation. - [x] Control-panel and command-based assignments enforce identical rules. +- [x] Removing or reassigning the Tamer class automatically releases the former holder's captured mobs while leaving ordinary inventory untouched. ## Related 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 fd785f0..0320f9e 100644 --- a/design/user-stories/us-007-capture-and-place-mobs.md +++ b/design/user-stories/us-007-capture-and-place-mobs.md @@ -21,6 +21,17 @@ As the **Tamer**, I want to capture a mob and release it elsewhere so that I can - [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. +- [x] When a player loses the Tamer class, every captured mob in their custody is automatically released at the former Tamer's location. +- [x] Ordinary inventory items are not dropped or changed during automatic release. +- [x] Each captured-mob item and custody record is removed only after its mob spawns successfully. +- [x] Multiple captured mobs are released without duplicating entities or custody records. +- [x] If the former Tamer is offline or no safe release location is available, release is deferred without losing the captured mob. +- [x] A deferred release is retried when the former Tamer next logs in or reaches a safe location. +- [x] Tyrant death, class reassignment, opt-out, administration, and other Tamer-removal paths use the same release behavior. + +## Validation + +Automated tests verify that only former Tamers are processed, offline custody remains deferred, successful releases remove only their corresponding custody records, and failed releases remain available for retry. The complete `./gradlew clean check jar` lifecycle passes. ## Related diff --git a/src/main/java/games/dmg/spigottyrant/BukkitCapturedMobSpawner.java b/src/main/java/games/dmg/spigottyrant/BukkitCapturedMobSpawner.java new file mode 100644 index 0000000..f610f31 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/BukkitCapturedMobSpawner.java @@ -0,0 +1,47 @@ +package games.dmg.spigottyrant; + +import org.bukkit.Location; +import org.bukkit.Server; +import org.bukkit.entity.EntitySnapshot; +import org.bukkit.entity.Player; + +public final class BukkitCapturedMobSpawner implements CapturedMobSpawner { + private final Server server; + private final PluginSettings settings; + + public BukkitCapturedMobSpawner(Server server, PluginSettings settings) { + this.server = server; + this.settings = settings; + } + + @Override + public boolean spawn(Player player, CapturedMob mob) { + Location location = player.getLocation(); + if (!safe(location) + || "ENDER_DRAGON".equals(mob.entityType()) + || settings.deniedMobTypes().contains(mob.entityType())) { + return false; + } + String serialized = mob.data().get("snapshot"); + if (serialized == null || serialized.isBlank()) { + return false; + } + try { + EntitySnapshot snapshot = server.getEntityFactory().createEntitySnapshot(serialized); + if (!snapshot.getEntityType().name().equals(mob.entityType())) { + return false; + } + snapshot.createEntity(location); + return true; + } catch (IllegalArgumentException | IllegalStateException exception) { + return false; + } + } + + 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/java/games/dmg/spigottyrant/CapturedMobCustodyRelease.java b/src/main/java/games/dmg/spigottyrant/CapturedMobCustodyRelease.java new file mode 100644 index 0000000..013c085 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/CapturedMobCustodyRelease.java @@ -0,0 +1,8 @@ +package games.dmg.spigottyrant; + +import org.bukkit.entity.Player; + +@FunctionalInterface +public interface CapturedMobCustodyRelease { + PlayerState releaseAll(Player player, PlayerState state); +} diff --git a/src/main/java/games/dmg/spigottyrant/CapturedMobSpawner.java b/src/main/java/games/dmg/spigottyrant/CapturedMobSpawner.java new file mode 100644 index 0000000..1d55695 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/CapturedMobSpawner.java @@ -0,0 +1,8 @@ +package games.dmg.spigottyrant; + +import org.bukkit.entity.Player; + +@FunctionalInterface +public interface CapturedMobSpawner { + boolean spawn(Player player, CapturedMob mob); +} diff --git a/src/main/java/games/dmg/spigottyrant/DefaultCapturedMobCustodyRelease.java b/src/main/java/games/dmg/spigottyrant/DefaultCapturedMobCustodyRelease.java new file mode 100644 index 0000000..76967a5 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/DefaultCapturedMobCustodyRelease.java @@ -0,0 +1,31 @@ +package games.dmg.spigottyrant; + +import java.util.ArrayList; +import java.util.List; +import org.bukkit.entity.Player; + +public final class DefaultCapturedMobCustodyRelease implements CapturedMobCustodyRelease { + private final CapturedMobSpawner spawner; + + public DefaultCapturedMobCustodyRelease(CapturedMobSpawner spawner) { + this.spawner = spawner; + } + + @Override + public PlayerState releaseAll(Player player, PlayerState state) { + List retained = new ArrayList<>(); + for (CapturedMob mob : state.capturedMobs()) { + if (!spawner.spawn(player, mob)) { + retained.add(mob); + } + } + if (retained.size() == state.capturedMobs().size()) { + return state; + } + return new PlayerState( + state.playerId(), state.latestName(), state.lastLogin(), state.optedOutUntil(), + state.tyrantClass(), state.followerOf(), state.cooldownEnds(), + state.readyAbilityItems(), retained + ); + } +} diff --git a/src/main/java/games/dmg/spigottyrant/FormerTamerReleaseTask.java b/src/main/java/games/dmg/spigottyrant/FormerTamerReleaseTask.java new file mode 100644 index 0000000..527d994 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/FormerTamerReleaseTask.java @@ -0,0 +1,60 @@ +package games.dmg.spigottyrant; + +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; +import org.bukkit.Server; +import org.bukkit.entity.Player; + +public final class FormerTamerReleaseTask implements Runnable { + private final TyrantStateManager stateManager; + private final Server server; + private final CapturedMobCustodyRelease release; + private final CapturedMobInventoryService inventory; + + public FormerTamerReleaseTask( + TyrantStateManager stateManager, + Server server, + CapturedMobCustodyRelease release + ) { + this(stateManager, server, release, null); + } + + public FormerTamerReleaseTask( + TyrantStateManager stateManager, + Server server, + CapturedMobCustodyRelease release, + CapturedMobInventoryService inventory + ) { + this.stateManager = stateManager; + this.server = server; + this.release = release; + this.inventory = inventory; + } + + @Override + public void run() { + Map online = server.getOnlinePlayers().stream() + .collect(Collectors.toMap(Player::getUniqueId, player -> player)); + for (PlayerState state : stateManager.players().values()) { + if (state.tyrantClass() == TyrantClass.TAMER || state.capturedMobs().isEmpty()) { + continue; + } + Player player = online.get(state.playerId()); + if (player == null || player.isDead()) { + continue; + } + PlayerState released = release.releaseAll(player, state); + if (released.equals(state)) { + continue; + } + stateManager.updatePlayer( + state.playerId(), state.latestName(), current -> released + ); + stateManager.saveIfDirty(); + if (inventory != null) { + inventory.reconcile(player, released); + } + } + } +} diff --git a/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java b/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java index 789d20e..4e009ff 100644 --- a/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java +++ b/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java @@ -64,6 +64,10 @@ public final class SpigotTyrantPlugin extends JavaPlugin { CapturedMobInventoryService capturedMobInventory = new CapturedMobInventoryService( capturedMobItems, settings ); + CapturedMobCustodyRelease formerTamerRelease = + new DefaultCapturedMobCustodyRelease( + new BukkitCapturedMobSpawner(getServer(), settings) + ); AssassinAbilityService assassinAbilities = new AssassinAbilityService( readiness, settings ); @@ -246,6 +250,14 @@ public final class SpigotTyrantPlugin extends JavaPlugin { 1L, 10L ); + getServer().getScheduler().runTaskTimer( + this, + new FormerTamerReleaseTask( + stateManager, getServer(), formerTamerRelease, capturedMobInventory + ), + 1L, + 10L + ); getServer().getScheduler().runTaskTimer(this, stateManager::saveIfDirty, 600L, 600L); getLogger().info("Spigot Tyrant enabled."); } diff --git a/src/test/java/games/dmg/spigottyrant/DefaultCapturedMobCustodyReleaseTest.java b/src/test/java/games/dmg/spigottyrant/DefaultCapturedMobCustodyReleaseTest.java new file mode 100644 index 0000000..371ad29 --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/DefaultCapturedMobCustodyReleaseTest.java @@ -0,0 +1,41 @@ +package games.dmg.spigottyrant; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +final class DefaultCapturedMobCustodyReleaseTest { + @Test + void removesOnlyCustodyThatSpawnedSuccessfully() { + UUID playerId = UUID.fromString("33333333-3333-3333-3333-333333333333"); + CapturedMob released = mob("COW"); + CapturedMob deferred = mob("SHEEP"); + PlayerState state = new PlayerState( + playerId, "Former", Optional.empty(), Optional.empty(), TyrantClass.NONE, + Optional.empty(), Map.of(), java.util.Set.of(), List.of(released, deferred) + ); + CapturedMobSpawner spawner = mock(CapturedMobSpawner.class); + Player player = mock(Player.class); + when(spawner.spawn(player, released)).thenReturn(true); + when(spawner.spawn(player, deferred)).thenReturn(false); + DefaultCapturedMobCustodyRelease service = new DefaultCapturedMobCustodyRelease(spawner); + + PlayerState result = service.releaseAll(player, state); + + assertEquals(List.of(deferred), result.capturedMobs()); + assertEquals(TyrantClass.NONE, result.tyrantClass()); + } + + private static CapturedMob mob(String type) { + return new CapturedMob(type, Map.of( + "capture-id", UUID.randomUUID().toString(), "snapshot", "snapshot-" + type + )); + } +} diff --git a/src/test/java/games/dmg/spigottyrant/FormerTamerReleaseTaskTest.java b/src/test/java/games/dmg/spigottyrant/FormerTamerReleaseTaskTest.java new file mode 100644 index 0000000..acba306 --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/FormerTamerReleaseTaskTest.java @@ -0,0 +1,78 @@ +package games.dmg.spigottyrant; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Map; +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 FormerTamerReleaseTaskTest { + @Test + void offlineFormerTamerCustodyRemainsDeferred() { + UUID formerId = UUID.fromString("33333333-3333-3333-3333-333333333333"); + PlayerState former = withMob(PlayerState.newPlayer(formerId, "Former")); + TyrantStateManager manager = mock(TyrantStateManager.class); + when(manager.players()).thenReturn(Map.of(formerId, former)); + Server server = mock(Server.class); + doReturn(Set.of()).when(server).getOnlinePlayers(); + CapturedMobCustodyRelease release = mock(CapturedMobCustodyRelease.class); + + new FormerTamerReleaseTask(manager, server, release).run(); + + verify(release, never()).releaseAll(any(), any()); + } + + @Test + void releasesCustodyOnlyAfterPlayerLosesTamerClass() { + UUID formerId = UUID.fromString("33333333-3333-3333-3333-333333333333"); + UUID tamerId = UUID.fromString("44444444-4444-4444-4444-444444444444"); + PlayerState former = withMob(PlayerState.newPlayer(formerId, "Former")); + PlayerState tamer = withMob(withClass( + PlayerState.newPlayer(tamerId, "Tamer"), TyrantClass.TAMER + )); + TyrantStateManager manager = mock(TyrantStateManager.class); + when(manager.players()).thenReturn(Map.of(formerId, former, tamerId, tamer)); + Player formerPlayer = mock(Player.class); + when(formerPlayer.getUniqueId()).thenReturn(formerId); + Player tamerPlayer = mock(Player.class); + when(tamerPlayer.getUniqueId()).thenReturn(tamerId); + Server server = mock(Server.class); + doReturn(Set.of(formerPlayer, tamerPlayer)).when(server).getOnlinePlayers(); + CapturedMobCustodyRelease release = mock(CapturedMobCustodyRelease.class); + when(release.releaseAll(formerPlayer, former)).thenReturn(former); + FormerTamerReleaseTask task = new FormerTamerReleaseTask(manager, server, release); + + task.run(); + + verify(release).releaseAll(formerPlayer, former); + verify(release, never()).releaseAll(tamerPlayer, tamer); + } + + private static PlayerState withMob(PlayerState player) { + CapturedMob mob = new CapturedMob("COW", Map.of( + "capture-id", UUID.randomUUID().toString(), "snapshot", "snapshot" + )); + return new PlayerState( + player.playerId(), player.latestName(), player.lastLogin(), player.optedOutUntil(), + player.tyrantClass(), player.followerOf(), player.cooldownEnds(), + player.readyAbilityItems(), java.util.List.of(mob) + ); + } + + private static PlayerState withClass(PlayerState player, TyrantClass tyrantClass) { + return new PlayerState( + player.playerId(), player.latestName(), player.lastLogin(), player.optedOutUntil(), + tyrantClass, player.followerOf(), player.cooldownEnds(), + player.readyAbilityItems(), player.capturedMobs() + ); + } +}