Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1addc93062 | ||
|
|
831f6a2ce4 | ||
|
|
a084f86c1d | ||
|
|
ba6573943b | ||
|
|
66ba2aa3b1 | ||
|
|
b0da1508b6 | ||
|
|
2bcba5071f | ||
|
|
6071bfbdce |
@@ -24,8 +24,10 @@ tasks.withType<JavaCompile>().configureEach {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
compileOnly("org.spigotmc:spigot-api:26.2-R0.1-SNAPSHOT")
|
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("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(platform("org.junit:junit-bom:5.13.4"))
|
||||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||||
testImplementation("org.mockito:mockito-core:5.18.0")
|
testImplementation("org.mockito:mockito-core:5.18.0")
|
||||||
|
|||||||
+3
-1
@@ -4,7 +4,9 @@ okf_version: "0.1"
|
|||||||
|
|
||||||
# Spigot Stealth Design
|
# 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
|
## Explore
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,28 @@
|
|||||||
# Spigot Stealth Design Log
|
# 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
|
## 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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Spigot Stealth User Stories
|
# 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.
|
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.
|
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.
|
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.
|
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
|
type: User Story
|
||||||
title: "US-002: Rejoin without a visible identity"
|
title: "US-002: Rejoin without a visible identity"
|
||||||
description: Let an unlocked player turn a qualifying invisible disconnect into one identity-concealed session.
|
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
|
# 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
|
## 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.
|
- [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.
|
||||||
- [ ] A player who has not unlocked stealth cannot prepare a concealed login.
|
- [x] 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.
|
- [x] 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.
|
- [x] 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.
|
- [x] When a concealed player disconnects, no public quit or disconnect announcement is shown.
|
||||||
- [ ] Throughout the concealed session, no overhead name tag identifies the player to any other player, including administrators.
|
- [x] Ordinary players' quit messages remain unchanged.
|
||||||
- [ ] The concealed player's physical character remains visible in the world and retains ordinary movement, interaction, combat, and permission behavior.
|
- [x] Concealment is checked before disconnect cleanup so announcement suppression is reliable.
|
||||||
- [ ] The concealed player receives a private message explaining that stealth is active for the session.
|
- [x] Throughout the concealed session, the player is absent from every other player's tab list, including administrators' tab lists.
|
||||||
- [ ] Concealment lasts until the player disconnects and is handled predictably across death and plugin reload or disable events.
|
- [x] The multiplayer server list's online-player count excludes currently concealed players.
|
||||||
- [ ] Disconnecting consumes the current concealed session; another concealed login requires another qualifying potion and qualifying disconnect.
|
- [x] Concealed players are excluded from any player-name sample shown for the server-list count, while ordinary players remain represented.
|
||||||
- [ ] Merely owning the unlock never conceals an ordinary login or carries concealment automatically into a later session.
|
- [x] The public count never becomes negative, and the configured maximum-player count remains unchanged.
|
||||||
- [ ] Prepared-login state survives a server restart between the qualifying disconnect and the next login.
|
- [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
|
## Related
|
||||||
|
|
||||||
|
|||||||
@@ -18,10 +18,12 @@ As a **player**, I want to check my stealth progress so that I know how close I
|
|||||||
- [x] Durations are presented in a concise, human-readable form.
|
- [x] Durations are presented in a concise, human-readable form.
|
||||||
- [x] Repeated command use does not change progression or concealment state.
|
- [x] Repeated command use does not change progression or concealment state.
|
||||||
- [x] The command has clear usage metadata and an appropriate player permission.
|
- [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
|
## Validation
|
||||||
|
|
||||||
Automated tests verify concise duration formatting, live in-flight progress, configured target and remaining output, command non-mutation, and unlocked usage guidance. Command metadata and the complete `./gradlew clean check jar` lifecycle pass.
|
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
|
## Related
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: User Story
|
type: User Story
|
||||||
title: "US-004: Inspect and manage player stealth"
|
title: "US-004: Inspect and manage player stealth"
|
||||||
description: Give administrators durable controls for online and offline progression, unlocks, and active concealment.
|
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
|
# 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
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] `/stealthadmin status <player|uuid>` reports accumulated time, unlock status, prepared-login state, and current concealment state.
|
- [x] `/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.
|
- [x] 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.
|
- [x] `/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.
|
- [x] 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.
|
- [x] 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.
|
- [x] `/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.
|
- [x] 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.
|
- [x] `/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.
|
- [x] Concealed players remain absent from administrators' ordinary tab lists and retain scrambled overhead names; the admin command is the supported discovery mechanism.
|
||||||
- [ ] Commands clearly reject unknown, ambiguous, malformed, or otherwise invalid targets without creating unintended player records.
|
- [x] An administrator joining while concealed players are online receives a private count and a hint to use `/stealthadmin list`.
|
||||||
- [ ] Administrative inspection and modification require an operator-default administrative permission.
|
- [x] When a concealed player joins, online administrators receive a private arrival notice, the updated concealed-player count, and the command hint.
|
||||||
- [ ] State-changing operations persist before success is reported and are safe under retries.
|
- [x] Notifications require `spigotstealth.admin`; ordinary players receive no administrative notice.
|
||||||
- [ ] Grant and reset actions record the administrator, target UUID, and action in the server log without blocking the server tick thread.
|
- [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
|
## 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] 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] Unknown forward-compatible fields are preserved where practical.
|
||||||
- [x] Persistence work does not perform blocking file operations on the server tick thread.
|
- [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
|
## 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
|
## 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,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();
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@ import org.bukkit.event.EventHandler;
|
|||||||
import org.bukkit.event.EventPriority;
|
import org.bukkit.event.EventPriority;
|
||||||
import org.bukkit.event.Listener;
|
import org.bukkit.event.Listener;
|
||||||
import org.bukkit.event.entity.EntityPotionEffectEvent;
|
import org.bukkit.event.entity.EntityPotionEffectEvent;
|
||||||
import org.bukkit.event.player.PlayerQuitEvent;
|
|
||||||
import org.bukkit.potion.PotionEffectType;
|
import org.bukkit.potion.PotionEffectType;
|
||||||
|
|
||||||
/** Attributes invisibility changes to directly consumed potions and closes intervals safely. */
|
/** Attributes invisibility changes to directly consumed potions and closes intervals safely. */
|
||||||
@@ -50,8 +49,4 @@ public final class InvisibilityEffectListener implements Listener {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@EventHandler(priority = EventPriority.MONITOR)
|
|
||||||
public void onQuit(PlayerQuitEvent event) {
|
|
||||||
progression.stop(event.getPlayer().getUniqueId());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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. */
|
/** Immutable snapshot of all durable plugin state. */
|
||||||
public record PersistentStealthState(
|
public record PersistentStealthState(
|
||||||
Map<UUID, PlayerStealthState> players,
|
Map<UUID, PlayerStealthState> players,
|
||||||
|
SleepCountPolicy sleepCountPolicy,
|
||||||
Map<String, Object> unknownFields) {
|
Map<String, Object> unknownFields) {
|
||||||
public PersistentStealthState {
|
public PersistentStealthState {
|
||||||
players = Map.copyOf(new LinkedHashMap<>(Objects.requireNonNull(players, "players")));
|
players = Map.copyOf(new LinkedHashMap<>(Objects.requireNonNull(players, "players")));
|
||||||
|
Objects.requireNonNull(sleepCountPolicy, "sleepCountPolicy");
|
||||||
unknownFields = Map.copyOf(new LinkedHashMap<>(Objects.requireNonNull(unknownFields, "unknownFields")));
|
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) {
|
public PlayerStealthState player(UUID playerId) {
|
||||||
return players.getOrDefault(playerId, PlayerStealthState.empty(playerId));
|
return players.getOrDefault(playerId, PlayerStealthState.empty(playerId));
|
||||||
}
|
}
|
||||||
@@ -21,6 +29,10 @@ public record PersistentStealthState(
|
|||||||
public PersistentStealthState withPlayer(PlayerStealthState player) {
|
public PersistentStealthState withPlayer(PlayerStealthState player) {
|
||||||
Map<UUID, PlayerStealthState> updated = new LinkedHashMap<>(players);
|
Map<UUID, PlayerStealthState> updated = new LinkedHashMap<>(players);
|
||||||
updated.put(player.playerId(), player);
|
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,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,5 +1,7 @@
|
|||||||
package games.dmg.spigotstealth;
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
import com.comphenix.protocol.ProtocolLibrary;
|
||||||
|
import com.comphenix.protocol.ProtocolManager;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
@@ -12,6 +14,9 @@ public final class SpigotStealthPlugin extends JavaPlugin {
|
|||||||
private CompletableFuture<StealthStateManager> stateManagerFuture;
|
private CompletableFuture<StealthStateManager> stateManagerFuture;
|
||||||
private StealthSettings settings;
|
private StealthSettings settings;
|
||||||
private QualifyingInvisibilityService progression;
|
private QualifyingInvisibilityService progression;
|
||||||
|
private StealthSessionService sessions;
|
||||||
|
private IdentityPresentation identityPresentation;
|
||||||
|
private ProtocolManager protocolManager;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onEnable() {
|
public void onEnable() {
|
||||||
@@ -38,6 +43,17 @@ public final class SpigotStealthPlugin extends JavaPlugin {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onDisable() {
|
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) {
|
if (progression != null) {
|
||||||
progression.stopAll().join();
|
progression.stopAll().join();
|
||||||
}
|
}
|
||||||
@@ -51,21 +67,62 @@ public final class SpigotStealthPlugin extends JavaPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void finishInitialization(StealthStateManager manager) {
|
private void finishInitialization(StealthStateManager manager) {
|
||||||
|
java.util.function.Consumer<Runnable> mainThread =
|
||||||
|
runnable -> getServer().getScheduler().runTask(this, runnable);
|
||||||
BukkitUnlockNotifier notifier = new BukkitUnlockNotifier(
|
BukkitUnlockNotifier notifier = new BukkitUnlockNotifier(
|
||||||
getServer()::getPlayer,
|
getServer()::getPlayer,
|
||||||
runnable -> getServer().getScheduler().runTask(this, runnable),
|
mainThread,
|
||||||
settings.unlockedMessage());
|
settings.unlockedMessage());
|
||||||
progression = new QualifyingInvisibilityService(
|
progression = new QualifyingInvisibilityService(
|
||||||
manager, settings.unlockThreshold(), System::nanoTime, notifier);
|
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(
|
getServer().getPluginManager().registerEvents(
|
||||||
new InvisibilityEffectListener(progression, Clock.systemUTC()), this);
|
new InvisibilityEffectListener(progression, Clock.systemUTC()), this);
|
||||||
Objects.requireNonNull(getCommand("stealth"), "stealth command")
|
getServer().getPluginManager().registerEvents(
|
||||||
.setExecutor(new StealthCommand(manager, progression, settings));
|
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 -> {
|
getServer().getScheduler().runTaskTimer(this, ignored -> {
|
||||||
for (java.util.UUID playerId : progression.activePlayerIds()) {
|
for (java.util.UUID playerId : progression.activePlayerIds()) {
|
||||||
logSaveFailure(progression.checkpoint(playerId));
|
logSaveFailure(progression.checkpoint(playerId));
|
||||||
}
|
}
|
||||||
}, 20L, 20L);
|
}, 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);
|
getServer().getScheduler().runTaskTimer(this, ignored -> logSaveFailure(manager.save()), 6000L, 6000L);
|
||||||
getLogger().info("Spigot Stealth enabled");
|
getLogger().info("Spigot Stealth enabled");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
package games.dmg.spigotstealth;
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import org.bukkit.command.Command;
|
import org.bukkit.command.Command;
|
||||||
import org.bukkit.command.CommandExecutor;
|
import org.bukkit.command.CommandExecutor;
|
||||||
import org.bukkit.command.CommandSender;
|
import org.bukkit.command.CommandSender;
|
||||||
|
import org.bukkit.command.TabCompleter;
|
||||||
import org.bukkit.entity.Player;
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
/** Player-facing progress command. */
|
/** Player-facing progress command. */
|
||||||
public final class StealthCommand implements CommandExecutor {
|
public final class StealthCommand implements CommandExecutor, TabCompleter {
|
||||||
private final StealthStateManager stateManager;
|
private final StealthStateManager stateManager;
|
||||||
private final QualifyingInvisibilityService progression;
|
private final QualifyingInvisibilityService progression;
|
||||||
private final StealthSettings settings;
|
private final StealthSettings settings;
|
||||||
@@ -22,6 +24,15 @@ public final class StealthCommand implements CommandExecutor {
|
|||||||
this.settings = Objects.requireNonNull(settings, "settings");
|
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
|
@Override
|
||||||
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
|
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
|
||||||
if (!(sender instanceof Player player)) {
|
if (!(sender instanceof Player player)) {
|
||||||
|
|||||||
@@ -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. */
|
/** Defensive YAML repository using atomic file replacement where available. */
|
||||||
public final class YamlStealthStateRepository implements StealthStateRepository {
|
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(
|
private static final Set<String> PLAYER_FIELDS = Set.of(
|
||||||
"last-known-name", "accumulated-millis", "unlocked", "prepared-login", "concealed", "qualifying-since");
|
"last-known-name", "accumulated-millis", "unlocked", "prepared-login", "concealed", "qualifying-since");
|
||||||
private final Path stateFile;
|
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
|
@Override
|
||||||
@@ -61,6 +64,7 @@ public final class YamlStealthStateRepository implements StealthStateRepository
|
|||||||
YamlConfiguration yaml = new YamlConfiguration();
|
YamlConfiguration yaml = new YamlConfiguration();
|
||||||
state.unknownFields().forEach(yaml::set);
|
state.unknownFields().forEach(yaml::set);
|
||||||
yaml.set("schema-version", 1);
|
yaml.set("schema-version", 1);
|
||||||
|
yaml.set("sleep-count-policy", state.sleepCountPolicy().persistedValue());
|
||||||
for (PlayerStealthState player : state.players().values()) {
|
for (PlayerStealthState player : state.players().values()) {
|
||||||
String base = "players." + player.playerId() + ".";
|
String base = "players." + player.playerId() + ".";
|
||||||
player.unknownFields().forEach((key, value) -> yaml.set(base + key, value));
|
player.unknownFields().forEach((key, value) -> yaml.set(base + key, value));
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ main: games.dmg.spigotstealth.SpigotStealthPlugin
|
|||||||
api-version: "1.20"
|
api-version: "1.20"
|
||||||
description: Rewards invisibility potion use with identity-concealed sessions.
|
description: Rewards invisibility potion use with identity-concealed sessions.
|
||||||
author: dmg.games
|
author: dmg.games
|
||||||
|
depend: [ProtocolLib]
|
||||||
commands:
|
commands:
|
||||||
stealth:
|
stealth:
|
||||||
description: View and use Spigot Stealth features.
|
description: View and use Spigot Stealth features.
|
||||||
@@ -11,7 +12,7 @@ commands:
|
|||||||
permission: spigotstealth.use
|
permission: spigotstealth.use
|
||||||
stealthadmin:
|
stealthadmin:
|
||||||
description: Inspect and administer Spigot Stealth.
|
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
|
permission: spigotstealth.admin
|
||||||
permissions:
|
permissions:
|
||||||
spigotstealth.use:
|
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,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,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"));
|
assertEquals("games.dmg.spigotstealth.SpigotStealthPlugin", metadata.get("main"));
|
||||||
assertNotNull(metadata.get("commands"));
|
assertNotNull(metadata.get("commands"));
|
||||||
assertNotNull(metadata.get("permissions"));
|
assertNotNull(metadata.get("permissions"));
|
||||||
|
assertEquals(java.util.List.of("ProtocolLib"), metadata.get("depend"));
|
||||||
} catch (java.io.IOException exception) {
|
} catch (java.io.IOException exception) {
|
||||||
throw new AssertionError(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,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\""));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import static org.mockito.Mockito.when;
|
|||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
@@ -16,6 +17,24 @@ import org.bukkit.entity.Player;
|
|||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
class StealthCommandTest {
|
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
|
@Test
|
||||||
void reportsLiveProgressTargetAndRemainingTimeWithoutMutatingState() {
|
void reportsLiveProgressTargetAndRemainingTimeWithoutMutatingState() {
|
||||||
UUID playerId = UUID.randomUUID();
|
UUID playerId = UUID.randomUUID();
|
||||||
|
|||||||
@@ -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 {
|
class YamlStealthStateRepositoryTest {
|
||||||
@TempDir Path temporaryDirectory;
|
@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
|
@Test
|
||||||
void roundTripsUuidStateAndPreservesUnknownFields() throws Exception {
|
void roundTripsUuidStateAndPreservesUnknownFields() throws Exception {
|
||||||
UUID playerId = UUID.randomUUID();
|
UUID playerId = UUID.randomUUID();
|
||||||
Path stateFile = temporaryDirectory.resolve("state.yml");
|
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);
|
YamlStealthStateRepository repository = new YamlStealthStateRepository(stateFile);
|
||||||
|
|
||||||
PersistentStealthState loaded = repository.load();
|
PersistentStealthState loaded = repository.load();
|
||||||
PlayerStealthState player = loaded.player(playerId);
|
PlayerStealthState player = loaded.player(playerId);
|
||||||
|
assertEquals(SleepCountPolicy.INCLUDE, loaded.sleepCountPolicy());
|
||||||
assertEquals(25L, player.accumulatedMillis());
|
assertEquals(25L, player.accumulatedMillis());
|
||||||
assertTrue(player.preparedLogin());
|
assertTrue(player.preparedLogin());
|
||||||
assertEquals(Instant.parse("2026-08-14T10:00:00Z"), player.qualifyingSince());
|
assertEquals(Instant.parse("2026-08-14T10:00:00Z"), player.qualifyingSince());
|
||||||
@@ -31,6 +44,7 @@ class YamlStealthStateRepositoryTest {
|
|||||||
repository.save(loaded.withPlayer(player.withAccumulatedMillis(50L)));
|
repository.save(loaded.withPlayer(player.withAccumulatedMillis(50L)));
|
||||||
String saved = Files.readString(stateFile);
|
String saved = Files.readString(stateFile);
|
||||||
assertTrue(saved.contains("future-root: keep"));
|
assertTrue(saved.contains("future-root: keep"));
|
||||||
|
assertTrue(saved.contains("sleep-count-policy: include"));
|
||||||
assertTrue(saved.contains("future-player: keep-too"));
|
assertTrue(saved.contains("future-player: keep-too"));
|
||||||
assertEquals(50L, repository.load().player(playerId).accumulatedMillis());
|
assertEquals(50L, repository.load().player(playerId).accumulatedMillis());
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user