diff --git a/design/log.md b/design/log.md index c66cbdf..419358a 100644 --- a/design/log.md +++ b/design/log.md @@ -6,6 +6,17 @@ description: Chronological record of material decisions affecting the Spigot Tyr # Spigot Tyrant Design Log +## 2026-08-14 — Tyrant succession completed + +- Completed US-002 with player-killer transfer, recent-player environmental succession, former-holder avoidance, pending offline confirmation, and distinct replacement Vigilante selection. +- Tyrant death clears all classes, Followers, cooldowns, item readiness, purchases, levels, and choices before granting the successor one starting choice. +- Added death-event integration, server announcements, and a scoreboard-team-backed red Tyrant name and glowing outline that is restored on login and removed on pause or shutdown. +- Verified succession attribution, random exclusion, reign cleanup, role separation, death integration, and red presentation with the Gradle suite. + +## 2026-08-14 — Tyrant succession implementation started + +- US-002 begins with test-first killer and environmental succession, reign-wide cleanup, replacement Vigilante selection, and red Tyrant presentation. + ## 2026-08-14 — Administrative lifecycle completed - Completed US-001 with permission-gated `/tyrantadmin` start, status, pause, resume, and confirmed reset operations. diff --git a/design/user-stories/us-002-transfer-and-reset-tyrant.md b/design/user-stories/us-002-transfer-and-reset-tyrant.md index 134f7c1..c93585d 100644 --- a/design/user-stories/us-002-transfer-and-reset-tyrant.md +++ b/design/user-stories/us-002-transfer-and-reset-tyrant.md @@ -2,7 +2,7 @@ type: User Story title: "US-002: Transfer and reset the Tyrant role" description: End a Tyrant's reign cleanly and establish a valid successor after death. -status: backlog +status: done --- # US-002: Transfer and reset the Tyrant role @@ -11,15 +11,15 @@ As a **player**, I want defeating the Tyrant to transfer power predictably so th ## Acceptance criteria -- [ ] A player directly credited with killing the Tyrant becomes the new Tyrant. -- [ ] A Tyrant death without a player killer selects an eligible opted-in player active within the preceding 24 hours at random. -- [ ] The former Tyrant is excluded from random succession when another eligible candidate exists. -- [ ] The former Tyrant's level, unlocks, unspent choices, and Tyrant ability cooldowns reset when the reign ends. -- [ ] Every Assassin, Fixer, Tamer, Vigilante, and Follower assignment is cleared when the Tyrant dies. -- [ ] A new Vigilante is selected under the normal candidate rules after succession. -- [ ] The new Tyrant receives exactly one starting unlock choice. -- [ ] The Tyrant's displayed name is red and the Tyrant has a red glowing outline while the game is running. -- [ ] Succession cannot assign the same player as both Tyrant and Vigilante. +- [x] A player directly credited with killing the Tyrant becomes the new Tyrant. +- [x] A Tyrant death without a player killer selects an eligible opted-in player active within the preceding 24 hours at random. +- [x] The former Tyrant is excluded from random succession when another eligible candidate exists. +- [x] The former Tyrant's level, unlocks, unspent choices, and Tyrant ability cooldowns reset when the reign ends. +- [x] Every Assassin, Fixer, Tamer, Vigilante, and Follower assignment is cleared when the Tyrant dies. +- [x] A new Vigilante is selected under the normal candidate rules after succession. +- [x] The new Tyrant receives exactly one starting unlock choice. +- [x] The Tyrant's displayed name is red and the Tyrant has a red glowing outline while the game is running. +- [x] Succession cannot assign the same player as both Tyrant and Vigilante. ## Related diff --git a/src/main/java/games/dmg/spigottyrant/BukkitManagedRoleEffects.java b/src/main/java/games/dmg/spigottyrant/BukkitManagedRoleEffects.java new file mode 100644 index 0000000..d5585b5 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/BukkitManagedRoleEffects.java @@ -0,0 +1,28 @@ +package games.dmg.spigottyrant; + +public final class BukkitManagedRoleEffects implements ManagedRoleEffects { + private final TyrantStateManager stateManager; + private final TyrantPresentation presentation; + + public BukkitManagedRoleEffects( + TyrantStateManager stateManager, + TyrantPresentation presentation + ) { + this.stateManager = stateManager; + this.presentation = presentation; + } + + @Override + public void suppressAll() { + presentation.clear(); + } + + @Override + public void restoreAll() { + if (stateManager.game().lifecycle() == GameLifecycle.RUNNING) { + presentation.reconcile(stateManager.game().tyrantId()); + } else { + presentation.clear(); + } + } +} diff --git a/src/main/java/games/dmg/spigottyrant/BukkitTyrantPresentation.java b/src/main/java/games/dmg/spigottyrant/BukkitTyrantPresentation.java new file mode 100644 index 0000000..36a31ee --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/BukkitTyrantPresentation.java @@ -0,0 +1,55 @@ +package games.dmg.spigottyrant; + +import java.util.HashSet; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import org.bukkit.ChatColor; +import org.bukkit.Server; +import org.bukkit.entity.Player; +import org.bukkit.scoreboard.Scoreboard; +import org.bukkit.scoreboard.ScoreboardManager; +import org.bukkit.scoreboard.Team; + +public final class BukkitTyrantPresentation implements TyrantPresentation { + private static final String TEAM_NAME = "tyrant-red"; + private final Server server; + private final Team team; + private Optional presentedTyrant = Optional.empty(); + + public BukkitTyrantPresentation(Server server) { + this.server = server; + ScoreboardManager manager = Objects.requireNonNull( + server.getScoreboardManager(), "Scoreboard manager is unavailable" + ); + Scoreboard scoreboard = manager.getMainScoreboard(); + Team existing = scoreboard.getTeam(TEAM_NAME); + team = existing == null ? scoreboard.registerNewTeam(TEAM_NAME) : existing; + team.setColor(ChatColor.RED); + } + + @Override + public void reconcile(Optional tyrantId) { + clear(); + presentedTyrant = tyrantId; + tyrantId.map(server::getPlayer).ifPresent(player -> { + team.addEntry(player.getName()); + player.setGlowing(true); + player.setPlayerListName(ChatColor.RED + player.getName()); + player.setDisplayName(ChatColor.RED + player.getName()); + }); + } + + @Override + public void clear() { + presentedTyrant.map(server::getPlayer).ifPresent(player -> { + player.setGlowing(false); + player.setPlayerListName(player.getName()); + player.setDisplayName(player.getName()); + }); + for (String entry : new HashSet<>(team.getEntries())) { + team.removeEntry(entry); + } + presentedTyrant = Optional.empty(); + } +} diff --git a/src/main/java/games/dmg/spigottyrant/PlayerJoinListener.java b/src/main/java/games/dmg/spigottyrant/PlayerJoinListener.java index fec92fa..4f9dc7c 100644 --- a/src/main/java/games/dmg/spigottyrant/PlayerJoinListener.java +++ b/src/main/java/games/dmg/spigottyrant/PlayerJoinListener.java @@ -8,10 +8,20 @@ import org.bukkit.event.player.PlayerJoinEvent; public final class PlayerJoinListener implements Listener { private final TyrantStateManager stateManager; private final Clock clock; + private final TyrantPresentation presentation; public PlayerJoinListener(TyrantStateManager stateManager, Clock clock) { + this(stateManager, clock, null); + } + + public PlayerJoinListener( + TyrantStateManager stateManager, + Clock clock, + TyrantPresentation presentation + ) { this.stateManager = stateManager; this.clock = clock; + this.presentation = presentation; } @EventHandler @@ -29,5 +39,11 @@ public final class PlayerJoinListener implements Listener { if (!reminder.isEmpty()) { event.getPlayer().sendMessage(reminder); } + if (presentation != null + && stateManager.game().lifecycle() == GameLifecycle.RUNNING + && stateManager.game().tyrantId() + .filter(event.getPlayer().getUniqueId()::equals).isPresent()) { + presentation.reconcile(stateManager.game().tyrantId()); + } } } diff --git a/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java b/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java index 27340f9..c83dd64 100644 --- a/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java +++ b/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java @@ -10,6 +10,7 @@ import org.bukkit.plugin.java.JavaPlugin; public final class SpigotTyrantPlugin extends JavaPlugin { private PluginSettings settings; private TyrantStateManager stateManager; + private TyrantPresentation tyrantPresentation; @Override public void onEnable() { @@ -27,6 +28,11 @@ public final class SpigotTyrantPlugin extends JavaPlugin { return; } + tyrantPresentation = new BukkitTyrantPresentation(getServer()); + ManagedRoleEffects managedEffects = new BukkitManagedRoleEffects( + stateManager, tyrantPresentation + ); + managedEffects.restoreAll(); Objects.requireNonNull(getCommand("tyrant"), "Missing tyrant command metadata") .setExecutor(new TyrantCommand(stateManager, new TyrantProgressionService())); Objects.requireNonNull(getCommand("tyrantadmin"), "Missing tyrantadmin metadata") @@ -34,7 +40,7 @@ public final class SpigotTyrantPlugin extends JavaPlugin { stateManager, new GameLifecycleService(), new BukkitOnlinePlayerDirectory(), - new NoopManagedRoleEffects(), + managedEffects, Clock.systemUTC(), new RoleCandidateSelector(), settings.candidateActivityWindow(), @@ -42,7 +48,22 @@ public final class SpigotTyrantPlugin extends JavaPlugin { java.util.random.RandomGenerator.getDefault() )); getServer().getPluginManager().registerEvents( - new PlayerJoinListener(stateManager, Clock.systemUTC()), + new PlayerJoinListener(stateManager, Clock.systemUTC(), tyrantPresentation), + this + ); + getServer().getPluginManager().registerEvents( + new TyrantDeathListener( + stateManager, + new TyrantSuccessionService( + new RoleCandidateSelector(), + settings.candidateActivityWindow(), + settings.pendingSelectionTimeout(), + java.util.random.RandomGenerator.getDefault() + ), + tyrantPresentation, + getServer(), + Clock.systemUTC() + ), this ); getServer().getScheduler().runTaskTimer(this, stateManager::saveIfDirty, 600L, 600L); @@ -51,6 +72,9 @@ public final class SpigotTyrantPlugin extends JavaPlugin { @Override public void onDisable() { + if (tyrantPresentation != null) { + tyrantPresentation.clear(); + } if (stateManager != null) { stateManager.saveIfDirty(); } diff --git a/src/main/java/games/dmg/spigottyrant/TyrantDeathListener.java b/src/main/java/games/dmg/spigottyrant/TyrantDeathListener.java new file mode 100644 index 0000000..7a0d797 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/TyrantDeathListener.java @@ -0,0 +1,65 @@ +package games.dmg.spigottyrant; + +import java.time.Clock; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import org.bukkit.ChatColor; +import org.bukkit.Server; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.PlayerDeathEvent; + +public final class TyrantDeathListener implements Listener { + private final TyrantStateManager stateManager; + private final TyrantSuccessionService succession; + private final TyrantPresentation presentation; + private final Server server; + private final Clock clock; + + public TyrantDeathListener( + TyrantStateManager stateManager, + TyrantSuccessionService succession, + TyrantPresentation presentation, + Server server, + Clock clock + ) { + this.stateManager = stateManager; + this.succession = succession; + this.presentation = presentation; + this.server = server; + this.clock = clock; + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onDeath(PlayerDeathEvent event) { + UUID victimId = event.getEntity().getUniqueId(); + GameState game = stateManager.game(); + if (game.lifecycle() != GameLifecycle.RUNNING + || game.tyrantId().filter(victimId::equals).isEmpty()) { + return; + } + Optional killerId = Optional.ofNullable(event.getEntity().getKiller()) + .map(Player::getUniqueId); + Set online = server.getOnlinePlayers().stream() + .map(Player::getUniqueId) + .collect(Collectors.toUnmodifiableSet()); + LifecycleState succeeded = succession.succeed( + stateManager.snapshot(), killerId, online, clock.instant() + ); + stateManager.replaceState(succeeded); + stateManager.saveIfDirty(); + presentation.reconcile(succeeded.game().tyrantId()); + String successor = succeeded.game().tyrantId() + .map(id -> { + Player player = server.getPlayer(id); + return player == null ? id.toString() : player.getName(); + }) + .orElse("pending selection"); + String message = ChatColor.RED + successor + " is the new Tyrant!"; + server.getOnlinePlayers().forEach(player -> player.sendMessage(message)); + } +} diff --git a/src/main/java/games/dmg/spigottyrant/TyrantPresentation.java b/src/main/java/games/dmg/spigottyrant/TyrantPresentation.java new file mode 100644 index 0000000..d505e4b --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/TyrantPresentation.java @@ -0,0 +1,10 @@ +package games.dmg.spigottyrant; + +import java.util.Optional; +import java.util.UUID; + +public interface TyrantPresentation { + void reconcile(Optional tyrantId); + + void clear(); +} diff --git a/src/main/java/games/dmg/spigottyrant/TyrantSuccessionService.java b/src/main/java/games/dmg/spigottyrant/TyrantSuccessionService.java new file mode 100644 index 0000000..5319115 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/TyrantSuccessionService.java @@ -0,0 +1,109 @@ +package games.dmg.spigottyrant; + +import java.time.Duration; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.random.RandomGenerator; + +public final class TyrantSuccessionService { + private final RoleCandidateSelector candidates; + private final Duration activityWindow; + private final Duration pendingTimeout; + private final RandomGenerator random; + + public TyrantSuccessionService( + RoleCandidateSelector candidates, + Duration activityWindow, + Duration pendingTimeout, + RandomGenerator random + ) { + this.candidates = candidates; + this.activityWindow = activityWindow; + this.pendingTimeout = pendingTimeout; + this.random = random; + } + + public LifecycleState succeed( + PersistentState state, + Optional killerId, + Set onlinePlayerIds, + Instant now + ) { + if (state.game().lifecycle() != GameLifecycle.RUNNING + || state.game().tyrantId().isEmpty()) { + throw new IllegalStateException("a running Tyrant reign is required"); + } + UUID formerTyrant = state.game().tyrantId().orElseThrow(); + Optional formerVigilante = state.game().vigilanteId(); + Map clearedPlayers = clearAssignments(state.players()); + Optional selectedTyrant = killerId.isPresent() + ? killerId + : candidates.select( + clearedPlayers, + Set.of(), + Optional.of(formerTyrant), + now, + activityWindow, + random + ); + Optional activeTyrant = selectedTyrant + .filter(playerId -> killerId.isPresent() || onlinePlayerIds.contains(playerId)); + Optional pendingTyrant = selectedTyrant + .filter(playerId -> activeTyrant.isEmpty()) + .map(playerId -> new PendingSelection(playerId, now.plus(pendingTimeout))); + + Optional selectedVigilante = activeTyrant.flatMap(tyrantId -> + candidates.select( + clearedPlayers, + Set.of(tyrantId), + formerVigilante, + now, + activityWindow, + random + ) + ); + Optional activeVigilante = selectedVigilante + .filter(onlinePlayerIds::contains); + Optional pendingVigilante = selectedVigilante + .filter(playerId -> activeVigilante.isEmpty()) + .map(playerId -> new PendingSelection(playerId, now.plus(pendingTimeout))); + + GameState game = new GameState( + GameLifecycle.RUNNING, + activeTyrant, + activeVigilante, + pendingTyrant, + pendingVigilante, + Optional.empty(), + state.game().accumulatedPausedTime(), + 0, + 1, + Set.of() + ); + return new LifecycleState(game, clearedPlayers); + } + + private static Map clearAssignments( + Map players + ) { + Map cleared = new HashMap<>(); + for (PlayerState player : players.values()) { + cleared.put(player.playerId(), new PlayerState( + player.playerId(), + player.latestName(), + player.lastLogin(), + player.optedOutUntil(), + TyrantClass.NONE, + Optional.empty(), + Map.of(), + Set.of(), + player.capturedMobs() + )); + } + return Map.copyOf(cleared); + } +} diff --git a/src/test/java/games/dmg/spigottyrant/BukkitTyrantPresentationTest.java b/src/test/java/games/dmg/spigottyrant/BukkitTyrantPresentationTest.java new file mode 100644 index 0000000..5826241 --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/BukkitTyrantPresentationTest.java @@ -0,0 +1,42 @@ +package games.dmg.spigottyrant; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import java.util.UUID; +import org.bukkit.ChatColor; +import org.bukkit.Server; +import org.bukkit.entity.Player; +import org.bukkit.scoreboard.Scoreboard; +import org.bukkit.scoreboard.ScoreboardManager; +import org.bukkit.scoreboard.Team; +import org.junit.jupiter.api.Test; + +final class BukkitTyrantPresentationTest { + @Test + void givesCurrentTyrantRedNameAndGlowingOutline() { + UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111"); + Server server = mock(Server.class); + ScoreboardManager manager = mock(ScoreboardManager.class); + Scoreboard scoreboard = mock(Scoreboard.class); + Team team = mock(Team.class); + Player tyrant = mock(Player.class); + when(server.getScoreboardManager()).thenReturn(manager); + when(manager.getMainScoreboard()).thenReturn(scoreboard); + when(scoreboard.getTeam("tyrant-red")).thenReturn(null); + when(scoreboard.registerNewTeam("tyrant-red")).thenReturn(team); + when(server.getPlayer(tyrantId)).thenReturn(tyrant); + when(tyrant.getName()).thenReturn("TyrantPlayer"); + BukkitTyrantPresentation presentation = new BukkitTyrantPresentation(server); + + presentation.reconcile(Optional.of(tyrantId)); + + verify(team).setColor(ChatColor.RED); + verify(team).addEntry("TyrantPlayer"); + verify(tyrant).setGlowing(true); + verify(tyrant).setPlayerListName(ChatColor.RED + "TyrantPlayer"); + verify(tyrant).setDisplayName(ChatColor.RED + "TyrantPlayer"); + } +} diff --git a/src/test/java/games/dmg/spigottyrant/TyrantDeathListenerTest.java b/src/test/java/games/dmg/spigottyrant/TyrantDeathListenerTest.java new file mode 100644 index 0000000..351cd14 --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/TyrantDeathListenerTest.java @@ -0,0 +1,71 @@ +package games.dmg.spigottyrant; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +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.entity.PlayerDeathEvent; +import org.junit.jupiter.api.Test; + +final class TyrantDeathListenerTest { + @Test + void tyrantDeathAppliesSuccessionAndPresentation() { + UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111"); + UUID killerId = UUID.fromString("22222222-2222-2222-2222-222222222222"); + GameState game = new GameState( + GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.empty(), + Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO, + 0, 1, Set.of() + ); + PersistentState before = new PersistentState(game, Map.of()); + GameState succeededGame = new GameState( + GameLifecycle.RUNNING, Optional.of(killerId), Optional.empty(), + Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO, + 0, 1, Set.of() + ); + LifecycleState succeeded = new LifecycleState(succeededGame, Map.of()); + TyrantStateManager manager = mock(TyrantStateManager.class); + when(manager.game()).thenReturn(game); + when(manager.snapshot()).thenReturn(before); + TyrantSuccessionService succession = mock(TyrantSuccessionService.class); + when(succession.succeed(eq(before), eq(Optional.of(killerId)), any(), any())) + .thenReturn(succeeded); + TyrantPresentation presentation = mock(TyrantPresentation.class); + Server server = mock(Server.class); + Player victim = mock(Player.class); + when(victim.getUniqueId()).thenReturn(tyrantId); + Player killer = mock(Player.class); + when(killer.getUniqueId()).thenReturn(killerId); + when(victim.getKiller()).thenReturn(killer); + doReturn(List.of(victim, killer)).when(server).getOnlinePlayers(); + PlayerDeathEvent event = mock(PlayerDeathEvent.class); + when(event.getEntity()).thenReturn(victim); + TyrantDeathListener listener = new TyrantDeathListener( + manager, + succession, + presentation, + server, + Clock.fixed(Instant.parse("2026-08-14T12:00:00Z"), ZoneOffset.UTC) + ); + + listener.onDeath(event); + + verify(manager).replaceState(succeeded); + verify(presentation).reconcile(Optional.of(killerId)); + verify(killer).sendMessage(org.mockito.ArgumentMatchers.contains("new Tyrant")); + } +} diff --git a/src/test/java/games/dmg/spigottyrant/TyrantSuccessionServiceTest.java b/src/test/java/games/dmg/spigottyrant/TyrantSuccessionServiceTest.java new file mode 100644 index 0000000..1ff456b --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/TyrantSuccessionServiceTest.java @@ -0,0 +1,95 @@ +package games.dmg.spigottyrant; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +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 java.util.random.RandomGenerator; +import org.junit.jupiter.api.Test; + +final class TyrantSuccessionServiceTest { + private static final UUID OLD_TYRANT = UUID.fromString("11111111-1111-1111-1111-111111111111"); + private static final UUID OLD_VIGILANTE = UUID.fromString("22222222-2222-2222-2222-222222222222"); + private static final UUID KILLER = UUID.fromString("33333333-3333-3333-3333-333333333333"); + private static final UUID OTHER = UUID.fromString("44444444-4444-4444-4444-444444444444"); + private static final Instant NOW = Instant.parse("2026-08-14T12:00:00Z"); + private final TyrantSuccessionService service = new TyrantSuccessionService( + new RoleCandidateSelector(), + Duration.ofHours(24), + Duration.ofHours(24), + RandomGenerator.of("L64X128MixRandom") + ); + + @Test + void playerKillerBecomesTyrantAndEntireReignIsReset() { + PersistentState before = progressedState(); + + LifecycleState after = service.succeed( + before, Optional.of(KILLER), Set.of(KILLER, OTHER), NOW + ); + + assertEquals(Optional.of(KILLER), after.game().tyrantId()); + assertEquals(0, after.game().tyrantLevel()); + assertEquals(1, after.game().unspentChoices()); + assertEquals(Set.of(), after.game().purchases()); + assertNotEquals(after.game().tyrantId(), after.game().vigilanteId()); + for (PlayerState player : after.players().values()) { + assertEquals(TyrantClass.NONE, player.tyrantClass()); + assertEquals(Optional.empty(), player.followerOf()); + assertEquals(Map.of(), player.cooldownEnds()); + } + } + + @Test + void environmentalDeathRandomlySelectsRecentPlayerInsteadOfFormerTyrant() { + PersistentState before = progressedState(); + + LifecycleState after = service.succeed(before, Optional.empty(), Set.of(OTHER), NOW); + + UUID selected = after.game().tyrantId().orElseGet(() -> + after.game().pendingTyrant().orElseThrow().candidateId() + ); + assertNotEquals(OLD_TYRANT, selected); + } + + private static PersistentState progressedState() { + GameState game = new GameState( + GameLifecycle.RUNNING, Optional.of(OLD_TYRANT), Optional.of(OLD_VIGILANTE), + Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO, + 5, 2, Set.of(TyrantUnlock.ASSASSIN, TyrantUnlock.STRENGTH) + ); + Map players = Map.of( + OLD_TYRANT, active(new PlayerState( + OLD_TYRANT, "OldTyrant", Optional.empty(), Optional.empty(), + TyrantClass.NONE, Optional.empty(), + Map.of(Ability.ROSTER_INTELLIGENCE, NOW.plusSeconds(20)), + Set.of(), java.util.List.of() + )), + OLD_VIGILANTE, active(new PlayerState( + OLD_VIGILANTE, "OldVigilante", Optional.empty(), Optional.empty(), + TyrantClass.ASSASSIN, Optional.empty(), + Map.of(Ability.ASSASSIN_INVISIBILITY, NOW.plusSeconds(20)), + Set.of(Ability.ASSASSIN_INVISIBILITY), java.util.List.of() + )), + KILLER, active(PlayerState.newPlayer(KILLER, "Killer")), + OTHER, active(new PlayerState( + OTHER, "Other", Optional.empty(), Optional.empty(), TyrantClass.FIXER, + Optional.of(OLD_VIGILANTE), Map.of(), Set.of(), java.util.List.of() + )) + ); + return new PersistentState(game, players); + } + + private static PlayerState active(PlayerState player) { + return new PlayerState( + player.playerId(), player.latestName(), Optional.of(NOW.minusSeconds(60)), + player.optedOutUntil(), player.tyrantClass(), player.followerOf(), + player.cooldownEnds(), player.readyAbilityItems(), player.capturedMobs() + ); + } +}