feat(participation): highlight opted-out players
Release / release (push) Successful in 2m40s
CI / build (push) Successful in 1m8s

This commit is contained in:
dmg
2026-08-15 09:13:34 -04:00
parent ea267ff867
commit 323d177573
7 changed files with 345 additions and 0 deletions
+12
View File
@@ -6,6 +6,18 @@ description: Chronological record of material decisions affecting the Spigot Tyr
# Spigot Tyrant Design Log # Spigot Tyrant Design Log
## 2026-08-14 — Opt-out proximity glow completed
- Extended US-011 with a globally visible green outline for opted-out players in the Tyrant's world and configured range while the event is running.
- A periodic controller removes the glow when eligibility, proximity, world, lifecycle, or player availability changes and clears managed state on logout and shutdown.
- Scoreboard presentation records and restores each player's previous team and glowing state instead of permanently replacing unrelated presentation.
- Verified inclusive range behavior, lifecycle and availability gating, green presentation, restoration, and the full Gradle build.
## 2026-08-14 — Opt-out proximity glow started
- US-011 is reopened for a test-first green outline on opted-out players near the Tyrant during a running event, using the existing configurable Tyrant range.
- Cleanup will cover range, world, participation, lifecycle, availability, shutdown, and restoration of pre-existing scoreboard-team membership.
## 2026-08-14 — Join messaging refinement completed ## 2026-08-14 — Join messaging refinement completed
- Updated US-011 so join output no longer announces running, paused, or unstarted lifecycle state. - Updated US-011 so join output no longer announces running, paused, or unstarted lifecycle state.
@@ -26,6 +26,10 @@ As a **player**, I want to understand my status and control my eligibility so th
- [x] Cancelled attackers are told that the target has chosen not to take part in the Tyrant event. - [x] Cancelled attackers are told that the target has chosen not to take part in the Tyrant event.
- [x] Protection covers direct player attacks and player-fired projectiles without changing unrelated combat. - [x] Protection covers direct player attacks and player-fired projectiles without changing unrelated combat.
- [x] Participation combat protection does not apply while the event is paused or not started. - [x] Participation combat protection does not apply while the event is paused or not started.
- [x] While the event is running, an opted-out player in the same world and within the configured Tyrant range has a green glowing outline visible to everyone.
- [x] The opt-out glow is removed when proximity, participation, player availability, Tyrant availability, or running-event requirements stop being satisfied.
- [x] Existing scoreboard-team membership is restored after the opt-out glow ends, and plugin shutdown safely clears managed glow state.
- [x] Opt-out proximity glow does not apply while the event is paused or not started.
## Related ## Related
@@ -0,0 +1,127 @@
package games.dmg.spigottyrant;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
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.player.PlayerQuitEvent;
import org.bukkit.scoreboard.Scoreboard;
import org.bukkit.scoreboard.ScoreboardManager;
import org.bukkit.scoreboard.Team;
public final class OptOutGlowController implements Runnable, Listener {
private static final String TEAM_NAME = "tyrant-optout";
private final TyrantStateManager stateManager;
private final Server server;
private final PluginSettings settings;
private final Scoreboard scoreboard;
private final Team greenTeam;
private final Map<UUID, PreviousPresentation> managed = new HashMap<>();
public OptOutGlowController(
TyrantStateManager stateManager,
Server server,
PluginSettings settings
) {
this.stateManager = stateManager;
this.server = server;
this.settings = settings;
ScoreboardManager manager = Objects.requireNonNull(
server.getScoreboardManager(), "Scoreboard manager is unavailable"
);
scoreboard = manager.getMainScoreboard();
Team existing = scoreboard.getTeam(TEAM_NAME);
greenTeam = existing == null ? scoreboard.registerNewTeam(TEAM_NAME) : existing;
greenTeam.setColor(ChatColor.GREEN);
}
@Override
public void run() {
GameState game = stateManager.game();
Player tyrant = game.tyrantId().map(server::getPlayer).orElse(null);
boolean tyrantAvailable = tyrant != null && !tyrant.isDead();
Set<UUID> online = new HashSet<>();
for (Player player : server.getOnlinePlayers()) {
online.add(player.getUniqueId());
PlayerState state = stateManager.players().get(player.getUniqueId());
boolean sameWorld = tyrantAvailable && !player.isDead()
&& player.getWorld().equals(tyrant.getWorld());
double distanceSquared = sameWorld
? player.getLocation().distanceSquared(tyrant.getLocation())
: Double.POSITIVE_INFINITY;
boolean shouldGlow = state != null && OptOutGlowPolicy.shouldGlow(
game, state, tyrantAvailable, sameWorld, distanceSquared,
settings.tyrantRangeBlocks()
);
if (shouldGlow && !managed.containsKey(player.getUniqueId())) {
apply(player);
} else if (!shouldGlow && managed.containsKey(player.getUniqueId())) {
restore(player.getUniqueId(), player);
}
}
for (UUID playerId : Set.copyOf(managed.keySet())) {
if (!online.contains(playerId) && server.getPlayer(playerId) == null) {
restore(playerId, null);
}
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onQuit(PlayerQuitEvent event) {
if (managed.containsKey(event.getPlayer().getUniqueId())) {
restore(event.getPlayer().getUniqueId(), event.getPlayer());
}
}
public void clearAll() {
for (UUID playerId : Set.copyOf(managed.keySet())) {
restore(playerId, server.getPlayer(playerId));
}
}
private void apply(Player player) {
Team previousTeam = scoreboard.getEntryTeam(player.getName());
if (previousTeam == greenTeam) {
previousTeam = null;
}
managed.put(
player.getUniqueId(),
new PreviousPresentation(player.getName(), previousTeam, player.isGlowing())
);
greenTeam.addEntry(player.getName());
player.setGlowing(true);
}
private void restore(UUID playerId, Player player) {
PreviousPresentation previous = managed.remove(playerId);
if (previous == null) {
return;
}
greenTeam.removeEntry(previous.playerName());
if (previous.team() != null) {
try {
previous.team().addEntry(previous.playerName());
} catch (IllegalStateException ignored) {
// The prior team was removed while this plugin managed the entry.
}
}
if (player != null) {
player.setGlowing(previous.glowing());
}
}
private record PreviousPresentation(
String playerName,
Team team,
boolean glowing
) {
}
}
@@ -0,0 +1,22 @@
package games.dmg.spigottyrant;
public final class OptOutGlowPolicy {
private OptOutGlowPolicy() {
}
public static boolean shouldGlow(
GameState game,
PlayerState player,
boolean tyrantAvailable,
boolean playerAvailableInTyrantWorld,
double distanceSquared,
double rangeBlocks
) {
return game.lifecycle() == GameLifecycle.RUNNING
&& game.tyrantId().isPresent()
&& player.optedOutUntil().isPresent()
&& tyrantAvailable
&& playerAvailableInTyrantWorld
&& distanceSquared <= rangeBlocks * rangeBlocks;
}
}
@@ -15,6 +15,7 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
private AssassinEffectController assassinEffects; private AssassinEffectController assassinEffects;
private FixerEffectController fixerEffects; private FixerEffectController fixerEffects;
private TyrantEffectController tyrantEffects; private TyrantEffectController tyrantEffects;
private OptOutGlowController optOutGlow;
@Override @Override
public void onEnable() { public void onEnable() {
@@ -69,6 +70,7 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
FixerAbilityService fixerAbilities = new FixerAbilityService(readiness, settings); FixerAbilityService fixerAbilities = new FixerAbilityService(readiness, settings);
fixerEffects = new FixerEffectController(stateManager, getServer(), settings, clock); fixerEffects = new FixerEffectController(stateManager, getServer(), settings, clock);
tyrantEffects = new TyrantEffectController(stateManager, getServer(), settings); tyrantEffects = new TyrantEffectController(stateManager, getServer(), settings);
optOutGlow = new OptOutGlowController(stateManager, getServer(), settings);
VigilanteCombatTracker combatTracker = new VigilanteCombatTracker(); VigilanteCombatTracker combatTracker = new VigilanteCombatTracker();
vigilanteEffects = new VigilanteEffectController( vigilanteEffects = new VigilanteEffectController(
stateManager, getServer(), combatTracker, settings, clock stateManager, getServer(), combatTracker, settings, clock
@@ -128,6 +130,7 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
), ),
this this
); );
getServer().getPluginManager().registerEvents(optOutGlow, this);
getServer().getPluginManager().registerEvents( getServer().getPluginManager().registerEvents(
new VigilanteCombatListener( new VigilanteCombatListener(
stateManager, combatTracker, clock, settings.vigilanteCombatDuration() stateManager, combatTracker, clock, settings.vigilanteCombatDuration()
@@ -176,6 +179,7 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
getServer().getScheduler().runTaskTimer(this, assassinEffects, 10L, 10L); getServer().getScheduler().runTaskTimer(this, assassinEffects, 10L, 10L);
getServer().getScheduler().runTaskTimer(this, fixerEffects, 10L, 10L); getServer().getScheduler().runTaskTimer(this, fixerEffects, 10L, 10L);
getServer().getScheduler().runTaskTimer(this, tyrantEffects, 10L, 10L); getServer().getScheduler().runTaskTimer(this, tyrantEffects, 10L, 10L);
getServer().getScheduler().runTaskTimer(this, optOutGlow, 10L, 10L);
getServer().getScheduler().runTaskTimer( getServer().getScheduler().runTaskTimer(
this, this,
new AbilityItemRefreshTask( new AbilityItemRefreshTask(
@@ -191,6 +195,9 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
@Override @Override
public void onDisable() { public void onDisable() {
if (optOutGlow != null) {
optOutGlow.clearAll();
}
if (tyrantEffects != null) { if (tyrantEffects != null) {
tyrantEffects.clearAll(); tyrantEffects.clearAll();
} }
@@ -0,0 +1,97 @@
package games.dmg.spigottyrant;
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.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.bukkit.ChatColor;
import org.bukkit.Location;
import org.bukkit.Server;
import org.bukkit.World;
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 OptOutGlowControllerTest {
private static final UUID TYRANT = UUID.fromString(
"11111111-1111-1111-1111-111111111111"
);
private static final UUID OUT = UUID.fromString(
"22222222-2222-2222-2222-222222222222"
);
@Test
void appliesGreenGlowAndRestoresPreviousTeamAfterLeavingRange() {
TyrantStateManager stateManager = mock(TyrantStateManager.class);
Server server = mock(Server.class);
PluginSettings settings = mock(PluginSettings.class);
ScoreboardManager manager = mock(ScoreboardManager.class);
Scoreboard scoreboard = mock(Scoreboard.class);
Team green = mock(Team.class);
Team previous = mock(Team.class);
Player tyrant = player(TYRANT, "Tyrant");
Player optedOut = player(OUT, "Out");
World world = mock(World.class);
Location tyrantLocation = mock(Location.class);
Location outLocation = mock(Location.class);
when(server.getScoreboardManager()).thenReturn(manager);
when(manager.getMainScoreboard()).thenReturn(scoreboard);
when(scoreboard.getTeam("tyrant-optout")).thenReturn(null);
when(scoreboard.registerNewTeam("tyrant-optout")).thenReturn(green);
when(scoreboard.getEntryTeam("Out")).thenReturn(previous);
when(server.getPlayer(TYRANT)).thenReturn(tyrant);
doReturn(Set.of(tyrant, optedOut)).when(server).getOnlinePlayers();
when(settings.tyrantRangeBlocks()).thenReturn(50.0);
when(tyrant.getWorld()).thenReturn(world);
when(optedOut.getWorld()).thenReturn(world);
when(tyrant.getLocation()).thenReturn(tyrantLocation);
when(optedOut.getLocation()).thenReturn(outLocation);
when(outLocation.distanceSquared(tyrantLocation)).thenReturn(25.0, 2601.0);
when(stateManager.game()).thenReturn(running());
when(stateManager.players()).thenReturn(Map.of(OUT, optedOut()));
OptOutGlowController controller = new OptOutGlowController(
stateManager, server, settings
);
controller.run();
controller.run();
verify(green).setColor(ChatColor.GREEN);
verify(green).addEntry("Out");
verify(optedOut).setGlowing(true);
verify(green).removeEntry("Out");
verify(previous).addEntry("Out");
verify(optedOut).setGlowing(false);
}
private static Player player(UUID id, String name) {
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(id);
when(player.getName()).thenReturn(name);
return player;
}
private static PlayerState optedOut() {
return new PlayerState(
OUT, "Out", Optional.empty(), Optional.of(Instant.MAX), TyrantClass.NONE,
Optional.empty(), Map.of(), Set.of(), java.util.List.of()
);
}
private static GameState running() {
return new GameState(
GameLifecycle.RUNNING, Optional.of(TYRANT), Optional.empty(),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
0, 0, Set.of()
);
}
}
@@ -0,0 +1,76 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class OptOutGlowPolicyTest {
private static final UUID TYRANT = UUID.fromString(
"11111111-1111-1111-1111-111111111111"
);
private static final UUID PLAYER = UUID.fromString(
"22222222-2222-2222-2222-222222222222"
);
@Test
void optedOutPlayerGlowsWithinInclusiveRunningRange() {
assertTrue(OptOutGlowPolicy.shouldGlow(
running(), optedOut(), true, true, 2500.0, 50.0
));
}
@Test
void requiresRunningEventAvailablePlayersSameWorldAndRange() {
assertFalse(OptOutGlowPolicy.shouldGlow(
GameState.empty(), optedOut(), true, true, 1.0, 50.0
));
assertFalse(OptOutGlowPolicy.shouldGlow(
paused(), optedOut(), true, true, 1.0, 50.0
));
assertFalse(OptOutGlowPolicy.shouldGlow(
running(), optedIn(), true, true, 1.0, 50.0
));
assertFalse(OptOutGlowPolicy.shouldGlow(
running(), optedOut(), false, true, 1.0, 50.0
));
assertFalse(OptOutGlowPolicy.shouldGlow(
running(), optedOut(), true, false, 1.0, 50.0
));
assertFalse(OptOutGlowPolicy.shouldGlow(
running(), optedOut(), true, true, 2500.01, 50.0
));
}
private static PlayerState optedOut() {
return new PlayerState(
PLAYER, "Player", Optional.empty(), Optional.of(Instant.MAX),
TyrantClass.NONE, Optional.empty(), Map.of(), Set.of(), java.util.List.of()
);
}
private static PlayerState optedIn() {
return PlayerState.newPlayer(PLAYER, "Player");
}
private static GameState running() {
return game(GameLifecycle.RUNNING, Optional.empty());
}
private static GameState paused() {
return game(GameLifecycle.PAUSED, Optional.of(Instant.EPOCH));
}
private static GameState game(GameLifecycle lifecycle, Optional<Instant> pausedAt) {
return new GameState(
lifecycle, Optional.of(TYRANT), Optional.empty(), Optional.empty(),
Optional.empty(), pausedAt, Duration.ZERO, 0, 0, Set.of()
);
}
}