Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df44bba9c6 |
@@ -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.
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user