feat(tamer): limit custody to one mob
Release / release (push) Successful in 2m42s
CI / build (push) Successful in 1m11s

This commit is contained in:
dmg
2026-08-21 21:47:42 -04:00
parent 34f32cd32d
commit 80810b3ba1
11 changed files with 182 additions and 8 deletions
@@ -2,7 +2,8 @@ package games.dmg.spigottyrant;
import org.bukkit.entity.Player;
@FunctionalInterface
public interface CapturedMobCustodyRelease {
PlayerState releaseAll(Player player, PlayerState state);
PlayerState releaseExcess(Player player, PlayerState state, int custodyLimit);
}
@@ -16,6 +16,11 @@ public final class CapturedMobService {
if (player.tyrantClass() != TyrantClass.TAMER) {
throw new IllegalStateException("only a Tamer can capture mobs");
}
if (!player.capturedMobs().isEmpty()) {
throw new IllegalStateException(
"release the currently captured mob before capturing another"
);
}
if ("ENDER_DRAGON".equalsIgnoreCase(entityType)) {
throw new IllegalArgumentException("Ender Dragons cannot be captured");
}
@@ -13,13 +13,27 @@ public final class DefaultCapturedMobCustodyRelease implements CapturedMobCustod
@Override
public PlayerState releaseAll(Player player, PlayerState state) {
return releaseExcess(player, state, 0);
}
@Override
public PlayerState releaseExcess(
Player player,
PlayerState state,
int custodyLimit
) {
if (custodyLimit < 0) {
throw new IllegalArgumentException("custodyLimit must not be negative");
}
List<CapturedMob> retained = new ArrayList<>();
for (CapturedMob mob : state.capturedMobs()) {
if (!spawner.spawn(player, mob)) {
List<CapturedMob> custody = state.capturedMobs();
for (int index = 0; index < custody.size(); index++) {
CapturedMob mob = custody.get(index);
if (index < custodyLimit || !spawner.spawn(player, mob)) {
retained.add(mob);
}
}
if (retained.size() == state.capturedMobs().size()) {
if (retained.equals(custody)) {
return state;
}
return new PlayerState(
@@ -37,14 +37,18 @@ public final class FormerTamerReleaseTask implements Runnable {
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()) {
if (state.capturedMobs().isEmpty()
|| state.tyrantClass() == TyrantClass.TAMER
&& state.capturedMobs().size() == 1) {
continue;
}
Player player = online.get(state.playerId());
if (player == null || player.isDead()) {
continue;
}
PlayerState released = release.releaseAll(player, state);
PlayerState released = state.tyrantClass() == TyrantClass.TAMER
? release.releaseExcess(player, state, 1)
: release.releaseAll(player, state);
if (released.equals(state)) {
continue;
}
@@ -63,6 +63,11 @@ public final class TamerListener implements Listener {
return;
}
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
if (!state.capturedMobs().isEmpty()) {
player.sendMessage(ChatColor.RED
+ "Release the currently captured mob before capturing another.");
return;
}
Entity target = event.getRightClicked();
if (!canCapture(state, target)) {
player.sendMessage(ChatColor.RED + "That mob cannot be captured.");
@@ -28,6 +28,21 @@ final class CapturedMobServiceTest {
assertEquals(java.util.List.of(), released.capturedMobs());
}
@Test
void tamerMustReleaseHeldMobBeforeCapturingAnother() {
PlayerState holding = service.capture(
tamer(), UUID.randomUUID(), "COW", "cow-snapshot"
);
IllegalStateException exception = assertThrows(IllegalStateException.class, () ->
service.capture(holding, UUID.randomUUID(), "SHEEP", "sheep-snapshot")
);
assertEquals("release the currently captured mob before capturing another",
exception.getMessage());
assertEquals(1, holding.capturedMobs().size());
}
@Test
void enderDragonCanNeverBeCapturedAndMissingReleaseDoesNotMutate() {
assertThrows(IllegalArgumentException.class, () -> service.capture(
@@ -12,6 +12,28 @@ import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
final class DefaultCapturedMobCustodyReleaseTest {
@Test
void legacyExcessReleaseRetainsOneAndAnyFailedExtra() {
UUID playerId = UUID.fromString("33333333-3333-3333-3333-333333333333");
CapturedMob retained = mob("COW");
CapturedMob releasedExtra = mob("SHEEP");
CapturedMob deferredExtra = mob("PIG");
PlayerState state = new PlayerState(
playerId, "Tamer", Optional.empty(), Optional.empty(), TyrantClass.TAMER,
Optional.empty(), Map.of(), java.util.Set.of(),
List.of(retained, releasedExtra, deferredExtra)
);
CapturedMobSpawner spawner = mock(CapturedMobSpawner.class);
Player player = mock(Player.class);
when(spawner.spawn(player, releasedExtra)).thenReturn(true);
when(spawner.spawn(player, deferredExtra)).thenReturn(false);
DefaultCapturedMobCustodyRelease service = new DefaultCapturedMobCustodyRelease(spawner);
PlayerState result = service.releaseExcess(player, state, 1);
assertEquals(List.of(retained, deferredExtra), result.capturedMobs());
}
@Test
void removesOnlyCustodyThatSpawnedSuccessfully() {
UUID playerId = UUID.fromString("33333333-3333-3333-3333-333333333333");
@@ -31,6 +31,28 @@ final class FormerTamerReleaseTaskTest {
verify(release, never()).releaseAll(any(), any());
}
@Test
void safelyReleasesLegacyCustodyAboveOneForActiveTamer() {
UUID tamerId = UUID.fromString("44444444-4444-4444-4444-444444444444");
PlayerState once = withMob(withClass(
PlayerState.newPlayer(tamerId, "Tamer"), TyrantClass.TAMER
));
PlayerState legacy = withMob(once);
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.players()).thenReturn(Map.of(tamerId, legacy));
Player tamer = mock(Player.class);
when(tamer.getUniqueId()).thenReturn(tamerId);
Server server = mock(Server.class);
doReturn(Set.of(tamer)).when(server).getOnlinePlayers();
CapturedMobCustodyRelease release = mock(CapturedMobCustodyRelease.class);
when(release.releaseExcess(tamer, legacy, 1)).thenReturn(legacy);
new FormerTamerReleaseTask(manager, server, release).run();
verify(release).releaseExcess(tamer, legacy, 1);
verify(release, never()).releaseAll(any(), any());
}
@Test
void releasesCustodyOnlyAfterPlayerLosesTamerClass() {
UUID formerId = UUID.fromString("33333333-3333-3333-3333-333333333333");
@@ -61,10 +83,12 @@ final class FormerTamerReleaseTaskTest {
CapturedMob mob = new CapturedMob("COW", Map.of(
"capture-id", UUID.randomUUID().toString(), "snapshot", "snapshot"
));
java.util.List<CapturedMob> custody = new java.util.ArrayList<>(player.capturedMobs());
custody.add(mob);
return new PlayerState(
player.playerId(), player.latestName(), player.lastLogin(), player.optedOutUntil(),
player.tyrantClass(), player.followerOf(), player.cooldownEnds(),
player.readyAbilityItems(), java.util.List.of(mob)
player.readyAbilityItems(), custody
);
}
@@ -0,0 +1,66 @@
package games.dmg.spigottyrant;
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.Entity;
import org.bukkit.entity.Player;
import org.bukkit.event.player.PlayerInteractEntityEvent;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
import org.junit.jupiter.api.Test;
final class TamerListenerTest {
@Test
void heldMobRejectsAnotherCaptureBeforeTargetOrInventoryChanges() {
UUID playerId = UUID.fromString("33333333-3333-3333-3333-333333333333");
CapturedMob heldMob = new CapturedMob("COW", Map.of(
"capture-id", UUID.randomUUID().toString(), "snapshot", "cow"
));
PlayerState state = new PlayerState(
playerId, "Tamer", Optional.empty(), Optional.empty(), TyrantClass.TAMER,
Optional.empty(), Map.of(), Set.of(Ability.TAMER_CAPTURE),
java.util.List.of(heldMob)
);
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Tamer");
PlayerInventory inventory = mock(PlayerInventory.class);
when(player.getInventory()).thenReturn(inventory);
ItemStack lead = mock(ItemStack.class);
when(inventory.getItem(org.bukkit.inventory.EquipmentSlot.HAND)).thenReturn(lead);
Entity target = mock(Entity.class);
PlayerInteractEntityEvent event = mock(PlayerInteractEntityEvent.class);
when(event.getPlayer()).thenReturn(player);
when(event.getHand()).thenReturn(org.bukkit.inventory.EquipmentSlot.HAND);
when(event.getRightClicked()).thenReturn(target);
AbilityItemService abilityItems = mock(AbilityItemService.class);
when(abilityItems.ability(lead)).thenReturn(Optional.of(Ability.TAMER_CAPTURE));
when(abilityItems.owner(lead)).thenReturn(Optional.of(playerId));
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.player(playerId, "Tamer")).thenReturn(state);
CapturedMobItemService capturedItems = mock(CapturedMobItemService.class);
TamerListener listener = new TamerListener(
manager, abilityItems, capturedItems, new CapturedMobService(),
PluginSettings.from(Map.of()), mock(Server.class)
);
listener.onCapture(event);
verify(player).sendMessage(org.mockito.ArgumentMatchers.contains(
"Release the currently captured mob"
));
verify(target, never()).createSnapshot();
verify(capturedItems, never()).create(
org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.anyString()
);
}
}