Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: Build

on:
pull_request:
branches: [main]

permissions:
contents: read

jobs:
build:
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 25
- uses: gradle/actions/setup-gradle@v4
- name: Build and test against published dependencies
run: bash gradlew clean build --refresh-dependencies --no-daemon
- name: Upload test reports
if: always()
uses: actions/upload-artifact@v4
with:
name: test-reports
path: build/reports/tests/test/
if-no-files-found: ignore
22 changes: 18 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,23 @@

## Compatibility

- Paper 26.2
- Paper 26.3 (compiled against `26.3.build.42-alpha`)
- Java 25
- KPaper lifecycle with all optional KPaper features disabled

Jenkins builds with the jdk25 tool and exposes the successful JAR as a downloadable build artifact.
### Build and validate

With JDK 25 selected, run the checked-in Gradle wrapper:

```sh
bash gradlew clean build --refresh-dependencies --no-daemon
```

The build compiles against Paper 26.3 and runs the unit tests. The plugin JAR is written to `build/libs/`. Pull requests run this same clean build without publishing a release. Jenkins uses its `jdk25` tool and exposes the successful JAR as a downloadable artifact.

KPaperGradle is pinned to the last version that resolves both build and runtime libraries through Maven Central, ModLabs' hosted repository, and CodeMC rather than depending on the broad ModLabs mirror. The KPaper library version remains `2026.9.23.1637`. Malformed mirror responses caused the failed release build; do not restore the mirror-only plugin until its repository routing is fixed.

Before releasing, smoke-test the JAR on an isolated Paper 26.3 server: start with an existing managed dimension, import a new 26.3 Misode share, restart, and verify world loading, teleportation, and generators. Unit tests do not replace this server check.

## PlotSquared compatibility

Expand All @@ -32,14 +44,16 @@ WorldEngine intentionally does not register itself as a `/plot setup` generator.

## Custom dimensions

Stage a void dimension from a Minecraft 26.2 Misode Dimension Type share link:
Stage a void dimension from a Minecraft 26.3 Misode Dimension Type share link:

```text
/worldengine dimension create <world> <share-link>
```

Restart the server once. WorldEngine registers the generated datapack during bootstrap and manages the loaded `worldengine:<world>` dimension afterward. Only HTTPS links from `misode.github.io/dimension-type/` are accepted.

Managed dimension datapacks use Minecraft 26.3 data pack format 121.0. Existing WorldEngine 26.2 packs are upgraded on bootstrap: pack metadata is refreshed and the bed-rule `explodes` field is migrated to `destroy_on_use`. New imports must use a 26.3 Misode share. Back up your worlds and `plugins/WorldEngine/dimension-datapack` before upgrading.

For Geyser compatibility, imported dimension types with a negative `min_y` are mapped to Bedrock's Overworld height model. Existing WorldEngine dimension types are migrated automatically during the next server bootstrap and loaded during that same start.

## API usage
Expand Down Expand Up @@ -116,7 +130,7 @@ WorldEngine is published to ModLabs Nexus.

