Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
323d177573 |
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 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();
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user