From 6071bfbdceb010128eca570aaca2c62d7e8d2247 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Sat, 15 Aug 2026 00:16:09 -0400 Subject: [PATCH] feat(stealth): conceal identity for prepared sessions --- build.gradle.kts | 2 + design/log.md | 2 + .../us-002-rejoin-without-visible-identity.md | 30 ++++---- .../BukkitIdentityPresentation.java | 75 ++++++++++++++++++ .../spigotstealth/IdentityPresentation.java | 12 +++ .../InvisibilityEffectListener.java | 5 -- .../ProtocolLibTabListController.java | 52 +++++++++++++ .../spigotstealth/SpigotStealthPlugin.java | 23 ++++++ .../spigotstealth/StealthSessionListener.java | 53 +++++++++++++ .../spigotstealth/StealthSessionService.java | 52 +++++++++++++ .../dmg/spigotstealth/TabListController.java | 11 +++ src/main/resources/plugin.yml | 1 + .../BukkitIdentityPresentationTest.java | 61 +++++++++++++++ .../dmg/spigotstealth/PluginMetadataTest.java | 1 + .../StealthSessionListenerTest.java | 77 +++++++++++++++++++ .../StealthSessionServiceTest.java | 71 +++++++++++++++++ 16 files changed, 510 insertions(+), 18 deletions(-) create mode 100644 src/main/java/games/dmg/spigotstealth/BukkitIdentityPresentation.java create mode 100644 src/main/java/games/dmg/spigotstealth/IdentityPresentation.java create mode 100644 src/main/java/games/dmg/spigotstealth/ProtocolLibTabListController.java create mode 100644 src/main/java/games/dmg/spigotstealth/StealthSessionListener.java create mode 100644 src/main/java/games/dmg/spigotstealth/StealthSessionService.java create mode 100644 src/main/java/games/dmg/spigotstealth/TabListController.java create mode 100644 src/test/java/games/dmg/spigotstealth/BukkitIdentityPresentationTest.java create mode 100644 src/test/java/games/dmg/spigotstealth/StealthSessionListenerTest.java create mode 100644 src/test/java/games/dmg/spigotstealth/StealthSessionServiceTest.java diff --git a/build.gradle.kts b/build.gradle.kts index e17e4df..355ac6d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -24,8 +24,10 @@ tasks.withType().configureEach { dependencies { compileOnly("org.spigotmc:spigot-api:26.2-R0.1-SNAPSHOT") + compileOnly("net.dmulloy2:ProtocolLib:5.4.0") testImplementation("org.spigotmc:spigot-api:26.2-R0.1-SNAPSHOT") + testImplementation("net.dmulloy2:ProtocolLib:5.4.0") testImplementation(platform("org.junit:junit-bom:5.13.4")) testImplementation("org.junit.jupiter:junit-jupiter") testImplementation("org.mockito:mockito-core:5.18.0") diff --git a/design/log.md b/design/log.md index 510115f..991261c 100644 --- a/design/log.md +++ b/design/log.md @@ -2,6 +2,8 @@ ## 2026-08-14 +- **Completion**: Completed US-002 with single-use durable prepared logins, join-announcement suppression, ProtocolLib tab-only removal, scoreboard overhead-name suppression, visible physical entities, periodic observer refresh, respawn restoration, and disconnect or disable cleanup; verified the full Gradle build. +- **Implementation**: Began US-002 with test-first prepared-login consumption, session-scoped concealment, join suppression, tab removal, and overhead-name presentation. - **Completion**: Completed US-003 with `/stealth progress`, live unsaved interval inclusion, configured target and remaining output, concise duration formatting, and unlocked usage guidance; verified the full Gradle build. - **Implementation**: Began US-003 with player-facing progress command and human-readable duration tests. - **Completion**: Completed US-001 with direct potion-drink effect attribution, monotonic elapsed-time accumulation, safe refresh and stop transitions, durable UUID progress, exact-once unlocking, and title plus chat presentation; verified the full Gradle build. 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 46de37e..80c8074 100644 --- a/design/user-stories/us-002-rejoin-without-visible-identity.md +++ b/design/user-stories/us-002-rejoin-without-visible-identity.md @@ -2,7 +2,7 @@ type: User Story title: "US-002: Rejoin without a visible identity" description: Let an unlocked player turn a qualifying invisible disconnect into one identity-concealed session. -status: backlog +status: done --- # US-002: Rejoin without a visible identity @@ -11,18 +11,22 @@ As an **unlocked player**, I want to disconnect while invisibility from a potion ## Acceptance criteria -- [ ] An unlocked player becomes eligible for a concealed login only by disconnecting while an invisibility effect from a potion they directly drank remains active. -- [ ] A player who has not unlocked stealth cannot prepare a concealed login. -- [ ] An ordinary disconnect without an active qualifying effect clears any preparation for the next login. -- [ ] On a prepared login, no public join announcement is shown. -- [ ] Throughout the concealed session, the player is absent from every other player's tab list, including administrators' tab lists. -- [ ] Throughout the concealed session, no overhead name tag identifies the player to any other player, including administrators. -- [ ] The concealed player's physical character remains visible in the world and retains ordinary movement, interaction, combat, and permission behavior. -- [ ] The concealed player receives a private message explaining that stealth is active for the session. -- [ ] Concealment lasts until the player disconnects and is handled predictably across death and plugin reload or disable events. -- [ ] Disconnecting consumes the current concealed session; another concealed login requires another qualifying potion and qualifying disconnect. -- [ ] Merely owning the unlock never conceals an ordinary login or carries concealment automatically into a later session. -- [ ] Prepared-login state survives a server restart between the qualifying disconnect and the next login. +- [x] An unlocked player becomes eligible for a concealed login only by disconnecting while an invisibility effect from a potion they directly drank remains active. +- [x] A player who has not unlocked stealth cannot prepare a concealed login. +- [x] An ordinary disconnect without an active qualifying effect clears any preparation for the next login. +- [x] On a prepared login, no public join announcement is shown. +- [x] Throughout the concealed session, the player is absent from every other player's tab list, including administrators' tab lists. +- [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. +- [x] Concealment lasts until the player disconnects and is handled predictably across death and plugin reload or disable events. +- [x] Disconnecting consumes the current concealed session; another concealed login requires another qualifying potion and qualifying disconnect. +- [x] Merely owning the unlock never conceals an ordinary login or carries concealment automatically into a later session. +- [x] Prepared-login state survives a server restart between the qualifying disconnect and the next login. + +## Validation + +Automated tests verify unlocked and locked disconnect transitions, ordinary-disconnect clearing, one-login consumption, announcement suppression, 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. ## Related diff --git a/src/main/java/games/dmg/spigotstealth/BukkitIdentityPresentation.java b/src/main/java/games/dmg/spigotstealth/BukkitIdentityPresentation.java new file mode 100644 index 0000000..5ceef54 --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/BukkitIdentityPresentation.java @@ -0,0 +1,75 @@ +package games.dmg.spigotstealth; + +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.function.Supplier; +import org.bukkit.entity.Player; +import org.bukkit.scoreboard.Scoreboard; +import org.bukkit.scoreboard.Team; + +/** Uses scoreboard metadata and tab-only packets while leaving player entities visible. */ +public final class BukkitIdentityPresentation implements IdentityPresentation { + private final Supplier> onlinePlayers; + private final Scoreboard scoreboard; + private final TabListController tabLists; + private final Map concealedPlayers = new LinkedHashMap<>(); + + public BukkitIdentityPresentation( + Supplier> onlinePlayers, + Scoreboard scoreboard, + TabListController tabLists) { + this.onlinePlayers = Objects.requireNonNull(onlinePlayers, "onlinePlayers"); + this.scoreboard = Objects.requireNonNull(scoreboard, "scoreboard"); + this.tabLists = Objects.requireNonNull(tabLists, "tabLists"); + } + + @Override + public void conceal(Player player) { + concealedPlayers.put(player.getUniqueId(), player); + Team team = scoreboard.getTeam(teamName(player.getUniqueId())); + if (team == null) { + team = scoreboard.registerNewTeam(teamName(player.getUniqueId())); + } + team.setOption(Team.Option.NAME_TAG_VISIBILITY, Team.OptionStatus.NEVER); + team.addEntry(player.getName()); + for (Player observer : onlinePlayers.get()) { + if (!observer.getUniqueId().equals(player.getUniqueId())) { + tabLists.remove(observer, player.getUniqueId()); + } + } + } + + @Override + public void reveal(Player player) { + boolean wasConcealed = concealedPlayers.remove(player.getUniqueId()) != null; + Team team = scoreboard.getTeam(teamName(player.getUniqueId())); + if (team != null) { + wasConcealed = true; + team.removeEntry(player.getName()); + team.unregister(); + } + if (wasConcealed) { + for (Player observer : onlinePlayers.get()) { + if (!observer.getUniqueId().equals(player.getUniqueId())) { + tabLists.add(observer, player); + } + } + } + } + + @Override + public void refreshForObserver(Player observer) { + for (UUID concealedPlayerId : concealedPlayers.keySet()) { + if (!observer.getUniqueId().equals(concealedPlayerId)) { + tabLists.remove(observer, concealedPlayerId); + } + } + } + + public static String teamName(UUID playerId) { + return "stlth" + playerId.toString().replace("-", "").substring(0, 11); + } +} diff --git a/src/main/java/games/dmg/spigotstealth/IdentityPresentation.java b/src/main/java/games/dmg/spigotstealth/IdentityPresentation.java new file mode 100644 index 0000000..6a17163 --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/IdentityPresentation.java @@ -0,0 +1,12 @@ +package games.dmg.spigotstealth; + +import org.bukkit.entity.Player; + +/** Presentation boundary that changes identity metadata without hiding the physical player entity. */ +public interface IdentityPresentation { + void conceal(Player player); + + void reveal(Player player); + + void refreshForObserver(Player observer); +} diff --git a/src/main/java/games/dmg/spigotstealth/InvisibilityEffectListener.java b/src/main/java/games/dmg/spigotstealth/InvisibilityEffectListener.java index 8c730eb..130b89a 100644 --- a/src/main/java/games/dmg/spigotstealth/InvisibilityEffectListener.java +++ b/src/main/java/games/dmg/spigotstealth/InvisibilityEffectListener.java @@ -8,7 +8,6 @@ import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; import org.bukkit.event.entity.EntityPotionEffectEvent; -import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.potion.PotionEffectType; /** Attributes invisibility changes to directly consumed potions and closes intervals safely. */ @@ -50,8 +49,4 @@ public final class InvisibilityEffectListener implements Listener { } } - @EventHandler(priority = EventPriority.MONITOR) - public void onQuit(PlayerQuitEvent event) { - progression.stop(event.getPlayer().getUniqueId()); - } } diff --git a/src/main/java/games/dmg/spigotstealth/ProtocolLibTabListController.java b/src/main/java/games/dmg/spigotstealth/ProtocolLibTabListController.java new file mode 100644 index 0000000..92eae7f --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/ProtocolLibTabListController.java @@ -0,0 +1,52 @@ +package games.dmg.spigotstealth; + +import com.comphenix.protocol.PacketType; +import com.comphenix.protocol.ProtocolManager; +import com.comphenix.protocol.events.PacketContainer; +import com.comphenix.protocol.wrappers.EnumWrappers; +import com.comphenix.protocol.wrappers.PlayerInfoData; +import com.comphenix.protocol.wrappers.WrappedChatComponent; +import com.comphenix.protocol.wrappers.WrappedGameProfile; +import com.comphenix.protocol.wrappers.WrappedRemoteChatSessionData; +import java.util.EnumSet; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import org.bukkit.entity.Player; + +/** ProtocolLib adapter for tab-list-only removal and restoration packets. */ +public final class ProtocolLibTabListController implements TabListController { + private final ProtocolManager protocolManager; + + public ProtocolLibTabListController(ProtocolManager protocolManager) { + this.protocolManager = Objects.requireNonNull(protocolManager, "protocolManager"); + } + + @Override + public void remove(Player observer, UUID targetPlayerId) { + PacketContainer packet = protocolManager.createPacket(PacketType.Play.Server.PLAYER_INFO_REMOVE); + packet.getUUIDLists().write(0, List.of(targetPlayerId)); + protocolManager.sendServerPacket(observer, packet); + } + + @Override + public void add(Player observer, Player target) { + PacketContainer packet = protocolManager.createPacket(PacketType.Play.Server.PLAYER_INFO); + packet.getPlayerInfoActions().write(0, EnumSet.of( + EnumWrappers.PlayerInfoAction.ADD_PLAYER, + EnumWrappers.PlayerInfoAction.UPDATE_GAME_MODE, + EnumWrappers.PlayerInfoAction.UPDATE_LISTED, + EnumWrappers.PlayerInfoAction.UPDATE_LATENCY, + EnumWrappers.PlayerInfoAction.UPDATE_DISPLAY_NAME)); + PlayerInfoData data = new PlayerInfoData( + target.getUniqueId(), + target.getPing(), + true, + EnumWrappers.NativeGameMode.fromBukkit(target.getGameMode()), + WrappedGameProfile.fromPlayer(target), + WrappedChatComponent.fromText(target.getPlayerListName()), + (WrappedRemoteChatSessionData) null); + packet.getPlayerInfoDataLists().write(1, List.of(data)); + protocolManager.sendServerPacket(observer, packet); + } +} diff --git a/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java b/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java index df621a0..069af62 100644 --- a/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java +++ b/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java @@ -1,5 +1,6 @@ package games.dmg.spigotstealth; +import com.comphenix.protocol.ProtocolLibrary; import java.nio.file.Path; import java.time.Clock; import java.util.Objects; @@ -12,6 +13,8 @@ public final class SpigotStealthPlugin extends JavaPlugin { private CompletableFuture stateManagerFuture; private StealthSettings settings; private QualifyingInvisibilityService progression; + private StealthSessionService sessions; + private IdentityPresentation identityPresentation; @Override public void onEnable() { @@ -38,6 +41,14 @@ public final class SpigotStealthPlugin extends JavaPlugin { @Override public void onDisable() { + if (identityPresentation != null && sessions != null) { + for (org.bukkit.entity.Player player : getServer().getOnlinePlayers()) { + if (sessions.isConcealed(player.getUniqueId())) { + identityPresentation.reveal(player); + sessions.endConcealment(player.getUniqueId()); + } + } + } if (progression != null) { progression.stopAll().join(); } @@ -57,8 +68,15 @@ public final class SpigotStealthPlugin extends JavaPlugin { settings.unlockedMessage()); progression = new QualifyingInvisibilityService( manager, settings.unlockThreshold(), System::nanoTime, notifier); + sessions = new StealthSessionService(manager, progression); + identityPresentation = new BukkitIdentityPresentation( + getServer()::getOnlinePlayers, + Objects.requireNonNull(getServer().getScoreboardManager(), "scoreboard manager").getMainScoreboard(), + new ProtocolLibTabListController(ProtocolLibrary.getProtocolManager())); getServer().getPluginManager().registerEvents( new InvisibilityEffectListener(progression, Clock.systemUTC()), this); + getServer().getPluginManager().registerEvents( + new StealthSessionListener(sessions, identityPresentation, settings.concealedMessage()), this); Objects.requireNonNull(getCommand("stealth"), "stealth command") .setExecutor(new StealthCommand(manager, progression, settings)); getServer().getScheduler().runTaskTimer(this, ignored -> { @@ -66,6 +84,11 @@ public final class SpigotStealthPlugin extends JavaPlugin { logSaveFailure(progression.checkpoint(playerId)); } }, 20L, 20L); + getServer().getScheduler().runTaskTimer(this, ignored -> { + for (org.bukkit.entity.Player observer : getServer().getOnlinePlayers()) { + identityPresentation.refreshForObserver(observer); + } + }, 20L, 20L); getServer().getScheduler().runTaskTimer(this, ignored -> logSaveFailure(manager.save()), 6000L, 6000L); getLogger().info("Spigot Stealth enabled"); } diff --git a/src/main/java/games/dmg/spigotstealth/StealthSessionListener.java b/src/main/java/games/dmg/spigotstealth/StealthSessionListener.java new file mode 100644 index 0000000..8b4a7a0 --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/StealthSessionListener.java @@ -0,0 +1,53 @@ +package games.dmg.spigotstealth; + +import java.util.Objects; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.event.player.PlayerRespawnEvent; + +/** Applies session transitions and identity presentation on Bukkit lifecycle events. */ +public final class StealthSessionListener implements Listener { + private final StealthSessionService sessions; + private final IdentityPresentation presentation; + private final String concealedMessage; + + public StealthSessionListener( + StealthSessionService sessions, + IdentityPresentation presentation, + String concealedMessage) { + this.sessions = Objects.requireNonNull(sessions, "sessions"); + this.presentation = Objects.requireNonNull(presentation, "presentation"); + this.concealedMessage = Objects.requireNonNull(concealedMessage, "concealedMessage"); + } + + @EventHandler(priority = EventPriority.HIGHEST) + public void onJoin(PlayerJoinEvent event) { + Player player = event.getPlayer(); + StealthSessionService.LoginTransition transition = sessions.login(player.getUniqueId(), player.getName()); + if (transition.concealed()) { + event.setJoinMessage(null); + presentation.conceal(player); + player.sendMessage(concealedMessage); + } else { + presentation.reveal(player); + } + presentation.refreshForObserver(player); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onQuit(PlayerQuitEvent event) { + presentation.reveal(event.getPlayer()); + sessions.disconnect(event.getPlayer().getUniqueId()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onRespawn(PlayerRespawnEvent event) { + if (sessions.isConcealed(event.getPlayer().getUniqueId())) { + presentation.conceal(event.getPlayer()); + } + } +} diff --git a/src/main/java/games/dmg/spigotstealth/StealthSessionService.java b/src/main/java/games/dmg/spigotstealth/StealthSessionService.java new file mode 100644 index 0000000..8b77f58 --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/StealthSessionService.java @@ -0,0 +1,52 @@ +package games.dmg.spigotstealth; + +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Owns prepared-login and current concealed-session state transitions. */ +public final class StealthSessionService { + private final StealthStateManager stateManager; + private final QualifyingInvisibilityService progression; + + public StealthSessionService( + StealthStateManager stateManager, + QualifyingInvisibilityService progression) { + this.stateManager = Objects.requireNonNull(stateManager, "stateManager"); + this.progression = Objects.requireNonNull(progression, "progression"); + } + + public CompletableFuture disconnect(UUID playerId) { + boolean qualifyingAtDisconnect = progression.isQualifying(playerId); + return progression.stop(playerId).thenCompose(ignored -> stateManager.update(state -> { + PlayerStealthState player = state.player(playerId); + boolean prepareNextLogin = qualifyingAtDisconnect && player.unlocked(); + return state.withPlayer(player.withSession(prepareNextLogin, false)); + })); + } + + public LoginTransition login(UUID playerId, String playerName) { + AtomicBoolean concealed = new AtomicBoolean(); + CompletableFuture saved = stateManager.update(state -> { + PlayerStealthState player = state.player(playerId).withLastKnownName(playerName); + boolean conceal = player.unlocked() && player.preparedLogin(); + concealed.set(conceal); + return state.withPlayer(player.withSession(false, conceal).withQualifyingSince(null)); + }); + return new LoginTransition(concealed.get(), saved); + } + + public CompletableFuture endConcealment(UUID playerId) { + return stateManager.update(state -> { + PlayerStealthState player = state.player(playerId); + return state.withPlayer(player.withSession(player.preparedLogin(), false)); + }); + } + + public boolean isConcealed(UUID playerId) { + return stateManager.snapshot().player(playerId).concealed(); + } + + public record LoginTransition(boolean concealed, CompletableFuture saved) { } +} diff --git a/src/main/java/games/dmg/spigotstealth/TabListController.java b/src/main/java/games/dmg/spigotstealth/TabListController.java new file mode 100644 index 0000000..9440b9e --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/TabListController.java @@ -0,0 +1,11 @@ +package games.dmg.spigotstealth; + +import java.util.UUID; +import org.bukkit.entity.Player; + +/** Packet-level tab-list operations that do not hide the physical player entity. */ +public interface TabListController { + void remove(Player observer, UUID targetPlayerId); + + void add(Player observer, Player target); +} diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 19fcb9f..d8fe4c1 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -4,6 +4,7 @@ main: games.dmg.spigotstealth.SpigotStealthPlugin api-version: "1.20" description: Rewards invisibility potion use with identity-concealed sessions. author: dmg.games +depend: [ProtocolLib] commands: stealth: description: View and use Spigot Stealth features. diff --git a/src/test/java/games/dmg/spigotstealth/BukkitIdentityPresentationTest.java b/src/test/java/games/dmg/spigotstealth/BukkitIdentityPresentationTest.java new file mode 100644 index 0000000..a294fb5 --- /dev/null +++ b/src/test/java/games/dmg/spigotstealth/BukkitIdentityPresentationTest.java @@ -0,0 +1,61 @@ +package games.dmg.spigotstealth; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.UUID; +import org.bukkit.entity.Player; +import org.bukkit.scoreboard.Scoreboard; +import org.bukkit.scoreboard.Team; +import org.junit.jupiter.api.Test; + +class BukkitIdentityPresentationTest { + @Test + void concealRemovesTabEntryAndHidesNameTagWithoutHidingEntity() { + UUID targetId = UUID.randomUUID(); + Player target = player(targetId, "Alex"); + Player observer = player(UUID.randomUUID(), "Morgan"); + Scoreboard scoreboard = mock(Scoreboard.class); + Team team = mock(Team.class); + when(scoreboard.getTeam(BukkitIdentityPresentation.teamName(targetId))).thenReturn(null); + when(scoreboard.registerNewTeam(BukkitIdentityPresentation.teamName(targetId))).thenReturn(team); + TabListController tabLists = mock(TabListController.class); + BukkitIdentityPresentation presentation = new BukkitIdentityPresentation( + () -> List.of(target, observer), scoreboard, tabLists); + + presentation.conceal(target); + + verify(team).setOption(Team.Option.NAME_TAG_VISIBILITY, Team.OptionStatus.NEVER); + verify(team).addEntry("Alex"); + verify(tabLists).remove(observer, targetId); + verify(observer, never()).hidePlayer(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); + } + + @Test + void newObserverHasEveryConcealedPlayerRemovedFromTab() { + UUID targetId = UUID.randomUUID(); + Player target = player(targetId, "Alex"); + Player observer = player(UUID.randomUUID(), "Morgan"); + Scoreboard scoreboard = mock(Scoreboard.class); + Team team = mock(Team.class); + when(scoreboard.getTeam(BukkitIdentityPresentation.teamName(targetId))).thenReturn(team); + TabListController tabLists = mock(TabListController.class); + BukkitIdentityPresentation presentation = new BukkitIdentityPresentation( + () -> List.of(target, observer), scoreboard, tabLists); + presentation.conceal(target); + + presentation.refreshForObserver(observer); + + verify(tabLists, org.mockito.Mockito.atLeastOnce()).remove(observer, targetId); + } + + private static Player player(UUID playerId, String name) { + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(playerId); + when(player.getName()).thenReturn(name); + return player; + } +} diff --git a/src/test/java/games/dmg/spigotstealth/PluginMetadataTest.java b/src/test/java/games/dmg/spigotstealth/PluginMetadataTest.java index 0421a53..eda4b7e 100644 --- a/src/test/java/games/dmg/spigotstealth/PluginMetadataTest.java +++ b/src/test/java/games/dmg/spigotstealth/PluginMetadataTest.java @@ -26,6 +26,7 @@ class PluginMetadataTest { assertEquals("games.dmg.spigotstealth.SpigotStealthPlugin", metadata.get("main")); assertNotNull(metadata.get("commands")); assertNotNull(metadata.get("permissions")); + assertEquals(java.util.List.of("ProtocolLib"), metadata.get("depend")); } catch (java.io.IOException exception) { throw new AssertionError(exception); } diff --git a/src/test/java/games/dmg/spigotstealth/StealthSessionListenerTest.java b/src/test/java/games/dmg/spigotstealth/StealthSessionListenerTest.java new file mode 100644 index 0000000..a0cf0fc --- /dev/null +++ b/src/test/java/games/dmg/spigotstealth/StealthSessionListenerTest.java @@ -0,0 +1,77 @@ +package games.dmg.spigotstealth; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.util.Map; +import java.util.UUID; +import org.bukkit.entity.Player; +import org.bukkit.event.player.PlayerJoinEvent; +import org.junit.jupiter.api.Test; + +class StealthSessionListenerTest { + @Test + void preparedLoginSuppressesAnnouncementAndConcealsIdentityForSession() { + UUID playerId = UUID.randomUUID(); + try (StealthStateManager manager = manager()) { + manager.update(state -> state.withPlayer(unlocked(playerId).withSession(true, false))).join(); + QualifyingInvisibilityService progression = new QualifyingInvisibilityService( + manager, Duration.ofHours(8), System::nanoTime, ignored -> { }); + IdentityPresentation presentation = mock(IdentityPresentation.class); + StealthSessionListener listener = new StealthSessionListener( + new StealthSessionService(manager, progression), presentation, "Stealth active"); + Player player = player(playerId); + PlayerJoinEvent event = mock(PlayerJoinEvent.class); + when(event.getPlayer()).thenReturn(player); + + listener.onJoin(event); + + verify(event).setJoinMessage(null); + verify(presentation).conceal(player); + verify(player).sendMessage("Stealth active"); + } + } + + @Test + void ordinaryLoginRetainsAnnouncementAndIdentity() { + UUID playerId = UUID.randomUUID(); + try (StealthStateManager manager = manager()) { + QualifyingInvisibilityService progression = new QualifyingInvisibilityService( + manager, Duration.ofHours(8), System::nanoTime, ignored -> { }); + IdentityPresentation presentation = mock(IdentityPresentation.class); + StealthSessionListener listener = new StealthSessionListener( + new StealthSessionService(manager, progression), presentation, "Stealth active"); + Player player = player(playerId); + PlayerJoinEvent event = mock(PlayerJoinEvent.class); + when(event.getPlayer()).thenReturn(player); + + listener.onJoin(event); + + verify(event, never()).setJoinMessage(null); + verify(presentation).reveal(player); + verify(player, never()).sendMessage("Stealth active"); + } + } + + private static Player player(UUID playerId) { + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(playerId); + when(player.getName()).thenReturn("Alex"); + return player; + } + + private static PlayerStealthState unlocked(UUID playerId) { + return new PlayerStealthState(playerId, "Alex", Duration.ofHours(8).toMillis(), true, false, false, null, Map.of()); + } + + private static StealthStateManager manager() { + StealthStateRepository repository = new StealthStateRepository() { + @Override public PersistentStealthState load() { return new PersistentStealthState(Map.of(), Map.of()); } + @Override public void save(PersistentStealthState state) { } + }; + return new StealthStateManager(repository, new PersistentStealthState(Map.of(), Map.of())); + } +} diff --git a/src/test/java/games/dmg/spigotstealth/StealthSessionServiceTest.java b/src/test/java/games/dmg/spigotstealth/StealthSessionServiceTest.java new file mode 100644 index 0000000..e80ac06 --- /dev/null +++ b/src/test/java/games/dmg/spigotstealth/StealthSessionServiceTest.java @@ -0,0 +1,71 @@ +package games.dmg.spigotstealth; + +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.UUID; +import org.junit.jupiter.api.Test; + +class StealthSessionServiceTest { + @Test + void unlockedQualifyingDisconnectPreparesAndConsumesOneConcealedLogin() { + UUID playerId = UUID.randomUUID(); + try (StealthStateManager manager = manager()) { + manager.update(state -> state.withPlayer(unlocked(playerId))).join(); + QualifyingInvisibilityService progression = new QualifyingInvisibilityService( + manager, Duration.ofHours(8), System::nanoTime, ignored -> { }); + progression.begin(playerId, "Alex", Instant.EPOCH).join(); + StealthSessionService sessions = new StealthSessionService(manager, progression); + + sessions.disconnect(playerId).join(); + assertTrue(manager.snapshot().player(playerId).preparedLogin()); + assertTrue(sessions.login(playerId, "Alex").concealed()); + assertFalse(manager.snapshot().player(playerId).preparedLogin()); + assertTrue(manager.snapshot().player(playerId).concealed()); + + sessions.disconnect(playerId).join(); + assertFalse(sessions.login(playerId, "Alex").concealed()); + } + } + + @Test + void lockedPlayerCannotPrepareConcealedLogin() { + UUID playerId = UUID.randomUUID(); + try (StealthStateManager manager = manager()) { + QualifyingInvisibilityService progression = new QualifyingInvisibilityService( + manager, Duration.ofHours(8), System::nanoTime, ignored -> { }); + progression.begin(playerId, "Alex", Instant.EPOCH).join(); + StealthSessionService sessions = new StealthSessionService(manager, progression); + sessions.disconnect(playerId).join(); + assertFalse(sessions.login(playerId, "Alex").concealed()); + } + } + + @Test + void ordinaryDisconnectClearsEarlierPreparation() { + UUID playerId = UUID.randomUUID(); + try (StealthStateManager manager = manager()) { + manager.update(state -> state.withPlayer(unlocked(playerId).withSession(true, false))).join(); + QualifyingInvisibilityService progression = new QualifyingInvisibilityService( + manager, Duration.ofHours(8), System::nanoTime, ignored -> { }); + StealthSessionService sessions = new StealthSessionService(manager, progression); + sessions.disconnect(playerId).join(); + assertFalse(manager.snapshot().player(playerId).preparedLogin()); + } + } + + private static PlayerStealthState unlocked(UUID playerId) { + return new PlayerStealthState(playerId, "Alex", Duration.ofHours(8).toMillis(), true, false, false, null, Map.of()); + } + + private static StealthStateManager manager() { + StealthStateRepository repository = new StealthStateRepository() { + @Override public PersistentStealthState load() { return new PersistentStealthState(Map.of(), Map.of()); } + @Override public void save(PersistentStealthState state) { } + }; + return new StealthStateManager(repository, new PersistentStealthState(Map.of(), Map.of())); + } +}