Compare commits

...
13 Commits
Author SHA1 Message Date
dmg 24a220ae4a feat(crafting): add cobweb recipe from string
Release / release (push) Successful in 1m30s
CI / build (push) Successful in 2m15s
US-007: 69 tests pass; both exact-head CI checks passed.
2026-10-08 00:24:19 +00:00
dmg ae899926fe feat(crafting): add cobweb recipe from string
CI / build (pull_request) Successful in 2m19s
CI / build (push) Successful in 6m46s
2026-10-07 19:54:57 -04:00
dmg 534cab061c feat(crafting): add stackable dispenser recipe
Release / release (push) Successful in 4m12s
CI / build (push) Successful in 3m23s
2026-09-22 21:20:47 -04:00
dmg 7854824f01 feat(inventory): discover sorting stick recipes
Release / release (push) Successful in 6m9s
CI / build (push) Successful in 2m2s
2026-09-20 14:48:40 -04:00
dmg 0452e6701d feat(inventory): adopt Purpur distribution naming
CI / build (push) Successful in 1m12s
Release / release (push) Successful in 4m38s
BREAKING CHANGE: New release assets use purpur-inventory-helper-<version>.jar. Runtime SpigotInventoryHelper identity and existing sorting-stick/recipe keys remain unchanged. Replace the old JAR rather than installing both distributions.
2026-09-12 13:26:11 -04:00
dmg 862c9a8a84 chore(knowledge): move canonical docs to shared SoMC wiki
CI / build (push) Successful in 1m22s
Release / release (push) Successful in 2m5s
2026-09-09 23:18:17 -04:00
dmg 3960672104 feat(tools): replace broken main-hand tools
Release / release (push) Successful in 2m54s
CI / build (push) Failing after 18m56s
2026-09-04 12:00:56 -04:00
dmg d36ba9da19 docs(tools): start broken-tool replacement story
CI / build (push) In progress
Release / release (push) In progress
2026-09-04 11:56:37 -04:00
dmg 1566437ba3 feat(sort): sort containers and player storage
CI / build (push) Failing after 5m25s
Release / release (push) Failing after 28m43s
2026-09-04 11:56:19 -04:00
dmg 5829d1d1dd docs(sort): start inventory sorting story
Release / release (push) Successful in 1m59s
CI / build (push) Successful in 1m1s
2026-09-04 11:48:20 -04:00
dmg 848f5c643f feat(sort): add craftable sorting stick
CI / build (push) Successful in 1m14s
Release / release (push) Successful in 2m11s
2026-09-04 11:48:09 -04:00
dmg ad29db0a79 docs(sort): start sorting stick story
CI / build (push) Successful in 1m7s
Release / release (push) Successful in 1m56s
2026-09-04 11:41:51 -04:00
dmg df5dd43539 docs(build): complete build and release story
CI / build (push) Successful in 1m4s
Release / release (push) Successful in 2m0s
2026-09-04 11:38:30 -04:00
48 changed files with 3822 additions and 203 deletions

No files matched your search

