Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24a220ae4a | ||
|
|
ae899926fe | ||
|
|
534cab061c | ||
|
|
7854824f01 | ||
|
|
0452e6701d | ||
|
|
862c9a8a84 |
No files matched your search
@@ -35,6 +35,6 @@ jobs:
|
||||
- name: Upload development artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: spigot-inventory-helper-${{ github.sha }}
|
||||
name: purpur-inventory-helper-${{ 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-inventory-helper-${{ steps.release.outputs.version }}
|
||||
path: build/libs/spigot-inventory-helper-${{ steps.release.outputs.version }}.jar
|
||||
name: purpur-inventory-helper-${{ steps.release.outputs.version }}
|
||||
path: build/libs/purpur-inventory-helper-${{ 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-inventory-helper-${VERSION}.jar"
|
||||
jar="build/libs/purpur-inventory-helper-${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-inventory-helper-${VERSION}.jar"
|
||||
"${api_url}/repos/${REPOSITORY}/releases/${release_id}/assets?name=purpur-inventory-helper-${VERSION}.jar"
|
||||
@@ -1,37 +1,9 @@
|
||||
# Project Agent Instructions
|
||||
# purpur-inventory-helper agent entrypoint
|
||||
|
||||
## Project
|
||||
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).
|
||||
|
||||
- This repository contains the Spigot Inventory Helper plugin.
|
||||
- Target Purpur `26.2.build.2618-stable` with Java 25, matching `../spigot-base/` unless an approved user story changes the target.
|
||||
- Use Gradle with Kotlin DSL and the Gradle wrapper.
|
||||
- Keep compiler lint warnings enabled and fail builds on warnings, excluding only warnings explicitly justified by the supported API.
|
||||
Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/purpur-inventory-helper/index.md`, `engineering.md` in that project section, and relevant `../somc-okf/user-stories/purpur-inventory-helper/` stories. Also follow the parent workspace `AGENTS.md` when present.
|
||||
|
||||
## Knowledge bundle
|
||||
For standalone checkouts, start at the [project page](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-inventory-helper/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.
|
||||
|
||||
- `design/` is the canonical OKF v0.1 knowledge bundle. Do not create a parallel `knowledge/` bundle.
|
||||
- Read `design/index.md`, the user-story catalog, relevant stories, and relevant implementation before changing behavior.
|
||||
- Every feature or behavior change must have a corresponding story under `design/user-stories/`.
|
||||
- Present proposed story additions or changes for review and wait for explicit approval before modifying stories or code.
|
||||
- Stories must have OKF frontmatter with `type: User Story`, a descriptive `title`, concise `description`, and one of `backlog`, `in-progress`, or `done` as `status`.
|
||||
- Stories must contain acceptance criteria using Markdown task-list items.
|
||||
- Set approved work to `in-progress` when implementation begins. Set it to `done` and check criteria only after implementation and verification.
|
||||
- Maintain standard Markdown links between the root index, story catalog, related stories, and other concepts.
|
||||
- Record material design and implementation decisions in `design/log.md`. Verify the current date before adding entries.
|
||||
|
||||
## Development
|
||||
|
||||
- Use test-driven development where practical: write a failing test, implement the minimum behavior, and refactor only while tests remain green.
|
||||
- Prefer small, cohesive Java types and server-independent logic that can be unit tested without a running Minecraft server.
|
||||
- Identify custom plugin items with persistent metadata rather than display names alone.
|
||||
- Preserve item quantities and metadata during inventory operations.
|
||||
- Respect Bukkit/Purpur event cancellation and protection-plugin decisions.
|
||||
- Run `./gradlew clean check jar` before marking implementation criteria complete.
|
||||
|
||||
## Delivery
|
||||
|
||||
- Model Gradle and Gitea Actions configuration on `../spigot-base/`, adapted to this plugin's names and identifiers.
|
||||
- Gitea workflows belong in `.gitea/workflows/`.
|
||||
- Pull requests must validate conventional commits, and main-branch conventional commits drive semantic releases.
|
||||
- Never commit generated Gradle output or plugin JARs.
|
||||
- Use conventional commit messages in the form `type(scope): description`, with a concise summary under 72 characters.
|
||||
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,8 +1,10 @@
|
||||
# Spigot Inventory Helper
|
||||
# Purpur Inventory Helper
|
||||
|
||||
A Purpur 26.2 plugin that sorts inventories with a craftable Sorting Stick and automatically replaces broken tools with equivalent tools from the player's inventory.
|
||||
A Purpur 26.2 plugin that sorts inventories with a craftable Sorting Stick, automatically replaces broken tools with equivalent tools from the player's inventory, and adds a stackable-component Dispenser recipe.
|
||||
|
||||
The approved behavior is specified in the [OKF design bundle](design/index.md).
|
||||
The approved behavior is specified in the [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-inventory-helper/index.md).
|
||||
|
||||
The repository, checkout and new JARs use `purpur-inventory-helper`. Runtime `SpigotInventoryHelper`, Java entrypoint/packages, data namespace and sorting-stick/recipe keys remain unchanged. Replace the old JAR instead of loading both distributions. Old tags and `spigot-inventory-helper-*` assets are retained; the previous v1.2.0 download was checked for identical bytes after the rename.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -17,6 +19,16 @@ The approved behavior is specified in the [OKF design bundle](design/index.md).
|
||||
|
||||
The plugin JAR is written to `build/libs/`.
|
||||
|
||||
`check` includes `nativeInventoryTest`. It downloads the exact Purpur 26.2 build 2618 launcher,
|
||||
verifies SHA-256
|
||||
`4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31`, and runs Paperclip in
|
||||
patch-only mode under `build/inventory-runtime/`; it does not start a server, open a port, create a
|
||||
world, or accept the EULA. The native suite executes real `ServerPlaceRecipe`, `ServerRecipeBook`
|
||||
known/highlight codec persistence, and `ResultSlot` ingredient consumption for both Sorting Stick variants and the alternative Dispenser recipe, including repeated batch completion.
|
||||
CraftServer, CraftPlayer, and client boundaries are doubled, so this is not evidence of live client
|
||||
rendering, search interaction, packet transport, or a full server lifecycle. Those remain
|
||||
non-blocking live checks.
|
||||
|
||||
## Sorting Stick recipe
|
||||
|
||||
Place these ingredients vertically in the crafting grid's center column:
|
||||
@@ -27,7 +39,15 @@ Redstone Dust
|
||||
Stick
|
||||
```
|
||||
|
||||
Either storage-block variant produces one Sorting Stick.
|
||||
Either storage-block variant produces one Sorting Stick. Both variants are universally discovered in
|
||||
the recipe book's Equipment category for online and joining players. Discovery is persistent and
|
||||
idempotent; reloading the plugin retains player recipe-book knowledge and ordinary vanilla state.
|
||||
If the two recipes cannot register as one complete group, recipe-book discovery is disabled while
|
||||
existing manual crafting and inventory behavior remain available.
|
||||
|
||||
## Alternative Dispenser recipe
|
||||
|
||||
One ordinary Dropper + Tripwire Hook produces one ordinary Dispenser through a shapeless recipe in the recipe book's Redstone category. The alternative is universally discovered for online and joining players, supports native autofill and batch completion, and leaves Minecraft's Bow-based Dispenser recipe unchanged. If the alternative cannot register safely, existing Inventory Helper behavior remains available and discovery retries after a later successful registration.
|
||||
|
||||
## Sorting inventories
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import java.net.URI
|
||||
import java.security.MessageDigest
|
||||
|
||||
plugins {
|
||||
java
|
||||
}
|
||||
@@ -35,8 +38,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 recipe-book and crafting engine without starting a server.
|
||||
// Paperclip only extracts/patches dependencies: no world, port, or EULA acceptance is involved.
|
||||
val inventoryRuntime = layout.buildDirectory.dir("inventory-runtime")
|
||||
val downloadInventoryRuntime = tasks.register("downloadInventoryRuntime") {
|
||||
val launcher = inventoryRuntime.map { it.file("purpur-26.2-2618.jar") }
|
||||
outputs.file(launcher)
|
||||
doLast {
|
||||
val file = launcher.get().asFile
|
||||
file.parentFile.mkdirs()
|
||||
val connection = URI("https://api.purpurmc.org/v2/purpur/26.2/2618/download")
|
||||
.toURL().openConnection()
|
||||
connection.connectTimeout = 30_000
|
||||
connection.readTimeout = 120_000
|
||||
val bytes = connection.getInputStream().use { it.readBytes() }
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(bytes)
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
check(digest == "4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31") {
|
||||
"Purpur 2618 checksum mismatch"
|
||||
}
|
||||
file.writeBytes(bytes)
|
||||
}
|
||||
}
|
||||
val prepareInventoryRuntime = tasks.register<JavaExec>("prepareInventoryRuntime") {
|
||||
dependsOn(downloadInventoryRuntime)
|
||||
javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(25) }
|
||||
classpath = files(inventoryRuntime.map { it.file("purpur-26.2-2618.jar") })
|
||||
mainClass = "io.papermc.paperclip.Main"
|
||||
jvmArgs("-Dpaperclip.patchonly=true")
|
||||
workingDir(inventoryRuntime)
|
||||
outputs.dir(inventoryRuntime.map { it.dir("versions") })
|
||||
outputs.dir(inventoryRuntime.map { it.dir("libraries") })
|
||||
}
|
||||
val nativeTest = sourceSets.create("nativeTest")
|
||||
dependencies {
|
||||
add(nativeTest.implementationConfigurationName, platform("org.junit:junit-bom:5.13.4"))
|
||||
add(nativeTest.implementationConfigurationName, "org.junit.jupiter:junit-jupiter")
|
||||
add(nativeTest.implementationConfigurationName, "org.mockito:mockito-core:5.18.0")
|
||||
add(nativeTest.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(inventoryRuntime) {
|
||||
include("versions/**/*.jar", "libraries/**/*.jar")
|
||||
}).builtBy(prepareInventoryRuntime)
|
||||
nativeTest.compileClasspath += sourceSets.main.get().output + nativeRuntimeJars
|
||||
nativeTest.runtimeClasspath += sourceSets.main.get().output + nativeRuntimeJars
|
||||
val nativeInventoryTest = tasks.register<Test>("nativeInventoryTest") {
|
||||
description = "Runs pinned Purpur recipe-book and crafting regressions without starting a server"
|
||||
testClassesDirs = nativeTest.output.classesDirs
|
||||
classpath = nativeTest.runtimeClasspath
|
||||
useJUnitPlatform()
|
||||
maxHeapSize = "1G"
|
||||
workingDir(inventoryRuntime)
|
||||
}
|
||||
tasks.check { dependsOn(nativeInventoryTest) }
|
||||
|
||||
val pluginVersion = version
|
||||
|
||||
tasks.processResources {
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
---
|
||||
type: Index
|
||||
title: Spigot Inventory Helper Design
|
||||
description: Entry point for the Spigot Inventory Helper OKF knowledge bundle.
|
||||
okf_version: "0.1"
|
||||
---
|
||||
|
||||
# Spigot Inventory Helper Design
|
||||
|
||||
This bundle documents inventory sorting, custom utility items, automatic tool replacement, and delivery requirements for the Spigot Inventory Helper plugin.
|
||||
|
||||
## Explore
|
||||
|
||||
- [User stories](user-stories/index.md)
|
||||
- [Design log](log.md)
|
||||
@@ -1,51 +0,0 @@
|
||||
---
|
||||
type: Log
|
||||
title: Spigot Inventory Helper Design Log
|
||||
description: Chronological record of material decisions affecting the Spigot Inventory Helper design.
|
||||
---
|
||||
|
||||
# Spigot Inventory Helper Design Log
|
||||
|
||||
## 2026-09-04 — Initial inventory-helper design
|
||||
|
||||
- Players will be able to craft a metadata-authenticated Sorting Stick.
|
||||
- The Sorting Stick will sort chests, double chests, barrels, or the player's main storage inventory depending on the interaction target.
|
||||
- Player-inventory sorting will leave the hotbar, armor, and off-hand slots unchanged.
|
||||
- When a main-hand tool breaks, an equivalent tool with the same material and tool type will be moved from the player's inventory into the selected hotbar slot when available.
|
||||
- The knowledge bundle and eventual Gradle, Purpur, Gitea CI, and semantic-release setup will follow the neighboring Spigot Base project where applicable.
|
||||
|
||||
## 2026-09-04 — Sorting Stick recipe
|
||||
|
||||
- The Sorting Stick uses a vertical recipe in the crafting grid's center column: a Chest or Barrel above Redstone Dust, with a Stick at the bottom.
|
||||
- Either storage-block variant produces one Sorting Stick.
|
||||
|
||||
## 2026-09-04 — Build and release foundation completed
|
||||
|
||||
- Added the Java 25 Gradle build, Purpur API dependency, strict compiler linting, JUnit 5 lifecycle, plugin metadata, and initial plugin entry point.
|
||||
- Added Gitea CI, conventional-commit validation for pull requests, development artifacts, semantic releases, and release-asset upload.
|
||||
- Verified `./gradlew clean check jar`, successful main and tag CI runs, and release `v1.0.0` with `spigot-inventory-helper-1.0.0.jar` attached.
|
||||
|
||||
## 2026-09-04 — Sorting Stick completed
|
||||
|
||||
- Added a gold-named, glinting Sorting Stick authenticated by persistent item metadata.
|
||||
- Registered equivalent chest and barrel recipe variants matching the approved center-column shape.
|
||||
- Verified recipe specification, registration, output, item appearance, identity, and renamed-stick rejection with automated tests and `./gradlew clean check jar`.
|
||||
|
||||
## 2026-09-04 — Inventory sorting completed
|
||||
|
||||
- Added metadata-preserving stack consolidation and stable alphabetical ordering by Minecraft material.
|
||||
- Added Sorting Stick interactions for single and double chests, barrels, and player main-storage slots while preserving hotbar, armor, and off-hand slots.
|
||||
- Rejected locked containers, cancelled protection events, non-right-click interactions, and unauthenticated sticks without changing inventories.
|
||||
- Added action-bar feedback, documented player behavior, and verified the complete feature with `./gradlew clean check jar`.
|
||||
|
||||
## 2026-09-04 — Automatic tool replacement completed
|
||||
|
||||
- Added next-tick replacement of broken main-hand tools with exact-material matches from player storage.
|
||||
- Main storage is searched before other hotbar slots, and the replacement item is moved intact into the captured selected slot without overwriting a newly occupied slot.
|
||||
- Off-hand breaks and unavailable or mismatched replacements leave inventory unchanged.
|
||||
- Added player feedback and verified matching, source priority, metadata-preserving movement, safety, and off-hand behavior with automated tests and `./gradlew clean check jar`.
|
||||
|
||||
## 2026-09-04 — Implementation started
|
||||
|
||||
- Approved implementation begins with the tested Gradle and Purpur foundation, followed by the Sorting Stick, inventory sorting, and automatic tool replacement.
|
||||
- Work proceeds one user story at a time using red-green-refactor cycles and a verified commit and push after each completed story.
|
||||
@@ -1,12 +0,0 @@
|
||||
---
|
||||
type: Index
|
||||
title: Spigot Inventory Helper User Stories
|
||||
description: Catalog of user stories for the Spigot Inventory Helper plugin.
|
||||
---
|
||||
|
||||
# Spigot Inventory Helper User Stories
|
||||
|
||||
1. [US-001: Build and release the plugin](us-001-build-and-release-plugin.md)
|
||||
2. [US-002: Craft a Sorting Stick](us-002-craft-sorting-stick.md)
|
||||
3. [US-003: Sort containers and player inventory](us-003-sort-inventories.md)
|
||||
4. [US-004: Automatically replace broken tools](us-004-replace-broken-tools.md)
|
||||
@@ -1,26 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-001: Build and release the plugin"
|
||||
description: Give maintainers repeatable Purpur builds, automated verification, and versioned Gitea releases.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-001: Build and release the plugin
|
||||
|
||||
As a **plugin maintainer**, I want repeatable builds and automated releases so that tested, correctly versioned artifacts can be distributed consistently.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] The Gradle project compiles against Purpur API `26.2.build.2618-stable` using a Java 25 toolchain.
|
||||
- [x] Compiler lint warnings fail the build.
|
||||
- [x] Automated JUnit 5 tests run as part of the Gradle check lifecycle.
|
||||
- [x] Pushes and pull requests build and test the plugin in Gitea Actions.
|
||||
- [x] Pull requests validate conventional commit messages.
|
||||
- [x] CI stores a development JAR as a workflow artifact.
|
||||
- [x] Main-branch conventional commits drive semantic versioning.
|
||||
- [x] A successful release builds a versioned JAR and attaches it to the corresponding Gitea release.
|
||||
- [x] Build files, Gradle wrapper, workflows, and release behavior follow `../spigot-base/` where applicable while using Spigot Inventory Helper names and identifiers.
|
||||
|
||||
## Related
|
||||
|
||||
- [User-story catalog](index.md)
|
||||
@@ -1,26 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-002: Craft a Sorting Stick"
|
||||
description: Let players craft a recognizable utility item that invokes inventory sorting.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-002: Craft a Sorting Stick
|
||||
|
||||
As a **player**, I want to craft a recognizable Sorting Stick so that I can invoke inventory sorting during normal gameplay.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] The plugin registers a vertical shaped recipe with a Chest or Barrel above Redstone Dust and Redstone Dust above a Stick.
|
||||
- [x] The three ingredients occupy the center column of the 3×3 crafting grid.
|
||||
- [x] Either the Chest or Barrel recipe variant produces one Sorting Stick.
|
||||
- [x] The Sorting Stick has a distinct player-facing name and appearance.
|
||||
- [x] The item is identified using persistent item metadata rather than its display name alone.
|
||||
- [x] Renamed ordinary sticks cannot trigger sorting.
|
||||
- [x] Automated tests cover recipe registration, the exact shape, both storage-block variants, output quantity, and resulting item identity.
|
||||
- [x] The crafting recipe and both accepted variants are documented for players.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-003: Sort containers and player inventory](us-003-sort-inventories.md)
|
||||
- [User-story catalog](index.md)
|
||||
@@ -1,30 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-003: Sort containers and player inventory"
|
||||
description: Let players use a Sorting Stick to consolidate and organize supported storage or their main inventory.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-003: Sort containers and player inventory
|
||||
|
||||
As a **player holding a Sorting Stick**, I want to sort storage containers or my main inventory so that related items are consolidated and organized automatically.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Using the Sorting Stick on a chest sorts the complete chest inventory.
|
||||
- [x] Using it on either half of a double chest sorts the combined inventory.
|
||||
- [x] Using it on a barrel sorts that barrel.
|
||||
- [x] Using it without targeting a supported container sorts the player's main storage slots.
|
||||
- [x] Player hotbar, armor, and off-hand slots are not changed during player-inventory sorting.
|
||||
- [x] Compatible partial stacks are consolidated without exceeding item stack limits.
|
||||
- [x] Remaining stacks are placed in a deterministic, documented order.
|
||||
- [x] Sorting preserves item quantities and all item metadata exactly.
|
||||
- [x] Sorting respects cancelled interactions and normal container-access protections.
|
||||
- [x] Unsupported or inaccessible targets fail safely without changing an inventory.
|
||||
- [x] Sorting provides concise player feedback without opening the target container.
|
||||
- [x] Automated tests cover single chests, double chests, barrels, player inventory boundaries, stack consolidation, metadata preservation, and rejected interactions.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-002: Craft a Sorting Stick](us-002-craft-sorting-stick.md)
|
||||
- [User-story catalog](index.md)
|
||||
@@ -1,26 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-004: Automatically replace broken tools"
|
||||
description: Move an equivalent inventory tool into the player's hand when their current main-hand tool breaks.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-004: Automatically replace broken tools
|
||||
|
||||
As a **player**, I want an equivalent tool moved into my hand when my current tool breaks so that I can continue working without searching my inventory.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] When a player's main-hand tool breaks, the plugin searches the player's inventory for a replacement.
|
||||
- [x] A replacement must have the same tool type and material as the broken tool; for example, a diamond pickaxe replaces only a diamond pickaxe.
|
||||
- [x] Eligible replacements may come from the main storage inventory or another hotbar slot.
|
||||
- [x] The replacement is moved into the currently selected hotbar slot.
|
||||
- [x] Existing durability, enchantments, custom names, persistent data, and other metadata on the replacement are preserved.
|
||||
- [x] No unrelated item is moved when an equivalent replacement is unavailable.
|
||||
- [x] Off-hand tool breakage does not replace the main-hand item.
|
||||
- [x] Cancelled damage or break events do not trigger replacement.
|
||||
- [x] Automated tests cover matching, unavailable replacements, hotbar and main-storage candidates, metadata preservation, and off-hand behavior.
|
||||
|
||||
## Related
|
||||
|
||||
- [User-story catalog](index.md)
|
||||
+1
-1
@@ -2,4 +2,4 @@ plugins {
|
||||
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
|
||||
}
|
||||
|
||||
rootProject.name = "spigot-inventory-helper"
|
||||
rootProject.name = "purpur-inventory-helper"
|
||||
@@ -0,0 +1,70 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.ShapedRecipe;
|
||||
import org.bukkit.inventory.recipe.CraftingBookCategory;
|
||||
|
||||
/** Independently owns the fixed, native String-to-Cobweb crafting recipe. */
|
||||
public final class CobwebRecipeRegistrar implements RecipeRegistrar {
|
||||
private final NamespacedKey recipeKey;
|
||||
private Server registeredServer;
|
||||
private boolean owned;
|
||||
|
||||
public CobwebRecipeRegistrar(NamespacedKey recipeKey) {
|
||||
this.recipeKey = Objects.requireNonNull(recipeKey, "recipeKey");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean register(Server server) {
|
||||
Objects.requireNonNull(server, "server");
|
||||
if (owned) {
|
||||
return true;
|
||||
}
|
||||
registeredServer = server;
|
||||
if (server.getRecipe(recipeKey) != null) {
|
||||
return false;
|
||||
}
|
||||
ShapedRecipe recipe = new ShapedRecipe(recipeKey, new ItemStack(Material.COBWEB));
|
||||
recipe.shape("S S", " S ", "S S");
|
||||
recipe.setIngredient('S', Material.STRING);
|
||||
recipe.setCategory(CraftingBookCategory.BUILDING);
|
||||
try {
|
||||
owned = server.addRecipe(recipe);
|
||||
return owned;
|
||||
} catch (RuntimeException exception) {
|
||||
try {
|
||||
owned = server.getRecipe(recipeKey) != null;
|
||||
unregister();
|
||||
} catch (RuntimeException cleanupFailure) {
|
||||
exception.addSuppressed(cleanupFailure);
|
||||
}
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<NamespacedKey> recipeKeys() {
|
||||
return List.of(recipeKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void unregister() {
|
||||
if (!owned || registeredServer == null) {
|
||||
return;
|
||||
}
|
||||
if (!registeredServer.removeRecipe(recipeKey)) {
|
||||
throw new IllegalStateException("Could not remove owned recipe " + recipeKey);
|
||||
}
|
||||
owned = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "Cobweb crafting recipe";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.ShapelessRecipe;
|
||||
import org.bukkit.inventory.recipe.CraftingBookCategory;
|
||||
|
||||
public final class DispenserRecipeRegistrar implements RecipeRegistrar {
|
||||
private final NamespacedKey recipeKey;
|
||||
private final Supplier<ItemStack> resultFactory;
|
||||
private final RecipeFactory recipeFactory;
|
||||
private Server registeredServer;
|
||||
private boolean owned;
|
||||
|
||||
public DispenserRecipeRegistrar(NamespacedKey recipeKey) {
|
||||
this(recipeKey, () -> new ItemStack(Material.DISPENSER), ShapelessRecipe::new);
|
||||
}
|
||||
|
||||
DispenserRecipeRegistrar(
|
||||
NamespacedKey recipeKey,
|
||||
Supplier<ItemStack> resultFactory,
|
||||
RecipeFactory recipeFactory
|
||||
) {
|
||||
this.recipeKey = Objects.requireNonNull(recipeKey, "recipeKey");
|
||||
this.resultFactory = Objects.requireNonNull(resultFactory, "resultFactory");
|
||||
this.recipeFactory = Objects.requireNonNull(recipeFactory, "recipeFactory");
|
||||
}
|
||||
|
||||
public boolean register(Server server) {
|
||||
Objects.requireNonNull(server, "server");
|
||||
if (owned) {
|
||||
return true;
|
||||
}
|
||||
registeredServer = server;
|
||||
if (server.getRecipe(recipeKey) != null) {
|
||||
return false;
|
||||
}
|
||||
ItemStack result = resultFactory.get();
|
||||
ShapelessRecipe recipe = recipeFactory.create(recipeKey, result);
|
||||
recipe.addIngredient(Material.DROPPER);
|
||||
recipe.addIngredient(Material.TRIPWIRE_HOOK);
|
||||
recipe.setCategory(CraftingBookCategory.REDSTONE);
|
||||
try {
|
||||
owned = server.addRecipe(recipe);
|
||||
return owned;
|
||||
} catch (RuntimeException exception) {
|
||||
try {
|
||||
owned = server.getRecipe(recipeKey) != null;
|
||||
unregister();
|
||||
} catch (RuntimeException cleanupFailure) {
|
||||
exception.addSuppressed(cleanupFailure);
|
||||
}
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<NamespacedKey> recipeKeys() {
|
||||
return List.of(recipeKey);
|
||||
}
|
||||
|
||||
public void unregister() {
|
||||
if (!owned || registeredServer == null) {
|
||||
return;
|
||||
}
|
||||
if (!registeredServer.removeRecipe(recipeKey)) {
|
||||
throw new IllegalStateException("Could not remove owned recipe " + recipeKey);
|
||||
}
|
||||
owned = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "alternative Dispenser recipe";
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface RecipeFactory {
|
||||
ShapelessRecipe create(NamespacedKey key, ItemStack result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
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 String description;
|
||||
private final Logger logger;
|
||||
|
||||
public RecipeDiscovery(List<NamespacedKey> recipeKeys, Logger logger) {
|
||||
this(recipeKeys, "recipes", logger);
|
||||
}
|
||||
|
||||
public RecipeDiscovery(List<NamespacedKey> recipeKeys, String description, Logger logger) {
|
||||
this.recipeKeys = List.copyOf(recipeKeys);
|
||||
this.description = Objects.requireNonNull(description, "description");
|
||||
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 " + description + " for "
|
||||
+ player.getName() + ": " + stillUnknown);
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
logger.log(Level.WARNING,
|
||||
"Could not discover " + description + " for " + player.getName(), exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
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 RecipeRegistrar recipes;
|
||||
private final Logger logger;
|
||||
private RecipeDiscovery discovery;
|
||||
|
||||
public RecipeLifecycle(
|
||||
Server server,
|
||||
Plugin plugin,
|
||||
RecipeRegistrar 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(server)) {
|
||||
logger.severe(recipes.description()
|
||||
+ " were not registered; discovery is disabled.");
|
||||
return false;
|
||||
}
|
||||
List<? extends Player> onlinePlayers = List.copyOf(server.getOnlinePlayers());
|
||||
discovery = new RecipeDiscovery(
|
||||
recipes.recipeKeys(), recipes.description(), 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,
|
||||
recipes.description() + " were not registered; discovery is disabled.",
|
||||
exception);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void disable() {
|
||||
unregisterDiscovery();
|
||||
recipes.unregister();
|
||||
}
|
||||
|
||||
private void unregisterDiscovery() {
|
||||
if (discovery != null) {
|
||||
HandlerList.unregisterAll(discovery);
|
||||
discovery = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public final class RecipeLifecycleGroup {
|
||||
private final List<RecipeLifecycle> lifecycles;
|
||||
|
||||
public RecipeLifecycleGroup(List<RecipeLifecycle> lifecycles) {
|
||||
this.lifecycles = List.copyOf(lifecycles);
|
||||
}
|
||||
|
||||
public void enableAll() {
|
||||
lifecycles.forEach(RecipeLifecycle::enable);
|
||||
}
|
||||
|
||||
public void disableAll() {
|
||||
RuntimeException failure = null;
|
||||
for (int index = lifecycles.size() - 1; index >= 0; index--) {
|
||||
try {
|
||||
lifecycles.get(index).disable();
|
||||
} catch (RuntimeException exception) {
|
||||
if (failure == null) {
|
||||
failure = exception;
|
||||
} else {
|
||||
failure.addSuppressed(exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (failure != null) {
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import java.util.List;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Server;
|
||||
|
||||
public interface RecipeRegistrar {
|
||||
boolean register(Server server);
|
||||
|
||||
List<NamespacedKey> recipeKeys();
|
||||
|
||||
void unregister();
|
||||
|
||||
String description();
|
||||
}
|
||||
@@ -1,18 +1,23 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.ShapedRecipe;
|
||||
import org.bukkit.inventory.recipe.CraftingBookCategory;
|
||||
|
||||
public final class SortingStickRecipeRegistrar {
|
||||
public final class SortingStickRecipeRegistrar implements RecipeRegistrar {
|
||||
private final NamespacedKey recipeKey;
|
||||
private final SortingStickItemFactory itemFactory;
|
||||
private final SortingStickRecipeSpec recipeSpec;
|
||||
private final RecipeFactory recipeFactory;
|
||||
private final List<NamespacedKey> ownedRecipeKeys = new ArrayList<>();
|
||||
private Server registeredServer;
|
||||
|
||||
public SortingStickRecipeRegistrar(
|
||||
NamespacedKey recipeKey,
|
||||
@@ -36,29 +41,100 @@ public final class SortingStickRecipeRegistrar {
|
||||
|
||||
public boolean register(Server server) {
|
||||
Objects.requireNonNull(server, "server");
|
||||
boolean registered = true;
|
||||
if (!ownedRecipeKeys.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
registeredServer = server;
|
||||
for (Material storage : List.of(Material.CHEST, Material.BARREL)) {
|
||||
ShapedRecipe recipe = createRecipe(storage);
|
||||
registered = server.addRecipe(recipe) && registered;
|
||||
NamespacedKey key = variantKey(storage);
|
||||
if (server.getRecipe(key) != null) {
|
||||
rollback();
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if (!server.addRecipe(recipe)) {
|
||||
rollback();
|
||||
return false;
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
try {
|
||||
if (server.getRecipe(key) != null) {
|
||||
ownedRecipeKeys.add(key);
|
||||
}
|
||||
rollback();
|
||||
} catch (RuntimeException cleanupFailure) {
|
||||
exception.addSuppressed(cleanupFailure);
|
||||
}
|
||||
throw exception;
|
||||
}
|
||||
ownedRecipeKeys.add(key);
|
||||
}
|
||||
return registered;
|
||||
return true;
|
||||
}
|
||||
|
||||
public List<NamespacedKey> recipeKeys() {
|
||||
return List.of(variantKey(Material.CHEST), variantKey(Material.BARREL));
|
||||
}
|
||||
|
||||
public void unregister() {
|
||||
rollback();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "Sorting Stick recipes";
|
||||
}
|
||||
|
||||
private void rollback() {
|
||||
if (registeredServer == null) {
|
||||
return;
|
||||
}
|
||||
RuntimeException failure = null;
|
||||
for (NamespacedKey key : List.copyOf(ownedRecipeKeys)) {
|
||||
try {
|
||||
if (registeredServer.removeRecipe(key)) {
|
||||
ownedRecipeKeys.remove(key);
|
||||
} else {
|
||||
failure = addFailure(failure,
|
||||
new IllegalStateException("Could not remove owned recipe " + key));
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
failure = addFailure(failure, exception);
|
||||
}
|
||||
}
|
||||
if (failure != null) {
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
private static RuntimeException addFailure(RuntimeException current, RuntimeException next) {
|
||||
if (current == null) {
|
||||
return next;
|
||||
}
|
||||
current.addSuppressed(next);
|
||||
return current;
|
||||
}
|
||||
|
||||
private ShapedRecipe createRecipe(Material storage) {
|
||||
ItemStack result = itemFactory.create();
|
||||
result.setAmount(recipeSpec.outputAmount());
|
||||
NamespacedKey variantKey = new NamespacedKey(
|
||||
recipeKey.getNamespace(),
|
||||
recipeKey.getKey() + "_" + storage.name().toLowerCase(java.util.Locale.ROOT)
|
||||
);
|
||||
ShapedRecipe recipe = recipeFactory.create(variantKey, result);
|
||||
ShapedRecipe recipe = recipeFactory.create(variantKey(storage), result);
|
||||
recipe.shape(recipeSpec.shape().toArray(String[]::new));
|
||||
recipe.setIngredient('C', storage);
|
||||
recipe.setIngredient('R', Material.REDSTONE);
|
||||
recipe.setIngredient('S', Material.STICK);
|
||||
recipe.setCategory(CraftingBookCategory.EQUIPMENT);
|
||||
return recipe;
|
||||
}
|
||||
|
||||
private NamespacedKey variantKey(Material storage) {
|
||||
return new NamespacedKey(
|
||||
recipeKey.getNamespace(),
|
||||
recipeKey.getKey() + "_" + storage.name().toLowerCase(Locale.ROOT)
|
||||
);
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface RecipeFactory {
|
||||
ShapedRecipe create(NamespacedKey key, ItemStack result);
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.List;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.plugin.PluginDescriptionFile;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.bukkit.plugin.java.JavaPluginLoader;
|
||||
|
||||
public final class SpigotInventoryHelperPlugin extends JavaPlugin {
|
||||
private SortingStickItemFactory sortingSticks;
|
||||
private RecipeLifecycleGroup recipeLifecycles;
|
||||
|
||||
public SpigotInventoryHelperPlugin() {}
|
||||
|
||||
// Bukkit's explicit initialization path keeps native tests on the real enable callback.
|
||||
SpigotInventoryHelperPlugin(
|
||||
JavaPluginLoader loader, PluginDescriptionFile description, File dataFolder, File file
|
||||
) {
|
||||
super(loader, description, dataFolder, file);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
@@ -14,9 +28,23 @@ public final class SpigotInventoryHelperPlugin extends JavaPlugin {
|
||||
sortingSticks,
|
||||
SortingStickRecipeSpec.defaultRecipe()
|
||||
);
|
||||
if (!recipes.register(getServer())) {
|
||||
getLogger().warning("One or more Sorting Stick recipes could not be registered.");
|
||||
}
|
||||
RecipeLifecycle sortingRecipes = new RecipeLifecycle(
|
||||
getServer(), this, recipes, getLogger());
|
||||
RecipeLifecycle dispenserRecipe = new RecipeLifecycle(
|
||||
getServer(),
|
||||
this,
|
||||
new DispenserRecipeRegistrar(new NamespacedKey(this, "dropper_dispenser")),
|
||||
getLogger()
|
||||
);
|
||||
RecipeLifecycle cobwebRecipe = new RecipeLifecycle(
|
||||
getServer(),
|
||||
this,
|
||||
new CobwebRecipeRegistrar(new NamespacedKey(this, "string_cobweb")),
|
||||
getLogger()
|
||||
);
|
||||
recipeLifecycles = new RecipeLifecycleGroup(
|
||||
List.of(sortingRecipes, dispenserRecipe, cobwebRecipe));
|
||||
recipeLifecycles.enableAll();
|
||||
InventorySortingService sorting = new InventorySortingService(new InventorySorter());
|
||||
getServer().getPluginManager().registerEvents(
|
||||
new SortingStickListener(sortingSticks, sorting),
|
||||
@@ -29,6 +57,14 @@ public final class SpigotInventoryHelperPlugin extends JavaPlugin {
|
||||
getLogger().info("Spigot Inventory Helper enabled.");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
if (recipeLifecycles != null) {
|
||||
recipeLifecycles.disableAll();
|
||||
recipeLifecycles = null;
|
||||
}
|
||||
}
|
||||
|
||||
SortingStickItemFactory sortingSticks() {
|
||||
if (sortingSticks == null) {
|
||||
throw new IllegalStateException("Sorting Stick service is unavailable");
|
||||
|
||||
+309
@@ -0,0 +1,309 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
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.reset;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import io.papermc.paper.plugin.manager.PaperPluginManagerImpl;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.logging.Handler;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.LogRecord;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Keyed;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.command.SimpleCommandMap;
|
||||
import org.bukkit.craftbukkit.inventory.CraftItemFactory;
|
||||
import org.bukkit.craftbukkit.util.CraftMagicNumbers;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.HandlerList;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.bukkit.inventory.Recipe;
|
||||
import org.bukkit.plugin.InvalidDescriptionException;
|
||||
import org.bukkit.plugin.PluginDescriptionFile;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import org.bukkit.plugin.java.JavaPluginLoader;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/** Real enable/disable callbacks using Bukkit's explicit initialization and existing native fixtures. */
|
||||
class NativeCobwebPluginWiringTest {
|
||||
private static final NamespacedKey COBWEB = key("string_cobweb");
|
||||
private static final Set<NamespacedKey> EXISTING = Set.of(key("dropper_dispenser"),
|
||||
key("sorting_stick_recipe_chest"), key("sorting_stick_recipe_barrel"));
|
||||
private static final NamespacedKey VANILLA = new NamespacedKey("minecraft", "stick");
|
||||
private static Server previousServer;
|
||||
private static Server serverUnderTest;
|
||||
@TempDir Path directory;
|
||||
private Map<NamespacedKey, Recipe> registry;
|
||||
private PaperPluginManagerImpl events;
|
||||
private Logger logger;
|
||||
private List<LogRecord> logs;
|
||||
private Handler logHandler;
|
||||
private SpigotInventoryHelperPlugin createdPlugin;
|
||||
|
||||
@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);
|
||||
logger = Logger.getAnonymousLogger();
|
||||
logs = new ArrayList<>();
|
||||
logHandler = new Handler() {
|
||||
@Override public void publish(LogRecord record) { logs.add(record); }
|
||||
@Override public void flush() {}
|
||||
@Override public void close() {}
|
||||
};
|
||||
logger.addHandler(logHandler);
|
||||
when(serverUnderTest.getLogger()).thenReturn(logger);
|
||||
registry = new HashMap<>();
|
||||
registry.put(VANILLA, mock(Recipe.class));
|
||||
when(serverUnderTest.getRecipe(any(NamespacedKey.class))).thenAnswer(
|
||||
invocation -> registry.get(invocation.getArgument(0)));
|
||||
when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
Recipe recipe = invocation.getArgument(0);
|
||||
return registry.putIfAbsent(((Keyed) recipe).getKey(), recipe) == null;
|
||||
});
|
||||
when(serverUnderTest.removeRecipe(any(NamespacedKey.class))).thenAnswer(
|
||||
invocation -> registry.remove(invocation.getArgument(0)) != null);
|
||||
events = new PaperPluginManagerImpl(serverUnderTest,
|
||||
new SimpleCommandMap(serverUnderTest, new HashMap<>()), null);
|
||||
when(serverUnderTest.getPluginManager()).thenReturn(events);
|
||||
doReturn(List.of()).when(serverUnderTest).getOnlinePlayers();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void removeLogHandler() {
|
||||
logger.removeHandler(logHandler);
|
||||
if (createdPlugin != null) {
|
||||
createdPlugin.getLogger().removeHandler(logHandler);
|
||||
}
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void restoreServer() throws Exception {
|
||||
setServer(previousServer);
|
||||
}
|
||||
|
||||
@Test
|
||||
void actualEnableDiscoversOnlineAndJoinersIdempotentlyAndReenablesWithoutLosingKnowledge() {
|
||||
Set<NamespacedKey> onlineKnown = new LinkedHashSet<>(Set.of(VANILLA));
|
||||
Set<NamespacedKey> joiningKnown = new LinkedHashSet<>(Set.of(VANILLA));
|
||||
AtomicInteger onlineGrants = new AtomicInteger();
|
||||
AtomicInteger joiningGrants = new AtomicInteger();
|
||||
Player online = player("Online", onlineKnown, onlineGrants, new AtomicBoolean());
|
||||
Player joining = player("Joining", joiningKnown, joiningGrants, new AtomicBoolean());
|
||||
doReturn(List.of(online)).when(serverUnderTest).getOnlinePlayers();
|
||||
Recipe vanilla = registry.get(VANILLA);
|
||||
var plugin = plugin();
|
||||
try {
|
||||
plugin.setEnabled(true);
|
||||
assertTrue(registry.keySet().containsAll(EXISTING));
|
||||
assertTrue(registry.containsKey(COBWEB));
|
||||
assertEquals(expectedKnown(), onlineKnown);
|
||||
plugin.setEnabled(true);
|
||||
events.callEvent(new PlayerJoinEvent(joining, "joined"));
|
||||
events.callEvent(new PlayerJoinEvent(joining, "joined again"));
|
||||
assertEquals(expectedKnown(), joiningKnown);
|
||||
assertEquals(1, onlineGrants.get());
|
||||
assertEquals(1, joiningGrants.get());
|
||||
|
||||
plugin.setEnabled(false);
|
||||
assertEquals(Set.of(VANILLA), registry.keySet());
|
||||
assertSame(vanilla, registry.get(VANILLA));
|
||||
assertEquals(expectedKnown(), onlineKnown);
|
||||
assertEquals(expectedKnown(), joiningKnown);
|
||||
Set<NamespacedKey> afterDisable = new LinkedHashSet<>();
|
||||
events.callEvent(new PlayerJoinEvent(player("AfterDisable", afterDisable,
|
||||
new AtomicInteger(), new AtomicBoolean()), "joined"));
|
||||
assertTrue(afterDisable.isEmpty(), "Disabled listeners must not award recipes");
|
||||
|
||||
plugin.setEnabled(true);
|
||||
assertTrue(registry.keySet().containsAll(EXISTING));
|
||||
assertTrue(registry.containsKey(COBWEB));
|
||||
assertEquals(1, onlineGrants.get(), "Re-enable must not rediscover a known recipe");
|
||||
assertNoSevereLogs();
|
||||
} finally {
|
||||
plugin.setEnabled(false);
|
||||
HandlerList.unregisterAll(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void cobwebKeyConflictPreservesForeignRecipeAndExistingFeaturesWithoutDiscovery() {
|
||||
Recipe conflict = mock(Recipe.class);
|
||||
registry.put(COBWEB, conflict);
|
||||
Set<NamespacedKey> known = new LinkedHashSet<>(Set.of(VANILLA));
|
||||
AtomicInteger grants = new AtomicInteger();
|
||||
doReturn(List.of(player("Online", known, grants, new AtomicBoolean())))
|
||||
.when(serverUnderTest).getOnlinePlayers();
|
||||
var plugin = plugin();
|
||||
try {
|
||||
plugin.setEnabled(true);
|
||||
assertSame(conflict, registry.get(COBWEB));
|
||||
assertTrue(registry.keySet().containsAll(EXISTING));
|
||||
assertTrue(known.containsAll(EXISTING));
|
||||
assertFalse(known.contains(COBWEB));
|
||||
assertEquals(0, grants.get());
|
||||
plugin.setEnabled(false);
|
||||
assertEquals(Set.of(VANILLA, COBWEB), registry.keySet());
|
||||
assertSame(conflict, registry.get(COBWEB));
|
||||
|
||||
registry.remove(COBWEB);
|
||||
plugin.setEnabled(true);
|
||||
assertTrue(registry.containsKey(COBWEB));
|
||||
assertTrue(known.contains(COBWEB));
|
||||
assertEquals(1, grants.get());
|
||||
assertEquals(1, logs.stream().filter(r -> r.getLevel() == Level.SEVERE).count(),
|
||||
"Only the deliberately injected key conflict is severe");
|
||||
} finally {
|
||||
plugin.setEnabled(false);
|
||||
HandlerList.unregisterAll(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void onePlayersDiscoveryFailureDoesNotBlockOthersAndCanRetryOnJoin() {
|
||||
Set<NamespacedKey> failedKnown = new LinkedHashSet<>(Set.of(VANILLA));
|
||||
Set<NamespacedKey> healthyKnown = new LinkedHashSet<>(Set.of(VANILLA));
|
||||
AtomicBoolean fail = new AtomicBoolean(true);
|
||||
AtomicInteger failedGrants = new AtomicInteger();
|
||||
Player failed = player("Failed", failedKnown, failedGrants, fail);
|
||||
Player healthy = player("Healthy", healthyKnown, new AtomicInteger(), new AtomicBoolean());
|
||||
doReturn(List.of(failed, healthy)).when(serverUnderTest).getOnlinePlayers();
|
||||
var plugin = plugin();
|
||||
try {
|
||||
plugin.setEnabled(true);
|
||||
assertFalse(failedKnown.contains(COBWEB));
|
||||
assertEquals(expectedKnown(), healthyKnown);
|
||||
assertTrue(failedKnown.containsAll(EXISTING));
|
||||
fail.set(false);
|
||||
events.callEvent(new PlayerJoinEvent(failed, "retry"));
|
||||
assertEquals(expectedKnown(), failedKnown);
|
||||
assertEquals(1, failedGrants.get());
|
||||
assertNoSevereLogs();
|
||||
assertEquals(1, logs.stream().filter(r -> r.getLevel() == Level.WARNING).count());
|
||||
} finally {
|
||||
plugin.setEnabled(false);
|
||||
HandlerList.unregisterAll(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void partialDiscoveryListenerFailureRollsBackOnlyCobwebAndWithholdsJoinDiscovery() {
|
||||
PluginManager throwing = mock(PluginManager.class);
|
||||
AtomicInteger discoveries = new AtomicInteger();
|
||||
doAnswer(invocation -> {
|
||||
Listener listener = invocation.getArgument(0);
|
||||
org.bukkit.plugin.Plugin owner = invocation.getArgument(1);
|
||||
events.registerEvents(listener, owner);
|
||||
if (listener instanceof RecipeDiscovery && discoveries.incrementAndGet() == 3) {
|
||||
throw new IllegalStateException("simulated post-listener-registration failure");
|
||||
}
|
||||
return null;
|
||||
}).when(throwing).registerEvents(any(), any());
|
||||
when(serverUnderTest.getPluginManager()).thenReturn(throwing);
|
||||
var plugin = plugin();
|
||||
try {
|
||||
plugin.setEnabled(true);
|
||||
assertFalse(registry.containsKey(COBWEB));
|
||||
assertTrue(registry.keySet().containsAll(EXISTING));
|
||||
Set<NamespacedKey> known = new LinkedHashSet<>(Set.of(VANILLA));
|
||||
events.callEvent(new PlayerJoinEvent(player("Joining", known, new AtomicInteger(),
|
||||
new AtomicBoolean()), "joined"));
|
||||
assertTrue(known.containsAll(EXISTING));
|
||||
assertFalse(known.contains(COBWEB));
|
||||
assertEquals(1, logs.stream().filter(r -> r.getLevel() == Level.SEVERE).count(),
|
||||
"Only the deliberately injected listener failure is severe");
|
||||
} finally {
|
||||
plugin.setEnabled(false);
|
||||
HandlerList.unregisterAll(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
private SpigotInventoryHelperPlugin plugin() {
|
||||
try (var metadata = SpigotInventoryHelperPlugin.class.getResourceAsStream("/plugin.yml")) {
|
||||
createdPlugin = new SpigotInventoryHelperPlugin(new JavaPluginLoader(serverUnderTest),
|
||||
new PluginDescriptionFile(metadata), directory.resolve("data").toFile(),
|
||||
directory.resolve("plugin.jar").toFile());
|
||||
createdPlugin.getLogger().addHandler(logHandler);
|
||||
return createdPlugin;
|
||||
} catch (java.io.IOException | InvalidDescriptionException exception) {
|
||||
throw new AssertionError("Could not read generated plugin metadata", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static Player player(String name, Set<NamespacedKey> known,
|
||||
AtomicInteger cobwebGrants, AtomicBoolean failCobweb) {
|
||||
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();
|
||||
if (requested.contains(COBWEB)) {
|
||||
if (failCobweb.get()) {
|
||||
throw new IllegalStateException("simulated player discovery failure");
|
||||
}
|
||||
cobwebGrants.incrementAndGet();
|
||||
}
|
||||
known.addAll(requested);
|
||||
return requested.size();
|
||||
});
|
||||
return player;
|
||||
}
|
||||
|
||||
private void assertNoSevereLogs() {
|
||||
assertEquals(0, logs.stream().filter(r -> r.getLevel() == Level.SEVERE).count());
|
||||
}
|
||||
|
||||
private static Set<NamespacedKey> expectedKnown() {
|
||||
Set<NamespacedKey> expected = new LinkedHashSet<>(EXISTING);
|
||||
expected.add(COBWEB);
|
||||
expected.add(VANILLA);
|
||||
return expected;
|
||||
}
|
||||
|
||||
private static NamespacedKey key(String name) {
|
||||
return new NamespacedKey("spigotinventoryhelper", name);
|
||||
}
|
||||
|
||||
private static void setServer(Server server) throws Exception {
|
||||
var field = Bukkit.class.getDeclaredField("server");
|
||||
field.setAccessible(true);
|
||||
field.set(null, server);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
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.mock;
|
||||
import static org.mockito.Mockito.reset;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalInt;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Server;
|
||||
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.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.persistence.PersistentDataType;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
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.ValueSource;
|
||||
|
||||
/** Registered recipe -> native display/autofill -> ordinary or quick-move result completion. */
|
||||
class NativeCobwebRecipeTest {
|
||||
private static final NamespacedKey KEY = new NamespacedKey("spigotinventoryhelper", "string_cobweb");
|
||||
private static final List<Integer> STRING_SLOTS = List.of(0, 2, 4, 6, 8);
|
||||
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);
|
||||
when(serverUnderTest.getPluginManager()).thenReturn(mock(PluginManager.class));
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void restoreServer() throws Exception {
|
||||
setServer(previousServer);
|
||||
}
|
||||
|
||||
@Test
|
||||
void registersFiveStringXForOneOrdinaryCobwebInBuildingCategory() {
|
||||
ShapedRecipe recipe = registeredRecipe();
|
||||
assertEquals(KEY, recipe.getKey());
|
||||
assertEquals(List.of("S S", " S ", "S S"), List.of(recipe.getShape()));
|
||||
assertEquals(List.of(Material.STRING),
|
||||
((RecipeChoice.MaterialChoice) recipe.getChoiceMap().get('S')).getChoices());
|
||||
assertEquals(CraftingBookCategory.BUILDING, recipe.getCategory());
|
||||
assertOrdinaryCobweb(recipe.getResult(), 1);
|
||||
var nativeRecipe = nativeRecipe(recipe);
|
||||
assertEquals(net.minecraft.world.item.crafting.RecipeBookCategories.CRAFTING_BUILDING_BLOCKS,
|
||||
nativeRecipe.recipeBookCategory());
|
||||
var display = (net.minecraft.world.item.crafting.display.ShapedCraftingRecipeDisplay)
|
||||
nativeRecipe.display().getFirst();
|
||||
assertEquals(3, display.width());
|
||||
assertEquals(3, display.height());
|
||||
for (int slot = 0; slot < 9; slot++) {
|
||||
if (STRING_SLOTS.contains(slot)) {
|
||||
var expected = CraftRecipe.toIngredient(
|
||||
new RecipeChoice.MaterialChoice(Material.STRING), false).display();
|
||||
assertEquals(expected, display.ingredients().get(slot));
|
||||
} else {
|
||||
assertEquals(net.minecraft.world.item.crafting.display.SlotDisplay.Empty.INSTANCE,
|
||||
display.ingredients().get(slot));
|
||||
}
|
||||
}
|
||||
var result = (net.minecraft.world.item.crafting.display.SlotDisplay.ItemStackSlotDisplay)
|
||||
display.result();
|
||||
assertOrdinaryCobweb(CraftItemStack.asBukkitCopy(result.stack().create()), 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void incorrectPatternsMissingIngredientsAndTwoByTwoCraftingNeverMatch() {
|
||||
var recipe = nativeRecipe(registeredRecipe());
|
||||
var level = mock(net.minecraft.world.level.Level.class);
|
||||
List<net.minecraft.world.item.ItemStack> valid = pattern();
|
||||
assertTrue(recipe.matches(input(valid), level));
|
||||
for (int slot = 0; slot < 9; slot++) {
|
||||
List<net.minecraft.world.item.ItemStack> wrong = new ArrayList<>(valid);
|
||||
wrong.set(slot, STRING_SLOTS.contains(slot) ? net.minecraft.world.item.ItemStack.EMPTY
|
||||
: CraftItemStack.asNMSCopy(new ItemStack(Material.STRING)));
|
||||
assertFalse(recipe.matches(input(wrong), level), "Wrong occupancy at slot " + slot);
|
||||
}
|
||||
List<net.minecraft.world.item.ItemStack> wrongMaterial = new ArrayList<>(valid);
|
||||
wrongMaterial.set(4, CraftItemStack.asNMSCopy(new ItemStack(Material.DIRT)));
|
||||
assertFalse(recipe.matches(input(wrongMaterial), level));
|
||||
assertFalse(recipe.matches(net.minecraft.world.item.crafting.CraftingInput.of(2, 2,
|
||||
java.util.stream.IntStream.range(0, 4).mapToObj(i ->
|
||||
CraftItemStack.asNMSCopy(new ItemStack(Material.STRING))).toList()), level));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ordinaryNativeResultSlotConsumesFiveStringAndPreservesRemaindersAndMetadata() {
|
||||
var recipe = nativeRecipe(registeredRecipe());
|
||||
var fixture = crafting();
|
||||
ItemStack unrelated = marked(Material.DIAMOND, 7);
|
||||
fixture.inventory().setItem(20, CraftItemStack.asNMSCopy(unrelated));
|
||||
for (int slot : STRING_SLOTS) {
|
||||
fixture.menu().craftSlots.setItem(slot,
|
||||
CraftItemStack.asNMSCopy(marked(Material.STRING, 2)));
|
||||
}
|
||||
var grid = fixture.menu().craftSlots;
|
||||
assertTrue(recipe.matches(nativeInput(grid), fixture.player().level()));
|
||||
List<ItemStack> before = grid.getItems().stream().map(CraftItemStack::asBukkitCopy).toList();
|
||||
var assembled = recipe.assemble(nativeInput(grid));
|
||||
assertOrdinaryCobweb(CraftItemStack.asBukkitCopy(assembled), 1);
|
||||
assertEquals(before, grid.getItems().stream().map(CraftItemStack::asBukkitCopy).toList(),
|
||||
"Preview must neither consume ingredients nor mutate metadata");
|
||||
setResult(fixture.menu(), recipe, assembled);
|
||||
var slot = fixture.menu().getResultSlot();
|
||||
var taken = slot.remove(1);
|
||||
slot.onTake(fixture.player(), taken);
|
||||
assertOrdinaryCobweb(CraftItemStack.asBukkitCopy(taken), 1);
|
||||
assertEquals(5, count(grid, Material.STRING));
|
||||
for (int index : STRING_SLOTS) {
|
||||
assertEquals(marked(Material.STRING, 1), CraftItemStack.asBukkitCopy(grid.getItem(index)));
|
||||
}
|
||||
assertEquals(unrelated, CraftItemStack.asBukkitCopy(fixture.inventory().getItem(20)));
|
||||
assertTrue(fixture.menu().resultSlots.isEmpty());
|
||||
assertEquals(0, count(fixture.inventory(), Material.COBWEB));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(ints = {1, 3, 64})
|
||||
void nativeAutofillAndQuickMoveBatchConsumeFiveStringPerCobweb(int crafts) {
|
||||
var recipe = nativeRecipe(registeredRecipe());
|
||||
var fixture = crafting();
|
||||
for (int i = 0; i < 5; i++) {
|
||||
fixture.inventory().setItem(i, CraftItemStack.asNMSCopy(new ItemStack(Material.STRING, crafts)));
|
||||
}
|
||||
ItemStack unrelated = marked(Material.DIAMOND, 7);
|
||||
fixture.inventory().setItem(20, CraftItemStack.asNMSCopy(unrelated));
|
||||
fixture.menu().craftSlots.setItem(0, CraftItemStack.asNMSCopy(new ItemStack(Material.DIRT, 2)));
|
||||
var action = autofill(fixture, recipe);
|
||||
assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.NOTHING, action);
|
||||
assertEquals(5L * crafts, count(fixture.menu().craftSlots, Material.STRING));
|
||||
assertEquals(0, count(fixture.inventory(), Material.COBWEB), "Autofill creates no output");
|
||||
assertEquals(2, count(fixture.inventory(), Material.DIRT), "Relocated input must be preserved");
|
||||
for (int attempt = 0; attempt < crafts; attempt++) {
|
||||
assertTrue(recipe.matches(nativeInput(fixture.menu().craftSlots), fixture.player().level()));
|
||||
setResult(fixture.menu(), recipe, recipe.assemble(nativeInput(fixture.menu().craftSlots)));
|
||||
var moved = fixture.menu().quickMoveStack(fixture.player(), 0);
|
||||
assertOrdinaryCobweb(CraftItemStack.asBukkitCopy(moved), 1);
|
||||
assertEquals(5L * (crafts - attempt - 1), count(fixture.menu().craftSlots, Material.STRING));
|
||||
}
|
||||
assertTrue(fixture.menu().craftSlots.isEmpty());
|
||||
assertEquals(crafts, count(fixture.inventory(), Material.COBWEB));
|
||||
assertEquals(unrelated, CraftItemStack.asBukkitCopy(fixture.inventory().getItem(20)));
|
||||
assertEquals(2, count(fixture.inventory(), Material.DIRT));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fullInventoryWithholdsQuickMoveOutputWithoutSpendingString() {
|
||||
var recipe = nativeRecipe(registeredRecipe());
|
||||
var fixture = crafting();
|
||||
for (int slot = 0; slot < 36; slot++) {
|
||||
fixture.inventory().setItem(slot, CraftItemStack.asNMSCopy(new ItemStack(Material.DIRT, 64)));
|
||||
}
|
||||
for (int slot : STRING_SLOTS) {
|
||||
fixture.menu().craftSlots.setItem(slot, CraftItemStack.asNMSCopy(new ItemStack(Material.STRING)));
|
||||
}
|
||||
setResult(fixture.menu(), recipe, recipe.assemble(nativeInput(fixture.menu().craftSlots)));
|
||||
assertTrue(fixture.menu().quickMoveStack(fixture.player(), 0).isEmpty());
|
||||
assertEquals(5, count(fixture.menu().craftSlots, Material.STRING));
|
||||
assertEquals(36L * 64, count(fixture.inventory(), Material.DIRT));
|
||||
assertEquals(0, count(fixture.inventory(), Material.COBWEB));
|
||||
assertOrdinaryCobweb(CraftItemStack.asBukkitCopy(fixture.menu().resultSlots.getItem(0)), 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void insufficientStringShowsGhostRecipeWithoutCreatingOutputOrSpendingItems() {
|
||||
var fixture = crafting();
|
||||
fixture.inventory().setItem(0, CraftItemStack.asNMSCopy(new ItemStack(Material.STRING, 4)));
|
||||
var action = autofill(fixture, nativeRecipe(registeredRecipe()));
|
||||
assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.PLACE_GHOST_RECIPE, action);
|
||||
assertEquals(4, count(fixture.inventory(), Material.STRING));
|
||||
assertEquals(0, count(fixture.inventory(), Material.COBWEB));
|
||||
assertTrue(fixture.menu().craftSlots.isEmpty());
|
||||
assertTrue(fixture.menu().resultSlots.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nativeDiscoveryCancellationWithholdsKnowledgeAndLaterRetryWorks() {
|
||||
var recipe = nativeRecipe(registeredRecipe());
|
||||
var holder = new net.minecraft.world.item.crafting.RecipeHolder<>(nativeKey(), recipe);
|
||||
var book = new TestRecipeBook((key, consumer) -> consumer.accept(
|
||||
new net.minecraft.world.item.crafting.display.RecipeDisplayEntry(
|
||||
new net.minecraft.world.item.crafting.display.RecipeDisplayId(0),
|
||||
recipe.display().getFirst(), OptionalInt.empty(), recipe.recipeBookCategory(),
|
||||
Optional.of(recipe.placementInfo().ingredients()))));
|
||||
var player = mock(net.minecraft.server.level.ServerPlayer.class);
|
||||
when(player.getBukkitEntity()).thenReturn(mock(org.bukkit.craftbukkit.entity.CraftPlayer.class));
|
||||
when(player.getAdvancements()).thenReturn(mock(net.minecraft.server.PlayerAdvancements.class));
|
||||
PluginManager events = mock(PluginManager.class);
|
||||
when(serverUnderTest.getPluginManager()).thenReturn(events);
|
||||
AtomicBoolean cancel = new AtomicBoolean(true);
|
||||
doAnswer(invocation -> {
|
||||
var event = (org.bukkit.event.player.PlayerRecipeDiscoverEvent) invocation.getArgument(0);
|
||||
event.setCancelled(cancel.get());
|
||||
return null;
|
||||
}).when(events).callEvent(any(org.bukkit.event.player.PlayerRecipeDiscoverEvent.class));
|
||||
assertEquals(0, book.addRecipes(List.of(holder), player));
|
||||
assertFalse(book.contains(nativeKey()));
|
||||
cancel.set(false);
|
||||
assertEquals(1, book.addRecipes(List.of(holder), player));
|
||||
assertEquals(0, book.addRecipes(List.of(holder), player));
|
||||
assertTrue(book.contains(nativeKey()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void registrationExceptionAfterInsertionRemovesOnlyTheOwnedCobwebRecipe() {
|
||||
Map<NamespacedKey, Recipe> registry = new HashMap<>();
|
||||
NamespacedKey foreign = new NamespacedKey("minecraft", "stick");
|
||||
Recipe unrelated = mock(Recipe.class);
|
||||
registry.put(foreign, unrelated);
|
||||
when(serverUnderTest.getRecipe(any(NamespacedKey.class))).thenAnswer(
|
||||
invocation -> registry.get(invocation.getArgument(0)));
|
||||
when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
registry.put(KEY, invocation.getArgument(0));
|
||||
throw new IllegalStateException("simulated post-insertion failure");
|
||||
});
|
||||
when(serverUnderTest.removeRecipe(any(NamespacedKey.class))).thenAnswer(
|
||||
invocation -> registry.remove(invocation.getArgument(0)) != null);
|
||||
var registrar = new CobwebRecipeRegistrar(KEY);
|
||||
assertThrows(IllegalStateException.class, () -> registrar.register(serverUnderTest));
|
||||
registrar.unregister();
|
||||
assertEquals(Map.of(foreign, unrelated), registry);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedRegistrationDoesNotClaimOwnershipAndRemovalFailureCanRetry() {
|
||||
AtomicInteger registrations = new AtomicInteger();
|
||||
AtomicBoolean allow = new AtomicBoolean();
|
||||
when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
registrations.incrementAndGet();
|
||||
return allow.get();
|
||||
});
|
||||
var registrar = new CobwebRecipeRegistrar(KEY);
|
||||
assertFalse(registrar.register(serverUnderTest));
|
||||
registrar.unregister();
|
||||
allow.set(true);
|
||||
assertTrue(registrar.register(serverUnderTest));
|
||||
assertTrue(registrar.register(serverUnderTest));
|
||||
assertEquals(2, registrations.get(), "Owned registration must be idempotent");
|
||||
AtomicInteger removals = new AtomicInteger();
|
||||
when(serverUnderTest.removeRecipe(KEY)).thenAnswer(invocation -> removals.incrementAndGet() > 1);
|
||||
assertThrows(IllegalStateException.class, registrar::unregister);
|
||||
registrar.unregister();
|
||||
registrar.unregister();
|
||||
assertEquals(2, removals.get());
|
||||
}
|
||||
|
||||
private static ShapedRecipe registeredRecipe() {
|
||||
List<Recipe> registered = new ArrayList<>();
|
||||
when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
registered.add(invocation.getArgument(0));
|
||||
return true;
|
||||
});
|
||||
assertTrue(new CobwebRecipeRegistrar(KEY).register(serverUnderTest));
|
||||
assertEquals(1, registered.size());
|
||||
return (ShapedRecipe) registered.getFirst();
|
||||
}
|
||||
|
||||
private static net.minecraft.world.item.crafting.ShapedRecipe nativeRecipe(ShapedRecipe recipe) {
|
||||
Map<Character, net.minecraft.world.item.crafting.Ingredient> ingredients = new HashMap<>();
|
||||
recipe.getChoiceMap().forEach((symbol, choice) -> {
|
||||
if (choice != null) {
|
||||
ingredients.put(symbol, CraftRecipe.toIngredient(choice, false));
|
||||
}
|
||||
});
|
||||
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()),
|
||||
net.minecraft.world.item.crafting.ShapedRecipePattern.of(ingredients, recipe.getShape()),
|
||||
CraftItemStack.asTemplate(recipe.getResult()));
|
||||
}
|
||||
|
||||
private static CraftingFixture crafting() {
|
||||
var player = mock(net.minecraft.world.entity.player.Player.class);
|
||||
when(player.level()).thenReturn(mock(net.minecraft.world.level.Level.class));
|
||||
when(player.getBukkitEntity()).thenReturn(mock(org.bukkit.craftbukkit.entity.CraftPlayer.class));
|
||||
var inventory = new net.minecraft.world.entity.player.Inventory(
|
||||
player, new net.minecraft.world.entity.EntityEquipment());
|
||||
when(player.getInventory()).thenReturn(inventory);
|
||||
return new CraftingFixture(player, inventory, new net.minecraft.world.inventory.CraftingMenu(0, inventory));
|
||||
}
|
||||
|
||||
private static net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction autofill(
|
||||
CraftingFixture fixture, net.minecraft.world.item.crafting.ShapedRecipe recipe) {
|
||||
var grid = fixture.menu().craftSlots;
|
||||
var slots = fixture.menu().getInputGridSlots();
|
||||
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(nativeInput(grid), fixture.player().level());
|
||||
}
|
||||
};
|
||||
return net.minecraft.recipebook.ServerPlaceRecipe.placeRecipe(access, 3, 3, slots, slots,
|
||||
fixture.inventory(), new net.minecraft.world.item.crafting.RecipeHolder<>(nativeKey(), recipe),
|
||||
true, false);
|
||||
}
|
||||
|
||||
private static void setResult(net.minecraft.world.inventory.CraftingMenu menu,
|
||||
net.minecraft.world.item.crafting.ShapedRecipe recipe, net.minecraft.world.item.ItemStack result) {
|
||||
var holder = new net.minecraft.world.item.crafting.RecipeHolder<
|
||||
net.minecraft.world.item.crafting.CraftingRecipe>(nativeKey(), recipe);
|
||||
menu.craftSlots.setCurrentRecipe(holder);
|
||||
menu.resultSlots.setRecipeUsed(holder);
|
||||
menu.resultSlots.setItem(0, result);
|
||||
}
|
||||
|
||||
private static List<net.minecraft.world.item.ItemStack> pattern() {
|
||||
return java.util.stream.IntStream.range(0, 9).mapToObj(slot -> STRING_SLOTS.contains(slot)
|
||||
? CraftItemStack.asNMSCopy(new ItemStack(Material.STRING))
|
||||
: net.minecraft.world.item.ItemStack.EMPTY).toList();
|
||||
}
|
||||
|
||||
private static net.minecraft.world.item.crafting.CraftingInput nativeInput(net.minecraft.world.Container grid) {
|
||||
return input(java.util.stream.IntStream.range(0, grid.getContainerSize())
|
||||
.mapToObj(grid::getItem).toList());
|
||||
}
|
||||
|
||||
private static net.minecraft.world.item.crafting.CraftingInput input(List<net.minecraft.world.item.ItemStack> items) {
|
||||
return net.minecraft.world.item.crafting.CraftingInput.of(3, 3, items);
|
||||
}
|
||||
|
||||
private static long count(net.minecraft.world.Container container, Material material) {
|
||||
return java.util.stream.IntStream.range(0, container.getContainerSize()).mapToObj(container::getItem)
|
||||
.map(CraftItemStack::asBukkitCopy).filter(item -> item.getType() == material)
|
||||
.mapToInt(ItemStack::getAmount).sum();
|
||||
}
|
||||
|
||||
private static ItemStack marked(Material material, int amount) {
|
||||
ItemStack item = new ItemStack(material, amount);
|
||||
var meta = item.getItemMeta();
|
||||
meta.displayName(Component.text("Keep metadata"));
|
||||
meta.getPersistentDataContainer().set(new NamespacedKey("acceptance", "keep"),
|
||||
PersistentDataType.STRING, "unchanged");
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
}
|
||||
|
||||
private static void assertOrdinaryCobweb(ItemStack item, int amount) {
|
||||
assertEquals(Material.COBWEB, item.getType());
|
||||
assertEquals(amount, item.getAmount());
|
||||
assertFalse(item.hasItemMeta());
|
||||
}
|
||||
|
||||
private static net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>> nativeKey() {
|
||||
return net.minecraft.resources.ResourceKey.create(net.minecraft.core.registries.Registries.RECIPE,
|
||||
net.minecraft.resources.Identifier.fromNamespaceAndPath(KEY.getNamespace(), KEY.getKey()));
|
||||
}
|
||||
|
||||
private static void setServer(Server server) throws Exception {
|
||||
var field = Bukkit.class.getDeclaredField("server");
|
||||
field.setAccessible(true);
|
||||
field.set(null, server);
|
||||
}
|
||||
|
||||
private static final class TestRecipeBook extends net.minecraft.stats.ServerRecipeBook {
|
||||
TestRecipeBook(net.minecraft.stats.ServerRecipeBook.DisplayResolver resolver) { super(resolver); }
|
||||
}
|
||||
|
||||
private record CraftingFixture(net.minecraft.world.entity.player.Player player,
|
||||
net.minecraft.world.entity.player.Inventory inventory, net.minecraft.world.inventory.CraftingMenu menu) {}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.reset;
|
||||
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.Map;
|
||||
import java.util.Set;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Keyed;
|
||||
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.inventory.ItemStack;
|
||||
import org.bukkit.inventory.Recipe;
|
||||
import org.bukkit.inventory.ShapelessRecipe;
|
||||
import org.bukkit.inventory.recipe.CraftingBookCategory;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.bukkit.plugin.PluginDescriptionFile;
|
||||
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 coverage for the alternative Dispenser's registered native path. */
|
||||
class NativeDispenserRecipeTest {
|
||||
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);
|
||||
}
|
||||
|
||||
@Test
|
||||
void registeredRecipeDiscoversIndependentlyAndPreservesVanillaDispenserRecipe() {
|
||||
NamespacedKey key = new NamespacedKey("spigotinventoryhelper", "dropper_dispenser");
|
||||
NamespacedKey vanilla = new NamespacedKey("minecraft", "dispenser");
|
||||
Map<NamespacedKey, Recipe> registry = new HashMap<>();
|
||||
Recipe vanillaRecipe = mock(Recipe.class);
|
||||
registry.put(vanilla, vanillaRecipe);
|
||||
when(serverUnderTest.getRecipe(any(NamespacedKey.class))).thenAnswer(
|
||||
invocation -> registry.get(invocation.getArgument(0)));
|
||||
when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
Recipe recipe = invocation.getArgument(0);
|
||||
return registry.putIfAbsent(((Keyed) recipe).getKey(), recipe) == null;
|
||||
});
|
||||
when(serverUnderTest.removeRecipe(any(NamespacedKey.class))).thenAnswer(
|
||||
invocation -> registry.remove(invocation.getArgument(0)) != null);
|
||||
var events = new PaperPluginManagerImpl(serverUnderTest,
|
||||
new SimpleCommandMap(serverUnderTest, new HashMap<>()), null);
|
||||
when(serverUnderTest.getPluginManager()).thenReturn(events);
|
||||
Set<NamespacedKey> known = new LinkedHashSet<>();
|
||||
doReturn(List.of(discoveryPlayer("Online", known))).when(serverUnderTest).getOnlinePlayers();
|
||||
Plugin plugin = enabledPlugin();
|
||||
DispenserRecipeRegistrar registrar = new DispenserRecipeRegistrar(key);
|
||||
RecipeLifecycle lifecycle = new RecipeLifecycle(
|
||||
serverUnderTest, plugin, registrar, plugin.getLogger());
|
||||
|
||||
assertTrue(lifecycle.enable());
|
||||
|
||||
ShapelessRecipe recipe = (ShapelessRecipe) registry.get(key);
|
||||
assertEquals(CraftingBookCategory.REDSTONE, recipe.getCategory());
|
||||
assertEquals(Material.DISPENSER, recipe.getResult().getType());
|
||||
assertEquals(1, recipe.getResult().getAmount());
|
||||
assertFalse(recipe.getResult().hasItemMeta());
|
||||
assertEquals(List.of(Material.DROPPER, Material.TRIPWIRE_HOOK),
|
||||
recipe.getChoiceList().stream()
|
||||
.map(org.bukkit.inventory.RecipeChoice.MaterialChoice.class::cast)
|
||||
.map(choice -> choice.getChoices().getFirst())
|
||||
.toList());
|
||||
assertEquals(Set.of(key), known);
|
||||
|
||||
lifecycle.disable();
|
||||
|
||||
assertEquals(Set.of(vanilla), registry.keySet());
|
||||
assertEquals(vanillaRecipe, registry.get(vanilla));
|
||||
assertEquals(Set.of(key), known, "Disable must retain native recipe-book knowledge");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nativeAutofillAndRepeatedCompletionConsumeOnePairPerDispenser() {
|
||||
when(serverUnderTest.getPluginManager()).thenReturn(mock(org.bukkit.plugin.PluginManager.class));
|
||||
List<ShapelessRecipe> registered = new ArrayList<>();
|
||||
when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
registered.add((ShapelessRecipe) invocation.getArgument(0));
|
||||
return true;
|
||||
});
|
||||
assertTrue(new DispenserRecipeRegistrar(new NamespacedKey(
|
||||
"spigotinventoryhelper", "dropper_dispenser")).register(serverUnderTest));
|
||||
ShapelessRecipe bukkit = registered.getFirst();
|
||||
var 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);
|
||||
inventory.setItem(0, CraftItemStack.asNMSCopy(new ItemStack(Material.DROPPER, 3)));
|
||||
inventory.setItem(1, CraftItemStack.asNMSCopy(new ItemStack(Material.TRIPWIRE_HOOK, 3)));
|
||||
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);
|
||||
grid.setItem(0, CraftItemStack.asNMSCopy(new ItemStack(Material.DIRT, 2)));
|
||||
List<net.minecraft.world.inventory.Slot> slots = 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.ShapelessRecipe>() {
|
||||
@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.ShapelessRecipe> candidate) {
|
||||
return candidate.value().matches(nativeInput(grid),
|
||||
mock(net.minecraft.world.level.Level.class));
|
||||
}
|
||||
};
|
||||
|
||||
var action = net.minecraft.recipebook.ServerPlaceRecipe.placeRecipe(
|
||||
access, 3, 3, slots, slots, inventory, holder, true, false);
|
||||
|
||||
assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.NOTHING, action);
|
||||
assertEquals(3, count(grid, Material.DROPPER));
|
||||
assertEquals(3, count(grid, Material.TRIPWIRE_HOOK));
|
||||
assertEquals(2, inventory.getNonEquipmentItems().stream()
|
||||
.map(CraftItemStack::asBukkitCopy)
|
||||
.filter(item -> item.getType() == Material.DIRT)
|
||||
.mapToInt(ItemStack::getAmount).sum());
|
||||
assertEquals(unrelated, CraftItemStack.asBukkitCopy(inventory.getItem(20)));
|
||||
int crafted = 0;
|
||||
for (int attempt = 0; attempt < 3; attempt++) {
|
||||
assertTrue(nativeRecipe.matches(nativeInput(grid),
|
||||
mock(net.minecraft.world.level.Level.class)));
|
||||
net.minecraft.world.item.ItemStack assembled = nativeRecipe.assemble(nativeInput(grid));
|
||||
ItemStack preview = CraftItemStack.asBukkitCopy(assembled);
|
||||
assertEquals(Material.DISPENSER, preview.getType());
|
||||
assertEquals(1, preview.getAmount());
|
||||
assertFalse(preview.hasItemMeta());
|
||||
grid.setCurrentRecipe(new net.minecraft.world.item.crafting.RecipeHolder<
|
||||
net.minecraft.world.item.crafting.CraftingRecipe>(holder.id(), holder.value()));
|
||||
var result = new net.minecraft.world.inventory.ResultContainer();
|
||||
result.setRecipeUsed(holder);
|
||||
result.setItem(0, assembled);
|
||||
var slot = new net.minecraft.world.inventory.ResultSlot(
|
||||
nativePlayer, grid, result, 0, 0, 0);
|
||||
net.minecraft.world.item.ItemStack taken = slot.remove(1);
|
||||
slot.onTake(nativePlayer, taken);
|
||||
crafted += taken.getCount();
|
||||
}
|
||||
|
||||
assertEquals(3, crafted);
|
||||
assertTrue(grid.isEmpty());
|
||||
assertEquals(unrelated, CraftItemStack.asBukkitCopy(inventory.getItem(20)));
|
||||
assertFalse(inventory.getNonEquipmentItems().stream()
|
||||
.map(CraftItemStack::asBukkitCopy)
|
||||
.anyMatch(item -> item.getType() == Material.DISPENSER),
|
||||
"Preview and autofill must not create an extra result");
|
||||
}
|
||||
|
||||
private static net.minecraft.world.item.crafting.CraftingInput nativeInput(
|
||||
net.minecraft.world.Container grid) {
|
||||
return net.minecraft.world.item.crafting.CraftingInput.of(3, 3,
|
||||
java.util.stream.IntStream.range(0, grid.getContainerSize())
|
||||
.mapToObj(grid::getItem).toList());
|
||||
}
|
||||
|
||||
private static long count(net.minecraft.world.Container grid, Material material) {
|
||||
return java.util.stream.IntStream.range(0, grid.getContainerSize())
|
||||
.mapToObj(grid::getItem).map(CraftItemStack::asBukkitCopy)
|
||||
.filter(item -> item.getType() == material)
|
||||
.mapToInt(ItemStack::getAmount).sum();
|
||||
}
|
||||
|
||||
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()));
|
||||
}
|
||||
|
||||
private static net.minecraft.world.item.crafting.ShapelessRecipe nativeRecipe(
|
||||
ShapelessRecipe recipe) {
|
||||
List<net.minecraft.world.item.crafting.Ingredient> ingredients = recipe.getChoiceList()
|
||||
.stream().map(choice -> CraftRecipe.toIngredient(choice, false)).toList();
|
||||
return new net.minecraft.world.item.crafting.ShapelessRecipe(
|
||||
new net.minecraft.world.item.crafting.Recipe.CommonInfo(true),
|
||||
new net.minecraft.world.item.crafting.CraftingRecipe.CraftingBookInfo(
|
||||
CraftRecipe.getCategory(recipe.getCategory()), recipe.getGroup()),
|
||||
CraftItemStack.asTemplate(recipe.getResult()), ingredients);
|
||||
}
|
||||
|
||||
private static org.bukkit.entity.Player discoveryPlayer(
|
||||
String name, Set<NamespacedKey> known) {
|
||||
org.bukkit.entity.Player player = mock(org.bukkit.entity.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;
|
||||
}
|
||||
|
||||
private static Plugin enabledPlugin() {
|
||||
Plugin plugin = mock(Plugin.class);
|
||||
when(plugin.isEnabled()).thenReturn(true);
|
||||
when(plugin.getName()).thenReturn("SpigotInventoryHelper");
|
||||
when(plugin.getLogger()).thenReturn(Logger.getLogger("NativeDispenserRecipe"));
|
||||
PluginDescriptionFile description = new PluginDescriptionFile(
|
||||
"SpigotInventoryHelper", "test", "unused.Main");
|
||||
when(plugin.getDescription()).thenReturn(description);
|
||||
when(plugin.getPluginMeta()).thenReturn(description);
|
||||
return plugin;
|
||||
}
|
||||
|
||||
private static void setServer(Server server) throws Exception {
|
||||
var field = Bukkit.class.getDeclaredField("server");
|
||||
field.setAccessible(true);
|
||||
field.set(null, server);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.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.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.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalInt;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
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.inventory.ItemStack;
|
||||
import org.bukkit.Keyed;
|
||||
import org.bukkit.inventory.Recipe;
|
||||
import org.bukkit.inventory.ShapedRecipe;
|
||||
import org.bukkit.inventory.recipe.CraftingBookCategory;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.bukkit.plugin.PluginDescriptionFile;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
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 recipe-book and crafting behavior with CraftServer, CraftPlayer, and client
|
||||
* boundaries doubled. No listening server, world, or client is started by this harness.
|
||||
*/
|
||||
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 bothSortingStickVariantsAppearInTheEquipmentCategory() {
|
||||
RegisteredRecipes registered = registerRecipes();
|
||||
|
||||
assertEquals(2, registered.bukkit().size());
|
||||
assertTrue(registered.bukkit().stream()
|
||||
.allMatch(recipe -> recipe.getCategory() == CraftingBookCategory.EQUIPMENT));
|
||||
assertTrue(registered.bukkit().stream().allMatch(recipe ->
|
||||
registered.items().isSortingStick(recipe.getResult())
|
||||
&& recipe.getResult().getAmount() == 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void serverRecipeBookPersistsKnownHighlightsAndVanillaStateWithOneNotification() {
|
||||
RegisteredRecipes registered = registerRecipes();
|
||||
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 (ShapedRecipe bukkit : registered.bukkit()) {
|
||||
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 nextDisplayId = new AtomicInteger();
|
||||
Map<net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>>,
|
||||
Integer> displayIds = new HashMap<>();
|
||||
nativeRecipes.keySet().forEach(key -> displayIds.put(key, nextDisplayId.getAndIncrement()));
|
||||
TestServerRecipeBook book = new TestServerRecipeBook((key, consumer) -> {
|
||||
var 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(), OptionalInt.empty(), recipe.recipeBookCategory(),
|
||||
Optional.of(recipe.placementInfo().ingredients())));
|
||||
});
|
||||
PluginManager events = mock(PluginManager.class);
|
||||
List<org.bukkit.event.Event> fired = new ArrayList<>();
|
||||
doAnswer(invocation -> {
|
||||
fired.add(invocation.getArgument(0));
|
||||
return null;
|
||||
}).when(events).callEvent(any());
|
||||
when(serverUnderTest.getPluginManager()).thenReturn(events);
|
||||
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(2, book.addRecipes(holders, nativePlayer));
|
||||
assertEquals(0, book.addRecipes(holders, nativePlayer));
|
||||
|
||||
var vanillaKey = nativeRecipeKey(new NamespacedKey("minecraft", "stick"));
|
||||
book.add(vanillaKey);
|
||||
book.highlight(vanillaKey);
|
||||
book.setBookSetting(net.minecraft.world.inventory.RecipeBookType.CRAFTING, true, true);
|
||||
assertEquals(new LinkedHashSet<>(nativeRecipes.keySet()),
|
||||
new LinkedHashSet<>(book.pack().known().stream().filter(nativeRecipes::containsKey).toList()));
|
||||
assertEquals(2, fired.size());
|
||||
assertTrue(fired.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, key -> true);
|
||||
|
||||
assertTrue(restored.contains(vanillaKey));
|
||||
nativeRecipes.keySet().forEach(key -> assertTrue(restored.contains(key)));
|
||||
assertEquals(new LinkedHashSet<>(decoded.known()), new LinkedHashSet<>(restored.pack().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 knownBefore = restored.pack().known().size();
|
||||
nativeRecipes.keySet().forEach(restored::add);
|
||||
assertEquals(knownBefore, restored.pack().known().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nativeLifecycleDiscoversOnlineAndJoinersThenReenablesWithoutLosingKnowledge() {
|
||||
Map<NamespacedKey, Recipe> registry = new HashMap<>();
|
||||
NamespacedKey unrelatedKey = new NamespacedKey("minecraft", "stick");
|
||||
registry.put(unrelatedKey, mock(Recipe.class));
|
||||
when(serverUnderTest.getRecipe(any(NamespacedKey.class))).thenAnswer(
|
||||
invocation -> registry.get(invocation.getArgument(0)));
|
||||
when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
Recipe recipe = invocation.getArgument(0);
|
||||
return registry.putIfAbsent(((Keyed) recipe).getKey(), recipe) == null;
|
||||
});
|
||||
when(serverUnderTest.removeRecipe(any(NamespacedKey.class))).thenAnswer(
|
||||
invocation -> registry.remove(invocation.getArgument(0)) != null);
|
||||
PluginManager events = new PaperPluginManagerImpl(serverUnderTest,
|
||||
new SimpleCommandMap(serverUnderTest, new HashMap<>()), null);
|
||||
when(serverUnderTest.getPluginManager()).thenReturn(events);
|
||||
Plugin plugin = enabledPlugin("NativeLifecycle");
|
||||
Set<NamespacedKey> onlineKnown = new LinkedHashSet<>();
|
||||
Set<NamespacedKey> joiningKnown = new LinkedHashSet<>();
|
||||
org.bukkit.entity.Player online = discoveryPlayer("Online", onlineKnown);
|
||||
org.bukkit.entity.Player joining = discoveryPlayer("Joining", joiningKnown);
|
||||
doReturn(List.of(online)).when(serverUnderTest).getOnlinePlayers();
|
||||
RegisteredRecipes firstRegistration = recipeServices();
|
||||
RecipeLifecycle first = new RecipeLifecycle(serverUnderTest, plugin,
|
||||
firstRegistration.registrar(), plugin.getLogger());
|
||||
|
||||
assertTrue(first.enable());
|
||||
events.callEvent(new org.bukkit.event.player.PlayerJoinEvent(joining, "joined"));
|
||||
first.disable();
|
||||
|
||||
assertEquals(Set.of(unrelatedKey), registry.keySet());
|
||||
assertEquals(new LinkedHashSet<>(firstRegistration.registrar().recipeKeys()), onlineKnown);
|
||||
assertEquals(new LinkedHashSet<>(firstRegistration.registrar().recipeKeys()), joiningKnown);
|
||||
RegisteredRecipes secondRegistration = recipeServices();
|
||||
assertTrue(new RecipeLifecycle(serverUnderTest, plugin,
|
||||
secondRegistration.registrar(), plugin.getLogger()).enable());
|
||||
assertTrue(registry.keySet().containsAll(secondRegistration.registrar().recipeKeys()));
|
||||
assertTrue(registry.containsKey(unrelatedKey));
|
||||
verify(online, times(1)).discoverRecipes(any());
|
||||
verify(online, never()).undiscoverRecipes(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nativeLifecycleConflictRollsBackOwnedVariantAndAwardsNobody() {
|
||||
Map<NamespacedKey, Recipe> registry = new HashMap<>();
|
||||
NamespacedKey unrelatedKey = new NamespacedKey("minecraft", "stick");
|
||||
NamespacedKey conflictKey = new NamespacedKey(
|
||||
"spigotinventoryhelper", "sorting_stick_recipe_barrel");
|
||||
Recipe conflict = mock(Recipe.class);
|
||||
registry.put(unrelatedKey, mock(Recipe.class));
|
||||
registry.put(conflictKey, conflict);
|
||||
when(serverUnderTest.getRecipe(any(NamespacedKey.class))).thenAnswer(
|
||||
invocation -> registry.get(invocation.getArgument(0)));
|
||||
when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
Recipe recipe = invocation.getArgument(0);
|
||||
return registry.putIfAbsent(((Keyed) recipe).getKey(), recipe) == null;
|
||||
});
|
||||
when(serverUnderTest.removeRecipe(any(NamespacedKey.class))).thenAnswer(
|
||||
invocation -> registry.remove(invocation.getArgument(0)) != null);
|
||||
PluginManager events = mock(PluginManager.class);
|
||||
when(serverUnderTest.getPluginManager()).thenReturn(events);
|
||||
Set<NamespacedKey> known = new LinkedHashSet<>();
|
||||
doReturn(List.of(discoveryPlayer("Online", known))).when(serverUnderTest).getOnlinePlayers();
|
||||
RegisteredRecipes services = recipeServices();
|
||||
RecipeLifecycle lifecycle = new RecipeLifecycle(serverUnderTest,
|
||||
mock(Plugin.class), services.registrar(), Logger.getAnonymousLogger());
|
||||
|
||||
assertFalse(lifecycle.enable());
|
||||
|
||||
assertEquals(Set.of(unrelatedKey, conflictKey), registry.keySet());
|
||||
assertTrue(known.isEmpty());
|
||||
assertEquals(conflict, registry.get(conflictKey));
|
||||
verify(events, never()).registerEvents(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nativeLifecycleFailureRemovesPartiallyRegisteredListenerAndRecipes() {
|
||||
Map<NamespacedKey, Recipe> registry = new HashMap<>();
|
||||
when(serverUnderTest.getRecipe(any(NamespacedKey.class))).thenAnswer(
|
||||
invocation -> registry.get(invocation.getArgument(0)));
|
||||
when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
Recipe recipe = invocation.getArgument(0);
|
||||
registry.put(((Keyed) recipe).getKey(), recipe);
|
||||
return true;
|
||||
});
|
||||
when(serverUnderTest.removeRecipe(any(NamespacedKey.class))).thenAnswer(
|
||||
invocation -> registry.remove(invocation.getArgument(0)) != null);
|
||||
PluginManager registeredEvents = new PaperPluginManagerImpl(serverUnderTest,
|
||||
new SimpleCommandMap(serverUnderTest, 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(serverUnderTest.getPluginManager()).thenReturn(throwingEvents);
|
||||
doReturn(List.of()).when(serverUnderTest).getOnlinePlayers();
|
||||
Plugin plugin = enabledPlugin("NativeLifecycleFailure");
|
||||
Set<NamespacedKey> joiningKnown = new LinkedHashSet<>();
|
||||
RegisteredRecipes services = recipeServices();
|
||||
RecipeLifecycle lifecycle = new RecipeLifecycle(serverUnderTest, plugin,
|
||||
services.registrar(), plugin.getLogger());
|
||||
|
||||
assertFalse(lifecycle.enable());
|
||||
registeredEvents.callEvent(new org.bukkit.event.player.PlayerJoinEvent(
|
||||
discoveryPlayer("Joining", joiningKnown), "joined"));
|
||||
|
||||
assertTrue(registry.isEmpty());
|
||||
assertTrue(joiningKnown.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nativeAutofillAndResultSlotConsumeEachVariantIntoOneAuthenticatedResult() {
|
||||
when(serverUnderTest.getPluginManager()).thenReturn(mock(PluginManager.class));
|
||||
RegisteredRecipes registered = registerRecipes();
|
||||
for (int index = 0; index < registered.bukkit().size(); index++) {
|
||||
ShapedRecipe bukkit = registered.bukkit().get(index);
|
||||
Material storage = index == 0 ? Material.CHEST : Material.BARREL;
|
||||
var 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);
|
||||
inventory.setItem(0, CraftItemStack.asNMSCopy(new ItemStack(storage)));
|
||||
inventory.setItem(1, CraftItemStack.asNMSCopy(new ItemStack(Material.REDSTONE)));
|
||||
inventory.setItem(2, CraftItemStack.asNMSCopy(new ItemStack(Material.STICK)));
|
||||
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);
|
||||
grid.setItem(0, CraftItemStack.asNMSCopy(new ItemStack(Material.DIRT, 3)));
|
||||
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(nativeInput(grid),
|
||||
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);
|
||||
assertTrue(nativeRecipe.matches(nativeInput(grid),
|
||||
mock(net.minecraft.world.level.Level.class)));
|
||||
assertEquals(1, count(grid, storage));
|
||||
assertEquals(1, count(grid, Material.REDSTONE));
|
||||
assertEquals(1, count(grid, Material.STICK));
|
||||
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());
|
||||
List<ItemStack> gridBeforePreview = grid.getItems().stream()
|
||||
.map(CraftItemStack::asBukkitCopy).toList();
|
||||
net.minecraft.world.item.ItemStack nativeResult = nativeRecipe.assemble(nativeInput(grid));
|
||||
ItemStack preview = CraftItemStack.asBukkitCopy(nativeResult);
|
||||
assertTrue(registered.items().isSortingStick(preview));
|
||||
assertEquals(1, preview.getAmount());
|
||||
assertEquals(gridBeforePreview, grid.getItems().stream()
|
||||
.map(CraftItemStack::asBukkitCopy).toList());
|
||||
|
||||
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);
|
||||
|
||||
ItemStack crafted = CraftItemStack.asBukkitCopy(taken);
|
||||
assertTrue(registered.items().isSortingStick(crafted));
|
||||
assertEquals(1, crafted.getAmount());
|
||||
assertTrue(resultContainer.isEmpty());
|
||||
assertTrue(grid.isEmpty());
|
||||
assertEquals(unrelated, CraftItemStack.asBukkitCopy(inventory.getItem(20)));
|
||||
assertFalse(inventory.getNonEquipmentItems().stream()
|
||||
.map(CraftItemStack::asBukkitCopy)
|
||||
.anyMatch(registered.items()::isSortingStick),
|
||||
"Preview, autofill, and completion must not create an extra result");
|
||||
}
|
||||
}
|
||||
|
||||
private static net.minecraft.world.item.crafting.CraftingInput nativeInput(
|
||||
net.minecraft.world.Container grid) {
|
||||
List<net.minecraft.world.item.ItemStack> items =
|
||||
java.util.stream.IntStream.range(0, grid.getContainerSize())
|
||||
.mapToObj(grid::getItem).toList();
|
||||
return net.minecraft.world.item.crafting.CraftingInput.of(3, 3, items);
|
||||
}
|
||||
|
||||
private static long count(net.minecraft.world.Container grid, Material material) {
|
||||
return java.util.stream.IntStream.range(0, grid.getContainerSize())
|
||||
.mapToObj(grid::getItem).map(CraftItemStack::asBukkitCopy)
|
||||
.filter(item -> item.getType() == material)
|
||||
.mapToInt(ItemStack::getAmount).sum();
|
||||
}
|
||||
|
||||
private static Plugin enabledPlugin(String loggerName) {
|
||||
Plugin plugin = mock(Plugin.class);
|
||||
when(plugin.isEnabled()).thenReturn(true);
|
||||
when(plugin.getName()).thenReturn("SpigotInventoryHelper");
|
||||
when(plugin.getLogger()).thenReturn(Logger.getLogger(loggerName));
|
||||
PluginDescriptionFile description = new PluginDescriptionFile(
|
||||
"SpigotInventoryHelper", "test", "unused.Main");
|
||||
when(plugin.getDescription()).thenReturn(description);
|
||||
when(plugin.getPluginMeta()).thenReturn(description);
|
||||
return plugin;
|
||||
}
|
||||
|
||||
private static org.bukkit.entity.Player discoveryPlayer(
|
||||
String name, Set<NamespacedKey> known) {
|
||||
org.bukkit.entity.Player player = mock(org.bukkit.entity.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;
|
||||
}
|
||||
|
||||
private static RegisteredRecipes recipeServices() {
|
||||
SortingStickItemFactory items = new SortingStickItemFactory(
|
||||
new NamespacedKey("spigotinventoryhelper", "sorting_stick"));
|
||||
SortingStickRecipeRegistrar recipes = new SortingStickRecipeRegistrar(
|
||||
new NamespacedKey("spigotinventoryhelper", "sorting_stick_recipe"),
|
||||
items, SortingStickRecipeSpec.defaultRecipe());
|
||||
return new RegisteredRecipes(items, recipes, List.of());
|
||||
}
|
||||
|
||||
private static RegisteredRecipes registerRecipes() {
|
||||
List<ShapedRecipe> registered = new ArrayList<>();
|
||||
when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
registered.add((ShapedRecipe) invocation.getArgument(0));
|
||||
return true;
|
||||
});
|
||||
RegisteredRecipes services = recipeServices();
|
||||
assertTrue(services.registrar().register(serverUnderTest));
|
||||
return new RegisteredRecipes(services.items(), services.registrar(), registered);
|
||||
}
|
||||
|
||||
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()));
|
||||
}
|
||||
|
||||
private static net.minecraft.world.item.crafting.ShapedRecipe nativeRecipe(ShapedRecipe recipe) {
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
|
||||
private record RegisteredRecipes(
|
||||
SortingStickItemFactory items,
|
||||
SortingStickRecipeRegistrar registrar,
|
||||
List<ShapedRecipe> bukkit
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.commands.Commands;
|
||||
import net.minecraft.core.HolderLookup;
|
||||
import net.minecraft.core.LayeredRegistryAccess;
|
||||
import net.minecraft.core.Registry;
|
||||
import net.minecraft.core.RegistryAccess;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.RegistryDataLoader;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.RegistryLayer;
|
||||
import net.minecraft.server.ReloadableServerResources;
|
||||
import net.minecraft.server.packs.PackType;
|
||||
import net.minecraft.server.packs.repository.ServerPacksSource;
|
||||
import net.minecraft.server.packs.resources.MultiPackResourceManager;
|
||||
import net.minecraft.server.permissions.LevelBasedPermissionSet;
|
||||
import net.minecraft.tags.TagLoader;
|
||||
import net.minecraft.util.Util;
|
||||
import net.minecraft.world.flag.FeatureFlags;
|
||||
import net.minecraft.world.level.DataPackConfig;
|
||||
import net.minecraft.world.level.WorldDataConfiguration;
|
||||
import org.bukkit.craftbukkit.CraftRegistry;
|
||||
|
||||
/** Loads vanilla registries, tags, and components using the server test bootstrap path. */
|
||||
final class NativeRuntime {
|
||||
private static boolean bootstrapped;
|
||||
|
||||
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.WORLDGEN), tags);
|
||||
RegistryAccess.Frozen worldgen = RegistryDataLoader.load(resources, lookups,
|
||||
RegistryDataLoader.WORLDGEN_REGISTRIES, Util.backgroundExecutor()).join();
|
||||
layers = layers.replaceFrom(RegistryLayer.WORLDGEN, worldgen);
|
||||
RegistryAccess.Frozen dimensions = RegistryDataLoader.load(resources,
|
||||
Stream.concat(lookups.stream(), worldgen.listRegistries()).toList(),
|
||||
RegistryDataLoader.DIMENSION_REGISTRIES, Util.backgroundExecutor()).join();
|
||||
layers = layers.replaceFrom(RegistryLayer.DIMENSIONS, dimensions);
|
||||
Class.forName(org.bukkit.Registry.class.getName());
|
||||
var datapack = ReloadableServerResources.loadResources(resources, layers, tags, flags,
|
||||
Commands.CommandSelection.DEDICATED, LevelBasedPermissionSet.ALL_PERMISSIONS,
|
||||
Util.backgroundExecutor(), Runnable::run).join();
|
||||
datapack.updateComponentsAndStaticRegistryTags();
|
||||
CraftRegistry.setMinecraftRegistry(layers.compositeAccess().freeze());
|
||||
bootstrapped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
mock-maker-subclass
|
||||
@@ -0,0 +1,113 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.ShapelessRecipe;
|
||||
import org.bukkit.inventory.recipe.CraftingBookCategory;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
final class DispenserRecipeRegistrarTest {
|
||||
@Test
|
||||
void registersOrdinaryDispenserRecipeInRedstoneCategory() {
|
||||
Server server = mock(Server.class);
|
||||
ShapelessRecipe recipe = mock(ShapelessRecipe.class);
|
||||
ItemStack dispenser = mock(ItemStack.class);
|
||||
when(dispenser.getType()).thenReturn(Material.DISPENSER);
|
||||
when(dispenser.getAmount()).thenReturn(1);
|
||||
DispenserRecipeRegistrar.RecipeFactory recipes =
|
||||
mock(DispenserRecipeRegistrar.RecipeFactory.class);
|
||||
when(recipes.create(any(), any())).thenReturn(recipe);
|
||||
when(server.addRecipe(recipe)).thenReturn(true);
|
||||
NamespacedKey key = new NamespacedKey("spigotinventoryhelper", "dropper_dispenser");
|
||||
DispenserRecipeRegistrar registrar = new DispenserRecipeRegistrar(
|
||||
key, () -> dispenser, recipes);
|
||||
|
||||
assertTrue(registrar.register(server));
|
||||
|
||||
ArgumentCaptor<ItemStack> result = ArgumentCaptor.forClass(ItemStack.class);
|
||||
verify(recipes).create(org.mockito.ArgumentMatchers.eq(key), result.capture());
|
||||
assertEquals(Material.DISPENSER, result.getValue().getType());
|
||||
assertEquals(1, result.getValue().getAmount());
|
||||
assertFalse(result.getValue().hasItemMeta());
|
||||
verify(recipe).addIngredient(Material.DROPPER);
|
||||
verify(recipe).addIngredient(Material.TRIPWIRE_HOOK);
|
||||
verify(recipe).setCategory(CraftingBookCategory.REDSTONE);
|
||||
verify(server).addRecipe(recipe);
|
||||
}
|
||||
|
||||
@Test
|
||||
void registrationExceptionAfterInsertionRemovesOnlyTheOwnedRecipe() {
|
||||
Server server = mock(Server.class);
|
||||
ShapelessRecipe recipe = mock(ShapelessRecipe.class);
|
||||
ItemStack result = mock(ItemStack.class);
|
||||
NamespacedKey key = new NamespacedKey("spigotinventoryhelper", "dropper_dispenser");
|
||||
when(server.getRecipe(key)).thenReturn(null, recipe);
|
||||
when(server.addRecipe(recipe)).thenThrow(new IllegalStateException("registry failed"));
|
||||
when(server.removeRecipe(key)).thenReturn(true);
|
||||
DispenserRecipeRegistrar.RecipeFactory recipes =
|
||||
mock(DispenserRecipeRegistrar.RecipeFactory.class);
|
||||
when(recipes.create(any(), any())).thenReturn(recipe);
|
||||
DispenserRecipeRegistrar registrar = new DispenserRecipeRegistrar(
|
||||
key, () -> result, recipes);
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> registrar.register(server));
|
||||
registrar.unregister();
|
||||
|
||||
verify(server).removeRecipe(key);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedRemovalRetainsOwnershipForRetry() {
|
||||
Server server = mock(Server.class);
|
||||
ShapelessRecipe recipe = mock(ShapelessRecipe.class);
|
||||
ItemStack result = mock(ItemStack.class);
|
||||
NamespacedKey key = new NamespacedKey("spigotinventoryhelper", "dropper_dispenser");
|
||||
when(server.addRecipe(recipe)).thenReturn(true);
|
||||
when(server.removeRecipe(key)).thenReturn(false, true);
|
||||
DispenserRecipeRegistrar.RecipeFactory recipes =
|
||||
mock(DispenserRecipeRegistrar.RecipeFactory.class);
|
||||
when(recipes.create(any(), any())).thenReturn(recipe);
|
||||
DispenserRecipeRegistrar registrar = new DispenserRecipeRegistrar(
|
||||
key, () -> result, recipes);
|
||||
|
||||
assertTrue(registrar.register(server));
|
||||
assertThrows(IllegalStateException.class, registrar::unregister);
|
||||
registrar.unregister();
|
||||
|
||||
verify(server, times(2)).removeRecipe(key);
|
||||
}
|
||||
|
||||
@Test
|
||||
void conflictingRecipeIsPreservedAndNotClaimed() {
|
||||
Server server = mock(Server.class);
|
||||
ShapelessRecipe recipe = mock(ShapelessRecipe.class);
|
||||
ItemStack result = mock(ItemStack.class);
|
||||
NamespacedKey key = new NamespacedKey("spigotinventoryhelper", "dropper_dispenser");
|
||||
when(server.getRecipe(key)).thenReturn(mock(org.bukkit.inventory.Recipe.class));
|
||||
DispenserRecipeRegistrar.RecipeFactory recipes =
|
||||
mock(DispenserRecipeRegistrar.RecipeFactory.class);
|
||||
when(recipes.create(any(), any())).thenReturn(recipe);
|
||||
DispenserRecipeRegistrar registrar = new DispenserRecipeRegistrar(
|
||||
key, () -> result, recipes);
|
||||
|
||||
assertFalse(registrar.register(server));
|
||||
registrar.unregister();
|
||||
|
||||
verify(server, never()).addRecipe(any());
|
||||
verify(server, never()).removeRecipe(any());
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
@@ -23,4 +25,13 @@ final class PluginFoundationTest {
|
||||
assertEquals("1.20", plugin.get("api-version"));
|
||||
assertNotNull(Class.forName((String) plugin.get("main")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void pluginEntrypointOwnsIndependentEnableDisableRecipeLifecycleWiring() throws Exception {
|
||||
Field lifecycles = SpigotInventoryHelperPlugin.class.getDeclaredField("recipeLifecycles");
|
||||
Method disable = SpigotInventoryHelperPlugin.class.getDeclaredMethod("onDisable");
|
||||
|
||||
assertEquals(RecipeLifecycleGroup.class, lifecycles.getType());
|
||||
assertEquals(void.class, disable.getReturnType());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.io.DataInputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.jar.JarFile;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
|
||||
class PurpurDistributionTest {
|
||||
@Test
|
||||
void packagesNewNameWithoutChangingRuntimeIdentityOrBundlingTheApi() throws Exception {
|
||||
Path artifact = Path.of(System.getProperty("distribution.jar"));
|
||||
String version = System.getProperty("distribution.version");
|
||||
assertEquals("purpur-inventory-helper-" + 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("SpigotInventoryHelper", metadata.get("name"), "Retain the data directory and persistent namespace");
|
||||
assertEquals("games.dmg.spigotinventoryhelper.SpigotInventoryHelperPlugin", metadata.get("main"));
|
||||
assertFalse(metadata.containsKey("permissions"));
|
||||
assertFalse(metadata.containsKey("commands"));
|
||||
var description = new org.bukkit.plugin.PluginDescriptionFile(jar.getInputStream(jar.getJarEntry("plugin.yml")));
|
||||
assertEquals("spigotinventoryhelper", description.namespace(), "Persistent sorting-stick namespace must survive distribution renaming");
|
||||
assertNotNull(jar.getJarEntry(
|
||||
"games/dmg/spigotinventoryhelper/RecipeDiscovery.class"));
|
||||
assertNotNull(jar.getJarEntry(
|
||||
"games/dmg/spigotinventoryhelper/RecipeLifecycle.class"));
|
||||
assertNotNull(jar.getJarEntry(
|
||||
"games/dmg/spigotinventoryhelper/RecipeLifecycleGroup.class"));
|
||||
assertNotNull(jar.getJarEntry(
|
||||
"games/dmg/spigotinventoryhelper/DispenserRecipeRegistrar.class"));
|
||||
assertFalse(jar.stream().anyMatch(entry -> entry.getName().startsWith("org/bukkit/")
|
||||
|| entry.getName().startsWith("org/purpurmc/") || entry.getName().endsWith("Test.class")
|
||||
|| entry.getName().contains("NativeRuntime")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void documentsPinnedNativeCoverageAndDoubledBoundaries() throws Exception {
|
||||
String readme = Files.readString(Path.of("README.md"));
|
||||
|
||||
assertTrue(readme.contains("ServerPlaceRecipe"));
|
||||
assertTrue(readme.contains("ServerRecipeBook"));
|
||||
assertTrue(readme.contains("ResultSlot"));
|
||||
assertTrue(readme.contains("Dropper + Tripwire Hook"));
|
||||
assertTrue(readme.contains("Redstone category"));
|
||||
assertTrue(readme.contains("CraftServer, CraftPlayer, and client boundaries are doubled"));
|
||||
assertTrue(readme.contains("4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void pluginBytecodeTargetsJava25() throws Exception {
|
||||
try (var jar = new JarFile(System.getProperty("distribution.jar"));
|
||||
var bytecode = new DataInputStream(jar.getInputStream(
|
||||
jar.getJarEntry("games/dmg/spigotinventoryhelper/SpigotInventoryHelperPlugin.class")))) {
|
||||
assertEquals(0xCAFEBABE, bytecode.readInt());
|
||||
bytecode.readUnsignedShort();
|
||||
assertEquals(69, bytecode.readUnsignedShort());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void releaseAndCiAgreeWithThePurpurDistributionNameAndToolchain() throws Exception {
|
||||
String build = Files.readString(Path.of("build.gradle.kts"));
|
||||
assertTrue(build.contains("purpur-api:26.2.build.2618-stable"));
|
||||
assertFalse(build.contains("org.spigotmc:spigot-api"));
|
||||
assertTrue(build.contains("nativeInventoryTest"));
|
||||
assertTrue(build.contains("tasks.check { dependsOn(nativeInventoryTest) }"));
|
||||
assertTrue(build.contains("4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31"));
|
||||
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-inventory-helper-"), workflow);
|
||||
assertFalse(content.contains("spigot-inventory-helper-"), workflow);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
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.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
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;
|
||||
|
||||
final class RecipeDiscoveryTest {
|
||||
private final List<NamespacedKey> keys = List.of(
|
||||
new NamespacedKey("spigotinventoryhelper", "sorting_stick_recipe_chest"),
|
||||
new NamespacedKey("spigotinventoryhelper", "sorting_stick_recipe_barrel"));
|
||||
|
||||
@Test
|
||||
void warningIdentifiesTheRecipeWhoseDiscoveryPostconditionFailed() {
|
||||
Set<NamespacedKey> known = new LinkedHashSet<>();
|
||||
Player player = player("Partial", known);
|
||||
doAnswer(invocation -> 0).when(player).discoverRecipes(any());
|
||||
List<LogRecord> warnings = new ArrayList<>();
|
||||
RecipeDiscovery discovery = new RecipeDiscovery(
|
||||
keys, "alternative Dispenser recipe", logger(warnings));
|
||||
|
||||
discovery.discover(player);
|
||||
|
||||
assertEquals(1, warnings.size());
|
||||
assertTrue(warnings.getFirst().getMessage().contains("alternative Dispenser recipe"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void onlineDiscoveryAwardsOnlyUnknownRecipesAndRepeatedDiscoveryIsIdempotent() {
|
||||
Set<NamespacedKey> known = new LinkedHashSet<>();
|
||||
known.add(keys.getFirst());
|
||||
Player player = player("Online", known);
|
||||
RecipeDiscovery discovery = new RecipeDiscovery(keys, Logger.getAnonymousLogger());
|
||||
|
||||
discovery.discoverAll(List.of(player));
|
||||
discovery.discoverAll(List.of(player));
|
||||
|
||||
assertEquals(new LinkedHashSet<>(keys), known);
|
||||
verify(player, times(1)).discoverRecipes(List.of(keys.getLast()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void joiningPlayerReceivesBothUnknownRecipes() {
|
||||
Set<NamespacedKey> known = new LinkedHashSet<>();
|
||||
Player player = player("Joining", known);
|
||||
RecipeDiscovery discovery = new RecipeDiscovery(keys, Logger.getAnonymousLogger());
|
||||
|
||||
discovery.onJoin(new PlayerJoinEvent(player, "joined"));
|
||||
|
||||
assertEquals(new LinkedHashSet<>(keys), known);
|
||||
verify(player).discoverRecipes(keys);
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingPostconditionIsLoggedAndOnlyTheMissingRecipeRetries() {
|
||||
Set<NamespacedKey> known = new LinkedHashSet<>();
|
||||
Player player = player("Partial", known);
|
||||
AtomicBoolean firstAward = new AtomicBoolean(true);
|
||||
doAnswer(invocation -> {
|
||||
List<NamespacedKey> requested = ((Collection<?>) invocation.getArgument(0)).stream()
|
||||
.map(NamespacedKey.class::cast).toList();
|
||||
if (firstAward.getAndSet(false)) {
|
||||
known.add(requested.getFirst());
|
||||
return 1;
|
||||
}
|
||||
known.addAll(requested);
|
||||
return requested.size();
|
||||
}).when(player).discoverRecipes(any());
|
||||
List<LogRecord> warnings = new ArrayList<>();
|
||||
RecipeDiscovery discovery = new RecipeDiscovery(keys, logger(warnings));
|
||||
|
||||
discovery.discover(player);
|
||||
discovery.discover(player);
|
||||
|
||||
assertEquals(new LinkedHashSet<>(keys), known);
|
||||
verify(player).discoverRecipes(keys);
|
||||
verify(player).discoverRecipes(List.of(keys.getLast()));
|
||||
assertEquals(1, warnings.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void onePlayerExceptionIsLoggedIsolatedAndRetriedLater() {
|
||||
Set<NamespacedKey> failedKnown = new LinkedHashSet<>();
|
||||
Set<NamespacedKey> healthyKnown = new LinkedHashSet<>();
|
||||
AtomicBoolean firstAttempt = new AtomicBoolean(true);
|
||||
Player failing = player("Failing", failedKnown);
|
||||
doAnswer(invocation -> {
|
||||
if (firstAttempt.getAndSet(false)) {
|
||||
throw new IllegalStateException("simulated discovery failure");
|
||||
}
|
||||
List<NamespacedKey> requested = ((Collection<?>) invocation.getArgument(0)).stream()
|
||||
.map(NamespacedKey.class::cast).toList();
|
||||
failedKnown.addAll(requested);
|
||||
return requested.size();
|
||||
}).when(failing).discoverRecipes(any());
|
||||
List<LogRecord> warnings = new ArrayList<>();
|
||||
RecipeDiscovery discovery = new RecipeDiscovery(keys, logger(warnings));
|
||||
|
||||
assertDoesNotThrow(() -> discovery.discover(failing));
|
||||
discovery.discover(player("Healthy", healthyKnown));
|
||||
discovery.discover(failing);
|
||||
|
||||
assertEquals(new LinkedHashSet<>(keys), healthyKnown);
|
||||
assertEquals(new LinkedHashSet<>(keys), failedKnown);
|
||||
assertEquals(1, warnings.size());
|
||||
assertEquals(IllegalStateException.class, warnings.getFirst().getThrown().getClass());
|
||||
}
|
||||
|
||||
private static Logger logger(List<LogRecord> records) {
|
||||
Logger logger = Logger.getAnonymousLogger();
|
||||
logger.setUseParentHandlers(false);
|
||||
logger.addHandler(new Handler() {
|
||||
@Override public void publish(LogRecord record) { records.add(record); }
|
||||
@Override public void flush() {}
|
||||
@Override public void close() {}
|
||||
});
|
||||
return logger;
|
||||
}
|
||||
|
||||
private static Player player(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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InOrder;
|
||||
|
||||
final class RecipeLifecycleGroupTest {
|
||||
@Test
|
||||
void cleanupFailureDoesNotPreventOtherRecipeCleanup() {
|
||||
RecipeLifecycle sorting = mock(RecipeLifecycle.class);
|
||||
RecipeLifecycle dispenser = mock(RecipeLifecycle.class);
|
||||
org.mockito.Mockito.doThrow(new IllegalStateException("cleanup failed"))
|
||||
.when(dispenser).disable();
|
||||
RecipeLifecycleGroup group = new RecipeLifecycleGroup(List.of(sorting, dispenser));
|
||||
|
||||
assertThrows(IllegalStateException.class, group::disableAll);
|
||||
|
||||
verify(dispenser).disable();
|
||||
verify(sorting).disable();
|
||||
}
|
||||
|
||||
@Test
|
||||
void oneFailedRecipeLifecycleDoesNotBlockTheOther() {
|
||||
RecipeLifecycle sorting = mock(RecipeLifecycle.class);
|
||||
RecipeLifecycle dispenser = mock(RecipeLifecycle.class);
|
||||
when(sorting.enable()).thenReturn(true);
|
||||
when(dispenser.enable()).thenReturn(false);
|
||||
RecipeLifecycleGroup group = new RecipeLifecycleGroup(List.of(sorting, dispenser));
|
||||
|
||||
group.enableAll();
|
||||
group.disableAll();
|
||||
|
||||
verify(sorting).enable();
|
||||
verify(dispenser).enable();
|
||||
InOrder disabled = inOrder(dispenser, sorting);
|
||||
disabled.verify(dispenser).disable();
|
||||
disabled.verify(sorting).disable();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
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.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
final class RecipeLifecycleTest {
|
||||
private final List<NamespacedKey> keys = List.of(
|
||||
new NamespacedKey("spigotinventoryhelper", "sorting_stick_recipe_chest"),
|
||||
new NamespacedKey("spigotinventoryhelper", "sorting_stick_recipe_barrel"));
|
||||
|
||||
@Test
|
||||
void failedRegistrationWithholdsDiscoveryUntilALaterRetrySucceeds() {
|
||||
Server server = mock(Server.class);
|
||||
Plugin plugin = mock(Plugin.class);
|
||||
PluginManager events = mock(PluginManager.class);
|
||||
RecipeRegistrar recipes = mock(RecipeRegistrar.class);
|
||||
NamespacedKey key = new NamespacedKey("spigotinventoryhelper", "dropper_dispenser");
|
||||
when(recipes.register(server)).thenReturn(false, true);
|
||||
when(recipes.recipeKeys()).thenReturn(List.of(key));
|
||||
when(recipes.description()).thenReturn("alternative Dispenser recipe");
|
||||
when(server.getPluginManager()).thenReturn(events);
|
||||
Set<NamespacedKey> known = new LinkedHashSet<>();
|
||||
doReturn(List.of(player("Online", known))).when(server).getOnlinePlayers();
|
||||
RecipeLifecycle lifecycle = new RecipeLifecycle(
|
||||
server, plugin, recipes, Logger.getAnonymousLogger());
|
||||
|
||||
assertFalse(lifecycle.enable());
|
||||
assertTrue(known.isEmpty());
|
||||
assertTrue(lifecycle.enable());
|
||||
|
||||
assertEquals(Set.of(key), known);
|
||||
verify(recipes, times(2)).register(server);
|
||||
verify(events).registerEvents(any(RecipeDiscovery.class),
|
||||
org.mockito.ArgumentMatchers.eq(plugin));
|
||||
}
|
||||
|
||||
@Test
|
||||
void independentlyDiscoversARegisteredAlternativeRecipe() {
|
||||
Server server = mock(Server.class);
|
||||
Plugin plugin = mock(Plugin.class);
|
||||
PluginManager events = mock(PluginManager.class);
|
||||
RecipeRegistrar recipes = mock(RecipeRegistrar.class);
|
||||
NamespacedKey key = new NamespacedKey("spigotinventoryhelper", "dropper_dispenser");
|
||||
when(recipes.register(server)).thenReturn(true);
|
||||
when(recipes.recipeKeys()).thenReturn(List.of(key));
|
||||
when(recipes.description()).thenReturn("alternative Dispenser recipe");
|
||||
when(server.getPluginManager()).thenReturn(events);
|
||||
Set<NamespacedKey> known = new LinkedHashSet<>();
|
||||
doReturn(List.of(player("Online", known))).when(server).getOnlinePlayers();
|
||||
RecipeLifecycle lifecycle = new RecipeLifecycle(
|
||||
server, plugin, recipes, Logger.getAnonymousLogger());
|
||||
|
||||
assertTrue(lifecycle.enable());
|
||||
|
||||
assertEquals(Set.of(key), known);
|
||||
verify(events).registerEvents(any(RecipeDiscovery.class),
|
||||
org.mockito.ArgumentMatchers.eq(plugin));
|
||||
}
|
||||
|
||||
@Test
|
||||
void registrationExceptionCleansUpAndLeavesDiscoveryInactive() {
|
||||
Server server = mock(Server.class);
|
||||
Plugin plugin = mock(Plugin.class);
|
||||
PluginManager events = mock(PluginManager.class);
|
||||
SortingStickRecipeRegistrar recipes = mock(SortingStickRecipeRegistrar.class);
|
||||
when(recipes.register(server)).thenThrow(new IllegalStateException("registry failed"));
|
||||
when(server.getPluginManager()).thenReturn(events);
|
||||
RecipeLifecycle lifecycle = new RecipeLifecycle(
|
||||
server, plugin, recipes, Logger.getAnonymousLogger());
|
||||
|
||||
boolean enabled = assertDoesNotThrow(lifecycle::enable);
|
||||
|
||||
assertFalse(enabled);
|
||||
verify(recipes).unregister();
|
||||
verify(events, never()).registerEvents(any(), any());
|
||||
verify(server, never()).getOnlinePlayers();
|
||||
}
|
||||
|
||||
@Test
|
||||
void disableRemovesCurrentLifecycleRecipes() {
|
||||
Server server = mock(Server.class);
|
||||
Plugin plugin = mock(Plugin.class);
|
||||
PluginManager events = mock(PluginManager.class);
|
||||
SortingStickRecipeRegistrar recipes = mock(SortingStickRecipeRegistrar.class);
|
||||
when(recipes.register(server)).thenReturn(true);
|
||||
when(recipes.recipeKeys()).thenReturn(keys);
|
||||
when(recipes.description()).thenReturn("Sorting Stick recipes");
|
||||
when(server.getPluginManager()).thenReturn(events);
|
||||
doReturn(List.of()).when(server).getOnlinePlayers();
|
||||
RecipeLifecycle lifecycle = new RecipeLifecycle(
|
||||
server, plugin, recipes, Logger.getAnonymousLogger());
|
||||
|
||||
assertTrue(lifecycle.enable());
|
||||
lifecycle.disable();
|
||||
|
||||
verify(recipes).unregister();
|
||||
}
|
||||
|
||||
@Test
|
||||
void successfulRegistrationDiscoversForOnlineAndLaterJoiningPlayers() {
|
||||
Server server = mock(Server.class);
|
||||
Plugin plugin = mock(Plugin.class);
|
||||
PluginManager events = mock(PluginManager.class);
|
||||
SortingStickRecipeRegistrar recipes = mock(SortingStickRecipeRegistrar.class);
|
||||
when(recipes.register(server)).thenReturn(true);
|
||||
when(recipes.recipeKeys()).thenReturn(keys);
|
||||
when(recipes.description()).thenReturn("Sorting Stick recipes");
|
||||
when(server.getPluginManager()).thenReturn(events);
|
||||
Set<NamespacedKey> onlineKnown = new LinkedHashSet<>();
|
||||
Set<NamespacedKey> joiningKnown = new LinkedHashSet<>();
|
||||
doReturn(List.of(player("Online", onlineKnown))).when(server).getOnlinePlayers();
|
||||
RecipeLifecycle lifecycle = new RecipeLifecycle(
|
||||
server, plugin, recipes, Logger.getAnonymousLogger());
|
||||
|
||||
assertTrue(lifecycle.enable());
|
||||
ArgumentCaptor<Listener> listener = ArgumentCaptor.forClass(Listener.class);
|
||||
verify(events).registerEvents(listener.capture(), org.mockito.ArgumentMatchers.eq(plugin));
|
||||
((RecipeDiscovery) listener.getValue()).onJoin(
|
||||
new PlayerJoinEvent(player("Joining", joiningKnown), "joined"));
|
||||
|
||||
assertEquals(new LinkedHashSet<>(keys), onlineKnown);
|
||||
assertEquals(new LinkedHashSet<>(keys), joiningKnown);
|
||||
}
|
||||
|
||||
private static Player player(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;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -59,4 +62,96 @@ final class SortingStickRecipeRegistrarTest {
|
||||
keys.getAllValues().stream().map(NamespacedKey::getKey).toList()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void disableRemovesBothCurrentLifecycleRecipesAndNothingElse() {
|
||||
Server server = mock(Server.class);
|
||||
SortingStickItemFactory items = mock(SortingStickItemFactory.class);
|
||||
ItemStack result = mock(ItemStack.class);
|
||||
ShapedRecipe chestRecipe = mock(ShapedRecipe.class);
|
||||
ShapedRecipe barrelRecipe = mock(ShapedRecipe.class);
|
||||
SortingStickRecipeRegistrar.RecipeFactory recipes =
|
||||
mock(SortingStickRecipeRegistrar.RecipeFactory.class);
|
||||
when(items.create()).thenReturn(result);
|
||||
when(recipes.create(any(NamespacedKey.class), any(ItemStack.class)))
|
||||
.thenReturn(chestRecipe, barrelRecipe);
|
||||
when(server.addRecipe(any(ShapedRecipe.class))).thenReturn(true);
|
||||
when(server.removeRecipe(any(NamespacedKey.class))).thenReturn(true);
|
||||
SortingStickRecipeRegistrar registrar = new SortingStickRecipeRegistrar(
|
||||
new NamespacedKey("test", "sorting_stick_recipe"),
|
||||
items,
|
||||
SortingStickRecipeSpec.defaultRecipe(),
|
||||
recipes
|
||||
);
|
||||
|
||||
assertTrue(registrar.register(server));
|
||||
registrar.unregister();
|
||||
|
||||
verify(server).removeRecipe(new NamespacedKey("test", "sorting_stick_recipe_chest"));
|
||||
verify(server).removeRecipe(new NamespacedKey("test", "sorting_stick_recipe_barrel"));
|
||||
verify(server, times(2)).removeRecipe(any(NamespacedKey.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void conflictingVariantRollsBackOnlyTheRecipeAddedByThisLifecycle() {
|
||||
Server server = mock(Server.class);
|
||||
SortingStickItemFactory items = mock(SortingStickItemFactory.class);
|
||||
ItemStack result = mock(ItemStack.class);
|
||||
ShapedRecipe chestRecipe = mock(ShapedRecipe.class);
|
||||
ShapedRecipe barrelRecipe = mock(ShapedRecipe.class);
|
||||
SortingStickRecipeRegistrar.RecipeFactory recipes =
|
||||
mock(SortingStickRecipeRegistrar.RecipeFactory.class);
|
||||
NamespacedKey chestKey = new NamespacedKey("test", "sorting_stick_recipe_chest");
|
||||
NamespacedKey barrelKey = new NamespacedKey("test", "sorting_stick_recipe_barrel");
|
||||
when(items.create()).thenReturn(result);
|
||||
when(recipes.create(any(NamespacedKey.class), any(ItemStack.class)))
|
||||
.thenReturn(chestRecipe, barrelRecipe);
|
||||
when(server.addRecipe(chestRecipe)).thenReturn(true);
|
||||
when(server.getRecipe(barrelKey)).thenReturn(mock(org.bukkit.inventory.Recipe.class));
|
||||
when(server.removeRecipe(chestKey)).thenReturn(true);
|
||||
SortingStickRecipeRegistrar registrar = new SortingStickRecipeRegistrar(
|
||||
new NamespacedKey("test", "sorting_stick_recipe"),
|
||||
items,
|
||||
SortingStickRecipeSpec.defaultRecipe(),
|
||||
recipes
|
||||
);
|
||||
|
||||
assertFalse(registrar.register(server));
|
||||
|
||||
verify(server).removeRecipe(chestKey);
|
||||
verify(server, never()).removeRecipe(barrelKey);
|
||||
verify(server, never()).addRecipe(barrelRecipe);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedRemovalRetainsOwnershipForLaterRetry() {
|
||||
Server server = mock(Server.class);
|
||||
SortingStickItemFactory items = mock(SortingStickItemFactory.class);
|
||||
ItemStack result = mock(ItemStack.class);
|
||||
ShapedRecipe chestRecipe = mock(ShapedRecipe.class);
|
||||
ShapedRecipe barrelRecipe = mock(ShapedRecipe.class);
|
||||
SortingStickRecipeRegistrar.RecipeFactory recipes =
|
||||
mock(SortingStickRecipeRegistrar.RecipeFactory.class);
|
||||
NamespacedKey chestKey = new NamespacedKey("test", "sorting_stick_recipe_chest");
|
||||
NamespacedKey barrelKey = new NamespacedKey("test", "sorting_stick_recipe_barrel");
|
||||
when(items.create()).thenReturn(result);
|
||||
when(recipes.create(any(NamespacedKey.class), any(ItemStack.class)))
|
||||
.thenReturn(chestRecipe, barrelRecipe);
|
||||
when(server.addRecipe(any(ShapedRecipe.class))).thenReturn(true);
|
||||
when(server.removeRecipe(chestKey)).thenReturn(false, true);
|
||||
when(server.removeRecipe(barrelKey)).thenReturn(true);
|
||||
SortingStickRecipeRegistrar registrar = new SortingStickRecipeRegistrar(
|
||||
new NamespacedKey("test", "sorting_stick_recipe"),
|
||||
items,
|
||||
SortingStickRecipeSpec.defaultRecipe(),
|
||||
recipes
|
||||
);
|
||||
|
||||
assertTrue(registrar.register(server));
|
||||
assertThrows(IllegalStateException.class, registrar::unregister);
|
||||
registrar.unregister();
|
||||
|
||||
verify(server, times(2)).removeRecipe(chestKey);
|
||||
verify(server).removeRecipe(barrelKey);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user