From 323d177573ad13485294bb48d64b9054ba5721c6 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Sat, 15 Aug 2026 09:13:34 -0400 Subject: [PATCH] feat(participation): highlight opted-out players --- design/log.md | 12 ++ .../us-011-inform-and-manage-participation.md | 4 + .../spigottyrant/OptOutGlowController.java | 127 ++++++++++++++++++ .../dmg/spigottyrant/OptOutGlowPolicy.java | 22 +++ .../dmg/spigottyrant/SpigotTyrantPlugin.java | 7 + .../OptOutGlowControllerTest.java | 97 +++++++++++++ .../spigottyrant/OptOutGlowPolicyTest.java | 76 +++++++++++ 7 files changed, 345 insertions(+) create mode 100644 src/main/java/games/dmg/spigottyrant/OptOutGlowController.java create mode 100644 src/main/java/games/dmg/spigottyrant/OptOutGlowPolicy.java create mode 100644 src/test/java/games/dmg/spigottyrant/OptOutGlowControllerTest.java create mode 100644 src/test/java/games/dmg/spigottyrant/OptOutGlowPolicyTest.java diff --git a/design/log.md b/design/log.md index e784c6e..3d7afc7 100644 --- a/design/log.md +++ b/design/log.md @@ -6,6 +6,18 @@ description: Chronological record of material decisions affecting the Spigot Tyr # 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 - Updated US-011 so join output no longer announces running, paused, or unstarted lifecycle state. diff --git a/design/user-stories/us-011-inform-and-manage-participation.md b/design/user-stories/us-011-inform-and-manage-participation.md index 95849c6..7112cd8 100644 --- a/design/user-stories/us-011-inform-and-manage-participation.md +++ b/design/user-stories/us-011-inform-and-manage-participation.md @@ -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] 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] 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 diff --git a/src/main/java/games/dmg/spigottyrant/OptOutGlowController.java b/src/main/java/games/dmg/spigottyrant/OptOutGlowController.java new file mode 100644 index 0000000..f6e1fd8 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/OptOutGlowController.java @@ -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 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 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 + ) { + } +} diff --git a/src/main/java/games/dmg/spigottyrant/OptOutGlowPolicy.java b/src/main/java/games/dmg/spigottyrant/OptOutGlowPolicy.java new file mode 100644 index 0000000..328a733 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/OptOutGlowPolicy.java @@ -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; + } +} diff --git a/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java b/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java index 49c33f8..d537af6 100644 --- a/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java +++ b/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java @@ -15,6 +15,7 @@ public final class SpigotTyrantPlugin extends JavaPlugin { private AssassinEffectController assassinEffects; private FixerEffectController fixerEffects; private TyrantEffectController tyrantEffects; + private OptOutGlowController optOutGlow; @Override public void onEnable() { @@ -69,6 +70,7 @@ public final class SpigotTyrantPlugin extends JavaPlugin { FixerAbilityService fixerAbilities = new FixerAbilityService(readiness, settings); fixerEffects = new FixerEffectController(stateManager, getServer(), settings, clock); tyrantEffects = new TyrantEffectController(stateManager, getServer(), settings); + optOutGlow = new OptOutGlowController(stateManager, getServer(), settings); VigilanteCombatTracker combatTracker = new VigilanteCombatTracker(); vigilanteEffects = new VigilanteEffectController( stateManager, getServer(), combatTracker, settings, clock @@ -128,6 +130,7 @@ public final class SpigotTyrantPlugin extends JavaPlugin { ), this ); + getServer().getPluginManager().registerEvents(optOutGlow, this); getServer().getPluginManager().registerEvents( new VigilanteCombatListener( 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, fixerEffects, 10L, 10L); getServer().getScheduler().runTaskTimer(this, tyrantEffects, 10L, 10L); + getServer().getScheduler().runTaskTimer(this, optOutGlow, 10L, 10L); getServer().getScheduler().runTaskTimer( this, new AbilityItemRefreshTask( @@ -191,6 +195,9 @@ public final class SpigotTyrantPlugin extends JavaPlugin { @Override public void onDisable() { + if (optOutGlow != null) { + optOutGlow.clearAll(); + } if (tyrantEffects != null) { tyrantEffects.clearAll(); } diff --git a/src/test/java/games/dmg/spigottyrant/OptOutGlowControllerTest.java b/src/test/java/games/dmg/spigottyrant/OptOutGlowControllerTest.java new file mode 100644 index 0000000..18c0c0e --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/OptOutGlowControllerTest.java @@ -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() + ); + } +} diff --git a/src/test/java/games/dmg/spigottyrant/OptOutGlowPolicyTest.java b/src/test/java/games/dmg/spigottyrant/OptOutGlowPolicyTest.java new file mode 100644 index 0000000..c07bafa --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/OptOutGlowPolicyTest.java @@ -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 pausedAt) { + return new GameState( + lifecycle, Optional.of(TYRANT), Optional.empty(), Optional.empty(), + Optional.empty(), pausedAt, Duration.ZERO, 0, 0, Set.of() + ); + } +}