diff --git a/crates/chunk-cli/src/creation.rs b/crates/chunk-cli/src/creation.rs index f32dafb9..e039782c 100644 --- a/crates/chunk-cli/src/creation.rs +++ b/crates/chunk-cli/src/creation.rs @@ -63,17 +63,17 @@ fn create(options: &Options) -> io::Result<()> { .file_name() .and_then(|name| name.to_str()) .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "project directory must have a UTF-8 name"))?; - let (plugin, settings, app_build, gameplay, gameplay_path) = match options.language { + let (settings, root_build, app_build, gameplay, gameplay_path) = match options.language { Language::Java => ( - "com.chunkzero.chunk", include_str!("../templates/java/settings.gradle.kts"), + include_str!("../templates/java/build.gradle.kts"), include_str!("../templates/java/app.gradle.kts"), include_str!("../templates/java/Lobby.java"), "apps/lobby/src/main/java/example/Lobby.java", ), Language::Kotlin => ( - "com.chunkzero.chunk.kotlin", include_str!("../templates/kotlin/settings.gradle.kts"), + include_str!("../templates/kotlin/build.gradle.kts"), include_str!("../templates/kotlin/app.gradle.kts"), include_str!("../templates/kotlin/Lobby.kt"), "apps/lobby/src/main/kotlin/example/Lobby.kt", @@ -90,18 +90,16 @@ fn create(options: &Options) -> io::Result<()> { .replace("\"@KOTLIN_VERSION@\"", &kotlin(&toolchain.versions.kotlin)) .replace("\"@FOOJAY_VERSION@\"", &kotlin(&toolchain.versions.foojay)) .replace("\"@PROJECT_NAME@\"", &kotlin(name)); + let app_build = app_build.replace("@CHUNK_VERSION@", &toolchain.versions.chunk); let readme = include_str!("../templates/common/README.md").replace("@PROJECT_NAME@", name); - let root_build = format!( - "plugins {{\n id(\"{plugin}\")\n}}\n\njava {{ toolchain.languageVersion = JavaLanguageVersion.of(25) }}\n" - ); let staging = tempfile::tempdir()?; for (name, content) in COMMON.iter().copied().chain([ ("settings.gradle.kts", settings.as_str()), ("README.md", &readme), - ("build.gradle.kts", &root_build), + ("build.gradle.kts", root_build), ("gradle.properties", &properties), - ("apps/lobby/build.gradle.kts", app_build), + ("apps/lobby/build.gradle.kts", &app_build), (gameplay_path, gameplay), ]) { let target = staging.path().join(name); diff --git a/crates/chunk-cli/src/creation/tests.rs b/crates/chunk-cli/src/creation/tests.rs index 198f7b02..434a8aab 100644 --- a/crates/chunk-cli/src/creation/tests.rs +++ b/crates/chunk-cli/src/creation/tests.rs @@ -44,6 +44,14 @@ fn cli_creates_both_languages_without_adjacent_sdk_files() { matches!(language, Language::Kotlin), ); assert!(settings.contains(&format!("rootProject.name = {name:?}"))); + let engine = match language { + Language::Java => "multistom", + Language::Kotlin => "multistom-kotlin", + }; + let app_build = fs::read_to_string(options.directory.join("apps/lobby/build.gradle.kts")).unwrap(); + assert!(app_build.contains(&format!("\"com.chunkzero.chunk:{engine}:{}\"", crate::RELEASE_VERSION))); + let root_build = fs::read_to_string(options.directory.join("build.gradle.kts")).unwrap(); + assert_eq!(root_build.contains("org.jetbrains.kotlin.jvm"), matches!(language, Language::Kotlin)); let properties = fs::read_to_string(options.directory.join("gradle.properties")).unwrap(); assert!(!properties.contains("chunk.source=")); assert!(!properties.contains("sdk/maven")); diff --git a/crates/chunk-cli/templates/common/README.md b/crates/chunk-cli/templates/common/README.md index 43f16647..fa6cb1ab 100644 --- a/crates/chunk-cli/templates/common/README.md +++ b/crates/chunk-cli/templates/common/README.md @@ -57,8 +57,8 @@ downloads it if needed. Node is not needed: the CLI includes its TypeScript comp `gradle/wrapper/` in version control. Gradle downloads the chunk libraries and plugins from `maven.chunkzero.com`, at the versions pinned in -`settings.gradle.kts` when the CLI created the project. A newer CLI does not change those pins, so keep the CLI and the -pins on the same version. +`settings.gradle.kts` and `apps/lobby/build.gradle.kts` when the CLI created the project. A newer CLI does not change +those pins, so keep the CLI and the pins on the same version. `./gradlew` and IDE builds run the `chunk` on your `PATH`; pass `-Pchunk.executable=...` to use another. `chunk build` and `chunk dev` always use the CLI you run them with. To build against unpublished chunk libraries, set the diff --git a/crates/chunk-cli/templates/java/app.gradle.kts b/crates/chunk-cli/templates/java/app.gradle.kts index 0cbef585..1bde0c24 100644 --- a/crates/chunk-cli/templates/java/app.gradle.kts +++ b/crates/chunk-cli/templates/java/app.gradle.kts @@ -5,4 +5,6 @@ plugins { java { toolchain.languageVersion = JavaLanguageVersion.of(25) } tasks.withType().configureEach { options.compilerArgs.addAll(listOf("-Xlint:all", "-Werror")) } +dependencies { implementation("com.chunkzero.chunk:multistom:@CHUNK_VERSION@") } + application { mainClass = "example.Lobby" } diff --git a/crates/chunk-cli/templates/java/build.gradle.kts b/crates/chunk-cli/templates/java/build.gradle.kts new file mode 100644 index 00000000..edd4fbcd --- /dev/null +++ b/crates/chunk-cli/templates/java/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("com.chunkzero.chunk") +} + +java { toolchain.languageVersion = JavaLanguageVersion.of(25) } diff --git a/crates/chunk-cli/templates/kotlin/app.gradle.kts b/crates/chunk-cli/templates/kotlin/app.gradle.kts index ebc9b72b..d4f5a62a 100644 --- a/crates/chunk-cli/templates/kotlin/app.gradle.kts +++ b/crates/chunk-cli/templates/kotlin/app.gradle.kts @@ -1,8 +1,13 @@ plugins { - id("com.chunkzero.chunk.kotlin") + id("org.jetbrains.kotlin.jvm") + id("com.chunkzero.chunk") } -java { toolchain.languageVersion = JavaLanguageVersion.of(25) } -kotlin { compilerOptions { allWarningsAsErrors = true } } +kotlin { + jvmToolchain(25) + compilerOptions { allWarningsAsErrors = true } +} + +dependencies { implementation("com.chunkzero.chunk:multistom-kotlin:@CHUNK_VERSION@") } application { mainClass = "example.LobbyKt" } diff --git a/crates/chunk-cli/templates/kotlin/build.gradle.kts b/crates/chunk-cli/templates/kotlin/build.gradle.kts new file mode 100644 index 00000000..b260c725 --- /dev/null +++ b/crates/chunk-cli/templates/kotlin/build.gradle.kts @@ -0,0 +1,6 @@ +plugins { + id("org.jetbrains.kotlin.jvm") + id("com.chunkzero.chunk") +} + +kotlin { jvmToolchain(25) } diff --git a/docs/distribution.md b/docs/distribution.md index c181d6df..6c81083d 100644 --- a/docs/distribution.md +++ b/docs/distribution.md @@ -8,9 +8,9 @@ How the chunk SDK is packaged, installed and published. A release of the SDK has TypeScript SDK, the project templates, the Gradle wrapper and the version pins that `chunk create` writes. - **The JVM artifacts**, published to `https://maven.chunkzero.com` (nightlies to `https://maven.chunkzero.com/nightlies`) under `com.chunkzero.chunk`: `proto`, `backend-api`, `backend-client`, - `backend-client-kotlin`, `runtime`, `multistom`, `multistom-kotlin`, `minestom`, the Gradle plugin and its three - plugin markers. Each library has `-sources.jar` and `-javadoc.jar` (Dokka HTML) artifacts. Projects resolve these - through Gradle; the CLI archives contain no JARs. + `backend-client-kotlin`, `runtime`, `multistom`, `multistom-kotlin`, `minestom`, `world-converter`, the Gradle plugin + and its two plugin markers. Each library has `-sources.jar` and `-javadoc.jar` (Dokka HTML) artifacts. Projects + resolve these through Gradle; the CLI archives contain no JARs. Linux archives are built on Ubuntu 24.04 and need its glibc and OpenSSL 3; macOS and Windows archives link OpenSSL statically. Users need Java to run Gradle; the Minestom runtime needs Java 25, which the generated projects download diff --git a/examples/arena/apps/arena/build.gradle.kts b/examples/arena/apps/arena/build.gradle.kts index ce378da9..a4cf50ee 100644 --- a/examples/arena/apps/arena/build.gradle.kts +++ b/examples/arena/apps/arena/build.gradle.kts @@ -8,6 +8,7 @@ tasks.withType().configureEach { } dependencies { + implementation("com.chunkzero.chunk:multistom:${libs.versions.chunk.get()}") testImplementation(platform(libs.junit.bom)) testImplementation(libs.junit.jupiter) testRuntimeOnly(libs.junit.platform.launcher) diff --git a/examples/arena/apps/lobby/build.gradle.kts b/examples/arena/apps/lobby/build.gradle.kts index cbfdecf6..90b14352 100644 --- a/examples/arena/apps/lobby/build.gradle.kts +++ b/examples/arena/apps/lobby/build.gradle.kts @@ -7,4 +7,6 @@ tasks.withType().configureEach { options.compilerArgs.addAll(listOf("-Xlint:all", "-Werror")) } +dependencies { implementation("com.chunkzero.chunk:multistom:${libs.versions.chunk.get()}") } + application { mainClass = "example.lobby.Lobby" } diff --git a/examples/load/apps/lobby/build.gradle.kts b/examples/load/apps/lobby/build.gradle.kts index 62412a07..06369118 100644 --- a/examples/load/apps/lobby/build.gradle.kts +++ b/examples/load/apps/lobby/build.gradle.kts @@ -1,8 +1,13 @@ plugins { - id("com.chunkzero.chunk.kotlin") + id("org.jetbrains.kotlin.jvm") + id("com.chunkzero.chunk") } -java { toolchain.languageVersion = JavaLanguageVersion.of(25) } -kotlin { compilerOptions { allWarningsAsErrors = true } } +kotlin { + jvmToolchain(25) + compilerOptions { allWarningsAsErrors = true } +} + +dependencies { implementation("com.chunkzero.chunk:multistom-kotlin:${libs.versions.chunk.get()}") } application { mainClass = "com.chunkzero.chunk.example.load.LobbyKt" } diff --git a/examples/load/build.gradle.kts b/examples/load/build.gradle.kts index 8dac283d..b260c725 100644 --- a/examples/load/build.gradle.kts +++ b/examples/load/build.gradle.kts @@ -1,5 +1,6 @@ plugins { - id("com.chunkzero.chunk.kotlin") + id("org.jetbrains.kotlin.jvm") + id("com.chunkzero.chunk") } -java { toolchain.languageVersion = JavaLanguageVersion.of(25) } +kotlin { jvmToolchain(25) } diff --git a/examples/load/settings.gradle.kts b/examples/load/settings.gradle.kts index 17c5e7ad..19e1a102 100644 --- a/examples/load/settings.gradle.kts +++ b/examples/load/settings.gradle.kts @@ -26,6 +26,7 @@ dependencyResolutionManagement { } mavenCentral() } + versionCatalogs { create("libs") { from(files("../../gradle/libs.versions.toml")) } } } includeBuild("../..") { name = "chunk-platform" } diff --git a/examples/local/apps/games/arena/build.gradle.kts b/examples/local/apps/games/arena/build.gradle.kts index d7ff0301..cdcaaa43 100644 --- a/examples/local/apps/games/arena/build.gradle.kts +++ b/examples/local/apps/games/arena/build.gradle.kts @@ -1,10 +1,16 @@ plugins { - id("com.chunkzero.chunk.kotlin") + id("org.jetbrains.kotlin.jvm") + id("com.chunkzero.chunk") } -java { toolchain.languageVersion = JavaLanguageVersion.of(25) } -kotlin { compilerOptions { allWarningsAsErrors = true } } +kotlin { + jvmToolchain(25) + compilerOptions { allWarningsAsErrors = true } +} -dependencies { implementation(project(":shared")) } +dependencies { + implementation(project(":shared")) + implementation("com.chunkzero.chunk:multistom-kotlin:${libs.versions.chunk.get()}") +} application { mainClass = "com.chunkzero.chunk.example.arena.ArenaSessionsKt" } diff --git a/examples/local/apps/lobby/build.gradle.kts b/examples/local/apps/lobby/build.gradle.kts index a37bea2c..9d6403da 100644 --- a/examples/local/apps/lobby/build.gradle.kts +++ b/examples/local/apps/lobby/build.gradle.kts @@ -1,10 +1,16 @@ plugins { - id("com.chunkzero.chunk.kotlin") + id("org.jetbrains.kotlin.jvm") + id("com.chunkzero.chunk") } -java { toolchain.languageVersion = JavaLanguageVersion.of(25) } -kotlin { compilerOptions { allWarningsAsErrors = true } } +kotlin { + jvmToolchain(25) + compilerOptions { allWarningsAsErrors = true } +} -dependencies { implementation(project(":shared")) } +dependencies { + implementation(project(":shared")) + implementation("com.chunkzero.chunk:multistom-kotlin:${libs.versions.chunk.get()}") +} application { mainClass = "com.chunkzero.chunk.example.lobby.LobbySessionsKt" } diff --git a/examples/local/build.gradle.kts b/examples/local/build.gradle.kts index 8dac283d..b260c725 100644 --- a/examples/local/build.gradle.kts +++ b/examples/local/build.gradle.kts @@ -1,5 +1,6 @@ plugins { - id("com.chunkzero.chunk.kotlin") + id("org.jetbrains.kotlin.jvm") + id("com.chunkzero.chunk") } -java { toolchain.languageVersion = JavaLanguageVersion.of(25) } +kotlin { jvmToolchain(25) } diff --git a/examples/local/shared/build.gradle.kts b/examples/local/shared/build.gradle.kts index 0bcca9c1..c27f4edc 100644 --- a/examples/local/shared/build.gradle.kts +++ b/examples/local/shared/build.gradle.kts @@ -1,11 +1,15 @@ plugins { - id("com.chunkzero.chunk.kotlin") + id("org.jetbrains.kotlin.jvm") + id("com.chunkzero.chunk") } -java { toolchain.languageVersion = JavaLanguageVersion.of(25) } -kotlin { compilerOptions { allWarningsAsErrors = true } } +kotlin { + jvmToolchain(25) + compilerOptions { allWarningsAsErrors = true } +} dependencies { + api("com.chunkzero.chunk:multistom-kotlin:${libs.versions.chunk.get()}") testImplementation("com.chunkzero.chunk:proto:${libs.versions.chunk.get()}") testImplementation(platform(libs.junit.bom)) testImplementation(libs.junit.jupiter) diff --git a/jvm/backend-client/README.md b/jvm/backend-client/README.md index da604056..01d4cdb5 100644 --- a/jvm/backend-client/README.md +++ b/jvm/backend-client/README.md @@ -68,9 +68,9 @@ wait. ## Kotlin `jvm/backend-client-kotlin` wraps a `BackendSession` as `CoroutineBackend`, with suspending calls, actions included, and -`Flow>` watches, bound to an owned `CoroutineScope` that closes its calls and watches when it ends. With -the `com.chunkzero.chunk.kotlin` plugin, projects also get a generated `CoroutineBackendClient` with property-style -namespaces: +`Flow>` watches, bound to an owned `CoroutineScope` that closes its calls and watches when it ends. When +the root build applies Kotlin, Kotlin projects also get a generated `CoroutineBackendClient` with property-style +namespaces (see the [Gradle plugin](../gradle-plugin/README.md#kotlin)): ```kotlin val backend = CoroutineBackendClient(scope.coroutines.backend(requireNotNull(scope.backend), player)) diff --git a/jvm/gradle-plugin/README.md b/jvm/gradle-plugin/README.md index 4ff55cc7..7ae02cdc 100644 --- a/jvm/gradle-plugin/README.md +++ b/jvm/gradle-plugin/README.md @@ -5,11 +5,13 @@ backend bindings before JVM compilation, index each app's session types, session the finished app JARs for `chunk build` to package into a release. This is a separate Kotlin build, included by the repository's root build. -| Plugin | Apply in | Does | -| ------------------------------ | ------------------------------- | --------------------------------------------------------------- | -| `com.chunkzero.chunk.settings` | `settings.gradle.kts` | Runs `chunk inspect` and includes every app as a Gradle project | -| `com.chunkzero.chunk` | The root build and Java modules | Shared Java bindings, app packaging and indexing | -| `com.chunkzero.chunk.kotlin` | The root build and Kotlin apps | Everything above, plus Kotlin and the coroutine backend facade | +| Plugin | Apply in | Does | +| ------------------------------ | -------------------------------------- | --------------------------------------------------------------- | +| `com.chunkzero.chunk.settings` | `settings.gradle.kts` | Runs `chunk inspect` and includes every app as a Gradle project | +| `com.chunkzero.chunk` | The root build, apps and their modules | Shared bindings, app packaging and indexing | + +The plugins don't choose a game engine or configure Kotlin: each app adds its engine library and, for Kotlin, applies +and configures the Kotlin plugin itself. ## Setup @@ -43,8 +45,15 @@ dependencyResolutionManagement { rootProject.name = "my-server" ``` -The root `build.gradle.kts` and each app's `build.gradle.kts` apply a project plugin and choose a Java toolchain; apps -also name their main class: +The root `build.gradle.kts` and each app's `build.gradle.kts` apply `com.chunkzero.chunk` and choose a Java toolchain; +apps also add their engine and name their main class: + +```kotlin +// build.gradle.kts +plugins { id("com.chunkzero.chunk") } + +java { toolchain.languageVersion = JavaLanguageVersion.of(25) } +``` ```kotlin // apps/lobby/build.gradle.kts @@ -52,20 +61,56 @@ plugins { id("com.chunkzero.chunk") } java { toolchain.languageVersion = JavaLanguageVersion.of(25) } +dependencies { implementation("com.chunkzero.chunk:multistom:0.1.0") } + application { mainClass = "example.Lobby" } ``` -- Every module that applies a Chunk plugin must set its Java toolchain explicitly, to Java 25 or newer (the Minestom - runtime's requirement), and compile for that same version. `validateChunkJvm` fails the build otherwise; Chunk never - falls back to the Gradle daemon's JVM. -- The root applies the same plugin as the apps before they do. For Kotlin apps that is `com.chunkzero.chunk.kotlin`, and - the Kotlin Gradle plugin's version must be declared in the settings `plugins` block, as above, so that Chunk's - settings plugin can see it. Java apps in a Kotlin project keep `com.chunkzero.chunk`. -- A library module (such as a `:shared` project with common gameplay code) applies a Chunk plugin too if it declares +- Every module that applies the Chunk plugin must set its Java toolchain explicitly, to Java 25 or newer (the Minestom + runtime's requirement), and compile Java and Kotlin for that same version. `validateChunkJvm` fails the build + otherwise; Chunk never falls back to the Gradle daemon's JVM. +- The root applies the plugin before the apps do. +- A library module (such as a `:shared` project with common gameplay code) applies the plugin too if it declares `@Component` factories, so its factories are indexed. -The project plugins add the Chunk libraries at the plugin's own version: the root exports -`com.chunkzero.chunk:backend-client`, Java apps get `multistom` and Kotlin apps `multistom-kotlin`. +The root exports `com.chunkzero.chunk:backend-client` at the plugin's own version. The engine is the app's choice: +`com.chunkzero.chunk:multistom` (or `multistom-kotlin` for coroutine sessions) runs sessions on the multistom fork, +while [`com.chunkzero.chunk:minestom`](../minestom/README.md) with upstream `net.minestom:minestom` runs your own +sessions on upstream Minestom. + +### Kotlin + +When the root project applies `org.jetbrains.kotlin.jvm`, the plugin also generates the coroutine backend facade, +`CoroutineBackendClient`, and every module that applies Kotlin depends on it. Modules that don't apply Kotlin, such as +Java apps in the same build, get no Kotlin dependency: the root's own classes are Java and it doesn't export the Kotlin +standard library. Declare the Kotlin plugin's version either in the settings `plugins` block with `apply false` or in +the root build's `plugins` block: + +```kotlin +// build.gradle.kts +plugins { + id("org.jetbrains.kotlin.jvm") // version in settings.gradle.kts: id("org.jetbrains.kotlin.jvm") version "2.4.10" apply false + id("com.chunkzero.chunk") +} + +kotlin { jvmToolchain(25) } +``` + +```kotlin +// apps/lobby/build.gradle.kts +plugins { + id("org.jetbrains.kotlin.jvm") + id("com.chunkzero.chunk") +} + +kotlin { jvmToolchain(25) } + +dependencies { implementation("com.chunkzero.chunk:multistom-kotlin:0.1.0") } + +application { mainClass = "example.LobbyKt" } +``` + +A Kotlin app in a build whose root doesn't apply Kotlin uses the Java `BackendClient`. ### Settings @@ -89,22 +134,23 @@ run as. The plugin runs that CLI; it never builds Rust tools or installs Node pa 1. Settings evaluation runs `chunk inspect` and includes each app directory as a Gradle project: `apps/games/arena` becomes `:apps:games:arena`. Adding an `app.ts` and a `build.gradle.kts` under `apps/` adds an app on the next build, including with the configuration cache. It also includes a reserved `:chunk:backend-kotlin` project under - `.chunk/gradle/`. + `.chunk/gradle/`, which stays empty unless the root applies Kotlin. 2. `generateChunkBackend`, on the root, runs `chunk gen` before any JVM compilation. The compiled backend goes to `.chunk/build/backend` and JVM sources to `.chunk/generated/jvm`: `java/` (models, references, `SessionMethods`, `SessionConfigs`, `Destinations`, `Vars`), `java-client/` (`BackendClient`), `java-session//` (configured provider interfaces) and, for Kotlin, `kotlin/` (`CoroutineBackendClient`). The task always runs, since the compiler resolves TypeScript dependencies itself; unchanged output keeps JVM compilation incremental. -3. The root compiles `java/` and `java-client/` once into the shared `chunk-backend` JAR. With the Kotlin plugin, +3. The root compiles `java/` and `java-client/` once into the shared `chunk-backend` JAR. When the root applies Kotlin, `:chunk:backend-kotlin` compiles the coroutine facade into `chunk-backend-kotlin`. Apps depend on these JARs, so a Java app has no Kotlin dependency. 4. Each app applies `application` and Shadow, compiles, and is indexed from its bytecode (below). Its `shadowJar` is an executable JAR with every dependency. 5. Each app's Anvil worlds (`format: "anvil"` in `chunk inspect`) are converted to `.chunk/build/worlds//.polar` by `convertChunkWorld_`, cropped to the world's `chunks` if given. The - converter runs in the app's Java toolchain with the Minestom and Polar of its runtime classpath, leaving out the - build's own projects so code changes don't reconvert. It rejects a save whose data version isn't the runtime's - Minecraft version. The conversion is cacheable and its output is the same bytes for the same save. + converter, `com.chunkzero.chunk:world-converter` at the plugin's version, runs in the app's Java toolchain on the + app's runtime classpath, leaving out the build's own projects so code changes don't reconvert. It uses the app's + Minestom, upstream or multistom, and the app's Polar if it has one. It rejects a save whose data version isn't that + Minestom's Minecraft version. The conversion is cacheable and its output is the same bytes for the same save. 6. `chunkArtifacts`, on the root, builds every app and writes `.chunk/build/jvm/artifacts.json`: each app's JAR, session types and Java version, and the Java executable of the newest toolchain. `chunk build` runs this task and packages the result into a release. `chunk dev` passes `-Pchunk.dev=true`, which skips the shadow JAR and lists each app's diff --git a/jvm/gradle-plugin/build.gradle.kts b/jvm/gradle-plugin/build.gradle.kts index fa03e3b7..298bb597 100644 --- a/jvm/gradle-plugin/build.gradle.kts +++ b/jvm/gradle-plugin/build.gradle.kts @@ -23,7 +23,6 @@ dependencies { implementation(libs.gson) implementation(libs.shadow.gradle.plugin) implementation(libs.asm) - compileOnly(libs.kotlin.gradle.plugin) testImplementation(gradleTestKit()) testImplementation(platform(libs.junit.bom)) testImplementation(libs.junit.jupiter) @@ -40,10 +39,6 @@ gradlePlugin { id = "com.chunkzero.chunk" implementationClass = "com.chunkzero.chunk.gradle.ChunkPlugin" } - create("chunkKotlin") { - id = "com.chunkzero.chunk.kotlin" - implementationClass = "com.chunkzero.chunk.gradle.ChunkKotlinPlugin" - } } } diff --git a/jvm/gradle-plugin/src/main/kotlin/com/chunkzero/chunk/gradle/ChunkKotlinPlugin.kt b/jvm/gradle-plugin/src/main/kotlin/com/chunkzero/chunk/gradle/ChunkKotlinPlugin.kt deleted file mode 100644 index 64a80f61..00000000 --- a/jvm/gradle-plugin/src/main/kotlin/com/chunkzero/chunk/gradle/ChunkKotlinPlugin.kt +++ /dev/null @@ -1,83 +0,0 @@ -package com.chunkzero.chunk.gradle - -import org.gradle.api.Plugin -import org.gradle.api.Project -import org.gradle.api.plugins.JavaLibraryPlugin -import org.gradle.api.plugins.JavaPluginExtension -import org.gradle.jvm.tasks.Jar -import org.jetbrains.kotlin.gradle.dsl.JvmTarget -import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension -import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile - -class ChunkKotlinPlugin : Plugin { - override fun apply(project: Project) { - project.pluginManager.apply(ChunkPlugin::class.java) - if (project == project.rootProject) { - project.tasks.named("generateChunkBackend", GenerateChunkBackend::class.java) { target.set("kotlin") } - project.project(KOTLIN_BINDINGS).pluginManager.apply(KotlinBindingsPlugin::class.java) - } else { - check(project.rootProject.plugins.hasPlugin(ChunkKotlinPlugin::class.java)) { - "Apply com.chunkzero.chunk.kotlin to the root project to enable the shared Kotlin facade" - } - applyKotlin(project) - project.dependencies.add("implementation", project.dependencies.project(mapOf("path" to KOTLIN_BINDINGS))) - project.dependencies.add("implementation", framework("multistom-kotlin")) - } - } -} - -internal class KotlinBindingsPlugin : Plugin { - override fun apply(project: Project) { - project.pluginManager.apply(JavaLibraryPlugin::class.java) - val rootJava = project.rootProject.extensions.getByType(JavaPluginExtension::class.java) - project.extensions - .getByType( - JavaPluginExtension::class.java, - ).toolchain.languageVersion - .set(rootJava.toolchain.languageVersion) - configureJava(project) - applyKotlin(project) - val generate = project.rootProject.tasks.named("generateChunkBackend", GenerateChunkBackend::class.java) - project.extensions.getByType(KotlinJvmProjectExtension::class.java).sourceSets.named("main") { - kotlin.srcDir(generate.flatMap { it.generatedDirectory.dir("kotlin") }) - } - project.tasks.named("compileKotlin") { dependsOn(generate) } - project.tasks.named("jar", Jar::class.java) { archiveBaseName.set("chunk-backend-kotlin") } - project.dependencies.add("api", project.dependencies.project(mapOf("path" to ":"))) - project.dependencies.add("api", framework("backend-client-kotlin")) - configureModule(project, "") - } -} - -private fun applyKotlin(project: Project) { - val compilationType = - try { - KotlinJvmCompile::class.java - } catch (error: NoClassDefFoundError) { - throw IllegalStateException( - "Declare the Kotlin JVM plugin version with apply false in the settings plugins block", - error, - ) - } - try { - project.pluginManager.apply("org.jetbrains.kotlin.jvm") - } catch (error: org.gradle.api.plugins.UnknownPluginException) { - throw IllegalStateException( - "Declare the Kotlin JVM plugin version with apply false in the settings plugins block", - error, - ) - } - val java = project.extensions.getByType(JavaPluginExtension::class.java) - val validation = project.tasks.named("validateChunkJvm", ValidateChunkJvm::class.java) - validation.configure { - project.tasks.withType(compilationType).forEach { compile -> - targets.put(compile.name, compile.compilerOptions.jvmTarget.map { it.target.toInt() }) - } - } - project.tasks.withType(compilationType).configureEach { - compilerOptions.jvmTarget.convention( - java.toolchain.languageVersion.map { JvmTarget.fromTarget(it.asInt().toString()) }, - ) - dependsOn(validation) - } -} diff --git a/jvm/gradle-plugin/src/main/kotlin/com/chunkzero/chunk/gradle/ChunkPlugin.kt b/jvm/gradle-plugin/src/main/kotlin/com/chunkzero/chunk/gradle/ChunkPlugin.kt index d19968e0..08a9033c 100644 --- a/jvm/gradle-plugin/src/main/kotlin/com/chunkzero/chunk/gradle/ChunkPlugin.kt +++ b/jvm/gradle-plugin/src/main/kotlin/com/chunkzero/chunk/gradle/ChunkPlugin.kt @@ -2,9 +2,13 @@ package com.chunkzero.chunk.gradle import org.gradle.api.Plugin import org.gradle.api.Project +import org.gradle.api.Task +import org.gradle.api.file.SourceDirectorySet import org.gradle.api.plugins.JavaLibraryPlugin import org.gradle.api.plugins.JavaPluginExtension +import org.gradle.api.provider.Provider import org.gradle.api.tasks.SourceSetContainer +import org.gradle.api.tasks.TaskProvider import org.gradle.api.tasks.compile.JavaCompile import org.gradle.jvm.tasks.Jar @@ -20,7 +24,14 @@ class ChunkPlugin : Plugin { "Apply com.chunkzero.chunk to the root project before its consumers" } project.dependencies.add("implementation", project.dependencies.project(mapOf("path" to ":"))) - project.dependencies.add("implementation", framework("multistom")) + project.rootProject.pluginManager.withPlugin(KOTLIN_PLUGIN) { + project.pluginManager.withPlugin(KOTLIN_PLUGIN) { + project.dependencies.add( + "implementation", + project.dependencies.project(mapOf("path" to KOTLIN_BINDINGS)), + ) + } + } } val app = configuration.apps.find { it.projectPath == project.path } configureModule(project, app?.id.orEmpty()) @@ -57,6 +68,42 @@ private fun configureRoot( appIds.set(configuration.apps.map { it.id }) outputFile.set(configuration.directory.resolve(".chunk/build/jvm/artifacts.json")) } + project.pluginManager.withPlugin(KOTLIN_PLUGIN) { + // The root's own sources are Java, so Java apps need no Kotlin standard library through it. + listOf("apiElements", "runtimeElements").forEach { + project.configurations.named(it) { + exclude(mapOf("group" to "org.jetbrains.kotlin", "module" to "kotlin-stdlib")) + } + } + configureKotlinBindings(project, generate) + } +} + +// A Kotlin root also builds the coroutine facade in a separate project, so Java apps stay free of Kotlin. +private fun configureKotlinBindings( + root: Project, + generate: TaskProvider, +) { + generate.configure { target.set("kotlin") } + val project = root.project(KOTLIN_BINDINGS) + project.pluginManager.apply(JavaLibraryPlugin::class.java) + val rootJava = root.extensions.getByType(JavaPluginExtension::class.java) + project.extensions + .getByType(JavaPluginExtension::class.java) + .toolchain.languageVersion + .set(rootJava.toolchain.languageVersion) + configureJava(project) + project.pluginManager.apply(KOTLIN_PLUGIN) + project.extensions.getByType(SourceSetContainer::class.java).named("main") { + (extensions.getByName("kotlin") as SourceDirectorySet).srcDir( + generate.flatMap { it.generatedDirectory.dir("kotlin") }, + ) + } + project.tasks.named("compileKotlin") { dependsOn(generate) } + project.tasks.named("jar", Jar::class.java) { archiveBaseName.set("chunk-backend-kotlin") } + project.dependencies.add("api", project.dependencies.project(mapOf("path" to ":"))) + project.dependencies.add("api", framework("backend-client-kotlin")) + configureModule(project, "") } internal fun configureJava(project: Project) { @@ -79,6 +126,39 @@ internal fun configureJava(project: Project) { isPreserveFileTimestamps = false isReproducibleFileOrder = true } + project.pluginManager.withPlugin(KOTLIN_PLUGIN) { + // Kotlin may be applied from a build classloader this plugin cannot see, so its types come from the plugin. + val loader = + project.plugins + .getPlugin(KOTLIN_PLUGIN) + .javaClass.classLoader + val compileType = + Class + .forName("org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile", false, loader) + .asSubclass(Task::class.java) + val compilerOptions = compileType.getMethod("getCompilerOptions") + val jvmTarget = + Class + .forName("org.jetbrains.kotlin.gradle.dsl.KotlinJvmCompilerOptions", false, loader) + .getMethod("getJvmTarget") + validation.configure { + project.tasks.withType(compileType).forEach { compile -> + val target = jvmTarget.invoke(compilerOptions.invoke(compile)) as Provider<*> + // JvmTarget constants are named JVM_1_8, JVM_25 and so on. + targets.put( + compile.name, + target.map { + (it as Enum<*>) + .name + .removePrefix("JVM_") + .removePrefix("1_") + .toInt() + }, + ) + } + } + project.tasks.withType(compileType).configureEach { dependsOn(validation) } + } } internal fun Project.configuration(): BuildConfiguration = @@ -86,6 +166,8 @@ internal fun Project.configuration(): BuildConfiguration = "Apply com.chunkzero.chunk.settings in settings.gradle.kts before the Chunk project plugin" } +internal const val KOTLIN_PLUGIN = "org.jetbrains.kotlin.jvm" + internal fun framework(module: String): String { val version = checkNotNull(ChunkPlugin::class.java.`package`.implementationVersion) { diff --git a/jvm/gradle-plugin/src/main/kotlin/com/chunkzero/chunk/gradle/ConvertAnvilWorld.kt b/jvm/gradle-plugin/src/main/kotlin/com/chunkzero/chunk/gradle/ConvertAnvilWorld.kt index 9e724332..cc76b67f 100644 --- a/jvm/gradle-plugin/src/main/kotlin/com/chunkzero/chunk/gradle/ConvertAnvilWorld.kt +++ b/jvm/gradle-plugin/src/main/kotlin/com/chunkzero/chunk/gradle/ConvertAnvilWorld.kt @@ -3,6 +3,10 @@ package com.chunkzero.chunk.gradle import org.gradle.api.DefaultTask import org.gradle.api.Project import org.gradle.api.artifacts.component.ProjectComponentIdentifier +import org.gradle.api.attributes.Bundling +import org.gradle.api.attributes.Category +import org.gradle.api.attributes.LibraryElements +import org.gradle.api.attributes.Usage import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.RegularFileProperty @@ -24,7 +28,7 @@ import org.gradle.process.ExecOperations import java.io.File import javax.inject.Inject -/** Converts an Anvil save to Polar with the converter in the app's runtime. */ +/** Converts an Anvil save to Polar with Chunk's world converter on the app's runtime classpath. */ @CacheableTask abstract class ConvertAnvilWorld : DefaultTask() { @get:InputDirectory @@ -55,7 +59,7 @@ abstract class ConvertAnvilWorld : DefaultTask() { .get() .executablePath.asFile.absolutePath classpath = this@ConvertAnvilWorld.classpath - mainClass.set("com.chunkzero.chunk.multistom.internal.AnvilConverter") + mainClass.set("com.chunkzero.chunk.worldconverter.AnvilConverter") jvmArgs("--enable-native-access=ALL-UNNAMED") args(source.get().asFile.absolutePath, output.get().asFile.absolutePath) args(chunks.get()) @@ -65,8 +69,10 @@ abstract class ConvertAnvilWorld : DefaultTask() { /** * Converts each of the app's Anvil worlds to `.chunk/build/worlds//.polar`, as part of - * `chunkArtifacts`. The converter runs on the app's resolved runtime classpath without the build's - * own projects, so it matches the app's Minestom and Polar and app code changes don't reconvert. + * `chunkArtifacts`. The converter, `com.chunkzero.chunk:world-converter` at the plugin's version, + * runs on the app's resolved runtime classpath without the build's own projects, so it uses the + * app's Minestom (upstream or multistom) and app code changes don't reconvert. Its Polar resolves + * to the app's version when the app has one. */ internal fun configureWorlds( project: Project, @@ -74,8 +80,22 @@ internal fun configureWorlds( app: AppMetadata, ) { if (app.anvilWorlds.isEmpty()) return + val runtimeClasspath = project.configurations.named("runtimeClasspath") + val converter = + project.configurations.detachedConfiguration(project.dependencies.create(framework("world-converter"))).apply { + shouldResolveConsistentlyWith(runtimeClasspath.get()) + attributes { + attribute(Usage.USAGE_ATTRIBUTE, project.objects.named(Usage::class.java, Usage.JAVA_RUNTIME)) + attribute(Category.CATEGORY_ATTRIBUTE, project.objects.named(Category::class.java, Category.LIBRARY)) + attribute( + LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, + project.objects.named(LibraryElements::class.java, LibraryElements.JAR), + ) + attribute(Bundling.BUNDLING_ATTRIBUTE, project.objects.named(Bundling::class.java, Bundling.EXTERNAL)) + } + } val runtime = - project.configurations.named("runtimeClasspath").map { configuration -> + runtimeClasspath.map { configuration -> configuration.incoming .artifactView { componentFilter { it !is ProjectComponentIdentifier || it.build.buildPath != ":" } @@ -90,7 +110,7 @@ internal fun configureWorlds( description = "Convert the Anvil world ${world.name} to Polar" source.set(directory.resolve(world.source)) chunks.set(world.chunks) - classpath.from(runtime) + classpath.from(converter, runtime) this.launcher.set(launcher) output.set(directory.resolve(".chunk/build/worlds/${app.id}/${world.name}.polar")) } diff --git a/jvm/gradle-plugin/src/test/kotlin/com/chunkzero/chunk/gradle/ChunkPluginTest.kt b/jvm/gradle-plugin/src/test/kotlin/com/chunkzero/chunk/gradle/ChunkPluginTest.kt index 5652356b..fd852fb1 100644 --- a/jvm/gradle-plugin/src/test/kotlin/com/chunkzero/chunk/gradle/ChunkPluginTest.kt +++ b/jvm/gradle-plugin/src/test/kotlin/com/chunkzero/chunk/gradle/ChunkPluginTest.kt @@ -109,21 +109,61 @@ class ChunkPluginTest { } @Test - fun `Kotlin opt in builds a facade separately from shared Java classes`() { + fun `Kotlin root builds a facade separately from shared Java classes`() { fixture(kotlin = true) app("lobby", kotlin = true) + app("arena") run("chunkArtifacts") assertSessionRegistries(descriptor()) - val executable = descriptor().getAsJsonArray("apps")[0].asJsonObject["jar"].asString - JarFile(executable).use { + val apps = descriptor().getAsJsonArray("apps").associate { it.asJsonObject["id"].asString to it.asJsonObject } + JarFile(apps.getValue("lobby")["jar"].asString).use { assertTrue(it.getEntry("fixture/generated/FacadeKt.class") != null) assertTrue(it.getEntry("fixture/generated/Bindings.class") != null) } + JarFile(apps.getValue("arena")["jar"].asString).use { + assertTrue(it.getEntry("fixture/generated/FacadeKt.class") == null) + assertTrue(it.getEntry("kotlin/Unit.class") == null) + } assertTrue(calls().contains("gen kotlin")) val reused = run("chunkArtifacts") assertTrue(reused.output.contains("Reusing configuration cache"), reused.output) } + @Test + fun `Kotlin applied with a version in the root build enables the facade and checks compiler targets`() { + fixture(kotlin = true, kotlinInSettings = false) + app("lobby", kotlin = true) + run("chunkArtifacts") + val executable = descriptor().getAsJsonArray("apps")[0].asJsonObject["jar"].asString + JarFile(executable).use { assertTrue(it.getEntry("fixture/generated/FacadeKt.class") != null) } + directory.resolve("apps/lobby/build.gradle.kts").toFile().appendText( + "\nkotlin { compilerOptions { jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21 } }\n", + ) + assertTrue( + runFailure(":apps:lobby:validateChunkJvm") + .output + .contains("compileKotlin targets Java 21 but the selected toolchain is 25"), + ) + } + + @Test + fun `Kotlin apps depend on the facade when the root applies Kotlin after they are evaluated`() { + fixture(kotlin = true) + app("lobby", kotlin = true) + write( + "build.gradle.kts", + """ + plugins { id("com.chunkzero.chunk") } + java { toolchain.languageVersion = JavaLanguageVersion.of(25) } + evaluationDependsOn(":apps:lobby") + apply(plugin = "org.jetbrains.kotlin.jvm") + """, + ) + run("chunkArtifacts") + val executable = descriptor().getAsJsonArray("apps")[0].asJsonObject["jar"].asString + JarFile(executable).use { assertTrue(it.getEntry("fixture/generated/FacadeKt.class") != null) } + } + @Test fun `requires explicit compatible toolchains and matching compiler targets`() { fixture() @@ -556,12 +596,27 @@ class ChunkPluginTest { assertEquals("echo:ok", output.trim()) } - private fun fixture(kotlin: Boolean = false) { + private fun fixture( + kotlin: Boolean = false, + kotlinInSettings: Boolean = true, + ) { val kotlinVersion = System.getProperty("chunk.kotlin.version") val pluginVersion = System.getProperty("chunk.plugin.version") val pluginRepository = System.getProperty("chunk.test.plugin.repository") - val kotlinPlugin = if (kotlin) "id(\"org.jetbrains.kotlin.jvm\") version \"$kotlinVersion\" apply false" else "" - val plugin = if (kotlin) "com.chunkzero.chunk.kotlin" else "com.chunkzero.chunk" + val kotlinSettings = + if (kotlin && + kotlinInSettings + ) { + "id(\"org.jetbrains.kotlin.jvm\") version \"$kotlinVersion\" apply false" + } else { + "" + } + val kotlinRoot = + when { + !kotlin -> "" + kotlinInSettings -> "id(\"org.jetbrains.kotlin.jvm\")" + else -> "id(\"org.jetbrains.kotlin.jvm\") version \"$kotlinVersion\"" + } write( "settings.gradle.kts", """ @@ -572,7 +627,7 @@ class ChunkPluginTest { mavenCentral() } } plugins { - $kotlinPlugin + $kotlinSettings id("com.chunkzero.chunk.settings") version "$pluginVersion" } extensions.configure { @@ -587,7 +642,8 @@ class ChunkPluginTest { "build.gradle.kts", """ plugins { - id("$plugin") + $kotlinRoot + id("com.chunkzero.chunk") } java { toolchain.languageVersion = JavaLanguageVersion.of(25) } """, @@ -603,12 +659,7 @@ class ChunkPluginTest { """, ) write("chunk.toml", "") - listOf( - "backend-client", - "multistom", - "backend-client-kotlin", - "multistom-kotlin", - ).forEach(::module) + listOf("backend-client", "backend-client-kotlin").forEach(::module) write( "chunk-fixture", """ @@ -664,11 +715,14 @@ class ChunkPluginTest { toolchain: String = "java { toolchain.languageVersion = JavaLanguageVersion.of(25) }", ) { write("apps/$id/app.toml", "") - val plugin = if (kotlin) "com.chunkzero.chunk.kotlin" else "com.chunkzero.chunk" + val kotlinPlugin = if (kotlin) "id(\"org.jetbrains.kotlin.jvm\")" else "" write( "apps/$id/build.gradle.kts", """ - plugins { id("$plugin") } + plugins { + $kotlinPlugin + id("com.chunkzero.chunk") + } $toolchain application { mainClass = "fixture.$id.App${if (kotlin) "Kt" else ""}" } """, diff --git a/jvm/world-converter/build.gradle.kts b/jvm/world-converter/build.gradle.kts new file mode 100644 index 00000000..52cab350 --- /dev/null +++ b/jvm/world-converter/build.gradle.kts @@ -0,0 +1,41 @@ +plugins { + id("chunk.java-conventions") + id("chunk.publishing-conventions") +} +java { toolchain.languageVersion = JavaLanguageVersion.of(25) } +tasks.withType().configureEach { options.release = 25 } + +// The Gradle plugin runs the converter on an app's runtime classpath, which supplies the app's +// Minestom: upstream or multistom. It compiles against upstream and is tested on both. +dependencies { + compileOnly(libs.minestom) + compileOnly(libs.jetbrains.annotations) + implementation(libs.polar) + testImplementation(libs.minestom) +} + +val multistomTestRuntime = + configurations.resolvable("multistomTestRuntimeClasspath") { + extendsFrom(configurations.testImplementation.get(), configurations.testRuntimeOnly.get()) + exclude(group = "net.minestom", module = "minestom") + attributes { + attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage.JAVA_RUNTIME)) + attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category.LIBRARY)) + attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named(LibraryElements.JAR)) + attribute(Bundling.BUNDLING_ATTRIBUTE, objects.named(Bundling.EXTERNAL)) + } + } +dependencies { "multistomTestRuntimeClasspath"(libs.multistom) } + +val multistomTest = + tasks.register("multistomTest") { + description = "Runs the tests against multistom." + group = "verification" + testClassesDirs = + sourceSets.test + .get() + .output.classesDirs + classpath = sourceSets.test.get().output + sourceSets.main.get().output + multistomTestRuntime.get() + } +tasks.check { dependsOn(multistomTest) } +tasks.withType().configureEach { jvmArgs("--enable-native-access=ALL-UNNAMED") } diff --git a/jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/internal/AnvilConverter.java b/jvm/world-converter/src/main/java/com/chunkzero/chunk/worldconverter/AnvilConverter.java similarity index 91% rename from jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/internal/AnvilConverter.java rename to jvm/world-converter/src/main/java/com/chunkzero/chunk/worldconverter/AnvilConverter.java index 0745409b..485eec2c 100644 --- a/jvm/multistom/src/main/java/com/chunkzero/chunk/multistom/internal/AnvilConverter.java +++ b/jvm/world-converter/src/main/java/com/chunkzero/chunk/worldconverter/AnvilConverter.java @@ -1,4 +1,4 @@ -package com.chunkzero.chunk.multistom.internal; +package com.chunkzero.chunk.worldconverter; import net.hollowcube.polar.PolarChunk; import net.hollowcube.polar.PolarDataConverter; @@ -6,10 +6,7 @@ import net.hollowcube.polar.PolarWorld; import net.hollowcube.polar.PolarWriter; import net.kyori.adventure.nbt.BinaryTagIO; -import net.minestom.server.MinecraftConstants; -import net.minestom.server.ServerProcess; import net.minestom.server.instance.Chunk; -import net.minestom.server.instance.InstanceContainer; import net.minestom.server.instance.anvil.AnvilLoader; import net.minestom.server.instance.block.Block; import net.minestom.server.instance.light.LightCompute; @@ -29,14 +26,13 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.UUID; import java.util.function.IntFunction; import java.util.regex.Matcher; import java.util.regex.Pattern; /** - * Converts an Anvil save's overworld to a Polar world for the Gradle plugin, with this runtime's - * Minestom and Polar. The same save converts to the same bytes. + * Converts an Anvil save's overworld to a Polar world for the Gradle plugin, with the Minestom + * (upstream or multistom) on the classpath. The same save converts to the same bytes. */ @ApiStatus.Internal public final class AnvilConverter { @@ -49,7 +45,7 @@ public final class AnvilConverter { new PolarDataConverter() { @Override public int dataVersion() { - return MinecraftConstants.DATA_VERSION; + return Engine.DATA_VERSION; } }; @@ -94,14 +90,12 @@ static byte[] convert(Path save, int @Nullable [] range) throws IOException { : save + " has no dimensions/minecraft/overworld/region directory"); } var chunks = new ArrayList(); - try (var process = ServerProcess.create()) { - process.exceptionManager() - .setExceptionHandler( - error -> { - throw new IllegalArgumentException(error.getMessage(), error); - }); - var instance = - new InstanceContainer(process, UUID.randomUUID(), DimensionType.OVERWORLD); + try (var engine = + new Engine( + error -> { + throw new IllegalArgumentException(error.getMessage(), error); + })) { + var instance = engine.overworld(); var loader = new AnvilLoader(save, DimensionType.OVERWORLD.key()); for (var region : regions(regions)) { for (int z = 0; z < 32; z++) { @@ -126,7 +120,7 @@ static byte[] convert(Path save, int @Nullable [] range) throws IOException { "Chunk %d, %d of %s".formatted(chunkX, chunkZ, save)); chunk.lockReadLock(); try { - chunks.add(polar(process.registries().biome(), chunk)); + chunks.add(polar(engine.process().registries().biome(), chunk)); } finally { chunk.unlockReadLock(); } @@ -144,7 +138,7 @@ static byte[] convert(Path save, int @Nullable [] range) throws IOException { var world = new PolarWorld( PolarWorld.LATEST_VERSION, - MinecraftConstants.DATA_VERSION, + Engine.DATA_VERSION, PolarWorld.CompressionType.ZSTD, (byte) MIN_SECTION, (byte) MAX_SECTION, @@ -165,20 +159,20 @@ private static void checkVersion(Path level, Path save) throws IOException { } private static void check(int version, String subject) { - if (version != MinecraftConstants.DATA_VERSION) + if (version != Engine.DATA_VERSION) throw new IllegalArgumentException( "%s has data version %d, but the runtime targets Minecraft %s (data version %d). %s" .formatted( subject, version, - MinecraftConstants.VERSION_NAME, - MinecraftConstants.DATA_VERSION, + Engine.VERSION_NAME, + Engine.DATA_VERSION, upgrade())); } private static String upgrade() { return "Open it in Minecraft " - + MinecraftConstants.VERSION_NAME + + Engine.VERSION_NAME + ", save it and build again; Optimize World upgrades every chunk."; } diff --git a/jvm/world-converter/src/main/java/com/chunkzero/chunk/worldconverter/Engine.java b/jvm/world-converter/src/main/java/com/chunkzero/chunk/worldconverter/Engine.java new file mode 100644 index 00000000..e313cdfe --- /dev/null +++ b/jvm/world-converter/src/main/java/com/chunkzero/chunk/worldconverter/Engine.java @@ -0,0 +1,98 @@ +package com.chunkzero.chunk.worldconverter; + +import net.minestom.server.MinecraftServer; +import net.minestom.server.ServerProcess; +import net.minestom.server.exception.ExceptionHandler; +import net.minestom.server.exception.ExceptionManager; +import net.minestom.server.instance.InstanceContainer; +import net.minestom.server.registry.RegistryKey; +import net.minestom.server.world.DimensionType; + +import java.lang.reflect.InvocationTargetException; +import java.util.UUID; + +/** + * A server process of the Minestom on the classpath. Upstream Minestom has one global process, + * started through {@link MinecraftServer}; multistom creates processes with {@code + * ServerProcess.create()} and passes them to instances. This is compiled against upstream, so + * multistom's API is called reflectively. + */ +final class Engine implements AutoCloseable { + // Read at run time: javac inlines Minestom's constants, and only multistom's class is public. + static final int DATA_VERSION = (Integer) constant("DATA_VERSION"); + static final String VERSION_NAME = (String) constant("VERSION_NAME"); + + private final ServerProcess process; + private final boolean multistom; + + Engine(ExceptionHandler handler) { + multistom = hasMethod("create"); + process = + multistom + ? (ServerProcess) call(ServerProcess.class, "create", null) + : MinecraftServer.updateProcess(); + var exceptions = + multistom + ? (ExceptionManager) call(ServerProcess.class, "exceptionManager", process) + : process.exception(); + exceptions.setExceptionHandler(handler); + } + + ServerProcess process() { + return process; + } + + InstanceContainer overworld() { + if (!multistom) + return new InstanceContainer(process, UUID.randomUUID(), DimensionType.OVERWORLD); + try { + return InstanceContainer.class + .getConstructor(ServerProcess.class, UUID.class, RegistryKey.class) + .newInstance(process, UUID.randomUUID(), DimensionType.OVERWORLD); + } catch (InvocationTargetException error) { + throw rethrow(error); + } catch (ReflectiveOperationException error) { + throw new IllegalStateException("Unsupported Minestom version", error); + } + } + + @Override + public void close() { + process.stop(); + } + + private static Object constant(String name) { + try { + var field = Class.forName("net.minestom.server.MinecraftConstants").getField(name); + field.setAccessible(true); + return field.get(null); + } catch (ReflectiveOperationException error) { + throw new IllegalStateException("Unsupported Minestom version", error); + } + } + + private static boolean hasMethod(String name) { + try { + ServerProcess.class.getMethod(name); + return true; + } catch (NoSuchMethodException error) { + return false; + } + } + + private static Object call(Class type, String method, Object target) { + try { + return type.getMethod(method).invoke(target); + } catch (InvocationTargetException error) { + throw rethrow(error); + } catch (ReflectiveOperationException error) { + throw new IllegalStateException("Unsupported Minestom version", error); + } + } + + private static RuntimeException rethrow(InvocationTargetException error) { + if (error.getCause() instanceof RuntimeException cause) return cause; + if (error.getCause() instanceof Error cause) throw cause; + return new IllegalStateException(error.getCause()); + } +} diff --git a/jvm/multistom/src/test/java/com/chunkzero/chunk/multistom/internal/AnvilConverterTest.java b/jvm/world-converter/src/test/java/com/chunkzero/chunk/worldconverter/AnvilConverterTest.java similarity index 81% rename from jvm/multistom/src/test/java/com/chunkzero/chunk/multistom/internal/AnvilConverterTest.java rename to jvm/world-converter/src/test/java/com/chunkzero/chunk/worldconverter/AnvilConverterTest.java index 5f08aa3d..85fc929a 100644 --- a/jvm/multistom/src/test/java/com/chunkzero/chunk/multistom/internal/AnvilConverterTest.java +++ b/jvm/world-converter/src/test/java/com/chunkzero/chunk/worldconverter/AnvilConverterTest.java @@ -1,14 +1,11 @@ -package com.chunkzero.chunk.multistom.internal; +package com.chunkzero.chunk.worldconverter; import static org.junit.jupiter.api.Assertions.*; import net.hollowcube.polar.PolarReader; import net.kyori.adventure.nbt.BinaryTagIO; import net.kyori.adventure.nbt.CompoundBinaryTag; -import net.minestom.server.MinecraftConstants; -import net.minestom.server.ServerProcess; import net.minestom.server.instance.DynamicChunk; -import net.minestom.server.instance.InstanceContainer; import net.minestom.server.instance.anvil.AnvilLoader; import net.minestom.server.instance.block.Block; import net.minestom.server.world.DimensionType; @@ -21,7 +18,6 @@ import java.nio.file.Path; import java.util.List; import java.util.Map; -import java.util.UUID; class AnvilConverterTest { private static final Block STAIRS = Block.OAK_STAIRS.withProperty("facing", "east"); @@ -30,9 +26,12 @@ class AnvilConverterTest { @Test void aSaveConvertsToTheSameBytesEveryTimeWithinItsChunkRange() throws IOException { - try (var process = ServerProcess.create()) { - var instance = - new InstanceContainer(process, UUID.randomUUID(), DimensionType.OVERWORLD); + try (var engine = + new Engine( + error -> { + throw new AssertionError(error); + })) { + var instance = engine.overworld(); var loader = new AnvilLoader(save, DimensionType.OVERWORLD.key()); for (var position : new int[][] {{0, 0}, {1, 0}, {-1, -1}}) { var chunk = new DynamicChunk(instance, position[0], position[1]); @@ -48,7 +47,7 @@ void aSaveConvertsToTheSameBytesEveryTimeWithinItsChunkRange() throws IOExceptio assertArrayEquals(polar, AnvilConverter.convert(save, null)); var world = PolarReader.read(polar); assertEquals(3, world.chunks().size()); - assertEquals(MinecraftConstants.DATA_VERSION, world.dataVersion()); + assertEquals(Engine.DATA_VERSION, world.dataVersion()); var section = world.chunkAt(-1, -1).sections()[64 / 16 + 4]; assertTrue(List.of(section.blockPalette()).contains(STAIRS.state())); var cropped = PolarReader.read(AnvilConverter.convert(save, new int[] {0, 0, 1, 0})); @@ -70,8 +69,7 @@ void savesOfAnotherMinecraftVersionAreRejected() throws IOException { assertThrows( IllegalArgumentException.class, () -> AnvilConverter.convert(save, null)); assertTrue( - error.getMessage() - .contains("Open it in Minecraft " + MinecraftConstants.VERSION_NAME), + error.getMessage().contains("Open it in Minecraft " + Engine.VERSION_NAME), error.getMessage()); } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 8680b6a9..479c530e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -30,3 +30,4 @@ include(":jvm:runtime") include(":jvm:multistom") include(":jvm:multistom-kotlin") include(":jvm:minestom") +include(":jvm:world-converter")