fix(ping): support Purpur NameAndId samples
Release / release (push) Successful in 2m24s
CI / build (push) Successful in 1m42s

This commit is contained in:
dmg
2026-09-05 08:58:46 -04:00
parent a084f86c1d
commit 831f6a2ce4
9 changed files with 145 additions and 25 deletions
+1
View File
@@ -2,6 +2,7 @@
## 2026-09-05 ## 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, 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. - **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.
@@ -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] 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] 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 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] 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] 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. - [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 ## 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 ## Related
@@ -3,42 +3,35 @@ package games.dmg.spigotstealth;
import com.comphenix.protocol.PacketType; import com.comphenix.protocol.PacketType;
import com.comphenix.protocol.events.PacketAdapter; import com.comphenix.protocol.events.PacketAdapter;
import com.comphenix.protocol.events.PacketEvent; import com.comphenix.protocol.events.PacketEvent;
import com.comphenix.protocol.wrappers.WrappedGameProfile;
import com.comphenix.protocol.wrappers.WrappedServerPing; import com.comphenix.protocol.wrappers.WrappedServerPing;
import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.function.Consumer;
import java.util.function.Supplier; import java.util.function.Supplier;
import org.bukkit.plugin.Plugin; 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 { public final class ProtocolLibServerListPingListener extends PacketAdapter {
private final Supplier<Set<UUID>> concealedPlayerIds; private final Supplier<Set<UUID>> concealedPlayerIds;
private final ServerPingCompatibilityGuard compatibilityGuard;
public ProtocolLibServerListPingListener(Plugin plugin, Supplier<Set<UUID>> concealedPlayerIds) { public ProtocolLibServerListPingListener(
Plugin plugin,
Supplier<Set<UUID>> concealedPlayerIds,
Consumer<String> warningLog) {
super(plugin, PacketType.Status.Server.SERVER_INFO); super(plugin, PacketType.Status.Server.SERVER_INFO);
this.concealedPlayerIds = Objects.requireNonNull(concealedPlayerIds, "concealedPlayerIds"); this.concealedPlayerIds = Objects.requireNonNull(concealedPlayerIds, "concealedPlayerIds");
this.compatibilityGuard = new ServerPingCompatibilityGuard(warningLog);
} }
@Override @Override
public void onPacketSending(PacketEvent event) { public void onPacketSending(PacketEvent event) {
WrappedServerPing visiblePing = event.getPacket().getServerPings().read(0).deepClone(); compatibilityGuard.run(() -> {
boolean sampleVisible = visiblePing.isPlayersVisible(); WrappedServerPing ping = event.getPacket().getServerPings().read(0);
List<WrappedGameProfile> sample = sampleVisible ? visiblePing.getPlayers() : List.of(); int visiblePlayers = ServerListPingVisibility.visibleOnlineCount(
Set<UUID> concealed = concealedPlayerIds.get(); ping.getPlayersOnline(), concealedPlayerIds.get().size());
ServerListPingVisibility.Snapshot visible = ServerListPingVisibility.adjust( ping.setPlayersOnline(visiblePlayers);
visiblePing.getPlayersOnline(), });
visiblePing.getPlayersMaximum(),
sample.stream().map(WrappedGameProfile::getUUID).toList(),
concealed);
Set<UUID> 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);
} }
} }
@@ -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<Set<UUID>> concealedPlayerIds;
public ServerListPingListener(Supplier<Set<UUID>> concealedPlayerIds) {
this.concealedPlayerIds = Objects.requireNonNull(concealedPlayerIds, "concealedPlayerIds");
}
@EventHandler(priority = EventPriority.HIGHEST)
public void onServerListPing(ServerListPingEvent event) {
Set<UUID> concealed = concealedPlayerIds.get();
Iterator<Player> sample = event.iterator();
while (sample.hasNext()) {
if (concealed.contains(sample.next().getUniqueId())) {
sample.remove();
}
}
}
}
@@ -20,11 +20,15 @@ public final class ServerListPingVisibility {
.filter(playerId -> !concealedPlayerIds.contains(playerId)) .filter(playerId -> !concealedPlayerIds.contains(playerId))
.toList(); .toList();
return new Snapshot( return new Snapshot(
Math.max(0, playersOnline - concealedPlayerIds.size()), visibleOnlineCount(playersOnline, concealedPlayerIds.size()),
playersMaximum, playersMaximum,
visibleSample); visibleSample);
} }
public static int visibleOnlineCount(int playersOnline, int concealedPlayers) {
return Math.max(0, playersOnline - concealedPlayers);
}
public record Snapshot(int playersOnline, int playersMaximum, List<UUID> samplePlayerIds) { public record Snapshot(int playersOnline, int playersMaximum, List<UUID> samplePlayerIds) {
public Snapshot { public Snapshot {
samplePlayerIds = List.copyOf(samplePlayerIds); samplePlayerIds = List.copyOf(samplePlayerIds);
@@ -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<String> warningLog;
private final AtomicBoolean warningLogged = new AtomicBoolean();
public ServerPingCompatibilityGuard(Consumer<String> 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());
}
}
}
}
@@ -82,8 +82,10 @@ public final class SpigotStealthPlugin extends JavaPlugin {
Objects.requireNonNull(getServer().getScoreboardManager(), "scoreboard manager").getMainScoreboard(), Objects.requireNonNull(getServer().getScoreboardManager(), "scoreboard manager").getMainScoreboard(),
new ProtocolLibTabListController(protocolManager), new ProtocolLibTabListController(protocolManager),
() -> manager.snapshot().sleepCountPolicy()); () -> manager.snapshot().sleepCountPolicy());
protocolManager.addPacketListener( protocolManager.addPacketListener(new ProtocolLibServerListPingListener(
new ProtocolLibServerListPingListener(this, sessions::concealedPlayerIds)); this, sessions::concealedPlayerIds, getLogger()::warning));
getServer().getPluginManager().registerEvents(
new ServerListPingListener(sessions::concealedPlayerIds), this);
getServer().getPluginManager().registerEvents( getServer().getPluginManager().registerEvents(
new InvisibilityEffectListener(progression, Clock.systemUTC()), this); new InvisibilityEffectListener(progression, Clock.systemUTC()), this);
getServer().getPluginManager().registerEvents( getServer().getPluginManager().registerEvents(
@@ -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<Player> 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;
}
}
@@ -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<String> 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());
}
}