1 Commits
Author SHA1 Message Date
dmg df44bba9c6 fix(heights): repair native stature potion brewing
Release / release (push) Successful in 6m27s
CI / build (push) Successful in 2m8s
2026-09-11 22:16:48 -04:00
7 changed files with 593 additions and 1 deletions
+5 -1
View File
@@ -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.
+59
View File
@@ -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<JavaExec>("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<Test>("nativeBrewingTest") {
description = "Runs native Purpur brewing regressions without starting a server"
testClassesDirs = nativeTest.output.classesDirs
classpath = nativeTest.runtimeClasspath
useJUnitPlatform()
maxHeapSize = "1G"
workingDir(brewingRuntime)
}
tasks.check { dependsOn(nativeBrewingTest) }
val pluginVersion = version
tasks.processResources {
filesMatching("plugin.yml") {
@@ -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);
@@ -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));
}
}
}
}
@@ -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<Arguments> conversionEdits() {
return java.util.stream.Stream.of(Material.GUNPOWDER, Material.DRAGON_BREATH)
.flatMap(ingredient -> java.util.stream.Stream.of(false, true)
.map(removeIdentity -> Arguments.of(ingredient, removeIdentity)));
}
@ParameterizedTest
@MethodSource("conversionEdits")
void 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<StaturePotion> applied = new ArrayList<>();
ThrowableStatureListener effects = new ThrowableStatureListener(recipes,
new NamespacedKey("spigotheights", "cloud_kind"), (source, player, kind, done) -> {
applied.add(kind);
done.accept(true);
});
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
when(player.isOnline()).thenReturn(true);
ThrownPotion thrown = mock(ThrownPotion.class);
when(thrown.getUniqueId()).thenReturn(UUID.randomUUID());
when(thrown.getItem()).thenReturn(result);
when(thrown.getPersistentDataContainer()).thenReturn(new ItemStack(Material.STONE).getItemMeta().getPersistentDataContainer());
var event = new PotionSplashEvent(thrown, null, null, null, Map.of(player, 1.0));
effects.onSplash(event);
effects.onSplash(event);
assertEquals(List.of(expected), applied, "brewed item reaches the stature adapter exactly once");
}
private void assertLingeringApplies(ItemStack result, StaturePotion expected) {
List<StaturePotion> applied = new ArrayList<>();
ThrowableStatureListener effects = new ThrowableStatureListener(recipes,
new NamespacedKey("spigotheights", "cloud_kind"), (source, player, kind, done) -> {
applied.add(kind);
done.accept(true);
});
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
when(player.isOnline()).thenReturn(true);
ThrownPotion thrown = mock(ThrownPotion.class);
when(thrown.getItem()).thenReturn(result);
AreaEffectCloud cloud = mock(AreaEffectCloud.class);
when(cloud.getUniqueId()).thenReturn(UUID.randomUUID());
when(cloud.getPersistentDataContainer()).thenReturn(new ItemStack(Material.STONE).getItemMeta().getPersistentDataContainer());
var creation = new LingeringPotionSplashEvent(thrown, null, null, null, cloud);
effects.onLingering(creation);
assertTrue(creation.allowsEmptyCreation());
var application = new AreaEffectCloudApplyEvent(cloud, new ArrayList<>(List.of(player)));
effects.onCloud(application);
effects.onCloud(application);
assertEquals(List.of(expected), applied, "brewed cloud reaches the stature adapter exactly once");
}
private final class NativeStand extends BrewingStandBlockEntity {
private final CraftInventoryBrewer inventory = new CraftInventoryBrewer(this);
private final 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);
}
}
}
@@ -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<RegistryLayer> layers = RegistryLayer.createRegistryAccess();
List<Registry.PendingTags<?>> tags = TagLoader.loadTagsForExistingRegistries(resources, layers.getLayer(RegistryLayer.STATIC));
List<HolderLookup.RegistryLookup<?>> lookups = TagLoader.buildUpdatedLookups(layers.getAccessForLoading(RegistryLayer.WORLDGEN), tags);
RegistryAccess.Frozen worldgen = RegistryDataLoader.load(resources, lookups,
RegistryDataLoader.WORLDGEN_REGISTRIES, Util.backgroundExecutor()).join();
layers = layers.replaceFrom(RegistryLayer.WORLDGEN, worldgen);
RegistryAccess.Frozen dimensions = RegistryDataLoader.load(resources,
Stream.concat(lookups.stream(), worldgen.listRegistries()).toList(),
RegistryDataLoader.DIMENSION_REGISTRIES, Util.backgroundExecutor()).join();
layers = layers.replaceFrom(RegistryLayer.DIMENSIONS, dimensions);
Class.forName(org.bukkit.Registry.class.getName());
var datapack = ReloadableServerResources.loadResources(resources, layers, tags, flags,
Commands.CommandSelection.DEDICATED, LevelBasedPermissionSet.ALL_PERMISSIONS,
Util.backgroundExecutor(), Runnable::run).join();
datapack.updateComponentsAndStaticRegistryTags();
CraftRegistry.setMinecraftRegistry(layers.compositeAccess().freeze());
}
}
}
@@ -0,0 +1 @@
mock-maker-subclass