feat(platform): support Purpur 26.3 #1

Merged
dmg merged 1 commits from ticket-413-support-purpur-26-3 into main 2026-10-17 03:51:05 +00:00
8 changed files with 39 additions and 23 deletions

No files matched your search

+1 -1
View File
@@ -4,6 +4,6 @@ Canonical knowledge and stories live in the sibling `somc-okf/` checkout, not th
Standalone checkout entrypoint: https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md . If unavailable, request access; do not create a substitute knowledge bundle or copy private wiki content here.
Obtain explicit story-change approval before implementation and operational approval before infrastructure, tooling or secret changes. Use vertical-slice TDD, retain native combat/protection decisions and verify `./gradlew clean check jar`, including mandatory non-listening native tests. Java 25, Gradle 9.6.1, Purpur API `26.2.build.2618-stable`; do not silently upgrade. Keep blocking I/O off the tick thread.
Obtain explicit story-change approval before implementation and operational approval before infrastructure, tooling or secret changes. Use vertical-slice TDD, retain native combat/protection decisions and verify `./gradlew clean check jar`, including mandatory non-listening native tests. Java 25, Gradle 9.6.1, Purpur API `26.3.build.2651-stable`; do not silently upgrade. Keep blocking I/O off the tick thread.
Publishing credentials and release-triggering pushes require separate readiness verification and authorization. Never put secret bytes in argv, URLs, logs or Git. No deployment/GitOps push follows implicitly from source work. Preserve unrelated changes and use conventional commits.
+2 -2
View File
@@ -1,6 +1,6 @@
# Purpur Potato
A Java 25 plugin for Purpur 26.2 build 2618. Right-click a poisonous potato to throw it with native snowball flight and a potato visual. Each successful launch costs one potato (including Creative), has a 20-tick cooldown, and deals one health point of base damage without poison. Native damage modifiers, PvP rules and protection cancellations remain authoritative. Projectiles expire after 100 ticks; misses are not refunded.
A Java 25 plugin for Purpur 26.3 build 2651. Right-click a poisonous potato to throw it with native snowball flight and a potato visual. Each successful launch costs one potato (including Creative), has a 20-tick cooldown, and deals one health point of base damage without poison. Native damage modifiers, PvP rules and protection cancellations remain authoritative. Projectiles expire after 100 ticks; misses are not refunded.
`/potato [help]` explains the mechanic to players and console. `purpurpotato.help` defaults to true. No recipe, unlock, persistent data or runtime credential is required.
@@ -11,7 +11,7 @@ A Java 25 plugin for Purpur 26.2 build 2618. Right-click a poisonous potato to t
./gradlew clean check jar -PreleaseVersion=1.2.3
```
Gradle 9.6.1 selects Java 25. Full checks include Java tests, mandatory non-listening native tests against the checksum-pinned Purpur runtime, and TypeScript release-tool tests (`npm ci` requires Node 20+). Native preparation runs Paperclip **patch-only**: it does not start a server, listen on a port or access production. Build downloads require network access. Generated runtime files stay under ignored `build/`.
Gradle 9.6.1 selects Java 25 and compiles against Purpur API `26.3.build.2651-stable`. Full checks include Java tests, mandatory non-listening native tests against the checksum-pinned `purpur-26.3-2651.jar` runtime, and TypeScript release-tool tests (`npm ci` requires Node 20+). Native preparation runs Paperclip **patch-only** in a build-specific directory: it does not start a server, listen on a port, access production, or reuse an extracted runtime from another supported build. Build downloads require network access. Generated runtime files stay under ignored `build/`.
Outputs: `build/libs/purpur-potato-<version>.jar`. Server and test dependencies are not bundled. Do not deploy snapshot workflow artifacts.
+14 -9
View File
@@ -25,10 +25,15 @@ tasks.withType<JavaCompile>().configureEach {
options.compilerArgs.addAll(listOf("-Xlint:all,-deprecation,-removal", "-Werror"))
}
dependencies {
compileOnly("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
val purpurApi = "org.purpurmc.purpur:purpur-api:26.3.build.2651-stable"
val nativeRuntimeName = "purpur-26.3-2651.jar"
val nativeRuntimeUrl = "https://api.purpurmc.org/v2/purpur/26.3/2651/download"
testImplementation("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
dependencies {
compileOnly(purpurApi)
testImplementation(purpurApi)
testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("org.mockito:mockito-core:5.18.0")
@@ -44,20 +49,20 @@ tasks.test {
}
// Verify native projectile adapters against the declared runtime, without starting a server.
val nativeRuntime = layout.buildDirectory.dir("native-runtime")
val nativeRuntime = layout.buildDirectory.dir("native-runtime/26.3-2651")
val downloadNativeRuntime = tasks.register("downloadNativeRuntime") {
val launcher = nativeRuntime.map { it.file("purpur-26.2-2618.jar") }
val launcher = nativeRuntime.map { it.file(nativeRuntimeName) }
outputs.file(launcher)
doLast {
val file = launcher.get().asFile
file.parentFile.mkdirs()
val connection = URI("https://api.purpurmc.org/v2/purpur/26.2/2618/download").toURL().openConnection()
val connection = URI(nativeRuntimeUrl).toURL().openConnection()
connection.connectTimeout = 30_000
connection.readTimeout = 120_000
val bytes = connection.getInputStream().use { it.readBytes() }
val digest = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
check(digest == "4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31") {
"Purpur 2618 checksum mismatch"
check(digest == "0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598") {
"Purpur 2651 checksum mismatch"
}
file.writeBytes(bytes)
}
@@ -65,7 +70,7 @@ val downloadNativeRuntime = tasks.register("downloadNativeRuntime") {
val prepareNativeRuntime = tasks.register<JavaExec>("prepareNativeRuntime") {
dependsOn(downloadNativeRuntime)
javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(25) }
classpath = files(nativeRuntime.map { it.file("purpur-26.2-2618.jar") })
classpath = files(nativeRuntime.map { it.file(nativeRuntimeName) })
mainClass = "io.papermc.paperclip.Main"
jvmArgs("-Dpaperclip.patchonly=true")
workingDir(nativeRuntime)
+1 -1
View File
@@ -1,7 +1,7 @@
name: PurpurPotato
version: '${version}'
main: games.dmg.purpurpotato.PurpurPotato
api-version: '26.2'
api-version: '26.3'
description: Throw consumable poisonous potatoes with native snowball flight.
commands:
potato:
@@ -101,6 +101,7 @@ class NativeCombatTest {
when(nativePlayer.getBukkitEntity()).thenReturn(player);
when(player.getScoreboard()).thenReturn(mock(org.bukkit.craftbukkit.scoreboard.CraftScoreboard.class));
when(nativePlayer.level()).thenReturn(world);
when(nativePlayer.position()).thenReturn(Vec3.ZERO);
when(nativePlayer.getAdvancements()).thenReturn(mock(net.minecraft.server.PlayerAdvancements.class));
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
var inventory = mock(PlayerInventory.class);
@@ -140,6 +141,7 @@ class NativeCombatTest {
if (target instanceof ServerPlayer nativeVictim) {
when(world.isPvpAllowed()).thenReturn(!mode.equals("pvp-denied"));
when(nativeVictim.canHarmPlayer(nativePlayer)).thenCallRealMethod();
when(nativeVictim.doTeamsAllowDamage(nativePlayer)).thenCallRealMethod();
var abilities = net.minecraft.world.entity.player.Player.class.getDeclaredField("abilities");
abilities.setAccessible(true);
abilities.set(nativeVictim, new net.minecraft.world.entity.player.Abilities());
@@ -43,12 +43,12 @@ final class NativeRuntime {
try (var resources = new MultiPackResourceManager(PackType.SERVER_DATA, packs.openAllSelected())) {
LayeredRegistryAccess<RegistryLayer> layers = RegistryLayer.createRegistryAccess();
List<Registry.PendingTags<?>> tags = TagLoader.loadTagsForExistingRegistries(resources, layers.getLayer(RegistryLayer.STATIC));
List<HolderLookup.RegistryLookup<?>> lookups = TagLoader.buildUpdatedLookups(layers.getAccessForLoading(RegistryLayer.WORLDGEN), tags);
RegistryAccess.Frozen worldgen = RegistryDataLoader.load(resources, lookups,
RegistryDataLoader.WORLDGEN_REGISTRIES, Util.backgroundExecutor()).join();
layers = layers.replaceFrom(RegistryLayer.WORLDGEN, worldgen);
List<HolderLookup.RegistryLookup<?>> lookups = TagLoader.buildUpdatedLookups(layers.getAccessForLoading(RegistryLayer.WORLD), tags);
RegistryAccess.Frozen world = RegistryDataLoader.load(resources, lookups,
RegistryDataLoader.WORLD_REGISTRIES, Util.backgroundExecutor()).join();
layers = layers.replaceFrom(RegistryLayer.WORLD, world);
RegistryAccess.Frozen dimensions = RegistryDataLoader.load(resources,
Stream.concat(lookups.stream(), worldgen.listRegistries()).toList(),
Stream.concat(lookups.stream(), world.listRegistries()).toList(),
RegistryDataLoader.DIMENSION_REGISTRIES, Util.backgroundExecutor()).join();
layers = layers.replaceFrom(RegistryLayer.DIMENSIONS, dimensions);
Class.forName(org.bukkit.Registry.class.getName());
@@ -47,7 +47,7 @@ class PluginLifecycleTest {
when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getLogger("PotatoTest"));
try (var stream = getClass().getResourceAsStream("/plugin.yml")) {
var description = new PluginDescriptionFile(stream);
assertEquals("26.2", description.getAPIVersion());
assertEquals("26.3", description.getAPIVersion());
when(plugin.getDescription()).thenReturn(description);
}
var constructor = PluginCommand.class.getDeclaredConstructor(String.class, org.bukkit.plugin.Plugin.class);
@@ -19,7 +19,7 @@ class DistributionTest {
assertEquals("PurpurPotato", metadata.get("name"));
assertEquals(version, metadata.get("version"));
assertEquals("games.dmg.purpurpotato.PurpurPotato", metadata.get("main"));
assertEquals("26.2", metadata.get("api-version"));
assertEquals("26.3", metadata.get("api-version"));
var commands = (Map<?, ?>) metadata.get("commands");
assertEquals(1, commands.size());
var potato = (Map<?, ?>) commands.get("potato");
@@ -27,6 +27,9 @@ class DistributionTest {
assertEquals("purpurpotato.help", potato.get("permission"));
var permissions = (Map<?, ?>) metadata.get("permissions");
assertEquals(true, ((Map<?, ?>) permissions.get("purpurpotato.help")).get("default"));
assertFalse(metadata.containsKey("depend"));
assertFalse(metadata.containsKey("softdepend"));
assertFalse(metadata.containsKey("loadbefore"));
var entry = jar.getEntry("games/dmg/purpurpotato/PurpurPotato.class");
assertNotNull(entry);
try (var bytes = new DataInputStream(jar.getInputStream(entry))) {
@@ -34,9 +37,15 @@ class DistributionTest {
bytes.readUnsignedShort();
assertEquals(69, bytes.readUnsignedShort());
}
assertTrue(jar.stream().filter(e -> e.getName().endsWith(".class"))
.allMatch(e -> e.getName().startsWith("games/dmg/purpurpotato/")
&& !e.getName().endsWith("Test.class")), "No server or test dependencies may be bundled");
assertTrue(jar.stream().allMatch(e -> {
String name = e.getName();
return e.isDirectory()
|| name.equals("META-INF/MANIFEST.MF")
|| name.equals("plugin.yml")
|| name.startsWith("games/dmg/purpurpotato/")
&& name.endsWith(".class")
&& !name.endsWith("Test.class");
}), "Only plugin classes, metadata, and the manifest may be bundled");
}
}
}