feat(tamer): release mobs after class loss
This commit is contained in:
@@ -6,6 +6,18 @@ description: Chronological record of material decisions affecting the Spigot Tyr
|
|||||||
|
|
||||||
# Spigot Tyrant Design Log
|
# 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
|
## 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.
|
- 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 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] 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] 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
|
## 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] 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] 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] 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
|
## 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(
|
CapturedMobInventoryService capturedMobInventory = new CapturedMobInventoryService(
|
||||||
capturedMobItems, settings
|
capturedMobItems, settings
|
||||||
);
|
);
|
||||||
|
CapturedMobCustodyRelease formerTamerRelease =
|
||||||
|
new DefaultCapturedMobCustodyRelease(
|
||||||
|
new BukkitCapturedMobSpawner(getServer(), settings)
|
||||||
|
);
|
||||||
AssassinAbilityService assassinAbilities = new AssassinAbilityService(
|
AssassinAbilityService assassinAbilities = new AssassinAbilityService(
|
||||||
readiness, settings
|
readiness, settings
|
||||||
);
|
);
|
||||||
@@ -246,6 +250,14 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
|
|||||||
1L,
|
1L,
|
||||||
10L
|
10L
|
||||||
);
|
);
|
||||||
|
getServer().getScheduler().runTaskTimer(
|
||||||
|
this,
|
||||||
|
new FormerTamerReleaseTask(
|
||||||
|
stateManager, getServer(), formerTamerRelease, capturedMobInventory
|
||||||
|
),
|
||||||
|
1L,
|
||||||
|
10L
|
||||||
|
);
|
||||||
getServer().getScheduler().runTaskTimer(this, stateManager::saveIfDirty, 600L, 600L);
|
getServer().getScheduler().runTaskTimer(this, stateManager::saveIfDirty, 600L, 600L);
|
||||||
getLogger().info("Spigot Tyrant enabled.");
|
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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user