feat(tyrant): add arena boss succession
Release / release (push) Successful in 2m29s
CI / build (push) Successful in 1m7s

This commit is contained in:
dmg
2026-08-23 17:55:14 -04:00
parent 10f670a7da
commit 9ed0eed832
44 changed files with 536 additions and 535 deletions
@@ -0,0 +1,6 @@
package games.dmg.spigottyrant;
public enum ArenaRole {
TYRANT,
VIGILANTE
}
@@ -18,9 +18,7 @@ public record PluginSettings(
double vigilanteBossArmor,
Duration optOutDuration,
Duration roleInactivity,
Duration candidateActivityWindow,
Duration pendingSelectionTimeout,
Duration selectionRetryInterval,
Duration roleMaintenanceInterval,
Duration assassinCooldown,
Duration assassinInvisibilityDuration,
Duration assassinDoubleJumpCooldown,
@@ -60,9 +58,7 @@ public record PluginSettings(
requireFiniteRange(vigilanteBossArmor, 0.0, 30.0, "vigilante-boss-armor");
requirePositive(optOutDuration, "opt-out-seconds");
requirePositive(roleInactivity, "role-inactivity-seconds");
requirePositive(candidateActivityWindow, "candidate-activity-window-seconds");
requirePositive(pendingSelectionTimeout, "pending-selection-timeout-seconds");
requirePositive(selectionRetryInterval, "selection-retry-seconds");
requirePositive(roleMaintenanceInterval, "role-maintenance-seconds");
requirePositive(assassinCooldown, "assassin-cooldown-seconds");
requirePositive(assassinInvisibilityDuration, "assassin-invisibility-seconds");
requirePositive(assassinDoubleJumpCooldown, "assassin-double-jump-cooldown-seconds");
@@ -109,9 +105,10 @@ public record PluginSettings(
decimal(values, "vigilante-boss-armor", 10.0),
duration(values, "opt-out-seconds", Duration.ofDays(7)),
duration(values, "role-inactivity-seconds", Duration.ofHours(48)),
duration(values, "candidate-activity-window-seconds", Duration.ofHours(24)),
duration(values, "pending-selection-timeout-seconds", Duration.ofHours(24)),
duration(values, "selection-retry-seconds", Duration.ofMinutes(1)),
durationAlias(
values, "role-maintenance-seconds", "selection-retry-seconds",
Duration.ofMinutes(1)
),
duration(values, "assassin-cooldown-seconds", Duration.ofHours(1)),
duration(values, "assassin-invisibility-seconds", Duration.ofMinutes(10)),
duration(values, "assassin-double-jump-cooldown-seconds", Duration.ofSeconds(60)),
@@ -289,6 +286,18 @@ public record PluginSettings(
return value.trim();
}
private static Duration durationAlias(
Map<String, ?> values,
String key,
String legacyKey,
Duration defaultValue
) {
if (values.containsKey(key)) {
return duration(values, key, defaultValue);
}
return duration(values, legacyKey, defaultValue);
}
private static Duration duration(Map<String, ?> values, String key, Duration defaultValue) {
Object raw = values.get(key);
if (raw == null) {
@@ -5,6 +5,7 @@ import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.bukkit.ChatColor;
import org.bukkit.Color;
import org.bukkit.Location;
import org.bukkit.Particle;
import org.bukkit.Server;
@@ -31,24 +32,27 @@ import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.player.PlayerRespawnEvent;
import org.bukkit.event.player.PlayerTeleportEvent;
public final class VigilanteArenaController implements Listener, Runnable {
private static final String BOSS_TAG = "spigottyrant-vigilante-boss";
public final class RoleArenaController implements Listener, Runnable {
private static final String BOSS_TAG = "spigottyrant-role-arena-boss";
private final TyrantStateManager stateManager;
private final Server server;
private final ArenaLocationStore locations;
private final PluginSettings settings;
private final VigilanteArenaSuccessionService succession =
private final VigilanteArenaSuccessionService vigilanteSuccession =
new VigilanteArenaSuccessionService();
private final TyrantArenaSuccessionService tyrantSuccession =
new TyrantArenaSuccessionService();
private final VigilanteArenaBarrier barrier = new VigilanteArenaBarrier();
private final Set<UUID> relocating = new HashSet<>();
private UUID bossId;
private UUID challengerId;
private boolean spawningBoss;
private boolean wasOpen;
private boolean warnedMissingLocation;
private ArenaRole previousOpenRole;
private ArenaRole bossRole;
private ArenaRole warnedMissingRole;
private ArenaLocation observedLocation;
public VigilanteArenaController(
public RoleArenaController(
TyrantStateManager stateManager,
Server server,
ArenaLocationStore locations,
@@ -62,12 +66,13 @@ public final class VigilanteArenaController implements Listener, Runnable {
@Override
public void run() {
Optional<ArenaRole> openRole = RoleArenaPolicy.openRole(stateManager.game());
Optional<ArenaLocation> configured = locations.location();
if (configured.isEmpty()) {
closeFight();
observedLocation = null;
warnIfOpenWithoutLocation();
wasOpen = false;
warnIfOpenWithoutLocation(openRole);
previousOpenRole = null;
return;
}
ArenaLocation arena = configured.orElseThrow();
@@ -78,27 +83,31 @@ public final class VigilanteArenaController implements Listener, Runnable {
World world = server.getWorld(arena.worldName());
if (world == null) {
closeFight();
warnIfOpenWithoutLocation();
wasOpen = false;
warnIfOpenWithoutLocation(openRole);
previousOpenRole = null;
return;
}
warnedMissingLocation = false;
drawBoundary(world, arena);
removeOtherMobs(world, arena);
boolean open = VigilanteArenaPolicy.isOpen(stateManager.game());
if (open && !wasOpen) {
announceVacancy(arena);
warnedMissingRole = null;
ArenaRole role = openRole.orElse(null);
if (role != previousOpenRole) {
closeFight();
if (role != null) {
announceVacancy(arena, role);
}
}
wasOpen = open;
if (!open) {
previousOpenRole = role;
drawBoundary(world, arena, role);
removeOtherMobs(world, arena);
if (role == null) {
closeFight();
return;
}
Zombie boss = boss(world);
if (boss == null) {
boss = spawnBoss(world, arena);
if (boss == null || bossRole != role) {
closeFight();
boss = spawnBoss(world, arena, role);
}
boss.setFireTicks(0);
if (!contains(arena, boss.getLocation())) {
@@ -107,8 +116,8 @@ public final class VigilanteArenaController implements Listener, Runnable {
Player challenger = challengerId == null ? null : server.getPlayer(challengerId);
if (challenger == null || challenger.isDead()
|| !contains(arena, challenger.getLocation())
|| !VigilanteArenaPolicy.isEligible(
stateManager.game(), playerState(challenger)
|| !RoleArenaPolicy.isEligible(
stateManager.game(), role, playerState(challenger)
)) {
if (challengerId != null) {
resetFight();
@@ -163,13 +172,16 @@ public final class VigilanteArenaController implements Listener, Runnable {
@EventHandler(priority = EventPriority.MONITOR)
public void onJoin(PlayerJoinEvent event) {
if (!VigilanteArenaPolicy.isOpen(stateManager.game())) {
Optional<ArenaRole> role = RoleArenaPolicy.openRole(stateManager.game());
if (role.isEmpty()) {
return;
}
PlayerState state = playerState(event.getPlayer());
locations.location().filter(location -> VigilanteArenaPolicy.isEligible(
stateManager.game(), state
)).ifPresent(location -> event.getPlayer().sendMessage(vacancyMessage(location)));
locations.location().filter(location -> RoleArenaPolicy.isEligible(
stateManager.game(), role.orElseThrow(), state
)).ifPresent(location -> event.getPlayer().sendMessage(
vacancyMessage(location, role.orElseThrow())
));
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
@@ -218,17 +230,20 @@ public final class VigilanteArenaController implements Listener, Runnable {
return;
}
UUID defeatedBoss = bossId;
ArenaRole defeatedRole = bossRole;
bossId = null;
bossRole = null;
Player killer = event.getEntity().getKiller();
if (killer != null && challengerId != null
if (killer != null && challengerId != null && defeatedRole != null
&& killer.getUniqueId().equals(challengerId)
&& VigilanteArenaPolicy.isEligible(
stateManager.game(), playerState(killer)
&& RoleArenaPolicy.isEligible(
stateManager.game(), defeatedRole, playerState(killer)
)) {
assignVigilante(challengerId);
assignRole(challengerId, defeatedRole);
return;
}
bossId = defeatedBoss;
bossRole = defeatedRole;
resetFight();
}
@@ -253,8 +268,9 @@ public final class VigilanteArenaController implements Listener, Runnable {
return false;
}
PlayerState state = playerState(player);
if (challengerId == null && bossId != null
&& VigilanteArenaPolicy.isEligible(stateManager.game(), state)) {
ArenaRole role = bossRole;
if (challengerId == null && bossId != null && role != null
&& RoleArenaPolicy.isEligible(stateManager.game(), role, state)) {
challengerId = player.getUniqueId();
World world = server.getWorld(arena.worldName());
if (world != null) {
@@ -266,7 +282,8 @@ public final class VigilanteArenaController implements Listener, Runnable {
boss.setTarget(player);
}
}
player.sendMessage(ChatColor.GOLD + "Defeat the arena boss to become Vigilante!");
player.sendMessage(ChatColor.GOLD + "Defeat the arena boss to become "
+ readable(role) + "!");
return false;
}
bounce(player, from, to, arena);
@@ -313,9 +330,10 @@ public final class VigilanteArenaController implements Listener, Runnable {
}
bossId = null;
}
bossRole = null;
}
private Zombie spawnBoss(World world, ArenaLocation arena) {
private Zombie spawnBoss(World world, ArenaLocation arena, ArenaRole role) {
for (Entity entity : world.getEntities()) {
if (entity.getScoreboardTags().contains(BOSS_TAG)) {
entity.remove();
@@ -325,7 +343,9 @@ public final class VigilanteArenaController implements Listener, Runnable {
try {
Zombie boss = world.spawn(toBukkit(world, arena), Zombie.class, zombie -> {
zombie.setAdult();
zombie.setCustomName(ChatColor.DARK_RED + "Vigilante's Trial");
zombie.setCustomName(role == ArenaRole.TYRANT
? ChatColor.RED + "Tyrant's Trial"
: ChatColor.BLUE + "Vigilante's Trial");
zombie.setCustomNameVisible(true);
zombie.setRemoveWhenFarAway(false);
zombie.setCanPickupItems(false);
@@ -336,6 +356,7 @@ public final class VigilanteArenaController implements Listener, Runnable {
zombie.setHealth(settings.vigilanteBossHealth());
});
bossId = boss.getUniqueId();
bossRole = role;
return boss;
} finally {
spawningBoss = false;
@@ -351,10 +372,12 @@ public final class VigilanteArenaController implements Listener, Runnable {
&& !zombie.isDead() ? zombie : null;
}
private void assignVigilante(UUID playerId) {
private void assignRole(UUID playerId, ArenaRole role) {
LifecycleState assigned;
try {
assigned = succession.claim(stateManager.snapshot(), playerId);
assigned = role == ArenaRole.TYRANT
? tyrantSuccession.claim(stateManager.snapshot(), playerId)
: vigilanteSuccession.claim(stateManager.snapshot(), playerId);
} catch (IllegalStateException exception) {
resetFight();
return;
@@ -365,39 +388,46 @@ public final class VigilanteArenaController implements Listener, Runnable {
barrier.clear();
String name = Optional.ofNullable(server.getPlayer(playerId))
.map(Player::getName).orElse(playerId.toString());
String message = ChatColor.YELLOW + name + " defeated the arena boss and is the Vigilante!";
String message = ChatColor.YELLOW + name + " defeated the arena boss and is the "
+ readable(role) + "!";
server.getOnlinePlayers().forEach(player -> player.sendMessage(message));
}
private void announceVacancy(ArenaLocation arena) {
String message = vacancyMessage(arena);
private void announceVacancy(ArenaLocation arena, ArenaRole role) {
String message = vacancyMessage(arena, role);
for (Player player : server.getOnlinePlayers()) {
PlayerState state = playerState(player);
if (VigilanteArenaPolicy.isEligible(stateManager.game(), state)) {
if (RoleArenaPolicy.isEligible(stateManager.game(), role, state)) {
player.sendMessage(message);
}
}
}
private void warnIfOpenWithoutLocation() {
if (!VigilanteArenaPolicy.isOpen(stateManager.game()) || warnedMissingLocation) {
private void warnIfOpenWithoutLocation(Optional<ArenaRole> openRole) {
if (openRole.isEmpty()) {
warnedMissingRole = null;
return;
}
warnedMissingLocation = true;
ArenaRole role = openRole.orElseThrow();
if (role == warnedMissingRole) {
return;
}
warnedMissingRole = role;
server.getConsoleSender().sendMessage(ChatColor.RED
+ "The Vigilante role is vacant, but no valid arena location is configured. "
+ "The " + readable(role)
+ " role is vacant, but no valid arena location is configured. "
+ "Use /tyrantadmin arena set.");
for (Player player : server.getOnlinePlayers()) {
if (player.hasPermission("spigottyrant.admin")) {
player.sendMessage(ChatColor.RED
+ "The Vigilante arena location is not configured.");
+ "The shared role arena location is not configured.");
}
}
}
private String vacancyMessage(ArenaLocation arena) {
return ChatColor.YELLOW + "The Vigilante position is open! Challenge the boss in "
+ arena.display() + ".";
private String vacancyMessage(ArenaLocation arena, ArenaRole role) {
return ChatColor.YELLOW + "The " + readable(role)
+ " position is open! Challenge the boss in " + arena.display() + ".";
}
private void removeOtherMobs(World world, ArenaLocation arena) {
@@ -411,11 +441,14 @@ public final class VigilanteArenaController implements Listener, Runnable {
}
}
private void drawBoundary(World world, ArenaLocation arena) {
private void drawBoundary(World world, ArenaLocation arena, ArenaRole role) {
if (barrier.active()) {
return;
}
double radius = settings.vigilanteArenaRadiusBlocks();
Color color = boundaryColor(role);
Particle.DustOptions dust = color == null
? null : new Particle.DustOptions(color, 1.25F);
for (int index = 0; index < 48; index++) {
double angle = 2.0 * Math.PI * index / 48.0;
Location point = new Location(
@@ -424,7 +457,11 @@ public final class VigilanteArenaController implements Listener, Runnable {
arena.y() + 1.0,
arena.z() + Math.sin(angle) * radius
);
world.spawnParticle(Particle.END_ROD, point, 1, 0.0, 0.0, 0.0, 0.0);
if (dust == null) {
world.spawnParticle(Particle.END_ROD, point, 1, 0.0, 0.0, 0.0, 0.0);
} else {
world.spawnParticle(Particle.DUST, point, 1, 0.0, 0.0, 0.0, 0.0, dust);
}
}
}
@@ -498,6 +535,17 @@ public final class VigilanteArenaController implements Listener, Runnable {
}
}
static Color boundaryColor(ArenaRole role) {
if (role == null) {
return null;
}
return role == ArenaRole.TYRANT ? Color.RED : Color.BLUE;
}
private static String readable(ArenaRole role) {
return role == ArenaRole.TYRANT ? "Tyrant" : "Vigilante";
}
private static boolean movedHorizontally(Location from, Location to) {
return from.getWorld() != to.getWorld()
|| from.getX() != to.getX() || from.getZ() != to.getZ();
@@ -0,0 +1,31 @@
package games.dmg.spigottyrant;
import java.util.Optional;
public final class RoleArenaPolicy {
private RoleArenaPolicy() {
}
public static Optional<ArenaRole> openRole(GameState game) {
if (game.lifecycle() != GameLifecycle.RUNNING) {
return Optional.empty();
}
if (game.tyrantId().isEmpty()) {
return game.pendingTyrant().isEmpty()
? Optional.of(ArenaRole.TYRANT) : Optional.empty();
}
return game.vigilanteId().isEmpty()
? Optional.of(ArenaRole.VIGILANTE) : Optional.empty();
}
public static boolean isEligible(
GameState game,
ArenaRole role,
PlayerState player
) {
return player != null
&& openRole(game).filter(role::equals).isPresent()
&& player.optedOutUntil().isEmpty()
&& game.tyrantId().filter(player.playerId()::equals).isEmpty();
}
}
@@ -1,47 +0,0 @@
package games.dmg.spigottyrant;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.random.RandomGenerator;
public final class RoleCandidateSelector {
public Optional<UUID> select(
Map<UUID, PlayerState> players,
Set<UUID> excluded,
Optional<UUID> previousHolder,
Instant now,
Duration activityWindow,
RandomGenerator random
) {
Instant cutoff = now.minus(activityWindow);
List<UUID> eligible = new ArrayList<>();
for (PlayerState player : players.values()) {
if (excluded.contains(player.playerId())) {
continue;
}
if (player.lastLogin().isEmpty()
|| player.lastLogin().orElseThrow().isBefore(cutoff)) {
continue;
}
if (player.optedOutUntil().isPresent()) {
continue;
}
eligible.add(player.playerId());
}
if (eligible.size() > 1) {
previousHolder.ifPresent(eligible::remove);
}
if (eligible.isEmpty()) {
return Optional.empty();
}
eligible.sort(Comparator.comparing(UUID::toString));
return Optional.of(eligible.get(random.nextInt(eligible.size())));
}
}
@@ -5,65 +5,39 @@ 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
Duration inactivity
) {
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
) {
public LifecycleState maintain(PersistentState state, Instant now) {
if (state.game().lifecycle() != GameLifecycle.RUNNING) {
return new LifecycleState(state.game(), state.players());
}
LifecycleState current = confirmPending(state, onlinePlayerIds, now);
LifecycleState current = clearLegacyPending(state);
if (isInactive(current.game().tyrantId(), current.players(), now)) {
return succession.succeed(
current.asPersistentState(), Optional.empty(), onlinePlayerIds, now
);
return succession.succeed(current.asPersistentState(), Optional.empty());
}
if (isInactive(current.game().vigilanteId(), current.players(), now)) {
current = rerollVigilante(
current.asPersistentState(), current.game().vigilanteId(), onlinePlayerIds, now
current.asPersistentState(), current.game().vigilanteId()
);
}
current = rerollExpiredTyrant(current, onlinePlayerIds, now);
if (current.game().tyrantId().isEmpty() && current.game().pendingTyrant().isEmpty()) {
current = selectTyrant(current, Optional.empty(), onlinePlayerIds, now);
}
return current;
}
public LifecycleState rerollVigilante(
PersistentState state,
Optional<UUID> previousVigilante,
Set<UUID> onlinePlayerIds,
Instant now
Optional<UUID> previousVigilante
) {
Map<UUID, PlayerState> players = clearFollowers(state.players(), previousVigilante);
GameState game = copyRoles(
@@ -73,70 +47,12 @@ public final class RoleMaintenanceService {
return new LifecycleState(game, players);
}
private LifecycleState confirmPending(
PersistentState state,
Set<UUID> onlinePlayerIds,
Instant now
) {
private static LifecycleState clearLegacyPending(PersistentState state) {
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();
}
}
// Legacy pending Vigilante selections become arena vacancies.
pendingVigilante = Optional.empty();
return new LifecycleState(
copyRoles(game, tyrant, vigilante, pendingTyrant, pendingVigilante),
state.players()
GameState cleared = copyRoles(
game, game.tyrantId(), game.vigilanteId(), Optional.empty(), Optional.empty()
);
}
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());
return new LifecycleState(cleared, state.players());
}
private boolean isInactive(
@@ -153,10 +69,6 @@ public final class RoleMaintenanceService {
&& !player.lastLogin().orElseThrow().plus(inactivity).isAfter(now);
}
private static boolean eligibleAtConfirmation(PlayerState player, Instant now) {
return player != null && player.optedOutUntil().isEmpty();
}
private static Map<UUID, PlayerState> clearFollowers(
Map<UUID, PlayerState> players,
Optional<UUID> previousVigilante
@@ -1,40 +1,29 @@
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());
LifecycleState after = maintenance.maintain(before, clock.instant());
if (!after.asPersistentState().equals(before)) {
stateManager.replaceState(after);
stateManager.saveIfDirty();
@@ -31,15 +31,13 @@ public final class RoleRelinquishmentService {
}
if (state.game().tyrantId().filter(playerId::equals).isPresent()) {
return new RelinquishmentResult(
succession.succeed(state, Optional.empty(), onlinePlayerIds, now),
succession.succeed(state, Optional.empty()),
RelinquishmentStatus.RELINQUISHED
);
}
if (state.game().vigilanteId().filter(playerId::equals).isPresent()) {
return new RelinquishmentResult(
maintenance.rerollVigilante(
state, Optional.of(playerId), onlinePlayerIds, now
),
maintenance.rerollVigilante(state, Optional.of(playerId)),
RelinquishmentStatus.RELINQUISHED
);
}
@@ -16,7 +16,7 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
private FixerEffectController fixerEffects;
private TyrantEffectController tyrantEffects;
private OptOutGlowController optOutGlow;
private VigilanteArenaController vigilanteArena;
private RoleArenaController roleArena;
@Override
public void onEnable() {
@@ -38,26 +38,14 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
try {
arenaLocations.location();
} catch (IllegalArgumentException exception) {
getLogger().log(Level.SEVERE, "Invalid Vigilante arena location", exception);
getLogger().log(Level.SEVERE, "Invalid shared role arena location", exception);
getServer().getPluginManager().disablePlugin(this);
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
);
TyrantSuccessionService succession = new TyrantSuccessionService();
RoleMaintenanceService maintenance = new RoleMaintenanceService(
candidateSelector,
succession,
settings.roleInactivity(),
settings.candidateActivityWindow(),
settings.pendingSelectionTimeout(),
random
succession, settings.roleInactivity()
);
BukkitOnlinePlayerDirectory onlinePlayers = new BukkitOnlinePlayerDirectory();
FollowerService followers = new FollowerService();
@@ -154,10 +142,10 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
new PlayerJoinListener(stateManager, clock, tyrantPresentation),
this
);
vigilanteArena = new VigilanteArenaController(
roleArena = new RoleArenaController(
stateManager, getServer(), arenaLocations, settings
);
getServer().getPluginManager().registerEvents(vigilanteArena, this);
getServer().getPluginManager().registerEvents(roleArena, this);
getServer().getPluginManager().registerEvents(controlPanelRenderer, this);
getServer().getPluginManager().registerEvents(vigilanteControlPanelRenderer, this);
getServer().getPluginManager().registerEvents(
@@ -180,14 +168,14 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
);
getServer().getPluginManager().registerEvents(
new TyrantDeathListener(
stateManager, succession, tyrantPresentation, getServer(), clock
stateManager, succession, tyrantPresentation, getServer()
),
this
);
getServer().getPluginManager().registerEvents(
new VigilanteDeathListener(
stateManager, new TyrantProgressionService(), maintenance,
getServer(), clock
getServer()
),
this
);
@@ -238,12 +226,12 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
this
);
long maintenanceTicks = Math.max(
1L, Math.multiplyExact(settings.selectionRetryInterval().toSeconds(), 20L)
1L, Math.multiplyExact(settings.roleMaintenanceInterval().toSeconds(), 20L)
);
getServer().getScheduler().runTaskTimer(
this,
new RoleMaintenanceTask(
stateManager, maintenance, tyrantPresentation, getServer(), clock
stateManager, maintenance, tyrantPresentation, clock
),
maintenanceTicks,
maintenanceTicks
@@ -253,7 +241,7 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
getServer().getScheduler().runTaskTimer(this, fixerEffects, 10L, 10L);
getServer().getScheduler().runTaskTimer(this, tyrantEffects, 10L, 10L);
getServer().getScheduler().runTaskTimer(this, optOutGlow, 10L, 10L);
getServer().getScheduler().runTaskTimer(this, vigilanteArena, 1L, 10L);
getServer().getScheduler().runTaskTimer(this, roleArena, 1L, 10L);
getServer().getScheduler().runTaskTimer(
this,
new AbilityItemRefreshTask(
@@ -283,8 +271,8 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
@Override
public void onDisable() {
if (vigilanteArena != null) {
vigilanteArena.close();
if (roleArena != null) {
roleArena.close();
}
if (optOutGlow != null) {
optOutGlow.clearAll();
@@ -143,7 +143,7 @@ public final class TyrantAdminCommand implements CommandExecutor {
private void arena(CommandSender sender, String[] arguments) {
if (arguments.length == 1 || arguments[1].equalsIgnoreCase("status")) {
sender.sendMessage("Vigilante arena: " + arenaLocations.location()
sender.sendMessage("Shared role arena: " + arenaLocations.location()
.map(ArenaLocation::display).orElse("not configured"));
return;
}
@@ -162,7 +162,7 @@ public final class TyrantAdminCommand implements CommandExecutor {
location.getYaw(), location.getPitch()
);
arenaLocations.set(arena);
sender.sendMessage(ChatColor.GREEN + "Vigilante arena set to " + arena.display() + ".");
sender.sendMessage(ChatColor.GREEN + "Shared role arena set to " + arena.display() + ".");
}
private void showStatus(CommandSender sender) {
@@ -176,7 +176,7 @@ public final class TyrantAdminCommand implements CommandExecutor {
+ ", purchases: " + game.purchases());
sender.sendMessage("Pending Tyrant: " + game.pendingTyrant());
sender.sendMessage("Pending Vigilante: " + game.pendingVigilante());
sender.sendMessage("Vigilante arena: " + arenaLocations.location()
sender.sendMessage("Shared role arena: " + arenaLocations.location()
.map(ArenaLocation::display).orElse("not configured"));
for (PlayerState player : stateManager.players().values()) {
boolean optedOut = player.optedOutUntil().isPresent();
@@ -0,0 +1,21 @@
package games.dmg.spigottyrant;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
public final class TyrantArenaSuccessionService {
public LifecycleState claim(PersistentState state, UUID winnerId) {
PlayerState winner = state.players().get(winnerId);
if (!RoleArenaPolicy.isEligible(state.game(), ArenaRole.TYRANT, winner)) {
throw new IllegalStateException("player is not eligible to claim the Tyrant role");
}
GameState game = state.game();
GameState assigned = new GameState(
game.lifecycle(), Optional.of(winnerId), Optional.empty(), Optional.empty(),
Optional.empty(), game.pausedAt(), game.accumulatedPausedTime(),
0, 1, Set.of()
);
return new LifecycleState(assigned, state.players());
}
}
@@ -1,10 +1,7 @@
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;
@@ -18,20 +15,17 @@ public final class TyrantDeathListener implements Listener {
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
Server server
) {
this.stateManager = stateManager;
this.succession = succession;
this.presentation = presentation;
this.server = server;
this.clock = clock;
}
@EventHandler(priority = EventPriority.MONITOR)
@@ -44,22 +38,18 @@ public final class TyrantDeathListener implements Listener {
}
Optional<UUID> killerId = Optional.ofNullable(event.getEntity().getKiller())
.map(Player::getUniqueId);
Set<UUID> online = server.getOnlinePlayers().stream()
.map(Player::getUniqueId)
.collect(Collectors.toUnmodifiableSet());
LifecycleState succeeded = succession.succeed(
stateManager.snapshot(), killerId, online, clock.instant()
stateManager.snapshot(), killerId
);
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!";
String message = succeeded.game().tyrantId().map(id -> {
Player player = server.getPlayer(id);
String successor = player == null ? id.toString() : player.getName();
return ChatColor.RED + successor + " is the new Tyrant!";
}).orElse(ChatColor.RED
+ "The Tyrant role is vacant; the red arena challenge is now open.");
server.getOnlinePlayers().forEach(player -> player.sendMessage(message));
}
}
@@ -1,65 +1,26 @@
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<UUID> killerId,
Set<UUID> onlinePlayerIds,
Instant now
Optional<UUID> killerId
) {
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();
Map<UUID, PlayerState> clearedPlayers = clearAssignments(state.players());
Optional<UUID> selectedTyrant = killerId.isPresent()
? killerId
: candidates.select(
clearedPlayers,
Set.of(),
Optional.of(formerTyrant),
now,
activityWindow,
random
);
Optional<UUID> activeTyrant = selectedTyrant
.filter(playerId -> killerId.isPresent() || onlinePlayerIds.contains(playerId));
Optional<PendingSelection> pendingTyrant = selectedTyrant
.filter(playerId -> activeTyrant.isEmpty())
.map(playerId -> new PendingSelection(playerId, now.plus(pendingTimeout)));
GameState game = new GameState(
GameLifecycle.RUNNING,
activeTyrant,
killerId,
Optional.empty(),
Optional.empty(),
pendingTyrant,
Optional.empty(),
Optional.empty(),
state.game().accumulatedPausedTime(),
@@ -1,19 +0,0 @@
package games.dmg.spigottyrant;
public final class VigilanteArenaPolicy {
private VigilanteArenaPolicy() {
}
public static boolean isOpen(GameState game) {
return game.lifecycle() == GameLifecycle.RUNNING
&& game.tyrantId().isPresent()
&& game.vigilanteId().isEmpty();
}
public static boolean isEligible(GameState game, PlayerState player) {
return player != null
&& isOpen(game)
&& player.optedOutUntil().isEmpty()
&& game.tyrantId().filter(player.playerId()::equals).isEmpty();
}
}
@@ -6,7 +6,9 @@ import java.util.UUID;
public final class VigilanteArenaSuccessionService {
public LifecycleState claim(PersistentState state, UUID winnerId) {
PlayerState winner = state.players().get(winnerId);
if (!VigilanteArenaPolicy.isEligible(state.game(), winner)) {
if (!RoleArenaPolicy.isEligible(
state.game(), ArenaRole.VIGILANTE, winner
)) {
throw new IllegalStateException("player is not eligible to claim the Vigilante role");
}
GameState game = state.game();
@@ -1,8 +1,6 @@
package games.dmg.spigottyrant;
import java.time.Clock;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.bukkit.ChatColor;
import org.bukkit.Server;
@@ -17,20 +15,17 @@ public final class VigilanteDeathListener implements Listener {
private final TyrantProgressionService progression;
private final RoleMaintenanceService maintenance;
private final Server server;
private final Clock clock;
public VigilanteDeathListener(
TyrantStateManager stateManager,
TyrantProgressionService progression,
RoleMaintenanceService maintenance,
Server server,
Clock clock
Server server
) {
this.stateManager = stateManager;
this.progression = progression;
this.maintenance = maintenance;
this.server = server;
this.clock = clock;
}
@EventHandler(priority = EventPriority.MONITOR)
@@ -47,11 +42,8 @@ public final class VigilanteDeathListener implements Listener {
PersistentState withProgress = new PersistentState(
progressed, stateManager.players()
);
Set<UUID> online = server.getOnlinePlayers().stream()
.map(Player::getUniqueId)
.collect(java.util.stream.Collectors.toUnmodifiableSet());
LifecycleState rerolled = maintenance.rerollVigilante(
withProgress, Optional.of(victimId), online, clock.instant()
withProgress, Optional.of(victimId)
);
stateManager.replaceState(rerolled);
stateManager.saveIfDirty();