Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
275284f35a | ||
|
|
453ed27293 | ||
|
|
7496564b94 |
No files matched your search
@@ -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.
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user