diff --git a/design/log.md b/design/log.md index edcb1f5..3bc40ae 100644 --- a/design/log.md +++ b/design/log.md @@ -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. 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 06135c3..c933d16 100644 --- a/design/user-stories/us-002-rejoin-without-visible-identity.md +++ b/design/user-stories/us-002-rejoin-without-visible-identity.md @@ -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 diff --git a/src/main/java/games/dmg/spigotstealth/ProtocolLibServerListPingListener.java b/src/main/java/games/dmg/spigotstealth/ProtocolLibServerListPingListener.java new file mode 100644 index 0000000..5a993d7 --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/ProtocolLibServerListPingListener.java @@ -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> concealedPlayerIds; + + public ProtocolLibServerListPingListener(Plugin plugin, Supplier> 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 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); + } +} diff --git a/src/main/java/games/dmg/spigotstealth/ServerListPingVisibility.java b/src/main/java/games/dmg/spigotstealth/ServerListPingVisibility.java new file mode 100644 index 0000000..3dc43f6 --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/ServerListPingVisibility.java @@ -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 samplePlayerIds, + Set concealedPlayerIds) { + Objects.requireNonNull(samplePlayerIds, "samplePlayerIds"); + Objects.requireNonNull(concealedPlayerIds, "concealedPlayerIds"); + List 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 samplePlayerIds) { + public Snapshot { + samplePlayerIds = List.copyOf(samplePlayerIds); + } + } +} diff --git a/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java b/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java index f90adc5..efdc27c 100644 --- a/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java +++ b/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java @@ -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( diff --git a/src/main/java/games/dmg/spigotstealth/StealthSessionService.java b/src/main/java/games/dmg/spigotstealth/StealthSessionService.java index 8b77f58..62a3012 100644 --- a/src/main/java/games/dmg/spigotstealth/StealthSessionService.java +++ b/src/main/java/games/dmg/spigotstealth/StealthSessionService.java @@ -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 concealedOnlinePlayerIds = ConcurrentHashMap.newKeySet(); public StealthSessionService( StealthStateManager stateManager, @@ -18,6 +21,7 @@ public final class StealthSessionService { } public CompletableFuture 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 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 concealedPlayerIds() { + return Set.copyOf(concealedOnlinePlayerIds); + } + public record LoginTransition(boolean concealed, CompletableFuture saved) { } } diff --git a/src/test/java/games/dmg/spigotstealth/ServerListPingVisibilityTest.java b/src/test/java/games/dmg/spigotstealth/ServerListPingVisibilityTest.java new file mode 100644 index 0000000..b871660 --- /dev/null +++ b/src/test/java/games/dmg/spigotstealth/ServerListPingVisibilityTest.java @@ -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()); + } +} diff --git a/src/test/java/games/dmg/spigotstealth/StealthSessionServiceTest.java b/src/test/java/games/dmg/spigotstealth/StealthSessionServiceTest.java index e80ac06..b086200 100644 --- a/src/test/java/games/dmg/spigotstealth/StealthSessionServiceTest.java +++ b/src/test/java/games/dmg/spigotstealth/StealthSessionServiceTest.java @@ -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();