Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1bd4406d2 | ||
|
|
0499a31f71 | ||
|
|
67942ced7c | ||
|
|
74b9ac59ff | ||
|
|
b7db69d010 | ||
|
|
d0000810c1 | ||
|
|
1addc93062 | ||
|
|
831f6a2ce4 | ||
|
|
a084f86c1d | ||
|
|
ba6573943b | ||
|
|
66ba2aa3b1 | ||
|
|
b0da1508b6 |
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# 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. The separately tracked signed-chat validation/system-message work remains pending.
|
||||||
|
|
||||||
|
## 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 and combat-session breaking are described above; system-chat routing remains a separate unfinished story. 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
@@ -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")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# Spigot Stealth Design Log
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
@@ -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,29 +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.
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
|
|
||||||
Automated tests verify concise duration formatting, live in-flight progress, configured target and remaining output, command non-mutation, and unlocked usage guidance. Command metadata and the complete `./gradlew clean check jar` lifecycle pass.
|
|
||||||
|
|
||||||
## 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,35 +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.
|
|
||||||
|
|
||||||
## 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, 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)
|
|
||||||
@@ -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
|
|
||||||
@@ -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
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,17 @@ public final class KnownPlayerResolver {
|
|||||||
this.onlinePlayers = Objects.requireNonNull(onlinePlayers, "onlinePlayers");
|
this.onlinePlayers = Objects.requireNonNull(onlinePlayers, "onlinePlayers");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public java.util.List<String> completeNames(String prefix) {
|
||||||
|
String normalized = prefix == null ? "" : prefix.toLowerCase(Locale.ROOT);
|
||||||
|
return knownPlayers().values().stream()
|
||||||
|
.map(KnownPlayer::name)
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.distinct()
|
||||||
|
.filter(name -> name.toLowerCase(Locale.ROOT).startsWith(normalized))
|
||||||
|
.sorted(String.CASE_INSENSITIVE_ORDER)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
public Resolution resolve(String query) {
|
public Resolution resolve(String query) {
|
||||||
if (query == null || query.isBlank()) {
|
if (query == null || query.isBlank()) {
|
||||||
return new Resolution(Status.UNKNOWN, null);
|
return new Resolution(Status.UNKNOWN, null);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() {
|
||||||
|
++lifecycleEpoch;
|
||||||
|
try {
|
||||||
|
if (eyePotions != null) {
|
||||||
|
eyePotions.checkpoint(getServer()::getPlayer);
|
||||||
|
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) {
|
if (identityPresentation != null && sessions != null) {
|
||||||
for (org.bukkit.entity.Player player : getServer().getOnlinePlayers()) {
|
for (org.bukkit.entity.Player player : getServer().getOnlinePlayers()) {
|
||||||
if (sessions.isConcealed(player.getUniqueId())) {
|
if (sessions.isConcealed(player.getUniqueId())) {
|
||||||
identityPresentation.reveal(player);
|
try { identityPresentation.reveal(player); }
|
||||||
sessions.endConcealment(player.getUniqueId());
|
finally { logSaveFailure(sessions.endConcealment(player.getUniqueId())); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (progression != null) {
|
} finally {
|
||||||
progression.stopAll().join();
|
try {
|
||||||
|
if (eyeRecipeRegistered) {
|
||||||
|
getServer().removeRecipe(EyeCrafting.RECIPE_KEY);
|
||||||
|
eyeRecipeRegistered = false;
|
||||||
}
|
}
|
||||||
if (stateManagerFuture != null && stateManagerFuture.isDone() && !stateManagerFuture.isCompletedExceptionally()) {
|
} finally {
|
||||||
stateManagerFuture.join().close();
|
if (stateManagerFuture != null) {
|
||||||
|
shutdown = stateManagerFuture.thenCompose(StealthStateManager::closeAsync);
|
||||||
|
logSaveFailure(shutdown);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Durable shutdown acknowledgement; never wait for it on the server thread. */
|
||||||
|
CompletableFuture<Void> shutdownCompletion() {
|
||||||
|
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,26 +114,87 @@ 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 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);
|
||||||
Objects.requireNonNull(getCommand("stealth"), "stealth command")
|
getServer().getPluginManager().registerEvents(
|
||||||
.setExecutor(new StealthCommand(manager, progression, settings));
|
new StealthAdminJoinListener(sessions, getServer()::getOnlinePlayers), this);
|
||||||
|
org.bukkit.command.PluginCommand stealthPluginCommand =
|
||||||
|
Objects.requireNonNull(getCommand("stealth"), "stealth command");
|
||||||
|
StealthCommand stealthCommand = new StealthCommand(manager, progression, settings);
|
||||||
|
stealthPluginCommand.setExecutor(stealthCommand);
|
||||||
|
stealthPluginCommand.setTabCompleter(stealthCommand);
|
||||||
StealthAdministrationService administration = new StealthAdministrationService(
|
StealthAdministrationService administration = new StealthAdministrationService(
|
||||||
manager, progression, identityPresentation, notifier, getServer()::getOnlinePlayers);
|
manager, progression, identityPresentation, notifier, getServer()::getOnlinePlayers);
|
||||||
KnownPlayerResolver resolver = new KnownPlayerResolver(manager::snapshot, getServer()::getOnlinePlayers);
|
KnownPlayerResolver resolver = new KnownPlayerResolver(manager::snapshot, getServer()::getOnlinePlayers);
|
||||||
Objects.requireNonNull(getCommand("stealthadmin"), "stealthadmin command")
|
org.bukkit.command.PluginCommand stealthAdminPluginCommand =
|
||||||
.setExecutor(new StealthAdminCommand(
|
Objects.requireNonNull(getCommand("stealthadmin"), "stealthadmin command");
|
||||||
|
StealthAdminCommand stealthAdminCommand = new StealthAdminCommand(
|
||||||
administration,
|
administration,
|
||||||
resolver,
|
resolver,
|
||||||
mainThread,
|
mainThread,
|
||||||
getLogger()::info,
|
getLogger()::info,
|
||||||
settings.unlockThreshold()));
|
settings.unlockThreshold());
|
||||||
|
stealthAdminPluginCommand.setExecutor(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();
|
||||||
|
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));
|
||||||
@@ -105,6 +209,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));
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ import java.util.function.Consumer;
|
|||||||
import org.bukkit.command.Command;
|
import org.bukkit.command.Command;
|
||||||
import org.bukkit.command.CommandExecutor;
|
import org.bukkit.command.CommandExecutor;
|
||||||
import org.bukkit.command.CommandSender;
|
import org.bukkit.command.CommandSender;
|
||||||
|
import org.bukkit.command.TabCompleter;
|
||||||
|
|
||||||
/** 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 {
|
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", "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;
|
||||||
@@ -32,19 +34,65 @@ public final class StealthAdminCommand implements CommandExecutor {
|
|||||||
this.unlockThreshold = Objects.requireNonNull(unlockThreshold, "unlockThreshold");
|
this.unlockThreshold = Objects.requireNonNull(unlockThreshold, "unlockThreshold");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> onTabComplete(
|
||||||
|
CommandSender sender, Command command, String label, String[] arguments) {
|
||||||
|
if (!sender.hasPermission(PERMISSION)) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
if (arguments.length == 1) {
|
||||||
|
return matching(OPERATIONS, arguments[0]);
|
||||||
|
}
|
||||||
|
String operation = arguments[0].toLowerCase(Locale.ROOT);
|
||||||
|
if (arguments.length == 2) {
|
||||||
|
if (List.of("status", "grant", "reset").contains(operation)) {
|
||||||
|
return resolver.completeNames(arguments[1]);
|
||||||
|
}
|
||||||
|
if ("list".equals(operation)) {
|
||||||
|
return matching(List.of("unlocked"), arguments[1]);
|
||||||
|
}
|
||||||
|
if ("sleepcount".equals(operation)) {
|
||||||
|
return matching(List.of("status", "include", "exclude"), arguments[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (arguments.length == 3 && "reset".equals(operation)) {
|
||||||
|
return matching(List.of("confirm"), arguments[2]);
|
||||||
|
}
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> matching(List<String> candidates, String prefix) {
|
||||||
|
String normalized = prefix.toLowerCase(Locale.ROOT);
|
||||||
|
return candidates.stream().filter(candidate -> candidate.startsWith(normalized)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
|
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
|
||||||
if (!sender.hasPermission(PERMISSION)) {
|
if (!sender.hasPermission(PERMISSION)) {
|
||||||
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 == 1 && "list".equalsIgnoreCase(arguments[0])) {
|
if (arguments.length > 0 && "sleepcount".equalsIgnoreCase(arguments[0])) {
|
||||||
|
return sleepCount(sender, label, arguments);
|
||||||
|
}
|
||||||
|
if (arguments.length > 0 && "list".equalsIgnoreCase(arguments[0])) {
|
||||||
|
if (arguments.length == 1) {
|
||||||
List<String> names = administration.concealedOnlineNames();
|
List<String> names = administration.concealedOnlineNames();
|
||||||
sender.sendMessage(names.isEmpty()
|
sender.sendMessage(names.isEmpty()
|
||||||
? "No players are currently concealed."
|
? "No players are currently concealed."
|
||||||
: "Currently concealed: " + String.join(", ", names));
|
: "Currently concealed: " + String.join(", ", names));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (arguments.length == 2 && "unlocked".equalsIgnoreCase(arguments[1])) {
|
||||||
|
List<String> names = administration.unlockedPlayerNames();
|
||||||
|
sender.sendMessage(names.isEmpty()
|
||||||
|
? "No players have unlocked stealth."
|
||||||
|
: "Stealth unlocked: " + String.join(", ", names));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
sendUsage(sender, label);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (arguments.length < 2) {
|
if (arguments.length < 2) {
|
||||||
sendUsage(sender, label);
|
sendUsage(sender, label);
|
||||||
return true;
|
return true;
|
||||||
@@ -69,6 +117,44 @@ public final class StealthAdminCommand implements CommandExecutor {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() + "):");
|
||||||
@@ -121,13 +207,17 @@ public final class StealthAdminCommand implements CommandExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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>");
|
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(
|
||||||
@@ -73,6 +93,17 @@ public final class StealthAdministrationService {
|
|||||||
return CompletableFuture.allOf(stopped, reset);
|
return CompletableFuture.allOf(stopped, reset);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<String> unlockedPlayerNames() {
|
||||||
|
return stateManager.snapshot().players().entrySet().stream()
|
||||||
|
.filter(entry -> entry.getValue().unlocked())
|
||||||
|
.map(entry -> {
|
||||||
|
String name = entry.getValue().lastKnownName();
|
||||||
|
return name == null || name.isBlank() ? entry.getKey().toString() : name;
|
||||||
|
})
|
||||||
|
.sorted(String.CASE_INSENSITIVE_ORDER)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
public List<String> concealedOnlineNames() {
|
public List<String> concealedOnlineNames() {
|
||||||
return onlinePlayers.get().stream()
|
return onlinePlayers.get().stream()
|
||||||
.filter(player -> stateManager.snapshot().player(player.getUniqueId()).concealed())
|
.filter(player -> stateManager.snapshot().player(player.getUniqueId()).concealed())
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonParseException;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/** Masks semantic name slots, never searching/replacing user-authored message text. */
|
||||||
|
public final class StealthChatMasker {
|
||||||
|
private static final Set<String> NAME_FIRST_TRANSLATIONS = Set.of(
|
||||||
|
"chat.type.text", "chat.type.announcement", "chat.type.emote",
|
||||||
|
"commands.message.display.incoming", "commands.message.display.outgoing",
|
||||||
|
"chat.type.advancement.task", "chat.type.advancement.goal", "chat.type.advancement.challenge");
|
||||||
|
private final Set<String> names;
|
||||||
|
|
||||||
|
public StealthChatMasker(Set<String> concealedNames) {
|
||||||
|
names = concealedNames.stream().map(name -> name.toLowerCase(Locale.ROOT)).collect(Collectors.toUnmodifiableSet());
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isConcealedName(String text) {
|
||||||
|
return names.contains(text.toLowerCase(Locale.ROOT));
|
||||||
|
}
|
||||||
|
|
||||||
|
public String maskAnnouncement(String json) {
|
||||||
|
try {
|
||||||
|
JsonElement component = JsonParser.parseString(json);
|
||||||
|
return maskAnnouncement(component) ? component.toString() : json;
|
||||||
|
} catch (JsonParseException | IllegalStateException exception) {
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String maskDisplayName(String json) {
|
||||||
|
try {
|
||||||
|
return isConcealedName(plainName(JsonParser.parseString(json))) ? anonymous().toString() : json;
|
||||||
|
} catch (JsonParseException | IllegalStateException exception) {
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean maskAnnouncement(JsonElement component) {
|
||||||
|
boolean changed = false;
|
||||||
|
if (component.isJsonArray()) {
|
||||||
|
for (JsonElement child : component.getAsJsonArray()) {
|
||||||
|
changed |= maskAnnouncement(child);
|
||||||
|
}
|
||||||
|
if (changed && !component.getAsJsonArray().isEmpty()) {
|
||||||
|
clearInheritedActions(component.getAsJsonArray().get(0));
|
||||||
|
}
|
||||||
|
} else if (component.isJsonObject()) {
|
||||||
|
JsonObject object = component.getAsJsonObject();
|
||||||
|
// Translation arguments other than the explicitly known name slot are message content.
|
||||||
|
if (object.has("translate") && object.get("translate").isJsonPrimitive()
|
||||||
|
&& NAME_FIRST_TRANSLATIONS.contains(object.get("translate").getAsString())
|
||||||
|
&& object.has("with") && object.get("with").isJsonArray()) {
|
||||||
|
JsonArray arguments = object.getAsJsonArray("with");
|
||||||
|
if (!arguments.isEmpty() && isConcealedName(plainName(arguments.get(0)))) {
|
||||||
|
arguments.set(0, anonymous());
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (object.has("extra")) {
|
||||||
|
changed |= maskAnnouncement(object.get("extra"));
|
||||||
|
}
|
||||||
|
// Prevent masked descendants from inheriting an identifying action from their parent.
|
||||||
|
if (changed) {
|
||||||
|
clearInheritedActions(object);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void clearInheritedActions(JsonElement component) {
|
||||||
|
if (component.isJsonObject()) {
|
||||||
|
for (String key : Set.of("hoverEvent", "clickEvent", "hover_event", "click_event", "insertion")) {
|
||||||
|
component.getAsJsonObject().remove(key);
|
||||||
|
}
|
||||||
|
} else if (component.isJsonArray() && !component.getAsJsonArray().isEmpty()) {
|
||||||
|
clearInheritedActions(component.getAsJsonArray().get(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String plainName(JsonElement component) {
|
||||||
|
if (component.isJsonPrimitive()) {
|
||||||
|
return component.getAsString();
|
||||||
|
}
|
||||||
|
StringBuilder text = new StringBuilder();
|
||||||
|
if (component.isJsonArray()) {
|
||||||
|
for (JsonElement child : component.getAsJsonArray()) {
|
||||||
|
text.append(plainName(child));
|
||||||
|
}
|
||||||
|
} else if (component.isJsonObject()) {
|
||||||
|
JsonObject object = component.getAsJsonObject();
|
||||||
|
if (object.has("text") && object.get("text").isJsonPrimitive()) {
|
||||||
|
text.append(object.get("text").getAsString());
|
||||||
|
}
|
||||||
|
if (object.has("extra")) {
|
||||||
|
text.append(plainName(object.get("extra")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonObject anonymous() {
|
||||||
|
JsonObject alias = new JsonObject();
|
||||||
|
alias.addProperty("text", "Anonymous");
|
||||||
|
alias.addProperty("obfuscated", true);
|
||||||
|
alias.add("hoverEvent", com.google.gson.JsonNull.INSTANCE);
|
||||||
|
alias.add("clickEvent", com.google.gson.JsonNull.INSTANCE);
|
||||||
|
alias.addProperty("insertion", "");
|
||||||
|
return alias;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
package games.dmg.spigotstealth;
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import org.bukkit.command.Command;
|
import org.bukkit.command.Command;
|
||||||
import org.bukkit.command.CommandExecutor;
|
import org.bukkit.command.CommandExecutor;
|
||||||
import org.bukkit.command.CommandSender;
|
import org.bukkit.command.CommandSender;
|
||||||
|
import org.bukkit.command.TabCompleter;
|
||||||
import org.bukkit.entity.Player;
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
/** Player-facing progress command. */
|
/** Player-facing progress command. */
|
||||||
public final class StealthCommand implements CommandExecutor {
|
public final class StealthCommand implements CommandExecutor, TabCompleter {
|
||||||
private final StealthStateManager stateManager;
|
private final StealthStateManager stateManager;
|
||||||
private final QualifyingInvisibilityService progression;
|
private final QualifyingInvisibilityService progression;
|
||||||
private final StealthSettings settings;
|
private final StealthSettings settings;
|
||||||
@@ -22,6 +24,15 @@ public final class StealthCommand implements CommandExecutor {
|
|||||||
this.settings = Objects.requireNonNull(settings, "settings");
|
this.settings = Objects.requireNonNull(settings, "settings");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> onTabComplete(
|
||||||
|
CommandSender sender, Command command, String label, String[] arguments) {
|
||||||
|
if (arguments.length == 1 && "progress".startsWith(arguments[0].toLowerCase(Locale.ROOT))) {
|
||||||
|
return List.of("progress");
|
||||||
|
}
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
|
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
|
||||||
if (!(sender instanceof Player player)) {
|
if (!(sender instanceof Player player)) {
|
||||||
|
|||||||
@@ -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,38 +49,53 @@ 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();
|
||||||
ioExecutor.shutdownNow();
|
} finally {
|
||||||
}
|
try {
|
||||||
|
if (!ioExecutor.awaitTermination(10L, TimeUnit.SECONDS)) { ioExecutor.shutdownNow(); }
|
||||||
} catch (InterruptedException exception) {
|
} catch (InterruptedException exception) {
|
||||||
ioExecutor.shutdownNow();
|
ioExecutor.shutdownNow();
|
||||||
Thread.currentThread().interrupt();
|
Thread.currentThread().interrupt();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static ExecutorService newIoExecutor() {
|
private static ExecutorService newIoExecutor() {
|
||||||
return Executors.newSingleThreadExecutor(runnable -> {
|
return Executors.newSingleThreadExecutor(runnable -> {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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|grant|reset|list>
|
usage: /stealthadmin <status <player|uuid>|grant <player|uuid>|reset <player|uuid> confirm|list [unlocked]|sleepcount <status|include|exclude>>
|
||||||
permission: spigotstealth.admin
|
permission: spigotstealth.admin
|
||||||
permissions:
|
permissions:
|
||||||
spigotstealth.use:
|
spigotstealth.use:
|
||||||
|
|||||||
@@ -0,0 +1,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,223 @@
|
|||||||
|
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(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,68 @@
|
|||||||
|
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));
|
||||||
|
new ProtocolLibTabListController(protocol).add(observer, target);
|
||||||
|
var packet = assertInstanceOf(ClientboundPlayerInfoUpdatePacket.class, sent.getFirst().getHandle());
|
||||||
|
assertTrue(packet.actions().contains(ClientboundPlayerInfoUpdatePacket.Action.ADD_PLAYER));
|
||||||
|
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());
|
||||||
|
assertEquals(packet.entries(), decoded.entries());
|
||||||
|
} 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,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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package games.dmg.spigotstealth;
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.mockito.ArgumentMatchers.contains;
|
import static org.mockito.ArgumentMatchers.contains;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
@@ -16,6 +17,80 @@ import org.bukkit.command.CommandSender;
|
|||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
class StealthAdminCommandTest {
|
class StealthAdminCommandTest {
|
||||||
|
@Test
|
||||||
|
void completesAdministrativeOperationsByPrefix() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
try (Fixture fixture = fixture(playerId)) {
|
||||||
|
assertEquals(
|
||||||
|
List.of("grant"),
|
||||||
|
fixture.command.onTabComplete(
|
||||||
|
sender(true), mock(Command.class), "stealthadmin", new String[] {"gr"}));
|
||||||
|
assertEquals(
|
||||||
|
List.of("status", "grant", "reset", "list", "sleepcount"),
|
||||||
|
fixture.command.onTabComplete(
|
||||||
|
sender(true), mock(Command.class), "stealthadmin", new String[] {""}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void completesContextualArgumentsAndSuppressesIrrelevantSuggestions() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
try (Fixture fixture = fixture(playerId)) {
|
||||||
|
Command command = mock(Command.class);
|
||||||
|
CommandSender administrator = sender(true);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
List.of("Alex"),
|
||||||
|
fixture.command.onTabComplete(
|
||||||
|
administrator, command, "stealthadmin", new String[] {"status", "al"}));
|
||||||
|
assertEquals(
|
||||||
|
List.of("confirm"),
|
||||||
|
fixture.command.onTabComplete(
|
||||||
|
administrator, command, "stealthadmin", new String[] {"reset", "Alex", "co"}));
|
||||||
|
assertEquals(
|
||||||
|
List.of("unlocked"),
|
||||||
|
fixture.command.onTabComplete(
|
||||||
|
administrator, command, "stealthadmin", new String[] {"list", "un"}));
|
||||||
|
assertEquals(
|
||||||
|
List.of("status", "include", "exclude"),
|
||||||
|
fixture.command.onTabComplete(
|
||||||
|
administrator, command, "stealthadmin", new String[] {"sleepcount", ""}));
|
||||||
|
assertEquals(
|
||||||
|
List.of(),
|
||||||
|
fixture.command.onTabComplete(
|
||||||
|
administrator, command, "stealthadmin", new String[] {"status", "Alex", ""}));
|
||||||
|
assertEquals(
|
||||||
|
List.of(),
|
||||||
|
fixture.command.onTabComplete(
|
||||||
|
sender(false), command, "stealthadmin", new String[] {""}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reportsAndChangesPersistentSleepCountPolicyWithImmediateRefreshAndAudit() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
try (Fixture fixture = fixture(playerId)) {
|
||||||
|
CommandSender sender = sender(true);
|
||||||
|
Command command = mock(Command.class);
|
||||||
|
|
||||||
|
fixture.command.onCommand(
|
||||||
|
sender, command, "stealthadmin", new String[] {"sleepcount", "status"});
|
||||||
|
verify(sender).sendMessage(contains("excluded"));
|
||||||
|
|
||||||
|
fixture.command.onCommand(
|
||||||
|
sender, command, "stealthadmin", new String[] {"sleepcount", "include"});
|
||||||
|
|
||||||
|
verify(sender, 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();
|
||||||
@@ -28,13 +103,35 @@ class StealthAdminCommandTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listsUnlockedPlayersAndClearlyReportsWhenThereAreNone() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
try (Fixture fixture = fixture(playerId)) {
|
||||||
|
Command command = mock(Command.class);
|
||||||
|
CommandSender emptySender = sender(true);
|
||||||
|
fixture.command.onCommand(
|
||||||
|
emptySender, command, "stealthadmin", new String[] {"list", "unlocked"});
|
||||||
|
verify(emptySender).sendMessage("No players have unlocked stealth.");
|
||||||
|
|
||||||
|
fixture.manager.update(state -> state.withPlayer(
|
||||||
|
state.player(playerId).withProgress(5000L, true))).join();
|
||||||
|
CommandSender populatedSender = sender(true);
|
||||||
|
fixture.command.onCommand(
|
||||||
|
populatedSender, command, "stealthadmin", new String[] {"list", "unlocked"});
|
||||||
|
verify(populatedSender).sendMessage("Stealth unlocked: Alex");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void grantPersistsThenReportsAndAuditsAction() {
|
void grantPersistsThenReportsAndAuditsAction() {
|
||||||
UUID playerId = UUID.randomUUID();
|
UUID playerId = UUID.randomUUID();
|
||||||
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())));
|
||||||
}
|
}
|
||||||
@@ -71,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();
|
||||||
@@ -58,6 +70,29 @@ class StealthAdministrationServiceTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listsAllUnlockedPlayersByNameOrUuidInCaseInsensitiveOrder() {
|
||||||
|
UUID namelessId = UUID.randomUUID();
|
||||||
|
UUID lockedId = UUID.randomUUID();
|
||||||
|
UUID zoeId = UUID.randomUUID();
|
||||||
|
UUID alexId = UUID.randomUUID();
|
||||||
|
try (StealthStateManager manager = manager()) {
|
||||||
|
manager.update(state -> state
|
||||||
|
.withPlayer(PlayerStealthState.empty(namelessId).withProgress(0L, true))
|
||||||
|
.withPlayer(PlayerStealthState.empty(lockedId).withLastKnownName("Locked"))
|
||||||
|
.withPlayer(PlayerStealthState.empty(zoeId).withLastKnownName("zoe").withProgress(1L, true))
|
||||||
|
.withPlayer(PlayerStealthState.empty(alexId).withLastKnownName("Alex").withProgress(2L, true)))
|
||||||
|
.join();
|
||||||
|
StealthAdministrationService administration = service(manager, List::of, ignored -> { });
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
List.of("Alex", namelessId.toString(), "zoe").stream()
|
||||||
|
.sorted(String.CASE_INSENSITIVE_ORDER)
|
||||||
|
.toList(),
|
||||||
|
administration.unlockedPlayerNames());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void listsOnlyCurrentlyOnlineConcealedPlayers() {
|
void listsOnlyCurrentlyOnlineConcealedPlayers() {
|
||||||
UUID concealedId = UUID.randomUUID();
|
UUID concealedId = 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\""));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import static org.mockito.Mockito.when;
|
|||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
@@ -16,6 +17,24 @@ import org.bukkit.entity.Player;
|
|||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
class StealthCommandTest {
|
class StealthCommandTest {
|
||||||
|
@Test
|
||||||
|
void completesProgressByPrefixWithoutSuggestingPlayersElsewhere() {
|
||||||
|
try (StealthStateManager manager = manager()) {
|
||||||
|
QualifyingInvisibilityService progression = new QualifyingInvisibilityService(
|
||||||
|
manager, Duration.ofHours(8), System::nanoTime, ignored -> { });
|
||||||
|
StealthCommand command = new StealthCommand(manager, progression, StealthSettings.from(Map.of()));
|
||||||
|
Command bukkitCommand = mock(Command.class);
|
||||||
|
Player player = player(UUID.randomUUID());
|
||||||
|
|
||||||
|
org.junit.jupiter.api.Assertions.assertEquals(
|
||||||
|
List.of("progress"),
|
||||||
|
command.onTabComplete(player, bukkitCommand, "stealth", new String[] {"pr"}));
|
||||||
|
org.junit.jupiter.api.Assertions.assertEquals(
|
||||||
|
List.of(),
|
||||||
|
command.onTabComplete(player, bukkitCommand, "stealth", new String[] {"progress", ""}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void reportsLiveProgressTargetAndRemainingTimeWithoutMutatingState() {
|
void reportsLiveProgressTargetAndRemainingTimeWithoutMutatingState() {
|
||||||
UUID playerId = UUID.randomUUID();
|
UUID playerId = UUID.randomUUID();
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user