10 Commits
Author SHA1 Message Date
dmg 1addc93062 feat(stealth): mask identities and notify admins on login
Release / release (push) Successful in 2m8s
CI / build (push) Successful in 1m6s
2026-09-06 21:19:59 -04:00
dmg 831f6a2ce4 fix(ping): support Purpur NameAndId samples
Release / release (push) Successful in 2m24s
CI / build (push) Successful in 1m42s
2026-09-05 08:58:46 -04:00
dmg a084f86c1d feat(admin): add persistent sleep-count policy
Release / release (push) Successful in 2m17s
CI / build (push) Successful in 1m3s
2026-09-05 08:34:50 -04:00
dmg ba6573943b fix(stealth): hide concealed players from server ping
Release / release (push) Successful in 2m13s
CI / build (push) Successful in 58s
2026-09-05 08:11:58 -04:00
dmg 66ba2aa3b1 fix(stealth): suppress concealed disconnect messages
CI / build (push) Successful in 1m1s
Release / release (push) Successful in 2m8s
2026-09-04 23:54:23 -04:00
dmg b0da1508b6 feat(commands): add contextual completion and unlock listing
Release / release (push) Successful in 3m46s
CI / build (push) Successful in 57s
2026-09-04 23:37:20 -04:00
dmg 2bcba5071f feat(admin): manage player stealth state
CI / build (push) Successful in 1m6s
Release / release (push) Successful in 2m20s
2026-08-15 00:20:29 -04:00
dmg 6071bfbdce feat(stealth): conceal identity for prepared sessions
CI / build (push) Successful in 5m4s
Release / release (push) Successful in 6m13s
2026-08-15 00:16:09 -04:00
dmg 90851a0d87 feat(commands): show player stealth progress
Release / release (push) Successful in 2m29s
CI / build (push) Successful in 1m15s
2026-08-15 00:03:09 -04:00
dmg 91c9e7b9ad feat(progression): unlock stealth from potion invisibility
CI / build (push) Successful in 2m31s
Release / release (push) Successful in 3m30s
2026-08-15 00:01:26 -04:00
56 changed files with 3153 additions and 57 deletions
+2
View File
@@ -24,8 +24,10 @@ tasks.withType<JavaCompile>().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")
+3 -1
View File
@@ -4,7 +4,9 @@ okf_version: "0.1"
# Spigot Stealth Design
Spigot Stealth rewards players who spend time under invisibility effects from potions they drink, then lets unlocked players conceal their identity for a later session.
Spigot Stealth rewards players who spend time under invisibility effects from potions they drink, then lets unlocked players conceal their identity for a later session. [Session concealment](user-stories/us-002-rejoin-without-visible-identity.md) includes command-suggestion filtering and scrambled message and overhead names.
[Administration](user-stories/us-004-administer-player-stealth.md) provides private concealed-player counts at login and command-based inspection.
## Explore
+25
View File
@@ -1,7 +1,32 @@
# Spigot Stealth Design Log
## 2026-09-06
- **Completion**: Extended US-004 with permission-gated private concealed-player counts for joining admins and arrival notices for online admins, including command hints, zero-count silence, and no duplicate notice for concealed admins. Verified all 70 tests, the JAR build, and OKF validation; not deployed or live-server tested.
- **Completion**: Extended US-002 with concealed-username suggestion filtering, scrambled default chat and vanilla message/advancement names, scrambled overhead tags, and display-name restoration. Verified 65 automated tests, the Gradle JAR build, and OKF validation; live-server compatibility remains untested and custom chat/scoreboard integrations are outside this slice.
## 2026-09-05
- **Fix**: Reworked US-002 server-list filtering for Purpur's native `NameAndId` samples by filtering names through Bukkit, limiting ProtocolLib to guarded count adjustment, and rate-limiting compatibility warnings; verified the complete Gradle build and OKF bundle.
- **Completion**: Extended US-002, US-004, and US-005 with a persistent, permission-gated sleep-count policy that excludes concealed players by default, applies immediately, restores prior player state, and supports contextual administration; verified the complete Gradle build and OKF bundle.
- **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.
- **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
- **Completion**: Completed US-004 with exact known online and offline targeting, complete status, idempotent grants, confirmed resets, active concealment discovery, operator-default permissions, asynchronous persisted replies, and console audits; verified the full Gradle build.
- **Implementation**: Began US-004 with test-first exact offline targeting, grant/reset transitions, concealed-player listing, permissions, confirmation, and audit logging.
- **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.
- **Implementation**: Began US-001 with monotonic-clock progression tests and potion-cause event attribution.
- **Completion**: Completed US-005 with validated eight-hour defaults and configurable messages, UUID-keyed immutable state, RFC 3339 timing metadata, safe invalid-record defaults, unknown-field preservation, atomic YAML replacement, periodic saves, and serialized off-thread persistence; verified the full Gradle build.
- **Implementation**: Began US-005 with test-first validated settings, UUID-keyed state, and defensive asynchronous persistence.
- **Completion**: Completed US-006 with a Java 17 Gradle build, strict compiler linting, Spigot API, JUnit 5, Mockito, plugin metadata, OKF validation, Gitea CI, conventional-commit checks, semantic releases, and versioned release assets; verified the full build and a `1.2.3` release JAR.
+2 -2
View File
@@ -1,8 +1,8 @@
# Spigot Stealth User Stories
1. [US-001: Accumulate invisibility time and unlock stealth](us-001-accumulate-invisibility-and-unlock.md) - Earn stealth by spending eight hours under invisibility effects from directly consumed potions.
2. [US-002: Rejoin without a visible identity](us-002-rejoin-without-visible-identity.md) - Turn a qualifying invisible disconnect into one concealed online session.
2. [US-002: Rejoin without a visible identity](us-002-rejoin-without-visible-identity.md) - Turn a qualifying invisible disconnect into one concealed online session with filtered name suggestions and scrambled message and overhead names.
3. [US-003: Check personal stealth progress](us-003-check-personal-progress.md) - View accumulated time, remaining time, and unlock status.
4. [US-004: Inspect and manage player stealth](us-004-administer-player-stealth.md) - Inspect and modify online or offline progression and find concealed players.
4. [US-004: Inspect and manage player stealth](us-004-administer-player-stealth.md) - Inspect and modify online or offline progression, find concealed players, and receive private login count notices.
5. [US-005: Configure and persist stealth progression](us-005-configure-and-persist-progression.md) - Keep progression durable and operator-configurable.
6. [US-006: Build, test, and release the plugin](us-006-build-test-and-release.md) - Provide repeatable Gradle builds and Gitea delivery.
@@ -2,7 +2,7 @@
type: User Story
title: "US-001: Accumulate invisibility time and unlock stealth"
description: Reward sustained use of directly consumed invisibility potions with the stealth ability.
status: backlog
status: done
---
# US-001: Accumulate invisibility time and unlock stealth
@@ -11,15 +11,19 @@ As a **player**, I want my qualifying invisibility time to accumulate so that su
## Acceptance criteria
- [ ] Only time spent online under an invisibility effect produced by a potion the player directly drank contributes to progression.
- [ ] Invisibility received from splash potions, lingering potions, tipped arrows, commands, plugins, or other sources does not contribute.
- [ ] Qualifying time stops when the effect ends, is removed, is replaced by a non-qualifying source, the player disconnects, or the plugin disables.
- [ ] Refreshed or overlapping qualifying effects never count elapsed time more than once.
- [ ] Qualifying time accumulates across effects, sessions, and server restarts.
- [ ] The ability unlocks when accumulated qualifying time reaches eight hours by default.
- [ ] Reaching the threshold grants the unlock exactly once without discarding excess elapsed time.
- [ ] When an online player unlocks stealth, they receive both a full-screen title and a chat message explaining the ability.
- [ ] Progress and unlock ownership are associated with the player's UUID rather than their current name.
- [x] Only time spent online under an invisibility effect produced by a potion the player directly drank contributes to progression.
- [x] Invisibility received from splash potions, lingering potions, tipped arrows, commands, plugins, or other sources does not contribute.
- [x] Qualifying time stops when the effect ends, is removed, is replaced by a non-qualifying source, the player disconnects, or the plugin disables.
- [x] Refreshed or overlapping qualifying effects never count elapsed time more than once.
- [x] Qualifying time accumulates across effects, sessions, and server restarts.
- [x] The ability unlocks when accumulated qualifying time reaches eight hours by default.
- [x] Reaching the threshold grants the unlock exactly once without discarding excess elapsed time.
- [x] When an online player unlocks stealth, they receive both a full-screen title and a chat message explaining the ability.
- [x] Progress and unlock ownership are associated with the player's UUID rather than their current name.
## Validation
Automated tests verify monotonic accumulation across intervals, refresh without duplicate time, exact-once threshold crossing with excess-time preservation, direct potion-drink cause filtering, non-qualifying replacement, UUID state, and online title and chat presentation. The complete `./gradlew clean check jar` lifecycle passes.
## Related
@@ -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,49 @@ 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] 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] 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] Server-list filtering remains error-free when Purpur represents player samples with native `NameAndId` values rather than Mojang `GameProfile` values.
- [x] A server-ping compatibility failure leaves the original response usable and does not produce repeated unhandled listener exceptions.
- [x] By default, concealed players are excluded from sleep-percentage calculations.
- [x] When the sleep-count policy is `include`, concealed players count normally; ordinary players are never modified by either policy.
- [x] A player's previous sleeping-ignore state is restored when concealment ends, the player disconnects or is reset, the policy changes to `include`, or the plugin disables.
- [x] Policy changes apply immediately to currently concealed players.
- [x] Throughout the concealed session, the overhead name is rendered as scrambled magic text, including for administrators, while the physical character stays visible.
- [x] Concealed usernames are excluded from outgoing command suggestions, including `/msg`, `/tell`, and `/w`; manually addressing a known username remains possible.
- [x] Default chat display names and vanilla private-message and advancement name components are scrambled without changing message content or signed chat bodies.
- [x] Masked message name components do not expose the original identity through hover or click actions.
- [x] Previous display names and ordinary suggestions return when concealment ends; ordinary players remain unaffected.
- [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, 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, ProtocolLib-independent native player-sample filtering, guarded public server-list count adjustment, nonnegative counts, unchanged maximum capacity, default sleep-count exclusion, immediate policy changes, restoration of prior sleeping-ignore state, 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.
Identity masking verification (2026-09-06): `./gradlew --offline clean check jar` passed all 65 tests and OKF validation. Tests cover semantic name slots, preserved message content, stripped identifying actions, reflected suggestion entries and chat-bound records, safe packet failure handling, immutable name snapshots, overhead formatting, and display-name restoration after repeated concealment. Presentation tests first failed to compile against the missing snapshot API, then passed after implementation. These are automated unit checks, not a live-client smoke test.
## Identity masking scope
Masking applies to active concealed sessions, not every potion effect. Outgoing username suggestions are filtered across commands; entering a known username manually still works. Default Bukkit chat uses a temporary scrambled `Anonymous` display name. Vanilla translated announcements and chat-type bound names are masked at the packet boundary without editing signed message bodies. The overhead tag uses scoreboard magic formatting on the profile name, not a profile rename; this is visual concealment, not anonymity against modified clients.
Custom chat formats that ignore Bukkit display names, nicknames, custom scoreboards, and third-party messaging plugins may need dedicated integration. Unsupported packet layouts retain the original packet and emit one warning per packet type rather than interrupting chat delivery. Live-server/client compatibility still requires a smoke test, particularly after Minecraft or ProtocolLib upgrades.
Implementation: [identity presentation](../../src/main/java/games/dmg/spigotstealth/BukkitIdentityPresentation.java), [packet listener](../../src/main/java/games/dmg/spigotstealth/ProtocolLibIdentityMaskingListener.java), and [semantic name masking](../../src/main/java/games/dmg/spigotstealth/StealthChatMasker.java).
## Related
@@ -2,7 +2,7 @@
type: User Story
title: "US-003: Check personal stealth progress"
description: Show a player their accumulated qualifying invisibility time and stealth unlock status.
status: backlog
status: done
---
# US-003: Check personal stealth progress
@@ -11,13 +11,19 @@ As a **player**, I want to check my stealth progress so that I know how close I
## Acceptance criteria
- [ ] `/stealth progress` reports the player's accumulated qualifying invisibility time.
- [ ] Before unlock, the command reports the configured target and remaining duration.
- [ ] While a qualifying effect is active, the report includes elapsed time not yet written during the current tracking interval.
- [ ] After unlock, the command clearly reports that stealth is unlocked and explains how to prepare a concealed login.
- [ ] Durations are presented in a concise, human-readable form.
- [ ] Repeated command use does not change progression or concealment state.
- [ ] The command has clear usage metadata and an appropriate player permission.
- [x] `/stealth progress` reports the player's accumulated qualifying invisibility time.
- [x] Before unlock, the command reports the configured target and remaining duration.
- [x] While a qualifying effect is active, the report includes elapsed time not yet written during the current tracking interval.
- [x] After unlock, the command clearly reports that stealth is unlocked and explains how to prepare a concealed login.
- [x] Durations are presented in a concise, human-readable form.
- [x] Repeated command use does not change progression or concealment state.
- [x] The command has clear usage metadata and an appropriate player permission.
- [x] `/stealth` tab-completes `progress`, filtered by the entered prefix.
- [x] Unsupported argument positions return no suggestions instead of Bukkit's generic player list.
## Validation
Automated tests verify concise duration formatting, live in-flight progress, configured target and remaining output, command non-mutation, unlocked usage guidance, prefix-filtered completion, and suppression of irrelevant suggestions. Command metadata and the complete `./gradlew clean check jar` lifecycle pass.
## Related
@@ -2,7 +2,7 @@
type: User Story
title: "US-004: Inspect and manage player stealth"
description: Give administrators durable controls for online and offline progression, unlocks, and active concealment.
status: backlog
status: done
---
# US-004: Inspect and manage player stealth
@@ -11,19 +11,41 @@ As a **server administrator**, I want to inspect and correct player stealth stat
## Acceptance criteria
- [ ] `/stealthadmin status <player|uuid>` reports accumulated time, unlock status, prepared-login state, and current concealment state.
- [ ] Status inspection works for online players and known offline players selected by exact current or previously recorded name or UUID.
- [ ] `/stealthadmin grant <player|uuid>` grants the unlock to an online or known offline player without altering accumulated time unnecessarily.
- [ ] Granting an already-owned unlock is safe and clearly reports that no change was needed.
- [ ] Granting the unlock sends the normal full-screen title and chat notification when the target is online.
- [ ] `/stealthadmin reset <player|uuid> confirm` clears accumulated time, unlock ownership, prepared-login state, and current concealment for an online or known offline player.
- [ ] Reset requires explicit confirmation and safely restores an online concealed player to ordinary identity presentation.
- [ ] `/stealthadmin list` lists every currently online concealed player and clearly reports when there are none.
- [ ] Concealed players remain absent from administrators' ordinary tab lists and retain hidden overhead name tags; the admin command is the supported discovery mechanism.
- [ ] Commands clearly reject unknown, ambiguous, malformed, or otherwise invalid targets without creating unintended player records.
- [ ] Administrative inspection and modification require an operator-default administrative permission.
- [ ] State-changing operations persist before success is reported and are safe under retries.
- [ ] Grant and reset actions record the administrator, target UUID, and action in the server log without blocking the server tick thread.
- [x] `/stealthadmin status <player|uuid>` reports accumulated time, unlock status, prepared-login state, and current concealment state.
- [x] Status inspection works for online players and known offline players selected by exact current or previously recorded name or UUID.
- [x] `/stealthadmin grant <player|uuid>` grants the unlock to an online or known offline player without altering accumulated time unnecessarily.
- [x] Granting an already-owned unlock is safe and clearly reports that no change was needed.
- [x] Granting the unlock sends the normal full-screen title and chat notification when the target is online.
- [x] `/stealthadmin reset <player|uuid> confirm` clears accumulated time, unlock ownership, prepared-login state, and current concealment for an online or known offline player.
- [x] Reset requires explicit confirmation and safely restores an online concealed player to ordinary identity presentation.
- [x] `/stealthadmin list` lists every currently online concealed player and clearly reports when there are none.
- [x] Concealed players remain absent from administrators' ordinary tab lists and retain scrambled overhead names; the admin command is the supported discovery mechanism.
- [x] An administrator joining while concealed players are online receives a private count and a hint to use `/stealthadmin list`.
- [x] When a concealed player joins, online administrators receive a private arrival notice, the updated concealed-player count, and the command hint.
- [x] Notifications require `spigotstealth.admin`; ordinary players receive no administrative notice.
- [x] Counts include the newly joined concealed player, and a joining concealed administrator receives only one administrative notification.
- [x] An administrator joining when no concealed players are online receives no notification.
- [x] Commands clearly reject unknown, ambiguous, malformed, or otherwise invalid targets without creating unintended player records.
- [x] Administrative inspection and modification require an operator-default administrative permission.
- [x] State-changing operations persist before success is reported and are safe under retries.
- [x] Grant and reset actions record the administrator, target UUID, and action in the server log without blocking the server tick thread.
- [x] `/stealthadmin` tab-completes `status`, `grant`, `reset`, and `list`, filtered by the entered prefix.
- [x] Target positions for `status`, `grant`, and `reset` suggest known player names, and reset's final argument suggests `confirm`.
- [x] Administrative completions require the administrative permission, and irrelevant positions return no suggestions instead of Bukkit's generic player list.
- [x] `/stealthadmin list unlocked` lists every known online or offline player with stealth unlocked.
- [x] The unlocked list is sorted case-insensitively, identifies nameless records by UUID, and clearly reports when it is empty.
- [x] Existing `/stealthadmin list` behavior continues to list currently concealed online players.
- [x] `/stealthadmin sleepcount status` reports whether concealed players are included in or excluded from sleep-percentage calculations.
- [x] `/stealthadmin sleepcount <include|exclude>` persists the policy before reporting success, applies it immediately, and records the administrator and policy in the server log.
- [x] The `sleepcount`, `status`, `include`, and `exclude` arguments are permission-gated and contextually tab-completed.
## Validation
Automated tests verify exact offline name and UUID resolution, ambiguous and unknown rejection without record creation, complete status output, idempotent grants and notification, complete resets with presentation cleanup, online concealed-player filtering, online and offline unlocked-player listing, contextual permission-gated completion, durable sleep-count status and policy changes, immediate policy refresh, confirmation and permission gates, persisted-before-success replies, and audit records. The complete `./gradlew clean check jar` lifecycle passes.
Login notice verification (2026-09-06): `./gradlew --offline clean check jar` passed all 70 tests and OKF validation. New tests first failed against the absent listener, then passed after implementation. Tests cover permission gates, zero-count silence, singular/plural counts, concealed-admin deduplication, ordinary-join silence for existing admins, and a prepared-login transition followed by an accurate administrative notice. Live-server verification remains pending.
Implementation: [admin join listener](../../src/main/java/games/dmg/spigotstealth/StealthAdminJoinListener.java), registered at `MONITOR` after the session listener's `HIGHEST` login transition. Notices expose only counts; names remain available through `/stealthadmin list`.
## Related
@@ -23,10 +23,11 @@ As a **server operator**, I want stealth progression to be configurable and dura
- [x] Corrupt, unknown, or invalid records cannot silently grant time, an unlock, a prepared login, or concealment.
- [x] Unknown forward-compatible fields are preserved where practical.
- [x] Persistence work does not perform blocking file operations on the server tick thread.
- [x] The sleep-count policy persists in `state.yml` and defaults safely to `exclude` when missing or invalid.
## Validation
Verified settings defaults and rejection, packaged configuration, safe UUID-state defaults, RFC 3339 round trips, unknown-field preservation, invalid-record rejection, atomic repository writes, and dedicated-thread loading and saving with automated tests and `./gradlew clean check jar`.
Verified settings defaults and rejection, packaged configuration, safe UUID-state defaults, sleep-count policy defaults and round trips, RFC 3339 round trips, unknown-field preservation, invalid-record rejection, atomic repository writes, and dedicated-thread loading and saving with automated tests and `./gradlew clean check jar`.
## Related
@@ -0,0 +1,132 @@
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<? extends Collection<? extends Player>> onlinePlayers;
private final Scoreboard scoreboard;
private final TabListController tabLists;
private final Supplier<SleepCountPolicy> sleepCountPolicy;
private final Map<UUID, Player> concealedPlayers = new LinkedHashMap<>();
private final Map<UUID, Boolean> previousSleepingIgnored = new LinkedHashMap<>();
private final Map<UUID, String> previousDisplayNames = new LinkedHashMap<>();
private volatile java.util.Set<String> concealedNames = java.util.Set.of();
/** Immutable snapshot safe to read from outgoing packet threads. */
public java.util.Set<String> concealedNames() {
return concealedNames;
}
private void publishNames() {
concealedNames = concealedPlayers.values().stream().map(Player::getName)
.collect(java.util.stream.Collectors.toUnmodifiableSet());
}
public BukkitIdentityPresentation(
Supplier<? extends Collection<? extends Player>> onlinePlayers,
Scoreboard scoreboard,
TabListController tabLists) {
this(onlinePlayers, scoreboard, tabLists, () -> SleepCountPolicy.EXCLUDE);
}
public BukkitIdentityPresentation(
Supplier<? extends Collection<? extends Player>> onlinePlayers,
Scoreboard scoreboard,
TabListController tabLists,
Supplier<SleepCountPolicy> sleepCountPolicy) {
this.onlinePlayers = Objects.requireNonNull(onlinePlayers, "onlinePlayers");
this.scoreboard = Objects.requireNonNull(scoreboard, "scoreboard");
this.tabLists = Objects.requireNonNull(tabLists, "tabLists");
this.sleepCountPolicy = Objects.requireNonNull(sleepCountPolicy, "sleepCountPolicy");
}
@Override
public void conceal(Player player) {
concealedPlayers.put(player.getUniqueId(), player);
publishNames();
previousDisplayNames.putIfAbsent(player.getUniqueId(), player.getDisplayName());
player.setDisplayName("§kAnonymous§r");
applySleepCountPolicy(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.ALWAYS);
team.setPrefix("§k");
team.setSuffix("§r");
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;
publishNames();
if (previousDisplayNames.containsKey(player.getUniqueId())) {
player.setDisplayName(previousDisplayNames.remove(player.getUniqueId()));
}
restoreSleepingIgnored(player);
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);
}
}
}
@Override
public void refreshSleepCountPolicy() {
for (Player player : concealedPlayers.values()) {
applySleepCountPolicy(player);
}
}
private void applySleepCountPolicy(Player player) {
if (sleepCountPolicy.get() == SleepCountPolicy.EXCLUDE) {
previousSleepingIgnored.computeIfAbsent(player.getUniqueId(), ignored -> player.isSleepingIgnored());
player.setSleepingIgnored(true);
} else {
restoreSleepingIgnored(player);
}
}
private void restoreSleepingIgnored(Player player) {
Boolean previous = previousSleepingIgnored.remove(player.getUniqueId());
if (previous != null) {
player.setSleepingIgnored(previous);
}
}
public static String teamName(UUID playerId) {
return "stlth" + playerId.toString().replace("-", "").substring(0, 11);
}
}
@@ -0,0 +1,35 @@
package games.dmg.spigotstealth;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.entity.Player;
/** Marshals unlock presentation onto the server thread. */
public final class BukkitUnlockNotifier implements Consumer<UUID> {
private final Function<UUID, Player> playerLookup;
private final Consumer<Runnable> mainThread;
private final String chatMessage;
public BukkitUnlockNotifier(
Function<UUID, Player> playerLookup,
Consumer<Runnable> mainThread,
String chatMessage) {
this.playerLookup = Objects.requireNonNull(playerLookup, "playerLookup");
this.mainThread = Objects.requireNonNull(mainThread, "mainThread");
this.chatMessage = Objects.requireNonNull(chatMessage, "chatMessage");
}
@Override
public void accept(UUID playerId) {
mainThread.accept(() -> {
Player player = playerLookup.apply(playerId);
if (player == null || !player.isOnline()) {
return;
}
player.sendTitle("Stealth Unlocked", "Your identity can now be concealed", 10, 100, 20);
player.sendMessage(chatMessage);
});
}
}
@@ -0,0 +1,46 @@
package games.dmg.spigotstealth;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.lang.reflect.RecordComponent;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.function.UnaryOperator;
/** Small reflective bridge: avoids a compile/runtime dependency on a specific NMS or Brigadier version. */
final class IdentityPacketFields {
private IdentityPacketFields() { }
static List<?> filterEntries(List<?> entries, StealthChatMasker masker) throws ReflectiveOperationException {
List<Object> filtered = new ArrayList<>();
for (Object entry : entries) {
Method text;
try {
text = entry.getClass().getMethod("text");
} catch (NoSuchMethodException exception) {
text = entry.getClass().getMethod("getText");
}
if (!masker.isConcealedName((String) text.invoke(entry))) {
filtered.add(entry);
}
}
return List.copyOf(filtered);
}
static Object mapRecord(Object record, UnaryOperator<Object> mapper) throws ReflectiveOperationException {
RecordComponent[] components = record.getClass().getRecordComponents();
if (components == null) {
throw new IllegalArgumentException("Unsupported non-record chat binding");
}
Class<?>[] types = new Class<?>[components.length];
Object[] values = new Object[components.length];
for (int i = 0; i < components.length; i++) {
types[i] = components[i].getType();
Object value = components[i].getAccessor().invoke(record);
values[i] = value instanceof Optional<?> optional ? optional.map(mapper) : mapper.apply(value);
}
Constructor<?> constructor = record.getClass().getDeclaredConstructor(types);
return constructor.newInstance(values);
}
}
@@ -0,0 +1,14 @@
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);
void refreshSleepCountPolicy();
}
@@ -0,0 +1,52 @@
package games.dmg.spigotstealth;
import java.time.Clock;
import java.time.Instant;
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.entity.EntityPotionEffectEvent;
import org.bukkit.potion.PotionEffectType;
/** Attributes invisibility changes to directly consumed potions and closes intervals safely. */
public final class InvisibilityEffectListener implements Listener {
private final QualifyingInvisibilityService progression;
private final Clock clock;
public InvisibilityEffectListener(QualifyingInvisibilityService progression, Clock clock) {
this.progression = Objects.requireNonNull(progression, "progression");
this.clock = Objects.requireNonNull(clock, "clock");
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onPotionEffect(EntityPotionEffectEvent event) {
if (!(event.getEntity() instanceof Player player)) {
return;
}
handlePotionEffect(
player,
PotionEffectType.INVISIBILITY.equals(event.getModifiedType()),
event.getCause(),
event.getAction());
}
void handlePotionEffect(
Player player,
boolean invisibility,
EntityPotionEffectEvent.Cause cause,
EntityPotionEffectEvent.Action action) {
if (!invisibility) {
return;
}
boolean addedOrChanged = action == EntityPotionEffectEvent.Action.ADDED
|| action == EntityPotionEffectEvent.Action.CHANGED;
if (cause == EntityPotionEffectEvent.Cause.POTION_DRINK && addedOrChanged) {
progression.begin(player.getUniqueId(), player.getName(), Instant.now(clock));
} else {
progression.stop(player.getUniqueId());
}
}
}
@@ -0,0 +1,75 @@
package games.dmg.spigotstealth;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Supplier;
import org.bukkit.entity.Player;
/** Resolves only already-known UUIDs and exact names without creating offline-player records. */
public final class KnownPlayerResolver {
private final Supplier<PersistentStealthState> state;
private final Supplier<? extends Collection<? extends Player>> onlinePlayers;
public KnownPlayerResolver(
Supplier<PersistentStealthState> state,
Supplier<? extends Collection<? extends Player>> onlinePlayers) {
this.state = Objects.requireNonNull(state, "state");
this.onlinePlayers = Objects.requireNonNull(onlinePlayers, "onlinePlayers");
}
public java.util.List<String> completeNames(String prefix) {
String normalized = prefix == null ? "" : prefix.toLowerCase(Locale.ROOT);
return knownPlayers().values().stream()
.map(KnownPlayer::name)
.filter(Objects::nonNull)
.distinct()
.filter(name -> name.toLowerCase(Locale.ROOT).startsWith(normalized))
.sorted(String.CASE_INSENSITIVE_ORDER)
.toList();
}
public Resolution resolve(String query) {
if (query == null || query.isBlank()) {
return new Resolution(Status.UNKNOWN, null);
}
Map<UUID, KnownPlayer> known = knownPlayers();
try {
UUID playerId = UUID.fromString(query);
KnownPlayer player = known.get(playerId);
return player == null ? new Resolution(Status.UNKNOWN, null) : new Resolution(Status.FOUND, player);
} catch (IllegalArgumentException ignored) {
// Continue with exact case-insensitive name resolution.
}
String normalized = query.toLowerCase(Locale.ROOT);
java.util.List<KnownPlayer> matches = known.values().stream()
.filter(player -> player.name() != null && player.name().toLowerCase(Locale.ROOT).equals(normalized))
.toList();
if (matches.isEmpty()) {
return new Resolution(Status.UNKNOWN, null);
}
if (matches.size() > 1) {
return new Resolution(Status.AMBIGUOUS, null);
}
return new Resolution(Status.FOUND, matches.get(0));
}
private Map<UUID, KnownPlayer> knownPlayers() {
Map<UUID, KnownPlayer> known = new LinkedHashMap<>();
state.get().players().forEach((playerId, playerState) ->
known.put(playerId, new KnownPlayer(playerId, playerState.lastKnownName(), null)));
for (Player player : onlinePlayers.get()) {
known.put(player.getUniqueId(), new KnownPlayer(player.getUniqueId(), player.getName(), player));
}
return known;
}
public enum Status { FOUND, UNKNOWN, AMBIGUOUS }
public record KnownPlayer(UUID playerId, String name, Player onlinePlayer) { }
public record Resolution(Status status, KnownPlayer player) { }
}
@@ -8,12 +8,20 @@ import java.util.UUID;
/** Immutable snapshot of all durable plugin state. */
public record PersistentStealthState(
Map<UUID, PlayerStealthState> players,
SleepCountPolicy sleepCountPolicy,
Map<String, Object> unknownFields) {
public PersistentStealthState {
players = Map.copyOf(new LinkedHashMap<>(Objects.requireNonNull(players, "players")));
Objects.requireNonNull(sleepCountPolicy, "sleepCountPolicy");
unknownFields = Map.copyOf(new LinkedHashMap<>(Objects.requireNonNull(unknownFields, "unknownFields")));
}
public PersistentStealthState(
Map<UUID, PlayerStealthState> players,
Map<String, Object> unknownFields) {
this(players, SleepCountPolicy.EXCLUDE, unknownFields);
}
public PlayerStealthState player(UUID playerId) {
return players.getOrDefault(playerId, PlayerStealthState.empty(playerId));
}
@@ -21,6 +29,10 @@ public record PersistentStealthState(
public PersistentStealthState withPlayer(PlayerStealthState player) {
Map<UUID, PlayerStealthState> updated = new LinkedHashMap<>(players);
updated.put(player.playerId(), player);
return new PersistentStealthState(updated, unknownFields);
return new PersistentStealthState(updated, sleepCountPolicy, unknownFields);
}
public PersistentStealthState withSleepCountPolicy(SleepCountPolicy policy) {
return new PersistentStealthState(players, policy, unknownFields);
}
}
@@ -0,0 +1,136 @@
package games.dmg.spigotstealth;
import com.comphenix.protocol.PacketType;
import com.comphenix.protocol.events.ListenerPriority;
import com.comphenix.protocol.events.PacketAdapter;
import com.comphenix.protocol.events.PacketContainer;
import com.comphenix.protocol.events.PacketEvent;
import com.comphenix.protocol.reflect.StructureModifier;
import com.comphenix.protocol.utility.MinecraftReflection;
import com.comphenix.protocol.wrappers.WrappedChatComponent;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import java.util.function.Supplier;
import org.bukkit.plugin.Plugin;
/**
* Outgoing identity-only masking. Registration is deliberately left to the plugin.
* The supplier must return an immutable, safely published snapshot (for example AtomicReference::get).
* It must not inspect Bukkit players or mutable session collections on the packet thread.
*/
public final class ProtocolLibIdentityMaskingListener extends PacketAdapter {
private final Supplier<Set<String>> concealedNames;
private final Consumer<String> warningLog;
private final Set<PacketType> warned = ConcurrentHashMap.newKeySet();
public ProtocolLibIdentityMaskingListener(
Plugin plugin, Supplier<Set<String>> concealedNames, Consumer<String> warningLog) {
// ProtocolLib calls the vanilla PLAYER_CHAT packet CHAT.
super(plugin, ListenerPriority.HIGHEST, PacketType.Play.Server.TAB_COMPLETE,
PacketType.Play.Server.SYSTEM_CHAT, PacketType.Play.Server.CHAT,
PacketType.Play.Server.DISGUISED_CHAT);
this.concealedNames = Objects.requireNonNull(concealedNames, "concealedNames");
this.warningLog = Objects.requireNonNull(warningLog, "warningLog");
}
@Override
public void onPacketSending(PacketEvent event) {
if (event.isCancelled()) {
return;
}
try {
Set<String> snapshot = concealedNames.get();
if (snapshot.isEmpty()) {
return;
}
StealthChatMasker masker = new StealthChatMasker(snapshot);
PacketContainer packet = event.getPacket().shallowClone();
if (event.getPacketType().equals(PacketType.Play.Server.TAB_COMPLETE)) {
filterSuggestions(packet, masker);
} else if (event.getPacketType().equals(PacketType.Play.Server.SYSTEM_CHAT)) {
maskSystemChat(packet, masker);
} else {
maskBoundNames(packet, masker);
}
event.setPacket(packet);
} catch (ReflectiveOperationException | RuntimeException exception) {
// Keep delivery usable; do not cancel signed chat and break the acknowledgement chain.
if (warned.add(event.getPacketType())) {
warningLog.accept("Stealth identity masking unavailable for " + event.getPacketType()
+ "; original packet retained. Failure: " + exception.getClass().getSimpleName());
}
}
}
private static void filterSuggestions(PacketContainer packet, StealthChatMasker masker)
throws ReflectiveOperationException {
StructureModifier<Object> fields = packet.getModifier();
for (int i = 0; i < fields.size(); i++) {
Object value = fields.read(i);
if (value instanceof List<?> entries) {
// Modern ClientboundCommandSuggestionsPacket stores its Entry list directly.
fields.write(i, IdentityPacketFields.filterEntries(entries, masker));
return;
}
if (value != null && value.getClass().getName().equals("com.mojang.brigadier.suggestion.Suggestions")) {
Object range = value.getClass().getMethod("getRange").invoke(value);
List<?> entries = (List<?>) value.getClass().getMethod("getList").invoke(value);
List<?> filtered = IdentityPacketFields.filterEntries(entries, masker);
fields.write(i, value.getClass().getConstructor(range.getClass(), List.class).newInstance(range, filtered));
return;
}
}
// Pre-Brigadier protocol compatibility.
StructureModifier<String[]> arrays = packet.getStringArrays();
if (arrays.size() > 0) {
arrays.write(0, java.util.Arrays.stream(arrays.read(0))
.filter(name -> !masker.isConcealedName(name)).toArray(String[]::new));
return;
}
throw new IllegalStateException("Unsupported command suggestions layout");
}
private static void maskSystemChat(PacketContainer packet, StealthChatMasker masker) {
StructureModifier<WrappedChatComponent> components = packet.getChatComponents();
if (components.size() > 0) {
WrappedChatComponent original = components.read(0);
String json = original.getJson();
String masked = masker.maskAnnouncement(json);
if (!json.equals(masked)) {
components.write(0, WrappedChatComponent.fromJson(masked));
}
} else if (packet.getStrings().size() > 0) {
// Older SYSTEM_CHAT represents its component as JSON text.
String json = packet.getStrings().read(0);
packet.getStrings().write(0, masker.maskAnnouncement(json));
} else {
throw new IllegalStateException("Unsupported system chat layout");
}
}
private static void maskBoundNames(PacketContainer packet, StealthChatMasker masker)
throws ReflectiveOperationException {
StructureModifier<Object> fields = packet.getModifier();
for (int i = 0; i < fields.size(); i++) {
Object value = fields.read(i);
if (value != null && (value.getClass().getName().endsWith("ChatType$BoundNetwork")
|| value.getClass().getName().endsWith("ChatType$Bound"))) {
Object masked = IdentityPacketFields.mapRecord(value, field -> {
if (field != null && MinecraftReflection.getIChatBaseComponentClass().isInstance(field)) {
String json = WrappedChatComponent.fromHandle(field).getJson();
String replacement = masker.maskDisplayName(json);
return json.equals(replacement) ? field : WrappedChatComponent.fromJson(replacement).getHandle();
}
return field;
});
fields.write(i, masked);
return;
}
}
// Intentionally never touch signed body, unsigned body, UUID, signature, or filter mask.
throw new IllegalStateException("Unsupported player chat display-name layout");
}
}
@@ -0,0 +1,37 @@
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.WrappedServerPing;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.function.Supplier;
import org.bukkit.plugin.Plugin;
/** Adjusts only the count in outgoing server-list responses through ProtocolLib. */
public final class ProtocolLibServerListPingListener extends PacketAdapter {
private final Supplier<Set<UUID>> concealedPlayerIds;
private final ServerPingCompatibilityGuard compatibilityGuard;
public ProtocolLibServerListPingListener(
Plugin plugin,
Supplier<Set<UUID>> concealedPlayerIds,
Consumer<String> warningLog) {
super(plugin, PacketType.Status.Server.SERVER_INFO);
this.concealedPlayerIds = Objects.requireNonNull(concealedPlayerIds, "concealedPlayerIds");
this.compatibilityGuard = new ServerPingCompatibilityGuard(warningLog);
}
@Override
public void onPacketSending(PacketEvent event) {
compatibilityGuard.run(() -> {
WrappedServerPing ping = event.getPacket().getServerPings().read(0);
int visiblePlayers = ServerListPingVisibility.visibleOnlineCount(
ping.getPlayersOnline(), concealedPlayerIds.get().size());
ping.setPlayersOnline(visiblePlayers);
});
}
}
@@ -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);
}
}
@@ -0,0 +1,122 @@
package games.dmg.spigotstealth;
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
import java.util.function.LongSupplier;
/** Accumulates qualifying potion invisibility using monotonic elapsed time. */
public final class QualifyingInvisibilityService {
private final StealthStateManager stateManager;
private final long unlockThresholdMillis;
private final LongSupplier nanoTime;
private final Consumer<UUID> unlockNotifier;
private final Map<UUID, ActiveInterval> activeIntervals = new HashMap<>();
public QualifyingInvisibilityService(
StealthStateManager stateManager,
Duration unlockThreshold,
LongSupplier nanoTime,
Consumer<UUID> unlockNotifier) {
this.stateManager = Objects.requireNonNull(stateManager, "stateManager");
this.unlockThresholdMillis = Objects.requireNonNull(unlockThreshold, "unlockThreshold").toMillis();
this.nanoTime = Objects.requireNonNull(nanoTime, "nanoTime");
this.unlockNotifier = Objects.requireNonNull(unlockNotifier, "unlockNotifier");
}
public CompletableFuture<Void> begin(UUID playerId, String playerName, Instant startedAt) {
Objects.requireNonNull(playerId, "playerId");
Objects.requireNonNull(startedAt, "startedAt");
long nowNanos = nanoTime.getAsLong();
ActiveInterval previous = activeIntervals.put(playerId, new ActiveInterval(nowNanos, startedAt, playerName));
return record(playerId, playerName, elapsedMillis(previous, nowNanos), startedAt, true);
}
public CompletableFuture<Void> checkpoint(UUID playerId) {
long nowNanos = nanoTime.getAsLong();
ActiveInterval previous = activeIntervals.get(playerId);
if (previous == null) {
return CompletableFuture.completedFuture(null);
}
long elapsedMillis = elapsedMillis(previous, nowNanos);
Instant continuedAt = previous.startedAt().plusMillis(elapsedMillis);
activeIntervals.put(playerId, new ActiveInterval(nowNanos, continuedAt, previous.playerName()));
return record(playerId, previous.playerName(), elapsedMillis, continuedAt, true);
}
public CompletableFuture<Void> stop(UUID playerId) {
long nowNanos = nanoTime.getAsLong();
ActiveInterval previous = activeIntervals.remove(playerId);
if (previous == null) {
return CompletableFuture.completedFuture(null);
}
return record(playerId, previous.playerName(), elapsedMillis(previous, nowNanos), null, false);
}
public boolean isQualifying(UUID playerId) {
return activeIntervals.containsKey(playerId);
}
public Set<UUID> activePlayerIds() {
return Set.copyOf(activeIntervals.keySet());
}
public CompletableFuture<Void> stopAll() {
CompletableFuture<?>[] stops = activePlayerIds().stream()
.map(this::stop)
.toArray(CompletableFuture[]::new);
return CompletableFuture.allOf(stops);
}
public long currentAccumulatedMillis(UUID playerId) {
long persisted = stateManager.snapshot().player(playerId).accumulatedMillis();
return Math.addExact(persisted, elapsedMillis(activeIntervals.get(playerId), nanoTime.getAsLong()));
}
private CompletableFuture<Void> record(
UUID playerId,
String playerName,
long elapsedMillis,
Instant qualifyingSince,
boolean active) {
AtomicBoolean unlockedNow = new AtomicBoolean();
CompletableFuture<Void> saved = stateManager.update(state -> {
PlayerStealthState player = state.player(playerId);
long accumulated = Math.addExact(player.accumulatedMillis(), elapsedMillis);
boolean unlocked = player.unlocked() || accumulated >= unlockThresholdMillis;
unlockedNow.set(unlocked && !player.unlocked());
PlayerStealthState updated = new PlayerStealthState(
playerId,
playerName,
accumulated,
unlocked,
player.preparedLogin(),
player.concealed(),
active ? qualifyingSince : null,
player.unknownFields());
return state.withPlayer(updated);
});
return saved.thenRun(() -> {
if (unlockedNow.get()) {
unlockNotifier.accept(playerId);
}
});
}
private static long elapsedMillis(ActiveInterval interval, long nowNanos) {
if (interval == null) {
return 0L;
}
long elapsedNanos = Math.max(0L, nowNanos - interval.startedNanos());
return Duration.ofNanos(elapsedNanos).toMillis();
}
private record ActiveInterval(long startedNanos, Instant startedAt, String playerName) { }
}
@@ -0,0 +1,32 @@
package games.dmg.spigotstealth;
import java.util.Iterator;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.function.Supplier;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.server.ServerListPingEvent;
/** Filters concealed players through Bukkit's native server-list sample representation. */
public final class ServerListPingListener implements Listener {
private final Supplier<Set<UUID>> concealedPlayerIds;
public ServerListPingListener(Supplier<Set<UUID>> concealedPlayerIds) {
this.concealedPlayerIds = Objects.requireNonNull(concealedPlayerIds, "concealedPlayerIds");
}
@EventHandler(priority = EventPriority.HIGHEST)
public void onServerListPing(ServerListPingEvent event) {
Set<UUID> concealed = concealedPlayerIds.get();
Iterator<Player> sample = event.iterator();
while (sample.hasNext()) {
if (concealed.contains(sample.next().getUniqueId())) {
sample.remove();
}
}
}
}
@@ -0,0 +1,37 @@
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(
visibleOnlineCount(playersOnline, concealedPlayerIds.size()),
playersMaximum,
visibleSample);
}
public static int visibleOnlineCount(int playersOnline, int concealedPlayers) {
return Math.max(0, playersOnline - concealedPlayers);
}
public record Snapshot(int playersOnline, int playersMaximum, List<UUID> samplePlayerIds) {
public Snapshot {
samplePlayerIds = List.copyOf(samplePlayerIds);
}
}
}
@@ -0,0 +1,26 @@
package games.dmg.spigotstealth;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
/** Keeps an incompatible server-ping adapter from breaking responses or flooding logs. */
public final class ServerPingCompatibilityGuard {
private final Consumer<String> warningLog;
private final AtomicBoolean warningLogged = new AtomicBoolean();
public ServerPingCompatibilityGuard(Consumer<String> warningLog) {
this.warningLog = Objects.requireNonNull(warningLog, "warningLog");
}
public void run(Runnable edit) {
try {
edit.run();
} catch (RuntimeException exception) {
if (warningLogged.compareAndSet(false, true)) {
warningLog.accept("Unable to adjust the public server-list player count; "
+ "the original response will be used: " + exception.getMessage());
}
}
}
}
@@ -0,0 +1,24 @@
package games.dmg.spigotstealth;
import java.util.Locale;
/** Whether concealed players participate in the server's sleep percentage. */
public enum SleepCountPolicy {
INCLUDE,
EXCLUDE;
public static SleepCountPolicy fromPersisted(Object value) {
if (value instanceof String text) {
try {
return valueOf(text.toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException ignored) {
// Invalid values use the privacy-preserving default.
}
}
return EXCLUDE;
}
public String persistedValue() {
return name().toLowerCase(Locale.ROOT);
}
}
@@ -1,6 +1,10 @@
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;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import org.bukkit.plugin.java.JavaPlugin;
@@ -9,6 +13,10 @@ import org.bukkit.plugin.java.JavaPlugin;
public final class SpigotStealthPlugin extends JavaPlugin {
private CompletableFuture<StealthStateManager> stateManagerFuture;
private StealthSettings settings;
private QualifyingInvisibilityService progression;
private StealthSessionService sessions;
private IdentityPresentation identityPresentation;
private ProtocolManager protocolManager;
@Override
public void onEnable() {
@@ -35,6 +43,20 @@ 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())) {
identityPresentation.reveal(player);
sessions.endConcealment(player.getUniqueId());
}
}
}
if (progression != null) {
progression.stopAll().join();
}
if (stateManagerFuture != null && stateManagerFuture.isDone() && !stateManagerFuture.isCompletedExceptionally()) {
stateManagerFuture.join().close();
}
@@ -45,11 +67,71 @@ public final class SpigotStealthPlugin extends JavaPlugin {
}
private void finishInitialization(StealthStateManager manager) {
getServer().getScheduler().runTaskTimer(this, ignored -> manager.save().exceptionally(failure -> {
java.util.function.Consumer<Runnable> mainThread =
runnable -> getServer().getScheduler().runTask(this, runnable);
BukkitUnlockNotifier notifier = new BukkitUnlockNotifier(
getServer()::getPlayer,
mainThread,
settings.unlockedMessage());
progression = new QualifyingInvisibilityService(
manager, settings.unlockThreshold(), System::nanoTime, notifier);
sessions = new StealthSessionService(manager, progression);
protocolManager = ProtocolLibrary.getProtocolManager();
BukkitIdentityPresentation bukkitPresentation = new BukkitIdentityPresentation(
getServer()::getOnlinePlayers,
Objects.requireNonNull(getServer().getScoreboardManager(), "scoreboard manager").getMainScoreboard(),
new ProtocolLibTabListController(protocolManager),
() -> manager.snapshot().sleepCountPolicy());
identityPresentation = bukkitPresentation;
protocolManager.addPacketListener(new ProtocolLibIdentityMaskingListener(
this, bukkitPresentation::concealedNames, getLogger()::warning));
protocolManager.addPacketListener(new ProtocolLibServerListPingListener(
this, sessions::concealedPlayerIds, getLogger()::warning));
getServer().getPluginManager().registerEvents(
new ServerListPingListener(sessions::concealedPlayerIds), this);
getServer().getPluginManager().registerEvents(
new InvisibilityEffectListener(progression, Clock.systemUTC()), this);
getServer().getPluginManager().registerEvents(
new StealthSessionListener(sessions, identityPresentation, settings.concealedMessage()), this);
getServer().getPluginManager().registerEvents(
new StealthAdminJoinListener(sessions, getServer()::getOnlinePlayers), this);
org.bukkit.command.PluginCommand stealthPluginCommand =
Objects.requireNonNull(getCommand("stealth"), "stealth command");
StealthCommand stealthCommand = new StealthCommand(manager, progression, settings);
stealthPluginCommand.setExecutor(stealthCommand);
stealthPluginCommand.setTabCompleter(stealthCommand);
StealthAdministrationService administration = new StealthAdministrationService(
manager, progression, identityPresentation, notifier, getServer()::getOnlinePlayers);
KnownPlayerResolver resolver = new KnownPlayerResolver(manager::snapshot, getServer()::getOnlinePlayers);
org.bukkit.command.PluginCommand stealthAdminPluginCommand =
Objects.requireNonNull(getCommand("stealthadmin"), "stealthadmin command");
StealthAdminCommand stealthAdminCommand = new StealthAdminCommand(
administration,
resolver,
mainThread,
getLogger()::info,
settings.unlockThreshold());
stealthAdminPluginCommand.setExecutor(stealthAdminCommand);
stealthAdminPluginCommand.setTabCompleter(stealthAdminCommand);
getServer().getScheduler().runTaskTimer(this, ignored -> {
for (java.util.UUID playerId : progression.activePlayerIds()) {
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");
}
private void logSaveFailure(CompletableFuture<Void> save) {
save.exceptionally(failure -> {
getLogger().severe("Unable to save Spigot Stealth state: " + rootMessage(failure));
return null;
}), 6000L, 6000L);
getLogger().info("Spigot Stealth enabled");
});
}
private static String rootMessage(Throwable throwable) {
@@ -0,0 +1,224 @@
package games.dmg.spigotstealth;
import java.time.Duration;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
/** Permission-gated administrative command for online and known offline players. */
public final class StealthAdminCommand implements CommandExecutor, TabCompleter {
private static final String PERMISSION = "spigotstealth.admin";
private static final List<String> OPERATIONS = List.of("status", "grant", "reset", "list", "sleepcount");
private final StealthAdministrationService administration;
private final KnownPlayerResolver resolver;
private final Consumer<Runnable> mainThread;
private final Consumer<String> auditLog;
private final Duration unlockThreshold;
public StealthAdminCommand(
StealthAdministrationService administration,
KnownPlayerResolver resolver,
Consumer<Runnable> mainThread,
Consumer<String> auditLog,
Duration unlockThreshold) {
this.administration = Objects.requireNonNull(administration, "administration");
this.resolver = Objects.requireNonNull(resolver, "resolver");
this.mainThread = Objects.requireNonNull(mainThread, "mainThread");
this.auditLog = Objects.requireNonNull(auditLog, "auditLog");
this.unlockThreshold = Objects.requireNonNull(unlockThreshold, "unlockThreshold");
}
@Override
public List<String> onTabComplete(
CommandSender sender, Command command, String label, String[] arguments) {
if (!sender.hasPermission(PERMISSION)) {
return List.of();
}
if (arguments.length == 1) {
return matching(OPERATIONS, arguments[0]);
}
String operation = arguments[0].toLowerCase(Locale.ROOT);
if (arguments.length == 2) {
if (List.of("status", "grant", "reset").contains(operation)) {
return resolver.completeNames(arguments[1]);
}
if ("list".equals(operation)) {
return matching(List.of("unlocked"), arguments[1]);
}
if ("sleepcount".equals(operation)) {
return matching(List.of("status", "include", "exclude"), arguments[1]);
}
}
if (arguments.length == 3 && "reset".equals(operation)) {
return matching(List.of("confirm"), arguments[2]);
}
return List.of();
}
private static List<String> matching(List<String> candidates, String prefix) {
String normalized = prefix.toLowerCase(Locale.ROOT);
return candidates.stream().filter(candidate -> candidate.startsWith(normalized)).toList();
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (!sender.hasPermission(PERMISSION)) {
sender.sendMessage("You do not have permission to administer Spigot Stealth.");
return true;
}
if (arguments.length > 0 && "sleepcount".equalsIgnoreCase(arguments[0])) {
return sleepCount(sender, label, arguments);
}
if (arguments.length > 0 && "list".equalsIgnoreCase(arguments[0])) {
if (arguments.length == 1) {
List<String> names = administration.concealedOnlineNames();
sender.sendMessage(names.isEmpty()
? "No players are currently concealed."
: "Currently concealed: " + String.join(", ", names));
return true;
}
if (arguments.length == 2 && "unlocked".equalsIgnoreCase(arguments[1])) {
List<String> names = administration.unlockedPlayerNames();
sender.sendMessage(names.isEmpty()
? "No players have unlocked stealth."
: "Stealth unlocked: " + String.join(", ", names));
return true;
}
sendUsage(sender, label);
return true;
}
if (arguments.length < 2) {
sendUsage(sender, label);
return true;
}
String operation = arguments[0].toLowerCase(Locale.ROOT);
KnownPlayerResolver.Resolution resolution = resolver.resolve(arguments[1]);
if (resolution.status() != KnownPlayerResolver.Status.FOUND) {
sender.sendMessage(resolution.status() == KnownPlayerResolver.Status.AMBIGUOUS
? "That player name is ambiguous; use a UUID."
: "Unknown player. Use an exact known name or UUID.");
return true;
}
KnownPlayerResolver.KnownPlayer target = resolution.player();
return switch (operation) {
case "status" -> status(sender, target);
case "grant" -> grant(sender, target);
case "reset" -> reset(sender, label, arguments, target);
default -> {
sendUsage(sender, label);
yield true;
}
};
}
private boolean sleepCount(CommandSender sender, String label, String[] arguments) {
if (arguments.length != 2) {
sendUsage(sender, label);
return true;
}
String action = arguments[1].toLowerCase(Locale.ROOT);
if ("status".equals(action)) {
sender.sendMessage("Concealed players are "
+ policyDescription(administration.sleepCountPolicy())
+ " sleep-percentage calculations.");
return true;
}
SleepCountPolicy policy = switch (action) {
case "include" -> SleepCountPolicy.INCLUDE;
case "exclude" -> SleepCountPolicy.EXCLUDE;
default -> null;
};
if (policy == null) {
sendUsage(sender, label);
return true;
}
administration.setSleepCountPolicy(policy).whenComplete((result, failure) -> mainThread.accept(() -> {
if (failure != null) {
sender.sendMessage("Unable to persist the sleep-count policy; check the server log.");
return;
}
administration.refreshSleepCountPolicy();
sender.sendMessage(result.changed()
? "Concealed players are now " + policyDescription(policy)
+ " sleep-percentage calculations."
: "Concealed players are already " + policyDescription(policy)
+ " sleep-percentage calculations.");
auditLog.accept("stealthadmin sleepcount administrator=" + sender.getName()
+ " policy=" + policy.persistedValue() + " changed=" + result.changed());
}));
return true;
}
private boolean status(CommandSender sender, KnownPlayerResolver.KnownPlayer target) {
StealthAdministrationService.PlayerStatus status = administration.status(target.playerId());
sender.sendMessage("Stealth status for " + displayName(target) + " (" + target.playerId() + "):");
sender.sendMessage("progress=" + StealthProgressFormatter.duration(status.accumulatedMillis())
+ "/" + StealthProgressFormatter.duration(unlockThreshold.toMillis())
+ " unlocked=" + yesNo(status.unlocked())
+ " prepared=" + yesNo(status.preparedLogin())
+ " concealed=" + yesNo(status.concealed())
+ " qualifying=" + yesNo(status.qualifyingNow()));
return true;
}
private boolean grant(CommandSender sender, KnownPlayerResolver.KnownPlayer target) {
CompletableFuture<StealthAdministrationService.ChangeResult> grant =
administration.grant(target.playerId(), target.name());
grant.whenComplete((result, failure) -> mainThread.accept(() -> {
if (failure != null) {
sender.sendMessage("Unable to persist the stealth grant; check the server log.");
return;
}
sender.sendMessage(result.changed()
? "Stealth unlock granted to " + displayName(target) + "."
: displayName(target) + " already has the stealth unlock.");
auditLog.accept("stealthadmin grant administrator=" + sender.getName()
+ " target=" + target.playerId() + " changed=" + result.changed());
}));
return true;
}
private boolean reset(
CommandSender sender,
String label,
String[] arguments,
KnownPlayerResolver.KnownPlayer target) {
if (arguments.length != 3 || !"confirm".equalsIgnoreCase(arguments[2])) {
sender.sendMessage("Confirm the complete reset with /" + label + " reset " + arguments[1] + " confirm");
return true;
}
administration.reset(target.playerId(), target.name(), target.onlinePlayer())
.whenComplete((ignored, failure) -> mainThread.accept(() -> {
if (failure != null) {
sender.sendMessage("Unable to persist the stealth reset; check the server log.");
return;
}
sender.sendMessage("Stealth reset complete for " + displayName(target) + ".");
auditLog.accept("stealthadmin reset administrator=" + sender.getName()
+ " target=" + target.playerId());
}));
return true;
}
private static void sendUsage(CommandSender sender, String label) {
sender.sendMessage("Usage: /" + label + " <status <player|uuid>|grant <player|uuid>|reset <player|uuid> confirm|list [unlocked]|sleepcount <status|include|exclude>>");
}
private static String displayName(KnownPlayerResolver.KnownPlayer player) {
return player.name() == null ? player.playerId().toString() : player.name();
}
private static String policyDescription(SleepCountPolicy policy) {
return policy == SleepCountPolicy.EXCLUDE ? "excluded from" : "included in";
}
private static String yesNo(boolean value) {
return value ? "yes" : "no";
}
}
@@ -0,0 +1,49 @@
package games.dmg.spigotstealth;
import java.util.Collection;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.function.Supplier;
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;
/** Private administrative notices after the HIGHEST-priority session transition. */
public final class StealthAdminJoinListener implements Listener {
private static final String PERMISSION = "spigotstealth.admin";
private static final String HINT = " Use /stealthadmin list to see who.";
private final StealthSessionService sessions;
private final Supplier<? extends Collection<? extends Player>> onlinePlayers;
public StealthAdminJoinListener(
StealthSessionService sessions,
Supplier<? extends Collection<? extends Player>> onlinePlayers) {
this.sessions = Objects.requireNonNull(sessions, "sessions");
this.onlinePlayers = Objects.requireNonNull(onlinePlayers, "onlinePlayers");
}
@EventHandler(priority = EventPriority.MONITOR)
public void onJoin(PlayerJoinEvent event) {
Player joining = event.getPlayer();
Set<UUID> concealed = sessions.concealedPlayerIds();
int count = concealed.size();
if (count == 0) {
return;
}
String quantity = count + (count == 1 ? " player is" : " players are");
if (joining.hasPermission(PERMISSION)) {
joining.sendMessage("[Stealth] " + quantity + " invisible." + HINT);
}
if (concealed.contains(joining.getUniqueId())) {
String notice = "[Stealth] An invisible player joined. " + quantity + " now invisible." + HINT;
for (Player observer : onlinePlayers.get()) {
if (!observer.getUniqueId().equals(joining.getUniqueId()) && observer.hasPermission(PERMISSION)) {
observer.sendMessage(notice);
}
}
}
}
}
@@ -0,0 +1,123 @@
package games.dmg.spigotstealth;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
import java.util.function.Supplier;
import org.bukkit.entity.Player;
/** Durable administrative operations over player stealth state. */
public final class StealthAdministrationService {
private final StealthStateManager stateManager;
private final QualifyingInvisibilityService progression;
private final IdentityPresentation presentation;
private final Consumer<UUID> unlockNotifier;
private final Supplier<? extends Collection<? extends Player>> onlinePlayers;
public StealthAdministrationService(
StealthStateManager stateManager,
QualifyingInvisibilityService progression,
IdentityPresentation presentation,
Consumer<UUID> unlockNotifier,
Supplier<? extends Collection<? extends Player>> onlinePlayers) {
this.stateManager = Objects.requireNonNull(stateManager, "stateManager");
this.progression = Objects.requireNonNull(progression, "progression");
this.presentation = Objects.requireNonNull(presentation, "presentation");
this.unlockNotifier = Objects.requireNonNull(unlockNotifier, "unlockNotifier");
this.onlinePlayers = Objects.requireNonNull(onlinePlayers, "onlinePlayers");
}
public SleepCountPolicy sleepCountPolicy() {
return stateManager.snapshot().sleepCountPolicy();
}
public CompletableFuture<ChangeResult> setSleepCountPolicy(SleepCountPolicy policy) {
Objects.requireNonNull(policy, "policy");
AtomicBoolean changed = new AtomicBoolean();
return stateManager.update(state -> {
if (state.sleepCountPolicy() == policy) {
return state;
}
changed.set(true);
return state.withSleepCountPolicy(policy);
}).thenApply(ignored -> new ChangeResult(changed.get()));
}
public void refreshSleepCountPolicy() {
presentation.refreshSleepCountPolicy();
}
public PlayerStatus status(UUID playerId) {
PlayerStealthState player = stateManager.snapshot().player(playerId);
return new PlayerStatus(
progression.currentAccumulatedMillis(playerId),
player.unlocked(),
player.preparedLogin(),
player.concealed(),
progression.isQualifying(playerId));
}
public CompletableFuture<ChangeResult> grant(UUID playerId, String playerName) {
AtomicBoolean changed = new AtomicBoolean();
CompletableFuture<Void> saved = stateManager.update(state -> {
PlayerStealthState player = state.player(playerId).withLastKnownName(playerName);
if (player.unlocked()) {
return state.withPlayer(player);
}
changed.set(true);
return state.withPlayer(player.withProgress(player.accumulatedMillis(), true));
});
return saved.thenApply(ignored -> {
if (changed.get()) {
unlockNotifier.accept(playerId);
}
return new ChangeResult(changed.get());
});
}
public CompletableFuture<Void> reset(UUID playerId, String playerName, Player onlinePlayer) {
CompletableFuture<Void> stopped = progression.stop(playerId);
CompletableFuture<Void> reset = stateManager.update(state -> {
PlayerStealthState player = state.player(playerId);
PlayerStealthState cleared = new PlayerStealthState(
playerId, playerName, 0L, false, false, false, null, player.unknownFields());
return state.withPlayer(cleared);
});
if (onlinePlayer != null) {
presentation.reveal(onlinePlayer);
}
return CompletableFuture.allOf(stopped, reset);
}
public List<String> unlockedPlayerNames() {
return stateManager.snapshot().players().entrySet().stream()
.filter(entry -> entry.getValue().unlocked())
.map(entry -> {
String name = entry.getValue().lastKnownName();
return name == null || name.isBlank() ? entry.getKey().toString() : name;
})
.sorted(String.CASE_INSENSITIVE_ORDER)
.toList();
}
public List<String> concealedOnlineNames() {
return onlinePlayers.get().stream()
.filter(player -> stateManager.snapshot().player(player.getUniqueId()).concealed())
.map(Player::getName)
.sorted(String.CASE_INSENSITIVE_ORDER)
.toList();
}
public record PlayerStatus(
long accumulatedMillis,
boolean unlocked,
boolean preparedLogin,
boolean concealed,
boolean qualifyingNow) { }
public record ChangeResult(boolean changed) { }
}
@@ -0,0 +1,117 @@
package games.dmg.spigotstealth;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonParser;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Collectors;
/** Masks semantic name slots, never searching/replacing user-authored message text. */
public final class StealthChatMasker {
private static final Set<String> NAME_FIRST_TRANSLATIONS = Set.of(
"chat.type.text", "chat.type.announcement", "chat.type.emote",
"commands.message.display.incoming", "commands.message.display.outgoing",
"chat.type.advancement.task", "chat.type.advancement.goal", "chat.type.advancement.challenge");
private final Set<String> names;
public StealthChatMasker(Set<String> concealedNames) {
names = concealedNames.stream().map(name -> name.toLowerCase(Locale.ROOT)).collect(Collectors.toUnmodifiableSet());
}
public boolean isConcealedName(String text) {
return names.contains(text.toLowerCase(Locale.ROOT));
}
public String maskAnnouncement(String json) {
try {
JsonElement component = JsonParser.parseString(json);
return maskAnnouncement(component) ? component.toString() : json;
} catch (JsonParseException | IllegalStateException exception) {
return json;
}
}
public String maskDisplayName(String json) {
try {
return isConcealedName(plainName(JsonParser.parseString(json))) ? anonymous().toString() : json;
} catch (JsonParseException | IllegalStateException exception) {
return json;
}
}
private boolean maskAnnouncement(JsonElement component) {
boolean changed = false;
if (component.isJsonArray()) {
for (JsonElement child : component.getAsJsonArray()) {
changed |= maskAnnouncement(child);
}
if (changed && !component.getAsJsonArray().isEmpty()) {
clearInheritedActions(component.getAsJsonArray().get(0));
}
} else if (component.isJsonObject()) {
JsonObject object = component.getAsJsonObject();
// Translation arguments other than the explicitly known name slot are message content.
if (object.has("translate") && object.get("translate").isJsonPrimitive()
&& NAME_FIRST_TRANSLATIONS.contains(object.get("translate").getAsString())
&& object.has("with") && object.get("with").isJsonArray()) {
JsonArray arguments = object.getAsJsonArray("with");
if (!arguments.isEmpty() && isConcealedName(plainName(arguments.get(0)))) {
arguments.set(0, anonymous());
changed = true;
}
}
if (object.has("extra")) {
changed |= maskAnnouncement(object.get("extra"));
}
// Prevent masked descendants from inheriting an identifying action from their parent.
if (changed) {
clearInheritedActions(object);
}
}
return changed;
}
private static void clearInheritedActions(JsonElement component) {
if (component.isJsonObject()) {
for (String key : Set.of("hoverEvent", "clickEvent", "hover_event", "click_event", "insertion")) {
component.getAsJsonObject().remove(key);
}
} else if (component.isJsonArray() && !component.getAsJsonArray().isEmpty()) {
clearInheritedActions(component.getAsJsonArray().get(0));
}
}
private static String plainName(JsonElement component) {
if (component.isJsonPrimitive()) {
return component.getAsString();
}
StringBuilder text = new StringBuilder();
if (component.isJsonArray()) {
for (JsonElement child : component.getAsJsonArray()) {
text.append(plainName(child));
}
} else if (component.isJsonObject()) {
JsonObject object = component.getAsJsonObject();
if (object.has("text") && object.get("text").isJsonPrimitive()) {
text.append(object.get("text").getAsString());
}
if (object.has("extra")) {
text.append(plainName(object.get("extra")));
}
}
return text.toString();
}
private static JsonObject anonymous() {
JsonObject alias = new JsonObject();
alias.addProperty("text", "Anonymous");
alias.addProperty("obfuscated", true);
alias.add("hoverEvent", com.google.gson.JsonNull.INSTANCE);
alias.add("clickEvent", com.google.gson.JsonNull.INSTANCE);
alias.addProperty("insertion", "");
return alias;
}
}
@@ -0,0 +1,62 @@
package games.dmg.spigotstealth;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player;
/** Player-facing progress command. */
public final class StealthCommand implements CommandExecutor, TabCompleter {
private final StealthStateManager stateManager;
private final QualifyingInvisibilityService progression;
private final StealthSettings settings;
public StealthCommand(
StealthStateManager stateManager,
QualifyingInvisibilityService progression,
StealthSettings settings) {
this.stateManager = Objects.requireNonNull(stateManager, "stateManager");
this.progression = Objects.requireNonNull(progression, "progression");
this.settings = Objects.requireNonNull(settings, "settings");
}
@Override
public List<String> onTabComplete(
CommandSender sender, Command command, String label, String[] arguments) {
if (arguments.length == 1 && "progress".startsWith(arguments[0].toLowerCase(Locale.ROOT))) {
return List.of("progress");
}
return List.of();
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (!(sender instanceof Player player)) {
sender.sendMessage("Only players can check personal stealth progress.");
return true;
}
if (arguments.length != 1 || !"progress".equals(arguments[0].toLowerCase(Locale.ROOT))) {
sender.sendMessage("Usage: /" + label + " progress");
return true;
}
PlayerStealthState state = stateManager.snapshot().player(player.getUniqueId());
long accumulated = progression.currentAccumulatedMillis(player.getUniqueId());
if (state.unlocked()) {
player.sendMessage("Stealth is unlocked.");
player.sendMessage("Drink an invisibility potion and log out while invisible to conceal your next login.");
return true;
}
long target = settings.unlockThreshold().toMillis();
long remaining = Math.max(0L, target - accumulated);
String message = settings.progressMessage()
.replace("{progress}", StealthProgressFormatter.duration(accumulated))
.replace("{target}", StealthProgressFormatter.duration(target))
.replace("{remaining}", StealthProgressFormatter.duration(remaining));
player.sendMessage(message);
return true;
}
}
@@ -0,0 +1,32 @@
package games.dmg.spigotstealth;
/** Human-readable duration and progress presentation. */
public final class StealthProgressFormatter {
private StealthProgressFormatter() { }
public static String duration(long milliseconds) {
long totalSeconds = Math.max(0L, milliseconds) / 1000L;
long hours = totalSeconds / 3600L;
long minutes = (totalSeconds % 3600L) / 60L;
long seconds = totalSeconds % 60L;
StringBuilder formatted = new StringBuilder();
if (hours > 0L) {
formatted.append(hours).append('h');
}
if (minutes > 0L) {
appendSpace(formatted);
formatted.append(minutes).append('m');
}
if (seconds > 0L || formatted.length() == 0) {
appendSpace(formatted);
formatted.append(seconds).append('s');
}
return formatted.toString();
}
private static void appendSpace(StringBuilder value) {
if (value.length() > 0) {
value.append(' ');
}
}
}
@@ -0,0 +1,57 @@
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.HIGHEST)
public void onQuit(PlayerQuitEvent event) {
Player player = event.getPlayer();
if (sessions.isConcealed(player.getUniqueId())) {
event.setQuitMessage(null);
}
presentation.reveal(player);
sessions.disconnect(player.getUniqueId());
}
@EventHandler(priority = EventPriority.MONITOR)
public void onRespawn(PlayerRespawnEvent event) {
if (sessions.isConcealed(event.getPlayer().getUniqueId())) {
presentation.conceal(event.getPlayer());
}
}
}
@@ -0,0 +1,69 @@
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;
/** Owns prepared-login and current concealed-session state transitions. */
public final class StealthSessionService {
private final StealthStateManager stateManager;
private final QualifyingInvisibilityService progression;
private final Set<UUID> concealedOnlinePlayerIds = ConcurrentHashMap.newKeySet();
public StealthSessionService(
StealthStateManager stateManager,
QualifyingInvisibilityService progression) {
this.stateManager = Objects.requireNonNull(stateManager, "stateManager");
this.progression = Objects.requireNonNull(progression, "progression");
}
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);
boolean prepareNextLogin = qualifyingAtDisconnect && player.unlocked();
return state.withPlayer(player.withSession(prepareNextLogin, false));
}));
}
public LoginTransition login(UUID playerId, String playerName) {
AtomicBoolean concealed = new AtomicBoolean();
CompletableFuture<Void> 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));
});
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));
});
}
public boolean isConcealed(UUID playerId) {
return stateManager.snapshot().player(playerId).concealed();
}
public Set<UUID> concealedPlayerIds() {
PersistentStealthState state = stateManager.snapshot();
return concealedOnlinePlayerIds.stream()
.filter(playerId -> state.player(playerId).concealed())
.collect(java.util.stream.Collectors.toUnmodifiableSet());
}
public record LoginTransition(boolean concealed, CompletableFuture<Void> saved) { }
}
@@ -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);
}
@@ -17,7 +17,7 @@ import org.bukkit.configuration.file.YamlConfiguration;
/** Defensive YAML repository using atomic file replacement where available. */
public final class YamlStealthStateRepository implements StealthStateRepository {
private static final Set<String> ROOT_FIELDS = Set.of("schema-version", "players");
private static final Set<String> ROOT_FIELDS = Set.of("schema-version", "sleep-count-policy", "players");
private static final Set<String> PLAYER_FIELDS = Set.of(
"last-known-name", "accumulated-millis", "unlocked", "prepared-login", "concealed", "qualifying-since");
private final Path stateFile;
@@ -53,7 +53,10 @@ public final class YamlStealthStateRepository implements StealthStateRepository
}
}
}
return new PersistentStealthState(players, unknownRoot);
return new PersistentStealthState(
players,
SleepCountPolicy.fromPersisted(yaml.get("sleep-count-policy")),
unknownRoot);
}
@Override
@@ -61,6 +64,7 @@ public final class YamlStealthStateRepository implements StealthStateRepository
YamlConfiguration yaml = new YamlConfiguration();
state.unknownFields().forEach(yaml::set);
yaml.set("schema-version", 1);
yaml.set("sleep-count-policy", state.sleepCountPolicy().persistedValue());
for (PlayerStealthState player : state.players().values()) {
String base = "players." + player.playerId() + ".";
player.unknownFields().forEach((key, value) -> yaml.set(base + key, value));
+2 -1
View File
@@ -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.
@@ -11,7 +12,7 @@ commands:
permission: spigotstealth.use
stealthadmin:
description: Inspect and administer Spigot Stealth.
usage: /stealthadmin <status|grant|reset|list>
usage: /stealthadmin <status <player|uuid>|grant <player|uuid>|reset <player|uuid> confirm|list [unlocked]|sleepcount <status|include|exclude>>
permission: spigotstealth.admin
permissions:
spigotstealth.use:
@@ -0,0 +1,128 @@
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 java.util.concurrent.atomic.AtomicReference;
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 excludedConcealmentIgnoresSleepCountAndRestoresPreviousState() {
UUID targetId = UUID.randomUUID();
Player target = player(targetId, "Alex");
when(target.isSleepingIgnored()).thenReturn(false);
Scoreboard scoreboard = mock(Scoreboard.class);
Team team = mock(Team.class);
when(scoreboard.getTeam(BukkitIdentityPresentation.teamName(targetId))).thenReturn(team);
BukkitIdentityPresentation presentation = new BukkitIdentityPresentation(
() -> List.of(target), scoreboard, mock(TabListController.class));
when(target.getDisplayName()).thenReturn("Original Alex");
presentation.conceal(target);
presentation.conceal(target);
presentation.reveal(target);
verify(target).setDisplayName("Original Alex");
org.junit.jupiter.api.Assertions.assertTrue(presentation.concealedNames().isEmpty());
verify(target, org.mockito.Mockito.times(2)).setSleepingIgnored(true);
verify(target).setSleepingIgnored(false);
}
@Test
void includedConcealmentDoesNotChangeOrdinarySleepParticipation() {
UUID targetId = UUID.randomUUID();
Player target = player(targetId, "Alex");
Scoreboard scoreboard = mock(Scoreboard.class);
Team team = mock(Team.class);
when(scoreboard.getTeam(BukkitIdentityPresentation.teamName(targetId))).thenReturn(team);
BukkitIdentityPresentation presentation = new BukkitIdentityPresentation(
() -> List.of(target),
scoreboard,
mock(TabListController.class),
() -> SleepCountPolicy.INCLUDE);
presentation.conceal(target);
verify(target, never()).setSleepingIgnored(org.mockito.ArgumentMatchers.anyBoolean());
}
@Test
void policyChangesImmediatelyRestoreAndReapplyConcealedSleepParticipation() {
UUID targetId = UUID.randomUUID();
Player target = player(targetId, "Alex");
when(target.isSleepingIgnored()).thenReturn(false);
Scoreboard scoreboard = mock(Scoreboard.class);
Team team = mock(Team.class);
when(scoreboard.getTeam(BukkitIdentityPresentation.teamName(targetId))).thenReturn(team);
AtomicReference<SleepCountPolicy> policy = new AtomicReference<>(SleepCountPolicy.EXCLUDE);
BukkitIdentityPresentation presentation = new BukkitIdentityPresentation(
() -> List.of(target), scoreboard, mock(TabListController.class), policy::get);
presentation.conceal(target);
policy.set(SleepCountPolicy.INCLUDE);
presentation.refreshSleepCountPolicy();
policy.set(SleepCountPolicy.EXCLUDE);
presentation.refreshSleepCountPolicy();
verify(target, org.mockito.Mockito.times(2)).setSleepingIgnored(true);
verify(target).setSleepingIgnored(false);
}
@Test
void concealRemovesTabEntryAndScramblesNameTagWithoutHidingEntity() {
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.ALWAYS);
verify(team).setPrefix("§k");
verify(team).setSuffix("§r");
verify(target).setDisplayName("§kAnonymous§r");
org.junit.jupiter.api.Assertions.assertEquals(java.util.Set.of("Alex"), presentation.concealedNames());
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;
}
}
@@ -0,0 +1,27 @@
package games.dmg.spigotstealth;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
class BukkitUnlockNotifierTest {
@Test
void sendsFullScreenTitleAndChatMessageToOnlinePlayer() {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
when(player.isOnline()).thenReturn(true);
Consumer<Runnable> immediateMainThread = Runnable::run;
BukkitUnlockNotifier notifier = new BukkitUnlockNotifier(
ignored -> player, immediateMainThread, "Stealth unlocked!");
notifier.accept(playerId);
verify(player).sendTitle("Stealth Unlocked", "Your identity can now be concealed", 10, 100, 20);
verify(player).sendMessage("Stealth unlocked!");
}
}
@@ -0,0 +1,32 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import org.junit.jupiter.api.Test;
class IdentityPacketFieldsTest {
private final StealthChatMasker masker = new StealthChatMasker(Set.of("Secret"));
record Entry(String text, Optional<String> tooltip) { }
record Bound(int chatType, String name, Optional<String> targetName) { }
@Test
void filtersModernSuggestionEntriesWithoutChangingOrdinaryEntries() throws ReflectiveOperationException {
Entry ordinary = new Entry("Someone", Optional.of("help"));
List<Entry> entries = List.of(new Entry("sEcReT", Optional.empty()), ordinary);
assertEquals(List.of(ordinary), IdentityPacketFields.filterEntries(entries, masker));
assertEquals(2, entries.size());
}
@Test
void rebuildsDisplayNameRecordWithoutMutatingOriginal() throws ReflectiveOperationException {
Bound original = new Bound(42, "Secret", Optional.of("Secret"));
Object copy = IdentityPacketFields.mapRecord(original,
value -> value.equals("Secret") ? "Anonymous" : value);
assertEquals(new Bound(42, "Anonymous", Optional.of("Anonymous")), copy);
assertEquals("Secret", original.name());
}
}
@@ -0,0 +1,68 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong;
import org.bukkit.entity.Player;
import org.bukkit.event.entity.EntityPotionEffectEvent;
import org.junit.jupiter.api.Test;
class InvisibilityEffectListenerTest {
@Test
void onlyPotionDrinkCauseStartsQualifyingInvisibility() {
UUID playerId = UUID.randomUUID();
AtomicLong nanos = new AtomicLong();
try (StealthStateManager manager = manager()) {
QualifyingInvisibilityService service = new QualifyingInvisibilityService(
manager, Duration.ofHours(8), nanos::get, ignored -> { });
InvisibilityEffectListener listener = new InvisibilityEffectListener(
service, Clock.fixed(Instant.EPOCH, ZoneOffset.UTC));
Player player = player(playerId);
listener.handlePotionEffect(player, true, EntityPotionEffectEvent.Cause.POTION_SPLASH, EntityPotionEffectEvent.Action.ADDED);
assertFalse(service.isQualifying(playerId));
listener.handlePotionEffect(player, true, EntityPotionEffectEvent.Cause.COMMAND, EntityPotionEffectEvent.Action.CHANGED);
assertFalse(service.isQualifying(playerId));
listener.handlePotionEffect(player, true, EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.ADDED);
assertTrue(service.isQualifying(playerId));
}
}
@Test
void anyLaterNonDrinkChangeStopsTheQualifyingInterval() {
UUID playerId = UUID.randomUUID();
try (StealthStateManager manager = manager()) {
QualifyingInvisibilityService service = new QualifyingInvisibilityService(
manager, Duration.ofHours(8), System::nanoTime, ignored -> { });
InvisibilityEffectListener listener = new InvisibilityEffectListener(service, Clock.systemUTC());
Player player = player(playerId);
listener.handlePotionEffect(player, true, EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.ADDED);
listener.handlePotionEffect(player, true, EntityPotionEffectEvent.Cause.PLUGIN, EntityPotionEffectEvent.Action.CHANGED);
assertFalse(service.isQualifying(playerId));
}
}
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 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()));
}
}
@@ -0,0 +1,33 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class KnownPlayerResolverTest {
@Test
void resolvesKnownOfflinePlayerByExactNameOrUuid() {
UUID playerId = UUID.randomUUID();
PlayerStealthState player = PlayerStealthState.empty(playerId).withLastKnownName("Alex");
PersistentStealthState state = new PersistentStealthState(Map.of(playerId, player), Map.of());
KnownPlayerResolver resolver = new KnownPlayerResolver(() -> state, List::of);
assertEquals(playerId, resolver.resolve("alex").player().playerId());
assertEquals(playerId, resolver.resolve(playerId.toString()).player().playerId());
}
@Test
void reportsAmbiguousAndUnknownNamesWithoutCreatingRecords() {
UUID first = UUID.randomUUID();
UUID second = UUID.randomUUID();
PersistentStealthState state = new PersistentStealthState(Map.of(
first, PlayerStealthState.empty(first).withLastKnownName("Alex"),
second, PlayerStealthState.empty(second).withLastKnownName("ALEX")), Map.of());
KnownPlayerResolver resolver = new KnownPlayerResolver(() -> state, List::of);
assertEquals(KnownPlayerResolver.Status.AMBIGUOUS, resolver.resolve("alex").status());
assertEquals(KnownPlayerResolver.Status.UNKNOWN, resolver.resolve("nobody").status());
assertEquals(2, state.players().size());
}
}
@@ -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);
}
@@ -0,0 +1,63 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import com.comphenix.protocol.PacketType;
import com.comphenix.protocol.events.PacketEvent;
import com.comphenix.protocol.utility.MinecraftReflection;
import com.comphenix.protocol.utility.MinecraftVersion;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
import org.bukkit.plugin.Plugin;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
class ProtocolLibIdentityMaskingListenerTest {
private static MockedStatic<MinecraftReflection> reflection;
@BeforeAll
static void initializeProtocolTypesWithoutServer() {
MinecraftVersion.setCurrentVersion(new MinecraftVersion(1, 21, 8));
reflection = mockStatic(MinecraftReflection.class);
}
@AfterAll
static void resetProtocolEnvironment() {
reflection.close();
MinecraftVersion.setCurrentVersion(null);
}
@Test
void skipsPacketWorkWhenNobodyIsConcealed() {
AtomicReference<Set<String>> names = new AtomicReference<>(Set.of());
var listener = new ProtocolLibIdentityMaskingListener(mock(Plugin.class), names::get, ignored -> { });
PacketEvent event = mock(PacketEvent.class);
listener.onPacketSending(event);
verify(event, never()).getPacket();
assertTrue(listener.getSendingWhitelist().getTypes().contains(PacketType.Play.Server.CHAT));
assertTrue(listener.getSendingWhitelist().getTypes().contains(PacketType.Play.Server.SYSTEM_CHAT));
}
@Test
void readsFreshSnapshotAndWarnsOnceWithoutCancellingOrReplacingFailedPacket() {
List<String> warnings = new ArrayList<>();
AtomicReference<Set<String>> names = new AtomicReference<>(Set.of());
var listener = new ProtocolLibIdentityMaskingListener(mock(Plugin.class), names::get, warnings::add);
PacketEvent event = mock(PacketEvent.class);
when(event.getPacketType()).thenReturn(PacketType.Play.Server.CHAT);
listener.onPacketSending(event);
assertTrue(warnings.isEmpty());
names.set(Set.of("Secret"));
// No real NMS packet: the clone attempt fails and must leave event delivery untouched.
listener.onPacketSending(event);
listener.onPacketSending(event);
assertEquals(1, warnings.size());
verify(event, never()).setPacket(any());
verify(event, never()).setCancelled(anyBoolean());
}
}
@@ -0,0 +1,80 @@
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.ArrayList;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong;
import org.junit.jupiter.api.Test;
class QualifyingInvisibilityServiceTest {
@Test
void accumulatesMonotonicElapsedTimeAcrossQualifyingIntervals() {
AtomicLong nanos = new AtomicLong();
UUID playerId = UUID.randomUUID();
try (StealthStateManager manager = manager()) {
QualifyingInvisibilityService service = new QualifyingInvisibilityService(
manager, Duration.ofHours(8), nanos::get, ignored -> { });
service.begin(playerId, "Alex", Instant.parse("2026-08-14T10:00:00Z"));
nanos.addAndGet(Duration.ofMinutes(3).toNanos());
service.stop(playerId).join();
service.begin(playerId, "Alex", Instant.parse("2026-08-14T11:00:00Z"));
nanos.addAndGet(Duration.ofMinutes(2).toNanos());
service.stop(playerId).join();
assertEquals(Duration.ofMinutes(5).toMillis(), manager.snapshot().player(playerId).accumulatedMillis());
assertFalse(service.isQualifying(playerId));
}
}
@Test
void refreshSettlesExistingIntervalWithoutDoubleCounting() {
AtomicLong nanos = new AtomicLong();
UUID playerId = UUID.randomUUID();
try (StealthStateManager manager = manager()) {
QualifyingInvisibilityService service = new QualifyingInvisibilityService(
manager, Duration.ofHours(8), nanos::get, ignored -> { });
service.begin(playerId, "Alex", Instant.EPOCH);
nanos.addAndGet(Duration.ofSeconds(5).toNanos());
service.begin(playerId, "Alex", Instant.EPOCH.plusSeconds(5)).join();
nanos.addAndGet(Duration.ofSeconds(5).toNanos());
service.stop(playerId).join();
assertEquals(Duration.ofSeconds(10).toMillis(), manager.snapshot().player(playerId).accumulatedMillis());
}
}
@Test
void unlocksAndNotifiesExactlyOnceWhilePreservingExcessTime() {
AtomicLong nanos = new AtomicLong();
UUID playerId = UUID.randomUUID();
ArrayList<UUID> notifications = new ArrayList<>();
try (StealthStateManager manager = manager()) {
QualifyingInvisibilityService service = new QualifyingInvisibilityService(
manager, Duration.ofSeconds(10), nanos::get, notifications::add);
service.begin(playerId, "Alex", Instant.EPOCH);
nanos.addAndGet(Duration.ofSeconds(11).toNanos());
service.checkpoint(playerId).join();
nanos.addAndGet(Duration.ofSeconds(2).toNanos());
service.stop(playerId).join();
PlayerStealthState player = manager.snapshot().player(playerId);
assertTrue(player.unlocked());
assertEquals(Duration.ofSeconds(13).toMillis(), player.accumulatedMillis());
assertEquals(java.util.List.of(playerId), notifications);
}
}
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()));
}
}
@@ -0,0 +1,36 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import org.bukkit.entity.Player;
import org.bukkit.event.server.ServerListPingEvent;
import org.junit.jupiter.api.Test;
class ServerListPingListenerTest {
@Test
void removesOnlyConcealedPlayersFromNativeServerPingSample() {
UUID concealedId = UUID.randomUUID();
Player visible = player(UUID.randomUUID());
Player concealed = player(concealedId);
ArrayList<Player> sample = new ArrayList<>(List.of(visible, concealed));
ServerListPingEvent event = mock(ServerListPingEvent.class);
when(event.iterator()).thenReturn(sample.iterator());
ServerListPingListener listener = new ServerListPingListener(() -> Set.of(concealedId));
listener.onServerListPing(event);
assertEquals(List.of(visible), sample);
}
private static Player player(UUID playerId) {
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
return player;
}
}
@@ -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());
}
}
@@ -0,0 +1,24 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import java.util.ArrayList;
import org.junit.jupiter.api.Test;
class ServerPingCompatibilityGuardTest {
@Test
void compatibilityFailureLeavesPingHandlingUsableAndWarnsOnlyOnce() {
ArrayList<String> warnings = new ArrayList<>();
ServerPingCompatibilityGuard guard = new ServerPingCompatibilityGuard(warnings::add);
assertDoesNotThrow(() -> guard.run(() -> {
throw new IllegalArgumentException("unsupported profile representation");
}));
assertDoesNotThrow(() -> guard.run(() -> {
throw new IllegalArgumentException("unsupported profile representation");
}));
assertEquals(1, warnings.size());
}
}
@@ -0,0 +1,182 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.contains;
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.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.junit.jupiter.api.Test;
class StealthAdminCommandTest {
@Test
void completesAdministrativeOperationsByPrefix() {
UUID playerId = UUID.randomUUID();
try (Fixture fixture = fixture(playerId)) {
assertEquals(
List.of("grant"),
fixture.command.onTabComplete(
sender(true), mock(Command.class), "stealthadmin", new String[] {"gr"}));
assertEquals(
List.of("status", "grant", "reset", "list", "sleepcount"),
fixture.command.onTabComplete(
sender(true), mock(Command.class), "stealthadmin", new String[] {""}));
}
}
@Test
void completesContextualArgumentsAndSuppressesIrrelevantSuggestions() {
UUID playerId = UUID.randomUUID();
try (Fixture fixture = fixture(playerId)) {
Command command = mock(Command.class);
CommandSender administrator = sender(true);
assertEquals(
List.of("Alex"),
fixture.command.onTabComplete(
administrator, command, "stealthadmin", new String[] {"status", "al"}));
assertEquals(
List.of("confirm"),
fixture.command.onTabComplete(
administrator, command, "stealthadmin", new String[] {"reset", "Alex", "co"}));
assertEquals(
List.of("unlocked"),
fixture.command.onTabComplete(
administrator, command, "stealthadmin", new String[] {"list", "un"}));
assertEquals(
List.of("status", "include", "exclude"),
fixture.command.onTabComplete(
administrator, command, "stealthadmin", new String[] {"sleepcount", ""}));
assertEquals(
List.of(),
fixture.command.onTabComplete(
administrator, command, "stealthadmin", new String[] {"status", "Alex", ""}));
assertEquals(
List.of(),
fixture.command.onTabComplete(
sender(false), command, "stealthadmin", new String[] {""}));
}
}
@Test
void reportsAndChangesPersistentSleepCountPolicyWithImmediateRefreshAndAudit() {
UUID playerId = UUID.randomUUID();
try (Fixture fixture = fixture(playerId)) {
CommandSender sender = sender(true);
Command command = mock(Command.class);
fixture.command.onCommand(
sender, command, "stealthadmin", new String[] {"sleepcount", "status"});
verify(sender).sendMessage(contains("excluded"));
fixture.command.onCommand(
sender, command, "stealthadmin", new String[] {"sleepcount", "include"});
verify(sender, org.mockito.Mockito.timeout(1000)).sendMessage(contains("now included"));
assertEquals(SleepCountPolicy.INCLUDE, fixture.manager.snapshot().sleepCountPolicy());
verify(fixture.presentation).refreshSleepCountPolicy();
org.junit.jupiter.api.Assertions.assertTrue(fixture.audit.stream()
.anyMatch(message -> message.contains("sleepcount") && message.contains("include")));
}
}
@Test
void statusInspectsKnownOfflinePlayerAndReportsAllState() {
UUID playerId = UUID.randomUUID();
try (Fixture fixture = fixture(playerId)) {
CommandSender sender = sender(true);
fixture.command.onCommand(sender, mock(Command.class), "stealthadmin", new String[] {"status", "Alex"});
verify(sender).sendMessage(contains(playerId.toString()));
verify(sender).sendMessage(contains("prepared=yes"));
verify(sender).sendMessage(contains("concealed=no"));
}
}
@Test
void listsUnlockedPlayersAndClearlyReportsWhenThereAreNone() {
UUID playerId = UUID.randomUUID();
try (Fixture fixture = fixture(playerId)) {
Command command = mock(Command.class);
CommandSender emptySender = sender(true);
fixture.command.onCommand(
emptySender, command, "stealthadmin", new String[] {"list", "unlocked"});
verify(emptySender).sendMessage("No players have unlocked stealth.");
fixture.manager.update(state -> state.withPlayer(
state.player(playerId).withProgress(5000L, true))).join();
CommandSender populatedSender = sender(true);
fixture.command.onCommand(
populatedSender, command, "stealthadmin", new String[] {"list", "unlocked"});
verify(populatedSender).sendMessage("Stealth unlocked: Alex");
}
}
@Test
void grantPersistsThenReportsAndAuditsAction() {
UUID playerId = UUID.randomUUID();
try (Fixture fixture = fixture(playerId)) {
CommandSender sender = sender(true);
fixture.command.onCommand(sender, mock(Command.class), "stealthadmin", new String[] {"grant", playerId.toString()});
verify(sender, org.mockito.Mockito.timeout(1000)).sendMessage(contains("granted"));
org.junit.jupiter.api.Assertions.assertTrue(fixture.manager.snapshot().player(playerId).unlocked());
org.junit.jupiter.api.Assertions.assertTrue(fixture.audit.stream().anyMatch(message -> message.contains("grant") && message.contains(playerId.toString())));
}
}
@Test
void resetRequiresConfirmationAndPermission() {
UUID playerId = UUID.randomUUID();
try (Fixture fixture = fixture(playerId)) {
CommandSender denied = sender(false);
fixture.command.onCommand(denied, mock(Command.class), "stealthadmin", new String[] {"reset", "Alex", "confirm"});
verify(denied).sendMessage(contains("permission"));
CommandSender allowed = sender(true);
fixture.command.onCommand(allowed, mock(Command.class), "stealthadmin", new String[] {"reset", "Alex"});
verify(allowed).sendMessage(contains("confirm"));
verify(allowed, never()).sendMessage(contains("reset complete"));
}
}
private static CommandSender sender(boolean permission) {
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigotstealth.admin")).thenReturn(permission);
when(sender.getName()).thenReturn("Operator");
return sender;
}
private static Fixture fixture(UUID playerId) {
StealthStateRepository repository = new StealthStateRepository() {
@Override public PersistentStealthState load() { return new PersistentStealthState(Map.of(), Map.of()); }
@Override public void save(PersistentStealthState state) { }
};
StealthStateManager manager = new StealthStateManager(repository, new PersistentStealthState(Map.of(), Map.of()));
PlayerStealthState player = new PlayerStealthState(playerId, "Alex", 5000L, false, true, false, null, Map.of());
manager.update(state -> state.withPlayer(player)).join();
QualifyingInvisibilityService progression = new QualifyingInvisibilityService(
manager, Duration.ofHours(8), System::nanoTime, ignored -> { });
IdentityPresentation presentation = mock(IdentityPresentation.class);
StealthAdministrationService administration = new StealthAdministrationService(
manager, progression, presentation, ignored -> { }, List::of);
KnownPlayerResolver resolver = new KnownPlayerResolver(manager::snapshot, List::of);
ArrayList<String> audit = new ArrayList<>();
StealthAdminCommand command = new StealthAdminCommand(
administration, resolver, Runnable::run, audit::add, Duration.ofHours(8));
return new Fixture(manager, command, presentation, audit);
}
private record Fixture(
StealthStateManager manager,
StealthAdminCommand command,
IdentityPresentation presentation,
ArrayList<String> audit) implements AutoCloseable {
@Override public void close() { manager.close(); }
}
}
@@ -0,0 +1,83 @@
package games.dmg.spigotstealth;
import static org.mockito.Mockito.*;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import org.bukkit.entity.Player;
import org.bukkit.event.player.PlayerJoinEvent;
import org.junit.jupiter.api.Test;
class StealthAdminJoinListenerTest {
@Test
void joiningAdminReceivesCountButExistingAdminsDoNotReceiveOrdinaryJoinNotice() {
Player joining = player(true);
Player existing = player(true);
StealthSessionService sessions = sessions(Set.of(UUID.randomUUID(), UUID.randomUUID()));
new StealthAdminJoinListener(sessions, () -> List.of(joining, existing)).onJoin(event(joining));
verify(joining).sendMessage("[Stealth] 2 players are invisible. Use /stealthadmin list to see who.");
verify(existing, never()).sendMessage(anyString());
}
@Test
void concealedJoinNotifiesOnlyAdminsWithUpdatedCount() {
Player joining = player(false);
Player admin = player(true);
Player ordinary = player(false);
StealthSessionService sessions = sessions(Set.of(joining.getUniqueId()));
new StealthAdminJoinListener(sessions, () -> List.of(joining, admin, ordinary)).onJoin(event(joining));
verify(admin).sendMessage("[Stealth] An invisible player joined. 1 player is now invisible. Use /stealthadmin list to see who.");
verify(joining, never()).sendMessage(anyString());
verify(ordinary, never()).sendMessage(anyString());
}
@Test
void concealedAdminGetsExactlyOneNoticeEvenWhenIncludedInOnlinePlayers() {
Player joining = player(true);
Player admin = player(true);
StealthSessionService sessions = sessions(Set.of(joining.getUniqueId(), UUID.randomUUID()));
new StealthAdminJoinListener(sessions, () -> List.of(joining, admin)).onJoin(event(joining));
verify(joining, times(1)).sendMessage("[Stealth] 2 players are invisible. Use /stealthadmin list to see who.");
verify(admin).sendMessage("[Stealth] An invisible player joined. 2 players are now invisible. Use /stealthadmin list to see who.");
}
@Test
void zeroCountIsSilent() {
Player joining = player(true);
new StealthAdminJoinListener(sessions(Set.of()), () -> List.of(joining)).onJoin(event(joining));
verify(joining, never()).sendMessage(anyString());
}
@Test
void ordinaryJoinIsSilentAndSinglePlayerCountUsesSingular() {
Player ordinary = player(false);
Player admin = player(true);
StealthAdminJoinListener listener = new StealthAdminJoinListener(
sessions(Set.of(UUID.randomUUID())), () -> List.of(ordinary, admin));
listener.onJoin(event(ordinary));
verify(ordinary, never()).sendMessage(anyString());
verify(admin, never()).sendMessage(anyString());
listener.onJoin(event(admin));
verify(admin).sendMessage("[Stealth] 1 player is invisible. Use /stealthadmin list to see who.");
}
private static StealthSessionService sessions(Set<UUID> ids) {
StealthSessionService sessions = mock(StealthSessionService.class);
when(sessions.concealedPlayerIds()).thenReturn(ids);
return sessions;
}
private static Player player(boolean admin) {
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
when(player.hasPermission("spigotstealth.admin")).thenReturn(admin);
return player;
}
private static PlayerJoinEvent event(Player player) {
PlayerJoinEvent event = mock(PlayerJoinEvent.class);
when(event.getPlayer()).thenReturn(player);
return event;
}
}
@@ -0,0 +1,136 @@
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 static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
class StealthAdministrationServiceTest {
@Test
void persistsSleepCountPolicyChangesIdempotently() {
try (StealthStateManager manager = manager()) {
StealthAdministrationService administration = service(manager, List::of, ignored -> { });
assertEquals(SleepCountPolicy.EXCLUDE, administration.sleepCountPolicy());
assertTrue(administration.setSleepCountPolicy(SleepCountPolicy.INCLUDE).join().changed());
assertEquals(SleepCountPolicy.INCLUDE, manager.snapshot().sleepCountPolicy());
assertFalse(administration.setSleepCountPolicy(SleepCountPolicy.INCLUDE).join().changed());
}
}
@Test
void grantsOfflineUnlockAndNotifiesExactlyOnce() {
UUID playerId = UUID.randomUUID();
ArrayList<UUID> notifications = new ArrayList<>();
try (StealthStateManager manager = manager()) {
StealthAdministrationService administration = service(manager, List::of, notifications::add);
assertTrue(administration.grant(playerId, "Alex").join().changed());
assertFalse(administration.grant(playerId, "Alex").join().changed());
assertTrue(manager.snapshot().player(playerId).unlocked());
assertEquals(List.of(playerId), notifications);
}
}
@Test
void resetClearsProgressUnlockPreparationConcealmentAndActiveTracking() {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
org.mockito.Mockito.when(player.getUniqueId()).thenReturn(playerId);
try (StealthStateManager manager = manager()) {
PlayerStealthState state = new PlayerStealthState(
playerId, "Alex", 1234L, true, true, true, Instant.EPOCH, Map.of("future", "keep"));
manager.update(all -> all.withPlayer(state)).join();
QualifyingInvisibilityService progression = new QualifyingInvisibilityService(
manager, Duration.ofHours(8), System::nanoTime, ignored -> { });
progression.begin(playerId, "Alex", Instant.EPOCH).join();
IdentityPresentation presentation = mock(IdentityPresentation.class);
StealthAdministrationService administration = new StealthAdministrationService(
manager, progression, presentation, ignored -> { }, () -> List.of(player));
administration.reset(playerId, "Alex", player).join();
PlayerStealthState reset = manager.snapshot().player(playerId);
assertEquals(0L, reset.accumulatedMillis());
assertFalse(reset.unlocked());
assertFalse(reset.preparedLogin());
assertFalse(reset.concealed());
assertFalse(progression.isQualifying(playerId));
assertEquals("keep", reset.unknownFields().get("future"));
verify(presentation).reveal(player);
}
}
@Test
void listsAllUnlockedPlayersByNameOrUuidInCaseInsensitiveOrder() {
UUID namelessId = UUID.randomUUID();
UUID lockedId = UUID.randomUUID();
UUID zoeId = UUID.randomUUID();
UUID alexId = UUID.randomUUID();
try (StealthStateManager manager = manager()) {
manager.update(state -> state
.withPlayer(PlayerStealthState.empty(namelessId).withProgress(0L, true))
.withPlayer(PlayerStealthState.empty(lockedId).withLastKnownName("Locked"))
.withPlayer(PlayerStealthState.empty(zoeId).withLastKnownName("zoe").withProgress(1L, true))
.withPlayer(PlayerStealthState.empty(alexId).withLastKnownName("Alex").withProgress(2L, true)))
.join();
StealthAdministrationService administration = service(manager, List::of, ignored -> { });
assertEquals(
List.of("Alex", namelessId.toString(), "zoe").stream()
.sorted(String.CASE_INSENSITIVE_ORDER)
.toList(),
administration.unlockedPlayerNames());
}
}
@Test
void listsOnlyCurrentlyOnlineConcealedPlayers() {
UUID concealedId = UUID.randomUUID();
UUID ordinaryId = UUID.randomUUID();
Player concealed = player(concealedId, "Hidden");
Player ordinary = player(ordinaryId, "Visible");
try (StealthStateManager manager = manager()) {
manager.update(state -> state
.withPlayer(PlayerStealthState.empty(concealedId).withLastKnownName("Hidden").withSession(false, true))
.withPlayer(PlayerStealthState.empty(ordinaryId).withLastKnownName("Visible"))).join();
StealthAdministrationService administration = service(
manager, () -> List.of(concealed, ordinary), ignored -> { });
assertEquals(List.of("Hidden"), administration.concealedOnlineNames());
}
}
private static StealthAdministrationService service(
StealthStateManager manager,
java.util.function.Supplier<? extends java.util.Collection<? extends Player>> online,
java.util.function.Consumer<UUID> notifier) {
QualifyingInvisibilityService progression = new QualifyingInvisibilityService(
manager, Duration.ofHours(8), System::nanoTime, ignored -> { });
return new StealthAdministrationService(
manager, progression, mock(IdentityPresentation.class), notifier, online);
}
private static Player player(UUID playerId, String name) {
Player player = mock(Player.class);
org.mockito.Mockito.when(player.getUniqueId()).thenReturn(playerId);
org.mockito.Mockito.when(player.getName()).thenReturn(name);
return player;
}
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()));
}
}
@@ -0,0 +1,70 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import com.google.gson.JsonParser;
import java.util.Set;
import org.junit.jupiter.api.Test;
class StealthChatMaskerTest {
private final StealthChatMasker masker = new StealthChatMasker(Set.of("Secret"));
@Test
void masksSenderAndRemovesIdentityMetadataWithoutChangingMessage() {
String input = """
{"translate":"chat.type.text","with":[
{"text":"Secret","hoverEvent":{"action":"show_entity","contents":{"name":"Secret"}},
"clickEvent":{"action":"suggest_command","value":"/msg Secret "}},
{"text":"Secret says hello","clickEvent":{"action":"copy_to_clipboard","value":"Secret"}}]}
""";
var result = JsonParser.parseString(masker.maskAnnouncement(input)).getAsJsonObject();
var args = result.getAsJsonArray("with");
assertEquals(JsonParser.parseString("{\"text\":\"Anonymous\",\"obfuscated\":true,\"hoverEvent\":null,\"clickEvent\":null,\"insertion\":\"\"}"), args.get(0));
assertEquals(JsonParser.parseString(input).getAsJsonObject().getAsJsonArray("with").get(1), args.get(1));
}
@Test
void handlesPrivateMessagesAndAdvancementNamesOnly() {
for (String key : Set.of("commands.message.display.incoming", "commands.message.display.outgoing",
"chat.type.advancement.task", "chat.type.advancement.goal", "chat.type.advancement.challenge")) {
String input = "{\"translate\":\"" + key + "\",\"with\":[\"secret\",{\"text\":\"Secret\"}]}";
var args = JsonParser.parseString(masker.maskAnnouncement(input)).getAsJsonObject().getAsJsonArray("with");
assertEquals("Anonymous", args.get(0).getAsJsonObject().get("text").getAsString());
assertEquals("Secret", args.get(1).getAsJsonObject().get("text").getAsString());
}
}
@Test
void doesNotTraverseMessageBodyEvenWhenItContainsChatTranslations() {
String input = """
{"translate":"chat.type.text","with":["Ordinary",
{"translate":"chat.type.text","with":["Secret","body"]}]}
""";
assertEquals(input, masker.maskAnnouncement(input));
}
@Test
void handlesNestedAnnouncementsButNotPlainTextOrUnknownTranslations() {
assertTrue(masker.maskAnnouncement("{\"text\":\"\",\"extra\":[{\"translate\":\"chat.type.text\",\"with\":[\"Secret\",\"hello\"]}]}").contains("Anonymous"));
for (String input : Set.of("{\"text\":\"Secret: hello\"}", "{\"translate\":\"custom\",\"with\":[\"Secret\"]}", "not json")) {
assertEquals(input, masker.maskAnnouncement(input));
}
}
@Test
void removesInheritedActionsFromArrayParent() {
String json = """
[{"text":"","hover_event":{"action":"show_text","value":"Secret"}},
{"translate":"chat.type.text","with":["Secret","hello"]}]
""";
assertFalse(masker.maskAnnouncement(json).contains("Secret"));
}
@Test
void recognizesSplitNamesAndMasksWholeNameComponent() {
String name = "{\"text\":\"Sec\",\"extra\":[{\"text\":\"ret\"}],\"insertion\":\"Secret\"}";
assertTrue(masker.maskDisplayName(name).contains("Anonymous"));
assertFalse(masker.maskDisplayName(name).contains("Secret"));
assertEquals("\"NotSecret\"", masker.maskDisplayName("\"NotSecret\""));
}
}
@@ -0,0 +1,88 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong;
import org.bukkit.command.Command;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
class StealthCommandTest {
@Test
void completesProgressByPrefixWithoutSuggestingPlayersElsewhere() {
try (StealthStateManager manager = manager()) {
QualifyingInvisibilityService progression = new QualifyingInvisibilityService(
manager, Duration.ofHours(8), System::nanoTime, ignored -> { });
StealthCommand command = new StealthCommand(manager, progression, StealthSettings.from(Map.of()));
Command bukkitCommand = mock(Command.class);
Player player = player(UUID.randomUUID());
org.junit.jupiter.api.Assertions.assertEquals(
List.of("progress"),
command.onTabComplete(player, bukkitCommand, "stealth", new String[] {"pr"}));
org.junit.jupiter.api.Assertions.assertEquals(
List.of(),
command.onTabComplete(player, bukkitCommand, "stealth", new String[] {"progress", ""}));
}
}
@Test
void reportsLiveProgressTargetAndRemainingTimeWithoutMutatingState() {
UUID playerId = UUID.randomUUID();
AtomicLong nanos = new AtomicLong();
try (StealthStateManager manager = manager()) {
manager.update(state -> state.withPlayer(state.player(playerId).withAccumulatedMillis(Duration.ofHours(2).toMillis()))).join();
QualifyingInvisibilityService progression = new QualifyingInvisibilityService(
manager, Duration.ofHours(8), nanos::get, ignored -> { });
progression.begin(playerId, "Alex", Instant.EPOCH).join();
nanos.addAndGet(Duration.ofMinutes(5).toNanos());
Player player = player(playerId);
StealthCommand command = new StealthCommand(manager, progression, StealthSettings.from(Map.of()));
assertTrue(command.onCommand(player, mock(Command.class), "stealth", new String[] {"progress"}));
verify(player).sendMessage(contains("2h 5m / 8h"));
verify(player).sendMessage(contains("5h 55m remaining"));
org.junit.jupiter.api.Assertions.assertEquals(Duration.ofHours(2).toMillis(), manager.snapshot().player(playerId).accumulatedMillis());
}
}
@Test
void unlockedPlayerReceivesUsageGuidance() {
UUID playerId = UUID.randomUUID();
try (StealthStateManager manager = manager()) {
PlayerStealthState unlocked = new PlayerStealthState(playerId, "Alex", Duration.ofHours(8).toMillis(), true, false, false, null, Map.of());
manager.update(state -> state.withPlayer(unlocked)).join();
QualifyingInvisibilityService progression = new QualifyingInvisibilityService(
manager, Duration.ofHours(8), System::nanoTime, ignored -> { });
Player player = player(playerId);
StealthCommand command = new StealthCommand(manager, progression, StealthSettings.from(Map.of()));
command.onCommand(player, mock(Command.class), "stealth", new String[] {"progress"});
verify(player).sendMessage(contains("unlocked"));
verify(player).sendMessage(contains("log out while invisible"));
}
}
private static Player player(UUID playerId) {
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
return player;
}
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()));
}
}
@@ -0,0 +1,15 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.time.Duration;
import org.junit.jupiter.api.Test;
class StealthProgressFormatterTest {
@Test
void presentsConciseHumanReadableDurations() {
assertEquals("2h 5m 9s", StealthProgressFormatter.duration(Duration.ofHours(2).plusMinutes(5).plusSeconds(9).toMillis()));
assertEquals("45s", StealthProgressFormatter.duration(45_900L));
assertEquals("0s", StealthProgressFormatter.duration(0L));
}
}
@@ -0,0 +1,123 @@
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.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();
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);
StealthSessionService sessions = new StealthSessionService(manager, progression);
StealthSessionListener listener = new StealthSessionListener(
sessions, 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");
Player admin = player(UUID.randomUUID());
when(admin.hasPermission("spigotstealth.admin")).thenReturn(true);
new StealthAdminJoinListener(sessions, () -> java.util.List.of(player, admin)).onJoin(event);
verify(admin).sendMessage("[Stealth] An invisible player joined. 1 player is now invisible. Use /stealthadmin list to see who.");
}
}
@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()));
}
}
@@ -0,0 +1,94 @@
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());
manager.update(state -> state.withPlayer(
state.player(concealedId).withSession(false, false))).join();
assertEquals(Set.of(), sessions.concealedPlayerIds());
}
}
@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()));
}
}
@@ -15,15 +15,28 @@ import org.junit.jupiter.api.io.TempDir;
class YamlStealthStateRepositoryTest {
@TempDir Path temporaryDirectory;
@Test
void missingOrInvalidSleepCountPolicyDefaultsToExclude() throws Exception {
Path missingFile = temporaryDirectory.resolve("missing.yml");
YamlStealthStateRepository missing = new YamlStealthStateRepository(missingFile);
assertEquals(SleepCountPolicy.EXCLUDE, missing.load().sleepCountPolicy());
Path invalidFile = temporaryDirectory.resolve("invalid.yml");
Files.writeString(invalidFile, "sleep-count-policy: surprise\n");
YamlStealthStateRepository invalid = new YamlStealthStateRepository(invalidFile);
assertEquals(SleepCountPolicy.EXCLUDE, invalid.load().sleepCountPolicy());
}
@Test
void roundTripsUuidStateAndPreservesUnknownFields() throws Exception {
UUID playerId = UUID.randomUUID();
Path stateFile = temporaryDirectory.resolve("state.yml");
Files.writeString(stateFile, "schema-version: 1\nfuture-root: keep\nplayers:\n " + playerId + ":\n accumulated-millis: 25\n unlocked: false\n prepared-login: true\n concealed: false\n qualifying-since: '2026-08-14T10:00:00Z'\n last-known-name: Alex\n future-player: keep-too\n");
Files.writeString(stateFile, "schema-version: 1\nsleep-count-policy: include\nfuture-root: keep\nplayers:\n " + playerId + ":\n accumulated-millis: 25\n unlocked: false\n prepared-login: true\n concealed: false\n qualifying-since: '2026-08-14T10:00:00Z'\n last-known-name: Alex\n future-player: keep-too\n");
YamlStealthStateRepository repository = new YamlStealthStateRepository(stateFile);
PersistentStealthState loaded = repository.load();
PlayerStealthState player = loaded.player(playerId);
assertEquals(SleepCountPolicy.INCLUDE, loaded.sleepCountPolicy());
assertEquals(25L, player.accumulatedMillis());
assertTrue(player.preparedLogin());
assertEquals(Instant.parse("2026-08-14T10:00:00Z"), player.qualifyingSince());
@@ -31,6 +44,7 @@ class YamlStealthStateRepositoryTest {
repository.save(loaded.withPlayer(player.withAccumulatedMillis(50L)));
String saved = Files.readString(stateFile);
assertTrue(saved.contains("future-root: keep"));
assertTrue(saved.contains("sleep-count-policy: include"));
assertTrue(saved.contains("future-player: keep-too"));
assertEquals(50L, repository.load().player(playerId).accumulatedMillis());
}