feat(identity): display Leaf protection prefixes
This commit is contained in:
@@ -34,7 +34,7 @@ Administrative commands require `leaf.admin`, granted to server operators by def
|
|||||||
|
|
||||||
## Compatibility
|
## 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
|
## Releases
|
||||||
|
|
||||||
|
|||||||
@@ -34,3 +34,11 @@
|
|||||||
- Added immediate durable opt-in persistence, join-time restoration, and quiet infinite Resistance reconciliation.
|
- 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.
|
- 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`.
|
- 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.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: User Story
|
type: User Story
|
||||||
title: "US-003: Identify protected players"
|
title: "US-003: Identify protected players"
|
||||||
description: Mark actively protected players with a visible leaf prefix wherever Spigot supports it.
|
description: Mark actively protected players with a visible leaf prefix wherever Spigot supports it.
|
||||||
status: backlog
|
status: in-progress
|
||||||
---
|
---
|
||||||
|
|
||||||
# US-003: Identify protected players
|
# US-003: Identify protected players
|
||||||
@@ -11,14 +11,14 @@ As a **server participant**, I want protected players to be visibly identified s
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] An actively protected player has a configurable leaf prefix before their name in the player list.
|
- [x] 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.
|
- [x] 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.
|
- [x] 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] 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.
|
- [ ] 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.
|
- [ ] 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.
|
- [x] 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] Scoreboard-team and third-party chat-plugin compatibility limitations are documented, and unsupported integrations fail without affecting protection state.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
|
|||||||
@@ -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<UUID, Names> names = new HashMap<>();
|
||||||
|
private final Map<UUID, String> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ public final class LeafListener implements Listener {
|
|||||||
runtime.observe(event.getPlayer(), clock.instant());
|
runtime.observe(event.getPlayer(), clock.instant());
|
||||||
} catch (IOException exception) {
|
} catch (IOException exception) {
|
||||||
logger.log(Level.SEVERE, "Could not persist Leaf player state on join", 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.");
|
event.getPlayer().sendMessage("Leaf protection is unavailable because state could not be saved.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ public final class LeafPlugin extends JavaPlugin {
|
|||||||
getServer(),
|
getServer(),
|
||||||
settingsProvider,
|
settingsProvider,
|
||||||
stateManager,
|
stateManager,
|
||||||
new LeafProtection()
|
new LeafProtection(),
|
||||||
|
new LeafIdentity(getServer(), getLogger())
|
||||||
);
|
);
|
||||||
registerRuntime();
|
registerRuntime();
|
||||||
} catch (IllegalArgumentException | IOException exception) {
|
} catch (IllegalArgumentException | IOException exception) {
|
||||||
@@ -43,7 +44,7 @@ public final class LeafPlugin extends JavaPlugin {
|
|||||||
public void onDisable() {
|
public void onDisable() {
|
||||||
if (runtime != null) {
|
if (runtime != null) {
|
||||||
for (Player player : getServer().getOnlinePlayers()) {
|
for (Player player : getServer().getOnlinePlayers()) {
|
||||||
runtime.removeProtection(player);
|
runtime.removePresentation(player);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
saveState();
|
saveState();
|
||||||
|
|||||||
@@ -27,17 +27,20 @@ public final class LeafRuntime {
|
|||||||
private final LeafSettingsProvider settingsProvider;
|
private final LeafSettingsProvider settingsProvider;
|
||||||
private final LeafStateManager stateManager;
|
private final LeafStateManager stateManager;
|
||||||
private final LeafProtection protection;
|
private final LeafProtection protection;
|
||||||
|
private final LeafIdentity identity;
|
||||||
|
|
||||||
public LeafRuntime(
|
public LeafRuntime(
|
||||||
Server server,
|
Server server,
|
||||||
LeafSettingsProvider settingsProvider,
|
LeafSettingsProvider settingsProvider,
|
||||||
LeafStateManager stateManager,
|
LeafStateManager stateManager,
|
||||||
LeafProtection protection
|
LeafProtection protection,
|
||||||
|
LeafIdentity identity
|
||||||
) {
|
) {
|
||||||
this.server = Objects.requireNonNull(server, "server");
|
this.server = Objects.requireNonNull(server, "server");
|
||||||
this.settingsProvider = Objects.requireNonNull(settingsProvider, "settingsProvider");
|
this.settingsProvider = Objects.requireNonNull(settingsProvider, "settingsProvider");
|
||||||
this.stateManager = Objects.requireNonNull(stateManager, "stateManager");
|
this.stateManager = Objects.requireNonNull(stateManager, "stateManager");
|
||||||
this.protection = Objects.requireNonNull(protection, "protection");
|
this.protection = Objects.requireNonNull(protection, "protection");
|
||||||
|
this.identity = Objects.requireNonNull(identity, "identity");
|
||||||
}
|
}
|
||||||
|
|
||||||
public PlayerLeafState observe(Player player, Instant observedAt) throws IOException {
|
public PlayerLeafState observe(Player player, Instant observedAt) throws IOException {
|
||||||
@@ -105,13 +108,15 @@ public final class LeafRuntime {
|
|||||||
LeafSettings settings = settingsProvider.current();
|
LeafSettings settings = settingsProvider.current();
|
||||||
if (state != null && state.optedIn() && settings.enabled()) {
|
if (state != null && state.optedIn() && settings.enabled()) {
|
||||||
protection.apply(player, settings.resistanceLevel());
|
protection.apply(player, settings.resistanceLevel());
|
||||||
|
identity.apply(player, settings.prefix());
|
||||||
} else {
|
} else {
|
||||||
protection.remove(player);
|
removePresentation(player);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void removeProtection(Player player) {
|
public void removePresentation(Player player) {
|
||||||
protection.remove(player);
|
protection.remove(player);
|
||||||
|
identity.remove(player);
|
||||||
}
|
}
|
||||||
|
|
||||||
public LeafStateManager stateManager() {
|
public LeafStateManager stateManager() {
|
||||||
|
|||||||
@@ -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) { }
|
||||||
|
}
|
||||||
@@ -91,7 +91,8 @@ final class LeafRuntimeTest {
|
|||||||
server,
|
server,
|
||||||
new LeafSettingsProvider(LeafSettings.from(Map.of())),
|
new LeafSettingsProvider(LeafSettings.from(Map.of())),
|
||||||
new LeafStateManager(new YamlLeafStateRepository(stateFile)),
|
new LeafStateManager(new YamlLeafStateRepository(stateFile)),
|
||||||
protection
|
protection,
|
||||||
|
mock(LeafIdentity.class)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user