From e064c7398c89176e36e043f9752f5e216a3f2f36 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Mon, 10 Aug 2026 21:13:02 -0400 Subject: [PATCH] fix(identity): remove stale Leaf prefixes --- README.md | 2 +- design/log.md | 7 +++ .../us-003-identify-protected-players.md | 3 + .../java/games/dmg/leaf/LeafIdentity.java | 58 +++++++++++-------- src/main/java/games/dmg/leaf/LeafRuntime.java | 4 +- .../java/games/dmg/leaf/LeafIdentityTest.java | 47 ++++++++++++++- .../java/games/dmg/leaf/LeafRuntimeTest.java | 2 +- 7 files changed, 93 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index d7ca891..b154b63 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Administrative commands require `leaf.admin`, granted to server operators by def ## Compatibility -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. +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. During cleanup, Leaf removes exact copies of its configured prefix while preserving surrounding name formatting; another plugin should therefore not intentionally use identical prefix text. Spigot identifies potion effects by type but does not expose their owning plugin. Leaf tracks the exact infinite, quiet Resistance effect it successfully installed and removes it only while the visible effect still matches. A distinct Resistance level, duration, or presentation is preserved. Spigot cannot distinguish an externally supplied effect with an identical fingerprint; Leaf therefore does not claim or later remove an identical effect that was already active when reconciliation ran. diff --git a/design/log.md b/design/log.md index 2aaa1e6..f99e88d 100644 --- a/design/log.md +++ b/design/log.md @@ -79,3 +79,10 @@ - Kept reserved OKF index and log documents in their specification-defined structures. - Verified all user stories are done, all acceptance criteria are checked, the repository has no remote, and no push was performed. - Final verification passed with `./gradlew clean check jar` and `okflint` validation. + +### US-003 sticky-prefix regression fixed + +- Reproduced the cleanup failure that occurred when another plugin changed a Leaf-decorated display name. +- Leaf now removes exact managed-prefix text without discarding surrounding third-party formatting, clears stale prefixes after reload, and normalizes repeated prefixes before applying one leaf. +- Added regression coverage for wrapped names, duplicate stale prefixes, untracked stale prefixes, unrelated formatting, tab names, and scoreboard cleanup. +- Verified the fix with `./gradlew clean check jar`. diff --git a/design/user-stories/us-003-identify-protected-players.md b/design/user-stories/us-003-identify-protected-players.md index d2b33f9..7ac279a 100644 --- a/design/user-stories/us-003-identify-protected-players.md +++ b/design/user-stories/us-003-identify-protected-players.md @@ -19,6 +19,9 @@ As a **server participant**, I want protected players to be visibly identified s - [x] Prefixes are suppressed while Leaf is globally disabled and restored for online opted-in players when it is re-enabled. - [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. +- [x] Repeated opt-in or reconciliation never produces multiple Leaf prefixes. +- [x] Opt-out removes every managed Leaf prefix while preserving unrelated name formatting. +- [x] A stale Leaf prefix left by an earlier plugin instance is normalized instead of duplicated. ## Related diff --git a/src/main/java/games/dmg/leaf/LeafIdentity.java b/src/main/java/games/dmg/leaf/LeafIdentity.java index b337141..055381c 100644 --- a/src/main/java/games/dmg/leaf/LeafIdentity.java +++ b/src/main/java/games/dmg/leaf/LeafIdentity.java @@ -15,11 +15,9 @@ import org.bukkit.scoreboard.Team; 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 managedPrefixes = new HashMap<>(); private final Map teamEntries = new HashMap<>(); public LeafIdentity(Server server, Logger logger) { @@ -28,43 +26,57 @@ public final class LeafIdentity { } public synchronized void apply(Player player, String configuredPrefix) { - String prefix = ChatColor.translateAlternateColorCodes('&', configuredPrefix); + String prefix = color(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; + String previousPrefix = managedPrefixes.get(playerId); + String display = normalized(player.getDisplayName(), previousPrefix, prefix); + String playerList = normalized(player.getPlayerListName(), previousPrefix, prefix); + String decoratedDisplay = prefix + display; + String decoratedList = prefix + playerList; - if (previous == null || player.getDisplayName().equals(previous.decoratedDisplay())) { + if (!player.getDisplayName().equals(decoratedDisplay)) { player.setDisplayName(decoratedDisplay); } - if (previous == null || player.getPlayerListName().equals(previous.decoratedList())) { + if (!player.getPlayerListName().equals(decoratedList)) { player.setPlayerListName(decoratedList); } - names.put(playerId, new Names(baseDisplay, baseList, decoratedDisplay, decoratedList)); + managedPrefixes.put(playerId, prefix); applyOverhead(player, prefix); } - public synchronized void remove(Player player) { + public synchronized void remove(Player player, String configuredPrefix) { 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 configured = color(configuredPrefix); + String installed = managedPrefixes.remove(playerId); + String display = normalized(player.getDisplayName(), installed, configured); + String playerList = normalized(player.getPlayerListName(), installed, configured); + + if (!player.getDisplayName().equals(display)) { + player.setDisplayName(display); + } + if (!player.getPlayerListName().equals(playerList)) { + player.setPlayerListName(playerList); } String entry = teamEntries.remove(playerId); Team team = leafTeam(false); - if (entry != null && team != null) { - team.removeEntry(entry); + if (team != null) { + team.removeEntry(entry == null ? player.getName() : entry); } } + private static String normalized(String value, String previousPrefix, String prefix) { + String normalized = value; + if (previousPrefix != null && !previousPrefix.equals(prefix)) { + normalized = normalized.replace(previousPrefix, ""); + } + return normalized.replace(prefix, ""); + } + + private static String color(String configuredPrefix) { + return ChatColor.translateAlternateColorCodes('&', configuredPrefix); + } + private void applyOverhead(Player player, String prefix) { Scoreboard scoreboard = mainScoreboard(); if (scoreboard == null) { diff --git a/src/main/java/games/dmg/leaf/LeafRuntime.java b/src/main/java/games/dmg/leaf/LeafRuntime.java index 81709c7..2948b70 100644 --- a/src/main/java/games/dmg/leaf/LeafRuntime.java +++ b/src/main/java/games/dmg/leaf/LeafRuntime.java @@ -222,7 +222,7 @@ public final class LeafRuntime { if (protection.apply(player, settings.resistanceLevel())) { identity.apply(player, settings.prefix()); } else { - identity.remove(player); + identity.remove(player, settings.prefix()); } } else { removePresentation(player); @@ -231,7 +231,7 @@ public final class LeafRuntime { public void removePresentation(Player player) { protection.remove(player); - identity.remove(player); + identity.remove(player, settingsProvider.current().prefix()); } public void reconcileAllOnline() { diff --git a/src/test/java/games/dmg/leaf/LeafIdentityTest.java b/src/test/java/games/dmg/leaf/LeafIdentityTest.java index 1b9f7d6..e97ebd8 100644 --- a/src/test/java/games/dmg/leaf/LeafIdentityTest.java +++ b/src/test/java/games/dmg/leaf/LeafIdentityTest.java @@ -30,7 +30,7 @@ final class LeafIdentityTest { when(fixtures.player().getDisplayName()).thenReturn(decorated); when(fixtures.player().getPlayerListName()).thenReturn(decorated); - identity.remove(fixtures.player()); + identity.remove(fixtures.player(), "&a๐Ÿƒ "); verify(fixtures.player()).setDisplayName("Alex"); verify(fixtures.player()).setPlayerListName("Alex"); @@ -38,7 +38,48 @@ final class LeafIdentityTest { } @Test - void leavesThirdPartyChangesAndTeamsUntouched() { + void removesManagedPrefixWhilePreservingLaterThirdPartyFormatting() { + Fixtures fixtures = fixtures(); + LeafIdentity identity = new LeafIdentity(fixtures.server(), Logger.getAnonymousLogger()); + identity.apply(fixtures.player(), "&a๐Ÿƒ "); + when(fixtures.player().getDisplayName()).thenReturn("[Admin] ยงa๐Ÿƒ Alex"); + when(fixtures.player().getPlayerListName()).thenReturn("[Admin] ยงa๐Ÿƒ Alex"); + + identity.remove(fixtures.player(), "&a๐Ÿƒ "); + + verify(fixtures.player()).setDisplayName("[Admin] Alex"); + verify(fixtures.player()).setPlayerListName("[Admin] Alex"); + verify(fixtures.team()).removeEntry("Alex"); + } + + @Test + void normalizesRepeatedStalePrefixesWhenApplying() { + Fixtures fixtures = fixtures(); + when(fixtures.player().getDisplayName()).thenReturn("ยงa๐Ÿƒ ยงa๐Ÿƒ Alex"); + when(fixtures.player().getPlayerListName()).thenReturn("ยงa๐Ÿƒ ยงa๐Ÿƒ Alex"); + LeafIdentity identity = new LeafIdentity(fixtures.server(), Logger.getAnonymousLogger()); + + identity.apply(fixtures.player(), "&a๐Ÿƒ "); + + verify(fixtures.player()).setDisplayName("ยงa๐Ÿƒ Alex"); + verify(fixtures.player()).setPlayerListName("ยงa๐Ÿƒ Alex"); + } + + @Test + void removesStalePrefixWithoutRuntimeTracking() { + Fixtures fixtures = fixtures(); + when(fixtures.player().getDisplayName()).thenReturn("ยงa๐Ÿƒ Alex"); + when(fixtures.player().getPlayerListName()).thenReturn("ยงa๐Ÿƒ Alex"); + LeafIdentity identity = new LeafIdentity(fixtures.server(), Logger.getAnonymousLogger()); + + identity.remove(fixtures.player(), "&a๐Ÿƒ "); + + verify(fixtures.player()).setDisplayName("Alex"); + verify(fixtures.player()).setPlayerListName("Alex"); + } + + @Test + void leavesUnrelatedThirdPartyChangesAndTeamsUntouched() { Fixtures fixtures = fixtures(); Team otherTeam = mock(Team.class); when(fixtures.scoreboard().getEntryTeam("Alex")).thenReturn(otherTeam); @@ -47,7 +88,7 @@ final class LeafIdentityTest { when(fixtures.player().getDisplayName()).thenReturn("ThirdPartyAlex"); when(fixtures.player().getPlayerListName()).thenReturn("ThirdPartyAlex"); - identity.remove(fixtures.player()); + identity.remove(fixtures.player(), "&a๐Ÿƒ "); verify(otherTeam, never()).removeEntry("Alex"); verify(fixtures.player(), never()).setDisplayName("Alex"); diff --git a/src/test/java/games/dmg/leaf/LeafRuntimeTest.java b/src/test/java/games/dmg/leaf/LeafRuntimeTest.java index 24bd0ec..e31054b 100644 --- a/src/test/java/games/dmg/leaf/LeafRuntimeTest.java +++ b/src/test/java/games/dmg/leaf/LeafRuntimeTest.java @@ -125,7 +125,7 @@ final class LeafRuntimeTest { assertEquals(4, settings.current().resistanceLevel()); verify(protection, org.mockito.Mockito.atLeastOnce()).remove(player); verify(protection).apply(player, 4); - verify(identity, org.mockito.Mockito.atLeastOnce()).remove(player); + verify(identity, org.mockito.Mockito.atLeastOnce()).remove(player, "&a๐Ÿƒ "); } @Test