```kotlin
repositories {
maven("https://repo-api.modlabs.cc/repo/maven/maven-mirror/")
maven("https://repo-api.modlabs.cc/repo/maven/maven-public/")
}

dependencies {
Expand Down
6 changes: 4 additions & 2 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ plugins {
kotlin("jvm") version "2.4.10"
`java-library`
id("io.papermc.paperweight.userdev") version "2.0.0-beta.22"
id("cc.modlabs.kpaper-gradle") version "2026.9.26.1231+kpaper.2026.9.23.1637"
// Keep the loader on canonical repositories; the newer plugin hardcodes the mirror.
id("cc.modlabs.kpaper-gradle") version "2026.9.23.1656+kpaper.2026.9.23.1637"
id("maven-publish")
}

Expand All @@ -31,11 +32,12 @@ val mcCoroutineVersion: String by project

repositories {
mavenLocal()
maven("https://repo-api.modlabs.cc/repo/maven/maven-mirror/")
mavenCentral()
maven("https://repo.extendedclip.com/releases/")
maven("https://repo.papermc.io/repository/maven-public/")
maven("https://repo.codemc.io/repository/maven-releases/")
maven("https://repo.codemc.io/repository/maven-snapshots/")
maven("https://repo-api.modlabs.cc/repo/maven/maven-public/")
}


Expand Down
2 changes: 1 addition & 1 deletion settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ rootProject.name = "WorldEngine"
pluginManagement {
repositories {
gradlePluginPortal()
maven("https://repo-api.modlabs.cc/repo/maven/maven-mirror/")
maven("https://repo-api.modlabs.cc/repo/maven/maven-public/")
maven("https://repo.papermc.io/repository/maven-public/")
mavenLocal()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ object DimensionDatapack {
private const val PACK_DIRECTORY = "dimension-datapack"
private const val SHARE_HOST = "misode.github.io"
private const val SNIPPET_HOST = "snippets.misode.workers.dev"
private const val MINECRAFT_VERSION = "26.2"
private const val MINECRAFT_VERSION = "26.3"
private const val MAX_RESPONSE_BYTES = 32 * 1024
private const val MAX_JSON_CHARS = 64 * 1024
private val shareIdPattern = Regex("[A-Za-z0-9_-]{6,64}")
Expand All @@ -44,7 +44,7 @@ object DimensionDatapack {
if (!Files.isRegularFile(pack.resolve("pack.mcmeta"))) return
val migrated = normalizeStagedDimensions(context.dataDirectory)
if (migrated > 0) {
context.logger.info("Made $migrated WorldEngine dimension type compatible with negative Y on Geyser")
context.logger.info("Migrated $migrated WorldEngine dimension types for Minecraft $MINECRAFT_VERSION and Geyser")
}
registrar.discoverPack(pack, "dimensions") { configurer ->
configurer.title(Component.text("WorldEngine dimensions"))
Expand Down Expand Up @@ -115,8 +115,14 @@ object DimensionDatapack {
return inlineCustomSounds(dimensionType)
}

private fun normalizeForGeyser(dimensionType: JsonObject): JsonObject {
private fun normalizeDimensionType(dimensionType: JsonObject): JsonObject {
val copy = dimensionType.deepCopy()
// Minecraft 26.3 renamed this field. Keep an explicit new value if both are present.
val bedRule = copy.getAsJsonObject("attributes")?.getAsJsonObject("minecraft:gameplay/bed_rule")
bedRule?.remove("explodes")?.let { legacyValue ->
if (!bedRule.has("destroy_on_use")) bedRule.add("destroy_on_use", legacyValue)
}

val minY = runCatching { copy["min_y"]?.asInt }.getOrNull() ?: return copy
if (minY < 0 && copy["skybox"]?.asString in setOf("end", "none")) {
copy.remove("skybox")
Expand Down Expand Up @@ -156,31 +162,53 @@ object DimensionDatapack {
val dimensionFile = pack.resolve("data/worldengine/dimension/$worldKey.json")
require(!Files.exists(dimensionFile)) { "Dimension '$worldKey' is already staged" }

normalizeStagedDimensions(dataDirectory)
writeIfMissing(pack.resolve("pack.mcmeta"), PACK_META)
writeAtomically(typeFile, gson.toJson(normalizeForGeyser(dimensionType)), replace = true)
writeAtomically(typeFile, gson.toJson(normalizeDimensionType(dimensionType)), replace = true)
writeAtomically(dimensionFile, gson.toJson(voidDimension(worldKey)), replace = false)
return dimensionFile
}

@Synchronized
internal fun normalizeStagedDimensions(dataDirectory: Path): Int {
val typeDirectory = packDirectory(dataDirectory).resolve("data/worldengine/dimension_type")
if (!Files.isDirectory(typeDirectory)) return 0
var changed = 0
Files.newDirectoryStream(typeDirectory, "*.json").use { files ->
for (file in files) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) continue
val original = JsonParser.parseString(Files.readString(file, StandardCharsets.UTF_8)).asJsonObject
val normalized = normalizeForGeyser(original)
if (normalized != original) {
writeAtomically(file, gson.toJson(normalized), replace = true)
changed++
if (Files.isDirectory(typeDirectory)) {
Files.newDirectoryStream(typeDirectory, "*.json").use { files ->
for (file in files) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) continue
val original = JsonParser.parseString(Files.readString(file, StandardCharsets.UTF_8)).asJsonObject
val normalized = normalizeDimensionType(original)
if (normalized != original) {
writeAtomically(file, gson.toJson(normalized), replace = true)
changed++
}
}
}
}
// Advertise the new format only after every dimension has migrated successfully.
upgradePackMetadata(packDirectory(dataDirectory).resolve("pack.mcmeta"))
return changed
}

private fun upgradePackMetadata(path: Path) {
if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) return
val metadata = JsonParser.parseString(Files.readString(path, StandardCharsets.UTF_8)).asJsonObject
val pack = metadata.getAsJsonObject("pack") ?: return
// Only upgrade the format produced by WorldEngine's 26.2 implementation.
// Leave current, unknown, or newer packs alone rather than silently downgrading them.
if (pack["pack_format"]?.asInt != 107) return
val currentPack = JsonParser.parseString(PACK_META).asJsonObject.getAsJsonObject("pack")
for (field in listOf("pack_format", "min_format", "max_format")) {
pack.add(field, currentPack[field].deepCopy())
}
val description = pack["description"]?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isString }
if (description?.asString == "WorldEngine managed dimensions for Minecraft Java 26.2") {
pack.add("description", currentPack["description"].deepCopy())
}
writeAtomically(path, gson.toJson(metadata), replace = true)
}

internal fun hasDimension(dataDirectory: Path, worldKey: String): Boolean {
if (!worldKeyPattern.matches(worldKey)) return false
return Files.isRegularFile(packDirectory(dataDirectory).resolve("data/worldengine/dimension/$worldKey.json"))
Expand Down Expand Up @@ -244,10 +272,10 @@ object DimensionDatapack {
private val PACK_META = """
{
"pack": {
"description": "WorldEngine managed dimensions for Minecraft Java 26.2",
"pack_format": 107,
"min_format": [107, 1],
"max_format": [107, 1]
"description": "WorldEngine managed dimensions for Minecraft Java $MINECRAFT_VERSION",
"pack_format": 121,
"min_format": [121, 0],
"max_format": [121, 0]
}
}
""".trimIndent()
Expand Down
110 changes: 108 additions & 2 deletions src/test/kotlin/cc/modlabs/worldengine/WorldEngineTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,14 @@ class WorldEngineTest {
val snippet = JsonObject().apply {
addProperty("id", "example123")
addProperty("type", "dimension_type")
addProperty("version", "26.2")
addProperty("version", "26.3")
addProperty("data", LZString.compressToBase64(shareJson.toString()))
}
val decoded = DimensionDatapack.decodeSnippet(snippet.toString(), "example123")
val outdatedSnippet = snippet.deepCopy().apply { addProperty("version", "26.2") }
assertFailsWith<IllegalArgumentException> {
DimensionDatapack.decodeSnippet(outdatedSnippet.toString(), "example123")
}
assertEquals(1024, decoded["height"].asInt)
assertEquals("none", decoded["skybox"].asString)
val sound = decoded["attributes"].asJsonObject["minecraft:audio/background_music"]
Expand All @@ -84,7 +88,10 @@ class WorldEngineTest {
try {
DimensionDatapack.stage(directory, "void", decoded)
val pack = directory.resolve("dimension-datapack")
assertTrue(Files.isRegularFile(pack.resolve("pack.mcmeta")))
val metadata = JsonParser.parseString(Files.readString(pack.resolve("pack.mcmeta"))).asJsonObject["pack"].asJsonObject
assertEquals(121, metadata["pack_format"].asInt)
assertEquals("[121,0]", metadata["min_format"].toString())
assertEquals("[121,0]", metadata["max_format"].toString())
val stagedType = JsonParser.parseString(
Files.readString(pack.resolve("data/worldengine/dimension_type/void.json"))
).asJsonObject
Expand All @@ -109,4 +116,103 @@ class WorldEngineTest {
directory.toFile().deleteRecursively()
}
}

@Test
fun `existing dimension packs migrate to 26_3 without losing custom metadata`() {
val directory = Files.createTempDirectory("worldengine-legacy-dimension-test")
try {
val pack = directory.resolve("dimension-datapack")
val types = pack.resolve("data/worldengine/dimension_type")
Files.createDirectories(types)
Files.writeString(pack.resolve("pack.mcmeta"), """
{"pack":{"description":"Custom description","pack_format":107,
"min_format":[107,1],"max_format":[107,1]},"custom":{"keep":true}}
""".trimIndent())
Files.writeString(types.resolve("legacy.json"), """
{"min_y":0,"height":256,"attributes":{
"minecraft:gameplay/bed_rule":{"can_sleep":"never","explodes":true}
}}
""".trimIndent())
Files.writeString(types.resolve("explicit.json"), """
{"height":256,"attributes":{
"minecraft:gameplay/bed_rule":{"explodes":true,"destroy_on_use":false}
}}
""".trimIndent())

Files.writeString(types.resolve("non_explosive.json"), """
{"attributes":{"minecraft:gameplay/bed_rule":{"explodes":false}}}
""".trimIndent())
assertEquals(3, DimensionDatapack.normalizeStagedDimensions(directory))
val metadata = JsonParser.parseString(Files.readString(pack.resolve("pack.mcmeta"))).asJsonObject
assertTrue(metadata["custom"].asJsonObject["keep"].asBoolean)
assertEquals(121, metadata["pack"].asJsonObject["pack_format"].asInt)
assertEquals("[121,0]", metadata["pack"].asJsonObject["min_format"].toString())
assertEquals("[121,0]", metadata["pack"].asJsonObject["max_format"].toString())
assertEquals("Custom description", metadata["pack"].asJsonObject["description"].asString)
val legacy = JsonParser.parseString(Files.readString(types.resolve("legacy.json")))
.asJsonObject["attributes"].asJsonObject["minecraft:gameplay/bed_rule"].asJsonObject
assertTrue(legacy["destroy_on_use"].asBoolean)
assertEquals("never", legacy["can_sleep"].asString)
assertFalse(legacy.has("explodes"))
val explicit = JsonParser.parseString(Files.readString(types.resolve("explicit.json")))
.asJsonObject["attributes"].asJsonObject["minecraft:gameplay/bed_rule"].asJsonObject
assertFalse(explicit["destroy_on_use"].asBoolean)
assertFalse(explicit.has("explodes"))
val nonExplosive = JsonParser.parseString(Files.readString(types.resolve("non_explosive.json")))
.asJsonObject["attributes"].asJsonObject["minecraft:gameplay/bed_rule"].asJsonObject
assertFalse(nonExplosive["destroy_on_use"].asBoolean)
assertFalse(nonExplosive.has("explodes"))
val migratedMetadata = Files.readString(pack.resolve("pack.mcmeta"))
assertEquals(0, DimensionDatapack.normalizeStagedDimensions(directory))
assertEquals(migratedMetadata, Files.readString(pack.resolve("pack.mcmeta")))
} finally {
directory.toFile().deleteRecursively()
}
}

@Test
fun `staging a dimension refreshes existing pack metadata`() {
val directory = Files.createTempDirectory("worldengine-staging-migration-test")
try {
val pack = directory.resolve("dimension-datapack")
Files.createDirectories(pack)
Files.writeString(pack.resolve("pack.mcmeta"), """
{"pack":{"description":{"text":"Custom","color":"gold"},"pack_format":107,
"min_format":[107,1],"max_format":[107,1]},"custom":{"keep":true}}
""".trimIndent())
DimensionDatapack.stage(directory, "new_void", JsonObject().apply {
addProperty("min_y", 0)
addProperty("height", 256)
})
val metadata = JsonParser.parseString(Files.readString(pack.resolve("pack.mcmeta"))).asJsonObject
assertEquals("Custom", metadata["pack"].asJsonObject["description"].asJsonObject["text"].asString)
assertEquals("gold", metadata["pack"].asJsonObject["description"].asJsonObject["color"].asString)
assertTrue(metadata["custom"].asJsonObject["keep"].asBoolean)
assertEquals(121, metadata["pack"].asJsonObject["pack_format"].asInt)
assertEquals("[121,0]", metadata["pack"].asJsonObject["min_format"].toString())
assertEquals("[121,0]", metadata["pack"].asJsonObject["max_format"].toString())
} finally {
directory.toFile().deleteRecursively()
}
}

@Test
fun `metadata-only packs migrate before dimension discovery`() {
val directory = Files.createTempDirectory("worldengine-metadata-migration-test")
try {
val pack = directory.resolve("dimension-datapack")
Files.createDirectories(pack)
Files.writeString(pack.resolve("pack.mcmeta"), """
{"pack":{"description":"WorldEngine dimensions","pack_format":107,
"min_format":[107,1],"max_format":[107,1]}}
""".trimIndent())
assertEquals(0, DimensionDatapack.normalizeStagedDimensions(directory))
val metadata = JsonParser.parseString(Files.readString(pack.resolve("pack.mcmeta"))).asJsonObject["pack"].asJsonObject
assertEquals(121, metadata["pack_format"].asInt)
assertEquals("[121,0]", metadata["min_format"].toString())
assertEquals("[121,0]", metadata["max_format"].toString())
} finally {
directory.toFile().deleteRecursively()
}
}
}
Loading