feat(vigilante): add arena boss succession
Release / release (push) Successful in 2m44s
CI / build (push) Successful in 1m3s

This commit is contained in:
dmg
2026-08-23 17:31:42 -04:00
parent 7318d4afa6
commit 10f670a7da
46 changed files with 1280 additions and 148 deletions
@@ -0,0 +1,27 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
final class ArenaGeometryTest {
private final ArenaLocation center = new ArenaLocation("world", 10.0, 64.0, -5.0, 0.0F, 0.0F);
@Test
void circleUsesHorizontalDistanceAndWorld() {
assertTrue(ArenaGeometry.contains(center, 10.0, "world", 16.0, 100.0, 3.0));
assertFalse(ArenaGeometry.contains(center, 10.0, "world", 21.0, 64.0, -5.0));
assertFalse(ArenaGeometry.contains(center, 10.0, "nether", 10.0, 64.0, -5.0));
}
@Test
void outsidePointProjectsBeyondNearestBoundary() {
ArenaPoint point = ArenaGeometry.outside(center, 10.0, 10.0, 64.0, -5.0);
assertEquals(21.0, point.x(), 0.001);
assertEquals(64.0, point.y(), 0.001);
assertEquals(-5.0, point.z(), 0.001);
}
}
@@ -0,0 +1,28 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.plugin.java.JavaPlugin;
import org.junit.jupiter.api.Test;
final class BukkitConfigArenaLocationStoreTest {
@Test
void arenaLocationRoundTripsThroughPluginConfiguration() {
JavaPlugin plugin = mock(JavaPlugin.class);
YamlConfiguration config = new YamlConfiguration();
when(plugin.getConfig()).thenReturn(config);
BukkitConfigArenaLocationStore store = new BukkitConfigArenaLocationStore(plugin);
ArenaLocation expected = new ArenaLocation(
"arena_world", 12.5, 70.0, -4.5, 90.0F, 5.0F
);
store.set(expected);
assertEquals(expected, store.location().orElseThrow());
verify(plugin).saveConfig();
}
}
@@ -24,6 +24,10 @@ final class PluginSettingsTest {
assertEquals(Duration.ofHours(1), settings.fixerCooldown());
assertEquals(Duration.ofMinutes(10), settings.fixerEffectDuration());
assertEquals(Duration.ofHours(24), settings.rosterIntelligenceCooldown());
assertEquals(10.0, settings.vigilanteArenaRadiusBlocks());
assertEquals(80.0, settings.vigilanteBossHealth());
assertEquals(8.0, settings.vigilanteBossDamage());
assertEquals(10.0, settings.vigilanteBossArmor());
}
@Test
@@ -67,5 +71,17 @@ final class PluginSettingsTest {
IllegalArgumentException.class,
() -> PluginSettings.from(Map.of("role-inactivity-seconds", -1))
);
assertThrows(
IllegalArgumentException.class,
() -> PluginSettings.from(Map.of("vigilante-arena-radius-blocks", 0))
);
assertThrows(
IllegalArgumentException.class,
() -> PluginSettings.from(Map.of("vigilante-boss-health", Double.NaN))
);
assertThrows(
IllegalArgumentException.class,
() -> PluginSettings.from(Map.of("vigilante-boss-armor", 31))
);
}
}
@@ -32,7 +32,7 @@ final class RoleMaintenanceServiceTest {
);
@Test
void onlinePendingVigilanteConfirmsWithoutConflictingWithTyrant() {
void legacyPendingVigilanteIsClearedForArenaSuccession() {
GameState game = game(
Optional.of(TYRANT), Optional.empty(), Optional.empty(),
Optional.of(new PendingSelection(CANDIDATE, NOW.plusSeconds(60)))
@@ -43,12 +43,12 @@ final class RoleMaintenanceServiceTest {
LifecycleState maintained = service.maintain(state, Set.of(TYRANT, CANDIDATE), NOW);
assertEquals(Optional.of(CANDIDATE), maintained.game().vigilanteId());
assertEquals(Optional.empty(), maintained.game().vigilanteId());
assertEquals(Optional.empty(), maintained.game().pendingVigilante());
}
@Test
void expiredPendingSelectionRerollsAwayFromPreviousCandidate() {
void expiredLegacyPendingVigilanteOpensArenaInsteadOfRerolling() {
GameState game = game(
Optional.of(TYRANT), Optional.empty(), Optional.empty(),
Optional.of(new PendingSelection(CANDIDATE, NOW.minusSeconds(1)))
@@ -59,12 +59,12 @@ final class RoleMaintenanceServiceTest {
LifecycleState maintained = service.maintain(state, Set.of(TYRANT), NOW);
assertEquals(OTHER,
maintained.game().pendingVigilante().orElseThrow().candidateId());
assertEquals(Optional.empty(), maintained.game().vigilanteId());
assertEquals(Optional.empty(), maintained.game().pendingVigilante());
}
@Test
void inactiveVigilanteIsRerolledAndFollowersAreCleared() {
void inactiveVigilanteOpensArenaAndFollowersAreCleared() {
PlayerState follower = withFollower(
withLogin(active(FOLLOWER), NOW.minus(Duration.ofHours(25))),
VIGILANTE
@@ -80,7 +80,7 @@ final class RoleMaintenanceServiceTest {
LifecycleState maintained = service.maintain(state, Set.of(TYRANT, OTHER), NOW);
assertEquals(Optional.of(OTHER), maintained.game().vigilanteId());
assertEquals(Optional.empty(), maintained.game().vigilanteId());
assertEquals(Optional.empty(), maintained.players().get(FOLLOWER).followerOf());
assertEquals(Optional.of(TYRANT), maintained.game().tyrantId());
}
@@ -13,7 +13,7 @@ import org.junit.jupiter.api.Test;
final class RoleRelinquishmentServiceTest {
@Test
void vigilanteCanRelinquishAndTriggerNormalReroll() {
void vigilanteCanRelinquishAndOpenArenaChallenge() {
UUID tyrant = UUID.fromString("11111111-1111-1111-1111-111111111111");
UUID vigilante = UUID.fromString("22222222-2222-2222-2222-222222222222");
UUID candidate = UUID.fromString("33333333-3333-3333-3333-333333333333");
@@ -49,7 +49,8 @@ final class RoleRelinquishmentServiceTest {
);
assertEquals(RelinquishmentStatus.RELINQUISHED, result.status());
assertEquals(Optional.of(candidate), result.state().game().vigilanteId());
assertEquals(Optional.empty(), result.state().game().vigilanteId());
assertEquals(Optional.empty(), result.state().game().pendingVigilante());
}
private static PlayerState active(UUID id, Instant now) {
@@ -14,8 +14,11 @@ import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
final class TyrantAdminCommandTest {
@@ -59,6 +62,30 @@ final class TyrantAdminCommandTest {
verify(effects).suppressAll();
}
@Test
void playerCanPersistArenaAtCurrentLocation() {
TyrantStateManager manager = mock(TyrantStateManager.class);
Player sender = mock(Player.class);
World world = mock(World.class);
when(sender.hasPermission("spigottyrant.admin")).thenReturn(true);
when(world.getName()).thenReturn("arena_world");
when(sender.getLocation()).thenReturn(new Location(world, 12.5, 70.0, -4.5, 90.0F, 0.0F));
ArenaLocationStore store = mock(ArenaLocationStore.class);
TyrantAdminCommand command = new TyrantAdminCommand(
manager, new GameLifecycleService(), mock(OnlinePlayerDirectory.class),
mock(ManagedRoleEffects.class),
Clock.fixed(Instant.parse("2026-08-14T12:00:00Z"), ZoneOffset.UTC), store
);
command.onCommand(sender, mock(Command.class), "tyrantadmin", new String[] {"arena", "set"});
verify(store).set(org.mockito.ArgumentMatchers.argThat(location ->
location.worldName().equals("arena_world")
&& location.x() == 12.5 && location.y() == 70.0 && location.z() == -4.5
));
verify(sender).sendMessage(contains("Vigilante arena set"));
}
@Test
void resetRequiresExplicitConfirmation() {
TyrantStateManager manager = mock(TyrantStateManager.class);
@@ -34,7 +34,9 @@ final class TyrantAdminTabCompleterTest {
));
assertTrue(completer.onTabComplete(
admin, command, "tyrantadmin", new String[] {""}
).containsAll(java.util.List.of("status", "start", "pause", "resume", "reset")));
).containsAll(java.util.List.of(
"status", "start", "pause", "resume", "reset", "arena"
)));
assertEquals(java.util.List.of("Alpha", "Beta"), completer.onTabComplete(
admin, command, "tyrantadmin", new String[] {"start", ""}
));
@@ -44,6 +46,9 @@ final class TyrantAdminTabCompleterTest {
assertEquals(java.util.List.of("confirm"), completer.onTabComplete(
admin, command, "tyrantadmin", new String[] {"reset", "c"}
));
assertEquals(java.util.List.of("set", "status"), completer.onTabComplete(
admin, command, "tyrantadmin", new String[] {"arena", "s"}
));
}
private static Player player(UUID id, String name) {
@@ -37,7 +37,8 @@ final class TyrantSuccessionServiceTest {
assertEquals(0, after.game().tyrantLevel());
assertEquals(1, after.game().unspentChoices());
assertEquals(Set.of(), after.game().purchases());
assertNotEquals(after.game().tyrantId(), after.game().vigilanteId());
assertEquals(Optional.empty(), after.game().vigilanteId());
assertEquals(Optional.empty(), after.game().pendingVigilante());
for (PlayerState player : after.players().values()) {
assertEquals(TyrantClass.NONE, player.tyrantClass());
assertEquals(Optional.empty(), player.followerOf());
@@ -0,0 +1,56 @@
package games.dmg.spigottyrant;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.atLeastOnce;
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 org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import org.junit.jupiter.api.Test;
final class VigilanteArenaBarrierTest {
private static final ArenaLocation CENTER = new ArenaLocation(
"world", 0.0, 64.0, 0.0, 0.0F, 0.0F
);
@Test
void doesNotReplaceExistingNonAirBlocks() {
World world = mock(World.class);
Block solid = mock(Block.class);
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(solid);
when(solid.getLocation()).thenReturn(new Location(world, 1.0, 64.0, 0.0));
when(solid.getType()).thenReturn(Material.CHEST);
new VigilanteArenaBarrier().build(world, CENTER, 10.0);
verify(solid, never()).setType(Material.BARRIER, false);
}
@Test
void restoresAirReplacedByTemporaryBarrier() {
World world = mock(World.class);
Block air = mock(Block.class);
BlockData original = mock(BlockData.class);
BlockData snapshot = mock(BlockData.class);
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(air);
when(world.getBlockAt(any(Location.class))).thenReturn(air);
when(air.getLocation()).thenReturn(new Location(world, 1.0, 64.0, 0.0));
when(air.getType()).thenReturn(Material.AIR);
when(air.getBlockData()).thenReturn(original);
when(original.clone()).thenReturn(snapshot);
VigilanteArenaBarrier barrier = new VigilanteArenaBarrier();
barrier.build(world, CENTER, 10.0);
barrier.clear();
verify(air, atLeastOnce()).setType(Material.BARRIER, false);
verify(air).setBlockData(snapshot, false);
}
}
@@ -0,0 +1,80 @@
package games.dmg.spigottyrant;
import static org.mockito.ArgumentMatchers.contains;
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.time.Duration;
import java.time.Instant;
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.bukkit.event.player.PlayerJoinEvent;
import org.junit.jupiter.api.Test;
final class VigilanteArenaControllerTest {
private static final UUID TYRANT = UUID.fromString("11111111-1111-1111-1111-111111111111");
private static final UUID PLAYER = UUID.fromString("22222222-2222-2222-2222-222222222222");
@Test
void optedInPlayerJoiningDuringVacancyReceivesArenaCoordinates() {
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(PLAYER);
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.game()).thenReturn(openGame());
when(manager.players()).thenReturn(Map.of(PLAYER, PlayerState.newPlayer(PLAYER, "Player")));
ArenaLocationStore locations = mock(ArenaLocationStore.class);
when(locations.location()).thenReturn(Optional.of(
new ArenaLocation("world", 10.0, 64.0, -5.0, 0.0F, 0.0F)
));
VigilanteArenaController controller = new VigilanteArenaController(
manager, mock(Server.class), locations, PluginSettings.from(Map.of())
);
controller.onJoin(mockJoin(player));
verify(player).sendMessage(contains("world at 10, 64, -5"));
}
@Test
void optedOutPlayerDoesNotReceiveVacancyNotice() {
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(PLAYER);
PlayerState optedOut = new PlayerState(
PLAYER, "Player", Optional.empty(), Optional.of(Instant.parse("2026-08-24T00:00:00Z")),
TyrantClass.NONE, Optional.empty(), Map.of(), Set.of(), java.util.List.of()
);
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.game()).thenReturn(openGame());
when(manager.players()).thenReturn(Map.of(PLAYER, optedOut));
ArenaLocationStore locations = mock(ArenaLocationStore.class);
when(locations.location()).thenReturn(Optional.of(
new ArenaLocation("world", 10.0, 64.0, -5.0, 0.0F, 0.0F)
));
VigilanteArenaController controller = new VigilanteArenaController(
manager, mock(Server.class), locations, PluginSettings.from(Map.of())
);
controller.onJoin(mockJoin(player));
verify(player, never()).sendMessage(org.mockito.ArgumentMatchers.anyString());
}
private static PlayerJoinEvent mockJoin(Player player) {
PlayerJoinEvent event = mock(PlayerJoinEvent.class);
when(event.getPlayer()).thenReturn(player);
return event;
}
private static GameState openGame() {
return new GameState(
GameLifecycle.RUNNING, Optional.of(TYRANT), Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(), Duration.ZERO, 0, 0, Set.of()
);
}
}
@@ -0,0 +1,61 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class VigilanteArenaPolicyTest {
private static final UUID TYRANT = UUID.fromString("11111111-1111-1111-1111-111111111111");
private static final UUID PLAYER = UUID.fromString("22222222-2222-2222-2222-222222222222");
@Test
void opensOnlyForRunningGameWithTyrantAndVacantVigilante() {
assertTrue(VigilanteArenaPolicy.isOpen(game(
GameLifecycle.RUNNING, Optional.of(TYRANT), Optional.empty()
)));
assertFalse(VigilanteArenaPolicy.isOpen(game(
GameLifecycle.PAUSED, Optional.of(TYRANT), Optional.empty()
)));
assertFalse(VigilanteArenaPolicy.isOpen(game(
GameLifecycle.RUNNING, Optional.empty(), Optional.empty()
)));
assertFalse(VigilanteArenaPolicy.isOpen(game(
GameLifecycle.RUNNING, Optional.of(TYRANT), Optional.of(PLAYER)
)));
}
@Test
void onlyOptedInNonTyrantMayChallenge() {
GameState game = game(GameLifecycle.RUNNING, Optional.of(TYRANT), Optional.empty());
PlayerState eligible = PlayerState.newPlayer(PLAYER, "Player");
PlayerState optedOut = new PlayerState(
PLAYER, "Player", Optional.empty(), Optional.of(Instant.parse("2026-08-24T12:00:00Z")),
TyrantClass.NONE, Optional.empty(), Map.of(), Set.of(), java.util.List.of()
);
assertTrue(VigilanteArenaPolicy.isEligible(game, eligible));
assertFalse(VigilanteArenaPolicy.isEligible(game, optedOut));
assertFalse(VigilanteArenaPolicy.isEligible(game,
PlayerState.newPlayer(TYRANT, "Tyrant")));
}
private static GameState game(
GameLifecycle lifecycle,
Optional<UUID> tyrant,
Optional<UUID> vigilante
) {
return new GameState(
lifecycle, tyrant, vigilante, Optional.empty(), Optional.empty(),
lifecycle == GameLifecycle.PAUSED
? Optional.of(Instant.parse("2026-08-23T12:00:00Z")) : Optional.empty(),
Duration.ZERO, 0, 0, Set.of()
);
}
}
@@ -0,0 +1,30 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.time.Duration;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class VigilanteArenaSuccessionServiceTest {
@Test
void eligibleBossWinnerClaimsVacantVigilanteRole() {
UUID tyrant = UUID.fromString("11111111-1111-1111-1111-111111111111");
UUID winner = UUID.fromString("22222222-2222-2222-2222-222222222222");
GameState game = new GameState(
GameLifecycle.RUNNING, Optional.of(tyrant), Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(), Duration.ZERO, 0, 0, Set.of()
);
PersistentState before = new PersistentState(
game, Map.of(winner, PlayerState.newPlayer(winner, "Winner"))
);
LifecycleState claimed = new VigilanteArenaSuccessionService().claim(before, winner);
assertEquals(Optional.of(winner), claimed.game().vigilanteId());
assertEquals(Optional.empty(), claimed.game().pendingVigilante());
}
}