feat(roles): maintain inactive and pending roles
Release / release (push) Successful in 2m19s
CI / build (push) Successful in 1m0s

This commit is contained in:
dmg
2026-08-14 22:50:43 -04:00
parent ddd256802b
commit a195658be4
6 changed files with 489 additions and 25 deletions
@@ -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<UUID> 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<UUID> previousVigilante,
Set<UUID> onlinePlayerIds,
Instant now
) {
Map<UUID, PlayerState> players = clearFollowers(state.players(), previousVigilante);
Set<UUID> excluded = state.game().tyrantId().map(Set::of).orElseGet(Set::of);
Optional<UUID> selected = candidates.select(
players, excluded, previousVigilante, now, activityWindow, random
);
Optional<UUID> active = selected.filter(onlinePlayerIds::contains);
Optional<PendingSelection> 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<UUID> onlinePlayerIds,
Instant now
) {
GameState game = state.game();
Optional<UUID> tyrant = game.tyrantId();
Optional<UUID> vigilante = game.vigilanteId();
Optional<PendingSelection> pendingTyrant = game.pendingTyrant();
Optional<PendingSelection> 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<UUID> onlinePlayerIds,
Instant now
) {
Optional<PendingSelection> 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<UUID> previous,
Set<UUID> onlinePlayerIds,
Instant now
) {
Set<UUID> excluded = state.game().vigilanteId().map(Set::of).orElseGet(Set::of);
Optional<UUID> selected = candidates.select(
state.players(), excluded, previous, now, activityWindow, random
);
Optional<UUID> active = selected.filter(onlinePlayerIds::contains);
Optional<PendingSelection> 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<UUID> onlinePlayerIds,
Instant now
) {
Optional<PendingSelection> 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<UUID> roleHolder,
Map<UUID, PlayerState> 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<UUID, PlayerState> clearFollowers(
Map<UUID, PlayerState> players,
Optional<UUID> previousVigilante
) {
if (previousVigilante.isEmpty()) {
return players;
}
Map<UUID, PlayerState> updated = new HashMap<>();
UUID previous = previousVigilante.orElseThrow();
for (PlayerState player : players.values()) {
Optional<UUID> 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<UUID> tyrant,
Optional<UUID> vigilante,
Optional<PendingSelection> pendingTyrant,
Optional<PendingSelection> pendingVigilante
) {
return new GameState(
game.lifecycle(), tyrant, vigilante, pendingTyrant, pendingVigilante,
game.pausedAt(), game.accumulatedPausedTime(), game.tyrantLevel(),
game.unspentChoices(), game.purchases()
);
}
}
@@ -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<UUID> 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());
}
}
}
@@ -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.");
}