Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83f0ea9a6e | ||
|
|
359b723897 | ||
|
|
3c6237946e | ||
|
|
68d4a854c3 | ||
|
|
a6c80e7ac9 | ||
|
|
960e8891a8 | ||
|
|
a949fd99c4 | ||
|
|
df44bba9c6 |
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,9 +1,9 @@
|
||||
# spigot-heights agent entrypoint
|
||||
# purpur-heights agent entrypoint
|
||||
|
||||
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).
|
||||
|
||||
Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/spigot-heights/index.md`, `engineering.md` in that project section, and relevant `../somc-okf/user-stories/spigot-heights/` stories. Also follow the parent workspace `AGENTS.md` when present.
|
||||
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.
|
||||
|
||||
For standalone checkouts, start at the [project page](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/spigot-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.
|
||||
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.
|
||||
|
||||
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 [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/spigot-heights/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
|
||||
@@ -55,29 +57,47 @@ Drinking a permanent stature potion ends the temporary sequence, using the curre
|
||||
|
||||
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.
|
||||
|
||||
**Verification:** automated domain and Bukkit-boundary tests cover this implementation. Actual brewing, cloud delivery, client appearance, and logout/restart gameplay on Purpur 26.2 build 2618 still require manual acceptance checks.
|
||||
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.
|
||||
|
||||
## Consensual carrying (US-010; live acceptance pending)
|
||||
**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.
|
||||
|
||||
Sneak-right-click another player with your main hand to request a ride or pickup. The larger player is always the carrier, regardless of who asks. The other player has **30 seconds** to explicitly accept:
|
||||
## 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 accept
|
||||
/heights carry decline
|
||||
/heights carry stop
|
||||
```
|
||||
|
||||
These commands require **no administrator permission**; settings and player-height commands still require `spigotheights.admin`. Console cannot carry players. One outstanding request per participant prevents replacement/ambiguous consent. Either participant can withdraw a pending request with `stop`; `decline` rejects an incoming request. Requests and carry tracking are not persisted.
|
||||
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, when requesting and accepting. Their **effective** scale difference must be at least **three current adjustment steps** (normally `0.3`), including other plugins' attribute modifiers. Neither may already have a vehicle or passengers. Heights permits only one rider per carrier, and blocks additional/nested mounts involving an active pair.
|
||||
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.
|
||||
|
||||
Either player can stop with **normal Minecraft dismounting**. Heights calls the rider's native `leaveVehicle()` and allows ordinary sneak-dismounts, 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.
|
||||
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:** automated domain and constructed Bukkit-event tests are not live-server evidence. Purpur 26.2 build 2618 still needs checks for scaled rider positioning and movement, ordinary dismount placement/falling, cancellation interoperability, both-role death/disconnect, portal/cross-world teleports, plugin disable and eligibility changes. No throwing or added launch force, player items, storage, or changes to throwable-potion opt-in behavior are included.
|
||||
**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
|
||||
|
||||
@@ -92,12 +112,19 @@ 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
|
||||
@@ -134,6 +161,8 @@ Player names must match an online player exactly (case-insensitive); partial nam
|
||||
|
||||
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
-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"
|
||||
@@ -15,7 +15,7 @@ import org.bukkit.event.player.PlayerInteractAtEntityEvent;
|
||||
import org.bukkit.event.player.PlayerInteractEntityEvent;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
|
||||
/** Bukkit boundary for the consent state machine. All methods run on the server thread. */
|
||||
/** 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;
|
||||
@@ -24,6 +24,8 @@ public final class BukkitCarry implements Listener, CarryService.Port, AutoClose
|
||||
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;
|
||||
|
||||
@@ -31,36 +33,40 @@ public final class BukkitCarry implements Listener, CarryService.Port, AutoClose
|
||||
this(server::getPlayer, settings, nextTick, logger, player -> {
|
||||
AttributeInstance attribute = player.getAttribute(Attribute.SCALE);
|
||||
return attribute == null ? Double.NaN : attribute.getValue();
|
||||
}, () -> System.nanoTime() / 1_000_000);
|
||||
});
|
||||
}
|
||||
BukkitCarry(Function<UUID, Player> players, Supplier<HeightSettings> settings,
|
||||
Consumer<Runnable> nextTick, Logger logger, ToDoubleFunction<Player> scales, LongSupplier clock) {
|
||||
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().adjustmentStep(), clock);
|
||||
service = new CarryService(this, () -> settings.get().carryMinimumScaleGap());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void interact(PlayerInteractEntityEvent event) {
|
||||
if (event.isCancelled() || event instanceof PlayerInteractAtEntityEvent
|
||||
|| event.getHand() != EquipmentSlot.HAND || !event.getPlayer().isSneaking()
|
||||
if (closed || event.isCancelled() || event.getHand() != EquipmentSlot.HAND
|
||||
|| !(event.getRightClicked() instanceof Player target)) { return; }
|
||||
event.setCancelled(true);
|
||||
Player sender = event.getPlayer();
|
||||
String result = service.request(sender.getUniqueId(), target.getUniqueId());
|
||||
sender.sendMessage(result);
|
||||
if (result.equals("Carry request sent.")) {
|
||||
target.sendMessage(sender.getName() + (scales.applyAsDouble(sender) > scales.applyAsDouble(target)
|
||||
? " wants to carry you." : " wants to ride with you."));
|
||||
}
|
||||
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 "accept" -> service.accept(id);
|
||||
case "decline" -> service.decline(id);
|
||||
case "stop" -> service.stop(id);
|
||||
default -> "Usage: /heights carry accept|decline|stop";
|
||||
default -> "Usage: /heights carry stop";
|
||||
});
|
||||
}
|
||||
@Override public CarryService.State state(UUID id) {
|
||||
@@ -169,6 +175,7 @@ public final class BukkitCarry implements Listener, CarryService.Port, AutoClose
|
||||
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."); }
|
||||
|
||||
@@ -82,6 +82,10 @@ public final class BukkitStatureService implements AutoCloseable {
|
||||
}
|
||||
|
||||
public void resume(Player player) {
|
||||
resume(player, true);
|
||||
}
|
||||
|
||||
public void resume(Player player, boolean notify) {
|
||||
UUID id = player.getUniqueId();
|
||||
queue.submit(() -> {
|
||||
requireOnline(id);
|
||||
@@ -89,7 +93,7 @@ public final class BukkitStatureService implements AutoCloseable {
|
||||
Instant now = Instant.now();
|
||||
return () -> stature.resume(id, limits, now);
|
||||
}, (result, error) -> {
|
||||
if (error == null) { applyRestoration(id, result); } else { report(id, error); }
|
||||
if (error == null) { applyRestoration(id, result, notify); } else { report(id, error); }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -171,8 +175,12 @@ public final class BukkitStatureService implements AutoCloseable {
|
||||
}
|
||||
|
||||
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) && pending != null && !Instant.now().isBefore(pending.expiresAt())) {
|
||||
if (applyOnline(id, value, notify) && pending != null && !Instant.now().isBefore(pending.expiresAt())) {
|
||||
worker.execute(() -> {
|
||||
try {
|
||||
stature.acknowledgeRestoration(id, pending);
|
||||
@@ -184,16 +192,30 @@ public final class BukkitStatureService implements AutoCloseable {
|
||||
}
|
||||
|
||||
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);
|
||||
if (plugin.isEnabled() && player != null && player.isOnline() && !player.isDead()) {
|
||||
AttributeInstance attribute = player.getAttribute(Attribute.SCALE);
|
||||
if (attribute != null) {
|
||||
attribute.setBaseValue(value);
|
||||
player.sendMessage("Your scale is now " + value + ".");
|
||||
return true;
|
||||
}
|
||||
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 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) {
|
||||
|
||||
@@ -4,13 +4,12 @@ package games.dmg.spigotheights;
|
||||
public final class CarryPolicy {
|
||||
private CarryPolicy() {}
|
||||
|
||||
public static boolean eligible(double carrier, double rider, double step) {
|
||||
if (!Double.isFinite(carrier) || !Double.isFinite(rider) || !Double.isFinite(step)
|
||||
|| carrier <= 0 || rider <= 0 || step <= 0 || carrier <= rider) {
|
||||
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 required = 3 * step;
|
||||
double tolerance = 4 * Math.ulp(Math.max(carrier, rider));
|
||||
return Double.isFinite(required) && carrier - rider + tolerance >= required;
|
||||
return carrier - rider + tolerance >= gap;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.function.DoubleSupplier;
|
||||
import java.util.function.LongSupplier;
|
||||
|
||||
/** Tick-thread state machine; ports must not perform blocking I/O. */
|
||||
public final class CarryService {
|
||||
@@ -18,53 +17,44 @@ public final class CarryService {
|
||||
boolean dismount(UUID carrier, UUID rider);
|
||||
void message(UUID player, String message);
|
||||
}
|
||||
private record Request(UUID sender, UUID recipient, UUID carrier, UUID rider, long sentAt) {}
|
||||
private record Ride(UUID carrier, UUID rider) {}
|
||||
private final Port port;
|
||||
private final DoubleSupplier step;
|
||||
private final LongSupplier clock;
|
||||
private final Map<UUID, Request> requests = new HashMap<>();
|
||||
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 step, LongSupplier clock) {
|
||||
this.port = port; this.step = step; this.clock = clock;
|
||||
public CarryService(Port port, DoubleSupplier gap) {
|
||||
this.port = port; this.gap = gap;
|
||||
}
|
||||
|
||||
public String request(UUID sender, UUID recipient) {
|
||||
/** 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."; }
|
||||
expireRequests();
|
||||
if (busy(sender) || busy(recipient)) { return "A player already has a carry request or ride."; }
|
||||
State a = port.state(sender), b = port.state(recipient);
|
||||
if (sender.equals(recipient) || a == null || b == null) { return "Players are unavailable."; }
|
||||
UUID carrier = a.scale() > b.scale() ? sender : recipient;
|
||||
UUID rider = carrier.equals(sender) ? recipient : sender;
|
||||
if (!eligible(carrier, rider, true) || !port.nearby(sender, recipient)) {
|
||||
return "Carry requires nearby, available players differing by at least three adjustment steps.";
|
||||
if (involved(rider) || involved(carrier) || mounting.contains(rider) || mounting.contains(carrier)) {
|
||||
return "A player already has a ride.";
|
||||
}
|
||||
Request request = new Request(sender, recipient, carrier, rider, clock.getAsLong());
|
||||
requests.put(recipient, request);
|
||||
port.message(recipient, "Carry request: /heights carry accept or /heights carry decline within 30 seconds.");
|
||||
return "Carry request sent.";
|
||||
}
|
||||
|
||||
public String accept(UUID recipient) {
|
||||
Request request = requests.remove(recipient);
|
||||
if (request == null) { return "No pending request."; }
|
||||
if (clock.getAsLong() - request.sentAt() >= 30_000
|
||||
|| involved(request.carrier()) || involved(request.rider())
|
||||
|| !eligible(request.carrier(), request.rider(), true)
|
||||
|| !port.nearby(request.carrier(), request.rider())) {
|
||||
return "Carry request expired or players are no longer eligible/nearby.";
|
||||
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 = port.mount(request.carrier(), request.rider());
|
||||
if (!mounted && !port.linked(request.carrier(), request.rider())) { return "The server refused the ride."; }
|
||||
Ride ride = new Ride(request.carrier(), request.rider());
|
||||
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) {
|
||||
if (!mounted || closed) {
|
||||
String stopped = stop(ride.rider());
|
||||
String message = "The server produced an invalid ride. " + stopped;
|
||||
String message = (closed ? "Carrying is shutting down. " : "The server produced an invalid ride. ") + stopped;
|
||||
if (involved(ride.rider())) { notify(ride, message); }
|
||||
return message;
|
||||
}
|
||||
@@ -76,35 +66,10 @@ public final class CarryService {
|
||||
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(), step.getAsDouble());
|
||||
&& CarryPolicy.eligible(a.scale(), b.scale(), gap.getAsDouble());
|
||||
}
|
||||
|
||||
private boolean busy(UUID player) {
|
||||
return involved(player) || requests.values().stream()
|
||||
.anyMatch(request -> request.sender().equals(player) || request.recipient().equals(player));
|
||||
}
|
||||
|
||||
private void expireRequests() {
|
||||
requests.values().removeIf(request -> clock.getAsLong() - request.sentAt() >= 30_000);
|
||||
}
|
||||
|
||||
public String decline(UUID recipient) {
|
||||
Request request = requests.remove(recipient);
|
||||
if (request == null) { return "No pending request."; }
|
||||
port.message(request.sender(), "Carry request declined.");
|
||||
return "Carry request declined.";
|
||||
}
|
||||
public String stop(UUID player) {
|
||||
var pending = requests.values().iterator();
|
||||
while (pending.hasNext()) {
|
||||
Request request = pending.next();
|
||||
if (request.sender().equals(player) || request.recipient().equals(player)) {
|
||||
pending.remove();
|
||||
port.message(request.sender().equals(player) ? request.recipient() : request.sender(),
|
||||
"Carry request cancelled.");
|
||||
return "Carry request cancelled.";
|
||||
}
|
||||
}
|
||||
Ride ride = rides.get(player);
|
||||
if (ride == null) { return "No active ride."; }
|
||||
if (!port.dismount(ride.carrier(), ride.rider())) {
|
||||
@@ -119,7 +84,6 @@ public final class CarryService {
|
||||
port.message(ride.carrier(), message); port.message(ride.rider(), message);
|
||||
}
|
||||
public void tick() {
|
||||
expireRequests();
|
||||
for (Ride ride : java.util.Set.copyOf(rides.values())) {
|
||||
if (!eligible(ride.carrier(), ride.rider(), false)
|
||||
|| !port.attached(ride.carrier(), ride.rider())) {
|
||||
@@ -131,14 +95,12 @@ public final class CarryService {
|
||||
}
|
||||
}
|
||||
public boolean cleanup(UUID player) {
|
||||
requests.values().removeIf(request -> request.sender().equals(player) || request.recipient().equals(player));
|
||||
stop(player);
|
||||
return !involved(player);
|
||||
}
|
||||
/** Returns server-refused dismounts; never bypasses cancellation or claims false detachment. */
|
||||
public int close() {
|
||||
closed = true;
|
||||
requests.clear();
|
||||
for (Ride ride : java.util.Set.copyOf(rides.values())) { stop(ride.rider()); }
|
||||
return rides.size() / 2;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -18,8 +18,8 @@ public final class HeightsCommand implements TabExecutor {
|
||||
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 accept|decline|stop";
|
||||
private static final List<String> CARRY_ACTIONS = List.of("accept", "decline", "stop");
|
||||
private static final String CARRY_USAGE = "Usage: /heights carry stop";
|
||||
private static final List<String> CARRY_ACTIONS = List.of("stop");
|
||||
|
||||
@FunctionalInterface
|
||||
public interface HeightSetter {
|
||||
|
||||
@@ -31,7 +31,7 @@ public final class LiveHeightSettings implements Supplier<HeightSettings> {
|
||||
bound.equals("min") ? value : current.minimum(),
|
||||
bound.equals("max") ? value : current.maximum(),
|
||||
current.adjustmentStep(), current.launcherThreshold(),
|
||||
current.launcherSpeed(), current.launcherCooldownTicks());
|
||||
current.launcherSpeed(), current.launcherCooldownTicks(), current.carryMinimumScaleGap());
|
||||
}
|
||||
|
||||
public void setBound(String bound, double value) throws IOException {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
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;
|
||||
@@ -13,6 +18,7 @@ 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;
|
||||
@@ -26,6 +32,8 @@ public final class PotionRecipes {
|
||||
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.getServer(), new NamespacedKey(plugin, "stature_potion"), ItemStack::new);
|
||||
@@ -41,12 +49,42 @@ public final class PotionRecipes {
|
||||
return new NamespacedKey(potionKindKey.getNamespace(), value);
|
||||
}
|
||||
|
||||
public void register() {
|
||||
registerShifting();
|
||||
registerGrowth();
|
||||
registerDiminution();
|
||||
registerRestoration();
|
||||
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) {
|
||||
@@ -61,16 +99,45 @@ public final class PotionRecipes {
|
||||
NamespacedKey mixKey = mixKey(kind, target);
|
||||
brewer.removePotionMix(mixKey);
|
||||
brewer.addPotionMix(new PotionMix(mixKey, create(kind, target),
|
||||
PotionMix.createPredicateChoice(item -> item != null && item.getType() == source
|
||||
predicateChoice(create(kind, source), item -> item != null && item.getType() == source
|
||||
&& identify(item) == kind),
|
||||
PotionMix.createPredicateChoice(item -> item != null && item.getType() == ingredient)));
|
||||
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() {
|
||||
for (StaturePotion kind : StaturePotion.values()) {
|
||||
server.getPotionBrewer().removePotionMix(mixKey(kind, Material.SPLASH_POTION));
|
||||
server.getPotionBrewer().removePotionMix(mixKey(kind, Material.LINGERING_POTION));
|
||||
if (ownedMixKeys.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
org.bukkit.potion.PotionBrewer brewer = server.getPotionBrewer();
|
||||
ownedMixKeys.forEach(brewer::removePotionMix);
|
||||
ownedMixKeys.clear();
|
||||
}
|
||||
|
||||
private NamespacedKey mixKey(StaturePotion kind, Material target) {
|
||||
@@ -123,26 +190,36 @@ public final class PotionRecipes {
|
||||
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);
|
||||
server.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() {
|
||||
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);
|
||||
server.addRecipe(recipe);
|
||||
recipe.setCategory(CraftingBookCategory.MISC);
|
||||
return recipe;
|
||||
}
|
||||
|
||||
private void registerDiminution() {
|
||||
private ShapedRecipe diminutionRecipe() {
|
||||
ShapedRecipe recipe = new ShapedRecipe(key("diminution"),
|
||||
create(StaturePotion.DIMINUTION));
|
||||
recipe.shape(DIMINUTION_SHAPE);
|
||||
@@ -150,10 +227,11 @@ public final class PotionRecipes {
|
||||
recipe.setIngredient('A', Material.AMETHYST_SHARD);
|
||||
recipe.setIngredient('S', new RecipeChoice.ExactChoice(create(StaturePotion.SHIFTING)));
|
||||
recipe.setIngredient('F', Material.FERMENTED_SPIDER_EYE);
|
||||
server.addRecipe(recipe);
|
||||
recipe.setCategory(CraftingBookCategory.MISC);
|
||||
return recipe;
|
||||
}
|
||||
|
||||
private void registerRestoration() {
|
||||
private ShapedRecipe restorationRecipe() {
|
||||
ShapedRecipe recipe = new ShapedRecipe(key("restoration"),
|
||||
create(StaturePotion.RESTORATION));
|
||||
recipe.shape(RESTORATION_SHAPE);
|
||||
@@ -161,7 +239,8 @@ public final class PotionRecipes {
|
||||
recipe.setIngredient('A', Material.AMETHYST_SHARD);
|
||||
recipe.setIngredient('S', new RecipeChoice.ExactChoice(create(StaturePotion.SHIFTING)));
|
||||
recipe.setIngredient('U', Material.SUGAR);
|
||||
server.addRecipe(recipe);
|
||||
recipe.setCategory(CraftingBookCategory.MISC);
|
||||
return recipe;
|
||||
}
|
||||
|
||||
private static Color color(StaturePotion kind) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ 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() {
|
||||
@@ -42,7 +43,9 @@ public final class SpigotHeightsPlugin extends JavaPlugin {
|
||||
command.setTabCompleter(executor);
|
||||
|
||||
potions = new PotionRecipes(this);
|
||||
potions.register();
|
||||
recipeLifecycle = new RecipeLifecycle(getServer(), this, potions, getLogger());
|
||||
recipeLifecycle.enable();
|
||||
getServer().getPluginManager().registerEvents(new StatureBrewing(potions), this);
|
||||
getServer().getPluginManager().registerEvents(
|
||||
new StatureListener(potions, stature::drink, stature::resume,
|
||||
task -> getServer().getScheduler().runTask(this, task)), this);
|
||||
@@ -66,18 +69,22 @@ public final class SpigotHeightsPlugin extends JavaPlugin {
|
||||
if (stature != null) {
|
||||
stature.close();
|
||||
}
|
||||
if (potions != null) {
|
||||
potions.unregisterBrewing();
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,11 +18,11 @@ import org.bukkit.inventory.Recipe;
|
||||
public final class StatureListener implements Listener {
|
||||
private final PotionRecipes potions;
|
||||
private final BiConsumer<Player, StaturePotion> drink;
|
||||
private final Consumer<Player> resume;
|
||||
private final BiConsumer<Player, Boolean> resume;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
|
||||
public StatureListener(PotionRecipes potions, BiConsumer<Player, StaturePotion> drink,
|
||||
Consumer<Player> resume, Consumer<Runnable> nextTick) {
|
||||
BiConsumer<Player, Boolean> resume, Consumer<Runnable> nextTick) {
|
||||
this.potions = potions;
|
||||
this.drink = drink;
|
||||
this.resume = resume;
|
||||
@@ -54,11 +54,11 @@ public final class StatureListener implements Listener {
|
||||
|
||||
@EventHandler
|
||||
public void onJoin(PlayerJoinEvent event) {
|
||||
resume.accept(event.getPlayer());
|
||||
resume.accept(event.getPlayer(), false);
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onRespawn(PlayerRespawnEvent event) {
|
||||
nextTick.accept(() -> resume.accept(event.getPlayer()));
|
||||
nextTick.accept(() -> resume.accept(event.getPlayer(), true));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -6,8 +6,8 @@ description: Craftable player stature potions and tiny-player dispenser launcher
|
||||
author: dmg.games
|
||||
commands:
|
||||
heights:
|
||||
description: Request consensual carrying or administer stature settings and player heights.
|
||||
usage: /heights carry accept|decline|stop | /heights settings | /heights set min|max <value> | /heights player <name> [scale]
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
@@ -28,7 +28,7 @@ class BukkitCarryTest {
|
||||
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, () -> 0);
|
||||
p -> actor(p).scale);
|
||||
|
||||
private Actor actor(Player p) { return p == small.player ? small : large; }
|
||||
private final class Actor {
|
||||
@@ -39,18 +39,19 @@ class BukkitCarryTest {
|
||||
Entity vehicle;
|
||||
Location location;
|
||||
double scale;
|
||||
boolean sneaking = true, online = true, dead;
|
||||
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" -> world;
|
||||
case "getWorld" -> location.getWorld();
|
||||
case "getLocation" -> location.clone();
|
||||
case "isOnline", "isValid" -> online;
|
||||
case "isDead" -> dead;
|
||||
case "isSleeping" -> false;
|
||||
case "getGameMode" -> GameMode.SURVIVAL;
|
||||
case "isSleeping" -> sleeping;
|
||||
case "getGameMode" -> gameMode;
|
||||
case "isSneaking" -> sneaking;
|
||||
case "isInsideVehicle" -> vehicle != null;
|
||||
case "getVehicle" -> vehicle;
|
||||
@@ -97,13 +98,85 @@ class BukkitCarryTest {
|
||||
event.setCancelled(cancelled); carry.interact(event); return event;
|
||||
}
|
||||
private void start() {
|
||||
click(small, large, EquipmentSlot.HAND, false); carry.command(large.player, "accept");
|
||||
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();
|
||||
@@ -195,7 +268,7 @@ class BukkitCarryTest {
|
||||
}
|
||||
@Test
|
||||
void effectiveScaleChangesUseNativeCleanupAndRespectRefusals() {
|
||||
start(); small.scale = 0.9; dismountAllowed = false;
|
||||
start(); small.scale = 0.95; dismountAllowed = false;
|
||||
carry.tick();
|
||||
assertSame(large.player, small.vehicle);
|
||||
dismountAllowed = true; carry.tick();
|
||||
@@ -225,7 +298,7 @@ class BukkitCarryTest {
|
||||
@Test
|
||||
void partialNativeMountIsTrackedUntilItsInvalidRelationshipCanBeDetached() {
|
||||
invalidMountTopology = true; dismountAllowed = false;
|
||||
click(small, large, EquipmentSlot.HAND, false); carry.command(large.player, "accept");
|
||||
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"));
|
||||
@@ -273,15 +346,13 @@ class BukkitCarryTest {
|
||||
assertEquals("No active ride.", large.messages.getLast());
|
||||
}
|
||||
@Test
|
||||
void onlySneakingUncancelledMainHandRequestsMountAfterAcceptance() {
|
||||
void onlyUncancelledMainHandSmallerInitiatorsMountImmediately() {
|
||||
click(small, large, EquipmentSlot.HAND, true);
|
||||
click(small, large, EquipmentSlot.OFF_HAND, false);
|
||||
small.sneaking = false; click(small, large, EquipmentSlot.HAND, false); small.sneaking = true;
|
||||
assertTrue(large.messages.isEmpty());
|
||||
assertTrue(click(small, large, EquipmentSlot.HAND, false).isCancelled());
|
||||
assertFalse(large.messages.isEmpty());
|
||||
click(large, small, EquipmentSlot.HAND, false);
|
||||
assertNull(small.vehicle);
|
||||
assertTrue(large.passengers.isEmpty());
|
||||
carry.command(large.player, "accept");
|
||||
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");
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5,16 +5,24 @@ import org.junit.jupiter.api.Test;
|
||||
|
||||
class CarryPolicyTest {
|
||||
@Test
|
||||
void requiresThreeCurrentStepsWithDecimalBoundaryTolerance() {
|
||||
assertTrue(CarryPolicy.eligible(1.0, 0.7, 0.1));
|
||||
assertTrue(CarryPolicy.eligible(0.7, 0.4, 0.1));
|
||||
assertFalse(CarryPolicy.eligible(1.0, 0.700001, 0.1));
|
||||
assertFalse(CarryPolicy.eligible(1.0, 0.7, 0.2));
|
||||
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));
|
||||
for (double invalid : new double[] {0, -1, Double.NaN, Double.POSITIVE_INFINITY}) {
|
||||
}
|
||||
|
||||
@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));
|
||||
assertFalse(CarryPolicy.eligible(1, 0.4, invalid));
|
||||
if (invalid != 0) { assertFalse(CarryPolicy.eligible(1, 0.4, invalid)); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,10 @@ class CarryServiceTest {
|
||||
small, new CarryService.State(0.4, true, true),
|
||||
large, new CarryService.State(1, true, true),
|
||||
third, new CarryService.State(2, true, true)));
|
||||
private long now;
|
||||
private double step = 0.1;
|
||||
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() {
|
||||
@@ -21,6 +21,7 @@ class CarryServiceTest {
|
||||
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; }
|
||||
@@ -31,50 +32,80 @@ class CarryServiceTest {
|
||||
return stopAllowed;
|
||||
}
|
||||
public void message(UUID id, String text) { messages.add(text); }
|
||||
}, () -> step, () -> now);
|
||||
}, () -> gap);
|
||||
|
||||
@Test
|
||||
void consentExpiresAtThirtySecondsAndRechecksLiveConditions() {
|
||||
service.request(small, large);
|
||||
now = 30_000;
|
||||
assertFalse(service.accept(large).contains("started"));
|
||||
assertEquals(0, mounts);
|
||||
for (int change = 0; change < 4; change++) {
|
||||
near = true; step = 0.1;
|
||||
states.put(small, new CarryService.State(0.4, true, true));
|
||||
service.request(small, large);
|
||||
switch (change) {
|
||||
case 0 -> near = false;
|
||||
case 1 -> step = 0.3;
|
||||
case 2 -> states.put(small, new CarryService.State(0.8, true, true));
|
||||
case 3 -> states.put(small, new CarryService.State(0.4, false, true));
|
||||
default -> throw new AssertionError();
|
||||
}
|
||||
assertFalse(service.accept(large).contains("started"));
|
||||
assertEquals(0, mounts);
|
||||
}
|
||||
near = true; step = 0.1;
|
||||
states.put(small, new CarryService.State(0.4, true, true));
|
||||
service.request(small, large);
|
||||
now += 29_999;
|
||||
assertTrue(service.accept(large).contains("started"));
|
||||
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 eitherParticipantCanWithdrawPendingConsentWithStop() {
|
||||
for (UUID caller : List.of(small, large)) {
|
||||
assertTrue(service.request(small, large).contains("sent"));
|
||||
assertTrue(service.stop(caller).contains("cancelled"));
|
||||
assertTrue(service.accept(large).contains("No pending"));
|
||||
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.request(small, large); service.accept(large);
|
||||
service.ride(small, large);
|
||||
messages.clear();
|
||||
states.put(small, new CarryService.State(0.9, true, true));
|
||||
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");
|
||||
@@ -84,51 +115,35 @@ class CarryServiceTest {
|
||||
assertFalse(service.involved(small));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reservesOneRequestPerPlayerAndDeclineReleasesBoth() {
|
||||
assertTrue(service.request(small, large).contains("sent"));
|
||||
assertFalse(service.request(third, large).contains("sent"));
|
||||
assertFalse(service.request(small, third).contains("sent"));
|
||||
assertTrue(service.decline(large).contains("declined"));
|
||||
assertTrue(service.request(small, third).contains("sent"));
|
||||
now = 30_000;
|
||||
service.tick();
|
||||
assertTrue(service.request(small, large).contains("sent"));
|
||||
}
|
||||
|
||||
@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.request(small, large).contains("sent"));
|
||||
assertFalse(service.ride(small, large).contains("started"));
|
||||
assertEquals(0, mounts);
|
||||
states.put(busy, before);
|
||||
}
|
||||
service.request(small, large);
|
||||
states.put(small, new CarryService.State(0.4, true, false));
|
||||
assertFalse(service.accept(large).contains("started"));
|
||||
states.put(small, new CarryService.State(0.4, true, true));
|
||||
service.request(small, large);
|
||||
mountAllowed = false;
|
||||
assertFalse(service.accept(large).contains("started"));
|
||||
assertTrue(service.ride(small, large).contains("refused"));
|
||||
assertFalse(service.involved(small));
|
||||
mountAllowed = true;
|
||||
service.request(small, large);
|
||||
service.accept(large);
|
||||
assertFalse(service.request(third, large).contains("sent"));
|
||||
assertFalse(service.request(small, third).contains("sent"));
|
||||
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.request(small, large); service.accept(large);
|
||||
service.ride(small, large);
|
||||
stopAllowed = false;
|
||||
states.put(small, new CarryService.State(0.9, true, true));
|
||||
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"));
|
||||
// No pending rescue job: if eligibility recovers, a voluntary stop can be requested again.
|
||||
states.put(small, new CarryService.State(0.4, true, true));
|
||||
stopAllowed = true;
|
||||
service.tick();
|
||||
@@ -139,31 +154,30 @@ class CarryServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void lifecycleClearsRequestsAndRidesAndReportsUnresolvedDisable() {
|
||||
service.request(small, large);
|
||||
void lifecycleCleansRidesAndReportsUnresolvedDisable() {
|
||||
service.ride(small, large);
|
||||
assertTrue(service.cleanup(small));
|
||||
assertTrue(service.accept(large).contains("No pending"));
|
||||
service.request(small, large); service.accept(large);
|
||||
service.ride(small, large);
|
||||
assertTrue(service.cleanup(large));
|
||||
assertFalse(service.involved(small));
|
||||
service.request(small, large); service.accept(large);
|
||||
service.ride(small, large);
|
||||
stopAllowed = false;
|
||||
assertFalse(service.cleanup(small));
|
||||
assertEquals(1, service.close());
|
||||
assertTrue(service.involved(small));
|
||||
assertFalse(service.request(third, large).contains("sent"));
|
||||
assertFalse(service.ride(third, large).contains("started"));
|
||||
stopAllowed = true;
|
||||
assertEquals(0, service.close());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unavailableOrDetachedParticipantsTriggerCleanup() {
|
||||
service.request(small, large); service.accept(large);
|
||||
service.ride(small, large);
|
||||
attached = false;
|
||||
service.tick();
|
||||
assertEquals(1, stops);
|
||||
assertFalse(service.involved(large));
|
||||
service.request(small, large); service.accept(large);
|
||||
service.ride(small, large);
|
||||
states.put(large, new CarryService.State(1, false, false));
|
||||
service.tick();
|
||||
assertEquals(2, stops);
|
||||
@@ -171,19 +185,13 @@ class CarryServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void eitherDirectionRequiresOtherPlayersExplicitConsent() {
|
||||
for (boolean pickup : new boolean[] {false, true}) {
|
||||
UUID sender = pickup ? large : small, recipient = pickup ? small : large;
|
||||
assertTrue(service.request(sender, recipient).contains("sent"));
|
||||
assertEquals(0, mounts);
|
||||
assertTrue(service.accept(sender).contains("No pending"));
|
||||
assertTrue(service.accept(recipient).contains("started"));
|
||||
assertEquals(large, mountedCarrier);
|
||||
assertEquals(small, mountedRider);
|
||||
assertTrue(service.involved(small));
|
||||
assertTrue(service.involved(large));
|
||||
assertTrue(service.stop(sender).contains("ended"));
|
||||
mounts = 0;
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -51,19 +51,26 @@ class HeightsCommandTest {
|
||||
for (String action : List.of("accept", "decline", "stop")) {
|
||||
command.onCommand(player, null, "heights", new String[] {"CaRrY", action.toUpperCase()});
|
||||
}
|
||||
assertEquals(List.of("accept", "decline", "stop"), actions);
|
||||
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(3, actions.size());
|
||||
assertEquals(1, actions.size());
|
||||
assertEquals(List.of("carry"), complete(player, ""));
|
||||
assertEquals(List.of("accept", "decline", "stop"), complete(player, "carry", ""));
|
||||
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"));
|
||||
}
|
||||
|
||||
@@ -64,7 +64,12 @@ class PotionRecipesTest {
|
||||
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()));
|
||||
org.junit.jupiter.api.Assertions.assertTrue(mix.getIngredient().test(new BukkitDoubles.Item(
|
||||
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)));
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,13 @@ 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;
|
||||
|
||||
@@ -16,7 +19,8 @@ class StatureListenerTest {
|
||||
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 -> {}, Runnable::run);
|
||||
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);
|
||||
@@ -29,4 +33,23 @@ class StatureListenerTest {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user