fix(stealth): hide concealed players from server ping
Release / release (push) Successful in 2m13s
CI / build (push) Successful in 58s

This commit is contained in:
dmg
2026-09-05 08:11:58 -04:00
parent 66ba2aa3b1
commit ba6573943b
8 changed files with 172 additions and 2 deletions
@@ -0,0 +1,44 @@
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.Supplier;
import org.bukkit.plugin.Plugin;
/** Rewrites outgoing server-list responses without changing actual online-player state. */
public final class ProtocolLibServerListPingListener extends PacketAdapter {
private final Supplier<Set<UUID>> concealedPlayerIds;
public ProtocolLibServerListPingListener(Plugin plugin, Supplier<Set<UUID>> concealedPlayerIds) {
super(plugin, PacketType.Status.Server.SERVER_INFO);
this.concealedPlayerIds = Objects.requireNonNull(concealedPlayerIds, "concealedPlayerIds");
}
@Override
public void onPacketSending(PacketEvent event) {
WrappedServerPing visiblePing = event.getPacket().getServerPings().read(0).deepClone();
boolean sampleVisible = visiblePing.isPlayersVisible();
List<WrappedGameProfile> sample = sampleVisible ? visiblePing.getPlayers() : List.of();
Set<UUID> concealed = concealedPlayerIds.get();
ServerListPingVisibility.Snapshot visible = ServerListPingVisibility.adjust(
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,33 @@
package games.dmg.spigotstealth;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
/** Computes the public multiplayer server-list view for concealed sessions. */
public final class ServerListPingVisibility {
private ServerListPingVisibility() { }
public static Snapshot adjust(
int playersOnline,
int playersMaximum,
List<UUID> samplePlayerIds,
Set<UUID> concealedPlayerIds) {
Objects.requireNonNull(samplePlayerIds, "samplePlayerIds");
Objects.requireNonNull(concealedPlayerIds, "concealedPlayerIds");
List<UUID> visibleSample = samplePlayerIds.stream()
.filter(playerId -> !concealedPlayerIds.contains(playerId))
.toList();
return new Snapshot(
Math.max(0, playersOnline - concealedPlayerIds.size()),
playersMaximum,
visibleSample);
}
public record Snapshot(int playersOnline, int playersMaximum, List<UUID> samplePlayerIds) {
public Snapshot {
samplePlayerIds = List.copyOf(samplePlayerIds);
}
}
}
@@ -1,6 +1,7 @@
package games.dmg.spigotstealth;
import com.comphenix.protocol.ProtocolLibrary;
import com.comphenix.protocol.ProtocolManager;
import java.nio.file.Path;
import java.time.Clock;
import java.util.Objects;
@@ -15,6 +16,7 @@ public final class SpigotStealthPlugin extends JavaPlugin {
private QualifyingInvisibilityService progression;
private StealthSessionService sessions;
private IdentityPresentation identityPresentation;
private ProtocolManager protocolManager;
@Override
public void onEnable() {
@@ -41,6 +43,9 @@ public final class SpigotStealthPlugin extends JavaPlugin {
@Override
public void onDisable() {
if (protocolManager != null) {
protocolManager.removePacketListeners(this);
}
if (identityPresentation != null && sessions != null) {
for (org.bukkit.entity.Player player : getServer().getOnlinePlayers()) {
if (sessions.isConcealed(player.getUniqueId())) {
@@ -71,10 +76,13 @@ public final class SpigotStealthPlugin extends JavaPlugin {
progression = new QualifyingInvisibilityService(
manager, settings.unlockThreshold(), System::nanoTime, notifier);
sessions = new StealthSessionService(manager, progression);
protocolManager = ProtocolLibrary.getProtocolManager();
identityPresentation = new BukkitIdentityPresentation(
getServer()::getOnlinePlayers,
Objects.requireNonNull(getServer().getScoreboardManager(), "scoreboard manager").getMainScoreboard(),
new ProtocolLibTabListController(ProtocolLibrary.getProtocolManager()));
new ProtocolLibTabListController(protocolManager));
protocolManager.addPacketListener(
new ProtocolLibServerListPingListener(this, sessions::concealedPlayerIds));
getServer().getPluginManager().registerEvents(
new InvisibilityEffectListener(progression, Clock.systemUTC()), this);
getServer().getPluginManager().registerEvents(
@@ -1,7 +1,9 @@
package games.dmg.spigotstealth;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -9,6 +11,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
public final class StealthSessionService {
private final StealthStateManager stateManager;
private final QualifyingInvisibilityService progression;
private final Set<UUID> concealedOnlinePlayerIds = ConcurrentHashMap.newKeySet();
public StealthSessionService(
StealthStateManager stateManager,
@@ -18,6 +21,7 @@ public final class StealthSessionService {
}
public CompletableFuture<Void> disconnect(UUID playerId) {
concealedOnlinePlayerIds.remove(playerId);
boolean qualifyingAtDisconnect = progression.isQualifying(playerId);
return progression.stop(playerId).thenCompose(ignored -> stateManager.update(state -> {
PlayerStealthState player = state.player(playerId);
@@ -34,10 +38,16 @@ public final class StealthSessionService {
concealed.set(conceal);
return state.withPlayer(player.withSession(false, conceal).withQualifyingSince(null));
});
if (concealed.get()) {
concealedOnlinePlayerIds.add(playerId);
} else {
concealedOnlinePlayerIds.remove(playerId);
}
return new LoginTransition(concealed.get(), saved);
}
public CompletableFuture<Void> endConcealment(UUID playerId) {
concealedOnlinePlayerIds.remove(playerId);
return stateManager.update(state -> {
PlayerStealthState player = state.player(playerId);
return state.withPlayer(player.withSession(player.preparedLogin(), false));
@@ -48,5 +58,9 @@ public final class StealthSessionService {
return stateManager.snapshot().player(playerId).concealed();
}
public Set<UUID> concealedPlayerIds() {
return Set.copyOf(concealedOnlinePlayerIds);
}
public record LoginTransition(boolean concealed, CompletableFuture<Void> saved) { }
}