4 Commits
Author SHA1 Message Date
dmg 3c6237946e Merge pull request #1 from ticket-409-support-purpur-26-3
Release / release (push) Successful in 1m35s
CI / build (push) Successful in 47s
fix(heights): support Purpur 26.3
2026-10-17 02:47:00 +00:00
dmg 68d4a854c3 fix(heights): support Purpur 26.3
CI / build (pull_request) Successful in 1m14s
CI / build (push) Successful in 1m10s
2026-10-16 22:44:55 -04:00
dmg a6c80e7ac9 feat(heights): discover stature potion recipes
Release / release (push) Successful in 5m12s
CI / build (push) Successful in 1m7s
2026-09-20 14:30:46 -04:00
dmg 960e8891a8 feat(heights): adopt Purpur distribution naming
Release / release (push) Successful in 6m47s
CI / build (push) Successful in 2m21s
BREAKING CHANGE: New release assets use purpur-heights-<version>.jar. Runtime SpigotHeights identity, saved player data, permissions and potion keys remain unchanged. Replace the old JAR instead of loading both distributions.
2026-09-12 13:13:44 -04:00
18 changed files with 1439 additions and 78 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-heights-${{ github.sha }}
name: purpur-heights-${{ 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-heights-${{ steps.release.outputs.version }}
path: build/libs/spigot-heights-${{ steps.release.outputs.version }}.jar
name: purpur-heights-${{ steps.release.outputs.version }}
path: build/libs/purpur-heights-${{ steps.release.outputs.version }}.jar
if-no-files-found: error
- name: Create Gitea release and upload plugin
@@ -101,7 +101,7 @@ jobs:
PREVIOUS_TAG: ${{ steps.previous_tag.outputs.value }}
run: |
api_url="${GITEA_SERVER_URL}/api/v1"
jar="build/libs/spigot-heights-${VERSION}.jar"
jar="build/libs/purpur-heights-${VERSION}.jar"
export RELEASE_BODY
if [ -n "$PREVIOUS_TAG" ]; then
RELEASE_BODY=$(git log --pretty='format:- %s (%h)' "${PREVIOUS_TAG}..HEAD")
@@ -138,4 +138,4 @@ jobs:
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${jar}" \
"${api_url}/repos/${REPOSITORY}/releases/${release_id}/assets?name=spigot-heights-${VERSION}.jar"
"${api_url}/repos/${REPOSITORY}/releases/${release_id}/assets?name=purpur-heights-${VERSION}.jar"
+3 -3
View File
@@ -1,9 +1,9 @@
# spigot-heights agent entrypoint
# purpur-heights agent entrypoint
The canonical stories, engineering guidance, and **all process documents** are in the private [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md).
Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/spigot-heights/index.md`, `engineering.md` in that project section, and relevant `../somc-okf/user-stories/spigot-heights/` stories. Also follow the parent workspace `AGENTS.md` when present.
Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/purpur-heights/index.md`, `engineering.md` in that project section, and relevant `../somc-okf/user-stories/purpur-heights/` stories. Also follow the parent workspace `AGENTS.md` when present.
For standalone checkouts, start at the [project page](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/spigot-heights/index.md) and [shared process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md). Obtain wiki access before feature work; do not recreate a local knowledge bundle. Source builds do not require private wiki access.
For standalone checkouts, start at the [project page](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-heights/index.md) and [shared process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md). Obtain wiki access before feature work; do not recreate a local knowledge bundle. Source builds do not require private wiki access.
Development follows [Development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md): approved stories, failing tests, passing implementation, verification, then source/wiki commit and push. GitOps updates are committed locally **without pushing**; only [Do release](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/do-release.md) authorizes a reviewed GitOps push.
+10 -8
View File
@@ -1,12 +1,14 @@
# Spigot Heights
# Purpur Heights
A Purpur 26.2 plugin adding craftable player-scaling potions and dispenser launch tubes for tiny players.
A Purpur 26.3 plugin adding craftable player-scaling potions and dispenser launch tubes for tiny players.
Approved behavior is specified in the [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/spigot-heights/index.md).
Approved behavior is specified in the [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-heights/index.md).
New releases use the `purpur-heights` repository and `purpur-heights-<version>.jar` distribution name. Runtime `SpigotHeights`, its existing data folder, Java entrypoint/packages, permissions and stature-potion/recipe keys remain unchanged. Replace the old JAR instead of installing both distributions. Historical tags and `spigot-heights-*` assets are retained; the previous v1.5.0 download was checked for identical bytes after the rename.
## Requirements
- Purpur 26.2 build 2618
- Purpur 26.3 build 2651
- Java 25 or newer
## Potions
@@ -55,9 +57,9 @@ Drinking a permanent stature potion ends the temporary sequence, using the curre
Heights tracks only its loaded clouds and checks them every five ticks, dispatching `AreaEffectCloudApplyEvent` and honoring cancellation and recipient filtering. This supports effectless stature clouds without adding a vanilla status effect. Online expiry is checked every second. Durable receipts survive cloud unload and are retired on observed permanent entity removal; abrupt process termination can leave harmless orphan receipts.
Heights corrects Purpur 2618's no-op conversion of custom potions without a vanilla base potion type inside `BrewEvent`. This covers existing items without changing metadata or crafting recipes. It detaches aliased results at LOWEST priority before normal result modifiers and validates/corrects at HIGHEST. Conflicting/missing results or item-construction failures cancel the brew; valid converted results and native ingredient/fuel accounting are retained. Cancellation remains authoritative. As with other Bukkit listeners, this cannot control plugins that directly mutate inventories or run conflicting handlers outside the normal event-priority contract.
Purpur 26.3's data-driven brewing engine natively converts authenticated custom potions without a vanilla base potion type. Heights supplies representative recipe choices so the server's brewing input/reagent indexes include those recipes, then validates results at the `BrewEvent` transaction boundary. The build-2618 no-op correction is not needed for normal build-2651 conversion; the defensive listener preserves cancellation and rejects conflicting, missing or identity-stripped results. Valid native results, compatible result modifiers, ingredient/fuel accounting, and dragon's-breath bottle return remain server-owned. Cancellation remains authoritative. As with other Bukkit listeners, this cannot control plugins that directly mutate inventories or run conflicting handlers outside the normal event-priority contract.
**Verification:** `nativeBrewingTest` executes real Purpur 2618 brewing cycles with native inventories, recipes, metadata and Paper event dispatch. It covers all four splash/lingering conversions, ingredient safety and cancellation, mixed/ordinary potions, and output compatibility with the stature adapters. World and entity boundaries are test doubles; these checks do not claim native projectile/cloud spawning, client appearance, or live logout/restart gameplay. Those remain supplementary live checks.
**Verification:** `nativeBrewingTest` executes real Purpur 26.3 build 2651 brewing cycles with native data-driven recipes, inventories, metadata and Paper event dispatch. It proves all four base-less potion kinds convert without the old correction, then covers splash/lingering conversion, representative recipe indexing, ingredient safety and cancellation, mixed/ordinary potions, immediate second-stage brewing, bottle return, and output compatibility with the stature adapters. World and entity boundaries are test doubles; these checks do not claim native projectile/cloud spawning, client appearance, or live logout/restart gameplay. Those remain supplementary live checks.
## Ride larger players (US-010)
@@ -87,7 +89,7 @@ Eligibility is rechecked every five ticks, including temporary potion applicatio
Server/plugin cancellation remains authoritative: a refused dismount leaves accurate tracking and is reported, not bypassed or called successful. A cancelled voluntary stop can be retried by either player; automatic eligibility cleanup continues checking and reports refusal once per ride rather than spamming. Lifecycle/disable refusals are logged. Native dismount events are reconciled after dispatch so a later cancellation is honored. An unrelated vehicle is never ejected during stale-pair cleanup.
**Verification:** API/domain tests preserve native cleanup and reject rescue/motion-reset calls.
`NativeCarryRoutingTest` registers the production listener through Purpur 2618's actual Paper
`NativeCarryRoutingTest` registers the production listener through Purpur 26.3 build 2651's actual Paper
plugin manager and dispatches both `PlayerInteractEntityEvent` and `PlayerInteractAtEntityEvent`.
Same-tick duplicate rider/target interactions are coalesced, including refused mounts; a fresh
interaction next tick can retry without a gameplay cooldown. Registered mount/dismount cancellation
@@ -159,7 +161,7 @@ Player names must match an online player exactly (case-insensitive); partial nam
The plugin JAR is written to `build/libs/`.
`check` includes `nativeBrewingTest`. It downloads the exact Purpur 26.2 build 2618 launcher (SHA-256 verified) and prepares its runtime dependencies with Paperclip's **patch-only** mode under `build/brewing-runtime/`. It does not start a server, open ports, create a world or accept the EULA. Initial/clean builds need network access; the native test JVM permits up to 1 GiB heap. No runtime implementation classes or test dependencies are packaged in the plugin. Run `./gradlew test` for the fast API/domain suite alone, or `./gradlew nativeBrewingTest` for native brewing and registered carry-event routing tests. Full verification remains `./gradlew clean check jar`.
`check` includes `nativeBrewingTest`. It downloads the exact Purpur 26.3 build 2651 launcher (SHA-256 `0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598`) and prepares its runtime dependencies with Paperclip's **patch-only** mode under `build/brewing-runtime/`. It does not start a server, open ports, create a world or accept the EULA. Initial/clean builds need network access; the native test JVM permits up to 1 GiB heap. No runtime implementation classes or test dependencies are packaged in the plugin. Run `./gradlew test` for the fast API/domain suite alone, or `./gradlew nativeBrewingTest` for native brewing and registered carry-event routing tests. Full verification remains `./gradlew clean check jar`.
## Releases
+10 -7
View File
@@ -24,8 +24,8 @@ tasks.withType<JavaCompile>().configureEach {
}
dependencies {
compileOnly("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
testImplementation("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
compileOnly("org.purpurmc.purpur:purpur-api:26.3.build.2651-stable")
testImplementation("org.purpurmc.purpur:purpur-api:26.3.build.2651-stable")
testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("org.yaml:snakeyaml:2.4")
@@ -34,25 +34,28 @@ dependencies {
tasks.test {
useJUnitPlatform()
dependsOn(tasks.jar)
systemProperty("distribution.jar", tasks.jar.get().archiveFile.get().asFile.absolutePath)
systemProperty("distribution.version", project.version.toString())
}
// Exercise the declared runtime's native brewing engine, not a recipe-registry double.
// Paperclip only extracts/patches dependencies: no server, world, port or EULA acceptance.
val brewingRuntime = layout.buildDirectory.dir("brewing-runtime")
val downloadBrewingRuntime = tasks.register("downloadBrewingRuntime") {
val launcher = brewingRuntime.map { it.file("purpur-26.2-2618.jar") }
val launcher = brewingRuntime.map { it.file("purpur-26.3-2651.jar") }
outputs.file(launcher)
doLast {
val file = launcher.get().asFile
file.parentFile.mkdirs()
val connection = URI("https://api.purpurmc.org/v2/purpur/26.2/2618/download").toURL().openConnection()
val connection = URI("https://api.purpurmc.org/v2/purpur/26.3/2651/download").toURL().openConnection()
connection.connectTimeout = 30_000
connection.readTimeout = 120_000
val bytes = connection.getInputStream().use { it.readBytes() }
val digest = MessageDigest.getInstance("SHA-256").digest(bytes)
.joinToString("") { "%02x".format(it) }
check(digest == "4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31") {
"Purpur 2618 checksum mismatch"
check(digest == "0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598") {
"Purpur 2651 checksum mismatch"
}
file.writeBytes(bytes)
}
@@ -60,7 +63,7 @@ val downloadBrewingRuntime = tasks.register("downloadBrewingRuntime") {
val prepareBrewingRuntime = tasks.register<JavaExec>("prepareBrewingRuntime") {
dependsOn(downloadBrewingRuntime)
javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(25) }
classpath = files(brewingRuntime.map { it.file("purpur-26.2-2618.jar") })
classpath = files(brewingRuntime.map { it.file("purpur-26.3-2651.jar") })
mainClass = "io.papermc.paperclip.Main"
jvmArgs("-Dpaperclip.patchonly=true")
workingDir(brewingRuntime)
+1 -1
View File
@@ -2,4 +2,4 @@ plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
rootProject.name = "spigot-heights"
rootProject.name = "purpur-heights"
@@ -1,7 +1,12 @@
package games.dmg.spigotheights;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.function.Function;
import java.util.function.Predicate;
import io.papermc.paper.potion.PotionMix;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
@@ -13,6 +18,7 @@ import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.RecipeChoice;
import org.bukkit.inventory.ShapedRecipe;
import org.bukkit.inventory.meta.PotionMeta;
import org.bukkit.inventory.recipe.CraftingBookCategory;
import org.bukkit.persistence.PersistentDataType;
import org.bukkit.plugin.java.JavaPlugin;
import org.bukkit.potion.PotionType;
@@ -26,6 +32,8 @@ public final class PotionRecipes {
private final Server server;
private final NamespacedKey potionKindKey;
private final Function<Material, ItemStack> items;
private final List<NamespacedKey> ownedRecipeKeys = new ArrayList<>();
private final Set<NamespacedKey> ownedMixKeys = new LinkedHashSet<>();
public PotionRecipes(JavaPlugin plugin) {
this(plugin.getServer(), new NamespacedKey(plugin, "stature_potion"), ItemStack::new);
@@ -41,12 +49,42 @@ public final class PotionRecipes {
return new NamespacedKey(potionKindKey.getNamespace(), value);
}
public void register() {
registerShifting();
registerGrowth();
registerDiminution();
registerRestoration();
public boolean register() {
boolean complete = registerCrafting();
registerBrewing(server.getPotionBrewer());
return complete;
}
private boolean registerCrafting() {
for (ShapedRecipe recipe : List.of(shiftingRecipe(), growthRecipe(), diminutionRecipe(), restorationRecipe())) {
NamespacedKey recipeKey = recipe.getKey();
if (server.getRecipe(recipeKey) != null) {
rollbackCrafting();
return false;
}
try {
if (!server.addRecipe(recipe)) {
rollbackCrafting();
return false;
}
} catch (RuntimeException exception) {
try {
if (server.getRecipe(recipeKey) != null) {
ownedRecipeKeys.add(recipeKey);
}
} catch (RuntimeException inspectionFailure) {
exception.addSuppressed(inspectionFailure);
}
throw exception;
}
ownedRecipeKeys.add(recipeKey);
}
return true;
}
private void rollbackCrafting() {
ownedRecipeKeys.forEach(server::removeRecipe);
ownedRecipeKeys.clear();
}
public void registerBrewing(org.bukkit.potion.PotionBrewer brewer) {
@@ -61,16 +99,45 @@ public final class PotionRecipes {
NamespacedKey mixKey = mixKey(kind, target);
brewer.removePotionMix(mixKey);
brewer.addPotionMix(new PotionMix(mixKey, create(kind, target),
PotionMix.createPredicateChoice(item -> item != null && item.getType() == source
predicateChoice(create(kind, source), item -> item != null && item.getType() == source
&& identify(item) == kind),
PotionMix.createPredicateChoice(item -> item != null && item.getType() == ingredient)));
predicateChoice(items.apply(ingredient), item -> item != null && item.getType() == ingredient)));
ownedMixKeys.add(mixKey);
}
private static RecipeChoice.PredicateChoice predicateChoice(
ItemStack example, Predicate<? super ItemStack> predicate) {
return new RecipeChoice.PredicateChoice() {
@Override
public ItemStack getItemStack() {
return example.clone();
}
@Override
public RecipeChoice.PredicateChoice clone() {
return this;
}
@Override
public boolean test(ItemStack item) {
return predicate.test(item);
}
};
}
public void unregister() {
ownedRecipeKeys.forEach(server::removeRecipe);
ownedRecipeKeys.clear();
unregisterBrewing();
}
public void unregisterBrewing() {
for (StaturePotion kind : StaturePotion.values()) {
server.getPotionBrewer().removePotionMix(mixKey(kind, Material.SPLASH_POTION));
server.getPotionBrewer().removePotionMix(mixKey(kind, Material.LINGERING_POTION));
if (ownedMixKeys.isEmpty()) {
return;
}
org.bukkit.potion.PotionBrewer brewer = server.getPotionBrewer();
ownedMixKeys.forEach(brewer::removePotionMix);
ownedMixKeys.clear();
}
private NamespacedKey mixKey(StaturePotion kind, Material target) {
@@ -123,26 +190,36 @@ public final class PotionRecipes {
return key("shifting_stature");
}
private void registerShifting() {
public List<NamespacedKey> recipeKeys() {
return List.of(shiftingKey(), key("growth"), key("diminution"), key("restoration"));
}
private ShapedRecipe shiftingRecipe() {
ShapedRecipe recipe = new ShapedRecipe(shiftingKey(), create(StaturePotion.SHIFTING));
recipe.shape(SHIFTING_SHAPE);
recipe.setIngredient('A', Material.AMETHYST_SHARD);
recipe.setIngredient('C', Material.CHORUS_FRUIT);
recipe.setIngredient('W', Material.POTION);
server.addRecipe(recipe);
ItemStack awkward = items.apply(Material.POTION);
PotionMeta awkwardMeta = (PotionMeta) awkward.getItemMeta();
awkwardMeta.setBasePotionType(PotionType.AWKWARD);
awkward.setItemMeta(awkwardMeta);
recipe.setIngredient('W', new RecipeChoice.ExactChoice(awkward));
recipe.setCategory(CraftingBookCategory.MISC);
return recipe;
}
private void registerGrowth() {
private ShapedRecipe growthRecipe() {
ShapedRecipe recipe = new ShapedRecipe(key("growth"), create(StaturePotion.GROWTH));
recipe.shape(GROWTH_SHAPE);
recipe.setIngredient('G', Material.GOLD_INGOT);
recipe.setIngredient('A', Material.AMETHYST_SHARD);
recipe.setIngredient('S', new RecipeChoice.ExactChoice(create(StaturePotion.SHIFTING)));
recipe.setIngredient('R', Material.RABBIT_FOOT);
server.addRecipe(recipe);
recipe.setCategory(CraftingBookCategory.MISC);
return recipe;
}
private void registerDiminution() {
private ShapedRecipe diminutionRecipe() {
ShapedRecipe recipe = new ShapedRecipe(key("diminution"),
create(StaturePotion.DIMINUTION));
recipe.shape(DIMINUTION_SHAPE);
@@ -150,10 +227,11 @@ public final class PotionRecipes {
recipe.setIngredient('A', Material.AMETHYST_SHARD);
recipe.setIngredient('S', new RecipeChoice.ExactChoice(create(StaturePotion.SHIFTING)));
recipe.setIngredient('F', Material.FERMENTED_SPIDER_EYE);
server.addRecipe(recipe);
recipe.setCategory(CraftingBookCategory.MISC);
return recipe;
}
private void registerRestoration() {
private ShapedRecipe restorationRecipe() {
ShapedRecipe recipe = new ShapedRecipe(key("restoration"),
create(StaturePotion.RESTORATION));
recipe.shape(RESTORATION_SHAPE);
@@ -161,7 +239,8 @@ public final class PotionRecipes {
recipe.setIngredient('A', Material.AMETHYST_SHARD);
recipe.setIngredient('S', new RecipeChoice.ExactChoice(create(StaturePotion.SHIFTING)));
recipe.setIngredient('U', Material.SUGAR);
server.addRecipe(recipe);
recipe.setCategory(CraftingBookCategory.MISC);
return recipe;
}
private static Color color(StaturePotion kind) {
@@ -0,0 +1,50 @@
package games.dmg.spigotheights;
import java.util.List;
import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.bukkit.NamespacedKey;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
public final class RecipeDiscovery implements Listener {
private final List<NamespacedKey> recipeKeys;
private final Logger logger;
public RecipeDiscovery(List<NamespacedKey> recipeKeys, Logger logger) {
this.recipeKeys = List.copyOf(recipeKeys);
this.logger = Objects.requireNonNull(logger, "logger");
}
public void discoverAll(Iterable<? extends Player> players) {
players.forEach(this::discover);
}
@EventHandler
public void onJoin(PlayerJoinEvent event) {
discover(event.getPlayer());
}
public void discover(Player player) {
try {
List<NamespacedKey> unknown = recipeKeys.stream()
.filter(key -> !player.hasDiscoveredRecipe(key))
.toList();
if (unknown.isEmpty()) {
return;
}
player.discoverRecipes(unknown);
List<NamespacedKey> stillUnknown = unknown.stream()
.filter(key -> !player.hasDiscoveredRecipe(key))
.toList();
if (!stillUnknown.isEmpty()) {
logger.warning("Could not discover stature recipes for " + player.getName() + ": " + stillUnknown);
}
} catch (RuntimeException exception) {
logger.log(Level.WARNING, "Could not discover stature recipes for " + player.getName(), exception);
}
}
}
@@ -0,0 +1,64 @@
package games.dmg.spigotheights;
import java.util.List;
import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.bukkit.event.HandlerList;
import org.bukkit.plugin.Plugin;
public final class RecipeLifecycle {
private final Server server;
private final Plugin plugin;
private final PotionRecipes recipes;
private final Logger logger;
private RecipeDiscovery discovery;
public RecipeLifecycle(Server server, Plugin plugin, PotionRecipes recipes, Logger logger) {
this.server = Objects.requireNonNull(server, "server");
this.plugin = Objects.requireNonNull(plugin, "plugin");
this.recipes = Objects.requireNonNull(recipes, "recipes");
this.logger = Objects.requireNonNull(logger, "logger");
}
public boolean enable() {
if (discovery != null) {
return true;
}
try {
if (!recipes.register()) {
logger.severe("Stature recipes were not registered as a complete group; discovery is disabled.");
return false;
}
List<? extends Player> onlinePlayers = List.copyOf(server.getOnlinePlayers());
discovery = new RecipeDiscovery(recipes.recipeKeys(), logger);
server.getPluginManager().registerEvents(discovery, plugin);
discovery.discoverAll(onlinePlayers);
return true;
} catch (RuntimeException exception) {
unregisterDiscovery();
try {
recipes.unregister();
} catch (RuntimeException cleanupFailure) {
exception.addSuppressed(cleanupFailure);
}
logger.log(Level.SEVERE,
"Stature recipes were not registered as a complete group; discovery is disabled.", exception);
return false;
}
}
public void disable() {
unregisterDiscovery();
recipes.unregister();
}
private void unregisterDiscovery() {
if (discovery != null) {
HandlerList.unregisterAll(discovery);
discovery = null;
}
}
}
@@ -8,6 +8,7 @@ import org.bukkit.plugin.java.JavaPlugin;
public final class SpigotHeightsPlugin extends JavaPlugin {
private BukkitStatureService stature;
private PotionRecipes potions;
private RecipeLifecycle recipeLifecycle;
private BukkitCarry carry;
@Override
public void onEnable() {
@@ -42,7 +43,8 @@ public final class SpigotHeightsPlugin extends JavaPlugin {
command.setTabCompleter(executor);
potions = new PotionRecipes(this);
potions.register();
recipeLifecycle = new RecipeLifecycle(getServer(), this, potions, getLogger());
recipeLifecycle.enable();
getServer().getPluginManager().registerEvents(new StatureBrewing(potions), this);
getServer().getPluginManager().registerEvents(
new StatureListener(potions, stature::drink, stature::resume,
@@ -67,8 +69,8 @@ public final class SpigotHeightsPlugin extends JavaPlugin {
if (stature != null) {
stature.close();
}
if (potions != null) {
potions.unregisterBrewing();
if (recipeLifecycle != null) {
recipeLifecycle.disable();
}
}
@@ -7,7 +7,7 @@ import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.inventory.BrewEvent;
/** Completes authenticated stature conversions at the native brewing transaction boundary. */
/** Validates authenticated stature conversions at the native brewing transaction boundary. */
public final class StatureBrewing implements Listener {
private final PotionRecipes potions;
@@ -80,8 +80,8 @@ public final class StatureBrewing implements Listener {
event.setCancelled(true);
return;
}
// 2618 recognizes custom recipes but mix() returns base-less potions unchanged.
// Correct only that no-op; leave inventory and ingredient accounting to the server.
// Correct only an unexpected native no-op; leave inventory and ingredient accounting
// to the server. Build 2651 normally returns the registered native result directly.
event.getResults().set(slot, potions.create(kind, target));
}
}
@@ -15,9 +15,10 @@ import java.util.UUID;
import java.util.logging.Logger;
import io.papermc.paper.plugin.manager.PaperPluginManagerImpl;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.Registries;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.flag.FeatureFlags;
import net.minecraft.world.item.alchemy.PotionBrewing;
import net.minecraft.world.level.Level;
import net.minecraft.world.item.crafting.RecipeManager;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.entity.BrewingStandBlockEntity;
import org.bukkit.Bukkit;
@@ -25,10 +26,11 @@ import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.Server;
import org.bukkit.command.SimpleCommandMap;
import org.bukkit.craftbukkit.inventory.CraftBrewingRecipe;
import org.bukkit.craftbukkit.inventory.CraftInventoryBrewer;
import org.bukkit.craftbukkit.inventory.CraftItemFactory;
import org.bukkit.craftbukkit.inventory.CraftItemStack;
import org.bukkit.craftbukkit.util.CraftMagicNumbers;
import org.bukkit.craftbukkit.util.CraftNamespacedKey;
import org.bukkit.event.HandlerList;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
@@ -55,12 +57,12 @@ import org.junit.jupiter.params.provider.EnumSource;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.Arguments;
/** Real 2618 brewing ticks, items, metadata, recipes, event dispatch and ingredient consumption. */
/** Real build-2651 brewing ticks, items, metadata, recipes, event dispatch and ingredient consumption. */
class NativeBrewingTest {
private static PluginManager events;
private static Plugin plugin;
private PotionRecipes recipes;
private PotionBrewing engine;
private RecipeManager engine;
@BeforeAll
static void bootstrap() throws Exception {
@@ -70,8 +72,8 @@ class NativeBrewingTest {
when(server.getUnsafe()).thenReturn(CraftMagicNumbers.INSTANCE);
when(server.getLogger()).thenReturn(Logger.getLogger("NativeBrewingTest"));
when(server.getName()).thenReturn("NativeBrewingTest");
when(server.getVersion()).thenReturn("26.2-2618");
when(server.getBukkitVersion()).thenReturn("26.2-2618");
when(server.getVersion()).thenReturn("26.3-2651");
when(server.getBukkitVersion()).thenReturn("26.3-2651");
when(server.isPrimaryThread()).thenReturn(true);
events = new PaperPluginManagerImpl(server, new SimpleCommandMap(server, new HashMap<>()), null);
when(server.getPluginManager()).thenReturn(events);
@@ -88,13 +90,23 @@ class NativeBrewingTest {
@BeforeEach
void registerActualRecipesAndListener() {
HandlerList.unregisterAll();
engine = PotionBrewing.bootstrap(FeatureFlags.VANILLA_SET);
engine = NativeRuntime.recipes();
recipes = new PotionRecipes(null, new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
PotionBrewer brewer = (PotionBrewer) Proxy.newProxyInstance(PotionBrewer.class.getClassLoader(),
new Class<?>[] {PotionBrewer.class}, (proxy, method, args) -> {
switch (method.getName()) {
case "addPotionMix" -> engine.addPotionMix((io.papermc.paper.potion.PotionMix) args[0]);
case "removePotionMix" -> engine.removePotionMix((NamespacedKey) args[0]);
case "addPotionMix" -> {
var mix = (io.papermc.paper.potion.PotionMix) args[0];
var recipe = new org.bukkit.inventory.BrewingRecipe(
mix.getKey(), mix.getResult(), mix.getInput(), mix.getIngredient());
engine.recipes.addRecipe(CraftBrewingRecipe.fromBukkitRecipe(recipe).toMinecraftRecipe());
engine.finalizeRecipeLoading(FeatureFlags.VANILLA_SET);
}
case "removePotionMix" -> {
engine.recipes.removeRecipe(CraftNamespacedKey.toResourceKey(
Registries.RECIPE, (NamespacedKey) args[0]));
engine.finalizeRecipeLoading(FeatureFlags.VANILLA_SET);
}
default -> throw new UnsupportedOperationException(method.getName());
}
return null;
@@ -103,6 +115,20 @@ class NativeBrewingTest {
events.registerEvents(new StatureBrewing(recipes), plugin);
}
@ParameterizedTest
@EnumSource(StaturePotion.class)
void purpur26_3ConvertsBaseLessStaturePotionsWithoutTheBuild2618Correction(StaturePotion kind) {
HandlerList.unregisterAll();
NativeStand stand = new NativeStand();
stand.inventory.setItem(0, recipes.create(kind));
stand.inventory.setIngredient(new ItemStack(Material.GUNPOWDER));
stand.completeCycle();
ItemStack result = stand.inventory.getItem(0);
assertEquals(Material.SPLASH_POTION, result.getType());
assertEquals(kind, recipes.identify(result));
assertTrue(stand.getItem(3).isEmpty());
}
@ParameterizedTest
@EnumSource(StaturePotion.class)
void gunpowderActuallyConvertsEveryAuthenticatedDrinkable(StaturePotion kind) {
@@ -127,7 +153,8 @@ class NativeBrewingTest {
ItemStack result = stand.inventory.getItem(0);
assertEquals(Material.LINGERING_POTION, result.getType());
assertEquals(kind, recipes.identify(result));
assertTrue(stand.getItem(3).isEmpty(), "one successful conversion spends one dragon's breath");
assertEquals(new ItemStack(Material.GLASS_BOTTLE), stand.inventory.getIngredient(),
"one successful conversion spends one dragon's breath and retains its bottle");
assertLingeringApplies(result, kind);
}
@@ -143,7 +170,7 @@ class NativeBrewingTest {
stand.completeCycle();
assertEquals(Material.LINGERING_POTION, stand.inventory.getItem(0).getType());
assertEquals(kind, recipes.identify(stand.inventory.getItem(0)));
assertTrue(stand.getItem(3).isEmpty());
assertEquals(new ItemStack(Material.GLASS_BOTTLE), stand.inventory.getIngredient());
assertEquals(18, stand.fuel);
}
@@ -271,7 +298,8 @@ class NativeBrewingTest {
@ParameterizedTest
@MethodSource("conversionEdits")
void inPlaceResultEditsCannotMutateInputOrBypassIngredientSafety(Material ingredient, boolean removeIdentity) {
void nativeResultEditsRemainDetachedAndIdentityRemovalCannotSpendIngredients(
Material ingredient, boolean removeIdentity) {
events.registerEvent(BrewEvent.class, new Listener() {}, EventPriority.NORMAL, (listener, event) -> {
ItemStack result = ((BrewEvent) event).getResults().get(0);
result.editMeta(meta -> {
@@ -288,13 +316,27 @@ class NativeBrewingTest {
stand.inventory.setItem(0, original);
stand.inventory.setIngredient(new ItemStack(ingredient));
stand.completeCycle();
assertEquals(new ItemStack(ingredient), stand.inventory.getIngredient(),
"in-place output conflicts cannot spend ingredients");
assertEquals(original, stand.inventory.getItem(0), "cancelled result edits must not mutate the input inventory");
if (removeIdentity) {
assertEquals(new ItemStack(ingredient), stand.inventory.getIngredient(),
"identity removal cannot spend ingredients");
assertEquals(original, stand.inventory.getItem(0),
"cancelled identity removal must not mutate the input inventory");
} else {
assertEquals(ingredient == Material.GUNPOWDER ? null : new ItemStack(Material.GLASS_BOTTLE),
stand.inventory.getIngredient());
ItemStack result = stand.inventory.getItem(0);
assertEquals(ingredient == Material.GUNPOWDER ? Material.SPLASH_POTION : Material.LINGERING_POTION,
result.getType());
assertEquals(StaturePotion.GROWTH, recipes.identify(result));
assertEquals(net.kyori.adventure.text.Component.text("Other plugin's conflicting edit"),
result.getItemMeta().displayName());
assertEquals(StaturePotion.GROWTH, recipes.identify(original),
"native result edits must stay detached from the input object");
}
}
@Test
void resultConstructionFailureDoesNotSpendTheIngredient() {
void purpur26_3NativeOutputDoesNotNeedTheCorrectionFactory() {
HandlerList.unregisterAll();
PotionRecipes brokenFactory = new PotionRecipes(null, new NamespacedKey("spigotheights", "stature_potion"),
material -> { throw new IllegalStateException("simulated item factory failure"); });
@@ -304,8 +346,9 @@ class NativeBrewingTest {
stand.inventory.setItem(0, original);
stand.inventory.setIngredient(new ItemStack(Material.GUNPOWDER));
stand.completeCycle();
assertEquals(original, stand.inventory.getItem(0));
assertEquals(new ItemStack(Material.GUNPOWDER), stand.inventory.getIngredient());
assertEquals(Material.SPLASH_POTION, stand.inventory.getItem(0).getType());
assertEquals(StaturePotion.GROWTH, recipes.identify(stand.inventory.getItem(0)));
assertTrue(stand.getItem(3).isEmpty());
}
private void assertSplashApplies(ItemStack result, StaturePotion expected) {
@@ -354,11 +397,11 @@ class NativeBrewingTest {
private final class NativeStand extends BrewingStandBlockEntity {
private final CraftInventoryBrewer inventory = new CraftInventoryBrewer(this);
private final Level level = mock(Level.class);
private final ServerLevel level = mock(ServerLevel.class);
NativeStand() {
super(BlockPos.ZERO, Blocks.BREWING_STAND.defaultBlockState());
when(level.potionBrewing()).thenReturn(engine);
when(level.recipeAccess()).thenReturn(engine);
fuel = 20;
}
@@ -23,7 +23,7 @@ import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
/** Actual Purpur 2618 listener discovery, distinct handler lists and Paper event dispatch.
/** Actual Purpur build-2651 listener discovery, distinct handler lists and Paper event dispatch.
* Only server/player boundaries are doubled; no listening server or client packets. */
class NativeCarryRoutingTest {
private static Server previousServer;
@@ -0,0 +1,864 @@
package games.dmg.spigotheights;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import io.papermc.paper.plugin.manager.PaperPluginManagerImpl;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.logging.Handler;
import java.util.logging.LogRecord;
import java.util.logging.Logger;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.Server;
import org.bukkit.command.SimpleCommandMap;
import org.bukkit.craftbukkit.inventory.CraftItemFactory;
import org.bukkit.craftbukkit.inventory.CraftItemStack;
import org.bukkit.craftbukkit.inventory.CraftRecipe;
import org.bukkit.craftbukkit.util.CraftMagicNumbers;
import org.bukkit.Keyed;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.Recipe;
import org.bukkit.inventory.RecipeChoice;
import org.bukkit.inventory.ShapedRecipe;
import org.bukkit.inventory.recipe.CraftingBookCategory;
import org.bukkit.entity.Player;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.plugin.Plugin;
import org.bukkit.plugin.PluginDescriptionFile;
import org.bukkit.plugin.PluginManager;
import org.bukkit.potion.PotionBrewer;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
/** Pinned Purpur book, codec, placement and result-slot behavior with server/player boundaries doubled.
* No listening server, client, CraftServer registry or CraftPlayer discovery bridge is started here. */
class NativeRecipeBookTest {
private static Server previousServer;
private static Server serverUnderTest;
@BeforeAll
static void bootstrap() throws Exception {
NativeRuntime.bootstrap();
previousServer = Bukkit.getServer();
serverUnderTest = mock(Server.class);
setServer(serverUnderTest);
}
@BeforeEach
void configureServer() {
reset(serverUnderTest);
when(serverUnderTest.getItemFactory()).thenAnswer(invocation -> CraftItemFactory.instance());
when(serverUnderTest.getUnsafe()).thenReturn(CraftMagicNumbers.INSTANCE);
when(serverUnderTest.isPrimaryThread()).thenReturn(true);
}
@AfterAll
static void restoreServer() throws Exception {
setServer(previousServer);
}
private static void setServer(Server server) throws Exception {
var field = Bukkit.class.getDeclaredField("server");
field.setAccessible(true);
field.set(null, server);
}
@Test
void nativeRecipeBookAwardsKnownHighlightsAndNotificationOnlyOnce() {
Server server = Bukkit.getServer();
List<Recipe> registered = new ArrayList<>();
when(server.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
registered.add(invocation.getArgument(0));
return true;
});
when(server.getPotionBrewer()).thenReturn(mock(PotionBrewer.class));
List<org.bukkit.event.Event> events = new ArrayList<>();
PluginManager pluginManager = mock(PluginManager.class);
doAnswer(invocation -> {
events.add(invocation.getArgument(0));
return null;
}).when(pluginManager).callEvent(any());
when(server.getPluginManager()).thenReturn(pluginManager);
PotionRecipes recipes = new PotionRecipes(server,
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
assertTrue(recipes.register());
java.util.Map<net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>>,
net.minecraft.world.item.crafting.ShapedRecipe> nativeRecipes = new HashMap<>();
List<net.minecraft.world.item.crafting.RecipeHolder<?>> holders = new ArrayList<>();
for (Recipe recipe : registered) {
ShapedRecipe bukkit = (ShapedRecipe) recipe;
var key = nativeRecipeKey(bukkit.getKey());
var nativeRecipe = nativeRecipe(bukkit);
nativeRecipes.put(key, nativeRecipe);
holders.add(new net.minecraft.world.item.crafting.RecipeHolder<>(key, nativeRecipe));
}
AtomicInteger displayId = new AtomicInteger();
java.util.Map<net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>>,
Integer> displayIds = new HashMap<>();
nativeRecipes.keySet().forEach(key -> displayIds.put(key, displayId.getAndIncrement()));
TestServerRecipeBook book = new TestServerRecipeBook((key, consumer) -> {
net.minecraft.world.item.crafting.ShapedRecipe recipe = nativeRecipes.get(key);
consumer.accept(new net.minecraft.world.item.crafting.display.RecipeDisplayEntry(
new net.minecraft.world.item.crafting.display.RecipeDisplayId(displayIds.get(key)),
recipe.display().getFirst(), java.util.OptionalInt.empty(), recipe.recipeBookCategory(),
java.util.Optional.of(recipe.placementInfo().ingredients())));
});
var nativePlayer = mock(net.minecraft.server.level.ServerPlayer.class);
when(nativePlayer.getBukkitEntity()).thenReturn(
mock(org.bukkit.craftbukkit.entity.CraftPlayer.class));
when(nativePlayer.getAdvancements()).thenReturn(mock(net.minecraft.server.PlayerAdvancements.class));
assertEquals(4, book.addRecipes(holders, nativePlayer));
assertEquals(0, book.addRecipes(holders, nativePlayer));
assertEquals(new LinkedHashSet<>(nativeRecipes.keySet()), book.known);
assertEquals(new LinkedHashSet<>(nativeRecipes.keySet()),
new LinkedHashSet<>(book.pack().highlight()));
assertEquals(4, events.size());
assertTrue(events.stream().allMatch(org.bukkit.event.player.PlayerRecipeDiscoverEvent.class::isInstance));
assertTrue(events.stream().map(org.bukkit.event.player.PlayerRecipeDiscoverEvent.class::cast)
.allMatch(org.bukkit.event.player.PlayerRecipeDiscoverEvent::shouldShowNotification));
net.minecraft.nbt.Tag serialized = net.minecraft.stats.ServerRecipeBook.Packed.CODEC
.encodeStart(net.minecraft.nbt.NbtOps.INSTANCE, book.pack()).getOrThrow();
net.minecraft.stats.ServerRecipeBook.Packed decoded = net.minecraft.stats.ServerRecipeBook.Packed.CODEC
.parse(net.minecraft.nbt.NbtOps.INSTANCE, serialized).getOrThrow();
TestServerRecipeBook restored = new TestServerRecipeBook();
restored.loadUntrusted(decoded, nativeRecipes::containsKey);
assertEquals(book.known, restored.known);
assertEquals(new LinkedHashSet<>(book.pack().highlight()),
new LinkedHashSet<>(restored.pack().highlight()));
}
@Test
void nativeRecipeBookSerializationPreservesKnownHighlightsAndVanillaStateAcrossReenable() {
List<net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>>> statureKeys =
List.of("shifting_stature", "growth", "diminution", "restoration").stream()
.map(value -> nativeRecipeKey(new NamespacedKey("spigotheights", value)))
.toList();
var vanillaKey = nativeRecipeKey(new NamespacedKey("minecraft", "stick"));
TestServerRecipeBook original = new TestServerRecipeBook();
original.setBookSetting(net.minecraft.world.inventory.RecipeBookType.CRAFTING, true, true);
original.add(vanillaKey);
original.highlight(vanillaKey);
statureKeys.forEach(key -> {
original.add(key);
original.highlight(key);
});
net.minecraft.nbt.Tag serialized = net.minecraft.stats.ServerRecipeBook.Packed.CODEC
.encodeStart(net.minecraft.nbt.NbtOps.INSTANCE, original.pack()).getOrThrow();
net.minecraft.stats.ServerRecipeBook.Packed decoded = net.minecraft.stats.ServerRecipeBook.Packed.CODEC
.parse(net.minecraft.nbt.NbtOps.INSTANCE, serialized).getOrThrow();
TestServerRecipeBook restored = new TestServerRecipeBook();
restored.loadUntrusted(decoded, key -> true);
assertTrue(restored.contains(vanillaKey));
statureKeys.forEach(key -> assertTrue(restored.contains(key)));
assertEquals(new LinkedHashSet<>(decoded.known()), restored.known);
assertEquals(new LinkedHashSet<>(decoded.highlight()), new LinkedHashSet<>(restored.pack().highlight()));
assertTrue(restored.isOpen(net.minecraft.world.inventory.RecipeBookType.CRAFTING));
assertTrue(restored.isFiltering(net.minecraft.world.inventory.RecipeBookType.CRAFTING));
int knownBeforeReenable = restored.known.size();
int highlightsBeforeReenable = restored.pack().highlight().size();
statureKeys.forEach(restored::add);
assertEquals(knownBeforeReenable, restored.known.size());
assertEquals(highlightsBeforeReenable, restored.pack().highlight().size(),
"Re-enable must not manufacture another first-time highlight");
assertTrue(restored.contains(vanillaKey));
}
private static final class TestServerRecipeBook extends net.minecraft.stats.ServerRecipeBook {
TestServerRecipeBook() {
this((key, consumer) -> { });
}
TestServerRecipeBook(net.minecraft.stats.ServerRecipeBook.DisplayResolver resolver) {
super(resolver);
}
void highlight(net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>> key) {
highlight.add(key);
}
}
@Test
void disableAndReenableReRegistersRecipesWithoutForgettingPlayerKnowledge() {
Server server = Bukkit.getServer();
Set<NamespacedKey> registry = new LinkedHashSet<>();
when(server.addRecipe(any(Recipe.class))).thenAnswer(invocation ->
registry.add(((Keyed) invocation.getArgument(0)).getKey()));
when(server.removeRecipe(any(NamespacedKey.class))).thenAnswer(invocation ->
registry.remove(invocation.getArgument(0)));
when(server.getPotionBrewer()).thenReturn(mock(PotionBrewer.class));
when(server.getPluginManager()).thenReturn(mock(PluginManager.class));
Set<NamespacedKey> known = new LinkedHashSet<>();
Player player = discoveryPlayer("Persistent", known);
doReturn(List.of(player)).when(server).getOnlinePlayers();
Plugin plugin = mock(Plugin.class);
Logger logger = Logger.getAnonymousLogger();
PotionRecipes firstRecipes = new PotionRecipes(server,
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
RecipeLifecycle first = new RecipeLifecycle(server, plugin, firstRecipes, logger);
assertTrue(first.enable());
first.disable();
assertTrue(registry.isEmpty());
assertEquals(new LinkedHashSet<>(firstRecipes.recipeKeys()), known);
PotionRecipes secondRecipes = new PotionRecipes(server,
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
assertTrue(new RecipeLifecycle(server, plugin, secondRecipes, logger).enable());
assertEquals(new LinkedHashSet<>(secondRecipes.recipeKeys()), registry);
verify(player, times(1)).discoverRecipes(any());
verify(player, never()).undiscoverRecipes(any());
}
@Test
void onlineEnumerationFailureDoesNotLeaveDiscoveryListenerActiveAfterCleanup() {
Server server = Bukkit.getServer();
when(server.addRecipe(any(Recipe.class))).thenReturn(true);
when(server.getPotionBrewer()).thenReturn(mock(PotionBrewer.class));
PluginManager events = new PaperPluginManagerImpl(server,
new SimpleCommandMap(server, new HashMap<>()), null);
when(server.getPluginManager()).thenReturn(events);
when(server.getOnlinePlayers()).thenThrow(new IllegalStateException("simulated lifecycle failure"));
Plugin plugin = mock(Plugin.class);
when(plugin.isEnabled()).thenReturn(true);
when(plugin.getName()).thenReturn("SpigotHeights");
when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
PluginDescriptionFile description = new PluginDescriptionFile("SpigotHeights", "test", "unused.Main");
when(plugin.getDescription()).thenReturn(description);
when(plugin.getPluginMeta()).thenReturn(description);
Set<NamespacedKey> joiningKnown = new LinkedHashSet<>();
Player joining = discoveryPlayer("Joining", joiningKnown);
PotionRecipes recipes = new PotionRecipes(server,
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
RecipeLifecycle lifecycle = new RecipeLifecycle(server, plugin, recipes, plugin.getLogger());
assertFalse(lifecycle.enable());
events.callEvent(new PlayerJoinEvent(joining, "joined"));
assertTrue(joiningKnown.isEmpty());
}
@Test
void listenerRegistrationExceptionRemovesPartiallyRegisteredDiscoveryListener() {
Server server = Bukkit.getServer();
when(server.addRecipe(any(Recipe.class))).thenReturn(true);
when(server.getPotionBrewer()).thenReturn(mock(PotionBrewer.class));
PluginManager registeredEvents = new PaperPluginManagerImpl(server,
new SimpleCommandMap(server, new HashMap<>()), null);
PluginManager throwingEvents = mock(PluginManager.class);
doAnswer(invocation -> {
registeredEvents.registerEvents(invocation.getArgument(0), invocation.getArgument(1));
throw new IllegalStateException("simulated post-registration failure");
}).when(throwingEvents).registerEvents(any(), any());
when(server.getPluginManager()).thenReturn(throwingEvents);
doReturn(List.of()).when(server).getOnlinePlayers();
Plugin plugin = enabledPlugin("ListenerFailure");
Set<NamespacedKey> joiningKnown = new LinkedHashSet<>();
Player joining = discoveryPlayer("Joining", joiningKnown);
PotionRecipes recipes = new PotionRecipes(server,
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
RecipeLifecycle lifecycle = new RecipeLifecycle(server, plugin, recipes, plugin.getLogger());
assertFalse(lifecycle.enable());
registeredEvents.callEvent(new PlayerJoinEvent(joining, "joined"));
assertTrue(joiningKnown.isEmpty(), "A failed lifecycle must not leave its listener active");
verify(server, times(4)).removeRecipe(any());
}
@Test
void lifecycleRegistrationExceptionCleansOwnedRecipesLogsAndAwardsNobody() {
Server server = Bukkit.getServer();
AtomicInteger attempts = new AtomicInteger();
List<NamespacedKey> removed = new ArrayList<>();
when(server.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
if (attempts.getAndIncrement() == 1) {
throw new IllegalStateException("simulated registry failure");
}
return true;
});
when(server.removeRecipe(any(NamespacedKey.class))).thenAnswer(invocation -> {
removed.add(invocation.getArgument(0));
return true;
});
PotionBrewer brewer = mock(PotionBrewer.class);
when(server.getPotionBrewer()).thenReturn(brewer);
PluginManager events = mock(PluginManager.class);
when(server.getPluginManager()).thenReturn(events);
Set<NamespacedKey> known = new LinkedHashSet<>();
doReturn(List.of(discoveryPlayer("Online", known))).when(server).getOnlinePlayers();
Plugin plugin = mock(Plugin.class);
Logger logger = Logger.getAnonymousLogger();
logger.setUseParentHandlers(false);
List<String> logs = new ArrayList<>();
logger.addHandler(new Handler() {
@Override public void publish(LogRecord record) { logs.add(record.getMessage()); }
@Override public void flush() {}
@Override public void close() {}
});
PotionRecipes recipes = new PotionRecipes(server,
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
RecipeLifecycle lifecycle = new RecipeLifecycle(server, plugin, recipes, logger);
boolean enabled = assertDoesNotThrow(lifecycle::enable);
assertFalse(enabled);
assertEquals(List.of(recipes.shiftingKey()), removed);
assertTrue(known.isEmpty());
assertEquals(1, logs.size());
verifyNoInteractions(events);
verifyNoInteractions(brewer);
}
@Test
void cleanupFailureIsLoggedWithoutEscapingOrActivatingDiscovery() {
Server server = Bukkit.getServer();
AtomicInteger attempts = new AtomicInteger();
when(server.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
if (attempts.incrementAndGet() == 2) {
throw new IllegalStateException("simulated registration failure");
}
return true;
});
when(server.removeRecipe(any(NamespacedKey.class)))
.thenThrow(new IllegalStateException("simulated cleanup failure"));
PotionBrewer brewer = mock(PotionBrewer.class);
when(server.getPotionBrewer()).thenReturn(brewer);
PluginManager events = mock(PluginManager.class);
when(server.getPluginManager()).thenReturn(events);
Logger logger = Logger.getAnonymousLogger();
logger.setUseParentHandlers(false);
List<LogRecord> logs = new ArrayList<>();
logger.addHandler(new Handler() {
@Override public void publish(LogRecord record) { logs.add(record); }
@Override public void flush() {}
@Override public void close() {}
});
PotionRecipes recipes = new PotionRecipes(server,
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
RecipeLifecycle lifecycle = new RecipeLifecycle(server, mock(Plugin.class), recipes, logger);
boolean enabled = assertDoesNotThrow(lifecycle::enable);
assertFalse(enabled);
assertEquals(1, logs.size());
assertEquals(1, logs.getFirst().getThrown().getSuppressed().length);
verifyNoInteractions(events, brewer);
}
@Test
void postInsertionRegistrationFailureRemovesEveryRecipeOwnedByThisLifecycle() {
Server server = Bukkit.getServer();
NamespacedKey unrelatedKey = new NamespacedKey("minecraft", "stick");
java.util.Map<NamespacedKey, Recipe> registry = new HashMap<>();
registry.put(unrelatedKey, mock(Recipe.class));
AtomicInteger attempts = new AtomicInteger();
when(server.getRecipe(any(NamespacedKey.class))).thenAnswer(invocation ->
registry.get(invocation.getArgument(0)));
when(server.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
Recipe recipe = invocation.getArgument(0);
registry.put(((Keyed) recipe).getKey(), recipe);
if (attempts.incrementAndGet() == 2) {
throw new IllegalStateException("simulated failure after native insertion");
}
return true;
});
when(server.removeRecipe(any(NamespacedKey.class))).thenAnswer(invocation ->
registry.remove(invocation.getArgument(0)) != null);
PotionBrewer brewer = mock(PotionBrewer.class);
when(server.getPotionBrewer()).thenReturn(brewer);
when(server.getPluginManager()).thenReturn(mock(PluginManager.class));
Plugin plugin = mock(Plugin.class);
Logger logger = Logger.getAnonymousLogger();
logger.setUseParentHandlers(false);
PotionRecipes recipes = new PotionRecipes(server,
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
RecipeLifecycle lifecycle = new RecipeLifecycle(server, plugin, recipes, logger);
assertFalse(lifecycle.enable());
assertEquals(Set.of(unrelatedKey), registry.keySet());
verifyNoInteractions(brewer);
}
@Test
void disableUnregistersTheCurrentLifecycleDiscoveryListener() {
Server server = Bukkit.getServer();
PluginManager events = new PaperPluginManagerImpl(server,
new SimpleCommandMap(server, new HashMap<>()), null);
when(server.getPluginManager()).thenReturn(events);
when(server.addRecipe(any(Recipe.class))).thenReturn(true);
when(server.getPotionBrewer()).thenReturn(mock(PotionBrewer.class));
doReturn(List.of()).when(server).getOnlinePlayers();
Plugin plugin = enabledPlugin("DisableListener");
Set<NamespacedKey> joiningKnown = new LinkedHashSet<>();
Player joining = discoveryPlayer("Joining", joiningKnown);
PotionRecipes recipes = new PotionRecipes(server,
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
RecipeLifecycle lifecycle = new RecipeLifecycle(server, plugin, recipes, plugin.getLogger());
assertTrue(lifecycle.enable());
lifecycle.disable();
events.callEvent(new PlayerJoinEvent(joining, "joined"));
assertTrue(joiningKnown.isEmpty());
}
@Test
void successfulLifecycleDiscoversForOnlineAndLaterJoiningPlayersThroughRegisteredListener() {
Server server = Bukkit.getServer();
PluginManager events = new PaperPluginManagerImpl(server,
new SimpleCommandMap(server, new HashMap<>()), null);
when(server.getPluginManager()).thenReturn(events);
when(server.addRecipe(any(Recipe.class))).thenReturn(true);
when(server.getPotionBrewer()).thenReturn(mock(PotionBrewer.class));
Plugin plugin = mock(Plugin.class);
when(plugin.isEnabled()).thenReturn(true);
when(plugin.getName()).thenReturn("SpigotHeights");
when(plugin.getLogger()).thenReturn(Logger.getLogger("NativeRecipeBookTest"));
PluginDescriptionFile description = new PluginDescriptionFile("SpigotHeights", "test", "unused.Main");
when(plugin.getDescription()).thenReturn(description);
when(plugin.getPluginMeta()).thenReturn(description);
Set<NamespacedKey> onlineKnown = new LinkedHashSet<>();
Set<NamespacedKey> joiningKnown = new LinkedHashSet<>();
Player online = discoveryPlayer("Online", onlineKnown);
Player joining = discoveryPlayer("Joining", joiningKnown);
doReturn(List.of(online)).when(server).getOnlinePlayers();
PotionRecipes recipes = new PotionRecipes(server,
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
RecipeLifecycle lifecycle = new RecipeLifecycle(server, plugin, recipes, plugin.getLogger());
assertTrue(lifecycle.enable());
events.callEvent(new PlayerJoinEvent(joining, "joined"));
assertEquals(new LinkedHashSet<>(recipes.recipeKeys()), onlineKnown);
assertEquals(new LinkedHashSet<>(recipes.recipeKeys()), joiningKnown);
}
private static Plugin enabledPlugin(String loggerName) {
Plugin plugin = mock(Plugin.class);
when(plugin.isEnabled()).thenReturn(true);
when(plugin.getName()).thenReturn("SpigotHeights");
when(plugin.getLogger()).thenReturn(Logger.getLogger(loggerName));
PluginDescriptionFile description = new PluginDescriptionFile("SpigotHeights", "test", "unused.Main");
when(plugin.getDescription()).thenReturn(description);
when(plugin.getPluginMeta()).thenReturn(description);
return plugin;
}
private static Player discoveryPlayer(String name, Set<NamespacedKey> known) {
Player player = mock(Player.class);
when(player.getName()).thenReturn(name);
when(player.hasDiscoveredRecipe(any(NamespacedKey.class)))
.thenAnswer(invocation -> known.contains(invocation.getArgument(0)));
when(player.discoverRecipes(any())).thenAnswer(invocation -> {
List<NamespacedKey> requested = ((Collection<?>) invocation.getArgument(0)).stream()
.map(NamespacedKey.class::cast).toList();
known.addAll(requested);
return requested.size();
});
return player;
}
@Test
void incompleteRegistrationRollsBackOnlyRecipesAddedInThisAttempt() {
Server server = Bukkit.getServer();
List<NamespacedKey> added = new ArrayList<>();
List<NamespacedKey> removed = new ArrayList<>();
NamespacedKey conflict = new NamespacedKey("spigotheights", "diminution");
when(server.getRecipe(conflict)).thenReturn(mock(Recipe.class));
when(server.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
added.add(((Keyed) invocation.getArgument(0)).getKey());
return true;
});
when(server.removeRecipe(any(NamespacedKey.class))).thenAnswer(invocation -> {
removed.add(invocation.getArgument(0));
return true;
});
PotionBrewer brewer = mock(PotionBrewer.class);
when(server.getPotionBrewer()).thenReturn(brewer);
PotionRecipes recipes = new PotionRecipes(server,
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
assertFalse(recipes.register());
assertEquals(List.of(
new NamespacedKey("spigotheights", "shifting_stature"),
new NamespacedKey("spigotheights", "growth")), added);
assertEquals(added, removed);
assertFalse(removed.contains(conflict), "A pre-existing conflict is not owned by this lifecycle");
verify(brewer, times(8)).addPotionMix(any());
}
@Test
void disableRemovesOnlySuccessfulCurrentLifecycleRegistrations() {
Server server = Bukkit.getServer();
List<NamespacedKey> added = new ArrayList<>();
List<NamespacedKey> removed = new ArrayList<>();
when(server.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
added.add(((Keyed) invocation.getArgument(0)).getKey());
return true;
});
when(server.removeRecipe(any(NamespacedKey.class))).thenAnswer(invocation -> {
removed.add(invocation.getArgument(0));
return true;
});
when(server.getPotionBrewer()).thenReturn(mock(PotionBrewer.class));
PotionRecipes recipes = new PotionRecipes(server,
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
recipes.register();
recipes.unregister();
assertEquals(4, added.size());
assertEquals(added, removed);
assertFalse(removed.contains(new NamespacedKey("minecraft", "unrelated")));
}
@Test
void nativeRecipeBookAutofillPlacesExactInputsAndPreservesUnrelatedInventory() {
Server server = Bukkit.getServer();
List<Recipe> registered = new ArrayList<>();
when(server.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
registered.add(invocation.getArgument(0));
return true;
});
when(server.getPotionBrewer()).thenReturn(mock(PotionBrewer.class));
when(server.getPluginManager()).thenReturn(mock(PluginManager.class));
PotionRecipes recipes = new PotionRecipes(server,
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
assertTrue(recipes.register());
for (int index = 0; index < registered.size(); index++) {
ShapedRecipe bukkit = (ShapedRecipe) registered.get(index);
net.minecraft.world.item.crafting.ShapedRecipe nativeRecipe = nativeRecipe(bukkit);
var holder = new net.minecraft.world.item.crafting.RecipeHolder<>(
nativeRecipeKey(bukkit.getKey()), nativeRecipe);
var nativePlayer = mock(net.minecraft.world.entity.player.Player.class);
when(nativePlayer.level()).thenReturn(mock(net.minecraft.world.level.Level.class));
when(nativePlayer.getBukkitEntity()).thenReturn(
mock(org.bukkit.craftbukkit.entity.CraftPlayer.class));
var inventory = new net.minecraft.world.entity.player.Inventory(nativePlayer,
new net.minecraft.world.entity.EntityEquipment());
when(nativePlayer.getInventory()).thenReturn(inventory);
List<ItemStack> expectedGrid = craftingGrid(recipes, index, null);
for (int slot = 0; slot < expectedGrid.size(); slot++) {
ItemStack ingredient = expectedGrid.get(slot);
if (!ingredient.isEmpty()) {
inventory.setItem(slot, CraftItemStack.asNMSCopy(ingredient));
}
}
ItemStack unrelated = new ItemStack(Material.DIAMOND, 7);
inventory.setItem(20, CraftItemStack.asNMSCopy(unrelated));
var grid = new net.minecraft.world.inventory.TransientCraftingContainer(
mock(net.minecraft.world.inventory.AbstractContainerMenu.class), 3, 3, nativePlayer);
ItemStack displaced = new ItemStack(Material.DIRT, 3);
grid.setItem(0, CraftItemStack.asNMSCopy(displaced));
List<net.minecraft.world.inventory.Slot> gridSlots = java.util.stream.IntStream.range(0, 9)
.mapToObj(slot -> new net.minecraft.world.inventory.Slot(grid, slot, 0, 0))
.toList();
var access = new net.minecraft.recipebook.ServerPlaceRecipe.CraftingMenuAccess<
net.minecraft.world.item.crafting.ShapedRecipe>() {
@Override
public void fillCraftSlotsStackedContents(
net.minecraft.world.entity.player.StackedItemContents contents) {
grid.fillStackedContents(contents);
}
@Override
public void clearCraftingContent() {
grid.clearContent();
}
@Override
public boolean recipeMatches(net.minecraft.world.item.crafting.RecipeHolder<
net.minecraft.world.item.crafting.ShapedRecipe> candidate) {
return candidate.value().matches(
net.minecraft.world.item.crafting.CraftingInput.of(3, 3, grid.getItems()),
mock(net.minecraft.world.level.Level.class));
}
};
var action = net.minecraft.recipebook.ServerPlaceRecipe.placeRecipe(access, 3, 3,
gridSlots, gridSlots, inventory, holder, false, false);
assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.NOTHING, action);
assertEquals(expectedGrid, grid.getItems().stream().map(CraftItemStack::asBukkitCopy).toList());
assertTrue(java.util.stream.IntStream.range(0, 9)
.allMatch(slot -> inventory.getItem(slot).isEmpty()),
"Autofill must relocate rather than duplicate or consume available ingredients");
assertEquals(unrelated, CraftItemStack.asBukkitCopy(inventory.getItem(20)));
assertEquals(3, inventory.getNonEquipmentItems().stream()
.map(CraftItemStack::asBukkitCopy)
.filter(item -> item.getType() == Material.DIRT)
.mapToInt(ItemStack::getAmount)
.sum(), "Autofill must return unrelated grid contents to inventory");
assertTrue(nativeRecipe.matches(nativeInput(grid.getItems().stream()
.map(CraftItemStack::asBukkitCopy).toList()), mock(net.minecraft.world.level.Level.class)));
net.minecraft.world.item.ItemStack nativeResult = nativeRecipe.assemble(nativeInput(
grid.getItems().stream().map(CraftItemStack::asBukkitCopy).toList()));
ItemStack result = CraftItemStack.asBukkitCopy(nativeResult);
assertEquals(StaturePotion.values()[index], recipes.identify(result));
assertEquals(1, result.getAmount());
grid.setCurrentRecipe(new net.minecraft.world.item.crafting.RecipeHolder<
net.minecraft.world.item.crafting.CraftingRecipe>(holder.id(), holder.value()));
var resultContainer = new net.minecraft.world.inventory.ResultContainer();
resultContainer.setRecipeUsed(holder);
resultContainer.setItem(0, nativeResult);
var resultSlot = new net.minecraft.world.inventory.ResultSlot(nativePlayer, grid,
resultContainer, 0, 0, 0);
net.minecraft.world.item.ItemStack taken = resultSlot.remove(1);
resultSlot.onTake(nativePlayer, taken);
assertEquals(StaturePotion.values()[index],
recipes.identify(CraftItemStack.asBukkitCopy(taken)));
assertTrue(resultContainer.isEmpty(), "Completed crafting must yield exactly one result");
assertTrue(grid.isEmpty(), "Completed crafting must consume exactly the placed ingredients");
assertEquals(unrelated, CraftItemStack.asBukkitCopy(inventory.getItem(20)));
assertEquals(3, inventory.getNonEquipmentItems().stream()
.map(CraftItemStack::asBukkitCopy)
.filter(item -> item.getType() == Material.DIRT)
.mapToInt(ItemStack::getAmount)
.sum());
}
}
@Test
void nativeRecipeBookAutofillRejectsInvalidPotionSubstitutionsWithoutMovingInventory() {
Server server = Bukkit.getServer();
List<Recipe> registered = new ArrayList<>();
when(server.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
registered.add(invocation.getArgument(0));
return true;
});
when(server.getPotionBrewer()).thenReturn(mock(PotionBrewer.class));
PotionRecipes recipes = new PotionRecipes(server,
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
assertTrue(recipes.register());
for (int index = 0; index < registered.size(); index++) {
ShapedRecipe bukkit = (ShapedRecipe) registered.get(index);
for (ItemStack invalidPotion : invalidPotionInputs(recipes, index)) {
assertNativeAutofillRejected(recipes, index, bukkit, invalidPotion);
}
}
}
private static void assertNativeAutofillRejected(PotionRecipes recipes, int recipeIndex,
ShapedRecipe bukkit, ItemStack invalidPotion) {
net.minecraft.world.item.crafting.ShapedRecipe nativeRecipe = nativeRecipe(bukkit);
var holder = new net.minecraft.world.item.crafting.RecipeHolder<>(
nativeRecipeKey(bukkit.getKey()), nativeRecipe);
var nativePlayer = mock(net.minecraft.world.entity.player.Player.class);
var inventory = new net.minecraft.world.entity.player.Inventory(nativePlayer,
new net.minecraft.world.entity.EntityEquipment());
List<ItemStack> available = craftingGrid(recipes, recipeIndex, invalidPotion);
for (int slot = 0; slot < available.size(); slot++) {
if (!available.get(slot).isEmpty()) {
inventory.setItem(slot, CraftItemStack.asNMSCopy(available.get(slot)));
}
}
inventory.setItem(20, CraftItemStack.asNMSCopy(new ItemStack(Material.DIAMOND, 7)));
List<ItemStack> inventoryBefore = inventory.getNonEquipmentItems().stream()
.map(CraftItemStack::asBukkitCopy).toList();
var grid = new net.minecraft.world.SimpleContainer(9);
List<net.minecraft.world.inventory.Slot> gridSlots = java.util.stream.IntStream.range(0, 9)
.mapToObj(slot -> new net.minecraft.world.inventory.Slot(grid, slot, 0, 0))
.toList();
var access = new net.minecraft.recipebook.ServerPlaceRecipe.CraftingMenuAccess<
net.minecraft.world.item.crafting.ShapedRecipe>() {
@Override
public void fillCraftSlotsStackedContents(
net.minecraft.world.entity.player.StackedItemContents contents) {
grid.fillStackedContents(contents);
}
@Override
public void clearCraftingContent() {
grid.clearContent();
}
@Override
public boolean recipeMatches(net.minecraft.world.item.crafting.RecipeHolder<
net.minecraft.world.item.crafting.ShapedRecipe> candidate) {
return false;
}
};
var action = net.minecraft.recipebook.ServerPlaceRecipe.placeRecipe(access, 3, 3,
gridSlots, gridSlots, inventory, holder, false, false);
assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.PLACE_GHOST_RECIPE,
action, invalidPotion::toString);
assertTrue(grid.isEmpty());
assertEquals(inventoryBefore, inventory.getNonEquipmentItems().stream()
.map(CraftItemStack::asBukkitCopy).toList());
}
private static net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>> nativeRecipeKey(
NamespacedKey key) {
return net.minecraft.resources.ResourceKey.create(net.minecraft.core.registries.Registries.RECIPE,
net.minecraft.resources.Identifier.fromNamespaceAndPath(key.getNamespace(), key.getKey()));
}
@Test
void nativeCraftingMatchesExactInputsAndProducesAllFourAuthenticatedResults() {
Server server = Bukkit.getServer();
List<Recipe> registered = new ArrayList<>();
when(server.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
registered.add(invocation.getArgument(0));
return true;
});
when(server.getPotionBrewer()).thenReturn(mock(PotionBrewer.class));
PotionRecipes recipes = new PotionRecipes(server,
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
assertTrue(recipes.register());
for (int index = 0; index < registered.size(); index++) {
ShapedRecipe bukkit = (ShapedRecipe) registered.get(index);
net.minecraft.world.item.crafting.ShapedRecipe nativeRecipe = nativeRecipe(bukkit);
assertTrue(nativeRecipe.getIngredients().get(4).orElseThrow().isExact(),
"Pinned recipe placement must retain the component-sensitive potion choice");
List<ItemStack> valid = craftingGrid(recipes, index, null);
List<ItemStack> unchanged = valid.stream().map(ItemStack::clone).toList();
net.minecraft.world.item.crafting.CraftingInput input = nativeInput(valid);
assertTrue(nativeRecipe.matches(input, mock(net.minecraft.world.level.Level.class)));
ItemStack result = CraftItemStack.asBukkitCopy(nativeRecipe.assemble(input));
assertEquals(StaturePotion.values()[index], recipes.identify(result));
assertEquals(1, result.getAmount());
assertEquals(unchanged, valid, "Matching and preview assembly must not consume the grid");
for (ItemStack invalidPotion : invalidPotionInputs(recipes, index)) {
assertFalse(nativeRecipe.matches(nativeInput(craftingGrid(recipes, index, invalidPotion)),
mock(net.minecraft.world.level.Level.class)));
}
}
}
private static net.minecraft.world.item.crafting.ShapedRecipe nativeRecipe(ShapedRecipe recipe) {
java.util.Map<Character, net.minecraft.world.item.crafting.Ingredient> ingredients = new HashMap<>();
recipe.getChoiceMap().forEach((symbol, choice) ->
ingredients.put(symbol, CraftRecipe.toIngredient(choice, false)));
var pattern = net.minecraft.world.item.crafting.ShapedRecipePattern.of(ingredients, recipe.getShape());
return new net.minecraft.world.item.crafting.ShapedRecipe(
new net.minecraft.world.item.crafting.Recipe.CommonInfo(true),
new net.minecraft.world.item.crafting.CraftingRecipe.CraftingBookInfo(
CraftRecipe.getCategory(recipe.getCategory()), recipe.getGroup()),
pattern, CraftItemStack.asTemplate(recipe.getResult()));
}
private static net.minecraft.world.item.crafting.CraftingInput nativeInput(List<ItemStack> items) {
return net.minecraft.world.item.crafting.CraftingInput.of(3, 3, CraftItemStack.asNMSCopy(items));
}
private static List<ItemStack> craftingGrid(PotionRecipes recipes, int recipeIndex, ItemStack potionOverride) {
String[] shape = switch (recipeIndex) {
case 0 -> PotionRecipes.SHIFTING_SHAPE;
case 1 -> PotionRecipes.GROWTH_SHAPE;
case 2 -> PotionRecipes.DIMINUTION_SHAPE;
case 3 -> PotionRecipes.RESTORATION_SHAPE;
default -> throw new IllegalArgumentException("Unknown recipe index");
};
List<ItemStack> items = new ArrayList<>();
for (String row : shape) {
for (char symbol : row.toCharArray()) {
items.add(switch (symbol) {
case 'A' -> new ItemStack(Material.AMETHYST_SHARD);
case 'C' -> new ItemStack(Material.CHORUS_FRUIT);
case 'G' -> new ItemStack(Material.GOLD_INGOT);
case 'R' -> new ItemStack(Material.RABBIT_FOOT);
case 'F' -> new ItemStack(Material.FERMENTED_SPIDER_EYE);
case 'U' -> new ItemStack(Material.SUGAR);
case 'W', 'S' -> potionOverride != null ? potionOverride.clone()
: recipeIndex == 0 ? awkwardPotion() : recipes.create(StaturePotion.SHIFTING);
default -> ItemStack.empty();
});
}
}
return items;
}
private static List<ItemStack> invalidPotionInputs(PotionRecipes recipes, int recipeIndex) {
ItemStack ordinary = new ItemStack(Material.POTION);
ItemStack unrelated = new ItemStack(Material.POTION);
unrelated.editMeta(org.bukkit.inventory.meta.PotionMeta.class,
meta -> meta.setBasePotionType(org.bukkit.potion.PotionType.HEALING));
if (recipeIndex == 0) {
return List.of(ordinary, unrelated, recipes.create(StaturePotion.SHIFTING));
}
ItemStack nameOnly = new ItemStack(Material.POTION);
nameOnly.editMeta(meta -> meta.displayName(
net.kyori.adventure.text.Component.text(StaturePotion.SHIFTING.displayName())));
return List.of(ordinary, unrelated, nameOnly, recipes.create(StaturePotion.GROWTH));
}
private static ItemStack awkwardPotion() {
ItemStack awkward = new ItemStack(Material.POTION);
awkward.editMeta(org.bukkit.inventory.meta.PotionMeta.class,
meta -> meta.setBasePotionType(org.bukkit.potion.PotionType.AWKWARD));
return awkward;
}
@Test
void registersAllAuthenticatedResultsInMiscWithExactPotionInputs() {
Server server = Bukkit.getServer();
List<Recipe> registered = new ArrayList<>();
when(server.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
registered.add(invocation.getArgument(0));
return true;
});
when(server.getPotionBrewer()).thenReturn(mock(PotionBrewer.class));
PotionRecipes recipes = new PotionRecipes(server,
new NamespacedKey("spigotheights", "stature_potion"), ItemStack::new);
recipes.register();
assertEquals(4, registered.size());
for (int index = 0; index < StaturePotion.values().length; index++) {
ShapedRecipe recipe = assertInstanceOf(ShapedRecipe.class, registered.get(index));
assertEquals(CraftingBookCategory.MISC, recipe.getCategory());
assertEquals(StaturePotion.values()[index], recipes.identify(recipe.getResult()));
RecipeChoice.ExactChoice choice = assertInstanceOf(RecipeChoice.ExactChoice.class,
recipe.getChoiceMap().get(index == 0 ? 'W' : 'S'));
ItemStack selected = choice.getChoices().getFirst();
if (index == 0) {
assertEquals(Material.POTION, selected.getType());
assertTrue(recipes.isAwkwardPotion(selected));
assertNull(recipes.identify(selected));
} else {
assertEquals(StaturePotion.SHIFTING, recipes.identify(selected));
}
}
}
}
@@ -21,15 +21,22 @@ import net.minecraft.server.permissions.LevelBasedPermissionSet;
import net.minecraft.tags.TagLoader;
import net.minecraft.util.Util;
import net.minecraft.world.flag.FeatureFlags;
import net.minecraft.world.item.crafting.RecipeManager;
import net.minecraft.world.level.DataPackConfig;
import net.minecraft.world.level.WorldDataConfiguration;
import org.bukkit.craftbukkit.CraftRegistry;
/** Loads vanilla registries/tags/components using the same path as the server's own tests. */
final class NativeRuntime {
private static boolean bootstrapped;
private static RecipeManager recipes;
private NativeRuntime() {}
static void bootstrap() throws Exception {
static synchronized void bootstrap() throws Exception {
if (bootstrapped) {
return;
}
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
var flags = FeatureFlags.VANILLA_SET;
@@ -39,12 +46,12 @@ final class NativeRuntime {
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);
List<HolderLookup.RegistryLookup<?>> lookups = TagLoader.buildUpdatedLookups(layers.getAccessForLoading(RegistryLayer.WORLD), tags);
RegistryAccess.Frozen world = RegistryDataLoader.load(resources, lookups,
RegistryDataLoader.WORLD_REGISTRIES, Util.backgroundExecutor()).join();
layers = layers.replaceFrom(RegistryLayer.WORLD, world);
RegistryAccess.Frozen dimensions = RegistryDataLoader.load(resources,
Stream.concat(lookups.stream(), worldgen.listRegistries()).toList(),
Stream.concat(lookups.stream(), world.listRegistries()).toList(),
RegistryDataLoader.DIMENSION_REGISTRIES, Util.backgroundExecutor()).join();
layers = layers.replaceFrom(RegistryLayer.DIMENSIONS, dimensions);
Class.forName(org.bukkit.Registry.class.getName());
@@ -53,6 +60,16 @@ final class NativeRuntime {
Util.backgroundExecutor(), Runnable::run).join();
datapack.updateComponentsAndStaticRegistryTags();
CraftRegistry.setMinecraftRegistry(layers.compositeAccess().freeze());
recipes = datapack.getRecipeManager();
recipes.finalizeRecipeLoading(flags);
bootstrapped = true;
}
}
static RecipeManager recipes() {
if (!bootstrapped) {
throw new IllegalStateException("Native runtime has not been bootstrapped");
}
return recipes;
}
}
@@ -64,7 +64,12 @@ class PotionRecipesTest {
input.getItemMeta().getPersistentDataContainer().set(key, PersistentDataType.STRING, kind.name());
org.junit.jupiter.api.Assertions.assertTrue(mix.getInput().test(input));
org.junit.jupiter.api.Assertions.assertFalse(mix.getInput().test(mix.getResult()));
org.junit.jupiter.api.Assertions.assertTrue(mix.getIngredient().test(new BukkitDoubles.Item(
var ingredient = org.junit.jupiter.api.Assertions.assertInstanceOf(
org.bukkit.inventory.RecipeChoice.PredicateChoice.class, mix.getIngredient(),
"Purpur 26.3 builds brewing reagent indexes from representative recipe choices");
assertEquals(splash ? Material.GUNPOWDER : Material.DRAGON_BREATH,
ingredient.getItemStack().getType());
org.junit.jupiter.api.Assertions.assertTrue(ingredient.test(new BukkitDoubles.Item(
splash ? Material.GUNPOWDER : Material.DRAGON_BREATH)));
org.junit.jupiter.api.Assertions.assertFalse(mix.getIngredient().test(new BukkitDoubles.Item(Material.SUGAR)));
}
@@ -0,0 +1,89 @@
package games.dmg.spigotheights;
import static org.junit.jupiter.api.Assertions.*;
import java.io.DataInputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.jar.JarFile;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
class PurpurDistributionTest {
@Test
void packagesNewNameWithoutChangingRuntimeIdentityOrBundlingTheApi() throws Exception {
Path artifact = Path.of(System.getProperty("distribution.jar"));
String version = System.getProperty("distribution.version");
assertEquals("purpur-heights-" + version + ".jar", artifact.getFileName().toString());
try (var jar = new JarFile(artifact.toFile())) {
Map<?, ?> metadata = new Yaml().load(jar.getInputStream(jar.getJarEntry("plugin.yml")));
assertEquals(version, metadata.get("version"));
assertEquals("SpigotHeights", metadata.get("name"), "Retain the data directory and persistent namespace");
assertEquals("games.dmg.spigotheights.SpigotHeightsPlugin", metadata.get("main"));
assertEquals("1.21", metadata.get("api-version"));
assertTrue(((Map<?, ?>) metadata.get("commands")).containsKey("heights"));
assertTrue(((Map<?, ?>) metadata.get("permissions")).containsKey("spigotheights.admin"));
assertFalse(jar.stream().anyMatch(entry -> entry.getName().startsWith("org/bukkit/")
|| entry.getName().startsWith("org/purpurmc/")
|| entry.getName().startsWith("net/minecraft/")
|| entry.getName().startsWith("com/comphenix/protocol/")
|| entry.getName().startsWith("org/junit/")
|| entry.getName().startsWith("org/mockito/")
|| entry.getName().endsWith("Test.class")));
}
}
@Test
void packagesTheIntegratedRecipeDiscoveryLifecycle() throws Exception {
try (var jar = new JarFile(System.getProperty("distribution.jar"))) {
String plugin = classConstants(jar, "games/dmg/spigotheights/SpigotHeightsPlugin.class");
String lifecycle = classConstants(jar, "games/dmg/spigotheights/RecipeLifecycle.class");
String discovery = classConstants(jar, "games/dmg/spigotheights/RecipeDiscovery.class");
String recipes = classConstants(jar, "games/dmg/spigotheights/PotionRecipes.class");
assertTrue(plugin.contains("RecipeLifecycle"));
assertTrue(lifecycle.contains("getOnlinePlayers"));
assertTrue(lifecycle.contains("registerEvents"));
assertTrue(discovery.contains("discoverRecipes"));
assertTrue(discovery.contains("hasDiscoveredRecipe"));
assertTrue(recipes.contains("CraftingBookCategory"));
assertTrue(recipes.contains("AWKWARD"));
assertTrue(recipes.contains("RecipeChoice$ExactChoice"));
assertTrue(recipes.contains("removeRecipe"));
}
}
private static String classConstants(JarFile jar, String entry) throws Exception {
assertNotNull(jar.getJarEntry(entry), entry);
return new String(jar.getInputStream(jar.getJarEntry(entry)).readAllBytes(), StandardCharsets.ISO_8859_1);
}
@Test
void pluginBytecodeTargetsJava25() throws Exception {
try (var jar = new JarFile(System.getProperty("distribution.jar"));
var bytecode = new DataInputStream(jar.getInputStream(
jar.getJarEntry("games/dmg/spigotheights/SpigotHeightsPlugin.class")))) {
assertEquals(0xCAFEBABE, bytecode.readInt());
bytecode.readUnsignedShort();
assertEquals(69, bytecode.readUnsignedShort());
}
}
@Test
void releaseAndCiAgreeWithThePurpur26_3DistributionAndToolchain() throws Exception {
String build = Files.readString(Path.of("build.gradle.kts"));
assertTrue(build.contains("purpur-api:26.3.build.2651-stable"));
assertTrue(build.contains("purpur-26.3-2651.jar"));
assertTrue(build.contains("https://api.purpurmc.org/v2/purpur/26.3/2651/download"));
assertTrue(build.contains("0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598"));
assertFalse(build.contains("26.2") || build.contains("2618"));
assertFalse(build.contains("org.spigotmc:spigot-api"));
for (String workflow : new String[] {"ci.yml", "release.yml"}) {
String content = Files.readString(Path.of(".gitea/workflows", workflow));
assertTrue(content.contains("java-version: 25"));
assertTrue(content.contains("purpur-heights-"), workflow);
assertFalse(content.contains("spigot-heights-"), workflow);
}
}
}
@@ -0,0 +1,143 @@
package games.dmg.spigotheights;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.Handler;
import java.util.logging.LogRecord;
import java.util.logging.Logger;
import org.bukkit.NamespacedKey;
import org.bukkit.entity.Player;
import org.bukkit.event.player.PlayerJoinEvent;
import org.junit.jupiter.api.Test;
class RecipeDiscoveryTest {
private final List<NamespacedKey> keys = List.of(
new NamespacedKey("spigotheights", "shifting_stature"),
new NamespacedKey("spigotheights", "growth"),
new NamespacedKey("spigotheights", "diminution"),
new NamespacedKey("spigotheights", "restoration"));
@Test
void allAlreadyOnlinePlayersReceiveEveryUnknownRecipe() {
Set<NamespacedKey> firstKnown = new LinkedHashSet<>();
Set<NamespacedKey> secondKnown = new LinkedHashSet<>();
RecipeDiscovery discovery = new RecipeDiscovery(keys, Logger.getAnonymousLogger());
discovery.discoverAll(List.of(
player("First", firstKnown, new ArrayList<>(), false),
player("Second", secondKnown, new ArrayList<>(), false)));
assertEquals(new LinkedHashSet<>(keys), firstKnown);
assertEquals(new LinkedHashSet<>(keys), secondKnown);
}
@Test
void joiningPlayerReceivesEveryUnknownRecipe() {
Set<NamespacedKey> known = new LinkedHashSet<>();
List<List<NamespacedKey>> awards = new ArrayList<>();
Player player = player("Joiner", known, awards, false);
RecipeDiscovery discovery = new RecipeDiscovery(keys, Logger.getAnonymousLogger());
discovery.onJoin(new PlayerJoinEvent(player, "joined"));
assertEquals(List.of(keys), awards);
assertEquals(new LinkedHashSet<>(keys), known);
}
@Test
void onePlayerExceptionIsLoggedWithoutBlockingOthersAndRetriesLater() {
Set<NamespacedKey> failedKnown = new LinkedHashSet<>();
Set<NamespacedKey> healthyKnown = new LinkedHashSet<>();
AtomicBoolean firstAttempt = new AtomicBoolean(true);
List<String> warnings = new ArrayList<>();
Logger logger = logger(warnings);
Player failing = BukkitDoubles.proxy(Player.class, (proxy, method, args) -> switch (method.getName()) {
case "getName" -> "Failing";
case "hasDiscoveredRecipe" -> failedKnown.contains(args[0]);
case "discoverRecipes" -> {
if (firstAttempt.getAndSet(false)) {
throw new IllegalStateException("simulated discovery failure");
}
List<NamespacedKey> requested = ((Collection<?>) args[0]).stream()
.map(NamespacedKey.class::cast).toList();
failedKnown.addAll(requested);
yield requested.size();
}
default -> throw new UnsupportedOperationException(method.getName());
});
RecipeDiscovery discovery = new RecipeDiscovery(keys, logger);
assertDoesNotThrow(() -> discovery.discover(failing));
discovery.discover(player("Healthy", healthyKnown, new ArrayList<>(), false));
discovery.discover(failing);
assertEquals(new LinkedHashSet<>(keys), healthyKnown);
assertEquals(new LinkedHashSet<>(keys), failedKnown);
assertEquals(1, warnings.size());
}
@Test
void missingPostconditionIsLoggedAndOnlyMissingRecipesRetryLater() {
Set<NamespacedKey> known = new LinkedHashSet<>();
List<List<NamespacedKey>> awards = new ArrayList<>();
List<String> warnings = new ArrayList<>();
Logger logger = logger(warnings);
RecipeDiscovery discovery = new RecipeDiscovery(keys, logger);
discovery.discover(player("Ada", known, awards, true));
discovery.discover(player("Ada", known, awards, false));
assertEquals(List.of(keys, List.of(keys.getLast())), awards);
assertEquals(1, warnings.size());
assertEquals(new LinkedHashSet<>(keys), known);
}
@Test
void discoversOnlyUnknownRecipesAndRepeatedDiscoveryIsSilent() {
Set<NamespacedKey> known = new LinkedHashSet<>();
List<List<NamespacedKey>> awards = new ArrayList<>();
Player player = player("Ada", known, awards, false);
RecipeDiscovery discovery = new RecipeDiscovery(keys, Logger.getAnonymousLogger());
discovery.discover(player);
discovery.discover(player);
assertEquals(List.of(keys), awards);
assertEquals(new LinkedHashSet<>(keys), known);
}
private static Logger logger(List<String> warnings) {
Logger logger = Logger.getAnonymousLogger();
logger.setUseParentHandlers(false);
logger.addHandler(new Handler() {
@Override public void publish(LogRecord record) { warnings.add(record.getMessage()); }
@Override public void flush() {}
@Override public void close() {}
});
return logger;
}
static Player player(String name, Set<NamespacedKey> known, List<List<NamespacedKey>> awards,
boolean omitLastAward) {
return BukkitDoubles.proxy(Player.class, (proxy, method, args) -> switch (method.getName()) {
case "getName" -> name;
case "hasDiscoveredRecipe" -> known.contains(args[0]);
case "discoverRecipes" -> {
List<NamespacedKey> requested = ((Collection<?>) args[0]).stream()
.map(NamespacedKey.class::cast).toList();
awards.add(requested);
int limit = omitLastAward ? Math.max(0, requested.size() - 1) : requested.size();
known.addAll(requested.subList(0, limit));
yield limit;
}
default -> throw new UnsupportedOperationException(method.getName());
});
}
}