fix(stealth): hide concealed players from server ping
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
# Spigot Stealth Design Log
|
||||
|
||||
## 2026-09-05
|
||||
|
||||
- **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.
|
||||
|
||||
## 2026-09-04
|
||||
|
||||
- **Completion**: Extended US-002 so concealed players disconnect without a public quit announcement while ordinary quit messages remain unchanged; verified listener tests, the complete Gradle build, and the OKF bundle.
|
||||
|
||||
@@ -19,6 +19,10 @@ As an **unlocked player**, I want to disconnect while invisibility from a potion
|
||||
- [x] Ordinary players' quit messages remain unchanged.
|
||||
- [x] Concealment is checked before disconnect cleanup so announcement suppression is reliable.
|
||||
- [x] Throughout the concealed session, the player is absent from every other player's tab list, including administrators' tab lists.
|
||||
- [x] The multiplayer server list's online-player count excludes currently concealed players.
|
||||
- [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] Throughout the concealed session, no overhead name tag identifies the player to any other player, including administrators.
|
||||
- [x] The concealed player's physical character remains visible in the world and retains ordinary movement, interaction, combat, and permission behavior.
|
||||
- [x] The concealed player receives a private message explaining that stealth is active for the session.
|
||||
@@ -29,7 +33,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, 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, public server-list count and sample filtering, nonnegative counts, unchanged maximum capacity, 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
|
||||
|
||||
|
||||
@@ -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) { }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ServerListPingVisibilityTest {
|
||||
@Test
|
||||
void publicPlayerCountNeverBecomesNegative() {
|
||||
ServerListPingVisibility.Snapshot adjusted = ServerListPingVisibility.adjust(
|
||||
0, 100, List.of(), Set.of(UUID.randomUUID()));
|
||||
|
||||
assertEquals(0, adjusted.playersOnline());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ordinaryPlayersRemainCountedAndSampled() {
|
||||
UUID firstId = UUID.randomUUID();
|
||||
UUID secondId = UUID.randomUUID();
|
||||
|
||||
ServerListPingVisibility.Snapshot adjusted = ServerListPingVisibility.adjust(
|
||||
2, 100, List.of(firstId, secondId), Set.of());
|
||||
|
||||
assertEquals(2, adjusted.playersOnline());
|
||||
assertEquals(List.of(firstId, secondId), adjusted.samplePlayerIds());
|
||||
}
|
||||
|
||||
@Test
|
||||
void excludesConcealedPlayersFromPublicCountAndSampleWithoutChangingMaximum() {
|
||||
UUID visibleId = UUID.randomUUID();
|
||||
UUID concealedId = UUID.randomUUID();
|
||||
|
||||
ServerListPingVisibility.Snapshot adjusted = ServerListPingVisibility.adjust(
|
||||
2, 100, List.of(visibleId, concealedId), Set.of(concealedId));
|
||||
|
||||
assertEquals(1, adjusted.playersOnline());
|
||||
assertEquals(100, adjusted.playersMaximum());
|
||||
assertEquals(List.of(visibleId), adjusted.samplePlayerIds());
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,35 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class StealthSessionServiceTest {
|
||||
@Test
|
||||
void reportsOnlyCurrentlyConcealedPlayerIdsForPublicPresentation() {
|
||||
UUID concealedId = UUID.randomUUID();
|
||||
UUID staleId = UUID.randomUUID();
|
||||
try (StealthStateManager manager = manager()) {
|
||||
manager.update(state -> state
|
||||
.withPlayer(unlocked(concealedId).withSession(true, false))
|
||||
.withPlayer(unlocked(staleId).withSession(false, true)))
|
||||
.join();
|
||||
QualifyingInvisibilityService progression = new QualifyingInvisibilityService(
|
||||
manager, Duration.ofHours(8), System::nanoTime, ignored -> { });
|
||||
StealthSessionService sessions = new StealthSessionService(manager, progression);
|
||||
sessions.login(concealedId, "Hidden");
|
||||
|
||||
assertEquals(Set.of(concealedId), sessions.concealedPlayerIds());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void unlockedQualifyingDisconnectPreparesAndConsumesOneConcealedLogin() {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
|
||||
Reference in New Issue
Block a user