12 Commits
Author SHA1 Message Date
dmg 8b412141da feat(stealth): route concealed public chat as system messages
Release / release (push) Successful in 4m59s
CI / build (push) Successful in 2m46s
Preserve final moderation, selected recipients, viewer-aware formatting, native content filters and client chat settings. Retain profile/chat sessions through tab-list status updates. Verify real native pipeline, packet codecs, RSA signed message chains and acknowledgement bookkeeping without changing authentication settings. No deployment is included.
2026-09-12 22:05:08 -04:00
dmg c1bd4406d2 feat(stealth): break concealed sessions on player damage
Release / release (push) Successful in 4m55s
CI / build (push) Successful in 2m47s
Resolve direct native damage sources and explicitly observed poison/wither provenance, including hidden layers and effective replacements. Preserve progression and potion effects while reusing identity/session cleanup. Verify native effect application, ticks, tab codecs and plugin registration. No deployment is included.
2026-09-12 21:09:10 -04:00
dmg 0499a31f71 feat(stealth): reveal nearby players to eligible Eye wearers
Release / release (push) Successful in 4m46s
CI / build (push) Successful in 2m31s
Use private client teams and current recipient-specific metadata authorization without changing server teams, potion effects or other identity channels. Preserve effective team visibility and platform hiding. Add immediate lifecycle invalidation, fail-closed capture and cleanup-before-recreation recovery, verified with native codecs, delivery, ProtocolLib and plugin lifecycle tests. No deployment is included.
2026-09-12 20:05:37 -04:00
dmg 67942ced7c feat(stealth): earn and equip the Eye of True Seeing
Release / release (push) Successful in 7m15s
CI / build (push) Successful in 2m15s
Add independent eight-hour Night Vision progression, save-gated crafting and holder eligibility, authenticated helmet items and guarded inventory movement. Verify source attribution, native item/recipe/event adapters and actual plugin lifecycle. Drain final state asynchronously and reject stale lifecycle callbacks. Per-viewer revelation remains the next story; no deployment is included.
2026-09-12 17:35:01 -04:00
dmg 74b9ac59ff test(stealth): await complete main-thread admin callbacks
Release / release (push) Successful in 3m10s
CI / build (push) Successful in 1m31s
2026-09-12 13:02:36 -04:00
dmg b7db69d010 feat(stealth): migrate distribution to Java 25 and Purpur
CI / build (push) Successful in 3m31s
Release / release (push) Failing after 3m32s
BREAKING CHANGE: New releases require Java 25/Purpur 26.2 and use purpur-stealth-<version>.jar. Runtime SpigotStealth identity, existing data and permissions remain unchanged. Replace the old JAR rather than installing both.
2026-09-12 12:43:18 -04:00
dmg d0000810c1 chore(knowledge): move canonical docs to shared SoMC wiki
CI / build (push) Successful in 3m19s
Release / release (push) Successful in 4m50s
2026-09-09 23:18:21 -04:00
dmg 1addc93062 feat(stealth): mask identities and notify admins on login
Release / release (push) Successful in 2m8s
CI / build (push) Successful in 1m6s
2026-09-06 21:19:59 -04:00
dmg 831f6a2ce4 fix(ping): support Purpur NameAndId samples
Release / release (push) Successful in 2m24s
CI / build (push) Successful in 1m42s
2026-09-05 08:58:46 -04:00
dmg a084f86c1d feat(admin): add persistent sleep-count policy
Release / release (push) Successful in 2m17s
CI / build (push) Successful in 1m3s
2026-09-05 08:34:50 -04:00
dmg ba6573943b fix(stealth): hide concealed players from server ping
Release / release (push) Successful in 2m13s
CI / build (push) Successful in 58s
2026-09-05 08:11:58 -04:00
dmg 66ba2aa3b1 fix(stealth): suppress concealed disconnect messages
CI / build (push) Successful in 1m1s
Release / release (push) Successful in 2m8s
2026-09-04 23:54:23 -04:00
98 changed files with 6033 additions and 512 deletions
+2 -2
View File
@@ -17,7 +17,7 @@ jobs:
uses: actions/setup-java@v4 uses: actions/setup-java@v4
with: with:
distribution: temurin distribution: temurin
java-version: 17 java-version: 25
cache: gradle cache: gradle
- name: Validate conventional commits - name: Validate conventional commits
@@ -35,6 +35,6 @@ jobs:
- name: Upload development artifact - name: Upload development artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: spigot-stealth-${{ github.sha }} name: purpur-stealth-${{ github.sha }}
path: build/libs/*.jar path: build/libs/*.jar
if-no-files-found: error if-no-files-found: error
+5 -5
View File
@@ -21,7 +21,7 @@ jobs:
uses: actions/setup-java@v4 uses: actions/setup-java@v4
with: with:
distribution: temurin distribution: temurin
java-version: 17 java-version: 25
cache: gradle cache: gradle
- name: Set up Node.js - name: Set up Node.js
@@ -86,8 +86,8 @@ jobs:
if: steps.release.outputs.created == 'true' if: steps.release.outputs.created == 'true'
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: spigot-stealth-${{ steps.release.outputs.version }} name: purpur-stealth-${{ steps.release.outputs.version }}
path: build/libs/spigot-stealth-${{ steps.release.outputs.version }}.jar path: build/libs/purpur-stealth-${{ steps.release.outputs.version }}.jar
if-no-files-found: error if-no-files-found: error
- name: Create Gitea release and upload plugin - name: Create Gitea release and upload plugin
@@ -101,7 +101,7 @@ jobs:
PREVIOUS_TAG: ${{ steps.previous_tag.outputs.value }} PREVIOUS_TAG: ${{ steps.previous_tag.outputs.value }}
run: | run: |
api_url="${GITEA_SERVER_URL}/api/v1" api_url="${GITEA_SERVER_URL}/api/v1"
jar="build/libs/spigot-stealth-${VERSION}.jar" jar="build/libs/purpur-stealth-${VERSION}.jar"
export RELEASE_BODY export RELEASE_BODY
if [ -n "$PREVIOUS_TAG" ]; then if [ -n "$PREVIOUS_TAG" ]; then
RELEASE_BODY=$(git log --pretty='format:- %s (%h)' "${PREVIOUS_TAG}..HEAD") RELEASE_BODY=$(git log --pretty='format:- %s (%h)' "${PREVIOUS_TAG}..HEAD")
@@ -138,4 +138,4 @@ jobs:
-H "Authorization: token ${GITEA_TOKEN}" \ -H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/octet-stream" \ -H "Content-Type: application/octet-stream" \
--data-binary "@${jar}" \ --data-binary "@${jar}" \
"${api_url}/repos/${REPOSITORY}/releases/${release_id}/assets?name=spigot-stealth-${VERSION}.jar" "${api_url}/repos/${REPOSITORY}/releases/${release_id}/assets?name=purpur-stealth-${VERSION}.jar"
+5 -49
View File
@@ -1,53 +1,9 @@
# Repository Agent Guidance # purpur-stealth agent entrypoint
## User-story-driven development The canonical stories, engineering guidance, and **all process documents** are in the private [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md).
The `design/` directory is the OKF v0.1 product record for this repository. Use user stories to plan, implement, verify, and track all behavior. Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/purpur-stealth/index.md`, `engineering.md` in that project section, and relevant `../somc-okf/user-stories/purpur-stealth/` stories. Also follow the parent workspace `AGENTS.md` when present.
Before changing behavior: For standalone checkouts, start at the [project page](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-stealth/index.md) and [shared process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md). Obtain wiki access before feature work; do not recreate a local knowledge bundle. Source builds do not require private wiki access.
1. Read `design/index.md` and every story related to the requested behavior. Development follows [Development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md): approved stories, failing tests, passing implementation, verification, then source/wiki commit and push. GitOps updates are committed locally **without pushing**; only [Do release](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/do-release.md) authorizes a reviewed GitOps push.
2. Draft updates to an existing story or create a new `design/user-stories/us-NNN-short-name.md` story before implementation.
3. Define observable acceptance criteria using user or operator language.
4. Present the relevant new or updated stories and acceptance criteria to the user for review, and wait for explicit confirmation before changing implementation code.
5. Incorporate requested story changes before proceeding.
6. Set story status to `in-progress` while approved implementation is incomplete.
While implementing:
1. Work in vertical slices against the documented acceptance criteria.
2. Add tests for important behavior before implementation when practical.
3. Keep implementation references and related-story links current.
4. Do not mark an acceptance criterion complete until the behavior exists and has been validated.
Before completing or committing:
1. Set a completed story status to `done` only when all acceptance criteria are complete and verified.
2. Check completed acceptance criteria and record validation evidence where appropriate.
3. Update `design/index.md` and `design/user-stories/index.md` whenever stories are added, renamed, moved, or materially reclassified.
4. Add a high-level entry to `design/log.md` under the verified current date.
5. Run OKF validation along with relevant Gradle tests, lint checks, and builds.
## OKF conventions
- Every non-reserved Markdown file in `design/` must have YAML frontmatter with a non-empty `type`.
- User stories use `type: User Story` and include `status`, `title`, and `description`.
- Allowed story statuses are `backlog`, `in-progress`, and `done`.
- Every user story includes an acceptance-criteria section using Markdown task-list items.
- `design/index.md` and `design/log.md` are reserved OKF files and follow the OKF index/log structures.
- Store user stories in `design/user-stories/` and keep their catalog current.
- Use standard Markdown links and keep repository-local links valid when files move.
- Preserve unknown frontmatter extensions.
## Java and Spigot development
- Use the Java version and Spigot API version declared by the Gradle build.
- Treat compiler warnings as errors.
- Prefer test-first development for domain rules and state transitions when practical.
- Run `./gradlew clean check jar` before completing implementation work.
- Keep Bukkit event handlers thin and move testable game rules into focused domain services.
- Do not perform blocking file or network operations on the server tick thread.
## Timestamps
Always run `date +%Y-%m-%d` before adding or updating dates in stories or the design log. Use RFC 3339 UTC timestamps (`YYYY-MM-DDTHH:MM:SSZ`) when a date-time is required. Never guess dates.
+47
View File
@@ -0,0 +1,47 @@
# Purpur Stealth
Progression-gated identity concealment for Purpur 26.2 build 2618, built and tested with Java 25. ProtocolLib 5.4.0 is required.
## Build
```sh
./gradlew clean check jar
```
Artifacts are written to `build/libs/purpur-stealth-<version>.jar`. Gitea CI verifies pushes/pull requests; approved main-branch conventional commits drive versioned releases. The distribution tests inspect the built JAR, embedded version/runtime identity, Java 25 bytecode, and workflow naming.
## Combat breaks stealth
Dealing uncancelled positive damage to another player ends the attacker's concealed session and tells them: **Your stealth was broken because you hurt another player.** Melee, player-attributed projectiles and potions qualify; misses, cancelled/zero hits, self-damage, attacking mobs and merely taking damage do not. Identity and prior sleep-count participation are restored, including withdrawing Eye projections. Progress, unlocks and unrelated invisibility remain intact; re-entry uses the existing qualifying logout/login process.
Delayed poison/wither use explicitly observed application sources and read-only native effect-layer snapshots, including hidden-layer restoration and rejected/cosmetic replacements. No nearby-player or wall-clock guess is made. Provenance is runtime-only and discarded on victim disconnect; unknown effects loaded after reconnect/restart are not assigned an invented attacker. Public chat uses the anonymous system-message route below.
## Anonymous public chat
Concealed players' public chat is delivered as unsigned system messages, normally **[Stealth] Anonymous: hello**. The final Paper chat pipeline retains selected recipients, cancellation and moderated content, preserves original per-viewer formatting, and enforces native content filtering and client chat visibility. Plain output removes identifying interactive metadata and anonymizes the sender's name/UUID. Private commands/messages are not rerouted. Ordinary senders keep native signed chat.
Tab hiding uses `UPDATE_LISTED=false`, not profile deletion; server initialization updates cannot relist concealed players for other observers. Existing profile and chat-session data remain available when normal signed chat resumes. No secure-profile/authentication settings are changed. Native signature, packet and acknowledgement regressions cover the route; live-client validation remains a separate follow-up. System messages do not provide signed-player authenticity or equivalent reporting support.
## Eye of True Seeing
Earn a separate, personal unlock through eight cumulative online hours of Night Vision from directly drunk potions. Splash/lingering potions, commands, plugins and other sources do not count. Rejected and cosmetic-only replacements do not change attribution. Unattributed restored effects cannot inherit the drink timer; a new effective drink can start a new interval. Offline time is excluded.
After the unlock is saved, craft an **Eye of True Seeing** with eight Netherite Blocks around an Eye of Ender. Right-click an authenticated Eye in either hand to move it into an empty helmet slot. Existing helmets are not replaced. Traded Eyes still require the holder's own saved unlock; renamed ordinary Eyes do not qualify. Automated crafters cannot bypass progression. Vanilla handles crafting ingredient consumption.
While worn by an eligible player, the Eye reveals active Stealth targets' real overhead names within **16 blocks in three dimensions and line of sight**. It also reveals their bodies if they have gameplay invisibility, only to that observer. Ordinary invisible players without a concealed Stealth session are not revealed. Platform hiding, spectator mode, vanished metadata and effective team name-visibility restrictions take precedence. Eligibility is checked even for an Eye manually placed in the helmet slot.
Revelation uses private client-only teams and recipient-specific metadata; it does not change server teams, remove potion effects or end Stealth sessions. Tab-list, chat, suggestions and server-list concealment remain unchanged, including for the wearer. Unequipping, leaving range/sight, lifecycle changes and disable withdraw the projection and restore current raw state. Failed deliveries revoke authorization and queue cleanup before recreation. Live-client appearance/timing and compatibility remain separate validation follow-ups.
Eye records are independently UUID-keyed in `plugins/SpigotStealth/state.yml` (schema 2). Legacy Stealth records and extension fields are preserved. Crafting/use wait for successful save acknowledgement. Shutdown queues a final snapshot without blocking the tick thread, and initialization callbacks cannot revive a disabled or superseded lifecycle. Unacknowledged progress at abrupt process/power loss is not guaranteed.
`check` includes `nativeStealthTest`, which downloads SHA-256-pinned Purpur 2618, patches it without starting a listening server, and exercises real potion events, items, recipes, event dispatch and plugin lifecycle with external platform doubles. Live-client appearance and gameplay checks remain separate follow-ups.
## Migration compatibility
The repository and checkout are now `purpur-stealth` (previously remote `spigot-stealth`, local `spigot-invisibilty`). Runtime identity remains **SpigotStealth**, including `plugins/SpigotStealth/`, the existing Java entrypoint/packages, command names, `spigotstealth` permission/namespace identifiers, and persisted progression/session data. Java 17 is no longer supported for new builds or artifacts.
Replace the old plugin JAR when installing the new distribution; never load both JARs together. Old tags and `spigot-stealth-*` release assets are preserved. Repository rename redirects have been checked against the previously deployed v1.5.0 download. Publishing a release does not authorize deployment.
The v2.0.0 migration itself left the Eye, combat reveal and concealed-chat stories pending. Subsequent Eye acquisition, local revelation, combat-session breaking and anonymous system-chat routing are described above. Runtime plugin metadata is unchanged.
See the [canonical project](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-stealth/index.md), [stories](https://git.garvis.dev/dmg/somc-okf/src/branch/main/user-stories/purpur-stealth/index.md), and [development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md).
+66 -13
View File
@@ -1,3 +1,6 @@
import java.net.URI
import java.security.MessageDigest
plugins { plugins {
java java
} }
@@ -9,24 +12,25 @@ version = providers.gradleProperty("releaseVersion")
repositories { repositories {
mavenCentral() mavenCentral()
maven("https://hub.spigotmc.org/nexus/content/repositories/snapshots/") maven("https://repo.purpurmc.org/snapshots/")
} }
java { java {
toolchain { toolchain {
languageVersion = JavaLanguageVersion.of(17) languageVersion = JavaLanguageVersion.of(25)
} }
} }
tasks.withType<JavaCompile>().configureEach { tasks.withType<JavaCompile>().configureEach {
options.compilerArgs.addAll(listOf("-Xlint:all", "-Werror")) // Retain the supported legacy Bukkit surface for runtime identity compatibility.
options.compilerArgs.addAll(listOf("-Xlint:all,-deprecation,-removal", "-Werror"))
} }
dependencies { dependencies {
compileOnly("org.spigotmc:spigot-api:26.2-R0.1-SNAPSHOT") compileOnly("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
compileOnly("net.dmulloy2:ProtocolLib:5.4.0") compileOnly("net.dmulloy2:ProtocolLib:5.4.0")
testImplementation("org.spigotmc:spigot-api:26.2-R0.1-SNAPSHOT") testImplementation("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
testImplementation("net.dmulloy2:ProtocolLib:5.4.0") 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")
@@ -37,8 +41,65 @@ dependencies {
tasks.test { tasks.test {
useJUnitPlatform() useJUnitPlatform()
dependsOn(tasks.jar)
systemProperty("distribution.jar", tasks.jar.get().archiveFile.get().asFile.absolutePath)
systemProperty("distribution.version", project.version.toString())
} }
// Exercise real potion events, item components and recipes without starting a listening server.
val stealthRuntime = layout.buildDirectory.dir("stealth-runtime")
val downloadStealthRuntime = tasks.register("downloadStealthRuntime") {
val launcher = stealthRuntime.map { it.file("purpur-26.2-2618.jar") }
outputs.file(launcher)
doLast {
val file = launcher.get().asFile
file.parentFile.mkdirs()
val connection = URI("https://api.purpurmc.org/v2/purpur/26.2/2618/download").toURL().openConnection()
connection.connectTimeout = 30_000
connection.readTimeout = 120_000
val bytes = connection.getInputStream().use { it.readBytes() }
val digest = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
check(digest == "4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31") {
"Purpur 2618 checksum mismatch"
}
file.writeBytes(bytes)
}
}
val prepareStealthRuntime = tasks.register<JavaExec>("prepareStealthRuntime") {
dependsOn(downloadStealthRuntime)
javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(25) }
classpath = files(stealthRuntime.map { it.file("purpur-26.2-2618.jar") })
mainClass = "io.papermc.paperclip.Main"
jvmArgs("-Dpaperclip.patchonly=true")
workingDir(stealthRuntime)
outputs.dir(stealthRuntime.map { it.dir("versions") })
outputs.dir(stealthRuntime.map { it.dir("libraries") })
}
val nativeTest = sourceSets.create("nativeTest")
dependencies {
add(nativeTest.implementationConfigurationName, platform("org.junit:junit-bom:5.13.4"))
add(nativeTest.implementationConfigurationName, "org.junit.jupiter:junit-jupiter")
add(nativeTest.implementationConfigurationName, "org.mockito:mockito-core:5.18.0")
add(nativeTest.implementationConfigurationName, "net.dmulloy2:ProtocolLib:5.4.0")
add(nativeTest.compileOnlyConfigurationName, "org.jetbrains:annotations:26.0.2")
add(nativeTest.compileOnlyConfigurationName, "org.checkerframework:checker-qual:3.49.2")
add(nativeTest.runtimeOnlyConfigurationName, "org.junit.platform:junit-platform-launcher")
}
val nativeRuntimeJars = files(fileTree(stealthRuntime) {
include("versions/**/*.jar", "libraries/**/*.jar")
}).builtBy(prepareStealthRuntime)
nativeTest.compileClasspath += sourceSets.main.get().output + nativeRuntimeJars
nativeTest.runtimeClasspath += sourceSets.main.get().output + nativeRuntimeJars
val nativeStealthTest = tasks.register<Test>("nativeStealthTest") {
description = "Runs native Purpur adapter regressions without starting a server"
testClassesDirs = nativeTest.output.classesDirs
classpath = nativeTest.runtimeClasspath
useJUnitPlatform()
maxHeapSize = "1G"
workingDir(stealthRuntime)
}
tasks.check { dependsOn(nativeStealthTest) }
val pluginVersion = version val pluginVersion = version
tasks.processResources { tasks.processResources {
@@ -46,11 +107,3 @@ tasks.processResources {
expand("version" to pluginVersion) expand("version" to pluginVersion)
} }
} }
tasks.register<Exec>("validateOkf") {
commandLine("./scripts/validate-okf.sh")
}
tasks.check {
dependsOn("validateOkf")
}
-12
View File
@@ -1,12 +0,0 @@
---
okf_version: "0.1"
---
# Spigot Stealth Design
Spigot Stealth rewards players who spend time under invisibility effects from potions they drink, then lets unlocked players conceal their identity for a later session.
## Explore
- [User stories](user-stories/) - Approved requirements for progression, stealth sessions, commands, persistence, and delivery.
- [Design log](log.md) - Material product decisions and bundle changes.
-26
View File
@@ -1,26 +0,0 @@
# Spigot Stealth Design Log
## 2026-09-04
- **Completion**: Extended US-003 and US-004 with contextual, prefix-filtered command completion that suppresses generic player suggestions, plus an administrative list of all known online and offline unlocked players; verified the complete Gradle build and OKF bundle.
## 2026-08-14
- **Completion**: Completed US-004 with exact known online and offline targeting, complete status, idempotent grants, confirmed resets, active concealment discovery, operator-default permissions, asynchronous persisted replies, and console audits; verified the full Gradle build.
- **Implementation**: Began US-004 with test-first exact offline targeting, grant/reset transitions, concealed-player listing, permissions, confirmation, and audit logging.
- **Completion**: Completed US-002 with single-use durable prepared logins, join-announcement suppression, ProtocolLib tab-only removal, scoreboard overhead-name suppression, visible physical entities, periodic observer refresh, respawn restoration, and disconnect or disable cleanup; verified the full Gradle build.
- **Implementation**: Began US-002 with test-first prepared-login consumption, session-scoped concealment, join suppression, tab removal, and overhead-name presentation.
- **Completion**: Completed US-003 with `/stealth progress`, live unsaved interval inclusion, configured target and remaining output, concise duration formatting, and unlocked usage guidance; verified the full Gradle build.
- **Implementation**: Began US-003 with player-facing progress command and human-readable duration tests.
- **Completion**: Completed US-001 with direct potion-drink effect attribution, monotonic elapsed-time accumulation, safe refresh and stop transitions, durable UUID progress, exact-once unlocking, and title plus chat presentation; verified the full Gradle build.
- **Implementation**: Began US-001 with monotonic-clock progression tests and potion-cause event attribution.
- **Completion**: Completed US-005 with validated eight-hour defaults and configurable messages, UUID-keyed immutable state, RFC 3339 timing metadata, safe invalid-record defaults, unknown-field preservation, atomic YAML replacement, periodic saves, and serialized off-thread persistence; verified the full Gradle build.
- **Implementation**: Began US-005 with test-first validated settings, UUID-keyed state, and defensive asynchronous persistence.
- **Completion**: Completed US-006 with a Java 17 Gradle build, strict compiler linting, Spigot API, JUnit 5, Mockito, plugin metadata, OKF validation, Gitea CI, conventional-commit checks, semantic releases, and versioned release assets; verified the full build and a `1.2.3` release JAR.
- **Implementation**: Began US-006 with a test-driven Java 17, Gradle, Spigot, OKF validation, and Gitea delivery foundation modeled on Spigot Tyrant.
- **Creation**: Established the OKF v0.1 product record for Spigot Stealth.
- **Decision**: Only invisibility from a potion the player directly drinks contributes to the default eight-hour unlock requirement.
- **Decision**: An unlocked player who disconnects while their qualifying potion effect remains active conceals their identity for the entirety of their next online session.
- **Decision**: Each concealed session requires a new qualifying potion and disconnect; concealment never carries automatically into another session.
- **Decision**: Concealed players remain physically visible but have no join announcement, tab-list entry, or overhead name tag for any player, including administrators.
- **Decision**: Administrators can inspect and manage online or offline progression and list currently concealed players.
-8
View File
@@ -1,8 +0,0 @@
# Spigot Stealth User Stories
1. [US-001: Accumulate invisibility time and unlock stealth](us-001-accumulate-invisibility-and-unlock.md) - Earn stealth by spending eight hours under invisibility effects from directly consumed potions.
2. [US-002: Rejoin without a visible identity](us-002-rejoin-without-visible-identity.md) - Turn a qualifying invisible disconnect into one concealed online session.
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.
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.
@@ -1,32 +0,0 @@
---
type: User Story
title: "US-001: Accumulate invisibility time and unlock stealth"
description: Reward sustained use of directly consumed invisibility potions with the stealth ability.
status: done
---
# US-001: Accumulate invisibility time and unlock stealth
As a **player**, I want my qualifying invisibility time to accumulate so that sustained potion use eventually unlocks stealth.
## Acceptance criteria
- [x] Only time spent online under an invisibility effect produced by a potion the player directly drank contributes to progression.
- [x] Invisibility received from splash potions, lingering potions, tipped arrows, commands, plugins, or other sources does not contribute.
- [x] Qualifying time stops when the effect ends, is removed, is replaced by a non-qualifying source, the player disconnects, or the plugin disables.
- [x] Refreshed or overlapping qualifying effects never count elapsed time more than once.
- [x] Qualifying time accumulates across effects, sessions, and server restarts.
- [x] The ability unlocks when accumulated qualifying time reaches eight hours by default.
- [x] Reaching the threshold grants the unlock exactly once without discarding excess elapsed time.
- [x] When an online player unlocks stealth, they receive both a full-screen title and a chat message explaining the ability.
- [x] Progress and unlock ownership are associated with the player's UUID rather than their current name.
## Validation
Automated tests verify monotonic accumulation across intervals, refresh without duplicate time, exact-once threshold crossing with excess-time preservation, direct potion-drink cause filtering, non-qualifying replacement, UUID state, and online title and chat presentation. The complete `./gradlew clean check jar` lifecycle passes.
## Related
- [Rejoin without a visible identity](us-002-rejoin-without-visible-identity.md)
- [Check personal stealth progress](us-003-check-personal-progress.md)
- [Configure and persist stealth progression](us-005-configure-and-persist-progression.md)
@@ -1,35 +0,0 @@
---
type: User Story
title: "US-002: Rejoin without a visible identity"
description: Let an unlocked player turn a qualifying invisible disconnect into one identity-concealed session.
status: done
---
# US-002: Rejoin without a visible identity
As an **unlocked player**, I want to disconnect while invisibility from a potion I drank is active so that my identity is concealed throughout my next session.
## Acceptance criteria
- [x] An unlocked player becomes eligible for a concealed login only by disconnecting while an invisibility effect from a potion they directly drank remains active.
- [x] A player who has not unlocked stealth cannot prepare a concealed login.
- [x] An ordinary disconnect without an active qualifying effect clears any preparation for the next login.
- [x] On a prepared login, no public join announcement is shown.
- [x] Throughout the concealed session, the player is absent from every other player's tab list, including administrators' tab lists.
- [x] Throughout the concealed session, no overhead name tag identifies the player to any other player, including administrators.
- [x] The concealed player's physical character remains visible in the world and retains ordinary movement, interaction, combat, and permission behavior.
- [x] The concealed player receives a private message explaining that stealth is active for the session.
- [x] Concealment lasts until the player disconnects and is handled predictably across death and plugin reload or disable events.
- [x] Disconnecting consumes the current concealed session; another concealed login requires another qualifying potion and qualifying disconnect.
- [x] Merely owning the unlock never conceals an ordinary login or carries concealment automatically into a later session.
- [x] Prepared-login state survives a server restart between the qualifying disconnect and the next login.
## Validation
Automated tests verify unlocked and locked disconnect transitions, ordinary-disconnect clearing, one-login consumption, announcement suppression, private activation messaging, ordinary-login presentation, tab removal for existing and new observers, overhead-name suppression, and the absence of entity-hiding calls. ProtocolLib is declared as a required dependency, prepared state round trips through YAML, and `./gradlew clean check jar` passes.
## Related
- [Accumulate invisibility time and unlock stealth](us-001-accumulate-invisibility-and-unlock.md)
- [Inspect and manage player stealth](us-004-administer-player-stealth.md)
- [Configure and persist stealth progression](us-005-configure-and-persist-progression.md)
@@ -1,31 +0,0 @@
---
type: User Story
title: "US-003: Check personal stealth progress"
description: Show a player their accumulated qualifying invisibility time and stealth unlock status.
status: done
---
# US-003: Check personal stealth progress
As a **player**, I want to check my stealth progress so that I know how close I am to unlocking the ability and how to use it afterward.
## Acceptance criteria
- [x] `/stealth progress` reports the player's accumulated qualifying invisibility time.
- [x] Before unlock, the command reports the configured target and remaining duration.
- [x] While a qualifying effect is active, the report includes elapsed time not yet written during the current tracking interval.
- [x] After unlock, the command clearly reports that stealth is unlocked and explains how to prepare a concealed login.
- [x] Durations are presented in a concise, human-readable form.
- [x] Repeated command use does not change progression or concealment state.
- [x] The command has clear usage metadata and an appropriate player permission.
- [x] `/stealth` tab-completes `progress`, filtered by the entered prefix.
- [x] Unsupported argument positions return no suggestions instead of Bukkit's generic player list.
## Validation
Automated tests verify concise duration formatting, live in-flight progress, configured target and remaining output, command non-mutation, unlocked usage guidance, prefix-filtered completion, and suppression of irrelevant suggestions. Command metadata and the complete `./gradlew clean check jar` lifecycle pass.
## Related
- [Accumulate invisibility time and unlock stealth](us-001-accumulate-invisibility-and-unlock.md)
- [Configure and persist stealth progression](us-005-configure-and-persist-progression.md)
@@ -1,41 +0,0 @@
---
type: User Story
title: "US-004: Inspect and manage player stealth"
description: Give administrators durable controls for online and offline progression, unlocks, and active concealment.
status: done
---
# US-004: Inspect and manage player stealth
As a **server administrator**, I want to inspect and correct player stealth state so that I can support players whether they are online or offline.
## Acceptance criteria
- [x] `/stealthadmin status <player|uuid>` reports accumulated time, unlock status, prepared-login state, and current concealment state.
- [x] Status inspection works for online players and known offline players selected by exact current or previously recorded name or UUID.
- [x] `/stealthadmin grant <player|uuid>` grants the unlock to an online or known offline player without altering accumulated time unnecessarily.
- [x] Granting an already-owned unlock is safe and clearly reports that no change was needed.
- [x] Granting the unlock sends the normal full-screen title and chat notification when the target is online.
- [x] `/stealthadmin reset <player|uuid> confirm` clears accumulated time, unlock ownership, prepared-login state, and current concealment for an online or known offline player.
- [x] Reset requires explicit confirmation and safely restores an online concealed player to ordinary identity presentation.
- [x] `/stealthadmin list` lists every currently online concealed player and clearly reports when there are none.
- [x] Concealed players remain absent from administrators' ordinary tab lists and retain hidden overhead name tags; the admin command is the supported discovery mechanism.
- [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.
## 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, confirmation and permission gates, persisted-before-success replies, and audit records. The complete `./gradlew clean check jar` lifecycle passes.
## Related
- [Rejoin without a visible identity](us-002-rejoin-without-visible-identity.md)
- [Configure and persist stealth progression](us-005-configure-and-persist-progression.md)
@@ -1,35 +0,0 @@
---
type: User Story
title: "US-005: Configure and persist stealth progression"
description: Give operators validated settings and durable, defensive storage for stealth behavior.
status: done
---
# US-005: Configure and persist stealth progression
As a **server operator**, I want stealth progression to be configurable and durable so that the plugin remains predictable across restarts and balance changes.
## Acceptance criteria
- [x] The qualifying-time threshold is configurable and defaults to eight hours.
- [x] Player-facing progress, unlock, prepared-login, and concealed-session messages are configurable.
- [x] Startup validates required settings before registering partially functional listeners, commands, or tasks.
- [x] Invalid required configuration prevents initialization and produces a clear server log message.
- [x] UUID-keyed state stores accumulated qualifying duration, unlock ownership, active qualifying timing data, prepared-login state, and any current concealment metadata needed for safe recovery.
- [x] Qualifying runtime intervals use a monotonic elapsed-time source so wall-clock adjustments cannot grant or remove progress.
- [x] Durable timestamps, when required, use RFC 3339 UTC notation.
- [x] State is saved periodically, after material state changes, and during orderly plugin disable.
- [x] State uses atomic replacement where supported so an interrupted write does not replace valid data with a partial file.
- [x] Corrupt, unknown, or invalid records cannot silently grant time, an unlock, a prepared login, or concealment.
- [x] Unknown forward-compatible fields are preserved where practical.
- [x] Persistence work does not perform blocking file operations on the server tick thread.
## 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`.
## Related
- [Accumulate invisibility time and unlock stealth](us-001-accumulate-invisibility-and-unlock.md)
- [Rejoin without a visible identity](us-002-rejoin-without-visible-identity.md)
- [Inspect and manage player stealth](us-004-administer-player-stealth.md)
@@ -1,31 +0,0 @@
---
type: User Story
title: "US-006: Build, test, and release the plugin"
description: Give maintainers repeatable builds, automated verification, and versioned Gitea releases.
status: done
---
# US-006: Build, test, and release the plugin
As a **plugin maintainer**, I want automated builds and releases modeled on Spigot Tyrant so that tested, correctly versioned artifacts can be distributed consistently.
## Acceptance criteria
- [x] The Gradle Kotlin DSL project compiles against Spigot API `26.2-R0.1-SNAPSHOT` using a Java 17 toolchain.
- [x] Compiler lint warnings fail the build.
- [x] Automated JUnit 5 and Mockito tests run as part of the Gradle check lifecycle.
- [x] Pushes and pull requests build and test the plugin in Gitea Actions.
- [x] Pull requests validate conventional commit messages.
- [x] CI stores a `spigot-stealth` development JAR as a workflow artifact.
- [x] Main-branch conventional commits drive semantic versioning.
- [x] A successful release builds a versioned JAR and attaches it to the corresponding Gitea release.
- [x] Build files, Gradle wrapper, workflows, and release behavior follow `../spigot-tyrant/` where applicable while using Spigot Stealth names and identifiers.
- [x] OKF validation using `okf-base.yaml` runs locally through the repository validation script and in CI.
## Validation
Verified with `./gradlew clean check jar`, a release-version `1.2.3` JAR with matching embedded plugin metadata, and `./scripts/validate-okf.sh`.
## Related
- [Configure and persist stealth progression](us-005-configure-and-persist-progression.md)
-23
View File
@@ -1,23 +0,0 @@
okf_version: "0.1"
base:
name: Spigot Stealth Design
roots:
- path: design
reserved_files:
index: index.md
log: log.md
profile:
types:
User Story:
required: [type, title, description, status]
optional: []
status_values: [backlog, in-progress, done]
date_fields: []
hygiene:
broken_links: error
reserved_files: error
unknown_fields: error
split_candidates: off
-111
View File
@@ -1,111 +0,0 @@
#!/usr/bin/env bash
# OKF Bundle Validator v0.1
# Usage: validate.sh <bundle-path>
# Checks conformance with OKF v0.1 spec:
# E1: All non-reserved .md files have YAML frontmatter
# E2: All frontmatter has non-empty 'type' field
# E3: Reserved files follow structure rules
set -euo pipefail
REPOSITORY_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BUNDLE="${1:-${REPOSITORY_ROOT}/design}"
MANIFEST="${REPOSITORY_ROOT}/okf-base.yaml"
if command -v okflint >/dev/null 2>&1; then
exec okflint validate --manifest "$MANIFEST" "$BUNDLE"
fi
echo "Warning: okflint is unavailable; running core OKF v0.1 validation only." >&2
ERRORS=0
WARNINGS=0
TOTAL=0
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
NC='\033[0m'
if [ ! -d "$BUNDLE" ]; then
echo -e "${RED}Error: '$BUNDLE' is not a directory${NC}"
exit 1
fi
echo "Validating OKF bundle: $BUNDLE"
echo "---"
# Find all .md files
while IFS= read -r -d '' file; do
TOTAL=$((TOTAL + 1))
relative="${file#$BUNDLE/}"
basename=$(basename "$file")
# Skip reserved files (validate separately)
if [[ "$basename" == "index.md" || "$basename" == "log.md" ]]; then
# E3: Check reserved file structure
if [[ "$basename" == "index.md" ]]; then
# index.md should NOT have frontmatter (except bundle root may have okf_version)
if head -1 "$file" | grep -q "^---$"; then
# Allow only if it's bundle root and contains okf_version
if [[ "$relative" != "index.md" ]]; then
echo -e "${RED}E3: $relative — index.md should not have frontmatter${NC}"
ERRORS=$((ERRORS + 1))
fi
fi
fi
if [[ "$basename" == "log.md" ]]; then
# log.md should have date headings in YYYY-MM-DD format
if ! grep -qE "^## [0-9]{4}-[0-9]{2}-[0-9]{2}" "$file" 2>/dev/null; then
if [ -s "$file" ]; then
echo -e "${YELLOW}W: $relative — log.md has no ISO 8601 date headings${NC}"
WARNINGS=$((WARNINGS + 1))
fi
fi
fi
continue
fi
# E1: Check for YAML frontmatter
if ! head -1 "$file" | grep -q "^---$"; then
echo -e "${RED}E1: $relative — no YAML frontmatter${NC}"
ERRORS=$((ERRORS + 1))
continue
fi
# Extract frontmatter (between first --- and second ---)
frontmatter=$(sed -n '2,/^---$/p' "$file" | sed '$d')
# E2: Check for non-empty type field
type_value=$(echo "$frontmatter" | grep -E "^type:" | sed 's/^type:\s*//' | tr -d '"' | tr -d "'" | xargs)
if [ -z "$type_value" ]; then
echo -e "${RED}E2: $relative — missing or empty 'type' field${NC}"
ERRORS=$((ERRORS + 1))
continue
fi
# Warnings for recommended fields
if ! echo "$frontmatter" | grep -qE "^title:"; then
echo -e "${YELLOW}W1: $relative — missing recommended 'title' field${NC}"
WARNINGS=$((WARNINGS + 1))
fi
if ! echo "$frontmatter" | grep -qE "^description:"; then
echo -e "${YELLOW}W1: $relative — missing recommended 'description' field${NC}"
WARNINGS=$((WARNINGS + 1))
fi
done < <(find "$BUNDLE" -name "*.md" -type f -print0 | sort -z)
# Summary
echo "---"
echo "Files scanned: $TOTAL"
if [ $ERRORS -eq 0 ]; then
echo -e "${GREEN}✅ Bundle is OKF v0.1 conformant${NC}"
else
echo -e "${RED}$ERRORS error(s) — bundle is NOT conformant${NC}"
fi
if [ $WARNINGS -gt 0 ]; then
echo -e "${YELLOW}$WARNINGS warning(s)${NC}"
fi
exit $ERRORS
+1 -1
View File
@@ -1 +1 @@
rootProject.name = "spigot-stealth" rootProject.name = "purpur-stealth"
@@ -15,25 +15,54 @@ public final class BukkitIdentityPresentation implements IdentityPresentation {
private final Supplier<? extends Collection<? extends Player>> onlinePlayers; private final Supplier<? extends Collection<? extends Player>> onlinePlayers;
private final Scoreboard scoreboard; private final Scoreboard scoreboard;
private final TabListController tabLists; private final TabListController tabLists;
private final Supplier<SleepCountPolicy> sleepCountPolicy;
private final Map<UUID, Player> concealedPlayers = new LinkedHashMap<>(); 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( public BukkitIdentityPresentation(
Supplier<? extends Collection<? extends Player>> onlinePlayers, Supplier<? extends Collection<? extends Player>> onlinePlayers,
Scoreboard scoreboard, Scoreboard scoreboard,
TabListController tabLists) { 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.onlinePlayers = Objects.requireNonNull(onlinePlayers, "onlinePlayers");
this.scoreboard = Objects.requireNonNull(scoreboard, "scoreboard"); this.scoreboard = Objects.requireNonNull(scoreboard, "scoreboard");
this.tabLists = Objects.requireNonNull(tabLists, "tabLists"); this.tabLists = Objects.requireNonNull(tabLists, "tabLists");
this.sleepCountPolicy = Objects.requireNonNull(sleepCountPolicy, "sleepCountPolicy");
} }
@Override @Override
public void conceal(Player player) { public void conceal(Player player) {
concealedPlayers.put(player.getUniqueId(), 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())); Team team = scoreboard.getTeam(teamName(player.getUniqueId()));
if (team == null) { if (team == null) {
team = scoreboard.registerNewTeam(teamName(player.getUniqueId())); team = scoreboard.registerNewTeam(teamName(player.getUniqueId()));
} }
team.setOption(Team.Option.NAME_TAG_VISIBILITY, Team.OptionStatus.NEVER); team.setOption(Team.Option.NAME_TAG_VISIBILITY, Team.OptionStatus.ALWAYS);
team.setPrefix("§k");
team.setSuffix("§r");
team.addEntry(player.getName()); team.addEntry(player.getName());
for (Player observer : onlinePlayers.get()) { for (Player observer : onlinePlayers.get()) {
if (!observer.getUniqueId().equals(player.getUniqueId())) { if (!observer.getUniqueId().equals(player.getUniqueId())) {
@@ -45,6 +74,11 @@ public final class BukkitIdentityPresentation implements IdentityPresentation {
@Override @Override
public void reveal(Player player) { public void reveal(Player player) {
boolean wasConcealed = concealedPlayers.remove(player.getUniqueId()) != null; 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())); Team team = scoreboard.getTeam(teamName(player.getUniqueId()));
if (team != null) { if (team != null) {
wasConcealed = true; wasConcealed = true;
@@ -69,6 +103,29 @@ public final class BukkitIdentityPresentation implements IdentityPresentation {
} }
} }
@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) { public static String teamName(UUID playerId) {
return "stlth" + playerId.toString().replace("-", "").substring(0, 11); return "stlth" + playerId.toString().replace("-", "").substring(0, 11);
} }
@@ -0,0 +1,44 @@
package games.dmg.spigotstealth;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageEvent;
/** Ends identity concealment on attributed, successful player damage; never removes potion effects. */
public final class CombatRevealListener implements Listener {
private final StealthSessionService sessions;
private final IdentityPresentation presentation;
private final Consumer<UUID> withdrawEyeTarget;
private final Consumer<Throwable> failures;
private final java.util.function.Function<EntityDamageEvent, org.bukkit.entity.Player> potionAttacker;
public CombatRevealListener(StealthSessionService sessions, IdentityPresentation presentation,
Consumer<UUID> withdrawEyeTarget, Consumer<Throwable> failures) {
this(sessions, presentation, withdrawEyeTarget, failures, event -> null);
}
public CombatRevealListener(StealthSessionService sessions, IdentityPresentation presentation,
Consumer<UUID> withdrawEyeTarget, Consumer<Throwable> failures,
java.util.function.Function<EntityDamageEvent, org.bukkit.entity.Player> potionAttacker) {
this.sessions = java.util.Objects.requireNonNull(sessions);
this.presentation = java.util.Objects.requireNonNull(presentation);
this.withdrawEyeTarget = java.util.Objects.requireNonNull(withdrawEyeTarget);
this.failures = java.util.Objects.requireNonNull(failures);
this.potionAttacker = java.util.Objects.requireNonNull(potionAttacker);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onDamage(EntityDamageEvent event) {
if (event.isCancelled() || !Double.isFinite(event.getFinalDamage()) || event.getFinalDamage() <= 0
|| !(event.getEntity() instanceof org.bukkit.entity.Player victim)) { return; }
var attacker = event.getDamageSource().getCausingEntity() instanceof org.bukkit.entity.Player direct ? direct : potionAttacker.apply(event);
if (attacker == null || attacker.getUniqueId().equals(victim.getUniqueId()) || !sessions.isConcealed(attacker.getUniqueId())) { return; }
sessions.endConcealment(attacker.getUniqueId()).exceptionally(failure -> { failures.accept(failure); return null; });
withdrawEyeTarget.accept(attacker.getUniqueId());
presentation.reveal(attacker);
attacker.sendMessage("Your stealth was broken because you hurt another player.");
}
}
@@ -0,0 +1,66 @@
package games.dmg.spigotstealth;
import java.util.Objects;
import org.bukkit.Keyed;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.inventory.PrepareItemCraftEvent;
import org.bukkit.inventory.ShapedRecipe;
/** Personal crafting unlock for the transferable Eye. Vanilla owns ingredient consumption. */
public final class EyeCrafting implements Listener {
public static final NamespacedKey RECIPE_KEY = new NamespacedKey("spigotstealth", "eye_of_true_seeing");
private final EyeItems items;
private final EyeProgressionService progression;
public EyeCrafting(EyeProgressionService progression, EyeItems items) {
this.progression = Objects.requireNonNull(progression, "progression");
this.items = Objects.requireNonNull(items, "items");
}
public ShapedRecipe recipe() {
return new ShapedRecipe(RECIPE_KEY, items.create()).shape("NNN", "NEN", "NNN")
.setIngredient('N', Material.NETHERITE_BLOCK).setIngredient('E', Material.ENDER_EYE);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onCraft(org.bukkit.event.inventory.CraftItemEvent event) {
if (event.isCancelled() || !matches(event.getRecipe())) { return; }
if (!(event.getWhoClicked() instanceof Player player) || !progression.isUnlocked(player.getUniqueId())
|| !accepts(event.getInventory().getMatrix()) || !items.isEye(event.getInventory().getResult())
|| event.getInventory().getResult().getAmount() != 1) {
event.setCancelled(true);
event.getInventory().setResult(null);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onCrafter(org.bukkit.event.block.CrafterCraftEvent event) {
if (matches(event.getRecipe())) { event.setCancelled(true); }
}
private static boolean matches(org.bukkit.inventory.Recipe recipe) {
return recipe instanceof Keyed keyed && RECIPE_KEY.equals(keyed.getKey());
}
private static boolean accepts(org.bukkit.inventory.ItemStack[] matrix) {
if (matrix.length != 9) { return false; }
for (int index = 0; index < matrix.length; index++) {
if (matrix[index] == null || matrix[index].getAmount() <= 0
|| matrix[index].getType() != (index == 4 ? Material.ENDER_EYE : Material.NETHERITE_BLOCK)) { return false; }
}
return true;
}
@EventHandler(priority = EventPriority.HIGHEST)
public void onPrepare(PrepareItemCraftEvent event) {
if (!(event.getInventory().getRecipe() instanceof Keyed keyed) || !RECIPE_KEY.equals(keyed.getKey())) { return; }
if (!(event.getView().getPlayer() instanceof Player player) || !progression.isUnlocked(player.getUniqueId())) {
event.getInventory().setResult(null);
}
}
}
@@ -0,0 +1,47 @@
package games.dmg.spigotstealth;
import java.util.Objects;
import org.bukkit.entity.Player;
import org.bukkit.event.Event;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.EquipmentSlot;
/** Player-specific eligibility and inventory movement; never invokes native Ender Eye use. */
public final class EyeEquipment implements Listener {
private final EyeProgressionService progression;
private final EyeItems items;
public EyeEquipment(EyeProgressionService progression, EyeItems items) {
this.progression = Objects.requireNonNull(progression, "progression");
this.items = Objects.requireNonNull(items, "items");
}
public boolean isEligibleWearer(Player player) {
return progression.isUnlocked(player.getUniqueId()) && items.isEye(player.getInventory().getHelmet());
}
// Interact-in-air may start with block use denied; respect item-use denial rather than isCancelled().
@EventHandler(priority = EventPriority.HIGHEST)
public void onUse(PlayerInteractEvent event) {
if (!event.getAction().isRightClick() || event.useItemInHand() == Event.Result.DENY || !items.isEye(event.getItem())) { return; }
event.setUseItemInHand(Event.Result.DENY);
event.setUseInteractedBlock(Event.Result.DENY);
Player player = event.getPlayer();
if (player.isDead() || !progression.isUnlocked(player.getUniqueId())
|| (event.getHand() != EquipmentSlot.HAND && event.getHand() != EquipmentSlot.OFF_HAND)) { return; }
var inventory = player.getInventory();
var helmet = inventory.getHelmet();
if (helmet != null && helmet.getAmount() > 0 && !helmet.getType().isAir()) { return; }
var held = inventory.getItem(event.getHand());
if (!items.isEye(held)) { return; }
var equipped = held.clone();
equipped.setAmount(1);
var remaining = held.clone();
remaining.setAmount(held.getAmount() - 1);
inventory.setItem(event.getHand(), remaining.getAmount() == 0 ? null : remaining);
inventory.setHelmet(equipped);
}
}
@@ -0,0 +1,34 @@
package games.dmg.spigotstealth;
import net.kyori.adventure.text.Component;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.ItemStack;
import org.bukkit.persistence.PersistentDataType;
/** Creates and identifies the transferable Eye; eligibility belongs to the holder's durable progression. */
public final class EyeItems {
private static final NamespacedKey IDENTITY = new NamespacedKey("spigotstealth", "eye_of_true_seeing");
public ItemStack create() {
var eye = new ItemStack(Material.ENDER_EYE);
var meta = eye.getItemMeta();
meta.displayName(Component.text("Eye of True Seeing"));
meta.getPersistentDataContainer().set(IDENTITY, PersistentDataType.BYTE, (byte) 1);
meta.setMaxStackSize(1);
var equippable = meta.getEquippable();
equippable.setSlot(EquipmentSlot.HEAD);
equippable.setDispensable(false);
equippable.setEquipOnInteract(false);
equippable.setDamageOnHurt(false);
meta.setEquippable(equippable);
eye.setItemMeta(meta);
return eye;
}
public boolean isEye(ItemStack item) {
return item != null && item.getAmount() > 0 && item.getType() == Material.ENDER_EYE && item.hasItemMeta()
&& Byte.valueOf((byte) 1).equals(item.getItemMeta().getPersistentDataContainer().get(IDENTITY, PersistentDataType.BYTE));
}
}
@@ -0,0 +1,66 @@
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 java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.function.Supplier;
import org.bukkit.plugin.Plugin;
/** Outgoing packet projection reads immutable authorization and never queries world or inventory state. */
public final class EyePacketListener extends PacketAdapter {
private final Supplier<Map<UUID, Map<Integer, EyeRevealController.Projection>>> views;
private final Consumer<UUID> refresh;
private final Consumer<String> warning;
private final Set<PacketType> warned = ConcurrentHashMap.newKeySet();
public EyePacketListener(Plugin plugin, Supplier<Map<UUID, Map<Integer, EyeRevealController.Projection>>> views,
Consumer<UUID> refresh, Consumer<String> warning) {
super(plugin, ListenerPriority.HIGHEST, PacketType.Play.Server.ENTITY_METADATA, PacketType.Play.Server.SCOREBOARD_TEAM);
this.views = Objects.requireNonNull(views);
this.refresh = Objects.requireNonNull(refresh);
this.warning = Objects.requireNonNull(warning);
}
@Override public void onPacketSending(PacketEvent event) {
if (event.isCancelled() || event.getPlayer() == null || event.isPlayerTemporary()) { return; }
try {
// UUID is immutable connection identity; no inventory, world, visibility or effect queries occur here.
UUID viewer = event.getPlayer().getUniqueId();
if (viewer == null) { return; }
var current = views.get().getOrDefault(viewer, Map.of());
if (current.isEmpty()) { return; }
Object original = event.getPacket().getHandle();
if (event.getPacketType().equals(PacketType.Play.Server.ENTITY_METADATA)) {
int id = (Integer) original.getClass().getMethod("id").invoke(original);
var projection = current.get(id);
if (projection != null && projection.revealBody()) {
event.setPacket(new PacketContainer(event.getPacketType(), EyePacketProjection.metadataPacket(original)));
}
} else if (event.getPacketType().equals(PacketType.Play.Server.SCOREBOARD_TEAM)) {
String name = (String) original.getClass().getMethod("getName").invoke(original);
var privateTeams = current.values().stream().map(EyeRevealController.Projection::privateTeam)
.collect(java.util.stream.Collectors.toUnmodifiableSet());
var names = current.values().stream().map(EyeRevealController.Projection::playerName)
.collect(java.util.stream.Collectors.toUnmodifiableSet());
if (!privateTeams.contains(name) && current.values().stream().anyMatch(p -> p.sourceTeam().filter(name::equals).isPresent())) {
refresh.accept(viewer); // Only marks work; the callback must not query Bukkit here.
}
Object projected = EyePacketProjection.teamPacket(original, privateTeams, names);
if (projected != original) { event.setPacket(new PacketContainer(event.getPacketType(), projected)); }
}
} catch (ReflectiveOperationException | RuntimeException exception) {
if (warned.add(event.getPacketType())) {
warning.accept("Eye projection unavailable for " + event.getPacketType()
+ "; original packet retained. Failure: " + exception.getClass().getSimpleName());
}
}
}
}
@@ -0,0 +1,115 @@
package games.dmg.spigotstealth;
import java.lang.reflect.RecordComponent;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/** Pure copies of declared-runtime packet data; no Bukkit lookups or global entity/team mutations. */
public final class EyePacketProjection {
private static final String TEAM_PARAMETERS = "net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket$Parameters";
private EyePacketProjection() { }
public static Object teamPacket(Object original, java.util.Set<String> privateTeams, java.util.Set<String> projectedPlayers) {
Objects.requireNonNull(original, "original");
Class<?> type = original.getClass();
if (!type.getName().equals("net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket")) {
throw new IllegalArgumentException("Unsupported native team packet");
}
try {
String name = (String) type.getMethod("getName").invoke(original);
var players = (java.util.Collection<?>) type.getMethod("getPlayers").invoke(original);
var parameters = (java.util.Optional<?>) type.getMethod("getParameters").invoke(original);
boolean privateTeam = privateTeams.contains(name);
var projectedParameters = privateTeam ? parameters.map(EyePacketProjection::teamParameters) : parameters;
var projectedMembers = privateTeam ? players : players.stream().filter(player -> !projectedPlayers.contains(player)).toList();
if (projectedParameters == parameters && projectedMembers.size() == players.size()) { return original; }
var method = type.getDeclaredField("method");
method.setAccessible(true);
var constructor = type.getDeclaredConstructor(String.class, int.class, java.util.Optional.class, java.util.Collection.class);
constructor.setAccessible(true);
return constructor.newInstance(name, method.getInt(original), projectedParameters, projectedMembers);
} catch (ReflectiveOperationException exception) {
throw new IllegalStateException("Could not project native team membership", exception);
}
}
public static Object metadataPacket(Object original) {
Objects.requireNonNull(original, "original");
Class<?> type = original.getClass();
if (!type.getName().equals("net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket")) {
throw new IllegalArgumentException("Unsupported native metadata packet");
}
try {
var values = (List<?>) type.getMethod("packedItems").invoke(original);
var projected = new ArrayList<Object>(values.size());
for (Object value : values) {
Class<?> valueType = value.getClass();
if (!valueType.getName().equals("net.minecraft.network.syncher.SynchedEntityData$DataValue")) {
throw new IllegalArgumentException("Unsupported native metadata value");
}
int index = (Integer) valueType.getMethod("id").invoke(value);
if (index != 0) { projected.add(value); continue; }
Object raw = valueType.getMethod("value").invoke(value);
if (!(raw instanceof Byte flags)) { throw new IllegalArgumentException("Shared entity flags must be a byte"); }
Object serializer = valueType.getMethod("serializer").invoke(value);
projected.add(valueType.getConstructor(int.class,
Class.forName("net.minecraft.network.syncher.EntityDataSerializer"), Object.class)
.newInstance(index, serializer, (byte) (flags & ~0x20)));
}
return type.getConstructor(int.class, List.class)
.newInstance(type.getMethod("id").invoke(original), List.copyOf(projected));
} catch (ReflectiveOperationException exception) {
throw new IllegalStateException("Could not project native entity metadata", exception);
}
}
public static Object teamParameters(Object original) {
try {
Object empty = Class.forName("net.minecraft.network.chat.Component").getMethod("empty").invoke(null);
return copyParameters(original, java.util.Map.of("playerPrefix", empty));
} catch (ReflectiveOperationException exception) {
throw new IllegalStateException("Could not project the native team prefix", exception);
}
}
public static Object teamVisibility(Object original, boolean visible) {
try {
Object visibility = Class.forName("net.minecraft.world.scores.Team$Visibility").getField(visible ? "ALWAYS" : "NEVER").get(null);
return copyParameters(original, java.util.Map.of("nameTagVisibility", visibility));
} catch (ReflectiveOperationException exception) {
throw new IllegalStateException("Could not preserve effective team visibility", exception);
}
}
private static Object copyParameters(Object original, java.util.Map<String, Object> replacements) {
Objects.requireNonNull(original, "original");
Class<?> type = original.getClass();
if (!TEAM_PARAMETERS.equals(type.getName()) || !type.isRecord()) {
throw new IllegalArgumentException("Unsupported native team parameter layout");
}
try {
RecordComponent[] components = type.getRecordComponents();
Class<?>[] types = new Class<?>[components.length];
Object[] values = new Object[components.length];
int replaced = 0;
for (int index = 0; index < components.length; index++) {
var component = components[index];
types[index] = component.getType();
if (replacements.containsKey(component.getName())) {
values[index] = replacements.get(component.getName());
replaced++;
} else {
// Accessors may consult global collision configuration. Copy the stored record data verbatim.
var field = type.getDeclaredField(component.getName());
field.setAccessible(true);
values[index] = field.get(original);
}
}
if (replaced != replacements.size()) { throw new IllegalArgumentException("Required native team fields are unavailable"); }
return type.getDeclaredConstructor(types).newInstance(values);
} catch (ReflectiveOperationException exception) {
throw new IllegalStateException("Could not project the native team prefix", exception);
}
}
}
@@ -0,0 +1,101 @@
package games.dmg.spigotstealth;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityPotionEffectEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
/** Night Vision potion-event adapter; progression and persistence are owned by the service. */
public final class EyePotionListener implements Listener {
private final EyeProgressionService progression;
private final Consumer<Throwable> saveFailure;
private final Map<UUID, PotionEffect> expectedEffects = new HashMap<>();
public EyePotionListener(EyeProgressionService progression, Consumer<Throwable> saveFailure) {
this.progression = Objects.requireNonNull(progression, "progression");
this.saveFailure = Objects.requireNonNull(saveFailure, "saveFailure");
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onPotionEffect(EntityPotionEffectEvent event) {
if (event.isCancelled() || !(event.getEntity() instanceof Player player)
|| !PotionEffectType.NIGHT_VISION.equals(event.getModifiedType())) {
return;
}
if (event.getAction() == EntityPotionEffectEvent.Action.CHANGED && !replacesActiveEffect(event)) {
return;
}
boolean addedOrChanged = event.getAction() == EntityPotionEffectEvent.Action.ADDED
|| event.getAction() == EntityPotionEffectEvent.Action.CHANGED;
if (addedOrChanged && event.getCause() == EntityPotionEffectEvent.Cause.POTION_DRINK) {
if (!continuesEffect(expectedEffects.get(player.getUniqueId()), event.getOldEffect())) {
progression.abandon(player.getUniqueId());
}
expectedEffects.put(player.getUniqueId(), event.getNewEffect());
observe(progression.begin(player.getUniqueId()));
} else {
finish(player.getUniqueId(), event.getOldEffect());
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onQuit(PlayerQuitEvent event) {
finish(event.getPlayer().getUniqueId(), event.getPlayer().getPotionEffect(PotionEffectType.NIGHT_VISION));
}
/** Server-thread observation; a missing/unattributed effect cannot inherit an old drink interval. */
public void checkpoint(Function<UUID, Player> onlinePlayer) {
for (var id : progression.activePlayerIds()) {
Player player = onlinePlayer.apply(id);
var actual = player == null || !player.isOnline() || player.isDead()
? null : player.getPotionEffect(PotionEffectType.NIGHT_VISION);
var expected = expectedEffects.get(id);
if (!continuesEffect(expected, actual) || (!actual.isInfinite() && actual.getDuration() <= 0)) {
expectedEffects.remove(id);
progression.abandon(id); // Discard the unobserved tail, never attribute it to the old source.
} else {
expectedEffects.put(id, actual);
observe(progression.checkpoint(id));
}
}
}
private void finish(UUID id, PotionEffect finalEffect) {
var expected = expectedEffects.remove(id);
if (continuesEffect(expected, finalEffect)) { observe(progression.stop(id)); }
else { progression.abandon(id); }
}
private static boolean continuesEffect(PotionEffect expected, PotionEffect actual) {
return actual != null && expected != null && actual.getAmplifier() == expected.getAmplifier()
&& (expected.isInfinite() ? actual.isInfinite()
: !actual.isInfinite() && actual.getDuration() <= expected.getDuration());
}
private void observe(CompletableFuture<Void> saved) {
saved.whenComplete((ignored, failure) -> {
if (failure != null) { saveFailure.accept(failure); }
});
}
private static boolean replacesActiveEffect(EntityPotionEffectEvent event) {
if (!event.isOverride() || event.getOldEffect() == null || event.getNewEffect() == null) { return false; }
var previous = event.getOldEffect();
var next = event.getNewEffect();
// Native update can return true for particles/icons alone. Hidden weaker effects are not active yet.
return next.getAmplifier() > previous.getAmplifier()
|| (next.getAmplifier() == previous.getAmplifier() && !previous.isInfinite()
&& (next.isInfinite() || next.getDuration() > previous.getDuration()));
}
}
@@ -0,0 +1,13 @@
package games.dmg.spigotstealth;
import java.util.Map;
/** Independent durable Eye of True Seeing progress. Active potion intervals are never resumed from disk. */
public record EyeProgress(long accumulatedMillis, boolean unlocked, Map<String, Object> unknownFields) {
public EyeProgress {
if (accumulatedMillis < 0) { throw new IllegalArgumentException("Eye progress cannot be negative"); }
unknownFields = Map.copyOf(unknownFields);
}
public EyeProgress(long accumulatedMillis, boolean unlocked) { this(accumulatedMillis, unlocked, Map.of()); }
public static EyeProgress empty() { return new EyeProgress(0, false); }
}
@@ -0,0 +1,73 @@
package games.dmg.spigotstealth;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import java.util.function.LongSupplier;
/** Server-thread qualifying intervals; durable rewards are acknowledged by the existing I/O executor. */
public final class EyeProgressionService {
public static final Duration UNLOCK_THRESHOLD = Duration.ofHours(8);
private record Interval(long startedNanos, long creditedMillis) { }
private final StealthStateManager states;
private final LongSupplier nanoTime;
private final Consumer<UUID> unlocked;
private final Map<UUID, Interval> active = new HashMap<>();
private final Set<UUID> announced = ConcurrentHashMap.newKeySet();
/** The notification callback must marshal Bukkit work onto the server thread. */
public EyeProgressionService(StealthStateManager states, LongSupplier nanoTime, Consumer<UUID> unlocked) {
this.states = Objects.requireNonNull(states, "states");
this.nanoTime = Objects.requireNonNull(nanoTime, "nanoTime");
this.unlocked = Objects.requireNonNull(unlocked, "unlocked");
states.durableSnapshot().eyes().forEach((id, progress) -> {
if (progress.unlocked()) { announced.add(id); }
});
}
public CompletableFuture<Void> begin(UUID id) {
Objects.requireNonNull(id, "id");
if (active.containsKey(id)) { return checkpoint(id); }
active.put(id, new Interval(nanoTime.getAsLong(), 0));
return CompletableFuture.completedFuture(null);
}
public CompletableFuture<Void> checkpoint(UUID id) {
Interval interval = active.get(id);
if (interval == null) { return CompletableFuture.completedFuture(null); }
// Keep the original start: repeated checkpoints must not discard fractional milliseconds.
long total = Math.max(interval.creditedMillis(), Math.max(0, nanoTime.getAsLong() - interval.startedNanos()) / 1_000_000);
long elapsed = total - interval.creditedMillis();
if (elapsed == 0) { return CompletableFuture.completedFuture(null); }
active.put(id, new Interval(interval.startedNanos(), total));
return states.update(state -> {
EyeProgress previous = state.eyeProgress(id);
long accumulated = Math.addExact(previous.accumulatedMillis(), elapsed);
return state.withEyeProgress(id, new EyeProgress(accumulated,
previous.unlocked() || accumulated >= UNLOCK_THRESHOLD.toMillis(), previous.unknownFields()));
}).thenRun(() -> {
if (isUnlocked(id) && announced.add(id)) { unlocked.accept(id); }
});
}
public CompletableFuture<Void> stop(UUID id) {
CompletableFuture<Void> saved = checkpoint(id);
active.remove(id);
return saved;
}
public CompletableFuture<Void> stopAll() {
return CompletableFuture.allOf(activePlayerIds().stream().map(this::stop).toArray(CompletableFuture[]::new));
}
/** Defensive offline sweep: do not invent credit for the unobserved interval after the last checkpoint. */
public void abandon(UUID id) { active.remove(id); }
public boolean isUnlocked(UUID id) { return states.durableSnapshot().eyeProgress(id).unlocked(); }
public Set<UUID> activePlayerIds() { return Set.copyOf(active.keySet()); }
}
@@ -0,0 +1,233 @@
package games.dmg.spigotstealth;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.function.Predicate;
import java.util.function.Supplier;
import org.bukkit.GameMode;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.metadata.MetadataValue;
import org.bukkit.potion.PotionEffectType;
/** Server-thread visibility capture and delivery; published projections contain no mutable Bukkit objects. */
public final class EyeRevealController implements Runnable, AutoCloseable {
public record Projection(UUID targetId, int entityId, String playerName, String privateTeam,
Optional<String> sourceTeam, boolean revealBody) { }
public interface Delivery {
void show(Player viewer, Player target, Projection projection, boolean create);
void restore(Player viewer, Optional<Player> target, Projection projection);
}
private final Supplier<? extends Collection<? extends Player>> onlinePlayers;
private final EyeEquipment equipment;
private final Predicate<UUID> concealed;
private final Delivery delivery;
private final Supplier<String> teamNames;
private final java.util.function.Consumer<Throwable> failure;
private record RestoreKey(UUID viewer, String team) { }
private record TargetKey(UUID viewer, UUID target) { }
private final Map<RestoreKey, Projection> restoring = new java.util.LinkedHashMap<>();
private final Set<UUID> warned = new HashSet<>();
private volatile Map<UUID, Map<Integer, Projection>> views = Map.of();
private volatile boolean closed;
private final Set<UUID> dirty = java.util.concurrent.ConcurrentHashMap.newKeySet();
public EyeRevealController(Supplier<? extends Collection<? extends Player>> onlinePlayers, EyeEquipment equipment,
Predicate<UUID> concealed, Delivery delivery, Supplier<String> teamNames) {
this(onlinePlayers, equipment, concealed, delivery, teamNames,
error -> java.util.logging.Logger.getLogger(EyeRevealController.class.getName())
.warning("Eye delivery failed; cleanup queued: " + error.getClass().getSimpleName()));
}
public EyeRevealController(Supplier<? extends Collection<? extends Player>> onlinePlayers, EyeEquipment equipment,
Predicate<UUID> concealed, Delivery delivery, Supplier<String> teamNames, java.util.function.Consumer<Throwable> failure) {
this.onlinePlayers = Objects.requireNonNull(onlinePlayers);
this.equipment = Objects.requireNonNull(equipment);
this.concealed = Objects.requireNonNull(concealed);
this.delivery = Objects.requireNonNull(delivery);
this.teamNames = Objects.requireNonNull(teamNames);
this.failure = Objects.requireNonNull(failure);
}
public Map<UUID, Map<Integer, Projection>> snapshot() { return views; }
/** Packet-thread safe: defer all Bukkit access and actual delivery to the next server-thread frame. */
public void requestRefresh(UUID viewer) {
if (!closed) { dirty.add(Objects.requireNonNull(viewer)); }
}
@Override public void run() {
if (closed) { return; }
var requested = Set.copyOf(dirty);
requested.forEach(dirty::remove);
List<? extends Player> online = List.copyOf(onlinePlayers.get());
var players = new HashMap<UUID, Player>();
online.forEach(player -> players.put(player.getUniqueId(), player));
var attemptedRestores = new HashSet<RestoreKey>();
retryRestores(players, attemptedRestores);
var blocked = restoring.entrySet().stream().map(entry -> new TargetKey(entry.getKey().viewer(), entry.getValue().targetId()))
.collect(java.util.stream.Collectors.toUnmodifiableSet());
Map<UUID, Map<Integer, Projection>> previous = views;
var usedNames = new HashSet<String>();
previous.values().forEach(view -> view.values().forEach(projection -> usedNames.add(projection.privateTeam())));
restoring.values().forEach(projection -> usedNames.add(projection.privateTeam()));
var next = new HashMap<UUID, Map<Integer, Projection>>();
for (Player viewer : online) {
try {
if (!viewer.isOnline() || viewer.isDead() || !equipment.isEligibleWearer(viewer)) { continue; }
Location origin = viewer.getLocation();
var observer = new EyeVisibility.Viewer(viewer.getUniqueId(), position(origin), true);
var targets = new HashMap<Integer, Projection>();
for (Player target : online) {
if (!target.isOnline() || target.isDead() || blocked.contains(new TargetKey(viewer.getUniqueId(), target.getUniqueId()))) { continue; }
Location destination = target.getLocation();
var subject = new EyeVisibility.Target(target.getUniqueId(), position(destination),
concealed.test(target.getUniqueId()), target.getGameMode() == GameMode.SPECTATOR,
target.getMetadata("vanished").stream().anyMatch(MetadataValue::asBoolean));
boolean visible = viewer.canSee(target) && target.getTrackedBy().contains(viewer);
if (!EyeVisibility.canReveal(observer, subject, visible, true)) { continue; }
var sourceTeam = viewer.getScoreboard().getEntryTeam(target.getName());
if (!EyeTeamVisibility.visible(viewer, sourceTeam)
|| !chunksLoaded(origin.getWorld(), origin, destination) || !viewer.hasLineOfSight(target)) { continue; }
Projection old = previous.getOrDefault(viewer.getUniqueId(), Map.of()).get(target.getEntityId());
String team = old != null && old.targetId().equals(target.getUniqueId()) && old.playerName().equals(target.getName())
? old.privateTeam() : allocateTeam(viewer, usedNames);
var baseline = Optional.ofNullable(sourceTeam).map(org.bukkit.scoreboard.Team::getName);
targets.put(target.getEntityId(), new Projection(target.getUniqueId(), target.getEntityId(), target.getName(),
team, baseline, target.hasPotionEffect(PotionEffectType.INVISIBILITY)));
}
if (!targets.isEmpty()) { next.put(viewer.getUniqueId(), Map.copyOf(targets)); }
} catch (RuntimeException error) {
// Never retain a stale authorization when an authoritative visibility query fails.
report(viewer.getUniqueId(), error);
}
}
views = Map.copyOf(next); // Publish authorization before any outgoing projection/restoration packet.
previous.forEach((viewerId, targets) -> {
Player viewer = players.get(viewerId);
if (viewer == null || !viewer.isOnline()) { return; }
targets.values().forEach(old -> {
Projection current = views.getOrDefault(viewerId, Map.of()).get(old.entityId());
if (current == null || !current.privateTeam().equals(old.privateTeam())) {
restoring.putIfAbsent(new RestoreKey(viewerId, old.privateTeam()), old);
}
});
});
retryRestores(players, attemptedRestores);
views.forEach((viewerId, targets) -> targets.values().forEach(current -> {
Projection old = previous.getOrDefault(viewerId, Map.of()).get(current.entityId());
if (!current.equals(old) || requested.contains(viewerId)) {
try {
delivery.show(players.get(viewerId), players.get(current.targetId()), current,
old == null || !current.privateTeam().equals(old.privateTeam()));
warned.remove(viewerId);
} catch (RuntimeException error) {
deny(viewerId, current.targetId());
restoring.putIfAbsent(new RestoreKey(viewerId, current.privateTeam()), current);
report(viewerId, error);
}
}
}));
}
public void hide(UUID viewer, UUID target) {
withdraw((observer, projection) -> observer.equals(viewer) && projection.targetId().equals(target));
}
public void withdrawViewer(UUID viewer) { withdraw((observer, projection) -> observer.equals(viewer)); }
public void withdrawTarget(UUID target) { withdraw((observer, projection) -> projection.targetId().equals(target)); }
/** Lifecycle callbacks call this on the server thread before the next scheduled visibility capture. */
private void withdraw(java.util.function.BiPredicate<UUID, Projection> remove) {
var previous = views;
var next = new HashMap<UUID, Map<Integer, Projection>>();
previous.forEach((viewer, targets) -> {
var retained = new HashMap<Integer, Projection>();
targets.forEach((id, projection) -> { if (!remove.test(viewer, projection)) { retained.put(id, projection); } });
if (!retained.isEmpty()) { next.put(viewer, Map.copyOf(retained)); }
});
views = Map.copyOf(next);
var players = new HashMap<UUID, Player>();
onlinePlayers.get().forEach(player -> players.put(player.getUniqueId(), player));
previous.forEach((viewerId, targets) -> {
Player viewer = players.get(viewerId);
if (viewer != null && viewer.isOnline()) {
targets.values().stream().filter(projection -> remove.test(viewerId, projection))
.forEach(projection -> restoring.putIfAbsent(new RestoreKey(viewerId, projection.privateTeam()), projection));
}
});
retryRestores(players, new HashSet<>());
}
@Override public void close() {
if (closed && restoring.isEmpty()) { return; }
closed = true;
dirty.clear();
withdraw((viewer, projection) -> true);
}
private void retryRestores(Map<UUID, Player> players, Set<RestoreKey> attempted) {
for (var entry : List.copyOf(restoring.entrySet())) {
var key = entry.getKey();
if (!attempted.add(key)) { continue; }
var projection = entry.getValue();
Player viewer = players.get(key.viewer());
if (viewer == null || !viewer.isOnline()) { restoring.remove(key); continue; }
try {
delivery.restore(viewer, Optional.ofNullable(players.get(projection.targetId())), projection);
restoring.remove(key);
} catch (RuntimeException error) {
deny(key.viewer(), projection.targetId());
report(key.viewer(), error);
}
}
}
private void deny(UUID viewer, UUID target) {
var next = new HashMap<>(views);
var retained = new HashMap<>(next.getOrDefault(viewer, Map.of()));
retained.values().removeIf(projection -> projection.targetId().equals(target));
if (retained.isEmpty()) { next.remove(viewer); }
else { next.put(viewer, Map.copyOf(retained)); }
views = Map.copyOf(next);
}
private void report(UUID viewer, RuntimeException error) {
if (warned.add(viewer)) { failure.accept(error); }
}
private String allocateTeam(Player viewer, Set<String> used) {
for (int attempt = 0; attempt < 32; attempt++) {
String name = teamNames.get();
if (name != null && name.matches("[a-zA-Z0-9_-]{1,16}") && !used.contains(name)
&& viewer.getScoreboard().getTeam(name) == null) {
used.add(name);
return name;
}
}
throw new IllegalStateException("Could not allocate an isolated Eye team name");
}
private static EyeVisibility.Position position(Location location) {
return new EyeVisibility.Position(location.getWorld().getUID(), location.getX(), location.getY(), location.getZ());
}
private static boolean chunksLoaded(World world, Location from, Location to) {
int minX = (int) Math.floor(Math.min(from.getX(), to.getX())) >> 4;
int maxX = (int) Math.floor(Math.max(from.getX(), to.getX())) >> 4;
int minZ = (int) Math.floor(Math.min(from.getZ(), to.getZ())) >> 4;
int maxZ = (int) Math.floor(Math.max(from.getZ(), to.getZ())) >> 4;
for (int x = minX; x <= maxX; x++) {
for (int z = minZ; z <= maxZ; z++) { if (!world.isChunkLoaded(x, z)) { return false; } }
}
return true;
}
}
@@ -0,0 +1,57 @@
package games.dmg.spigotstealth;
import com.destroystokyo.paper.event.player.PlayerArmorChangeEvent;
import io.papermc.paper.event.player.PlayerUntrackEntityEvent;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityPotionEffectEvent;
import org.bukkit.event.entity.PlayerDeathEvent;
import org.bukkit.event.player.PlayerChangedWorldEvent;
import org.bukkit.event.player.PlayerGameModeChangeEvent;
import org.bukkit.event.player.PlayerHideEntityEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.player.PlayerRespawnEvent;
import org.bukkit.event.player.PlayerTeleportEvent;
/** Immediate server-thread invalidation; ordinary motion and line-of-sight changes are polled each tick. */
public final class EyeRevealEvents implements Listener {
private final EyeRevealController controller;
public EyeRevealEvents(EyeRevealController controller) { this.controller = java.util.Objects.requireNonNull(controller); }
@EventHandler(priority = EventPriority.MONITOR) public void onHide(PlayerHideEntityEvent event) {
if (event.getEntity() instanceof org.bukkit.entity.Player player) { controller.hide(event.getPlayer().getUniqueId(), player.getUniqueId()); }
}
@EventHandler(priority = EventPriority.MONITOR) public void onUntrack(PlayerUntrackEntityEvent event) {
if (event.getEntity() instanceof org.bukkit.entity.Player player) { controller.hide(event.getPlayer().getUniqueId(), player.getUniqueId()); }
}
@EventHandler(priority = EventPriority.MONITOR) public void onArmor(PlayerArmorChangeEvent event) {
if (event.getSlot() == org.bukkit.inventory.EquipmentSlot.HEAD) { controller.withdrawViewer(event.getPlayer().getUniqueId()); }
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onGameMode(PlayerGameModeChangeEvent event) {
if (!event.isCancelled() && event.getNewGameMode() == org.bukkit.GameMode.SPECTATOR) { both(event.getPlayer().getUniqueId()); }
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onEffect(EntityPotionEffectEvent event) {
if (!event.isCancelled() && event.getEntity() instanceof org.bukkit.entity.Player player
&& org.bukkit.potion.PotionEffectType.INVISIBILITY.equals(event.getModifiedType())) {
controller.withdrawTarget(player.getUniqueId());
}
}
@EventHandler(priority = EventPriority.MONITOR) public void onQuit(PlayerQuitEvent event) { both(event.getPlayer().getUniqueId()); }
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onTeleport(PlayerTeleportEvent event) {
if (!event.isCancelled()) { both(event.getPlayer().getUniqueId()); }
}
@EventHandler(priority = EventPriority.MONITOR) public void onRespawn(PlayerRespawnEvent event) { both(event.getPlayer().getUniqueId()); }
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onDeath(PlayerDeathEvent event) {
if (!cancelled(event)) { both(event.getEntity().getUniqueId()); }
}
@EventHandler(priority = EventPriority.MONITOR) public void onWorld(PlayerChangedWorldEvent event) { both(event.getPlayer().getUniqueId()); }
private void both(java.util.UUID player) {
controller.withdrawViewer(player);
controller.withdrawTarget(player);
}
private static boolean cancelled(org.bukkit.event.Event event) {
return event instanceof org.bukkit.event.Cancellable cancellable && cancellable.isCancelled();
}
}
@@ -0,0 +1,60 @@
package games.dmg.spigotstealth;
import com.comphenix.protocol.ProtocolManager;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Predicate;
import org.bukkit.event.HandlerList;
import org.bukkit.plugin.java.JavaPlugin;
import org.bukkit.scheduler.BukkitTask;
/** Owns only the Eye's viewer-specific runtime and its lifecycle resources. */
final class EyeRevealRuntime implements AutoCloseable {
private final JavaPlugin plugin;
private final ProtocolManager protocol;
private final EyeRevealController controller;
private final EyePacketListener packets;
private final EyeRevealEvents events;
private BukkitTask task;
private boolean started;
private boolean closed;
EyeRevealRuntime(JavaPlugin plugin, EyeEquipment equipment, Predicate<UUID> concealed, ProtocolManager protocol) {
this.plugin = Objects.requireNonNull(plugin);
this.protocol = Objects.requireNonNull(protocol);
controller = new EyeRevealController(plugin.getServer()::getOnlinePlayers, equipment, concealed, new NativeEyeDelivery(),
() -> UUID.randomUUID().toString().replace("-", "").substring(0, 16),
failure -> plugin.getLogger().warning("Eye rendering failed; unsafe views withheld: " + failure.getClass().getSimpleName()));
packets = new EyePacketListener(plugin, controller::snapshot, controller::requestRefresh, plugin.getLogger()::warning);
events = new EyeRevealEvents(controller);
}
public void start() {
if (closed) { throw new IllegalStateException("Eye runtime is closed"); }
if (started) { return; }
if (!plugin.isEnabled()) { throw new IllegalStateException("Cannot start Eye rendering for a disabled plugin"); }
started = true;
try {
protocol.addPacketListener(packets);
plugin.getServer().getPluginManager().registerEvents(events, plugin);
task = plugin.getServer().getScheduler().runTaskTimer(plugin, controller, 1L, 1L);
} catch (RuntimeException exception) {
close();
throw exception;
}
}
public void withdrawTarget(UUID player) { controller.withdrawTarget(player); }
@Override public void close() {
if (closed) { return; }
closed = true;
if (task != null) { task.cancel(); }
try {
controller.close(); // Withdraw authorization and restore raw state before unregistering the outgoing gate.
} finally {
protocol.removePacketListener(packets);
HandlerList.unregisterAll(events);
}
}
}
@@ -0,0 +1,20 @@
package games.dmg.spigotstealth;
import org.bukkit.entity.Player;
import org.bukkit.scoreboard.Team;
/** Preserve effective name visibility when a viewer-only team changes client membership relationships. */
final class EyeTeamVisibility {
private EyeTeamVisibility() { }
static boolean visible(Player viewer, Team source) {
if (source == null) { return true; }
boolean sameTeam = source.equals(viewer.getScoreboard().getEntryTeam(viewer.getName()));
return switch (source.getOption(Team.Option.NAME_TAG_VISIBILITY)) {
case ALWAYS -> true;
case NEVER -> false;
// CraftTeam maps these to native HIDE_FOR_* values by ordinal, not to SHOW_FOR_*.
case FOR_OWN_TEAM -> !sameTeam;
case FOR_OTHER_TEAMS -> sameTeam;
};
}
}
@@ -0,0 +1,21 @@
package games.dmg.spigotstealth;
import java.util.UUID;
/** Immutable, platform-independent inputs to the local Eye reveal decision. */
public final class EyeVisibility {
private EyeVisibility() { }
public record Position(UUID world, double x, double y, double z) { }
public record Viewer(UUID id, Position position, boolean eligible) { }
public record Target(UUID id, Position position, boolean concealed, boolean spectator, boolean vanished) { }
public static boolean canReveal(Viewer viewer, Target target, boolean visibleToViewer, boolean lineOfSight) {
if (!viewer.eligible() || !target.concealed() || target.spectator() || target.vanished()
|| !visibleToViewer || !lineOfSight || viewer.id().equals(target.id())
|| !viewer.position().world().equals(target.position().world())) { return false; }
double dx = viewer.position().x() - target.position().x();
double dy = viewer.position().y() - target.position().y();
double dz = viewer.position().z() - target.position().z();
return dx * dx + dy * dy + dz * dz <= 16.0 * 16.0;
}
}
@@ -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);
}
}
@@ -9,4 +9,6 @@ public interface IdentityPresentation {
void reveal(Player player); void reveal(Player player);
void refreshForObserver(Player observer); void refreshForObserver(Player observer);
void refreshSleepCountPolicy();
} }
@@ -0,0 +1,44 @@
package games.dmg.spigotstealth;
import java.lang.reflect.Method;
import net.kyori.adventure.audience.Audience;
import net.kyori.adventure.chat.SignedMessage;
import org.bukkit.entity.Player;
/** Preserves the native per-recipient text-filter decision when selecting system transport. */
final class NativeChatFiltering {
record Result(boolean applies, String text) { }
private final Method handle, shouldFilter, nativeMessage, mask, empty, apply, visibility;
private final Object fullVisibility;
NativeChatFiltering() {
try {
var player = Class.forName("net.minecraft.server.level.ServerPlayer");
var message = Class.forName("net.minecraft.network.chat.PlayerChatMessage");
var filter = Class.forName("net.minecraft.network.chat.FilterMask");
handle = Class.forName("org.bukkit.craftbukkit.entity.CraftPlayer").getMethod("getHandle");
shouldFilter = player.getMethod("shouldFilterMessageTo", player);
visibility = player.getMethod("getChatVisibility");
fullVisibility = Class.forName("net.minecraft.world.entity.player.ChatVisiblity").getField("FULL").get(null);
nativeMessage = Class.forName("net.minecraft.network.chat.PlayerChatMessage$AdventureView").getMethod("playerChatMessage");
mask = message.getMethod("filterMask");
empty = filter.getMethod("isEmpty");
apply = filter.getMethod("apply", String.class);
} catch (ReflectiveOperationException failure) { throw new IllegalStateException("Unsupported native chat filtering layout", failure); }
}
Result filter(Player sender, Audience audience, SignedMessage message) throws ReflectiveOperationException {
int depth = 0;
while (audience instanceof net.kyori.adventure.audience.ForwardingAudience.Single single) {
if (++depth > 64) { throw new IllegalStateException("Invalid forwarding audience chain"); }
audience = single.audience();
}
if (!(audience instanceof Player recipient)) { return new Result(false, null); }
Object target = handle.invoke(recipient);
// Public chat must not slip through a client's commands-only/hidden setting merely because transport is SYSTEM.
if (visibility.invoke(target) != fullVisibility) { return new Result(true, null); }
if (!(boolean) shouldFilter.invoke(handle.invoke(sender), target)) { return new Result(false, null); }
Object filter = mask.invoke(nativeMessage.invoke(message));
return (boolean) empty.invoke(filter) ? new Result(false, null) : new Result(true, (String) apply.invoke(filter, message.message()));
}
}
@@ -0,0 +1,108 @@
package games.dmg.spigotstealth;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import org.bukkit.entity.Player;
/** Main-thread native packet preparation/delivery. Outgoing authorization performs the final projection. */
final class NativeEyeDelivery implements EyeRevealController.Delivery {
private final Constructor<?> teamPacket;
private final Constructor<?> parameters;
private final Constructor<?> scoreboard;
private final Constructor<?> playerTeam;
private final Constructor<?> metadata;
private final Constructor<?> dataValue;
private final Field nativeTeam;
private final Method playerHandle;
private final Method entityData;
private final Method dataGet;
private final Object sharedFlags;
private final Object byteSerializer;
private final Field connection;
private final Method sendPacket;
NativeEyeDelivery() {
try {
Class<?> team = Class.forName("net.minecraft.world.scores.PlayerTeam");
Class<?> board = Class.forName("net.minecraft.world.scores.Scoreboard");
teamPacket = Class.forName("net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket")
.getDeclaredConstructor(String.class, int.class, Optional.class, Collection.class);
teamPacket.setAccessible(true);
parameters = Class.forName("net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket$Parameters")
.getConstructor(team);
scoreboard = board.getConstructor();
playerTeam = team.getConstructor(board, String.class);
nativeTeam = Class.forName("org.bukkit.craftbukkit.scoreboard.CraftTeam").getDeclaredField("team");
nativeTeam.setAccessible(true);
playerHandle = Class.forName("org.bukkit.craftbukkit.entity.CraftPlayer").getMethod("getHandle");
Class<?> entity = Class.forName("net.minecraft.world.entity.Entity");
entityData = entity.getMethod("getEntityData");
var flags = entity.getDeclaredField("DATA_SHARED_FLAGS_ID");
flags.setAccessible(true);
sharedFlags = flags.get(null);
Class<?> accessor = Class.forName("net.minecraft.network.syncher.EntityDataAccessor");
dataGet = Class.forName("net.minecraft.network.syncher.SynchedEntityData").getMethod("get", accessor);
byteSerializer = Class.forName("net.minecraft.network.syncher.EntityDataSerializers").getField("BYTE").get(null);
dataValue = Class.forName("net.minecraft.network.syncher.SynchedEntityData$DataValue").getConstructor(
int.class, Class.forName("net.minecraft.network.syncher.EntityDataSerializer"), Object.class);
metadata = Class.forName("net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket").getConstructor(int.class, List.class);
connection = Class.forName("net.minecraft.server.level.ServerPlayer").getField("connection");
sendPacket = Class.forName("net.minecraft.server.network.ServerCommonPacketListenerImpl")
.getMethod("send", Class.forName("net.minecraft.network.protocol.Packet"));
} catch (ReflectiveOperationException exception) {
throw new IllegalStateException("Native Eye delivery is unsupported on this server version", exception);
}
}
@Override public void show(Player viewer, Player target, EyeRevealController.Projection projection, boolean create) {
try {
var source = viewer.getScoreboard().getEntryTeam(projection.playerName());
Object basis = source == null ? playerTeam.newInstance(scoreboard.newInstance(), projection.privateTeam()) : nativeTeam.get(source);
// Send unprojected data. The packet listener checks the latest per-viewer authorization at dispatch.
Object privateParameters = EyePacketProjection.teamVisibility(parameters.newInstance(basis), EyeTeamVisibility.visible(viewer, source));
Object team = teamPacket.newInstance(projection.privateTeam(), create ? 0 : 2,
Optional.of(privateParameters), create ? List.of(projection.playerName()) : List.of());
var packets = List.of(team, rawMetadata(target));
for (Object packet : packets) { send(viewer, packet); }
} catch (ReflectiveOperationException exception) {
throw new IllegalStateException("Could not deliver a private Eye view", exception);
}
}
@Override public void restore(Player viewer, Optional<Player> target, EyeRevealController.Projection projection) {
try {
var packets = new ArrayList<Object>();
var source = viewer.getScoreboard().getEntryTeam(projection.playerName());
Object basis = source == null ? playerTeam.newInstance(scoreboard.newInstance(), projection.privateTeam()) : nativeTeam.get(source);
Object baseline = parameters.newInstance(basis);
// Restore presentation before changing membership, avoiding a temporary unteamed/plain-name gap.
packets.add(teamPacket.newInstance(projection.privateTeam(), 2,
Optional.of(EyePacketProjection.teamVisibility(baseline, EyeTeamVisibility.visible(viewer, source))), List.of()));
if (target.isPresent() && target.orElseThrow().isOnline()) { packets.add(rawMetadata(target.orElseThrow())); }
if (source != null) {
packets.add(teamPacket.newInstance(source.getName(), 2, Optional.of(baseline), List.of()));
packets.add(teamPacket.newInstance(source.getName(), 3, Optional.empty(), List.of(projection.playerName())));
}
packets.add(teamPacket.newInstance(projection.privateTeam(), 1, Optional.empty(), List.of()));
// Prepare everything before sending, avoiding half-created views on reflection/layout failures.
for (Object packet : packets) { send(viewer, packet); }
} catch (ReflectiveOperationException exception) {
throw new IllegalStateException("Could not restore an Eye observer's baseline", exception);
}
}
private Object rawMetadata(Player player) throws ReflectiveOperationException {
Object value = dataGet.invoke(entityData.invoke(playerHandle.invoke(player)), sharedFlags);
if (!(value instanceof Byte)) { throw new IllegalStateException("Native player flags are unavailable"); }
return metadata.newInstance(player.getEntityId(), List.of(dataValue.newInstance(0, byteSerializer, value)));
}
private void send(Player viewer, Object packet) throws ReflectiveOperationException {
sendPacket.invoke(connection.get(playerHandle.invoke(viewer)), packet);
}
}
@@ -0,0 +1,40 @@
package games.dmg.spigotstealth;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import org.bukkit.entity.Player;
/** Read-only, server-thread snapshots of the exact Purpur effect objects and their hidden layers. */
final class NativePotionEffects {
private final Method handle, effect, duration, amplifier;
private final Field hidden;
private final Object poison, wither;
NativePotionEffects() {
try {
var living = Class.forName("net.minecraft.world.entity.LivingEntity");
var instance = Class.forName("net.minecraft.world.effect.MobEffectInstance");
var effects = Class.forName("net.minecraft.world.effect.MobEffects");
handle = Class.forName("org.bukkit.craftbukkit.entity.CraftLivingEntity").getMethod("getHandle");
effect = living.getMethod("getEffect", Class.forName("net.minecraft.core.Holder"));
duration = instance.getMethod("getDuration");
amplifier = instance.getMethod("getAmplifier");
hidden = instance.getField("hiddenEffect");
poison = effects.getField("POISON").get(null);
wither = effects.getField("WITHER").get(null);
} catch (ReflectiveOperationException exception) {
throw new IllegalStateException("Unsupported native potion provenance layout", exception);
}
}
PotionDamageAttribution.Snapshot snapshot(Player player, PotionDamageAttribution.Kind kind) throws ReflectiveOperationException {
Object current = effect.invoke(handle.invoke(player), kind == PotionDamageAttribution.Kind.POISON ? poison : wither);
return current == null ? null : new PotionDamageAttribution.Snapshot(current, read(current, 0));
}
private PotionDamageAttribution.Effect read(Object current, int depth) throws ReflectiveOperationException {
if (current == null) { return null; }
if (depth > 256) { throw new IllegalStateException("Invalid native effect chain"); }
return new PotionDamageAttribution.Effect((int) amplifier.invoke(current), (int) duration.invoke(current), read(hidden.get(current), depth + 1));
}
}
@@ -8,10 +8,33 @@ 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,
Map<String, Object> unknownFields) { SleepCountPolicy sleepCountPolicy,
Map<String, Object> unknownFields,
Map<UUID, EyeProgress> eyes) {
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")));
eyes = Map.copyOf(Objects.requireNonNull(eyes, "eyes"));
}
public PersistentStealthState(Map<UUID, PlayerStealthState> players, SleepCountPolicy sleepCountPolicy,
Map<String, Object> unknownFields) {
this(players, sleepCountPolicy, unknownFields, Map.of());
}
public PersistentStealthState(
Map<UUID, PlayerStealthState> players,
Map<String, Object> unknownFields) {
this(players, SleepCountPolicy.EXCLUDE, unknownFields);
}
public EyeProgress eyeProgress(UUID playerId) { return eyes.getOrDefault(playerId, EyeProgress.empty()); }
public PersistentStealthState withEyeProgress(UUID playerId, EyeProgress progress) {
Map<UUID, EyeProgress> updated = new LinkedHashMap<>(eyes);
updated.put(playerId, progress);
return new PersistentStealthState(players, sleepCountPolicy, unknownFields, updated);
} }
public PlayerStealthState player(UUID playerId) { public PlayerStealthState player(UUID playerId) {
@@ -21,6 +44,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, eyes);
}
public PersistentStealthState withSleepCountPolicy(SleepCountPolicy policy) {
return new PersistentStealthState(players, policy, unknownFields, eyes);
} }
} }
@@ -0,0 +1,96 @@
package games.dmg.spigotstealth;
import java.util.Optional;
import java.util.UUID;
/** Runtime provenance of poison/wither layers, reconciled against actual native effect state. */
final class PotionDamageAttribution {
enum Kind { POISON, WITHER }
record Effect(int amplifier, int duration, Effect hidden) { }
record Snapshot(Object identity, Effect effect) { }
private record Key(UUID victim, Kind kind) { }
private record Owned(int amplifier, int duration, UUID source, Owned hidden) { }
private record Entry(Object identity, Owned effect) { }
private final java.util.Map<Key, Entry> entries = new java.util.HashMap<>();
public void change(UUID victim, Kind kind, Snapshot before, Effect incoming, UUID source, boolean override) {
var key = new Key(victim, kind);
if (incoming == null) { entries.remove(key); return; }
var old = reconcile(entries.get(key), before);
if (old == null && before != null) { old = unowned(before.effect()); }
var next = merge(old, incoming, source, override);
entries.put(key, new Entry(before == null ? null : before.identity(), next));
}
public void forget(UUID victim) { entries.keySet().removeIf(key -> key.victim().equals(victim)); }
public void clear() { entries.clear(); }
private static Owned merge(Owned old, Effect incoming, UUID source, boolean override) {
if (old == null) { return new Owned(incoming.amplifier(), incoming.duration(), source, null); }
if (override && incoming.amplifier() > old.amplifier()) {
return new Owned(incoming.amplifier(), incoming.duration(), source,
shorter(incoming.duration(), old.duration()) ? old : old.hidden());
}
if (shorter(old.duration(), incoming.duration())) {
if (override && incoming.amplifier() == old.amplifier()) {
return new Owned(old.amplifier(), incoming.duration(), source, old.hidden());
}
if (incoming.amplifier() < old.amplifier()) {
return new Owned(old.amplifier(), old.duration(), old.source(), merge(old.hidden(), incoming, source, true));
}
}
return old; // Rejected or cosmetic-only changes do not replace the damaging layer.
}
public Optional<UUID> attacker(UUID victim, Kind kind, Snapshot current) {
var key = new Key(victim, kind);
var owned = reconcile(entries.get(key), current);
if (owned == null || owned.duration() != -1 && owned.duration() <= 0) { entries.remove(key); return Optional.empty(); }
entries.put(key, new Entry(current.identity(), owned));
return Optional.ofNullable(owned.source());
}
private static Owned reconcile(Entry entry, Snapshot current) {
if (entry == null || current == null || entry.identity() != null && entry.identity() != current.identity()) { return null; }
int expired = 0;
for (var candidate = entry.effect(); candidate != null; candidate = candidate.hidden()) {
if (matches(candidate, current.effect(), expired)) { return rebase(candidate, current.effect()); }
if (candidate.duration() == -1) { break; } // An infinite active layer cannot expire into its hidden layer.
expired = Math.max(expired, candidate.duration());
}
return null;
}
private static boolean shorter(int duration, int other) {
return duration != -1 && (other == -1 || duration < other);
}
/** All layers count down together. Solve their elapsed-duration constraints, including frozen/infinite effects. */
private static boolean matches(Owned expected, Effect actual, int expired) {
long minimum = expired, maximum = Long.MAX_VALUE;
while (expected != null && actual != null) {
if (expected.amplifier() != actual.amplifier() || actual.duration() < -1) { return false; }
if (expected.duration() == -1) {
if (actual.duration() != -1) { return false; }
} else {
if (actual.duration() == -1 || actual.duration() > expected.duration()) { return false; }
if (actual.duration() == 0) { minimum = Math.max(minimum, expected.duration()); }
else {
long elapsed = (long) expected.duration() - actual.duration();
minimum = Math.max(minimum, elapsed);
maximum = Math.min(maximum, elapsed);
}
}
expected = expected.hidden();
actual = actual.hidden();
}
return expected == null && actual == null && minimum <= maximum;
}
private static Owned rebase(Owned owner, Effect actual) {
return owner == null ? null : new Owned(actual.amplifier(), actual.duration(), owner.source(), rebase(owner.hidden(), actual.hidden()));
}
private static Owned unowned(Effect effect) {
return effect == null ? null : new Owned(effect.amplifier(), effect.duration(), null, unowned(effect.hidden()));
}
}
@@ -0,0 +1,71 @@
package games.dmg.spigotstealth;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.EntityPotionEffectEvent;
/** Observes explicit effect sources; does not change effects or infer a nearby attacker. */
final class PotionDamageListener implements Listener, Function<EntityDamageEvent, Player> {
private final Function<UUID, Player> onlinePlayer;
private final Consumer<Throwable> failures;
private final PotionDamageAttribution attribution = new PotionDamageAttribution();
private final NativePotionEffects effects = new NativePotionEffects();
private boolean reported;
PotionDamageListener(Function<UUID, Player> onlinePlayer, Consumer<Throwable> failures) {
this.onlinePlayer = java.util.Objects.requireNonNull(onlinePlayer);
this.failures = java.util.Objects.requireNonNull(failures);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onEffect(EntityPotionEffectEvent event) {
if (event.isCancelled() || !(event.getEntity() instanceof Player player)) { return; }
var kind = kind(event.getModifiedType());
if (kind == null) { return; }
try {
var incoming = event.getNewEffect();
var source = source(event.getSource());
attribution.change(player.getUniqueId(), kind, effects.snapshot(player, kind),
incoming == null ? null : new PotionDamageAttribution.Effect(Math.clamp(incoming.getAmplifier(), 0, 255), incoming.getDuration(), null),
source == null ? null : source.getUniqueId(), event.isOverride());
} catch (ReflectiveOperationException | RuntimeException exception) { attribution.forget(player.getUniqueId()); report(exception); }
}
@Override public Player apply(EntityDamageEvent event) {
if (!(event.getEntity() instanceof Player player)) { return null; }
var kind = switch (event.getCause()) {
case POISON -> PotionDamageAttribution.Kind.POISON;
case WITHER -> PotionDamageAttribution.Kind.WITHER;
default -> null;
};
if (kind == null) { return null; }
try {
return attribution.attacker(player.getUniqueId(), kind, effects.snapshot(player, kind)).map(onlinePlayer).orElse(null);
} catch (ReflectiveOperationException | RuntimeException exception) { attribution.forget(player.getUniqueId()); report(exception); return null; }
}
@EventHandler(priority = EventPriority.MONITOR)
public void onQuit(org.bukkit.event.player.PlayerQuitEvent event) { attribution.forget(event.getPlayer().getUniqueId()); }
public void close() { attribution.clear(); }
private static PotionDamageAttribution.Kind kind(org.bukkit.potion.PotionEffectType type) {
if (org.bukkit.potion.PotionEffectType.POISON.equals(type)) { return PotionDamageAttribution.Kind.POISON; }
if (org.bukkit.potion.PotionEffectType.WITHER.equals(type)) { return PotionDamageAttribution.Kind.WITHER; }
return null;
}
private static Player source(org.bukkit.entity.Entity entity) {
if (entity instanceof Player player) { return player; }
if (entity instanceof org.bukkit.entity.Projectile projectile && projectile.getShooter() instanceof Player player) { return player; }
if (entity instanceof org.bukkit.entity.AreaEffectCloud cloud && cloud.getSource() instanceof Player player) { return player; }
return null;
}
private void report(Throwable failure) {
if (!reported) { reported = true; failures.accept(failure); }
}
}
@@ -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);
});
}
}
@@ -14,7 +14,7 @@ import java.util.Objects;
import java.util.UUID; import java.util.UUID;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
/** ProtocolLib adapter for tab-list-only removal and restoration packets. */ /** Changes tab listing without deleting the client profile or signed-chat session. */
public final class ProtocolLibTabListController implements TabListController { public final class ProtocolLibTabListController implements TabListController {
private final ProtocolManager protocolManager; private final ProtocolManager protocolManager;
@@ -24,8 +24,11 @@ public final class ProtocolLibTabListController implements TabListController {
@Override @Override
public void remove(Player observer, UUID targetPlayerId) { public void remove(Player observer, UUID targetPlayerId) {
PacketContainer packet = protocolManager.createPacket(PacketType.Play.Server.PLAYER_INFO_REMOVE); PacketContainer packet = protocolManager.createPacket(PacketType.Play.Server.PLAYER_INFO);
packet.getUUIDLists().write(0, List.of(targetPlayerId)); packet.getPlayerInfoActions().write(0, EnumSet.of(EnumWrappers.PlayerInfoAction.UPDATE_LISTED));
PlayerInfoData data = new PlayerInfoData(targetPlayerId, 0, false, EnumWrappers.NativeGameMode.SURVIVAL,
null, null, (WrappedRemoteChatSessionData) null);
packet.getPlayerInfoDataLists().write(1, List.of(data));
protocolManager.sendServerPacket(observer, packet); protocolManager.sendServerPacket(observer, packet);
} }
@@ -33,7 +36,6 @@ public final class ProtocolLibTabListController implements TabListController {
public void add(Player observer, Player target) { public void add(Player observer, Player target) {
PacketContainer packet = protocolManager.createPacket(PacketType.Play.Server.PLAYER_INFO); PacketContainer packet = protocolManager.createPacket(PacketType.Play.Server.PLAYER_INFO);
packet.getPlayerInfoActions().write(0, EnumSet.of( packet.getPlayerInfoActions().write(0, EnumSet.of(
EnumWrappers.PlayerInfoAction.ADD_PLAYER,
EnumWrappers.PlayerInfoAction.UPDATE_GAME_MODE, EnumWrappers.PlayerInfoAction.UPDATE_GAME_MODE,
EnumWrappers.PlayerInfoAction.UPDATE_LISTED, EnumWrappers.PlayerInfoAction.UPDATE_LISTED,
EnumWrappers.PlayerInfoAction.UPDATE_LATENCY, EnumWrappers.PlayerInfoAction.UPDATE_LATENCY,
@@ -0,0 +1,32 @@
package games.dmg.spigotstealth;
import java.util.Iterator;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.function.Supplier;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.server.ServerListPingEvent;
/** Filters concealed players through Bukkit's native server-list sample representation. */
public final class ServerListPingListener implements Listener {
private final Supplier<Set<UUID>> concealedPlayerIds;
public ServerListPingListener(Supplier<Set<UUID>> concealedPlayerIds) {
this.concealedPlayerIds = Objects.requireNonNull(concealedPlayerIds, "concealedPlayerIds");
}
@EventHandler(priority = EventPriority.HIGHEST)
public void onServerListPing(ServerListPingEvent event) {
Set<UUID> concealed = concealedPlayerIds.get();
Iterator<Player> sample = event.iterator();
while (sample.hasNext()) {
if (concealed.contains(sample.next().getUniqueId())) {
sample.remove();
}
}
}
}
@@ -0,0 +1,37 @@
package games.dmg.spigotstealth;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
/** Computes the public multiplayer server-list view for concealed sessions. */
public final class ServerListPingVisibility {
private ServerListPingVisibility() { }
public static Snapshot adjust(
int playersOnline,
int playersMaximum,
List<UUID> samplePlayerIds,
Set<UUID> concealedPlayerIds) {
Objects.requireNonNull(samplePlayerIds, "samplePlayerIds");
Objects.requireNonNull(concealedPlayerIds, "concealedPlayerIds");
List<UUID> visibleSample = samplePlayerIds.stream()
.filter(playerId -> !concealedPlayerIds.contains(playerId))
.toList();
return new Snapshot(
visibleOnlineCount(playersOnline, concealedPlayerIds.size()),
playersMaximum,
visibleSample);
}
public static int visibleOnlineCount(int playersOnline, int concealedPlayers) {
return Math.max(0, playersOnline - concealedPlayers);
}
public record Snapshot(int playersOnline, int playersMaximum, List<UUID> samplePlayerIds) {
public Snapshot {
samplePlayerIds = List.copyOf(samplePlayerIds);
}
}
}
@@ -0,0 +1,26 @@
package games.dmg.spigotstealth;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
/** Keeps an incompatible server-ping adapter from breaking responses or flooding logs. */
public final class ServerPingCompatibilityGuard {
private final Consumer<String> warningLog;
private final AtomicBoolean warningLogged = new AtomicBoolean();
public ServerPingCompatibilityGuard(Consumer<String> warningLog) {
this.warningLog = Objects.requireNonNull(warningLog, "warningLog");
}
public void run(Runnable edit) {
try {
edit.run();
} catch (RuntimeException exception) {
if (warningLogged.compareAndSet(false, true)) {
warningLog.accept("Unable to adjust the public server-list player count; "
+ "the original response will be used: " + exception.getMessage());
}
}
}
}
@@ -0,0 +1,24 @@
package games.dmg.spigotstealth;
import java.util.Locale;
/** Whether concealed players participate in the server's sleep percentage. */
public enum SleepCountPolicy {
INCLUDE,
EXCLUDE;
public static SleepCountPolicy fromPersisted(Object value) {
if (value instanceof String text) {
try {
return valueOf(text.toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException ignored) {
// Invalid values use the privacy-preserving default.
}
}
return EXCLUDE;
}
public String persistedValue() {
return name().toLowerCase(Locale.ROOT);
}
}
@@ -1,6 +1,7 @@
package games.dmg.spigotstealth; package games.dmg.spigotstealth;
import com.comphenix.protocol.ProtocolLibrary; 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;
@@ -11,13 +12,22 @@ import org.bukkit.plugin.java.JavaPlugin;
/** Bukkit entry point for Spigot Stealth. */ /** Bukkit entry point for Spigot Stealth. */
public final class SpigotStealthPlugin extends JavaPlugin { public final class SpigotStealthPlugin extends JavaPlugin {
private CompletableFuture<StealthStateManager> stateManagerFuture; private CompletableFuture<StealthStateManager> stateManagerFuture;
private CompletableFuture<Void> shutdown;
private volatile long lifecycleEpoch;
private StealthSettings settings; private StealthSettings settings;
private QualifyingInvisibilityService progression; private QualifyingInvisibilityService progression;
private StealthSessionService sessions; private StealthSessionService sessions;
private IdentityPresentation identityPresentation; private IdentityPresentation identityPresentation;
private ProtocolManager protocolManager;
private EyeProgressionService eyeProgression;
private EyePotionListener eyePotions;
private boolean eyeRecipeRegistered;
private EyeRevealRuntime eyeReveal;
private PotionDamageListener combatPotions;
@Override @Override
public void onEnable() { public void onEnable() {
long epoch = ++lifecycleEpoch;
saveDefaultConfig(); saveDefaultConfig();
try { try {
settings = StealthSettings.from(getConfig().getValues(true)); settings = StealthSettings.from(getConfig().getValues(true));
@@ -28,42 +38,75 @@ public final class SpigotStealthPlugin extends JavaPlugin {
} }
Path stateFile = getDataFolder().toPath().resolve("state.yml"); Path stateFile = getDataFolder().toPath().resolve("state.yml");
stateManagerFuture = StealthStateManager.load(new YamlStealthStateRepository(stateFile)); stateManagerFuture = shutdownCompletion().thenCompose(ignored ->
StealthStateManager.load(new YamlStealthStateRepository(stateFile)));
stateManagerFuture.whenComplete((manager, failure) -> { stateManagerFuture.whenComplete((manager, failure) -> {
if (failure != null) { if (failure != null) {
getLogger().severe("Unable to load Spigot Stealth state: " + rootMessage(failure)); getLogger().severe("Unable to load Spigot Stealth state: " + rootMessage(failure));
getServer().getScheduler().runTask(this, () -> getServer().getPluginManager().disablePlugin(this)); onServer(epoch, () -> getServer().getPluginManager().disablePlugin(this));
return; return;
} }
getServer().getScheduler().runTask(this, () -> finishInitialization(manager)); if (!isEnabled() || epoch != lifecycleEpoch) {
logSaveFailure(manager.closeAsync());
return;
}
try {
getServer().getScheduler().runTask(this, () -> {
if (!isEnabled() || epoch != lifecycleEpoch) { logSaveFailure(manager.closeAsync()); }
else { finishInitialization(manager, epoch); }
});
} catch (org.bukkit.plugin.IllegalPluginAccessException ignored) {
logSaveFailure(manager.closeAsync());
}
}); });
} }
@Override @Override
public void onDisable() { public void onDisable() {
if (identityPresentation != null && sessions != null) { ++lifecycleEpoch;
for (org.bukkit.entity.Player player : getServer().getOnlinePlayers()) { try {
if (sessions.isConcealed(player.getUniqueId())) { if (eyePotions != null) {
identityPresentation.reveal(player); eyePotions.checkpoint(getServer()::getPlayer);
sessions.endConcealment(player.getUniqueId()); logSaveFailure(eyeProgression.stopAll());
}
if (progression != null) { logSaveFailure(progression.stopAll()); }
if (combatPotions != null) { combatPotions.close(); }
if (eyeReveal != null) { eyeReveal.close(); }
if (protocolManager != null) { protocolManager.removePacketListeners(this); }
if (identityPresentation != null && sessions != null) {
for (org.bukkit.entity.Player player : getServer().getOnlinePlayers()) {
if (sessions.isConcealed(player.getUniqueId())) {
try { identityPresentation.reveal(player); }
finally { logSaveFailure(sessions.endConcealment(player.getUniqueId())); }
}
}
}
} finally {
try {
if (eyeRecipeRegistered) {
getServer().removeRecipe(EyeCrafting.RECIPE_KEY);
eyeRecipeRegistered = false;
}
} finally {
if (stateManagerFuture != null) {
shutdown = stateManagerFuture.thenCompose(StealthStateManager::closeAsync);
logSaveFailure(shutdown);
} }
} }
} }
if (progression != null) { }
progression.stopAll().join();
} /** Durable shutdown acknowledgement; never wait for it on the server thread. */
if (stateManagerFuture != null && stateManagerFuture.isDone() && !stateManagerFuture.isCompletedExceptionally()) { CompletableFuture<Void> shutdownCompletion() {
stateManagerFuture.join().close(); return shutdown == null ? CompletableFuture.completedFuture(null) : shutdown;
}
} }
public StealthSettings settings() { public StealthSettings settings() {
return settings; return settings;
} }
private void finishInitialization(StealthStateManager manager) { private void finishInitialization(StealthStateManager manager, long epoch) {
java.util.function.Consumer<Runnable> mainThread = java.util.function.Consumer<Runnable> mainThread = runnable -> onServer(epoch, runnable);
runnable -> getServer().getScheduler().runTask(this, runnable);
BukkitUnlockNotifier notifier = new BukkitUnlockNotifier( BukkitUnlockNotifier notifier = new BukkitUnlockNotifier(
getServer()::getPlayer, getServer()::getPlayer,
mainThread, mainThread,
@@ -71,14 +114,26 @@ public final class SpigotStealthPlugin extends JavaPlugin {
progression = new QualifyingInvisibilityService( progression = new QualifyingInvisibilityService(
manager, settings.unlockThreshold(), System::nanoTime, notifier); manager, settings.unlockThreshold(), System::nanoTime, notifier);
sessions = new StealthSessionService(manager, progression); sessions = new StealthSessionService(manager, progression);
identityPresentation = new BukkitIdentityPresentation( protocolManager = ProtocolLibrary.getProtocolManager();
BukkitIdentityPresentation bukkitPresentation = new BukkitIdentityPresentation(
getServer()::getOnlinePlayers, getServer()::getOnlinePlayers,
Objects.requireNonNull(getServer().getScoreboardManager(), "scoreboard manager").getMainScoreboard(), Objects.requireNonNull(getServer().getScoreboardManager(), "scoreboard manager").getMainScoreboard(),
new ProtocolLibTabListController(ProtocolLibrary.getProtocolManager())); new ProtocolLibTabListController(protocolManager),
() -> manager.snapshot().sleepCountPolicy());
identityPresentation = bukkitPresentation;
protocolManager.addPacketListener(new ProtocolLibIdentityMaskingListener(
this, bukkitPresentation::concealedNames, getLogger()::warning));
protocolManager.addPacketListener(new TabListMaskingListener(this, sessions::concealedPlayerIds, 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);
getServer().getPluginManager().registerEvents( getServer().getPluginManager().registerEvents(
new StealthSessionListener(sessions, identityPresentation, settings.concealedMessage()), this); new StealthSessionListener(sessions, identityPresentation, settings.concealedMessage()), this);
getServer().getPluginManager().registerEvents(
new StealthAdminJoinListener(sessions, getServer()::getOnlinePlayers), this);
org.bukkit.command.PluginCommand stealthPluginCommand = org.bukkit.command.PluginCommand stealthPluginCommand =
Objects.requireNonNull(getCommand("stealth"), "stealth command"); Objects.requireNonNull(getCommand("stealth"), "stealth command");
StealthCommand stealthCommand = new StealthCommand(manager, progression, settings); StealthCommand stealthCommand = new StealthCommand(manager, progression, settings);
@@ -97,6 +152,52 @@ public final class SpigotStealthPlugin extends JavaPlugin {
settings.unlockThreshold()); settings.unlockThreshold());
stealthAdminPluginCommand.setExecutor(stealthAdminCommand); stealthAdminPluginCommand.setExecutor(stealthAdminCommand);
stealthAdminPluginCommand.setTabCompleter(stealthAdminCommand); stealthAdminPluginCommand.setTabCompleter(stealthAdminCommand);
eyeProgression = new EyeProgressionService(manager, this::monotonicNanos, id -> mainThread.accept(() -> {
var player = getServer().getPlayer(id);
if (player != null) {
player.discoverRecipe(EyeCrafting.RECIPE_KEY);
player.sendMessage("You unlocked the Eye of True Seeing! Craft it with eight Netherite Blocks around an Eye of Ender.");
}
}));
eyePotions = new EyePotionListener(eyeProgression,
failure -> getLogger().severe("Unable to save Eye progression: " + rootMessage(failure)));
var eyeItems = new EyeItems();
var eyeCrafting = new EyeCrafting(eyeProgression, eyeItems);
if (!getServer().addRecipe(eyeCrafting.recipe())) {
getLogger().severe("Unable to register the Eye recipe; disabling Stealth rather than replacing another recipe.");
getServer().getPluginManager().disablePlugin(this);
return;
}
eyeRecipeRegistered = true;
getServer().getPluginManager().registerEvents(eyePotions, this);
getServer().getPluginManager().registerEvents(eyeCrafting, this);
var eyeEquipment = new EyeEquipment(eyeProgression, eyeItems);
getServer().getPluginManager().registerEvents(eyeEquipment, this);
try {
eyeReveal = new EyeRevealRuntime(this, eyeEquipment, sessions::isConcealed, protocolManager);
eyeReveal.start();
getServer().getPluginManager().registerEvents(new StealthSystemChat(sessions::isConcealed,
failure -> getLogger().warning("Anonymous public-chat delivery failed closed: " + rootMessage(failure))), this);
java.util.function.Consumer<Throwable> combatFailure = failure -> getLogger().warning("Unable to complete combat reveal: " + rootMessage(failure));
combatPotions = new PotionDamageListener(getServer()::getPlayer, combatFailure);
getServer().getPluginManager().registerEvents(combatPotions, this);
getServer().getPluginManager().registerEvents(new CombatRevealListener(sessions, identityPresentation,
eyeReveal::withdrawTarget, combatFailure, combatPotions), this);
} catch (RuntimeException exception) {
getLogger().severe("Unable to initialize Stealth gameplay: " + rootMessage(exception));
getServer().getPluginManager().disablePlugin(this);
return;
}
getServer().getScheduler().runTaskTimer(this, ignored -> {
eyePotions.checkpoint(getServer()::getPlayer);
for (var player : getServer().getOnlinePlayers()) {
if (eyeProgression.isUnlocked(player.getUniqueId())) {
if (!player.hasDiscoveredRecipe(EyeCrafting.RECIPE_KEY)) { player.discoverRecipe(EyeCrafting.RECIPE_KEY); }
} else if (player.hasDiscoveredRecipe(EyeCrafting.RECIPE_KEY)) {
player.undiscoverRecipe(EyeCrafting.RECIPE_KEY);
}
}
}, 20L, 20L);
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));
@@ -111,6 +212,20 @@ public final class SpigotStealthPlugin extends JavaPlugin {
getLogger().info("Spigot Stealth enabled"); getLogger().info("Spigot Stealth enabled");
} }
/** Clock boundary for deterministic gameplay lifecycle verification. */
long monotonicNanos() { return System.nanoTime(); }
private void onServer(long epoch, Runnable action) {
if (!isEnabled() || epoch != lifecycleEpoch) { return; }
try {
getServer().getScheduler().runTask(this, () -> {
if (isEnabled() && epoch == lifecycleEpoch) { action.run(); }
});
} catch (org.bukkit.plugin.IllegalPluginAccessException ignored) {
// A durable unlock remains saved even if disable races its notification.
}
}
private void logSaveFailure(CompletableFuture<Void> save) { private void logSaveFailure(CompletableFuture<Void> save) {
save.exceptionally(failure -> { save.exceptionally(failure -> {
getLogger().severe("Unable to save Spigot Stealth state: " + rootMessage(failure)); getLogger().severe("Unable to save Spigot Stealth state: " + rootMessage(failure));
@@ -14,7 +14,7 @@ import org.bukkit.command.TabCompleter;
/** Permission-gated administrative command for online and known offline players. */ /** Permission-gated administrative command for online and known offline players. */
public final class StealthAdminCommand implements CommandExecutor, TabCompleter { public final class StealthAdminCommand implements CommandExecutor, TabCompleter {
private static final String PERMISSION = "spigotstealth.admin"; private static final String PERMISSION = "spigotstealth.admin";
private static final List<String> OPERATIONS = List.of("status", "grant", "reset", "list"); private static final List<String> OPERATIONS = List.of("status", "grant", "reset", "list", "sleepcount");
private final StealthAdministrationService administration; private final StealthAdministrationService administration;
private final KnownPlayerResolver resolver; private final KnownPlayerResolver resolver;
private final Consumer<Runnable> mainThread; private final Consumer<Runnable> mainThread;
@@ -51,6 +51,9 @@ public final class StealthAdminCommand implements CommandExecutor, TabCompleter
if ("list".equals(operation)) { if ("list".equals(operation)) {
return matching(List.of("unlocked"), arguments[1]); 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)) { if (arguments.length == 3 && "reset".equals(operation)) {
return matching(List.of("confirm"), arguments[2]); return matching(List.of("confirm"), arguments[2]);
@@ -69,6 +72,9 @@ public final class StealthAdminCommand implements CommandExecutor, TabCompleter
sender.sendMessage("You do not have permission to administer Spigot Stealth."); sender.sendMessage("You do not have permission to administer Spigot Stealth.");
return true; 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 > 0 && "list".equalsIgnoreCase(arguments[0])) {
if (arguments.length == 1) { if (arguments.length == 1) {
List<String> names = administration.concealedOnlineNames(); List<String> names = administration.concealedOnlineNames();
@@ -111,6 +117,44 @@ public final class StealthAdminCommand implements CommandExecutor, TabCompleter
}; };
} }
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) { private boolean status(CommandSender sender, KnownPlayerResolver.KnownPlayer target) {
StealthAdministrationService.PlayerStatus status = administration.status(target.playerId()); StealthAdministrationService.PlayerStatus status = administration.status(target.playerId());
sender.sendMessage("Stealth status for " + displayName(target) + " (" + target.playerId() + "):"); sender.sendMessage("Stealth status for " + displayName(target) + " (" + target.playerId() + "):");
@@ -163,13 +207,17 @@ public final class StealthAdminCommand implements CommandExecutor, TabCompleter
} }
private static void sendUsage(CommandSender sender, String label) { private static void sendUsage(CommandSender sender, String label) {
sender.sendMessage("Usage: /" + label + " <status <player|uuid>|grant <player|uuid>|reset <player|uuid> confirm|list [unlocked]>"); 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) { private static String displayName(KnownPlayerResolver.KnownPlayer player) {
return player.name() == null ? player.playerId().toString() : player.name(); 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) { private static String yesNo(boolean value) {
return value ? "yes" : "no"; 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);
}
}
}
}
}
@@ -31,6 +31,26 @@ public final class StealthAdministrationService {
this.onlinePlayers = Objects.requireNonNull(onlinePlayers, "onlinePlayers"); 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) { public PlayerStatus status(UUID playerId) {
PlayerStealthState player = stateManager.snapshot().player(playerId); PlayerStealthState player = stateManager.snapshot().player(playerId);
return new PlayerStatus( return new PlayerStatus(
@@ -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;
}
}
@@ -38,10 +38,14 @@ public final class StealthSessionListener implements Listener {
presentation.refreshForObserver(player); presentation.refreshForObserver(player);
} }
@EventHandler(priority = EventPriority.MONITOR) @EventHandler(priority = EventPriority.HIGHEST)
public void onQuit(PlayerQuitEvent event) { public void onQuit(PlayerQuitEvent event) {
presentation.reveal(event.getPlayer()); Player player = event.getPlayer();
sessions.disconnect(event.getPlayer().getUniqueId()); if (sessions.isConcealed(player.getUniqueId())) {
event.setQuitMessage(null);
}
presentation.reveal(player);
sessions.disconnect(player.getUniqueId());
} }
@EventHandler(priority = EventPriority.MONITOR) @EventHandler(priority = EventPriority.MONITOR)
@@ -1,7 +1,9 @@
package games.dmg.spigotstealth; package games.dmg.spigotstealth;
import java.util.Objects; import java.util.Objects;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
@@ -9,6 +11,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
public final class StealthSessionService { public final class StealthSessionService {
private final StealthStateManager stateManager; private final StealthStateManager stateManager;
private final QualifyingInvisibilityService progression; private final QualifyingInvisibilityService progression;
private final Set<UUID> concealedOnlinePlayerIds = ConcurrentHashMap.newKeySet();
public StealthSessionService( public StealthSessionService(
StealthStateManager stateManager, StealthStateManager stateManager,
@@ -18,6 +21,7 @@ public final class StealthSessionService {
} }
public CompletableFuture<Void> disconnect(UUID playerId) { public CompletableFuture<Void> disconnect(UUID playerId) {
concealedOnlinePlayerIds.remove(playerId);
boolean qualifyingAtDisconnect = progression.isQualifying(playerId); boolean qualifyingAtDisconnect = progression.isQualifying(playerId);
return progression.stop(playerId).thenCompose(ignored -> stateManager.update(state -> { return progression.stop(playerId).thenCompose(ignored -> stateManager.update(state -> {
PlayerStealthState player = state.player(playerId); PlayerStealthState player = state.player(playerId);
@@ -34,10 +38,16 @@ public final class StealthSessionService {
concealed.set(conceal); concealed.set(conceal);
return state.withPlayer(player.withSession(false, conceal).withQualifyingSince(null)); 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); return new LoginTransition(concealed.get(), saved);
} }
public CompletableFuture<Void> endConcealment(UUID playerId) { public CompletableFuture<Void> endConcealment(UUID playerId) {
concealedOnlinePlayerIds.remove(playerId);
return stateManager.update(state -> { return stateManager.update(state -> {
PlayerStealthState player = state.player(playerId); PlayerStealthState player = state.player(playerId);
return state.withPlayer(player.withSession(player.preparedLogin(), false)); return state.withPlayer(player.withSession(player.preparedLogin(), false));
@@ -48,5 +58,12 @@ public final class StealthSessionService {
return stateManager.snapshot().player(playerId).concealed(); 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) { } public record LoginTransition(boolean concealed, CompletableFuture<Void> saved) { }
} }
@@ -14,7 +14,9 @@ import java.util.function.UnaryOperator;
public final class StealthStateManager implements AutoCloseable { public final class StealthStateManager implements AutoCloseable {
private final StealthStateRepository repository; private final StealthStateRepository repository;
private final AtomicReference<PersistentStealthState> state; private final AtomicReference<PersistentStealthState> state;
private final AtomicReference<PersistentStealthState> durableState;
private final ExecutorService ioExecutor; private final ExecutorService ioExecutor;
private CompletableFuture<Void> closing;
public StealthStateManager(StealthStateRepository repository, PersistentStealthState initialState) { public StealthStateManager(StealthStateRepository repository, PersistentStealthState initialState) {
this(repository, initialState, newIoExecutor()); this(repository, initialState, newIoExecutor());
@@ -26,6 +28,7 @@ public final class StealthStateManager implements AutoCloseable {
ExecutorService ioExecutor) { ExecutorService ioExecutor) {
this.repository = Objects.requireNonNull(repository, "repository"); this.repository = Objects.requireNonNull(repository, "repository");
this.state = new AtomicReference<>(Objects.requireNonNull(initialState, "initialState")); this.state = new AtomicReference<>(Objects.requireNonNull(initialState, "initialState"));
this.durableState = new AtomicReference<>(initialState);
this.ioExecutor = ioExecutor; this.ioExecutor = ioExecutor;
} }
@@ -35,7 +38,7 @@ public final class StealthStateManager implements AutoCloseable {
return CompletableFuture.supplyAsync(() -> { return CompletableFuture.supplyAsync(() -> {
try { try {
return new StealthStateManager(repository, repository.load(), executor); return new StealthStateManager(repository, repository.load(), executor);
} catch (IOException exception) { } catch (IOException | RuntimeException exception) {
executor.shutdown(); executor.shutdown();
throw new CompletionException(exception); throw new CompletionException(exception);
} }
@@ -46,36 +49,51 @@ public final class StealthStateManager implements AutoCloseable {
return state.get(); return state.get();
} }
public CompletableFuture<Void> update(UnaryOperator<PersistentStealthState> operation) { /** Last successfully written snapshot, for rewards that must not precede persistence acknowledgement. */
public PersistentStealthState durableSnapshot() { return durableState.get(); }
public synchronized CompletableFuture<Void> update(UnaryOperator<PersistentStealthState> operation) {
if (closing != null) { return CompletableFuture.failedFuture(new IllegalStateException("Stealth state is closing")); }
PersistentStealthState updated = state.updateAndGet(operation); PersistentStealthState updated = state.updateAndGet(operation);
return persist(updated); return persist(updated);
} }
public CompletableFuture<Void> save() { public synchronized CompletableFuture<Void> save() {
return persist(state.get()); return closing == null ? persist(state.get()) : closing;
} }
private CompletableFuture<Void> persist(PersistentStealthState snapshot) { private CompletableFuture<Void> persist(PersistentStealthState snapshot) {
return CompletableFuture.runAsync(() -> { return CompletableFuture.runAsync(() -> {
try { try {
repository.save(snapshot); repository.save(snapshot);
durableState.set(snapshot);
} catch (IOException exception) { } catch (IOException exception) {
throw new CompletionException(exception); throw new CompletionException(exception);
} }
}, ioExecutor); }, ioExecutor);
} }
/** Queue the final snapshot and stop accepting mutations, without waiting on the caller's thread. */
public synchronized CompletableFuture<Void> closeAsync() {
if (closing == null) {
closing = persist(state.get());
ioExecutor.shutdown();
}
return closing;
}
/** Blocking convenience for non-server callers such as tests; server lifecycle callers should use closeAsync. */
@Override @Override
public void close() { public void close() {
save().join();
ioExecutor.shutdown();
try { try {
if (!ioExecutor.awaitTermination(10L, TimeUnit.SECONDS)) { closeAsync().join();
} finally {
try {
if (!ioExecutor.awaitTermination(10L, TimeUnit.SECONDS)) { ioExecutor.shutdownNow(); }
} catch (InterruptedException exception) {
ioExecutor.shutdownNow(); ioExecutor.shutdownNow();
Thread.currentThread().interrupt();
} }
} catch (InterruptedException exception) {
ioExecutor.shutdownNow();
Thread.currentThread().interrupt();
} }
} }
@@ -0,0 +1,64 @@
package games.dmg.spigotstealth;
import io.papermc.paper.event.player.ChatEvent;
import java.util.UUID;
import java.util.function.Predicate;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
/** Public-chat transport selection after moderation; private command messages are not handled here. */
public final class StealthSystemChat implements Listener {
private final Predicate<UUID> concealed;
private final NativeChatFiltering filtering = new NativeChatFiltering();
private final java.util.function.Consumer<Throwable> failures;
public StealthSystemChat(Predicate<UUID> concealed) {
this(concealed, failure -> System.getLogger(StealthSystemChat.class.getName()).log(System.Logger.Level.WARNING, "System chat delivery failed closed", failure));
}
public StealthSystemChat(Predicate<UUID> concealed, java.util.function.Consumer<Throwable> failures) {
this.concealed = java.util.Objects.requireNonNull(concealed);
this.failures = java.util.Objects.requireNonNull(failures);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onChat(ChatEvent event) {
if (event.isCancelled() || !concealed.test(event.getPlayer().getUniqueId())) { return; }
// Transport adaptation only, after the ordinary moderation priorities. Do not cancel or deliver here:
// Paper must still make its final cancellation decision before invoking any recipient.
var renderer = event.renderer();
var hiddenIdentity = java.util.regex.Pattern.compile(java.util.regex.Pattern.quote(event.getPlayer().getUniqueId().toString())
+ "|(?<![A-Za-z0-9_])" + java.util.regex.Pattern.quote(event.getPlayer().getName()) + "(?![A-Za-z0-9_])", java.util.regex.Pattern.CASE_INSENSITIVE);
var recipients = event.viewers().stream().map(viewer -> new SystemRecipient(viewer, hiddenIdentity, event.getPlayer())).toList();
event.viewers().clear();
event.viewers().addAll(recipients);
event.renderer((source, displayName, message, viewer) -> {
var original = viewer instanceof SystemRecipient recipient ? recipient.original : viewer;
if (renderer instanceof io.papermc.paper.chat.ChatRenderer.Default) {
return net.kyori.adventure.text.Component.text("[Stealth] Anonymous: ").append(message);
}
return renderer.render(source, net.kyori.adventure.text.Component.text("Anonymous"), message, original);
});
}
/** Deliberately not ForwardingAudience.Single: Paper unwraps it to the native signed-player path. */
private final class SystemRecipient implements net.kyori.adventure.audience.Audience {
private final net.kyori.adventure.audience.Audience original;
private final java.util.regex.Pattern hiddenIdentity;
private final org.bukkit.entity.Player sender;
private SystemRecipient(net.kyori.adventure.audience.Audience original, java.util.regex.Pattern hiddenIdentity, org.bukkit.entity.Player sender) {
this.original = original;
this.hiddenIdentity = hiddenIdentity;
this.sender = sender;
}
@Override public void sendMessage(net.kyori.adventure.chat.SignedMessage message, net.kyori.adventure.chat.ChatType.Bound type) {
try {
var filtered = filtering.filter(sender, original, message);
if (filtered.applies() && filtered.text() == null) { return; }
var body = message.unsignedContent() == null ? net.kyori.adventure.text.Component.text(message.message()) : message.unsignedContent();
var plain = filtered.applies() ? "[Stealth] Anonymous: " + filtered.text()
: net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(body);
original.sendMessage(net.kyori.adventure.text.Component.text(hiddenIdentity.matcher(plain).replaceAll("Anonymous")));
} catch (ReflectiveOperationException | RuntimeException failure) { failures.accept(failure); }
}
}
}
@@ -0,0 +1,60 @@
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.PacketEvent;
import java.util.Set;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.function.Supplier;
import org.bukkit.plugin.Plugin;
/** Unlists concealed entries without removing native profiles or chat-session data. */
public final class TabListMaskingListener extends PacketAdapter {
private final Supplier<Set<UUID>> concealed;
private final Consumer<String> warning;
private final java.util.concurrent.atomic.AtomicBoolean warned = new java.util.concurrent.atomic.AtomicBoolean();
public TabListMaskingListener(Plugin plugin, Supplier<Set<UUID>> concealed, Consumer<String> warning) {
super(plugin, ListenerPriority.HIGHEST, PacketType.Play.Server.PLAYER_INFO);
this.concealed = java.util.Objects.requireNonNull(concealed);
this.warning = java.util.Objects.requireNonNull(warning);
}
@Override public void onPacketSending(PacketEvent event) {
if (event.isCancelled() || event.isPlayerTemporary() || event.getPlayer() == null) { return; }
try {
var snapshot = concealed.get();
if (snapshot.isEmpty()) { return; }
Object original = event.getPacket().getHandle();
Object projected = project(original, snapshot, event.getPlayer().getUniqueId());
if (projected != original) { event.setPacket(new com.comphenix.protocol.events.PacketContainer(PacketType.Play.Server.PLAYER_INFO, projected)); }
} catch (ReflectiveOperationException | RuntimeException failure) {
if (warned.compareAndSet(false, true)) { warning.accept("Tab listing projection unavailable; native profile/session retained: " + failure.getClass().getSimpleName()); }
}
}
private static Object project(Object original, Set<UUID> hidden, UUID viewer) throws ReflectiveOperationException {
var packetType = Class.forName("net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket");
var actions = (java.util.EnumSet<?>) packetType.getMethod("actions").invoke(original);
if (actions.stream().noneMatch(action -> action.name().equals("UPDATE_LISTED"))) { return original; }
var entries = (java.util.List<?>) packetType.getMethod("entries").invoke(original);
var projected = new java.util.ArrayList<Object>(entries.size());
boolean changed = false;
for (var entry : entries) {
var type = entry.getClass();
UUID id = (UUID) type.getMethod("profileId").invoke(entry);
if (!id.equals(viewer) && hidden.contains(id) && (boolean) type.getMethod("listed").invoke(entry)) {
var components = type.getRecordComponents();
var parameterTypes = new Class<?>[components.length];
var values = new Object[components.length];
for (int i = 0; i < components.length; i++) {
parameterTypes[i] = components[i].getType();
values[i] = components[i].getName().equals("listed") ? false : components[i].getAccessor().invoke(entry);
}
projected.add(type.getConstructor(parameterTypes).newInstance(values));
changed = true;
} else { projected.add(entry); }
}
return changed ? packetType.getConstructor(java.util.EnumSet.class, java.util.List.class).newInstance(actions.clone(), java.util.List.copyOf(projected)) : original;
}
}
@@ -17,7 +17,8 @@ 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", "eye-progress");
private static final Set<String> EYE_FIELDS = Set.of("accumulated-millis", "unlocked");
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,14 +54,19 @@ public final class YamlStealthStateRepository implements StealthStateRepository
} }
} }
} }
return new PersistentStealthState(players, unknownRoot); return new PersistentStealthState(
players,
SleepCountPolicy.fromPersisted(yaml.get("sleep-count-policy")),
unknownRoot,
parseEyes(yaml.getConfigurationSection("eye-progress")));
} }
@Override @Override
public void save(PersistentStealthState state) throws IOException { public void save(PersistentStealthState state) throws IOException {
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", 2);
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));
@@ -71,6 +77,12 @@ public final class YamlStealthStateRepository implements StealthStateRepository
yaml.set(base + "concealed", player.concealed()); yaml.set(base + "concealed", player.concealed());
yaml.set(base + "qualifying-since", player.qualifyingSince() == null ? null : player.qualifyingSince().toString()); yaml.set(base + "qualifying-since", player.qualifyingSince() == null ? null : player.qualifyingSince().toString());
} }
state.eyes().forEach((id, progress) -> {
String base = "eye-progress." + id + ".";
progress.unknownFields().forEach((key, value) -> yaml.set(base + key, value));
yaml.set(base + "accumulated-millis", progress.accumulatedMillis());
yaml.set(base + "unlocked", progress.unlocked());
});
Path parent = stateFile.toAbsolutePath().getParent(); Path parent = stateFile.toAbsolutePath().getParent();
if (parent != null) { if (parent != null) {
Files.createDirectories(parent); Files.createDirectories(parent);
@@ -84,6 +96,27 @@ public final class YamlStealthStateRepository implements StealthStateRepository
} }
} }
private static Map<UUID, EyeProgress> parseEyes(ConfigurationSection section) {
Map<UUID, EyeProgress> eyes = new LinkedHashMap<>();
if (section == null) { return eyes; }
for (String key : section.getKeys(false)) {
try {
UUID id = UUID.fromString(key);
ConfigurationSection entry = section.getConfigurationSection(key);
if (entry == null) { continue; }
Object raw = entry.get("accumulated-millis");
if (!(raw instanceof Long || raw instanceof Integer || raw instanceof Short || raw instanceof Byte)) {
continue;
}
eyes.put(id, new EyeProgress(requireNonNegativeLong(entry, "accumulated-millis"),
requireBoolean(entry, "unlocked"), unknownValues(entry, EYE_FIELDS)));
} catch (IllegalArgumentException ignored) {
// A malformed Eye record cannot grant an unlock or erase independent Stealth records.
}
}
return eyes;
}
private static PlayerStealthState parsePlayer(UUID playerId, ConfigurationSection section) { private static PlayerStealthState parsePlayer(UUID playerId, ConfigurationSection section) {
long accumulated = requireNonNegativeLong(section, "accumulated-millis"); long accumulated = requireNonNegativeLong(section, "accumulated-millis");
boolean unlocked = requireBoolean(section, "unlocked"); boolean unlocked = requireBoolean(section, "unlocked");
+1 -1
View File
@@ -12,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 <player|uuid>|grant <player|uuid>|reset <player|uuid> confirm|list [unlocked]> 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,135 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import net.minecraft.world.scores.Scoreboard;
import org.bukkit.craftbukkit.scoreboard.CraftScoreboard;
import org.bukkit.entity.Player;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent.DamageCause;
import org.junit.jupiter.api.BeforeAll;
class CombatRevealTest {
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"melee", "arrow", "instant-potion", "cancelled", "zero", "self", "mob-target", "taking-only", "miss", "poison", "wither"})
void attributedDamageEndsOnlyAttackerSessionAndRestoresIdentityExactlyOnce(String kind) throws Exception {
Player attacker = player("Attacker"), victim = player("Victim");
var initial = new PersistentStealthState(Map.of(), Map.of())
.withPlayer(unlocked(attacker).withSession(true, false))
.withPlayer(unlocked(victim).withSession(true, false));
var repository = new StealthStateRepository() {
@Override public PersistentStealthState load() { return initial; }
@Override public void save(PersistentStealthState state) { }
};
try (var states = new StealthStateManager(repository, initial)) {
var progression = new QualifyingInvisibilityService(states, Duration.ofHours(8), () -> 0L, id -> { });
var sessions = new StealthSessionService(states, progression);
sessions.login(attacker.getUniqueId(), attacker.getName()).saved().join();
sessions.login(victim.getUniqueId(), victim.getName()).saved().join();
var board = new Scoreboard();
var constructor = CraftScoreboard.class.getDeclaredConstructor(Scoreboard.class);
constructor.setAccessible(true);
var restoredTabs = new ArrayList<UUID>();
var tabs = new TabListController() {
@Override public void remove(Player observer, UUID target) { }
@Override public void add(Player observer, Player target) { restoredTabs.add(target.getUniqueId()); }
};
var presentation = new BukkitIdentityPresentation(() -> List.of(attacker, victim), constructor.newInstance(board), tabs);
presentation.conceal(attacker);
presentation.conceal(victim);
var withdrawn = new ArrayList<UUID>();
var failures = new ArrayList<Throwable>();
var potions = new PotionDamageListener(id -> id.equals(attacker.getUniqueId()) ? attacker : null, failures::add);
var listener = new CombatRevealListener(sessions, presentation, withdrawn::add, failures::add, potions);
boolean delayed = kind.equals("poison") || kind.equals("wither");
if (delayed) {
var type = kind.equals("poison") ? org.bukkit.potion.PotionEffectType.POISON : org.bukkit.potion.PotionEffectType.WITHER;
var nativeType = kind.equals("poison") ? net.minecraft.world.effect.MobEffects.POISON : net.minecraft.world.effect.MobEffects.WITHER;
potions.onEffect(new org.bukkit.event.entity.EntityPotionEffectEvent(victim, null,
new org.bukkit.potion.PotionEffect(type, 100, 0), attacker,
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.POTION_SPLASH,
org.bukkit.event.entity.EntityPotionEffectEvent.Action.ADDED, true));
when(((org.bukkit.craftbukkit.entity.CraftPlayer) victim).getHandle().getEffect(nativeType))
.thenReturn(new net.minecraft.world.effect.MobEffectInstance(nativeType, 100, 0));
}
var sources = new net.minecraft.world.damagesource.DamageSources(new net.minecraft.core.RegistryAccess.ImmutableRegistryAccess(
List.of(org.bukkit.craftbukkit.CraftRegistry.getMinecraftRegistry(net.minecraft.core.registries.Registries.DAMAGE_TYPE))));
var handle = ((org.bukkit.craftbukkit.entity.CraftPlayer) attacker).getHandle();
var nativeSource = switch (kind) {
case "arrow" -> sources.arrow(mock(net.minecraft.world.entity.projectile.arrow.AbstractArrow.class), handle);
case "instant-potion" -> sources.indirectMagic(mock(net.minecraft.world.entity.Entity.class), handle);
case "taking-only", "poison" -> sources.magic();
case "wither" -> sources.wither();
default -> sources.playerAttack(handle);
};
var source = new org.bukkit.craftbukkit.damage.CraftDamageSource(nativeSource);
org.bukkit.entity.Entity target = switch (kind) {
case "mob-target" -> mock(org.bukkit.entity.Zombie.class);
case "self" -> attacker;
default -> victim;
};
org.bukkit.event.entity.EntityDamageEvent damage = delayed
? new org.bukkit.event.entity.EntityDamageEvent(victim, kind.equals("poison") ? DamageCause.POISON : DamageCause.WITHER, source, 1.0)
: new EntityDamageByEntityEvent(attacker, target, DamageCause.ENTITY_ATTACK, source, kind.equals("zero") ? 0 : 3.0);
damage.setCancelled(kind.equals("cancelled"));
var before = states.snapshot();
if (!kind.equals("miss")) { listener.onDamage(damage); }
if (java.util.Set.of("cancelled", "zero", "self", "mob-target", "taking-only", "miss").contains(kind)) {
assertEquals(before, states.snapshot());
assertEquals(java.util.Set.of("Attacker", "Victim"), presentation.concealedNames());
assertTrue(restoredTabs.isEmpty());
assertTrue(withdrawn.isEmpty());
verify(attacker, never()).sendMessage(anyString());
return;
}
assertFalse(sessions.isConcealed(attacker.getUniqueId()), "positive damage must end the attacker's active session");
assertTrue(sessions.isConcealed(victim.getUniqueId()), "taking damage alone must not end the victim's session");
assertEquals(java.util.Set.of(victim.getUniqueId()), sessions.concealedPlayerIds());
assertEquals(java.util.Set.of("Victim"), presentation.concealedNames());
assertNull(board.getPlayerTeam(BukkitIdentityPresentation.teamName(attacker.getUniqueId())));
assertNotNull(board.getPlayerTeam(BukkitIdentityPresentation.teamName(victim.getUniqueId())));
verify(attacker).setDisplayName("Original Attacker");
verify(attacker).setSleepingIgnored(false);
assertEquals(List.of(attacker.getUniqueId()), restoredTabs);
assertEquals(List.of(attacker.getUniqueId()), withdrawn);
verify(attacker).sendMessage("Your stealth was broken because you hurt another player.");
verify(attacker, never()).removePotionEffect(any());
verify(attacker, never()).setInvisible(anyBoolean());
assertTrue(states.snapshot().player(attacker.getUniqueId()).unlocked());
assertEquals(28_800_000, states.snapshot().player(attacker.getUniqueId()).accumulatedMillis());
assertFalse(states.snapshot().player(attacker.getUniqueId()).preparedLogin());
listener.onDamage(damage);
assertEquals(1, restoredTabs.size());
verify(attacker, times(1)).sendMessage("Your stealth was broken because you hurt another player.");
assertFalse(sessions.login(attacker.getUniqueId(), attacker.getName()).concealed(), "damage must not prepare a direct re-entry");
progression.begin(attacker.getUniqueId(), attacker.getName(), java.time.Instant.EPOCH).join();
sessions.disconnect(attacker.getUniqueId()).join();
assertTrue(sessions.login(attacker.getUniqueId(), attacker.getName()).concealed(), "a later qualifying logout/login still works");
states.save().join();
assertTrue(failures.isEmpty());
}
}
private static Player player(String name) {
var player = mock(org.bukkit.craftbukkit.entity.CraftPlayer.class);
var handle = mock(net.minecraft.server.level.ServerPlayer.class);
when(handle.getBukkitEntity()).thenReturn(player);
when(player.getHandle()).thenReturn(handle);
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
when(player.getName()).thenReturn(name);
when(player.getDisplayName()).thenReturn("Original " + name);
return player;
}
private static PlayerStealthState unlocked(Player player) {
return new PlayerStealthState(player.getUniqueId(), player.getName(), 28_800_000, true, false, false, null, Map.of());
}
}
@@ -0,0 +1,215 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.nio.file.Path;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.event.inventory.PrepareItemCraftEvent;
import org.bukkit.inventory.CraftingInventory;
import org.bukkit.inventory.InventoryView;
import org.bukkit.inventory.ItemStack;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class EyeCraftingTest {
@TempDir Path directory;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test
void eightNetheriteBlocksAroundAnEnderEyeCanOnlyBeCraftedAfterThePersonalUnlock() throws Exception {
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
UUID id = UUID.randomUUID();
try (var states = new StealthStateManager(repository, repository.load())) {
var progression = new EyeProgressionService(states, () -> 0L, ignored -> { });
var items = new EyeItems();
var crafting = new EyeCrafting(progression, items);
var recipe = crafting.recipe();
assertArrayEquals(new String[] {"NNN", "NEN", "NNN"}, recipe.getShape());
assertEquals(Material.NETHERITE_BLOCK, recipe.getIngredientMap().get('N').getType());
assertEquals(Material.ENDER_EYE, recipe.getIngredientMap().get('E').getType());
assertTrue(items.isEye(recipe.getResult()));
var player = mock(Player.class);
when(player.getUniqueId()).thenReturn(id);
var inventory = mock(CraftingInventory.class);
when(inventory.getRecipe()).thenReturn(recipe);
when(inventory.getMatrix()).thenReturn(matrix());
var result = new AtomicReference<>(recipe.getResult());
when(inventory.getResult()).thenAnswer(ignored -> result.get());
doAnswer(call -> { result.set(call.getArgument(0)); return null; }).when(inventory).setResult(any());
var view = mock(InventoryView.class);
when(view.getPlayer()).thenReturn(player);
when(view.getTopInventory()).thenReturn(inventory);
var event = new PrepareItemCraftEvent(inventory, view, false);
crafting.onPrepare(event);
assertNull(result.get(), "a locked player cannot obtain the recipe result");
states.update(state -> state.withEyeProgress(id, new EyeProgress(28_800_000, true))).get(3, TimeUnit.SECONDS);
result.set(recipe.getResult()); // A fresh native recipe preparation after earning the unlock.
crafting.onPrepare(event);
assertTrue(items.isEye(result.get()));
assertEquals(1, result.get().getAmount());
verify(inventory, never()).setMatrix(any()); // Vanilla owns all ingredient consumption.
}
}
@Test
void resultClicksRecheckEligibilityIngredientsAndIdentityWithoutBypassingCancellation() throws Exception {
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
UUID id = UUID.randomUUID();
try (var states = new StealthStateManager(repository, repository.load())) {
var progression = new EyeProgressionService(states, () -> 0L, ignored -> { });
var items = new EyeItems();
var crafting = new EyeCrafting(progression, items);
var recipe = crafting.recipe();
var player = mock(Player.class);
when(player.getUniqueId()).thenReturn(id);
var inventory = mock(CraftingInventory.class);
when(inventory.getRecipe()).thenReturn(recipe);
ItemStack[] ingredients = matrix();
when(inventory.getMatrix()).thenReturn(ingredients);
var result = new AtomicReference<>(recipe.getResult());
when(inventory.getResult()).thenAnswer(ignored -> result.get());
doAnswer(call -> { result.set(call.getArgument(0)); return null; }).when(inventory).setResult(any());
var view = mock(InventoryView.class);
when(view.getPlayer()).thenReturn(player);
when(view.getTopInventory()).thenReturn(inventory);
var locked = click(recipe, view);
crafting.onCraft(locked);
assertTrue(locked.isCancelled(), "a stale preview must not bypass the personal unlock on shift-click");
assertNull(result.get());
states.update(state -> state.withEyeProgress(id, new EyeProgress(28_800_000, true))).get(3, TimeUnit.SECONDS);
result.set(recipe.getResult());
var allowed = click(recipe, view);
crafting.onCraft(allowed);
assertFalse(allowed.isCancelled());
assertTrue(items.isEye(result.get()));
ingredients[0] = new ItemStack(Material.DIRT);
var invalidIngredients = click(recipe, view);
crafting.onCraft(invalidIngredients);
assertTrue(invalidIngredients.isCancelled());
assertNull(result.get());
ingredients[0] = new ItemStack(Material.NETHERITE_BLOCK);
result.set(new ItemStack(Material.ENDER_EYE));
var invalidResult = click(recipe, view);
crafting.onCraft(invalidResult);
assertTrue(invalidResult.isCancelled());
result.set(recipe.getResult());
result.get().setAmount(2);
var inflatedResult = click(recipe, view);
crafting.onCraft(inflatedResult);
assertTrue(inflatedResult.isCancelled());
result.set(recipe.getResult());
var cancelled = click(recipe, view);
cancelled.setCancelled(true);
crafting.onCraft(cancelled);
assertTrue(cancelled.isCancelled());
var otherRecipe = new org.bukkit.inventory.ShapedRecipe(new org.bukkit.NamespacedKey("other", "eye"),
new ItemStack(Material.ENDER_EYE)).shape("E").setIngredient('E', Material.ENDER_EYE);
var unrelated = click(otherRecipe, view);
crafting.onCraft(unrelated);
assertFalse(unrelated.isCancelled());
verify(inventory, never()).setMatrix(any());
}
}
@Test
void pendingOrFailedUnlockWritesCannotExposeACraftableResult() throws Exception {
var disk = new YamlStealthStateRepository(directory.resolve("state.yml"));
var started = new java.util.concurrent.CountDownLatch(1);
var release = new java.util.concurrent.CountDownLatch(1);
var firstWrite = new java.util.concurrent.atomic.AtomicBoolean(true);
StealthStateRepository repository = new StealthStateRepository() {
@Override public PersistentStealthState load() throws java.io.IOException { return disk.load(); }
@Override public void save(PersistentStealthState state) throws java.io.IOException {
if (firstWrite.getAndSet(false)) {
started.countDown();
try {
if (!release.await(3, TimeUnit.SECONDS)) { throw new java.io.IOException("Test write gate timed out"); }
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new java.io.IOException(exception);
}
throw new java.io.IOException("Injected write failure");
}
disk.save(state);
}
};
UUID id = UUID.randomUUID();
try (var states = new StealthStateManager(repository, repository.load())) {
try {
var items = new EyeItems();
var crafting = new EyeCrafting(new EyeProgressionService(states, () -> 0L, ignored -> { }), items);
var recipe = crafting.recipe();
var player = mock(Player.class);
when(player.getUniqueId()).thenReturn(id);
var inventory = mock(CraftingInventory.class);
when(inventory.getRecipe()).thenReturn(recipe);
when(inventory.getMatrix()).thenReturn(matrix());
var result = new AtomicReference<>(recipe.getResult());
when(inventory.getResult()).thenAnswer(ignored -> result.get());
doAnswer(call -> { result.set(call.getArgument(0)); return null; }).when(inventory).setResult(any());
var view = mock(InventoryView.class);
when(view.getPlayer()).thenReturn(player);
when(view.getTopInventory()).thenReturn(inventory);
var event = new PrepareItemCraftEvent(inventory, view, false);
var pending = states.update(state -> state.withEyeProgress(id, new EyeProgress(28_800_000, true)));
assertTrue(started.await(3, TimeUnit.SECONDS));
assertTrue(states.snapshot().eyeProgress(id).unlocked());
crafting.onPrepare(event);
assertNull(result.get(), "in-memory progress is not a durable crafting unlock");
release.countDown();
assertThrows(java.util.concurrent.ExecutionException.class, () -> pending.get(3, TimeUnit.SECONDS));
result.set(recipe.getResult());
crafting.onPrepare(event);
assertNull(result.get(), "failed writes must not grant the recipe");
var denied = click(recipe, view);
crafting.onCraft(denied);
assertTrue(denied.isCancelled());
states.save().get(3, TimeUnit.SECONDS);
result.set(recipe.getResult());
crafting.onPrepare(event);
assertTrue(items.isEye(result.get()));
var allowed = click(recipe, view);
crafting.onCraft(allowed);
assertFalse(allowed.isCancelled());
} finally { release.countDown(); }
}
}
@Test
void automatedCraftersCannotBypassPersonalProgression() throws Exception {
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
try (var states = new StealthStateManager(repository, repository.load())) {
var items = new EyeItems();
var crafting = new EyeCrafting(new EyeProgressionService(states, () -> 0L, ignored -> { }), items);
var block = mock(org.bukkit.block.Block.class);
var event = new org.bukkit.event.block.CrafterCraftEvent(block, crafting.recipe(), items.create());
crafting.onCrafter(event);
assertTrue(event.isCancelled(), "a block has no personal Eye unlock");
var other = new org.bukkit.inventory.ShapedRecipe(new org.bukkit.NamespacedKey("other", "eye"),
new ItemStack(Material.ENDER_EYE)).shape("E").setIngredient('E', Material.ENDER_EYE);
var unrelated = new org.bukkit.event.block.CrafterCraftEvent(block, other, other.getResult());
crafting.onCrafter(unrelated);
assertFalse(unrelated.isCancelled());
}
}
private static org.bukkit.event.inventory.CraftItemEvent click(org.bukkit.inventory.Recipe recipe, InventoryView view) {
return new org.bukkit.event.inventory.CraftItemEvent(recipe, view,
org.bukkit.event.inventory.InventoryType.SlotType.RESULT, 0,
org.bukkit.event.inventory.ClickType.SHIFT_LEFT, org.bukkit.event.inventory.InventoryAction.MOVE_TO_OTHER_INVENTORY);
}
private static ItemStack[] matrix() {
var result = new ItemStack[9];
for (int index = 0; index < result.length; index++) {
result[index] = new ItemStack(index == 4 ? Material.ENDER_EYE : Material.NETHERITE_BLOCK);
}
return result;
}
}
@@ -0,0 +1,92 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.nio.file.Path;
import java.util.EnumMap;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import org.bukkit.Material;
import org.bukkit.block.BlockFace;
import org.bukkit.entity.Player;
import org.bukkit.event.Event;
import org.bukkit.event.block.Action;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class EyeEquipmentTest {
@TempDir Path directory;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test
void tradedEyesRequireTheHoldersOwnUnlockAndEquipWithoutThrowingOrReplacingAHelmet() throws Exception {
UUID earner = UUID.randomUUID(), holder = UUID.randomUUID();
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
try (var states = new StealthStateManager(repository, repository.load())) {
states.update(state -> state.withEyeProgress(earner, new EyeProgress(28_800_000, true))).get(3, TimeUnit.SECONDS);
var items = new EyeItems();
var equipment = new EyeEquipment(new EyeProgressionService(states, () -> 0L, ignored -> { }), items);
var player = mock(Player.class);
when(player.getUniqueId()).thenReturn(holder);
var inventory = mock(PlayerInventory.class);
when(player.getInventory()).thenReturn(inventory);
var helmet = new AtomicReference<ItemStack>();
when(inventory.getHelmet()).thenAnswer(ignored -> helmet.get());
doAnswer(call -> { helmet.set(call.getArgument(0)); return null; }).when(inventory).setHelmet(any());
var hands = new EnumMap<EquipmentSlot, ItemStack>(EquipmentSlot.class);
when(inventory.getItem(any(EquipmentSlot.class))).thenAnswer(call -> hands.get(call.getArgument(0)));
doAnswer(call -> { hands.put(call.getArgument(0), call.getArgument(1)); return null; })
.when(inventory).setItem(any(EquipmentSlot.class), nullable(ItemStack.class));
ItemStack traded = items.create();
hands.put(EquipmentSlot.HAND, traded);
helmet.set(traded.clone());
assertFalse(equipment.isEligibleWearer(player), "equipping somebody else's Eye must not bypass the personal unlock");
helmet.set(null);
var denied = use(player, traded, EquipmentSlot.HAND);
equipment.onUse(denied);
assertEquals(Event.Result.DENY, denied.useItemInHand(), "custom Eyes must never fall through to vanilla Ender Eye use");
assertNull(helmet.get());
assertSame(traded, hands.get(EquipmentSlot.HAND));
states.update(state -> state.withEyeProgress(holder, new EyeProgress(28_800_000, true))).get(3, TimeUnit.SECONDS);
var allowed = use(player, traded, EquipmentSlot.HAND);
equipment.onUse(allowed);
assertEquals(Event.Result.DENY, allowed.useItemInHand());
assertNull(hands.get(EquipmentSlot.HAND));
assertTrue(items.isEye(helmet.get()));
assertEquals(1, helmet.get().getAmount());
assertTrue(equipment.isEligibleWearer(player));
var ordinaryHelmet = new ItemStack(Material.IRON_HELMET);
helmet.set(ordinaryHelmet);
hands.put(EquipmentSlot.OFF_HAND, items.create());
equipment.onUse(use(player, hands.get(EquipmentSlot.OFF_HAND), EquipmentSlot.OFF_HAND));
assertSame(ordinaryHelmet, helmet.get());
assertTrue(items.isEye(hands.get(EquipmentSlot.OFF_HAND)));
assertFalse(equipment.isEligibleWearer(player), "holding an Eye is not wearing it");
helmet.set(null);
equipment.onUse(use(player, hands.get(EquipmentSlot.OFF_HAND), EquipmentSlot.OFF_HAND));
assertNull(hands.get(EquipmentSlot.OFF_HAND));
assertTrue(equipment.isEligibleWearer(player));
helmet.set(null);
hands.put(EquipmentSlot.HAND, items.create());
var cancelled = use(player, hands.get(EquipmentSlot.HAND), EquipmentSlot.HAND);
cancelled.setUseItemInHand(Event.Result.DENY);
equipment.onUse(cancelled);
assertNull(helmet.get());
assertTrue(items.isEye(hands.get(EquipmentSlot.HAND)));
var ordinary = use(player, new ItemStack(Material.ENDER_EYE), EquipmentSlot.HAND);
equipment.onUse(ordinary);
assertEquals(Event.Result.DEFAULT, ordinary.useItemInHand());
}
}
private static PlayerInteractEvent use(Player player, ItemStack item, EquipmentSlot hand) {
return new PlayerInteractEvent(player, Action.RIGHT_CLICK_AIR, item, null, BlockFace.SELF, hand);
}
}
@@ -0,0 +1,39 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import net.kyori.adventure.text.Component;
import org.bukkit.Material;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.ItemStack;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
class EyeItemsTest {
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test
void createdEyeIsAnAuthenticatedHelmetItemButPlainAndRenamedEyesAreNot() {
var items = new EyeItems();
ItemStack eye = items.create();
assertEquals(Material.ENDER_EYE, eye.getType());
assertEquals(Component.text("Eye of True Seeing"), eye.getItemMeta().displayName());
assertTrue(items.isEye(eye));
assertTrue(items.isEye(eye.clone()), "ordinary inventory transfer must retain item identity");
assertEquals(EquipmentSlot.HEAD, eye.getItemMeta().getEquippable().getSlot());
assertEquals(1, eye.getMaxStackSize());
assertFalse(eye.getItemMeta().getEquippable().isDispensable());
assertFalse(eye.getItemMeta().getEquippable().isEquipOnInteract(),
"the eligibility-checked interaction handler, not native item use, equips the Eye");
var ordinary = new ItemStack(Material.ENDER_EYE);
assertFalse(items.isEye(ordinary));
var renamed = ordinary.getItemMeta();
renamed.displayName(eye.getItemMeta().displayName());
ordinary.setItemMeta(renamed);
assertFalse(items.isEye(ordinary), "anvil/display names cannot authenticate an Eye");
var wrongMaterial = eye.clone();
wrongMaterial.setType(Material.STONE);
assertFalse(items.isEye(wrongMaterial));
assertFalse(items.isEye(null));
}
}
@@ -0,0 +1,58 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import io.netty.buffer.Unpooled;
import java.util.List;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket;
import net.minecraft.network.syncher.EntityDataAccessor;
import net.minecraft.network.syncher.EntityDataSerializers;
import net.minecraft.network.syncher.SynchedEntityData;
import org.bukkit.craftbukkit.CraftRegistry;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
class EyeMetadataProjectionTest {
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test
void nativeMetadataProjectionClearsOnlyInvisibilityAndKeepsTheSharedPacketUnchanged() throws Exception {
var config = new io.papermc.paper.configuration.GlobalConfiguration();
config.anticheat = config.new Anticheat();
config.anticheat.obfuscation = config.anticheat.new Obfuscation();
config.anticheat.obfuscation.items = config.anticheat.obfuscation.new Items();
var server = org.mockito.Mockito.mock(net.minecraft.server.MinecraftServer.class);
org.mockito.Mockito.when(server.registryAccess()).thenReturn(CraftRegistry.getMinecraftRegistry().freeze());
try (var runtime = org.mockito.Mockito.mockStatic(net.minecraft.server.MinecraftServer.class);
var platform = org.mockito.Mockito.mockStatic(io.papermc.paper.configuration.GlobalConfiguration.class)) {
runtime.when(net.minecraft.server.MinecraftServer::getServer).thenReturn(server);
platform.when(io.papermc.paper.configuration.GlobalConfiguration::get).thenReturn(config);
config.anticheat.obfuscation.items.bindDataSanitizer();
assertProjection();
}
}
private static void assertProjection() throws Exception {
var accessorField = net.minecraft.world.entity.Entity.class.getDeclaredField("DATA_SHARED_FLAGS_ID");
accessorField.setAccessible(true);
var accessor = assertInstanceOf(EntityDataAccessor.class, accessorField.get(null));
assertEquals(0, accessor.id());
assertSame(EntityDataSerializers.BYTE, accessor.serializer());
for (int flags = 0; flags <= 255; flags++) {
var sharedFlags = new SynchedEntityData.DataValue<>(0, EntityDataSerializers.BYTE, (byte) flags);
var air = new SynchedEntityData.DataValue<>(1, EntityDataSerializers.INT, 300);
var original = new ClientboundSetEntityDataPacket(42, List.of(sharedFlags, air));
var projected = assertInstanceOf(ClientboundSetEntityDataPacket.class, EyePacketProjection.metadataPacket(original));
assertEquals(42, projected.id());
assertEquals((byte) (flags & ~0x20), projected.packedItems().getFirst().value(), "flag byte " + flags);
assertSame(air, projected.packedItems().get(1));
assertEquals((byte) flags, original.packedItems().getFirst().value());
var buffer = new RegistryFriendlyByteBuf(Unpooled.buffer(), CraftRegistry.getMinecraftRegistry());
try {
ClientboundSetEntityDataPacket.STREAM_CODEC.encode(buffer, projected);
assertEquals(projected, ClientboundSetEntityDataPacket.STREAM_CODEC.decode(buffer));
} finally { buffer.release(); }
}
}
}
@@ -0,0 +1,122 @@
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.PacketContainer;
import com.comphenix.protocol.events.PacketEvent;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket;
import net.minecraft.network.syncher.EntityDataSerializers;
import net.minecraft.network.syncher.SynchedEntityData;
import org.bukkit.entity.Player;
import org.bukkit.plugin.Plugin;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
class EyePacketListenerTest {
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test
@SuppressWarnings("try")
void metadataIsProjectedOnlyForTheCurrentlyAuthorizedRecipientWithoutMutatingSharedPackets() {
var server = mock(org.bukkit.craftbukkit.CraftServer.class);
try (var platform = mockStatic(org.bukkit.Bukkit.class, call -> switch (call.getMethod().getName()) {
case "getServer" -> server;
case "isPrimaryThread" -> true;
case "getVersion" -> "Purpur 2618 (MC: 26.2)";
case "getMinecraftVersion" -> "26.2";
case "getBukkitVersion" -> "26.2-R0.1-SNAPSHOT";
default -> call.callRealMethod();
})) {
UUID viewerId = UUID.randomUUID();
var viewer = mock(Player.class);
when(viewer.getUniqueId()).thenReturn(viewerId);
var other = mock(Player.class);
when(other.getUniqueId()).thenReturn(UUID.randomUUID());
var projection = new EyeRevealController.Projection(UUID.randomUUID(), 42, "Concealed", "eye-private", Optional.of("source"), true);
var snapshot = new AtomicReference<>(Map.of(viewerId, Map.of(42, projection)));
var warnings = new ArrayList<String>();
var dirty = new ArrayList<UUID>();
var listener = new EyePacketListener(mock(Plugin.class), snapshot::get, dirty::add, warnings::add);
assertEquals(Set.of(PacketType.Play.Server.ENTITY_METADATA, PacketType.Play.Server.SCOREBOARD_TEAM),
listener.getSendingWhitelist().getTypes());
var raw = new ClientboundSetEntityDataPacket(42, List.of(new SynchedEntityData.DataValue<>(0, EntityDataSerializers.BYTE, (byte) 0x63)));
var shared = new PacketContainer(PacketType.Play.Server.ENTITY_METADATA, raw);
var first = PacketEvent.fromServer(this, shared, viewer);
listener.onPacketSending(first);
var shown = assertInstanceOf(ClientboundSetEntityDataPacket.class, first.getPacket().getHandle());
assertEquals((byte) 0x43, shown.packedItems().getFirst().value());
assertEquals((byte) 0x63, raw.packedItems().getFirst().value());
var second = PacketEvent.fromServer(this, shared, other);
listener.onPacketSending(second);
assertSame(shared, second.getPacket());
snapshot.set(Map.of());
var late = PacketEvent.fromServer(this, shared, viewer);
listener.onPacketSending(late);
assertSame(shared, late.getPacket(), "queued packets must use current, not captured authorization");
snapshot.set(Map.of(viewerId, Map.of(42, projection)));
var cancelled = PacketEvent.fromServer(this, shared, viewer);
cancelled.setCancelled(true);
listener.onPacketSending(cancelled);
assertTrue(cancelled.isCancelled());
assertSame(shared, cancelled.getPacket());
var noBody = new EyeRevealController.Projection(projection.targetId(), 42, projection.playerName(),
projection.privateTeam(), projection.sourceTeam(), false);
snapshot.set(Map.of(viewerId, Map.of(42, noBody)));
var notGameplayInvisible = PacketEvent.fromServer(this, shared, viewer);
listener.onPacketSending(notGameplayInvisible);
assertSame(shared, notGameplayInvisible.getPacket());
snapshot.set(Map.of(viewerId, Map.of(42, projection)));
var board = new net.minecraft.world.scores.Scoreboard();
var privateTeam = board.addPlayerTeam("eye-private");
privateTeam.setPlayerPrefix(net.minecraft.network.chat.Component.empty().withStyle(net.minecraft.ChatFormatting.OBFUSCATED));
board.addPlayerToTeam("Concealed", privateTeam);
var privateRaw = net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.createAddOrModifyPacket(privateTeam, true);
var privateWrapped = new PacketContainer(PacketType.Play.Server.SCOREBOARD_TEAM, privateRaw);
var privateEvent = PacketEvent.fromServer(this, privateWrapped, viewer);
listener.onPacketSending(privateEvent);
var shownTeam = assertInstanceOf(net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.class, privateEvent.getPacket().getHandle());
assertFalse(shownTeam.getParameters().orElseThrow().playerPrefix().getStyle().isObfuscated());
assertTrue(privateRaw.getParameters().orElseThrow().playerPrefix().getStyle().isObfuscated());
assertTrue(dirty.isEmpty(), "our own private updates must not create a refresh loop");
var source = board.addPlayerTeam("source");
source.setPlayerPrefix(net.minecraft.network.chat.Component.empty().withStyle(net.minecraft.ChatFormatting.OBFUSCATED));
board.addPlayerToTeam("Concealed", source);
board.addPlayerToTeam("Other", source);
var sourceRaw = net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.createAddOrModifyPacket(source, true);
var sourceWrapped = new PacketContainer(PacketType.Play.Server.SCOREBOARD_TEAM, sourceRaw);
var sourceEvent = PacketEvent.fromServer(this, sourceWrapped, viewer);
listener.onPacketSending(sourceEvent);
var filtered = assertInstanceOf(net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.class, sourceEvent.getPacket().getHandle());
assertEquals(Set.of("Other"), Set.copyOf(filtered.getPlayers()));
assertTrue(filtered.getParameters().orElseThrow().playerPrefix().getStyle().isObfuscated());
assertEquals(List.of(viewerId), dirty, "source style updates request a main-thread refresh");
var otherEvent = PacketEvent.fromServer(this, sourceWrapped, other);
listener.onPacketSending(otherEvent);
assertSame(sourceWrapped, otherEvent.getPacket());
var system = new PacketContainer(PacketType.Play.Server.SYSTEM_CHAT,
new net.minecraft.network.protocol.game.ClientboundSystemChatPacket(net.minecraft.network.chat.Component.literal("Unchanged"), false));
var systemEvent = PacketEvent.fromServer(this, system, viewer);
listener.onPacketSending(systemEvent);
assertSame(system, systemEvent.getPacket());
var tabRemoval = new PacketContainer(PacketType.Play.Server.PLAYER_INFO_REMOVE);
tabRemoval.getUUIDLists().write(0, List.of(projection.targetId()));
var tabEvent = PacketEvent.fromServer(this, tabRemoval, viewer);
listener.onPacketSending(tabEvent);
assertSame(tabRemoval, tabEvent.getPacket());
assertEquals(List.of(projection.targetId()), tabEvent.getPacket().getUUIDLists().read(0));
assertTrue(warnings.isEmpty());
verify(viewer, never()).getInventory();
verify(viewer, never()).getWorld();
verify(viewer, never()).hasLineOfSight(any(org.bukkit.entity.Entity.class));
}
}
}
@@ -0,0 +1,225 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import com.comphenix.protocol.ProtocolLibrary;
import com.comphenix.protocol.ProtocolManager;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import java.util.logging.Logger;
import org.bukkit.Server;
import org.bukkit.command.PluginCommand;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.entity.Player;
import org.bukkit.event.Event;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.inventory.PrepareItemCraftEvent;
import org.bukkit.inventory.CraftingInventory;
import org.bukkit.inventory.InventoryView;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.Recipe;
import org.bukkit.plugin.EventExecutor;
import org.bukkit.plugin.PluginDescriptionFile;
import org.bukkit.plugin.PluginManager;
import org.bukkit.plugin.RegisteredListener;
import org.bukkit.scheduler.BukkitScheduler;
import org.bukkit.scheduler.BukkitTask;
import org.bukkit.scoreboard.Scoreboard;
import org.bukkit.scoreboard.ScoreboardManager;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class EyePluginLifecycleTest {
@TempDir Path directory;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"normal", "disabled", "reenabled", "presentation-failure"})
@SuppressWarnings("try") // The scoped Bukkit boundary delegates non-server-metadata operations to native code.
void startupAndLateCallbacksRespectSavedUnlocksAndDisable(String mode) throws Exception {
UUID id = UUID.randomUUID(), learner = UUID.randomUUID();
var disk = new YamlStealthStateRepository(directory.resolve("state.yml"));
disk.save(new PersistentStealthState(Map.of(), Map.of()).withEyeProgress(id, new EyeProgress(28_800_000, true)));
var plugin = mock(SpigotStealthPlugin.class, CALLS_REAL_METHODS);
var clock = new java.util.concurrent.atomic.AtomicLong();
doAnswer(ignored -> clock.get()).when(plugin).monotonicNanos();
Server server = mock(org.bukkit.craftbukkit.CraftServer.class);
var plugins = mock(PluginManager.class);
BukkitScheduler scheduler = mock(org.bukkit.craftbukkit.scheduler.CraftScheduler.class);
var player = mock(Player.class);
when(player.getUniqueId()).thenReturn(id);
doReturn(List.of(player)).when(server).getOnlinePlayers();
when(server.getPlayer(id)).thenReturn(player);
doReturn(server).when(plugin).getServer();
doReturn(true).when(plugin).isEnabled();
doReturn(directory.toFile()).when(plugin).getDataFolder();
doReturn(new YamlConfiguration()).when(plugin).getConfig();
doReturn(Logger.getAnonymousLogger()).when(plugin).getLogger();
doReturn(new PluginDescriptionFile("SpigotStealth", "test", SpigotStealthPlugin.class.getName()))
.when(plugin).getDescription();
doNothing().when(plugin).saveDefaultConfig();
doReturn(mock(PluginCommand.class)).when(plugin).getCommand("stealth");
doReturn(mock(PluginCommand.class)).when(plugin).getCommand("stealthadmin");
when(server.getPluginManager()).thenReturn(plugins);
when(server.getScheduler()).thenReturn(scheduler);
ScoreboardManager boards = mock(org.bukkit.craftbukkit.scoreboard.CraftScoreboardManager.class);
when(server.getScoreboardManager()).thenReturn(boards);
when(boards.getMainScoreboard()).thenReturn(mock(org.bukkit.craftbukkit.scoreboard.CraftScoreboard.class));
var main = new LinkedBlockingQueue<Runnable>();
when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenAnswer(call -> {
main.add(call.getArgument(1)); return mock(BukkitTask.class);
});
var timers = new ArrayList<Runnable>();
doAnswer(call -> {
Consumer<BukkitTask> action = call.getArgument(1);
timers.add(() -> action.accept(mock(BukkitTask.class)));
return null;
}).when(scheduler).runTaskTimer(eq(plugin), org.mockito.ArgumentMatchers.<Consumer<BukkitTask>>any(), anyLong(), anyLong());
when(scheduler.runTaskTimer(eq(plugin), any(Runnable.class), anyLong(), anyLong())).thenAnswer(call -> {
timers.add(call.getArgument(1)); return mock(BukkitTask.class);
});
var recipe = new AtomicReference<Recipe>();
when(server.addRecipe(any(Recipe.class))).thenAnswer(call -> { recipe.set(call.getArgument(0)); return true; });
var listeners = new ArrayList<Listener>();
doAnswer(call -> { listeners.add(call.getArgument(0)); return null; }).when(plugins).registerEvents(any(), eq(plugin));
try (var platform = mockStatic(org.bukkit.Bukkit.class, call -> switch (call.getMethod().getName()) {
case "getServer" -> server;
case "getVersion" -> "Purpur 2618 (MC: 26.2)";
case "getMinecraftVersion" -> "26.2";
case "getBukkitVersion" -> "26.2-R0.1-SNAPSHOT";
default -> call.callRealMethod();
}); var protocol = mockStatic(ProtocolLibrary.class)) {
var protocolManager = mock(ProtocolManager.class);
var registeredPackets = new ArrayList<com.comphenix.protocol.events.PacketListener>();
doAnswer(call -> { registeredPackets.add(call.getArgument(0)); return null; }).when(protocolManager).addPacketListener(any());
doAnswer(call -> { registeredPackets.remove(call.getArgument(0)); return null; }).when(protocolManager).removePacketListener(any());
protocol.when(ProtocolLibrary::getProtocolManager).thenReturn(protocolManager);
try {
plugin.onEnable();
assertNull(recipe.get(), "Bukkit registration must wait for durable state initialization and main-thread dispatch");
Runnable initialization = main.poll(3, TimeUnit.SECONDS);
assertNotNull(initialization);
if (mode.equals("disabled") || mode.equals("reenabled")) {
doReturn(false).when(plugin).isEnabled();
plugin.onDisable();
if (mode.equals("reenabled")) {
doReturn(true).when(plugin).isEnabled();
plugin.onEnable();
Runnable currentInitialization = main.poll(3, TimeUnit.SECONDS);
assertNotNull(currentInitialization);
currentInitialization.run();
int registeredCount = listeners.size();
assertNotNull(recipe.get());
initialization.run();
assertEquals(registeredCount, listeners.size(), "an old lifecycle callback must not touch a re-enabled plugin");
verify(server, times(1)).addRecipe(any(Recipe.class));
return;
}
initialization.run();
assertTrue(listeners.isEmpty(), "a queued initialization must not register listeners after disable");
assertNull(recipe.get());
verify(server, never()).removeRecipe(any());
return;
}
initialization.run();
assertNotNull(recipe.get(), "plugin startup must register the Eye recipe");
assertTrue(new EyeItems().isEye(recipe.get().getResult()));
assertTrue(listeners.stream().anyMatch(EyePotionListener.class::isInstance));
assertTrue(listeners.stream().anyMatch(EyeEquipment.class::isInstance));
assertTrue(listeners.stream().anyMatch(CombatRevealListener.class::isInstance), "plugin startup must install combat breaking");
assertTrue(listeners.stream().anyMatch(StealthSystemChat.class::isInstance), "plugin startup must register anonymous public-chat routing");
assertTrue(registeredPackets.stream().anyMatch(TabListMaskingListener.class::isInstance), "plugin startup must preserve profile/session data while hiding tab entries");
assertTrue(listeners.stream().anyMatch(PotionDamageListener.class::isInstance), "plugin startup must observe potion provenance");
assertTrue(listeners.stream().anyMatch(EyeRevealEvents.class::isInstance), "plugin startup must register reveal invalidation hooks");
assertTrue(registeredPackets.stream().anyMatch(EyePacketListener.class::isInstance), "plugin startup must register the per-observer packet gate");
var inventory = mock(CraftingInventory.class);
when(inventory.getRecipe()).thenAnswer(ignored -> recipe.get());
var result = new AtomicReference<>(recipe.get().getResult());
when(inventory.getResult()).thenAnswer(ignored -> result.get());
doAnswer(call -> { result.set(call.getArgument(0)); return null; }).when(inventory).setResult(any());
var view = mock(InventoryView.class);
when(view.getPlayer()).thenReturn(player);
when(view.getTopInventory()).thenReturn(inventory);
var prepare = new PrepareItemCraftEvent(inventory, view, false);
dispatch(plugin, listeners, prepare);
assertTrue(new EyeItems().isEye(result.get()), "the restored personal unlock must permit crafting");
when(player.getUniqueId()).thenReturn(learner);
when(server.getPlayer(learner)).thenReturn(player);
dispatch(plugin, listeners, prepare);
assertNull(result.get(), "registered handlers must deny an unearned player's result");
var effect = new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.NIGHT_VISION, 9600, 0);
dispatch(plugin, listeners, new org.bukkit.event.entity.EntityPotionEffectEvent(player, null, effect, player,
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.POTION_DRINK,
org.bukkit.event.entity.EntityPotionEffectEvent.Action.ADDED, false));
clock.set(java.time.Duration.ofHours(8).toNanos());
dispatch(plugin, listeners, new org.bukkit.event.entity.EntityPotionEffectEvent(player, effect, null, null,
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.EXPIRATION,
org.bukkit.event.entity.EntityPotionEffectEvent.Action.REMOVED, false));
Runnable unlocked = main.poll(3, TimeUnit.SECONDS);
assertNotNull(unlocked, "the registered potion listener must persist the unlock and schedule its notification");
assertTrue(disk.load().eyeProgress(learner).unlocked());
unlocked.run();
result.set(recipe.get().getResult());
dispatch(plugin, listeners, prepare);
assertTrue(new EyeItems().isEye(result.get()), "the registered crafting listener must see the newly saved unlock");
clearInvocations(player);
when(player.getUniqueId()).thenReturn(id);
timers.forEach(Runnable::run);
verify(player).discoverRecipe(EyeCrafting.RECIPE_KEY);
// Disable must checkpoint and drain an in-flight qualifying interval, not just remove the recipe.
when(player.getUniqueId()).thenReturn(learner);
when(player.isOnline()).thenReturn(true);
when(player.getPotionEffect(org.bukkit.potion.PotionEffectType.NIGHT_VISION)).thenReturn(effect);
dispatch(plugin, listeners, new org.bukkit.event.entity.EntityPotionEffectEvent(player, null, effect, player,
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.POTION_DRINK,
org.bukkit.event.entity.EntityPotionEffectEvent.Action.ADDED, false));
clock.addAndGet(1_234_000_000L);
} finally {
if (mode.equals("presentation-failure")) {
doThrow(new IllegalStateException("Injected presentation cleanup failure"))
.when(protocolManager).removePacketListeners(plugin);
try {
assertThrows(IllegalStateException.class, plugin::onDisable);
plugin.shutdownCompletion().get(3, TimeUnit.SECONDS);
assertEquals(28_801_234, disk.load().eyeProgress(learner).accumulatedMillis(),
"presentation cleanup failure must not prevent Eye progress from draining");
} finally {
doNothing().when(protocolManager).removePacketListeners(plugin);
plugin.onDisable();
plugin.shutdownCompletion().get(3, TimeUnit.SECONDS);
}
} else {
plugin.onDisable();
plugin.shutdownCompletion().get(3, TimeUnit.SECONDS);
}
}
}
verify(server).removeRecipe(EyeCrafting.RECIPE_KEY);
assertEquals(28_801_234, disk.load().eyeProgress(learner).accumulatedMillis());
}
private static void dispatch(SpigotStealthPlugin plugin, List<Listener> listeners, Event event) throws Exception {
var registered = new ArrayList<RegisteredListener>();
for (var listener : listeners) {
for (var method : listener.getClass().getMethods()) {
var handler = method.getAnnotation(EventHandler.class);
if (handler == null || method.getParameterCount() != 1 || !method.getParameterTypes()[0].isInstance(event)) { continue; }
registered.add(new RegisteredListener(listener,
EventExecutor.create(method, method.getParameterTypes()[0].asSubclass(Event.class)),
handler.priority(), plugin, handler.ignoreCancelled()));
}
}
registered.sort(java.util.Comparator.comparing(RegisteredListener::getPriority));
for (var handler : registered) { handler.callEvent(event); }
}
}
@@ -0,0 +1,302 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.nio.file.Path;
import java.time.Duration;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import org.bukkit.entity.Player;
import org.bukkit.event.entity.EntityPotionEffectEvent;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Real Purpur effect/event objects, real progression/persistence, only player and clock are external doubles. */
class EyePotionProgressionTest {
@TempDir Path directory;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test
void aTransitionBeforeTheNextObservationCannotCreditAnUnattributedRestoredEffect() throws Exception {
for (String transition : java.util.List.of("quit", "remove", "refresh")) {
UUID id = UUID.randomUUID();
var player = mock(Player.class);
when(player.getUniqueId()).thenReturn(id);
when(player.isOnline()).thenReturn(true);
var clock = new AtomicLong();
var repository = new YamlStealthStateRepository(directory.resolve(transition + ".yml"));
try (var states = new StealthStateManager(repository, repository.load())) {
var progression = new EyeProgressionService(states, clock::get, ignored -> { });
var listener = new EyePotionListener(progression, failure -> fail(failure));
var strong = new PotionEffect(PotionEffectType.NIGHT_VISION, 100, 1);
var restored = new PotionEffect(PotionEffectType.NIGHT_VISION, 400, 0);
listener.onPotionEffect(new EntityPotionEffectEvent(player, null, strong, player,
EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.ADDED, false));
when(player.getPotionEffect(PotionEffectType.NIGHT_VISION)).thenReturn(strong);
clock.set(Duration.ofSeconds(1).toNanos());
listener.checkpoint(ignored -> player);
clock.set(Duration.ofSeconds(3).toNanos());
when(player.getPotionEffect(PotionEffectType.NIGHT_VISION)).thenReturn(restored);
switch (transition) {
case "quit" -> listener.onQuit(new org.bukkit.event.player.PlayerQuitEvent(
player, net.kyori.adventure.text.Component.empty()));
case "remove" -> listener.onPotionEffect(new EntityPotionEffectEvent(player, restored, null, null,
EntityPotionEffectEvent.Cause.EXPIRATION, EntityPotionEffectEvent.Action.REMOVED, false));
case "refresh" -> listener.onPotionEffect(new EntityPotionEffectEvent(player, restored,
new PotionEffect(PotionEffectType.NIGHT_VISION, 800, 0), player,
EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.CHANGED, true));
default -> throw new AssertionError(transition);
}
states.save().get(3, TimeUnit.SECONDS);
assertEquals(1000, repository.load().eyeProgress(id).accumulatedMillis(), transition);
}
}
}
@Test
void periodicObservationStopsAtUnattributedHiddenEffectRestorationOrMissingPlayer() throws Exception {
UUID id = UUID.randomUUID();
var player = mock(Player.class);
when(player.getUniqueId()).thenReturn(id);
when(player.isOnline()).thenReturn(true);
var clock = new AtomicLong();
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
try (var states = new StealthStateManager(repository, repository.load())) {
var progression = new EyeProgressionService(states, clock::get, ignored -> { });
var listener = new EyePotionListener(progression, failure -> fail(failure));
var strong = new PotionEffect(PotionEffectType.NIGHT_VISION, 100, 1);
listener.onPotionEffect(new EntityPotionEffectEvent(player, null, strong, player,
EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.ADDED, false));
when(player.getPotionEffect(PotionEffectType.NIGHT_VISION)).thenReturn(
new PotionEffect(PotionEffectType.NIGHT_VISION, 80, 1));
clock.set(Duration.ofSeconds(1).toNanos());
listener.checkpoint(ignored -> player);
states.save().get(3, TimeUnit.SECONDS);
assertEquals(1000, repository.load().eyeProgress(id).accumulatedMillis());
// Native hidden-effect promotion need not emit a new potion-source event.
when(player.getPotionEffect(PotionEffectType.NIGHT_VISION)).thenReturn(
new PotionEffect(PotionEffectType.NIGHT_VISION, 400, 0));
clock.set(Duration.ofSeconds(2).toNanos());
listener.checkpoint(ignored -> player);
clock.set(Duration.ofHours(12).toNanos());
listener.checkpoint(ignored -> player);
states.save().get(3, TimeUnit.SECONDS);
assertEquals(1000, repository.load().eyeProgress(id).accumulatedMillis(),
"unattributed restored effects must not inherit the drink timer");
assertTrue(progression.activePlayerIds().isEmpty());
listener.onPotionEffect(new EntityPotionEffectEvent(player, null, strong, player,
EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.ADDED, false));
clock.addAndGet(Duration.ofDays(1).toNanos());
listener.checkpoint(ignored -> null);
states.save().get(3, TimeUnit.SECONDS);
assertEquals(1000, repository.load().eyeProgress(id).accumulatedMillis());
assertTrue(progression.activePlayerIds().isEmpty());
}
}
@Test
void disconnectClosesTheIntervalAndRestartDoesNotCountTheOfflineGap() throws Exception {
UUID id = UUID.randomUUID();
var player = mock(Player.class);
when(player.getUniqueId()).thenReturn(id);
var clock = new AtomicLong();
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
try (var states = new StealthStateManager(repository, repository.load())) {
var progression = new EyeProgressionService(states, clock::get, ignored -> { });
var listener = new EyePotionListener(progression, failure -> fail(failure));
var effect = new PotionEffect(PotionEffectType.NIGHT_VISION, 200, 0);
listener.onPotionEffect(new EntityPotionEffectEvent(player, null, effect, player,
EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.ADDED, false));
clock.set(Duration.ofSeconds(1).toNanos());
when(player.getPotionEffect(PotionEffectType.NIGHT_VISION)).thenReturn(effect);
listener.onQuit(new org.bukkit.event.player.PlayerQuitEvent(player, net.kyori.adventure.text.Component.empty()));
clock.addAndGet(Duration.ofDays(7).toNanos());
progression.checkpoint(id).get(3, TimeUnit.SECONDS);
states.save().get(3, TimeUnit.SECONDS);
assertEquals(1000, repository.load().eyeProgress(id).accumulatedMillis(),
"disconnect must close the qualifying interval before offline time passes");
assertFalse(progression.isUnlocked(id));
}
try (var restarted = new StealthStateManager(repository, repository.load())) {
var progression = new EyeProgressionService(restarted, clock::get, ignored -> { });
assertEquals(1000, restarted.snapshot().eyeProgress(id).accumulatedMillis());
assertTrue(progression.activePlayerIds().isEmpty());
assertFalse(progression.isUnlocked(id));
}
}
@Test
void everyNonDrinkSourceIsExcludedAndAnEffectiveExternalReplacementStopsCredit() throws Exception {
UUID id = UUID.randomUUID();
var player = mock(Player.class);
when(player.getUniqueId()).thenReturn(id);
var clock = new AtomicLong();
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
try (var states = new StealthStateManager(repository, repository.load())) {
var progression = new EyeProgressionService(states, clock::get, ignored -> { });
var listener = new EyePotionListener(progression, failure -> fail(failure));
var effect = new PotionEffect(PotionEffectType.NIGHT_VISION, 200, 0);
for (var cause : EntityPotionEffectEvent.Cause.values()) {
if (cause == EntityPotionEffectEvent.Cause.POTION_DRINK) { continue; }
listener.onPotionEffect(new EntityPotionEffectEvent(player, null, effect, null,
cause, EntityPotionEffectEvent.Action.ADDED, false));
clock.addAndGet(Duration.ofSeconds(1).toNanos());
progression.checkpoint(id).get(3, TimeUnit.SECONDS);
assertEquals(0, states.snapshot().eyeProgress(id).accumulatedMillis(), cause.toString());
listener.onPotionEffect(new EntityPotionEffectEvent(player, effect, null, null,
EntityPotionEffectEvent.Cause.EXPIRATION, EntityPotionEffectEvent.Action.REMOVED, false));
}
listener.onPotionEffect(new EntityPotionEffectEvent(player, null, effect, player,
EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.ADDED, false));
clock.addAndGet(Duration.ofSeconds(1).toNanos());
listener.onPotionEffect(new EntityPotionEffectEvent(player, effect,
new PotionEffect(PotionEffectType.NIGHT_VISION, 400, 0), null,
EntityPotionEffectEvent.Cause.PLUGIN, EntityPotionEffectEvent.Action.CHANGED, true));
clock.addAndGet(Duration.ofHours(12).toNanos());
progression.checkpoint(id).get(3, TimeUnit.SECONDS);
states.save().get(3, TimeUnit.SECONDS);
assertEquals(1000, repository.load().eyeProgress(id).accumulatedMillis());
assertFalse(progression.isUnlocked(id));
}
}
@Test
void cancelledAndUnrelatedEffectEventsCannotStartOrInterruptCredit() throws Exception {
UUID id = UUID.randomUUID();
var player = mock(Player.class);
when(player.getUniqueId()).thenReturn(id);
var clock = new AtomicLong();
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
try (var states = new StealthStateManager(repository, repository.load())) {
var progression = new EyeProgressionService(states, clock::get, ignored -> { });
var listener = new EyePotionListener(progression, failure -> fail(failure));
var effect = new PotionEffect(PotionEffectType.NIGHT_VISION, 200, 0);
var cancelled = new EntityPotionEffectEvent(player, null, effect, player,
EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.ADDED, false);
cancelled.setCancelled(true);
listener.onPotionEffect(cancelled);
clock.set(Duration.ofSeconds(1).toNanos());
progression.checkpoint(id).get(3, TimeUnit.SECONDS);
assertEquals(0, states.snapshot().eyeProgress(id).accumulatedMillis());
cancelled.setCancelled(false);
listener.onPotionEffect(cancelled);
clock.set(Duration.ofSeconds(2).toNanos());
var cancelledRemoval = new EntityPotionEffectEvent(player, effect, null, null,
EntityPotionEffectEvent.Cause.MILK, EntityPotionEffectEvent.Action.CLEARED, false);
cancelledRemoval.setCancelled(true);
listener.onPotionEffect(cancelledRemoval);
listener.onPotionEffect(new EntityPotionEffectEvent(player, null,
new PotionEffect(PotionEffectType.INVISIBILITY, 200, 0), null,
EntityPotionEffectEvent.Cause.PLUGIN, EntityPotionEffectEvent.Action.ADDED, false));
clock.set(Duration.ofSeconds(3).toNanos());
progression.stop(id).get(3, TimeUnit.SECONDS);
assertEquals(2000, repository.load().eyeProgress(id).accumulatedMillis());
}
}
@Test
void cosmeticOnlyOverridesDoNotReassignTheActiveEffectsSource() throws Exception {
// Purpur reports override=true even when only particle/icon flags change.
var nativeActive = new net.minecraft.world.effect.MobEffectInstance(
net.minecraft.world.effect.MobEffects.NIGHT_VISION, 200, 1);
assertTrue(nativeActive.update(new net.minecraft.world.effect.MobEffectInstance(
net.minecraft.world.effect.MobEffects.NIGHT_VISION, 100, 0, false, false, false)));
assertEquals(1, nativeActive.getAmplifier());
assertEquals(200, nativeActive.getDuration());
UUID id = UUID.randomUUID();
var player = mock(Player.class);
when(player.getUniqueId()).thenReturn(id);
var clock = new AtomicLong();
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
try (var states = new StealthStateManager(repository, repository.load())) {
var progression = new EyeProgressionService(states, clock::get, ignored -> { });
var listener = new EyePotionListener(progression, failure -> fail(failure));
var weak = new PotionEffect(PotionEffectType.NIGHT_VISION, 100, 0, false, false, false);
var strong = new PotionEffect(PotionEffectType.NIGHT_VISION, 200, 1);
listener.onPotionEffect(new EntityPotionEffectEvent(player, strong, weak, player,
EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.CHANGED, true));
clock.set(Duration.ofSeconds(1).toNanos());
progression.checkpoint(id).get(3, TimeUnit.SECONDS);
assertEquals(0, states.snapshot().eyeProgress(id).accumulatedMillis(),
"a cosmetic drink change must not claim the stronger existing effect");
listener.onPotionEffect(new EntityPotionEffectEvent(player, null, strong, player,
EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.ADDED, false));
clock.set(Duration.ofSeconds(2).toNanos());
listener.onPotionEffect(new EntityPotionEffectEvent(player, strong, weak, null,
EntityPotionEffectEvent.Cause.PLUGIN, EntityPotionEffectEvent.Action.CHANGED, true));
clock.set(Duration.ofSeconds(3).toNanos());
listener.onPotionEffect(new EntityPotionEffectEvent(player, strong, null, null,
EntityPotionEffectEvent.Cause.EXPIRATION, EntityPotionEffectEvent.Action.REMOVED, false));
states.save().get(3, TimeUnit.SECONDS);
assertEquals(2000, states.snapshot().eyeProgress(id).accumulatedMillis());
}
}
@Test
void rejectedReplacementsNeitherStartNorStopQualifyingTime() throws Exception {
UUID id = UUID.randomUUID();
var player = mock(Player.class);
when(player.getUniqueId()).thenReturn(id);
var clock = new AtomicLong();
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
try (var states = new StealthStateManager(repository, repository.load())) {
var progression = new EyeProgressionService(states, clock::get, ignored -> { });
var listener = new EyePotionListener(progression, failure -> fail(failure));
var weak = new PotionEffect(PotionEffectType.NIGHT_VISION, 100, 0);
var strong = new PotionEffect(PotionEffectType.NIGHT_VISION, 200, 1);
listener.onPotionEffect(new EntityPotionEffectEvent(player, strong, weak, player,
EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.CHANGED, false));
clock.set(Duration.ofSeconds(1).toNanos());
progression.checkpoint(id).get(3, TimeUnit.SECONDS);
assertEquals(0, states.snapshot().eyeProgress(id).accumulatedMillis(),
"a rejected drink must not claim an existing non-drink effect");
listener.onPotionEffect(new EntityPotionEffectEvent(player, null, strong, player,
EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.ADDED, false));
clock.set(Duration.ofSeconds(2).toNanos());
listener.onPotionEffect(new EntityPotionEffectEvent(player, strong, weak, null,
EntityPotionEffectEvent.Cause.PLUGIN, EntityPotionEffectEvent.Action.CHANGED, false));
clock.set(Duration.ofSeconds(3).toNanos());
listener.onPotionEffect(new EntityPotionEffectEvent(player, strong, null, null,
EntityPotionEffectEvent.Cause.EXPIRATION, EntityPotionEffectEvent.Action.REMOVED, false));
states.save().get(3, TimeUnit.SECONDS);
assertEquals(2000, states.snapshot().eyeProgress(id).accumulatedMillis(),
"a rejected plugin replacement must not interrupt the effective drunk potion");
}
}
@Test
void effectiveDirectDrinkAccumulatesAcrossRefreshAndDurablyUnlocksAtEightHours() throws Exception {
UUID id = UUID.randomUUID();
var player = mock(Player.class);
when(player.getUniqueId()).thenReturn(id);
var clock = new AtomicLong();
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
try (var states = new StealthStateManager(repository, repository.load())) {
var progression = new EyeProgressionService(states, clock::get, ignored -> { });
var listener = new EyePotionListener(progression, failure -> fail(failure));
var nightVision = new PotionEffect(PotionEffectType.NIGHT_VISION, 9600, 0);
listener.onPotionEffect(new EntityPotionEffectEvent(player, null, nightVision, player,
EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.ADDED, true));
for (int refresh = 1; refresh < 60; refresh++) {
clock.set(Duration.ofSeconds(refresh * 480L).toNanos());
listener.onPotionEffect(new EntityPotionEffectEvent(player, new PotionEffect(PotionEffectType.NIGHT_VISION, 1, 0), nightVision, player,
EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.CHANGED, true));
}
clock.set(Duration.ofHours(8).toNanos() - 1_000_000);
progression.checkpoint(id).get(3, TimeUnit.SECONDS);
assertFalse(progression.isUnlocked(id));
clock.addAndGet(1_000_000);
listener.onPotionEffect(new EntityPotionEffectEvent(player, nightVision, null, null,
EntityPotionEffectEvent.Cause.EXPIRATION, EntityPotionEffectEvent.Action.REMOVED, false));
states.save().get(3, TimeUnit.SECONDS);
assertTrue(progression.isUnlocked(id), "eight hours of effective drink events must unlock the Eye");
assertEquals(28_800_000, repository.load().eyeProgress(id).accumulatedMillis());
assertTrue(progression.activePlayerIds().isEmpty());
}
}
}
@@ -0,0 +1,335 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import org.bukkit.GameMode;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
import org.bukkit.potion.PotionEffectType;
import org.bukkit.scoreboard.Scoreboard;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class EyeRevealControllerTest {
@TempDir Path directory;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test
void onlyTheEligibleObserverGetsAnImmutableProjectionAndUnequippingRestoresTheBaseline() throws Exception {
var world = mock(World.class);
when(world.getUID()).thenReturn(UUID.randomUUID());
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
var viewer = player(world, "Viewer", 1, 0);
var unearned = player(world, "Unearned", 2, 0);
var target = player(world, "Concealed", 42, 8);
when(target.getTrackedBy()).thenReturn(Set.of(viewer, unearned));
when(viewer.canSee(target)).thenReturn(true);
when(viewer.hasLineOfSight(target)).thenReturn(true);
when(target.hasPotionEffect(PotionEffectType.INVISIBILITY)).thenReturn(true);
var items = new EyeItems();
var helmet = new AtomicReference<>(items.create());
when(viewer.getInventory().getHelmet()).thenAnswer(ignored -> helmet.get());
when(unearned.getInventory().getHelmet()).thenReturn(items.create());
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
var initial = new PersistentStealthState(Map.of(), Map.of())
.withEyeProgress(viewer.getUniqueId(), new EyeProgress(28_800_000, true));
var actions = new ArrayList<String>();
var controllerRef = new AtomicReference<EyeRevealController>();
var delivery = new EyeRevealController.Delivery() {
@Override public void show(Player observer, Player subject, EyeRevealController.Projection projection, boolean create) {
actions.add((create ? "create:" : "refresh:") + observer.getName() + ":" + subject.getName());
assertTrue(projection.revealBody());
assertEquals(projection, controllerRef.get().snapshot().get(observer.getUniqueId()).get(projection.entityId()));
}
@Override public void restore(Player observer, Optional<Player> subject, EyeRevealController.Projection projection) {
assertFalse(controllerRef.get().snapshot().getOrDefault(observer.getUniqueId(), Map.of()).containsKey(projection.entityId()),
"authorization must be withdrawn before sending restoration packets");
actions.add("restore:" + observer.getName() + ":" + projection.playerName());
}
};
try (var states = new StealthStateManager(repository, initial)) {
var equipment = new EyeEquipment(new EyeProgressionService(states, () -> 0L, ignored -> { }), items);
var ids = new AtomicInteger();
var controller = new EyeRevealController(() -> List.of(viewer, unearned, target), equipment,
id -> id.equals(target.getUniqueId()), delivery, () -> "eye" + ids.incrementAndGet());
controllerRef.set(controller);
controller.run();
var snapshot = controller.snapshot();
assertNotNull(snapshot.get(viewer.getUniqueId()), "an eligible observer needs a projection");
assertEquals(Set.of(42), snapshot.get(viewer.getUniqueId()).keySet());
assertFalse(snapshot.containsKey(unearned.getUniqueId()));
assertEquals(List.of("create:Viewer:Concealed"), actions);
assertThrows(UnsupportedOperationException.class, () -> snapshot.clear());
assertThrows(UnsupportedOperationException.class, () -> snapshot.get(viewer.getUniqueId()).clear());
controller.run();
assertEquals(1, actions.size(), "unchanged frames must not resend packets");
UUID observerId = viewer.getUniqueId();
java.util.concurrent.CompletableFuture.runAsync(() -> {
controller.requestRefresh(observerId);
controller.requestRefresh(observerId);
}).get(3, java.util.concurrent.TimeUnit.SECONDS);
assertEquals(1, actions.size(), "packet-thread invalidation must not perform delivery");
controller.run();
assertEquals(List.of("create:Viewer:Concealed", "refresh:Viewer:Concealed"), actions,
"source updates must coalesce into a main-thread refresh");
helmet.set(new ItemStack(Material.IRON_HELMET));
controller.run();
assertTrue(controller.snapshot().isEmpty());
assertEquals(List.of("create:Viewer:Concealed", "refresh:Viewer:Concealed", "restore:Viewer:Concealed"), actions);
assertEquals(1, snapshot.get(viewer.getUniqueId()).size(), "published snapshots cannot be mutated by later frames");
helmet.set(items.create());
controller.run();
controller.close();
assertTrue(controller.snapshot().isEmpty(), "disable must withdraw all projections");
assertEquals(5, actions.size());
assertEquals("restore:Viewer:Concealed", actions.getLast());
controller.close();
controller.run();
assertEquals(5, actions.size(), "closed controllers cannot resend or revive projections");
verify(target, never()).removePotionEffect(any());
verify(target, never()).setInvisible(anyBoolean());
verify(viewer, never()).hasLineOfSight(unearned);
verify(unearned, never()).hasLineOfSight(any(org.bukkit.entity.Entity.class));
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"range", "sight", "hidden", "spectator", "vanished", "untracked", "unloaded", "world", "death", "session", "capture-failure", "team-hidden"})
void platformChangesWithdrawTheProjectionWithoutLoadingChunksOrForcingVisibility(String change) throws Exception {
var world = mock(World.class);
when(world.getUID()).thenReturn(UUID.randomUUID());
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
var viewer = player(world, "Viewer", 1, 0);
var target = player(world, "Concealed", 42, 8);
when(target.getTrackedBy()).thenReturn(Set.of(viewer));
when(viewer.canSee(target)).thenReturn(true);
when(viewer.hasLineOfSight(target)).thenReturn(true);
var items = new EyeItems();
when(viewer.getInventory().getHelmet()).thenReturn(items.create());
var initial = new PersistentStealthState(Map.of(), Map.of())
.withEyeProgress(viewer.getUniqueId(), new EyeProgress(28_800_000, true));
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
var session = new java.util.concurrent.atomic.AtomicBoolean(true);
var restored = new AtomicInteger();
var delivery = new EyeRevealController.Delivery() {
@Override public void show(Player observer, Player subject, EyeRevealController.Projection projection, boolean create) {
assertFalse(projection.revealBody(), "identity concealment alone does not authorize changing body flags");
}
@Override public void restore(Player observer, Optional<Player> subject, EyeRevealController.Projection projection) {
assertSame(viewer, observer);
assertEquals(target.getUniqueId(), projection.targetId());
restored.incrementAndGet();
}
};
try (var states = new StealthStateManager(repository, initial)) {
var controller = new EyeRevealController(() -> List.of(viewer, target),
new EyeEquipment(new EyeProgressionService(states, () -> 0L, ignored -> { }), items),
id -> id.equals(target.getUniqueId()) && session.get(), delivery, () -> "eye1");
controller.run();
assertEquals(1, controller.snapshot().get(viewer.getUniqueId()).size());
switch (change) {
case "range" -> when(target.getLocation()).thenReturn(new Location(world, 16.001, 64, 0));
case "sight" -> when(viewer.hasLineOfSight(target)).thenReturn(false);
case "hidden" -> when(viewer.canSee(target)).thenReturn(false);
case "spectator" -> when(target.getGameMode()).thenReturn(GameMode.SPECTATOR);
case "vanished" -> {
var flag = mock(org.bukkit.metadata.MetadataValue.class);
when(flag.asBoolean()).thenReturn(true);
when(target.getMetadata("vanished")).thenReturn(List.of(flag));
}
case "untracked" -> when(target.getTrackedBy()).thenReturn(Set.of());
case "unloaded" -> when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(false);
case "world" -> {
var otherWorld = mock(World.class);
when(otherWorld.getUID()).thenReturn(UUID.randomUUID());
when(target.getLocation()).thenReturn(new Location(otherWorld, 8, 64, 0));
}
case "death" -> when(target.isDead()).thenReturn(true);
case "session" -> session.set(false);
case "capture-failure" -> when(target.getMetadata("vanished")).thenThrow(new IllegalStateException("Visibility state unavailable"));
case "team-hidden" -> {
var hidden = mock(org.bukkit.scoreboard.Team.class);
when(hidden.getOption(org.bukkit.scoreboard.Team.Option.NAME_TAG_VISIBILITY)).thenReturn(org.bukkit.scoreboard.Team.OptionStatus.NEVER);
when(viewer.getScoreboard().getEntryTeam(target.getName())).thenReturn(hidden);
}
default -> throw new AssertionError(change);
}
org.mockito.Mockito.<Object>clearInvocations(world, viewer, target);
controller.run();
assertTrue(controller.snapshot().isEmpty(), change);
assertEquals(1, restored.get());
if (!change.equals("sight")) { verify(viewer, never()).hasLineOfSight(target); }
verify(world, never()).getChunkAt(anyInt(), anyInt());
verify(world, never()).loadChunk(anyInt(), anyInt());
verify(viewer, never()).showPlayer(any(), any());
verify(target, never()).removePotionEffect(any());
verify(target, never()).setInvisible(anyBoolean());
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"pair", "viewer", "target", "hide-event", "untrack-event", "helmet-event", "spectator-event", "potion-event", "quit-event", "teleport-event", "respawn-event", "death-event", "world-event", "cancelled-teleport", "cancelled-mode", "cancelled-potion", "other-armor", "other-effect"})
void lifecycleInvalidationWithdrawsAuthorizationImmediatelyAndOnlyForAffectedViews(String change) throws Exception {
var world = mock(World.class);
when(world.getUID()).thenReturn(UUID.randomUUID());
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
var first = player(world, "First", 1, 0);
var second = player(world, "Second", 2, 0);
var target = player(world, "Concealed", 42, 8);
when(target.getTrackedBy()).thenReturn(Set.of(first, second));
var items = new EyeItems();
for (var viewer : List.of(first, second)) {
when(viewer.canSee(target)).thenReturn(true);
when(viewer.hasLineOfSight(target)).thenReturn(true);
when(viewer.getInventory().getHelmet()).thenReturn(items.create());
}
var initial = new PersistentStealthState(Map.of(), Map.of())
.withEyeProgress(first.getUniqueId(), new EyeProgress(28_800_000, true))
.withEyeProgress(second.getUniqueId(), new EyeProgress(28_800_000, true));
var restored = new ArrayList<UUID>();
var delivery = new EyeRevealController.Delivery() {
@Override public void show(Player observer, Player subject, EyeRevealController.Projection projection, boolean create) { }
@Override public void restore(Player observer, Optional<Player> subject, EyeRevealController.Projection projection) {
restored.add(observer.getUniqueId());
}
};
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
try (var states = new StealthStateManager(repository, initial)) {
var ids = new AtomicInteger();
var controller = new EyeRevealController(() -> List.of(first, second, target),
new EyeEquipment(new EyeProgressionService(states, () -> 0L, ignored -> { }), items),
id -> id.equals(target.getUniqueId()), delivery, () -> "eye" + ids.incrementAndGet());
controller.run();
assertEquals(2, controller.snapshot().size());
var events = new EyeRevealEvents(controller);
switch (change) {
case "cancelled-teleport" -> {
var event = new org.bukkit.event.player.PlayerTeleportEvent(first, first.getLocation(), target.getLocation());
event.setCancelled(true);
events.onTeleport(event);
}
case "cancelled-mode" -> {
var event = new org.bukkit.event.player.PlayerGameModeChangeEvent(target, GameMode.SPECTATOR);
event.setCancelled(true);
events.onGameMode(event);
}
case "cancelled-potion" -> {
var event = new org.bukkit.event.entity.EntityPotionEffectEvent(target,
new org.bukkit.potion.PotionEffect(PotionEffectType.INVISIBILITY, 100, 0), null, null,
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.MILK, org.bukkit.event.entity.EntityPotionEffectEvent.Action.REMOVED, false);
event.setCancelled(true);
events.onEffect(event);
}
case "other-armor" -> events.onArmor(new com.destroystokyo.paper.event.player.PlayerArmorChangeEvent(first,
com.destroystokyo.paper.event.player.PlayerArmorChangeEvent.SlotType.CHEST, new ItemStack(Material.AIR), new ItemStack(Material.IRON_CHESTPLATE)));
case "other-effect" -> events.onEffect(new org.bukkit.event.entity.EntityPotionEffectEvent(target, null,
new org.bukkit.potion.PotionEffect(PotionEffectType.NIGHT_VISION, 100, 0), null,
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.COMMAND, org.bukkit.event.entity.EntityPotionEffectEvent.Action.ADDED, false));
case "hide-event" -> events.onHide(new org.bukkit.event.player.PlayerHideEntityEvent(first, target));
case "untrack-event" -> events.onUntrack(new io.papermc.paper.event.player.PlayerUntrackEntityEvent(first, target));
case "helmet-event" -> events.onArmor(new com.destroystokyo.paper.event.player.PlayerArmorChangeEvent(first,
com.destroystokyo.paper.event.player.PlayerArmorChangeEvent.SlotType.HEAD, items.create(), new ItemStack(Material.AIR)));
case "spectator-event" -> events.onGameMode(new org.bukkit.event.player.PlayerGameModeChangeEvent(target, GameMode.SPECTATOR));
case "potion-event" -> events.onEffect(new org.bukkit.event.entity.EntityPotionEffectEvent(target,
new org.bukkit.potion.PotionEffect(PotionEffectType.INVISIBILITY, 100, 0), null, null,
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.MILK, org.bukkit.event.entity.EntityPotionEffectEvent.Action.REMOVED, false));
case "quit-event" -> events.onQuit(new org.bukkit.event.player.PlayerQuitEvent(first, net.kyori.adventure.text.Component.empty()));
case "teleport-event" -> events.onTeleport(new org.bukkit.event.player.PlayerTeleportEvent(first, first.getLocation(), target.getLocation()));
case "respawn-event" -> events.onRespawn(new org.bukkit.event.player.PlayerRespawnEvent(first, first.getLocation(), false));
case "death-event" -> events.onDeath(new org.bukkit.event.entity.PlayerDeathEvent(target, mock(org.bukkit.damage.DamageSource.class),
new ArrayList<>(), 0, net.kyori.adventure.text.Component.empty(), false));
case "world-event" -> events.onWorld(new org.bukkit.event.player.PlayerChangedWorldEvent(first, world));
case "pair" -> controller.hide(first.getUniqueId(), target.getUniqueId());
case "viewer" -> controller.withdrawViewer(first.getUniqueId());
case "target" -> controller.withdrawTarget(target.getUniqueId());
default -> throw new AssertionError(change);
}
if (change.startsWith("cancelled-") || change.startsWith("other-")) {
assertEquals(2, controller.snapshot().size());
assertTrue(restored.isEmpty(), "cancelled or unrelated events must not disturb valid projections");
return;
}
assertFalse(controller.snapshot().containsKey(first.getUniqueId()), "invalidation cannot wait for the next frame");
boolean allObservers = Set.of("target", "spectator-event", "potion-event", "death-event").contains(change);
assertEquals(allObservers ? 0 : 1, controller.snapshot().size());
assertEquals(allObservers ? Set.of(first.getUniqueId(), second.getUniqueId()) : Set.of(first.getUniqueId()), Set.copyOf(restored));
}
}
@Test
void failedDeliveryRevokesAuthorizationAndCleanupMustSucceedBeforeRetryingTheView() throws Exception {
var world = mock(World.class);
when(world.getUID()).thenReturn(UUID.randomUUID());
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
var viewer = player(world, "Viewer", 1, 0);
var target = player(world, "Concealed", 42, 8);
when(target.getTrackedBy()).thenReturn(Set.of(viewer));
when(viewer.canSee(target)).thenReturn(true);
when(viewer.hasLineOfSight(target)).thenReturn(true);
var items = new EyeItems();
when(viewer.getInventory().getHelmet()).thenReturn(items.create());
var initial = new PersistentStealthState(Map.of(), Map.of())
.withEyeProgress(viewer.getUniqueId(), new EyeProgress(28_800_000, true));
var shows = new AtomicInteger();
var restores = new AtomicInteger();
var failures = new ArrayList<Throwable>();
var delivery = new EyeRevealController.Delivery() {
@Override public void show(Player observer, Player subject, EyeRevealController.Projection projection, boolean create) {
assertTrue(create);
if (shows.incrementAndGet() == 1) { throw new IllegalStateException("Injected partial delivery failure"); }
}
@Override public void restore(Player observer, Optional<Player> subject, EyeRevealController.Projection projection) {
if (restores.incrementAndGet() == 1) { throw new IllegalStateException("Injected restoration failure"); }
}
};
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
try (var states = new StealthStateManager(repository, initial)) {
var ids = new AtomicInteger();
var controller = new EyeRevealController(() -> List.of(viewer, target),
new EyeEquipment(new EyeProgressionService(states, () -> 0L, ignored -> { }), items),
id -> id.equals(target.getUniqueId()), delivery, () -> "eye" + ids.incrementAndGet(), failures::add);
assertDoesNotThrow(controller::run);
assertTrue(controller.snapshot().isEmpty(), "failed delivery cannot retain outgoing reveal authorization");
assertDoesNotThrow(controller::run);
assertEquals(1, shows.get(), "failed cleanup must block recreation of the view");
assertTrue(controller.snapshot().isEmpty());
controller.run();
assertEquals(2, shows.get());
assertEquals(2, restores.get());
assertEquals(1, controller.snapshot().get(viewer.getUniqueId()).size());
assertFalse(failures.isEmpty());
controller.withdrawViewer(viewer.getUniqueId());
assertEquals(3, restores.get());
assertTrue(controller.snapshot().isEmpty());
controller.close();
}
}
private static Player player(World world, String name, int entityId, double x) {
var player = mock(Player.class);
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
when(player.getName()).thenReturn(name);
when(player.getEntityId()).thenReturn(entityId);
when(player.getLocation()).thenReturn(new Location(world, x, 64, 0));
when(player.isOnline()).thenReturn(true);
when(player.getGameMode()).thenReturn(GameMode.SURVIVAL);
when(player.getInventory()).thenReturn(mock(PlayerInventory.class));
when(player.getScoreboard()).thenReturn(mock(Scoreboard.class));
return player;
}
}
@@ -0,0 +1,169 @@
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.ProtocolManager;
import com.comphenix.protocol.events.PacketContainer;
import com.comphenix.protocol.events.PacketEvent;
import com.comphenix.protocol.events.PacketListener;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.Component;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket;
import net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket;
import net.minecraft.network.syncher.EntityDataAccessor;
import net.minecraft.network.syncher.SynchedEntityData;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.network.ServerGamePacketListenerImpl;
import net.minecraft.world.scores.Scoreboard;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.craftbukkit.CraftServer;
import org.bukkit.craftbukkit.entity.CraftPlayer;
import org.bukkit.craftbukkit.inventory.CraftInventoryPlayer;
import org.bukkit.craftbukkit.scheduler.CraftScheduler;
import org.bukkit.craftbukkit.scoreboard.CraftScoreboard;
import org.bukkit.event.Listener;
import org.bukkit.plugin.PluginManager;
import org.bukkit.plugin.java.JavaPlugin;
import org.bukkit.potion.PotionEffectType;
import org.bukkit.scheduler.BukkitTask;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class EyeRevealRuntimeTest {
@TempDir Path directory;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test
@SuppressWarnings({"try", "unchecked"})
void registeredRuntimeProjectsAndRestoresRealPacketsWithoutChangingTheServerOrIdentityChannels() throws Exception {
var server = mock(CraftServer.class);
try (var platform = mockStatic(org.bukkit.Bukkit.class, call -> switch (call.getMethod().getName()) {
case "getServer" -> server;
case "isPrimaryThread" -> true;
case "getVersion" -> "Purpur 2618 (MC: 26.2)";
case "getMinecraftVersion" -> "26.2";
case "getBukkitVersion" -> "26.2-R0.1-SNAPSHOT";
default -> call.callRealMethod();
})) {
var world = mock(World.class);
when(world.getUID()).thenReturn(UUID.randomUUID());
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
var viewer = mock(CraftPlayer.class);
var target = mock(CraftPlayer.class);
UUID viewerId = UUID.randomUUID(), targetId = UUID.randomUUID();
when(viewer.getUniqueId()).thenReturn(viewerId);
when(target.getUniqueId()).thenReturn(targetId);
when(viewer.getName()).thenReturn("Viewer");
when(target.getName()).thenReturn("Concealed");
when(viewer.isOnline()).thenReturn(true);
when(target.isOnline()).thenReturn(true);
when(viewer.getEntityId()).thenReturn(1);
when(target.getEntityId()).thenReturn(42);
when(viewer.getLocation()).thenReturn(new Location(world, 0, 64, 0));
when(target.getLocation()).thenReturn(new Location(world, 8, 64, 0));
when(viewer.canSee(target)).thenReturn(true);
when(viewer.hasLineOfSight(target)).thenReturn(true);
when(target.getTrackedBy()).thenReturn(Set.of(viewer));
when(target.hasPotionEffect(PotionEffectType.INVISIBILITY)).thenReturn(true);
var inventory = mock(CraftInventoryPlayer.class);
when(viewer.getInventory()).thenReturn(inventory);
var items = new EyeItems();
when(inventory.getHelmet()).thenReturn(items.create());
var board = new Scoreboard();
var baseline = board.addPlayerTeam("source");
baseline.setPlayerPrefix(Component.empty().withStyle(ChatFormatting.OBFUSCATED));
board.addPlayerToTeam("Concealed", baseline);
var constructor = CraftScoreboard.class.getDeclaredConstructor(Scoreboard.class);
constructor.setAccessible(true);
when(viewer.getScoreboard()).thenReturn(constructor.newInstance(board));
var viewerHandle = mock(ServerPlayer.class);
viewerHandle.connection = mock(ServerGamePacketListenerImpl.class);
when(viewer.getHandle()).thenReturn(viewerHandle);
var targetHandle = mock(ServerPlayer.class);
when(target.getHandle()).thenReturn(targetHandle);
var data = mock(SynchedEntityData.class);
when(targetHandle.getEntityData()).thenReturn(data);
var field = net.minecraft.world.entity.Entity.class.getDeclaredField("DATA_SHARED_FLAGS_ID");
field.setAccessible(true);
var flags = (EntityDataAccessor<Byte>) field.get(null);
when(data.get(flags)).thenReturn((byte) 0x60);
var protocol = mock(ProtocolManager.class);
var packetListeners = new ArrayList<PacketListener>();
doAnswer(call -> { packetListeners.add(call.getArgument(0)); return null; }).when(protocol).addPacketListener(any());
doAnswer(call -> { packetListeners.remove(call.getArgument(0)); return null; }).when(protocol).removePacketListener(any());
var sent = new ArrayList<Packet<?>>();
doAnswer(call -> {
Packet<?> raw = call.getArgument(0);
PacketType type = raw instanceof ClientboundSetPlayerTeamPacket ? PacketType.Play.Server.SCOREBOARD_TEAM : PacketType.Play.Server.ENTITY_METADATA;
var event = PacketEvent.fromServer(this, new PacketContainer(type, raw), viewer);
for (var listener : List.copyOf(packetListeners)) {
if (listener.getSendingWhitelist().getTypes().contains(type)) { listener.onPacketSending(event); }
}
if (!event.isCancelled()) { sent.add((Packet<?>) event.getPacket().getHandle()); }
return null;
}).when(viewerHandle.connection).send(org.mockito.ArgumentMatchers.<Packet<?>>any());
var plugin = mock(JavaPlugin.class);
when(plugin.getServer()).thenReturn(server);
when(plugin.isEnabled()).thenReturn(true);
when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger());
var manager = mock(PluginManager.class);
when(server.getPluginManager()).thenReturn(manager);
var listeners = new ArrayList<Listener>();
doAnswer(call -> { listeners.add(call.getArgument(0)); return null; }).when(manager).registerEvents(any(), eq(plugin));
doReturn(List.of(viewer, target)).when(server).getOnlinePlayers();
var scheduler = mock(CraftScheduler.class);
when(server.getScheduler()).thenReturn(scheduler);
var ticks = new ArrayList<Runnable>();
var task = mock(BukkitTask.class);
when(scheduler.runTaskTimer(eq(plugin), any(Runnable.class), eq(1L), eq(1L))).thenAnswer(call -> {
ticks.add(call.getArgument(1)); return task;
});
var initial = new PersistentStealthState(Map.of(), Map.of()).withEyeProgress(viewerId, new EyeProgress(28_800_000, true));
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
try (var states = new StealthStateManager(repository, initial)) {
var equipment = new EyeEquipment(new EyeProgressionService(states, () -> 0L, ignored -> { }), items);
var runtime = new EyeRevealRuntime(plugin, equipment, targetId::equals, protocol);
runtime.start();
assertEquals(1, ticks.size(), "the runtime must register a one-tick visibility loop");
ticks.getFirst().run();
var create = assertInstanceOf(ClientboundSetPlayerTeamPacket.class, sent.getFirst());
assertFalse(create.getParameters().orElseThrow().playerPrefix().getStyle().isObfuscated());
assertEquals((byte) 0x40, assertInstanceOf(ClientboundSetEntityDataPacket.class, sent.get(1)).packedItems().getFirst().value());
assertSame(baseline, board.getPlayersTeam("Concealed"));
assertNull(board.getPlayerTeam(create.getName()));
var events = assertInstanceOf(EyeRevealEvents.class, listeners.getFirst());
when(viewer.canSee(target)).thenReturn(false);
events.onHide(new org.bukkit.event.player.PlayerHideEntityEvent(viewer, target));
assertTrue(assertInstanceOf(ClientboundSetPlayerTeamPacket.class, sent.get(2)).getParameters().orElseThrow().playerPrefix().getStyle().isObfuscated());
assertEquals((byte) 0x60, assertInstanceOf(ClientboundSetEntityDataPacket.class, sent.get(3)).packedItems().getFirst().value());
ticks.getFirst().run();
assertEquals(7, sent.size(), "platform-hidden targets must remain hidden");
when(viewer.canSee(target)).thenReturn(true);
ticks.getFirst().run();
assertFalse(assertInstanceOf(ClientboundSetPlayerTeamPacket.class, sent.get(7)).getParameters().orElseThrow().playerPrefix().getStyle().isObfuscated());
runtime.close();
int count = sent.size();
ticks.getFirst().run();
assertEquals(count, sent.size());
assertTrue(packetListeners.isEmpty());
verify(task).cancel();
assertSame(baseline, board.getPlayersTeam("Concealed"));
assertEquals((byte) 0x60, data.get(flags));
verify(target, never()).removePotionEffect(any());
verify(target, never()).setInvisible(anyBoolean());
assertEquals(initial, states.snapshot());
}
}
}
}
@@ -0,0 +1,82 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import io.netty.buffer.Unpooled;
import java.util.Optional;
import net.minecraft.ChatFormatting;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.chat.Component;
import net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.Parameters;
import net.minecraft.world.scores.Team;
import net.minecraft.world.scores.TeamColor;
import org.bukkit.craftbukkit.CraftRegistry;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
class EyeTeamProjectionTest {
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test
void nativeTeamProjectionRemovesOnlyTheScramblingPrefixAndRoundTripsEveryOtherField() {
var config = new io.papermc.paper.configuration.GlobalConfiguration();
config.collisions = config.new Collisions();
config.collisions.enablePlayerCollisions = true;
try (var platform = org.mockito.Mockito.mockStatic(io.papermc.paper.configuration.GlobalConfiguration.class)) {
platform.when(io.papermc.paper.configuration.GlobalConfiguration::get).thenReturn(config);
assertProjection();
}
}
@Test
void privateTeamsRevealOnlyTheirTargetWhileSharedServerMembershipUpdatesStayMasked() {
var board = new net.minecraft.world.scores.Scoreboard();
var shared = board.addPlayerTeam("shared");
shared.setPlayerPrefix(Component.empty().withStyle(ChatFormatting.OBFUSCATED));
board.addPlayerToTeam("Alice", shared);
board.addPlayerToTeam("Bob", shared);
var packet = net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.createAddOrModifyPacket(shared, true);
var filtered = assertInstanceOf(net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.class,
EyePacketProjection.teamPacket(packet, java.util.Set.of("eye-private"), java.util.Set.of("Alice")));
assertEquals(java.util.Set.of("Bob"), java.util.Set.copyOf(filtered.getPlayers()),
"server membership updates must not pull an active Eye target out of its private team");
assertSame(packet.getParameters().orElseThrow(), filtered.getParameters().orElseThrow());
assertTrue(filtered.getParameters().orElseThrow().playerPrefix().getStyle().isObfuscated());
assertEquals(java.util.Set.of("Alice", "Bob"), java.util.Set.copyOf(packet.getPlayers()));
var isolated = board.addPlayerTeam("eye-private");
isolated.setPlayerPrefix(Component.empty().withStyle(ChatFormatting.OBFUSCATED));
board.addPlayerToTeam("Alice", isolated);
var privatePacket = net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.createAddOrModifyPacket(isolated, true);
var revealed = assertInstanceOf(net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.class,
EyePacketProjection.teamPacket(privatePacket, java.util.Set.of("eye-private"), java.util.Set.of("Alice")));
assertEquals(java.util.Set.of("Alice"), java.util.Set.copyOf(revealed.getPlayers()));
assertFalse(revealed.getParameters().orElseThrow().playerPrefix().getStyle().isObfuscated());
assertTrue(privatePacket.getParameters().orElseThrow().playerPrefix().getStyle().isObfuscated());
assertSame(privatePacket, EyePacketProjection.teamPacket(privatePacket, java.util.Set.of(), java.util.Set.of()),
"without current authorization even queued private packets must retain their original mask");
for (var action : net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.Action.values()) {
var membership = net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.createPlayerPacket(shared, "Alice", action);
var projected = assertInstanceOf(net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket.class,
EyePacketProjection.teamPacket(membership, java.util.Set.of("eye-private"), java.util.Set.of("Alice")));
assertTrue(projected.getPlayers().isEmpty());
assertEquals(membership.getPlayerAction(), projected.getPlayerAction());
}
}
private static void assertProjection() {
var original = new Parameters(Component.literal("Keep team display"),
Component.empty().withStyle(ChatFormatting.OBFUSCATED), Component.literal("Keep suffix"),
Team.Visibility.ALWAYS, Team.CollisionRule.PUSH_OWN_TEAM, Optional.of(TeamColor.AQUA), (byte) 0x53);
var projected = assertInstanceOf(Parameters.class, EyePacketProjection.teamParameters(original));
var expected = new Parameters(original.displayName(), Component.empty(), original.playerSuffix(),
original.nameTagVisibility(), original.collisionRule(), original.color(), original.options());
assertEquals(expected, projected);
assertNotSame(original, projected);
assertTrue(original.playerPrefix().getStyle().isObfuscated(), "the shared server/team packet must remain unchanged");
var buffer = new RegistryFriendlyByteBuf(Unpooled.buffer(), CraftRegistry.getMinecraftRegistry());
try {
Parameters.STREAM_CODEC.encode(buffer, projected);
assertEquals(projected, Parameters.STREAM_CODEC.decode(buffer));
} finally { buffer.release(); }
}
}
@@ -0,0 +1,74 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.util.ArrayList;
import java.util.UUID;
import net.minecraft.network.chat.*;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.ClientboundPlayerChatPacket;
import net.minecraft.network.protocol.game.ClientboundSystemChatPacket;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.network.ServerGamePacketListenerImpl;
import org.bukkit.craftbukkit.CraftRegistry;
import org.bukkit.craftbukkit.entity.CraftPlayer;
/** Real native dispatch/codec/signature/ack state, with only the socket and already-authenticated player boundaries doubled. */
final class NativeChatWire {
final ArrayList<Packet<?>> packets = new ArrayList<>();
final LastSeenMessagesValidator serverSeen = new LastSeenMessagesValidator(20);
private final LastSeenMessagesTracker clientSeen = new LastSeenMessagesTracker(20);
private final MessageSignatureCache clientCache = MessageSignatureCache.createDefault();
private int nextChatIndex;
NativeChatWire(CraftPlayer recipient) throws Exception {
var player = recipient.getHandle();
set(ServerPlayer.class, player, "chatVisibility", net.minecraft.world.entity.player.ChatVisiblity.FULL);
var connection = mock(ServerGamePacketListenerImpl.class);
player.connection = connection;
when(connection.getCraftPlayer()).thenReturn(recipient);
when(recipient.canSeePlayer(any(UUID.class))).thenReturn(true);
set(ServerGamePacketListenerImpl.class, connection, "lastSeenMessages", serverSeen);
set(ServerGamePacketListenerImpl.class, connection, "messageSignatureCache", MessageSignatureCache.createDefault());
doCallRealMethod().when(connection).sendPlayerChatMessage(any(PlayerChatMessage.class), any(ChatType.Bound.class));
doCallRealMethod().when(player).sendChatMessage(any(OutgoingChatMessage.class), anyBoolean(), any(ChatType.Bound.class), nullable(Component.class));
doCallRealMethod().when(recipient).sendMessage(any(net.kyori.adventure.text.Component.class));
doAnswer(call -> { packets.add(call.getArgument(0)); return null; }).when(connection).send(org.mockito.ArgumentMatchers.<Packet<?>>any());
}
void receive(boolean system, UUID session, SignedMessageValidator validator, String expected) throws Exception {
assertEquals(1, packets.size(), "exactly one packet for the selected recipient");
var buffer = new net.minecraft.network.RegistryFriendlyByteBuf(io.netty.buffer.Unpooled.buffer(), CraftRegistry.getMinecraftRegistry());
try {
if (system) {
var packet = assertInstanceOf(ClientboundSystemChatPacket.class, packets.getFirst());
ClientboundSystemChatPacket.STREAM_CODEC.encode(buffer, packet);
var decoded = ClientboundSystemChatPacket.STREAM_CODEC.decode(buffer);
assertFalse(decoded.overlay());
assertEquals("[Stealth] Anonymous: " + expected, decoded.content().getString());
} else {
var packet = assertInstanceOf(ClientboundPlayerChatPacket.class, packets.getFirst());
ClientboundPlayerChatPacket.STREAM_CODEC.encode(buffer, packet);
var decoded = ClientboundPlayerChatPacket.STREAM_CODEC.decode(buffer);
assertEquals(nextChatIndex++, decoded.globalIndex(), "system messages must not create signed packet-index gaps");
var body = decoded.body().unpack(clientCache).orElseThrow();
var message = new PlayerChatMessage(new SignedMessageLink(decoded.index(), decoded.sender(), session), decoded.signature(), body,
decoded.unsignedContent(), decoded.filterMask());
assertEquals(expected, body.content());
assertNotNull(validator.updateAndValidate(message), "the retained native client signature chain must accept normal chat again");
assertTrue(clientSeen.addPending(decoded.signature(), true));
clientCache.push(body, decoded.signature());
}
} finally { buffer.release(); }
var acknowledgement = clientSeen.generateAndApplyUpdate();
assertEquals(acknowledgement.lastSeen(), serverSeen.applyUpdate(acknowledgement.update()));
assertEquals(20, serverSeen.trackedMessagesCount(), "no invisible signed packets may accumulate in server pending state");
packets.clear();
}
private static void set(Class<?> type, Object receiver, String name, Object value) throws Exception {
var field = type.getDeclaredField(name);
field.setAccessible(true);
field.set(receiver, value);
}
}
@@ -0,0 +1,83 @@
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.ProtocolManager;
import com.comphenix.protocol.events.PacketContainer;
import java.util.ArrayList;
import java.util.UUID;
import net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket;
import org.bukkit.Bukkit;
import org.bukkit.craftbukkit.CraftServer;
import org.bukkit.craftbukkit.entity.CraftPlayer;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
class NativeCombatTabRestoreTest {
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test
@SuppressWarnings("try")
void sharedRestorationProducesARealListedPlayerEntryWithCurrentIdentity() {
var server = mock(CraftServer.class);
try (var platform = mockStatic(Bukkit.class, call -> switch (call.getMethod().getName()) {
case "getServer" -> server;
case "isPrimaryThread" -> true;
case "getVersion" -> "Purpur 2618 (MC: 26.2)";
case "getMinecraftVersion" -> "26.2";
case "getBukkitVersion" -> "26.2-R0.1-SNAPSHOT";
default -> call.callRealMethod();
})) {
var target = mock(CraftPlayer.class);
var observer = mock(CraftPlayer.class);
UUID id = UUID.randomUUID();
when(target.getUniqueId()).thenReturn(id);
when(target.getName()).thenReturn("Attacker");
when(target.getPlayerListName()).thenReturn("Original Attacker");
when(target.getGameMode()).thenReturn(org.bukkit.GameMode.SURVIVAL);
when(target.getPing()).thenReturn(37);
var handle = mock(net.minecraft.server.level.ServerPlayer.class);
handle.gameProfile = new com.mojang.authlib.GameProfile(id, "Attacker");
when(target.getHandle()).thenReturn(handle);
when(target.getProfile()).thenReturn(handle.gameProfile);
var protocol = mock(ProtocolManager.class);
when(protocol.createPacket(any(PacketType.class))).thenAnswer(call -> new PacketContainer(call.getArgument(0)));
var sent = new ArrayList<PacketContainer>();
doAnswer(call -> { sent.add(call.getArgument(1)); return null; }).when(protocol).sendServerPacket(eq(observer), any(PacketContainer.class));
var controller = new ProtocolLibTabListController(protocol);
controller.remove(observer, id);
var hidden = assertInstanceOf(ClientboundPlayerInfoUpdatePacket.class, sent.getFirst().getHandle(),
"tab hiding must not remove the profile/chat session needed for later signed messages");
assertEquals(java.util.EnumSet.of(ClientboundPlayerInfoUpdatePacket.Action.UPDATE_LISTED), hidden.actions());
assertEquals(id, hidden.entries().getFirst().profileId());
assertFalse(hidden.entries().getFirst().listed());
sent.clear();
controller.add(observer, target);
var packet = assertInstanceOf(ClientboundPlayerInfoUpdatePacket.class, sent.getFirst().getHandle());
assertFalse(packet.actions().contains(ClientboundPlayerInfoUpdatePacket.Action.ADD_PLAYER), "restoration must retain the existing profile/chat session");
assertTrue(packet.actions().contains(ClientboundPlayerInfoUpdatePacket.Action.UPDATE_LISTED));
var entry = packet.entries().getFirst();
assertEquals(id, entry.profileId());
assertEquals("Attacker", entry.profile().name());
assertTrue(entry.listed());
assertEquals(37, entry.latency());
assertEquals(net.minecraft.world.level.GameType.SURVIVAL, entry.gameMode());
assertEquals("Original Attacker", entry.displayName().getString());
var buffer = new net.minecraft.network.RegistryFriendlyByteBuf(io.netty.buffer.Unpooled.buffer(), org.bukkit.craftbukkit.CraftRegistry.getMinecraftRegistry());
try {
ClientboundPlayerInfoUpdatePacket.STREAM_CODEC.encode(buffer, packet);
var decoded = ClientboundPlayerInfoUpdatePacket.STREAM_CODEC.decode(buffer);
assertEquals(packet.actions(), decoded.actions());
var restored = decoded.entries().getFirst();
assertEquals(entry.profileId(), restored.profileId());
assertEquals(entry.listed(), restored.listed());
assertEquals(entry.latency(), restored.latency());
assertEquals(entry.gameMode(), restored.gameMode());
assertEquals(entry.displayName(), restored.displayName());
assertNull(restored.profile(), "no profile replacement is encoded");
assertNull(restored.chatSession(), "no chat-session replacement is encoded");
} finally { buffer.release(); }
}
}
}
@@ -0,0 +1,125 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.util.ArrayList;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.Component;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket;
import net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket;
import net.minecraft.network.syncher.EntityDataAccessor;
import net.minecraft.network.syncher.SynchedEntityData;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.network.ServerGamePacketListenerImpl;
import net.minecraft.world.scores.Scoreboard;
import net.minecraft.world.scores.TeamColor;
import org.bukkit.craftbukkit.entity.CraftPlayer;
import org.bukkit.craftbukkit.scoreboard.CraftScoreboard;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
class NativeEyeDeliveryTest {
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.CsvSource({"ALWAYS,false,true", "NEVER,false,false", "HIDE_FOR_OWN_TEAM,true,false",
"HIDE_FOR_OWN_TEAM,false,true", "HIDE_FOR_OTHER_TEAMS,true,true", "HIDE_FOR_OTHER_TEAMS,false,false"})
@SuppressWarnings("unchecked") // The declared runtime accessor's BYTE serializer is verified in the metadata codec tests.
void deliveryUsesOnlyTheViewerConnectionAndRestoresCurrentStateWithoutChangingServerTeamsOrFlags(
net.minecraft.world.scores.Team.Visibility visibility, boolean teammate, boolean visible) throws Exception {
var board = new Scoreboard();
var baseline = board.addPlayerTeam("source");
baseline.setPlayerPrefix(Component.empty().withStyle(ChatFormatting.OBFUSCATED));
baseline.setColor(Optional.of(TeamColor.AQUA));
baseline.setNameTagVisibility(visibility);
if (teammate) { board.addPlayerToTeam("Viewer", baseline); }
board.addPlayerToTeam("Concealed", baseline);
board.addPlayerToTeam("OtherConcealed", baseline);
var boardConstructor = CraftScoreboard.class.getDeclaredConstructor(Scoreboard.class);
boardConstructor.setAccessible(true);
var bukkitBoard = boardConstructor.newInstance(board);
var viewer = mock(CraftPlayer.class);
var viewerHandle = mock(ServerPlayer.class);
var connection = mock(ServerGamePacketListenerImpl.class);
viewerHandle.connection = connection;
when(viewer.getHandle()).thenReturn(viewerHandle);
when(viewer.getName()).thenReturn("Viewer");
when(viewer.getScoreboard()).thenReturn(bukkitBoard);
var sent = new ArrayList<Packet<?>>();
doAnswer(call -> { sent.add(call.getArgument(0)); return null; })
.when(connection).send(org.mockito.ArgumentMatchers.<Packet<?>>any());
var target = mock(CraftPlayer.class);
var targetHandle = mock(ServerPlayer.class);
when(target.getHandle()).thenReturn(targetHandle);
when(target.getEntityId()).thenReturn(42);
when(target.isOnline()).thenReturn(true);
var data = mock(SynchedEntityData.class);
when(targetHandle.getEntityData()).thenReturn(data);
var accessorField = net.minecraft.world.entity.Entity.class.getDeclaredField("DATA_SHARED_FLAGS_ID");
accessorField.setAccessible(true);
var flagsAccessor = (EntityDataAccessor<Byte>) accessorField.get(null);
var flags = new AtomicReference<>((byte) 0xa0);
when(data.get(flagsAccessor)).thenAnswer(ignored -> flags.get());
var projection = new EyeRevealController.Projection(UUID.randomUUID(), 42, "Concealed", "eye-private",
Optional.of("source"), true);
var delivery = new NativeEyeDelivery();
delivery.show(viewer, target, projection, true);
assertEquals(2, sent.size(), "a private team and raw metadata refresh are required");
var team = assertInstanceOf(ClientboundSetPlayerTeamPacket.class, sent.get(0));
assertEquals("eye-private", team.getName());
assertEquals(ClientboundSetPlayerTeamPacket.Action.ADD, team.getTeamAction());
assertEquals(java.util.List.of("Concealed"), java.util.List.copyOf(team.getPlayers()));
assertEquals(visible ? net.minecraft.world.scores.Team.Visibility.ALWAYS : net.minecraft.world.scores.Team.Visibility.NEVER,
team.getParameters().orElseThrow().nameTagVisibility(), "private teams must preserve effective visibility, not change team relationships");
assertTrue(team.getParameters().orElseThrow().playerPrefix().getStyle().isObfuscated(),
"raw state must reach the outgoing authorization gate before any prefix is revealed");
var metadata = assertInstanceOf(ClientboundSetEntityDataPacket.class, sent.get(1));
assertEquals(42, metadata.id());
assertEquals((byte) 0xa0, metadata.packedItems().getFirst().value());
assertNull(board.getPlayerTeam("eye-private"));
assertEquals(teammate ? Set.of("Viewer", "Concealed", "OtherConcealed") : Set.of("Concealed", "OtherConcealed"), Set.copyOf(baseline.getPlayers()));
assertEquals((byte) 0xa0, flags.get());
// Another plugin can move the actor to a different real team while the private view is active.
var replacement = board.addPlayerTeam("replacement");
replacement.setPlayerPrefix(Component.empty().withStyle(ChatFormatting.OBFUSCATED));
replacement.setColor(Optional.of(TeamColor.GOLD));
replacement.setNameTagVisibility(visibility);
board.addPlayerToTeam("Concealed", replacement);
flags.set((byte) 9);
sent.clear();
delivery.restore(viewer, Optional.of(target), projection);
assertEquals(5, sent.size(), "restore the mask and flags before moving or removing client team membership");
var privateMask = assertInstanceOf(ClientboundSetPlayerTeamPacket.class, sent.get(0));
assertEquals("eye-private", privateMask.getName());
assertTrue(privateMask.getParameters().orElseThrow().playerPrefix().getStyle().isObfuscated());
boolean nowVisible = visibility == net.minecraft.world.scores.Team.Visibility.ALWAYS
|| visibility == net.minecraft.world.scores.Team.Visibility.HIDE_FOR_OWN_TEAM;
assertEquals(nowVisible ? net.minecraft.world.scores.Team.Visibility.ALWAYS : net.minecraft.world.scores.Team.Visibility.NEVER,
privateMask.getParameters().orElseThrow().nameTagVisibility());
var restoredFlags = assertInstanceOf(ClientboundSetEntityDataPacket.class, sent.get(1));
assertEquals((byte) 9, restoredFlags.packedItems().getFirst().value());
var restoredTeam = assertInstanceOf(ClientboundSetPlayerTeamPacket.class, sent.get(2));
assertEquals("replacement", restoredTeam.getName());
assertEquals(Optional.of(TeamColor.GOLD), restoredTeam.getParameters().orElseThrow().color());
assertEquals(visibility, restoredTeam.getParameters().orElseThrow().nameTagVisibility());
var membership = assertInstanceOf(ClientboundSetPlayerTeamPacket.class, sent.get(3));
assertEquals("replacement", membership.getName());
assertEquals(ClientboundSetPlayerTeamPacket.Action.ADD, membership.getPlayerAction());
assertEquals(java.util.List.of("Concealed"), java.util.List.copyOf(membership.getPlayers()));
var removal = assertInstanceOf(ClientboundSetPlayerTeamPacket.class, sent.get(4));
assertEquals("eye-private", removal.getName());
assertEquals(ClientboundSetPlayerTeamPacket.Action.REMOVE, removal.getTeamAction());
assertSame(replacement, board.getPlayersTeam("Concealed"));
assertEquals(teammate ? Set.of("Viewer", "OtherConcealed") : Set.of("OtherConcealed"), Set.copyOf(baseline.getPlayers()));
assertNull(board.getPlayerTeam("eye-private"));
verify(data, never()).set(any(), any());
verify(connection, times(7)).send(org.mockito.ArgumentMatchers.<Packet<?>>any());
verifyNoMoreInteractions(connection);
}
}
@@ -0,0 +1,104 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.UUID;
import net.minecraft.core.RegistryAccess;
import net.minecraft.core.registries.Registries;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.damagesource.DamageSource;
import net.minecraft.world.damagesource.DamageSources;
import net.minecraft.world.effect.MobEffectInstance;
import net.minecraft.world.effect.MobEffects;
import net.minecraft.world.entity.LivingEntity;
import org.bukkit.Bukkit;
import org.bukkit.craftbukkit.CraftRegistry;
import org.bukkit.craftbukkit.damage.CraftDamageSource;
import org.bukkit.craftbukkit.entity.CraftPlayer;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.EntityPotionEffectEvent;
import org.bukkit.plugin.PluginManager;
import org.junit.jupiter.api.BeforeAll;
class NativePotionDamageTest {
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
@SuppressWarnings("try")
void actualNativeApplicationAndDamageTickKeepTheObservedPlayerSource(boolean wither) throws Exception {
var manager = mock(PluginManager.class);
try (var platform = mockStatic(Bukkit.class, call -> switch (call.getMethod().getName()) {
case "getPluginManager" -> manager;
case "isPrimaryThread" -> true;
default -> call.callRealMethod();
})) {
var victim = mock(CraftPlayer.class);
var attacker = mock(CraftPlayer.class);
UUID victimId = UUID.randomUUID(), attackerId = UUID.randomUUID();
when(victim.getUniqueId()).thenReturn(victimId);
when(attacker.getUniqueId()).thenReturn(attackerId);
var nativeVictim = mock(ServerPlayer.class);
var nativeAttacker = mock(ServerPlayer.class);
when(victim.getHandle()).thenReturn(nativeVictim);
when(nativeVictim.getBukkitEntity()).thenReturn(victim);
when(nativeVictim.getBukkitLivingEntity()).thenReturn(victim);
when(nativeAttacker.getBukkitEntity()).thenReturn(attacker);
var active = new HashMap<net.minecraft.core.Holder<net.minecraft.world.effect.MobEffect>, MobEffectInstance>();
var field = LivingEntity.class.getDeclaredField("activeEffects");
field.setAccessible(true);
field.set(nativeVictim, active);
when(nativeVictim.canBeAffected(any(MobEffectInstance.class))).thenReturn(true);
when(nativeVictim.getEffect(any())).thenAnswer(call -> active.get(call.getArgument(0)));
when(nativeVictim.addEffect(any(MobEffectInstance.class), any(net.minecraft.world.entity.Entity.class), any(EntityPotionEffectEvent.Cause.class), anyBoolean())).thenCallRealMethod();
var failures = new ArrayList<Throwable>();
var attribution = new PotionDamageListener(id -> id.equals(attackerId) ? attacker : null, failures::add);
var applications = new ArrayList<EntityPotionEffectEvent>();
doAnswer(call -> {
if (call.getArgument(0) instanceof EntityPotionEffectEvent event) {
applications.add(event);
attribution.onEffect(event);
}
return null;
}).when(manager).callEvent(any());
var type = wither ? MobEffects.WITHER : MobEffects.POISON;
var potion = new MobEffectInstance(type, wither ? 80 : 100, 0);
assertTrue(nativeVictim.addEffect(potion, nativeAttacker, EntityPotionEffectEvent.Cause.POTION_SPLASH, true));
assertSame(attacker, applications.getFirst().getSource());
assertSame(potion, active.get(type));
var world = mock(ServerLevel.class);
var config = mock(org.purpurmc.purpur.PurpurWorldConfig.class);
config.entityMinimalHealthPoison = 1;
config.entityPoisonDegenerationAmount = 1;
config.entityWitherDegenerationAmount = 1;
var worldConfig = net.minecraft.world.level.Level.class.getField("purpurConfig");
worldConfig.setAccessible(true);
worldConfig.set(world, config);
when(nativeVictim.level()).thenReturn(world);
when(nativeVictim.getHealth()).thenReturn(10f);
var sources = new DamageSources(new RegistryAccess.ImmutableRegistryAccess(List.of(CraftRegistry.getMinecraftRegistry(Registries.DAMAGE_TYPE))));
when(nativeVictim.damageSources()).thenReturn(sources);
var resolved = new ArrayList<UUID>();
doAnswer(call -> {
DamageSource raw = call.getArgument(1);
assertNull(raw.getEntity(), "native delayed damage does not carry the applying player's identity");
var event = new EntityDamageEvent(victim, wither ? EntityDamageEvent.DamageCause.WITHER : EntityDamageEvent.DamageCause.POISON,
new CraftDamageSource(raw), ((Float) call.getArgument(2)).doubleValue());
var responsible = attribution.apply(event);
assertNotNull(responsible, "observed native potion provenance must resolve the responsible player");
resolved.add(responsible.getUniqueId());
return true;
}).when(nativeVictim).hurtServer(eq(world), any(DamageSource.class), anyFloat());
assertTrue(potion.tickServer(world, nativeVictim, () -> { }));
assertEquals(List.of(attackerId), resolved);
attribution.onQuit(new org.bukkit.event.player.PlayerQuitEvent(victim, net.kyori.adventure.text.Component.empty()));
assertNull(attribution.apply(new EntityDamageEvent(victim, wither ? EntityDamageEvent.DamageCause.WITHER : EntityDamageEvent.DamageCause.POISON,
new CraftDamageSource(wither ? sources.wither() : sources.magic()), 1.0)), "disconnect must discard runtime-only victim provenance");
assertTrue(failures.isEmpty(), failures.toString());
}
}
}
@@ -0,0 +1,61 @@
package games.dmg.spigotstealth;
import java.util.List;
import java.util.stream.Stream;
import net.minecraft.SharedConstants;
import net.minecraft.commands.Commands;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.LayeredRegistryAccess;
import net.minecraft.core.Registry;
import net.minecraft.core.RegistryAccess;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.RegistryDataLoader;
import net.minecraft.server.Bootstrap;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.RegistryLayer;
import net.minecraft.server.ReloadableServerResources;
import net.minecraft.server.packs.PackType;
import net.minecraft.server.packs.repository.ServerPacksSource;
import net.minecraft.server.packs.resources.MultiPackResourceManager;
import net.minecraft.server.permissions.LevelBasedPermissionSet;
import net.minecraft.tags.TagLoader;
import net.minecraft.util.Util;
import net.minecraft.world.flag.FeatureFlags;
import net.minecraft.world.level.DataPackConfig;
import net.minecraft.world.level.WorldDataConfiguration;
import org.bukkit.craftbukkit.CraftRegistry;
/** Loads vanilla registries/tags/components using the same path as the server's own tests. */
final class NativeRuntime {
private NativeRuntime() {}
private static boolean ready;
static synchronized void bootstrap() throws Exception {
if (ready) { return; }
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
var flags = FeatureFlags.VANILLA_SET;
var packs = ServerPacksSource.createVanillaTrustedRepository();
MinecraftServer.configurePackRepository(packs, new WorldDataConfiguration(new DataPackConfig(
FeatureFlags.REGISTRY.toNames(flags).stream().map(Identifier::getPath).toList(), List.of()), flags), true, false);
try (var resources = new MultiPackResourceManager(PackType.SERVER_DATA, packs.openAllSelected())) {
LayeredRegistryAccess<RegistryLayer> layers = RegistryLayer.createRegistryAccess();
List<Registry.PendingTags<?>> tags = TagLoader.loadTagsForExistingRegistries(resources, layers.getLayer(RegistryLayer.STATIC));
List<HolderLookup.RegistryLookup<?>> lookups = TagLoader.buildUpdatedLookups(layers.getAccessForLoading(RegistryLayer.WORLDGEN), tags);
RegistryAccess.Frozen worldgen = RegistryDataLoader.load(resources, lookups,
RegistryDataLoader.WORLDGEN_REGISTRIES, Util.backgroundExecutor()).join();
layers = layers.replaceFrom(RegistryLayer.WORLDGEN, worldgen);
RegistryAccess.Frozen dimensions = RegistryDataLoader.load(resources,
Stream.concat(lookups.stream(), worldgen.listRegistries()).toList(),
RegistryDataLoader.DIMENSION_REGISTRIES, Util.backgroundExecutor()).join();
layers = layers.replaceFrom(RegistryLayer.DIMENSIONS, dimensions);
Class.forName(org.bukkit.Registry.class.getName());
var datapack = ReloadableServerResources.loadResources(resources, layers, tags, flags,
Commands.CommandSelection.DEDICATED, LevelBasedPermissionSet.ALL_PERMISSIONS,
Util.backgroundExecutor(), Runnable::run).join();
datapack.updateComponentsAndStaticRegistryTags();
CraftRegistry.setMinecraftRegistry(layers.compositeAccess().freeze());
}
ready = true;
}
}
@@ -0,0 +1,203 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import io.papermc.paper.adventure.ChatProcessor;
import io.papermc.paper.event.player.AsyncChatEvent;
import io.papermc.paper.event.player.ChatEvent;
import java.util.List;
import java.util.UUID;
import net.kyori.adventure.text.Component;
import net.minecraft.network.chat.PlayerChatMessage;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import org.bukkit.Bukkit;
import org.bukkit.craftbukkit.CraftRegistry;
import org.bukkit.craftbukkit.CraftServer;
import org.bukkit.craftbukkit.CraftWorld;
import org.bukkit.craftbukkit.entity.CraftPlayer;
import org.bukkit.event.EventHandler;
import org.bukkit.event.HandlerList;
import org.bukkit.plugin.EventExecutor;
import org.bukkit.plugin.PluginManager;
import org.bukkit.plugin.RegisteredListener;
import org.bukkit.plugin.java.JavaPlugin;
import org.junit.jupiter.api.BeforeAll;
class NativeSystemChatTest {
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"local", "cancelled", "late-cancelled", "ordinary", "metadata", "custom-renderer", "transition", "filtered", "partially-filtered", "commands-only", "chat-hidden", "signed-chain", "async-main", "legacy", "legacy-cancelled"})
@SuppressWarnings("try")
void actualPaperPipelinePreservesModerationAndAnonymousSystemDelivery(String scenario) throws Exception {
var server = mock(CraftServer.class);
var console = mock(org.bukkit.command.ConsoleCommandSender.class);
try (var platform = mockStatic(Bukkit.class, call -> switch (call.getMethod().getName()) {
case "getServer" -> server;
case "getConsoleSender" -> console;
case "isPrimaryThread" -> true;
default -> call.callRealMethod();
})) {
var sender = mock(CraftPlayer.class);
var recipient = mock(CraftPlayer.class);
var outsider = mock(CraftPlayer.class);
UUID senderId = UUID.randomUUID();
when(sender.getUniqueId()).thenReturn(senderId);
when(sender.getName()).thenReturn("Secret");
when(sender.displayName()).thenReturn(Component.text("Secret"));
when(sender.getDisplayName()).thenReturn("Secret");
var nativeSender = mock(ServerPlayer.class);
var nativeRecipient = mock(ServerPlayer.class);
var nativeOutsider = mock(ServerPlayer.class);
when(sender.getHandle()).thenReturn(nativeSender);
when(recipient.getHandle()).thenReturn(nativeRecipient);
when(outsider.getHandle()).thenReturn(nativeOutsider);
when(nativeSender.getBukkitEntity()).thenReturn(sender);
when(nativeRecipient.getBukkitEntity()).thenReturn(recipient);
when(nativeRecipient.getChatVisibility()).thenReturn(scenario.equals("commands-only") ? net.minecraft.world.entity.player.ChatVisiblity.SYSTEM
: scenario.equals("chat-hidden") ? net.minecraft.world.entity.player.ChatVisiblity.HIDDEN : net.minecraft.world.entity.player.ChatVisiblity.FULL);
when(nativeOutsider.getBukkitEntity()).thenReturn(outsider);
var world = mock(CraftWorld.class);
var nativeWorld = mock(ServerLevel.class);
when(sender.getWorld()).thenReturn(world);
when(world.getHandle()).thenReturn(nativeWorld);
when(nativeSender.level()).thenReturn(nativeWorld);
when(nativeWorld.registryAccess()).thenReturn(CraftRegistry.getMinecraftRegistry());
var config = mock(io.papermc.paper.configuration.WorldConfiguration.class);
config.scoreboards = mock(io.papermc.paper.configuration.WorldConfiguration.Scoreboards.class);
when(nativeWorld.paperConfig()).thenReturn(config);
var nativeServer = mock(MinecraftServer.class);
nativeServer.server = server;
nativeServer.console = console;
var playerList = mock(net.minecraft.server.players.PlayerList.class);
when(nativeServer.getPlayerList()).thenReturn(playerList);
when(playerList.getPlayers()).thenReturn(List.of(nativeSender, nativeRecipient, nativeOutsider));
var plugin = mock(JavaPlugin.class);
when(plugin.isEnabled()).thenReturn(true);
var concealed = new java.util.concurrent.atomic.AtomicBoolean(!scenario.equals("ordinary"));
var listener = new StealthSystemChat(id -> senderId.equals(id) && concealed.get());
var method = StealthSystemChat.class.getMethod("onChat", ChatEvent.class);
var annotation = method.getAnnotation(EventHandler.class);
ChatEvent.getHandlerList().register(new RegisteredListener(listener, EventExecutor.create(method, ChatEvent.class), annotation.priority(), plugin, annotation.ignoreCancelled()));
org.bukkit.event.Listener legacy = new org.bukkit.event.Listener() { };
if (scenario.startsWith("legacy")) {
org.bukkit.event.player.AsyncPlayerChatEvent.getHandlerList().register(new RegisteredListener(legacy, (registered, event) -> {
var chat = (org.bukkit.event.player.AsyncPlayerChatEvent) event;
chat.getRecipients().retainAll(java.util.Set.of(recipient));
chat.setMessage("moderated");
chat.setFormat("[Local] %1$s: %2$s");
if (scenario.equals("legacy-cancelled")) { chat.setCancelled(true); }
}, org.bukkit.event.EventPriority.HIGHEST, plugin, false));
}
var manager = mock(PluginManager.class);
when(server.getPluginManager()).thenReturn(manager);
doAnswer(call -> {
var event = call.getArgument(0, org.bukkit.event.Event.class);
if (event instanceof AsyncChatEvent chat && !scenario.equals("async-main")) {
chat.viewers().retainAll(java.util.Set.of(recipient)); // Ordinary local-chat/moderation stage.
if (scenario.equals("cancelled")) { chat.setCancelled(true); }
if (scenario.equals("metadata")) {
chat.message(Component.text("hello Secret").hoverEvent(net.kyori.adventure.text.event.HoverEvent.showEntity(
net.kyori.adventure.key.Key.key("minecraft:player"), senderId, Component.text("Secret")))
.clickEvent(net.kyori.adventure.text.event.ClickEvent.suggestCommand("/msg Secret hi")).insertion(senderId.toString()));
}
if (scenario.equals("custom-renderer")) {
chat.renderer((source, display, message, viewer) -> {
assertSame(recipient, viewer, "viewer-aware formatting must see the original recipient");
return Component.text("[Local] ").append(display).append(Component.text(": ")).append(message);
});
}
}
if (event instanceof ChatEvent chat && scenario.equals("async-main")) {
assertFalse(chat.isAsynchronous());
chat.viewers().retainAll(java.util.Set.of(recipient));
}
for (var registered : event.getHandlers().getRegisteredListeners()) { registered.callEvent(event); }
if (event instanceof ChatEvent chat && scenario.equals("late-cancelled")) { chat.setCancelled(true); }
return null;
}).when(manager).callEvent(any());
try {
if (scenario.equals("signed-chain")) {
verifySignedChain(nativeServer, nativeSender, recipient, senderId, concealed);
return;
}
var message = PlayerChatMessage.unsigned(senderId, "hello");
if (scenario.endsWith("filtered")) {
when(nativeSender.shouldFilterMessageTo(nativeRecipient)).thenReturn(true);
var mask = new net.minecraft.network.chat.FilterMask(5);
mask.setFiltered(0);
message = message.filter(scenario.equals("filtered") ? net.minecraft.network.chat.FilterMask.FULLY_FILTERED : mask);
}
if (scenario.equals("async-main")) {
var queue = new java.util.concurrent.LinkedBlockingQueue<Runnable>();
var queueField = MinecraftServer.class.getDeclaredField("processQueue");
queueField.setAccessible(true);
queueField.set(nativeServer, queue);
Thread main = Thread.currentThread();
doAnswer(call -> { assertSame(main, Thread.currentThread(), "system delivery must stay on the main event thread"); return null; }).when(recipient).sendMessage(any(Component.class));
var processor = new ChatProcessor(nativeServer, nativeSender, message, true);
var completed = java.util.concurrent.CompletableFuture.runAsync(processor::process);
var work = queue.poll(5, java.util.concurrent.TimeUnit.SECONDS);
assertNotNull(work, "native asynchronous chat must queue its synchronous event");
work.run();
completed.get(5, java.util.concurrent.TimeUnit.SECONDS);
} else { new ChatProcessor(nativeServer, nativeSender, message, false).process(); }
if (scenario.equals("ordinary")) {
verify(recipient, never()).sendMessage(any(Component.class));
verify(nativeRecipient).sendChatMessage(any(net.minecraft.network.chat.OutgoingChatMessage.class), anyBoolean(), any(net.minecraft.network.chat.ChatType.Bound.class), nullable(net.minecraft.network.chat.Component.class));
return;
}
if (scenario.contains("cancelled") || java.util.Set.of("filtered", "commands-only", "chat-hidden").contains(scenario)) { verify(recipient, never()).sendMessage(any(Component.class)); }
else {
String expected = scenario.equals("legacy") ? "[Local] Anonymous: moderated" : scenario.equals("partially-filtered") ? "[Stealth] Anonymous: #ello" : scenario.equals("custom-renderer") ? "[Local] Anonymous: hello" : "[Stealth] Anonymous: hello" + (scenario.equals("metadata") ? " Anonymous" : "");
verify(recipient).sendMessage(Component.text(expected));
}
verify(nativeRecipient, never()).sendChatMessage(any(net.minecraft.network.chat.OutgoingChatMessage.class), anyBoolean(), any(net.minecraft.network.chat.ChatType.Bound.class), any());
verify(outsider, never()).sendMessage(any(Component.class));
verify(console, never()).sendMessage(any(Component.class));
verify(sender, never()).sendMessage(any(Component.class));
verify(nativeOutsider, never()).sendChatMessage(any(net.minecraft.network.chat.OutgoingChatMessage.class), anyBoolean(), any(net.minecraft.network.chat.ChatType.Bound.class), any());
if (scenario.equals("transition")) {
concealed.set(false);
new ChatProcessor(nativeServer, nativeSender, PlayerChatMessage.unsigned(senderId, "normal"), false).process();
verify(nativeRecipient).sendChatMessage(any(net.minecraft.network.chat.OutgoingChatMessage.class), anyBoolean(), any(net.minecraft.network.chat.ChatType.Bound.class), nullable(net.minecraft.network.chat.Component.class));
verify(recipient, times(1)).sendMessage(any(Component.class));
}
} finally { HandlerList.unregisterAll(listener); HandlerList.unregisterAll(legacy); }
}
}
private static void verifySignedChain(MinecraftServer server, ServerPlayer sender, CraftPlayer recipient, UUID senderId,
java.util.concurrent.atomic.AtomicBoolean concealed) throws Exception {
var generator = java.security.KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
var keys = generator.generateKeyPair();
var publicKey = new net.minecraft.world.entity.player.ProfilePublicKey(new net.minecraft.world.entity.player.ProfilePublicKey.Data(
java.time.Instant.now().plusSeconds(3600), keys.getPublic(), new byte[0]));
UUID session = UUID.randomUUID();
var encoder = new net.minecraft.network.chat.SignedMessageChain(senderId, session)
.encoder(net.minecraft.util.Signer.from(keys.getPrivate(), "SHA256withRSA"));
var decoder = new net.minecraft.network.chat.SignedMessageChain(senderId, session).decoder(publicKey);
var receiver = new net.minecraft.network.chat.SignedMessageValidator.KeyBased(publicKey.createSignatureValidator(), publicKey.data()::hasExpired);
var wire = new NativeChatWire(recipient);
for (int index = 0; index < 66; index++) {
boolean anonymous = index > 0 && index < 65;
concealed.set(anonymous);
String text = "hello " + index;
var body = new net.minecraft.network.chat.SignedMessageBody(text, java.time.Instant.now(), index + 1L, net.minecraft.network.chat.LastSeenMessages.EMPTY);
var signature = encoder.pack(body);
var message = decoder.unpack(signature, body);
assertTrue(message.verify(publicKey.createSignatureValidator()));
assertEquals(index, message.link().index());
new ChatProcessor(server, sender, message, false).process();
wire.receive(anonymous, session, receiver, text);
}
var invalid = new net.minecraft.network.chat.SignedMessageChain(senderId, session).decoder(publicKey);
assertThrows(net.minecraft.network.chat.SignedMessageChain.DecodeException.class,
() -> invalid.unpack(new net.minecraft.network.chat.MessageSignature(new byte[256]), net.minecraft.network.chat.SignedMessageBody.unsigned("tampered")));
assertThrows(net.minecraft.network.chat.LastSeenMessagesValidator.ValidationException.class,
() -> new net.minecraft.network.chat.LastSeenMessagesValidator(20).applyOffset(Integer.MAX_VALUE));
}
}
@@ -0,0 +1,81 @@
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.PacketContainer;
import com.comphenix.protocol.events.PacketEvent;
import java.util.EnumSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
class NativeTabMaskingTest {
private static net.minecraft.network.chat.RemoteChatSession.Data session;
@BeforeAll static void bootstrap() throws Exception {
NativeRuntime.bootstrap();
var generator = java.security.KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
session = new net.minecraft.network.chat.RemoteChatSession.Data(UUID.randomUUID(), new net.minecraft.world.entity.player.ProfilePublicKey.Data(
java.time.Instant.now().plusSeconds(3600).truncatedTo(java.time.temporal.ChronoUnit.MILLIS), generator.generateKeyPair().getPublic(), new byte[0]));
}
@Test
@SuppressWarnings("try")
void serverInitializationKeepsProfileAndChatStateButCannotRelistAConcealedPlayer() {
var server = mock(org.bukkit.craftbukkit.CraftServer.class);
try (var platform = mockStatic(org.bukkit.Bukkit.class, call -> switch (call.getMethod().getName()) {
case "getServer" -> server;
case "isPrimaryThread" -> true;
case "getVersion" -> "Purpur 2618 (MC: 26.2)";
case "getMinecraftVersion" -> "26.2";
case "getBukkitVersion" -> "26.2-R0.1-SNAPSHOT";
default -> call.callRealMethod();
})) {
UUID hiddenId = UUID.randomUUID(), otherId = UUID.randomUUID(), viewerId = UUID.randomUUID();
var viewer = mock(org.bukkit.entity.Player.class);
when(viewer.getUniqueId()).thenReturn(viewerId);
var snapshot = new java.util.concurrent.atomic.AtomicReference<>(Set.of(hiddenId));
var warnings = new java.util.ArrayList<String>();
var listener = new TabListMaskingListener(mock(org.bukkit.plugin.Plugin.class), snapshot::get, warnings::add);
var hidden = entry(hiddenId, "Hidden");
var other = entry(otherId, "Other");
var original = new ClientboundPlayerInfoUpdatePacket(EnumSet.allOf(ClientboundPlayerInfoUpdatePacket.Action.class), List.of(hidden, other));
var packet = new PacketContainer(PacketType.Play.Server.PLAYER_INFO, original);
var event = PacketEvent.fromServer(this, packet, viewer);
listener.onPacketSending(event);
var projected = assertInstanceOf(ClientboundPlayerInfoUpdatePacket.class, event.getPacket().getHandle());
assertFalse(projected.entries().getFirst().listed(), "native initialization must not undo concealment");
assertEquals(original.actions(), projected.actions());
var expected = new ClientboundPlayerInfoUpdatePacket.Entry(hiddenId, hidden.profile(), false, hidden.latency(), hidden.gameMode(), hidden.displayName(), hidden.showHat(), hidden.listOrder(), hidden.chatSession());
assertEquals(expected, projected.entries().getFirst());
assertSame(other, projected.entries().get(1));
assertTrue(original.entries().getFirst().listed());
var buffer = new net.minecraft.network.RegistryFriendlyByteBuf(io.netty.buffer.Unpooled.buffer(), org.bukkit.craftbukkit.CraftRegistry.getMinecraftRegistry());
try {
ClientboundPlayerInfoUpdatePacket.STREAM_CODEC.encode(buffer, projected);
var decoded = ClientboundPlayerInfoUpdatePacket.STREAM_CODEC.decode(buffer);
assertEquals(projected.actions(), decoded.actions());
assertEquals(projected.entries(), decoded.entries(), "profile/session and all other native fields survive the wire codec");
} finally { buffer.release(); }
when(viewer.getUniqueId()).thenReturn(hiddenId);
var self = PacketEvent.fromServer(this, packet, viewer);
listener.onPacketSending(self);
assertSame(packet, self.getPacket(), "retain existing self-tab behavior");
snapshot.set(Set.of());
var ended = PacketEvent.fromServer(this, packet, viewer);
listener.onPacketSending(ended);
assertSame(packet, ended.getPacket());
assertTrue(warnings.isEmpty(), warnings.toString());
}
}
private static ClientboundPlayerInfoUpdatePacket.Entry entry(UUID id, String name) {
return new ClientboundPlayerInfoUpdatePacket.Entry(id, new com.mojang.authlib.GameProfile(id, name), true, 37,
net.minecraft.world.level.GameType.SURVIVAL, net.minecraft.network.chat.Component.literal(name), true, 8,
session);
}
}
@@ -0,0 +1,117 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import java.util.UUID;
import net.minecraft.world.effect.MobEffectInstance;
import net.minecraft.world.effect.MobEffects;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
class PotionDamageAttributionTest {
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test
void actualPoisonCountdownRetainsTheExplicitSourceWithoutAClockGuess() {
UUID victim = UUID.randomUUID(), owner = UUID.randomUUID();
var attribution = new PotionDamageAttribution();
var poison = new MobEffectInstance(MobEffects.POISON, 100, 0);
attribution.change(victim, PotionDamageAttribution.Kind.POISON, null, effect(poison), owner, true);
assertEquals(java.util.Optional.of(owner), attribution.attacker(victim, PotionDamageAttribution.Kind.POISON, snapshot(poison)));
for (int tick = 0; tick < 20; tick++) { poison.tickClient(); }
assertEquals(java.util.Optional.of(owner), attribution.attacker(victim, PotionDamageAttribution.Kind.POISON, snapshot(poison)));
assertTrue(attribution.attacker(UUID.randomUUID(), PotionDamageAttribution.Kind.POISON, snapshot(poison)).isEmpty());
}
@Test
void strongerShortPotionReturnsOwnershipToTheRestoredHiddenLayer() {
UUID victim = UUID.randomUUID(), first = UUID.randomUUID(), second = UUID.randomUUID();
var attribution = new PotionDamageAttribution();
var poison = new MobEffectInstance(MobEffects.POISON, 100, 0);
attribution.change(victim, PotionDamageAttribution.Kind.POISON, null, effect(poison), first, true);
assertEquals(java.util.Optional.of(first), attribution.attacker(victim, PotionDamageAttribution.Kind.POISON, snapshot(poison)));
var stronger = new MobEffectInstance(MobEffects.POISON, 20, 1);
attribution.change(victim, PotionDamageAttribution.Kind.POISON, snapshot(poison), effect(stronger), second, true);
assertTrue(poison.update(stronger));
assertEquals(java.util.Optional.of(second), attribution.attacker(victim, PotionDamageAttribution.Kind.POISON, snapshot(poison)));
for (int tick = 0; tick < 20; tick++) { poison.tickClient(); }
assertEquals(0, poison.getAmplifier());
assertEquals(80, poison.getDuration());
assertEquals(java.util.Optional.of(first), attribution.attacker(victim, PotionDamageAttribution.Kind.POISON, snapshot(poison)));
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"weaker-hidden", "equal-shorter", "cosmetic", "equal-longer", "stronger-longer", "rejected-stronger", "unknown-stronger"})
void nativeReplacementRulesDoNotGiveRejectedOrCosmeticSourcesOwnership(String scenario) {
UUID victim = UUID.randomUUID(), first = UUID.randomUUID(), second = UUID.randomUUID();
var attribution = new PotionDamageAttribution();
boolean weaker = scenario.equals("weaker-hidden");
var poison = new MobEffectInstance(MobEffects.POISON, weaker ? 20 : 100, weaker ? 2 : 0);
attribution.change(victim, PotionDamageAttribution.Kind.POISON, null, effect(poison), first, true);
int amp = scenario.contains("stronger") || weaker ? 1 : 0;
int duration = scenario.contains("longer") ? 200 : weaker ? 100 : 20;
var incoming = new MobEffectInstance(MobEffects.POISON, duration, amp, false, !scenario.equals("cosmetic"));
boolean override = !scenario.equals("rejected-stronger") && new MobEffectInstance(poison).update(incoming);
attribution.change(victim, PotionDamageAttribution.Kind.POISON, snapshot(poison), effect(incoming),
scenario.equals("unknown-stronger") ? null : second, override);
if (!scenario.equals("rejected-stronger")) { poison.update(incoming); }
UUID expected = scenario.equals("unknown-stronger") ? null : scenario.contains("longer") ? second : first;
assertEquals(java.util.Optional.ofNullable(expected), attribution.attacker(victim, PotionDamageAttribution.Kind.POISON, snapshot(poison)));
if (weaker || scenario.equals("unknown-stronger")) {
for (int tick = 0; tick < 20; tick++) { poison.tickClient(); }
assertEquals(java.util.Optional.of(weaker ? second : first), attribution.attacker(victim, PotionDamageAttribution.Kind.POISON, snapshot(poison)));
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"infinite-active", "infinite-hidden", "expired", "removed", "replaced-native"})
void witherInfiniteDurationsAndEffectLifecycleDoNotLeakOldOwnership(String scenario) {
UUID victim = UUID.randomUUID(), owner = UUID.randomUUID(), other = UUID.randomUUID();
var attribution = new PotionDamageAttribution();
var kind = PotionDamageAttribution.Kind.WITHER;
var effect = new MobEffectInstance(MobEffects.WITHER, scenario.startsWith("infinite") ? -1 : 100, 0);
attribution.change(victim, kind, null, effect(effect), owner, true);
assertEquals(java.util.Optional.of(owner), attribution.attacker(victim, kind, snapshot(effect)));
assertTrue(attribution.attacker(victim, PotionDamageAttribution.Kind.POISON, snapshot(effect)).isEmpty());
if (scenario.equals("infinite-hidden")) {
var incoming = new MobEffectInstance(MobEffects.WITHER, 20, 1);
attribution.change(victim, kind, snapshot(effect), effect(incoming), other, true);
effect.update(incoming);
assertEquals(java.util.Optional.of(other), attribution.attacker(victim, kind, snapshot(effect)));
}
for (int tick = 0; tick < (scenario.equals("expired") ? 100 : 20); tick++) { effect.tickClient(); }
if (scenario.equals("removed")) { attribution.change(victim, kind, snapshot(effect), null, null, false); }
if (scenario.equals("replaced-native")) { effect = new MobEffectInstance(effect); }
assertEquals(scenario.startsWith("infinite") ? java.util.Optional.of(owner) : java.util.Optional.empty(), attribution.attacker(victim, kind, snapshot(effect)));
}
@Test
void repeatedNativeMergesAndCountdownsKeepKnownOwnershipAcrossDeepChains() {
var random = new java.util.Random(2618);
UUID victim = UUID.randomUUID(), owner = UUID.randomUUID();
var attribution = new PotionDamageAttribution();
MobEffectInstance current = null;
for (int step = 0; step < 2_000; step++) {
if (current == null || random.nextBoolean()) {
var incoming = new MobEffectInstance(MobEffects.POISON, random.nextInt(10) == 0 ? -1 : 1 + random.nextInt(200), random.nextInt(8));
boolean override = current == null || new MobEffectInstance(current).update(incoming);
attribution.change(victim, PotionDamageAttribution.Kind.POISON, snapshot(current), effect(incoming), owner, override);
if (current == null) { current = incoming; } else { current.update(incoming); }
} else {
for (int tick = 0; tick < 1 + random.nextInt(20) && current.getDuration() != 0; tick++) { current.tickClient(); }
}
if (current.getDuration() == 0) {
attribution.change(victim, PotionDamageAttribution.Kind.POISON, snapshot(current), null, null, false);
current = null;
}
assertEquals(current == null ? java.util.Optional.empty() : java.util.Optional.of(owner),
attribution.attacker(victim, PotionDamageAttribution.Kind.POISON, snapshot(current)), "native operation " + step);
}
}
static PotionDamageAttribution.Snapshot snapshot(MobEffectInstance effect) {
return effect == null ? null : new PotionDamageAttribution.Snapshot(effect, effect(effect));
}
static PotionDamageAttribution.Effect effect(MobEffectInstance effect) {
return effect == null ? null : new PotionDamageAttribution.Effect(effect.getAmplifier(), effect.getDuration(), effect(effect.hiddenEffect));
}
}
@@ -7,6 +7,7 @@ import static org.mockito.Mockito.when;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.scoreboard.Scoreboard; import org.bukkit.scoreboard.Scoreboard;
import org.bukkit.scoreboard.Team; import org.bukkit.scoreboard.Team;
@@ -14,7 +15,69 @@ import org.junit.jupiter.api.Test;
class BukkitIdentityPresentationTest { class BukkitIdentityPresentationTest {
@Test @Test
void concealRemovesTabEntryAndHidesNameTagWithoutHidingEntity() { 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(); UUID targetId = UUID.randomUUID();
Player target = player(targetId, "Alex"); Player target = player(targetId, "Alex");
Player observer = player(UUID.randomUUID(), "Morgan"); Player observer = player(UUID.randomUUID(), "Morgan");
@@ -28,7 +91,11 @@ class BukkitIdentityPresentationTest {
presentation.conceal(target); presentation.conceal(target);
verify(team).setOption(Team.Option.NAME_TAG_VISIBILITY, Team.OptionStatus.NEVER); 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(team).addEntry("Alex");
verify(tabLists).remove(observer, targetId); verify(tabLists).remove(observer, targetId);
verify(observer, never()).hidePlayer(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); verify(observer, never()).hidePlayer(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
@@ -13,7 +13,7 @@ class DeliveryConfigurationTest {
String workflow = Files.readString(Path.of(".gitea/workflows/ci.yml")); String workflow = Files.readString(Path.of(".gitea/workflows/ci.yml"));
assertTrue(workflow.contains("./gradlew clean check jar")); assertTrue(workflow.contains("./gradlew clean check jar"));
assertTrue(workflow.contains("commitlint")); assertTrue(workflow.contains("commitlint"));
assertTrue(workflow.contains("spigot-stealth-${{ github.sha }}")); assertTrue(workflow.contains("purpur-stealth-${{ github.sha }}"));
} }
@Test @Test
@@ -21,7 +21,7 @@ class DeliveryConfigurationTest {
String workflow = Files.readString(Path.of(".gitea/workflows/release.yml")); String workflow = Files.readString(Path.of(".gitea/workflows/release.yml"));
assertTrue(workflow.contains("semantic-release")); assertTrue(workflow.contains("semantic-release"));
assertTrue(workflow.contains("-PreleaseVersion=\"$VERSION\"")); assertTrue(workflow.contains("-PreleaseVersion=\"$VERSION\""));
assertTrue(workflow.contains("spigot-stealth-${VERSION}.jar")); assertTrue(workflow.contains("purpur-stealth-${VERSION}.jar"));
assertTrue(workflow.contains("/releases/${release_id}/assets")); assertTrue(workflow.contains("/releases/${release_id}/assets"));
} }
} }
@@ -0,0 +1,37 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class EyeProgressStateTest {
@Test
void eyeProgressRemainsIndependentAcrossExistingStealthAndSleepUpdates() {
UUID id = UUID.randomUUID();
var original = new PersistentStealthState(Map.of(), Map.of("future-root", "preserved"));
var eye = new EyeProgress(28_800_123, true, Map.of("future-eye", "preserved"));
var withEye = original.withEyeProgress(id, eye);
assertEquals(eye, withEye.eyeProgress(id));
assertEquals(EyeProgress.empty(), original.eyeProgress(id), "Updates must leave old snapshots immutable");
var changed = withEye.withPlayer(PlayerStealthState.empty(id).withProgress(123, true))
.withSleepCountPolicy(SleepCountPolicy.INCLUDE);
assertEquals(eye, changed.eyeProgress(id));
assertEquals(123, changed.player(id).accumulatedMillis());
assertEquals("preserved", changed.unknownFields().get("future-root"));
assertEquals(EyeProgress.empty(), changed.eyeProgress(UUID.randomUUID()));
}
@Test
void progressRejectsNegativeTimeAndCopiesUnknownFields() {
assertThrows(IllegalArgumentException.class, () -> new EyeProgress(-1, false));
var extras = new HashMap<String, Object>();
extras.put("extension", "old");
var progress = new EyeProgress(1, false, extras);
extras.put("extension", "new");
assertEquals("old", progress.unknownFields().get("extension"));
assertThrows(UnsupportedOperationException.class, () -> progress.unknownFields().put("other", 1));
}
}
@@ -0,0 +1,90 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicLong;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class EyeProgressionServiceTest {
@TempDir Path directory;
private final UUID id = UUID.randomUUID();
private final AtomicLong clock = new AtomicLong();
private final List<UUID> notifications = new CopyOnWriteArrayList<>();
@Test
void exactEightHoursUnlocksOnceWithoutOverlapOrLostSubmillisecondCredit() throws Exception {
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
try (var states = new StealthStateManager(repository, repository.load())) {
var progression = new EyeProgressionService(states, clock::get, notifications::add);
progression.begin(id).join();
clock.set(600_000);
progression.checkpoint(id).join();
clock.set(1_200_000);
progression.begin(id).join(); // A refresh, not an overlapping timer.
assertEquals(1, states.snapshot().eyeProgress(id).accumulatedMillis());
clock.set(Duration.ofHours(8).toNanos() - 1);
progression.checkpoint(id).join();
assertFalse(progression.isUnlocked(id));
clock.incrementAndGet();
progression.checkpoint(id).join();
assertTrue(progression.isUnlocked(id));
assertEquals(List.of(id), notifications);
clock.addAndGet(Duration.ofSeconds(3).toNanos());
progression.stop(id).join();
assertEquals(28_803_000, states.snapshot().eyeProgress(id).accumulatedMillis());
assertEquals(List.of(id), notifications);
assertTrue(progression.activePlayerIds().isEmpty());
}
}
@Test
void intervalsAndRestartPreserveProgressWithoutOfflineOrUnobservedCredit() throws Exception {
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
try (var states = new StealthStateManager(repository, repository.load())) {
var progression = new EyeProgressionService(states, clock::get, notifications::add);
progression.begin(id).join();
clock.addAndGet(Duration.ofHours(2).toNanos());
progression.stop(id).join();
clock.addAndGet(Duration.ofDays(30).toNanos());
progression.checkpoint(id).join();
assertEquals(Duration.ofHours(2).toMillis(), states.snapshot().eyeProgress(id).accumulatedMillis());
}
try (var states = new StealthStateManager(repository, repository.load())) {
var restarted = new EyeProgressionService(states, clock::get, notifications::add);
assertTrue(restarted.activePlayerIds().isEmpty());
restarted.begin(id).join();
clock.addAndGet(Duration.ofHours(6).toNanos());
restarted.stopAll().join();
assertTrue(restarted.isUnlocked(id));
assertEquals(28_800_000, repository.load().eyeProgress(id).accumulatedMillis());
assertTrue(restarted.activePlayerIds().isEmpty());
}
}
@Test
void missingDisconnectObservationDiscardsUnobservedTailAndExistingUnlocksDoNotRenotify() throws Exception {
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
var existing = new PersistentStealthState(Map.of(), Map.of())
.withEyeProgress(id, new EyeProgress(28_800_000, true));
try (var states = new StealthStateManager(repository, existing)) {
var progression = new EyeProgressionService(states, clock::get, notifications::add);
assertTrue(progression.isUnlocked(id));
progression.begin(id).join();
clock.addAndGet(Duration.ofSeconds(1).toNanos());
progression.checkpoint(id).join();
clock.addAndGet(Duration.ofHours(1).toNanos());
progression.abandon(id);
progression.checkpoint(id).join();
assertEquals(28_801_000, states.snapshot().eyeProgress(id).accumulatedMillis());
assertTrue(progression.activePlayerIds().isEmpty());
assertTrue(notifications.isEmpty());
}
}
}
@@ -0,0 +1,103 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletionException;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class EyeStatePersistenceTest {
@TempDir Path directory;
@Test
void yamlRoundTripPreservesEyeAndStealthProgressAndUnknownFields() throws Exception {
UUID id = UUID.randomUUID();
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
var eye = new EyeProgress(28_800_123, true, Map.of("future-eye", "keep"));
var state = new PersistentStealthState(Map.of(id, PlayerStealthState.empty(id).withProgress(456, true)),
Map.of("future-root", "keep")).withEyeProgress(id, eye);
repository.save(state);
var loaded = repository.load();
assertEquals(eye, loaded.eyeProgress(id));
assertEquals(state.player(id), loaded.player(id));
assertEquals(state.unknownFields(), loaded.unknownFields());
}
@Test
void legacyFilesStartAtZeroAndMalformedEyeRecordsCannotGrantUnlocksOrEraseStealth() throws Exception {
UUID id = UUID.randomUUID();
Path file = directory.resolve("state.yml");
var repository = new YamlStealthStateRepository(file);
var legacy = new PersistentStealthState(Map.of(id, PlayerStealthState.empty(id).withProgress(456, true)), Map.of());
repository.save(legacy);
assertEquals(EyeProgress.empty(), repository.load().eyeProgress(id));
// Deliberately malformed independent section, not a malformed existing player record.
Files.writeString(file, Files.readString(file) + "\neye-progress:\n " + id
+ ":\n accumulated-millis: -1\n unlocked: true\n");
var loaded = repository.load();
assertEquals(EyeProgress.empty(), loaded.eyeProgress(id));
assertEquals(legacy.player(id), loaded.player(id));
}
@Test
void pendingWriteDoesNotExposeDurableUnlockAndSavesOffTheCallingThread() throws Exception {
UUID id = UUID.randomUUID();
var initial = new PersistentStealthState(Map.of(), Map.of());
var entered = new CountDownLatch(1);
var release = new CountDownLatch(1);
Thread caller = Thread.currentThread();
var repository = new StealthStateRepository() {
@Override public PersistentStealthState load() { return initial; }
@Override public void save(PersistentStealthState state) throws IOException {
assertNotEquals(caller, Thread.currentThread());
entered.countDown();
try {
if (!release.await(5, TimeUnit.SECONDS)) { throw new IOException("test write timeout"); }
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new IOException(exception);
}
}
};
var manager = new StealthStateManager(repository, initial);
try {
var saved = manager.update(state -> state.withEyeProgress(id, new EyeProgress(28_800_000, true)));
assertTrue(entered.await(5, TimeUnit.SECONDS));
assertTrue(manager.snapshot().eyeProgress(id).unlocked());
assertFalse(manager.durableSnapshot().eyeProgress(id).unlocked());
release.countDown();
saved.join();
assertTrue(manager.durableSnapshot().eyeProgress(id).unlocked());
} finally { release.countDown(); manager.close(); }
}
@Test
void failedWriteCannotGrantDurableUnlockAndSuccessfulRetryCan() {
UUID id = UUID.randomUUID();
var initial = new PersistentStealthState(Map.of(), Map.of());
var fail = new AtomicBoolean(true);
var repository = new StealthStateRepository() {
@Override public PersistentStealthState load() { return initial; }
@Override public void save(PersistentStealthState state) throws IOException {
if (fail.get()) { throw new IOException("test disk failure"); }
}
};
var manager = new StealthStateManager(repository, initial);
try {
assertThrows(CompletionException.class, () -> manager.update(state ->
state.withEyeProgress(id, new EyeProgress(28_800_000, true))).join());
assertFalse(manager.durableSnapshot().eyeProgress(id).unlocked());
fail.set(false);
manager.save().join();
assertTrue(manager.durableSnapshot().eyeProgress(id).unlocked());
} finally { fail.set(false); manager.close(); }
}
}
@@ -0,0 +1,47 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class EyeVisibilityTest {
private final UUID world = UUID.randomUUID();
private final UUID viewerId = UUID.randomUUID();
private final UUID targetId = UUID.randomUUID();
@Test
void personalEligibilityConcealmentVisibilityAndSightMustAllPermitTheReveal() {
var position = new EyeVisibility.Position(world, 0, 64, 0);
var viewer = new EyeVisibility.Viewer(viewerId, position, true);
var target = new EyeVisibility.Target(targetId, position, true, false, false);
assertFalse(EyeVisibility.canReveal(new EyeVisibility.Viewer(viewerId, position, false), target, true, true),
"an unearned or unequipped observer cannot reveal a target");
assertFalse(EyeVisibility.canReveal(viewer, new EyeVisibility.Target(targetId, position, false, false, false), true, true));
assertFalse(EyeVisibility.canReveal(viewer, new EyeVisibility.Target(targetId, position, true, true, false), true, true),
"the Eye must not bypass spectator hiding");
assertFalse(EyeVisibility.canReveal(viewer, new EyeVisibility.Target(targetId, position, true, false, true), true, true),
"the Eye must not bypass administrative vanish");
assertFalse(EyeVisibility.canReveal(viewer, target, false, true), "platform visibility denial takes precedence");
assertFalse(EyeVisibility.canReveal(viewer, target, true, false), "walls must block revelation");
assertFalse(EyeVisibility.canReveal(viewer, new EyeVisibility.Target(viewerId, position, true, false, false), true, true));
assertFalse(EyeVisibility.canReveal(viewer, new EyeVisibility.Target(targetId,
new EyeVisibility.Position(UUID.randomUUID(), 0, 64, 0), true, false, false), true, true));
assertFalse(EyeVisibility.canReveal(viewer, new EyeVisibility.Target(targetId,
new EyeVisibility.Position(world, Double.NaN, 64, 0), true, false, false), true, true));
assertTrue(EyeVisibility.canReveal(viewer, target, true, true));
}
@Test
void anEligibleWearerCanRevealAtTheInclusiveThreeDimensionalSixteenBlockBoundary() {
var viewer = new EyeVisibility.Viewer(viewerId, new EyeVisibility.Position(world, 0, 64, 0), true);
var horizontal = new EyeVisibility.Target(targetId, new EyeVisibility.Position(world, 16, 64, 0), true, false, false);
var vertical = new EyeVisibility.Target(targetId, new EyeVisibility.Position(world, 0, 80, 0), true, false, false);
assertTrue(EyeVisibility.canReveal(viewer, horizontal, true, true));
assertTrue(EyeVisibility.canReveal(viewer, vertical, true, true));
var diagonal = new EyeVisibility.Target(targetId, new EyeVisibility.Position(world, 12, 76, 0), true, false, false);
var beyond = new EyeVisibility.Target(targetId, new EyeVisibility.Position(world, 16.001, 64, 0), true, false, false);
assertFalse(EyeVisibility.canReveal(viewer, diagonal, true, true));
assertFalse(EyeVisibility.canReveal(viewer, beyond, true, true));
}
}
@@ -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,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,53 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import java.io.DataInputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.jar.JarFile;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
class PurpurDistributionTest {
@Test
void packagesNewNameWithoutChangingRuntimeIdentityOrBundlingTheApi() throws Exception {
Path artifact = Path.of(System.getProperty("distribution.jar"));
String version = System.getProperty("distribution.version");
assertEquals("purpur-stealth-" + version + ".jar", artifact.getFileName().toString());
try (var jar = new JarFile(artifact.toFile())) {
Map<?, ?> metadata = new Yaml().load(jar.getInputStream(jar.getJarEntry("plugin.yml")));
assertEquals(version, metadata.get("version"));
assertEquals("SpigotStealth", metadata.get("name"), "Retain the data directory and persistent namespace");
assertEquals("games.dmg.spigotstealth.SpigotStealthPlugin", metadata.get("main"));
assertTrue(((Map<?, ?>) metadata.get("permissions")).containsKey("spigotstealth.admin"));
assertFalse(jar.stream().anyMatch(entry -> entry.getName().startsWith("org/bukkit/")
|| entry.getName().startsWith("org/purpurmc/") || entry.getName().endsWith("Test.class")));
}
}
@Test
void pluginBytecodeTargetsJava25() throws Exception {
try (var jar = new JarFile(System.getProperty("distribution.jar"));
var bytecode = new DataInputStream(jar.getInputStream(
jar.getJarEntry("games/dmg/spigotstealth/SpigotStealthPlugin.class")))) {
assertEquals(0xCAFEBABE, bytecode.readInt());
bytecode.readUnsignedShort();
assertEquals(69, bytecode.readUnsignedShort());
}
}
@Test
void releaseAndCiAgreeWithThePurpurDistributionNameAndToolchain() throws Exception {
String build = Files.readString(Path.of("build.gradle.kts"));
assertTrue(build.contains("purpur-api:26.2.build.2618-stable"));
assertFalse(build.contains("org.spigotmc:spigot-api"));
for (String workflow : new String[] {"ci.yml", "release.yml"}) {
String content = Files.readString(Path.of(".gitea/workflows", workflow));
assertTrue(content.contains("java-version: 25"));
assertTrue(content.contains("purpur-stealth-"), workflow);
assertFalse(content.contains("spigot-stealth-"), workflow);
}
}
}
@@ -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());
}
}
@@ -26,7 +26,7 @@ class StealthAdminCommandTest {
fixture.command.onTabComplete( fixture.command.onTabComplete(
sender(true), mock(Command.class), "stealthadmin", new String[] {"gr"})); sender(true), mock(Command.class), "stealthadmin", new String[] {"gr"}));
assertEquals( assertEquals(
List.of("status", "grant", "reset", "list"), List.of("status", "grant", "reset", "list", "sleepcount"),
fixture.command.onTabComplete( fixture.command.onTabComplete(
sender(true), mock(Command.class), "stealthadmin", new String[] {""})); sender(true), mock(Command.class), "stealthadmin", new String[] {""}));
} }
@@ -51,6 +51,10 @@ class StealthAdminCommandTest {
List.of("unlocked"), List.of("unlocked"),
fixture.command.onTabComplete( fixture.command.onTabComplete(
administrator, command, "stealthadmin", new String[] {"list", "un"})); administrator, command, "stealthadmin", new String[] {"list", "un"}));
assertEquals(
List.of("status", "include", "exclude"),
fixture.command.onTabComplete(
administrator, command, "stealthadmin", new String[] {"sleepcount", ""}));
assertEquals( assertEquals(
List.of(), List.of(),
fixture.command.onTabComplete( fixture.command.onTabComplete(
@@ -62,6 +66,31 @@ class StealthAdminCommandTest {
} }
} }
@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, never()).sendMessage(contains("now included"));
assertEquals(List.of(), fixture.audit);
fixture.runMainThreadCallback();
verify(sender).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 @Test
void statusInspectsKnownOfflinePlayerAndReportsAllState() { void statusInspectsKnownOfflinePlayerAndReportsAllState() {
UUID playerId = UUID.randomUUID(); UUID playerId = UUID.randomUUID();
@@ -99,7 +128,10 @@ class StealthAdminCommandTest {
try (Fixture fixture = fixture(playerId)) { try (Fixture fixture = fixture(playerId)) {
CommandSender sender = sender(true); CommandSender sender = sender(true);
fixture.command.onCommand(sender, mock(Command.class), "stealthadmin", new String[] {"grant", playerId.toString()}); fixture.command.onCommand(sender, mock(Command.class), "stealthadmin", new String[] {"grant", playerId.toString()});
verify(sender, org.mockito.Mockito.timeout(1000)).sendMessage(contains("granted")); verify(sender, never()).sendMessage(contains("granted"));
assertEquals(List.of(), fixture.audit);
fixture.runMainThreadCallback();
verify(sender).sendMessage(contains("granted"));
org.junit.jupiter.api.Assertions.assertTrue(fixture.manager.snapshot().player(playerId).unlocked()); 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()))); org.junit.jupiter.api.Assertions.assertTrue(fixture.audit.stream().anyMatch(message -> message.contains("grant") && message.contains(playerId.toString())));
} }
@@ -136,19 +168,35 @@ class StealthAdminCommandTest {
manager.update(state -> state.withPlayer(player)).join(); manager.update(state -> state.withPlayer(player)).join();
QualifyingInvisibilityService progression = new QualifyingInvisibilityService( QualifyingInvisibilityService progression = new QualifyingInvisibilityService(
manager, Duration.ofHours(8), System::nanoTime, ignored -> { }); manager, Duration.ofHours(8), System::nanoTime, ignored -> { });
IdentityPresentation presentation = mock(IdentityPresentation.class);
StealthAdministrationService administration = new StealthAdministrationService( StealthAdministrationService administration = new StealthAdministrationService(
manager, progression, mock(IdentityPresentation.class), ignored -> { }, List::of); manager, progression, presentation, ignored -> { }, List::of);
KnownPlayerResolver resolver = new KnownPlayerResolver(manager::snapshot, List::of); KnownPlayerResolver resolver = new KnownPlayerResolver(manager::snapshot, List::of);
ArrayList<String> audit = new ArrayList<>(); ArrayList<String> audit = new ArrayList<>();
var mainThread = new java.util.concurrent.LinkedBlockingQueue<Runnable>();
StealthAdminCommand command = new StealthAdminCommand( StealthAdminCommand command = new StealthAdminCommand(
administration, resolver, Runnable::run, audit::add, Duration.ofHours(8)); administration, resolver, mainThread::add, audit::add, Duration.ofHours(8));
return new Fixture(manager, command, audit); return new Fixture(manager, command, presentation, audit, mainThread);
} }
private record Fixture( private record Fixture(
StealthStateManager manager, StealthStateManager manager,
StealthAdminCommand command, StealthAdminCommand command,
ArrayList<String> audit) implements AutoCloseable { IdentityPresentation presentation,
ArrayList<String> audit,
java.util.concurrent.BlockingQueue<Runnable> mainThread) implements AutoCloseable {
void runMainThreadCallback() {
// A reply is emitted before the audit append. Await and run the whole scheduled callback,
// not just the reply invocation, and never read the ArrayList concurrently with the I/O thread.
try {
Runnable callback = mainThread.poll(5, java.util.concurrent.TimeUnit.SECONDS);
org.junit.jupiter.api.Assertions.assertNotNull(callback, "Expected a scheduled main-thread completion");
callback.run();
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new AssertionError("Interrupted waiting for command completion", exception);
}
}
@Override public void close() { manager.close(); } @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;
}
}
@@ -16,6 +16,18 @@ import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
class StealthAdministrationServiceTest { 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 @Test
void grantsOfflineUnlockAndNotifiesExactlyOnce() { void grantsOfflineUnlockAndNotifiesExactlyOnce() {
UUID playerId = UUID.randomUUID(); UUID playerId = UUID.randomUUID();
@@ -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\""));
}
}
@@ -10,9 +10,50 @@ import java.util.Map;
import java.util.UUID; import java.util.UUID;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
class StealthSessionListenerTest { 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 @Test
void preparedLoginSuppressesAnnouncementAndConcealsIdentityForSession() { void preparedLoginSuppressesAnnouncementAndConcealsIdentityForSession() {
UUID playerId = UUID.randomUUID(); UUID playerId = UUID.randomUUID();
@@ -21,8 +62,9 @@ class StealthSessionListenerTest {
QualifyingInvisibilityService progression = new QualifyingInvisibilityService( QualifyingInvisibilityService progression = new QualifyingInvisibilityService(
manager, Duration.ofHours(8), System::nanoTime, ignored -> { }); manager, Duration.ofHours(8), System::nanoTime, ignored -> { });
IdentityPresentation presentation = mock(IdentityPresentation.class); IdentityPresentation presentation = mock(IdentityPresentation.class);
StealthSessionService sessions = new StealthSessionService(manager, progression);
StealthSessionListener listener = new StealthSessionListener( StealthSessionListener listener = new StealthSessionListener(
new StealthSessionService(manager, progression), presentation, "Stealth active"); sessions, presentation, "Stealth active");
Player player = player(playerId); Player player = player(playerId);
PlayerJoinEvent event = mock(PlayerJoinEvent.class); PlayerJoinEvent event = mock(PlayerJoinEvent.class);
when(event.getPlayer()).thenReturn(player); when(event.getPlayer()).thenReturn(player);
@@ -32,6 +74,10 @@ class StealthSessionListenerTest {
verify(event).setJoinMessage(null); verify(event).setJoinMessage(null);
verify(presentation).conceal(player); verify(presentation).conceal(player);
verify(player).sendMessage("Stealth active"); 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.");
} }
} }
@@ -1,15 +1,38 @@
package games.dmg.spigotstealth; 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.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
class StealthSessionServiceTest { 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 @Test
void unlockedQualifyingDisconnectPreparesAndConsumesOneConcealedLogin() { void unlockedQualifyingDisconnectPreparesAndConsumesOneConcealedLogin() {
UUID playerId = UUID.randomUUID(); UUID playerId = UUID.randomUUID();
@@ -0,0 +1,57 @@
package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.*;
import java.io.IOException;
import java.nio.file.Path;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class StealthStateShutdownTest {
@TempDir Path directory;
@Test
void shutdownReturnsWithoutWaitingForDiskButDrainsTheFinalSnapshotAndRejectsLaterMutations() throws Exception {
var disk = new YamlStealthStateRepository(directory.resolve("state.yml"));
var saving = new CountDownLatch(1);
var release = new CountDownLatch(1);
StealthStateRepository repository = new StealthStateRepository() {
@Override public PersistentStealthState load() throws IOException { return disk.load(); }
@Override public void save(PersistentStealthState state) throws IOException {
saving.countDown();
try {
if (!release.await(5, TimeUnit.SECONDS)) { throw new IOException("Test disk gate timed out"); }
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new IOException(exception);
}
disk.save(state);
}
};
var states = new StealthStateManager(repository, repository.load());
UUID id = UUID.randomUUID();
try {
states.update(state -> state.withEyeProgress(id, new EyeProgress(1234, false)));
assertTrue(saving.await(3, TimeUnit.SECONDS));
// A blocked save must not block the lifecycle caller. The test thread still releases the disk on failure.
var closing = CompletableFuture.supplyAsync(states::closeAsync).get(1, TimeUnit.SECONDS);
assertFalse(closing.isDone(), "shutdown completion must await the queued durable snapshot");
assertSame(closing, states.closeAsync());
assertThrows(ExecutionException.class, () -> states.update(state ->
state.withEyeProgress(id, new EyeProgress(28_800_000, true))).get(1, TimeUnit.SECONDS));
assertEquals(1234, states.snapshot().eyeProgress(id).accumulatedMillis());
release.countDown();
closing.get(3, TimeUnit.SECONDS);
assertEquals(1234, disk.load().eyeProgress(id).accumulatedMillis());
assertEquals(disk.load(), states.durableSnapshot());
} finally {
release.countDown();
states.close();
}
}
}
@@ -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());
} }