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
@@ -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()
);
}
}