Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83f0ea9a6e | ||
|
|
359b723897 | ||
|
|
3c6237946e | ||
|
|
68d4a854c3 | ||
|
|
a6c80e7ac9 | ||
|
|
960e8891a8 | ||
|
|
a949fd99c4 | ||
|
|
df44bba9c6 | ||
|
|
ff0523860c | ||
|
|
47d5060dac | ||
|
|
82415ed501 | ||
|
|
f011410b17 | ||
|
|
e802319f45 | ||
|
|
fd601961eb |
No files matched your search
@@ -35,6 +35,6 @@ jobs:
|
||||
- name: Upload development artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: spigot-heights-${{ github.sha }}
|
||||
name: purpur-heights-${{ github.sha }}
|
||||
path: build/libs/*.jar
|
||||
if-no-files-found: error
|
||||
@@ -86,8 +86,8 @@ jobs:
|
||||
if: steps.release.outputs.created == 'true'
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: spigot-heights-${{ steps.release.outputs.version }}
|
||||
path: build/libs/spigot-heights-${{ steps.release.outputs.version }}.jar
|
||||
name: purpur-heights-${{ steps.release.outputs.version }}
|
||||
path: build/libs/purpur-heights-${{ steps.release.outputs.version }}.jar
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Create Gitea release and upload plugin
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
PREVIOUS_TAG: ${{ steps.previous_tag.outputs.value }}
|
||||
run: |
|
||||
api_url="${GITEA_SERVER_URL}/api/v1"
|
||||
jar="build/libs/spigot-heights-${VERSION}.jar"
|
||||
jar="build/libs/purpur-heights-${VERSION}.jar"
|
||||
export RELEASE_BODY
|
||||
if [ -n "$PREVIOUS_TAG" ]; then
|
||||
RELEASE_BODY=$(git log --pretty='format:- %s (%h)' "${PREVIOUS_TAG}..HEAD")
|
||||
@@ -138,4 +138,4 @@ jobs:
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@${jar}" \
|
||||
"${api_url}/repos/${REPOSITORY}/releases/${release_id}/assets?name=spigot-heights-${VERSION}.jar"
|
||||
"${api_url}/repos/${REPOSITORY}/releases/${release_id}/assets?name=purpur-heights-${VERSION}.jar"
|
||||
@@ -1,18 +1,9 @@
|
||||
# Spigot Heights Agent Guide
|
||||
# purpur-heights agent entrypoint
|
||||
|
||||
## Canonical design
|
||||
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).
|
||||
|
||||
- `design/` is the canonical OKF v0.1 knowledge bundle.
|
||||
- Read relevant user stories before implementation and keep acceptance criteria and `design/log.md` synchronized with verified behavior.
|
||||
Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/purpur-heights/index.md`, `engineering.md` in that project section, and relevant `../somc-okf/user-stories/purpur-heights/` stories. Also follow the parent workspace `AGENTS.md` when present.
|
||||
|
||||
## Engineering
|
||||
For standalone checkouts, start at the [project page](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-heights/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.
|
||||
|
||||
- Target Java 25 and Purpur `26.2.build.2618-stable`.
|
||||
- Use Gradle Kotlin DSL and JUnit 5.
|
||||
- Prefer small, server-independent domain classes with Bukkit adapters at the boundary.
|
||||
- Develop test-first where practical and verify with `./gradlew clean check jar`.
|
||||
- Treat custom item identity as persistent metadata; never trust display names.
|
||||
- Validate all configuration before enabling gameplay behavior.
|
||||
- Persist player data by UUID and use atomic replacement where supported.
|
||||
- Keep event handlers on the server thread and avoid unnecessary work on player movement.
|
||||
- Use conventional commits in the form `type(scope): description`.
|
||||
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.
|
||||
@@ -1,12 +1,14 @@
|
||||
# Spigot Heights
|
||||
# Purpur Heights
|
||||
|
||||
A Purpur 26.2 plugin adding craftable player-scaling potions and dispenser launch tubes for tiny players.
|
||||
A Purpur 26.3 plugin adding craftable player-scaling potions and dispenser launch tubes for tiny players.
|
||||
|
||||
Approved behavior is specified in the [OKF design bundle](design/index.md).
|
||||
Approved behavior is specified in the [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-heights/index.md).
|
||||
|
||||
New releases use the `purpur-heights` repository and `purpur-heights-<version>.jar` distribution name. Runtime `SpigotHeights`, its existing data folder, Java entrypoint/packages, permissions and stature-potion/recipe keys remain unchanged. Replace the old JAR instead of installing both distributions. Historical tags and `spigot-heights-*` assets are retained; the previous v1.5.0 download was checked for identical bytes after the rename.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Purpur 26.2 build 2618
|
||||
- Purpur 26.3 build 2651
|
||||
- Java 25 or newer
|
||||
|
||||
## Potions
|
||||
@@ -33,6 +35,70 @@ Gold Ingot | Rabbit's Foot | Gold Ingot
|
||||
|
||||
Use the Growth recipe with a Fermented Spider Eye instead of the Rabbit's Foot. Growth and Diminution adjust scale by one configured step and clamp at the limits.
|
||||
|
||||
### Potion of Restoration
|
||||
|
||||
```text
|
||||
Gold Ingot | Amethyst Shard | Gold Ingot
|
||||
Amethyst Shard | Potion of Shifting Stature | Amethyst Shard
|
||||
Gold Ingot | Sugar | Gold Ingot
|
||||
```
|
||||
|
||||
Produces one Potion of Restoration. Drinking it restores scale to exactly `1.0` (normal size), even outside configured limits. This size is saved across reconnects, respawns, and server restarts. Other permanent saved scales are still clamped to the configured range.
|
||||
|
||||
### Temporary splash and lingering variants
|
||||
|
||||
Brew any of the four authenticated stature potions with **gunpowder** to make its splash variant; brew that with **dragon's breath** to make its lingering variant. Brewing preserves the stature kind, not arbitrary cosmetic renames/lore. Ordinary potions and cancelled brewing/application events are not overridden. Renaming an ordinary item cannot turn it into a stature potion.
|
||||
|
||||
Each splash/cloud applies once per player, without recipient opt-in. The first application saves the player's original base scale. Further applications operate on the temporary scale and reset expiry to **five minutes after the latest application**, including a valid application already at a stature bound. Growth/shrinking respect the current limits; throwable Restoration sets temporary scale to exactly `1.0`.
|
||||
|
||||
Example: `1.0 → 0.8`, then `0.8 → 0.6` four minutes later, returns to `1.0` five minutes after the second application. Expiry restores the **exact original baseline**, even if administrator limits changed meanwhile. Offline time counts. Baseline, expiry, and application receipts are persisted; a pending restoration survives disconnect during persistence and is acknowledged only after live application. After that sequence is cleared, ordinary later joins/respawns use the usual limit policy.
|
||||
|
||||
Drinking a permanent stature potion ends the temporary sequence, using the current temporary scale (or restored baseline if already expired). Administrator height changes also end the sequence. File writes are serialized off the server tick thread; failed saves do not apply an unsaved change. Disable waits for the active writer and drains its result before returning. Do not forcibly kill the server while it is saving.
|
||||
|
||||
Heights tracks only its loaded clouds and checks them every five ticks, dispatching `AreaEffectCloudApplyEvent` and honoring cancellation and recipient filtering. This supports effectless stature clouds without adding a vanilla status effect. Online expiry is checked every second. Durable receipts survive cloud unload and are retired on observed permanent entity removal; abrupt process termination can leave harmless orphan receipts.
|
||||
|
||||
Purpur 26.3's data-driven brewing engine natively converts authenticated custom potions without a vanilla base potion type. Heights supplies representative recipe choices so the server's brewing input/reagent indexes include those recipes, then validates results at the `BrewEvent` transaction boundary. The build-2618 no-op correction is not needed for normal build-2651 conversion; the defensive listener preserves cancellation and rejects conflicting, missing or identity-stripped results. Valid native results, compatible result modifiers, ingredient/fuel accounting, and dragon's-breath bottle return remain server-owned. Cancellation remains authoritative. As with other Bukkit listeners, this cannot control plugins that directly mutate inventories or run conflicting handlers outside the normal event-priority contract.
|
||||
|
||||
**Verification:** `nativeBrewingTest` executes real Purpur 26.3 build 2651 brewing cycles with native data-driven recipes, inventories, metadata and Paper event dispatch. It proves all four base-less potion kinds convert without the old correction, then covers splash/lingering conversion, representative recipe indexing, ingredient safety and cancellation, mixed/ordinary potions, immediate second-stage brewing, bottle return, and output compatibility with the stature adapters. World and entity boundaries are test doubles; these checks do not claim native projectile/cloud spawning, client appearance, or live logout/restart gameplay. Those remain supplementary live checks.
|
||||
|
||||
## Ride larger players (US-010)
|
||||
|
||||
Right-click a larger player with your **main hand** to mount immediately. Sneaking and consent are
|
||||
not required. Only the smaller rider may initiate: larger players cannot force pickup of smaller
|
||||
players. Either participant can end the ride, without administrator permission or rider approval:
|
||||
|
||||
```text
|
||||
/heights carry stop
|
||||
```
|
||||
|
||||
There are no consent requests, expiry timers, or accept/decline commands. Settings and player-height
|
||||
commands still require `spigotheights.admin`; console cannot carry players. Ride tracking is not
|
||||
persisted. There is no remount cooldown or opt-out: a smaller player can mount again after ejection.
|
||||
|
||||
Both players must be available and within **4 blocks**, in the same world. Their **effective** scale
|
||||
difference, including attribute modifiers, must meet `carry.minimum-scale-gap` (default **0.1**,
|
||||
independent of potion adjustment sizes). Zero still requires a strictly larger carrier. Neither
|
||||
may already have a vehicle or passengers. Heights permits only one rider per carrier and blocks
|
||||
additional/nested mounts involving an active pair. Ineligible player clicks remain uncancelled for
|
||||
other plugins; only eligible carry attempts and their same-tick duplicates are consumed.
|
||||
|
||||
The rider can **sneak-dismount normally**; either participant can use the stop command. Heights calls only the rider's native `leaveVehicle()` when commanded, leaving placement, falling, damage, and environmental hazards to Minecraft. There is no safe-ground requirement or search, rescue teleport, saved-position/spawn fallback, motion/fall-distance reset, invulnerability, or recovery system. Getting off above a drop is a gameplay risk.
|
||||
|
||||
Eligibility is rechecked every five ticks, including temporary potion application/expiry, administrator edits and external scale modifiers. Death, disconnect, plugin disable and loss of eligibility attempt native cleanup. For observed teleport events, Heights detaches the pair and lets the original teleport continue, without replaying or redirecting it. Native carrier teleports that refuse passengers before emitting an event may still require `/heights carry stop` first.
|
||||
|
||||
Server/plugin cancellation remains authoritative: a refused dismount leaves accurate tracking and is reported, not bypassed or called successful. A cancelled voluntary stop can be retried by either player; automatic eligibility cleanup continues checking and reports refusal once per ride rather than spamming. Lifecycle/disable refusals are logged. Native dismount events are reconciled after dispatch so a later cancellation is honored. An unrelated vehicle is never ejected during stale-pair cleanup.
|
||||
|
||||
**Verification:** API/domain tests preserve native cleanup and reject rescue/motion-reset calls.
|
||||
`NativeCarryRoutingTest` registers the production listener through Purpur 26.3 build 2651's actual Paper
|
||||
plugin manager and dispatches both `PlayerInteractEntityEvent` and `PlayerInteractAtEntityEvent`.
|
||||
Same-tick duplicate rider/target interactions are coalesced, including refused mounts; a fresh
|
||||
interaction next tick can retry without a gameplay cooldown. Registered mount/dismount cancellation
|
||||
and nonadmin stop routing are also covered. Server/player boundaries are doubled; this is not
|
||||
client-packet generation or live-server evidence. Supplementary Purpur checks remain for empty/occupied
|
||||
hands, scaled rider positioning and movement, native placement/falling, cancellation interoperability,
|
||||
both-role death/disconnect, portal/cross-world teleports, disable and potion/admin/expiry eligibility
|
||||
changes. No throwing, added launch force, player items, storage, opt-out or rescue behavior is included.
|
||||
|
||||
## Tiny-player launchers
|
||||
|
||||
A player below the configured scale threshold can walk onto a hopper whose output points into a dispenser. If the block in front of that dispenser is passable, the player is moved there and launched in the direction the dispenser faces. The launcher does not require redstone.
|
||||
@@ -46,14 +112,47 @@ height:
|
||||
minimum: 0.4
|
||||
maximum: 2.0
|
||||
adjustment-step: 0.1
|
||||
carry:
|
||||
minimum-scale-gap: 0.1
|
||||
launcher:
|
||||
maximum-player-scale-exclusive: 0.5
|
||||
speed: 1.5
|
||||
cooldown-ticks: 20
|
||||
```
|
||||
|
||||
`carry.minimum-scale-gap` is independent of potion `height.adjustment-step` and defaults to `0.1`,
|
||||
including existing files without the key. It must be a finite, nonnegative number; invalid values
|
||||
prevent plugin enable. Zero permits any strictly positive scale difference, **not equal-size riding**.
|
||||
Edit this setting in the file and restart; min/max commands preserve it.
|
||||
|
||||
Player scales are stored by UUID in `plugins/SpigotHeights/state.yml`.
|
||||
|
||||
## Commands
|
||||
|
||||
Operators (or senders granted `spigotheights.admin`) can use:
|
||||
|
||||
```text
|
||||
/heights settings
|
||||
/heights set min 0.2
|
||||
/heights set max 3.0
|
||||
/heights player Steve
|
||||
/heights player Steve 0.5
|
||||
```
|
||||
|
||||
Console is supported. Tab completion suggests subcommands, bound names, and valid example values; other valid numbers may also be entered.
|
||||
|
||||
Changes are saved to `plugins/SpigotHeights/config.yml` and take effect without a restart. Minimum must be at least `0.0625`, maximum at most `16.0`, minimum cannot exceed maximum, and the launcher threshold must remain within the range. Invalid changes or save failures leave active settings unchanged.
|
||||
|
||||
Players are not immediately resized. Subsequent potion use, joins, and respawns use the new limits; Restoration still returns and preserves exact scale `1.0`. Processing an expired temporary sequence is another exception: its exact baseline is restored before ordinary later joins/respawns follow the new limits. The settings commands only change min/max; edit other settings in the configuration file and restart the server.
|
||||
|
||||
### Player height
|
||||
|
||||
`/heights player <name>` reports an online player's scale (`1.0` = normal size). If another plugin adds scale modifiers, both the base scale and effective scale are shown.
|
||||
|
||||
`/heights player <name> <scale>` sets that player's base scale by UUID after an asynchronous durable save and ends any temporary sequence. Values must be finite and within current min/max limits, except `1.0` is always allowed. Saved scales follow the usual limits on reconnect and respawn if configuration changes later. A failed save leaves the player's height unchanged.
|
||||
|
||||
Player names must match an online player exactly (case-insensitive); partial names and offline players are rejected. Tab completion suggests online names and valid example sizes. Operators, console, and senders with `spigotheights.admin` may view or set heights, including their own.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
@@ -62,6 +161,8 @@ Player scales are stored by UUID in `plugins/SpigotHeights/state.yml`.
|
||||
|
||||
The plugin JAR is written to `build/libs/`.
|
||||
|
||||
`check` includes `nativeBrewingTest`. It downloads the exact Purpur 26.3 build 2651 launcher (SHA-256 `0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598`) and prepares its runtime dependencies with Paperclip's **patch-only** mode under `build/brewing-runtime/`. It does not start a server, open ports, create a world or accept the EULA. Initial/clean builds need network access; the native test JVM permits up to 1 GiB heap. No runtime implementation classes or test dependencies are packaged in the plugin. Run `./gradlew test` for the fast API/domain suite alone, or `./gradlew nativeBrewingTest` for native brewing and registered carry-event routing tests. Full verification remains `./gradlew clean check jar`.
|
||||
|
||||
## Releases
|
||||
|
||||
Gitea Actions checks pushes and pull requests and stores a development JAR. Pull requests validate conventional commits. Main-branch conventional commits drive semantic releases when the repository defines a `RELEASE_TOKEN` with contents-write permission.
|
||||
+64
-2
@@ -1,3 +1,6 @@
|
||||
import java.net.URI
|
||||
import java.security.MessageDigest
|
||||
|
||||
plugins {
|
||||
java
|
||||
}
|
||||
@@ -21,8 +24,8 @@ tasks.withType<JavaCompile>().configureEach {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
|
||||
testImplementation("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
|
||||
compileOnly("org.purpurmc.purpur:purpur-api:26.3.build.2651-stable")
|
||||
testImplementation("org.purpurmc.purpur:purpur-api:26.3.build.2651-stable")
|
||||
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
testImplementation("org.yaml:snakeyaml:2.4")
|
||||
@@ -31,8 +34,67 @@ dependencies {
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
dependsOn(tasks.jar)
|
||||
systemProperty("distribution.jar", tasks.jar.get().archiveFile.get().asFile.absolutePath)
|
||||
systemProperty("distribution.version", project.version.toString())
|
||||
}
|
||||
|
||||
// Exercise the declared runtime's native brewing engine, not a recipe-registry double.
|
||||
// Paperclip only extracts/patches dependencies: no server, world, port or EULA acceptance.
|
||||
val brewingRuntime = layout.buildDirectory.dir("brewing-runtime")
|
||||
val downloadBrewingRuntime = tasks.register("downloadBrewingRuntime") {
|
||||
val launcher = brewingRuntime.map { it.file("purpur-26.3-2651.jar") }
|
||||
outputs.file(launcher)
|
||||
doLast {
|
||||
val file = launcher.get().asFile
|
||||
file.parentFile.mkdirs()
|
||||
val connection = URI("https://api.purpurmc.org/v2/purpur/26.3/2651/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 == "0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598") {
|
||||
"Purpur 2651 checksum mismatch"
|
||||
}
|
||||
file.writeBytes(bytes)
|
||||
}
|
||||
}
|
||||
val prepareBrewingRuntime = tasks.register<JavaExec>("prepareBrewingRuntime") {
|
||||
dependsOn(downloadBrewingRuntime)
|
||||
javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(25) }
|
||||
classpath = files(brewingRuntime.map { it.file("purpur-26.3-2651.jar") })
|
||||
mainClass = "io.papermc.paperclip.Main"
|
||||
jvmArgs("-Dpaperclip.patchonly=true")
|
||||
workingDir(brewingRuntime)
|
||||
outputs.dir(brewingRuntime.map { it.dir("versions") })
|
||||
outputs.dir(brewingRuntime.map { it.dir("libraries") })
|
||||
}
|
||||
val nativeTest = sourceSets.create("nativeTest")
|
||||
dependencies {
|
||||
// Use the runtime's bundled API/dependencies, not the API POM's older transitive versions.
|
||||
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.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(brewingRuntime) {
|
||||
include("versions/**/*.jar", "libraries/**/*.jar")
|
||||
}).builtBy(prepareBrewingRuntime)
|
||||
nativeTest.compileClasspath += sourceSets.main.get().output + nativeRuntimeJars
|
||||
nativeTest.runtimeClasspath += sourceSets.main.get().output + nativeRuntimeJars
|
||||
val nativeBrewingTest = tasks.register<Test>("nativeBrewingTest") {
|
||||
description = "Runs native Purpur brewing regressions without starting a server"
|
||||
testClassesDirs = nativeTest.output.classesDirs
|
||||
classpath = nativeTest.runtimeClasspath
|
||||
useJUnitPlatform()
|
||||
maxHeapSize = "1G"
|
||||
workingDir(brewingRuntime)
|
||||
}
|
||||
tasks.check { dependsOn(nativeBrewingTest) }
|
||||
|
||||
val pluginVersion = version
|
||||
tasks.processResources {
|
||||
filesMatching("plugin.yml") {
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
---
|
||||
type: Index
|
||||
title: Spigot Heights Design
|
||||
description: Entry point for the Spigot Heights OKF knowledge bundle.
|
||||
okf_version: "0.1"
|
||||
---
|
||||
|
||||
# Spigot Heights Design
|
||||
|
||||
This bundle documents craftable player-scaling potions, configurable stature limits, tiny-player dispenser launchers, persistence, and delivery requirements.
|
||||
|
||||
## Explore
|
||||
|
||||
- [User stories](user-stories/index.md)
|
||||
- [Design log](log.md)
|
||||
@@ -1,28 +0,0 @@
|
||||
---
|
||||
type: Log
|
||||
title: Spigot Heights Design Log
|
||||
description: Chronological record of material decisions affecting Spigot Heights.
|
||||
---
|
||||
|
||||
# Spigot Heights Design Log
|
||||
|
||||
## 2026-09-04 — Initial design approved
|
||||
|
||||
- Player scale defaults to a configurable range of `0.4` through `2.0`.
|
||||
- A moderate recipe creates a random stature potion; more expensive upgrades create targeted growth and diminution potions.
|
||||
- Random stature is selected uniformly in configurable `0.1` increments.
|
||||
- Players strictly below scale `0.5` can be launched through a dispenser fed by the hopper beneath them.
|
||||
- Launcher speed defaults to 1.5 blocks per tick with a 20-tick cooldown and safe-exit checks.
|
||||
- The project follows the neighboring Spigot Base Java 25, Purpur, Gradle, OKF, CI, and release conventions.
|
||||
|
||||
## 2026-09-04 — Implementation started
|
||||
|
||||
- Approved implementation began with user stories, tests, and the Gradle/Purpur foundation.
|
||||
|
||||
## 2026-09-04 — Initial release scope completed
|
||||
|
||||
- Implemented authenticated random, growth, and diminution potions with approved recipes and durable UUID-keyed player scale.
|
||||
- Added validated stature and launcher configuration with safe clamping to Minecraft's scale range.
|
||||
- Implemented cooldown-protected hopper-to-dispenser launch tubes for tiny players with six-axis launch support and empty-exit checks.
|
||||
- Added the Java 25 Purpur build, automated tests, Gitea CI and semantic-release workflows, README, and project-specific agent guidance.
|
||||
- Verified the implementation with `./gradlew clean check jar` and created the public `dmg/spigot-heights` Gitea repository.
|
||||
@@ -1,13 +0,0 @@
|
||||
---
|
||||
type: Index
|
||||
title: Spigot Heights User Stories
|
||||
description: Catalog of user stories for the Spigot Heights plugin.
|
||||
---
|
||||
|
||||
# Spigot Heights User Stories
|
||||
|
||||
1. [US-001: Drink a Potion of Shifting Stature](us-001-drink-shifting-stature-potion.md)
|
||||
2. [US-002: Make precise stature adjustments](us-002-adjust-stature.md)
|
||||
3. [US-003: Launch tiny players through dispensers](us-003-launch-tiny-players.md)
|
||||
4. [US-004: Configure and persist stature behavior](us-004-configure-and-persist.md)
|
||||
5. [US-005: Build and release the plugin](us-005-build-and-release.md)
|
||||
@@ -1,25 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-001: Drink a Potion of Shifting Stature"
|
||||
description: Let players craft and drink a potion that gives them a random configured scale.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-001: Drink a Potion of Shifting Stature
|
||||
|
||||
As a **player**, I want to drink a craftable potion that changes my stature unpredictably so that player size becomes a fun survival mechanic.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] The distinct potion is authenticated with persistent item metadata rather than its display name alone.
|
||||
- [x] Its shaped recipe is `ACA/AWA/ACA`, where `A` is Amethyst Shard, `C` is Chorus Fruit, and `W` is an Awkward Potion.
|
||||
- [x] Drinking it consumes one potion, leaves normal bottle handling intact, and selects a scale from the configured inclusive range.
|
||||
- [x] Outcomes are uniformly selected in configured adjustment-step increments, including both endpoints when aligned.
|
||||
- [x] The resulting Minecraft scale attribute is applied and reported to the player.
|
||||
- [x] The resulting scale persists across logout, restart, world change, and death.
|
||||
- [x] Automated tests cover bounds, endpoint reachability, and recipe identity.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-002: Make precise stature adjustments](us-002-adjust-stature.md)
|
||||
- [US-004: Configure and persist stature behavior](us-004-configure-and-persist.md)
|
||||
@@ -1,24 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-002: Make precise stature adjustments"
|
||||
description: Let players craft upgraded potions that increase or decrease scale by one configured step.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-002: Make precise stature adjustments
|
||||
|
||||
As a **player**, I want more expensive growth and diminution potions so that I can adjust my stature predictably.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] The Potion of Growth uses `GAG/ASA/GRG`, where `G` is Gold Ingot, `A` is Amethyst Shard, `S` is an authenticated Potion of Shifting Stature, and `R` is Rabbit's Foot.
|
||||
- [x] The Potion of Diminution uses `GAG/ASA/GFG`, where `F` is Fermented Spider Eye.
|
||||
- [x] Each resulting potion has distinct persistent metadata.
|
||||
- [x] Growth adds one configured adjustment step and clamps at the maximum.
|
||||
- [x] Diminution subtracts one configured adjustment step and clamps at the minimum.
|
||||
- [x] Adjusted scales are reported and persisted under the same rules as random stature.
|
||||
- [x] Automated tests cover adjustment, clamping, and potion identity.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-001: Drink a Potion of Shifting Stature](us-001-drink-shifting-stature-potion.md)
|
||||
@@ -1,25 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-003: Launch tiny players through dispensers"
|
||||
description: Launch sufficiently small players from a dispenser connected to the hopper beneath them.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-003: Launch tiny players through dispensers
|
||||
|
||||
As a **tiny player**, I want connected hoppers and dispensers to act as launch tubes so that my stature enables playful transport systems.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] A player strictly below the configured threshold triggers when walking onto a hopper whose output points into a dispenser.
|
||||
- [x] No redstone signal is required.
|
||||
- [x] The player moves to a safe centered position immediately in front of the dispenser and receives velocity in its facing direction.
|
||||
- [x] Velocity magnitude and cooldown are configurable and default to 1.5 blocks per tick and 20 ticks.
|
||||
- [x] Obstructed or unsafe exits abort without moving the player.
|
||||
- [x] Players at or above the threshold are not launched.
|
||||
- [x] Cooldown prevents immediate repeated or cyclic launching.
|
||||
- [x] Automated tests cover threshold boundaries, orientations, cooldown, and obstruction policy.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-004: Configure and persist stature behavior](us-004-configure-and-persist.md)
|
||||
@@ -1,25 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-004: Configure and persist stature behavior"
|
||||
description: Give operators validated settings and durable UUID-keyed player scales.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-004: Configure and persist stature behavior
|
||||
|
||||
As a **server operator**, I want validated stature and launcher settings with durable state so that behavior remains safe and predictable.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Defaults are minimum `0.4`, maximum `2.0`, adjustment step `0.1`, launcher threshold `0.5`, speed `1.5`, and cooldown `20` ticks.
|
||||
- [x] Configuration requires finite positive values, minimum no greater than maximum, and a launcher threshold within the supported scale range.
|
||||
- [x] Invalid startup configuration disables the plugin with a clear error.
|
||||
- [x] Player scales are stored by UUID using atomic file replacement where supported.
|
||||
- [x] Updating known state preserves unknown forward-compatible YAML fields.
|
||||
- [x] Missing state defaults safely to scale `1.0` clamped to the configured range.
|
||||
- [x] Saved out-of-range state is clamped before it is applied.
|
||||
- [x] Configuration and state behavior have automated tests.
|
||||
|
||||
## Related
|
||||
|
||||
- [User-story catalog](index.md)
|
||||
@@ -1,24 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-005: Build and release the plugin"
|
||||
description: Give maintainers repeatable Purpur builds, automated verification, and versioned Gitea releases.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-005: Build and release the plugin
|
||||
|
||||
As a **plugin maintainer**, I want automated builds and releases modeled on Spigot Base so that tested artifacts can be distributed consistently.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Gradle compiles against Purpur API `26.2.build.2618-stable` using Java 25.
|
||||
- [x] Compiler lint warnings fail the build and JUnit 5 tests run during `check`.
|
||||
- [x] Gitea Actions verifies pushes and pull requests and stores a development JAR.
|
||||
- [x] Pull requests validate conventional commits.
|
||||
- [x] Main-branch conventional commits drive semantic releases and attach versioned JARs to Gitea releases.
|
||||
- [x] The README documents requirements, recipes, configuration, building, and releases.
|
||||
- [x] `./gradlew clean check jar` succeeds.
|
||||
|
||||
## Related
|
||||
|
||||
- [User-story catalog](index.md)
|
||||
+1
-1
@@ -2,4 +2,4 @@ plugins {
|
||||
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
|
||||
}
|
||||
|
||||
rootProject.name = "spigot-heights"
|
||||
rootProject.name = "purpur-heights"
|
||||
@@ -0,0 +1,183 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import java.util.UUID;
|
||||
import java.util.function.*;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.GameMode;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.attribute.Attribute;
|
||||
import org.bukkit.attribute.AttributeInstance;
|
||||
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.PlayerInteractAtEntityEvent;
|
||||
import org.bukkit.event.player.PlayerInteractEntityEvent;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
|
||||
/** Bukkit boundary for rider-initiated carrying. All methods run on the server thread. */
|
||||
public final class BukkitCarry implements Listener, CarryService.Port, AutoCloseable {
|
||||
private final Function<UUID, Player> players;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Logger logger;
|
||||
private final ToDoubleFunction<Player> scales;
|
||||
private final CarryService service;
|
||||
private final java.util.Set<UUID> dismounting = new java.util.HashSet<>();
|
||||
private final java.util.Set<UUID> queuedStops = new java.util.HashSet<>();
|
||||
private record Interaction(UUID rider, UUID carrier) {}
|
||||
private final java.util.Set<Interaction> interactions = new java.util.HashSet<>();
|
||||
private UUID mountingCarrier, mountingRider;
|
||||
private boolean closed;
|
||||
|
||||
public BukkitCarry(Server server, Supplier<HeightSettings> settings, Consumer<Runnable> nextTick, Logger logger) {
|
||||
this(server::getPlayer, settings, nextTick, logger, player -> {
|
||||
AttributeInstance attribute = player.getAttribute(Attribute.SCALE);
|
||||
return attribute == null ? Double.NaN : attribute.getValue();
|
||||
});
|
||||
}
|
||||
BukkitCarry(Function<UUID, Player> players, Supplier<HeightSettings> settings,
|
||||
Consumer<Runnable> nextTick, Logger logger, ToDoubleFunction<Player> scales) {
|
||||
this.players = players; this.nextTick = nextTick; this.logger = logger;
|
||||
this.scales = scales;
|
||||
service = new CarryService(this, () -> settings.get().carryMinimumScaleGap());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void interact(PlayerInteractEntityEvent event) {
|
||||
if (closed || event.isCancelled() || event.getHand() != EquipmentSlot.HAND
|
||||
|| !(event.getRightClicked() instanceof Player target)) { return; }
|
||||
Player sender = event.getPlayer();
|
||||
Interaction interaction = new Interaction(sender.getUniqueId(), target.getUniqueId());
|
||||
// Consume duplicates of a handled attempt, even if that attempt was refused or mounted.
|
||||
if (interactions.contains(interaction)) { event.setCancelled(true); return; }
|
||||
// Do not suppress other plugins' interaction handlers when this is not a valid carry attempt.
|
||||
if (!service.canAttemptRide(interaction.rider(), interaction.carrier())) { return; }
|
||||
event.setCancelled(true);
|
||||
// A client interaction can dispatch AtEntity and then Entity in the same tick.
|
||||
// Coalesce that pair even on native refusal; this is not a remount cooldown.
|
||||
interactions.add(interaction);
|
||||
nextTick.accept(() -> interactions.remove(interaction));
|
||||
sender.sendMessage(service.ride(interaction.rider(), interaction.carrier()));
|
||||
}
|
||||
// AtEntity has its own HandlerList; registering only the superclass method misses it.
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void interactAt(PlayerInteractAtEntityEvent event) { interact(event); }
|
||||
public void command(Player player, String action) {
|
||||
UUID id = player.getUniqueId();
|
||||
player.sendMessage(switch (action) {
|
||||
case "stop" -> service.stop(id);
|
||||
default -> "Usage: /heights carry stop";
|
||||
});
|
||||
}
|
||||
@Override public CarryService.State state(UUID id) {
|
||||
Player player = players.apply(id);
|
||||
if (player == null) { return null; }
|
||||
return new CarryService.State(scales.applyAsDouble(player),
|
||||
player.isOnline() && player.isValid() && !player.isDead() && !player.isSleeping()
|
||||
&& player.getGameMode() != GameMode.SPECTATOR,
|
||||
!player.isInsideVehicle() && player.getPassengers().isEmpty());
|
||||
}
|
||||
@Override public boolean nearby(UUID first, UUID second) {
|
||||
Player a = players.apply(first), b = players.apply(second);
|
||||
return a != null && b != null && a.getWorld().getUID().equals(b.getWorld().getUID())
|
||||
&& a.getLocation().toVector().distanceSquared(b.getLocation().toVector()) <= 16;
|
||||
}
|
||||
@Override public boolean mount(UUID carrier, UUID rider) {
|
||||
Player a = players.apply(carrier), b = players.apply(rider);
|
||||
if (a == null || b == null) { return false; }
|
||||
mountingCarrier = carrier; mountingRider = rider;
|
||||
try { return a.addPassenger(b) && attached(carrier, rider); }
|
||||
finally { mountingCarrier = null; mountingRider = null; }
|
||||
}
|
||||
@Override public boolean linked(UUID carrier, UUID rider) {
|
||||
Player b = players.apply(rider);
|
||||
return b != null && b.getVehicle() != null && b.getVehicle().getUniqueId().equals(carrier);
|
||||
}
|
||||
@Override public boolean attached(UUID carrier, UUID rider) {
|
||||
Player a = players.apply(carrier), b = players.apply(rider);
|
||||
return a != null && b != null && b.getVehicle() != null && b.getVehicle().getUniqueId().equals(carrier)
|
||||
&& !a.isInsideVehicle() && b.getPassengers().isEmpty() && a.getPassengers().size() == 1
|
||||
&& a.getPassengers().getFirst().getUniqueId().equals(rider)
|
||||
&& a.getWorld().getUID().equals(b.getWorld().getUID());
|
||||
}
|
||||
@Override public boolean dismount(UUID carrier, UUID rider) {
|
||||
Player b = players.apply(rider);
|
||||
if (b == null) {
|
||||
Player a = players.apply(carrier);
|
||||
if (a != null) {
|
||||
b = a.getPassengers().stream().filter(entity -> entity.getUniqueId().equals(rider))
|
||||
.filter(Player.class::isInstance).map(Player.class::cast).findFirst().orElse(null);
|
||||
}
|
||||
}
|
||||
if (b == null || b.getVehicle() == null || !b.getVehicle().getUniqueId().equals(carrier)) {
|
||||
return true; // Already detached; never disturb an unrelated vehicle.
|
||||
}
|
||||
if (!dismounting.add(rider)) { return false; }
|
||||
try {
|
||||
b.leaveVehicle();
|
||||
} finally {
|
||||
dismounting.remove(rider);
|
||||
}
|
||||
// Native API return values do not reliably reflect cancellation. Inspect the relationship.
|
||||
return b.getVehicle() == null || !b.getVehicle().getUniqueId().equals(carrier);
|
||||
}
|
||||
@Override public void message(UUID id, String message) {
|
||||
Player player = players.apply(id);
|
||||
if (player != null && player.isOnline()) { player.sendMessage(message); }
|
||||
}
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void mountEvent(org.bukkit.event.entity.EntityMountEvent event) {
|
||||
UUID rider = event.getEntity().getUniqueId(), carrier = event.getMount().getUniqueId();
|
||||
if (rider.equals(mountingRider) && carrier.equals(mountingCarrier)) { return; }
|
||||
if (service.involved(rider) || service.involved(carrier)
|
||||
|| rider.equals(mountingCarrier) || carrier.equals(mountingRider)
|
||||
|| rider.equals(mountingRider) || carrier.equals(mountingCarrier)) { event.setCancelled(true); }
|
||||
}
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void dismountEvent(org.bukkit.event.entity.EntityDismountEvent event) {
|
||||
UUID id = event.getEntity().getUniqueId();
|
||||
if (event.isCancelled() || dismounting.contains(id) || !service.involved(id)
|
||||
|| closed || !queuedStops.add(id)) { return; }
|
||||
// The event precedes native detachment. Reconcile actual attachment next tick;
|
||||
// never cancel sneak-dismount or force a second dismount if another listener cancels.
|
||||
nextTick.accept(() -> {
|
||||
queuedStops.remove(id);
|
||||
if (!closed && !event.isCancelled()) { service.tick(); }
|
||||
});
|
||||
}
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void teleportEvent(org.bukkit.event.player.PlayerTeleportEvent event) {
|
||||
if (event.isCancelled()) { return; }
|
||||
Player player = event.getPlayer();
|
||||
UUID id = player.getUniqueId();
|
||||
// No rescue/replay teleport: detach through the native API, then allow the original.
|
||||
// If another plugin refuses detachment, do not move an attached pair behind its back.
|
||||
if (!service.cleanup(id)) {
|
||||
event.setCancelled(true);
|
||||
player.sendMessage("The server refused the dismount; stop carrying before teleporting.");
|
||||
}
|
||||
}
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void quit(org.bukkit.event.player.PlayerQuitEvent event) {
|
||||
UUID id = event.getPlayer().getUniqueId();
|
||||
if (!service.cleanup(id)) { reportUnresolved(id, "disconnect"); }
|
||||
}
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void death(org.bukkit.event.entity.PlayerDeathEvent event) {
|
||||
if (event.isCancelled()) { return; }
|
||||
UUID id = event.getEntity().getUniqueId();
|
||||
if (!service.cleanup(id)) { reportUnresolved(id, "death"); }
|
||||
}
|
||||
private void reportUnresolved(UUID id, String cause) {
|
||||
logger.severe("Heights " + cause + ": carry involving " + id
|
||||
+ " remains attached because the server refused native dismounting; no cancellation was bypassed.");
|
||||
}
|
||||
public void tick() { if (!closed) { service.tick(); } }
|
||||
@Override public void close() {
|
||||
closed = true;
|
||||
interactions.clear();
|
||||
int unresolved = service.close();
|
||||
if (unresolved != 0) { logger.severe("Heights disable: " + unresolved
|
||||
+ " native dismount(s) were refused by the server; no cancellation was bypassed."); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import java.util.List;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.attribute.Attribute;
|
||||
import org.bukkit.attribute.AttributeInstance;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Bukkit boundary for exact online-player lookup; used only on the server thread. */
|
||||
public final class BukkitPlayerHeights {
|
||||
private final Server server;
|
||||
|
||||
public BukkitPlayerHeights(Server server) {
|
||||
this.server = server;
|
||||
}
|
||||
|
||||
public PlayerHeights.Target find(String name) {
|
||||
Player player = server.getPlayerExact(name);
|
||||
if (player == null) {
|
||||
return null;
|
||||
}
|
||||
AttributeInstance scale = player.getAttribute(Attribute.SCALE);
|
||||
if (scale == null) {
|
||||
throw new IllegalArgumentException("Player " + player.getName() + " has no scale attribute.");
|
||||
}
|
||||
return new PlayerHeights.Target(player.getUniqueId(), player.getName(), scale.getBaseValue(),
|
||||
scale.getValue(), scale::setBaseValue);
|
||||
}
|
||||
|
||||
public List<String> names() {
|
||||
return server.getOnlinePlayers().stream().map(Player::getName)
|
||||
.sorted(String.CASE_INSENSITIVE_ORDER).toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.attribute.Attribute;
|
||||
import org.bukkit.attribute.AttributeInstance;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
/** Captures Bukkit state on the tick thread; all stature file writes run on one worker. */
|
||||
public final class BukkitStatureService implements AutoCloseable {
|
||||
private final JavaPlugin plugin;
|
||||
private final Supplier<HeightSettings> settings;
|
||||
private final HeightStore store;
|
||||
private final TemporaryStature stature;
|
||||
private final ExecutorService worker = Executors.newSingleThreadExecutor(Thread.ofPlatform()
|
||||
.name("heights-state").factory());
|
||||
private final StatureQueue queue;
|
||||
private final CompletionMailbox completions;
|
||||
private final Set<UUID> expiring = new HashSet<>();
|
||||
|
||||
public BukkitStatureService(JavaPlugin plugin, Supplier<HeightSettings> settings, HeightStore store) {
|
||||
this.plugin = plugin;
|
||||
this.settings = settings;
|
||||
this.store = store;
|
||||
this.stature = new TemporaryStature(store);
|
||||
completions = new CompletionMailbox(task -> {
|
||||
if (plugin.isEnabled()) {
|
||||
try {
|
||||
plugin.getServer().getScheduler().runTask(plugin, task);
|
||||
} catch (org.bukkit.plugin.IllegalPluginAccessException ignored) {
|
||||
// Disable raced with a completed, already durable write. Login will resume it.
|
||||
}
|
||||
}
|
||||
});
|
||||
queue = new StatureQueue(worker, completions);
|
||||
}
|
||||
|
||||
public void temporary(UUID source, Player player, StaturePotion kind, Consumer<Boolean> completed) {
|
||||
change(player, kind, source, completed);
|
||||
}
|
||||
|
||||
public void drink(Player player, StaturePotion kind) {
|
||||
change(player, kind, null, success -> {});
|
||||
}
|
||||
|
||||
private void change(Player player, StaturePotion kind, UUID source, Consumer<Boolean> completed) {
|
||||
UUID id = player.getUniqueId();
|
||||
queue.submit(() -> {
|
||||
Player currentPlayer = requireOnline(id);
|
||||
double current = scale(currentPlayer);
|
||||
HeightSettings limits = settings.get();
|
||||
Instant now = Instant.now();
|
||||
return () -> {
|
||||
// A duplicate returns null; do not unbox it through a mixed Double/double ternary.
|
||||
if (source != null) {
|
||||
return stature.applyOnce(id, source, current, kind, limits, now,
|
||||
bound -> ThreadLocalRandom.current().nextInt(bound));
|
||||
}
|
||||
return stature.drink(id, current, kind, limits, now,
|
||||
bound -> ThreadLocalRandom.current().nextInt(bound));
|
||||
};
|
||||
}, (result, error) -> {
|
||||
if (error == null) {
|
||||
if (result != null) {
|
||||
applyOnline(id, result);
|
||||
}
|
||||
completed.accept(true);
|
||||
} else {
|
||||
report(id, error);
|
||||
completed.accept(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void resume(Player player) {
|
||||
resume(player, true);
|
||||
}
|
||||
|
||||
public void resume(Player player, boolean notify) {
|
||||
UUID id = player.getUniqueId();
|
||||
queue.submit(() -> {
|
||||
requireOnline(id);
|
||||
HeightSettings limits = settings.get();
|
||||
Instant now = Instant.now();
|
||||
return () -> stature.resume(id, limits, now);
|
||||
}, (result, error) -> {
|
||||
if (error == null) { applyRestoration(id, result, notify); } else { report(id, error); }
|
||||
});
|
||||
}
|
||||
|
||||
/** Called once a second, not on movement; reads immutable cached state only. */
|
||||
public void expireOnline() {
|
||||
Instant now = Instant.now();
|
||||
for (Player player : plugin.getServer().getOnlinePlayers()) {
|
||||
UUID id = player.getUniqueId();
|
||||
HeightStore.Sequence sequence = store.sequence(id);
|
||||
if (player.isDead() || sequence == null || now.isBefore(sequence.expiresAt()) || !expiring.add(id)) {
|
||||
continue;
|
||||
}
|
||||
queue.submit(() -> {
|
||||
HeightSettings limits = settings.get();
|
||||
Instant at = Instant.now();
|
||||
// Recheck at queue execution: a permanent override or a later splash may have won.
|
||||
Callable<Double> work = () -> {
|
||||
HeightStore.Sequence latest = store.sequence(id);
|
||||
return latest != null && !at.isBefore(latest.expiresAt()) ? stature.resume(id, limits, at) : null;
|
||||
};
|
||||
return work;
|
||||
}, (result, error) -> {
|
||||
expiring.remove(id);
|
||||
if (error != null) { report(id, error); }
|
||||
else if (result != null) { applyRestoration(id, result); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public void retireSource(UUID source) {
|
||||
queue.submit(() -> () -> { store.forgetSource(source); return null; }, (result, error) -> {
|
||||
if (error != null) {
|
||||
plugin.getLogger().warning("Could not retire throwable receipt " + source + ": " + error.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void set(String name, double value, Consumer<String> reply) {
|
||||
Player target = plugin.getServer().getPlayerExact(name);
|
||||
if (target == null) {
|
||||
reply.accept("No online player named '" + name + "'. Use their full name.");
|
||||
return;
|
||||
}
|
||||
UUID id = target.getUniqueId();
|
||||
queue.submit(() -> {
|
||||
requireOnline(id);
|
||||
HeightSettings limits = settings.get();
|
||||
if (!Double.isFinite(value) || (value != 1.0 && (value < limits.minimum() || value > limits.maximum()))) {
|
||||
throw new IllegalArgumentException("Scale must be finite and within current limits, or exactly 1.0.");
|
||||
}
|
||||
return () -> { store.save(id, value); return value; };
|
||||
}, (result, error) -> {
|
||||
if (error == null) {
|
||||
applyOnline(id, result);
|
||||
reply.accept("Set " + name + "'s base scale to " + result + ". Saved and active.");
|
||||
} else if (error instanceof IllegalArgumentException || error instanceof IllegalStateException) {
|
||||
reply.accept(error.getMessage());
|
||||
} else {
|
||||
report(id, error);
|
||||
reply.accept("Could not save player height. Their height is unchanged; check the server log.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private Player requireOnline(UUID id) {
|
||||
Player player = plugin.getServer().getPlayer(id);
|
||||
if (player == null || !player.isOnline() || player.isDead()) {
|
||||
throw new IllegalStateException("Player is no longer available for a stature change.");
|
||||
}
|
||||
return player;
|
||||
}
|
||||
|
||||
private static double scale(Player player) {
|
||||
AttributeInstance attribute = player.getAttribute(Attribute.SCALE);
|
||||
if (attribute == null) {
|
||||
throw new IllegalStateException("Player has no scale attribute.");
|
||||
}
|
||||
return attribute.getBaseValue();
|
||||
}
|
||||
|
||||
private void applyRestoration(UUID id, double value) {
|
||||
applyRestoration(id, value, true);
|
||||
}
|
||||
|
||||
private void applyRestoration(UUID id, double value, boolean notify) {
|
||||
HeightStore.Sequence pending = store.sequence(id);
|
||||
if (applyOnline(id, value, notify) && pending != null && !Instant.now().isBefore(pending.expiresAt())) {
|
||||
worker.execute(() -> {
|
||||
try {
|
||||
stature.acknowledgeRestoration(id, pending);
|
||||
} catch (java.io.IOException exception) {
|
||||
report(id, exception);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private boolean applyOnline(UUID id, double value) {
|
||||
return applyOnline(id, value, true);
|
||||
}
|
||||
|
||||
private boolean applyOnline(UUID id, double value, boolean notify) {
|
||||
Player player = plugin.getServer().getPlayer(id);
|
||||
return plugin.isEnabled() && applyToPlayer(player, value, notify);
|
||||
}
|
||||
|
||||
static boolean applyToPlayer(Player player, double value, boolean notify) {
|
||||
if (player == null || !player.isOnline() || player.isDead()) {
|
||||
return false;
|
||||
}
|
||||
return applyScale(player, player.getAttribute(Attribute.SCALE), value, notify);
|
||||
}
|
||||
|
||||
static boolean applyScale(Player player, AttributeInstance attribute, double value, boolean notify) {
|
||||
if (attribute == null) {
|
||||
return false;
|
||||
}
|
||||
attribute.setBaseValue(value);
|
||||
if (notify) {
|
||||
player.sendMessage("Your scale is now " + value + ".");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void report(UUID id, Exception error) {
|
||||
plugin.getLogger().warning("Could not update stature for " + id + ": " + error.getMessage());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
queue.close();
|
||||
// Disable is the one blocking lifecycle boundary: never permit an old writer to outlive
|
||||
// this store and overwrite a replacement plugin instance. close() joins even if interrupted.
|
||||
worker.close();
|
||||
completions.drain();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
/** Server-independent carry eligibility, using effective rather than base scale. */
|
||||
public final class CarryPolicy {
|
||||
private CarryPolicy() {}
|
||||
|
||||
public static boolean eligible(double carrier, double rider, double gap) {
|
||||
if (!Double.isFinite(carrier) || !Double.isFinite(rider) || !Double.isFinite(gap)
|
||||
|| carrier <= 0 || rider <= 0 || gap < 0 || carrier <= rider) {
|
||||
return false;
|
||||
}
|
||||
double tolerance = 4 * Math.ulp(Math.max(carrier, rider));
|
||||
return carrier - rider + tolerance >= gap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.function.DoubleSupplier;
|
||||
|
||||
/** Tick-thread state machine; ports must not perform blocking I/O. */
|
||||
public final class CarryService {
|
||||
public record State(double scale, boolean available, boolean free) {}
|
||||
public interface Port {
|
||||
State state(UUID player);
|
||||
boolean nearby(UUID first, UUID second);
|
||||
boolean mount(UUID carrier, UUID rider);
|
||||
boolean attached(UUID carrier, UUID rider);
|
||||
boolean linked(UUID carrier, UUID rider);
|
||||
boolean dismount(UUID carrier, UUID rider);
|
||||
void message(UUID player, String message);
|
||||
}
|
||||
private record Ride(UUID carrier, UUID rider) {}
|
||||
private final Port port;
|
||||
private final DoubleSupplier gap;
|
||||
private final Map<UUID, Ride> rides = new HashMap<>();
|
||||
private final java.util.Set<UUID> mounting = new java.util.HashSet<>();
|
||||
private final java.util.Set<UUID> reportedRefusals = new java.util.HashSet<>();
|
||||
private boolean closed;
|
||||
|
||||
public CarryService(Port port, DoubleSupplier gap) {
|
||||
this.port = port; this.gap = gap;
|
||||
}
|
||||
|
||||
/** Whether this interaction belongs to Heights; does not mutate or notify either player. */
|
||||
public boolean canAttemptRide(UUID rider, UUID carrier) {
|
||||
return !closed && !involved(rider) && !involved(carrier)
|
||||
&& !mounting.contains(rider) && !mounting.contains(carrier)
|
||||
&& !rider.equals(carrier) && eligible(carrier, rider, true) && port.nearby(rider, carrier);
|
||||
}
|
||||
|
||||
/** Only the smaller rider may initiate; the target can never force a pickup. */
|
||||
public String ride(UUID rider, UUID carrier) {
|
||||
if (closed) { return "Carrying is shutting down."; }
|
||||
if (involved(rider) || involved(carrier) || mounting.contains(rider) || mounting.contains(carrier)) {
|
||||
return "A player already has a ride.";
|
||||
}
|
||||
if (rider.equals(carrier) || !eligible(carrier, rider, true) || !port.nearby(rider, carrier)) {
|
||||
return "Right-click an available larger player within 4 blocks; the configured scale gap is required.";
|
||||
}
|
||||
boolean mounted;
|
||||
mounting.add(carrier); mounting.add(rider);
|
||||
try { mounted = port.mount(carrier, rider); }
|
||||
finally { mounting.remove(carrier); mounting.remove(rider); }
|
||||
if (!mounted && !port.linked(carrier, rider)) { return "The server refused the ride."; }
|
||||
Ride ride = new Ride(carrier, rider);
|
||||
rides.put(ride.carrier(), ride); rides.put(ride.rider(), ride);
|
||||
if (!mounted || closed) {
|
||||
String stopped = stop(ride.rider());
|
||||
String message = (closed ? "Carrying is shutting down. " : "The server produced an invalid ride. ") + stopped;
|
||||
if (involved(ride.rider())) { notify(ride, message); }
|
||||
return message;
|
||||
}
|
||||
notify(ride, "Carry started. Either player can /heights carry stop.");
|
||||
return "Carry started.";
|
||||
}
|
||||
|
||||
private boolean eligible(UUID carrier, UUID rider, boolean free) {
|
||||
State a = port.state(carrier), b = port.state(rider);
|
||||
return a != null && b != null && a.available() && b.available()
|
||||
&& (!free || (a.free() && b.free()))
|
||||
&& CarryPolicy.eligible(a.scale(), b.scale(), gap.getAsDouble());
|
||||
}
|
||||
|
||||
public String stop(UUID player) {
|
||||
Ride ride = rides.get(player);
|
||||
if (ride == null) { return "No active ride."; }
|
||||
if (!port.dismount(ride.carrier(), ride.rider())) {
|
||||
return "The server refused the dismount. The ride is still active; retry /heights carry stop.";
|
||||
}
|
||||
rides.remove(ride.carrier()); rides.remove(ride.rider());
|
||||
reportedRefusals.remove(ride.rider());
|
||||
notify(ride, "Carry ended.");
|
||||
return "Carry ended.";
|
||||
}
|
||||
private void notify(Ride ride, String message) {
|
||||
port.message(ride.carrier(), message); port.message(ride.rider(), message);
|
||||
}
|
||||
public void tick() {
|
||||
for (Ride ride : java.util.Set.copyOf(rides.values())) {
|
||||
if (!eligible(ride.carrier(), ride.rider(), false)
|
||||
|| !port.attached(ride.carrier(), ride.rider())) {
|
||||
String result = stop(ride.rider());
|
||||
if (involved(ride.rider()) && reportedRefusals.add(ride.rider())) {
|
||||
notify(ride, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
public boolean cleanup(UUID player) {
|
||||
stop(player);
|
||||
return !involved(player);
|
||||
}
|
||||
/** Returns server-refused dismounts; never bypasses cancellation or claims false detachment. */
|
||||
public int close() {
|
||||
closed = true;
|
||||
for (Ride ride : java.util.Set.copyOf(rides.values())) { stop(ride.rider()); }
|
||||
return rides.size() / 2;
|
||||
}
|
||||
public boolean involved(UUID player) { return rides.containsKey(player); }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/** Retains worker outcomes even if the plugin scheduler stops during disable. */
|
||||
public final class CompletionMailbox implements Executor {
|
||||
private final Consumer<Runnable> wakeup;
|
||||
private final java.util.concurrent.ConcurrentLinkedQueue<Runnable> ready = new java.util.concurrent.ConcurrentLinkedQueue<>();
|
||||
|
||||
public CompletionMailbox(Consumer<Runnable> wakeup) {
|
||||
this.wakeup = wakeup;
|
||||
}
|
||||
|
||||
@Override public void execute(Runnable task) {
|
||||
ready.add(task);
|
||||
wakeup.accept(this::drain);
|
||||
}
|
||||
|
||||
/** Must be called on the main thread, also after the writer joins during disable. */
|
||||
public void drain() {
|
||||
Runnable task;
|
||||
while ((task = ready.poll()) != null) {
|
||||
task.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,8 @@ public final class HeightMath {
|
||||
if (stored == null || !Double.isFinite(stored)) {
|
||||
return clamp(1.0, settings);
|
||||
}
|
||||
return clamp(stored, settings);
|
||||
// Restoration is an explicit escape from configured stature limits.
|
||||
return stored == 1.0 ? 1.0 : clamp(stored, settings);
|
||||
}
|
||||
|
||||
public static double clamp(double value, HeightSettings settings) {
|
||||
|
||||
@@ -6,9 +6,21 @@ public record HeightSettings(
|
||||
double adjustmentStep,
|
||||
double launcherThreshold,
|
||||
double launcherSpeed,
|
||||
int launcherCooldownTicks) {
|
||||
int launcherCooldownTicks,
|
||||
double carryMinimumScaleGap) {
|
||||
|
||||
public static final double DEFAULT_CARRY_MINIMUM_SCALE_GAP = 0.1;
|
||||
|
||||
public HeightSettings(double minimum, double maximum, double adjustmentStep,
|
||||
double launcherThreshold, double launcherSpeed, int launcherCooldownTicks) {
|
||||
this(minimum, maximum, adjustmentStep, launcherThreshold, launcherSpeed, launcherCooldownTicks,
|
||||
DEFAULT_CARRY_MINIMUM_SCALE_GAP);
|
||||
}
|
||||
|
||||
public HeightSettings {
|
||||
if (!Double.isFinite(carryMinimumScaleGap) || carryMinimumScaleGap < 0) {
|
||||
throw new IllegalArgumentException("carry.minimum-scale-gap must be a finite nonnegative number");
|
||||
}
|
||||
requirePositiveFinite("height.minimum", minimum);
|
||||
requirePositiveFinite("height.maximum", maximum);
|
||||
requirePositiveFinite("height.adjustment-step", adjustmentStep);
|
||||
|
||||
@@ -7,34 +7,117 @@ import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.time.Instant;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.configuration.InvalidConfigurationException;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
|
||||
public final class HeightStore {
|
||||
private final Path statePath;
|
||||
private final YamlConfiguration state;
|
||||
private volatile YamlConfiguration state;
|
||||
|
||||
public record Sequence(double baseline, Instant expiresAt) {}
|
||||
|
||||
public HeightStore(File dataFolder) {
|
||||
statePath = dataFolder.toPath().resolve("state.yml");
|
||||
state = YamlConfiguration.loadConfiguration(statePath.toFile());
|
||||
}
|
||||
|
||||
public synchronized Double find(UUID playerId) {
|
||||
public Double find(UUID playerId) {
|
||||
YamlConfiguration snapshot = state;
|
||||
String path = path(playerId);
|
||||
return state.contains(path) ? state.getDouble(path) : null;
|
||||
return snapshot.contains(path) ? snapshot.getDouble(path) : null;
|
||||
}
|
||||
|
||||
public synchronized void save(UUID playerId, double scale) throws IOException {
|
||||
state.set(path(playerId), scale);
|
||||
public Sequence sequence(UUID playerId) {
|
||||
YamlConfiguration snapshot = state;
|
||||
String path = temporaryPath(playerId);
|
||||
if (!snapshot.isDouble(path + ".baseline") && !snapshot.isInt(path + ".baseline")) {
|
||||
return null;
|
||||
}
|
||||
double baseline = snapshot.getDouble(path + ".baseline");
|
||||
if (!Double.isFinite(baseline) || baseline < 0.0625 || baseline > 16.0) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new Sequence(baseline, Instant.parse(snapshot.getString(path + ".expires-at", "")));
|
||||
} catch (DateTimeParseException exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** A permanent write also invalidates any outstanding temporary sequence. */
|
||||
public void save(UUID playerId, double scale) throws IOException {
|
||||
save(playerId, scale, null);
|
||||
}
|
||||
|
||||
public boolean hasReceipt(UUID playerId, UUID source) {
|
||||
return state.getBoolean(receiptPath(playerId, source));
|
||||
}
|
||||
|
||||
public void save(UUID playerId, double scale, Sequence sequence) throws IOException {
|
||||
save(playerId, scale, sequence, null);
|
||||
}
|
||||
|
||||
public synchronized void save(UUID playerId, double scale, Sequence sequence, UUID source) throws IOException {
|
||||
YamlConfiguration next = copy();
|
||||
next.set(path(playerId), scale);
|
||||
next.set(temporaryPath(playerId), null);
|
||||
if (sequence != null) {
|
||||
next.set(temporaryPath(playerId) + ".baseline", sequence.baseline());
|
||||
next.set(temporaryPath(playerId) + ".expires-at", sequence.expiresAt().toString());
|
||||
}
|
||||
if (source != null) {
|
||||
next.set(receiptPath(playerId, source), true);
|
||||
}
|
||||
replace(next);
|
||||
}
|
||||
|
||||
public synchronized void forgetSource(UUID source) throws IOException {
|
||||
var players = state.getConfigurationSection("players");
|
||||
if (players == null) {
|
||||
return;
|
||||
}
|
||||
java.util.List<String> paths = players.getKeys(false).stream()
|
||||
.map(player -> "players." + player + ".throwables." + source)
|
||||
.filter(state::contains).toList();
|
||||
if (!paths.isEmpty()) {
|
||||
YamlConfiguration next = copy();
|
||||
paths.forEach(path -> next.set(path, null));
|
||||
replace(next);
|
||||
}
|
||||
}
|
||||
|
||||
private YamlConfiguration copy() throws IOException {
|
||||
YamlConfiguration next = new YamlConfiguration();
|
||||
try {
|
||||
next.loadFromString(state.saveToString());
|
||||
} catch (InvalidConfigurationException exception) {
|
||||
throw new IOException("Could not copy player state", exception);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
private void replace(YamlConfiguration next) throws IOException {
|
||||
Files.createDirectories(statePath.getParent());
|
||||
Path temporary = statePath.resolveSibling("state.yml.tmp");
|
||||
Files.writeString(temporary, state.saveToString(), StandardCharsets.UTF_8);
|
||||
Files.writeString(temporary, next.saveToString(), StandardCharsets.UTF_8);
|
||||
try {
|
||||
Files.move(temporary, statePath, StandardCopyOption.ATOMIC_MOVE,
|
||||
StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (AtomicMoveNotSupportedException exception) {
|
||||
Files.move(temporary, statePath, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
state = next;
|
||||
}
|
||||
|
||||
private static String receiptPath(UUID playerId, UUID source) {
|
||||
return "players." + playerId + ".throwables." + source;
|
||||
}
|
||||
|
||||
private static String temporaryPath(UUID playerId) {
|
||||
return "players." + playerId + ".temporary";
|
||||
}
|
||||
|
||||
private static String path(UUID playerId) {
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.logging.Logger;
|
||||
import java.util.stream.Stream;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.command.TabExecutor;
|
||||
|
||||
public final class HeightsCommand implements TabExecutor {
|
||||
private static final String PERMISSION = "spigotheights.admin";
|
||||
private static final String USAGE = "Usage: /heights settings | /heights set min|max <value>"
|
||||
+ " | /heights player <name> [scale]";
|
||||
private final LiveHeightSettings settings;
|
||||
private final Logger logger;
|
||||
private final PlayerHeights players;
|
||||
private final HeightSetter heightSetter;
|
||||
private final java.util.function.BiConsumer<org.bukkit.entity.Player, String> carry;
|
||||
private static final String CARRY_USAGE = "Usage: /heights carry stop";
|
||||
private static final List<String> CARRY_ACTIONS = List.of("stop");
|
||||
|
||||
@FunctionalInterface
|
||||
public interface HeightSetter {
|
||||
void set(String name, double value, java.util.function.Consumer<String> reply) throws IOException;
|
||||
}
|
||||
|
||||
public HeightsCommand(LiveHeightSettings settings, PlayerHeights players, Logger logger) {
|
||||
this(settings, players, logger, (name, value, reply) -> reply.accept(players.set(name, value)));
|
||||
}
|
||||
|
||||
public HeightsCommand(LiveHeightSettings settings, PlayerHeights players, Logger logger, HeightSetter heightSetter) {
|
||||
this(settings, players, logger, heightSetter,
|
||||
(player, action) -> player.sendMessage("Carrying is unavailable."));
|
||||
}
|
||||
|
||||
public HeightsCommand(LiveHeightSettings settings, PlayerHeights players, Logger logger, HeightSetter heightSetter,
|
||||
java.util.function.BiConsumer<org.bukkit.entity.Player, String> carry) {
|
||||
this.settings = settings;
|
||||
this.players = players;
|
||||
this.logger = logger;
|
||||
this.heightSetter = heightSetter;
|
||||
this.carry = carry;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||
if (args.length > 0 && args[0].equalsIgnoreCase("carry")) {
|
||||
if (!(sender instanceof org.bukkit.entity.Player player)) {
|
||||
sender.sendMessage("Only players can use carry commands.");
|
||||
} else if (args.length != 2 || !CARRY_ACTIONS.contains(args[1].toLowerCase(Locale.ROOT))) {
|
||||
sender.sendMessage(CARRY_USAGE);
|
||||
} else {
|
||||
carry.accept(player, args[1].toLowerCase(Locale.ROOT));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)) {
|
||||
sender.sendMessage("You do not have permission to manage Spigot Heights.");
|
||||
return true;
|
||||
}
|
||||
if (args.length > 0 && args[0].equalsIgnoreCase("player")) {
|
||||
return playerCommand(sender, args);
|
||||
}
|
||||
if (args.length == 1 && args[0].equalsIgnoreCase("settings")) {
|
||||
HeightSettings current = settings.get();
|
||||
sender.sendMessage("Spigot Heights: min=" + current.minimum() + ", max=" + current.maximum()
|
||||
+ ", adjustment-step=" + current.adjustmentStep()
|
||||
+ ", launcher-threshold=" + current.launcherThreshold()
|
||||
+ ", launcher-speed=" + current.launcherSpeed()
|
||||
+ ", launcher-cooldown-ticks=" + current.launcherCooldownTicks());
|
||||
return true;
|
||||
}
|
||||
if (args.length != 3 || !args[0].equalsIgnoreCase("set") || !isBound(args[1])) {
|
||||
sender.sendMessage(USAGE);
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
String bound = args[1].toLowerCase(Locale.ROOT);
|
||||
double value = Double.parseDouble(args[2]);
|
||||
settings.setBound(bound, value);
|
||||
sender.sendMessage("Set " + bound + " to " + value + ". Saved and active; existing players were not resized.");
|
||||
} catch (NumberFormatException exception) {
|
||||
sender.sendMessage("Invalid value: enter a finite number, for example 0.2 or 3.0.");
|
||||
} catch (IllegalArgumentException exception) {
|
||||
sender.sendMessage("Invalid settings: " + exception.getMessage());
|
||||
} catch (IOException exception) {
|
||||
logger.warning("Could not save height settings: " + exception.getMessage());
|
||||
sender.sendMessage("Could not save settings. Active settings are unchanged; check the server log.");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean playerCommand(CommandSender sender, String[] args) {
|
||||
if (args.length != 2 && args.length != 3) {
|
||||
sender.sendMessage(USAGE);
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(players.describe(args[1]));
|
||||
} else {
|
||||
heightSetter.set(args[1], Double.parseDouble(args[2]), sender::sendMessage);
|
||||
}
|
||||
} catch (NumberFormatException exception) {
|
||||
sender.sendMessage("Invalid scale: enter a finite number, for example 0.5 or 1.0.");
|
||||
} catch (IllegalArgumentException exception) {
|
||||
sender.sendMessage(exception.getMessage());
|
||||
} catch (IOException exception) {
|
||||
logger.warning("Could not save player height: " + exception.getMessage());
|
||||
sender.sendMessage("Could not save player height. Their height is unchanged; check the server log.");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
|
||||
boolean player = sender instanceof org.bukkit.entity.Player;
|
||||
if (player && args.length == 2 && args[0].equalsIgnoreCase("carry")) {
|
||||
return matching(CARRY_ACTIONS.stream(), args[1]);
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)) {
|
||||
return player && args.length == 1 ? matching(Stream.of("carry"), args[0]) : List.of();
|
||||
}
|
||||
if (args.length == 1) {
|
||||
return matching(player ? Stream.of("carry", "settings", "set", "player")
|
||||
: Stream.of("settings", "set", "player"), args[0]);
|
||||
}
|
||||
if (args.length == 2 && args[0].equalsIgnoreCase("player")) {
|
||||
return matching(players.names().stream(), args[1]);
|
||||
}
|
||||
if (args.length == 3 && args[0].equalsIgnoreCase("player")
|
||||
&& players.names().stream().anyMatch(name -> name.equalsIgnoreCase(args[1]))) {
|
||||
HeightSettings current = settings.get();
|
||||
return matching(Stream.of(current.minimum(), current.maximum(), 0.0625, 0.2, 0.4, 0.5, 1.0, 2.0, 3.0, 16.0)
|
||||
.distinct().sorted().filter(players::isValid).map(String::valueOf), args[2]);
|
||||
}
|
||||
if (args.length == 2 && args[0].equalsIgnoreCase("set")) {
|
||||
return matching(Stream.of("min", "max"), args[1]);
|
||||
}
|
||||
if (args.length == 3 && args[0].equalsIgnoreCase("set") && isBound(args[1])) {
|
||||
String bound = args[1].toLowerCase(Locale.ROOT);
|
||||
HeightSettings current = settings.get();
|
||||
return matching(Stream.of(current.minimum(), current.maximum(), current.launcherThreshold(),
|
||||
0.0625, 0.2, 0.4, 0.5, 1.0, 2.0, 3.0, 16.0)
|
||||
.distinct().sorted().filter(value -> isValid(bound, value)).map(String::valueOf), args[2]);
|
||||
}
|
||||
// Never fall back to Bukkit's player-name completion.
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private boolean isValid(String bound, double value) {
|
||||
try {
|
||||
settings.candidate(bound, value);
|
||||
return true;
|
||||
} catch (IllegalArgumentException exception) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isBound(String value) {
|
||||
return value.equalsIgnoreCase("min") || value.equalsIgnoreCase("max");
|
||||
}
|
||||
|
||||
private static List<String> matching(Stream<String> candidates, String prefix) {
|
||||
String normalized = prefix.toLowerCase(Locale.ROOT);
|
||||
return candidates.filter(value -> value.toLowerCase(Locale.ROOT).startsWith(normalized)).toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/** Server-thread settings shared by commands and gameplay listeners. */
|
||||
public final class LiveHeightSettings implements Supplier<HeightSettings> {
|
||||
@FunctionalInterface
|
||||
public interface Saver {
|
||||
void save(HeightSettings settings) throws IOException;
|
||||
}
|
||||
|
||||
private HeightSettings current;
|
||||
private final Saver saver;
|
||||
|
||||
public LiveHeightSettings(HeightSettings initial, Saver saver) {
|
||||
this.current = initial;
|
||||
this.saver = saver;
|
||||
}
|
||||
|
||||
@Override
|
||||
public HeightSettings get() {
|
||||
return current;
|
||||
}
|
||||
|
||||
public HeightSettings candidate(String bound, double value) {
|
||||
if (!bound.equals("min") && !bound.equals("max")) {
|
||||
throw new IllegalArgumentException("Choose min or max.");
|
||||
}
|
||||
return new HeightSettings(
|
||||
bound.equals("min") ? value : current.minimum(),
|
||||
bound.equals("max") ? value : current.maximum(),
|
||||
current.adjustmentStep(), current.launcherThreshold(),
|
||||
current.launcherSpeed(), current.launcherCooldownTicks(), current.carryMinimumScaleGap());
|
||||
}
|
||||
|
||||
public void setBound(String bound, double value) throws IOException {
|
||||
HeightSettings next = candidate(bound, value);
|
||||
saver.save(next);
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.function.DoubleConsumer;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/** Online-player operations, called on the server thread. */
|
||||
public final class PlayerHeights {
|
||||
public record Target(UUID id, String name, double baseScale, double effectiveScale, DoubleConsumer applyScale) {}
|
||||
|
||||
private final Supplier<HeightSettings> settings;
|
||||
private final HeightStore store;
|
||||
private final Function<String, Target> lookup;
|
||||
private final Supplier<List<String>> onlineNames;
|
||||
|
||||
public PlayerHeights(Supplier<HeightSettings> settings, HeightStore store,
|
||||
Function<String, Target> lookup, Supplier<List<String>> onlineNames) {
|
||||
this.settings = settings;
|
||||
this.store = store;
|
||||
this.lookup = lookup;
|
||||
this.onlineNames = onlineNames;
|
||||
}
|
||||
|
||||
public String describe(String name) {
|
||||
Target target = requireTarget(name);
|
||||
String message = target.name() + " has scale " + target.baseScale() + " (1.0 = normal)";
|
||||
if (target.effectiveScale() != target.baseScale()) {
|
||||
message += "; effective scale with modifiers: " + target.effectiveScale();
|
||||
}
|
||||
return message + ".";
|
||||
}
|
||||
|
||||
public String set(String name, double value) throws IOException {
|
||||
Target target = requireTarget(name);
|
||||
if (!isValid(value)) {
|
||||
HeightSettings current = settings.get();
|
||||
throw new IllegalArgumentException("Scale must be a finite number between " + current.minimum()
|
||||
+ " and " + current.maximum() + ", or exactly 1.0 for normal size.");
|
||||
}
|
||||
store.save(target.id(), value);
|
||||
target.applyScale().accept(value);
|
||||
return "Set " + target.name() + "'s base scale to " + value + ". Saved and active.";
|
||||
}
|
||||
|
||||
public List<String> names() {
|
||||
return onlineNames.get();
|
||||
}
|
||||
|
||||
public boolean isValid(double value) {
|
||||
HeightSettings current = settings.get();
|
||||
return Double.isFinite(value) && (value == 1.0
|
||||
|| value >= current.minimum() && value <= current.maximum());
|
||||
}
|
||||
|
||||
private Target requireTarget(String name) {
|
||||
Target target = lookup.apply(name);
|
||||
if (target == null) {
|
||||
throw new IllegalArgumentException("No online player named '" + name + "'. Use their full name.");
|
||||
}
|
||||
return target;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,24 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Predicate;
|
||||
import io.papermc.paper.potion.PotionMix;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import org.bukkit.Color;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.RecipeChoice;
|
||||
import org.bukkit.inventory.ShapedRecipe;
|
||||
import org.bukkit.inventory.meta.PotionMeta;
|
||||
import org.bukkit.inventory.recipe.CraftingBookCategory;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.bukkit.potion.PotionType;
|
||||
@@ -17,23 +27,132 @@ public final class PotionRecipes {
|
||||
static final String[] SHIFTING_SHAPE = {"ACA", "AWA", "ACA"};
|
||||
static final String[] GROWTH_SHAPE = {"GAG", "ASA", "GRG"};
|
||||
static final String[] DIMINUTION_SHAPE = {"GAG", "ASA", "GFG"};
|
||||
static final String[] RESTORATION_SHAPE = {"GAG", "ASA", "GUG"};
|
||||
|
||||
private final JavaPlugin plugin;
|
||||
private final Server server;
|
||||
private final NamespacedKey potionKindKey;
|
||||
private final Function<Material, ItemStack> items;
|
||||
private final List<NamespacedKey> ownedRecipeKeys = new ArrayList<>();
|
||||
private final Set<NamespacedKey> ownedMixKeys = new LinkedHashSet<>();
|
||||
|
||||
public PotionRecipes(JavaPlugin plugin) {
|
||||
this.plugin = plugin;
|
||||
potionKindKey = new NamespacedKey(plugin, "stature_potion");
|
||||
this(plugin.getServer(), new NamespacedKey(plugin, "stature_potion"), ItemStack::new);
|
||||
}
|
||||
|
||||
public void register() {
|
||||
registerShifting();
|
||||
registerGrowth();
|
||||
registerDiminution();
|
||||
PotionRecipes(Server server, NamespacedKey potionKindKey, Function<Material, ItemStack> items) {
|
||||
this.server = server;
|
||||
this.potionKindKey = potionKindKey;
|
||||
this.items = items;
|
||||
}
|
||||
|
||||
private NamespacedKey key(String value) {
|
||||
return new NamespacedKey(potionKindKey.getNamespace(), value);
|
||||
}
|
||||
|
||||
public boolean register() {
|
||||
boolean complete = registerCrafting();
|
||||
registerBrewing(server.getPotionBrewer());
|
||||
return complete;
|
||||
}
|
||||
|
||||
private boolean registerCrafting() {
|
||||
for (ShapedRecipe recipe : List.of(shiftingRecipe(), growthRecipe(), diminutionRecipe(), restorationRecipe())) {
|
||||
NamespacedKey recipeKey = recipe.getKey();
|
||||
if (server.getRecipe(recipeKey) != null) {
|
||||
rollbackCrafting();
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if (!server.addRecipe(recipe)) {
|
||||
rollbackCrafting();
|
||||
return false;
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
try {
|
||||
if (server.getRecipe(recipeKey) != null) {
|
||||
ownedRecipeKeys.add(recipeKey);
|
||||
}
|
||||
} catch (RuntimeException inspectionFailure) {
|
||||
exception.addSuppressed(inspectionFailure);
|
||||
}
|
||||
throw exception;
|
||||
}
|
||||
ownedRecipeKeys.add(recipeKey);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void rollbackCrafting() {
|
||||
ownedRecipeKeys.forEach(server::removeRecipe);
|
||||
ownedRecipeKeys.clear();
|
||||
}
|
||||
|
||||
public void registerBrewing(org.bukkit.potion.PotionBrewer brewer) {
|
||||
for (StaturePotion kind : StaturePotion.values()) {
|
||||
mix(brewer, kind, Material.POTION, Material.GUNPOWDER, Material.SPLASH_POTION);
|
||||
mix(brewer, kind, Material.SPLASH_POTION, Material.DRAGON_BREATH, Material.LINGERING_POTION);
|
||||
}
|
||||
}
|
||||
|
||||
private void mix(org.bukkit.potion.PotionBrewer brewer, StaturePotion kind, Material source,
|
||||
Material ingredient, Material target) {
|
||||
NamespacedKey mixKey = mixKey(kind, target);
|
||||
brewer.removePotionMix(mixKey);
|
||||
brewer.addPotionMix(new PotionMix(mixKey, create(kind, target),
|
||||
predicateChoice(create(kind, source), item -> item != null && item.getType() == source
|
||||
&& identify(item) == kind),
|
||||
predicateChoice(items.apply(ingredient), item -> item != null && item.getType() == ingredient)));
|
||||
ownedMixKeys.add(mixKey);
|
||||
}
|
||||
|
||||
private static RecipeChoice.PredicateChoice predicateChoice(
|
||||
ItemStack example, Predicate<? super ItemStack> predicate) {
|
||||
return new RecipeChoice.PredicateChoice() {
|
||||
@Override
|
||||
public ItemStack getItemStack() {
|
||||
return example.clone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public RecipeChoice.PredicateChoice clone() {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean test(ItemStack item) {
|
||||
return predicate.test(item);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public void unregister() {
|
||||
ownedRecipeKeys.forEach(server::removeRecipe);
|
||||
ownedRecipeKeys.clear();
|
||||
unregisterBrewing();
|
||||
}
|
||||
|
||||
public void unregisterBrewing() {
|
||||
if (ownedMixKeys.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
org.bukkit.potion.PotionBrewer brewer = server.getPotionBrewer();
|
||||
ownedMixKeys.forEach(brewer::removePotionMix);
|
||||
ownedMixKeys.clear();
|
||||
}
|
||||
|
||||
private NamespacedKey mixKey(StaturePotion kind, Material target) {
|
||||
return key((kind.name() + "_" + target.name()).toLowerCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
public ItemStack create(StaturePotion kind) {
|
||||
ItemStack item = new ItemStack(Material.POTION);
|
||||
return create(kind, Material.POTION);
|
||||
}
|
||||
|
||||
public ItemStack create(StaturePotion kind, Material form) {
|
||||
if (!isPotionForm(form)) {
|
||||
throw new IllegalArgumentException("Unsupported potion form");
|
||||
}
|
||||
ItemStack item = items.apply(form);
|
||||
PotionMeta meta = (PotionMeta) item.getItemMeta();
|
||||
meta.displayName(Component.text(kind.displayName(), NamedTextColor.LIGHT_PURPLE));
|
||||
meta.getPersistentDataContainer().set(potionKindKey, PersistentDataType.STRING, kind.name());
|
||||
@@ -43,7 +162,8 @@ public final class PotionRecipes {
|
||||
}
|
||||
|
||||
public StaturePotion identify(ItemStack item) {
|
||||
if (item == null || item.getType() != Material.POTION || !(item.getItemMeta() instanceof PotionMeta meta)) {
|
||||
if (item == null || !isPotionForm(item.getType()) || !(item.getItemMeta() instanceof PotionMeta meta)
|
||||
|| !meta.getPersistentDataContainer().has(potionKindKey, PersistentDataType.STRING)) {
|
||||
return null;
|
||||
}
|
||||
String value = meta.getPersistentDataContainer().get(potionKindKey, PersistentDataType.STRING);
|
||||
@@ -57,43 +177,70 @@ public final class PotionRecipes {
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isPotionForm(Material form) {
|
||||
return form == Material.POTION || form == Material.SPLASH_POTION || form == Material.LINGERING_POTION;
|
||||
}
|
||||
|
||||
public boolean isAwkwardPotion(ItemStack item) {
|
||||
return item != null && item.getItemMeta() instanceof PotionMeta meta
|
||||
&& meta.getBasePotionType() == PotionType.AWKWARD;
|
||||
}
|
||||
|
||||
public NamespacedKey shiftingKey() {
|
||||
return new NamespacedKey(plugin, "shifting_stature");
|
||||
return key("shifting_stature");
|
||||
}
|
||||
|
||||
private void registerShifting() {
|
||||
public List<NamespacedKey> recipeKeys() {
|
||||
return List.of(shiftingKey(), key("growth"), key("diminution"), key("restoration"));
|
||||
}
|
||||
|
||||
private ShapedRecipe shiftingRecipe() {
|
||||
ShapedRecipe recipe = new ShapedRecipe(shiftingKey(), create(StaturePotion.SHIFTING));
|
||||
recipe.shape(SHIFTING_SHAPE);
|
||||
recipe.setIngredient('A', Material.AMETHYST_SHARD);
|
||||
recipe.setIngredient('C', Material.CHORUS_FRUIT);
|
||||
recipe.setIngredient('W', Material.POTION);
|
||||
plugin.getServer().addRecipe(recipe);
|
||||
ItemStack awkward = items.apply(Material.POTION);
|
||||
PotionMeta awkwardMeta = (PotionMeta) awkward.getItemMeta();
|
||||
awkwardMeta.setBasePotionType(PotionType.AWKWARD);
|
||||
awkward.setItemMeta(awkwardMeta);
|
||||
recipe.setIngredient('W', new RecipeChoice.ExactChoice(awkward));
|
||||
recipe.setCategory(CraftingBookCategory.MISC);
|
||||
return recipe;
|
||||
}
|
||||
|
||||
private void registerGrowth() {
|
||||
ShapedRecipe recipe = new ShapedRecipe(new NamespacedKey(plugin, "growth"), create(StaturePotion.GROWTH));
|
||||
private ShapedRecipe growthRecipe() {
|
||||
ShapedRecipe recipe = new ShapedRecipe(key("growth"), create(StaturePotion.GROWTH));
|
||||
recipe.shape(GROWTH_SHAPE);
|
||||
recipe.setIngredient('G', Material.GOLD_INGOT);
|
||||
recipe.setIngredient('A', Material.AMETHYST_SHARD);
|
||||
recipe.setIngredient('S', new RecipeChoice.ExactChoice(create(StaturePotion.SHIFTING)));
|
||||
recipe.setIngredient('R', Material.RABBIT_FOOT);
|
||||
plugin.getServer().addRecipe(recipe);
|
||||
recipe.setCategory(CraftingBookCategory.MISC);
|
||||
return recipe;
|
||||
}
|
||||
|
||||
private void registerDiminution() {
|
||||
ShapedRecipe recipe = new ShapedRecipe(new NamespacedKey(plugin, "diminution"),
|
||||
private ShapedRecipe diminutionRecipe() {
|
||||
ShapedRecipe recipe = new ShapedRecipe(key("diminution"),
|
||||
create(StaturePotion.DIMINUTION));
|
||||
recipe.shape(DIMINUTION_SHAPE);
|
||||
recipe.setIngredient('G', Material.GOLD_INGOT);
|
||||
recipe.setIngredient('A', Material.AMETHYST_SHARD);
|
||||
recipe.setIngredient('S', new RecipeChoice.ExactChoice(create(StaturePotion.SHIFTING)));
|
||||
recipe.setIngredient('F', Material.FERMENTED_SPIDER_EYE);
|
||||
plugin.getServer().addRecipe(recipe);
|
||||
recipe.setCategory(CraftingBookCategory.MISC);
|
||||
return recipe;
|
||||
}
|
||||
|
||||
private ShapedRecipe restorationRecipe() {
|
||||
ShapedRecipe recipe = new ShapedRecipe(key("restoration"),
|
||||
create(StaturePotion.RESTORATION));
|
||||
recipe.shape(RESTORATION_SHAPE);
|
||||
recipe.setIngredient('G', Material.GOLD_INGOT);
|
||||
recipe.setIngredient('A', Material.AMETHYST_SHARD);
|
||||
recipe.setIngredient('S', new RecipeChoice.ExactChoice(create(StaturePotion.SHIFTING)));
|
||||
recipe.setIngredient('U', Material.SUGAR);
|
||||
recipe.setCategory(CraftingBookCategory.MISC);
|
||||
return recipe;
|
||||
}
|
||||
|
||||
private static Color color(StaturePotion kind) {
|
||||
@@ -101,6 +248,7 @@ public final class PotionRecipes {
|
||||
case SHIFTING -> Color.PURPLE;
|
||||
case GROWTH -> Color.LIME;
|
||||
case DIMINUTION -> Color.FUCHSIA;
|
||||
case RESTORATION -> Color.WHITE;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
|
||||
public final class RecipeDiscovery implements Listener {
|
||||
private final List<NamespacedKey> recipeKeys;
|
||||
private final Logger logger;
|
||||
|
||||
public RecipeDiscovery(List<NamespacedKey> recipeKeys, Logger logger) {
|
||||
this.recipeKeys = List.copyOf(recipeKeys);
|
||||
this.logger = Objects.requireNonNull(logger, "logger");
|
||||
}
|
||||
|
||||
public void discoverAll(Iterable<? extends Player> players) {
|
||||
players.forEach(this::discover);
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onJoin(PlayerJoinEvent event) {
|
||||
discover(event.getPlayer());
|
||||
}
|
||||
|
||||
public void discover(Player player) {
|
||||
try {
|
||||
List<NamespacedKey> unknown = recipeKeys.stream()
|
||||
.filter(key -> !player.hasDiscoveredRecipe(key))
|
||||
.toList();
|
||||
if (unknown.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
player.discoverRecipes(unknown);
|
||||
List<NamespacedKey> stillUnknown = unknown.stream()
|
||||
.filter(key -> !player.hasDiscoveredRecipe(key))
|
||||
.toList();
|
||||
if (!stillUnknown.isEmpty()) {
|
||||
logger.warning("Could not discover stature recipes for " + player.getName() + ": " + stillUnknown);
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
logger.log(Level.WARNING, "Could not discover stature recipes for " + player.getName(), exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.HandlerList;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
public final class RecipeLifecycle {
|
||||
private final Server server;
|
||||
private final Plugin plugin;
|
||||
private final PotionRecipes recipes;
|
||||
private final Logger logger;
|
||||
private RecipeDiscovery discovery;
|
||||
|
||||
public RecipeLifecycle(Server server, Plugin plugin, PotionRecipes recipes, Logger logger) {
|
||||
this.server = Objects.requireNonNull(server, "server");
|
||||
this.plugin = Objects.requireNonNull(plugin, "plugin");
|
||||
this.recipes = Objects.requireNonNull(recipes, "recipes");
|
||||
this.logger = Objects.requireNonNull(logger, "logger");
|
||||
}
|
||||
|
||||
public boolean enable() {
|
||||
if (discovery != null) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
if (!recipes.register()) {
|
||||
logger.severe("Stature recipes were not registered as a complete group; discovery is disabled.");
|
||||
return false;
|
||||
}
|
||||
List<? extends Player> onlinePlayers = List.copyOf(server.getOnlinePlayers());
|
||||
discovery = new RecipeDiscovery(recipes.recipeKeys(), logger);
|
||||
server.getPluginManager().registerEvents(discovery, plugin);
|
||||
discovery.discoverAll(onlinePlayers);
|
||||
return true;
|
||||
} catch (RuntimeException exception) {
|
||||
unregisterDiscovery();
|
||||
try {
|
||||
recipes.unregister();
|
||||
} catch (RuntimeException cleanupFailure) {
|
||||
exception.addSuppressed(cleanupFailure);
|
||||
}
|
||||
logger.log(Level.SEVERE,
|
||||
"Stature recipes were not registered as a complete group; discovery is disabled.", exception);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void disable() {
|
||||
unregisterDiscovery();
|
||||
recipes.unregister();
|
||||
}
|
||||
|
||||
private void unregisterDiscovery() {
|
||||
if (discovery != null) {
|
||||
HandlerList.unregisterAll(discovery);
|
||||
discovery = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import org.bukkit.configuration.InvalidConfigurationException;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
|
||||
public final class SettingsFileStore {
|
||||
private final Path path;
|
||||
|
||||
public SettingsFileStore(Path path) {
|
||||
this.path = path.toAbsolutePath();
|
||||
}
|
||||
|
||||
public void save(HeightSettings settings) throws IOException {
|
||||
// Read a separate document so failed writes cannot mutate the active configuration.
|
||||
YamlConfiguration config = new YamlConfiguration();
|
||||
try {
|
||||
config.load(path.toFile());
|
||||
} catch (InvalidConfigurationException exception) {
|
||||
throw new IOException("Existing config.yml is invalid; refusing to overwrite it", exception);
|
||||
}
|
||||
config.set("height.minimum", settings.minimum());
|
||||
config.set("height.maximum", settings.maximum());
|
||||
Path temporary = Files.createTempFile(path.getParent(), "config-", ".yml.tmp");
|
||||
try {
|
||||
Files.writeString(temporary, config.saveToString(), StandardCharsets.UTF_8);
|
||||
try {
|
||||
Files.move(temporary, path, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (AtomicMoveNotSupportedException exception) {
|
||||
Files.move(temporary, path, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
} finally {
|
||||
Files.deleteIfExists(temporary);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import java.util.Objects;
|
||||
import org.bukkit.command.PluginCommand;
|
||||
import org.bukkit.configuration.file.FileConfiguration;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
public final class SpigotHeightsPlugin extends JavaPlugin {
|
||||
private BukkitStatureService stature;
|
||||
private PotionRecipes potions;
|
||||
private RecipeLifecycle recipeLifecycle;
|
||||
private BukkitCarry carry;
|
||||
@Override
|
||||
public void onEnable() {
|
||||
saveDefaultConfig();
|
||||
@@ -16,22 +22,69 @@ public final class SpigotHeightsPlugin extends JavaPlugin {
|
||||
return;
|
||||
}
|
||||
|
||||
SettingsFileStore configStore = new SettingsFileStore(getDataFolder().toPath().resolve("config.yml"));
|
||||
LiveHeightSettings liveSettings = new LiveHeightSettings(settings, next -> {
|
||||
configStore.save(next);
|
||||
getConfig().set("height.minimum", next.minimum());
|
||||
getConfig().set("height.maximum", next.maximum());
|
||||
});
|
||||
HeightStore store = new HeightStore(getDataFolder());
|
||||
PotionRecipes potions = new PotionRecipes(this);
|
||||
potions.register();
|
||||
BukkitPlayerHeights onlinePlayers = new BukkitPlayerHeights(getServer());
|
||||
PlayerHeights playerHeights = new PlayerHeights(liveSettings, store, onlinePlayers::find, onlinePlayers::names);
|
||||
stature = new BukkitStatureService(this, liveSettings, store);
|
||||
carry = new BukkitCarry(getServer(), liveSettings,
|
||||
task -> getServer().getScheduler().runTask(this, task), getLogger());
|
||||
getServer().getPluginManager().registerEvents(carry, this);
|
||||
// Includes external attribute modifiers and US-009 expiry/admin updates; no movement-event scans.
|
||||
getServer().getScheduler().runTaskTimer(this, carry::tick, 5L, 5L);
|
||||
HeightsCommand executor = new HeightsCommand(liveSettings, playerHeights, getLogger(), stature::set, carry::command);
|
||||
PluginCommand command = Objects.requireNonNull(getCommand("heights"), "Missing heights command declaration");
|
||||
command.setExecutor(executor);
|
||||
command.setTabCompleter(executor);
|
||||
|
||||
potions = new PotionRecipes(this);
|
||||
recipeLifecycle = new RecipeLifecycle(getServer(), this, potions, getLogger());
|
||||
recipeLifecycle.enable();
|
||||
getServer().getPluginManager().registerEvents(new StatureBrewing(potions), this);
|
||||
getServer().getPluginManager().registerEvents(
|
||||
new StatureListener(this, settings, store, potions), this);
|
||||
getServer().getPluginManager().registerEvents(new TinyPlayerLauncher(settings), this);
|
||||
new StatureListener(potions, stature::drink, stature::resume,
|
||||
task -> getServer().getScheduler().runTask(this, task)), this);
|
||||
ThrowableStatureListener throwable = new ThrowableStatureListener(potions,
|
||||
new org.bukkit.NamespacedKey(this, "cloud_kind"), stature::temporary);
|
||||
getServer().getPluginManager().registerEvents(throwable, this);
|
||||
StatureClouds clouds = new StatureClouds(getServer(), throwable, stature::retireSource);
|
||||
getServer().getPluginManager().registerEvents(clouds, this);
|
||||
getServer().getScheduler().runTaskTimer(this, clouds::tick, 5L, 5L);
|
||||
getServer().getScheduler().runTaskTimer(this, stature::expireOnline, 20L, 20L);
|
||||
getServer().getOnlinePlayers().forEach(stature::resume);
|
||||
getServer().getPluginManager().registerEvents(new TinyPlayerLauncher(liveSettings), this);
|
||||
getLogger().info("Spigot Heights enabled.");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
if (carry != null) {
|
||||
carry.close();
|
||||
}
|
||||
if (stature != null) {
|
||||
stature.close();
|
||||
}
|
||||
if (recipeLifecycle != null) {
|
||||
recipeLifecycle.disable();
|
||||
}
|
||||
}
|
||||
|
||||
static HeightSettings loadSettings(FileConfiguration config) {
|
||||
Object configuredGap = config.get("carry.minimum-scale-gap", HeightSettings.DEFAULT_CARRY_MINIMUM_SCALE_GAP);
|
||||
if (!(configuredGap instanceof Number gap)) {
|
||||
throw new IllegalArgumentException("carry.minimum-scale-gap must be a finite nonnegative number");
|
||||
}
|
||||
return new HeightSettings(
|
||||
config.getDouble("height.minimum"),
|
||||
config.getDouble("height.maximum"),
|
||||
config.getDouble("height.adjustment-step"),
|
||||
config.getDouble("launcher.maximum-player-scale-exclusive"),
|
||||
config.getDouble("launcher.speed"),
|
||||
config.getInt("launcher.cooldown-ticks"));
|
||||
config.getInt("launcher.cooldown-ticks"), gap.doubleValue());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.inventory.BrewEvent;
|
||||
|
||||
/** Validates authenticated stature conversions at the native brewing transaction boundary. */
|
||||
public final class StatureBrewing implements Listener {
|
||||
private final PotionRecipes potions;
|
||||
|
||||
public StatureBrewing(PotionRecipes potions) {
|
||||
this.potions = potions;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true)
|
||||
public void detachResults(BrewEvent event) {
|
||||
if (event.isCancelled()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Native no-op results can mirror the input itself. Detach before normal result modifiers
|
||||
// run, otherwise an in-place result edit also changes the inventory despite cancellation.
|
||||
for (int slot = 0; slot < Math.min(3, event.getResults().size()); slot++) {
|
||||
ItemStack input = event.getContents().getItem(slot);
|
||||
if (potions.identify(input) != null && input.equals(event.getResults().get(slot))) {
|
||||
event.getResults().set(slot, input.clone());
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
event.setCancelled(true);
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void brew(BrewEvent event) {
|
||||
try {
|
||||
convert(event);
|
||||
} catch (RuntimeException exception) {
|
||||
// Paper logs listener exceptions and continues dispatch: cancel first to prevent item loss.
|
||||
event.setCancelled(true);
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
private void convert(BrewEvent event) {
|
||||
ItemStack ingredient = event.getContents().getIngredient();
|
||||
if (event.isCancelled() || ingredient == null) {
|
||||
return;
|
||||
}
|
||||
Material source;
|
||||
Material target;
|
||||
if (ingredient.getType() == Material.GUNPOWDER) {
|
||||
source = Material.POTION;
|
||||
target = Material.SPLASH_POTION;
|
||||
} else if (ingredient.getType() == Material.DRAGON_BREATH) {
|
||||
source = Material.SPLASH_POTION;
|
||||
target = Material.LINGERING_POTION;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
for (int slot = 0; slot < 3; slot++) {
|
||||
ItemStack input = event.getContents().getItem(slot);
|
||||
StaturePotion kind = potions.identify(input);
|
||||
if (kind != null && input.getType() == source) {
|
||||
if (slot >= event.getResults().size() || input.getAmount() != 1) {
|
||||
event.setCancelled(true);
|
||||
return;
|
||||
}
|
||||
ItemStack result = event.getResults().get(slot);
|
||||
if (result != null && result.getType() == target
|
||||
&& result.getAmount() == 1 && potions.identify(result) == kind) {
|
||||
continue;
|
||||
}
|
||||
if (!input.equals(result)) {
|
||||
// Do not overwrite another plugin's output or spend ingredients on a failed conversion.
|
||||
event.setCancelled(true);
|
||||
return;
|
||||
}
|
||||
// Correct only an unexpected native no-op; leave inventory and ingredient accounting
|
||||
// to the server. Build 2651 normally returns the registered native result directly.
|
||||
event.getResults().set(slot, potions.create(kind, target));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.entity.AreaEffectCloud;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.LingeringPotionSplashEvent;
|
||||
import org.bukkit.event.world.EntitiesLoadEvent;
|
||||
import org.bukkit.event.world.EntitiesUnloadEvent;
|
||||
|
||||
/** Tracks only loaded, authenticated Heights clouds; does not scan worlds every tick. */
|
||||
public final class StatureClouds implements Listener {
|
||||
private final Server server;
|
||||
private final ThrowableStatureListener listener;
|
||||
private final java.util.function.Consumer<UUID> retire;
|
||||
private final Map<UUID, AreaEffectCloud> clouds = new HashMap<>();
|
||||
|
||||
public StatureClouds(Server server, ThrowableStatureListener listener, java.util.function.Consumer<UUID> retire) {
|
||||
this.server = server;
|
||||
this.listener = listener;
|
||||
this.retire = retire;
|
||||
server.getWorlds().forEach(world -> world.getEntitiesByClass(AreaEffectCloud.class).forEach(this::track));
|
||||
}
|
||||
|
||||
private void track(Entity entity) {
|
||||
if (entity instanceof AreaEffectCloud cloud && listener.cloudKind(cloud) != null) {
|
||||
clouds.put(cloud.getUniqueId(), cloud);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onCreate(LingeringPotionSplashEvent event) {
|
||||
if (!event.isCancelled()) {
|
||||
track(event.getAreaEffectCloud());
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onLoad(EntitiesLoadEvent event) {
|
||||
event.getEntities().forEach(this::track);
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onUnload(EntitiesUnloadEvent event) {
|
||||
event.getEntities().forEach(entity -> clouds.remove(entity.getUniqueId()));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onRemove(org.bukkit.event.entity.EntityRemoveEvent event) {
|
||||
if (event.getCause() != org.bukkit.event.entity.EntityRemoveEvent.Cause.UNLOAD
|
||||
&& listener.isStatureSource(event.getEntity())) {
|
||||
clouds.remove(event.getEntity().getUniqueId());
|
||||
retire.accept(event.getEntity().getUniqueId());
|
||||
}
|
||||
}
|
||||
|
||||
public void tick() {
|
||||
clouds.values().removeIf(cloud -> !cloud.isValid());
|
||||
// Event callbacks may load/unload entities; iterate a snapshot to avoid reentrant mutation.
|
||||
for (AreaEffectCloud cloud : java.util.List.copyOf(clouds.values())) {
|
||||
listener.tickCloud(cloud, server.getPluginManager()::callEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import org.bukkit.attribute.Attribute;
|
||||
import org.bukkit.attribute.AttributeInstance;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.Keyed;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
@@ -14,20 +14,19 @@ import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.bukkit.event.player.PlayerRespawnEvent;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.Recipe;
|
||||
import org.bukkit.Keyed;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
public final class StatureListener implements Listener {
|
||||
private final JavaPlugin plugin;
|
||||
private final HeightSettings settings;
|
||||
private final HeightStore store;
|
||||
private final PotionRecipes potions;
|
||||
private final BiConsumer<Player, StaturePotion> drink;
|
||||
private final BiConsumer<Player, Boolean> resume;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
|
||||
public StatureListener(JavaPlugin plugin, HeightSettings settings, HeightStore store, PotionRecipes potions) {
|
||||
this.plugin = plugin;
|
||||
this.settings = settings;
|
||||
this.store = store;
|
||||
public StatureListener(PotionRecipes potions, BiConsumer<Player, StaturePotion> drink,
|
||||
BiConsumer<Player, Boolean> resume, Consumer<Runnable> nextTick) {
|
||||
this.potions = potions;
|
||||
this.drink = drink;
|
||||
this.resume = resume;
|
||||
this.nextTick = nextTick;
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
@@ -44,54 +43,22 @@ public final class StatureListener implements Listener {
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onConsume(PlayerItemConsumeEvent event) {
|
||||
StaturePotion kind = potions.identify(event.getItem());
|
||||
if (kind == null) {
|
||||
if (event.isCancelled() || event.getItem().getType() != Material.POTION) {
|
||||
return;
|
||||
}
|
||||
Player player = event.getPlayer();
|
||||
double current = currentScale(player);
|
||||
double scale = switch (kind) {
|
||||
case SHIFTING -> HeightMath.randomScale(settings,
|
||||
bound -> ThreadLocalRandom.current().nextInt(bound));
|
||||
case GROWTH -> HeightMath.grow(current, settings);
|
||||
case DIMINUTION -> HeightMath.shrink(current, settings);
|
||||
};
|
||||
applyAndSave(player, scale);
|
||||
player.sendMessage("Your scale is now " + scale + ".");
|
||||
StaturePotion kind = potions.identify(event.getItem());
|
||||
if (kind != null) {
|
||||
drink.accept(event.getPlayer(), kind);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onJoin(PlayerJoinEvent event) {
|
||||
apply(event.getPlayer(), HeightMath.safeStoredScale(store.find(event.getPlayer().getUniqueId()), settings));
|
||||
resume.accept(event.getPlayer(), false);
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onRespawn(PlayerRespawnEvent event) {
|
||||
plugin.getServer().getScheduler().runTask(plugin, () -> {
|
||||
Player player = event.getPlayer();
|
||||
apply(player, HeightMath.safeStoredScale(store.find(player.getUniqueId()), settings));
|
||||
});
|
||||
}
|
||||
|
||||
private void applyAndSave(Player player, double scale) {
|
||||
apply(player, scale);
|
||||
try {
|
||||
store.save(player.getUniqueId(), scale);
|
||||
} catch (IOException exception) {
|
||||
plugin.getLogger().severe("Could not save scale for " + player.getUniqueId() + ": "
|
||||
+ exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static void apply(Player player, double scale) {
|
||||
AttributeInstance attribute = player.getAttribute(Attribute.SCALE);
|
||||
if (attribute != null) {
|
||||
attribute.setBaseValue(scale);
|
||||
}
|
||||
}
|
||||
|
||||
private static double currentScale(Player player) {
|
||||
AttributeInstance attribute = player.getAttribute(Attribute.SCALE);
|
||||
return attribute == null ? 1.0 : attribute.getBaseValue();
|
||||
nextTick.accept(() -> resume.accept(event.getPlayer(), true));
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import java.util.function.IntUnaryOperator;
|
||||
|
||||
public enum StaturePotion {
|
||||
SHIFTING("Potion of Shifting Stature"),
|
||||
GROWTH("Potion of Growth"),
|
||||
DIMINUTION("Potion of Diminution");
|
||||
DIMINUTION("Potion of Diminution"),
|
||||
RESTORATION("Potion of Restoration");
|
||||
|
||||
private final String displayName;
|
||||
|
||||
@@ -11,6 +14,15 @@ public enum StaturePotion {
|
||||
this.displayName = displayName;
|
||||
}
|
||||
|
||||
public double scaleAfterDrinking(double current, HeightSettings settings, IntUnaryOperator randomIndex) {
|
||||
return switch (this) {
|
||||
case SHIFTING -> HeightMath.randomScale(settings, randomIndex);
|
||||
case GROWTH -> HeightMath.grow(current, settings);
|
||||
case DIMINUTION -> HeightMath.shrink(current, settings);
|
||||
case RESTORATION -> 1.0;
|
||||
};
|
||||
}
|
||||
|
||||
public String displayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/** Serializes main-thread capture, worker persistence, and main-thread completion. */
|
||||
public final class StatureQueue {
|
||||
private final Executor worker;
|
||||
private final Executor main;
|
||||
private final ArrayDeque<Job> jobs = new ArrayDeque<>();
|
||||
private boolean running;
|
||||
private boolean closed;
|
||||
|
||||
private record Job(Supplier<Callable<Double>> prepare, BiConsumer<Double, Exception> complete) {}
|
||||
|
||||
public StatureQueue(Executor worker, Executor main) {
|
||||
this.worker = worker;
|
||||
this.main = main;
|
||||
}
|
||||
|
||||
public void submit(Supplier<Callable<Double>> prepare, BiConsumer<Double, Exception> complete) {
|
||||
if (closed) {
|
||||
complete.accept(null, new IllegalStateException("Stature service is shutting down."));
|
||||
return;
|
||||
}
|
||||
if (jobs.size() >= 256) {
|
||||
complete.accept(null, new IllegalStateException("Too many pending stature operations; try again shortly."));
|
||||
return;
|
||||
}
|
||||
jobs.addLast(new Job(prepare, complete));
|
||||
start();
|
||||
}
|
||||
|
||||
public void close() {
|
||||
closed = true;
|
||||
while (!jobs.isEmpty()) {
|
||||
jobs.removeFirst().complete().accept(null, new IllegalStateException("Stature service is shutting down."));
|
||||
}
|
||||
}
|
||||
|
||||
private void start() {
|
||||
if (running || jobs.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
running = true;
|
||||
Job job = jobs.removeFirst();
|
||||
try {
|
||||
Callable<Double> work = job.prepare().get();
|
||||
worker.execute(() -> {
|
||||
Double result = null;
|
||||
Exception failure = null;
|
||||
try {
|
||||
result = work.call();
|
||||
} catch (Exception exception) {
|
||||
failure = exception;
|
||||
}
|
||||
Double saved = result;
|
||||
Exception error = failure;
|
||||
main.execute(() -> finish(job, saved, error));
|
||||
});
|
||||
} catch (Exception exception) {
|
||||
finish(job, null, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void finish(Job job, Double result, Exception error) {
|
||||
try {
|
||||
job.complete().accept(result, error);
|
||||
} finally {
|
||||
running = false;
|
||||
start();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
import java.util.function.IntUnaryOperator;
|
||||
|
||||
/** Durable stature operations, independent of the server. */
|
||||
public final class TemporaryStature {
|
||||
private final HeightStore store;
|
||||
|
||||
public TemporaryStature(HeightStore store) {
|
||||
this.store = store;
|
||||
}
|
||||
|
||||
public Double applyOnce(UUID player, UUID source, double current, StaturePotion kind, HeightSettings settings,
|
||||
Instant now, IntUnaryOperator random) throws IOException {
|
||||
if (source != null && store.hasReceipt(player, source)) {
|
||||
return null;
|
||||
}
|
||||
HeightStore.Sequence sequence = store.sequence(player);
|
||||
if (sequence != null && !now.isBefore(sequence.expiresAt())) {
|
||||
current = sequence.baseline();
|
||||
sequence = null;
|
||||
}
|
||||
double baseline = sequence == null ? current : sequence.baseline();
|
||||
double result = kind.scaleAfterDrinking(current, settings, random);
|
||||
store.save(player, result, new HeightStore.Sequence(baseline, now.plusSeconds(300)), source);
|
||||
return result;
|
||||
}
|
||||
|
||||
public double apply(UUID player, double current, StaturePotion kind, HeightSettings settings,
|
||||
Instant now, IntUnaryOperator random) throws IOException {
|
||||
return applyOnce(player, null, current, kind, settings, now, random);
|
||||
}
|
||||
|
||||
public double drink(UUID player, double current, StaturePotion kind, HeightSettings settings,
|
||||
Instant now, IntUnaryOperator random) throws IOException {
|
||||
HeightStore.Sequence sequence = store.sequence(player);
|
||||
if (sequence != null && !now.isBefore(sequence.expiresAt())) {
|
||||
current = sequence.baseline();
|
||||
}
|
||||
double result = kind.scaleAfterDrinking(current, settings, random);
|
||||
store.save(player, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
public void acknowledgeRestoration(UUID player, HeightStore.Sequence expected) throws IOException {
|
||||
if (expected != null && expected.equals(store.sequence(player))
|
||||
&& Double.valueOf(expected.baseline()).equals(store.find(player))) {
|
||||
store.save(player, expected.baseline());
|
||||
}
|
||||
}
|
||||
|
||||
public double resume(UUID player, HeightSettings settings, Instant now) throws IOException {
|
||||
HeightStore.Sequence sequence = store.sequence(player);
|
||||
if (sequence != null && !now.isBefore(sequence.expiresAt())) {
|
||||
// Keep an expired restoration pending until the server actually applies its result.
|
||||
store.save(player, sequence.baseline(), sequence);
|
||||
return sequence.baseline();
|
||||
}
|
||||
return HeightMath.safeStoredScale(store.find(player), settings);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.persistence.PersistentDataContainer;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.AreaEffectCloudApplyEvent;
|
||||
import org.bukkit.event.entity.LingeringPotionSplashEvent;
|
||||
import org.bukkit.event.entity.PotionSplashEvent;
|
||||
|
||||
/** Bukkit delivery boundary; the effect service reports durable application success. */
|
||||
public final class ThrowableStatureListener implements Listener {
|
||||
@FunctionalInterface
|
||||
public interface Effects {
|
||||
void apply(java.util.UUID source, Player player, StaturePotion kind, Consumer<Boolean> completed);
|
||||
}
|
||||
|
||||
private final PotionRecipes potions;
|
||||
private final NamespacedKey cloudKind;
|
||||
private final Effects effects;
|
||||
private record Delivery(java.util.UUID source, java.util.UUID player) {}
|
||||
private final java.util.Set<Delivery> pending = new java.util.HashSet<>();
|
||||
|
||||
public ThrowableStatureListener(PotionRecipes potions, NamespacedKey cloudKind, Effects effects) {
|
||||
this.potions = potions;
|
||||
this.cloudKind = cloudKind;
|
||||
this.effects = effects;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onSplash(PotionSplashEvent event) {
|
||||
if (event.isCancelled() || event.getPotion().getItem().getType() != Material.SPLASH_POTION) {
|
||||
return;
|
||||
}
|
||||
StaturePotion kind = potions.identify(event.getPotion().getItem());
|
||||
if (kind == null) {
|
||||
return;
|
||||
}
|
||||
for (var entity : event.getAffectedEntities()) {
|
||||
if (entity instanceof Player player && event.getIntensity(entity) > 0) {
|
||||
applyOnce(event.getPotion(), player, kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void applyOnce(Entity source, Player player, StaturePotion kind) {
|
||||
PersistentDataContainer data = source.getPersistentDataContainer();
|
||||
NamespacedKey receipt = new NamespacedKey(cloudKind.getNamespace(), "hit_" + player.getUniqueId());
|
||||
Delivery delivery = new Delivery(source.getUniqueId(), player.getUniqueId());
|
||||
if (data.has(receipt) || !player.isOnline() || player.isDead() || !pending.add(delivery)) {
|
||||
return;
|
||||
}
|
||||
// Pending reservations are not serialized by chunk unload. The service commits the
|
||||
// successful receipt atomically with stature, so reload cannot duplicate an application.
|
||||
try {
|
||||
effects.apply(source.getUniqueId(), player, kind, success -> {
|
||||
pending.remove(delivery);
|
||||
if (success) {
|
||||
data.set(receipt, PersistentDataType.BYTE, (byte) 1);
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException exception) {
|
||||
pending.remove(delivery);
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void onLingering(LingeringPotionSplashEvent event) {
|
||||
if (event.isCancelled() || event.getEntity().getItem().getType() != Material.LINGERING_POTION) {
|
||||
return;
|
||||
}
|
||||
StaturePotion kind = potions.identify(event.getEntity().getItem());
|
||||
if (kind != null) {
|
||||
event.allowsEmptyCreation(true);
|
||||
event.getAreaEffectCloud().getPersistentDataContainer().set(cloudKind,
|
||||
PersistentDataType.STRING, kind.name());
|
||||
}
|
||||
}
|
||||
|
||||
/** Empty vanilla clouds do not discover victims. Dispatch the normal cancellable event ourselves. */
|
||||
public void tickCloud(org.bukkit.entity.AreaEffectCloud cloud, Consumer<AreaEffectCloudApplyEvent> dispatch) {
|
||||
if (!cloud.isValid() || cloudKind(cloud) == null || cloud.getTicksLived() < cloud.getWaitTime()) {
|
||||
return;
|
||||
}
|
||||
double radius = cloud.getRadius();
|
||||
if (radius < 0.5) {
|
||||
return;
|
||||
}
|
||||
var center = cloud.getLocation();
|
||||
java.util.List<org.bukkit.entity.LivingEntity> targets = new java.util.ArrayList<>();
|
||||
for (Entity entity : cloud.getNearbyEntities(radius, 0.5, radius)) {
|
||||
if (entity instanceof Player player && player.isOnline() && !player.isDead()
|
||||
&& player.getGameMode() != org.bukkit.GameMode.SPECTATOR
|
||||
&& !hasReceipt(cloud, player) && cloud.getBoundingBox().overlaps(player.getBoundingBox())) {
|
||||
var position = player.getLocation();
|
||||
double x = position.getX() - center.getX();
|
||||
double z = position.getZ() - center.getZ();
|
||||
if (x * x + z * z <= radius * radius) {
|
||||
targets.add(player);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!targets.isEmpty()) {
|
||||
dispatch.accept(new AreaEffectCloudApplyEvent(cloud, targets));
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasReceipt(Entity source, Player player) {
|
||||
return pending.contains(new Delivery(source.getUniqueId(), player.getUniqueId()))
|
||||
|| source.getPersistentDataContainer().has(new NamespacedKey(cloudKind.getNamespace(),
|
||||
"hit_" + player.getUniqueId()));
|
||||
}
|
||||
|
||||
boolean isStatureSource(Entity source) {
|
||||
return source instanceof org.bukkit.entity.AreaEffectCloud cloud && cloudKind(cloud) != null
|
||||
|| source instanceof org.bukkit.entity.ThrownPotion thrown && potions.identify(thrown.getItem()) != null;
|
||||
}
|
||||
|
||||
StaturePotion cloudKind(org.bukkit.entity.AreaEffectCloud cloud) {
|
||||
PersistentDataContainer data = cloud.getPersistentDataContainer();
|
||||
if (!data.has(cloudKind, PersistentDataType.STRING)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return StaturePotion.valueOf(data.get(cloudKind, PersistentDataType.STRING));
|
||||
} catch (IllegalArgumentException exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onCloud(AreaEffectCloudApplyEvent event) {
|
||||
if (event.isCancelled()) {
|
||||
return;
|
||||
}
|
||||
StaturePotion kind = cloudKind(event.getEntity());
|
||||
if (kind != null) {
|
||||
for (var entity : event.getAffectedEntities()) {
|
||||
if (entity instanceof Player player) {
|
||||
applyOnce(event.getEntity(), player, kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package games.dmg.spigotheights;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
@@ -19,11 +20,11 @@ import org.bukkit.event.player.PlayerMoveEvent;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
public final class TinyPlayerLauncher implements Listener {
|
||||
private final HeightSettings settings;
|
||||
private final Supplier<HeightSettings> settingsSupplier;
|
||||
private final Map<UUID, Long> lastLaunchTicks = new HashMap<>();
|
||||
|
||||
public TinyPlayerLauncher(HeightSettings settings) {
|
||||
this.settings = settings;
|
||||
public TinyPlayerLauncher(Supplier<HeightSettings> settingsSupplier) {
|
||||
this.settingsSupplier = settingsSupplier;
|
||||
}
|
||||
|
||||
@EventHandler(ignoreCancelled = true)
|
||||
@@ -32,6 +33,7 @@ public final class TinyPlayerLauncher implements Listener {
|
||||
if (destination == null || sameBlock(event.getFrom(), destination)) {
|
||||
return;
|
||||
}
|
||||
HeightSettings settings = settingsSupplier.get();
|
||||
Player player = event.getPlayer();
|
||||
if (!LauncherPolicy.isSmallEnough(scale(player), settings.launcherThreshold())) {
|
||||
return;
|
||||
|
||||
@@ -3,6 +3,10 @@ height:
|
||||
maximum: 2.0
|
||||
adjustment-step: 0.1
|
||||
|
||||
# Independent of potion adjustment-step. Zero still requires a strictly larger carrier.
|
||||
carry:
|
||||
minimum-scale-gap: 0.1
|
||||
|
||||
launcher:
|
||||
maximum-player-scale-exclusive: 0.5
|
||||
speed: 1.5
|
||||
|
||||
@@ -4,3 +4,11 @@ main: games.dmg.spigotheights.SpigotHeightsPlugin
|
||||
api-version: "1.21"
|
||||
description: Craftable player stature potions and tiny-player dispenser launchers.
|
||||
author: dmg.games
|
||||
commands:
|
||||
heights:
|
||||
description: Stop a ride or administer stature settings and player heights.
|
||||
usage: /heights carry stop | /heights settings | /heights set min|max <value> | /heights player <name> [scale]
|
||||
permissions:
|
||||
spigotheights.admin:
|
||||
description: View and change Spigot Heights settings and player heights.
|
||||
default: op
|
||||
@@ -0,0 +1,423 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.logging.Logger;
|
||||
import io.papermc.paper.plugin.manager.PaperPluginManagerImpl;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.flag.FeatureFlags;
|
||||
import net.minecraft.world.item.crafting.RecipeManager;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.entity.BrewingStandBlockEntity;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.command.SimpleCommandMap;
|
||||
import org.bukkit.craftbukkit.inventory.CraftBrewingRecipe;
|
||||
import org.bukkit.craftbukkit.inventory.CraftInventoryBrewer;
|
||||
import org.bukkit.craftbukkit.inventory.CraftItemFactory;
|
||||
import org.bukkit.craftbukkit.util.CraftMagicNumbers;
|
||||
import org.bukkit.craftbukkit.util.CraftNamespacedKey;
|
||||
import org.bukkit.event.HandlerList;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.inventory.BrewEvent;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.entity.AreaEffectCloud;
|
||||
import org.bukkit.entity.ThrownPotion;
|
||||
import org.bukkit.event.entity.PotionSplashEvent;
|
||||
import org.bukkit.event.entity.LingeringPotionSplashEvent;
|
||||
import org.bukkit.event.entity.AreaEffectCloudApplyEvent;
|
||||
import org.bukkit.inventory.InventoryHolder;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.PotionMeta;
|
||||
import org.bukkit.potion.PotionType;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.bukkit.plugin.PluginDescriptionFile;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import org.bukkit.potion.PotionBrewer;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.EnumSource;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
|
||||
/** Real build-2651 brewing ticks, items, metadata, recipes, event dispatch and ingredient consumption. */
|
||||
class NativeBrewingTest {
|
||||
private static PluginManager events;
|
||||
private static Plugin plugin;
|
||||
private PotionRecipes recipes;
|
||||
private RecipeManager engine;
|
||||
|
||||
@BeforeAll
|
||||
static void bootstrap() throws Exception {
|
||||
NativeRuntime.bootstrap();
|
||||
Server server = mock(Server.class);
|
||||
when(server.getItemFactory()).thenAnswer(invocation -> CraftItemFactory.instance());
|
||||
when(server.getUnsafe()).thenReturn(CraftMagicNumbers.INSTANCE);
|
||||
when(server.getLogger()).thenReturn(Logger.getLogger("NativeBrewingTest"));
|
||||
when(server.getName()).thenReturn("NativeBrewingTest");
|
||||
when(server.getVersion()).thenReturn("26.3-2651");
|
||||
when(server.getBukkitVersion()).thenReturn("26.3-2651");
|
||||
when(server.isPrimaryThread()).thenReturn(true);
|
||||
events = new PaperPluginManagerImpl(server, new SimpleCommandMap(server, new HashMap<>()), null);
|
||||
when(server.getPluginManager()).thenReturn(events);
|
||||
Bukkit.setServer(server);
|
||||
plugin = mock(Plugin.class);
|
||||
when(plugin.isEnabled()).thenReturn(true);
|
||||
when(plugin.getName()).thenReturn("SpigotHeights");
|
||||
when(plugin.getLogger()).thenReturn(Logger.getLogger("NativeBrewingTest"));
|
||||
PluginDescriptionFile description = new PluginDescriptionFile("SpigotHeights", "test", "unused.Main");
|
||||
when(plugin.getDescription()).thenReturn(description);
|
||||
when(plugin.getPluginMeta()).thenReturn(description);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void registerActualRecipesAndListener() {
|
||||
HandlerList.unregisterAll();
|
||||
engine = NativeRuntime.recipes();
|
||||
recipes = new PotionRecipes(null, new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
PotionBrewer brewer = (PotionBrewer) Proxy.newProxyInstance(PotionBrewer.class.getClassLoader(),
|
||||
new Class<?>[] {PotionBrewer.class}, (proxy, method, args) -> {
|
||||
switch (method.getName()) {
|
||||
case "addPotionMix" -> {
|
||||
var mix = (io.papermc.paper.potion.PotionMix) args[0];
|
||||
var recipe = new org.bukkit.inventory.BrewingRecipe(
|
||||
mix.getKey(), mix.getResult(), mix.getInput(), mix.getIngredient());
|
||||
engine.recipes.addRecipe(CraftBrewingRecipe.fromBukkitRecipe(recipe).toMinecraftRecipe());
|
||||
engine.finalizeRecipeLoading(FeatureFlags.VANILLA_SET);
|
||||
}
|
||||
case "removePotionMix" -> {
|
||||
engine.recipes.removeRecipe(CraftNamespacedKey.toResourceKey(
|
||||
Registries.RECIPE, (NamespacedKey) args[0]));
|
||||
engine.finalizeRecipeLoading(FeatureFlags.VANILLA_SET);
|
||||
}
|
||||
default -> throw new UnsupportedOperationException(method.getName());
|
||||
}
|
||||
return null;
|
||||
});
|
||||
recipes.registerBrewing(brewer);
|
||||
events.registerEvents(new StatureBrewing(recipes), plugin);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(StaturePotion.class)
|
||||
void purpur26_3ConvertsBaseLessStaturePotionsWithoutTheBuild2618Correction(StaturePotion kind) {
|
||||
HandlerList.unregisterAll();
|
||||
NativeStand stand = new NativeStand();
|
||||
stand.inventory.setItem(0, recipes.create(kind));
|
||||
stand.inventory.setIngredient(new ItemStack(Material.GUNPOWDER));
|
||||
stand.completeCycle();
|
||||
ItemStack result = stand.inventory.getItem(0);
|
||||
assertEquals(Material.SPLASH_POTION, result.getType());
|
||||
assertEquals(kind, recipes.identify(result));
|
||||
assertTrue(stand.getItem(3).isEmpty());
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(StaturePotion.class)
|
||||
void gunpowderActuallyConvertsEveryAuthenticatedDrinkable(StaturePotion kind) {
|
||||
NativeStand stand = new NativeStand();
|
||||
stand.inventory.setItem(0, recipes.create(kind));
|
||||
stand.inventory.setIngredient(new ItemStack(Material.GUNPOWDER));
|
||||
stand.completeCycle();
|
||||
ItemStack result = stand.inventory.getItem(0);
|
||||
assertEquals(Material.SPLASH_POTION, result.getType(), "recognized recipe must actually convert");
|
||||
assertEquals(kind, recipes.identify(result));
|
||||
assertTrue(stand.getItem(3).isEmpty(), "one completed brew consumes one ingredient");
|
||||
assertSplashApplies(result, kind);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(StaturePotion.class)
|
||||
void dragonBreathConvertsExistingSplashIntoFunctionalLingering(StaturePotion kind) {
|
||||
NativeStand stand = new NativeStand();
|
||||
stand.inventory.setItem(0, recipes.create(kind, Material.SPLASH_POTION));
|
||||
stand.inventory.setIngredient(new ItemStack(Material.DRAGON_BREATH));
|
||||
stand.completeCycle();
|
||||
ItemStack result = stand.inventory.getItem(0);
|
||||
assertEquals(Material.LINGERING_POTION, result.getType());
|
||||
assertEquals(kind, recipes.identify(result));
|
||||
assertEquals(new ItemStack(Material.GLASS_BOTTLE), stand.inventory.getIngredient(),
|
||||
"one successful conversion spends one dragon's breath and retains its bottle");
|
||||
assertLingeringApplies(result, kind);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(StaturePotion.class)
|
||||
void newlyBrewedSplashCanImmediatelyBeBrewedAgain(StaturePotion kind) {
|
||||
NativeStand stand = new NativeStand();
|
||||
stand.inventory.setItem(0, recipes.create(kind));
|
||||
stand.inventory.setIngredient(new ItemStack(Material.GUNPOWDER));
|
||||
stand.completeCycle();
|
||||
assertEquals(Material.SPLASH_POTION, stand.inventory.getItem(0).getType());
|
||||
stand.inventory.setIngredient(new ItemStack(Material.DRAGON_BREATH));
|
||||
stand.completeCycle();
|
||||
assertEquals(Material.LINGERING_POTION, stand.inventory.getItem(0).getType());
|
||||
assertEquals(kind, recipes.identify(stand.inventory.getItem(0)));
|
||||
assertEquals(new ItemStack(Material.GLASS_BOTTLE), stand.inventory.getIngredient());
|
||||
assertEquals(18, stand.fuel);
|
||||
}
|
||||
|
||||
@Test
|
||||
void conflictingOutputCannotConsumeAnIngredientOrOverrideAnotherPlugin() {
|
||||
events.registerEvent(BrewEvent.class, new Listener() {}, EventPriority.NORMAL,
|
||||
(listener, event) -> ((BrewEvent) event).getResults().set(0, new ItemStack(Material.STONE)), plugin);
|
||||
NativeStand stand = new NativeStand();
|
||||
ItemStack original = recipes.create(StaturePotion.GROWTH);
|
||||
stand.inventory.setItem(0, original);
|
||||
stand.inventory.setIngredient(new ItemStack(Material.GUNPOWDER, 2));
|
||||
stand.completeCycle();
|
||||
assertEquals(2, stand.inventory.getIngredient().getAmount(), "failed conversion must not spend gunpowder");
|
||||
assertEquals(original, stand.inventory.getItem(0), "conflict cancels the native transaction");
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(value = EventPriority.class, names = {"LOW", "MONITOR"})
|
||||
void cancellationBeforeOrAfterHeightsPreservesInventory(EventPriority priority) {
|
||||
events.registerEvent(BrewEvent.class, new Listener() {}, priority,
|
||||
(listener, event) -> ((BrewEvent) event).setCancelled(true), plugin);
|
||||
NativeStand stand = new NativeStand();
|
||||
ItemStack original = recipes.create(StaturePotion.SHIFTING);
|
||||
stand.inventory.setItem(0, original);
|
||||
stand.inventory.setIngredient(new ItemStack(Material.GUNPOWDER, 2));
|
||||
stand.completeCycle();
|
||||
assertEquals(original, stand.inventory.getItem(0));
|
||||
assertEquals(2, stand.inventory.getIngredient().getAmount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingOutputCancelsTheWholeMixedBatchWithoutLosingItems() {
|
||||
events.registerEvent(BrewEvent.class, new Listener() {}, EventPriority.NORMAL,
|
||||
(listener, event) -> ((BrewEvent) event).getResults().remove(2), plugin);
|
||||
NativeStand stand = new NativeStand();
|
||||
ItemStack first = recipes.create(StaturePotion.GROWTH);
|
||||
ItemStack last = recipes.create(StaturePotion.DIMINUTION);
|
||||
stand.inventory.setItem(0, first);
|
||||
stand.inventory.setItem(2, last);
|
||||
stand.inventory.setIngredient(new ItemStack(Material.GUNPOWDER));
|
||||
stand.completeCycle();
|
||||
assertEquals(first, stand.inventory.getItem(0));
|
||||
assertEquals(last, stand.inventory.getItem(2));
|
||||
assertEquals(1, stand.inventory.getIngredient().getAmount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void oneIngredientConvertsMixedSlotsWithoutTouchingIneligiblePotions() {
|
||||
NativeStand stand = new NativeStand();
|
||||
stand.inventory.setItem(0, recipes.create(StaturePotion.GROWTH));
|
||||
stand.inventory.setItem(1, recipes.create(StaturePotion.DIMINUTION));
|
||||
ItemStack alreadySplash = recipes.create(StaturePotion.RESTORATION, Material.SPLASH_POTION);
|
||||
stand.inventory.setItem(2, alreadySplash);
|
||||
stand.inventory.setIngredient(new ItemStack(Material.GUNPOWDER, 2));
|
||||
stand.completeCycle();
|
||||
assertEquals(Material.SPLASH_POTION, stand.inventory.getItem(0).getType());
|
||||
assertEquals(StaturePotion.GROWTH, recipes.identify(stand.inventory.getItem(0)));
|
||||
assertEquals(Material.SPLASH_POTION, stand.inventory.getItem(1).getType());
|
||||
assertEquals(StaturePotion.DIMINUTION, recipes.identify(stand.inventory.getItem(1)));
|
||||
assertEquals(alreadySplash, stand.inventory.getItem(2));
|
||||
assertEquals(1, stand.inventory.getIngredient().getAmount());
|
||||
assertEquals(19, stand.fuel, "fuel accounting remains native");
|
||||
}
|
||||
|
||||
@Test
|
||||
void ordinaryAndNameOnlyPotionsRetainNativeBrewingAndNeverGainStatureIdentity() {
|
||||
NativeStand stand = new NativeStand();
|
||||
ItemStack ordinary = new ItemStack(Material.POTION);
|
||||
PotionMeta meta = (PotionMeta) ordinary.getItemMeta();
|
||||
meta.setBasePotionType(PotionType.HEALING);
|
||||
meta.displayName(net.kyori.adventure.text.Component.text(StaturePotion.GROWTH.displayName()));
|
||||
ordinary.setItemMeta(meta);
|
||||
stand.inventory.setItem(0, ordinary);
|
||||
stand.inventory.setItem(2, recipes.create(StaturePotion.SHIFTING));
|
||||
stand.inventory.setIngredient(new ItemStack(Material.GUNPOWDER));
|
||||
stand.completeCycle();
|
||||
ItemStack result = stand.inventory.getItem(0);
|
||||
assertEquals(Material.SPLASH_POTION, result.getType());
|
||||
assertEquals(PotionType.HEALING, ((PotionMeta) result.getItemMeta()).getBasePotionType());
|
||||
assertNull(recipes.identify(result));
|
||||
assertEquals(StaturePotion.SHIFTING, recipes.identify(stand.inventory.getItem(2)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void interruptedBrewDoesNotConvertOrSpendReplacementIngredient() {
|
||||
NativeStand stand = new NativeStand();
|
||||
ItemStack original = recipes.create(StaturePotion.RESTORATION);
|
||||
stand.inventory.setItem(0, original);
|
||||
stand.inventory.setIngredient(new ItemStack(Material.GUNPOWDER));
|
||||
stand.tick();
|
||||
assertEquals(400, stand.brewTime);
|
||||
stand.inventory.setIngredient(new ItemStack(Material.SUGAR, 2));
|
||||
stand.tick();
|
||||
assertEquals(0, stand.brewTime);
|
||||
assertEquals(original, stand.inventory.getItem(0));
|
||||
assertEquals(new ItemStack(Material.SUGAR, 2), stand.inventory.getIngredient());
|
||||
}
|
||||
|
||||
@Test
|
||||
void validNativeOutputFromBaseTypedPotionIsNotRewritten() {
|
||||
NativeStand stand = new NativeStand();
|
||||
ItemStack input = recipes.create(StaturePotion.GROWTH);
|
||||
PotionMeta meta = (PotionMeta) input.getItemMeta();
|
||||
meta.setBasePotionType(PotionType.THICK);
|
||||
input.setItemMeta(meta);
|
||||
var name = net.kyori.adventure.text.Component.text("Other plugin's valid result");
|
||||
events.registerEvent(BrewEvent.class, new Listener() {}, EventPriority.NORMAL, (listener, event) -> {
|
||||
ItemStack result = ((BrewEvent) event).getResults().get(0);
|
||||
assertEquals(Material.SPLASH_POTION, result.getType(), "base-typed items use the native custom mix");
|
||||
result.editMeta(resultMeta -> resultMeta.displayName(name));
|
||||
}, plugin);
|
||||
stand.inventory.setItem(0, input);
|
||||
stand.inventory.setIngredient(new ItemStack(Material.GUNPOWDER));
|
||||
stand.completeCycle();
|
||||
assertEquals(name, stand.inventory.getItem(0).getItemMeta().displayName());
|
||||
assertEquals(StaturePotion.GROWTH, recipes.identify(stand.inventory.getItem(0)));
|
||||
assertTrue(stand.getItem(3).isEmpty());
|
||||
}
|
||||
|
||||
static java.util.stream.Stream<Arguments> conversionEdits() {
|
||||
return java.util.stream.Stream.of(Material.GUNPOWDER, Material.DRAGON_BREATH)
|
||||
.flatMap(ingredient -> java.util.stream.Stream.of(false, true)
|
||||
.map(removeIdentity -> Arguments.of(ingredient, removeIdentity)));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("conversionEdits")
|
||||
void nativeResultEditsRemainDetachedAndIdentityRemovalCannotSpendIngredients(
|
||||
Material ingredient, boolean removeIdentity) {
|
||||
events.registerEvent(BrewEvent.class, new Listener() {}, EventPriority.NORMAL, (listener, event) -> {
|
||||
ItemStack result = ((BrewEvent) event).getResults().get(0);
|
||||
result.editMeta(meta -> {
|
||||
if (removeIdentity) {
|
||||
meta.getPersistentDataContainer().remove(new NamespacedKey("spigotheights", "stature_potion"));
|
||||
} else {
|
||||
meta.displayName(net.kyori.adventure.text.Component.text("Other plugin's conflicting edit"));
|
||||
}
|
||||
});
|
||||
}, plugin);
|
||||
NativeStand stand = new NativeStand();
|
||||
ItemStack original = recipes.create(StaturePotion.GROWTH,
|
||||
ingredient == Material.GUNPOWDER ? Material.POTION : Material.SPLASH_POTION);
|
||||
stand.inventory.setItem(0, original);
|
||||
stand.inventory.setIngredient(new ItemStack(ingredient));
|
||||
stand.completeCycle();
|
||||
if (removeIdentity) {
|
||||
assertEquals(new ItemStack(ingredient), stand.inventory.getIngredient(),
|
||||
"identity removal cannot spend ingredients");
|
||||
assertEquals(original, stand.inventory.getItem(0),
|
||||
"cancelled identity removal must not mutate the input inventory");
|
||||
} else {
|
||||
assertEquals(ingredient == Material.GUNPOWDER ? null : new ItemStack(Material.GLASS_BOTTLE),
|
||||
stand.inventory.getIngredient());
|
||||
ItemStack result = stand.inventory.getItem(0);
|
||||
assertEquals(ingredient == Material.GUNPOWDER ? Material.SPLASH_POTION : Material.LINGERING_POTION,
|
||||
result.getType());
|
||||
assertEquals(StaturePotion.GROWTH, recipes.identify(result));
|
||||
assertEquals(net.kyori.adventure.text.Component.text("Other plugin's conflicting edit"),
|
||||
result.getItemMeta().displayName());
|
||||
assertEquals(StaturePotion.GROWTH, recipes.identify(original),
|
||||
"native result edits must stay detached from the input object");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void purpur26_3NativeOutputDoesNotNeedTheCorrectionFactory() {
|
||||
HandlerList.unregisterAll();
|
||||
PotionRecipes brokenFactory = new PotionRecipes(null, new NamespacedKey("spigotheights", "stature_potion"),
|
||||
material -> { throw new IllegalStateException("simulated item factory failure"); });
|
||||
events.registerEvents(new StatureBrewing(brokenFactory), plugin);
|
||||
NativeStand stand = new NativeStand();
|
||||
ItemStack original = recipes.create(StaturePotion.GROWTH);
|
||||
stand.inventory.setItem(0, original);
|
||||
stand.inventory.setIngredient(new ItemStack(Material.GUNPOWDER));
|
||||
stand.completeCycle();
|
||||
assertEquals(Material.SPLASH_POTION, stand.inventory.getItem(0).getType());
|
||||
assertEquals(StaturePotion.GROWTH, recipes.identify(stand.inventory.getItem(0)));
|
||||
assertTrue(stand.getItem(3).isEmpty());
|
||||
}
|
||||
|
||||
private void assertSplashApplies(ItemStack result, StaturePotion expected) {
|
||||
List<StaturePotion> applied = new ArrayList<>();
|
||||
ThrowableStatureListener effects = new ThrowableStatureListener(recipes,
|
||||
new NamespacedKey("spigotheights", "cloud_kind"), (source, player, kind, done) -> {
|
||||
applied.add(kind);
|
||||
done.accept(true);
|
||||
});
|
||||
Player player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(player.isOnline()).thenReturn(true);
|
||||
ThrownPotion thrown = mock(ThrownPotion.class);
|
||||
when(thrown.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(thrown.getItem()).thenReturn(result);
|
||||
when(thrown.getPersistentDataContainer()).thenReturn(new ItemStack(Material.STONE).getItemMeta().getPersistentDataContainer());
|
||||
var event = new PotionSplashEvent(thrown, null, null, null, Map.of(player, 1.0));
|
||||
effects.onSplash(event);
|
||||
effects.onSplash(event);
|
||||
assertEquals(List.of(expected), applied, "brewed item reaches the stature adapter exactly once");
|
||||
}
|
||||
|
||||
private void assertLingeringApplies(ItemStack result, StaturePotion expected) {
|
||||
List<StaturePotion> applied = new ArrayList<>();
|
||||
ThrowableStatureListener effects = new ThrowableStatureListener(recipes,
|
||||
new NamespacedKey("spigotheights", "cloud_kind"), (source, player, kind, done) -> {
|
||||
applied.add(kind);
|
||||
done.accept(true);
|
||||
});
|
||||
Player player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(player.isOnline()).thenReturn(true);
|
||||
ThrownPotion thrown = mock(ThrownPotion.class);
|
||||
when(thrown.getItem()).thenReturn(result);
|
||||
AreaEffectCloud cloud = mock(AreaEffectCloud.class);
|
||||
when(cloud.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(cloud.getPersistentDataContainer()).thenReturn(new ItemStack(Material.STONE).getItemMeta().getPersistentDataContainer());
|
||||
var creation = new LingeringPotionSplashEvent(thrown, null, null, null, cloud);
|
||||
effects.onLingering(creation);
|
||||
assertTrue(creation.allowsEmptyCreation());
|
||||
var application = new AreaEffectCloudApplyEvent(cloud, new ArrayList<>(List.of(player)));
|
||||
effects.onCloud(application);
|
||||
effects.onCloud(application);
|
||||
assertEquals(List.of(expected), applied, "brewed cloud reaches the stature adapter exactly once");
|
||||
}
|
||||
|
||||
private final class NativeStand extends BrewingStandBlockEntity {
|
||||
private final CraftInventoryBrewer inventory = new CraftInventoryBrewer(this);
|
||||
private final ServerLevel level = mock(ServerLevel.class);
|
||||
|
||||
NativeStand() {
|
||||
super(BlockPos.ZERO, Blocks.BREWING_STAND.defaultBlockState());
|
||||
when(level.recipeAccess()).thenReturn(engine);
|
||||
fuel = 20;
|
||||
}
|
||||
|
||||
@Override public InventoryHolder getOwner() { return () -> inventory; }
|
||||
|
||||
void completeCycle() {
|
||||
tick();
|
||||
assertEquals(400, brewTime, "native stand must start brewing");
|
||||
for (int i = 0; i < 400; i++) {
|
||||
tick();
|
||||
}
|
||||
assertEquals(0, brewTime);
|
||||
}
|
||||
|
||||
void tick() {
|
||||
BrewingStandBlockEntity.serverTick(level, BlockPos.ZERO, getBlockState(), this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import io.papermc.paper.plugin.manager.PaperPluginManagerImpl;
|
||||
import java.util.*;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.*;
|
||||
import org.bukkit.command.SimpleCommandMap;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.HandlerList;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerInteractAtEntityEvent;
|
||||
import org.bukkit.event.player.PlayerInteractEntityEvent;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.bukkit.plugin.PluginDescriptionFile;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import org.bukkit.util.Vector;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
/** Actual Purpur build-2651 listener discovery, distinct handler lists and Paper event dispatch.
|
||||
* Only server/player boundaries are doubled; no listening server or client packets. */
|
||||
class NativeCarryRoutingTest {
|
||||
private static Server previousServer;
|
||||
private PluginManager events;
|
||||
private Plugin plugin;
|
||||
private BukkitCarry carry;
|
||||
private final List<Runnable> scheduled = new ArrayList<>();
|
||||
private final Player small = mock(Player.class), large = mock(Player.class);
|
||||
private final List<String> replies = new ArrayList<>();
|
||||
private boolean attached, mountAllowed = true;
|
||||
private int mounts;
|
||||
private double smallScale = 0.9, largeScale = 1.0;
|
||||
|
||||
@BeforeAll
|
||||
static void rememberServer() { previousServer = Bukkit.getServer(); }
|
||||
|
||||
@AfterAll
|
||||
static void restoreServer() throws Exception { setServer(previousServer); }
|
||||
|
||||
private static void setServer(Server server) throws Exception {
|
||||
// Bukkit's global is restored so the separate existing native brewing suite is untouched.
|
||||
var field = Bukkit.class.getDeclaredField("server");
|
||||
field.setAccessible(true);
|
||||
field.set(null, server);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void registerProductionListener() throws Exception {
|
||||
HandlerList.unregisterAll();
|
||||
Server server = mock(Server.class);
|
||||
when(server.isPrimaryThread()).thenReturn(true);
|
||||
when(server.getLogger()).thenReturn(Logger.getLogger("NativeCarryRoutingTest"));
|
||||
setServer(server);
|
||||
events = new PaperPluginManagerImpl(server, new SimpleCommandMap(server, new HashMap<>()), null);
|
||||
when(server.getPluginManager()).thenReturn(events);
|
||||
plugin = mock(Plugin.class);
|
||||
when(plugin.isEnabled()).thenReturn(true);
|
||||
when(plugin.getName()).thenReturn("SpigotHeights");
|
||||
when(plugin.getLogger()).thenReturn(Logger.getLogger("NativeCarryRoutingTest"));
|
||||
var description = new PluginDescriptionFile("SpigotHeights", "test", "unused.Main");
|
||||
when(plugin.getDescription()).thenReturn(description);
|
||||
when(plugin.getPluginMeta()).thenReturn(description);
|
||||
World world = mock(World.class);
|
||||
when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
for (Player p : List.of(small, large)) {
|
||||
when(p.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(p.getWorld()).thenReturn(world);
|
||||
when(p.getLocation()).thenReturn(new Location(world, 0, 64, 0));
|
||||
when(p.isOnline()).thenReturn(true);
|
||||
when(p.isValid()).thenReturn(true);
|
||||
when(p.getGameMode()).thenReturn(GameMode.SURVIVAL);
|
||||
doAnswer(invocation -> { replies.add(invocation.getArgument(0, String.class)); return null; })
|
||||
.when(p).sendMessage(anyString());
|
||||
}
|
||||
when(small.getVehicle()).thenAnswer(invocation -> attached ? large : null);
|
||||
when(small.isInsideVehicle()).thenAnswer(invocation -> attached);
|
||||
when(large.getPassengers()).thenAnswer(invocation -> attached ? List.of(small) : List.of());
|
||||
when(large.addPassenger(small)).thenAnswer(invocation -> {
|
||||
mounts++;
|
||||
var event = new org.bukkit.event.entity.EntityMountEvent(small, large);
|
||||
events.callEvent(event);
|
||||
attached = mountAllowed && !event.isCancelled();
|
||||
return attached;
|
||||
});
|
||||
when(small.leaveVehicle()).thenAnswer(invocation -> {
|
||||
var event = new org.bukkit.event.entity.EntityDismountEvent(small, large, true);
|
||||
events.callEvent(event);
|
||||
if (!event.isCancelled()) { attached = false; }
|
||||
return true; // Intentionally unreliable: production code must inspect attachment.
|
||||
});
|
||||
carry = new BukkitCarry(id -> id.equals(small.getUniqueId()) ? small : large,
|
||||
() -> new HeightSettings(0.4, 2, 0.25, 0.5, 1.5, 20), scheduled::add,
|
||||
server.getLogger(), p -> p == small ? smallScale : largeScale);
|
||||
events.registerEvents(carry, plugin);
|
||||
}
|
||||
|
||||
private PlayerInteractEntityEvent click(boolean at, Player sender, Player target, EquipmentSlot hand) {
|
||||
return at ? new PlayerInteractAtEntityEvent(sender, target, new Vector(), hand)
|
||||
: new PlayerInteractEntityEvent(sender, target, hand);
|
||||
}
|
||||
|
||||
private void drain() {
|
||||
var tasks = List.copyOf(scheduled);
|
||||
scheduled.clear();
|
||||
tasks.forEach(Runnable::run);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void duplicateEventTypesDoNotRepeatMountAttemptsOrMessagesEvenAfterRefusal(boolean atFirst) {
|
||||
for (boolean allowed : new boolean[] {false, true}) {
|
||||
mountAllowed = allowed;
|
||||
int before = mounts;
|
||||
var first = click(atFirst, small, large, EquipmentSlot.HAND);
|
||||
events.callEvent(first);
|
||||
assertTrue(first.isCancelled());
|
||||
int messageCount = replies.size();
|
||||
var duplicate = click(!atFirst, small, large, EquipmentSlot.HAND);
|
||||
events.callEvent(duplicate);
|
||||
assertTrue(duplicate.isCancelled(), "A duplicate of a handled interaction stays consumed");
|
||||
events.callEvent(click(atFirst, small, large, EquipmentSlot.OFF_HAND));
|
||||
assertEquals(before + 1, mounts, "One native attempt for the duplicate event pair, even on refusal");
|
||||
assertEquals(messageCount, replies.size(), "Duplicate dispatch must not send another refusal/busy reply");
|
||||
assertEquals(allowed, attached);
|
||||
if (attached) { carry.command(large, "stop"); }
|
||||
drain(); // A fresh interaction next tick may immediately remount: no gameplay cooldown.
|
||||
}
|
||||
events.callEvent(click(atFirst, small, large, EquipmentSlot.HAND));
|
||||
assertTrue(attached);
|
||||
assertEquals(3, mounts);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void registeredRoutingHonorsCancellationAndOffhandWithoutSwallowingNextValidEvent(boolean at) {
|
||||
var cancelled = click(at, small, large, EquipmentSlot.HAND);
|
||||
cancelled.setCancelled(true);
|
||||
events.callEvent(cancelled);
|
||||
events.callEvent(click(at, small, large, EquipmentSlot.OFF_HAND));
|
||||
assertEquals(0, mounts);
|
||||
assertTrue(replies.isEmpty());
|
||||
events.callEvent(click(!at, small, large, EquipmentSlot.HAND));
|
||||
assertTrue(attached);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"equal", "larger", "offline", "distant", "already-riding"})
|
||||
void ineligibleInteractionsRemainAvailableToOtherPlugins(String reason) {
|
||||
switch (reason) {
|
||||
case "equal" -> smallScale = largeScale;
|
||||
case "larger" -> smallScale = largeScale + 0.2;
|
||||
case "offline" -> when(small.isOnline()).thenReturn(false);
|
||||
case "distant" -> {
|
||||
Location distant = new Location(small.getWorld(), 0, 64, 5);
|
||||
when(small.getLocation()).thenReturn(distant);
|
||||
}
|
||||
case "already-riding" -> when(small.isInsideVehicle()).thenReturn(true);
|
||||
default -> throw new AssertionError(reason);
|
||||
}
|
||||
for (boolean at : new boolean[] {true, false}) {
|
||||
var interaction = click(at, small, large, EquipmentSlot.HAND);
|
||||
events.callEvent(interaction);
|
||||
assertFalse(interaction.isCancelled(), "Rejected carry must leave unrelated interaction handlers available");
|
||||
}
|
||||
assertEquals(0, mounts);
|
||||
assertTrue(replies.isEmpty());
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void nativeMountCancellationIsRespectedAndFreshInteractionCanRetry(boolean at) {
|
||||
Listener canceller = new Listener() {};
|
||||
events.registerEvent(org.bukkit.event.entity.EntityMountEvent.class, canceller, EventPriority.HIGHEST,
|
||||
(listener, event) -> ((org.bukkit.event.entity.EntityMountEvent) event).setCancelled(true), plugin);
|
||||
events.callEvent(click(at, small, large, EquipmentSlot.HAND));
|
||||
assertFalse(attached);
|
||||
assertEquals(1, mounts);
|
||||
assertTrue(replies.getLast().contains("refused"));
|
||||
HandlerList.unregisterAll(canceller);
|
||||
drain();
|
||||
events.callEvent(click(at, small, large, EquipmentSlot.HAND));
|
||||
assertTrue(attached);
|
||||
assertEquals(2, mounts);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonadminCommandRoutingLetsEitherParticipantStopAndNativeSneakDismountClearsTracking() {
|
||||
var settings = new LiveHeightSettings(new HeightSettings(0.4, 2, 0.1, 0.5, 1.5, 20), ignored -> {});
|
||||
var command = new HeightsCommand(settings, null, plugin.getLogger(),
|
||||
(name, value, reply) -> fail("Carry must not reach admin setter"), carry::command);
|
||||
for (Player participant : List.of(small, large)) {
|
||||
events.callEvent(click(true, small, large, EquipmentSlot.HAND));
|
||||
assertTrue(attached);
|
||||
assertFalse(participant.hasPermission("spigotheights.admin"));
|
||||
command.onCommand(participant, null, "heights", new String[] {"carry", "stop"});
|
||||
assertFalse(attached);
|
||||
drain();
|
||||
}
|
||||
events.callEvent(click(false, small, large, EquipmentSlot.HAND));
|
||||
assertTrue(attached);
|
||||
small.leaveVehicle(); // Native sneak path: real registered MONITOR observer sees the event.
|
||||
drain();
|
||||
events.callEvent(click(true, small, large, EquipmentSlot.HAND));
|
||||
assertTrue(attached, "Ordinary native detach must release Heights tracking");
|
||||
assertEquals(4, mounts);
|
||||
}
|
||||
|
||||
@Test
|
||||
void registeredDismountLateCancellationRetainsRideUntilCarrierRetriesStop() {
|
||||
events.callEvent(click(true, small, large, EquipmentSlot.HAND));
|
||||
Listener canceller = new Listener() {};
|
||||
events.registerEvent(org.bukkit.event.entity.EntityDismountEvent.class, canceller, EventPriority.MONITOR,
|
||||
(listener, event) -> ((org.bukkit.event.entity.EntityDismountEvent) event).setCancelled(true), plugin);
|
||||
small.leaveVehicle();
|
||||
drain();
|
||||
assertTrue(attached);
|
||||
carry.command(large, "stop");
|
||||
assertTrue(attached);
|
||||
assertTrue(replies.getLast().contains("refused"));
|
||||
HandlerList.unregisterAll(canceller);
|
||||
carry.command(large, "stop");
|
||||
assertFalse(attached);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void registeredMainHandEventsImmediatelyMountOnlyTheSmallerInitiator(boolean at) {
|
||||
events.callEvent(click(at, large, small, EquipmentSlot.HAND));
|
||||
assertEquals(0, mounts, "Larger players cannot initiate pickup");
|
||||
assertFalse(small.isSneaking());
|
||||
events.callEvent(click(at, small, large, EquipmentSlot.HAND));
|
||||
assertTrue(attached, "Registered handler must receive this event without sneaking or acceptance");
|
||||
assertEquals(1, mounts);
|
||||
assertTrue(replies.stream().noneMatch(reply -> reply.contains("request")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,864 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.reset;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import io.papermc.paper.plugin.manager.PaperPluginManagerImpl;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.logging.Handler;
|
||||
import java.util.logging.LogRecord;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.command.SimpleCommandMap;
|
||||
import org.bukkit.craftbukkit.inventory.CraftItemFactory;
|
||||
import org.bukkit.craftbukkit.inventory.CraftItemStack;
|
||||
import org.bukkit.craftbukkit.inventory.CraftRecipe;
|
||||
import org.bukkit.craftbukkit.util.CraftMagicNumbers;
|
||||
import org.bukkit.Keyed;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.Recipe;
|
||||
import org.bukkit.inventory.RecipeChoice;
|
||||
import org.bukkit.inventory.ShapedRecipe;
|
||||
import org.bukkit.inventory.recipe.CraftingBookCategory;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.bukkit.plugin.PluginDescriptionFile;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import org.bukkit.potion.PotionBrewer;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/** Pinned Purpur book, codec, placement and result-slot behavior with server/player boundaries doubled.
|
||||
* No listening server, client, CraftServer registry or CraftPlayer discovery bridge is started here. */
|
||||
class NativeRecipeBookTest {
|
||||
private static Server previousServer;
|
||||
private static Server serverUnderTest;
|
||||
|
||||
@BeforeAll
|
||||
static void bootstrap() throws Exception {
|
||||
NativeRuntime.bootstrap();
|
||||
previousServer = Bukkit.getServer();
|
||||
serverUnderTest = mock(Server.class);
|
||||
setServer(serverUnderTest);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void configureServer() {
|
||||
reset(serverUnderTest);
|
||||
when(serverUnderTest.getItemFactory()).thenAnswer(invocation -> CraftItemFactory.instance());
|
||||
when(serverUnderTest.getUnsafe()).thenReturn(CraftMagicNumbers.INSTANCE);
|
||||
when(serverUnderTest.isPrimaryThread()).thenReturn(true);
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void restoreServer() throws Exception {
|
||||
setServer(previousServer);
|
||||
}
|
||||
|
||||
private static void setServer(Server server) throws Exception {
|
||||
var field = Bukkit.class.getDeclaredField("server");
|
||||
field.setAccessible(true);
|
||||
field.set(null, server);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nativeRecipeBookAwardsKnownHighlightsAndNotificationOnlyOnce() {
|
||||
Server server = Bukkit.getServer();
|
||||
List<Recipe> registered = new ArrayList<>();
|
||||
when(server.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
registered.add(invocation.getArgument(0));
|
||||
return true;
|
||||
});
|
||||
when(server.getPotionBrewer()).thenReturn(mock(PotionBrewer.class));
|
||||
List<org.bukkit.event.Event> events = new ArrayList<>();
|
||||
PluginManager pluginManager = mock(PluginManager.class);
|
||||
doAnswer(invocation -> {
|
||||
events.add(invocation.getArgument(0));
|
||||
return null;
|
||||
}).when(pluginManager).callEvent(any());
|
||||
when(server.getPluginManager()).thenReturn(pluginManager);
|
||||
PotionRecipes recipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
assertTrue(recipes.register());
|
||||
|
||||
java.util.Map<net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>>,
|
||||
net.minecraft.world.item.crafting.ShapedRecipe> nativeRecipes = new HashMap<>();
|
||||
List<net.minecraft.world.item.crafting.RecipeHolder<?>> holders = new ArrayList<>();
|
||||
for (Recipe recipe : registered) {
|
||||
ShapedRecipe bukkit = (ShapedRecipe) recipe;
|
||||
var key = nativeRecipeKey(bukkit.getKey());
|
||||
var nativeRecipe = nativeRecipe(bukkit);
|
||||
nativeRecipes.put(key, nativeRecipe);
|
||||
holders.add(new net.minecraft.world.item.crafting.RecipeHolder<>(key, nativeRecipe));
|
||||
}
|
||||
AtomicInteger displayId = new AtomicInteger();
|
||||
java.util.Map<net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>>,
|
||||
Integer> displayIds = new HashMap<>();
|
||||
nativeRecipes.keySet().forEach(key -> displayIds.put(key, displayId.getAndIncrement()));
|
||||
TestServerRecipeBook book = new TestServerRecipeBook((key, consumer) -> {
|
||||
net.minecraft.world.item.crafting.ShapedRecipe recipe = nativeRecipes.get(key);
|
||||
consumer.accept(new net.minecraft.world.item.crafting.display.RecipeDisplayEntry(
|
||||
new net.minecraft.world.item.crafting.display.RecipeDisplayId(displayIds.get(key)),
|
||||
recipe.display().getFirst(), java.util.OptionalInt.empty(), recipe.recipeBookCategory(),
|
||||
java.util.Optional.of(recipe.placementInfo().ingredients())));
|
||||
});
|
||||
var nativePlayer = mock(net.minecraft.server.level.ServerPlayer.class);
|
||||
when(nativePlayer.getBukkitEntity()).thenReturn(
|
||||
mock(org.bukkit.craftbukkit.entity.CraftPlayer.class));
|
||||
when(nativePlayer.getAdvancements()).thenReturn(mock(net.minecraft.server.PlayerAdvancements.class));
|
||||
|
||||
assertEquals(4, book.addRecipes(holders, nativePlayer));
|
||||
assertEquals(0, book.addRecipes(holders, nativePlayer));
|
||||
|
||||
assertEquals(new LinkedHashSet<>(nativeRecipes.keySet()), book.known);
|
||||
assertEquals(new LinkedHashSet<>(nativeRecipes.keySet()),
|
||||
new LinkedHashSet<>(book.pack().highlight()));
|
||||
assertEquals(4, events.size());
|
||||
assertTrue(events.stream().allMatch(org.bukkit.event.player.PlayerRecipeDiscoverEvent.class::isInstance));
|
||||
assertTrue(events.stream().map(org.bukkit.event.player.PlayerRecipeDiscoverEvent.class::cast)
|
||||
.allMatch(org.bukkit.event.player.PlayerRecipeDiscoverEvent::shouldShowNotification));
|
||||
|
||||
net.minecraft.nbt.Tag serialized = net.minecraft.stats.ServerRecipeBook.Packed.CODEC
|
||||
.encodeStart(net.minecraft.nbt.NbtOps.INSTANCE, book.pack()).getOrThrow();
|
||||
net.minecraft.stats.ServerRecipeBook.Packed decoded = net.minecraft.stats.ServerRecipeBook.Packed.CODEC
|
||||
.parse(net.minecraft.nbt.NbtOps.INSTANCE, serialized).getOrThrow();
|
||||
TestServerRecipeBook restored = new TestServerRecipeBook();
|
||||
restored.loadUntrusted(decoded, nativeRecipes::containsKey);
|
||||
assertEquals(book.known, restored.known);
|
||||
assertEquals(new LinkedHashSet<>(book.pack().highlight()),
|
||||
new LinkedHashSet<>(restored.pack().highlight()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nativeRecipeBookSerializationPreservesKnownHighlightsAndVanillaStateAcrossReenable() {
|
||||
List<net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>>> statureKeys =
|
||||
List.of("shifting_stature", "growth", "diminution", "restoration").stream()
|
||||
.map(value -> nativeRecipeKey(new NamespacedKey("spigotheights", value)))
|
||||
.toList();
|
||||
var vanillaKey = nativeRecipeKey(new NamespacedKey("minecraft", "stick"));
|
||||
TestServerRecipeBook original = new TestServerRecipeBook();
|
||||
original.setBookSetting(net.minecraft.world.inventory.RecipeBookType.CRAFTING, true, true);
|
||||
original.add(vanillaKey);
|
||||
original.highlight(vanillaKey);
|
||||
statureKeys.forEach(key -> {
|
||||
original.add(key);
|
||||
original.highlight(key);
|
||||
});
|
||||
|
||||
net.minecraft.nbt.Tag serialized = net.minecraft.stats.ServerRecipeBook.Packed.CODEC
|
||||
.encodeStart(net.minecraft.nbt.NbtOps.INSTANCE, original.pack()).getOrThrow();
|
||||
net.minecraft.stats.ServerRecipeBook.Packed decoded = net.minecraft.stats.ServerRecipeBook.Packed.CODEC
|
||||
.parse(net.minecraft.nbt.NbtOps.INSTANCE, serialized).getOrThrow();
|
||||
TestServerRecipeBook restored = new TestServerRecipeBook();
|
||||
restored.loadUntrusted(decoded, key -> true);
|
||||
|
||||
assertTrue(restored.contains(vanillaKey));
|
||||
statureKeys.forEach(key -> assertTrue(restored.contains(key)));
|
||||
assertEquals(new LinkedHashSet<>(decoded.known()), restored.known);
|
||||
assertEquals(new LinkedHashSet<>(decoded.highlight()), new LinkedHashSet<>(restored.pack().highlight()));
|
||||
assertTrue(restored.isOpen(net.minecraft.world.inventory.RecipeBookType.CRAFTING));
|
||||
assertTrue(restored.isFiltering(net.minecraft.world.inventory.RecipeBookType.CRAFTING));
|
||||
|
||||
int knownBeforeReenable = restored.known.size();
|
||||
int highlightsBeforeReenable = restored.pack().highlight().size();
|
||||
statureKeys.forEach(restored::add);
|
||||
assertEquals(knownBeforeReenable, restored.known.size());
|
||||
assertEquals(highlightsBeforeReenable, restored.pack().highlight().size(),
|
||||
"Re-enable must not manufacture another first-time highlight");
|
||||
assertTrue(restored.contains(vanillaKey));
|
||||
}
|
||||
|
||||
private static final class TestServerRecipeBook extends net.minecraft.stats.ServerRecipeBook {
|
||||
TestServerRecipeBook() {
|
||||
this((key, consumer) -> { });
|
||||
}
|
||||
|
||||
TestServerRecipeBook(net.minecraft.stats.ServerRecipeBook.DisplayResolver resolver) {
|
||||
super(resolver);
|
||||
}
|
||||
|
||||
void highlight(net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>> key) {
|
||||
highlight.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void disableAndReenableReRegistersRecipesWithoutForgettingPlayerKnowledge() {
|
||||
Server server = Bukkit.getServer();
|
||||
Set<NamespacedKey> registry = new LinkedHashSet<>();
|
||||
when(server.addRecipe(any(Recipe.class))).thenAnswer(invocation ->
|
||||
registry.add(((Keyed) invocation.getArgument(0)).getKey()));
|
||||
when(server.removeRecipe(any(NamespacedKey.class))).thenAnswer(invocation ->
|
||||
registry.remove(invocation.getArgument(0)));
|
||||
when(server.getPotionBrewer()).thenReturn(mock(PotionBrewer.class));
|
||||
when(server.getPluginManager()).thenReturn(mock(PluginManager.class));
|
||||
Set<NamespacedKey> known = new LinkedHashSet<>();
|
||||
Player player = discoveryPlayer("Persistent", known);
|
||||
doReturn(List.of(player)).when(server).getOnlinePlayers();
|
||||
Plugin plugin = mock(Plugin.class);
|
||||
Logger logger = Logger.getAnonymousLogger();
|
||||
PotionRecipes firstRecipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
RecipeLifecycle first = new RecipeLifecycle(server, plugin, firstRecipes, logger);
|
||||
|
||||
assertTrue(first.enable());
|
||||
first.disable();
|
||||
assertTrue(registry.isEmpty());
|
||||
assertEquals(new LinkedHashSet<>(firstRecipes.recipeKeys()), known);
|
||||
|
||||
PotionRecipes secondRecipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
assertTrue(new RecipeLifecycle(server, plugin, secondRecipes, logger).enable());
|
||||
assertEquals(new LinkedHashSet<>(secondRecipes.recipeKeys()), registry);
|
||||
verify(player, times(1)).discoverRecipes(any());
|
||||
verify(player, never()).undiscoverRecipes(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void onlineEnumerationFailureDoesNotLeaveDiscoveryListenerActiveAfterCleanup() {
|
||||
Server server = Bukkit.getServer();
|
||||
when(server.addRecipe(any(Recipe.class))).thenReturn(true);
|
||||
when(server.getPotionBrewer()).thenReturn(mock(PotionBrewer.class));
|
||||
PluginManager events = new PaperPluginManagerImpl(server,
|
||||
new SimpleCommandMap(server, new HashMap<>()), null);
|
||||
when(server.getPluginManager()).thenReturn(events);
|
||||
when(server.getOnlinePlayers()).thenThrow(new IllegalStateException("simulated lifecycle failure"));
|
||||
Plugin plugin = mock(Plugin.class);
|
||||
when(plugin.isEnabled()).thenReturn(true);
|
||||
when(plugin.getName()).thenReturn("SpigotHeights");
|
||||
when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
|
||||
PluginDescriptionFile description = new PluginDescriptionFile("SpigotHeights", "test", "unused.Main");
|
||||
when(plugin.getDescription()).thenReturn(description);
|
||||
when(plugin.getPluginMeta()).thenReturn(description);
|
||||
Set<NamespacedKey> joiningKnown = new LinkedHashSet<>();
|
||||
Player joining = discoveryPlayer("Joining", joiningKnown);
|
||||
PotionRecipes recipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
RecipeLifecycle lifecycle = new RecipeLifecycle(server, plugin, recipes, plugin.getLogger());
|
||||
|
||||
assertFalse(lifecycle.enable());
|
||||
events.callEvent(new PlayerJoinEvent(joining, "joined"));
|
||||
|
||||
assertTrue(joiningKnown.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listenerRegistrationExceptionRemovesPartiallyRegisteredDiscoveryListener() {
|
||||
Server server = Bukkit.getServer();
|
||||
when(server.addRecipe(any(Recipe.class))).thenReturn(true);
|
||||
when(server.getPotionBrewer()).thenReturn(mock(PotionBrewer.class));
|
||||
PluginManager registeredEvents = new PaperPluginManagerImpl(server,
|
||||
new SimpleCommandMap(server, new HashMap<>()), null);
|
||||
PluginManager throwingEvents = mock(PluginManager.class);
|
||||
doAnswer(invocation -> {
|
||||
registeredEvents.registerEvents(invocation.getArgument(0), invocation.getArgument(1));
|
||||
throw new IllegalStateException("simulated post-registration failure");
|
||||
}).when(throwingEvents).registerEvents(any(), any());
|
||||
when(server.getPluginManager()).thenReturn(throwingEvents);
|
||||
doReturn(List.of()).when(server).getOnlinePlayers();
|
||||
Plugin plugin = enabledPlugin("ListenerFailure");
|
||||
Set<NamespacedKey> joiningKnown = new LinkedHashSet<>();
|
||||
Player joining = discoveryPlayer("Joining", joiningKnown);
|
||||
PotionRecipes recipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
RecipeLifecycle lifecycle = new RecipeLifecycle(server, plugin, recipes, plugin.getLogger());
|
||||
|
||||
assertFalse(lifecycle.enable());
|
||||
registeredEvents.callEvent(new PlayerJoinEvent(joining, "joined"));
|
||||
|
||||
assertTrue(joiningKnown.isEmpty(), "A failed lifecycle must not leave its listener active");
|
||||
verify(server, times(4)).removeRecipe(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void lifecycleRegistrationExceptionCleansOwnedRecipesLogsAndAwardsNobody() {
|
||||
Server server = Bukkit.getServer();
|
||||
AtomicInteger attempts = new AtomicInteger();
|
||||
List<NamespacedKey> removed = new ArrayList<>();
|
||||
when(server.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
if (attempts.getAndIncrement() == 1) {
|
||||
throw new IllegalStateException("simulated registry failure");
|
||||
}
|
||||
return true;
|
||||
});
|
||||
when(server.removeRecipe(any(NamespacedKey.class))).thenAnswer(invocation -> {
|
||||
removed.add(invocation.getArgument(0));
|
||||
return true;
|
||||
});
|
||||
PotionBrewer brewer = mock(PotionBrewer.class);
|
||||
when(server.getPotionBrewer()).thenReturn(brewer);
|
||||
PluginManager events = mock(PluginManager.class);
|
||||
when(server.getPluginManager()).thenReturn(events);
|
||||
Set<NamespacedKey> known = new LinkedHashSet<>();
|
||||
doReturn(List.of(discoveryPlayer("Online", known))).when(server).getOnlinePlayers();
|
||||
Plugin plugin = mock(Plugin.class);
|
||||
Logger logger = Logger.getAnonymousLogger();
|
||||
logger.setUseParentHandlers(false);
|
||||
List<String> logs = new ArrayList<>();
|
||||
logger.addHandler(new Handler() {
|
||||
@Override public void publish(LogRecord record) { logs.add(record.getMessage()); }
|
||||
@Override public void flush() {}
|
||||
@Override public void close() {}
|
||||
});
|
||||
PotionRecipes recipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
RecipeLifecycle lifecycle = new RecipeLifecycle(server, plugin, recipes, logger);
|
||||
|
||||
boolean enabled = assertDoesNotThrow(lifecycle::enable);
|
||||
|
||||
assertFalse(enabled);
|
||||
assertEquals(List.of(recipes.shiftingKey()), removed);
|
||||
assertTrue(known.isEmpty());
|
||||
assertEquals(1, logs.size());
|
||||
verifyNoInteractions(events);
|
||||
verifyNoInteractions(brewer);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupFailureIsLoggedWithoutEscapingOrActivatingDiscovery() {
|
||||
Server server = Bukkit.getServer();
|
||||
AtomicInteger attempts = new AtomicInteger();
|
||||
when(server.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
if (attempts.incrementAndGet() == 2) {
|
||||
throw new IllegalStateException("simulated registration failure");
|
||||
}
|
||||
return true;
|
||||
});
|
||||
when(server.removeRecipe(any(NamespacedKey.class)))
|
||||
.thenThrow(new IllegalStateException("simulated cleanup failure"));
|
||||
PotionBrewer brewer = mock(PotionBrewer.class);
|
||||
when(server.getPotionBrewer()).thenReturn(brewer);
|
||||
PluginManager events = mock(PluginManager.class);
|
||||
when(server.getPluginManager()).thenReturn(events);
|
||||
Logger logger = Logger.getAnonymousLogger();
|
||||
logger.setUseParentHandlers(false);
|
||||
List<LogRecord> logs = new ArrayList<>();
|
||||
logger.addHandler(new Handler() {
|
||||
@Override public void publish(LogRecord record) { logs.add(record); }
|
||||
@Override public void flush() {}
|
||||
@Override public void close() {}
|
||||
});
|
||||
PotionRecipes recipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
RecipeLifecycle lifecycle = new RecipeLifecycle(server, mock(Plugin.class), recipes, logger);
|
||||
|
||||
boolean enabled = assertDoesNotThrow(lifecycle::enable);
|
||||
|
||||
assertFalse(enabled);
|
||||
assertEquals(1, logs.size());
|
||||
assertEquals(1, logs.getFirst().getThrown().getSuppressed().length);
|
||||
verifyNoInteractions(events, brewer);
|
||||
}
|
||||
|
||||
@Test
|
||||
void postInsertionRegistrationFailureRemovesEveryRecipeOwnedByThisLifecycle() {
|
||||
Server server = Bukkit.getServer();
|
||||
NamespacedKey unrelatedKey = new NamespacedKey("minecraft", "stick");
|
||||
java.util.Map<NamespacedKey, Recipe> registry = new HashMap<>();
|
||||
registry.put(unrelatedKey, mock(Recipe.class));
|
||||
AtomicInteger attempts = new AtomicInteger();
|
||||
when(server.getRecipe(any(NamespacedKey.class))).thenAnswer(invocation ->
|
||||
registry.get(invocation.getArgument(0)));
|
||||
when(server.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
Recipe recipe = invocation.getArgument(0);
|
||||
registry.put(((Keyed) recipe).getKey(), recipe);
|
||||
if (attempts.incrementAndGet() == 2) {
|
||||
throw new IllegalStateException("simulated failure after native insertion");
|
||||
}
|
||||
return true;
|
||||
});
|
||||
when(server.removeRecipe(any(NamespacedKey.class))).thenAnswer(invocation ->
|
||||
registry.remove(invocation.getArgument(0)) != null);
|
||||
PotionBrewer brewer = mock(PotionBrewer.class);
|
||||
when(server.getPotionBrewer()).thenReturn(brewer);
|
||||
when(server.getPluginManager()).thenReturn(mock(PluginManager.class));
|
||||
Plugin plugin = mock(Plugin.class);
|
||||
Logger logger = Logger.getAnonymousLogger();
|
||||
logger.setUseParentHandlers(false);
|
||||
PotionRecipes recipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
RecipeLifecycle lifecycle = new RecipeLifecycle(server, plugin, recipes, logger);
|
||||
|
||||
assertFalse(lifecycle.enable());
|
||||
|
||||
assertEquals(Set.of(unrelatedKey), registry.keySet());
|
||||
verifyNoInteractions(brewer);
|
||||
}
|
||||
|
||||
@Test
|
||||
void disableUnregistersTheCurrentLifecycleDiscoveryListener() {
|
||||
Server server = Bukkit.getServer();
|
||||
PluginManager events = new PaperPluginManagerImpl(server,
|
||||
new SimpleCommandMap(server, new HashMap<>()), null);
|
||||
when(server.getPluginManager()).thenReturn(events);
|
||||
when(server.addRecipe(any(Recipe.class))).thenReturn(true);
|
||||
when(server.getPotionBrewer()).thenReturn(mock(PotionBrewer.class));
|
||||
doReturn(List.of()).when(server).getOnlinePlayers();
|
||||
Plugin plugin = enabledPlugin("DisableListener");
|
||||
Set<NamespacedKey> joiningKnown = new LinkedHashSet<>();
|
||||
Player joining = discoveryPlayer("Joining", joiningKnown);
|
||||
PotionRecipes recipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
RecipeLifecycle lifecycle = new RecipeLifecycle(server, plugin, recipes, plugin.getLogger());
|
||||
|
||||
assertTrue(lifecycle.enable());
|
||||
lifecycle.disable();
|
||||
events.callEvent(new PlayerJoinEvent(joining, "joined"));
|
||||
|
||||
assertTrue(joiningKnown.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void successfulLifecycleDiscoversForOnlineAndLaterJoiningPlayersThroughRegisteredListener() {
|
||||
Server server = Bukkit.getServer();
|
||||
PluginManager events = new PaperPluginManagerImpl(server,
|
||||
new SimpleCommandMap(server, new HashMap<>()), null);
|
||||
when(server.getPluginManager()).thenReturn(events);
|
||||
when(server.addRecipe(any(Recipe.class))).thenReturn(true);
|
||||
when(server.getPotionBrewer()).thenReturn(mock(PotionBrewer.class));
|
||||
Plugin plugin = mock(Plugin.class);
|
||||
when(plugin.isEnabled()).thenReturn(true);
|
||||
when(plugin.getName()).thenReturn("SpigotHeights");
|
||||
when(plugin.getLogger()).thenReturn(Logger.getLogger("NativeRecipeBookTest"));
|
||||
PluginDescriptionFile description = new PluginDescriptionFile("SpigotHeights", "test", "unused.Main");
|
||||
when(plugin.getDescription()).thenReturn(description);
|
||||
when(plugin.getPluginMeta()).thenReturn(description);
|
||||
Set<NamespacedKey> onlineKnown = new LinkedHashSet<>();
|
||||
Set<NamespacedKey> joiningKnown = new LinkedHashSet<>();
|
||||
Player online = discoveryPlayer("Online", onlineKnown);
|
||||
Player joining = discoveryPlayer("Joining", joiningKnown);
|
||||
doReturn(List.of(online)).when(server).getOnlinePlayers();
|
||||
PotionRecipes recipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
RecipeLifecycle lifecycle = new RecipeLifecycle(server, plugin, recipes, plugin.getLogger());
|
||||
|
||||
assertTrue(lifecycle.enable());
|
||||
events.callEvent(new PlayerJoinEvent(joining, "joined"));
|
||||
|
||||
assertEquals(new LinkedHashSet<>(recipes.recipeKeys()), onlineKnown);
|
||||
assertEquals(new LinkedHashSet<>(recipes.recipeKeys()), joiningKnown);
|
||||
}
|
||||
|
||||
private static Plugin enabledPlugin(String loggerName) {
|
||||
Plugin plugin = mock(Plugin.class);
|
||||
when(plugin.isEnabled()).thenReturn(true);
|
||||
when(plugin.getName()).thenReturn("SpigotHeights");
|
||||
when(plugin.getLogger()).thenReturn(Logger.getLogger(loggerName));
|
||||
PluginDescriptionFile description = new PluginDescriptionFile("SpigotHeights", "test", "unused.Main");
|
||||
when(plugin.getDescription()).thenReturn(description);
|
||||
when(plugin.getPluginMeta()).thenReturn(description);
|
||||
return plugin;
|
||||
}
|
||||
|
||||
private static Player discoveryPlayer(String name, Set<NamespacedKey> known) {
|
||||
Player player = mock(Player.class);
|
||||
when(player.getName()).thenReturn(name);
|
||||
when(player.hasDiscoveredRecipe(any(NamespacedKey.class)))
|
||||
.thenAnswer(invocation -> known.contains(invocation.getArgument(0)));
|
||||
when(player.discoverRecipes(any())).thenAnswer(invocation -> {
|
||||
List<NamespacedKey> requested = ((Collection<?>) invocation.getArgument(0)).stream()
|
||||
.map(NamespacedKey.class::cast).toList();
|
||||
known.addAll(requested);
|
||||
return requested.size();
|
||||
});
|
||||
return player;
|
||||
}
|
||||
|
||||
@Test
|
||||
void incompleteRegistrationRollsBackOnlyRecipesAddedInThisAttempt() {
|
||||
Server server = Bukkit.getServer();
|
||||
List<NamespacedKey> added = new ArrayList<>();
|
||||
List<NamespacedKey> removed = new ArrayList<>();
|
||||
NamespacedKey conflict = new NamespacedKey("spigotheights", "diminution");
|
||||
when(server.getRecipe(conflict)).thenReturn(mock(Recipe.class));
|
||||
when(server.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
added.add(((Keyed) invocation.getArgument(0)).getKey());
|
||||
return true;
|
||||
});
|
||||
when(server.removeRecipe(any(NamespacedKey.class))).thenAnswer(invocation -> {
|
||||
removed.add(invocation.getArgument(0));
|
||||
return true;
|
||||
});
|
||||
PotionBrewer brewer = mock(PotionBrewer.class);
|
||||
when(server.getPotionBrewer()).thenReturn(brewer);
|
||||
PotionRecipes recipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
|
||||
assertFalse(recipes.register());
|
||||
|
||||
assertEquals(List.of(
|
||||
new NamespacedKey("spigotheights", "shifting_stature"),
|
||||
new NamespacedKey("spigotheights", "growth")), added);
|
||||
assertEquals(added, removed);
|
||||
assertFalse(removed.contains(conflict), "A pre-existing conflict is not owned by this lifecycle");
|
||||
verify(brewer, times(8)).addPotionMix(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void disableRemovesOnlySuccessfulCurrentLifecycleRegistrations() {
|
||||
Server server = Bukkit.getServer();
|
||||
List<NamespacedKey> added = new ArrayList<>();
|
||||
List<NamespacedKey> removed = new ArrayList<>();
|
||||
when(server.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
added.add(((Keyed) invocation.getArgument(0)).getKey());
|
||||
return true;
|
||||
});
|
||||
when(server.removeRecipe(any(NamespacedKey.class))).thenAnswer(invocation -> {
|
||||
removed.add(invocation.getArgument(0));
|
||||
return true;
|
||||
});
|
||||
when(server.getPotionBrewer()).thenReturn(mock(PotionBrewer.class));
|
||||
PotionRecipes recipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
|
||||
recipes.register();
|
||||
recipes.unregister();
|
||||
|
||||
assertEquals(4, added.size());
|
||||
assertEquals(added, removed);
|
||||
assertFalse(removed.contains(new NamespacedKey("minecraft", "unrelated")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nativeRecipeBookAutofillPlacesExactInputsAndPreservesUnrelatedInventory() {
|
||||
Server server = Bukkit.getServer();
|
||||
List<Recipe> registered = new ArrayList<>();
|
||||
when(server.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
registered.add(invocation.getArgument(0));
|
||||
return true;
|
||||
});
|
||||
when(server.getPotionBrewer()).thenReturn(mock(PotionBrewer.class));
|
||||
when(server.getPluginManager()).thenReturn(mock(PluginManager.class));
|
||||
PotionRecipes recipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
assertTrue(recipes.register());
|
||||
|
||||
for (int index = 0; index < registered.size(); index++) {
|
||||
ShapedRecipe bukkit = (ShapedRecipe) registered.get(index);
|
||||
net.minecraft.world.item.crafting.ShapedRecipe nativeRecipe = nativeRecipe(bukkit);
|
||||
var holder = new net.minecraft.world.item.crafting.RecipeHolder<>(
|
||||
nativeRecipeKey(bukkit.getKey()), nativeRecipe);
|
||||
var nativePlayer = mock(net.minecraft.world.entity.player.Player.class);
|
||||
when(nativePlayer.level()).thenReturn(mock(net.minecraft.world.level.Level.class));
|
||||
when(nativePlayer.getBukkitEntity()).thenReturn(
|
||||
mock(org.bukkit.craftbukkit.entity.CraftPlayer.class));
|
||||
var inventory = new net.minecraft.world.entity.player.Inventory(nativePlayer,
|
||||
new net.minecraft.world.entity.EntityEquipment());
|
||||
when(nativePlayer.getInventory()).thenReturn(inventory);
|
||||
List<ItemStack> expectedGrid = craftingGrid(recipes, index, null);
|
||||
for (int slot = 0; slot < expectedGrid.size(); slot++) {
|
||||
ItemStack ingredient = expectedGrid.get(slot);
|
||||
if (!ingredient.isEmpty()) {
|
||||
inventory.setItem(slot, CraftItemStack.asNMSCopy(ingredient));
|
||||
}
|
||||
}
|
||||
ItemStack unrelated = new ItemStack(Material.DIAMOND, 7);
|
||||
inventory.setItem(20, CraftItemStack.asNMSCopy(unrelated));
|
||||
var grid = new net.minecraft.world.inventory.TransientCraftingContainer(
|
||||
mock(net.minecraft.world.inventory.AbstractContainerMenu.class), 3, 3, nativePlayer);
|
||||
ItemStack displaced = new ItemStack(Material.DIRT, 3);
|
||||
grid.setItem(0, CraftItemStack.asNMSCopy(displaced));
|
||||
List<net.minecraft.world.inventory.Slot> gridSlots = java.util.stream.IntStream.range(0, 9)
|
||||
.mapToObj(slot -> new net.minecraft.world.inventory.Slot(grid, slot, 0, 0))
|
||||
.toList();
|
||||
var access = new net.minecraft.recipebook.ServerPlaceRecipe.CraftingMenuAccess<
|
||||
net.minecraft.world.item.crafting.ShapedRecipe>() {
|
||||
@Override
|
||||
public void fillCraftSlotsStackedContents(
|
||||
net.minecraft.world.entity.player.StackedItemContents contents) {
|
||||
grid.fillStackedContents(contents);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearCraftingContent() {
|
||||
grid.clearContent();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean recipeMatches(net.minecraft.world.item.crafting.RecipeHolder<
|
||||
net.minecraft.world.item.crafting.ShapedRecipe> candidate) {
|
||||
return candidate.value().matches(
|
||||
net.minecraft.world.item.crafting.CraftingInput.of(3, 3, grid.getItems()),
|
||||
mock(net.minecraft.world.level.Level.class));
|
||||
}
|
||||
};
|
||||
|
||||
var action = net.minecraft.recipebook.ServerPlaceRecipe.placeRecipe(access, 3, 3,
|
||||
gridSlots, gridSlots, inventory, holder, false, false);
|
||||
|
||||
assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.NOTHING, action);
|
||||
assertEquals(expectedGrid, grid.getItems().stream().map(CraftItemStack::asBukkitCopy).toList());
|
||||
assertTrue(java.util.stream.IntStream.range(0, 9)
|
||||
.allMatch(slot -> inventory.getItem(slot).isEmpty()),
|
||||
"Autofill must relocate rather than duplicate or consume available ingredients");
|
||||
assertEquals(unrelated, CraftItemStack.asBukkitCopy(inventory.getItem(20)));
|
||||
assertEquals(3, inventory.getNonEquipmentItems().stream()
|
||||
.map(CraftItemStack::asBukkitCopy)
|
||||
.filter(item -> item.getType() == Material.DIRT)
|
||||
.mapToInt(ItemStack::getAmount)
|
||||
.sum(), "Autofill must return unrelated grid contents to inventory");
|
||||
assertTrue(nativeRecipe.matches(nativeInput(grid.getItems().stream()
|
||||
.map(CraftItemStack::asBukkitCopy).toList()), mock(net.minecraft.world.level.Level.class)));
|
||||
net.minecraft.world.item.ItemStack nativeResult = nativeRecipe.assemble(nativeInput(
|
||||
grid.getItems().stream().map(CraftItemStack::asBukkitCopy).toList()));
|
||||
ItemStack result = CraftItemStack.asBukkitCopy(nativeResult);
|
||||
assertEquals(StaturePotion.values()[index], recipes.identify(result));
|
||||
assertEquals(1, result.getAmount());
|
||||
|
||||
grid.setCurrentRecipe(new net.minecraft.world.item.crafting.RecipeHolder<
|
||||
net.minecraft.world.item.crafting.CraftingRecipe>(holder.id(), holder.value()));
|
||||
var resultContainer = new net.minecraft.world.inventory.ResultContainer();
|
||||
resultContainer.setRecipeUsed(holder);
|
||||
resultContainer.setItem(0, nativeResult);
|
||||
var resultSlot = new net.minecraft.world.inventory.ResultSlot(nativePlayer, grid,
|
||||
resultContainer, 0, 0, 0);
|
||||
net.minecraft.world.item.ItemStack taken = resultSlot.remove(1);
|
||||
resultSlot.onTake(nativePlayer, taken);
|
||||
|
||||
assertEquals(StaturePotion.values()[index],
|
||||
recipes.identify(CraftItemStack.asBukkitCopy(taken)));
|
||||
assertTrue(resultContainer.isEmpty(), "Completed crafting must yield exactly one result");
|
||||
assertTrue(grid.isEmpty(), "Completed crafting must consume exactly the placed ingredients");
|
||||
assertEquals(unrelated, CraftItemStack.asBukkitCopy(inventory.getItem(20)));
|
||||
assertEquals(3, inventory.getNonEquipmentItems().stream()
|
||||
.map(CraftItemStack::asBukkitCopy)
|
||||
.filter(item -> item.getType() == Material.DIRT)
|
||||
.mapToInt(ItemStack::getAmount)
|
||||
.sum());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void nativeRecipeBookAutofillRejectsInvalidPotionSubstitutionsWithoutMovingInventory() {
|
||||
Server server = Bukkit.getServer();
|
||||
List<Recipe> registered = new ArrayList<>();
|
||||
when(server.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
registered.add(invocation.getArgument(0));
|
||||
return true;
|
||||
});
|
||||
when(server.getPotionBrewer()).thenReturn(mock(PotionBrewer.class));
|
||||
PotionRecipes recipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
assertTrue(recipes.register());
|
||||
|
||||
for (int index = 0; index < registered.size(); index++) {
|
||||
ShapedRecipe bukkit = (ShapedRecipe) registered.get(index);
|
||||
for (ItemStack invalidPotion : invalidPotionInputs(recipes, index)) {
|
||||
assertNativeAutofillRejected(recipes, index, bukkit, invalidPotion);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertNativeAutofillRejected(PotionRecipes recipes, int recipeIndex,
|
||||
ShapedRecipe bukkit, ItemStack invalidPotion) {
|
||||
net.minecraft.world.item.crafting.ShapedRecipe nativeRecipe = nativeRecipe(bukkit);
|
||||
var holder = new net.minecraft.world.item.crafting.RecipeHolder<>(
|
||||
nativeRecipeKey(bukkit.getKey()), nativeRecipe);
|
||||
var nativePlayer = mock(net.minecraft.world.entity.player.Player.class);
|
||||
var inventory = new net.minecraft.world.entity.player.Inventory(nativePlayer,
|
||||
new net.minecraft.world.entity.EntityEquipment());
|
||||
List<ItemStack> available = craftingGrid(recipes, recipeIndex, invalidPotion);
|
||||
for (int slot = 0; slot < available.size(); slot++) {
|
||||
if (!available.get(slot).isEmpty()) {
|
||||
inventory.setItem(slot, CraftItemStack.asNMSCopy(available.get(slot)));
|
||||
}
|
||||
}
|
||||
inventory.setItem(20, CraftItemStack.asNMSCopy(new ItemStack(Material.DIAMOND, 7)));
|
||||
List<ItemStack> inventoryBefore = inventory.getNonEquipmentItems().stream()
|
||||
.map(CraftItemStack::asBukkitCopy).toList();
|
||||
var grid = new net.minecraft.world.SimpleContainer(9);
|
||||
List<net.minecraft.world.inventory.Slot> gridSlots = java.util.stream.IntStream.range(0, 9)
|
||||
.mapToObj(slot -> new net.minecraft.world.inventory.Slot(grid, slot, 0, 0))
|
||||
.toList();
|
||||
var access = new net.minecraft.recipebook.ServerPlaceRecipe.CraftingMenuAccess<
|
||||
net.minecraft.world.item.crafting.ShapedRecipe>() {
|
||||
@Override
|
||||
public void fillCraftSlotsStackedContents(
|
||||
net.minecraft.world.entity.player.StackedItemContents contents) {
|
||||
grid.fillStackedContents(contents);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearCraftingContent() {
|
||||
grid.clearContent();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean recipeMatches(net.minecraft.world.item.crafting.RecipeHolder<
|
||||
net.minecraft.world.item.crafting.ShapedRecipe> candidate) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
var action = net.minecraft.recipebook.ServerPlaceRecipe.placeRecipe(access, 3, 3,
|
||||
gridSlots, gridSlots, inventory, holder, false, false);
|
||||
|
||||
assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.PLACE_GHOST_RECIPE,
|
||||
action, invalidPotion::toString);
|
||||
assertTrue(grid.isEmpty());
|
||||
assertEquals(inventoryBefore, inventory.getNonEquipmentItems().stream()
|
||||
.map(CraftItemStack::asBukkitCopy).toList());
|
||||
}
|
||||
|
||||
private static net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>> nativeRecipeKey(
|
||||
NamespacedKey key) {
|
||||
return net.minecraft.resources.ResourceKey.create(net.minecraft.core.registries.Registries.RECIPE,
|
||||
net.minecraft.resources.Identifier.fromNamespaceAndPath(key.getNamespace(), key.getKey()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nativeCraftingMatchesExactInputsAndProducesAllFourAuthenticatedResults() {
|
||||
Server server = Bukkit.getServer();
|
||||
List<Recipe> registered = new ArrayList<>();
|
||||
when(server.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
registered.add(invocation.getArgument(0));
|
||||
return true;
|
||||
});
|
||||
when(server.getPotionBrewer()).thenReturn(mock(PotionBrewer.class));
|
||||
PotionRecipes recipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
assertTrue(recipes.register());
|
||||
|
||||
for (int index = 0; index < registered.size(); index++) {
|
||||
ShapedRecipe bukkit = (ShapedRecipe) registered.get(index);
|
||||
net.minecraft.world.item.crafting.ShapedRecipe nativeRecipe = nativeRecipe(bukkit);
|
||||
assertTrue(nativeRecipe.getIngredients().get(4).orElseThrow().isExact(),
|
||||
"Pinned recipe placement must retain the component-sensitive potion choice");
|
||||
List<ItemStack> valid = craftingGrid(recipes, index, null);
|
||||
List<ItemStack> unchanged = valid.stream().map(ItemStack::clone).toList();
|
||||
net.minecraft.world.item.crafting.CraftingInput input = nativeInput(valid);
|
||||
assertTrue(nativeRecipe.matches(input, mock(net.minecraft.world.level.Level.class)));
|
||||
ItemStack result = CraftItemStack.asBukkitCopy(nativeRecipe.assemble(input));
|
||||
assertEquals(StaturePotion.values()[index], recipes.identify(result));
|
||||
assertEquals(1, result.getAmount());
|
||||
assertEquals(unchanged, valid, "Matching and preview assembly must not consume the grid");
|
||||
|
||||
for (ItemStack invalidPotion : invalidPotionInputs(recipes, index)) {
|
||||
assertFalse(nativeRecipe.matches(nativeInput(craftingGrid(recipes, index, invalidPotion)),
|
||||
mock(net.minecraft.world.level.Level.class)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static net.minecraft.world.item.crafting.ShapedRecipe nativeRecipe(ShapedRecipe recipe) {
|
||||
java.util.Map<Character, net.minecraft.world.item.crafting.Ingredient> ingredients = new HashMap<>();
|
||||
recipe.getChoiceMap().forEach((symbol, choice) ->
|
||||
ingredients.put(symbol, CraftRecipe.toIngredient(choice, false)));
|
||||
var pattern = net.minecraft.world.item.crafting.ShapedRecipePattern.of(ingredients, recipe.getShape());
|
||||
return new net.minecraft.world.item.crafting.ShapedRecipe(
|
||||
new net.minecraft.world.item.crafting.Recipe.CommonInfo(true),
|
||||
new net.minecraft.world.item.crafting.CraftingRecipe.CraftingBookInfo(
|
||||
CraftRecipe.getCategory(recipe.getCategory()), recipe.getGroup()),
|
||||
pattern, CraftItemStack.asTemplate(recipe.getResult()));
|
||||
}
|
||||
|
||||
private static net.minecraft.world.item.crafting.CraftingInput nativeInput(List<ItemStack> items) {
|
||||
return net.minecraft.world.item.crafting.CraftingInput.of(3, 3, CraftItemStack.asNMSCopy(items));
|
||||
}
|
||||
|
||||
private static List<ItemStack> craftingGrid(PotionRecipes recipes, int recipeIndex, ItemStack potionOverride) {
|
||||
String[] shape = switch (recipeIndex) {
|
||||
case 0 -> PotionRecipes.SHIFTING_SHAPE;
|
||||
case 1 -> PotionRecipes.GROWTH_SHAPE;
|
||||
case 2 -> PotionRecipes.DIMINUTION_SHAPE;
|
||||
case 3 -> PotionRecipes.RESTORATION_SHAPE;
|
||||
default -> throw new IllegalArgumentException("Unknown recipe index");
|
||||
};
|
||||
List<ItemStack> items = new ArrayList<>();
|
||||
for (String row : shape) {
|
||||
for (char symbol : row.toCharArray()) {
|
||||
items.add(switch (symbol) {
|
||||
case 'A' -> new ItemStack(Material.AMETHYST_SHARD);
|
||||
case 'C' -> new ItemStack(Material.CHORUS_FRUIT);
|
||||
case 'G' -> new ItemStack(Material.GOLD_INGOT);
|
||||
case 'R' -> new ItemStack(Material.RABBIT_FOOT);
|
||||
case 'F' -> new ItemStack(Material.FERMENTED_SPIDER_EYE);
|
||||
case 'U' -> new ItemStack(Material.SUGAR);
|
||||
case 'W', 'S' -> potionOverride != null ? potionOverride.clone()
|
||||
: recipeIndex == 0 ? awkwardPotion() : recipes.create(StaturePotion.SHIFTING);
|
||||
default -> ItemStack.empty();
|
||||
});
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
private static List<ItemStack> invalidPotionInputs(PotionRecipes recipes, int recipeIndex) {
|
||||
ItemStack ordinary = new ItemStack(Material.POTION);
|
||||
ItemStack unrelated = new ItemStack(Material.POTION);
|
||||
unrelated.editMeta(org.bukkit.inventory.meta.PotionMeta.class,
|
||||
meta -> meta.setBasePotionType(org.bukkit.potion.PotionType.HEALING));
|
||||
if (recipeIndex == 0) {
|
||||
return List.of(ordinary, unrelated, recipes.create(StaturePotion.SHIFTING));
|
||||
}
|
||||
ItemStack nameOnly = new ItemStack(Material.POTION);
|
||||
nameOnly.editMeta(meta -> meta.displayName(
|
||||
net.kyori.adventure.text.Component.text(StaturePotion.SHIFTING.displayName())));
|
||||
return List.of(ordinary, unrelated, nameOnly, recipes.create(StaturePotion.GROWTH));
|
||||
}
|
||||
|
||||
private static ItemStack awkwardPotion() {
|
||||
ItemStack awkward = new ItemStack(Material.POTION);
|
||||
awkward.editMeta(org.bukkit.inventory.meta.PotionMeta.class,
|
||||
meta -> meta.setBasePotionType(org.bukkit.potion.PotionType.AWKWARD));
|
||||
return awkward;
|
||||
}
|
||||
|
||||
@Test
|
||||
void registersAllAuthenticatedResultsInMiscWithExactPotionInputs() {
|
||||
Server server = Bukkit.getServer();
|
||||
List<Recipe> registered = new ArrayList<>();
|
||||
when(server.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
registered.add(invocation.getArgument(0));
|
||||
return true;
|
||||
});
|
||||
when(server.getPotionBrewer()).thenReturn(mock(PotionBrewer.class));
|
||||
PotionRecipes recipes = new PotionRecipes(server,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
|
||||
|
||||
recipes.register();
|
||||
|
||||
assertEquals(4, registered.size());
|
||||
for (int index = 0; index < StaturePotion.values().length; index++) {
|
||||
ShapedRecipe recipe = assertInstanceOf(ShapedRecipe.class, registered.get(index));
|
||||
assertEquals(CraftingBookCategory.MISC, recipe.getCategory());
|
||||
assertEquals(StaturePotion.values()[index], recipes.identify(recipe.getResult()));
|
||||
RecipeChoice.ExactChoice choice = assertInstanceOf(RecipeChoice.ExactChoice.class,
|
||||
recipe.getChoiceMap().get(index == 0 ? 'W' : 'S'));
|
||||
ItemStack selected = choice.getChoices().getFirst();
|
||||
if (index == 0) {
|
||||
assertEquals(Material.POTION, selected.getType());
|
||||
assertTrue(recipes.isAwkwardPotion(selected));
|
||||
assertNull(recipes.identify(selected));
|
||||
} else {
|
||||
assertEquals(StaturePotion.SHIFTING, recipes.identify(selected));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
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.item.crafting.RecipeManager;
|
||||
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 static boolean bootstrapped;
|
||||
private static RecipeManager recipes;
|
||||
|
||||
private NativeRuntime() {}
|
||||
|
||||
static synchronized void bootstrap() throws Exception {
|
||||
if (bootstrapped) {
|
||||
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.WORLD), tags);
|
||||
RegistryAccess.Frozen world = RegistryDataLoader.load(resources, lookups,
|
||||
RegistryDataLoader.WORLD_REGISTRIES, Util.backgroundExecutor()).join();
|
||||
layers = layers.replaceFrom(RegistryLayer.WORLD, world);
|
||||
RegistryAccess.Frozen dimensions = RegistryDataLoader.load(resources,
|
||||
Stream.concat(lookups.stream(), world.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());
|
||||
recipes = datapack.getRecipeManager();
|
||||
recipes.finalizeRecipeLoading(flags);
|
||||
bootstrapped = true;
|
||||
}
|
||||
}
|
||||
|
||||
static RecipeManager recipes() {
|
||||
if (!bootstrapped) {
|
||||
throw new IllegalStateException("Native runtime has not been bootstrapped");
|
||||
}
|
||||
return recipes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
mock-maker-subclass
|
||||
@@ -0,0 +1,361 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.util.*;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.*;
|
||||
import org.bukkit.entity.*;
|
||||
import org.bukkit.event.player.PlayerInteractEntityEvent;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@SuppressWarnings("removal") // Construct declared-Purpur events without installing a server.
|
||||
class BukkitCarryTest {
|
||||
private final UUID worldId = UUID.randomUUID();
|
||||
private final World world = BukkitDoubles.proxy(World.class, (p, m, a) -> {
|
||||
if (m.getName().equals("getUID")) { return worldId; }
|
||||
throw new UnsupportedOperationException(m.getName());
|
||||
});
|
||||
private final Actor small = new Actor("Small", 0.4, 0), large = new Actor("Large", 1, 1);
|
||||
private final List<Runnable> scheduled = new ArrayList<>();
|
||||
private final List<String> warnings = new ArrayList<>();
|
||||
private final Logger logger = new Logger("carry-test", null) {
|
||||
@Override public void warning(String message) { warnings.add(message); }
|
||||
@Override public void severe(String message) { warnings.add(message); }
|
||||
};
|
||||
private boolean dismountAllowed = true, reenterStop, invalidMountTopology;
|
||||
private int nativeDismounts;
|
||||
private final BukkitCarry carry = new BukkitCarry(id -> id.equals(small.id) ? small.player
|
||||
: id.equals(large.id) ? large.player : null,
|
||||
() -> new HeightSettings(0.4, 2, 0.1, 0.5, 1.5, 20), scheduled::add, logger,
|
||||
p -> actor(p).scale);
|
||||
|
||||
private Actor actor(Player p) { return p == small.player ? small : large; }
|
||||
private final class Actor {
|
||||
final UUID id = UUID.randomUUID();
|
||||
final List<String> messages = new ArrayList<>();
|
||||
final List<Entity> passengers = new ArrayList<>();
|
||||
final Player player;
|
||||
Entity vehicle;
|
||||
Location location;
|
||||
double scale;
|
||||
boolean sneaking, online = true, dead, sleeping;
|
||||
GameMode gameMode = GameMode.SURVIVAL;
|
||||
Actor(String name, double scale, double x) {
|
||||
this.scale = scale; location = new Location(world, x, 64, 0);
|
||||
player = BukkitDoubles.proxy(Player.class, (p, m, a) -> switch (m.getName()) {
|
||||
case "getUniqueId" -> id;
|
||||
case "getName" -> name;
|
||||
case "getWorld" -> location.getWorld();
|
||||
case "getLocation" -> location.clone();
|
||||
case "isOnline", "isValid" -> online;
|
||||
case "isDead" -> dead;
|
||||
case "isSleeping" -> sleeping;
|
||||
case "getGameMode" -> gameMode;
|
||||
case "isSneaking" -> sneaking;
|
||||
case "isInsideVehicle" -> vehicle != null;
|
||||
case "getVehicle" -> vehicle;
|
||||
case "getPassengers" -> List.copyOf(passengers);
|
||||
case "addPassenger" -> {
|
||||
Player rider = (Player) a[0]; passengers.add(rider); actor(rider).vehicle = (Entity) p;
|
||||
if (invalidMountTopology) {
|
||||
UUID otherId = UUID.randomUUID();
|
||||
vehicle = BukkitDoubles.proxy(Entity.class, (entity, method, args) -> {
|
||||
if (method.getName().equals("getUniqueId")) { return otherId; }
|
||||
throw new UnsupportedOperationException(method.getName());
|
||||
});
|
||||
}
|
||||
yield true;
|
||||
}
|
||||
case "leaveVehicle" -> {
|
||||
nativeDismounts++;
|
||||
if (reenterStop) {
|
||||
reenterStop = false;
|
||||
carry.command((Player) p, "stop");
|
||||
}
|
||||
if (vehicle != null) {
|
||||
var event = new org.bukkit.event.entity.EntityDismountEvent((Player) p, vehicle, true);
|
||||
if (!dismountAllowed) { event.setCancelled(true); }
|
||||
carry.dismountEvent(event);
|
||||
if (!event.isCancelled()) {
|
||||
actor((Player) vehicle).passengers.removeIf(e -> e == p); vehicle = null;
|
||||
}
|
||||
}
|
||||
yield true; // Intentionally misleading: adapter must check actual native attachment.
|
||||
}
|
||||
case "sendMessage" -> { messages.add((String) a[0]); yield null; }
|
||||
case "equals" -> p == a[0];
|
||||
case "hashCode" -> id.hashCode();
|
||||
case "toString" -> name;
|
||||
case "teleport", "setFallDistance", "setVelocity", "setInvulnerable", "eject", "removePassenger" ->
|
||||
throw new AssertionError("Native dismount must not rescue, reset motion, or eject other passengers");
|
||||
default -> throw new UnsupportedOperationException(m.getName());
|
||||
});
|
||||
}
|
||||
}
|
||||
private PlayerInteractEntityEvent click(Actor from, Actor to, EquipmentSlot hand, boolean cancelled) {
|
||||
PlayerInteractEntityEvent event = new PlayerInteractEntityEvent(from.player, to.player, hand);
|
||||
event.setCancelled(cancelled); carry.interact(event); return event;
|
||||
}
|
||||
private void start() {
|
||||
drain();
|
||||
click(small, large, EquipmentSlot.HAND, false);
|
||||
assertSame(large.player, small.vehicle);
|
||||
}
|
||||
private void drain() {
|
||||
List<Runnable> tasks = List.copyOf(scheduled); scheduled.clear(); tasks.forEach(Runnable::run);
|
||||
}
|
||||
@Test
|
||||
void mountingKeepsFourBlockSameWorldAndAvailableParticipantBoundaries() {
|
||||
large.location.setX(4.000001);
|
||||
click(small, large, EquipmentSlot.HAND, false);
|
||||
assertNull(small.vehicle);
|
||||
drain();
|
||||
large.location.setX(4);
|
||||
start();
|
||||
carry.command(small.player, "stop");
|
||||
drain();
|
||||
UUID otherWorldId = UUID.randomUUID();
|
||||
World otherWorld = BukkitDoubles.proxy(World.class, (p, m, a) -> {
|
||||
if (m.getName().equals("getUID")) { return otherWorldId; }
|
||||
throw new UnsupportedOperationException(m.getName());
|
||||
});
|
||||
large.location.setWorld(otherWorld);
|
||||
click(small, large, EquipmentSlot.HAND, false);
|
||||
assertNull(small.vehicle);
|
||||
large.location.setWorld(world);
|
||||
for (Actor unavailable : List.of(small, large)) {
|
||||
for (int state = 0; state < 4; state++) {
|
||||
drain();
|
||||
unavailable.online = state != 0;
|
||||
unavailable.dead = state == 1;
|
||||
unavailable.sleeping = state == 2;
|
||||
unavailable.gameMode = state == 3 ? GameMode.SPECTATOR : GameMode.SURVIVAL;
|
||||
click(small, large, EquipmentSlot.HAND, false);
|
||||
assertNull(small.vehicle);
|
||||
}
|
||||
unavailable.online = true; unavailable.dead = false; unavailable.sleeping = false;
|
||||
unavailable.gameMode = GameMode.SURVIVAL;
|
||||
}
|
||||
start();
|
||||
}
|
||||
@Test
|
||||
void existingVehiclesOrPassengersOfEitherParticipantAreNeverTouched() {
|
||||
for (Actor busy : List.of(small, large)) {
|
||||
Actor other = busy == small ? large : small;
|
||||
busy.vehicle = other.player;
|
||||
click(small, large, EquipmentSlot.HAND, false);
|
||||
assertSame(other.player, busy.vehicle);
|
||||
assertEquals(0, nativeDismounts);
|
||||
busy.vehicle = null;
|
||||
drain();
|
||||
busy.passengers.add(other.player);
|
||||
click(small, large, EquipmentSlot.HAND, false);
|
||||
assertEquals(List.of(other.player), busy.passengers);
|
||||
assertNull(small.vehicle);
|
||||
assertEquals(0, nativeDismounts);
|
||||
busy.passengers.clear();
|
||||
drain();
|
||||
}
|
||||
start();
|
||||
// Additional passengers/mounts involving either occupied role are rejected.
|
||||
UUID extraId = UUID.randomUUID();
|
||||
Entity extra = BukkitDoubles.proxy(Entity.class, (p, m, a) -> {
|
||||
if (m.getName().equals("getUniqueId")) { return extraId; }
|
||||
throw new UnsupportedOperationException(m.getName());
|
||||
});
|
||||
var extraRider = new org.bukkit.event.entity.EntityMountEvent(extra, large.player);
|
||||
carry.mountEvent(extraRider);
|
||||
assertTrue(extraRider.isCancelled());
|
||||
}
|
||||
@Test
|
||||
void removedConsentActionsOnlyShowStopUsage() {
|
||||
for (String action : List.of("accept", "decline", "unknown")) {
|
||||
carry.command(large.player, action);
|
||||
assertEquals("Usage: /heights carry stop", large.messages.getLast());
|
||||
assertNull(small.vehicle);
|
||||
}
|
||||
}
|
||||
@Test
|
||||
void startsWithoutSafeGroundAndNativeStopDoesNotTeleportOrResetMotion() {
|
||||
start();
|
||||
Location before = small.location.clone();
|
||||
carry.command(small.player, "stop");
|
||||
assertEquals(1, nativeDismounts);
|
||||
assertNull(small.vehicle);
|
||||
assertEquals(before, small.location);
|
||||
}
|
||||
@Test
|
||||
void carrierCanAlsoStopUsingOnlyTheRidersNativeDismount() {
|
||||
start();
|
||||
carry.command(large.player, "stop");
|
||||
assertEquals(1, nativeDismounts);
|
||||
assertNull(small.vehicle);
|
||||
}
|
||||
@Test
|
||||
void preventsNestedMountsButDoesNotCancelNaturalSneakDismount() {
|
||||
start();
|
||||
var nested = new org.bukkit.event.entity.EntityMountEvent(large.player, small.player);
|
||||
carry.mountEvent(nested);
|
||||
assertTrue(nested.isCancelled());
|
||||
var exit = new org.bukkit.event.entity.EntityDismountEvent(small.player, large.player, true);
|
||||
carry.dismountEvent(exit);
|
||||
assertFalse(exit.isCancelled());
|
||||
small.player.leaveVehicle();
|
||||
drain();
|
||||
assertNull(small.vehicle);
|
||||
assertEquals(1, nativeDismounts);
|
||||
start(); // The native detach also cleared Heights' pair tracking.
|
||||
}
|
||||
@Test
|
||||
void allowedTeleportDetachesWithoutCancellingOrReplayingTeleport() {
|
||||
start();
|
||||
var event = new org.bukkit.event.player.PlayerTeleportEvent(
|
||||
large.player, large.location, new Location(world, 50, 70, 50));
|
||||
event.setCancelled(true); carry.teleportEvent(event); drain();
|
||||
assertSame(large.player, small.vehicle);
|
||||
event.setCancelled(false); carry.teleportEvent(event);
|
||||
assertFalse(event.isCancelled());
|
||||
assertNull(small.vehicle);
|
||||
assertEquals(1, nativeDismounts);
|
||||
assertEquals(50, event.getTo().getX());
|
||||
}
|
||||
@Test
|
||||
void nativeCancellationIsNotMistakenForDetachmentAndCanBeRetried() {
|
||||
start(); dismountAllowed = false;
|
||||
carry.command(small.player, "stop");
|
||||
assertSame(large.player, small.vehicle);
|
||||
assertTrue(small.messages.stream().anyMatch(m -> m.contains("refused")));
|
||||
dismountAllowed = true;
|
||||
carry.tick();
|
||||
assertEquals(1, nativeDismounts, "A cancelled voluntary stop is not an automatic rescue/retry job");
|
||||
carry.command(large.player, "stop");
|
||||
assertNull(small.vehicle);
|
||||
assertEquals(2, nativeDismounts);
|
||||
}
|
||||
@Test
|
||||
void refusedDismountPreventsTeleportFromMovingTheActivePair() {
|
||||
start(); dismountAllowed = false;
|
||||
var event = new org.bukkit.event.player.PlayerTeleportEvent(
|
||||
large.player, large.location, new Location(world, 50, 70, 50));
|
||||
carry.teleportEvent(event);
|
||||
assertTrue(event.isCancelled());
|
||||
assertSame(large.player, small.vehicle);
|
||||
}
|
||||
@Test
|
||||
void quitCleansBothRolesIncludingAnOfflineRiderWithoutRescue() {
|
||||
start();
|
||||
carry.quit(new org.bukkit.event.player.PlayerQuitEvent(large.player, (net.kyori.adventure.text.Component) null));
|
||||
assertNull(small.vehicle);
|
||||
start(); small.online = false;
|
||||
carry.quit(new org.bukkit.event.player.PlayerQuitEvent(small.player, (net.kyori.adventure.text.Component) null));
|
||||
assertNull(small.vehicle);
|
||||
assertTrue(warnings.isEmpty());
|
||||
}
|
||||
@Test
|
||||
void cancelledPurpurDeathDoesNotStopRideButActualDeathDoesForEitherRole() {
|
||||
for (Actor actor : List.of(large, small)) {
|
||||
start();
|
||||
var death = new org.bukkit.event.entity.PlayerDeathEvent(
|
||||
actor.player, null, List.of(), 0, (net.kyori.adventure.text.Component) null, true);
|
||||
death.setCancelled(true); carry.death(death);
|
||||
assertSame(large.player, small.vehicle);
|
||||
death.setCancelled(false); actor.dead = true; carry.death(death);
|
||||
assertNull(small.vehicle);
|
||||
actor.dead = false;
|
||||
}
|
||||
assertEquals(2, nativeDismounts);
|
||||
}
|
||||
@Test
|
||||
void effectiveScaleChangesUseNativeCleanupAndRespectRefusals() {
|
||||
start(); small.scale = 0.95; dismountAllowed = false;
|
||||
carry.tick();
|
||||
assertSame(large.player, small.vehicle);
|
||||
dismountAllowed = true; carry.tick();
|
||||
assertNull(small.vehicle);
|
||||
}
|
||||
@Test
|
||||
void disableUsesNativeDismountAndReportsServerRefusalsWithoutClaimingSuccess() {
|
||||
start(); dismountAllowed = false;
|
||||
carry.close();
|
||||
assertSame(large.player, small.vehicle);
|
||||
assertTrue(warnings.stream().anyMatch(w -> w.contains("refused")));
|
||||
dismountAllowed = true; carry.close();
|
||||
assertNull(small.vehicle);
|
||||
}
|
||||
@Test
|
||||
void nonCancellableNativeDismountNeedsNoRescueOrWarning() {
|
||||
start();
|
||||
var event = new org.bukkit.event.entity.EntityDismountEvent(small.player, large.player, false);
|
||||
carry.dismountEvent(event);
|
||||
assertFalse(event.isCancelled());
|
||||
large.passengers.clear(); small.vehicle = null; // Native forced detachment after event dispatch.
|
||||
drain();
|
||||
assertEquals(0, nativeDismounts);
|
||||
assertTrue(warnings.isEmpty());
|
||||
start();
|
||||
}
|
||||
@Test
|
||||
void partialNativeMountIsTrackedUntilItsInvalidRelationshipCanBeDetached() {
|
||||
invalidMountTopology = true; dismountAllowed = false;
|
||||
click(small, large, EquipmentSlot.HAND, false);
|
||||
assertSame(large.player, small.vehicle);
|
||||
assertEquals(1, nativeDismounts, "Attempt native cleanup of a partially successful invalid mount");
|
||||
assertFalse(large.messages.getLast().contains("started"));
|
||||
dismountAllowed = true;
|
||||
carry.command(small.player, "stop");
|
||||
assertNull(small.vehicle);
|
||||
assertEquals(2, nativeDismounts);
|
||||
assertNotNull(large.vehicle, "Do not eject an unrelated relationship owned by another plugin");
|
||||
}
|
||||
@Test
|
||||
void synchronousPluginReentryCannotRecursivelyDismountTheSameRider() {
|
||||
start(); reenterStop = true;
|
||||
carry.command(small.player, "stop");
|
||||
assertEquals(1, nativeDismounts);
|
||||
assertNull(small.vehicle);
|
||||
assertEquals("Carry ended.", small.messages.getLast());
|
||||
}
|
||||
@Test
|
||||
void cancelledNaturalDismountIsNotRetriedAndLateCancellationIsObserved() {
|
||||
start(); dismountAllowed = false;
|
||||
small.player.leaveVehicle();
|
||||
drain();
|
||||
assertSame(large.player, small.vehicle);
|
||||
assertEquals(1, nativeDismounts);
|
||||
var lateCancelled = new org.bukkit.event.entity.EntityDismountEvent(small.player, large.player, true);
|
||||
carry.dismountEvent(lateCancelled);
|
||||
lateCancelled.setCancelled(true);
|
||||
drain();
|
||||
assertSame(large.player, small.vehicle);
|
||||
assertEquals(1, nativeDismounts);
|
||||
}
|
||||
@Test
|
||||
void stalePairDoesNotEjectRiderFromAnUnrelatedVehicle() {
|
||||
start();
|
||||
UUID otherId = UUID.randomUUID();
|
||||
Entity unrelated = BukkitDoubles.proxy(Entity.class, (p, method, args) -> {
|
||||
if (method.getName().equals("getUniqueId")) { return otherId; }
|
||||
throw new UnsupportedOperationException(method.getName());
|
||||
});
|
||||
large.passengers.clear(); small.vehicle = unrelated;
|
||||
carry.tick();
|
||||
assertSame(unrelated, small.vehicle);
|
||||
assertEquals(0, nativeDismounts);
|
||||
carry.command(large.player, "stop");
|
||||
assertEquals("No active ride.", large.messages.getLast());
|
||||
}
|
||||
@Test
|
||||
void onlyUncancelledMainHandSmallerInitiatorsMountImmediately() {
|
||||
click(small, large, EquipmentSlot.HAND, true);
|
||||
click(small, large, EquipmentSlot.OFF_HAND, false);
|
||||
click(large, small, EquipmentSlot.HAND, false);
|
||||
assertNull(small.vehicle);
|
||||
assertTrue(large.passengers.isEmpty());
|
||||
assertTrue(click(small, large, EquipmentSlot.HAND, false).isCancelled());
|
||||
assertSame(large.player, small.vehicle);
|
||||
assertEquals(List.of(small.player), large.passengers);
|
||||
carry.command(large.player, "stop");
|
||||
assertNull(small.vehicle);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.PotionMeta;
|
||||
import org.bukkit.persistence.PersistentDataContainer;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
|
||||
/** Small boundary doubles: no server installation or mocked domain rules. */
|
||||
final class BukkitDoubles {
|
||||
private BukkitDoubles() {}
|
||||
|
||||
static <T> T proxy(Class<T> type, InvocationHandler handler) {
|
||||
return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[] {type}, handler));
|
||||
}
|
||||
|
||||
static PersistentDataContainer container() {
|
||||
Map<NamespacedKey, Object> values = new HashMap<>();
|
||||
Map<NamespacedKey, PersistentDataType<?, ?>> types = new HashMap<>();
|
||||
return proxy(PersistentDataContainer.class, (proxy, method, args) -> {
|
||||
return switch (method.getName()) {
|
||||
case "set" -> {
|
||||
values.put((NamespacedKey) args[0], args[2]);
|
||||
types.put((NamespacedKey) args[0], (PersistentDataType<?, ?>) args[1]);
|
||||
yield null;
|
||||
}
|
||||
case "get" -> types.get(args[0]) == args[1] ? values.get(args[0]) : null;
|
||||
case "has" -> values.containsKey(args[0]) && (args.length == 1 || types.get(args[0]) == args[1]);
|
||||
case "remove" -> { types.remove(args[0]); values.remove(args[0]); yield null; }
|
||||
case "getKeys" -> Set.copyOf(values.keySet());
|
||||
case "isEmpty" -> values.isEmpty();
|
||||
default -> throw new UnsupportedOperationException(method.getName());
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
static final class Item extends ItemStack {
|
||||
private final Material material;
|
||||
private final PersistentDataContainer data = container();
|
||||
private Object displayName;
|
||||
private final PotionMeta meta = proxy(PotionMeta.class, (proxy, method, args) -> {
|
||||
return switch (method.getName()) {
|
||||
case "getPersistentDataContainer" -> data;
|
||||
case "displayName" -> {
|
||||
if (args != null && args.length == 1) { displayName = args[0]; yield null; }
|
||||
yield displayName;
|
||||
}
|
||||
case "setColor" -> null;
|
||||
default -> throw new UnsupportedOperationException(method.getName());
|
||||
};
|
||||
});
|
||||
|
||||
Item(Material material) { this.material = material; }
|
||||
@Override public Material getType() { return material; }
|
||||
@Override public PotionMeta getItemMeta() { return meta; }
|
||||
@Override public boolean setItemMeta(org.bukkit.inventory.meta.ItemMeta value) { return value == meta; }
|
||||
@Override public Item clone() { return this; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.attribute.AttributeInstance;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class BukkitStatureServiceTest {
|
||||
@TempDir
|
||||
Path directory;
|
||||
|
||||
@Test
|
||||
void silentRestorationKeepsStoredClampingAndExpiredBaselineWithoutFeedback() throws Exception {
|
||||
HeightSettings settings = new HeightSettings(0.4, 2.0, 0.1, 0.5, 1.5, 20);
|
||||
HeightStore store = new HeightStore(directory.toFile());
|
||||
TemporaryStature stature = new TemporaryStature(store);
|
||||
PlayerFixture player = new PlayerFixture();
|
||||
|
||||
UUID saved = UUID.randomUUID();
|
||||
store.save(saved, 3.0);
|
||||
assertTrue(BukkitStatureService.applyScale(
|
||||
player.player, player.attribute, stature.resume(saved, settings, Instant.now()), false));
|
||||
assertEquals(2.0, player.scale);
|
||||
assertEquals(List.of(), player.messages);
|
||||
|
||||
UUID expired = UUID.randomUUID();
|
||||
Instant start = Instant.parse("2026-01-01T00:00:00Z");
|
||||
stature.apply(expired, 1.4, StaturePotion.DIMINUTION, settings, start, bound -> 0);
|
||||
assertTrue(BukkitStatureService.applyScale(player.player, player.attribute,
|
||||
stature.resume(expired, settings, start.plusSeconds(301)), false));
|
||||
assertEquals(1.4, player.scale);
|
||||
assertEquals(List.of(), player.messages);
|
||||
}
|
||||
|
||||
@Test
|
||||
void notifyingApplicationPreservesPotionAdministratorAndRespawnFeedback() {
|
||||
PlayerFixture player = new PlayerFixture();
|
||||
|
||||
assertTrue(BukkitStatureService.applyScale(player.player, player.attribute, 1.2, true));
|
||||
assertEquals(1.2, player.scale);
|
||||
assertEquals(List.of("Your scale is now 1.2."), player.messages);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unavailablePlayerStillRefusesRestorationWithoutFeedback() {
|
||||
Player offline = BukkitDoubles.proxy(Player.class, (proxy, method, args) -> {
|
||||
if (method.getName().equals("isOnline")) {
|
||||
return false;
|
||||
}
|
||||
throw new AssertionError("Offline player should not be changed: " + method.getName());
|
||||
});
|
||||
|
||||
assertFalse(BukkitStatureService.applyToPlayer(offline, 0.8, false));
|
||||
assertFalse(BukkitStatureService.applyToPlayer(null, 0.8, true));
|
||||
assertFalse(BukkitStatureService.applyScale(offline, null, 0.8, true));
|
||||
}
|
||||
|
||||
private static final class PlayerFixture {
|
||||
private double scale = 1.0;
|
||||
private final List<String> messages = new ArrayList<>();
|
||||
private final AttributeInstance attribute = BukkitDoubles.proxy(
|
||||
AttributeInstance.class, (proxy, method, args) -> switch (method.getName()) {
|
||||
case "setBaseValue" -> { scale = (double) args[0]; yield null; }
|
||||
case "getBaseValue" -> scale;
|
||||
default -> throw new UnsupportedOperationException(method.getName());
|
||||
});
|
||||
private final Player player = BukkitDoubles.proxy(Player.class, (proxy, method, args) -> {
|
||||
if (method.getName().equals("sendMessage")) {
|
||||
messages.add((String) args[0]);
|
||||
return null;
|
||||
}
|
||||
throw new UnsupportedOperationException(method.getName());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class CarryPolicyTest {
|
||||
@Test
|
||||
void usesIndependentGapWithDecimalBoundaryTolerance() {
|
||||
assertTrue(CarryPolicy.eligible(1.0, 0.9, 0.1));
|
||||
assertTrue(CarryPolicy.eligible(0.5, 0.4, 0.1));
|
||||
assertFalse(CarryPolicy.eligible(1.0, 0.900001, 0.1));
|
||||
assertFalse(CarryPolicy.eligible(1.0, 0.9, 0.2));
|
||||
assertTrue(CarryPolicy.eligible(1.0, 0.8, 0.2));
|
||||
assertFalse(CarryPolicy.eligible(0.7, 1.0, 0.1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void zeroGapStillRequiresStrictlyLargerCarrierAndAllInputsMustBeValid() {
|
||||
assertTrue(CarryPolicy.eligible(1, Math.nextDown(1.0), 0));
|
||||
assertFalse(CarryPolicy.eligible(1, 1, 0));
|
||||
assertFalse(CarryPolicy.eligible(0.9, 1, 0));
|
||||
for (double invalid : new double[] {0, -1, Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY}) {
|
||||
assertFalse(CarryPolicy.eligible(invalid, 0.4, 0.1));
|
||||
assertFalse(CarryPolicy.eligible(1, invalid, 0.1));
|
||||
if (invalid != 0) { assertFalse(CarryPolicy.eligible(1, 0.4, invalid)); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.util.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class CarryServiceTest {
|
||||
private final UUID small = UUID.randomUUID(), large = UUID.randomUUID(), third = UUID.randomUUID();
|
||||
private final Map<UUID, CarryService.State> states = new HashMap<>(Map.of(
|
||||
small, new CarryService.State(0.4, true, true),
|
||||
large, new CarryService.State(1, true, true),
|
||||
third, new CarryService.State(2, true, true)));
|
||||
private double gap = 0.1;
|
||||
private boolean near = true, mountAllowed = true, stopAllowed = true, attached;
|
||||
private int mounts, stops;
|
||||
private Runnable onMount = () -> {};
|
||||
private UUID mountedCarrier, mountedRider;
|
||||
private final List<String> messages = new ArrayList<>();
|
||||
private final CarryService service = new CarryService(new CarryService.Port() {
|
||||
public CarryService.State state(UUID id) { return states.get(id); }
|
||||
public boolean nearby(UUID a, UUID b) { return near; }
|
||||
public boolean mount(UUID carrier, UUID rider) {
|
||||
mounts++; mountedCarrier = carrier; mountedRider = rider;
|
||||
onMount.run();
|
||||
attached = mountAllowed; return mountAllowed;
|
||||
}
|
||||
public boolean attached(UUID carrier, UUID rider) { return attached; }
|
||||
public boolean linked(UUID carrier, UUID rider) { return attached; }
|
||||
public boolean dismount(UUID carrier, UUID rider) {
|
||||
stops++;
|
||||
if (stopAllowed) { attached = false; }
|
||||
return stopAllowed;
|
||||
}
|
||||
public void message(UUID id, String text) { messages.add(text); }
|
||||
}, () -> gap);
|
||||
|
||||
@Test
|
||||
void shutdownDuringNativeMountDetachesInsteadOfStartingANewRide() {
|
||||
onMount = () -> service.close();
|
||||
String result = service.ride(small, large);
|
||||
assertFalse(result.contains("started"));
|
||||
assertEquals(1, stops);
|
||||
assertFalse(service.involved(small));
|
||||
assertFalse(attached);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shutdownDuringMountStillReportsRefusedNativeDetachmentHonestly() {
|
||||
stopAllowed = false;
|
||||
onMount = () -> service.close();
|
||||
String result = service.ride(small, large);
|
||||
assertFalse(result.contains("started"));
|
||||
assertTrue(result.contains("refused"));
|
||||
assertTrue(service.involved(small));
|
||||
assertTrue(attached);
|
||||
assertEquals(1, service.close());
|
||||
stopAllowed = true;
|
||||
assertEquals(0, service.close());
|
||||
}
|
||||
|
||||
@Test
|
||||
void synchronousMountCallbacksCannotStartAnotherRideForEitherParticipant() {
|
||||
onMount = () -> {
|
||||
onMount = () -> {};
|
||||
assertFalse(service.ride(small, third).contains("started"));
|
||||
assertFalse(service.ride(large, third).contains("started"));
|
||||
};
|
||||
assertTrue(service.ride(small, large).contains("started"));
|
||||
assertEquals(1, mounts);
|
||||
assertEquals(large, mountedCarrier);
|
||||
assertEquals(small, mountedRider);
|
||||
assertFalse(service.involved(third));
|
||||
}
|
||||
|
||||
@Test
|
||||
void immediateRideIsSmallerInitiatedOnlyAndChecksCurrentEligibility() {
|
||||
assertFalse(service.ride(large, small).contains("started"));
|
||||
assertEquals(0, mounts);
|
||||
for (int change = 0; change < 5; change++) {
|
||||
near = true; gap = 0.1;
|
||||
states.put(small, new CarryService.State(0.4, true, true));
|
||||
switch (change) {
|
||||
case 0 -> near = false;
|
||||
case 1 -> gap = 0.7;
|
||||
case 2 -> states.put(small, new CarryService.State(0.95, true, true));
|
||||
case 3 -> states.put(small, new CarryService.State(0.4, false, true));
|
||||
case 4 -> states.remove(small);
|
||||
default -> throw new AssertionError();
|
||||
}
|
||||
assertFalse(service.ride(small, large).contains("started"));
|
||||
assertEquals(0, mounts);
|
||||
}
|
||||
near = true; gap = 0.1;
|
||||
states.put(small, new CarryService.State(0.9, true, true));
|
||||
assertTrue(service.ride(small, large).contains("started"));
|
||||
assertEquals(1, mounts);
|
||||
assertEquals(large, mountedCarrier);
|
||||
assertEquals(small, mountedRider);
|
||||
assertTrue(service.involved(small));
|
||||
assertTrue(service.involved(large));
|
||||
assertTrue(messages.stream().noneMatch(message -> message.contains("request")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void automaticNativeRefusalIsReportedOnceRatherThanSilentlyRetried() {
|
||||
service.ride(small, large);
|
||||
messages.clear();
|
||||
states.put(small, new CarryService.State(0.95, true, true));
|
||||
stopAllowed = false;
|
||||
service.tick(); service.tick();
|
||||
assertEquals(2, messages.size(), "Notify both participants once, not every tick");
|
||||
assertTrue(messages.stream().allMatch(message -> message.contains("refused")));
|
||||
assertTrue(service.involved(small));
|
||||
stopAllowed = true; service.tick();
|
||||
assertFalse(service.involved(small));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsExistingPassengersVehiclesAndActiveRidesAndFailedMounts() {
|
||||
for (UUID busy : List.of(small, large)) {
|
||||
CarryService.State before = states.get(busy);
|
||||
states.put(busy, new CarryService.State(before.scale(), true, false));
|
||||
assertFalse(service.ride(small, large).contains("started"));
|
||||
assertEquals(0, mounts);
|
||||
states.put(busy, before);
|
||||
}
|
||||
mountAllowed = false;
|
||||
assertTrue(service.ride(small, large).contains("refused"));
|
||||
assertFalse(service.involved(small));
|
||||
mountAllowed = true;
|
||||
assertTrue(service.ride(small, large).contains("started"));
|
||||
assertFalse(service.ride(third, large).contains("started"));
|
||||
assertFalse(service.ride(small, third).contains("started"));
|
||||
assertFalse(service.ride(large, third).contains("started"));
|
||||
assertEquals(2, mounts);
|
||||
}
|
||||
|
||||
@Test
|
||||
void lossOfEligibilityAttemptsNativeStopAndRefusedVoluntaryStopsRemainAccuratelyTracked() {
|
||||
service.ride(small, large);
|
||||
stopAllowed = false;
|
||||
states.put(small, new CarryService.State(0.95, true, true));
|
||||
service.tick();
|
||||
assertEquals(1, stops);
|
||||
assertTrue(service.involved(small));
|
||||
assertTrue(service.stop(large).contains("refused"));
|
||||
states.put(small, new CarryService.State(0.4, true, true));
|
||||
stopAllowed = true;
|
||||
service.tick();
|
||||
assertTrue(service.involved(small));
|
||||
assertTrue(service.stop(small).contains("ended"));
|
||||
assertFalse(service.involved(small));
|
||||
assertFalse(service.involved(large));
|
||||
}
|
||||
|
||||
@Test
|
||||
void lifecycleCleansRidesAndReportsUnresolvedDisable() {
|
||||
service.ride(small, large);
|
||||
assertTrue(service.cleanup(small));
|
||||
service.ride(small, large);
|
||||
assertTrue(service.cleanup(large));
|
||||
assertFalse(service.involved(small));
|
||||
service.ride(small, large);
|
||||
stopAllowed = false;
|
||||
assertFalse(service.cleanup(small));
|
||||
assertEquals(1, service.close());
|
||||
assertTrue(service.involved(small));
|
||||
assertFalse(service.ride(third, large).contains("started"));
|
||||
stopAllowed = true;
|
||||
assertEquals(0, service.close());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unavailableOrDetachedParticipantsTriggerCleanup() {
|
||||
service.ride(small, large);
|
||||
attached = false;
|
||||
service.tick();
|
||||
assertEquals(1, stops);
|
||||
assertFalse(service.involved(large));
|
||||
service.ride(small, large);
|
||||
states.put(large, new CarryService.State(1, false, false));
|
||||
service.tick();
|
||||
assertEquals(2, stops);
|
||||
assertFalse(service.involved(large));
|
||||
}
|
||||
|
||||
@Test
|
||||
void eitherParticipantCanStopImmediatelyWithoutApprovalOrRemountCooldown() {
|
||||
for (UUID caller : List.of(small, large)) {
|
||||
assertTrue(service.ride(small, large).contains("started"));
|
||||
assertTrue(service.stop(caller).contains("ended"));
|
||||
assertFalse(service.involved(small));
|
||||
}
|
||||
assertEquals(2, mounts);
|
||||
assertEquals(2, stops);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class CompletionMailboxTest {
|
||||
@Test
|
||||
void shutdownCanDrainExactlyOnceEvenWhenScheduledWakeupWasDropped() {
|
||||
List<Runnable> scheduled = new ArrayList<>();
|
||||
List<String> results = new ArrayList<>();
|
||||
CompletionMailbox mailbox = new CompletionMailbox(scheduled::add);
|
||||
mailbox.execute(() -> results.add("failed save released reservation"));
|
||||
assertEquals(List.of(), results);
|
||||
mailbox.drain();
|
||||
assertEquals(List.of("failed save released reservation"), results);
|
||||
scheduled.forEach(Runnable::run);
|
||||
mailbox.drain();
|
||||
assertEquals(1, results.size());
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,20 @@ class HeightMathTest {
|
||||
assertEquals(1.1, HeightMath.grow(1.0, SETTINGS), 0.000001);
|
||||
}
|
||||
|
||||
@Test
|
||||
void savedDefaultSizeSurvivesRangesThatExcludeIt() {
|
||||
for (HeightSettings settings : new HeightSettings[] {
|
||||
new HeightSettings(0.2, 0.8, 0.1, 0.5, 1.5, 20),
|
||||
new HeightSettings(1.2, 2.0, 0.1, 1.5, 1.5, 20)}) {
|
||||
assertEquals(1.0, HeightMath.safeStoredScale(1.0, settings));
|
||||
assertEquals(settings.minimum(), HeightMath.safeStoredScale(0.1, settings));
|
||||
assertEquals(settings.maximum(), HeightMath.safeStoredScale(3.0, settings));
|
||||
double fallback = HeightMath.clamp(1.0, settings);
|
||||
assertEquals(fallback, HeightMath.safeStoredScale(null, settings));
|
||||
assertEquals(fallback, HeightMath.safeStoredScale(Double.NaN, settings));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingAndStoredValuesAreSafelyClamped() {
|
||||
assertEquals(1.0, HeightMath.safeStoredScale(null, SETTINGS), 0.000001);
|
||||
|
||||
@@ -13,6 +13,38 @@ class HeightSettingsTest {
|
||||
assertEquals(16, settings.randomStepCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingGapDefaultsIndependentlyAndBoundsKeepExplicitGap() throws Exception {
|
||||
var config = new org.bukkit.configuration.file.YamlConfiguration();
|
||||
config.loadFromString(java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/config.yml")));
|
||||
assertEquals(0.1, SpigotHeightsPlugin.loadSettings(config).carryMinimumScaleGap());
|
||||
config.set("carry.minimum-scale-gap", null);
|
||||
config.set("height.adjustment-step", 0.25);
|
||||
assertEquals(0.1, SpigotHeightsPlugin.loadSettings(config).carryMinimumScaleGap());
|
||||
for (double gap : new double[] {0, 0.2, 1.5}) {
|
||||
config.set("carry.minimum-scale-gap", gap);
|
||||
HeightSettings settings = SpigotHeightsPlugin.loadSettings(config);
|
||||
assertEquals(gap, settings.carryMinimumScaleGap());
|
||||
assertEquals(0.25, settings.adjustmentStep());
|
||||
var live = new LiveHeightSettings(settings, ignored -> {});
|
||||
live.setBound("max", 3);
|
||||
assertEquals(gap, live.get().carryMinimumScaleGap());
|
||||
assertEquals(0.25, live.get().adjustmentStep());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidCarryGapInExistingConfigLoader() throws Exception {
|
||||
var config = new org.bukkit.configuration.file.YamlConfiguration();
|
||||
config.loadFromString(java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/config.yml")));
|
||||
for (Object invalid : new Object[] {-0.1, Double.NaN, Double.POSITIVE_INFINITY,
|
||||
Double.NEGATIVE_INFINITY, "garbage", true, java.util.List.of(0.1)}) {
|
||||
config.set("carry.minimum-scale-gap", invalid);
|
||||
assertThrows(IllegalArgumentException.class, () -> SpigotHeightsPlugin.loadSettings(config),
|
||||
"Invalid carry gap must fail startup validation: " + invalid);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidRangesAndNumbers() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -12,6 +15,48 @@ class HeightStoreTest {
|
||||
@TempDir
|
||||
Path temporaryDirectory;
|
||||
|
||||
@Test
|
||||
void failedSaveDoesNotChangeCachedOrSavedScale() throws Exception {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
HeightStore store = new HeightStore(temporaryDirectory.toFile());
|
||||
store.save(playerId, 0.7);
|
||||
String original = Files.readString(temporaryDirectory.resolve("state.yml"));
|
||||
Files.createDirectory(temporaryDirectory.resolve("state.yml.tmp"));
|
||||
assertThrows(IOException.class, () -> store.save(playerId, 0.2));
|
||||
assertEquals(0.7, store.find(playerId));
|
||||
assertEquals(original, Files.readString(temporaryDirectory.resolve("state.yml")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void restorationReplacesPreviousSizeAndSurvivesReloadOutsideLimits() throws Exception {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
HeightSettings settings = new HeightSettings(0.2, 0.8, 0.1, 0.5, 1.5, 20);
|
||||
HeightStore store = new HeightStore(temporaryDirectory.toFile());
|
||||
store.save(playerId, 0.4);
|
||||
store.save(playerId, StaturePotion.RESTORATION.scaleAfterDrinking(0.4, settings, bound -> 0));
|
||||
|
||||
HeightStore reloaded = new HeightStore(temporaryDirectory.toFile());
|
||||
assertEquals(1.0, reloaded.find(playerId));
|
||||
assertEquals(1.0, HeightMath.safeStoredScale(reloaded.find(playerId), settings));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sourceRemovalRetiresOnlyItsReceiptsWithoutChangingStature() throws Exception {
|
||||
UUID player = UUID.randomUUID();
|
||||
UUID source = UUID.randomUUID();
|
||||
UUID other = UUID.randomUUID();
|
||||
HeightStore store = new HeightStore(temporaryDirectory.toFile());
|
||||
HeightStore.Sequence sequence = new HeightStore.Sequence(1.0, java.time.Instant.EPOCH.plusSeconds(300));
|
||||
store.save(player, 0.8, sequence, source);
|
||||
store.save(player, 0.6, sequence, other);
|
||||
store.forgetSource(source);
|
||||
HeightStore reloaded = new HeightStore(temporaryDirectory.toFile());
|
||||
assertEquals(false, reloaded.hasReceipt(player, source));
|
||||
assertEquals(true, reloaded.hasReceipt(player, other));
|
||||
assertEquals(0.6, reloaded.find(player));
|
||||
assertEquals(sequence, reloaded.sequence(player));
|
||||
}
|
||||
|
||||
@Test
|
||||
void storesByUuidAndPreservesUnknownYamlFields() throws Exception {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class HeightsCommandTest {
|
||||
private final List<HeightSettings> saved = new ArrayList<>();
|
||||
private final List<String> messages = new ArrayList<>();
|
||||
private final LiveHeightSettings live = new LiveHeightSettings(
|
||||
new HeightSettings(0.4, 2.0, 0.1, 0.5, 1.5, 20), saved::add);
|
||||
@TempDir
|
||||
Path directory;
|
||||
private final UUID playerId = UUID.randomUUID();
|
||||
private double playerScale = 0.7;
|
||||
private PlayerHeights players;
|
||||
private HeightStore playerStore;
|
||||
private HeightsCommand command;
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
playerStore = new HeightStore(directory.toFile());
|
||||
players = new PlayerHeights(live, playerStore, name -> name.equalsIgnoreCase("Steve")
|
||||
? new PlayerHeights.Target(playerId, "Steve", playerScale, playerScale, value -> playerScale = value)
|
||||
: null, () -> List.of("Steve"));
|
||||
command = new HeightsCommand(live, players, Logger.getAnonymousLogger());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonAdminPlayersCanUseOnlySafeCarryActionsAndCompletion() throws Exception {
|
||||
List<String> actions = new ArrayList<>();
|
||||
org.bukkit.entity.Player player = BukkitDoubles.proxy(org.bukkit.entity.Player.class, (p, method, args) -> {
|
||||
if (method.getName().equals("hasPermission")) { return false; }
|
||||
if (method.getName().equals("sendMessage")) { messages.add((String) args[0]); return null; }
|
||||
throw new UnsupportedOperationException(method.getName());
|
||||
});
|
||||
command = new HeightsCommand(live, players, Logger.getAnonymousLogger(),
|
||||
(name, value, reply) -> { throw new AssertionError("admin setter reached"); },
|
||||
(p, action) -> actions.add(action));
|
||||
for (String action : List.of("accept", "decline", "stop")) {
|
||||
command.onCommand(player, null, "heights", new String[] {"CaRrY", action.toUpperCase()});
|
||||
}
|
||||
assertEquals(List.of("stop"), actions);
|
||||
assertTrue(messages.stream().allMatch(message -> message.equals("Usage: /heights carry stop")));
|
||||
for (String[] args : new String[][] {{"carry"}, {"carry", "throw"}, {"carry", "stop", "extra"},
|
||||
{"player", "Steve", "0.5"}, {"settings"}}) {
|
||||
command.onCommand(player, null, "heights", args);
|
||||
}
|
||||
command.onCommand(sender(true), null, "heights", new String[] {"carry", "accept"});
|
||||
assertEquals(1, actions.size());
|
||||
assertEquals(List.of("carry"), complete(player, ""));
|
||||
assertEquals(List.of("stop"), complete(player, "carry", ""));
|
||||
assertEquals(List.of(), complete(player, "carry", "a"));
|
||||
assertEquals(List.of(), complete(player, "carry", "d"));
|
||||
assertEquals(List.of("stop"), complete(player, "carry", "s"));
|
||||
assertEquals(List.of(), complete(player, "player", ""));
|
||||
org.bukkit.configuration.file.YamlConfiguration yaml = org.bukkit.configuration.file.YamlConfiguration
|
||||
.loadConfiguration(new java.io.File("src/main/resources/plugin.yml"));
|
||||
assertTrue(yaml.getString("commands.heights.usage").contains("carry stop"));
|
||||
assertTrue(!yaml.getString("commands.heights.usage").contains("accept"));
|
||||
assertTrue(!yaml.getString("commands.heights.usage").contains("decline"));
|
||||
assertTrue(!yaml.getString("commands.heights.description").contains("consensual"));
|
||||
assertEquals(null, yaml.getString("commands.heights.permission"));
|
||||
assertEquals("op", yaml.getString("permissions.spigotheights.admin.default"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void runtimeHeightSetterCanPersistAsynchronouslyWithoutBypassingPermissions() {
|
||||
List<java.util.function.Consumer<String>> replies = new ArrayList<>();
|
||||
HeightsCommand async = new HeightsCommand(live, players, Logger.getAnonymousLogger(), (name, value, reply) -> {
|
||||
assertEquals("Steve", name);
|
||||
assertEquals(0.5, value);
|
||||
replies.add(reply);
|
||||
});
|
||||
async.onCommand(sender(false), null, "heights", new String[] {"player", "Steve", "0.5"});
|
||||
assertEquals(0, replies.size());
|
||||
messages.clear();
|
||||
async.onCommand(sender(true), null, "heights", new String[] {"player", "Steve", "0.5"});
|
||||
assertEquals(1, replies.size());
|
||||
assertEquals(List.of(), messages);
|
||||
assertEquals(0.7, playerScale);
|
||||
replies.getFirst().accept("Saved and active.");
|
||||
assertEquals(List.of("Saved and active."), messages);
|
||||
}
|
||||
|
||||
@Test
|
||||
void viewsAndSetsOnlinePlayerWithNameAndSizeCompletion() {
|
||||
CommandSender sender = sender(true);
|
||||
command.onCommand(sender, null, "heights", new String[] {"player", "Steve"});
|
||||
assertTrue(messages.getLast().contains("0.7"));
|
||||
command.onCommand(sender, null, "heights", new String[] {"player", "steve", "0.5"});
|
||||
assertEquals(0.5, playerScale);
|
||||
assertEquals(0.5, playerStore.find(playerId));
|
||||
assertTrue(messages.getLast().contains("Steve"));
|
||||
assertEquals(List.of("player"), complete(sender, "pl"));
|
||||
assertEquals(List.of("Steve"), complete(sender, "player", "st"));
|
||||
assertTrue(complete(sender, "player", "Steve", "0.").contains("0.5"));
|
||||
assertTrue(complete(sender, "player", "Steve", "1").contains("1.0"));
|
||||
assertEquals(List.of(), complete(sender, "player", "Offline", ""));
|
||||
assertEquals(List.of(), complete(sender, "player", "Steve", "0.5", ""));
|
||||
for (String value : complete(sender, "player", "Steve", "")) {
|
||||
assertTrue(players.isValid(Double.parseDouble(value)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidPlayerCommandsAndFailedSavesAreReportedWithoutResizing() throws Exception {
|
||||
for (String[] args : new String[][] {
|
||||
{"player"}, {"player", "Steve", "0.5", "extra"},
|
||||
{"player", "Offline"}, {"player", "Ste", "0.5"},
|
||||
{"player", "Steve", "nope"}, {"player", "Steve", "NaN"},
|
||||
{"player", "Steve", "0.2"}, {"player", "Steve", "3.0"}}) {
|
||||
int before = messages.size();
|
||||
command.onCommand(sender(true), null, "heights", args);
|
||||
assertEquals(before + 1, messages.size());
|
||||
assertEquals(0.7, playerScale);
|
||||
assertEquals(null, playerStore.find(playerId));
|
||||
}
|
||||
java.nio.file.Files.createDirectory(directory.resolve("state.yml.tmp"));
|
||||
command.onCommand(sender(true), null, "heights", new String[] {"player", "Steve", "0.5"});
|
||||
assertTrue(messages.getLast().contains("Could not save"));
|
||||
assertEquals(0.7, playerScale);
|
||||
assertEquals(null, playerStore.find(playerId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void authorizedSenderCanInspectAndChangeBounds() {
|
||||
CommandSender sender = sender(true);
|
||||
command.onCommand(sender, null, "heights", new String[] {"settings"});
|
||||
assertTrue(messages.getLast().contains("min=0.4"));
|
||||
assertTrue(messages.getLast().contains("max=2.0"));
|
||||
command.onCommand(sender, null, "heights", new String[] {"set", "min", "0.2"});
|
||||
command.onCommand(sender, null, "heights", new String[] {"set", "max", "3"});
|
||||
assertEquals(0.2, live.get().minimum());
|
||||
assertEquals(3.0, live.get().maximum());
|
||||
assertEquals(2, saved.size());
|
||||
assertTrue(messages.getLast().contains("3.0"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unauthorizedSenderCannotInspectMutateOrComplete() {
|
||||
CommandSender sender = sender(false);
|
||||
command.onCommand(sender, null, "heights", new String[] {"set", "min", "0.2"});
|
||||
command.onCommand(sender, null, "heights", new String[] {"settings"});
|
||||
command.onCommand(sender, null, "heights", new String[] {"player", "Steve"});
|
||||
command.onCommand(sender, null, "heights", new String[] {"player", "Steve", "0.5"});
|
||||
assertEquals(0.7, playerScale);
|
||||
assertEquals(null, playerStore.find(playerId));
|
||||
assertEquals(List.of(), complete(sender, "player", ""));
|
||||
assertEquals(List.of(), complete(sender, "player", "Steve", ""));
|
||||
assertTrue(messages.stream().allMatch(message -> message.contains("permission")));
|
||||
assertEquals(List.of(), saved);
|
||||
assertEquals(List.of(), command.onTabComplete(sender, null, "heights", new String[] {""}));
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedAndInvalidInputNeverSaves() {
|
||||
for (String[] args : new String[][] {
|
||||
{}, {"set"}, {"set", "min"}, {"set", "min", "0.2", "extra"},
|
||||
{"settings", "extra"}, {"unknown"}, {"set", "speed", "1"},
|
||||
{"set", "min", "nope"}, {"set", "min", "NaN"},
|
||||
{"set", "max", "Infinity"}, {"set", "min", "0.6"}, {"set", "max", "0.3"}}) {
|
||||
int before = messages.size();
|
||||
assertTrue(command.onCommand(sender(true), null, "heights", args));
|
||||
assertEquals(before + 1, messages.size());
|
||||
}
|
||||
assertEquals(List.of(), saved);
|
||||
assertEquals(0.4, live.get().minimum());
|
||||
assertEquals(2.0, live.get().maximum());
|
||||
}
|
||||
|
||||
@Test
|
||||
void completionsArePrefixFilteredAndValidForCurrentRange() throws Exception {
|
||||
CommandSender sender = sender(true);
|
||||
assertEquals(List.of("settings", "set"), complete(sender, "s"));
|
||||
assertEquals(List.of("settings"), complete(sender, "sett"));
|
||||
assertEquals(List.of("min", "max"), complete(sender, "set", "m"));
|
||||
assertEquals(List.of("max"), complete(sender, "set", "ma"));
|
||||
assertEquals(List.of(), complete(sender, "settings", ""));
|
||||
assertEquals(List.of(), complete(sender, "set", "other", ""));
|
||||
assertEquals(List.of(), complete(sender, "set", "min", "0.2", ""));
|
||||
assertTrue(complete(sender, "set", "min", "0.").contains("0.2"));
|
||||
for (String bound : List.of("min", "max")) {
|
||||
for (String value : complete(sender, "set", bound, "")) {
|
||||
live.candidate(bound, Double.parseDouble(value));
|
||||
}
|
||||
}
|
||||
live.setBound("max", 0.8);
|
||||
assertTrue(complete(sender, "set", "max", "0.").contains("0.8"));
|
||||
assertEquals(List.of(), complete(sender, "set", "min", "1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveFailureIsReportedWithoutChangingActiveSettings() {
|
||||
LiveHeightSettings failing = new LiveHeightSettings(live.get(), settings -> {
|
||||
throw new IOException("test save failure");
|
||||
});
|
||||
new HeightsCommand(failing, players, Logger.getAnonymousLogger()).onCommand(
|
||||
sender(true), null, "heights", new String[] {"set", "min", "0.2"});
|
||||
assertTrue(messages.getLast().contains("Could not save"));
|
||||
assertEquals(0.4, failing.get().minimum());
|
||||
}
|
||||
|
||||
private List<String> complete(CommandSender sender, String... args) {
|
||||
return command.onTabComplete(sender, null, "heights", args);
|
||||
}
|
||||
|
||||
private CommandSender sender(boolean permitted) {
|
||||
return (CommandSender) Proxy.newProxyInstance(CommandSender.class.getClassLoader(),
|
||||
new Class<?>[] {CommandSender.class}, (proxy, method, args) -> {
|
||||
if (method.getName().equals("hasPermission")) {
|
||||
assertEquals("spigotheights.admin", args[0]);
|
||||
return permitted;
|
||||
}
|
||||
if (method.getName().equals("sendMessage") && args[0] instanceof String text) {
|
||||
messages.add(text);
|
||||
return null;
|
||||
}
|
||||
throw new UnsupportedOperationException(method.getName());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class LiveHeightSettingsTest {
|
||||
private static final HeightSettings DEFAULTS = new HeightSettings(0.4, 2.0, 0.1, 0.5, 1.5, 20);
|
||||
|
||||
@Test
|
||||
void savesBeforePublishingAndGameplayReadsNewBounds() throws Exception {
|
||||
List<HeightSettings> saved = new ArrayList<>();
|
||||
LiveHeightSettings live = new LiveHeightSettings(DEFAULTS, saved::add);
|
||||
live.setBound("min", 0.2);
|
||||
live.setBound("max", 3.0);
|
||||
assertEquals(2, saved.size());
|
||||
assertSame(saved.getLast(), live.get());
|
||||
assertEquals(0.2, HeightMath.randomScale(live.get(), bound -> 0));
|
||||
assertEquals(3.0, HeightMath.grow(3.0, live.get()));
|
||||
assertEquals(0.2, HeightMath.safeStoredScale(0.1, live.get()));
|
||||
assertEquals(1.0, HeightMath.safeStoredScale(1.0, live.get()));
|
||||
assertEquals(0.1, live.get().adjustmentStep());
|
||||
assertEquals(0.5, live.get().launcherThreshold());
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidChangesAndSaveFailuresDoNotPublish() {
|
||||
List<HeightSettings> saved = new ArrayList<>();
|
||||
LiveHeightSettings live = new LiveHeightSettings(DEFAULTS, saved::add);
|
||||
for (double value : new double[] {Double.NaN, Double.POSITIVE_INFINITY, -1, 0, 0.01, 0.6, 3}) {
|
||||
assertThrows(IllegalArgumentException.class, () -> live.setBound("min", value));
|
||||
}
|
||||
for (double value : new double[] {0.3, 0.45, 17, Double.NEGATIVE_INFINITY}) {
|
||||
assertThrows(IllegalArgumentException.class, () -> live.setBound("max", value));
|
||||
}
|
||||
assertThrows(IllegalArgumentException.class, () -> live.setBound("speed", 1));
|
||||
assertEquals(List.of(), saved);
|
||||
assertSame(DEFAULTS, live.get());
|
||||
LiveHeightSettings failing = new LiveHeightSettings(DEFAULTS, settings -> {
|
||||
throw new IOException("disk full");
|
||||
});
|
||||
assertThrows(IOException.class, () -> failing.setBound("min", 0.2));
|
||||
assertSame(DEFAULTS, failing.get());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class PlayerHeightsTest {
|
||||
@TempDir
|
||||
Path directory;
|
||||
private final UUID id = UUID.randomUUID();
|
||||
private double scale = 0.7;
|
||||
private LiveHeightSettings settings;
|
||||
private HeightStore store;
|
||||
private PlayerHeights heights;
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
settings = new LiveHeightSettings(new HeightSettings(0.4, 2.0, 0.1, 0.5, 1.5, 20), value -> {});
|
||||
store = new HeightStore(directory.toFile());
|
||||
heights = new PlayerHeights(settings, store, name -> name.equalsIgnoreCase("Steve")
|
||||
? new PlayerHeights.Target(id, "Steve", scale, scale * 2, value -> scale = value) : null,
|
||||
() -> List.of("Steve"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void viewsActualBaseAndEffectiveScaleAndPersistsChangesByUuid() throws Exception {
|
||||
assertTrue(heights.describe("steve").contains("0.7"));
|
||||
assertTrue(heights.describe("Steve").contains("1.4"));
|
||||
heights.set("STEVE", 0.5);
|
||||
assertEquals(0.5, scale);
|
||||
HeightStore reloaded = new HeightStore(directory.toFile());
|
||||
assertEquals(0.5, reloaded.find(id));
|
||||
assertEquals(0.5, HeightMath.safeStoredScale(reloaded.find(id), settings.get()));
|
||||
assertEquals(List.of("Steve"), heights.names());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnknownPlayersAndInvalidScalesWithoutChangingState() {
|
||||
assertThrows(IllegalArgumentException.class, () -> heights.describe("Ste"));
|
||||
assertThrows(IllegalArgumentException.class, () -> heights.set("Offline", 0.5));
|
||||
for (double value : new double[] {Double.NaN, Double.POSITIVE_INFINITY, -1, 0.2, 3.0}) {
|
||||
assertThrows(IllegalArgumentException.class, () -> heights.set("Steve", value));
|
||||
}
|
||||
assertEquals(0.7, scale);
|
||||
assertEquals(null, store.find(id));
|
||||
}
|
||||
|
||||
@Test
|
||||
void usesLiveLimitsAndAllowsRestorationOutsideThem() throws Exception {
|
||||
settings.setBound("max", 0.8);
|
||||
heights.set("Steve", 1.0);
|
||||
assertEquals(1.0, scale);
|
||||
assertEquals(1.0, HeightMath.safeStoredScale(new HeightStore(directory.toFile()).find(id), settings.get()));
|
||||
assertThrows(IllegalArgumentException.class, () -> heights.set("Steve", 0.9));
|
||||
settings.setBound("min", 0.2);
|
||||
heights.set("Steve", 0.2);
|
||||
assertEquals(0.2, scale);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedSaveLeavesLiveAndCachedHeightUnchanged() throws Exception {
|
||||
store.save(id, scale);
|
||||
Files.createDirectory(directory.resolve("state.yml.tmp"));
|
||||
assertThrows(IOException.class, () -> heights.set("Steve", 0.5));
|
||||
assertEquals(0.7, scale);
|
||||
assertEquals(0.7, store.find(id));
|
||||
assertEquals(0.7, new HeightStore(directory.toFile()).find(id));
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,79 @@ package games.dmg.spigotheights;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class PotionRecipesTest {
|
||||
private final NamespacedKey key = new NamespacedKey("spigotheights", "stature_potion");
|
||||
private final PotionRecipes potions = new PotionRecipes(null, key, BukkitDoubles.Item::new);
|
||||
|
||||
@Test
|
||||
void authenticatesAllFormsByTypedMetadataNotDisplayName() {
|
||||
for (StaturePotion kind : StaturePotion.values()) {
|
||||
for (Material form : new Material[] {Material.POTION, Material.SPLASH_POTION, Material.LINGERING_POTION}) {
|
||||
BukkitDoubles.Item item = new BukkitDoubles.Item(form);
|
||||
item.getItemMeta().displayName(net.kyori.adventure.text.Component.text(kind.displayName()));
|
||||
assertNull(potions.identify(item));
|
||||
item.getItemMeta().getPersistentDataContainer().set(key, PersistentDataType.INTEGER, 1);
|
||||
assertNull(potions.identify(item));
|
||||
item.getItemMeta().getPersistentDataContainer().set(key, PersistentDataType.STRING, "UNKNOWN");
|
||||
assertNull(potions.identify(item));
|
||||
item.getItemMeta().getPersistentDataContainer().set(key, PersistentDataType.STRING, kind.name());
|
||||
assertEquals(kind, potions.identify(item));
|
||||
}
|
||||
BukkitDoubles.Item fake = new BukkitDoubles.Item(Material.STONE);
|
||||
fake.getItemMeta().getPersistentDataContainer().set(key, PersistentDataType.STRING, kind.name());
|
||||
assertNull(potions.identify(fake));
|
||||
}
|
||||
assertNull(potions.identify(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void registersEightScopedBrewingConversionsWithAuthenticatedResults() {
|
||||
java.util.List<io.papermc.paper.potion.PotionMix> mixes = new java.util.ArrayList<>();
|
||||
java.util.List<NamespacedKey> removed = new java.util.ArrayList<>();
|
||||
org.bukkit.potion.PotionBrewer brewer = BukkitDoubles.proxy(org.bukkit.potion.PotionBrewer.class,
|
||||
(proxy, method, args) -> {
|
||||
switch (method.getName()) {
|
||||
case "addPotionMix" -> mixes.add((io.papermc.paper.potion.PotionMix) args[0]);
|
||||
case "removePotionMix" -> removed.add((NamespacedKey) args[0]);
|
||||
default -> throw new UnsupportedOperationException(method.getName());
|
||||
}
|
||||
return null;
|
||||
});
|
||||
potions.registerBrewing(brewer);
|
||||
assertEquals(8, mixes.size());
|
||||
assertEquals(8, mixes.stream().map(io.papermc.paper.potion.PotionMix::getKey).distinct().count());
|
||||
assertEquals(mixes.stream().map(io.papermc.paper.potion.PotionMix::getKey).toList(), removed);
|
||||
for (var mix : mixes) {
|
||||
Material target = mix.getResult().getType();
|
||||
boolean splash = target == Material.SPLASH_POTION;
|
||||
assertEquals(splash ? Material.SPLASH_POTION : Material.LINGERING_POTION, target);
|
||||
StaturePotion kind = potions.identify(mix.getResult());
|
||||
org.junit.jupiter.api.Assertions.assertNotNull(kind);
|
||||
Material source = splash ? Material.POTION : Material.SPLASH_POTION;
|
||||
BukkitDoubles.Item input = new BukkitDoubles.Item(source);
|
||||
org.junit.jupiter.api.Assertions.assertFalse(mix.getInput().test(input));
|
||||
input.getItemMeta().getPersistentDataContainer().set(key, PersistentDataType.STRING, kind.name());
|
||||
org.junit.jupiter.api.Assertions.assertTrue(mix.getInput().test(input));
|
||||
org.junit.jupiter.api.Assertions.assertFalse(mix.getInput().test(mix.getResult()));
|
||||
var ingredient = org.junit.jupiter.api.Assertions.assertInstanceOf(
|
||||
org.bukkit.inventory.RecipeChoice.PredicateChoice.class, mix.getIngredient(),
|
||||
"Purpur 26.3 builds brewing reagent indexes from representative recipe choices");
|
||||
assertEquals(splash ? Material.GUNPOWDER : Material.DRAGON_BREATH,
|
||||
ingredient.getItemStack().getType());
|
||||
org.junit.jupiter.api.Assertions.assertTrue(ingredient.test(new BukkitDoubles.Item(
|
||||
splash ? Material.GUNPOWDER : Material.DRAGON_BREATH)));
|
||||
org.junit.jupiter.api.Assertions.assertFalse(mix.getIngredient().test(new BukkitDoubles.Item(Material.SUGAR)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void recipesUseApprovedShapes() {
|
||||
assertArrayEquals(new String[] {"ACA", "AWA", "ACA"}, PotionRecipes.SHIFTING_SHAPE);
|
||||
@@ -13,6 +82,17 @@ class PotionRecipesTest {
|
||||
assertArrayEquals(new String[] {"GAG", "ASA", "GFG"}, PotionRecipes.DIMINUTION_SHAPE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void restorationUsesApprovedShapeAndDistinctIdentity() {
|
||||
assertArrayEquals(new String[] {"GAG", "ASA", "GUG"}, PotionRecipes.RESTORATION_SHAPE);
|
||||
assertEquals("RESTORATION", StaturePotion.RESTORATION.name());
|
||||
for (StaturePotion kind : StaturePotion.values()) {
|
||||
if (kind != StaturePotion.RESTORATION) {
|
||||
assertNotEquals(kind.name(), StaturePotion.RESTORATION.name());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyPotionHasASeparatePersistentIdentity() {
|
||||
assertNotEquals(StaturePotion.SHIFTING.name(), StaturePotion.GROWTH.name());
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.io.DataInputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
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-heights-" + 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("SpigotHeights", metadata.get("name"), "Retain the data directory and persistent namespace");
|
||||
assertEquals("games.dmg.spigotheights.SpigotHeightsPlugin", metadata.get("main"));
|
||||
assertEquals("1.21", metadata.get("api-version"));
|
||||
assertTrue(((Map<?, ?>) metadata.get("commands")).containsKey("heights"));
|
||||
assertTrue(((Map<?, ?>) metadata.get("permissions")).containsKey("spigotheights.admin"));
|
||||
assertFalse(jar.stream().anyMatch(entry -> entry.getName().startsWith("org/bukkit/")
|
||||
|| entry.getName().startsWith("org/purpurmc/")
|
||||
|| entry.getName().startsWith("net/minecraft/")
|
||||
|| entry.getName().startsWith("com/comphenix/protocol/")
|
||||
|| entry.getName().startsWith("org/junit/")
|
||||
|| entry.getName().startsWith("org/mockito/")
|
||||
|| entry.getName().endsWith("Test.class")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void packagesTheIntegratedRecipeDiscoveryLifecycle() throws Exception {
|
||||
try (var jar = new JarFile(System.getProperty("distribution.jar"))) {
|
||||
String plugin = classConstants(jar, "games/dmg/spigotheights/SpigotHeightsPlugin.class");
|
||||
String lifecycle = classConstants(jar, "games/dmg/spigotheights/RecipeLifecycle.class");
|
||||
String discovery = classConstants(jar, "games/dmg/spigotheights/RecipeDiscovery.class");
|
||||
String recipes = classConstants(jar, "games/dmg/spigotheights/PotionRecipes.class");
|
||||
assertTrue(plugin.contains("RecipeLifecycle"));
|
||||
assertTrue(lifecycle.contains("getOnlinePlayers"));
|
||||
assertTrue(lifecycle.contains("registerEvents"));
|
||||
assertTrue(discovery.contains("discoverRecipes"));
|
||||
assertTrue(discovery.contains("hasDiscoveredRecipe"));
|
||||
assertTrue(recipes.contains("CraftingBookCategory"));
|
||||
assertTrue(recipes.contains("AWKWARD"));
|
||||
assertTrue(recipes.contains("RecipeChoice$ExactChoice"));
|
||||
assertTrue(recipes.contains("removeRecipe"));
|
||||
}
|
||||
}
|
||||
|
||||
private static String classConstants(JarFile jar, String entry) throws Exception {
|
||||
assertNotNull(jar.getJarEntry(entry), entry);
|
||||
return new String(jar.getInputStream(jar.getJarEntry(entry)).readAllBytes(), StandardCharsets.ISO_8859_1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void pluginBytecodeTargetsJava25() throws Exception {
|
||||
try (var jar = new JarFile(System.getProperty("distribution.jar"));
|
||||
var bytecode = new DataInputStream(jar.getInputStream(
|
||||
jar.getJarEntry("games/dmg/spigotheights/SpigotHeightsPlugin.class")))) {
|
||||
assertEquals(0xCAFEBABE, bytecode.readInt());
|
||||
bytecode.readUnsignedShort();
|
||||
assertEquals(69, bytecode.readUnsignedShort());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void releaseAndCiAgreeWithThePurpur26_3DistributionAndToolchain() throws Exception {
|
||||
String build = Files.readString(Path.of("build.gradle.kts"));
|
||||
assertTrue(build.contains("purpur-api:26.3.build.2651-stable"));
|
||||
assertTrue(build.contains("purpur-26.3-2651.jar"));
|
||||
assertTrue(build.contains("https://api.purpurmc.org/v2/purpur/26.3/2651/download"));
|
||||
assertTrue(build.contains("0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598"));
|
||||
assertFalse(build.contains("26.2") || build.contains("2618"));
|
||||
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-heights-"), workflow);
|
||||
assertFalse(content.contains("spigot-heights-"), workflow);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.logging.Handler;
|
||||
import java.util.logging.LogRecord;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class RecipeDiscoveryTest {
|
||||
private final List<NamespacedKey> keys = List.of(
|
||||
new NamespacedKey("spigotheights", "shifting_stature"),
|
||||
new NamespacedKey("spigotheights", "growth"),
|
||||
new NamespacedKey("spigotheights", "diminution"),
|
||||
new NamespacedKey("spigotheights", "restoration"));
|
||||
|
||||
@Test
|
||||
void allAlreadyOnlinePlayersReceiveEveryUnknownRecipe() {
|
||||
Set<NamespacedKey> firstKnown = new LinkedHashSet<>();
|
||||
Set<NamespacedKey> secondKnown = new LinkedHashSet<>();
|
||||
RecipeDiscovery discovery = new RecipeDiscovery(keys, Logger.getAnonymousLogger());
|
||||
|
||||
discovery.discoverAll(List.of(
|
||||
player("First", firstKnown, new ArrayList<>(), false),
|
||||
player("Second", secondKnown, new ArrayList<>(), false)));
|
||||
|
||||
assertEquals(new LinkedHashSet<>(keys), firstKnown);
|
||||
assertEquals(new LinkedHashSet<>(keys), secondKnown);
|
||||
}
|
||||
|
||||
@Test
|
||||
void joiningPlayerReceivesEveryUnknownRecipe() {
|
||||
Set<NamespacedKey> known = new LinkedHashSet<>();
|
||||
List<List<NamespacedKey>> awards = new ArrayList<>();
|
||||
Player player = player("Joiner", known, awards, false);
|
||||
RecipeDiscovery discovery = new RecipeDiscovery(keys, Logger.getAnonymousLogger());
|
||||
|
||||
discovery.onJoin(new PlayerJoinEvent(player, "joined"));
|
||||
|
||||
assertEquals(List.of(keys), awards);
|
||||
assertEquals(new LinkedHashSet<>(keys), known);
|
||||
}
|
||||
|
||||
@Test
|
||||
void onePlayerExceptionIsLoggedWithoutBlockingOthersAndRetriesLater() {
|
||||
Set<NamespacedKey> failedKnown = new LinkedHashSet<>();
|
||||
Set<NamespacedKey> healthyKnown = new LinkedHashSet<>();
|
||||
AtomicBoolean firstAttempt = new AtomicBoolean(true);
|
||||
List<String> warnings = new ArrayList<>();
|
||||
Logger logger = logger(warnings);
|
||||
Player failing = BukkitDoubles.proxy(Player.class, (proxy, method, args) -> switch (method.getName()) {
|
||||
case "getName" -> "Failing";
|
||||
case "hasDiscoveredRecipe" -> failedKnown.contains(args[0]);
|
||||
case "discoverRecipes" -> {
|
||||
if (firstAttempt.getAndSet(false)) {
|
||||
throw new IllegalStateException("simulated discovery failure");
|
||||
}
|
||||
List<NamespacedKey> requested = ((Collection<?>) args[0]).stream()
|
||||
.map(NamespacedKey.class::cast).toList();
|
||||
failedKnown.addAll(requested);
|
||||
yield requested.size();
|
||||
}
|
||||
default -> throw new UnsupportedOperationException(method.getName());
|
||||
});
|
||||
RecipeDiscovery discovery = new RecipeDiscovery(keys, logger);
|
||||
|
||||
assertDoesNotThrow(() -> discovery.discover(failing));
|
||||
discovery.discover(player("Healthy", healthyKnown, new ArrayList<>(), false));
|
||||
discovery.discover(failing);
|
||||
|
||||
assertEquals(new LinkedHashSet<>(keys), healthyKnown);
|
||||
assertEquals(new LinkedHashSet<>(keys), failedKnown);
|
||||
assertEquals(1, warnings.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingPostconditionIsLoggedAndOnlyMissingRecipesRetryLater() {
|
||||
Set<NamespacedKey> known = new LinkedHashSet<>();
|
||||
List<List<NamespacedKey>> awards = new ArrayList<>();
|
||||
List<String> warnings = new ArrayList<>();
|
||||
Logger logger = logger(warnings);
|
||||
RecipeDiscovery discovery = new RecipeDiscovery(keys, logger);
|
||||
|
||||
discovery.discover(player("Ada", known, awards, true));
|
||||
discovery.discover(player("Ada", known, awards, false));
|
||||
|
||||
assertEquals(List.of(keys, List.of(keys.getLast())), awards);
|
||||
assertEquals(1, warnings.size());
|
||||
assertEquals(new LinkedHashSet<>(keys), known);
|
||||
}
|
||||
|
||||
@Test
|
||||
void discoversOnlyUnknownRecipesAndRepeatedDiscoveryIsSilent() {
|
||||
Set<NamespacedKey> known = new LinkedHashSet<>();
|
||||
List<List<NamespacedKey>> awards = new ArrayList<>();
|
||||
Player player = player("Ada", known, awards, false);
|
||||
RecipeDiscovery discovery = new RecipeDiscovery(keys, Logger.getAnonymousLogger());
|
||||
|
||||
discovery.discover(player);
|
||||
discovery.discover(player);
|
||||
|
||||
assertEquals(List.of(keys), awards);
|
||||
assertEquals(new LinkedHashSet<>(keys), known);
|
||||
}
|
||||
|
||||
private static Logger logger(List<String> warnings) {
|
||||
Logger logger = Logger.getAnonymousLogger();
|
||||
logger.setUseParentHandlers(false);
|
||||
logger.addHandler(new Handler() {
|
||||
@Override public void publish(LogRecord record) { warnings.add(record.getMessage()); }
|
||||
@Override public void flush() {}
|
||||
@Override public void close() {}
|
||||
});
|
||||
return logger;
|
||||
}
|
||||
|
||||
static Player player(String name, Set<NamespacedKey> known, List<List<NamespacedKey>> awards,
|
||||
boolean omitLastAward) {
|
||||
return BukkitDoubles.proxy(Player.class, (proxy, method, args) -> switch (method.getName()) {
|
||||
case "getName" -> name;
|
||||
case "hasDiscoveredRecipe" -> known.contains(args[0]);
|
||||
case "discoverRecipes" -> {
|
||||
List<NamespacedKey> requested = ((Collection<?>) args[0]).stream()
|
||||
.map(NamespacedKey.class::cast).toList();
|
||||
awards.add(requested);
|
||||
int limit = omitLastAward ? Math.max(0, requested.size() - 1) : requested.size();
|
||||
known.addAll(requested.subList(0, limit));
|
||||
yield limit;
|
||||
}
|
||||
default -> throw new UnsupportedOperationException(method.getName());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class SettingsFileStoreTest {
|
||||
@TempDir
|
||||
Path directory;
|
||||
|
||||
@Test
|
||||
void savedBoundsSurviveReloadAndPreserveOtherConfiguration() throws Exception {
|
||||
Path config = directory.resolve("config.yml");
|
||||
try (var defaults = getClass().getResourceAsStream("/config.yml")) {
|
||||
Files.copy(defaults, config);
|
||||
}
|
||||
Files.writeString(config, Files.readString(config) + "\nfuture-setting: retained\n");
|
||||
HeightSettings initial = SpigotHeightsPlugin.loadSettings(YamlConfiguration.loadConfiguration(config.toFile()));
|
||||
LiveHeightSettings live = new LiveHeightSettings(initial, new SettingsFileStore(config)::save);
|
||||
live.setBound("min", 0.2);
|
||||
live.setBound("max", 3.0);
|
||||
YamlConfiguration reloaded = YamlConfiguration.loadConfiguration(config.toFile());
|
||||
assertEquals(live.get(), SpigotHeightsPlugin.loadSettings(reloaded));
|
||||
assertEquals("retained", reloaded.getString("future-setting"));
|
||||
try (var files = Files.list(directory)) {
|
||||
assertEquals(1, files.count());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedConfigurationIsNotOverwrittenOrActivated() throws Exception {
|
||||
Path config = directory.resolve("config.yml");
|
||||
String malformed = "height: [unterminated";
|
||||
Files.writeString(config, malformed);
|
||||
HeightSettings initial = new HeightSettings(0.4, 2.0, 0.1, 0.5, 1.5, 20);
|
||||
LiveHeightSettings live = new LiveHeightSettings(initial, new SettingsFileStore(config)::save);
|
||||
assertThrows(IOException.class, () -> live.setBound("min", 0.2));
|
||||
assertEquals(initial, live.get());
|
||||
assertEquals(malformed, Files.readString(config));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.entity.ThrownPotion;
|
||||
import org.bukkit.event.entity.EntityRemoveEvent;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class StatureCloudsTest {
|
||||
@Test
|
||||
void unloadRetainsDurableReceiptsButPermanentSourceRemovalRetiresThem() {
|
||||
PotionRecipes potions = new PotionRecipes(null, new NamespacedKey("spigotheights", "stature_potion"),
|
||||
BukkitDoubles.Item::new);
|
||||
ThrowableStatureListener listener = new ThrowableStatureListener(potions,
|
||||
new NamespacedKey("spigotheights", "cloud_kind"), (source, player, kind, done) -> {});
|
||||
Server server = BukkitDoubles.proxy(Server.class, (proxy, method, args) -> {
|
||||
if (method.getName().equals("getWorlds")) { return List.of(); }
|
||||
throw new UnsupportedOperationException(method.getName());
|
||||
});
|
||||
List<UUID> retired = new ArrayList<>();
|
||||
StatureClouds clouds = new StatureClouds(server, listener, retired::add);
|
||||
ThrownPotion source = ThrowableStatureListenerTest.entity(ThrownPotion.class,
|
||||
potions.create(StaturePotion.GROWTH, Material.SPLASH_POTION));
|
||||
clouds.onRemove(new EntityRemoveEvent(source, EntityRemoveEvent.Cause.UNLOAD));
|
||||
clouds.onRemove(new EntityRemoveEvent(ThrowableStatureListenerTest.entity(ThrownPotion.class,
|
||||
new BukkitDoubles.Item(Material.SPLASH_POTION)), EntityRemoveEvent.Cause.HIT));
|
||||
assertEquals(List.of(), retired);
|
||||
clouds.onRemove(new EntityRemoveEvent(source, EntityRemoveEvent.Cause.HIT));
|
||||
assertEquals(List.of(source.getUniqueId()), retired);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.player.PlayerItemConsumeEvent;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.bukkit.event.player.PlayerRespawnEvent;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class StatureListenerTest {
|
||||
@Test
|
||||
void onlyUncancelledAuthenticatedDrinkableItemsReachPermanentService() {
|
||||
PotionRecipes potions = new PotionRecipes(null, new NamespacedKey("spigotheights", "stature_potion"),
|
||||
BukkitDoubles.Item::new);
|
||||
List<StaturePotion> drinks = new ArrayList<>();
|
||||
StatureListener listener = new StatureListener(potions, (player, kind) -> drinks.add(kind),
|
||||
(player, notify) -> {}, Runnable::run);
|
||||
Player player = ThrowableStatureListenerTest.entity(Player.class, null);
|
||||
var cancelled = new PlayerItemConsumeEvent(player, potions.create(StaturePotion.GROWTH), EquipmentSlot.HAND);
|
||||
cancelled.setCancelled(true);
|
||||
listener.onConsume(cancelled);
|
||||
listener.onConsume(new PlayerItemConsumeEvent(player,
|
||||
potions.create(StaturePotion.GROWTH, Material.SPLASH_POTION), EquipmentSlot.HAND));
|
||||
listener.onConsume(new PlayerItemConsumeEvent(player, new BukkitDoubles.Item(Material.POTION), EquipmentSlot.HAND));
|
||||
assertEquals(List.of(), drinks);
|
||||
cancelled.setCancelled(false);
|
||||
listener.onConsume(cancelled);
|
||||
assertEquals(List.of(StaturePotion.GROWTH), drinks);
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginRestorationIsSilentWhileRespawnRestorationStillNotifies() {
|
||||
PotionRecipes potions = new PotionRecipes(null, new NamespacedKey("spigotheights", "stature_potion"),
|
||||
BukkitDoubles.Item::new);
|
||||
List<Boolean> notificationModes = new ArrayList<>();
|
||||
List<Runnable> nextTick = new ArrayList<>();
|
||||
StatureListener listener = new StatureListener(potions, (player, kind) -> {},
|
||||
(player, notify) -> notificationModes.add(notify), nextTick::add);
|
||||
Player player = ThrowableStatureListenerTest.entity(Player.class, null);
|
||||
|
||||
listener.onJoin(new PlayerJoinEvent(player, "joined"));
|
||||
assertEquals(List.of(false), notificationModes);
|
||||
|
||||
listener.onRespawn(new PlayerRespawnEvent(player, new Location(null, 0, 0, 0), false));
|
||||
assertEquals(1, nextTick.size());
|
||||
nextTick.getFirst().run();
|
||||
assertEquals(List.of(false, true), notificationModes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class StaturePotionTest {
|
||||
@Test
|
||||
void existingPotionsKeepTheirScaleRules() {
|
||||
HeightSettings settings = new HeightSettings(0.4, 2.0, 0.1, 0.5, 1.5, 20);
|
||||
assertEquals(0.4, StaturePotion.SHIFTING.scaleAfterDrinking(1.0, settings, bound -> 0));
|
||||
assertEquals(2.0, StaturePotion.SHIFTING.scaleAfterDrinking(1.0, settings, bound -> bound - 1));
|
||||
assertEquals(1.1, StaturePotion.GROWTH.scaleAfterDrinking(1.0, settings, bound -> 0));
|
||||
assertEquals(0.9, StaturePotion.DIMINUTION.scaleAfterDrinking(1.0, settings, bound -> 0));
|
||||
assertEquals(2.0, StaturePotion.GROWTH.scaleAfterDrinking(2.0, settings, bound -> 0));
|
||||
assertEquals(0.4, StaturePotion.DIMINUTION.scaleAfterDrinking(0.4, settings, bound -> 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void restorationAlwaysReturnsNormalSizeWithoutRandomness() {
|
||||
for (HeightSettings settings : new HeightSettings[] {
|
||||
new HeightSettings(0.4, 2.0, 0.1, 0.5, 1.5, 20),
|
||||
new HeightSettings(0.2, 0.8, 0.1, 0.5, 1.5, 20),
|
||||
new HeightSettings(1.2, 2.0, 0.1, 1.5, 1.5, 20)}) {
|
||||
for (double current : new double[] {0.2, 1.0, 2.0}) {
|
||||
assertEquals(1.0, StaturePotion.RESTORATION.scaleAfterDrinking(current, settings,
|
||||
bound -> { throw new AssertionError("Restoration must not roll a random size"); }));
|
||||
}
|
||||
}
|
||||
assertEquals("Potion of Restoration", StaturePotion.RESTORATION.displayName());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class StatureQueueTest {
|
||||
@Test
|
||||
void capturesNextOperationOnlyAfterDurableCompletionAndNeverWritesOnMain() {
|
||||
ArrayDeque<Runnable> worker = new ArrayDeque<>();
|
||||
ArrayDeque<Runnable> main = new ArrayDeque<>();
|
||||
List<String> events = new ArrayList<>();
|
||||
double[] scale = {1.0};
|
||||
StatureQueue queue = new StatureQueue(worker::add, main::add);
|
||||
for (int i = 0; i < 2; i++) {
|
||||
queue.submit(() -> {
|
||||
events.add("capture");
|
||||
double next = scale[0] - 0.2;
|
||||
return () -> { events.add("save"); return next; };
|
||||
}, (result, error) -> { assertNull(error); events.add("apply"); scale[0] = result; });
|
||||
}
|
||||
assertEquals(List.of("capture"), events);
|
||||
assertEquals(1.0, scale[0]);
|
||||
worker.removeFirst().run();
|
||||
assertEquals(List.of("capture", "save"), events);
|
||||
assertEquals(1.0, scale[0]);
|
||||
main.removeFirst().run();
|
||||
assertEquals(List.of("capture", "save", "apply", "capture"), events);
|
||||
worker.removeFirst().run(); main.removeFirst().run();
|
||||
assertEquals(0.6, scale[0], 0.00001);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shutdownRejectsPendingAndNewOperationsButAllowsInflightSaveToFinish() {
|
||||
ArrayDeque<Runnable> worker = new ArrayDeque<>();
|
||||
ArrayDeque<Runnable> main = new ArrayDeque<>();
|
||||
List<Exception> failures = new ArrayList<>();
|
||||
StatureQueue queue = new StatureQueue(worker::add, main::add);
|
||||
queue.submit(() -> () -> 0.8, (result, error) -> assertNull(error));
|
||||
queue.submit(() -> () -> 0.6, (result, error) -> failures.add(error));
|
||||
queue.close();
|
||||
queue.submit(() -> () -> 0.4, (result, error) -> failures.add(error));
|
||||
assertEquals(2, failures.size());
|
||||
failures.forEach(org.junit.jupiter.api.Assertions::assertNotNull);
|
||||
worker.removeFirst().run(); main.removeFirst().run();
|
||||
assertEquals(0, worker.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedPersistenceReportsFailureAndDoesNotStrandNextOperation() {
|
||||
ArrayDeque<Runnable> worker = new ArrayDeque<>();
|
||||
ArrayDeque<Runnable> main = new ArrayDeque<>();
|
||||
List<Double> applied = new ArrayList<>();
|
||||
StatureQueue queue = new StatureQueue(worker::add, main::add);
|
||||
queue.submit(() -> () -> { throw new java.io.IOException("disk failure"); },
|
||||
(result, error) -> { assertNull(result); assertNotNull(error); });
|
||||
queue.submit(() -> () -> 1.4, (result, error) -> { assertNull(error); applied.add(result); });
|
||||
assertEquals(1, worker.size());
|
||||
worker.removeFirst().run(); main.removeFirst().run();
|
||||
worker.removeFirst().run(); main.removeFirst().run();
|
||||
assertEquals(List.of(1.4), applied);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class TemporaryStatureTest {
|
||||
@TempDir Path directory;
|
||||
private final UUID player = UUID.randomUUID();
|
||||
private final HeightSettings settings = new HeightSettings(0.2, 3.0, 0.2, 0.5, 1.5, 20);
|
||||
private final Instant start = Instant.parse("2026-01-01T00:00:00Z");
|
||||
|
||||
@Test
|
||||
void applicationAfterExpiryStartsFromRestoredBaselineNotStaleTemporaryScale() throws Exception {
|
||||
TemporaryStature stature = new TemporaryStature(new HeightStore(directory.toFile()));
|
||||
stature.apply(player, 1.0, StaturePotion.DIMINUTION, settings, start, bound -> 0);
|
||||
assertEquals(1.2, stature.apply(player, 0.8, StaturePotion.GROWTH, settings,
|
||||
start.plusSeconds(300), bound -> 0));
|
||||
assertEquals(1.0, stature.resume(player, settings, start.plusSeconds(600)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void durableCloudReceiptPreventsDuplicateAfterUnloadReloadAndPermanentOverride() throws Exception {
|
||||
UUID source = UUID.randomUUID();
|
||||
HeightStore store = new HeightStore(directory.toFile());
|
||||
TemporaryStature stature = new TemporaryStature(store);
|
||||
assertEquals(0.8, stature.applyOnce(player, source, 1.0, StaturePotion.DIMINUTION, settings, start, bound -> 0));
|
||||
store.save(player, 1.4);
|
||||
HeightStore reloaded = new HeightStore(directory.toFile());
|
||||
assertNull(new TemporaryStature(reloaded).applyOnce(player, source, 1.4, StaturePotion.DIMINUTION,
|
||||
settings, start.plusSeconds(10), bound -> 0));
|
||||
assertEquals(1.4, reloaded.find(player));
|
||||
assertNull(reloaded.sequence(player));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unobservedExpirySurvivesDisconnectBetweenSaveAndLiveApplication() throws Exception {
|
||||
HeightStore store = new HeightStore(directory.toFile());
|
||||
TemporaryStature stature = new TemporaryStature(store);
|
||||
stature.apply(player, 2.0, StaturePotion.DIMINUTION, settings, start, bound -> 0);
|
||||
HeightSettings smaller = new HeightSettings(0.2, 0.8, 0.2, 0.5, 1.5, 20);
|
||||
assertEquals(2.0, stature.resume(player, smaller, start.plusSeconds(300)));
|
||||
// Player disconnected before observing the result: login must still restore exactly.
|
||||
assertEquals(2.0, new TemporaryStature(new HeightStore(directory.toFile()))
|
||||
.resume(player, smaller, start.plusSeconds(301)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reloadCountsOfflineTimeAndRestoresExactBaselineOutsideChangedLimits() throws Exception {
|
||||
TemporaryStature stature = new TemporaryStature(new HeightStore(directory.toFile()));
|
||||
stature.apply(player, 2.0, StaturePotion.DIMINUTION, settings, start, bound -> 0);
|
||||
HeightStore reloaded = new HeightStore(directory.toFile());
|
||||
TemporaryStature restarted = new TemporaryStature(reloaded);
|
||||
HeightSettings smaller = new HeightSettings(0.2, 0.8, 0.2, 0.5, 1.5, 20);
|
||||
assertEquals(2.0, restarted.resume(player, smaller, start.plusSeconds(301)));
|
||||
restarted.acknowledgeRestoration(player, reloaded.sequence(player));
|
||||
assertNull(reloaded.sequence(player));
|
||||
// Only processing the expiry bypasses new limits; later ordinary joins use them.
|
||||
assertEquals(0.8, restarted.resume(player, smaller, start.plusSeconds(302)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void permanentSaveEndsSequenceAndFailedSavePreservesIt() throws Exception {
|
||||
HeightStore store = new HeightStore(directory.toFile());
|
||||
TemporaryStature stature = new TemporaryStature(store);
|
||||
stature.apply(player, 1.0, StaturePotion.DIMINUTION, settings, start, bound -> 0);
|
||||
Files.createDirectory(directory.resolve("state.yml.tmp"));
|
||||
assertThrows(IOException.class, () -> store.save(player, 1.4));
|
||||
assertEquals(0.8, store.find(player));
|
||||
assertEquals(1.0, store.sequence(player).baseline());
|
||||
Files.delete(directory.resolve("state.yml.tmp"));
|
||||
store.save(player, 1.4);
|
||||
assertNull(new HeightStore(directory.toFile()).sequence(player));
|
||||
assertEquals(1.4, stature.resume(player, settings, start.plusSeconds(600)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void permanentDrinkUsesCurrentSizeAndEndsSequenceIncludingAtExpiryBoundary() throws Exception {
|
||||
HeightStore store = new HeightStore(directory.toFile());
|
||||
TemporaryStature stature = new TemporaryStature(store);
|
||||
stature.apply(player, 1.0, StaturePotion.DIMINUTION, settings, start, bound -> 0);
|
||||
assertEquals(0.6, stature.drink(player, 0.8, StaturePotion.DIMINUTION, settings,
|
||||
start.plusSeconds(1), bound -> 0));
|
||||
assertNull(store.sequence(player));
|
||||
assertEquals(0.6, stature.resume(player, settings, start.plusSeconds(600)));
|
||||
stature.apply(player, 0.6, StaturePotion.GROWTH, settings, start, bound -> 0);
|
||||
assertEquals(0.4, stature.drink(player, 0.8, StaturePotion.DIMINUTION, settings,
|
||||
start.plusSeconds(300), bound -> 0));
|
||||
assertNull(store.sequence(player));
|
||||
}
|
||||
|
||||
@Test
|
||||
void allKindsRespectBoundsRestorationAndTimerRefreshEvenAtBounds() throws Exception {
|
||||
HeightStore store = new HeightStore(directory.toFile());
|
||||
TemporaryStature stature = new TemporaryStature(store);
|
||||
assertEquals(0.2, stature.apply(player, 0.2, StaturePotion.DIMINUTION, settings, start, bound -> 0));
|
||||
assertEquals(3.0, stature.apply(player, 0.2, StaturePotion.SHIFTING, settings,
|
||||
start.plusSeconds(10), bound -> bound - 1));
|
||||
assertEquals(3.0, stature.apply(player, 3.0, StaturePotion.GROWTH, settings,
|
||||
start.plusSeconds(20), bound -> 0));
|
||||
assertEquals(start.plusSeconds(320), store.sequence(player).expiresAt());
|
||||
assertEquals(1.0, stature.apply(player, 3.0, StaturePotion.RESTORATION, settings,
|
||||
start.plusSeconds(30), bound -> 0));
|
||||
assertEquals(0.2, stature.resume(player, settings, start.plusSeconds(330)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void chainedEffectsRestoreFirstBaselineFiveMinutesAfterLastApplication() throws Exception {
|
||||
HeightStore store = new HeightStore(directory.toFile());
|
||||
TemporaryStature stature = new TemporaryStature(store);
|
||||
assertEquals(0.8, stature.apply(player, 1.0, StaturePotion.DIMINUTION, settings, start, bound -> 0));
|
||||
assertEquals(0.6, stature.apply(player, 0.8, StaturePotion.DIMINUTION, settings,
|
||||
start.plusSeconds(240), bound -> 0));
|
||||
assertEquals(0.6, stature.resume(player, settings, start.plusSeconds(539)));
|
||||
assertEquals(1.0, stature.resume(player, settings, start.plusSeconds(540)));
|
||||
assertEquals(1.0, store.find(player));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package games.dmg.spigotheights;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.entity.AreaEffectCloud;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.entity.ThrownPotion;
|
||||
import org.bukkit.event.entity.AreaEffectCloudApplyEvent;
|
||||
import org.bukkit.event.entity.LingeringPotionSplashEvent;
|
||||
import org.bukkit.event.entity.PotionSplashEvent;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.persistence.PersistentDataContainer;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ThrowableStatureListenerTest {
|
||||
private final PotionRecipes potions = new PotionRecipes(null,
|
||||
new NamespacedKey("spigotheights", "stature_potion"), BukkitDoubles.Item::new);
|
||||
private final List<StaturePotion> applied = new ArrayList<>();
|
||||
private final List<Consumer<Boolean>> pending = new ArrayList<>();
|
||||
private boolean asynchronous;
|
||||
private final ThrowableStatureListener listener = new ThrowableStatureListener(potions,
|
||||
new NamespacedKey("spigotheights", "cloud_kind"), (source, player, kind, done) -> {
|
||||
applied.add(kind);
|
||||
if (asynchronous) { pending.add(done); } else { done.accept(true); }
|
||||
});
|
||||
|
||||
@Test
|
||||
void splashUsesAuthenticatedItemPositiveIntensityAndOncePerPlayer() {
|
||||
Player player = entity(Player.class, null);
|
||||
Player missed = entity(Player.class, null);
|
||||
LivingEntity mob = entity(LivingEntity.class, null);
|
||||
ThrownPotion thrown = entity(ThrownPotion.class, potions.create(StaturePotion.DIMINUTION, Material.SPLASH_POTION));
|
||||
Map<LivingEntity, Double> recipients = new HashMap<>();
|
||||
recipients.put(player, 0.1); recipients.put(missed, 0.0); recipients.put(mob, 1.0);
|
||||
PotionSplashEvent event = new PotionSplashEvent(thrown, null, null, null, recipients);
|
||||
event.setCancelled(true);
|
||||
listener.onSplash(event);
|
||||
assertEquals(List.of(), applied);
|
||||
event.setCancelled(false);
|
||||
listener.onSplash(event);
|
||||
listener.onSplash(event);
|
||||
assertEquals(List.of(StaturePotion.DIMINUTION), applied);
|
||||
ThrownPotion ordinary = entity(ThrownPotion.class, new BukkitDoubles.Item(Material.SPLASH_POTION));
|
||||
listener.onSplash(new PotionSplashEvent(ordinary, null, null, null, recipients));
|
||||
assertEquals(1, applied.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cloudRequiresUncancelledAuthenticatedCreationAndAppliesOnlyOncePerPlayer() {
|
||||
Player player = entity(Player.class, null);
|
||||
AreaEffectCloud cloud = entity(AreaEffectCloud.class, null);
|
||||
var application = new AreaEffectCloudApplyEvent(cloud, new ArrayList<>(List.of(player)));
|
||||
listener.onCloud(application);
|
||||
assertEquals(0, applied.size());
|
||||
var creation = new LingeringPotionSplashEvent(entity(ThrownPotion.class,
|
||||
potions.create(StaturePotion.GROWTH, Material.LINGERING_POTION)), null, null, null, cloud);
|
||||
creation.setCancelled(true);
|
||||
listener.onLingering(creation);
|
||||
assertFalse(creation.allowsEmptyCreation());
|
||||
listener.onCloud(application);
|
||||
assertEquals(0, applied.size());
|
||||
creation.setCancelled(false);
|
||||
listener.onLingering(creation);
|
||||
assertTrue(creation.allowsEmptyCreation());
|
||||
application.setCancelled(true);
|
||||
listener.onCloud(application);
|
||||
assertEquals(0, applied.size());
|
||||
application.setCancelled(false);
|
||||
asynchronous = true;
|
||||
listener.onCloud(application);
|
||||
listener.onCloud(application);
|
||||
assertEquals(List.of(StaturePotion.GROWTH), applied);
|
||||
assertFalse(cloud.getPersistentDataContainer().has(new NamespacedKey("spigotheights",
|
||||
"hit_" + player.getUniqueId())), "Pending reservations must not be serialized on unload");
|
||||
pending.removeFirst().accept(false);
|
||||
listener.onCloud(application);
|
||||
assertEquals(2, applied.size());
|
||||
pending.removeFirst().accept(true);
|
||||
listener.onCloud(application);
|
||||
assertEquals(2, applied.size());
|
||||
// Receipts are cloud-owned metadata, not player-object or listener-instance identity.
|
||||
new ThrowableStatureListener(potions, new NamespacedKey("spigotheights", "cloud_kind"),
|
||||
(source, recipient, kind, done) -> { throw new AssertionError("duplicate after listener restart"); })
|
||||
.onCloud(application);
|
||||
}
|
||||
|
||||
@Test
|
||||
void effectlessCloudTickDispatchesCancellableEventAndHonorsWaitAndRadius() {
|
||||
Player inside = locatedPlayer(1.0, 0.0);
|
||||
Player outsideCircle = locatedPlayer(2.0, 2.0);
|
||||
PersistentDataContainer data = BukkitDoubles.container();
|
||||
int[] age = {9};
|
||||
UUID cloudId = UUID.randomUUID();
|
||||
AreaEffectCloud cloud = BukkitDoubles.proxy(AreaEffectCloud.class, (proxy, method, args) -> switch (method.getName()) {
|
||||
case "getPersistentDataContainer" -> data;
|
||||
case "isValid" -> true;
|
||||
case "getUniqueId" -> cloudId;
|
||||
case "getTicksLived" -> age[0];
|
||||
case "getWaitTime" -> 10;
|
||||
case "getRadius" -> 2.0f;
|
||||
case "getLocation" -> new org.bukkit.Location(null, 0, 0, 0);
|
||||
case "getBoundingBox" -> new org.bukkit.util.BoundingBox(-2, 0, -2, 2, 0.5, 2);
|
||||
case "getNearbyEntities" -> List.of(inside, outsideCircle);
|
||||
default -> throw new UnsupportedOperationException(method.getName());
|
||||
});
|
||||
listener.onLingering(new LingeringPotionSplashEvent(entity(ThrownPotion.class,
|
||||
potions.create(StaturePotion.RESTORATION, Material.LINGERING_POTION)), null, null, null, cloud));
|
||||
List<AreaEffectCloudApplyEvent> dispatched = new ArrayList<>();
|
||||
Consumer<AreaEffectCloudApplyEvent> cancel = event -> {
|
||||
dispatched.add(event); event.setCancelled(true); listener.onCloud(event);
|
||||
};
|
||||
listener.tickCloud(cloud, cancel);
|
||||
assertEquals(0, dispatched.size());
|
||||
age[0] = 10;
|
||||
listener.tickCloud(cloud, cancel);
|
||||
assertEquals(1, dispatched.size());
|
||||
assertEquals(List.of(inside), dispatched.getFirst().getAffectedEntities());
|
||||
assertEquals(0, applied.size());
|
||||
listener.tickCloud(cloud, listener::onCloud);
|
||||
listener.tickCloud(cloud, listener::onCloud);
|
||||
assertEquals(List.of(StaturePotion.RESTORATION), applied);
|
||||
}
|
||||
|
||||
private static Player locatedPlayer(double x, double z) {
|
||||
Player delegate = entity(Player.class, null);
|
||||
return BukkitDoubles.proxy(Player.class, (proxy, method, args) -> switch (method.getName()) {
|
||||
case "getLocation" -> new org.bukkit.Location(null, x, 0, z);
|
||||
case "getBoundingBox" -> new org.bukkit.util.BoundingBox(x - 0.2, 0, z - 0.2, x + 0.2, 1.8, z + 0.2);
|
||||
case "getGameMode" -> org.bukkit.GameMode.SURVIVAL;
|
||||
case "equals" -> proxy == args[0];
|
||||
default -> method.invoke(delegate, args);
|
||||
});
|
||||
}
|
||||
|
||||
static <T> T entity(Class<T> type, ItemStack item) {
|
||||
UUID id = UUID.randomUUID();
|
||||
PersistentDataContainer data = BukkitDoubles.container();
|
||||
return BukkitDoubles.proxy(type, (proxy, method, args) -> switch (method.getName()) {
|
||||
case "getUniqueId" -> id;
|
||||
case "getPersistentDataContainer" -> data;
|
||||
case "getItem" -> item;
|
||||
case "isOnline", "isValid" -> true;
|
||||
case "isDead" -> false;
|
||||
case "hashCode" -> id.hashCode();
|
||||
case "equals" -> proxy == args[0];
|
||||
case "toString" -> id.toString();
|
||||
default -> throw new UnsupportedOperationException(method.getName());
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user