From df44bba9c6a42c7d80929ef08e6449ac6da63046 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Fri, 11 Sep 2026 22:16:48 -0400 Subject: [PATCH] fix(heights): repair native stature potion brewing --- README.md | 6 +- build.gradle.kts | 59 +++ .../spigotheights/SpigotHeightsPlugin.java | 1 + .../dmg/spigotheights/StatureBrewing.java | 89 ++++ .../dmg/spigotheights/NativeBrewingTest.java | 380 ++++++++++++++++++ .../dmg/spigotheights/NativeRuntime.java | 58 +++ .../org.mockito.plugins.MockMaker | 1 + 7 files changed, 593 insertions(+), 1 deletion(-) create mode 100644 src/main/java/games/dmg/spigotheights/StatureBrewing.java create mode 100644 src/nativeTest/java/games/dmg/spigotheights/NativeBrewingTest.java create mode 100644 src/nativeTest/java/games/dmg/spigotheights/NativeRuntime.java create mode 100644 src/nativeTest/resources/mockito-extensions/org.mockito.plugins.MockMaker diff --git a/README.md b/README.md index 40304e3..0ff1e28 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,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. -**Verification:** automated domain and Bukkit-boundary tests cover this implementation. Actual brewing, cloud delivery, client appearance, and logout/restart gameplay on Purpur 26.2 build 2618 still require manual acceptance checks. +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. + +**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. ## Consensual carrying (US-010; live acceptance pending) @@ -134,6 +136,8 @@ Player names must match an online player exactly (case-insensitive); partial nam The plugin JAR is written to `build/libs/`. +`check` includes `nativeBrewingTest`. It downloads the exact Purpur 26.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 the native brewing suite. Full verification remains `./gradlew clean check jar`. + ## Releases Gitea Actions checks pushes and pull requests and stores a development JAR. Pull requests validate conventional commits. Main-branch conventional commits drive semantic releases when the repository defines a `RELEASE_TOKEN` with contents-write permission. diff --git a/build.gradle.kts b/build.gradle.kts index 71c839b..16b9474 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,6 @@ +import java.net.URI +import java.security.MessageDigest + plugins { java } @@ -33,6 +36,62 @@ tasks.test { useJUnitPlatform() } +// 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") } + 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 prepareBrewingRuntime = tasks.register("prepareBrewingRuntime") { + dependsOn(downloadBrewingRuntime) + javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(25) } + classpath = files(brewingRuntime.map { it.file("purpur-26.2-2618.jar") }) + mainClass = "io.papermc.paperclip.Main" + jvmArgs("-Dpaperclip.patchonly=true") + workingDir(brewingRuntime) + outputs.dir(brewingRuntime.map { it.dir("versions") }) + outputs.dir(brewingRuntime.map { it.dir("libraries") }) +} +val nativeTest = sourceSets.create("nativeTest") +dependencies { + // Use the runtime's bundled API/dependencies, not the API POM's older transitive versions. + add(nativeTest.implementationConfigurationName, platform("org.junit:junit-bom:5.13.4")) + add(nativeTest.implementationConfigurationName, "org.junit.jupiter:junit-jupiter") + add(nativeTest.implementationConfigurationName, "org.mockito:mockito-core:5.18.0") + add(nativeTest.compileOnlyConfigurationName, "org.jetbrains:annotations:26.0.2") + add(nativeTest.compileOnlyConfigurationName, "org.checkerframework:checker-qual:3.49.2") + add(nativeTest.runtimeOnlyConfigurationName, "org.junit.platform:junit-platform-launcher") +} +val nativeRuntimeJars = files(fileTree(brewingRuntime) { + include("versions/**/*.jar", "libraries/**/*.jar") +}).builtBy(prepareBrewingRuntime) +nativeTest.compileClasspath += sourceSets.main.get().output + nativeRuntimeJars +nativeTest.runtimeClasspath += sourceSets.main.get().output + nativeRuntimeJars +val nativeBrewingTest = tasks.register("nativeBrewingTest") { + description = "Runs native Purpur brewing regressions without starting a server" + testClassesDirs = nativeTest.output.classesDirs + classpath = nativeTest.runtimeClasspath + useJUnitPlatform() + maxHeapSize = "1G" + workingDir(brewingRuntime) +} +tasks.check { dependsOn(nativeBrewingTest) } + val pluginVersion = version tasks.processResources { filesMatching("plugin.yml") { diff --git a/src/main/java/games/dmg/spigotheights/SpigotHeightsPlugin.java b/src/main/java/games/dmg/spigotheights/SpigotHeightsPlugin.java index 222d8fc..12e157c 100644 --- a/src/main/java/games/dmg/spigotheights/SpigotHeightsPlugin.java +++ b/src/main/java/games/dmg/spigotheights/SpigotHeightsPlugin.java @@ -43,6 +43,7 @@ public final class SpigotHeightsPlugin extends JavaPlugin { potions = new PotionRecipes(this); potions.register(); + getServer().getPluginManager().registerEvents(new StatureBrewing(potions), this); getServer().getPluginManager().registerEvents( new StatureListener(potions, stature::drink, stature::resume, task -> getServer().getScheduler().runTask(this, task)), this); diff --git a/src/main/java/games/dmg/spigotheights/StatureBrewing.java b/src/main/java/games/dmg/spigotheights/StatureBrewing.java new file mode 100644 index 0000000..6453c91 --- /dev/null +++ b/src/main/java/games/dmg/spigotheights/StatureBrewing.java @@ -0,0 +1,89 @@ +package games.dmg.spigotheights; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.BrewEvent; + +/** Completes authenticated stature conversions at the native brewing transaction boundary. */ +public final class StatureBrewing implements Listener { + private final PotionRecipes potions; + + public StatureBrewing(PotionRecipes potions) { + this.potions = potions; + } + + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void detachResults(BrewEvent event) { + if (event.isCancelled()) { + return; + } + try { + // Native no-op results can mirror the input itself. Detach before normal result modifiers + // run, otherwise an in-place result edit also changes the inventory despite cancellation. + for (int slot = 0; slot < Math.min(3, event.getResults().size()); slot++) { + ItemStack input = event.getContents().getItem(slot); + if (potions.identify(input) != null && input.equals(event.getResults().get(slot))) { + event.getResults().set(slot, input.clone()); + } + } + } catch (RuntimeException exception) { + event.setCancelled(true); + throw exception; + } + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void brew(BrewEvent event) { + try { + convert(event); + } catch (RuntimeException exception) { + // Paper logs listener exceptions and continues dispatch: cancel first to prevent item loss. + event.setCancelled(true); + throw exception; + } + } + + private void convert(BrewEvent event) { + ItemStack ingredient = event.getContents().getIngredient(); + if (event.isCancelled() || ingredient == null) { + return; + } + Material source; + Material target; + if (ingredient.getType() == Material.GUNPOWDER) { + source = Material.POTION; + target = Material.SPLASH_POTION; + } else if (ingredient.getType() == Material.DRAGON_BREATH) { + source = Material.SPLASH_POTION; + target = Material.LINGERING_POTION; + } else { + return; + } + for (int slot = 0; slot < 3; slot++) { + ItemStack input = event.getContents().getItem(slot); + StaturePotion kind = potions.identify(input); + if (kind != null && input.getType() == source) { + if (slot >= event.getResults().size() || input.getAmount() != 1) { + event.setCancelled(true); + return; + } + ItemStack result = event.getResults().get(slot); + if (result != null && result.getType() == target + && result.getAmount() == 1 && potions.identify(result) == kind) { + continue; + } + if (!input.equals(result)) { + // Do not overwrite another plugin's output or spend ingredients on a failed conversion. + event.setCancelled(true); + return; + } + // 2618 recognizes custom recipes but mix() returns base-less potions unchanged. + // Correct only that no-op; leave inventory and ingredient accounting to the server. + event.getResults().set(slot, potions.create(kind, target)); + } + } + } +} diff --git a/src/nativeTest/java/games/dmg/spigotheights/NativeBrewingTest.java b/src/nativeTest/java/games/dmg/spigotheights/NativeBrewingTest.java new file mode 100644 index 0000000..7f2db92 --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotheights/NativeBrewingTest.java @@ -0,0 +1,380 @@ +package games.dmg.spigotheights; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.logging.Logger; +import io.papermc.paper.plugin.manager.PaperPluginManagerImpl; +import net.minecraft.core.BlockPos; +import net.minecraft.world.flag.FeatureFlags; +import net.minecraft.world.item.alchemy.PotionBrewing; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.entity.BrewingStandBlockEntity; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.Server; +import org.bukkit.command.SimpleCommandMap; +import org.bukkit.craftbukkit.inventory.CraftInventoryBrewer; +import org.bukkit.craftbukkit.inventory.CraftItemFactory; +import org.bukkit.craftbukkit.inventory.CraftItemStack; +import org.bukkit.craftbukkit.util.CraftMagicNumbers; +import org.bukkit.event.HandlerList; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.BrewEvent; +import org.bukkit.entity.Player; +import org.bukkit.entity.AreaEffectCloud; +import org.bukkit.entity.ThrownPotion; +import org.bukkit.event.entity.PotionSplashEvent; +import org.bukkit.event.entity.LingeringPotionSplashEvent; +import org.bukkit.event.entity.AreaEffectCloudApplyEvent; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.PotionMeta; +import org.bukkit.potion.PotionType; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.PluginDescriptionFile; +import org.bukkit.plugin.PluginManager; +import org.bukkit.potion.PotionBrewer; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.Arguments; + +/** Real 2618 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; + + @BeforeAll + static void bootstrap() throws Exception { + NativeRuntime.bootstrap(); + Server server = mock(Server.class); + when(server.getItemFactory()).thenAnswer(invocation -> CraftItemFactory.instance()); + when(server.getUnsafe()).thenReturn(CraftMagicNumbers.INSTANCE); + when(server.getLogger()).thenReturn(Logger.getLogger("NativeBrewingTest")); + when(server.getName()).thenReturn("NativeBrewingTest"); + when(server.getVersion()).thenReturn("26.2-2618"); + when(server.getBukkitVersion()).thenReturn("26.2-2618"); + when(server.isPrimaryThread()).thenReturn(true); + events = new PaperPluginManagerImpl(server, new SimpleCommandMap(server, new HashMap<>()), null); + when(server.getPluginManager()).thenReturn(events); + Bukkit.setServer(server); + plugin = mock(Plugin.class); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getName()).thenReturn("SpigotHeights"); + when(plugin.getLogger()).thenReturn(Logger.getLogger("NativeBrewingTest")); + PluginDescriptionFile description = new PluginDescriptionFile("SpigotHeights", "test", "unused.Main"); + when(plugin.getDescription()).thenReturn(description); + when(plugin.getPluginMeta()).thenReturn(description); + } + + @BeforeEach + void registerActualRecipesAndListener() { + HandlerList.unregisterAll(); + engine = PotionBrewing.bootstrap(FeatureFlags.VANILLA_SET); + 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]); + default -> throw new UnsupportedOperationException(method.getName()); + } + return null; + }); + recipes.registerBrewing(brewer); + events.registerEvents(new StatureBrewing(recipes), plugin); + } + + @ParameterizedTest + @EnumSource(StaturePotion.class) + void gunpowderActuallyConvertsEveryAuthenticatedDrinkable(StaturePotion kind) { + NativeStand stand = new NativeStand(); + stand.inventory.setItem(0, recipes.create(kind)); + stand.inventory.setIngredient(new ItemStack(Material.GUNPOWDER)); + stand.completeCycle(); + ItemStack result = stand.inventory.getItem(0); + assertEquals(Material.SPLASH_POTION, result.getType(), "recognized recipe must actually convert"); + assertEquals(kind, recipes.identify(result)); + assertTrue(stand.getItem(3).isEmpty(), "one completed brew consumes one ingredient"); + assertSplashApplies(result, kind); + } + + @ParameterizedTest + @EnumSource(StaturePotion.class) + void dragonBreathConvertsExistingSplashIntoFunctionalLingering(StaturePotion kind) { + NativeStand stand = new NativeStand(); + stand.inventory.setItem(0, recipes.create(kind, Material.SPLASH_POTION)); + stand.inventory.setIngredient(new ItemStack(Material.DRAGON_BREATH)); + stand.completeCycle(); + ItemStack result = stand.inventory.getItem(0); + assertEquals(Material.LINGERING_POTION, result.getType()); + assertEquals(kind, recipes.identify(result)); + assertTrue(stand.getItem(3).isEmpty(), "one successful conversion spends one dragon's breath"); + assertLingeringApplies(result, kind); + } + + @ParameterizedTest + @EnumSource(StaturePotion.class) + void newlyBrewedSplashCanImmediatelyBeBrewedAgain(StaturePotion kind) { + NativeStand stand = new NativeStand(); + stand.inventory.setItem(0, recipes.create(kind)); + stand.inventory.setIngredient(new ItemStack(Material.GUNPOWDER)); + stand.completeCycle(); + assertEquals(Material.SPLASH_POTION, stand.inventory.getItem(0).getType()); + stand.inventory.setIngredient(new ItemStack(Material.DRAGON_BREATH)); + stand.completeCycle(); + assertEquals(Material.LINGERING_POTION, stand.inventory.getItem(0).getType()); + assertEquals(kind, recipes.identify(stand.inventory.getItem(0))); + assertTrue(stand.getItem(3).isEmpty()); + assertEquals(18, stand.fuel); + } + + @Test + void conflictingOutputCannotConsumeAnIngredientOrOverrideAnotherPlugin() { + events.registerEvent(BrewEvent.class, new Listener() {}, EventPriority.NORMAL, + (listener, event) -> ((BrewEvent) event).getResults().set(0, new ItemStack(Material.STONE)), plugin); + NativeStand stand = new NativeStand(); + ItemStack original = recipes.create(StaturePotion.GROWTH); + stand.inventory.setItem(0, original); + stand.inventory.setIngredient(new ItemStack(Material.GUNPOWDER, 2)); + stand.completeCycle(); + assertEquals(2, stand.inventory.getIngredient().getAmount(), "failed conversion must not spend gunpowder"); + assertEquals(original, stand.inventory.getItem(0), "conflict cancels the native transaction"); + } + + @ParameterizedTest + @EnumSource(value = EventPriority.class, names = {"LOW", "MONITOR"}) + void cancellationBeforeOrAfterHeightsPreservesInventory(EventPriority priority) { + events.registerEvent(BrewEvent.class, new Listener() {}, priority, + (listener, event) -> ((BrewEvent) event).setCancelled(true), plugin); + NativeStand stand = new NativeStand(); + ItemStack original = recipes.create(StaturePotion.SHIFTING); + stand.inventory.setItem(0, original); + stand.inventory.setIngredient(new ItemStack(Material.GUNPOWDER, 2)); + stand.completeCycle(); + assertEquals(original, stand.inventory.getItem(0)); + assertEquals(2, stand.inventory.getIngredient().getAmount()); + } + + @Test + void missingOutputCancelsTheWholeMixedBatchWithoutLosingItems() { + events.registerEvent(BrewEvent.class, new Listener() {}, EventPriority.NORMAL, + (listener, event) -> ((BrewEvent) event).getResults().remove(2), plugin); + NativeStand stand = new NativeStand(); + ItemStack first = recipes.create(StaturePotion.GROWTH); + ItemStack last = recipes.create(StaturePotion.DIMINUTION); + stand.inventory.setItem(0, first); + stand.inventory.setItem(2, last); + stand.inventory.setIngredient(new ItemStack(Material.GUNPOWDER)); + stand.completeCycle(); + assertEquals(first, stand.inventory.getItem(0)); + assertEquals(last, stand.inventory.getItem(2)); + assertEquals(1, stand.inventory.getIngredient().getAmount()); + } + + @Test + void oneIngredientConvertsMixedSlotsWithoutTouchingIneligiblePotions() { + NativeStand stand = new NativeStand(); + stand.inventory.setItem(0, recipes.create(StaturePotion.GROWTH)); + stand.inventory.setItem(1, recipes.create(StaturePotion.DIMINUTION)); + ItemStack alreadySplash = recipes.create(StaturePotion.RESTORATION, Material.SPLASH_POTION); + stand.inventory.setItem(2, alreadySplash); + stand.inventory.setIngredient(new ItemStack(Material.GUNPOWDER, 2)); + stand.completeCycle(); + assertEquals(Material.SPLASH_POTION, stand.inventory.getItem(0).getType()); + assertEquals(StaturePotion.GROWTH, recipes.identify(stand.inventory.getItem(0))); + assertEquals(Material.SPLASH_POTION, stand.inventory.getItem(1).getType()); + assertEquals(StaturePotion.DIMINUTION, recipes.identify(stand.inventory.getItem(1))); + assertEquals(alreadySplash, stand.inventory.getItem(2)); + assertEquals(1, stand.inventory.getIngredient().getAmount()); + assertEquals(19, stand.fuel, "fuel accounting remains native"); + } + + @Test + void ordinaryAndNameOnlyPotionsRetainNativeBrewingAndNeverGainStatureIdentity() { + NativeStand stand = new NativeStand(); + ItemStack ordinary = new ItemStack(Material.POTION); + PotionMeta meta = (PotionMeta) ordinary.getItemMeta(); + meta.setBasePotionType(PotionType.HEALING); + meta.displayName(net.kyori.adventure.text.Component.text(StaturePotion.GROWTH.displayName())); + ordinary.setItemMeta(meta); + stand.inventory.setItem(0, ordinary); + stand.inventory.setItem(2, recipes.create(StaturePotion.SHIFTING)); + stand.inventory.setIngredient(new ItemStack(Material.GUNPOWDER)); + stand.completeCycle(); + ItemStack result = stand.inventory.getItem(0); + assertEquals(Material.SPLASH_POTION, result.getType()); + assertEquals(PotionType.HEALING, ((PotionMeta) result.getItemMeta()).getBasePotionType()); + assertNull(recipes.identify(result)); + assertEquals(StaturePotion.SHIFTING, recipes.identify(stand.inventory.getItem(2))); + } + + @Test + void interruptedBrewDoesNotConvertOrSpendReplacementIngredient() { + NativeStand stand = new NativeStand(); + ItemStack original = recipes.create(StaturePotion.RESTORATION); + stand.inventory.setItem(0, original); + stand.inventory.setIngredient(new ItemStack(Material.GUNPOWDER)); + stand.tick(); + assertEquals(400, stand.brewTime); + stand.inventory.setIngredient(new ItemStack(Material.SUGAR, 2)); + stand.tick(); + assertEquals(0, stand.brewTime); + assertEquals(original, stand.inventory.getItem(0)); + assertEquals(new ItemStack(Material.SUGAR, 2), stand.inventory.getIngredient()); + } + + @Test + void validNativeOutputFromBaseTypedPotionIsNotRewritten() { + NativeStand stand = new NativeStand(); + ItemStack input = recipes.create(StaturePotion.GROWTH); + PotionMeta meta = (PotionMeta) input.getItemMeta(); + meta.setBasePotionType(PotionType.THICK); + input.setItemMeta(meta); + var name = net.kyori.adventure.text.Component.text("Other plugin's valid result"); + events.registerEvent(BrewEvent.class, new Listener() {}, EventPriority.NORMAL, (listener, event) -> { + ItemStack result = ((BrewEvent) event).getResults().get(0); + assertEquals(Material.SPLASH_POTION, result.getType(), "base-typed items use the native custom mix"); + result.editMeta(resultMeta -> resultMeta.displayName(name)); + }, plugin); + stand.inventory.setItem(0, input); + stand.inventory.setIngredient(new ItemStack(Material.GUNPOWDER)); + stand.completeCycle(); + assertEquals(name, stand.inventory.getItem(0).getItemMeta().displayName()); + assertEquals(StaturePotion.GROWTH, recipes.identify(stand.inventory.getItem(0))); + assertTrue(stand.getItem(3).isEmpty()); + } + + static java.util.stream.Stream conversionEdits() { + return java.util.stream.Stream.of(Material.GUNPOWDER, Material.DRAGON_BREATH) + .flatMap(ingredient -> java.util.stream.Stream.of(false, true) + .map(removeIdentity -> Arguments.of(ingredient, removeIdentity))); + } + + @ParameterizedTest + @MethodSource("conversionEdits") + void inPlaceResultEditsCannotMutateInputOrBypassIngredientSafety(Material ingredient, boolean removeIdentity) { + events.registerEvent(BrewEvent.class, new Listener() {}, EventPriority.NORMAL, (listener, event) -> { + ItemStack result = ((BrewEvent) event).getResults().get(0); + result.editMeta(meta -> { + if (removeIdentity) { + meta.getPersistentDataContainer().remove(new NamespacedKey("spigotheights", "stature_potion")); + } else { + meta.displayName(net.kyori.adventure.text.Component.text("Other plugin's conflicting edit")); + } + }); + }, plugin); + NativeStand stand = new NativeStand(); + ItemStack original = recipes.create(StaturePotion.GROWTH, + ingredient == Material.GUNPOWDER ? Material.POTION : Material.SPLASH_POTION); + stand.inventory.setItem(0, original); + stand.inventory.setIngredient(new ItemStack(ingredient)); + stand.completeCycle(); + 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"); + } + + @Test + void resultConstructionFailureDoesNotSpendTheIngredient() { + HandlerList.unregisterAll(); + PotionRecipes brokenFactory = new PotionRecipes(null, new NamespacedKey("spigotheights", "stature_potion"), + material -> { throw new IllegalStateException("simulated item factory failure"); }); + events.registerEvents(new StatureBrewing(brokenFactory), plugin); + NativeStand stand = new NativeStand(); + ItemStack original = recipes.create(StaturePotion.GROWTH); + stand.inventory.setItem(0, original); + stand.inventory.setIngredient(new ItemStack(Material.GUNPOWDER)); + stand.completeCycle(); + assertEquals(original, stand.inventory.getItem(0)); + assertEquals(new ItemStack(Material.GUNPOWDER), stand.inventory.getIngredient()); + } + + private void assertSplashApplies(ItemStack result, StaturePotion expected) { + List applied = new ArrayList<>(); + ThrowableStatureListener effects = new ThrowableStatureListener(recipes, + new NamespacedKey("spigotheights", "cloud_kind"), (source, player, kind, done) -> { + applied.add(kind); + done.accept(true); + }); + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + when(player.isOnline()).thenReturn(true); + ThrownPotion thrown = mock(ThrownPotion.class); + when(thrown.getUniqueId()).thenReturn(UUID.randomUUID()); + when(thrown.getItem()).thenReturn(result); + when(thrown.getPersistentDataContainer()).thenReturn(new ItemStack(Material.STONE).getItemMeta().getPersistentDataContainer()); + var event = new PotionSplashEvent(thrown, null, null, null, Map.of(player, 1.0)); + effects.onSplash(event); + effects.onSplash(event); + assertEquals(List.of(expected), applied, "brewed item reaches the stature adapter exactly once"); + } + + private void assertLingeringApplies(ItemStack result, StaturePotion expected) { + List applied = new ArrayList<>(); + ThrowableStatureListener effects = new ThrowableStatureListener(recipes, + new NamespacedKey("spigotheights", "cloud_kind"), (source, player, kind, done) -> { + applied.add(kind); + done.accept(true); + }); + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + when(player.isOnline()).thenReturn(true); + ThrownPotion thrown = mock(ThrownPotion.class); + when(thrown.getItem()).thenReturn(result); + AreaEffectCloud cloud = mock(AreaEffectCloud.class); + when(cloud.getUniqueId()).thenReturn(UUID.randomUUID()); + when(cloud.getPersistentDataContainer()).thenReturn(new ItemStack(Material.STONE).getItemMeta().getPersistentDataContainer()); + var creation = new LingeringPotionSplashEvent(thrown, null, null, null, cloud); + effects.onLingering(creation); + assertTrue(creation.allowsEmptyCreation()); + var application = new AreaEffectCloudApplyEvent(cloud, new ArrayList<>(List.of(player))); + effects.onCloud(application); + effects.onCloud(application); + assertEquals(List.of(expected), applied, "brewed cloud reaches the stature adapter exactly once"); + } + + private final class NativeStand extends BrewingStandBlockEntity { + private final CraftInventoryBrewer inventory = new CraftInventoryBrewer(this); + private final Level level = mock(Level.class); + + NativeStand() { + super(BlockPos.ZERO, Blocks.BREWING_STAND.defaultBlockState()); + when(level.potionBrewing()).thenReturn(engine); + fuel = 20; + } + + @Override public InventoryHolder getOwner() { return () -> inventory; } + + void completeCycle() { + tick(); + assertEquals(400, brewTime, "native stand must start brewing"); + for (int i = 0; i < 400; i++) { + tick(); + } + assertEquals(0, brewTime); + } + + void tick() { + BrewingStandBlockEntity.serverTick(level, BlockPos.ZERO, getBlockState(), this); + } + } +} diff --git a/src/nativeTest/java/games/dmg/spigotheights/NativeRuntime.java b/src/nativeTest/java/games/dmg/spigotheights/NativeRuntime.java new file mode 100644 index 0000000..79a0d40 --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotheights/NativeRuntime.java @@ -0,0 +1,58 @@ +package games.dmg.spigotheights; + +import java.util.List; +import java.util.stream.Stream; +import net.minecraft.SharedConstants; +import net.minecraft.commands.Commands; +import net.minecraft.core.HolderLookup; +import net.minecraft.core.LayeredRegistryAccess; +import net.minecraft.core.Registry; +import net.minecraft.core.RegistryAccess; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.RegistryDataLoader; +import net.minecraft.server.Bootstrap; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.RegistryLayer; +import net.minecraft.server.ReloadableServerResources; +import net.minecraft.server.packs.PackType; +import net.minecraft.server.packs.repository.ServerPacksSource; +import net.minecraft.server.packs.resources.MultiPackResourceManager; +import net.minecraft.server.permissions.LevelBasedPermissionSet; +import net.minecraft.tags.TagLoader; +import net.minecraft.util.Util; +import net.minecraft.world.flag.FeatureFlags; +import net.minecraft.world.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 NativeRuntime() {} + + static void bootstrap() throws Exception { + 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 layers = RegistryLayer.createRegistryAccess(); + List> tags = TagLoader.loadTagsForExistingRegistries(resources, layers.getLayer(RegistryLayer.STATIC)); + List> 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()); + } + } +} diff --git a/src/nativeTest/resources/mockito-extensions/org.mockito.plugins.MockMaker b/src/nativeTest/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000..fdbd0b1 --- /dev/null +++ b/src/nativeTest/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-subclass