3 Commits
Author SHA1 Message Date
dmg 275284f35a docs: point story tracking to Kaneo
CI / build (push) Successful in 1m14s
Release / release (push) Successful in 1m47s
2026-10-28 20:36:22 -04:00
dmg 453ed27293 feat(platform): support Purpur 26.3 (#1)
Release / release (push) Successful in 1m14s
CI / build (push) Successful in 42s
2026-10-17 03:51:17 +00:00
dmg 7496564b94 feat(platform): support Purpur 26.3
CI / build (push) Successful in 1m7s
CI / build (pull_request) Successful in 49s
2026-10-16 23:49:39 -04:00
5 changed files with 236 additions and 13 deletions

No files matched your search

+5 -3
View File
@@ -1,9 +1,11 @@
# purpur-quest-board agent entrypoint
The canonical stories, engineering guidance, and **all process documents** are in the private [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md).
Canonical engineering guidance and technical/process documents are in the private [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md).
Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/purpur-quest-board/index.md`, `engineering.md` in that project section, and relevant `../somc-okf/user-stories/purpur-quest-board/` stories. Also follow the parent workspace `AGENTS.md` when present.
Stories, acceptance criteria, status and evidence belong in [Kaneo Social Minecraft](https://cloud.kaneo.app/dmg/SM), with subjects `[purpur-quest-board] Descriptive title` (no `US-###` or `FS-*` prefix). Use the configured authenticated `kaneo` MCP server per [Kaneo access and migration](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md#kaneo-access-and-migration); report unavailable access rather than creating local story copies. OpenProject is read-only history.
Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/purpur-quest-board/index.md`, `engineering.md` in that project section, and relevant Kaneo tickets with subject prefix `[purpur-quest-board]`. Also follow the parent workspace `AGENTS.md` when present.
For standalone checkouts, start at the [project page](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-quest-board/index.md) and [shared process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md). Obtain wiki access before feature work; do not recreate a local knowledge bundle. Source builds do not require private wiki access.
Development follows [Development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md): approved stories, failing tests, passing implementation, verification, then source/wiki commit and push. GitOps updates are committed locally **without pushing**; only [Do release](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/do-release.md) authorizes a reviewed GitOps push.
Follow the full [development process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md) and [development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md) for approvals, test-first/full-suite verification, required Gitea PR checks/reviews and merge, and merged-tree verification before ticket completion. Never push source `main` directly. GitOps updates are committed locally **without pushing**; only [Do release](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/do-release.md) authorizes a reviewed GitOps push.
+4 -4
View File
@@ -1,6 +1,6 @@
# Purpur Quest Board
A Purpur 26.2 plugin for shared physical quest boards. Players can escrow rewards for item deliveries or player bounties, fulfill them, and collect exact rewards through shared boards.
A Purpur 26.3 plugin for shared physical quest boards. Players can escrow rewards for item deliveries or player bounties, fulfill them, and collect exact rewards through shared boards.
The approved behavior is specified in the [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-quest-board/index.md).
@@ -36,8 +36,8 @@ The repository, checkout and new JARs use `purpur-quest-board`. Runtime `SpigotQ
## Requirements
- Purpur 26.2 build 2618
- Java 25 or newer
- Purpur 26.3 build 2651
- Java 25
## Build
@@ -45,7 +45,7 @@ The repository, checkout and new JARs use `purpur-quest-board`. Runtime `SpigotQ
./gradlew clean check jar
```
The plugin JAR is written to `build/libs/`.
The plugin JAR is written to `build/libs/`. The verification task downloads the fixed Purpur 26.3 build 2651 runtime, verifies SHA-256 `0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598`, and starts an isolated server to prove plugin load, enable, and orderly disable. Mock/API tests remain separate coverage for dialogs, boards, escrow, claims, persistence, and ownership locking.
## Player commands
+95 -3
View File
@@ -1,3 +1,8 @@
import java.net.URI
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.security.MessageDigest
plugins {
java
}
@@ -23,9 +28,9 @@ tasks.withType<JavaCompile>().configureEach {
}
dependencies {
compileOnly("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
compileOnly("org.purpurmc.purpur:purpur-api:26.3.build.2651-stable")
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("org.junit.jupiter:junit-jupiter")
testImplementation("org.mockito:mockito-core:5.18.0")
@@ -33,13 +38,100 @@ dependencies {
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
val purpurVersion = "26.3"
val purpurBuild = "2651"
val purpurRuntimeSha256 = "0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598"
val purpurRuntimeJar = layout.projectDirectory.file(".gradle/purpur-runtime/purpur-$purpurVersion-$purpurBuild.jar")
fun sha256(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
file.inputStream().buffered().use { input ->
val buffer = ByteArray(8192)
while (true) {
val read = input.read(buffer)
if (read < 0) break
digest.update(buffer, 0, read)
}
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
val downloadPurpurRuntime = tasks.register("downloadPurpurRuntime") {
description = "Downloads and verifies the exact supported Purpur runtime"
group = "verification"
outputs.file(purpurRuntimeJar)
outputs.upToDateWhen { false }
doLast {
val destination = purpurRuntimeJar.asFile
destination.parentFile.mkdirs()
if (!destination.isFile || sha256(destination) != purpurRuntimeSha256) {
Files.deleteIfExists(destination.toPath())
val temporary = Files.createTempFile(destination.parentFile.toPath(), destination.name, ".part")
try {
val connection = URI(
"https://api.purpurmc.org/v2/purpur/$purpurVersion/$purpurBuild/download"
).toURL().openConnection().apply {
connectTimeout = 30_000
readTimeout = 120_000
}
connection.getInputStream().use { input ->
Files.newOutputStream(temporary).use(input::copyTo)
}
val actual = sha256(temporary.toFile())
check(actual == purpurRuntimeSha256) {
"Purpur runtime SHA-256 mismatch: expected $purpurRuntimeSha256, got $actual"
}
Files.move(
temporary,
destination.toPath(),
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING
)
} finally {
Files.deleteIfExists(temporary)
}
}
check(sha256(destination) == purpurRuntimeSha256) {
"Cached Purpur runtime failed SHA-256 verification"
}
}
}
val java25Launcher = javaToolchains.launcherFor {
languageVersion = JavaLanguageVersion.of(25)
}
val pluginJar = tasks.jar.flatMap { it.archiveFile }
tasks.test {
useJUnitPlatform()
dependsOn(tasks.jar)
systemProperty("distribution.jar", tasks.jar.get().archiveFile.get().asFile.absolutePath)
exclude("**/PurpurRuntimeLifecycleTest.class")
javaLauncher = java25Launcher
systemProperty("distribution.jar", pluginJar.get().asFile.absolutePath)
systemProperty("distribution.version", project.version.toString())
}
val purpurRuntimeTest = tasks.register<Test>("purpurRuntimeTest") {
description = "Loads, enables, and disables the plugin on the supported Purpur runtime"
group = "verification"
dependsOn(downloadPurpurRuntime, tasks.jar)
testClassesDirs = sourceSets.test.get().output.classesDirs
classpath = sourceSets.test.get().runtimeClasspath
useJUnitPlatform()
include("**/PurpurRuntimeLifecycleTest.class")
javaLauncher = java25Launcher
systemProperty("purpur.runtime.jar", purpurRuntimeJar.asFile.absolutePath)
systemProperty("purpur.runtime.version", purpurVersion)
systemProperty("purpur.runtime.build", purpurBuild)
systemProperty("distribution.jar", pluginJar.get().asFile.absolutePath)
shouldRunAfter(tasks.test)
}
tasks.check {
dependsOn(purpurRuntimeTest)
}
val pluginVersion = version
tasks.processResources {
@@ -21,11 +21,27 @@ class PurpurDistributionTest {
assertEquals(version, metadata.get("version"));
assertEquals("SpigotQuestBoard", metadata.get("name"), "Retain the data directory and persistent namespace");
assertEquals("games.dmg.spigotquestboard.SpigotQuestBoardPlugin", metadata.get("main"));
assertEquals("1.20", metadata.get("api-version"), "Retain the deliberate compatibility floor");
assertEquals(java.util.Set.of("quests", "questadmin"), ((Map<?, ?>) metadata.get("commands")).keySet());
assertTrue(((Map<?, ?>) metadata.get("permissions")).containsKey("spigotquestboard.admin"));
assertNull(metadata.get("depend"));
assertNull(metadata.get("softdepend"));
assertNull(metadata.get("loadbefore"));
var description = new org.bukkit.plugin.PluginDescriptionFile(jar.getInputStream(jar.getJarEntry("plugin.yml")));
assertEquals("spigotquestboard", description.namespace());
assertFalse(jar.stream().anyMatch(entry -> entry.getName().startsWith("org/bukkit/")
|| entry.getName().startsWith("org/purpurmc/") || entry.getName().endsWith("Test.class")));
assertFalse(jar.stream().anyMatch(entry -> {
String name = entry.getName();
return name.startsWith("org/bukkit/")
|| name.startsWith("org/purpurmc/")
|| name.startsWith("io/papermc/")
|| name.startsWith("com/destroystokyo/paper/")
|| name.startsWith("net/minecraft/")
|| name.startsWith("com/comphenix/protocol/")
|| name.startsWith("org/junit/")
|| name.startsWith("org/mockito/")
|| name.endsWith("Test.class")
|| name.contains("/test/");
}));
}
}
@@ -43,7 +59,8 @@ class PurpurDistributionTest {
@Test
void releaseAndCiAgreeWithThePurpurDistributionNameAndToolchain() throws Exception {
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"));
assertFalse(build.contains("26.2.build.2618"));
assertFalse(build.contains("org.spigotmc:spigot-api"));
for (String workflow : new String[] {"ci.yml", "release.yml"}) {
String content = Files.readString(Path.of(".gitea/workflows", workflow));
@@ -0,0 +1,112 @@
package games.dmg.spigotquestboard;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class PurpurRuntimeLifecycleTest {
private static final Duration STARTUP_TIMEOUT = Duration.ofMinutes(3);
@TempDir
Path serverDirectory;
@Test
void loadsEnablesAndDisablesOnExactSupportedPurpurRuntime() throws Exception {
Path runtimeJar = requiredPath("purpur.runtime.jar");
Path pluginJar = requiredPath("distribution.jar");
String version = System.getProperty("purpur.runtime.version");
String build = System.getProperty("purpur.runtime.build");
Path plugins = Files.createDirectories(serverDirectory.resolve("plugins"));
Files.copy(pluginJar, plugins.resolve(pluginJar.getFileName()), StandardCopyOption.REPLACE_EXISTING);
Files.writeString(serverDirectory.resolve("eula.txt"), "eula=true\n", UTF_8);
Files.writeString(serverDirectory.resolve("server.properties"), String.join("\n",
"level-name=runtime-world",
"online-mode=false",
"server-port=0",
"spawn-protection=0",
"sync-chunk-writes=true",
"") , UTF_8);
Process process = new ProcessBuilder(List.of(
javaExecutable().toString(),
"-Xms256M",
"-Xmx768M",
"-jar",
runtimeJar.toString(),
"--nogui"
)).directory(serverDirectory.toFile()).redirectErrorStream(true).start();
StringBuffer output = new StringBuffer();
AtomicBoolean stopSent = new AtomicBoolean();
try (var readerExecutor = Executors.newSingleThreadExecutor(
Thread.ofPlatform().name("purpur-runtime-output").factory())) {
var outputRead = readerExecutor.submit(() -> readOutputAndStop(process, output, stopSent));
boolean exited = process.waitFor(STARTUP_TIMEOUT.toSeconds(), TimeUnit.SECONDS);
if (!exited) {
process.destroyForcibly();
process.waitFor(30, TimeUnit.SECONDS);
}
outputRead.get(30, TimeUnit.SECONDS);
String log = output.toString();
assertTrue(exited, () -> "Purpur did not stop within " + STARTUP_TIMEOUT + "\n" + log);
assertEquals(0, process.exitValue(), log);
assertTrue(stopSent.get(), () -> "Purpur never completed startup\n" + log);
assertTrue(log.contains("Purpur") && log.contains(version) && log.contains(build),
() -> "Expected Purpur " + version + " build " + build + " identity\n" + log);
assertTrue(log.contains("Enabling SpigotQuestBoard"), log);
assertTrue(log.contains("Disabling SpigotQuestBoard"), log);
assertFalse(log.contains("Could not load plugin"), log);
assertFalse(log.contains("Error occurred while enabling SpigotQuestBoard"), log);
} finally {
if (process.isAlive()) {
process.destroyForcibly();
process.waitFor(30, TimeUnit.SECONDS);
}
}
}
private static Void readOutputAndStop(Process process, StringBuffer output, AtomicBoolean stopSent)
throws IOException {
try (var reader = new BufferedReader(new InputStreamReader(process.getInputStream(), UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
output.append(line).append('\n');
if (line.contains("Done (") && stopSent.compareAndSet(false, true)) {
process.getOutputStream().write("stop\n".getBytes(UTF_8));
process.getOutputStream().flush();
}
}
}
return null;
}
private static Path requiredPath(String property) {
String value = System.getProperty(property);
assertFalse(value == null || value.isBlank(), property + " must be configured");
Path path = Path.of(value);
assertTrue(Files.isRegularFile(path), () -> property + " does not exist: " + path);
return path;
}
private static Path javaExecutable() {
String executable = System.getProperty("os.name").startsWith("Windows") ? "java.exe" : "java";
return Path.of(System.getProperty("java.home"), "bin", executable);
}
}