+1 -1
View File
@@ -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
+4 -4
View File
@@ -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"
+5 -33
View File
@@ -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.
+38 -4
View File
@@ -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,29 @@ 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
Hold an authentic Sorting Stick and right-click:
- A chest or either half of a double chest to sort its complete inventory.
- A barrel to sort its inventory.
- Air or another block to sort player inventory slots 9–35.
Player hotbar, armor, and off-hand slots remain unchanged during player sorting. Compatible stacks are consolidated first, then items are ordered alphabetically by Minecraft material. Dissimilar metadata variants of the same material retain their relative order. Locked containers and interactions cancelled by protection plugins are not sorted.
## Automatic tool replacement
When a main-hand tool breaks, an item with the exact same material is moved into the broken tool's selected hotbar slot on the next server tick. Main-storage candidates are preferred before tools in other hotbar slots. The complete replacement item is moved unchanged, preserving durability, enchantments, custom names, and other metadata. Off-hand breaks are ignored, and no unrelated item is moved when a match is unavailable.
## Releases
+62
View File
@@ -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 {
-15
View File
@@ -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)
-25
View File
@@ -1,25 +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 — 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.
-12
View File
@@ -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: in-progress
---
# 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
- [ ] The Gradle project compiles against Purpur API `26.2.build.2618-stable` using a Java 25 toolchain.
- [ ] Compiler lint warnings fail the build.
- [ ] Automated JUnit 5 tests run as part of the Gradle check lifecycle.
- [ ] Pushes and pull requests build and test the plugin in Gitea Actions.
- [ ] Pull requests validate conventional commit messages.
- [ ] CI stores a development JAR as a workflow artifact.
- [ ] Main-branch conventional commits drive semantic versioning.
- [ ] A successful release builds a versioned JAR and attaches it to the corresponding Gitea release.
- [ ] 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: backlog
---
# 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
- [ ] The plugin registers a vertical shaped recipe with a Chest or Barrel above Redstone Dust and Redstone Dust above a Stick.
- [ ] The three ingredients occupy the center column of the 3×3 crafting grid.
- [ ] Either the Chest or Barrel recipe variant produces one Sorting Stick.
- [ ] The Sorting Stick has a distinct player-facing name and appearance.
- [ ] The item is identified using persistent item metadata rather than its display name alone.
- [ ] Renamed ordinary sticks cannot trigger sorting.
- [ ] Automated tests cover recipe registration, the exact shape, both storage-block variants, output quantity, and resulting item identity.
- [ ] 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: backlog
---
# 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
- [ ] Using the Sorting Stick on a chest sorts the complete chest inventory.
- [ ] Using it on either half of a double chest sorts the combined inventory.
- [ ] Using it on a barrel sorts that barrel.
- [ ] Using it without targeting a supported container sorts the player's main storage slots.
- [ ] Player hotbar, armor, and off-hand slots are not changed during player-inventory sorting.
- [ ] Compatible partial stacks are consolidated without exceeding item stack limits.
- [ ] Remaining stacks are placed in a deterministic, documented order.
- [ ] Sorting preserves item quantities and all item metadata exactly.
- [ ] Sorting respects cancelled interactions and normal container-access protections.
- [ ] Unsupported or inaccessible targets fail safely without changing an inventory.
- [ ] Sorting provides concise player feedback without opening the target container.
- [ ] 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: backlog
---
# 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
- [ ] When a player's main-hand tool breaks, the plugin searches the player's inventory for a replacement.
- [ ] A replacement must have the same tool type and material as the broken tool; for example, a diamond pickaxe replaces only a diamond pickaxe.
- [ ] Eligible replacements may come from the main storage inventory or another hotbar slot.
- [ ] The replacement is moved into the currently selected hotbar slot.
- [ ] Existing durability, enchantments, custom names, persistent data, and other metadata on the replacement are preserved.
- [ ] No unrelated item is moved when an equivalent replacement is unavailable.
- [ ] Off-hand tool breakage does not replace the main-hand item.
- [ ] Cancelled damage or break events do not trigger replacement.
- [ ] 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
View File
@@ -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,58 @@
package games.dmg.spigotinventoryhelper;
import java.util.Objects;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerItemBreakEvent;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
import org.bukkit.plugin.Plugin;
public final class BrokenToolListener implements Listener {
private final ToolReplacementService replacements;
private final TaskScheduler scheduler;
public BrokenToolListener(Plugin plugin, ToolReplacementService replacements) {
this(replacements, task -> plugin.getServer().getScheduler().runTask(plugin, task));
}
BrokenToolListener(ToolReplacementService replacements, TaskScheduler scheduler) {
this.replacements = Objects.requireNonNull(replacements, "replacements");
this.scheduler = Objects.requireNonNull(scheduler, "scheduler");
}
@EventHandler(priority = EventPriority.MONITOR)
public void onItemBreak(PlayerItemBreakEvent event) {
Player player = event.getPlayer();
PlayerInventory inventory = player.getInventory();
ItemStack broken = event.getBrokenItem();
if (inventory.getItemInMainHand() != broken) {
return;
}
Material material = broken.getType();
int selectedSlot = inventory.getHeldItemSlot();
scheduler.schedule(() -> {
if (replacements.replace(inventory, material, selectedSlot)) {
player.sendActionBar(Component.text(
"Equipped another " + readableName(material) + ".",
NamedTextColor.GREEN
));
}
});
}
private static String readableName(Material material) {
return material.name().toLowerCase(java.util.Locale.ROOT).replace('_', ' ');
}
@FunctionalInterface
interface TaskScheduler {
void schedule(Runnable task);
}
}
@@ -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.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import org.bukkit.Material;
import org.bukkit.inventory.ItemStack;
public final class InventorySorter {
public ItemStack[] sort(ItemStack[] contents) {
Objects.requireNonNull(contents, "contents");
List<ItemStack> items = Arrays.stream(contents)
.filter(Objects::nonNull)
.filter(item -> item.getType() != Material.AIR)
.filter(item -> item.getAmount() > 0)
.map(ItemStack::clone)
.sorted(Comparator.comparing(item -> item.getType().name()))
.toList();
List<ItemStack> packed = new ArrayList<>();
for (ItemStack item : items) {
pack(item, packed);
}
ItemStack[] sorted = new ItemStack[contents.length];
for (int slot = 0; slot < packed.size(); slot++) {
sorted[slot] = packed.get(slot);
}
return sorted;
}
private static void pack(ItemStack item, List<ItemStack> packed) {
int remaining = item.getAmount();
for (ItemStack existing : packed) {
if (!existing.isSimilar(item) || existing.getAmount() >= existing.getMaxStackSize()) {
continue;
}
int transferred = Math.min(
remaining,
existing.getMaxStackSize() - existing.getAmount()
);
existing.setAmount(existing.getAmount() + transferred);
remaining -= transferred;
if (remaining == 0) {
return;
}
}
while (remaining > 0) {
ItemStack stack = item.clone();
int amount = Math.min(remaining, stack.getMaxStackSize());
stack.setAmount(amount);
packed.add(stack);
remaining -= amount;
}
}
}
@@ -0,0 +1,39 @@
package games.dmg.spigotinventoryhelper;
import java.util.Arrays;
import java.util.Objects;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
public final class InventorySortingService {
private static final int HOTBAR_SIZE = 9;
private static final int PLAYER_STORAGE_SIZE = 36;
private final InventorySorter sorter;
public InventorySortingService(InventorySorter sorter) {
this.sorter = Objects.requireNonNull(sorter, "sorter");
}
public void sortPlayerMainStorage(PlayerInventory inventory) {
Objects.requireNonNull(inventory, "inventory");
ItemStack[] storage = inventory.getStorageContents().clone();
if (storage.length < PLAYER_STORAGE_SIZE) {
throw new IllegalArgumentException("Player storage must contain 36 slots");
}
ItemStack[] mainStorage = Arrays.copyOfRange(
storage,
HOTBAR_SIZE,
PLAYER_STORAGE_SIZE
);
ItemStack[] sorted = sorter.sort(mainStorage);
System.arraycopy(sorted, 0, storage, HOTBAR_SIZE, sorted.length);
inventory.setStorageContents(storage);
}
public void sortContainer(Inventory inventory) {
Objects.requireNonNull(inventory, "inventory");
inventory.setContents(sorter.sort(inventory.getContents()));
}
}
@@ -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();
}
@@ -0,0 +1,58 @@
package games.dmg.spigotinventoryhelper;
import java.util.Objects;
import java.util.function.Supplier;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.persistence.PersistentDataType;
public final class SortingStickItemFactory {
private static final byte AUTHENTIC_MARKER = 1;
private final NamespacedKey identityKey;
private final Supplier<ItemStack> itemSupplier;
public SortingStickItemFactory(NamespacedKey identityKey) {
this(identityKey, () -> new ItemStack(Material.STICK));
}
SortingStickItemFactory(NamespacedKey identityKey, Supplier<ItemStack> itemSupplier) {
this.identityKey = Objects.requireNonNull(identityKey, "identityKey");
this.itemSupplier = Objects.requireNonNull(itemSupplier, "itemSupplier");
}
public ItemStack create() {
ItemStack item = itemSupplier.get();
item.setType(Material.STICK);
item.setAmount(1);
ItemMeta metadata = Objects.requireNonNull(item.getItemMeta(), "Stick metadata");
metadata.displayName(Component.text("Sorting Stick", NamedTextColor.GOLD));
metadata.setEnchantmentGlintOverride(true);
metadata.getPersistentDataContainer().set(
identityKey,
PersistentDataType.BYTE,
AUTHENTIC_MARKER
);
item.setItemMeta(metadata);
return item;
}
public boolean isSortingStick(ItemStack item) {
if (item == null || item.getType() != Material.STICK || !item.hasItemMeta()) {
return false;
}
ItemMeta metadata = item.getItemMeta();
if (metadata == null) {
return false;
}
Byte marker = metadata.getPersistentDataContainer().get(
identityKey,
PersistentDataType.BYTE
);
return marker != null && marker == AUTHENTIC_MARKER;
}
}
@@ -0,0 +1,66 @@
package games.dmg.spigotinventoryhelper;
import java.util.Objects;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.block.Barrel;
import org.bukkit.block.Block;
import org.bukkit.block.Chest;
import org.bukkit.block.Container;
import org.bukkit.block.Lockable;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.block.Action;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.EquipmentSlot;
public final class SortingStickListener implements Listener {
private final SortingStickItemFactory sticks;
private final InventorySortingService sorting;
public SortingStickListener(
SortingStickItemFactory sticks,
InventorySortingService sorting
) {
this.sticks = Objects.requireNonNull(sticks, "sticks");
this.sorting = Objects.requireNonNull(sorting, "sorting");
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onInteract(PlayerInteractEvent event) {
if (event.isCancelled()
|| event.getHand() != EquipmentSlot.HAND
|| !isRightClick(event.getAction())
|| !sticks.isSortingStick(event.getItem())) {
return;
}
event.setCancelled(true);
Player player = event.getPlayer();
Block clicked = event.getClickedBlock();
if (clicked != null) {
Object state = clicked.getState();
if (state instanceof Chest || state instanceof Barrel) {
if (((Lockable) state).isLocked()) {
player.sendActionBar(Component.text(
"That container is locked.",
NamedTextColor.RED
));
return;
}
sorting.sortContainer(((Container) state).getInventory());
player.sendActionBar(Component.text("Container sorted.", NamedTextColor.GREEN));
return;
}
}
sorting.sortPlayerMainStorage(player.getInventory());
player.sendActionBar(Component.text("Inventory sorted.", NamedTextColor.GREEN));
}
private static boolean isRightClick(Action action) {
return action == Action.RIGHT_CLICK_AIR || action == Action.RIGHT_CLICK_BLOCK;
}
}
@@ -0,0 +1,142 @@
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 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,
SortingStickItemFactory itemFactory,
SortingStickRecipeSpec recipeSpec
) {
this(recipeKey, itemFactory, recipeSpec, ShapedRecipe::new);
}
SortingStickRecipeRegistrar(
NamespacedKey recipeKey,
SortingStickItemFactory itemFactory,
SortingStickRecipeSpec recipeSpec,
RecipeFactory recipeFactory
) {
this.recipeKey = Objects.requireNonNull(recipeKey, "recipeKey");
this.itemFactory = Objects.requireNonNull(itemFactory, "itemFactory");
this.recipeSpec = Objects.requireNonNull(recipeSpec, "recipeSpec");
this.recipeFactory = Objects.requireNonNull(recipeFactory, "recipeFactory");
}
public boolean register(Server server) {
Objects.requireNonNull(server, "server");
if (!ownedRecipeKeys.isEmpty()) {
return true;
}
registeredServer = server;
for (Material storage : List.of(Material.CHEST, Material.BARREL)) {
ShapedRecipe recipe = createRecipe(storage);
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 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());
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);
}
}
@@ -0,0 +1,29 @@
package games.dmg.spigotinventoryhelper;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.bukkit.Material;
public record SortingStickRecipeSpec(
List<String> shape,
Map<Character, Set<Material>> ingredients,
int outputAmount
) {
public SortingStickRecipeSpec {
shape = List.copyOf(shape);
ingredients = Map.copyOf(ingredients);
}
public static SortingStickRecipeSpec defaultRecipe() {
return new SortingStickRecipeSpec(
List.of(" C ", " R ", " S "),
Map.of(
'C', Set.of(Material.CHEST, Material.BARREL),
'R', Set.of(Material.REDSTONE),
'S', Set.of(Material.STICK)
),
1
);
}
}
@@ -1,10 +1,74 @@
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() {
sortingSticks = new SortingStickItemFactory(new NamespacedKey(this, "sorting_stick"));
SortingStickRecipeRegistrar recipes = new SortingStickRecipeRegistrar(
new NamespacedKey(this, "sorting_stick_recipe"),
sortingSticks,
SortingStickRecipeSpec.defaultRecipe()
);
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),
this
);
getServer().getPluginManager().registerEvents(
new BrokenToolListener(this, new ToolReplacementService()),
this
);
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");
}
return sortingSticks;
}
}
@@ -0,0 +1,61 @@
package games.dmg.spigotinventoryhelper;
import java.util.Objects;
import org.bukkit.Material;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
public final class ToolReplacementService {
private static final int HOTBAR_SIZE = 9;
private static final int PLAYER_STORAGE_SIZE = 36;
public boolean replace(
PlayerInventory inventory,
Material brokenMaterial,
int selectedSlot
) {
Objects.requireNonNull(inventory, "inventory");
Objects.requireNonNull(brokenMaterial, "brokenMaterial");
if (selectedSlot < 0 || selectedSlot >= HOTBAR_SIZE || !isEmpty(inventory.getItem(selectedSlot))) {
return false;
}
ItemStack[] storage = inventory.getStorageContents();
int source = find(storage, brokenMaterial, HOTBAR_SIZE, PLAYER_STORAGE_SIZE, selectedSlot);
if (source < 0) {
source = find(storage, brokenMaterial, 0, HOTBAR_SIZE, selectedSlot);
}
if (source < 0) {
return false;
}
ItemStack replacement = storage[source];
inventory.setItem(selectedSlot, replacement);
inventory.setItem(source, null);
return true;
}
private static int find(
ItemStack[] storage,
Material material,
int from,
int to,
int excludedSlot
) {
int limit = Math.min(to, storage.length);
for (int slot = from; slot < limit; slot++) {
ItemStack candidate = storage[slot];
if (slot != excludedSlot
&& candidate != null
&& candidate.getAmount() > 0
&& candidate.getType() == material) {
return slot;
}
}
return -1;
}
private static boolean isEmpty(ItemStack item) {
return item == null || item.getType() == Material.AIR || item.getAmount() == 0;
}
}
@@ -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,59 @@
package games.dmg.spigotinventoryhelper;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.util.concurrent.atomic.AtomicReference;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.event.player.PlayerItemBreakEvent;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
import org.junit.jupiter.api.Test;
final class BrokenToolListenerTest {
@Test
void replacesAMainHandToolOnTheNextServerTick() {
ToolReplacementService replacements = mock(ToolReplacementService.class);
AtomicReference<Runnable> scheduled = new AtomicReference<>();
BrokenToolListener listener = new BrokenToolListener(replacements, scheduled::set);
PlayerItemBreakEvent event = mock(PlayerItemBreakEvent.class);
Player player = mock(Player.class);
PlayerInventory inventory = mock(PlayerInventory.class);
ItemStack broken = mock(ItemStack.class);
when(event.getPlayer()).thenReturn(player);
when(event.getBrokenItem()).thenReturn(broken);
when(player.getInventory()).thenReturn(inventory);
when(inventory.getItemInMainHand()).thenReturn(broken);
when(inventory.getHeldItemSlot()).thenReturn(3);
when(broken.getType()).thenReturn(Material.DIAMOND_PICKAXE);
listener.onItemBreak(event);
scheduled.get().run();
verify(replacements).replace(inventory, Material.DIAMOND_PICKAXE, 3);
}
@Test
void ignoresAnOffHandToolBreak() {
ToolReplacementService replacements = mock(ToolReplacementService.class);
AtomicReference<Runnable> scheduled = new AtomicReference<>();
BrokenToolListener listener = new BrokenToolListener(replacements, scheduled::set);
PlayerItemBreakEvent event = mock(PlayerItemBreakEvent.class);
Player player = mock(Player.class);
PlayerInventory inventory = mock(PlayerInventory.class);
ItemStack mainHand = mock(ItemStack.class);
ItemStack brokenOffHand = mock(ItemStack.class);
when(event.getPlayer()).thenReturn(player);
when(event.getBrokenItem()).thenReturn(brokenOffHand);
when(player.getInventory()).thenReturn(inventory);
when(inventory.getItemInMainHand()).thenReturn(mainHand);
listener.onItemBreak(event);
verifyNoInteractions(replacements);
org.junit.jupiter.api.Assertions.assertNull(scheduled.get());
}
}
@@ -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());
}
}
@@ -0,0 +1,89 @@
package games.dmg.spigotinventoryhelper;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.concurrent.atomic.AtomicInteger;
import org.bukkit.Material;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.junit.jupiter.api.Test;
final class InventorySorterTest {
@Test
void consolidatesStacksAndOrdersMaterialsAlphabetically() {
ItemStack[] contents = {
stack(Material.STONE, 5),
stack(Material.DIRT, 40),
stack(Material.DIRT, 30),
null
};
ItemStack[] sorted = new InventorySorter().sort(contents);
assertAll(
() -> assertEquals(Material.DIRT, sorted[0].getType()),
() -> assertEquals(64, sorted[0].getAmount()),
() -> assertEquals(Material.DIRT, sorted[1].getType()),
() -> assertEquals(6, sorted[1].getAmount()),
() -> assertEquals(Material.STONE, sorted[2].getType()),
() -> assertEquals(5, sorted[2].getAmount()),
() -> assertNull(sorted[3])
);
}
@Test
void preservesMetadataAndStableOrderForDissimilarVariants() {
ItemMeta named = mock(ItemMeta.class, "named");
ItemMeta enchanted = mock(ItemMeta.class, "enchanted");
ItemStack[] contents = {
stack(Material.STONE, 5, named),
stack(Material.STONE, 2, enchanted),
stack(Material.STONE, 4, named)
};
ItemStack[] sorted = new InventorySorter().sort(contents);
assertAll(
() -> assertSame(named, sorted[0].getItemMeta()),
() -> assertEquals(9, sorted[0].getAmount()),
() -> assertSame(enchanted, sorted[1].getItemMeta()),
() -> assertEquals(2, sorted[1].getAmount()),
() -> assertNull(sorted[2])
);
}
private static ItemStack stack(Material material, int initialAmount) {
return stack(material, initialAmount, null);
}
private static ItemStack stack(
Material material,
int initialAmount,
ItemMeta metadata
) {
AtomicInteger amount = new AtomicInteger(initialAmount);
ItemStack item = mock(ItemStack.class);
when(item.getType()).thenReturn(material);
when(item.getAmount()).thenAnswer(ignored -> amount.get());
when(item.getItemMeta()).thenReturn(metadata);
doAnswer(invocation -> {
amount.set(invocation.getArgument(0));
return null;
}).when(item).setAmount(anyInt());
when(item.getMaxStackSize()).thenReturn(64);
when(item.isSimilar(any(ItemStack.class))).thenAnswer(invocation -> {
ItemStack other = invocation.getArgument(0);
return other.getType() == material && other.getItemMeta() == metadata;
});
when(item.clone()).thenAnswer(ignored -> stack(material, amount.get(), metadata));
return item;
}
}
@@ -0,0 +1,44 @@
package games.dmg.spigotinventoryhelper;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Arrays;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
final class InventorySortingServiceTest {
@Test
void sortsOnlyPlayerMainStorageSlots() {
PlayerInventory inventory = mock(PlayerInventory.class);
InventorySorter sorter = mock(InventorySorter.class);
ItemStack[] storage = stacks(36);
ItemStack[] mainStorage = Arrays.copyOfRange(storage, 9, 36);
ItemStack[] sortedMain = mainStorage.clone();
ItemStack first = sortedMain[0];
sortedMain[0] = sortedMain[26];
sortedMain[26] = first;
when(inventory.getStorageContents()).thenReturn(storage);
when(sorter.sort(mainStorage)).thenReturn(sortedMain);
new InventorySortingService(sorter).sortPlayerMainStorage(inventory);
ArgumentCaptor<ItemStack[]> result = ArgumentCaptor.forClass(ItemStack[].class);
verify(inventory).setStorageContents(result.capture());
assertArrayEquals(Arrays.copyOfRange(storage, 0, 9),
Arrays.copyOfRange(result.getValue(), 0, 9));
assertArrayEquals(sortedMain, Arrays.copyOfRange(result.getValue(), 9, 36));
}
private static ItemStack[] stacks(int size) {
ItemStack[] stacks = new ItemStack[size];
for (int slot = 0; slot < size; slot++) {
stacks[slot] = mock(ItemStack.class, "slot-" + slot);
}
return stacks;
}
}
@@ -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;
}
}
@@ -0,0 +1,74 @@
package games.dmg.spigotinventoryhelper;
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.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.persistence.PersistentDataContainer;
import org.bukkit.persistence.PersistentDataType;
import org.junit.jupiter.api.Test;
final class SortingStickItemFactoryTest {
@Test
void createsOneDistinctMetadataAuthenticatedStick() {
NamespacedKey key = new NamespacedKey("test", "sorting_stick");
ItemStack item = mock(ItemStack.class);
ItemMeta metadata = mock(ItemMeta.class);
PersistentDataContainer data = mock(PersistentDataContainer.class);
when(item.getItemMeta()).thenReturn(metadata);
when(metadata.getPersistentDataContainer()).thenReturn(data);
SortingStickItemFactory factory = new SortingStickItemFactory(key, () -> item);
assertSame(item, factory.create());
verify(item).setAmount(1);
verify(metadata).displayName(Component.text("Sorting Stick", NamedTextColor.GOLD));
verify(metadata).setEnchantmentGlintOverride(true);
verify(data).set(key, PersistentDataType.BYTE, (byte) 1);
verify(item).setItemMeta(metadata);
verify(item).getItemMeta();
verify(item).setType(Material.STICK);
}
@Test
void rejectsARegularRenamedStickWithoutPersistentIdentity() {
NamespacedKey key = new NamespacedKey("test", "sorting_stick");
ItemStack item = mock(ItemStack.class);
ItemMeta metadata = mock(ItemMeta.class);
PersistentDataContainer data = mock(PersistentDataContainer.class);
when(item.getType()).thenReturn(Material.STICK);
when(item.hasItemMeta()).thenReturn(true);
when(item.getItemMeta()).thenReturn(metadata);
when(metadata.getPersistentDataContainer()).thenReturn(data);
SortingStickItemFactory factory = new SortingStickItemFactory(key, () -> item);
assertFalse(factory.isSortingStick(item));
}
@Test
void acceptsAStickWithTheAuthenticPersistentMarker() {
NamespacedKey key = new NamespacedKey("test", "sorting_stick");
ItemStack item = mock(ItemStack.class);
ItemMeta metadata = mock(ItemMeta.class);
PersistentDataContainer data = mock(PersistentDataContainer.class);
when(item.getType()).thenReturn(Material.STICK);
when(item.hasItemMeta()).thenReturn(true);
when(item.getItemMeta()).thenReturn(metadata);
when(metadata.getPersistentDataContainer()).thenReturn(data);
when(data.get(key, PersistentDataType.BYTE)).thenReturn((byte) 1);
SortingStickItemFactory factory = new SortingStickItemFactory(key, () -> item);
assertTrue(factory.isSortingStick(item));
}
}
@@ -0,0 +1,173 @@
package games.dmg.spigotinventoryhelper;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import org.bukkit.block.Barrel;
import org.bukkit.block.Block;
import org.bukkit.block.Chest;
import org.bukkit.entity.Player;
import org.bukkit.event.block.Action;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.DoubleChestInventory;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
import org.junit.jupiter.api.Test;
final class SortingStickListenerTest {
@Test
void sortsAChestWithoutOpeningIt() {
SortingStickItemFactory sticks = mock(SortingStickItemFactory.class);
InventorySortingService sorting = mock(InventorySortingService.class);
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
Player player = mock(Player.class);
ItemStack stick = mock(ItemStack.class);
Block block = mock(Block.class);
Chest chest = mock(Chest.class);
Inventory inventory = mock(Inventory.class);
when(event.getHand()).thenReturn(EquipmentSlot.HAND);
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK);
when(event.getItem()).thenReturn(stick);
when(event.getPlayer()).thenReturn(player);
when(event.getClickedBlock()).thenReturn(block);
when(block.getState()).thenReturn(chest);
when(chest.getInventory()).thenReturn(inventory);
when(sticks.isSortingStick(stick)).thenReturn(true);
new SortingStickListener(sticks, sorting).onInteract(event);
verify(sorting).sortContainer(inventory);
verify(event).setCancelled(true);
}
@Test
void sortsABarrelWithoutOpeningIt() {
SortingStickItemFactory sticks = mock(SortingStickItemFactory.class);
InventorySortingService sorting = mock(InventorySortingService.class);
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
ItemStack stick = mock(ItemStack.class);
Block block = mock(Block.class);
Barrel barrel = mock(Barrel.class);
Inventory inventory = mock(Inventory.class);
when(event.getHand()).thenReturn(EquipmentSlot.HAND);
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK);
when(event.getItem()).thenReturn(stick);
when(event.getPlayer()).thenReturn(mock(Player.class));
when(event.getClickedBlock()).thenReturn(block);
when(block.getState()).thenReturn(barrel);
when(barrel.getInventory()).thenReturn(inventory);
when(sticks.isSortingStick(stick)).thenReturn(true);
new SortingStickListener(sticks, sorting).onInteract(event);
verify(sorting).sortContainer(inventory);
verify(event).setCancelled(true);
}
@Test
void refusesToSortALockedContainer() {
SortingStickItemFactory sticks = mock(SortingStickItemFactory.class);
InventorySortingService sorting = mock(InventorySortingService.class);
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
ItemStack stick = mock(ItemStack.class);
Block block = mock(Block.class);
Chest chest = mock(Chest.class);
when(event.getHand()).thenReturn(EquipmentSlot.HAND);
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK);
when(event.getItem()).thenReturn(stick);
when(event.getPlayer()).thenReturn(mock(Player.class));
when(event.getClickedBlock()).thenReturn(block);
when(block.getState()).thenReturn(chest);
when(chest.isLocked()).thenReturn(true);
when(sticks.isSortingStick(stick)).thenReturn(true);
new SortingStickListener(sticks, sorting).onInteract(event);
verifyNoInteractions(sorting);
verify(event).setCancelled(true);
}
@Test
void sortsTheCombinedInventoryReturnedByEitherHalfOfADoubleChest() {
SortingStickItemFactory sticks = mock(SortingStickItemFactory.class);
InventorySortingService sorting = mock(InventorySortingService.class);
PlayerInteractEvent event = authenticBlockInteraction(sticks);
Block block = event.getClickedBlock();
Chest chest = mock(Chest.class);
DoubleChestInventory combined = mock(DoubleChestInventory.class);
when(block.getState()).thenReturn(chest);
when(chest.getInventory()).thenReturn(combined);
new SortingStickListener(sticks, sorting).onInteract(event);
verify(sorting).sortContainer(combined);
}
@Test
void rightClickingAirSortsOnlyThePlayerMainStorage() {
SortingStickItemFactory sticks = mock(SortingStickItemFactory.class);
InventorySortingService sorting = mock(InventorySortingService.class);
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
Player player = mock(Player.class);
PlayerInventory inventory = mock(PlayerInventory.class);
ItemStack stick = mock(ItemStack.class);
when(event.getHand()).thenReturn(EquipmentSlot.HAND);
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_AIR);
when(event.getItem()).thenReturn(stick);
when(event.getPlayer()).thenReturn(player);
when(player.getInventory()).thenReturn(inventory);
when(sticks.isSortingStick(stick)).thenReturn(true);
new SortingStickListener(sticks, sorting).onInteract(event);
verify(sorting).sortPlayerMainStorage(inventory);
verify(event).setCancelled(true);
}
@Test
void respectsAnInteractionCancelledByAnotherPlugin() {
SortingStickItemFactory sticks = mock(SortingStickItemFactory.class);
InventorySortingService sorting = mock(InventorySortingService.class);
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
when(event.isCancelled()).thenReturn(true);
new SortingStickListener(sticks, sorting).onInteract(event);
verifyNoInteractions(sticks, sorting);
verify(event, never()).setCancelled(true);
}
@Test
void ignoresAnOrdinaryStick() {
SortingStickItemFactory sticks = mock(SortingStickItemFactory.class);
InventorySortingService sorting = mock(InventorySortingService.class);
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
when(event.getHand()).thenReturn(EquipmentSlot.HAND);
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_AIR);
when(event.getItem()).thenReturn(mock(ItemStack.class));
new SortingStickListener(sticks, sorting).onInteract(event);
verifyNoInteractions(sorting);
verify(event, never()).setCancelled(true);
}
private static PlayerInteractEvent authenticBlockInteraction(
SortingStickItemFactory sticks
) {
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
ItemStack stick = mock(ItemStack.class);
when(event.getHand()).thenReturn(EquipmentSlot.HAND);
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK);
when(event.getItem()).thenReturn(stick);
when(event.getPlayer()).thenReturn(mock(Player.class));
when(event.getClickedBlock()).thenReturn(mock(Block.class));
when(sticks.isSortingStick(stick)).thenReturn(true);
return event;
}
}
@@ -0,0 +1,157 @@
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;
import java.util.List;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.Server;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.ShapedRecipe;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
final class SortingStickRecipeRegistrarTest {
@Test
void registersChestAndBarrelVariants() {
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(chestRecipe)).thenReturn(true);
when(server.addRecipe(barrelRecipe)).thenReturn(true);
SortingStickRecipeRegistrar registrar = new SortingStickRecipeRegistrar(
new NamespacedKey("test", "sorting_stick_recipe"),
items,
SortingStickRecipeSpec.defaultRecipe(),
recipes
);
assertTrue(registrar.register(server));
verify(chestRecipe).shape(" C ", " R ", " S ");
verify(chestRecipe).setIngredient('C', Material.CHEST);
verify(chestRecipe).setIngredient('R', Material.REDSTONE);
verify(chestRecipe).setIngredient('S', Material.STICK);
verify(barrelRecipe).shape(" C ", " R ", " S ");
verify(barrelRecipe).setIngredient('C', Material.BARREL);
verify(barrelRecipe).setIngredient('R', Material.REDSTONE);
verify(barrelRecipe).setIngredient('S', Material.STICK);
verify(server).addRecipe(chestRecipe);
verify(server).addRecipe(barrelRecipe);
verify(result, times(2)).setAmount(1);
ArgumentCaptor<NamespacedKey> keys = ArgumentCaptor.forClass(NamespacedKey.class);
verify(recipes, times(2)).create(keys.capture(), any(ItemStack.class));
assertEquals(
List.of("sorting_stick_recipe_chest", "sorting_stick_recipe_barrel"),
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);
}
}
@@ -0,0 +1,21 @@
package games.dmg.spigotinventoryhelper;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.List;
import java.util.Set;
import org.bukkit.Material;
import org.junit.jupiter.api.Test;
final class SortingStickRecipeSpecTest {
@Test
void definesBothStorageVariantsInTheCenterColumn() {
SortingStickRecipeSpec recipe = SortingStickRecipeSpec.defaultRecipe();
assertEquals(List.of(" C ", " R ", " S "), recipe.shape());
assertEquals(Set.of(Material.CHEST, Material.BARREL), recipe.ingredients().get('C'));
assertEquals(Set.of(Material.REDSTONE), recipe.ingredients().get('R'));
assertEquals(Set.of(Material.STICK), recipe.ingredients().get('S'));
assertEquals(1, recipe.outputAmount());
}
}
@@ -0,0 +1,91 @@
package games.dmg.spigotinventoryhelper;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.bukkit.Material;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
import org.junit.jupiter.api.Test;
final class ToolReplacementServiceTest {
@Test
void movesAnExactMainStorageReplacementIntoTheBrokenSlot() {
PlayerInventory inventory = mock(PlayerInventory.class);
ItemStack[] storage = new ItemStack[36];
ItemStack hotbarCandidate = tool(Material.DIAMOND_PICKAXE);
ItemStack mainCandidate = tool(Material.DIAMOND_PICKAXE);
storage[1] = hotbarCandidate;
storage[10] = mainCandidate;
when(inventory.getStorageContents()).thenReturn(storage);
boolean replaced = new ToolReplacementService().replace(
inventory,
Material.DIAMOND_PICKAXE,
0
);
assertTrue(replaced);
verify(inventory).setItem(0, mainCandidate);
verify(inventory).setItem(10, null);
verify(inventory, never()).setItem(1, null);
}
@Test
void fallsBackToAnotherHotbarSlot() {
PlayerInventory inventory = mock(PlayerInventory.class);
ItemStack[] storage = new ItemStack[36];
ItemStack replacement = tool(Material.IRON_AXE);
storage[7] = replacement;
when(inventory.getStorageContents()).thenReturn(storage);
assertTrue(new ToolReplacementService().replace(
inventory,
Material.IRON_AXE,
2
));
verify(inventory).setItem(2, replacement);
verify(inventory).setItem(7, null);
}
@Test
void leavesInventoryUnchangedWithoutAnExactMaterialMatch() {
PlayerInventory inventory = mock(PlayerInventory.class);
ItemStack[] storage = new ItemStack[36];
storage[10] = tool(Material.IRON_PICKAXE);
when(inventory.getStorageContents()).thenReturn(storage);
assertFalse(new ToolReplacementService().replace(
inventory,
Material.DIAMOND_PICKAXE,
0
));
verify(inventory, never()).setItem(0, storage[10]);
verify(inventory, never()).setItem(10, null);
}
@Test
void doesNotOverwriteAnItemPlacedIntoTheBrokenSlot() {
PlayerInventory inventory = mock(PlayerInventory.class);
ItemStack occupied = tool(Material.COBBLESTONE);
when(inventory.getItem(0)).thenReturn(occupied);
assertFalse(new ToolReplacementService().replace(
inventory,
Material.DIAMOND_PICKAXE,
0
));
verify(inventory, never()).getStorageContents();
}
private static ItemStack tool(Material material) {
ItemStack item = mock(ItemStack.class);
when(item.getType()).thenReturn(material);
when(item.getAmount()).thenReturn(1);
return item;
}
}