From 66ba2aa3b168f8afe1472cb5ca60f558fa0693b4 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Fri, 4 Sep 2026 23:54:23 -0400 Subject: [PATCH] fix(stealth): suppress concealed disconnect messages --- design/log.md | 1 + .../us-002-rejoin-without-visible-identity.md | 5 ++- .../spigotstealth/StealthSessionListener.java | 10 +++-- .../StealthSessionListenerTest.java | 41 +++++++++++++++++++ 4 files changed, 53 insertions(+), 4 deletions(-) diff --git a/design/log.md b/design/log.md index 655f7a6..edcb1f5 100644 --- a/design/log.md +++ b/design/log.md @@ -2,6 +2,7 @@ ## 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. - **Completion**: Extended US-003 and US-004 with contextual, prefix-filtered command completion that suppresses generic player suggestions, plus an administrative list of all known online and offline unlocked players; verified the complete Gradle build and OKF bundle. ## 2026-08-14 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 80c8074..06135c3 100644 --- a/design/user-stories/us-002-rejoin-without-visible-identity.md +++ b/design/user-stories/us-002-rejoin-without-visible-identity.md @@ -15,6 +15,9 @@ As an **unlocked player**, I want to disconnect while invisibility from a potion - [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] When a concealed player disconnects, no public quit or disconnect announcement is shown. +- [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] 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. @@ -26,7 +29,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, 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. +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. ## Related diff --git a/src/main/java/games/dmg/spigotstealth/StealthSessionListener.java b/src/main/java/games/dmg/spigotstealth/StealthSessionListener.java index 8b4a7a0..48089df 100644 --- a/src/main/java/games/dmg/spigotstealth/StealthSessionListener.java +++ b/src/main/java/games/dmg/spigotstealth/StealthSessionListener.java @@ -38,10 +38,14 @@ public final class StealthSessionListener implements Listener { presentation.refreshForObserver(player); } - @EventHandler(priority = EventPriority.MONITOR) + @EventHandler(priority = EventPriority.HIGHEST) public void onQuit(PlayerQuitEvent event) { - presentation.reveal(event.getPlayer()); - sessions.disconnect(event.getPlayer().getUniqueId()); + Player player = event.getPlayer(); + if (sessions.isConcealed(player.getUniqueId())) { + event.setQuitMessage(null); + } + presentation.reveal(player); + sessions.disconnect(player.getUniqueId()); } @EventHandler(priority = EventPriority.MONITOR) diff --git a/src/test/java/games/dmg/spigotstealth/StealthSessionListenerTest.java b/src/test/java/games/dmg/spigotstealth/StealthSessionListenerTest.java index a0cf0fc..c119e36 100644 --- a/src/test/java/games/dmg/spigotstealth/StealthSessionListenerTest.java +++ b/src/test/java/games/dmg/spigotstealth/StealthSessionListenerTest.java @@ -10,9 +10,50 @@ import java.util.Map; import java.util.UUID; import org.bukkit.entity.Player; import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerQuitEvent; import org.junit.jupiter.api.Test; class StealthSessionListenerTest { + @Test + void concealedDisconnectSuppressesPublicAnnouncementBeforeCleanup() { + UUID playerId = UUID.randomUUID(); + try (StealthStateManager manager = manager()) { + manager.update(state -> state.withPlayer(unlocked(playerId).withSession(false, true))).join(); + QualifyingInvisibilityService progression = new QualifyingInvisibilityService( + manager, Duration.ofHours(8), System::nanoTime, ignored -> { }); + StealthSessionService sessions = new StealthSessionService(manager, progression); + StealthSessionListener listener = new StealthSessionListener( + sessions, mock(IdentityPresentation.class), "Stealth active"); + PlayerQuitEvent event = mock(PlayerQuitEvent.class); + Player player = player(playerId); + when(event.getPlayer()).thenReturn(player); + + listener.onQuit(event); + + verify(event).setQuitMessage(null); + } + } + + @Test + void ordinaryDisconnectRetainsPublicAnnouncement() { + UUID playerId = UUID.randomUUID(); + try (StealthStateManager manager = manager()) { + QualifyingInvisibilityService progression = new QualifyingInvisibilityService( + manager, Duration.ofHours(8), System::nanoTime, ignored -> { }); + StealthSessionListener listener = new StealthSessionListener( + new StealthSessionService(manager, progression), + mock(IdentityPresentation.class), + "Stealth active"); + PlayerQuitEvent event = mock(PlayerQuitEvent.class); + Player player = player(playerId); + when(event.getPlayer()).thenReturn(player); + + listener.onQuit(event); + + verify(event, never()).setQuitMessage(null); + } + } + @Test void preparedLoginSuppressesAnnouncementAndConcealsIdentityForSession() { UUID playerId = UUID.randomUUID();