fix(identity): remove stale Leaf prefixes
Release / release (push) Successful in 2m11s
CI / build (push) Successful in 57s

This commit is contained in:
dmg
2026-08-10 21:13:02 -04:00
parent 07bc1d9382
commit e064c7398c
7 changed files with 93 additions and 30 deletions
+1 -1
View File
@@ -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.
+7
View File
@@ -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`.
@@ -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
+34 -22
View File
@@ -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<UUID, Names> names = new HashMap<>();
private final Map<UUID, String> managedPrefixes = new HashMap<>();
private final Map<UUID, String> 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) {
@@ -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() {
@@ -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");
@@ -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