feat(tamer): release mobs after class loss
Release / release (push) Successful in 2m40s
CI / build (push) Successful in 1m13s

This commit is contained in:
dmg
2026-08-21 21:37:09 -04:00
parent 0b202f588d
commit 34f32cd32d
11 changed files with 309 additions and 0 deletions
+12
View File
@@ -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.
@@ -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
@@ -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
@@ -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);
}
}
@@ -0,0 +1,8 @@
package games.dmg.spigottyrant;
import org.bukkit.entity.Player;
@FunctionalInterface
public interface CapturedMobCustodyRelease {
PlayerState releaseAll(Player player, PlayerState state);
}
@@ -0,0 +1,8 @@
package games.dmg.spigottyrant;
import org.bukkit.entity.Player;
@FunctionalInterface
public interface CapturedMobSpawner {
boolean spawn(Player player, CapturedMob mob);
}
@@ -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<CapturedMob> 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
);
}
}
@@ -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<UUID, Player> 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);
}
}
}
}
@@ -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.");
}
@@ -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
));
}
}
@@ -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()
);
}
}