From a195658be47ff4a913b200d1f27bc290d06dbaca Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Fri, 14 Aug 2026 22:50:43 -0400 Subject: [PATCH] feat(roles): maintain inactive and pending roles --- design/log.md | 11 + .../us-010-handle-inactivity-and-selection.md | 22 +- .../spigottyrant/RoleMaintenanceService.java | 235 ++++++++++++++++++ .../dmg/spigottyrant/RoleMaintenanceTask.java | 44 ++++ .../dmg/spigottyrant/SpigotTyrantPlugin.java | 47 ++-- .../RoleMaintenanceServiceTest.java | 155 ++++++++++++ 6 files changed, 489 insertions(+), 25 deletions(-) create mode 100644 src/main/java/games/dmg/spigottyrant/RoleMaintenanceService.java create mode 100644 src/main/java/games/dmg/spigottyrant/RoleMaintenanceTask.java create mode 100644 src/test/java/games/dmg/spigottyrant/RoleMaintenanceServiceTest.java diff --git a/design/log.md b/design/log.md index 419358a..cc5ab93 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 — Role maintenance completed + +- Completed US-010 with configurable periodic role maintenance, exact 48-hour inactivity handling, and 24-hour recently-active candidate filtering. +- Added online confirmation for pending roles, 24-hour expiry and reroll, former-holder fallback rules, vacant-role retries, opt-out filtering, and Tyrant/Vigilante conflict prevention. +- Tyrant inactivity uses full reign succession cleanup; Vigilante inactivity clears only that role's Followers before replacement. +- Verified confirmation, expiry reroll, Follower cleanup, exact inactivity boundary, full Tyrant succession, and pause-safe scheduler integration with the Gradle suite. + +## 2026-08-14 — Role maintenance implementation started + +- US-010 begins with test-first inactivity replacement, pending confirmation and expiry, conflict-safe candidate retries, and pause awareness. + ## 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. diff --git a/design/user-stories/us-010-handle-inactivity-and-selection.md b/design/user-stories/us-010-handle-inactivity-and-selection.md index e006e54..b0d3e12 100644 --- a/design/user-stories/us-010-handle-inactivity-and-selection.md +++ b/design/user-stories/us-010-handle-inactivity-and-selection.md @@ -2,7 +2,7 @@ type: User Story title: "US-010: Handle inactivity and pending selections" description: Replace absent or unconfirmed role holders using recently active eligible players. -status: backlog +status: done --- # US-010: Handle inactivity and pending selections @@ -11,16 +11,16 @@ As a **participant**, I want inactive central roles to be replaced fairly so tha ## Acceptance criteria -- [ ] A Tyrant or Vigilante who has not logged in for 48 unpaused hours loses the position. -- [ ] Random candidates are drawn from eligible opted-in players who logged in during the preceding 24 hours. -- [ ] The Tyrant is always excluded from Vigilante selection. -- [ ] A previous role holder is excluded when another eligible candidate exists and may be reused only as a fallback. -- [ ] Tyrant inactivity ends the reign, clears all assignments, and invokes normal random Tyrant succession followed by Vigilante selection. -- [ ] Vigilante inactivity clears Followers and rerolls only the Vigilante position. -- [ ] An offline randomly selected candidate must log in within 24 unpaused hours to confirm the assignment. -- [ ] An unconfirmed selection expires and rerolls automatically under the same eligibility rules. -- [ ] Selection remains vacant and retries periodically when no valid candidate exists. -- [ ] Candidate selection cannot assign conflicting roles or choose an opted-out player, including during concurrent joins and deaths. +- [x] A Tyrant or Vigilante who has not logged in for 48 unpaused hours loses the position. +- [x] Random candidates are drawn from eligible opted-in players who logged in during the preceding 24 hours. +- [x] The Tyrant is always excluded from Vigilante selection. +- [x] A previous role holder is excluded when another eligible candidate exists and may be reused only as a fallback. +- [x] Tyrant inactivity ends the reign, clears all assignments, and invokes normal random Tyrant succession followed by Vigilante selection. +- [x] Vigilante inactivity clears Followers and rerolls only the Vigilante position. +- [x] An offline randomly selected candidate must log in within 24 unpaused hours to confirm the assignment. +- [x] An unconfirmed selection expires and rerolls automatically under the same eligibility rules. +- [x] Selection remains vacant and retries periodically when no valid candidate exists. +- [x] Candidate selection cannot assign conflicting roles or choose an opted-out player, including during concurrent joins and deaths. ## Related diff --git a/src/main/java/games/dmg/spigottyrant/RoleMaintenanceService.java b/src/main/java/games/dmg/spigottyrant/RoleMaintenanceService.java new file mode 100644 index 0000000..e70095f --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/RoleMaintenanceService.java @@ -0,0 +1,235 @@ +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 RoleMaintenanceService { + private final RoleCandidateSelector candidates; + private final TyrantSuccessionService succession; + private final Duration inactivity; + private final Duration activityWindow; + private final Duration pendingTimeout; + private final RandomGenerator random; + + public RoleMaintenanceService( + RoleCandidateSelector candidates, + TyrantSuccessionService succession, + Duration inactivity, + Duration activityWindow, + Duration pendingTimeout, + RandomGenerator random + ) { + this.candidates = candidates; + this.succession = succession; + this.inactivity = inactivity; + this.activityWindow = activityWindow; + this.pendingTimeout = pendingTimeout; + this.random = random; + } + + public LifecycleState maintain( + PersistentState state, + Set onlinePlayerIds, + Instant now + ) { + if (state.game().lifecycle() != GameLifecycle.RUNNING) { + return new LifecycleState(state.game(), state.players()); + } + LifecycleState current = confirmPending(state, onlinePlayerIds, now); + if (isInactive(current.game().tyrantId(), current.players(), now)) { + return succession.succeed( + current.asPersistentState(), Optional.empty(), onlinePlayerIds, now + ); + } + if (isInactive(current.game().vigilanteId(), current.players(), now)) { + current = rerollVigilante( + current.asPersistentState(), current.game().vigilanteId(), onlinePlayerIds, now + ); + } + current = rerollExpiredTyrant(current, onlinePlayerIds, now); + current = rerollExpiredVigilante(current, onlinePlayerIds, now); + if (current.game().tyrantId().isEmpty() && current.game().pendingTyrant().isEmpty()) { + current = selectTyrant(current, Optional.empty(), onlinePlayerIds, now); + } + if (current.game().tyrantId().isPresent() + && current.game().vigilanteId().isEmpty() + && current.game().pendingVigilante().isEmpty()) { + current = rerollVigilante( + current.asPersistentState(), Optional.empty(), onlinePlayerIds, now + ); + } + return current; + } + + public LifecycleState rerollVigilante( + PersistentState state, + Optional previousVigilante, + Set onlinePlayerIds, + Instant now + ) { + Map players = clearFollowers(state.players(), previousVigilante); + Set excluded = state.game().tyrantId().map(Set::of).orElseGet(Set::of); + Optional selected = candidates.select( + players, excluded, previousVigilante, now, activityWindow, random + ); + Optional active = selected.filter(onlinePlayerIds::contains); + Optional pending = selected + .filter(playerId -> active.isEmpty()) + .map(playerId -> new PendingSelection(playerId, now.plus(pendingTimeout))); + GameState game = copyRoles( + state.game(), state.game().tyrantId(), active, + state.game().pendingTyrant(), pending + ); + return new LifecycleState(game, players); + } + + private LifecycleState confirmPending( + PersistentState state, + Set onlinePlayerIds, + Instant now + ) { + GameState game = state.game(); + Optional tyrant = game.tyrantId(); + Optional vigilante = game.vigilanteId(); + Optional pendingTyrant = game.pendingTyrant(); + Optional pendingVigilante = game.pendingVigilante(); + if (pendingTyrant.isPresent()) { + PendingSelection pending = pendingTyrant.orElseThrow(); + if (!now.isAfter(pending.expiresAt()) + && onlinePlayerIds.contains(pending.candidateId()) + && eligibleAtConfirmation(state.players().get(pending.candidateId()), now) + && vigilante.filter(pending.candidateId()::equals).isEmpty()) { + tyrant = Optional.of(pending.candidateId()); + pendingTyrant = Optional.empty(); + } + } + if (pendingVigilante.isPresent()) { + PendingSelection pending = pendingVigilante.orElseThrow(); + if (!now.isAfter(pending.expiresAt()) + && onlinePlayerIds.contains(pending.candidateId()) + && eligibleAtConfirmation(state.players().get(pending.candidateId()), now) + && tyrant.filter(pending.candidateId()::equals).isEmpty()) { + vigilante = Optional.of(pending.candidateId()); + pendingVigilante = Optional.empty(); + } + } + return new LifecycleState( + copyRoles(game, tyrant, vigilante, pendingTyrant, pendingVigilante), + state.players() + ); + } + + private LifecycleState rerollExpiredTyrant( + LifecycleState state, + Set onlinePlayerIds, + Instant now + ) { + Optional pending = state.game().pendingTyrant(); + if (pending.isEmpty() || !now.isAfter(pending.orElseThrow().expiresAt())) { + return state; + } + return selectTyrant( + state, + Optional.of(pending.orElseThrow().candidateId()), + onlinePlayerIds, + now + ); + } + + private LifecycleState selectTyrant( + LifecycleState state, + Optional previous, + Set onlinePlayerIds, + Instant now + ) { + Set excluded = state.game().vigilanteId().map(Set::of).orElseGet(Set::of); + Optional selected = candidates.select( + state.players(), excluded, previous, now, activityWindow, random + ); + Optional active = selected.filter(onlinePlayerIds::contains); + Optional pending = selected + .filter(playerId -> active.isEmpty()) + .map(playerId -> new PendingSelection(playerId, now.plus(pendingTimeout))); + GameState game = copyRoles( + state.game(), active, state.game().vigilanteId(), pending, + state.game().pendingVigilante() + ); + return new LifecycleState(game, state.players()); + } + + private LifecycleState rerollExpiredVigilante( + LifecycleState state, + Set onlinePlayerIds, + Instant now + ) { + Optional pending = state.game().pendingVigilante(); + if (pending.isEmpty() || !now.isAfter(pending.orElseThrow().expiresAt())) { + return state; + } + return rerollVigilante( + state.asPersistentState(), + Optional.of(pending.orElseThrow().candidateId()), + onlinePlayerIds, + now + ); + } + + private boolean isInactive( + Optional roleHolder, + Map players, + Instant now + ) { + if (roleHolder.isEmpty()) { + return false; + } + PlayerState player = players.get(roleHolder.orElseThrow()); + return player != null + && player.lastLogin().isPresent() + && !player.lastLogin().orElseThrow().plus(inactivity).isAfter(now); + } + + private static boolean eligibleAtConfirmation(PlayerState player, Instant now) { + return player != null + && player.optedOutUntil().filter(deadline -> deadline.isAfter(now)).isEmpty(); + } + + private static Map clearFollowers( + Map players, + Optional previousVigilante + ) { + if (previousVigilante.isEmpty()) { + return players; + } + Map updated = new HashMap<>(); + UUID previous = previousVigilante.orElseThrow(); + for (PlayerState player : players.values()) { + Optional followerOf = player.followerOf().filter(id -> !id.equals(previous)); + updated.put(player.playerId(), new PlayerState( + player.playerId(), player.latestName(), player.lastLogin(), player.optedOutUntil(), + player.tyrantClass(), followerOf, player.cooldownEnds(), + player.readyAbilityItems(), player.capturedMobs() + )); + } + return Map.copyOf(updated); + } + + private static GameState copyRoles( + GameState game, + Optional tyrant, + Optional vigilante, + Optional pendingTyrant, + Optional pendingVigilante + ) { + return new GameState( + game.lifecycle(), tyrant, vigilante, pendingTyrant, pendingVigilante, + game.pausedAt(), game.accumulatedPausedTime(), game.tyrantLevel(), + game.unspentChoices(), game.purchases() + ); + } +} diff --git a/src/main/java/games/dmg/spigottyrant/RoleMaintenanceTask.java b/src/main/java/games/dmg/spigottyrant/RoleMaintenanceTask.java new file mode 100644 index 0000000..36956ef --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/RoleMaintenanceTask.java @@ -0,0 +1,44 @@ +package games.dmg.spigottyrant; + +import java.time.Clock; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import org.bukkit.Server; +import org.bukkit.entity.Player; + +public final class RoleMaintenanceTask implements Runnable { + private final TyrantStateManager stateManager; + private final RoleMaintenanceService maintenance; + private final TyrantPresentation presentation; + private final Server server; + private final Clock clock; + + public RoleMaintenanceTask( + TyrantStateManager stateManager, + RoleMaintenanceService maintenance, + TyrantPresentation presentation, + Server server, + Clock clock + ) { + this.stateManager = stateManager; + this.maintenance = maintenance; + this.presentation = presentation; + this.server = server; + this.clock = clock; + } + + @Override + public void run() { + PersistentState before = stateManager.snapshot(); + Set online = server.getOnlinePlayers().stream() + .map(Player::getUniqueId) + .collect(Collectors.toUnmodifiableSet()); + LifecycleState after = maintenance.maintain(before, online, clock.instant()); + if (!after.asPersistentState().equals(before)) { + stateManager.replaceState(after); + stateManager.saveIfDirty(); + presentation.reconcile(after.game().tyrantId()); + } + } +} diff --git a/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java b/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java index c83dd64..fe3bb51 100644 --- a/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java +++ b/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java @@ -28,6 +28,15 @@ public final class SpigotTyrantPlugin extends JavaPlugin { return; } + Clock clock = Clock.systemUTC(); + RoleCandidateSelector candidateSelector = new RoleCandidateSelector(); + java.util.random.RandomGenerator random = java.util.random.RandomGenerator.getDefault(); + TyrantSuccessionService succession = new TyrantSuccessionService( + candidateSelector, + settings.candidateActivityWindow(), + settings.pendingSelectionTimeout(), + random + ); tyrantPresentation = new BukkitTyrantPresentation(getServer()); ManagedRoleEffects managedEffects = new BukkitManagedRoleEffects( stateManager, tyrantPresentation @@ -41,31 +50,41 @@ public final class SpigotTyrantPlugin extends JavaPlugin { new GameLifecycleService(), new BukkitOnlinePlayerDirectory(), managedEffects, - Clock.systemUTC(), - new RoleCandidateSelector(), + clock, + candidateSelector, settings.candidateActivityWindow(), settings.pendingSelectionTimeout(), - java.util.random.RandomGenerator.getDefault() + random )); getServer().getPluginManager().registerEvents( - new PlayerJoinListener(stateManager, Clock.systemUTC(), tyrantPresentation), + new PlayerJoinListener(stateManager, clock, 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() + stateManager, succession, tyrantPresentation, getServer(), clock ), this ); + RoleMaintenanceService maintenance = new RoleMaintenanceService( + candidateSelector, + succession, + settings.roleInactivity(), + settings.candidateActivityWindow(), + settings.pendingSelectionTimeout(), + random + ); + long maintenanceTicks = Math.max( + 1L, Math.multiplyExact(settings.selectionRetryInterval().toSeconds(), 20L) + ); + getServer().getScheduler().runTaskTimer( + this, + new RoleMaintenanceTask( + stateManager, maintenance, tyrantPresentation, getServer(), clock + ), + maintenanceTicks, + maintenanceTicks + ); getServer().getScheduler().runTaskTimer(this, stateManager::saveIfDirty, 600L, 600L); getLogger().info("Spigot Tyrant enabled."); } diff --git a/src/test/java/games/dmg/spigottyrant/RoleMaintenanceServiceTest.java b/src/test/java/games/dmg/spigottyrant/RoleMaintenanceServiceTest.java new file mode 100644 index 0000000..147beee --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/RoleMaintenanceServiceTest.java @@ -0,0 +1,155 @@ +package games.dmg.spigottyrant; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +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 RoleMaintenanceServiceTest { + private static final UUID TYRANT = UUID.fromString("11111111-1111-1111-1111-111111111111"); + private static final UUID VIGILANTE = UUID.fromString("22222222-2222-2222-2222-222222222222"); + private static final UUID CANDIDATE = UUID.fromString("33333333-3333-3333-3333-333333333333"); + private static final UUID OTHER = UUID.fromString("44444444-4444-4444-4444-444444444444"); + private static final UUID FOLLOWER = UUID.fromString("55555555-5555-5555-5555-555555555555"); + private static final Instant NOW = Instant.parse("2026-08-14T12:00:00Z"); + private final RandomGenerator random = RandomGenerator.of("L64X128MixRandom"); + private final RoleCandidateSelector selector = new RoleCandidateSelector(); + private final RoleMaintenanceService service = new RoleMaintenanceService( + selector, + new TyrantSuccessionService( + selector, Duration.ofHours(24), Duration.ofHours(24), random + ), + Duration.ofHours(48), + Duration.ofHours(24), + Duration.ofHours(24), + random + ); + + @Test + void onlinePendingVigilanteConfirmsWithoutConflictingWithTyrant() { + GameState game = game( + Optional.of(TYRANT), Optional.empty(), Optional.empty(), + Optional.of(new PendingSelection(CANDIDATE, NOW.plusSeconds(60))) + ); + PersistentState state = new PersistentState(game, players( + active(TYRANT), active(CANDIDATE) + )); + + LifecycleState maintained = service.maintain(state, Set.of(TYRANT, CANDIDATE), NOW); + + assertEquals(Optional.of(CANDIDATE), maintained.game().vigilanteId()); + assertEquals(Optional.empty(), maintained.game().pendingVigilante()); + } + + @Test + void expiredPendingSelectionRerollsAwayFromPreviousCandidate() { + GameState game = game( + Optional.of(TYRANT), Optional.empty(), Optional.empty(), + Optional.of(new PendingSelection(CANDIDATE, NOW.minusSeconds(1))) + ); + PersistentState state = new PersistentState(game, players( + active(TYRANT), active(CANDIDATE), active(OTHER) + )); + + LifecycleState maintained = service.maintain(state, Set.of(TYRANT), NOW); + + assertEquals(OTHER, + maintained.game().pendingVigilante().orElseThrow().candidateId()); + } + + @Test + void inactiveVigilanteIsRerolledAndFollowersAreCleared() { + PlayerState follower = withFollower( + withLogin(active(FOLLOWER), NOW.minus(Duration.ofHours(25))), + VIGILANTE + ); + PlayerState staleVigilante = withLogin(active(VIGILANTE), NOW.minus(Duration.ofHours(49))); + GameState game = game( + Optional.of(TYRANT), Optional.of(VIGILANTE), Optional.empty(), Optional.empty() + ); + PersistentState state = new PersistentState(game, Map.of( + TYRANT, active(TYRANT), VIGILANTE, staleVigilante, + FOLLOWER, follower, OTHER, active(OTHER) + )); + + LifecycleState maintained = service.maintain(state, Set.of(TYRANT, OTHER), NOW); + + assertEquals(Optional.of(OTHER), maintained.game().vigilanteId()); + assertEquals(Optional.empty(), maintained.players().get(FOLLOWER).followerOf()); + assertEquals(Optional.of(TYRANT), maintained.game().tyrantId()); + } + + @Test + void inactiveTyrantEndsReignAndUsesNormalSuccession() { + PlayerState staleTyrant = withLogin( + active(TYRANT), NOW.minus(Duration.ofHours(48)) + ); + PlayerState successor = new PlayerState( + OTHER, "Successor", Optional.of(NOW.minusSeconds(60)), Optional.empty(), + TyrantClass.FIXER, Optional.of(VIGILANTE), Map.of(), Set.of(), + java.util.List.of() + ); + GameState game = new GameState( + GameLifecycle.RUNNING, Optional.of(TYRANT), Optional.of(VIGILANTE), + Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO, + 5, 0, Set.of(TyrantUnlock.FIXER) + ); + PersistentState state = new PersistentState(game, Map.of( + TYRANT, staleTyrant, VIGILANTE, active(VIGILANTE), OTHER, successor + )); + + LifecycleState maintained = service.maintain(state, Set.of(OTHER), NOW); + + assertEquals(0, maintained.game().tyrantLevel()); + assertEquals(1, maintained.game().unspentChoices()); + assertEquals(Set.of(), maintained.game().purchases()); + assertEquals(TyrantClass.NONE, maintained.players().get(OTHER).tyrantClass()); + assertEquals(Optional.empty(), maintained.players().get(OTHER).followerOf()); + } + + private static GameState game( + Optional tyrant, + Optional vigilante, + Optional pendingTyrant, + Optional pendingVigilante + ) { + return new GameState( + GameLifecycle.RUNNING, tyrant, vigilante, pendingTyrant, pendingVigilante, + Optional.empty(), Duration.ZERO, 1, 0, Set.of() + ); + } + + private static Map players(PlayerState... players) { + java.util.HashMap map = new java.util.HashMap<>(); + for (PlayerState player : players) { + map.put(player.playerId(), player); + } + return Map.copyOf(map); + } + + private static PlayerState active(UUID id) { + return withLogin(PlayerState.newPlayer(id, id.toString()), NOW.minusSeconds(60)); + } + + private static PlayerState withLogin(PlayerState player, Instant login) { + return new PlayerState( + player.playerId(), player.latestName(), Optional.of(login), player.optedOutUntil(), + player.tyrantClass(), player.followerOf(), player.cooldownEnds(), + player.readyAbilityItems(), player.capturedMobs() + ); + } + + private static PlayerState withFollower(PlayerState player, UUID vigilante) { + return new PlayerState( + player.playerId(), player.latestName(), player.lastLogin(), player.optedOutUntil(), + player.tyrantClass(), Optional.of(vigilante), player.cooldownEnds(), + player.readyAbilityItems(), player.capturedMobs() + ); + } +}