From 831f6a2ce433c59eac163e251deeb360ff0a56c3 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Sat, 5 Sep 2026 08:58:46 -0400 Subject: [PATCH] fix(ping): support Purpur NameAndId samples --- design/log.md | 1 + .../us-002-rejoin-without-visible-identity.md | 4 ++- .../ProtocolLibServerListPingListener.java | 35 ++++++++---------- .../spigotstealth/ServerListPingListener.java | 32 +++++++++++++++++ .../ServerListPingVisibility.java | 6 +++- .../ServerPingCompatibilityGuard.java | 26 ++++++++++++++ .../spigotstealth/SpigotStealthPlugin.java | 6 ++-- .../ServerListPingListenerTest.java | 36 +++++++++++++++++++ .../ServerPingCompatibilityGuardTest.java | 24 +++++++++++++ 9 files changed, 145 insertions(+), 25 deletions(-) create mode 100644 src/main/java/games/dmg/spigotstealth/ServerListPingListener.java create mode 100644 src/main/java/games/dmg/spigotstealth/ServerPingCompatibilityGuard.java create mode 100644 src/test/java/games/dmg/spigotstealth/ServerListPingListenerTest.java create mode 100644 src/test/java/games/dmg/spigotstealth/ServerPingCompatibilityGuardTest.java diff --git a/design/log.md b/design/log.md index 4b60bdd..c44025e 100644 --- a/design/log.md +++ b/design/log.md @@ -2,6 +2,7 @@ ## 2026-09-05 +- **Fix**: Reworked US-002 server-list filtering for Purpur's native `NameAndId` samples by filtering names through Bukkit, limiting ProtocolLib to guarded count adjustment, and rate-limiting compatibility warnings; verified the complete Gradle build and OKF bundle. - **Completion**: Extended US-002, US-004, and US-005 with a persistent, permission-gated sleep-count policy that excludes concealed players by default, applies immediately, restores prior player state, and supports contextual administration; verified the complete Gradle build and OKF bundle. - **Completion**: Extended US-002 with ProtocolLib filtering of concealed sessions from multiplayer server-list counts and player samples while preserving actual online state and advertised capacity; verified the complete Gradle build and OKF bundle. diff --git a/design/user-stories/us-002-rejoin-without-visible-identity.md b/design/user-stories/us-002-rejoin-without-visible-identity.md index 28f6ef3..b5c682c 100644 --- a/design/user-stories/us-002-rejoin-without-visible-identity.md +++ b/design/user-stories/us-002-rejoin-without-visible-identity.md @@ -23,6 +23,8 @@ As an **unlocked player**, I want to disconnect while invisibility from a potion - [x] Concealed players are excluded from any player-name sample shown for the server-list count, while ordinary players remain represented. - [x] The public count never becomes negative, and the configured maximum-player count remains unchanged. - [x] Server-list concealment changes only the public ping response and does not alter actual online-player state or gameplay. +- [x] Server-list filtering remains error-free when Purpur represents player samples with native `NameAndId` values rather than Mojang `GameProfile` values. +- [x] A server-ping compatibility failure leaves the original response usable and does not produce repeated unhandled listener exceptions. - [x] By default, concealed players are excluded from sleep-percentage calculations. - [x] When the sleep-count policy is `include`, concealed players count normally; ordinary players are never modified by either policy. - [x] A player's previous sleeping-ignore state is restored when concealment ends, the player disconnects or is reset, the policy changes to `include`, or the plugin disables. @@ -37,7 +39,7 @@ As an **unlocked player**, I want to disconnect while invisibility from a potion ## Validation -Automated tests verify unlocked and locked disconnect transitions, ordinary-disconnect clearing, one-login consumption, concealed join and quit announcement suppression, preservation of ordinary announcements, private activation messaging, ordinary-login presentation, tab removal for existing and new observers, overhead-name suppression, active concealed-session tracking, public server-list count and sample filtering, nonnegative counts, unchanged maximum capacity, default sleep-count exclusion, immediate policy changes, restoration of prior sleeping-ignore state, and the absence of entity-hiding calls. ProtocolLib is declared as a required dependency, prepared state round trips through YAML, and `./gradlew clean check jar` passes. +Automated tests verify unlocked and locked disconnect transitions, ordinary-disconnect clearing, one-login consumption, concealed join and quit announcement suppression, preservation of ordinary announcements, private activation messaging, ordinary-login presentation, tab removal for existing and new observers, overhead-name suppression, active concealed-session tracking, ProtocolLib-independent native player-sample filtering, guarded public server-list count adjustment, nonnegative counts, unchanged maximum capacity, default sleep-count exclusion, immediate policy changes, restoration of prior sleeping-ignore state, and the absence of entity-hiding calls. ProtocolLib is declared as a required dependency, prepared state round trips through YAML, and `./gradlew clean check jar` passes. ## Related diff --git a/src/main/java/games/dmg/spigotstealth/ProtocolLibServerListPingListener.java b/src/main/java/games/dmg/spigotstealth/ProtocolLibServerListPingListener.java index 5a993d7..0155050 100644 --- a/src/main/java/games/dmg/spigotstealth/ProtocolLibServerListPingListener.java +++ b/src/main/java/games/dmg/spigotstealth/ProtocolLibServerListPingListener.java @@ -3,42 +3,35 @@ package games.dmg.spigotstealth; import com.comphenix.protocol.PacketType; import com.comphenix.protocol.events.PacketAdapter; import com.comphenix.protocol.events.PacketEvent; -import com.comphenix.protocol.wrappers.WrappedGameProfile; import com.comphenix.protocol.wrappers.WrappedServerPing; -import java.util.List; import java.util.Objects; import java.util.Set; import java.util.UUID; +import java.util.function.Consumer; import java.util.function.Supplier; import org.bukkit.plugin.Plugin; -/** Rewrites outgoing server-list responses without changing actual online-player state. */ +/** Adjusts only the count in outgoing server-list responses through ProtocolLib. */ public final class ProtocolLibServerListPingListener extends PacketAdapter { private final Supplier> concealedPlayerIds; + private final ServerPingCompatibilityGuard compatibilityGuard; - public ProtocolLibServerListPingListener(Plugin plugin, Supplier> concealedPlayerIds) { + public ProtocolLibServerListPingListener( + Plugin plugin, + Supplier> concealedPlayerIds, + Consumer warningLog) { super(plugin, PacketType.Status.Server.SERVER_INFO); this.concealedPlayerIds = Objects.requireNonNull(concealedPlayerIds, "concealedPlayerIds"); + this.compatibilityGuard = new ServerPingCompatibilityGuard(warningLog); } @Override public void onPacketSending(PacketEvent event) { - WrappedServerPing visiblePing = event.getPacket().getServerPings().read(0).deepClone(); - boolean sampleVisible = visiblePing.isPlayersVisible(); - List sample = sampleVisible ? visiblePing.getPlayers() : List.of(); - Set concealed = concealedPlayerIds.get(); - ServerListPingVisibility.Snapshot visible = ServerListPingVisibility.adjust( - visiblePing.getPlayersOnline(), - visiblePing.getPlayersMaximum(), - sample.stream().map(WrappedGameProfile::getUUID).toList(), - concealed); - Set visibleSampleIds = Set.copyOf(visible.samplePlayerIds()); - visiblePing.setPlayersOnline(visible.playersOnline()); - if (sampleVisible) { - visiblePing.setPlayers(sample.stream() - .filter(profile -> visibleSampleIds.contains(profile.getUUID())) - .toList()); - } - event.getPacket().getServerPings().write(0, visiblePing); + compatibilityGuard.run(() -> { + WrappedServerPing ping = event.getPacket().getServerPings().read(0); + int visiblePlayers = ServerListPingVisibility.visibleOnlineCount( + ping.getPlayersOnline(), concealedPlayerIds.get().size()); + ping.setPlayersOnline(visiblePlayers); + }); } } diff --git a/src/main/java/games/dmg/spigotstealth/ServerListPingListener.java b/src/main/java/games/dmg/spigotstealth/ServerListPingListener.java new file mode 100644 index 0000000..e19ffb6 --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/ServerListPingListener.java @@ -0,0 +1,32 @@ +package games.dmg.spigotstealth; + +import java.util.Iterator; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.function.Supplier; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.server.ServerListPingEvent; + +/** Filters concealed players through Bukkit's native server-list sample representation. */ +public final class ServerListPingListener implements Listener { + private final Supplier> concealedPlayerIds; + + public ServerListPingListener(Supplier> concealedPlayerIds) { + this.concealedPlayerIds = Objects.requireNonNull(concealedPlayerIds, "concealedPlayerIds"); + } + + @EventHandler(priority = EventPriority.HIGHEST) + public void onServerListPing(ServerListPingEvent event) { + Set concealed = concealedPlayerIds.get(); + Iterator sample = event.iterator(); + while (sample.hasNext()) { + if (concealed.contains(sample.next().getUniqueId())) { + sample.remove(); + } + } + } +} diff --git a/src/main/java/games/dmg/spigotstealth/ServerListPingVisibility.java b/src/main/java/games/dmg/spigotstealth/ServerListPingVisibility.java index 3dc43f6..a4a960d 100644 --- a/src/main/java/games/dmg/spigotstealth/ServerListPingVisibility.java +++ b/src/main/java/games/dmg/spigotstealth/ServerListPingVisibility.java @@ -20,11 +20,15 @@ public final class ServerListPingVisibility { .filter(playerId -> !concealedPlayerIds.contains(playerId)) .toList(); return new Snapshot( - Math.max(0, playersOnline - concealedPlayerIds.size()), + visibleOnlineCount(playersOnline, concealedPlayerIds.size()), playersMaximum, visibleSample); } + public static int visibleOnlineCount(int playersOnline, int concealedPlayers) { + return Math.max(0, playersOnline - concealedPlayers); + } + public record Snapshot(int playersOnline, int playersMaximum, List samplePlayerIds) { public Snapshot { samplePlayerIds = List.copyOf(samplePlayerIds); diff --git a/src/main/java/games/dmg/spigotstealth/ServerPingCompatibilityGuard.java b/src/main/java/games/dmg/spigotstealth/ServerPingCompatibilityGuard.java new file mode 100644 index 0000000..a5f0431 --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/ServerPingCompatibilityGuard.java @@ -0,0 +1,26 @@ +package games.dmg.spigotstealth; + +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; + +/** Keeps an incompatible server-ping adapter from breaking responses or flooding logs. */ +public final class ServerPingCompatibilityGuard { + private final Consumer warningLog; + private final AtomicBoolean warningLogged = new AtomicBoolean(); + + public ServerPingCompatibilityGuard(Consumer warningLog) { + this.warningLog = Objects.requireNonNull(warningLog, "warningLog"); + } + + public void run(Runnable edit) { + try { + edit.run(); + } catch (RuntimeException exception) { + if (warningLogged.compareAndSet(false, true)) { + warningLog.accept("Unable to adjust the public server-list player count; " + + "the original response will be used: " + exception.getMessage()); + } + } + } +} diff --git a/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java b/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java index 557d4a8..aca5fec 100644 --- a/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java +++ b/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java @@ -82,8 +82,10 @@ public final class SpigotStealthPlugin extends JavaPlugin { Objects.requireNonNull(getServer().getScoreboardManager(), "scoreboard manager").getMainScoreboard(), new ProtocolLibTabListController(protocolManager), () -> manager.snapshot().sleepCountPolicy()); - protocolManager.addPacketListener( - new ProtocolLibServerListPingListener(this, sessions::concealedPlayerIds)); + protocolManager.addPacketListener(new ProtocolLibServerListPingListener( + this, sessions::concealedPlayerIds, getLogger()::warning)); + getServer().getPluginManager().registerEvents( + new ServerListPingListener(sessions::concealedPlayerIds), this); getServer().getPluginManager().registerEvents( new InvisibilityEffectListener(progression, Clock.systemUTC()), this); getServer().getPluginManager().registerEvents( diff --git a/src/test/java/games/dmg/spigotstealth/ServerListPingListenerTest.java b/src/test/java/games/dmg/spigotstealth/ServerListPingListenerTest.java new file mode 100644 index 0000000..31c1b12 --- /dev/null +++ b/src/test/java/games/dmg/spigotstealth/ServerListPingListenerTest.java @@ -0,0 +1,36 @@ +package games.dmg.spigotstealth; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import org.bukkit.entity.Player; +import org.bukkit.event.server.ServerListPingEvent; +import org.junit.jupiter.api.Test; + +class ServerListPingListenerTest { + @Test + void removesOnlyConcealedPlayersFromNativeServerPingSample() { + UUID concealedId = UUID.randomUUID(); + Player visible = player(UUID.randomUUID()); + Player concealed = player(concealedId); + ArrayList sample = new ArrayList<>(List.of(visible, concealed)); + ServerListPingEvent event = mock(ServerListPingEvent.class); + when(event.iterator()).thenReturn(sample.iterator()); + ServerListPingListener listener = new ServerListPingListener(() -> Set.of(concealedId)); + + listener.onServerListPing(event); + + assertEquals(List.of(visible), sample); + } + + private static Player player(UUID playerId) { + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(playerId); + return player; + } +} diff --git a/src/test/java/games/dmg/spigotstealth/ServerPingCompatibilityGuardTest.java b/src/test/java/games/dmg/spigotstealth/ServerPingCompatibilityGuardTest.java new file mode 100644 index 0000000..d9d87e2 --- /dev/null +++ b/src/test/java/games/dmg/spigotstealth/ServerPingCompatibilityGuardTest.java @@ -0,0 +1,24 @@ +package games.dmg.spigotstealth; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import java.util.ArrayList; +import org.junit.jupiter.api.Test; + +class ServerPingCompatibilityGuardTest { + @Test + void compatibilityFailureLeavesPingHandlingUsableAndWarnsOnlyOnce() { + ArrayList warnings = new ArrayList<>(); + ServerPingCompatibilityGuard guard = new ServerPingCompatibilityGuard(warnings::add); + + assertDoesNotThrow(() -> guard.run(() -> { + throw new IllegalArgumentException("unsupported profile representation"); + })); + assertDoesNotThrow(() -> guard.run(() -> { + throw new IllegalArgumentException("unsupported profile representation"); + })); + + assertEquals(1, warnings.size()); + } +}