feat(platform): support Purpur 26.3
CI / build (push) Successful in 54s
CI / build (pull_request) Successful in 1m43s

This commit is contained in:
dmg committed 2026-10-16 23:46:50 -04:00
1 parent 8d21617553
commit 95a7ac41e1
15 files changed
+80 -55

No files matched your search

+5 -5
View File
@@ -1,6 +1,6 @@
# Purpur Inventory Helper # Purpur Inventory Helper
A Purpur 26.2 plugin with Sorting Sticks, automatic broken-tool replacement, alternative Dispenser/Cobweb recipes, Powder Snow crafting, and risky over-level enchantment combining in ordinary anvils. A Purpur 26.3 plugin with Sorting Sticks, automatic broken-tool replacement, alternative Dispenser/Cobweb recipes, Powder Snow crafting, and risky over-level enchantment combining in ordinary anvils.
The approved behavior is specified in the [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-inventory-helper/index.md). The approved behavior is specified in the [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-inventory-helper/index.md).
@@ -8,7 +8,7 @@ The repository, checkout and new JARs use `purpur-inventory-helper`. Runtime `Sp
## Requirements ## Requirements
- Purpur 26.2 build 2618 - Purpur 26.3 build 2651
- Java 25 or newer - Java 25 or newer
- ProtocolLib 5.4.0 (required runtime library; missing ProtocolLib prevents plugin loading) - ProtocolLib 5.4.0 (required runtime library; missing ProtocolLib prevents plugin loading)
@@ -20,9 +20,9 @@ The repository, checkout and new JARs use `purpur-inventory-helper`. Runtime `Sp
The plugin JAR is written to `build/libs/`. The plugin JAR is written to `build/libs/`.
`check` includes `nativeInventoryTest`. It downloads the exact Purpur 26.2 build 2618 launcher, `check` includes `nativeInventoryTest`. It downloads the exact Purpur 26.3 build 2651 launcher,
verifies SHA-256 verifies SHA-256
`4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31`, and runs Paperclip in `0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598`, and runs Paperclip in
patch-only mode under `build/inventory-runtime/`; it does not start a server, open a port, create a patch-only mode under `build/inventory-runtime/`; it does not start a server, open a port, create a
world, or accept the EULA. The native suite executes real `ServerPlaceRecipe`, `ServerRecipeBook` world, or accept the EULA. The native suite executes real `ServerPlaceRecipe`, `ServerRecipeBook`
known/highlight codec persistence, and `ResultSlot` ingredient consumption for both Sorting Stick variants and the alternative Dispenser recipe, including repeated batch completion. known/highlight codec persistence, and `ResultSlot` ingredient consumption for both Sorting Stick variants and the alternative Dispenser recipe, including repeated batch completion.
@@ -122,7 +122,7 @@ Default risk uses the **largest resulting enchantment excess above its normal ma
| 6 | 95% | | 6 | 95% |
| 7 or more | 99% | | 7 or more | 99% |
ProtocolLib binds taking to the displayed menu generation before payment. A bounded 40-server-tick admission accommodates the main → Netty → native-main handoff; an owned native dispatch shim carries exact packet identity through that queue, so one click cannot borrow another click's cancellation or payment ticket. Native aggregate and packet-specific limits remain authoritative, using the actual connection's counters and violation actions; expired/refused/cancelled dispatches never pay. Transport ownership is bounded to 32 records per connection. A queued ProtocolLib-owned main callback followed by a barrier on the same native channel event loop proves whether each exact raw was forwarded; mutable packet/post-event snapshots do not. This library-owned cleanup survives this plugin's task cancellation. Revoked native wrappers retain their denial even when native admission or queue processing skips their handler, while genuinely unresolved late raws keep protective ownership until arrival, the ordered fence or disconnect. Missing/rejected library scheduling or event-loop execution fails closed; it does not establish successful cleanup. Input or acknowledged risk changes invalidate old clicks even when the result item is identical; the ordinary anvil stays open without another confirmation. The adapter is pinned to Purpur 26.2 build 2618 and fails closed when enhanced generation admission is unavailable. This is a library dependency, not a Fantasy Stick/gameplay-plugin bridge. ProtocolLib binds taking to the displayed menu generation before payment. A bounded 40-server-tick admission accommodates the main → Netty → native-main handoff; an owned native dispatch shim carries exact packet identity through that queue, so one click cannot borrow another click's cancellation or payment ticket. Native aggregate and packet-specific limits remain authoritative, using the actual connection's counters and violation actions; expired/refused/cancelled dispatches never pay. Transport ownership is bounded to 32 records per connection. A queued ProtocolLib-owned main callback followed by a barrier on the same native channel event loop proves whether each exact raw was forwarded; mutable packet/post-event snapshots do not. This library-owned cleanup survives this plugin's task cancellation. Revoked native wrappers retain their denial even when native admission or queue processing skips their handler, while genuinely unresolved late raws keep protective ownership until arrival, the ordered fence or disconnect. Missing/rejected library scheduling or event-loop execution fails closed; it does not establish successful cleanup. Input or acknowledged risk changes invalidate old clicks even when the result item is identical; the ordinary anvil stays open without another confirmation. The adapter is pinned to Purpur 26.3 build 2651 and fails closed when enhanced generation admission is unavailable. This is a library dependency, not a Fantasy Stick/gameplay-plugin bridge.
Use left/right pickup, shift-click or a native-eligible empty hotbar/offhand destination. Full destinations, cancelled/stale takes and unsupported cloning/drop/collection actions do not roll or spend items. Ordinary anvil wear remains in effect. Dedicated gameplay-plugin compatibility is unsupported; declared native cancellations and unrelated inventory state are preserved. Use left/right pickup, shift-click or a native-eligible empty hotbar/offhand destination. Full destinations, cancelled/stale takes and unsupported cloning/drop/collection actions do not roll or spend items. Ordinary anvil wear remains in effect. Dedicated gameplay-plugin compatibility is unsupported; declared native cancellations and unrelated inventory state are preserved.
+7 -7
View File
@@ -26,11 +26,11 @@ tasks.withType<JavaCompile>().configureEach {
} }
dependencies { dependencies {
compileOnly("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable") compileOnly("org.purpurmc.purpur:purpur-api:26.3.build.2651-stable")
compileOnly("net.dmulloy2:ProtocolLib:5.4.0") compileOnly("net.dmulloy2:ProtocolLib:5.4.0")
testImplementation("net.dmulloy2:ProtocolLib:5.4.0") testImplementation("net.dmulloy2:ProtocolLib:5.4.0")
testImplementation("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable") testImplementation("org.purpurmc.purpur:purpur-api:26.3.build.2651-stable")
testImplementation(platform("org.junit:junit-bom:5.13.4")) testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter") testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("org.mockito:mockito-core:5.18.0") testImplementation("org.mockito:mockito-core:5.18.0")
@@ -49,20 +49,20 @@ tasks.test {
// Paperclip only extracts/patches dependencies: no world, port, or EULA acceptance is involved. // Paperclip only extracts/patches dependencies: no world, port, or EULA acceptance is involved.
val inventoryRuntime = layout.buildDirectory.dir("inventory-runtime") val inventoryRuntime = layout.buildDirectory.dir("inventory-runtime")
val downloadInventoryRuntime = tasks.register("downloadInventoryRuntime") { val downloadInventoryRuntime = tasks.register("downloadInventoryRuntime") {
val launcher = inventoryRuntime.map { it.file("purpur-26.2-2618.jar") } val launcher = inventoryRuntime.map { it.file("purpur-26.3-2651.jar") }
outputs.file(launcher) outputs.file(launcher)
doLast { doLast {
val file = launcher.get().asFile val file = launcher.get().asFile
file.parentFile.mkdirs() file.parentFile.mkdirs()
val connection = URI("https://api.purpurmc.org/v2/purpur/26.2/2618/download") val connection = URI("https://api.purpurmc.org/v2/purpur/26.3/2651/download")
.toURL().openConnection() .toURL().openConnection()
connection.connectTimeout = 30_000 connection.connectTimeout = 30_000
connection.readTimeout = 120_000 connection.readTimeout = 120_000
val bytes = connection.getInputStream().use { it.readBytes() } val bytes = connection.getInputStream().use { it.readBytes() }
val digest = MessageDigest.getInstance("SHA-256").digest(bytes) val digest = MessageDigest.getInstance("SHA-256").digest(bytes)
.joinToString("") { "%02x".format(it) } .joinToString("") { "%02x".format(it) }
check(digest == "4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31") { check(digest == "0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598") {
"Purpur 2618 checksum mismatch" "Purpur 2651 checksum mismatch"
} }
file.writeBytes(bytes) file.writeBytes(bytes)
} }
@@ -70,7 +70,7 @@ val downloadInventoryRuntime = tasks.register("downloadInventoryRuntime") {
val prepareInventoryRuntime = tasks.register<JavaExec>("prepareInventoryRuntime") { val prepareInventoryRuntime = tasks.register<JavaExec>("prepareInventoryRuntime") {
dependsOn(downloadInventoryRuntime) dependsOn(downloadInventoryRuntime)
javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(25) } javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(25) }
classpath = files(inventoryRuntime.map { it.file("purpur-26.2-2618.jar") }) classpath = files(inventoryRuntime.map { it.file("purpur-26.3-2651.jar") })
mainClass = "io.papermc.paperclip.Main" mainClass = "io.papermc.paperclip.Main"
jvmArgs("-Dpaperclip.patchonly=true") jvmArgs("-Dpaperclip.patchonly=true")
workingDir(inventoryRuntime) workingDir(inventoryRuntime)
@@ -79,7 +79,7 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable {
this.plugin = Objects.requireNonNull(plugin); this.plugin = Objects.requireNonNull(plugin);
this.manager = Objects.requireNonNull(manager); this.manager = Objects.requireNonNull(manager);
this.owner = Objects.requireNonNull(owner); this.owner = Objects.requireNonNull(owner);
if (version == null || !version.contains("Purpur") || !version.contains("26.2.build.2618-stable") if (version == null || !version.contains("Purpur") || !version.contains("26.3.build.2651-stable")
|| manager.isClosed()) throw new IllegalStateException("Unsupported Advanced Anvil generation bridge"); || manager.isClosed()) throw new IllegalStateException("Unsupported Advanced Anvil generation bridge");
gate = new PacketAdapter(PacketAdapter.params(plugin, PacketType.Play.Client.WINDOW_CLICK) gate = new PacketAdapter(PacketAdapter.params(plugin, PacketType.Play.Client.WINDOW_CLICK)
.listenerPriority(ListenerPriority.LOWEST).optionSync()) { .listenerPriority(ListenerPriority.LOWEST).optionSync()) {
@@ -8,7 +8,7 @@ import org.bukkit.event.inventory.ClickType;
import org.bukkit.event.inventory.InventoryClickEvent; import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.inventory.view.AnvilView; import org.bukkit.inventory.view.AnvilView;
/** Compile-only linkage to the checksum-pinned Purpur 26.2 build 2618 menu/packet API. */ /** Compile-only linkage to the checksum-pinned Purpur 26.3 build 2651 menu/packet API. */
final class NativeAnvilGeneration { final class NativeAnvilGeneration {
private NativeAnvilGeneration() {} private NativeAnvilGeneration() {}
@@ -193,7 +193,10 @@ class NativeAdvancedAnvilPacketTest {
assertEquals(0, countOutput(f, expected)); assertEquals(0, countOutput(f, expected));
org.mockito.Mockito.verify(f.nativePlayer, org.mockito.Mockito.never()) org.mockito.Mockito.verify(f.nativePlayer, org.mockito.Mockito.never())
.drop(org.mockito.ArgumentMatchers.any(net.minecraft.world.item.ItemStack.class), .drop(org.mockito.ArgumentMatchers.any(net.minecraft.world.item.ItemStack.class),
org.mockito.ArgumentMatchers.anyBoolean()); org.mockito.ArgumentMatchers.anyBoolean(),
org.mockito.ArgumentMatchers.any(net.minecraft.util.Prediction.class),
org.mockito.ArgumentMatchers.anyBoolean(),
org.mockito.ArgumentMatchers.<java.util.function.Consumer<org.bukkit.entity.Item>>isNull());
} }
} }
@@ -249,7 +249,7 @@ class NativeAdvancedAnvilProtocolTest {
@Test void unsupportedRuntimeFailsEnableBeforeAnyEnhancedOutputBypassOrProtocolRegistration() throws Exception { @Test void unsupportedRuntimeFailsEnableBeforeAnyEnhancedOutputBypassOrProtocolRegistration() throws Exception {
AtomicInteger rolls = new AtomicInteger(); AtomicInteger rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1, try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1,
"Purpur 26.2.build.2619-stable")) { "Purpur 26.3.build.2652-stable")) {
assertEquals(1, f.eventErrors.size(), "Only the explicitly unsupported bridge should report failure"); assertEquals(1, f.eventErrors.size(), "Only the explicitly unsupported bridge should report failure");
assertInstanceOf(IllegalStateException.class, f.eventErrors.getFirst()); assertInstanceOf(IllegalStateException.class, f.eventErrors.getFirst());
assertEquals("Unsupported Advanced Anvil generation bridge", f.eventErrors.getFirst().getMessage()); assertEquals("Unsupported Advanced Anvil generation bridge", f.eventErrors.getFirst().getMessage());
@@ -104,16 +104,16 @@ final class NativeAnvilFixture implements AutoCloseable {
NativeAnvilFixture(Path directory, java.util.function.IntSupplier risk, NativeAnvilFixture(Path directory, java.util.function.IntSupplier risk,
java.util.function.DoubleSupplier wear) throws Exception { java.util.function.DoubleSupplier wear) throws Exception {
this(directory, risk, wear, "Purpur 26.2.build.2618-stable"); this(directory, risk, wear, "Purpur 26.3.build.2651-stable");
} }
NativeAnvilFixture(Path directory, boolean queuedTransport) throws Exception { NativeAnvilFixture(Path directory, boolean queuedTransport) throws Exception {
this(directory, () -> 99, () -> 1.0, "Purpur 26.2.build.2618-stable", queuedTransport); this(directory, () -> 99, () -> 1.0, "Purpur 26.3.build.2651-stable", queuedTransport);
} }
NativeAnvilFixture(Path directory, java.util.function.IntSupplier risk, NativeAnvilFixture(Path directory, java.util.function.IntSupplier risk,
java.util.function.DoubleSupplier wear, boolean queuedTransport) throws Exception { java.util.function.DoubleSupplier wear, boolean queuedTransport) throws Exception {
this(directory, risk, wear, "Purpur 26.2.build.2618-stable", queuedTransport); this(directory, risk, wear, "Purpur 26.3.build.2651-stable", queuedTransport);
} }
NativeAnvilFixture(Path directory, java.util.function.IntSupplier risk, NativeAnvilFixture(Path directory, java.util.function.IntSupplier risk,
@@ -169,9 +169,9 @@ final class NativeAnvilFixture implements AutoCloseable {
when(server.getUnsafe()).thenReturn(CraftMagicNumbers.INSTANCE); when(server.getUnsafe()).thenReturn(CraftMagicNumbers.INSTANCE);
var mainThread = Thread.currentThread(); var mainThread = Thread.currentThread();
when(server.isPrimaryThread()).thenAnswer(ignored -> Thread.currentThread() == mainThread); when(server.isPrimaryThread()).thenAnswer(ignored -> Thread.currentThread() == mainThread);
when(server.getVersion()).thenReturn("Purpur 2618 (MC: 26.2)"); when(server.getVersion()).thenReturn("Purpur 2651 (MC: 26.3)");
when(server.getMinecraftVersion()).thenReturn("26.2"); when(server.getMinecraftVersion()).thenReturn("26.3");
when(server.getBukkitVersion()).thenReturn("26.2.build.2618-stable"); when(server.getBukkitVersion()).thenReturn("26.3.build.2651-stable");
when(server.getWarningState()).thenReturn(org.bukkit.Warning.WarningState.DEFAULT); when(server.getWarningState()).thenReturn(org.bukkit.Warning.WarningState.DEFAULT);
when(server.getLogger()).thenReturn(Logger.getAnonymousLogger()); when(server.getLogger()).thenReturn(Logger.getAnonymousLogger());
when(server.getPluginManager()).thenReturn(events); when(server.getPluginManager()).thenReturn(events);
@@ -366,7 +366,9 @@ final class NativeAnvilFixture implements AutoCloseable {
doCallRealMethod().when(nativePlayer).doCloseContainer(); doCallRealMethod().when(nativePlayer).doCloseContainer();
doCallRealMethod().when(nativePlayer).closeContainer(any(org.bukkit.event.inventory.InventoryCloseEvent.Reason.class)); doCallRealMethod().when(nativePlayer).closeContainer(any(org.bukkit.event.inventory.InventoryCloseEvent.Reason.class));
doAnswer(inv -> { throw new AssertionError("Unexpected second-user drop"); }) doAnswer(inv -> { throw new AssertionError("Unexpected second-user drop"); })
.when(nativePlayer).drop(any(net.minecraft.world.item.ItemStack.class), anyBoolean()); .when(nativePlayer).drop(any(net.minecraft.world.item.ItemStack.class), anyBoolean(),
any(net.minecraft.util.Prediction.class), anyBoolean(),
org.mockito.ArgumentMatchers.<java.util.function.Consumer<org.bukkit.entity.Item>>isNull());
field(net.minecraft.server.network.ServerCommonPacketListenerImpl.class, handler, "server", nativeServer); field(net.minecraft.server.network.ServerCommonPacketListenerImpl.class, handler, "server", nativeServer);
field(net.minecraft.server.network.ServerCommonPacketListenerImpl.class, handler, "cserver", primary.craftServer); field(net.minecraft.server.network.ServerCommonPacketListenerImpl.class, handler, "cserver", primary.craftServer);
field(net.minecraft.server.network.ServerCommonPacketListenerImpl.class, handler, "connection", connection); field(net.minecraft.server.network.ServerCommonPacketListenerImpl.class, handler, "connection", connection);
@@ -68,7 +68,9 @@ final class NativeAnvilPacketFixture implements AutoCloseable {
doCallRealMethod().when(anvil.nativePlayer).doCloseContainer(); doCallRealMethod().when(anvil.nativePlayer).doCloseContainer();
doCallRealMethod().when(anvil.nativePlayer).closeContainer(any(org.bukkit.event.inventory.InventoryCloseEvent.Reason.class)); doCallRealMethod().when(anvil.nativePlayer).closeContainer(any(org.bukkit.event.inventory.InventoryCloseEvent.Reason.class));
doAnswer(inv -> { drops.add(inv.<net.minecraft.world.item.ItemStack>getArgument(0).copy()); return null; }) doAnswer(inv -> { drops.add(inv.<net.minecraft.world.item.ItemStack>getArgument(0).copy()); return null; })
.when(anvil.nativePlayer).drop(any(net.minecraft.world.item.ItemStack.class), anyBoolean()); .when(anvil.nativePlayer).drop(any(net.minecraft.world.item.ItemStack.class), anyBoolean(),
any(net.minecraft.util.Prediction.class), anyBoolean(),
org.mockito.ArgumentMatchers.<java.util.function.Consumer<org.bukkit.entity.Item>>isNull());
when(nativeServer.packetProcessor()).thenReturn(processor); when(nativeServer.packetProcessor()).thenReturn(processor);
when(craftServer.getPluginManager()).thenReturn(anvil.events); when(craftServer.getPluginManager()).thenReturn(anvil.events);
when(anvil.nativePlayer.isWithinBlockInteractionRange(anvil.position, 4.0)).thenReturn(true); when(anvil.nativePlayer.isWithinBlockInteractionRange(anvil.position, 4.0)).thenReturn(true);
@@ -88,9 +88,9 @@ class NativeCobwebPluginWiringTest {
when(serverUnderTest.getItemFactory()).thenAnswer(invocation -> CraftItemFactory.instance()); when(serverUnderTest.getItemFactory()).thenAnswer(invocation -> CraftItemFactory.instance());
when(serverUnderTest.getUnsafe()).thenReturn(CraftMagicNumbers.INSTANCE); when(serverUnderTest.getUnsafe()).thenReturn(CraftMagicNumbers.INSTANCE);
when(serverUnderTest.isPrimaryThread()).thenReturn(true); when(serverUnderTest.isPrimaryThread()).thenReturn(true);
when(serverUnderTest.getVersion()).thenReturn("Purpur 2618 (MC: 26.2)"); when(serverUnderTest.getVersion()).thenReturn("Purpur 2651 (MC: 26.3)");
when(serverUnderTest.getMinecraftVersion()).thenReturn("26.2"); when(serverUnderTest.getMinecraftVersion()).thenReturn("26.3");
when(serverUnderTest.getBukkitVersion()).thenReturn("26.2.build.2618-stable"); when(serverUnderTest.getBukkitVersion()).thenReturn("26.3.build.2651-stable");
when(serverUnderTest.getWarningState()).thenReturn(org.bukkit.Warning.WarningState.DEFAULT); when(serverUnderTest.getWarningState()).thenReturn(org.bukkit.Warning.WarningState.DEFAULT);
logger = Logger.getAnonymousLogger(); logger = Logger.getAnonymousLogger();
logs = new ArrayList<>(); logs = new ArrayList<>();
@@ -278,7 +278,7 @@ class NativeCobwebPluginWiringTest {
createdPlugin = new SpigotInventoryHelperPlugin(new JavaPluginLoader(serverUnderTest), createdPlugin = new SpigotInventoryHelperPlugin(new JavaPluginLoader(serverUnderTest),
NativeAnvilFixture.description(), directory.resolve("data").toFile(), NativeAnvilFixture.description(), directory.resolve("data").toFile(),
directory.resolve("plugin.jar").toFile(), () -> 99, () -> 1, protocol, directory.resolve("plugin.jar").toFile(), () -> 99, () -> 1, protocol,
() -> "Purpur 26.2.build.2618-stable"); () -> "Purpur 26.3.build.2651-stable");
commands.registerAll(createdPlugin.getName(), commands.registerAll(createdPlugin.getName(),
org.bukkit.command.PluginCommandYamlParser.parse(createdPlugin)); org.bukkit.command.PluginCommandYamlParser.parse(createdPlugin));
createdPlugin.getLogger().addHandler(logHandler); createdPlugin.getLogger().addHandler(logHandler);
@@ -45,10 +45,10 @@ class NativePowderSnowBookTest {
var holder = f.holders.get(key); var holder = f.holders.get(key);
assertEquals(RecipeBookCategories.CRAFTING_MISC, holder.value().recipeBookCategory()); assertEquals(RecipeBookCategories.CRAFTING_MISC, holder.value().recipeBookCategory());
var display = assertInstanceOf(ShapelessCraftingRecipeDisplay.class, holder.value().display().getFirst()); var display = assertInstanceOf(ShapelessCraftingRecipeDisplay.class, holder.value().display().getFirst());
var context = new net.minecraft.util.context.ContextMap.Builder() var context = net.minecraft.util.context.ContextMap.builder()
.withParameter(net.minecraft.world.item.crafting.display.SlotDisplayContext.REGISTRIES, .set(net.minecraft.world.item.crafting.display.SlotDisplayContext.REGISTRIES,
org.bukkit.craftbukkit.CraftRegistry.getMinecraftRegistry()) org.bukkit.craftbukkit.CraftRegistry.getMinecraftRegistry())
.create(net.minecraft.world.item.crafting.display.SlotDisplayContext.CONTEXT); .buildAndValidate(net.minecraft.world.item.crafting.display.SlotDisplayContext.CONTEXT);
var ingredients = display.ingredients().stream().map(slot -> slot.resolveForStacks(context)).toList(); var ingredients = display.ingredients().stream().map(slot -> slot.resolveForStacks(context)).toList();
assertEquals(List.of(Material.ANVIL, Material.CHIPPED_ANVIL, Material.DAMAGED_ANVIL), ingredients.getFirst() assertEquals(List.of(Material.ANVIL, Material.CHIPPED_ANVIL, Material.DAMAGED_ANVIL), ingredients.getFirst()
.stream().map(stack -> CraftItemStack.asBukkitCopy(stack).getType()).toList()); .stream().map(stack -> CraftItemStack.asBukkitCopy(stack).getType()).toList());
@@ -231,10 +231,10 @@ class NativePowderSnowBookTest {
var recipe = f.powder().value(); var recipe = f.powder().value();
assertEquals(RecipeBookCategories.CRAFTING_MISC, recipe.recipeBookCategory()); assertEquals(RecipeBookCategories.CRAFTING_MISC, recipe.recipeBookCategory());
var display = assertInstanceOf(ShapelessCraftingRecipeDisplay.class, recipe.display().getFirst()); var display = assertInstanceOf(ShapelessCraftingRecipeDisplay.class, recipe.display().getFirst());
var context = new net.minecraft.util.context.ContextMap.Builder() var context = net.minecraft.util.context.ContextMap.builder()
.withParameter(net.minecraft.world.item.crafting.display.SlotDisplayContext.REGISTRIES, .set(net.minecraft.world.item.crafting.display.SlotDisplayContext.REGISTRIES,
org.bukkit.craftbukkit.CraftRegistry.getMinecraftRegistry()) org.bukkit.craftbukkit.CraftRegistry.getMinecraftRegistry())
.create(net.minecraft.world.item.crafting.display.SlotDisplayContext.CONTEXT); .buildAndValidate(net.minecraft.world.item.crafting.display.SlotDisplayContext.CONTEXT);
var ingredients = display.ingredients().stream().map(slot -> slot.resolveForStacks(context)).toList(); var ingredients = display.ingredients().stream().map(slot -> slot.resolveForStacks(context)).toList();
assertEquals(List.of(1, 1), ingredients.stream().map(List::size).toList()); assertEquals(List.of(1, 1), ingredients.stream().map(List::size).toList());
assertEquals(List.of(Material.SNOW_BLOCK, Material.BUCKET), ingredients.stream() assertEquals(List.of(Material.SNOW_BLOCK, Material.BUCKET), ingredients.stream()
@@ -22,7 +22,6 @@ import net.minecraft.world.item.crafting.RecipeManager;
import net.minecraft.world.item.crafting.RecipeMap; import net.minecraft.world.item.crafting.RecipeMap;
import org.bukkit.Keyed; import org.bukkit.Keyed;
import org.bukkit.NamespacedKey; import org.bukkit.NamespacedKey;
import org.bukkit.craftbukkit.CraftRegistry;
import org.bukkit.craftbukkit.inventory.CraftItemStack; import org.bukkit.craftbukkit.inventory.CraftItemStack;
import org.bukkit.craftbukkit.inventory.CraftRecipe; import org.bukkit.craftbukkit.inventory.CraftRecipe;
import org.bukkit.craftbukkit.util.CraftNamespacedKey; import org.bukkit.craftbukkit.util.CraftNamespacedKey;
@@ -65,7 +64,7 @@ final class NativePowderSnowFixture implements AutoCloseable {
actor.events.registerEvent(PlayerRecipeDiscoverEvent.class, new Listener() {}, EventPriority.MONITOR, actor.events.registerEvent(PlayerRecipeDiscoverEvent.class, new Listener() {}, EventPriority.MONITOR,
(ignored, event) -> discoveries.add((PlayerRecipeDiscoverEvent) event), actor.plugin, false); (ignored, event) -> discoveries.add((PlayerRecipeDiscoverEvent) event), actor.plugin, false);
actor.plugin.setEnabled(false); actor.plugin.setEnabled(false);
manager = new RecipeManager(CraftRegistry.getMinecraftRegistry()); manager = NativeRuntime.recipeManager();
when(packets.nativeServer.getRecipeManager()).thenReturn(manager); when(packets.nativeServer.getRecipeManager()).thenReturn(manager);
when(actor.level.recipeAccess()).thenReturn(manager); when(actor.level.recipeAccess()).thenReturn(manager);
when(actor.level.getGameRules()).thenReturn( when(actor.level.getGameRules()).thenReturn(
@@ -170,7 +169,8 @@ final class NativePowderSnowFixture implements AutoCloseable {
} }
private void refreshManager() { private void refreshManager() {
manager.recipes = RecipeMap.create(new ArrayList<RecipeHolder<?>>(holders.values())); manager.recipes = RecipeMap.createEmpty();
holders.values().forEach(manager.recipes::addRecipe);
manager.finalizeRecipeLoading(FeatureFlags.VANILLA_SET); manager.finalizeRecipeLoading(FeatureFlags.VANILLA_SET);
// Pinned CraftServer.add/removeRecipe calls PlayerList.reloadRecipes after finalizing. // Pinned CraftServer.add/removeRecipe calls PlayerList.reloadRecipes after finalizing.
// Run that real native book synchronization at the actor/network boundary too: later // Run that real native book synchronization at the actor/network boundary too: later
@@ -95,9 +95,9 @@ class NativePowderSnowPluginWiringTest {
when(serverUnderTest.getItemFactory()).thenAnswer(invocation -> CraftItemFactory.instance()); when(serverUnderTest.getItemFactory()).thenAnswer(invocation -> CraftItemFactory.instance());
when(serverUnderTest.getUnsafe()).thenReturn(CraftMagicNumbers.INSTANCE); when(serverUnderTest.getUnsafe()).thenReturn(CraftMagicNumbers.INSTANCE);
when(serverUnderTest.isPrimaryThread()).thenReturn(true); when(serverUnderTest.isPrimaryThread()).thenReturn(true);
when(serverUnderTest.getVersion()).thenReturn("Purpur 2618 (MC: 26.2)"); when(serverUnderTest.getVersion()).thenReturn("Purpur 2651 (MC: 26.3)");
when(serverUnderTest.getMinecraftVersion()).thenReturn("26.2"); when(serverUnderTest.getMinecraftVersion()).thenReturn("26.3");
when(serverUnderTest.getBukkitVersion()).thenReturn("26.2.build.2618-stable"); when(serverUnderTest.getBukkitVersion()).thenReturn("26.3.build.2651-stable");
when(serverUnderTest.getWarningState()).thenReturn(org.bukkit.Warning.WarningState.DEFAULT); when(serverUnderTest.getWarningState()).thenReturn(org.bukkit.Warning.WarningState.DEFAULT);
logger = Logger.getAnonymousLogger(); logger = Logger.getAnonymousLogger();
logs = new ArrayList<>(); logs = new ArrayList<>();
@@ -643,7 +643,7 @@ class NativePowderSnowPluginWiringTest {
createdPlugin = new SpigotInventoryHelperPlugin(new JavaPluginLoader(serverUnderTest), createdPlugin = new SpigotInventoryHelperPlugin(new JavaPluginLoader(serverUnderTest),
NativeAnvilFixture.description(), directory.resolve("data").toFile(), NativeAnvilFixture.description(), directory.resolve("data").toFile(),
directory.resolve("plugin.jar").toFile(), () -> 99, () -> 1, protocol, directory.resolve("plugin.jar").toFile(), () -> 99, () -> 1, protocol,
() -> "Purpur 26.2.build.2618-stable"); () -> "Purpur 26.3.build.2651-stable");
commands.registerAll(createdPlugin.getName(), commands.registerAll(createdPlugin.getName(),
org.bukkit.command.PluginCommandYamlParser.parse(createdPlugin)); org.bukkit.command.PluginCommandYamlParser.parse(createdPlugin));
createdPlugin.getLogger().addHandler(logHandler); createdPlugin.getLogger().addHandler(logHandler);
@@ -2166,7 +2166,6 @@ final class NativeReinforcementCraftTest {
doAnswer(inv -> { doAnswer(inv -> {
BlockPos pos = inv.getArgument(0); BlockPos pos = inv.getArgument(0);
net.minecraft.world.level.block.state.BlockState state = inv.getArgument(1); net.minecraft.world.level.block.state.BlockState state = inv.getArgument(1);
assertEquals(3, inv.<Integer>getArgument(2));
if (source.equals(pos)) { if (source.equals(pos)) {
assertTrue(state.isAir(), "Only native source departure is supported"); assertTrue(state.isAir(), "Only native source departure is supported");
sourceState.set(state); sourceState.set(state);
@@ -2177,7 +2176,8 @@ final class NativeReinforcementCraftTest {
effects.data.set(CraftBlockData.createData(state)); effects.data.set(CraftBlockData.createData(state));
} }
return true; return true;
}).when(f.actor.level).setBlock(any(BlockPos.class), any(net.minecraft.world.level.block.state.BlockState.class), anyInt()); }).when(f.actor.level).setBlockAndUpdate(any(BlockPos.class),
any(net.minecraft.world.level.block.state.BlockState.class));
when(f.actor.level.addFreshEntity(any(Entity.class))).thenAnswer(inv -> { when(f.actor.level.addFreshEntity(any(Entity.class))).thenAnswer(inv -> {
Entity candidate = inv.getArgument(0); Entity candidate = inv.getArgument(0);
if (candidate instanceof net.minecraft.world.entity.item.ItemEntity item) { if (candidate instanceof net.minecraft.world.entity.item.ItemEntity item) {
@@ -2310,7 +2310,7 @@ final class NativeReinforcementCraftTest {
entity.discard(org.bukkit.event.entity.EntityRemoveEvent.Cause.DESPAWN); entity.discard(org.bukkit.event.entity.EntityRemoveEvent.Cause.DESPAWN);
return false; return false;
} }
boolean written = f.actor.level.setBlock(entity.blockPosition(), entity.blockState, 3); boolean written = f.actor.level.setBlockAndUpdate(entity.blockPosition(), entity.blockState);
if (written) entity.discard(org.bukkit.event.entity.EntityRemoveEvent.Cause.DESPAWN); if (written) entity.discard(org.bukkit.event.entity.EntityRemoveEvent.Cause.DESPAWN);
else assertFalse(entity.dropItem, "Only inspected failed-write/no-drop outcome is supplied"); else assertFalse(entity.dropItem, "Only inspected failed-write/no-drop outcome is supplied");
f.actor.assertNoEventErrors(); f.actor.assertNoEventErrors();
@@ -2334,7 +2334,7 @@ final class NativeReinforcementCraftTest {
new NamespacedKey("spigotinventoryhelper", "falling_reinforcement"), PersistentDataType.TAG_CONTAINER); new NamespacedKey("spigotinventoryhelper", "falling_reinforcement"), PersistentDataType.TAG_CONTAINER);
assertNotNull(record); assertNotNull(record);
assertEquals((byte) 2, record.get(new NamespacedKey("spigotinventoryhelper", "transport_phase"), PersistentDataType.BYTE)); assertEquals((byte) 2, record.get(new NamespacedKey("spigotinventoryhelper", "transport_phase"), PersistentDataType.BYTE));
assertFalse(f.actor.level.setBlock(entity.blockPosition(), entity.blockState, 3)); assertFalse(f.actor.level.setBlockAndUpdate(entity.blockPosition(), entity.blockState));
assertEquals(Material.AIR, effects.block.getType()); assertEquals(Material.AIR, effects.block.getType());
// Inspected FallingBlockEntity.tick branch, but NOT a claim to run physics/tick. // Inspected FallingBlockEntity.tick branch, but NOT a claim to run physics/tick.
entity.discard(org.bukkit.event.entity.EntityRemoveEvent.Cause.DROP); entity.discard(org.bukkit.event.entity.EntityRemoveEvent.Cause.DROP);
@@ -2361,7 +2361,7 @@ final class NativeReinforcementCraftTest {
assertSame(f.actor.world, event.getBlock().getWorld()); assertSame(f.actor.world, event.getBlock().getWorld());
assertEquals(f.actor.position, ((org.bukkit.craftbukkit.block.CraftBlock) event.getBlock()).getPosition()); assertEquals(f.actor.position, ((org.bukkit.craftbukkit.block.CraftBlock) event.getBlock()).getPosition());
assertEquals(Material.DAMAGED_ANVIL, event.getTo()); assertEquals(Material.DAMAGED_ANVIL, event.getTo());
if (accepted) assertTrue(f.actor.level.setBlock(entity.blockPosition(), entity.blockState, 3)); if (accepted) assertTrue(f.actor.level.setBlockAndUpdate(entity.blockPosition(), entity.blockState));
entity.discard(org.bukkit.event.entity.EntityRemoveEvent.Cause.DESPAWN); entity.discard(org.bukkit.event.entity.EntityRemoveEvent.Cause.DESPAWN);
f.actor.assertNoEventErrors(); f.actor.assertNoEventErrors();
return accepted; return accepted;
@@ -28,6 +28,7 @@ import org.bukkit.craftbukkit.CraftRegistry;
/** Loads vanilla registries, tags, and components using the server test bootstrap path. */ /** Loads vanilla registries, tags, and components using the server test bootstrap path. */
final class NativeRuntime { final class NativeRuntime {
private static boolean bootstrapped; private static boolean bootstrapped;
private static HolderLookup.Provider reloadableRegistries;
private NativeRuntime() {} private NativeRuntime() {}
@@ -47,12 +48,12 @@ final class NativeRuntime {
List<Registry.PendingTags<?>> tags = TagLoader.loadTagsForExistingRegistries( List<Registry.PendingTags<?>> tags = TagLoader.loadTagsForExistingRegistries(
resources, layers.getLayer(RegistryLayer.STATIC)); resources, layers.getLayer(RegistryLayer.STATIC));
List<HolderLookup.RegistryLookup<?>> lookups = TagLoader.buildUpdatedLookups( List<HolderLookup.RegistryLookup<?>> lookups = TagLoader.buildUpdatedLookups(
layers.getAccessForLoading(RegistryLayer.WORLDGEN), tags); layers.getAccessForLoading(RegistryLayer.WORLD), tags);
RegistryAccess.Frozen worldgen = RegistryDataLoader.load(resources, lookups, RegistryAccess.Frozen world = RegistryDataLoader.load(resources, lookups,
RegistryDataLoader.WORLDGEN_REGISTRIES, Util.backgroundExecutor()).join(); RegistryDataLoader.WORLD_REGISTRIES, Util.backgroundExecutor()).join();
layers = layers.replaceFrom(RegistryLayer.WORLDGEN, worldgen); layers = layers.replaceFrom(RegistryLayer.WORLD, world);
RegistryAccess.Frozen dimensions = RegistryDataLoader.load(resources, RegistryAccess.Frozen dimensions = RegistryDataLoader.load(resources,
Stream.concat(lookups.stream(), worldgen.listRegistries()).toList(), Stream.concat(lookups.stream(), world.listRegistries()).toList(),
RegistryDataLoader.DIMENSION_REGISTRIES, Util.backgroundExecutor()).join(); RegistryDataLoader.DIMENSION_REGISTRIES, Util.backgroundExecutor()).join();
layers = layers.replaceFrom(RegistryLayer.DIMENSIONS, dimensions); layers = layers.replaceFrom(RegistryLayer.DIMENSIONS, dimensions);
Class.forName(org.bukkit.Registry.class.getName()); Class.forName(org.bukkit.Registry.class.getName());
@@ -61,7 +62,15 @@ final class NativeRuntime {
Util.backgroundExecutor(), Runnable::run).join(); Util.backgroundExecutor(), Runnable::run).join();
datapack.updateComponentsAndStaticRegistryTags(); datapack.updateComponentsAndStaticRegistryTags();
CraftRegistry.setMinecraftRegistry(layers.compositeAccess().freeze()); CraftRegistry.setMinecraftRegistry(layers.compositeAccess().freeze());
reloadableRegistries = datapack.fullRegistries().lookup();
bootstrapped = true; bootstrapped = true;
} }
} }
static net.minecraft.world.item.crafting.RecipeManager recipeManager() {
if (!bootstrapped) {
throw new IllegalStateException("Native runtime has not been bootstrapped");
}
return new net.minecraft.world.item.crafting.RecipeManager(reloadableRegistries);
}
} }
@@ -20,6 +20,7 @@ class PurpurDistributionTest {
Map<?, ?> metadata = new Yaml().load(jar.getInputStream(jar.getJarEntry("plugin.yml"))); Map<?, ?> metadata = new Yaml().load(jar.getInputStream(jar.getJarEntry("plugin.yml")));
assertEquals(version, metadata.get("version")); assertEquals(version, metadata.get("version"));
assertEquals("SpigotInventoryHelper", metadata.get("name"), "Retain the data directory and persistent namespace"); assertEquals("SpigotInventoryHelper", metadata.get("name"), "Retain the data directory and persistent namespace");
assertEquals("1.20", metadata.get("api-version"));
assertEquals(java.util.List.of("ProtocolLib"), metadata.get("depend")); assertEquals(java.util.List.of("ProtocolLib"), metadata.get("depend"));
assertEquals("games.dmg.spigotinventoryhelper.SpigotInventoryHelperPlugin", metadata.get("main")); assertEquals("games.dmg.spigotinventoryhelper.SpigotInventoryHelperPlugin", metadata.get("main"));
Map<?, ?> permissions = (Map<?, ?>) metadata.get("permissions"); Map<?, ?> permissions = (Map<?, ?>) metadata.get("permissions");
@@ -68,7 +69,9 @@ class PurpurDistributionTest {
assertTrue(readme.contains("Dropper + Tripwire Hook")); assertTrue(readme.contains("Dropper + Tripwire Hook"));
assertTrue(readme.contains("Redstone category")); assertTrue(readme.contains("Redstone category"));
assertTrue(readme.contains("CraftServer, CraftPlayer, and client boundaries are doubled")); assertTrue(readme.contains("CraftServer, CraftPlayer, and client boundaries are doubled"));
assertTrue(readme.contains("4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31")); assertTrue(readme.contains("Purpur 26.3 build 2651"));
assertTrue(readme.contains("0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598"));
assertFalse(readme.contains("26.2") || readme.contains("2618"));
} }
@Test @Test
@@ -83,14 +86,20 @@ class PurpurDistributionTest {
} }
@Test @Test
void releaseAndCiAgreeWithThePurpurDistributionNameAndToolchain() throws Exception { void releaseAndCiAgreeWithThePurpur26_3DistributionAndToolchain() throws Exception {
String build = Files.readString(Path.of("build.gradle.kts")); String build = Files.readString(Path.of("build.gradle.kts"));
assertTrue(build.contains("purpur-api:26.2.build.2618-stable")); assertTrue(build.contains("purpur-api:26.3.build.2651-stable"));
assertTrue(build.contains("purpur-26.3-2651.jar"));
assertTrue(build.contains("https://api.purpurmc.org/v2/purpur/26.3/2651/download"));
assertTrue(build.contains("0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598"));
assertTrue(build.contains("compileOnly(\"net.dmulloy2:ProtocolLib:5.4.0\")")); assertTrue(build.contains("compileOnly(\"net.dmulloy2:ProtocolLib:5.4.0\")"));
assertFalse(build.contains("26.2") || build.contains("2618"));
assertFalse(build.contains("org.spigotmc:spigot-api")); assertFalse(build.contains("org.spigotmc:spigot-api"));
assertTrue(build.contains("nativeInventoryTest")); assertTrue(build.contains("nativeInventoryTest"));
assertTrue(build.contains("tasks.check { dependsOn(nativeInventoryTest) }")); assertTrue(build.contains("tasks.check { dependsOn(nativeInventoryTest) }"));
assertTrue(build.contains("4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31")); String guard = Files.readString(Path.of("src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilProtocolGuard.java"));
assertTrue(guard.contains("26.3.build.2651-stable"));
assertFalse(guard.contains("26.2") || guard.contains("2618"));
for (String workflow : new String[] {"ci.yml", "release.yml"}) { for (String workflow : new String[] {"ci.yml", "release.yml"}) {
String content = Files.readString(Path.of(".gitea/workflows", workflow)); String content = Files.readString(Path.of(".gitea/workflows", workflow));
assertTrue(content.contains("java-version: 25")); assertTrue(content.contains("java-version: 25"));