From 1b622211cb02bae22dc585e32734997fadedf21b Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Mon, 10 Aug 2026 18:21:15 -0400 Subject: [PATCH] feat(identity): display Leaf protection prefixes --- README.md | 2 +- design/log.md | 8 ++ .../us-003-identify-protected-players.md | 14 +-- .../java/games/dmg/leaf/LeafIdentity.java | 111 ++++++++++++++++++ .../java/games/dmg/leaf/LeafListener.java | 2 +- src/main/java/games/dmg/leaf/LeafPlugin.java | 5 +- src/main/java/games/dmg/leaf/LeafRuntime.java | 11 +- .../java/games/dmg/leaf/LeafIdentityTest.java | 74 ++++++++++++ .../java/games/dmg/leaf/LeafRuntimeTest.java | 3 +- 9 files changed, 215 insertions(+), 15 deletions(-) create mode 100644 src/main/java/games/dmg/leaf/LeafIdentity.java create mode 100644 src/test/java/games/dmg/leaf/LeafIdentityTest.java diff --git a/README.md b/README.md index dba344f..aa56a26 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Administrative commands require `leaf.admin`, granted to server operators by def ## Compatibility -Leaf will use standard Spigot facilities for tab-list, overhead-name, and chat prefixes. Scoreboard or chat-management plugins may override those facilities; unsupported integrations must not affect protection state. +Leaf prefixes the standard Spigot display name (used by standard chat), player-list name, and a dedicated `leaf_protected` main-scoreboard team for overhead names. Legacy `&` color codes are supported. Leaf does not move players out of an existing scoreboard team, and per-viewer/custom scoreboards may not show its overhead prefix. Chat or tab-management plugins may replace Leaf's decorated names. Leaf restores a name only when it still matches the value Leaf installed, so unsupported integrations do not affect protection state or get overwritten during cleanup. ## Releases diff --git a/design/log.md b/design/log.md index f92b4bb..a7789d1 100644 --- a/design/log.md +++ b/design/log.md @@ -34,3 +34,11 @@ - Added immediate durable opt-in persistence, join-time restoration, and quiet infinite Resistance reconciliation. - Leaf tracks its live Resistance fingerprint and conservatively preserves a visibly distinct Resistance effect. - Verified player choice, lock, persistence, command, autocomplete, and build behavior with `./gradlew clean check jar`. + +### US-003 identity presentation checkpoint + +- Added configurable, color-compatible prefixes to standard chat display names and player-list names. +- Added conservative main-scoreboard team prefixes for overhead names without displacing unrelated teams. +- Name cleanup restores only values Leaf installed; third-party changes are preserved. +- Documented scoreboard, custom chat, tab-list, and per-viewer scoreboard limitations. +- US-003 remains in progress until combat and global administration paths are verified. diff --git a/design/user-stories/us-003-identify-protected-players.md b/design/user-stories/us-003-identify-protected-players.md index 5a83b8b..eddeb3a 100644 --- a/design/user-stories/us-003-identify-protected-players.md +++ b/design/user-stories/us-003-identify-protected-players.md @@ -2,7 +2,7 @@ type: User Story title: "US-003: Identify protected players" description: Mark actively protected players with a visible leaf prefix wherever Spigot supports it. -status: backlog +status: in-progress --- # US-003: Identify protected players @@ -11,14 +11,14 @@ As a **server participant**, I want protected players to be visibly identified s ## Acceptance criteria -- [ ] An actively protected player has a configurable leaf prefix before their name in the player list. -- [ ] An actively protected player has the same prefix before their overhead name where Spigot supports it. -- [ ] An actively protected player has the same prefix in standard Spigot chat where the active chat format supports it. -- [ ] The default prefix uses a leaf symbol with readable spacing and Minecraft-compatible formatting. +- [x] An actively protected player has a configurable leaf prefix before their name in the player list. +- [x] An actively protected player has the same prefix before their overhead name where Spigot supports it. +- [x] An actively protected player has the same prefix in standard Spigot chat where the active chat format supports it. +- [x] The default prefix uses a leaf symbol with readable spacing and Minecraft-compatible formatting. - [ ] The prefix is removed immediately when the player opts out or is opted out by PvP or an administrator. - [ ] Prefixes are suppressed while Leaf is globally disabled and restored for online opted-in players when it is re-enabled. -- [ ] Prefix updates do not overwrite unrelated display-name text where the Spigot API allows the values to coexist. -- [ ] Scoreboard-team and third-party chat-plugin compatibility limitations are documented, and unsupported integrations fail without affecting protection state. +- [x] Prefix updates do not overwrite unrelated display-name text where the Spigot API allows the values to coexist. +- [x] Scoreboard-team and third-party chat-plugin compatibility limitations are documented, and unsupported integrations fail without affecting protection state. ## Related diff --git a/src/main/java/games/dmg/leaf/LeafIdentity.java b/src/main/java/games/dmg/leaf/LeafIdentity.java new file mode 100644 index 0000000..b337141 --- /dev/null +++ b/src/main/java/games/dmg/leaf/LeafIdentity.java @@ -0,0 +1,111 @@ +package games.dmg.leaf; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.logging.Logger; +import org.bukkit.ChatColor; +import org.bukkit.Server; +import org.bukkit.entity.Player; +import org.bukkit.scoreboard.Scoreboard; +import org.bukkit.scoreboard.ScoreboardManager; +import org.bukkit.scoreboard.Team; + +/** Conservatively decorates standard Spigot player identity surfaces. */ +public final class LeafIdentity { + private static final String TEAM_NAME = "leaf_protected"; + + private record Names(String display, String playerList, String decoratedDisplay, String decoratedList) { } + + private final Server server; + private final Logger logger; + private final Map names = new HashMap<>(); + private final Map teamEntries = new HashMap<>(); + + public LeafIdentity(Server server, Logger logger) { + this.server = server; + this.logger = logger; + } + + public synchronized void apply(Player player, String configuredPrefix) { + String prefix = ChatColor.translateAlternateColorCodes('&', configuredPrefix); + UUID playerId = player.getUniqueId(); + Names previous = names.get(playerId); + String baseDisplay = previous == null ? player.getDisplayName() : previous.display(); + String baseList = previous == null ? player.getPlayerListName() : previous.playerList(); + String decoratedDisplay = prefix + baseDisplay; + String decoratedList = prefix + baseList; + + if (previous == null || player.getDisplayName().equals(previous.decoratedDisplay())) { + player.setDisplayName(decoratedDisplay); + } + if (previous == null || player.getPlayerListName().equals(previous.decoratedList())) { + player.setPlayerListName(decoratedList); + } + names.put(playerId, new Names(baseDisplay, baseList, decoratedDisplay, decoratedList)); + applyOverhead(player, prefix); + } + + public synchronized void remove(Player player) { + UUID playerId = player.getUniqueId(); + Names installed = names.remove(playerId); + if (installed != null) { + if (player.getDisplayName().equals(installed.decoratedDisplay())) { + player.setDisplayName(installed.display()); + } + if (player.getPlayerListName().equals(installed.decoratedList())) { + player.setPlayerListName(installed.playerList()); + } + } + + String entry = teamEntries.remove(playerId); + Team team = leafTeam(false); + if (entry != null && team != null) { + team.removeEntry(entry); + } + } + + private void applyOverhead(Player player, String prefix) { + Scoreboard scoreboard = mainScoreboard(); + if (scoreboard == null) { + return; + } + String entry = player.getName(); + Team existing = scoreboard.getEntryTeam(entry); + Team leafTeam = leafTeam(true); + if (leafTeam == null) { + return; + } + if (existing != null && existing != leafTeam) { + logger.fine("Leaf overhead prefix skipped for " + entry + ": player uses another team"); + return; + } + String oldEntry = teamEntries.put(player.getUniqueId(), entry); + if (oldEntry != null && !oldEntry.equals(entry)) { + leafTeam.removeEntry(oldEntry); + } + leafTeam.setPrefix(prefix); + leafTeam.addEntry(entry); + } + + private Team leafTeam(boolean create) { + Scoreboard scoreboard = mainScoreboard(); + if (scoreboard == null) { + return null; + } + Team team = scoreboard.getTeam(TEAM_NAME); + if (team == null && create) { + try { + team = scoreboard.registerNewTeam(TEAM_NAME); + } catch (IllegalArgumentException exception) { + logger.warning("Could not create Leaf scoreboard team: " + exception.getMessage()); + } + } + return team; + } + + private Scoreboard mainScoreboard() { + ScoreboardManager manager = server.getScoreboardManager(); + return manager == null ? null : manager.getMainScoreboard(); + } +} diff --git a/src/main/java/games/dmg/leaf/LeafListener.java b/src/main/java/games/dmg/leaf/LeafListener.java index 64e1d8e..14df438 100644 --- a/src/main/java/games/dmg/leaf/LeafListener.java +++ b/src/main/java/games/dmg/leaf/LeafListener.java @@ -26,7 +26,7 @@ public final class LeafListener implements Listener { runtime.observe(event.getPlayer(), clock.instant()); } catch (IOException exception) { logger.log(Level.SEVERE, "Could not persist Leaf player state on join", exception); - runtime.removeProtection(event.getPlayer()); + runtime.removePresentation(event.getPlayer()); event.getPlayer().sendMessage("Leaf protection is unavailable because state could not be saved."); } } diff --git a/src/main/java/games/dmg/leaf/LeafPlugin.java b/src/main/java/games/dmg/leaf/LeafPlugin.java index af9518c..d41688c 100644 --- a/src/main/java/games/dmg/leaf/LeafPlugin.java +++ b/src/main/java/games/dmg/leaf/LeafPlugin.java @@ -26,7 +26,8 @@ public final class LeafPlugin extends JavaPlugin { getServer(), settingsProvider, stateManager, - new LeafProtection() + new LeafProtection(), + new LeafIdentity(getServer(), getLogger()) ); registerRuntime(); } catch (IllegalArgumentException | IOException exception) { @@ -43,7 +44,7 @@ public final class LeafPlugin extends JavaPlugin { public void onDisable() { if (runtime != null) { for (Player player : getServer().getOnlinePlayers()) { - runtime.removeProtection(player); + runtime.removePresentation(player); } } saveState(); diff --git a/src/main/java/games/dmg/leaf/LeafRuntime.java b/src/main/java/games/dmg/leaf/LeafRuntime.java index 940e8ad..08eb486 100644 --- a/src/main/java/games/dmg/leaf/LeafRuntime.java +++ b/src/main/java/games/dmg/leaf/LeafRuntime.java @@ -27,17 +27,20 @@ public final class LeafRuntime { private final LeafSettingsProvider settingsProvider; private final LeafStateManager stateManager; private final LeafProtection protection; + private final LeafIdentity identity; public LeafRuntime( Server server, LeafSettingsProvider settingsProvider, LeafStateManager stateManager, - LeafProtection protection + LeafProtection protection, + LeafIdentity identity ) { this.server = Objects.requireNonNull(server, "server"); this.settingsProvider = Objects.requireNonNull(settingsProvider, "settingsProvider"); this.stateManager = Objects.requireNonNull(stateManager, "stateManager"); this.protection = Objects.requireNonNull(protection, "protection"); + this.identity = Objects.requireNonNull(identity, "identity"); } public PlayerLeafState observe(Player player, Instant observedAt) throws IOException { @@ -105,13 +108,15 @@ public final class LeafRuntime { LeafSettings settings = settingsProvider.current(); if (state != null && state.optedIn() && settings.enabled()) { protection.apply(player, settings.resistanceLevel()); + identity.apply(player, settings.prefix()); } else { - protection.remove(player); + removePresentation(player); } } - public void removeProtection(Player player) { + public void removePresentation(Player player) { protection.remove(player); + identity.remove(player); } public LeafStateManager stateManager() { diff --git a/src/test/java/games/dmg/leaf/LeafIdentityTest.java b/src/test/java/games/dmg/leaf/LeafIdentityTest.java new file mode 100644 index 0000000..1b9f7d6 --- /dev/null +++ b/src/test/java/games/dmg/leaf/LeafIdentityTest.java @@ -0,0 +1,74 @@ +package games.dmg.leaf; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.UUID; +import java.util.logging.Logger; +import org.bukkit.Server; +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 LeafIdentityTest { + @Test + void decoratesTabChatAndAnAvailableOverheadTeamThenRestoresNames() { + Fixtures fixtures = fixtures(); + LeafIdentity identity = new LeafIdentity(fixtures.server(), Logger.getAnonymousLogger()); + + identity.apply(fixtures.player(), "&a๐Ÿƒ "); + + String decorated = "ยงa๐Ÿƒ Alex"; + verify(fixtures.player()).setDisplayName(decorated); + verify(fixtures.player()).setPlayerListName(decorated); + verify(fixtures.team()).setPrefix("ยงa๐Ÿƒ "); + verify(fixtures.team()).addEntry("Alex"); + + when(fixtures.player().getDisplayName()).thenReturn(decorated); + when(fixtures.player().getPlayerListName()).thenReturn(decorated); + identity.remove(fixtures.player()); + + verify(fixtures.player()).setDisplayName("Alex"); + verify(fixtures.player()).setPlayerListName("Alex"); + verify(fixtures.team()).removeEntry("Alex"); + } + + @Test + void leavesThirdPartyChangesAndTeamsUntouched() { + Fixtures fixtures = fixtures(); + Team otherTeam = mock(Team.class); + when(fixtures.scoreboard().getEntryTeam("Alex")).thenReturn(otherTeam); + LeafIdentity identity = new LeafIdentity(fixtures.server(), Logger.getAnonymousLogger()); + identity.apply(fixtures.player(), "&a๐Ÿƒ "); + when(fixtures.player().getDisplayName()).thenReturn("ThirdPartyAlex"); + when(fixtures.player().getPlayerListName()).thenReturn("ThirdPartyAlex"); + + identity.remove(fixtures.player()); + + verify(otherTeam, never()).removeEntry("Alex"); + verify(fixtures.player(), never()).setDisplayName("Alex"); + verify(fixtures.player(), never()).setPlayerListName("Alex"); + } + + private static Fixtures fixtures() { + Server server = mock(Server.class); + ScoreboardManager manager = mock(ScoreboardManager.class); + Scoreboard scoreboard = mock(Scoreboard.class); + Team team = mock(Team.class); + Player player = mock(Player.class); + when(server.getScoreboardManager()).thenReturn(manager); + when(manager.getMainScoreboard()).thenReturn(scoreboard); + when(scoreboard.getTeam("leaf_protected")).thenReturn(team); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + when(player.getName()).thenReturn("Alex"); + when(player.getDisplayName()).thenReturn("Alex"); + when(player.getPlayerListName()).thenReturn("Alex"); + return new Fixtures(server, scoreboard, team, player); + } + + private record Fixtures(Server server, Scoreboard scoreboard, Team team, Player player) { } +} diff --git a/src/test/java/games/dmg/leaf/LeafRuntimeTest.java b/src/test/java/games/dmg/leaf/LeafRuntimeTest.java index 969a7da..6b6b56b 100644 --- a/src/test/java/games/dmg/leaf/LeafRuntimeTest.java +++ b/src/test/java/games/dmg/leaf/LeafRuntimeTest.java @@ -91,7 +91,8 @@ final class LeafRuntimeTest { server, new LeafSettingsProvider(LeafSettings.from(Map.of())), new LeafStateManager(new YamlLeafStateRepository(stateFile)), - protection + protection, + mock(LeafIdentity.class) ); }