From 88c9fe1227c8eff6f44b56e95e39929cd877621a Mon Sep 17 00:00:00 2001 From: THEROER Date: Thu, 9 Jul 2026 20:55:34 +0300 Subject: [PATCH 01/39] build: release 1.24.0 (build-logic 0.1.6) Bump the library version to 1.24.0 and the reusable build-logic plugins to 0.1.6. 1.24.0 adds mountSubCommands for flat sub-command grafting and the standalone bungee/velocity bundles; 0.1.6 carries the publish fixes (skip_shadow_publish ordering, java-component binding) and per-target fabric java that landed after the 0.1.5 publish. Published to Reposilite across all targets (+1.20.1, +1.21.10, +1.21.11, +26.1.1, +26.2). --- build-logic/gradle.properties | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/build-logic/gradle.properties b/build-logic/gradle.properties index 876bbf79..eb6b1f34 100644 --- a/build-logic/gradle.properties +++ b/build-logic/gradle.properties @@ -12,4 +12,4 @@ neoForgeModdevVersion=2.0.139 # Coordinates for publishing the build-logic plugins as a reusable tool. # Independent of the MagicUtils library version (root gradle.properties). pluginsGroup=dev.ua.theroer.magicutils.build -pluginsVersion=0.1.5 \ No newline at end of file +pluginsVersion=0.1.6 \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 0d8b9ffe..e0fcf6df 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,6 @@ org.gradle.caching=true org.gradle.parallel=true org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 -version=1.23.0 +version=1.24.0 # Minecraft version is now managed by gradle/targets.properties # You can set it via -Ptarget=... when running Gradle From ccc35ca48199dfd675fbbe8dfacd5000d34e2715 Mon Sep 17 00:00:00 2001 From: THEROER Date: Fri, 10 Jul 2026 04:12:54 +0300 Subject: [PATCH 02/39] =?UTF-8?q?build:=20release=201.25.0=20=E2=80=94=20p?= =?UTF-8?q?er-Java=20publish=20coordinate=20+=20build-logic=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch the published Maven coordinate from + to +java. MagicUtils' compiled bytecode depends only on the Java level, not the Minecraft version: the whole library is byte-identical between targets that share a Java level (measured: +1.21.10 vs +1.21.11 differed in 0 classes) and differs wholesale only across Java levels (bytecode major 65 vs 69). The old per-Minecraft scheme published five near-duplicate copies where three real variants exist. Now one representative target per Java level (mc1201/17, mc12110/21, mc2611/25) publishes +java17, +java21, +java25; the other targets remain for the build/smoke matrix. Consumers keep declaring the bare version and resolve +java from their target's Java level, so downstreams need no change. build-logic cleanup (0.1.6 -> 0.1.7): - Merge bukkit/bungee/velocity bundle plugins into one parameterised MagicUtilsJvmBundlePlugin (JvmBundlePlatform table). - Extract the duplicated publication pom-strip into stripPomDependencies and the Bukkit/Velocity consumer embed logic into configureJvmConsumerEmbed. - processor is build-only (annotationProcessor, baked into consumers, never a runtime dependency) -> category NONE, no longer published. Published to Reposilite across java17/java21/java25; processor excluded. --- build-logic/gradle.properties | 2 +- .../MagicUtilsConsumerBukkitPlugin.kt | 53 +---- .../consumer/MagicUtilsConsumerExtension.kt | 48 +++++ .../MagicUtilsConsumerVelocityPlugin.kt | 43 +---- .../build/matrix/MagicUtilsMatrixModel.kt | 50 +++-- .../matrix/MagicUtilsMatrixRootPlugin.kt | 8 +- .../module/MagicUtilsBukkitBundlePlugin.kt | 121 ------------ .../module/MagicUtilsBungeeBundlePlugin.kt | 126 ------------ .../module/MagicUtilsFabricBundlePlugin.kt | 6 +- .../build/module/MagicUtilsJvmBundlePlugin.kt | 182 ++++++++++++++++++ .../module/MagicUtilsNeoForgeBundlePlugin.kt | 6 +- .../module/MagicUtilsVelocityBundlePlugin.kt | 129 ------------- .../build/release/MagicUtilsModrinthTasks.kt | 27 ++- .../support/MagicUtilsProjectExtensions.kt | 15 ++ .../target/MagicUtilsTargetConventions.kt | 28 ++- .../test/kotlin/MagicUtilsMatrixModelTest.kt | 43 +++-- build.gradle.kts | 17 +- gradle.properties | 2 +- processor/build.gradle.kts | 6 +- 19 files changed, 376 insertions(+), 536 deletions(-) delete mode 100644 build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsBukkitBundlePlugin.kt delete mode 100644 build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsBungeeBundlePlugin.kt create mode 100644 build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsJvmBundlePlugin.kt delete mode 100644 build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsVelocityBundlePlugin.kt diff --git a/build-logic/gradle.properties b/build-logic/gradle.properties index eb6b1f34..72f70472 100644 --- a/build-logic/gradle.properties +++ b/build-logic/gradle.properties @@ -12,4 +12,4 @@ neoForgeModdevVersion=2.0.139 # Coordinates for publishing the build-logic plugins as a reusable tool. # Independent of the MagicUtils library version (root gradle.properties). pluginsGroup=dev.ua.theroer.magicutils.build -pluginsVersion=0.1.6 \ No newline at end of file +pluginsVersion=0.1.7 \ No newline at end of file diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerBukkitPlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerBukkitPlugin.kt index fbdb37aa..841b8ca0 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerBukkitPlugin.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerBukkitPlugin.kt @@ -26,7 +26,6 @@ class MagicUtilsConsumerBukkitPlugin : Plugin { project.pluginManager.apply("magicutils.target") val target = project.extensions.getByType(MagicUtilsTargetExtension::class.java) - val consumer = project.magicUtilsConsumerExtension() project.extensions.configure(JavaPluginExtension::class.java) { java -> java.toolchain.languageVersion.set(JavaLanguageVersion.of(target.java.get())) @@ -43,53 +42,11 @@ class MagicUtilsConsumerBukkitPlugin : Plugin { project.exposeMagicUtilsTargetFacts(target) - // MagicUtils modules the consumer declared. Bukkit has no jar-in-jar, so - // embedMode maps onto the dependency configuration + the shadow jar: - // SHADED → api/implementation, so shadow relocates the modules into the - // fat jar (a self-contained plugin). This is the Bukkit default - // (AUTO resolves here), but see the SHADED note in EmbedMode: - // two shaded consumers on one server load two isolated copies — - // use EXTERNAL there. - // EXTERNAL → compileOnly, and dev/ua/theroer/magicutils is stripped from - // the shadow jar, so the built jar is thin and expects - // MagicUtils installed as a separate server plugin. - // JAR_IN_JAR → rejected by resolveEmbedMode (fail-fast; no JiJ on Bukkit). - // Resolved at afterEvaluate: the consumer sets embedMode in its DSL block, - // which runs after this plugin applies. Bukkit has no Loom early-observe of - // configurations, so a late `add` is safe (unlike the Fabric consumer). - project.afterEvaluate { - val embed = resolveEmbedMode(consumer.embedMode.get(), ConsumerLoader.BUKKIT) - val shaded = embed == EmbedMode.SHADED - val apiConfig = if (shaded) "api" else "compileOnly" - val implConfig = if (shaded) "implementation" else "compileOnly" - val base = consumer.magicutilsVersion.get() - consumer.apiModules.get().forEach { module -> - project.dependencies.add(apiConfig, magicUtilsModuleCoordinate(module, base, target)) - } - consumer.implementationModules.get().forEach { module -> - project.dependencies.add(implConfig, magicUtilsModuleCoordinate(module, base, target)) - } - - // EXTERNAL: strip MagicUtils and its bundled libraries from the shadow - // jar, so this jar carries none of them and the standalone MagicUtils - // plugin provides the single copy at runtime. These reach the fat jar - // transitively via the common module (whose MagicUtils deps are `api`), - // so moving this module's deps to compileOnly alone does not remove - // them; the shadow exclude does. jackson is the config modules' only - // external dependency and the bundle plugin owns its own relocated - // copy — shipping a second here produced a ClassCastException under - // Paper's isolated classloaders, so it is excluded too. - if (!shaded) { - project.tasks.named("shadowJar", com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar::class.java) - .configure { shadow -> - // The `**/` prefix also catches multi-release copies under - // META-INF/versions//, which a root-anchored pattern - // (e.g. `com/fasterxml/...`) would leave behind. - shadow.exclude("**/dev/ua/theroer/magicutils/**") - shadow.exclude("**/com/fasterxml/jackson/**") - } - } - } + // MagicUtils modules per embedMode + the EXTERNAL shadow-strip. Bukkit and + // Velocity share this exactly (flat-classpath JVM plugins, no jar-in-jar), + // so it lives in one helper. SHADED is the Bukkit default (AUTO); EXTERNAL + // makes a thin jar that expects the standalone MagicUtils bundle beside it. + project.configureJvmConsumerEmbed(target, ConsumerLoader.BUKKIT) // Local dev server (runPaper/runFolia), only when the consumer opted in // with `magicutilsConsumer { devServer { ... } }`. The Folia runner is diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerExtension.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerExtension.kt index dd19a49e..4af0eb1c 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerExtension.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerExtension.kt @@ -452,3 +452,51 @@ internal fun Project.addConsumerMagicUtilsModules( register(apiConfiguration, consumer.apiModules) register(implementationConfiguration, consumer.implementationModules) } + +/** + * Wires the consumer's MagicUtils modules onto a plain-JVM loader (Bukkit, + * Velocity) honouring [EmbedMode], and — for EXTERNAL — strips MagicUtils and its + * bundled jackson from the shadow jar so the standalone bundle owns the single + * runtime copy. Bukkit and Velocity are identical here (both are flat-classpath + * JVM plugins with no jar-in-jar), so this is the one place that logic lives; + * each consumer plugin just passes its [ConsumerLoader]. BungeeCord is always + * shaded (no EXTERNAL split) and uses [addConsumerMagicUtilsModules] instead. + * + * Runs in `afterEvaluate` because the consumer sets `embedMode` in its DSL block, + * which executes after the plugin applies; a plain JVM loader has no Loom + * early-observe of configurations, so a late `add` is safe. + */ +internal fun Project.configureJvmConsumerEmbed( + target: MagicUtilsTargetExtension, + loader: ConsumerLoader, +) { + val consumer = magicUtilsConsumerExtension() + afterEvaluate { + val shaded = resolveEmbedMode(consumer.embedMode.get(), loader) == EmbedMode.SHADED + val apiConfig = if (shaded) "api" else "compileOnly" + val implConfig = if (shaded) "implementation" else "compileOnly" + val base = consumer.magicutilsVersion.get() + consumer.apiModules.get().forEach { module -> + dependencies.add(apiConfig, magicUtilsModuleCoordinate(module, base, target)) + } + consumer.implementationModules.get().forEach { module -> + dependencies.add(implConfig, magicUtilsModuleCoordinate(module, base, target)) + } + + // EXTERNAL: the modules reach the fat jar transitively via the common + // module (whose MagicUtils deps are `api`), so moving this module's own + // deps to compileOnly doesn't remove them — the shadow exclude does. The + // `**/` prefix also catches multi-release copies under + // META-INF/versions//. jackson is the config modules' only external + // dependency and the bundle ships its own relocated copy; a second one + // here clashes under the isolated plugin/proxy classloaders, so it goes + // too. + if (!shaded) { + tasks.named("shadowJar", com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar::class.java) + .configure { shadow -> + shadow.exclude("**/dev/ua/theroer/magicutils/**") + shadow.exclude("**/com/fasterxml/jackson/**") + } + } + } +} diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerVelocityPlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerVelocityPlugin.kt index 003becab..704ab9f3 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerVelocityPlugin.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerVelocityPlugin.kt @@ -55,44 +55,11 @@ class MagicUtilsConsumerVelocityPlugin : Plugin { project.exposeMagicUtilsTargetFacts(target) - // MagicUtils modules the consumer declared, wired per embedMode. Velocity - // has no jar-in-jar, so this maps onto the dependency configuration + the - // shadow jar exactly like the Bukkit consumer. Resolved at afterEvaluate - // because the consumer sets embedMode in its DSL block, which runs after - // this plugin applies; Velocity has no Loom early-observe of - // configurations, so a late `add` is safe (unlike the Fabric consumer). - project.afterEvaluate { - val embed = resolveEmbedMode(consumer.embedMode.get(), ConsumerLoader.VELOCITY) - val shaded = embed == EmbedMode.SHADED - val apiConfig = if (shaded) "api" else "compileOnly" - val implConfig = if (shaded) "implementation" else "compileOnly" - val base = consumer.magicutilsVersion.get() - consumer.apiModules.get().forEach { module -> - project.dependencies.add(apiConfig, magicUtilsModuleCoordinate(module, base, target)) - } - consumer.implementationModules.get().forEach { module -> - project.dependencies.add(implConfig, magicUtilsModuleCoordinate(module, base, target)) - } - - // EXTERNAL: strip MagicUtils and its bundled libraries from the shadow - // jar, so this jar carries none of them and the standalone - // velocity-bundle provides the single copy at runtime. They reach the - // fat jar transitively via the common module (whose MagicUtils deps are - // `api`), so moving this module's deps to compileOnly alone does not - // remove them; the shadow exclude does. jackson is the config modules' - // only external dependency and the bundle owns its own copy — shipping - // a second here clashes under the proxy classloader, so exclude it too. - if (!shaded) { - project.tasks.named("shadowJar", com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar::class.java) - .configure { shadow -> - // The `**/` prefix also catches multi-release copies under - // META-INF/versions//, which a root-anchored pattern - // would leave behind. - shadow.exclude("**/dev/ua/theroer/magicutils/**") - shadow.exclude("**/com/fasterxml/jackson/**") - } - } - } + // MagicUtils modules per embedMode + the EXTERNAL shadow-strip — shared + // with the Bukkit consumer (see configureJvmConsumerEmbed). Velocity's + // default is SHADED (self-contained proxy plugin); EXTERNAL yields a thin + // jar that expects the standalone velocity-bundle beside it on the proxy. + project.configureJvmConsumerEmbed(target, ConsumerLoader.VELOCITY) project.afterEvaluate { val spec = consumer.devServerSpec.orNull ?: return@afterEvaluate diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixModel.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixModel.kt index c24cd520..c7e1ee2d 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixModel.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixModel.kt @@ -61,24 +61,44 @@ internal fun MagicUtilsMatrixDefinition.availablePlatformsFor(target: String): S platforms.values.filter { it.isEnabledFor(target) }.map { it.name }.toSet() /** - * Publish units for every target. The default target publishes all - * categories (DEFAULT_ONLY runs exactly once, here). Non-default targets - * publish COMMON_MATRIX, plus FABRIC_MATRIX when the fabric platform is - * enabled for that target. Non-default targets carry the `mcXXXX` - * classifier suffix. + * Publish units, one per distinct Java level rather than per Minecraft target. + * + * The published coordinate is `+java` (see [publishedVersion]) because + * MagicUtils' compiled bytecode depends only on the Java level, not the + * Minecraft version. Several targets can share a Java level (e.g. mc1205 / + * mc12110 / mc12111 are all Java 21); publishing each would re-upload a + * byte-identical jar to the same coordinate and 409 in the immutable releases + * repo. So we pick one representative target per Java level — the default target + * for its own level, otherwise the first target in file order — and publish the + * full module set there (`publishDefaultMatrix` covers every category, plus the + * Fabric matrix when that representative enables the fabric platform). + * + * The remaining targets still exist for the build/smoke matrix (each Minecraft + * version boots a real server); they just don't publish. */ -internal fun MagicUtilsMatrixDefinition.publishUnits(allTargets: List): List = - allTargets.map { target -> - if (target == defaultTarget) { - MagicUtilsPublishUnit(target, listOf("publishDefaultMatrix"), suffix = false) - } else { - val tasks = mutableListOf("publishCommonMatrix") - if ("fabric" in availablePlatformsFor(target)) { - tasks += "publishFabricMatrix" - } - MagicUtilsPublishUnit(target, tasks, suffix = true) +internal fun MagicUtilsMatrixDefinition.publishUnits( + allTargets: List, + javaLevelOf: (String) -> Int, +): List { + val representatives = LinkedHashMap() + for (target in allTargets) { + val java = javaLevelOf(target) + // Prefer the default target as its level's representative; otherwise the + // first-seen target for that level wins (file order). + if (java !in representatives || target == defaultTarget) { + representatives[java] = target + } + } + return representatives.values.map { target -> + val tasks = mutableListOf("publishDefaultMatrix") + if ("fabric" in availablePlatformsFor(target)) { + tasks += "publishFabricMatrix" } + // suffix is always false now: the +java suffix is intrinsic to the + // coordinate (publishedVersion), not a per-target CI toggle. + MagicUtilsPublishUnit(target, tasks.distinct(), suffix = false) } +} /** Minimal JSON array serializer for the matrix outputs (no dependency needed). */ internal fun List.toMatrixJson(): String = diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixRootPlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixRootPlugin.kt index a4e35e4e..6ebc585b 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixRootPlugin.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixRootPlugin.kt @@ -91,7 +91,13 @@ private fun registerMatrixJsonTasks( task.group = "help" task.description = "Print the publish matrix (target + publish tasks) as JSON for CI." task.doLast { - val units = definition.publishUnits(loadAllTargetNames(targetsFile)) + val units = definition.publishUnits(loadAllTargetNames(targetsFile)) { target -> + resolveMagicUtilsTargetSpec( + targetsFile = targetsFile, + defaultTarget = definition.defaultTarget, + explicitTarget = target, + ).java + } println(units.toMatrixJson()) } } diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsBukkitBundlePlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsBukkitBundlePlugin.kt deleted file mode 100644 index 1da01f79..00000000 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsBukkitBundlePlugin.kt +++ /dev/null @@ -1,121 +0,0 @@ -package dev.ua.theroer.magicutils.build.module - -import dev.ua.theroer.magicutils.build.support.* -import dev.ua.theroer.magicutils.build.target.* - -import org.gradle.api.Plugin -import org.gradle.api.Project -import org.gradle.api.attributes.Usage -import org.gradle.api.publish.maven.MavenPublication -import org.gradle.kotlin.dsl.* -import org.gradle.language.jvm.tasks.ProcessResources -import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar - -class MagicUtilsBukkitBundlePlugin : Plugin { - override fun apply(project: Project) { - project.pluginManager.apply("magicutils.java-library") - project.pluginManager.apply("magicutils.common") - project.pluginManager.apply("magicutils.target") - project.pluginManager.apply("magicutils.shadow") - - val magicutilsTarget = project.extensions.getByType(MagicUtilsTargetExtension::class.java) - val moduleName = project.magicUtilsModuleName() - val apiVersion = bukkitApiVersion(magicutilsTarget.minecraft.get()) - - with(project) { - val bundleShadow = configurations.create("bundleShadow") - bundleShadow.isCanBeConsumed = false - bundleShadow.isCanBeResolved = true - bundleShadow.attributes.attribute( - Usage.USAGE_ATTRIBUTE, - objects.named(Usage::class.java, Usage.JAVA_RUNTIME) - ) - - val bundleProjects = listOf( - project(":platform-api"), - project(":logger"), - project(":commands"), - project(":diagnostics"), - project(":http-client"), - project(":placeholders"), - project(":core"), - project(":platform-bukkit") - ) - - val bundleShadedProjects = listOf( - project(":config"), - project(":config-yaml"), - project(":config-toml"), - project(":lang") - ) - - bundleShadedProjects.forEach { dep -> - val depProject = project(dep.path) - val depDependency = dependencies.add("bundleShadow", depProject) as org.gradle.api.artifacts.ProjectDependency - depDependency.targetConfiguration = "shadedElements" - } - - bundleProjects.forEach { dep -> - dependencies.add("bundleShadow", project(dep.path)) - dependencies.add("compileOnly", project(dep.path)) - } - - bundleShadedProjects.forEach { dep -> - dependencies.add("compileOnly", project(dep.path)) - } - - dependencies.add("compileOnly", "io.papermc.paper:paper-api:${magicutilsTarget.paper.get()}") - - tasks.named("processResources", ProcessResources::class.java).configure { resources -> - resources.inputs.property("version", version) - resources.inputs.property("apiVersion", apiVersion) - resources.filesMatching(listOf("plugin.yml", "paper-plugin.yml")) { details -> - details.expand( - mapOf( - "version" to version, - "apiVersion" to apiVersion, - ) - ) - } - } - - tasks.named("shadowJar", ShadowJar::class.java).configure { shadowJarTask -> - shadowJarTask.archiveBaseName.set(moduleName) - shadowJarTask.archiveClassifier.set("") - shadowJarTask.configurations.set(setOf(bundleShadow)) - shadowJarTask.mergeServiceFiles() - } - - tasks.configureEach { - if (it.javaClass.simpleName == "GenerateModuleMetadata") { - it.enabled = false - } - } - } - - project.extensions.configure(org.gradle.api.publish.PublishingExtension::class.java) { publishing -> - publishing.publications.create("mavenJava", MavenPublication::class.java) { publication -> - publication.artifactId = moduleName - publication.artifact(project.tasks.named("shadowJar", ShadowJar::class.java).get()) - publication.artifact(project.tasks.named("sourcesJar").get()) - publication.artifact(project.tasks.named("javadocJar").get()) - publication.pom.withXml { xml -> - xml.asElement().getElementsByTagName("dependencies").item(0)?.let { node -> - node.parentNode.removeChild(node) - } - } - } - - project.magicUtilsPublishRepository(publishing) - } - } -} - -private fun bukkitApiVersion(minecraftVersion: String): String { - val parts = minecraftVersion.split('.') - return if (parts.size >= 3) { - "${parts[0]}.${parts[1]}" - } else { - minecraftVersion - } -} diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsBungeeBundlePlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsBungeeBundlePlugin.kt deleted file mode 100644 index 4c70ed9d..00000000 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsBungeeBundlePlugin.kt +++ /dev/null @@ -1,126 +0,0 @@ -package dev.ua.theroer.magicutils.build.module - -import dev.ua.theroer.magicutils.build.consumer.MagicUtilsConsumerBungeePlugin -import dev.ua.theroer.magicutils.build.support.* -import dev.ua.theroer.magicutils.build.target.* - -import org.gradle.api.Plugin -import org.gradle.api.Project -import org.gradle.api.attributes.Usage -import org.gradle.api.publish.maven.MavenPublication -import org.gradle.kotlin.dsl.* -import org.gradle.language.jvm.tasks.ProcessResources -import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar - -/** - * Builds the standalone `bungee-bundle` plugin: a shaded, drop-in BungeeCord - * plugin that jar-in-jars every MagicUtils module so proxy owners install one - * runtime. This mirrors [MagicUtilsVelocityBundlePlugin] (BungeeCord is a plain - * JVM plugin like Velocity/Bukkit, so it shades via the `bundleShadow` - * configuration rather than jar-in-jarring like the loader-based Fabric/NeoForge - * bundles). - * - * Unlike bukkit/fabric there is no per-target BungeeCord API: the API is - * decoupled from the Minecraft version, so the version comes from the - * `bungeeApiVersion` gradle property (default - * [MagicUtilsConsumerBungeePlugin.DEFAULT_BUNGEE_API]). - */ -class MagicUtilsBungeeBundlePlugin : Plugin { - override fun apply(project: Project) { - project.pluginManager.apply("magicutils.java-library") - project.pluginManager.apply("magicutils.common") - project.pluginManager.apply("magicutils.target") - project.pluginManager.apply("magicutils.shadow") - - val moduleName = project.magicUtilsModuleName() - val bungeeApiVersion = project.providers.gradleProperty("bungeeApiVersion") - .orElse(MagicUtilsConsumerBungeePlugin.DEFAULT_BUNGEE_API).get() - - with(project) { - val bundleShadow = configurations.create("bundleShadow") - bundleShadow.isCanBeConsumed = false - bundleShadow.isCanBeResolved = true - bundleShadow.attributes.attribute( - Usage.USAGE_ATTRIBUTE, - objects.named(Usage::class.java, Usage.JAVA_RUNTIME) - ) - - val bundleProjects = listOf( - project(":platform-api"), - project(":logger"), - project(":commands"), - project(":diagnostics"), - project(":http-client"), - project(":placeholders"), - project(":core"), - project(":platform-bungee") - ) - - val bundleShadedProjects = listOf( - project(":config"), - project(":config-yaml"), - project(":config-toml"), - project(":lang") - ) - - bundleShadedProjects.forEach { dep -> - val depProject = project(dep.path) - val depDependency = dependencies.add("bundleShadow", depProject) as org.gradle.api.artifacts.ProjectDependency - depDependency.targetConfiguration = "shadedElements" - } - - bundleProjects.forEach { dep -> - dependencies.add("bundleShadow", project(dep.path)) - dependencies.add("compileOnly", project(dep.path)) - } - - bundleShadedProjects.forEach { dep -> - dependencies.add("compileOnly", project(dep.path)) - } - - // BungeeCord API on the compile classpath for the plugin entrypoint - // (Plugin/ProxyServer). It is provided by the proxy at runtime, so it - // stays out of the bundle jar (compileOnly, not bundleShadow). The - // plugin descriptor is the hand-authored plugin.yml below. - dependencies.add("compileOnly", "net.md-5:bungeecord-api:$bungeeApiVersion") - - // Fill plugin.yml's ${version} from the build version, the same token - // flow bukkit-bundle/velocity-bundle use. - tasks.named("processResources", ProcessResources::class.java).configure { resources -> - resources.inputs.property("version", version) - resources.filesMatching(listOf("plugin.yml")) { details -> - details.expand(mapOf("version" to version)) - } - } - - tasks.named("shadowJar", ShadowJar::class.java).configure { shadowJarTask -> - shadowJarTask.archiveBaseName.set(moduleName) - shadowJarTask.archiveClassifier.set("") - shadowJarTask.configurations.set(setOf(bundleShadow)) - shadowJarTask.mergeServiceFiles() - } - - tasks.configureEach { - if (it.javaClass.simpleName == "GenerateModuleMetadata") { - it.enabled = false - } - } - } - - project.extensions.configure(org.gradle.api.publish.PublishingExtension::class.java) { publishing -> - publishing.publications.create("mavenJava", MavenPublication::class.java) { publication -> - publication.artifactId = moduleName - publication.artifact(project.tasks.named("shadowJar", ShadowJar::class.java).get()) - publication.artifact(project.tasks.named("sourcesJar").get()) - publication.artifact(project.tasks.named("javadocJar").get()) - publication.pom.withXml { xml -> - xml.asElement().getElementsByTagName("dependencies").item(0)?.let { node -> - node.parentNode.removeChild(node) - } - } - } - - project.magicUtilsPublishRepository(publishing) - } - } -} diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsFabricBundlePlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsFabricBundlePlugin.kt index 866be044..fff35d0e 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsFabricBundlePlugin.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsFabricBundlePlugin.kt @@ -206,11 +206,7 @@ class MagicUtilsFabricBundlePlugin : Plugin { artifact.classifier = "dev" } } - publication.pom.withXml { xml -> - xml.asElement().getElementsByTagName("dependencies").item(0)?.let { node -> - node.parentNode.removeChild(node) - } - } + publication.stripPomDependencies() } project.magicUtilsPublishRepository(publishing) diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsJvmBundlePlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsJvmBundlePlugin.kt new file mode 100644 index 00000000..216f1d27 --- /dev/null +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsJvmBundlePlugin.kt @@ -0,0 +1,182 @@ +package dev.ua.theroer.magicutils.build.module + +import dev.ua.theroer.magicutils.build.consumer.MagicUtilsConsumerBungeePlugin +import dev.ua.theroer.magicutils.build.consumer.MagicUtilsConsumerVelocityPlugin +import dev.ua.theroer.magicutils.build.support.* +import dev.ua.theroer.magicutils.build.target.* + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.attributes.Usage +import org.gradle.api.publish.maven.MavenPublication +import org.gradle.kotlin.dsl.* +import org.gradle.language.jvm.tasks.ProcessResources +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar + +/** + * Builds a standalone, shaded, drop-in plugin for a plain-JVM server platform + * (Bukkit/Paper, BungeeCord, Velocity). All three shade every MagicUtils module + * into one runtime jar via the `bundleShadow` configuration and differ only in: + * + * - which `platform-*` module carries the entrypoint, + * - the compile-only server API coordinate (from the target for Paper, from a + * gradle property for the version-decoupled proxy APIs), + * - the plugin-descriptor resource file(s) filtered for `${version}` (and, for + * Bukkit, the derived `${apiVersion}`). + * + * Those three axes are the [JvmBundlePlatform] table below; everything else (the + * `bundleShadow` wiring, shaded config modules, shadowJar setup, publication) is + * shared. The loader-based Fabric/NeoForge bundles jar-in-jar instead of shading + * and keep their own plugins. + */ +abstract class MagicUtilsJvmBundlePlugin(private val platform: JvmBundlePlatform) : Plugin { + override fun apply(project: Project) { + project.pluginManager.apply("magicutils.java-library") + project.pluginManager.apply("magicutils.common") + project.pluginManager.apply("magicutils.target") + project.pluginManager.apply("magicutils.shadow") + + val moduleName = project.magicUtilsModuleName() + + with(project) { + val bundleShadow = configurations.create("bundleShadow") + bundleShadow.isCanBeConsumed = false + bundleShadow.isCanBeResolved = true + bundleShadow.attributes.attribute( + Usage.USAGE_ATTRIBUTE, + objects.named(Usage::class.java, Usage.JAVA_RUNTIME) + ) + + val bundleProjects = listOf( + project(":platform-api"), + project(":logger"), + project(":commands"), + project(":diagnostics"), + project(":http-client"), + project(":placeholders"), + project(":core"), + project(platform.platformProjectPath) + ) + + val bundleShadedProjects = listOf( + project(":config"), + project(":config-yaml"), + project(":config-toml"), + project(":lang") + ) + + bundleShadedProjects.forEach { dep -> + val depProject = project(dep.path) + val depDependency = dependencies.add("bundleShadow", depProject) as org.gradle.api.artifacts.ProjectDependency + depDependency.targetConfiguration = "shadedElements" + } + + bundleProjects.forEach { dep -> + dependencies.add("bundleShadow", project(dep.path)) + dependencies.add("compileOnly", project(dep.path)) + } + + bundleShadedProjects.forEach { dep -> + dependencies.add("compileOnly", project(dep.path)) + } + + // Server API on the compile classpath for the plugin entrypoint; the + // server provides it at runtime, so it stays out of the bundle jar + // (compileOnly, not bundleShadow). The plugin descriptor is the + // hand-authored resource file(s) filtered below. + dependencies.add("compileOnly", platform.apiCoordinate(project)) + + // Extra ${...} tokens the descriptor needs beyond ${version}: Bukkit's + // plugin.yml/paper-plugin.yml carry an ${apiVersion} derived from the + // runtime Minecraft; the proxies have none. + val extraTokens = platform.extraResourceTokens(project) + + tasks.named("processResources", ProcessResources::class.java).configure { resources -> + resources.inputs.property("version", version) + extraTokens.forEach { (key, value) -> resources.inputs.property(key, value) } + resources.filesMatching(platform.resourceFiles) { details -> + details.expand(mapOf("version" to version) + extraTokens) + } + } + + tasks.named("shadowJar", ShadowJar::class.java).configure { shadowJarTask -> + shadowJarTask.archiveBaseName.set(moduleName) + shadowJarTask.archiveClassifier.set("") + shadowJarTask.configurations.set(setOf(bundleShadow)) + shadowJarTask.mergeServiceFiles() + } + + tasks.configureEach { + if (it.javaClass.simpleName == "GenerateModuleMetadata") { + it.enabled = false + } + } + } + + project.extensions.configure(org.gradle.api.publish.PublishingExtension::class.java) { publishing -> + publishing.publications.create("mavenJava", MavenPublication::class.java) { publication -> + publication.artifactId = moduleName + publication.artifact(project.tasks.named("shadowJar", ShadowJar::class.java).get()) + publication.artifact(project.tasks.named("sourcesJar").get()) + publication.artifact(project.tasks.named("javadocJar").get()) + publication.stripPomDependencies() + } + + project.magicUtilsPublishRepository(publishing) + } + } +} + +/** + * The three axes that distinguish a JVM-bundle platform. Each concrete plugin + * (registered under `magicutils.-bundle`) passes one of these. + */ +enum class JvmBundlePlatform( + val platformProjectPath: String, + val resourceFiles: List, +) { + BUKKIT(":platform-bukkit", listOf("plugin.yml", "paper-plugin.yml")) { + override fun apiCoordinate(project: Project): String { + val target = project.extensions.getByType(MagicUtilsTargetExtension::class.java) + return "io.papermc.paper:paper-api:${target.paper.get()}" + } + + override fun extraResourceTokens(project: Project): Map { + val target = project.extensions.getByType(MagicUtilsTargetExtension::class.java) + return mapOf("apiVersion" to bukkitApiVersion(target.minecraft.get())) + } + }, + BUNGEE(":platform-bungee", listOf("plugin.yml")) { + override fun apiCoordinate(project: Project): String { + val version = project.providers.gradleProperty("bungeeApiVersion") + .orElse(MagicUtilsConsumerBungeePlugin.DEFAULT_BUNGEE_API).get() + return "net.md-5:bungeecord-api:$version" + } + }, + VELOCITY(":platform-velocity", listOf("velocity-plugin.json")) { + override fun apiCoordinate(project: Project): String { + val version = project.providers.gradleProperty("velocityApiVersion") + .orElse(MagicUtilsConsumerVelocityPlugin.DEFAULT_VELOCITY_API).get() + return "com.velocitypowered:velocity-api:$version" + } + }; + + /** The compileOnly server-API coordinate for this platform. */ + abstract fun apiCoordinate(project: Project): String + + /** Descriptor tokens beyond `${version}`; empty for the proxies. */ + open fun extraResourceTokens(project: Project): Map = emptyMap() +} + +class MagicUtilsBukkitBundlePlugin : MagicUtilsJvmBundlePlugin(JvmBundlePlatform.BUKKIT) +class MagicUtilsBungeeBundlePlugin : MagicUtilsJvmBundlePlugin(JvmBundlePlatform.BUNGEE) +class MagicUtilsVelocityBundlePlugin : MagicUtilsJvmBundlePlugin(JvmBundlePlatform.VELOCITY) + +private fun bukkitApiVersion(minecraftVersion: String): String { + val parts = minecraftVersion.split('.') + return if (parts.size >= 3) { + "${parts[0]}.${parts[1]}" + } else { + minecraftVersion + } +} diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsNeoForgeBundlePlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsNeoForgeBundlePlugin.kt index 00a223c2..ad513d11 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsNeoForgeBundlePlugin.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsNeoForgeBundlePlugin.kt @@ -148,11 +148,7 @@ class MagicUtilsNeoForgeBundlePlugin : Plugin { publishing.publications.create("mavenJava", MavenPublication::class.java) { publication -> publication.artifactId = moduleName publication.from(project.components.getByName("java")) - publication.pom.withXml { xml -> - xml.asElement().getElementsByTagName("dependencies").item(0)?.let { node -> - node.parentNode.removeChild(node) - } - } + publication.stripPomDependencies() } project.magicUtilsPublishRepository(publishing) diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsVelocityBundlePlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsVelocityBundlePlugin.kt deleted file mode 100644 index f46345b6..00000000 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsVelocityBundlePlugin.kt +++ /dev/null @@ -1,129 +0,0 @@ -package dev.ua.theroer.magicutils.build.module - -import dev.ua.theroer.magicutils.build.support.* -import dev.ua.theroer.magicutils.build.target.* - -import org.gradle.api.Plugin -import org.gradle.api.Project -import org.gradle.api.attributes.Usage -import org.gradle.api.publish.maven.MavenPublication -import org.gradle.kotlin.dsl.* -import org.gradle.language.jvm.tasks.ProcessResources -import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar - -/** - * Builds the standalone `velocity-bundle` plugin: a shaded, drop-in Velocity - * plugin that jar-in-jars every MagicUtils module so proxy owners install one - * runtime. This mirrors [MagicUtilsBukkitBundlePlugin] (Velocity is a plain JVM - * plugin like Bukkit, so it shades via the `bundleShadow` configuration rather - * than jar-in-jarring like the loader-based Fabric/NeoForge bundles). - * - * Unlike bukkit/fabric there is no per-target Velocity API: Velocity's API is - * decoupled from the Minecraft version, so the version comes from the - * `velocityApiVersion` gradle property (default - * [MagicUtilsConsumerVelocityPlugin.DEFAULT_VELOCITY_API]). - */ -class MagicUtilsVelocityBundlePlugin : Plugin { - override fun apply(project: Project) { - project.pluginManager.apply("magicutils.java-library") - project.pluginManager.apply("magicutils.common") - project.pluginManager.apply("magicutils.target") - project.pluginManager.apply("magicutils.shadow") - - val moduleName = project.magicUtilsModuleName() - val velocityApiVersion = project.providers.gradleProperty("velocityApiVersion") - .orElse(DEFAULT_VELOCITY_API).get() - - with(project) { - val bundleShadow = configurations.create("bundleShadow") - bundleShadow.isCanBeConsumed = false - bundleShadow.isCanBeResolved = true - bundleShadow.attributes.attribute( - Usage.USAGE_ATTRIBUTE, - objects.named(Usage::class.java, Usage.JAVA_RUNTIME) - ) - - val bundleProjects = listOf( - project(":platform-api"), - project(":logger"), - project(":commands"), - project(":diagnostics"), - project(":http-client"), - project(":placeholders"), - project(":core"), - project(":platform-velocity") - ) - - val bundleShadedProjects = listOf( - project(":config"), - project(":config-yaml"), - project(":config-toml"), - project(":lang") - ) - - bundleShadedProjects.forEach { dep -> - val depProject = project(dep.path) - val depDependency = dependencies.add("bundleShadow", depProject) as org.gradle.api.artifacts.ProjectDependency - depDependency.targetConfiguration = "shadedElements" - } - - bundleProjects.forEach { dep -> - dependencies.add("bundleShadow", project(dep.path)) - dependencies.add("compileOnly", project(dep.path)) - } - - bundleShadedProjects.forEach { dep -> - dependencies.add("compileOnly", project(dep.path)) - } - - // Velocity API on the compile classpath for the plugin entrypoint - // (@Subscribe/ProxyServer/@DataDirectory). It is provided by the proxy - // at runtime, so it stays out of the bundle jar (compileOnly, not - // bundleShadow). No annotationProcessor: the plugin descriptor is the - // hand-authored velocity-plugin.json below, not the @Plugin AP. - dependencies.add("compileOnly", "com.velocitypowered:velocity-api:$velocityApiVersion") - - // Fill velocity-plugin.json's ${version} from the build version, the - // same token flow bukkit-bundle uses for plugin.yml. - tasks.named("processResources", ProcessResources::class.java).configure { resources -> - resources.inputs.property("version", version) - resources.filesMatching(listOf("velocity-plugin.json")) { details -> - details.expand(mapOf("version" to version)) - } - } - - tasks.named("shadowJar", ShadowJar::class.java).configure { shadowJarTask -> - shadowJarTask.archiveBaseName.set(moduleName) - shadowJarTask.archiveClassifier.set("") - shadowJarTask.configurations.set(setOf(bundleShadow)) - shadowJarTask.mergeServiceFiles() - } - - tasks.configureEach { - if (it.javaClass.simpleName == "GenerateModuleMetadata") { - it.enabled = false - } - } - } - - project.extensions.configure(org.gradle.api.publish.PublishingExtension::class.java) { publishing -> - publishing.publications.create("mavenJava", MavenPublication::class.java) { publication -> - publication.artifactId = moduleName - publication.artifact(project.tasks.named("shadowJar", ShadowJar::class.java).get()) - publication.artifact(project.tasks.named("sourcesJar").get()) - publication.artifact(project.tasks.named("javadocJar").get()) - publication.pom.withXml { xml -> - xml.asElement().getElementsByTagName("dependencies").item(0)?.let { node -> - node.parentNode.removeChild(node) - } - } - } - - project.magicUtilsPublishRepository(publishing) - } - } - - companion object { - const val DEFAULT_VELOCITY_API = "3.1.1" - } -} diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsModrinthTasks.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsModrinthTasks.kt index 671bffce..40856698 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsModrinthTasks.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsModrinthTasks.kt @@ -109,12 +109,10 @@ private fun modrinthArtifactsFromMatrix( ): List = smokeSpecs .filter { it.name !in MODRINTH_NON_PUBLISHED_PLATFORMS } .flatMap { platform -> - // The bundle jar is named by the *library* Minecraft, not the runtime one: - // mc1201 and mc1205 both publish `+1.20.1` (one 1.20.x library coordinate, - // two Java runtimes for the smoke). So two smoke entries can map to a single - // jar. Group entries by their library-Minecraft coordinate and merge their - // advertised game versions into ONE Modrinth version — otherwise we'd upload - // the same file twice and split its supported versions across duplicates. + // Group smoke entries by Java level: every Minecraft version sharing a Java + // level ships the same bundle jar (the coordinate is `+java`), so they + // fold into one Modrinth version whose game_versions is their union — + // otherwise we'd upload the same file once per Minecraft version. val ver = version ?: "{version}" platform.versionMatrix .groupBy { entry -> @@ -122,16 +120,17 @@ private fun modrinthArtifactsFromMatrix( targetsFile = targetsFile, defaultTarget = defaultTarget, explicitTarget = entry.target ?: defaultTarget, - ).libraryMinecraft + ).java } - .map { (libraryMc, entries) -> - val fileName = "magicutils-${platform.name}-bundle-$ver+$libraryMc.jar" - // Merge + de-dup game versions across the entries sharing this jar, - // in matrix order (the versions the release advertises for this file). + .map { (java, entries) -> + // The bundle jar is named by the Java level (the published coordinate + // is `+java`), so all Minecraft versions sharing a Java level + // map to one jar. Merge their advertised game versions into ONE + // Modrinth version instead of re-uploading the same file per MC. + val fileName = "magicutils-${platform.name}-bundle-$ver+java$java.jar" val gameVersions = entries.flatMap { it.versions.expandVersionsFull() }.distinct() - // Stable key from the library coordinate (dots dropped) so it is a - // valid Modrinth file part and unique per jar. - val key = "${platform.name}-mc${libraryMc.replace(".", "")}" + // Stable, valid Modrinth file part, unique per jar. + val key = "${platform.name}-java$java" ModrinthArtifact( key = key, file = "${platform.name}-bundle/build/libs/$fileName", diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/support/MagicUtilsProjectExtensions.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/support/MagicUtilsProjectExtensions.kt index 6ea8016b..c0f6b5fe 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/support/MagicUtilsProjectExtensions.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/support/MagicUtilsProjectExtensions.kt @@ -4,6 +4,7 @@ import dev.ua.theroer.magicutils.build.module.* import org.gradle.api.Project import org.gradle.api.publish.PublishingExtension +import org.gradle.api.publish.maven.MavenPublication internal fun Project.magicUtilsModuleName(projectName: String = name): String { val namingSpec = extensions.extraProperties @@ -49,3 +50,17 @@ internal fun Project.magicUtilsPublishRepository(publishing: PublishingExtension private fun Project.findMagicUtilsPublishSecret(propertyName: String, envName: String): String? = (findProperty(propertyName) as? String)?.trim()?.takeIf(String::isNotEmpty) ?: System.getenv(envName)?.trim()?.takeIf(String::isNotEmpty) + +/** + * Drops the `` node from the generated POM. Bundle publications + * ship a fat/jar-in-jar artifact whose dependencies are already inside the jar, + * so advertising them as Maven deps would make consumers double-resolve them. + * Shared by every bundle plugin instead of copying the `pom.withXml` block. + */ +internal fun MavenPublication.stripPomDependencies() { + pom.withXml { xml -> + xml.asElement().getElementsByTagName("dependencies").item(0)?.let { node -> + node.parentNode.removeChild(node) + } + } +} diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/target/MagicUtilsTargetConventions.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/target/MagicUtilsTargetConventions.kt index 1bd3e676..415bb5cd 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/target/MagicUtilsTargetConventions.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/target/MagicUtilsTargetConventions.kt @@ -32,18 +32,26 @@ val MagicUtilsTargetExtension.mcClassifier: String get() = "mc${libraryMinecraft.get().substringBeforeLast('.')}" /** - * Published MagicUtils version for [baseVersion] on this target, Fabric-style: - * `+` (e.g. `1.22.0+26.2`). Every target carries the - * suffix so each Minecraft version is its own Maven version — its own module - * metadata, Java level and transitive deps — instead of one version whose - * metadata the targets overwrite. Consumers pass the bare base version - * (`magicutils_version=1.22.0`); the consumer plugins add the suffix from the - * resolved target's *library* Minecraft, so no build script writes it by hand. - * The library Minecraft equals the runtime one unless the target overrides - * `library_minecraft` (see [MagicUtilsTargetSpec]). + * Published MagicUtils version for [baseVersion] on this target: + * `+java` (e.g. `1.25.0+java21`). + * + * Empirically MagicUtils' compiled bytecode depends only on the Java level, not + * on the Minecraft version: the whole library (core/config/commands/lang, the + * platform modules, even the Fabric mod's classes) is byte-identical between two + * targets that share a Java level (e.g. +1.21.10 vs +1.21.11 differed in 0 + * classes) and differs wholesale only across Java levels (major 65 vs 69). So a + * per-Minecraft coordinate published five near-duplicate copies where three real + * variants exist — one per Java level (17 / 21 / 25). obf/deobf is not a second + * axis: it is a function of the Java level (26.x is Java 25 + deobfuscated). + * + * Consumers pass the bare base version (`magicutils_version=1.25.0`); the + * consumer plugins add `+java` from the resolved target's Java level, so the + * coordinate a consumer resolves always matches one that was published. Fabric + * mods pin their runtime Minecraft through `fabric.mod.json` (a version range), + * not through the Maven coordinate. */ fun MagicUtilsTargetExtension.publishedVersion(baseVersion: String): String = - "$baseVersion+${libraryMinecraft.get()}" + "$baseVersion+java${java.get()}" /** Loom plugin id for the target: no-remap on deobfuscated, remapping otherwise. */ val MagicUtilsTargetExtension.loomPluginId: String diff --git a/build-logic/src/test/kotlin/MagicUtilsMatrixModelTest.kt b/build-logic/src/test/kotlin/MagicUtilsMatrixModelTest.kt index 534f8c6b..580a137c 100644 --- a/build-logic/src/test/kotlin/MagicUtilsMatrixModelTest.kt +++ b/build-logic/src/test/kotlin/MagicUtilsMatrixModelTest.kt @@ -67,30 +67,51 @@ class MagicUtilsMatrixModelTest { assertEquals(setOf("bukkit", "fabric", "neoforge"), def.availablePlatformsFor("mc12110")) } + // Java level per test target, mirroring writeTargets: one target per level, + // so each is its own level's representative. + private val javaLevels = mapOf("mc1201" to 17, "mc12110" to 21, "mc262" to 25) + private val javaLevelOf: (String) -> Int = { javaLevels.getValue(it) } + + @Test + fun `publishUnits publishes one representative per Java level, no suffix`() { + val units = definition().publishUnits(listOf("mc1201", "mc12110", "mc262"), javaLevelOf) + // Three distinct Java levels (17/21/25) -> three representatives. + assertEquals(listOf("mc1201", "mc12110", "mc262"), units.map { it.target }) + units.forEach { unit -> + assertTrue("publishDefaultMatrix" in unit.publishTasks) + assertFalse(unit.suffix) // +java is intrinsic to the coordinate now + } + } + @Test - fun `publishUnits marks default target without suffix and all-categories`() { - val units = definition().publishUnits(listOf("mc1201", "mc12110", "mc262")) - val default = units.single { it.target == "mc12110" } - assertEquals(listOf("publishDefaultMatrix"), default.publishTasks) - assertFalse(default.suffix) + fun `publishUnits collapses targets sharing a Java level to one representative`() { + // mc12110 and a hypothetical mc12111 both Java 21 -> one unit; the default + // target wins as the representative for its level. + val levels = mapOf("mc1201" to 17, "mc12110" to 21, "mc12111" to 21, "mc262" to 25) + val units = definition().publishUnits( + listOf("mc1201", "mc12111", "mc12110", "mc262"), + ) { levels.getValue(it) } + assertEquals(listOf(17, 21, 25).size, units.size) + // The Java-21 representative is the default target (mc12110), not mc12111. + assertTrue(units.any { it.target == "mc12110" }) + assertFalse(units.any { it.target == "mc12111" }) } @Test fun `publishUnits adds fabric only when platform enabled`() { - val units = definition().publishUnits(listOf("mc1201", "mc262")) + val units = definition().publishUnits(listOf("mc1201", "mc262"), javaLevelOf) val mc1201 = units.single { it.target == "mc1201" } val mc262 = units.single { it.target == "mc262" } assertTrue("publishFabricMatrix" in mc1201.publishTasks) assertFalse("publishFabricMatrix" in mc262.publishTasks) // fabric disabled on mc26 - assertTrue(mc1201.suffix) - assertTrue(mc262.suffix) } @Test fun `toMatrixJson emits valid boolean suffix and joined tasks`() { - val json = definition().publishUnits(listOf("mc12110", "mc1201")).toMatrixJson() + val json = definition().publishUnits(listOf("mc12110", "mc1201"), javaLevelOf).toMatrixJson() assertTrue(json.startsWith("[") && json.endsWith("]")) - assertTrue(json.contains(""""target":"mc12110","tasks":"publishDefaultMatrix","suffix":false""")) - assertTrue(json.contains(""""tasks":"publishCommonMatrix publishFabricMatrix","suffix":true""")) + // Both representatives enable fabric here (fabric is only disabled on mc26). + assertTrue(json.contains(""""target":"mc12110","tasks":"publishDefaultMatrix publishFabricMatrix","suffix":false""")) + assertTrue(json.contains(""""target":"mc1201","tasks":"publishDefaultMatrix publishFabricMatrix","suffix":false""")) } } diff --git a/build.gradle.kts b/build.gradle.kts index de71d207..42d3fa82 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,18 +7,15 @@ plugins { } val baseVersion = (project.properties["version"] as String?) ?: "0.0.0" -// Fabric-style version per target: + (e.g. 1.22.0+26.2). Every -// target is its own Maven version, so its module metadata (Java level, -// transitive deps) never collides with another target's. The consumer plugins -// mirror this suffix from their resolved target, so downstream builds keep -// declaring the bare base version. +// Version per Java level: +java (e.g. 1.25.0+java21). MagicUtils' +// compiled bytecode depends only on the Java level, not the Minecraft version, +// so several Minecraft targets share one coordinate (mc1205/mc12110/mc12111 are +// all +java21). The consumer plugins mirror this exact suffix from their +// resolved target's Java level (see publishedVersion), so downstream builds keep +// declaring the bare base version and always resolve a coordinate that exists. val resolvedContext = gradle.extensions.extraProperties.get("magicutilsMatrixResolved") as dev.ua.theroer.magicutils.build.matrix.MagicUtilsMatrixResolvedContext -// Use the library Minecraft (the published coordinate's branch), not the runtime -// one — they differ when a target overrides library_minecraft, and consumers -// resolve against the library branch (publishedVersion mirrors this). -val targetMinecraft = resolvedContext.target.libraryMinecraft -val targetVersion = "$baseVersion+$targetMinecraft" +val targetVersion = "$baseVersion+java${resolvedContext.target.java}" val publishingSpec = gradle.extensions.extraProperties.get("magicutilsPublishingSpec") as MagicUtilsPublishingSpec diff --git a/gradle.properties b/gradle.properties index e0fcf6df..1886a057 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,6 @@ org.gradle.caching=true org.gradle.parallel=true org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 -version=1.24.0 +version=1.25.0 # Minecraft version is now managed by gradle/targets.properties # You can set it via -Ptarget=... when running Gradle diff --git a/processor/build.gradle.kts b/processor/build.gradle.kts index b1df435b..6d26cc6b 100644 --- a/processor/build.gradle.kts +++ b/processor/build.gradle.kts @@ -8,7 +8,11 @@ plugins { } magicutilsPublish { - category = MagicUtilsPublishCategory.COMMON_MATRIX + // Build-only: applied as an annotationProcessor to the library modules (see + // MagicUtilsAnnotationProcessingPlugin), its generated code is baked into + // those jars and it never appears as a runtime dependency in any published + // POM. Downstreams don't need it, so it is not published. + category = MagicUtilsPublishCategory.NONE } java { From 4fee51550036fe7d09f11fc4ac46c1e239eb9e0c Mon Sep 17 00:00:00 2001 From: THEROER Date: Fri, 10 Jul 2026 04:37:07 +0300 Subject: [PATCH 03/39] feat(messaging): cross-server messaging module (release 1.26.0) Add the messaging module: a typed MessageBus over a MessageTransport SPI with envelope codec and Target-based addressing (broadcast / all-backends / proxy / server / player). Two interchangeable transports behind one SPI: the default plugin-messaging transport (no extra dependency) and an optional Redis pub/sub transport (Jedis). Platform wiring mirrors the diagnostics pattern: BukkitMessagingSupport / VelocityMessagingSupport / BungeeMessagingSupport plus enableMessaging() / messagingRedis() / configureMessaging() on each Bootstrap.Builder. platform-bukkit/bungee/velocity depend on :messaging. Jedis is compileOnly in :messaging (the Redis transport is class-loaded lazily) and shaded+relocated into the standalone bundles via the shared magicUtilsBundleRedis helper, so operators get the Redis transport out of the box once they enable it in messaging.yml. Verified: bundle carries 647 relocated jedis classes, 0 unrelocated, 29 messaging classes. Merged from the MagicUtils-messaging branch onto the 1.25.0 +java publish scheme. refreshReflectionAllowlist run (messaging imports shifted the getMethod marker line in BukkitMagicUtilsConsumerRegistry). --- .../build/module/MagicUtilsJvmBundlePlugin.kt | 4 + .../support/MagicUtilsProjectExtensions.kt | 32 ++ gradle.properties | 2 +- gradle/libs.versions.toml | 3 + gradle/reflection-allowlist.txt | 2 +- messaging/build.gradle.kts | 28 ++ .../magicutils/messaging/IncomingMessage.java | 73 +++++ .../messaging/LoopbackTransport.java | 63 ++++ .../magicutils/messaging/MessageBus.java | 279 ++++++++++++++++++ .../magicutils/messaging/MessageCodec.java | 163 ++++++++++ .../magicutils/messaging/MessageEnvelope.java | 144 +++++++++ .../magicutils/messaging/MessageHandler.java | 16 + .../magicutils/messaging/MessageSource.java | 114 +++++++ .../messaging/MessageTransport.java | 72 +++++ .../messaging/MessagingService.java | 248 ++++++++++++++++ .../theroer/magicutils/messaging/Target.java | 155 ++++++++++ .../messaging/redis/RedisConfig.java | 83 ++++++ .../redis/RedisMessageTransport.java | 186 ++++++++++++ .../magicutils/messaging/MessageBusTest.java | 127 ++++++++ .../messaging/MessageCodecTest.java | 61 ++++ platform-bukkit/build.gradle.kts | 1 + .../magicutils/bootstrap/BukkitBootstrap.java | 46 +++ .../bukkit/BukkitMessagingSupport.java | 77 +++++ .../bukkit/BukkitPluginMessageTransport.java | 162 ++++++++++ platform-bungee/build.gradle.kts | 1 + .../magicutils/bootstrap/BungeeBootstrap.java | 44 +++ .../bungee/BungeeMessagingSupport.java | 85 ++++++ .../bungee/BungeePluginMessageTransport.java | 168 +++++++++++ platform-velocity/build.gradle.kts | 1 + .../bootstrap/VelocityBootstrap.java | 44 +++ .../velocity/VelocityMessagingSupport.java | 87 ++++++ .../VelocityPluginMessageTransport.java | 168 +++++++++++ settings.gradle.kts | 1 + 33 files changed, 2738 insertions(+), 2 deletions(-) create mode 100644 messaging/build.gradle.kts create mode 100644 messaging/src/main/java/dev/ua/theroer/magicutils/messaging/IncomingMessage.java create mode 100644 messaging/src/main/java/dev/ua/theroer/magicutils/messaging/LoopbackTransport.java create mode 100644 messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessageBus.java create mode 100644 messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessageCodec.java create mode 100644 messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessageEnvelope.java create mode 100644 messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessageHandler.java create mode 100644 messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessageSource.java create mode 100644 messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessageTransport.java create mode 100644 messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessagingService.java create mode 100644 messaging/src/main/java/dev/ua/theroer/magicutils/messaging/Target.java create mode 100644 messaging/src/main/java/dev/ua/theroer/magicutils/messaging/redis/RedisConfig.java create mode 100644 messaging/src/main/java/dev/ua/theroer/magicutils/messaging/redis/RedisMessageTransport.java create mode 100644 messaging/src/test/java/dev/ua/theroer/magicutils/messaging/MessageBusTest.java create mode 100644 messaging/src/test/java/dev/ua/theroer/magicutils/messaging/MessageCodecTest.java create mode 100644 platform-bukkit/src/main/java/dev/ua/theroer/magicutils/messaging/bukkit/BukkitMessagingSupport.java create mode 100644 platform-bukkit/src/main/java/dev/ua/theroer/magicutils/messaging/bukkit/BukkitPluginMessageTransport.java create mode 100644 platform-bungee/src/main/java/dev/ua/theroer/magicutils/messaging/bungee/BungeeMessagingSupport.java create mode 100644 platform-bungee/src/main/java/dev/ua/theroer/magicutils/messaging/bungee/BungeePluginMessageTransport.java create mode 100644 platform-velocity/src/main/java/dev/ua/theroer/magicutils/messaging/velocity/VelocityMessagingSupport.java create mode 100644 platform-velocity/src/main/java/dev/ua/theroer/magicutils/messaging/velocity/VelocityPluginMessageTransport.java diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsJvmBundlePlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsJvmBundlePlugin.kt index 216f1d27..ec1e105e 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsJvmBundlePlugin.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsJvmBundlePlugin.kt @@ -80,6 +80,10 @@ abstract class MagicUtilsJvmBundlePlugin(private val platform: JvmBundlePlatform dependencies.add("compileOnly", project(dep.path)) } + // Ship the optional Redis transport (Jedis) so operators can enable it + // in messaging.yml; the default plugin-messaging transport needs it not. + magicUtilsBundleRedis("bundleShadow") + // Server API on the compile classpath for the plugin entrypoint; the // server provides it at runtime, so it stays out of the bundle jar // (compileOnly, not bundleShadow). The plugin descriptor is the diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/support/MagicUtilsProjectExtensions.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/support/MagicUtilsProjectExtensions.kt index c0f6b5fe..fea6974e 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/support/MagicUtilsProjectExtensions.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/support/MagicUtilsProjectExtensions.kt @@ -3,8 +3,10 @@ package dev.ua.theroer.magicutils.build.support import dev.ua.theroer.magicutils.build.module.* import org.gradle.api.Project +import org.gradle.api.artifacts.VersionCatalogsExtension import org.gradle.api.publish.PublishingExtension import org.gradle.api.publish.maven.MavenPublication +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar internal fun Project.magicUtilsModuleName(projectName: String = name): String { val namingSpec = extensions.extraProperties @@ -64,3 +66,33 @@ internal fun MavenPublication.stripPomDependencies() { } } } + +/** + * Bundles the optional Redis messaging transport (Jedis) into a standalone + * bundle and relocates it under the MagicUtils `libs` namespace, the same way + * [MagicUtilsShadedModulePlugin] relocates Jackson. + * + * MagicUtils bundles are drop-in plugins that provide the messaging runtime for + * the whole network, so shipping Jedis makes the Redis transport work out of the + * box once an operator enables it in `messaging.yml`. The default + * plugin-messaging transport needs no extra dependency, so a network can still + * run without Redis. Called by every bundle plugin so the coordinate and + * relocation prefix live in one place. + * + * @param bundleShadowConfiguration name of the shade configuration the bundle jar draws from + */ +internal fun Project.magicUtilsBundleRedis(bundleShadowConfiguration: String) { + val jedis = extensions + .getByType(VersionCatalogsExtension::class.java) + .named("libs") + .findLibrary("jedis") + .get() + .get() + dependencies.add(bundleShadowConfiguration, jedis) + + tasks.named("shadowJar", ShadowJar::class.java).configure { shadowJarTask -> + shadowJarTask.relocate("redis.clients.jedis", "dev.ua.theroer.magicutils.libs.jedis") + // Jedis pulls in Apache Commons Pool for its connection pool. + shadowJarTask.relocate("org.apache.commons.pool2", "dev.ua.theroer.magicutils.libs.commons.pool2") + } +} diff --git a/gradle.properties b/gradle.properties index 1886a057..22adb235 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,6 @@ org.gradle.caching=true org.gradle.parallel=true org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 -version=1.25.0 +version=1.26.0 # Minecraft version is now managed by gradle/targets.properties # You can set it via -Ptarget=... when running Gradle diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d56a56b9..080d0034 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,6 +9,7 @@ javaGradlePlugin = "8.14.1" annotations = "24.0.1" lombok = "1.18.44" jackson = "2.17.1" +jedis = "5.1.5" kyoriAdventure = "4.24.0" kyoriAdventureLegacy = "4.24.0" kyoriAnsi = "1.1.1" @@ -31,6 +32,8 @@ jetbrains-annotations = { module = "org.jetbrains:annotations", version.ref = "a projectlombok-lombok = { module = "org.projectlombok:lombok", version.ref = "lombok" } # Jackson +# Redis (optional messaging transport) +jedis = { module = "redis.clients:jedis", version.ref = "jedis" } jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" } jackson-dataformat-toml = { module = "com.fasterxml.jackson.dataformat:jackson-dataformat-toml", version.ref = "jackson" } jackson-dataformat-yaml = { module = "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml", version.ref = "jackson" } diff --git a/gradle/reflection-allowlist.txt b/gradle/reflection-allowlist.txt index c2283ebd..1cc32a51 100644 --- a/gradle/reflection-allowlist.txt +++ b/gradle/reflection-allowlist.txt @@ -48,7 +48,7 @@ platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/BukkitCo platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/BukkitConsoleAudience.java:176:.getMethod( platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/BukkitConsoleAudience.java:177:.getMethod( platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/BukkitConsoleAudience.java:178:.getMethod( -platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/BukkitMagicUtilsConsumerRegistry.java:138:.getMethod( +platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/BukkitMagicUtilsConsumerRegistry.java:145:.getMethod( platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/BukkitPlaceholderRegistrar.java:66:Class.forName( platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/BukkitPlaceholderRegistrar.java:71:Class.forName( platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/BukkitThreading.java:181:Class.forName( diff --git a/messaging/build.gradle.kts b/messaging/build.gradle.kts new file mode 100644 index 00000000..3b09c2bb --- /dev/null +++ b/messaging/build.gradle.kts @@ -0,0 +1,28 @@ +import dev.ua.theroer.magicutils.build.target.* + +plugins { + id("magicutils.java-library") + id("magicutils.annotation-processing") + id("magicutils.publishing") +} + +magicutilsPublish { + category = MagicUtilsPublishCategory.COMMON_MATRIX +} + +dependencies { + api(project(":platform-api")) + api(project(":config")) + implementation(libs.jackson.databind) + // Redis is an optional transport: RedisMessageTransport is only class-loaded + // when Redis is enabled, so Jedis stays compile-only here. Bundles that ship + // the Redis transport add jedis to their runtime classpath (and shade it). + compileOnly(libs.jedis) + compileOnly(libs.jetbrains.annotations) + compileOnly(libs.projectlombok.lombok) + annotationProcessor(libs.projectlombok.lombok) + testImplementation(libs.junit.jupiter) + testImplementation(libs.jedis) + testRuntimeOnly(libs.junit.platform.launcher) + testRuntimeOnly(project(":config-yaml")) +} diff --git a/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/IncomingMessage.java b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/IncomingMessage.java new file mode 100644 index 00000000..51efb500 --- /dev/null +++ b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/IncomingMessage.java @@ -0,0 +1,73 @@ +package dev.ua.theroer.magicutils.messaging; + +/** + * A received message as seen by a typed subscriber. + * + *

Lazily decodes the payload into the subscriber's requested type on first + * access to {@link #payload()}, so a handler that only inspects the + * {@link #source() source} never pays the deserialization cost.

+ * + * @param payload type + */ +public final class IncomingMessage { + private final MessageEnvelope envelope; + private final MessageCodec codec; + private final Class type; + private boolean decoded; + private T payload; + + IncomingMessage(MessageEnvelope envelope, MessageCodec codec, Class type) { + this.envelope = envelope; + this.codec = codec; + this.type = type; + } + + /** + * Returns the channel this message arrived on. + * + * @return channel name + */ + public String channel() { + return envelope.channel(); + } + + /** + * Returns the member that sent this message. + * + * @return originating source + */ + public MessageSource source() { + return envelope.source(); + } + + /** + * Returns the addressing hint the sender used. + * + * @return target + */ + public Target target() { + return envelope.target(); + } + + /** + * Returns the underlying envelope for advanced use. + * + * @return envelope + */ + public MessageEnvelope envelope() { + return envelope; + } + + /** + * Returns the decoded payload, deserializing on first access. + * + * @return decoded payload (may be null for empty payloads) + */ + public T payload() { + if (!decoded) { + payload = codec.decodePayload(envelope.payload(), type); + decoded = true; + } + return payload; + } +} diff --git a/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/LoopbackTransport.java b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/LoopbackTransport.java new file mode 100644 index 00000000..6d6b10e6 --- /dev/null +++ b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/LoopbackTransport.java @@ -0,0 +1,63 @@ +package dev.ua.theroer.magicutils.messaging; + +import org.jetbrains.annotations.Nullable; + +/** + * In-process transport that delivers published envelopes straight back to the + * local sink. + * + *

Useful as a no-network default (single server), and as a test double. Since + * {@link MessageEnvelope#acceptedBy} rejects a member's own messages, a lone + * member never sees its own traffic; wire two loopback transports together via + * {@link #link(LoopbackTransport)} to simulate two members in a unit test.

+ */ +public final class LoopbackTransport implements MessageTransport { + private volatile EnvelopeSink sink; + private volatile @Nullable LoopbackTransport peer; + + /** + * Creates an unlinked loopback transport. + */ + public LoopbackTransport() { + } + + @Override + public String name() { + return "loopback"; + } + + @Override + public void start(EnvelopeSink sink) { + this.sink = sink; + } + + /** + * Links this transport to a peer so published envelopes are delivered to it, + * emulating a two-member network in-process. + * + * @param other the peer transport + */ + public void link(LoopbackTransport other) { + this.peer = other; + other.peer = this; + } + + @Override + public void publish(MessageEnvelope envelope) { + LoopbackTransport target = peer; + EnvelopeSink localSink = sink; + if (target != null && target.sink != null) { + target.sink.accept(envelope); + } + // Deliver to self too; the bus filters out own-origin messages. + if (localSink != null) { + localSink.accept(envelope); + } + } + + @Override + public void close() { + sink = null; + peer = null; + } +} diff --git a/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessageBus.java b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessageBus.java new file mode 100644 index 00000000..009d5550 --- /dev/null +++ b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessageBus.java @@ -0,0 +1,279 @@ +package dev.ua.theroer.magicutils.messaging; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Predicate; + +import dev.ua.theroer.magicutils.platform.ListenerSubscription; +import dev.ua.theroer.magicutils.platform.PlatformLogger; +import org.jetbrains.annotations.Nullable; + +/** + * Typed cross-server publish/subscribe bus for MagicUtils plugins. + * + *

Wraps a pluggable {@link MessageTransport} (the default per-platform + * plugin-messaging transport, or Redis) with a channel-oriented, typed API: + * {@link #subscribe(String, Class, MessageHandler)} and + * {@link #publish(Target, String, Object)}. Payloads are serialized with a shared + * {@link MessageCodec}. The bus applies {@link Target} addressing on receipt so + * routing behaves identically regardless of which transport is in use.

+ * + *

The bus is {@link AutoCloseable} and is meant to be registered with a + * {@code MagicRuntime} so it is torn down with the plugin.

+ */ +public final class MessageBus implements AutoCloseable { + private final MessageSource self; + private final MessageTransport transport; + private final MessageCodec codec; + private final PlatformLogger logger; + private final Map>> subscriptions = new ConcurrentHashMap<>(); + private volatile @Nullable Predicate hostsPlayer; + private volatile boolean closed; + + private MessageBus(Builder builder) { + this.self = builder.self; + this.transport = builder.transport; + this.codec = builder.codec != null ? builder.codec : new MessageCodec(); + this.logger = builder.logger; + this.hostsPlayer = builder.hostsPlayer; + this.transport.start(this::dispatch); + } + + /** + * Creates a bus builder. + * + * @param self this member's identity + * @param transport underlying transport + * @return builder + */ + public static Builder builder(MessageSource self, MessageTransport transport) { + return new Builder(self, transport); + } + + /** + * Returns this member's identity. + * + * @return self source + */ + public MessageSource self() { + return self; + } + + /** + * Returns the underlying transport. + * + * @return transport + */ + public MessageTransport transport() { + return transport; + } + + /** + * Returns the codec used for payload serialization. + * + * @return message codec + */ + public MessageCodec codec() { + return codec; + } + + /** + * Sets the predicate answering whether this member hosts a given player id. + * + *

Used to honour {@link Target#player(UUID)} on backends. Ignored on the + * proxy. May be updated at any time (for example wired to the online-player + * registry).

+ * + * @param hostsPlayer predicate, or null to disable player-target delivery + */ + public void hostsPlayer(@Nullable Predicate hostsPlayer) { + this.hostsPlayer = hostsPlayer; + } + + /** + * Publishes a payload on a channel to the given target. + * + * @param target addressing hint + * @param channel logical channel name + * @param payload payload object (serialized with the shared codec; may be null) + */ + public void publish(Target target, String channel, @Nullable Object payload) { + if (closed) { + throw new IllegalStateException("MessageBus is closed"); + } + byte[] bytes = codec.encodePayload(payload); + MessageEnvelope envelope = MessageEnvelope.create(channel, target, self, bytes); + try { + transport.publish(envelope); + } catch (RuntimeException error) { + logger.warn("Failed to publish message on channel '" + channel + "'", error); + throw error; + } + } + + /** + * Broadcasts a payload on a channel to every subscriber on the network. + * + * @param channel logical channel name + * @param payload payload object + */ + public void broadcast(String channel, @Nullable Object payload) { + publish(Target.broadcast(), channel, payload); + } + + /** + * Subscribes a typed handler to a channel. + * + * @param channel logical channel name + * @param type payload type + * @param handler message handler + * @param payload type + * @return subscription handle; close it to unsubscribe + */ + public ListenerSubscription subscribe(String channel, Class type, MessageHandler handler) { + Subscription subscription = new Subscription<>(channel, type, handler); + subscriptions.computeIfAbsent(channel, key -> new CopyOnWriteArrayList<>()).add(subscription); + return () -> { + CopyOnWriteArrayList> list = subscriptions.get(channel); + if (list != null) { + list.remove(subscription); + if (list.isEmpty()) { + subscriptions.remove(channel, list); + } + } + }; + } + + private void dispatch(MessageEnvelope envelope) { + if (closed) { + return; + } + if (!envelope.acceptedBy(self, hostsPlayer)) { + return; + } + List> handlers = subscriptions.get(envelope.channel()); + if (handlers == null || handlers.isEmpty()) { + return; + } + for (Subscription subscription : handlers) { + deliver(subscription, envelope); + } + } + + private void deliver(Subscription subscription, MessageEnvelope envelope) { + try { + IncomingMessage message = new IncomingMessage<>(envelope, codec, subscription.type); + subscription.handler.onMessage(message); + } catch (RuntimeException error) { + logger.warn("Messaging handler for channel '" + envelope.channel() + "' threw", error); + } + } + + @Override + public void close() { + if (closed) { + return; + } + closed = true; + subscriptions.clear(); + try { + transport.close(); + } catch (Exception error) { + logger.warn("Failed to close messaging transport '" + transport.name() + "'", error); + } + } + + private record Subscription(String channel, Class type, MessageHandler handler) { + } + + /** + * Builder for {@link MessageBus}. + */ + public static final class Builder { + private final MessageSource self; + private final MessageTransport transport; + private @Nullable MessageCodec codec; + private PlatformLogger logger = NoopLogger.INSTANCE; + private @Nullable Predicate hostsPlayer; + + private Builder(MessageSource self, MessageTransport transport) { + this.self = java.util.Objects.requireNonNull(self, "self"); + this.transport = java.util.Objects.requireNonNull(transport, "transport"); + } + + /** + * Overrides the codec (and thus the Jackson mapper) used for payloads. + * + * @param codec message codec + * @return builder + */ + public Builder codec(MessageCodec codec) { + this.codec = codec; + return this; + } + + /** + * Sets the logger for transport/handler failures. + * + * @param logger platform logger + * @return builder + */ + public Builder logger(PlatformLogger logger) { + if (logger != null) { + this.logger = logger; + } + return this; + } + + /** + * Sets the initial player-hosting predicate for {@link Target#player(UUID)} delivery. + * + * @param hostsPlayer predicate, or null + * @return builder + */ + public Builder hostsPlayer(@Nullable Predicate hostsPlayer) { + this.hostsPlayer = hostsPlayer; + return this; + } + + /** + * Builds and starts the bus. + * + * @return message bus + */ + public MessageBus build() { + return new MessageBus(this); + } + } + + private enum NoopLogger implements PlatformLogger { + INSTANCE; + + @Override + public void info(String message) { + } + + @Override + public void warn(String message) { + } + + @Override + public void warn(String message, Throwable throwable) { + } + + @Override + public void error(String message) { + } + + @Override + public void error(String message, Throwable throwable) { + } + + @Override + public void debug(String message) { + } + } +} diff --git a/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessageCodec.java b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessageCodec.java new file mode 100644 index 00000000..b98c2ac3 --- /dev/null +++ b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessageCodec.java @@ -0,0 +1,163 @@ +package dev.ua.theroer.magicutils.messaging; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.UUID; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * Serializes {@link MessageEnvelope} instances and user payloads to/from bytes. + * + *

The envelope header is encoded as compact JSON; the user payload is encoded + * separately by {@link #encodePayload(Object)} and carried as opaque bytes inside + * the envelope. This keeps the transport free of any knowledge of user types and + * lets a single codec instance be shared across transports.

+ */ +public final class MessageCodec { + private final ObjectMapper mapper; + + /** + * Creates a codec with a default {@link ObjectMapper}. + */ + public MessageCodec() { + this(new ObjectMapper()); + } + + /** + * Creates a codec backed by the given mapper. + * + * @param mapper Jackson mapper used for both envelope and payloads + */ + public MessageCodec(ObjectMapper mapper) { + this.mapper = mapper; + } + + /** + * Returns the underlying mapper (for advanced payload handling). + * + * @return object mapper + */ + public ObjectMapper mapper() { + return mapper; + } + + /** + * Serializes a user payload object to bytes. + * + * @param payload payload object (may be null → empty bytes) + * @return serialized payload bytes + */ + public byte[] encodePayload(Object payload) { + if (payload == null) { + return new byte[0]; + } + try { + return mapper.writeValueAsBytes(payload); + } catch (IOException error) { + throw new UncheckedIOException("Failed to serialize messaging payload", error); + } + } + + /** + * Deserializes payload bytes into the requested type. + * + * @param payload serialized payload bytes + * @param type target type + * @param payload type + * @return deserialized payload, or null when the bytes are empty + */ + public T decodePayload(byte[] payload, Class type) { + if (payload == null || payload.length == 0) { + return null; + } + try { + return mapper.readValue(payload, type); + } catch (IOException error) { + throw new UncheckedIOException("Failed to deserialize messaging payload", error); + } + } + + /** + * Serializes a full envelope (header + embedded payload) to bytes. + * + * @param envelope envelope to encode + * @return serialized envelope bytes + */ + public byte[] encode(MessageEnvelope envelope) { + ObjectNode root = mapper.createObjectNode(); + root.put("id", envelope.id().toString()); + root.put("channel", envelope.channel()); + root.put("ts", envelope.timestamp()); + + ObjectNode target = root.putObject("target"); + target.put("kind", envelope.target().kind().name()); + if (envelope.target().serverName() != null) { + target.put("server", envelope.target().serverName()); + } + if (envelope.target().playerId() != null) { + target.put("player", envelope.target().playerId().toString()); + } + + ObjectNode source = root.putObject("source"); + source.put("id", envelope.source().id()); + source.put("type", envelope.source().type().name()); + if (envelope.source().serverName() != null) { + source.put("server", envelope.source().serverName()); + } + + root.put("payload", envelope.payload()); + try { + return mapper.writeValueAsBytes(root); + } catch (IOException error) { + throw new UncheckedIOException("Failed to serialize messaging envelope", error); + } + } + + /** + * Deserializes envelope bytes back into an {@link MessageEnvelope}. + * + * @param bytes serialized envelope bytes + * @return decoded envelope + */ + public MessageEnvelope decode(byte[] bytes) { + try { + JsonNode root = mapper.readTree(bytes); + UUID id = UUID.fromString(root.path("id").asText()); + String channel = root.path("channel").asText(); + long ts = root.path("ts").asLong(); + + JsonNode targetNode = root.path("target"); + Target target = decodeTarget(targetNode); + + JsonNode sourceNode = root.path("source"); + MessageSource.Type type = MessageSource.Type.valueOf(sourceNode.path("type").asText()); + String sourceId = sourceNode.path("id").asText(); + String sourceServer = sourceNode.hasNonNull("server") ? sourceNode.get("server").asText() : null; + MessageSource source = type == MessageSource.Type.PROXY + ? MessageSource.proxy(sourceId) + : MessageSource.backend(sourceId, sourceServer); + + byte[] payload = root.path("payload").binaryValue(); + if (payload == null) { + payload = new byte[0]; + } + return new MessageEnvelope(id, channel, target, source, payload, ts); + } catch (IOException error) { + throw new UncheckedIOException("Failed to deserialize messaging envelope", error); + } + } + + private static Target decodeTarget(JsonNode targetNode) { + Target.Kind kind = Target.Kind.valueOf(targetNode.path("kind").asText()); + return switch (kind) { + case BROADCAST -> Target.broadcast(); + case ALL_BACKENDS -> Target.allBackends(); + case PROXY -> Target.proxy(); + case SERVER -> Target.server(targetNode.path("server").asText()); + case PLAYER -> Target.player(UUID.fromString(targetNode.path("player").asText())); + }; + } +} diff --git a/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessageEnvelope.java b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessageEnvelope.java new file mode 100644 index 00000000..15383f08 --- /dev/null +++ b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessageEnvelope.java @@ -0,0 +1,144 @@ +package dev.ua.theroer.magicutils.messaging; + +import java.util.Objects; +import java.util.UUID; + +import org.jetbrains.annotations.Nullable; + +/** + * Wire representation of a single message on the bus. + * + *

The envelope is transport-neutral: both the default plugin-messaging + * transport and the Redis transport serialize it to bytes (via + * {@link MessageCodec}) and reconstruct it on the receiving side. It carries the + * logical {@code channel}, the {@link Target addressing hint}, the originating + * {@link MessageSource}, and the already-serialized {@code payload} bytes so the + * transport never needs to understand the user payload type.

+ */ +public final class MessageEnvelope { + private final UUID id; + private final String channel; + private final Target target; + private final MessageSource source; + private final byte[] payload; + private final long timestamp; + + /** + * Creates an envelope. + * + * @param id unique message id (for de-duplication / correlation) + * @param channel logical channel name + * @param target addressing hint + * @param source originating member + * @param payload serialized payload bytes + * @param timestamp epoch millis when the message was created + */ + public MessageEnvelope(UUID id, String channel, Target target, MessageSource source, + byte[] payload, long timestamp) { + this.id = Objects.requireNonNull(id, "id"); + this.channel = Objects.requireNonNull(channel, "channel"); + this.target = Objects.requireNonNull(target, "target"); + this.source = Objects.requireNonNull(source, "source"); + this.payload = Objects.requireNonNull(payload, "payload"); + this.timestamp = timestamp; + } + + /** + * Creates an envelope with a fresh id and the current timestamp. + * + * @param channel logical channel name + * @param target addressing hint + * @param source originating member + * @param payload serialized payload bytes + * @return new envelope + */ + public static MessageEnvelope create(String channel, Target target, MessageSource source, byte[] payload) { + return new MessageEnvelope(UUID.randomUUID(), channel, target, source, payload, System.currentTimeMillis()); + } + + /** + * Returns the unique message id. + * + * @return message id + */ + public UUID id() { + return id; + } + + /** + * Returns the logical channel name. + * + * @return channel name + */ + public String channel() { + return channel; + } + + /** + * Returns the addressing hint. + * + * @return target + */ + public Target target() { + return target; + } + + /** + * Returns the originating member. + * + * @return source + */ + public MessageSource source() { + return source; + } + + /** + * Returns the serialized payload bytes. + * + * @return payload bytes (do not mutate) + */ + public byte[] payload() { + return payload; + } + + /** + * Returns the creation timestamp (epoch millis). + * + * @return timestamp + */ + public long timestamp() { + return timestamp; + } + + /** + * Decides whether a local member should accept this envelope given the target. + * + *

Transports that cannot pre-filter (for example Redis pub/sub, which + * broadcasts to every subscriber) call this on receipt so addressing is honoured + * uniformly regardless of transport.

+ * + * @param self the local member + * @param hostsPlayer predicate answering whether the local member hosts a player id; + * may be null on the proxy or when player hosting is unknown + * @return true when the local member is an intended recipient + */ + public boolean acceptedBy(MessageSource self, @Nullable java.util.function.Predicate hostsPlayer) { + Objects.requireNonNull(self, "self"); + // Never deliver a message back to its own originator. + if (self.id().equals(source.id())) { + return false; + } + return switch (target.kind()) { + case BROADCAST -> true; + case PROXY -> self.isProxy(); + case ALL_BACKENDS -> !self.isProxy(); + case SERVER -> !self.isProxy() + && target.serverName() != null + && target.serverName().equalsIgnoreCase(self.serverName()); + case PLAYER -> !self.isProxy() + && hostsPlayer != null + && target.playerId() != null + && hostsPlayer.test(target.playerId()); + }; + } +} diff --git a/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessageHandler.java b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessageHandler.java new file mode 100644 index 00000000..9bd80f7a --- /dev/null +++ b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessageHandler.java @@ -0,0 +1,16 @@ +package dev.ua.theroer.magicutils.messaging; + +/** + * Callback invoked for each message received on a subscribed channel. + * + * @param payload type + */ +@FunctionalInterface +public interface MessageHandler { + /** + * Handles a received message. + * + * @param message received message with lazy payload decoding + */ + void onMessage(IncomingMessage message); +} diff --git a/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessageSource.java b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessageSource.java new file mode 100644 index 00000000..665c3722 --- /dev/null +++ b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessageSource.java @@ -0,0 +1,114 @@ +package dev.ua.theroer.magicutils.messaging; + +import java.util.Objects; + +import org.jetbrains.annotations.Nullable; + +/** + * Identifies a member of the network for message origin and routing. + * + *

A source has a stable {@link #id() id} (unique per running process) and a + * {@link #type() type} distinguishing the proxy from backend servers. Backends + * additionally carry a {@link #serverName() server name} as registered with the + * proxy, which the proxy uses to route {@link Target#server(String)} messages.

+ */ +public final class MessageSource { + /** + * Whether a network member is a proxy or a backend server. + */ + public enum Type { + /** A proxy (Velocity or BungeeCord). */ + PROXY, + /** A backend Minecraft server. */ + BACKEND + } + + private final String id; + private final Type type; + private final @Nullable String serverName; + + private MessageSource(String id, Type type, @Nullable String serverName) { + this.id = Objects.requireNonNull(id, "id"); + this.type = Objects.requireNonNull(type, "type"); + this.serverName = serverName; + } + + /** + * Creates a proxy source. + * + * @param id unique process id + * @return proxy source + */ + public static MessageSource proxy(String id) { + return new MessageSource(id, Type.PROXY, null); + } + + /** + * Creates a backend source. + * + * @param id unique process id + * @param serverName server name registered with the proxy (may be null when unknown) + * @return backend source + */ + public static MessageSource backend(String id, @Nullable String serverName) { + return new MessageSource(id, Type.BACKEND, serverName); + } + + /** + * Returns the unique process id of this member. + * + * @return member id + */ + public String id() { + return id; + } + + /** + * Returns the member type. + * + * @return member type + */ + public Type type() { + return type; + } + + /** + * Returns the backend server name, when known. + * + * @return server name, or null for proxies / unknown backends + */ + public @Nullable String serverName() { + return serverName; + } + + /** + * Returns whether this source is a proxy. + * + * @return true when a proxy + */ + public boolean isProxy() { + return type == Type.PROXY; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof MessageSource that)) { + return false; + } + return id.equals(that.id) && type == that.type && Objects.equals(serverName, that.serverName); + } + + @Override + public int hashCode() { + return Objects.hash(id, type, serverName); + } + + @Override + public String toString() { + return "MessageSource[" + type + ":" + id + + (serverName != null ? "@" + serverName : "") + "]"; + } +} diff --git a/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessageTransport.java b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessageTransport.java new file mode 100644 index 00000000..84dbe018 --- /dev/null +++ b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessageTransport.java @@ -0,0 +1,72 @@ +package dev.ua.theroer.magicutils.messaging; + +/** + * Low-level transport that moves encoded envelopes between network members. + * + *

A transport knows nothing about payload types or channels beyond routing: + * it accepts an already-encoded {@link MessageEnvelope} on {@link #publish}, + * and pushes decoded envelopes it receives to the {@link EnvelopeSink} supplied + * at {@link #start}. The two shipped implementations are the default + * plugin-messaging transport (per platform) and the Redis pub/sub transport; + * both are interchangeable behind this SPI, so a network can run with or without + * Redis.

+ */ +public interface MessageTransport extends AutoCloseable { + /** + * Receives envelopes arriving from the network. + */ + @FunctionalInterface + interface EnvelopeSink { + /** + * Accepts a received envelope for local dispatch. + * + * @param envelope decoded envelope + */ + void accept(MessageEnvelope envelope); + } + + /** + * Human-readable transport name for diagnostics/logging (for example "plugin-messaging", "redis"). + * + * @return transport name + */ + String name(); + + /** + * Starts the transport and begins delivering received envelopes to the sink. + * + *

Called once before any {@link #publish}. Implementations that need a + * background subscriber thread or channel registration set it up here.

+ * + * @param sink destination for received envelopes + */ + void start(EnvelopeSink sink); + + /** + * Publishes an encoded envelope to the network. + * + *

The envelope's {@link Target} is advisory; transports that cannot route + * precisely broadcast and rely on receiver-side filtering.

+ * + * @param envelope envelope to send + */ + void publish(MessageEnvelope envelope); + + /** + * Returns whether the transport is currently connected/operational. + * + *

The default plugin-messaging transport reports connectivity based on + * whether the platform channel is registered; Redis reports pool health.

+ * + * @return true when the transport can currently deliver messages + */ + default boolean isConnected() { + return true; + } + + /** + * Stops the transport and releases resources. + */ + @Override + void close(); +} diff --git a/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessagingService.java b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessagingService.java new file mode 100644 index 00000000..91616b07 --- /dev/null +++ b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/MessagingService.java @@ -0,0 +1,248 @@ +package dev.ua.theroer.magicutils.messaging; + +import java.util.Objects; +import java.util.UUID; +import java.util.function.Predicate; +import java.util.function.Supplier; + +import dev.ua.theroer.magicutils.messaging.redis.RedisConfig; +import dev.ua.theroer.magicutils.messaging.redis.RedisMessageTransport; +import dev.ua.theroer.magicutils.platform.ListenerSubscription; +import dev.ua.theroer.magicutils.platform.PlatformLogger; +import org.jetbrains.annotations.Nullable; + +/** + * High-level entry point for MagicUtils cross-server messaging. + * + *

Owns a {@link MessageBus} over a chosen {@link MessageTransport} and is the + * component registered with a {@code MagicRuntime}. The transport is selected at + * build time: when Redis is enabled in {@link RedisConfig.Redis} a + * {@link RedisMessageTransport} is used; otherwise the platform's default + * plugin-messaging transport is used, so the network runs without Redis.

+ */ +public final class MessagingService implements AutoCloseable { + private final MessageBus bus; + private final String transportName; + + private MessagingService(MessageBus bus, String transportName) { + this.bus = bus; + this.transportName = transportName; + } + + /** + * Creates a service builder. + * + * @param self this member's identity + * @return builder + */ + public static Builder builder(MessageSource self) { + return new Builder(self); + } + + /** + * Returns the underlying bus. + * + * @return message bus + */ + public MessageBus bus() { + return bus; + } + + /** + * Returns the name of the active transport ("redis" or "plugin-messaging" etc.). + * + * @return transport name + */ + public String transportName() { + return transportName; + } + + /** + * Returns whether the active transport is currently connected. + * + * @return true when connected + */ + public boolean isConnected() { + return bus.transport().isConnected(); + } + + /** + * Publishes a payload on a channel to a target. Delegates to the bus. + * + * @param target addressing hint + * @param channel channel name + * @param payload payload object + */ + public void publish(Target target, String channel, @Nullable Object payload) { + bus.publish(target, channel, payload); + } + + /** + * Broadcasts a payload on a channel. Delegates to the bus. + * + * @param channel channel name + * @param payload payload object + */ + public void broadcast(String channel, @Nullable Object payload) { + bus.broadcast(channel, payload); + } + + /** + * Subscribes a typed handler to a channel. Delegates to the bus. + * + * @param channel channel name + * @param type payload type + * @param handler handler + * @param payload type + * @return subscription handle + */ + public ListenerSubscription subscribe(String channel, Class type, MessageHandler handler) { + return bus.subscribe(channel, type, handler); + } + + @Override + public void close() { + bus.close(); + } + + /** + * Builder that wires the transport and bus together. + */ + public static final class Builder { + private final MessageSource self; + private @Nullable RedisConfig.Redis redis; + private @Nullable Supplier defaultTransport; + private @Nullable MessageCodec codec; + private PlatformLogger logger = NoopLogger.INSTANCE; + private @Nullable Predicate hostsPlayer; + + private Builder(MessageSource self) { + this.self = Objects.requireNonNull(self, "self"); + } + + /** + * Supplies Redis settings; when {@code enabled}, the Redis transport is used. + * + * @param redis redis settings (nullable → treated as disabled) + * @return builder + */ + public Builder redis(@Nullable RedisConfig.Redis redis) { + this.redis = redis; + return this; + } + + /** + * Supplies the platform's default (plugin-messaging) transport factory, + * used when Redis is disabled or unavailable. + * + * @param defaultTransport factory for the default transport + * @return builder + */ + public Builder defaultTransport(Supplier defaultTransport) { + this.defaultTransport = defaultTransport; + return this; + } + + /** + * Overrides the codec used for envelope/payload serialization. + * + * @param codec message codec + * @return builder + */ + public Builder codec(MessageCodec codec) { + this.codec = codec; + return this; + } + + /** + * Sets the logger. + * + * @param logger platform logger + * @return builder + */ + public Builder logger(PlatformLogger logger) { + if (logger != null) { + this.logger = logger; + } + return this; + } + + /** + * Sets the player-hosting predicate for {@link Target#player(UUID)} delivery. + * + * @param hostsPlayer predicate + * @return builder + */ + public Builder hostsPlayer(@Nullable Predicate hostsPlayer) { + this.hostsPlayer = hostsPlayer; + return this; + } + + /** + * Builds the service, selecting and starting the transport. + * + * @return messaging service + */ + public MessagingService build() { + MessageCodec resolvedCodec = codec != null ? codec : new MessageCodec(); + MessageTransport transport = resolveTransport(resolvedCodec); + MessageBus bus = MessageBus.builder(self, transport) + .codec(resolvedCodec) + .logger(logger) + .hostsPlayer(hostsPlayer) + .build(); + return new MessagingService(bus, transport.name()); + } + + private MessageTransport resolveTransport(MessageCodec resolvedCodec) { + if (redis != null && redis.isEnabled()) { + try { + return new RedisMessageTransport(redis, resolvedCodec, logger); + } catch (RuntimeException | LinkageError error) { + logger.error("Redis transport requested but could not be initialized; " + + "falling back to the default transport", asThrowable(error)); + } + } + if (defaultTransport != null) { + MessageTransport transport = defaultTransport.get(); + if (transport != null) { + return transport; + } + } + // Last resort: a self-contained loopback so the bus is always usable. + return new LoopbackTransport(); + } + + private static Throwable asThrowable(Object error) { + return error instanceof Throwable throwable ? throwable : new RuntimeException(String.valueOf(error)); + } + } + + private enum NoopLogger implements PlatformLogger { + INSTANCE; + + @Override + public void info(String message) { + } + + @Override + public void warn(String message) { + } + + @Override + public void warn(String message, Throwable throwable) { + } + + @Override + public void error(String message) { + } + + @Override + public void error(String message, Throwable throwable) { + } + + @Override + public void debug(String message) { + } + } +} diff --git a/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/Target.java b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/Target.java new file mode 100644 index 00000000..90e31a5b --- /dev/null +++ b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/Target.java @@ -0,0 +1,155 @@ +package dev.ua.theroer.magicutils.messaging; + +import java.util.Objects; +import java.util.UUID; + +import org.jetbrains.annotations.Nullable; + +/** + * Addressing hint describing which network members should receive a message. + * + *

Targets are transport-agnostic. The default plugin-messaging transport and + * the Redis transport interpret each {@link Kind} on a best-effort basis; where a + * transport cannot honour a target precisely it falls back to a broadcast and + * lets receivers filter by {@link #serverName()} / {@link #playerId()}.

+ */ +public final class Target { + /** + * Classifies the intended recipients of a message. + */ + public enum Kind { + /** Deliver to every network member subscribed to the channel. */ + BROADCAST, + /** Deliver to every backend server (proxy excluded). */ + ALL_BACKENDS, + /** Deliver to the proxy only. */ + PROXY, + /** Deliver to a single backend server identified by {@link #serverName()}. */ + SERVER, + /** Deliver to the backend currently hosting {@link #playerId()}. */ + PLAYER + } + + private static final Target BROADCAST = new Target(Kind.BROADCAST, null, null); + private static final Target ALL_BACKENDS = new Target(Kind.ALL_BACKENDS, null, null); + private static final Target PROXY = new Target(Kind.PROXY, null, null); + + private final Kind kind; + private final @Nullable String serverName; + private final @Nullable UUID playerId; + + private Target(Kind kind, @Nullable String serverName, @Nullable UUID playerId) { + this.kind = kind; + this.serverName = serverName; + this.playerId = playerId; + } + + /** + * A message for every subscriber on the channel, on every member. + * + * @return broadcast target + */ + public static Target broadcast() { + return BROADCAST; + } + + /** + * A message for every backend server, excluding the proxy. + * + * @return all-backends target + */ + public static Target allBackends() { + return ALL_BACKENDS; + } + + /** + * A message for the proxy only. + * + * @return proxy target + */ + public static Target proxy() { + return PROXY; + } + + /** + * A message for a single backend server by its registered name. + * + * @param serverName backend server name (as known to the proxy) + * @return server target + */ + public static Target server(String serverName) { + return new Target(Kind.SERVER, requireText(serverName, "serverName"), null); + } + + /** + * A message for whichever backend currently hosts the given player. + * + * @param playerId player UUID + * @return player target + */ + public static Target player(UUID playerId) { + return new Target(Kind.PLAYER, null, Objects.requireNonNull(playerId, "playerId")); + } + + /** + * Returns the target kind. + * + * @return target kind + */ + public Kind kind() { + return kind; + } + + /** + * Returns the destination server name for {@link Kind#SERVER}. + * + * @return server name, or null for other kinds + */ + public @Nullable String serverName() { + return serverName; + } + + /** + * Returns the destination player id for {@link Kind#PLAYER}. + * + * @return player id, or null for other kinds + */ + public @Nullable UUID playerId() { + return playerId; + } + + private static String requireText(String value, String field) { + Objects.requireNonNull(value, field); + if (value.isBlank()) { + throw new IllegalArgumentException(field + " must not be blank"); + } + return value; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof Target target)) { + return false; + } + return kind == target.kind + && Objects.equals(serverName, target.serverName) + && Objects.equals(playerId, target.playerId); + } + + @Override + public int hashCode() { + return Objects.hash(kind, serverName, playerId); + } + + @Override + public String toString() { + return switch (kind) { + case SERVER -> "Target[server=" + serverName + "]"; + case PLAYER -> "Target[player=" + playerId + "]"; + default -> "Target[" + kind + "]"; + }; + } +} diff --git a/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/redis/RedisConfig.java b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/redis/RedisConfig.java new file mode 100644 index 00000000..0f3d5ae1 --- /dev/null +++ b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/redis/RedisConfig.java @@ -0,0 +1,83 @@ +package dev.ua.theroer.magicutils.messaging.redis; + +import dev.ua.theroer.magicutils.config.annotations.Comment; +import dev.ua.theroer.magicutils.config.annotations.ConfigFile; +import dev.ua.theroer.magicutils.config.annotations.ConfigReloadable; +import dev.ua.theroer.magicutils.config.annotations.ConfigSection; +import dev.ua.theroer.magicutils.config.annotations.ConfigValue; +import lombok.Data; + +/** + * Configuration for the optional Redis messaging transport. + * + *

Stored in {@code messaging.{ext}}. When {@code enabled} is false the + * messaging service falls back to the default plugin-messaging transport, so a + * network can run entirely without Redis.

+ */ +@ConfigFile("messaging.{ext}") +@ConfigReloadable(sections = { "redis" }) +@Comment("MagicUtils cross-server messaging configuration") +@Data +public class RedisConfig { + /** + * Creates a config instance with default values. + */ + public RedisConfig() { + } + + @ConfigSection("redis") + @Comment("Redis pub/sub transport. When disabled, the default plugin-messaging transport is used instead.") + private Redis redis = new Redis(); + + /** + * Redis connection and channel settings. + */ + @Data + public static class Redis { + /** + * Creates default Redis settings. + */ + public Redis() { + } + + @ConfigValue("enabled") + @Comment("Use Redis for cross-server messaging. If false, plugin messaging is used.") + private boolean enabled = false; + + @ConfigValue("host") + @Comment("Redis host") + private String host = "127.0.0.1"; + + @ConfigValue("port") + @Comment("Redis port") + private int port = 6379; + + @ConfigValue("username") + @Comment("Redis username (ACL); leave blank for legacy AUTH") + private String username = ""; + + @ConfigValue("password") + @Comment("Redis password; leave blank when unauthenticated") + private String password = ""; + + @ConfigValue("database") + @Comment("Redis database index") + private int database = 0; + + @ConfigValue("ssl") + @Comment("Connect using TLS") + private boolean ssl = false; + + @ConfigValue("timeout-millis") + @Comment("Socket/connect timeout in milliseconds") + private int timeoutMillis = 2000; + + @ConfigValue("channel") + @Comment("Redis pub/sub channel all MagicUtils members share") + private String channel = "magicutils:bus"; + + @ConfigValue("pool-max-total") + @Comment("Maximum pooled connections") + private int poolMaxTotal = 8; + } +} diff --git a/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/redis/RedisMessageTransport.java b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/redis/RedisMessageTransport.java new file mode 100644 index 00000000..c91d4618 --- /dev/null +++ b/messaging/src/main/java/dev/ua/theroer/magicutils/messaging/redis/RedisMessageTransport.java @@ -0,0 +1,186 @@ +package dev.ua.theroer.magicutils.messaging.redis; + +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; + +import dev.ua.theroer.magicutils.messaging.MessageCodec; +import dev.ua.theroer.magicutils.messaging.MessageEnvelope; +import dev.ua.theroer.magicutils.messaging.MessageTransport; +import dev.ua.theroer.magicutils.platform.PlatformLogger; + +import redis.clients.jedis.BinaryJedisPubSub; +import redis.clients.jedis.HostAndPort; +import redis.clients.jedis.Jedis; +import redis.clients.jedis.JedisPool; +import redis.clients.jedis.JedisPoolConfig; +import redis.clients.jedis.DefaultJedisClientConfig; + +/** + * Redis pub/sub messaging transport (backed by Jedis). + * + *

Publishing borrows a pooled connection and issues {@code PUBLISH channel bytes}; + * receiving runs a dedicated daemon thread blocked in {@code SUBSCRIBE}, reconnecting + * with backoff when the connection drops. Redis fans a message out to every + * subscriber, so {@link MessageEnvelope} carries the {@link dev.ua.theroer.magicutils.messaging.Target} + * and the bus filters on receipt.

+ * + *

This class references Jedis types directly and is only loaded when the Redis + * transport is selected, keeping Jedis an optional dependency of the messaging + * module.

+ */ +public final class RedisMessageTransport implements MessageTransport { + private final RedisConfig.Redis config; + private final MessageCodec codec; + private final PlatformLogger logger; + private final byte[] channelBytes; + + private final AtomicBoolean running = new AtomicBoolean(false); + private volatile JedisPool pool; + private volatile Subscriber subscriber; + private volatile Thread subscriberThread; + private volatile EnvelopeSink sink; + + /** + * Creates a Redis transport from resolved settings. + * + * @param config redis settings + * @param codec envelope codec + * @param logger platform logger + */ + public RedisMessageTransport(RedisConfig.Redis config, MessageCodec codec, PlatformLogger logger) { + this.config = Objects.requireNonNull(config, "config"); + this.codec = Objects.requireNonNull(codec, "codec"); + this.logger = Objects.requireNonNull(logger, "logger"); + this.channelBytes = config.getChannel().getBytes(java.nio.charset.StandardCharsets.UTF_8); + } + + @Override + public String name() { + return "redis"; + } + + @Override + public void start(EnvelopeSink sink) { + if (!running.compareAndSet(false, true)) { + return; + } + this.sink = Objects.requireNonNull(sink, "sink"); + this.pool = buildPool(); + + Thread thread = new Thread(this::runSubscriber, "MagicUtils-Redis-Subscriber"); + thread.setDaemon(true); + this.subscriberThread = thread; + thread.start(); + } + + @Override + public void publish(MessageEnvelope envelope) { + JedisPool current = pool; + if (!running.get() || current == null) { + throw new IllegalStateException("Redis transport is not running"); + } + byte[] bytes = codec.encode(envelope); + try (Jedis jedis = current.getResource()) { + jedis.publish(channelBytes, bytes); + } + } + + @Override + public boolean isConnected() { + JedisPool current = pool; + return running.get() && current != null && !current.isClosed(); + } + + @Override + public void close() { + if (!running.compareAndSet(true, false)) { + return; + } + Subscriber current = subscriber; + if (current != null && current.isSubscribed()) { + try { + current.unsubscribe(); + } catch (RuntimeException ignored) { + // best effort + } + } + Thread thread = subscriberThread; + if (thread != null) { + thread.interrupt(); + } + JedisPool current2 = pool; + if (current2 != null) { + current2.close(); + } + pool = null; + subscriber = null; + subscriberThread = null; + } + + private JedisPool buildPool() { + JedisPoolConfig poolConfig = new JedisPoolConfig(); + poolConfig.setMaxTotal(Math.max(1, config.getPoolMaxTotal())); + poolConfig.setTestOnBorrow(true); + + DefaultJedisClientConfig.Builder clientConfig = DefaultJedisClientConfig.builder() + .connectionTimeoutMillis(config.getTimeoutMillis()) + .socketTimeoutMillis(config.getTimeoutMillis()) + .database(config.getDatabase()) + .ssl(config.isSsl()); + if (!config.getPassword().isBlank()) { + clientConfig.password(config.getPassword()); + } + if (!config.getUsername().isBlank()) { + clientConfig.user(config.getUsername()); + } + HostAndPort address = new HostAndPort(config.getHost(), config.getPort()); + return new JedisPool(poolConfig, address, clientConfig.build()); + } + + private void runSubscriber() { + long backoffMillis = 500L; + while (running.get()) { + Subscriber current = new Subscriber(); + this.subscriber = current; + try (Jedis jedis = pool.getResource()) { + backoffMillis = 500L; + jedis.subscribe(current, channelBytes); + } catch (RuntimeException error) { + if (!running.get()) { + return; + } + logger.warn("Redis subscriber disconnected, reconnecting in " + backoffMillis + "ms", error); + } + if (!running.get()) { + return; + } + try { + Thread.sleep(backoffMillis); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return; + } + backoffMillis = Math.min(backoffMillis * 2, 10_000L); + } + } + + private void onRedisMessage(byte[] payload) { + EnvelopeSink target = sink; + if (target == null) { + return; + } + try { + MessageEnvelope envelope = codec.decode(payload); + target.accept(envelope); + } catch (RuntimeException error) { + logger.warn("Failed to decode message from Redis", error); + } + } + + private final class Subscriber extends BinaryJedisPubSub { + @Override + public void onMessage(byte[] channel, byte[] message) { + onRedisMessage(message); + } + } +} diff --git a/messaging/src/test/java/dev/ua/theroer/magicutils/messaging/MessageBusTest.java b/messaging/src/test/java/dev/ua/theroer/magicutils/messaging/MessageBusTest.java new file mode 100644 index 00000000..93ea2545 --- /dev/null +++ b/messaging/src/test/java/dev/ua/theroer/magicutils/messaging/MessageBusTest.java @@ -0,0 +1,127 @@ +package dev.ua.theroer.magicutils.messaging; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +class MessageBusTest { + record Greeting(String text) { + } + + /** Wires two buses (a proxy and a backend) over linked loopback transports. */ + private static final class Pair implements AutoCloseable { + final MessageBus proxy; + final MessageBus backend; + + Pair(MessageSource proxySource, MessageSource backendSource) { + LoopbackTransport proxyTransport = new LoopbackTransport(); + LoopbackTransport backendTransport = new LoopbackTransport(); + proxyTransport.link(backendTransport); + this.proxy = MessageBus.builder(proxySource, proxyTransport).build(); + this.backend = MessageBus.builder(backendSource, backendTransport).build(); + } + + @Override + public void close() { + proxy.close(); + backend.close(); + } + } + + @Test + void backendReceivesProxyBroadcast() { + MessageSource proxy = MessageSource.proxy("proxy"); + MessageSource backend = MessageSource.backend("b1", "lobby"); + try (Pair pair = new Pair(proxy, backend)) { + List received = new CopyOnWriteArrayList<>(); + pair.backend.subscribe("greet", Greeting.class, msg -> received.add(msg.payload().text())); + + pair.proxy.broadcast("greet", new Greeting("hi")); + + assertEquals(List.of("hi"), received); + } + } + + @Test + void senderDoesNotReceiveOwnMessage() { + MessageSource proxy = MessageSource.proxy("proxy"); + MessageSource backend = MessageSource.backend("b1", "lobby"); + try (Pair pair = new Pair(proxy, backend)) { + List received = new CopyOnWriteArrayList<>(); + pair.proxy.subscribe("greet", Greeting.class, msg -> received.add(msg.payload().text())); + + pair.proxy.broadcast("greet", new Greeting("echo?")); + + assertTrue(received.isEmpty(), "sender must not receive its own broadcast"); + } + } + + @Test + void proxyTargetIsRejectedByBackend() { + MessageSource proxy = MessageSource.proxy("proxy"); + MessageSource backend = MessageSource.backend("b1", "lobby"); + try (Pair pair = new Pair(proxy, backend)) { + List received = new CopyOnWriteArrayList<>(); + pair.backend.subscribe("c", Greeting.class, msg -> received.add(msg.payload().text())); + + pair.proxy.publish(Target.proxy(), "c", new Greeting("for-proxy")); + + assertTrue(received.isEmpty(), "backend must ignore proxy-targeted messages"); + } + } + + @Test + void serverTargetMatchesByName() { + MessageSource proxy = MessageSource.proxy("proxy"); + MessageSource lobby = MessageSource.backend("b1", "lobby"); + try (Pair pair = new Pair(proxy, lobby)) { + List received = new CopyOnWriteArrayList<>(); + pair.backend.subscribe("c", Greeting.class, msg -> received.add(msg.payload().text())); + + pair.proxy.publish(Target.server("survival"), "c", new Greeting("nope")); + assertTrue(received.isEmpty(), "wrong server name must not match"); + + pair.proxy.publish(Target.server("lobby"), "c", new Greeting("yes")); + assertEquals(List.of("yes"), received); + } + } + + @Test + void playerTargetUsesHostsPredicate() { + MessageSource proxy = MessageSource.proxy("proxy"); + MessageSource backend = MessageSource.backend("b1", "lobby"); + UUID hosted = UUID.randomUUID(); + try (Pair pair = new Pair(proxy, backend)) { + pair.backend.hostsPlayer(hosted::equals); + List received = new CopyOnWriteArrayList<>(); + pair.backend.subscribe("c", Greeting.class, msg -> received.add(msg.payload().text())); + + pair.proxy.publish(Target.player(UUID.randomUUID()), "c", new Greeting("other")); + assertTrue(received.isEmpty(), "non-hosted player must not match"); + + pair.proxy.publish(Target.player(hosted), "c", new Greeting("mine")); + assertEquals(List.of("mine"), received); + } + } + + @Test + void unsubscribeStopsDelivery() { + MessageSource proxy = MessageSource.proxy("proxy"); + MessageSource backend = MessageSource.backend("b1", "lobby"); + try (Pair pair = new Pair(proxy, backend)) { + List received = new CopyOnWriteArrayList<>(); + var handle = pair.backend.subscribe("c", Greeting.class, msg -> received.add(msg.payload().text())); + + pair.proxy.broadcast("c", new Greeting("one")); + handle.close(); + pair.proxy.broadcast("c", new Greeting("two")); + + assertEquals(List.of("one"), received); + } + } +} diff --git a/messaging/src/test/java/dev/ua/theroer/magicutils/messaging/MessageCodecTest.java b/messaging/src/test/java/dev/ua/theroer/magicutils/messaging/MessageCodecTest.java new file mode 100644 index 00000000..116ce354 --- /dev/null +++ b/messaging/src/test/java/dev/ua/theroer/magicutils/messaging/MessageCodecTest.java @@ -0,0 +1,61 @@ +package dev.ua.theroer.magicutils.messaging; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +class MessageCodecTest { + private final MessageCodec codec = new MessageCodec(); + + @Test + void payloadRoundTrips() { + Sample sample = new Sample("hello", 42); + byte[] bytes = codec.encodePayload(sample); + Sample decoded = codec.decodePayload(bytes, Sample.class); + assertEquals(sample, decoded); + } + + @Test + void nullPayloadEncodesEmptyAndDecodesNull() { + byte[] bytes = codec.encodePayload(null); + assertEquals(0, bytes.length); + assertNull(codec.decodePayload(bytes, Sample.class)); + } + + @Test + void envelopeRoundTripsServerTarget() { + MessageSource source = MessageSource.backend("proc-1", "lobby"); + byte[] payload = codec.encodePayload(new Sample("x", 1)); + MessageEnvelope envelope = MessageEnvelope.create("chan", Target.server("survival"), source, payload); + + MessageEnvelope decoded = codec.decode(codec.encode(envelope)); + + assertEquals(envelope.id(), decoded.id()); + assertEquals("chan", decoded.channel()); + assertEquals(Target.Kind.SERVER, decoded.target().kind()); + assertEquals("survival", decoded.target().serverName()); + assertEquals(MessageSource.Type.BACKEND, decoded.source().type()); + assertEquals("lobby", decoded.source().serverName()); + assertArrayEquals(payload, decoded.payload()); + } + + @Test + void envelopeRoundTripsPlayerTarget() { + UUID player = UUID.randomUUID(); + MessageSource source = MessageSource.proxy("proxy-1"); + MessageEnvelope envelope = MessageEnvelope.create("chan", Target.player(player), source, new byte[0]); + + MessageEnvelope decoded = codec.decode(codec.encode(envelope)); + + assertEquals(Target.Kind.PLAYER, decoded.target().kind()); + assertEquals(player, decoded.target().playerId()); + assertEquals(MessageSource.Type.PROXY, decoded.source().type()); + } + + record Sample(String name, int value) { + } +} diff --git a/platform-bukkit/build.gradle.kts b/platform-bukkit/build.gradle.kts index 75a8a4f9..f7c348ca 100644 --- a/platform-bukkit/build.gradle.kts +++ b/platform-bukkit/build.gradle.kts @@ -16,6 +16,7 @@ val target = project.extensions.getByType(MagicUtilsTargetExtension::class.java) dependencies { api(project(":core")) api(project(":diagnostics")) + api(project(":messaging")) compileOnly("io.papermc.paper:paper-api:${target.paper.get()}") compileOnly(libs.jetbrains.annotations) compileOnly(libs.projectlombok.lombok) diff --git a/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/bootstrap/BukkitBootstrap.java b/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/bootstrap/BukkitBootstrap.java index f2d68f99..101d5a60 100644 --- a/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/bootstrap/BukkitBootstrap.java +++ b/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/bootstrap/BukkitBootstrap.java @@ -8,6 +8,9 @@ import dev.ua.theroer.magicutils.diagnostics.DiagnosticsService; import dev.ua.theroer.magicutils.diagnostics.DiagnosticsSupport; import dev.ua.theroer.magicutils.lang.Messages; +import dev.ua.theroer.magicutils.messaging.MessagingService; +import dev.ua.theroer.magicutils.messaging.bukkit.BukkitMessagingSupport; +import dev.ua.theroer.magicutils.messaging.redis.RedisConfig; import dev.ua.theroer.magicutils.platform.Platform; import dev.ua.theroer.magicutils.platform.bukkit.BukkitMagicUtilsConsumerRegistry; import dev.ua.theroer.magicutils.platform.bukkit.BukkitPlatformProvider; @@ -57,6 +60,9 @@ public static final class Builder { private Consumer commandConfigurer; private boolean enableDiagnostics; private Consumer diagnosticsConfigurer; + private boolean enableMessaging; + private RedisConfig.Redis messagingRedis; + private Consumer messagingConfigurer; private Builder(JavaPlugin plugin) { this.plugin = Objects.requireNonNull(plugin, "plugin"); @@ -249,6 +255,43 @@ public Builder configureDiagnostics(Consumer diagnosticsConf return this; } + /** + * Enables cross-server messaging. Uses the default plugin-messaging + * transport unless Redis settings are supplied via {@link #messagingRedis}. + * + * @return builder + */ + public Builder enableMessaging() { + this.enableMessaging = true; + return this; + } + + /** + * Supplies Redis settings for messaging. When {@code enabled}, the Redis + * transport is used; otherwise the default plugin-messaging transport is. + * + * @param redis redis settings + * @return builder + */ + public Builder messagingRedis(RedisConfig.Redis redis) { + this.messagingRedis = redis; + this.enableMessaging = true; + return this; + } + + /** + * Allows configuring the messaging service builder before it is built. + * + * @param messagingConfigurer messaging builder callback + * @return builder + */ + public Builder configureMessaging( + Consumer messagingConfigurer) { + this.messagingConfigurer = messagingConfigurer; + this.enableMessaging = true; + return this; + } + /** * Builds the bootstrap result and wires requested services. * @@ -291,6 +334,9 @@ public RuntimeResult buildRuntime() { if (enableDiagnostics) { DiagnosticsSupport.install(runtime, diagnosticsConfigurer); } + if (enableMessaging) { + BukkitMessagingSupport.install(runtime, plugin, messagingRedis, messagingConfigurer); + } // The registry keeps a live payload supplier, so a single registration // suffices — the bundle re-reads the runtime whenever /magicutils mods // runs, no need to re-push on every state/command change. diff --git a/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/messaging/bukkit/BukkitMessagingSupport.java b/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/messaging/bukkit/BukkitMessagingSupport.java new file mode 100644 index 00000000..76f65611 --- /dev/null +++ b/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/messaging/bukkit/BukkitMessagingSupport.java @@ -0,0 +1,77 @@ +package dev.ua.theroer.magicutils.messaging.bukkit; + +import java.util.UUID; +import java.util.function.Consumer; + +import dev.ua.theroer.magicutils.bootstrap.MagicRuntime; +import dev.ua.theroer.magicutils.messaging.MessageCodec; +import dev.ua.theroer.magicutils.messaging.MessageSource; +import dev.ua.theroer.magicutils.messaging.MessagingService; +import dev.ua.theroer.magicutils.messaging.redis.RedisConfig; +import dev.ua.theroer.magicutils.platform.Platform; +import dev.ua.theroer.magicutils.platform.PlatformLogger; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.plugin.java.JavaPlugin; +import org.jetbrains.annotations.Nullable; + +/** + * Wires a {@link MessagingService} into a Bukkit plugin's {@link MagicRuntime}. + * + *

Selects the Redis transport when {@code redis.enabled}, otherwise the + * {@link BukkitPluginMessageTransport default plugin-messaging transport}, so the + * plugin works with or without Redis. The service is registered as a runtime + * component and closed with the runtime.

+ */ +public final class BukkitMessagingSupport { + private BukkitMessagingSupport() { + } + + /** + * Installs the messaging service on the runtime. + * + * @param runtime plugin runtime + * @param plugin owning plugin + * @param redis redis settings, or null to force the default transport + * @param configurer optional callback to tweak the service builder + * @return the installed service + */ + public static MessagingService install( + MagicRuntime runtime, + JavaPlugin plugin, + @Nullable RedisConfig.Redis redis, + @Nullable Consumer configurer) { + Platform platform = runtime.platform(); + PlatformLogger logger = platform.logger(); + + MessageSource self = MessageSource.backend( + plugin.getName() + ":" + UUID.randomUUID(), + null); // server name is not known to a backend until the proxy tells it + + MessagingService.Builder builder = MessagingService.builder(self) + .logger(logger) + .redis(redis) + .defaultTransport(() -> new BukkitPluginMessageTransport(plugin, new MessageCodec(), logger)) + .hostsPlayer(id -> hostsPlayer(platform, id)); + + if (configurer != null) { + configurer.accept(builder); + } + + MessagingService service = builder.build(); + runtime.putComponent(MessagingService.class, service); + runtime.manage("messaging.service", service); + logger.info("MagicUtils messaging enabled (transport: " + service.transportName() + ")"); + return service; + } + + private static boolean hostsPlayer(Platform platform, UUID id) { + if (platform.playerById(id) != null) { + return true; + } + // Fallback for platforms whose audience lookup lags the connection state. + Player player = Bukkit.getPlayer(id); + return player != null && player.isOnline(); + } +} diff --git a/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/messaging/bukkit/BukkitPluginMessageTransport.java b/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/messaging/bukkit/BukkitPluginMessageTransport.java new file mode 100644 index 00000000..c9dd4a88 --- /dev/null +++ b/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/messaging/bukkit/BukkitPluginMessageTransport.java @@ -0,0 +1,162 @@ +package dev.ua.theroer.magicutils.messaging.bukkit; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.Objects; + +import dev.ua.theroer.magicutils.messaging.MessageCodec; +import dev.ua.theroer.magicutils.messaging.MessageEnvelope; +import dev.ua.theroer.magicutils.messaging.MessageTransport; +import dev.ua.theroer.magicutils.messaging.Target; +import dev.ua.theroer.magicutils.platform.PlatformLogger; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.plugin.messaging.PluginMessageListener; +import org.jetbrains.annotations.Nullable; + +/** + * Default (Redis-less) messaging transport for Bukkit/Paper backends. + * + *

Rides the proxy's BungeeCord plugin-messaging channel. Backends cannot open + * a socket to the proxy directly, so this transport uses the {@code Forward} + * sub-channel: a backend sends {@code Forward ALL } and the proxy + * relays the raw {@code } to every other backend that listens on + * {@code }. That gives full backend↔backend and backend↔proxy delivery over + * the vanilla mechanism, with no Redis required.

+ * + *

The one native constraint: plugin messages travel through a player + * connection, so at least one player must be online for a backend to reach the + * proxy. When no player is online, outgoing messages are dropped (and logged at + * debug); the Redis transport is the way to remove that constraint.

+ */ +public final class BukkitPluginMessageTransport implements MessageTransport, PluginMessageListener { + /** The vanilla BungeeCord/Velocity plugin channel. */ + static final String BUNGEE_CHANNEL = "BungeeCord"; + /** Our sub-channel carried inside BungeeCord Forward frames. */ + static final String FORWARD_SUBCHANNEL = "magicutils:bus"; + + private final JavaPlugin plugin; + private final MessageCodec codec; + private final PlatformLogger logger; + + private volatile EnvelopeSink sink; + private volatile boolean running; + + /** + * Creates the transport. + * + * @param plugin owning plugin + * @param codec envelope codec + * @param logger platform logger + */ + public BukkitPluginMessageTransport(JavaPlugin plugin, MessageCodec codec, PlatformLogger logger) { + this.plugin = Objects.requireNonNull(plugin, "plugin"); + this.codec = Objects.requireNonNull(codec, "codec"); + this.logger = Objects.requireNonNull(logger, "logger"); + } + + @Override + public String name() { + return "plugin-messaging"; + } + + @Override + public void start(EnvelopeSink sink) { + this.sink = Objects.requireNonNull(sink, "sink"); + Bukkit.getMessenger().registerOutgoingPluginChannel(plugin, BUNGEE_CHANNEL); + Bukkit.getMessenger().registerIncomingPluginChannel(plugin, BUNGEE_CHANNEL, this); + this.running = true; + } + + @Override + public void publish(MessageEnvelope envelope) { + if (!running) { + throw new IllegalStateException("Transport is not running"); + } + Player carrier = anyPlayer(); + if (carrier == null) { + logger.debug("Dropping message on channel '" + envelope.channel() + + "': no player online to carry it through the proxy"); + return; + } + byte[] payload = codec.encode(envelope); + byte[] frame = buildForwardFrame(envelope.target(), payload); + carrier.sendPluginMessage(plugin, BUNGEE_CHANNEL, frame); + } + + @Override + public boolean isConnected() { + return running && anyPlayer() != null; + } + + @Override + public void onPluginMessageReceived(String channel, Player player, byte[] message) { + if (!running || !BUNGEE_CHANNEL.equals(channel)) { + return; + } + // The proxy strips the Forward/destination header before delivering, so a + // backend receives a plain frame here. + try (DataInputStream in = new DataInputStream(new ByteArrayInputStream(message))) { + String subChannel = in.readUTF(); + if (!FORWARD_SUBCHANNEL.equals(subChannel)) { + return; + } + short length = in.readShort(); + byte[] payload = new byte[length]; + in.readFully(payload); + EnvelopeSink target = sink; + if (target != null) { + target.accept(codec.decode(payload)); + } + } catch (IOException error) { + logger.warn("Failed to read forwarded plugin message", error); + } + } + + @Override + public void close() { + running = false; + sink = null; + try { + Bukkit.getMessenger().unregisterIncomingPluginChannel(plugin, BUNGEE_CHANNEL, this); + Bukkit.getMessenger().unregisterOutgoingPluginChannel(plugin, BUNGEE_CHANNEL); + } catch (RuntimeException ignored) { + // messenger may already be torn down during shutdown + } + } + + /** + * Builds a BungeeCord {@code Forward} frame. The destination is "ALL" for + * broadcast/all-backends, or the specific server name for a SERVER target; + * PROXY/PLAYER targets go out as ALL and are filtered by the bus on receipt. + */ + private byte[] buildForwardFrame(Target target, byte[] payload) { + String destination = target.kind() == Target.Kind.SERVER && target.serverName() != null + ? target.serverName() + : "ALL"; + try (ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(bytes)) { + out.writeUTF("Forward"); + out.writeUTF(destination); + out.writeUTF(FORWARD_SUBCHANNEL); + out.writeShort(payload.length); + out.write(payload); + return bytes.toByteArray(); + } catch (IOException error) { + throw new UncheckedIOException("Failed to build Forward frame", error); + } + } + + private @Nullable Player anyPlayer() { + for (Player player : Bukkit.getOnlinePlayers()) { + return player; + } + return null; + } +} diff --git a/platform-bungee/build.gradle.kts b/platform-bungee/build.gradle.kts index f5831de4..45177de1 100644 --- a/platform-bungee/build.gradle.kts +++ b/platform-bungee/build.gradle.kts @@ -13,6 +13,7 @@ magicutilsPublish { dependencies { api(project(":core")) api(project(":diagnostics")) + api(project(":messaging")) compileOnly(libs.bungeecord.api) api(libs.kyori.adventure.text.serializer.legacy) implementation(libs.kyori.adventure.text.serializer.plain) diff --git a/platform-bungee/src/main/java/dev/ua/theroer/magicutils/bootstrap/BungeeBootstrap.java b/platform-bungee/src/main/java/dev/ua/theroer/magicutils/bootstrap/BungeeBootstrap.java index 865c7143..521f21be 100644 --- a/platform-bungee/src/main/java/dev/ua/theroer/magicutils/bootstrap/BungeeBootstrap.java +++ b/platform-bungee/src/main/java/dev/ua/theroer/magicutils/bootstrap/BungeeBootstrap.java @@ -8,6 +8,9 @@ import dev.ua.theroer.magicutils.diagnostics.DiagnosticsSupport; import dev.ua.theroer.magicutils.lang.Messages; import dev.ua.theroer.magicutils.logger.LoggerCore; +import dev.ua.theroer.magicutils.messaging.MessagingService; +import dev.ua.theroer.magicutils.messaging.bungee.BungeeMessagingSupport; +import dev.ua.theroer.magicutils.messaging.redis.RedisConfig; import dev.ua.theroer.magicutils.platform.Platform; import dev.ua.theroer.magicutils.platform.bungee.BungeePlatformProvider; import net.md_5.bungee.api.ProxyServer; @@ -65,6 +68,9 @@ public static final class Builder { private Consumer commandConfigurer; private boolean enableDiagnostics; private Consumer diagnosticsConfigurer; + private boolean enableMessaging; + private RedisConfig.Redis messagingRedis; + private Consumer messagingConfigurer; private Builder(Plugin plugin, String pluginName) { this.plugin = Objects.requireNonNull(plugin, "plugin"); @@ -293,6 +299,40 @@ public Builder configureDiagnostics(Consumer diagnosticsConf return this; } + /** + * Enables cross-server messaging using the default plugin-messaging transport. + * + * @return this builder + */ + public Builder enableMessaging() { + this.enableMessaging = true; + return this; + } + + /** + * Supplies Redis settings for messaging; when enabled, Redis is used. + * + * @param redis redis settings + * @return this builder + */ + public Builder messagingRedis(RedisConfig.Redis redis) { + this.messagingRedis = redis; + this.enableMessaging = true; + return this; + } + + /** + * Allows configuring the messaging service builder before it is built. + * + * @param messagingConfigurer messaging builder callback + * @return this builder + */ + public Builder configureMessaging(Consumer messagingConfigurer) { + this.messagingConfigurer = messagingConfigurer; + this.enableMessaging = true; + return this; + } + /** * Builds the bootstrap result. * @@ -335,6 +375,10 @@ public RuntimeResult buildRuntime() { if (enableDiagnostics) { DiagnosticsSupport.install(runtime, diagnosticsConfigurer); } + if (enableMessaging) { + BungeeMessagingSupport.install( + runtime, proxy, plugin, pluginName, messagingRedis, messagingConfigurer); + } if (registerMessages) { runtime.onClose("messages.scope", () -> Messages.unregister(pluginName)); } diff --git a/platform-bungee/src/main/java/dev/ua/theroer/magicutils/messaging/bungee/BungeeMessagingSupport.java b/platform-bungee/src/main/java/dev/ua/theroer/magicutils/messaging/bungee/BungeeMessagingSupport.java new file mode 100644 index 00000000..974ccf1e --- /dev/null +++ b/platform-bungee/src/main/java/dev/ua/theroer/magicutils/messaging/bungee/BungeeMessagingSupport.java @@ -0,0 +1,85 @@ +package dev.ua.theroer.magicutils.messaging.bungee; + +import java.util.UUID; +import java.util.function.Consumer; + +import dev.ua.theroer.magicutils.bootstrap.MagicRuntime; +import dev.ua.theroer.magicutils.messaging.MessageCodec; +import dev.ua.theroer.magicutils.messaging.MessageSource; +import dev.ua.theroer.magicutils.messaging.MessageTransport; +import dev.ua.theroer.magicutils.messaging.MessagingService; +import dev.ua.theroer.magicutils.messaging.redis.RedisConfig; +import dev.ua.theroer.magicutils.platform.Platform; +import dev.ua.theroer.magicutils.platform.PlatformLogger; + +import net.md_5.bungee.api.ProxyServer; +import net.md_5.bungee.api.plugin.Plugin; +import org.jetbrains.annotations.Nullable; + +/** + * Wires a {@link MessagingService} into a BungeeCord plugin's {@link MagicRuntime}. + * + *

Mirror of the Velocity support: Redis when enabled, otherwise the default + * {@link BungeePluginMessageTransport} registered as a Bungee listener. Service + * registered as a runtime component and closed with the runtime.

+ */ +public final class BungeeMessagingSupport { + private BungeeMessagingSupport() { + } + + /** + * Installs the messaging service on the runtime. + * + * @param runtime plugin runtime + * @param proxy bungee proxy + * @param plugin owning plugin + * @param pluginName plugin name (used to derive the member id) + * @param redis redis settings, or null to force the default transport + * @param configurer optional callback to tweak the service builder + * @return the installed service + */ + public static MessagingService install( + MagicRuntime runtime, + ProxyServer proxy, + Plugin plugin, + String pluginName, + @Nullable RedisConfig.Redis redis, + @Nullable Consumer configurer) { + Platform platform = runtime.platform(); + PlatformLogger logger = platform.logger(); + MessageCodec codec = new MessageCodec(); + + MessageSource self = MessageSource.proxy(pluginName + ":" + UUID.randomUUID()); + + BungeePluginMessageTransport[] defaultTransport = new BungeePluginMessageTransport[1]; + + MessagingService.Builder builder = MessagingService.builder(self) + .logger(logger) + .redis(redis) + .codec(codec) + .defaultTransport(() -> { + BungeePluginMessageTransport transport = + new BungeePluginMessageTransport(proxy, plugin, codec, logger); + defaultTransport[0] = transport; + return transport; + }); + + if (configurer != null) { + configurer.accept(builder); + } + + MessagingService service = builder.build(); + + MessageTransport active = service.bus().transport(); + if (active == defaultTransport[0] && defaultTransport[0] != null) { + proxy.getPluginManager().registerListener(plugin, defaultTransport[0]); + runtime.onClose("messaging.listener", + () -> proxy.getPluginManager().unregisterListener(defaultTransport[0])); + } + + runtime.putComponent(MessagingService.class, service); + runtime.manage("messaging.service", service); + logger.info("MagicUtils messaging enabled (transport: " + service.transportName() + ")"); + return service; + } +} diff --git a/platform-bungee/src/main/java/dev/ua/theroer/magicutils/messaging/bungee/BungeePluginMessageTransport.java b/platform-bungee/src/main/java/dev/ua/theroer/magicutils/messaging/bungee/BungeePluginMessageTransport.java new file mode 100644 index 00000000..ef1645b8 --- /dev/null +++ b/platform-bungee/src/main/java/dev/ua/theroer/magicutils/messaging/bungee/BungeePluginMessageTransport.java @@ -0,0 +1,168 @@ +package dev.ua.theroer.magicutils.messaging.bungee; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.Objects; + +import dev.ua.theroer.magicutils.messaging.MessageCodec; +import dev.ua.theroer.magicutils.messaging.MessageEnvelope; +import dev.ua.theroer.magicutils.messaging.MessageTransport; +import dev.ua.theroer.magicutils.messaging.Target; +import dev.ua.theroer.magicutils.platform.PlatformLogger; + +import net.md_5.bungee.api.ProxyServer; +import net.md_5.bungee.api.config.ServerInfo; +import net.md_5.bungee.api.connection.Server; +import net.md_5.bungee.api.event.PluginMessageEvent; +import net.md_5.bungee.api.plugin.Listener; +import net.md_5.bungee.api.plugin.Plugin; +import net.md_5.bungee.event.EventHandler; + +/** + * Default (Redis-less) messaging transport for the BungeeCord/Waterfall proxy. + * + *

Mirror of {@code VelocityPluginMessageTransport}: it listens on the + * BungeeCord plugin channel for {@code Forward} frames from backends (feeding + * proxy-side subscribers) and dispatches proxy-originated envelopes to backend + * servers wrapped in the shared sub-channel frame.

+ * + *

Registered as a Bungee {@link Listener} via + * {@code proxy.getPluginManager().registerListener(plugin, transport)} by the + * bootstrap wiring.

+ */ +public final class BungeePluginMessageTransport implements MessageTransport, Listener { + private static final String BUNGEE_CHANNEL = "BungeeCord"; + private static final String FORWARD_SUBCHANNEL = "magicutils:bus"; + + private final ProxyServer proxy; + private final Plugin plugin; + private final MessageCodec codec; + private final PlatformLogger logger; + + private volatile EnvelopeSink sink; + private volatile boolean running; + + /** + * Creates the transport. + * + * @param proxy bungee proxy + * @param plugin owning plugin + * @param codec envelope codec + * @param logger platform logger + */ + public BungeePluginMessageTransport(ProxyServer proxy, Plugin plugin, MessageCodec codec, PlatformLogger logger) { + this.proxy = Objects.requireNonNull(proxy, "proxy"); + this.plugin = Objects.requireNonNull(plugin, "plugin"); + this.codec = Objects.requireNonNull(codec, "codec"); + this.logger = Objects.requireNonNull(logger, "logger"); + } + + @Override + public String name() { + return "plugin-messaging"; + } + + @Override + public void start(EnvelopeSink sink) { + this.sink = Objects.requireNonNull(sink, "sink"); + proxy.registerChannel(BUNGEE_CHANNEL); + this.running = true; + } + + @Override + public void publish(MessageEnvelope envelope) { + if (!running) { + throw new IllegalStateException("Transport is not running"); + } + byte[] payload = codec.encode(envelope); + byte[] frame = buildFrame(payload); + Target target = envelope.target(); + if (target.kind() == Target.Kind.SERVER && target.serverName() != null) { + ServerInfo server = proxy.getServerInfo(target.serverName()); + if (server != null) { + sendToServer(server, frame); + } + } else { + for (ServerInfo server : proxy.getServers().values()) { + sendToServer(server, frame); + } + } + } + + @Override + public boolean isConnected() { + return running; + } + + /** + * Handles inbound BungeeCord {@code Forward} frames from backends. + * + * @param event plugin message event + */ + @EventHandler + public void onPluginMessage(PluginMessageEvent event) { + if (!running || !BUNGEE_CHANNEL.equals(event.getTag())) { + return; + } + // Only accept frames coming from a backend server connection. + if (!(event.getSender() instanceof Server)) { + return; + } + try (DataInputStream in = new DataInputStream(new ByteArrayInputStream(event.getData()))) { + String subChannel = in.readUTF(); + if (!"Forward".equals(subChannel)) { + return; + } + in.readUTF(); // destination + String forwardChannel = in.readUTF(); + if (!FORWARD_SUBCHANNEL.equals(forwardChannel)) { + return; + } + short length = in.readShort(); + byte[] payload = new byte[length]; + in.readFully(payload); + EnvelopeSink target = sink; + if (target != null) { + target.accept(codec.decode(payload)); + } + } catch (IOException error) { + logger.warn("Failed to read Forward frame on the proxy", error); + } + } + + @Override + public void close() { + running = false; + sink = null; + try { + proxy.unregisterChannel(BUNGEE_CHANNEL); + } catch (RuntimeException ignored) { + // channel may already be unregistered during shutdown + } + } + + private void sendToServer(ServerInfo server, byte[] frame) { + try { + // queue=true so the frame is buffered until the backend connection is ready. + server.sendData(BUNGEE_CHANNEL, frame, true); + } catch (RuntimeException error) { + logger.warn("Failed to send plugin message to server '" + server.getName() + "'", error); + } + } + + private byte[] buildFrame(byte[] payload) { + try (ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(bytes)) { + out.writeUTF(FORWARD_SUBCHANNEL); + out.writeShort(payload.length); + out.write(payload); + return bytes.toByteArray(); + } catch (IOException error) { + throw new UncheckedIOException("Failed to build proxy frame", error); + } + } +} diff --git a/platform-velocity/build.gradle.kts b/platform-velocity/build.gradle.kts index febc7cde..7a431592 100644 --- a/platform-velocity/build.gradle.kts +++ b/platform-velocity/build.gradle.kts @@ -13,6 +13,7 @@ magicutilsPublish { dependencies { api(project(":core")) api(project(":diagnostics")) + api(project(":messaging")) compileOnly(libs.velocity.api) compileOnly(libs.slf4j.api) compileOnly(libs.jetbrains.annotations) diff --git a/platform-velocity/src/main/java/dev/ua/theroer/magicutils/bootstrap/VelocityBootstrap.java b/platform-velocity/src/main/java/dev/ua/theroer/magicutils/bootstrap/VelocityBootstrap.java index 37abe2e0..b7de3aa2 100644 --- a/platform-velocity/src/main/java/dev/ua/theroer/magicutils/bootstrap/VelocityBootstrap.java +++ b/platform-velocity/src/main/java/dev/ua/theroer/magicutils/bootstrap/VelocityBootstrap.java @@ -12,6 +12,9 @@ import dev.ua.theroer.magicutils.diagnostics.DiagnosticsSupport; import dev.ua.theroer.magicutils.lang.Messages; import dev.ua.theroer.magicutils.logger.LoggerCore; +import dev.ua.theroer.magicutils.messaging.MessagingService; +import dev.ua.theroer.magicutils.messaging.redis.RedisConfig; +import dev.ua.theroer.magicutils.messaging.velocity.VelocityMessagingSupport; import dev.ua.theroer.magicutils.platform.MagicUtilsConsumerRegistry; import dev.ua.theroer.magicutils.platform.Platform; import dev.ua.theroer.magicutils.platform.velocity.VelocityMagicUtilsConsumerRegistry; @@ -75,6 +78,9 @@ public static final class Builder { private Consumer commandConfigurer; private boolean enableDiagnostics; private Consumer diagnosticsConfigurer; + private boolean enableMessaging; + private RedisConfig.Redis messagingRedis; + private Consumer messagingConfigurer; private Builder(ProxyServer proxy, Object plugin, String pluginName, Path dataDirectory) { this.proxy = Objects.requireNonNull(proxy, "proxy"); @@ -303,6 +309,40 @@ public Builder configureDiagnostics(Consumer diagnosticsConf return this; } + /** + * Enables cross-server messaging using the default plugin-messaging transport. + * + * @return builder + */ + public Builder enableMessaging() { + this.enableMessaging = true; + return this; + } + + /** + * Supplies Redis settings for messaging; when enabled, Redis is used. + * + * @param redis redis settings + * @return builder + */ + public Builder messagingRedis(RedisConfig.Redis redis) { + this.messagingRedis = redis; + this.enableMessaging = true; + return this; + } + + /** + * Allows configuring the messaging service builder before it is built. + * + * @param messagingConfigurer messaging builder callback + * @return builder + */ + public Builder configureMessaging(Consumer messagingConfigurer) { + this.messagingConfigurer = messagingConfigurer; + this.enableMessaging = true; + return this; + } + /** * Builds the bootstrap result without exposing the runtime wrapper. * @@ -344,6 +384,10 @@ public RuntimeResult buildRuntime() { if (enableDiagnostics) { DiagnosticsSupport.install(runtime, diagnosticsConfigurer); } + if (enableMessaging) { + VelocityMessagingSupport.install( + runtime, proxy, plugin, pluginName, messagingRedis, messagingConfigurer); + } if (registerMessages) { runtime.onClose("messages.scope", () -> Messages.unregister(pluginName)); } diff --git a/platform-velocity/src/main/java/dev/ua/theroer/magicutils/messaging/velocity/VelocityMessagingSupport.java b/platform-velocity/src/main/java/dev/ua/theroer/magicutils/messaging/velocity/VelocityMessagingSupport.java new file mode 100644 index 00000000..aa9dfb0d --- /dev/null +++ b/platform-velocity/src/main/java/dev/ua/theroer/magicutils/messaging/velocity/VelocityMessagingSupport.java @@ -0,0 +1,87 @@ +package dev.ua.theroer.magicutils.messaging.velocity; + +import java.util.UUID; +import java.util.function.Consumer; + +import com.velocitypowered.api.proxy.ProxyServer; + +import dev.ua.theroer.magicutils.bootstrap.MagicRuntime; +import dev.ua.theroer.magicutils.messaging.MessageCodec; +import dev.ua.theroer.magicutils.messaging.MessageSource; +import dev.ua.theroer.magicutils.messaging.MessageTransport; +import dev.ua.theroer.magicutils.messaging.MessagingService; +import dev.ua.theroer.magicutils.messaging.redis.RedisConfig; +import dev.ua.theroer.magicutils.platform.Platform; +import dev.ua.theroer.magicutils.platform.PlatformLogger; +import org.jetbrains.annotations.Nullable; + +/** + * Wires a {@link MessagingService} into a Velocity plugin's {@link MagicRuntime}. + * + *

When Redis is enabled the Redis transport is used; otherwise the default + * {@link VelocityPluginMessageTransport} is created and registered as a Velocity + * event listener so it receives backend {@code Forward} frames. Either way the + * service is registered as a runtime component and closed with the runtime.

+ */ +public final class VelocityMessagingSupport { + private VelocityMessagingSupport() { + } + + /** + * Installs the messaging service on the runtime. + * + * @param runtime plugin runtime + * @param proxy velocity proxy + * @param plugin owning plugin instance + * @param pluginName plugin name (used to derive the member id) + * @param redis redis settings, or null to force the default transport + * @param configurer optional callback to tweak the service builder + * @return the installed service + */ + public static MessagingService install( + MagicRuntime runtime, + ProxyServer proxy, + Object plugin, + String pluginName, + @Nullable RedisConfig.Redis redis, + @Nullable Consumer configurer) { + Platform platform = runtime.platform(); + PlatformLogger logger = platform.logger(); + MessageCodec codec = new MessageCodec(); + + MessageSource self = MessageSource.proxy(pluginName + ":" + UUID.randomUUID()); + + // Track the default transport so it can be registered/unregistered as an + // event listener; a Redis transport needs no Velocity listener. + VelocityPluginMessageTransport[] defaultTransport = new VelocityPluginMessageTransport[1]; + + MessagingService.Builder builder = MessagingService.builder(self) + .logger(logger) + .redis(redis) + .codec(codec) + .defaultTransport(() -> { + VelocityPluginMessageTransport transport = + new VelocityPluginMessageTransport(proxy, plugin, codec, logger); + defaultTransport[0] = transport; + return transport; + }); + + if (configurer != null) { + configurer.accept(builder); + } + + MessagingService service = builder.build(); + + MessageTransport active = service.bus().transport(); + if (active == defaultTransport[0] && defaultTransport[0] != null) { + proxy.getEventManager().register(plugin, defaultTransport[0]); + runtime.onClose("messaging.listener", + () -> proxy.getEventManager().unregisterListener(plugin, defaultTransport[0])); + } + + runtime.putComponent(MessagingService.class, service); + runtime.manage("messaging.service", service); + logger.info("MagicUtils messaging enabled (transport: " + service.transportName() + ")"); + return service; + } +} diff --git a/platform-velocity/src/main/java/dev/ua/theroer/magicutils/messaging/velocity/VelocityPluginMessageTransport.java b/platform-velocity/src/main/java/dev/ua/theroer/magicutils/messaging/velocity/VelocityPluginMessageTransport.java new file mode 100644 index 00000000..a815c993 --- /dev/null +++ b/platform-velocity/src/main/java/dev/ua/theroer/magicutils/messaging/velocity/VelocityPluginMessageTransport.java @@ -0,0 +1,168 @@ +package dev.ua.theroer.magicutils.messaging.velocity; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.Objects; + +import com.velocitypowered.api.event.Subscribe; +import com.velocitypowered.api.event.connection.PluginMessageEvent; +import com.velocitypowered.api.proxy.ProxyServer; +import com.velocitypowered.api.proxy.ServerConnection; +import com.velocitypowered.api.proxy.messages.LegacyChannelIdentifier; +import com.velocitypowered.api.proxy.server.RegisteredServer; + +import dev.ua.theroer.magicutils.messaging.MessageCodec; +import dev.ua.theroer.magicutils.messaging.MessageEnvelope; +import dev.ua.theroer.magicutils.messaging.MessageTransport; +import dev.ua.theroer.magicutils.messaging.Target; +import dev.ua.theroer.magicutils.platform.PlatformLogger; + +/** + * Default (Redis-less) messaging transport for the Velocity proxy. + * + *

Listens on the BungeeCord plugin channel for {@code Forward} frames sent by + * backends and hands the embedded envelope to the bus so proxy-side subscribers + * receive backend messages. To send from the proxy to backends, it wraps the + * envelope in a {@code Forward} frame and dispatches it to the relevant + * server(s), which Velocity relays to that server's backend plugin.

+ * + *

The transport must be registered as a Velocity event listener via + * {@code proxy.getEventManager().register(plugin, transport)} (done by the + * bootstrap wiring).

+ */ +public final class VelocityPluginMessageTransport implements MessageTransport { + private static final LegacyChannelIdentifier BUNGEE_CHANNEL = new LegacyChannelIdentifier("BungeeCord"); + private static final String FORWARD_SUBCHANNEL = "magicutils:bus"; + + private final ProxyServer proxy; + private final Object plugin; + private final MessageCodec codec; + private final PlatformLogger logger; + + private volatile EnvelopeSink sink; + private volatile boolean running; + + /** + * Creates the transport. + * + * @param proxy velocity proxy + * @param plugin owning plugin instance (for channel registration) + * @param codec envelope codec + * @param logger platform logger + */ + public VelocityPluginMessageTransport(ProxyServer proxy, Object plugin, MessageCodec codec, PlatformLogger logger) { + this.proxy = Objects.requireNonNull(proxy, "proxy"); + this.plugin = Objects.requireNonNull(plugin, "plugin"); + this.codec = Objects.requireNonNull(codec, "codec"); + this.logger = Objects.requireNonNull(logger, "logger"); + } + + @Override + public String name() { + return "plugin-messaging"; + } + + @Override + public void start(EnvelopeSink sink) { + this.sink = Objects.requireNonNull(sink, "sink"); + proxy.getChannelRegistrar().register(BUNGEE_CHANNEL); + this.running = true; + } + + @Override + public void publish(MessageEnvelope envelope) { + if (!running) { + throw new IllegalStateException("Transport is not running"); + } + byte[] payload = codec.encode(envelope); + byte[] frame = buildForwardFrame(payload); + Target target = envelope.target(); + if (target.kind() == Target.Kind.SERVER && target.serverName() != null) { + proxy.getServer(target.serverName()) + .ifPresent(server -> sendToServer(server, frame)); + } else { + // BROADCAST / ALL_BACKENDS / PLAYER: send to every backend, bus filters. + for (RegisteredServer server : proxy.getAllServers()) { + sendToServer(server, frame); + } + } + } + + @Override + public boolean isConnected() { + return running; + } + + /** + * Handles inbound BungeeCord {@code Forward} frames from backends. + * + * @param event plugin message event + */ + @Subscribe + public void onPluginMessage(PluginMessageEvent event) { + if (!running || !(event.getIdentifier().getId().equals(BUNGEE_CHANNEL.getId()))) { + return; + } + // Only handle messages originating from a backend server connection. + if (!(event.getSource() instanceof ServerConnection)) { + return; + } + byte[] data = event.getData(); + try (DataInputStream in = new DataInputStream(new ByteArrayInputStream(data))) { + String subChannel = in.readUTF(); + if (!"Forward".equals(subChannel)) { + return; + } + in.readUTF(); // destination ("ALL" or server name) - Velocity already routes backend↔backend + String forwardChannel = in.readUTF(); + if (!FORWARD_SUBCHANNEL.equals(forwardChannel)) { + return; + } + short length = in.readShort(); + byte[] payload = new byte[length]; + in.readFully(payload); + EnvelopeSink target = sink; + if (target != null) { + target.accept(codec.decode(payload)); + } + } catch (IOException error) { + logger.warn("Failed to read Forward frame on the proxy", error); + } + } + + @Override + public void close() { + running = false; + sink = null; + try { + proxy.getChannelRegistrar().unregister(BUNGEE_CHANNEL); + } catch (RuntimeException ignored) { + // registrar may already be gone during shutdown + } + } + + private void sendToServer(RegisteredServer server, byte[] frame) { + try { + server.sendPluginMessage(BUNGEE_CHANNEL, frame); + } catch (RuntimeException error) { + logger.warn("Failed to send plugin message to server '" + server.getServerInfo().getName() + "'", error); + } + } + + private byte[] buildForwardFrame(byte[] payload) { + try (ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(bytes)) { + // Proxy → backend: a plain sub-channel frame the backend reads as our bus. + out.writeUTF(FORWARD_SUBCHANNEL); + out.writeShort(payload.length); + out.write(payload); + return bytes.toByteArray(); + } catch (IOException error) { + throw new UncheckedIOException("Failed to build proxy frame", error); + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index e3b2508a..8c4c005d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -40,6 +40,7 @@ magicMatrix { "commands-brigadier", "placeholders", "http-client", + "messaging", "config-yaml", "config-toml", "diagnostics", From dd3c908d383598735dd2edcb8bf724e88fead9f4 Mon Sep 17 00:00:00 2001 From: THEROER Date: Fri, 10 Jul 2026 05:26:53 +0300 Subject: [PATCH 04/39] refactor(bootstrap): extract shared language wiring into core LanguageBootstrap The five platform bootstrap builders (Bukkit, Bungee, Velocity, Fabric, NeoForge) each duplicated the same eight language/messages flags, their setters, and the apply/close logic. Extract that byte-identical wiring into core/bootstrap/LanguageBootstrap (single source of truth) and delegate from BukkitBootstrap. Also adds withRecommendedDefaults()/minimal() presets so the default flag set is explicit instead of a wall of booleans. Remaining platforms are migrated in follow-up commits. --- .../bootstrap/LanguageBootstrap.java | 266 ++++++++++++++++++ .../magicutils/bootstrap/BukkitBootstrap.java | 90 +++--- 2 files changed, 300 insertions(+), 56 deletions(-) create mode 100644 core/src/main/java/dev/ua/theroer/magicutils/bootstrap/LanguageBootstrap.java diff --git a/core/src/main/java/dev/ua/theroer/magicutils/bootstrap/LanguageBootstrap.java b/core/src/main/java/dev/ua/theroer/magicutils/bootstrap/LanguageBootstrap.java new file mode 100644 index 00000000..eb79763c --- /dev/null +++ b/core/src/main/java/dev/ua/theroer/magicutils/bootstrap/LanguageBootstrap.java @@ -0,0 +1,266 @@ +package dev.ua.theroer.magicutils.bootstrap; + +import dev.ua.theroer.magicutils.lang.LanguageManager; +import dev.ua.theroer.magicutils.lang.Messages; +import dev.ua.theroer.magicutils.logger.LoggerCore; +import dev.ua.theroer.magicutils.platform.Platform; +import java.util.function.Consumer; +import org.jetbrains.annotations.Nullable; + +/** + * Shared language and {@link Messages} wiring for platform bootstrap helpers. + * + *

Historically every platform bootstrap (Bukkit, Bungee, Velocity, Fabric, + * NeoForge) duplicated the same eight language flags, their setters, and the + * apply/close logic. That logic only touches {@link LanguageManager}, + * {@link Messages}, {@link LoggerCore} and {@link Platform}, all of which are + * visible from {@code core}, so it lives here as the single source of truth. + * Platform builders own an instance and delegate their language setters to it; + * command, diagnostics, and messaging wiring stay platform-specific because + * those types differ per platform. + * + *

Terminology: a scope is the owner name a plugin/mod registers its + * messages under (plugin name or mod id). + */ +public final class LanguageBootstrap { + + private String language = "en"; + private boolean initLanguage = true; + private boolean bindLoggerLanguage = true; + private boolean setMessagesManager = true; + private boolean registerMessages = true; + private boolean addMagicUtilsMessages = true; + private boolean bindClientLocaleSync = true; + private @Nullable Consumer translations; + + /** + * Creates a language bootstrap with the recommended defaults already + * applied (every flag on, language {@code "en"}). + */ + public LanguageBootstrap() { + } + + /** + * Sets the default language code to initialize. Blank values are ignored. + * + * @param language language code (for example, {@code "en"}) + * @return this + */ + public LanguageBootstrap language(String language) { + if (language != null && !language.isBlank()) { + this.language = language; + } + return this; + } + + /** + * Toggles automatic language initialization via + * {@link LanguageManager#init(String)}. + * + * @param initLanguage true to initialize the language on build + * @return this + */ + public LanguageBootstrap initLanguage(boolean initLanguage) { + this.initLanguage = initLanguage; + return this; + } + + /** + * Toggles binding the language manager to the logger so log output is + * localized. + * + * @param bindLoggerLanguage true to set the logger language manager + * @return this + */ + public LanguageBootstrap bindLoggerLanguage(boolean bindLoggerLanguage) { + this.bindLoggerLanguage = bindLoggerLanguage; + return this; + } + + /** + * Toggles setting the global {@link Messages} language manager to this + * plugin/mod's manager. + * + * @param setMessagesManager true to set the global manager + * @return this + */ + public LanguageBootstrap setMessagesManager(boolean setMessagesManager) { + this.setMessagesManager = setMessagesManager; + return this; + } + + /** + * Toggles registering a scoped {@link Messages} manager keyed by the + * plugin name or mod id. + * + * @param registerMessages true to register the scope + * @return this + */ + public LanguageBootstrap registerMessages(boolean registerMessages) { + this.registerMessages = registerMessages; + return this; + } + + /** + * Toggles adding the bundled MagicUtils default language entries. + * + * @param addMagicUtilsMessages true to add bundled defaults + * @return this + */ + public LanguageBootstrap addMagicUtilsMessages(boolean addMagicUtilsMessages) { + this.addMagicUtilsMessages = addMagicUtilsMessages; + return this; + } + + /** + * Toggles synchronizing a player's language from client locale updates. + * + * @param bindClientLocaleSync true to bind client locale synchronization + * @return this + */ + public LanguageBootstrap bindClientLocaleSync(boolean bindClientLocaleSync) { + this.bindClientLocaleSync = bindClientLocaleSync; + return this; + } + + /** + * Registers plugin/mod translations against the language manager on build. + * + * @param translations translation registrar + * @return this + */ + public LanguageBootstrap translations(@Nullable Consumer translations) { + this.translations = translations; + return this; + } + + /** + * Applies the recommended defaults: every flag enabled. This is already the + * initial state, so it exists mainly to make intent explicit and to reset a + * builder that called {@link #minimal()}. + * + * @return this + */ + public LanguageBootstrap withRecommendedDefaults() { + this.initLanguage = true; + this.bindLoggerLanguage = true; + this.setMessagesManager = true; + this.registerMessages = true; + this.addMagicUtilsMessages = true; + this.bindClientLocaleSync = true; + return this; + } + + /** + * Disables all automatic language wiring for callers that manage the + * {@link LanguageManager} and {@link Messages} themselves. Leaves + * {@link #language(String)} untouched. + * + * @return this + */ + public LanguageBootstrap minimal() { + this.initLanguage = false; + this.bindLoggerLanguage = false; + this.setMessagesManager = false; + this.registerMessages = false; + this.addMagicUtilsMessages = false; + this.bindClientLocaleSync = false; + return this; + } + + /** + * Whether client-locale synchronization should be bound on the runtime. + * + * @return true when enabled + */ + public boolean bindsClientLocaleSync() { + return bindClientLocaleSync; + } + + /** + * Whether a scoped messages registration was requested (drives the close + * hook that unregisters it). + * + * @return true when enabled + */ + public boolean registersMessages() { + return registerMessages; + } + + /** + * Whether the global messages manager was set (drives the close hook that + * clears it). + * + * @return true when enabled + */ + public boolean setsMessagesManager() { + return setMessagesManager; + } + + /** + * Runs the byte-identical language/messages wiring against a resolved + * language manager and logger. Mirrors the former per-platform + * {@code prepare()} bodies exactly. + * + * @param scope owner name (plugin name or mod id) used for the messages scope + * @param languageManager resolved language manager + * @param logger resolved logger core + */ + public void apply(String scope, LanguageManager languageManager, LoggerCore logger) { + if (initLanguage) { + languageManager.init(language); + } + if (translations != null) { + translations.accept(languageManager); + } + if (addMagicUtilsMessages) { + languageManager.addMagicUtilsMessages(); + } + if (registerMessages) { + Messages.register(scope, languageManager); + } + if (setMessagesManager) { + Messages.setLanguageManager(languageManager); + } + if (bindLoggerLanguage) { + logger.setLanguageManager(languageManager); + } + } + + /** + * Binds client-locale synchronization on the runtime when enabled. Mirrors + * the former per-platform {@code buildRuntime()} block. + * + * @param runtime managed runtime + * @param platform platform adapter + * @param languageManager resolved language manager + */ + public void bindClientLocaleSync(MagicRuntime runtime, Platform platform, LanguageManager languageManager) { + if (bindClientLocaleSync) { + runtime.manage("language.clientLocaleSync", + languageManager.bindClientLocaleSync(platform)); + } + } + + /** + * Installs the messages-related close hooks on the runtime when the + * corresponding flags are enabled. Mirrors the former per-platform + * {@code buildRuntime()} close blocks. + * + * @param runtime managed runtime + * @param scope owner name (plugin name or mod id) used for the messages scope + * @param languageManager resolved language manager + */ + public void installMessagesCloseHooks(MagicRuntime runtime, String scope, LanguageManager languageManager) { + if (registerMessages) { + runtime.onClose("messages.scope", () -> Messages.unregister(scope)); + } + if (setMessagesManager) { + runtime.onClose("messages.default", () -> { + if (Messages.getLanguageManager() == languageManager) { + Messages.setLanguageManager(null); + } + }); + } + } +} diff --git a/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/bootstrap/BukkitBootstrap.java b/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/bootstrap/BukkitBootstrap.java index 101d5a60..e2e8f779 100644 --- a/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/bootstrap/BukkitBootstrap.java +++ b/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/bootstrap/BukkitBootstrap.java @@ -7,7 +7,6 @@ import dev.ua.theroer.magicutils.diagnostics.DiagnosticRegistry; import dev.ua.theroer.magicutils.diagnostics.DiagnosticsService; import dev.ua.theroer.magicutils.diagnostics.DiagnosticsSupport; -import dev.ua.theroer.magicutils.lang.Messages; import dev.ua.theroer.magicutils.messaging.MessagingService; import dev.ua.theroer.magicutils.messaging.bukkit.BukkitMessagingSupport; import dev.ua.theroer.magicutils.messaging.redis.RedisConfig; @@ -43,18 +42,11 @@ public static Builder forPlugin(JavaPlugin plugin) { */ public static final class Builder { private final JavaPlugin plugin; + private final LanguageBootstrap lang = new LanguageBootstrap(); private Platform platform; private ConfigManager configManager; private Logger logger; private LanguageManager languageManager; - private String language = "en"; - private boolean initLanguage = true; - private boolean bindLoggerLanguage = true; - private boolean setMessagesManager = true; - private boolean registerMessages = true; - private boolean addMagicUtilsMessages = true; - private boolean bindClientLocaleSync = true; - private Consumer translations; private boolean enableCommands; private String permissionPrefix; private Consumer commandConfigurer; @@ -112,6 +104,28 @@ public Builder languageManager(LanguageManager languageManager) { return this; } + /** + * Applies the recommended language defaults (every flag enabled). This + * is already the initial state; call it to make intent explicit. + * + * @return builder + */ + public Builder withRecommendedDefaults() { + lang.withRecommendedDefaults(); + return this; + } + + /** + * Disables all automatic language/messages wiring for callers that + * manage localization themselves. + * + * @return builder + */ + public Builder minimal() { + lang.minimal(); + return this; + } + /** * Sets the default language code to initialize. * @@ -119,9 +133,7 @@ public Builder languageManager(LanguageManager languageManager) { * @return builder */ public Builder language(String language) { - if (language != null && !language.isBlank()) { - this.language = language; - } + lang.language(language); return this; } @@ -132,7 +144,7 @@ public Builder language(String language) { * @return builder */ public Builder initLanguage(boolean initLanguage) { - this.initLanguage = initLanguage; + lang.initLanguage(initLanguage); return this; } @@ -143,7 +155,7 @@ public Builder initLanguage(boolean initLanguage) { * @return builder */ public Builder bindLoggerLanguage(boolean bindLoggerLanguage) { - this.bindLoggerLanguage = bindLoggerLanguage; + lang.bindLoggerLanguage(bindLoggerLanguage); return this; } @@ -154,7 +166,7 @@ public Builder bindLoggerLanguage(boolean bindLoggerLanguage) { * @return builder */ public Builder setMessagesManager(boolean setMessagesManager) { - this.setMessagesManager = setMessagesManager; + lang.setMessagesManager(setMessagesManager); return this; } @@ -165,7 +177,7 @@ public Builder setMessagesManager(boolean setMessagesManager) { * @return builder */ public Builder registerMessages(boolean registerMessages) { - this.registerMessages = registerMessages; + lang.registerMessages(registerMessages); return this; } @@ -176,7 +188,7 @@ public Builder registerMessages(boolean registerMessages) { * @return builder */ public Builder addMagicUtilsMessages(boolean addMagicUtilsMessages) { - this.addMagicUtilsMessages = addMagicUtilsMessages; + lang.addMagicUtilsMessages(addMagicUtilsMessages); return this; } @@ -187,7 +199,7 @@ public Builder addMagicUtilsMessages(boolean addMagicUtilsMessages) { * @return builder */ public Builder bindClientLocaleSync(boolean bindClientLocaleSync) { - this.bindClientLocaleSync = bindClientLocaleSync; + lang.bindClientLocaleSync(bindClientLocaleSync); return this; } @@ -198,7 +210,7 @@ public Builder bindClientLocaleSync(boolean bindClientLocaleSync) { * @return builder */ public Builder translations(Consumer translations) { - this.translations = translations; + lang.translations(translations); return this; } @@ -320,10 +332,7 @@ public RuntimeResult buildRuntime() { .component(Logger.class, prepared.logger()) .build(); - if (bindClientLocaleSync) { - runtime.manage("language.clientLocaleSync", - prepared.languageManager().bindClientLocaleSync(prepared.platform())); - } + lang.bindClientLocaleSync(runtime, prepared.platform(), prepared.languageManager()); if (prepared.commandRegistry() != null) { runtime.putComponent(CommandRegistry.class, prepared.commandRegistry()); @@ -342,16 +351,7 @@ public RuntimeResult buildRuntime() { // runs, no need to re-push on every state/command change. BukkitMagicUtilsConsumerRegistry.register(plugin, runtime, prepared.commandRegistry()); runtime.onClose("magicutils.consumerRegistry", () -> BukkitMagicUtilsConsumerRegistry.unregister(plugin)); - if (registerMessages) { - runtime.onClose("messages.scope", () -> Messages.unregister(plugin.getName())); - } - if (setMessagesManager) { - runtime.onClose("messages.default", () -> { - if (Messages.getLanguageManager() == prepared.languageManager()) { - Messages.setLanguageManager(null); - } - }); - } + lang.installMessagesCloseHooks(runtime, plugin.getName(), prepared.languageManager()); return new RuntimeResult(runtime, prepared.platform(), prepared.configManager(), prepared.logger(), prepared.languageManager(), prepared.commandRegistry()); @@ -374,29 +374,7 @@ private Prepared prepare() { ? languageManager : new LanguageManager(plugin, resolvedConfigManager); - if (initLanguage) { - resolvedLanguageManager.init(language); - } - - if (translations != null) { - translations.accept(resolvedLanguageManager); - } - - if (addMagicUtilsMessages) { - resolvedLanguageManager.addMagicUtilsMessages(); - } - - if (registerMessages) { - Messages.register(plugin.getName(), resolvedLanguageManager); - } - - if (setMessagesManager) { - Messages.setLanguageManager(resolvedLanguageManager); - } - - if (bindLoggerLanguage) { - resolvedLogger.setLanguageManager(resolvedLanguageManager); - } + lang.apply(plugin.getName(), resolvedLanguageManager, resolvedLogger.getCore()); CommandRegistry registry = null; if (enableCommands) { From d1d0fa683f861248fc9c54ed68d5a710bee95223 Mon Sep 17 00:00:00 2001 From: THEROER Date: Fri, 10 Jul 2026 05:31:41 +0300 Subject: [PATCH 05/39] refactor(bootstrap): migrate Fabric/NeoForge/Velocity/Bungee to LanguageBootstrap Delegate the language/messages flags, setters, apply and close-hook logic to core LanguageBootstrap in the four remaining platform bootstraps, matching the Bukkit migration. Each builder now exposes withRecommendedDefaults()/minimal() presets. Proxy builders pass their LoggerCore directly; MC builders pass logger.getCore(). Removes the duplicated boolean-flag blocks entirely. --- .../magicutils/bootstrap/FabricBootstrap.java | 84 ++++++++---------- .../bootstrap/NeoForgeBootstrap.java | 85 ++++++++----------- .../magicutils/bootstrap/BungeeBootstrap.java | 85 ++++++++----------- .../bootstrap/VelocityBootstrap.java | 84 ++++++++---------- 4 files changed, 136 insertions(+), 202 deletions(-) diff --git a/commands-fabric/src/main/java/dev/ua/theroer/magicutils/bootstrap/FabricBootstrap.java b/commands-fabric/src/main/java/dev/ua/theroer/magicutils/bootstrap/FabricBootstrap.java index 0285ff5f..63c9562c 100644 --- a/commands-fabric/src/main/java/dev/ua/theroer/magicutils/bootstrap/FabricBootstrap.java +++ b/commands-fabric/src/main/java/dev/ua/theroer/magicutils/bootstrap/FabricBootstrap.java @@ -51,20 +51,13 @@ public static Builder forMod(String modName, Supplier serverSup public static final class Builder { private final String modName; private final Supplier serverSupplier; + private final LanguageBootstrap lang = new LanguageBootstrap(); private org.slf4j.Logger slf4j; private Path configDir; private Platform platform; private ConfigManager configManager; private Logger logger; private LanguageManager languageManager; - private String language = "en"; - private boolean initLanguage = true; - private boolean bindLoggerLanguage = true; - private boolean setMessagesManager = true; - private boolean registerMessages = true; - private boolean addMagicUtilsMessages = true; - private boolean bindClientLocaleSync = true; - private Consumer translations; private boolean enableCommands; private String permissionPrefix; private int opLevel = 2; @@ -143,6 +136,28 @@ public Builder languageManager(LanguageManager languageManager) { return this; } + /** + * Applies the recommended language defaults (every flag enabled). This + * is already the initial state; call it to make intent explicit. + * + * @return builder + */ + public Builder withRecommendedDefaults() { + lang.withRecommendedDefaults(); + return this; + } + + /** + * Disables all automatic language/messages wiring for callers that + * manage localization themselves. + * + * @return builder + */ + public Builder minimal() { + lang.minimal(); + return this; + } + /** * Sets the default language code to initialize. * @@ -150,9 +165,7 @@ public Builder languageManager(LanguageManager languageManager) { * @return builder */ public Builder language(String language) { - if (language != null && !language.isBlank()) { - this.language = language; - } + lang.language(language); return this; } @@ -163,7 +176,7 @@ public Builder language(String language) { * @return builder */ public Builder initLanguage(boolean initLanguage) { - this.initLanguage = initLanguage; + lang.initLanguage(initLanguage); return this; } @@ -174,7 +187,7 @@ public Builder initLanguage(boolean initLanguage) { * @return builder */ public Builder bindLoggerLanguage(boolean bindLoggerLanguage) { - this.bindLoggerLanguage = bindLoggerLanguage; + lang.bindLoggerLanguage(bindLoggerLanguage); return this; } @@ -185,7 +198,7 @@ public Builder bindLoggerLanguage(boolean bindLoggerLanguage) { * @return builder */ public Builder setMessagesManager(boolean setMessagesManager) { - this.setMessagesManager = setMessagesManager; + lang.setMessagesManager(setMessagesManager); return this; } @@ -196,7 +209,7 @@ public Builder setMessagesManager(boolean setMessagesManager) { * @return builder */ public Builder registerMessages(boolean registerMessages) { - this.registerMessages = registerMessages; + lang.registerMessages(registerMessages); return this; } @@ -207,7 +220,7 @@ public Builder registerMessages(boolean registerMessages) { * @return builder */ public Builder addMagicUtilsMessages(boolean addMagicUtilsMessages) { - this.addMagicUtilsMessages = addMagicUtilsMessages; + lang.addMagicUtilsMessages(addMagicUtilsMessages); return this; } @@ -218,7 +231,7 @@ public Builder addMagicUtilsMessages(boolean addMagicUtilsMessages) { * @return builder */ public Builder bindClientLocaleSync(boolean bindClientLocaleSync) { - this.bindClientLocaleSync = bindClientLocaleSync; + lang.bindClientLocaleSync(bindClientLocaleSync); return this; } @@ -229,7 +242,7 @@ public Builder bindClientLocaleSync(boolean bindClientLocaleSync) { * @return builder */ public Builder translations(Consumer translations) { - this.translations = translations; + lang.translations(translations); return this; } @@ -324,10 +337,7 @@ public RuntimeResult buildRuntime() { .component(Logger.class, prepared.logger()) .build(); - if (bindClientLocaleSync) { - runtime.manage("language.clientLocaleSync", - prepared.languageManager().bindClientLocaleSync(prepared.platform())); - } + lang.bindClientLocaleSync(runtime, prepared.platform(), prepared.languageManager()); if (prepared.commandRegistry() != null) { runtime.putComponent(CommandRegistry.class, prepared.commandRegistry()); @@ -338,16 +348,7 @@ public RuntimeResult buildRuntime() { if (enableDiagnostics) { DiagnosticsSupport.install(runtime, diagnosticsConfigurer); } - if (registerMessages) { - runtime.onClose("messages.scope", () -> Messages.unregister(modName)); - } - if (setMessagesManager) { - runtime.onClose("messages.default", () -> { - if (Messages.getLanguageManager() == prepared.languageManager()) { - Messages.setLanguageManager(null); - } - }); - } + lang.installMessagesCloseHooks(runtime, modName, prepared.languageManager()); // Register this mod in the shared-runtime consumer registry so the // standalone bundle command can list it (/magicutils mods|mod ), @@ -415,24 +416,7 @@ private Prepared prepare() { ? languageManager : new LanguageManager(resolvedPlatform, resolvedConfigManager); - if (initLanguage) { - resolvedLanguageManager.init(language); - } - if (translations != null) { - translations.accept(resolvedLanguageManager); - } - if (addMagicUtilsMessages) { - resolvedLanguageManager.addMagicUtilsMessages(); - } - if (registerMessages) { - Messages.register(modName, resolvedLanguageManager); - } - if (setMessagesManager) { - Messages.setLanguageManager(resolvedLanguageManager); - } - if (bindLoggerLanguage) { - resolvedLogger.setLanguageManager(resolvedLanguageManager); - } + lang.apply(modName, resolvedLanguageManager, resolvedLogger.getCore()); CommandRegistry registry = null; if (enableCommands) { diff --git a/commands-neoforge/src/main/java/dev/ua/theroer/magicutils/bootstrap/NeoForgeBootstrap.java b/commands-neoforge/src/main/java/dev/ua/theroer/magicutils/bootstrap/NeoForgeBootstrap.java index 64591f90..a8066459 100644 --- a/commands-neoforge/src/main/java/dev/ua/theroer/magicutils/bootstrap/NeoForgeBootstrap.java +++ b/commands-neoforge/src/main/java/dev/ua/theroer/magicutils/bootstrap/NeoForgeBootstrap.java @@ -7,7 +7,6 @@ import dev.ua.theroer.magicutils.diagnostics.DiagnosticRegistry; import dev.ua.theroer.magicutils.diagnostics.DiagnosticsService; import dev.ua.theroer.magicutils.diagnostics.DiagnosticsSupport; -import dev.ua.theroer.magicutils.lang.Messages; import dev.ua.theroer.magicutils.platform.MagicUtilsConsumerRegistry; import dev.ua.theroer.magicutils.platform.Platform; import dev.ua.theroer.magicutils.platform.neoforge.NeoForgePlatformProvider; @@ -51,20 +50,13 @@ public static Builder forMod(String modName, Supplier serverSup public static final class Builder { private final String modName; private final Supplier serverSupplier; + private final LanguageBootstrap lang = new LanguageBootstrap(); private org.slf4j.Logger slf4j; private Path configDir; private Platform platform; private ConfigManager configManager; private Logger logger; private LanguageManager languageManager; - private String language = "en"; - private boolean initLanguage = true; - private boolean bindLoggerLanguage = true; - private boolean setMessagesManager = true; - private boolean registerMessages = true; - private boolean addMagicUtilsMessages = true; - private boolean bindClientLocaleSync = true; - private Consumer translations; private boolean enableCommands; private String permissionPrefix; private int opLevel = 2; @@ -143,6 +135,28 @@ public Builder languageManager(LanguageManager languageManager) { return this; } + /** + * Applies the recommended language defaults (every flag enabled). This + * is already the initial state; call it to make intent explicit. + * + * @return this builder + */ + public Builder withRecommendedDefaults() { + lang.withRecommendedDefaults(); + return this; + } + + /** + * Disables all automatic language/messages wiring for callers that + * manage localization themselves. + * + * @return this builder + */ + public Builder minimal() { + lang.minimal(); + return this; + } + /** * Sets the default language. * @@ -150,9 +164,7 @@ public Builder languageManager(LanguageManager languageManager) { * @return this builder */ public Builder language(String language) { - if (language != null && !language.isBlank()) { - this.language = language; - } + lang.language(language); return this; } @@ -163,7 +175,7 @@ public Builder language(String language) { * @return this builder */ public Builder initLanguage(boolean initLanguage) { - this.initLanguage = initLanguage; + lang.initLanguage(initLanguage); return this; } @@ -174,7 +186,7 @@ public Builder initLanguage(boolean initLanguage) { * @return this builder */ public Builder bindLoggerLanguage(boolean bindLoggerLanguage) { - this.bindLoggerLanguage = bindLoggerLanguage; + lang.bindLoggerLanguage(bindLoggerLanguage); return this; } @@ -185,7 +197,7 @@ public Builder bindLoggerLanguage(boolean bindLoggerLanguage) { * @return this builder */ public Builder setMessagesManager(boolean setMessagesManager) { - this.setMessagesManager = setMessagesManager; + lang.setMessagesManager(setMessagesManager); return this; } @@ -196,7 +208,7 @@ public Builder setMessagesManager(boolean setMessagesManager) { * @return this builder */ public Builder registerMessages(boolean registerMessages) { - this.registerMessages = registerMessages; + lang.registerMessages(registerMessages); return this; } @@ -207,7 +219,7 @@ public Builder registerMessages(boolean registerMessages) { * @return this builder */ public Builder addMagicUtilsMessages(boolean addMagicUtilsMessages) { - this.addMagicUtilsMessages = addMagicUtilsMessages; + lang.addMagicUtilsMessages(addMagicUtilsMessages); return this; } @@ -218,7 +230,7 @@ public Builder addMagicUtilsMessages(boolean addMagicUtilsMessages) { * @return this builder */ public Builder bindClientLocaleSync(boolean bindClientLocaleSync) { - this.bindClientLocaleSync = bindClientLocaleSync; + lang.bindClientLocaleSync(bindClientLocaleSync); return this; } @@ -229,7 +241,7 @@ public Builder bindClientLocaleSync(boolean bindClientLocaleSync) { * @return this builder */ public Builder translations(Consumer translations) { - this.translations = translations; + lang.translations(translations); return this; } @@ -324,10 +336,7 @@ public RuntimeResult buildRuntime() { .component(Logger.class, prepared.logger()) .build(); - if (bindClientLocaleSync) { - runtime.manage("language.clientLocaleSync", - prepared.languageManager().bindClientLocaleSync(prepared.platform())); - } + lang.bindClientLocaleSync(runtime, prepared.platform(), prepared.languageManager()); if (prepared.commandRegistry() != null) { runtime.putComponent(CommandRegistry.class, prepared.commandRegistry()); @@ -338,16 +347,7 @@ public RuntimeResult buildRuntime() { if (enableDiagnostics) { DiagnosticsSupport.install(runtime, diagnosticsConfigurer); } - if (registerMessages) { - runtime.onClose("messages.scope", () -> Messages.unregister(modName)); - } - if (setMessagesManager) { - runtime.onClose("messages.default", () -> { - if (Messages.getLanguageManager() == prepared.languageManager()) { - Messages.setLanguageManager(null); - } - }); - } + lang.installMessagesCloseHooks(runtime, modName, prepared.languageManager()); // Publish this mod into the shared-runtime registry so the bundle's // `/magicutils mods` lists it (mirrors FabricBootstrap). @@ -433,24 +433,7 @@ private Prepared prepare() { ? languageManager : new LanguageManager(resolvedPlatform, resolvedConfigManager); - if (initLanguage) { - resolvedLanguageManager.init(language); - } - if (translations != null) { - translations.accept(resolvedLanguageManager); - } - if (addMagicUtilsMessages) { - resolvedLanguageManager.addMagicUtilsMessages(); - } - if (registerMessages) { - Messages.register(modName, resolvedLanguageManager); - } - if (setMessagesManager) { - Messages.setLanguageManager(resolvedLanguageManager); - } - if (bindLoggerLanguage) { - resolvedLogger.setLanguageManager(resolvedLanguageManager); - } + lang.apply(modName, resolvedLanguageManager, resolvedLogger.getCore()); CommandRegistry registry = null; if (enableCommands) { diff --git a/platform-bungee/src/main/java/dev/ua/theroer/magicutils/bootstrap/BungeeBootstrap.java b/platform-bungee/src/main/java/dev/ua/theroer/magicutils/bootstrap/BungeeBootstrap.java index 521f21be..61053e52 100644 --- a/platform-bungee/src/main/java/dev/ua/theroer/magicutils/bootstrap/BungeeBootstrap.java +++ b/platform-bungee/src/main/java/dev/ua/theroer/magicutils/bootstrap/BungeeBootstrap.java @@ -6,7 +6,6 @@ import dev.ua.theroer.magicutils.diagnostics.DiagnosticRegistry; import dev.ua.theroer.magicutils.diagnostics.DiagnosticsService; import dev.ua.theroer.magicutils.diagnostics.DiagnosticsSupport; -import dev.ua.theroer.magicutils.lang.Messages; import dev.ua.theroer.magicutils.logger.LoggerCore; import dev.ua.theroer.magicutils.messaging.MessagingService; import dev.ua.theroer.magicutils.messaging.bungee.BungeeMessagingSupport; @@ -48,20 +47,13 @@ public static final class Builder { private final Plugin plugin; private final ProxyServer proxy; private final String pluginName; + private final LanguageBootstrap lang = new LanguageBootstrap(); private Path dataDirectory; private Logger jul; private Platform platform; private ConfigManager configManager; private LoggerCore logger; private LanguageManager languageManager; - private String language = "en"; - private boolean initLanguage = true; - private boolean bindLoggerLanguage = true; - private boolean setMessagesManager = true; - private boolean registerMessages = true; - private boolean addMagicUtilsMessages = true; - private boolean bindClientLocaleSync = true; - private Consumer translations; private boolean enableCommands; private String permissionPrefix; private Executor asyncExecutor; @@ -145,6 +137,28 @@ public Builder languageManager(LanguageManager languageManager) { return this; } + /** + * Applies the recommended language defaults (every flag enabled). This + * is already the initial state; call it to make intent explicit. + * + * @return this builder + */ + public Builder withRecommendedDefaults() { + lang.withRecommendedDefaults(); + return this; + } + + /** + * Disables all automatic language/messages wiring for callers that + * manage localization themselves. + * + * @return this builder + */ + public Builder minimal() { + lang.minimal(); + return this; + } + /** * Sets the default language. * @@ -152,9 +166,7 @@ public Builder languageManager(LanguageManager languageManager) { * @return this builder */ public Builder language(String language) { - if (language != null && !language.isBlank()) { - this.language = language; - } + lang.language(language); return this; } @@ -165,7 +177,7 @@ public Builder language(String language) { * @return this builder */ public Builder initLanguage(boolean initLanguage) { - this.initLanguage = initLanguage; + lang.initLanguage(initLanguage); return this; } @@ -176,7 +188,7 @@ public Builder initLanguage(boolean initLanguage) { * @return this builder */ public Builder bindLoggerLanguage(boolean bindLoggerLanguage) { - this.bindLoggerLanguage = bindLoggerLanguage; + lang.bindLoggerLanguage(bindLoggerLanguage); return this; } @@ -187,7 +199,7 @@ public Builder bindLoggerLanguage(boolean bindLoggerLanguage) { * @return this builder */ public Builder setMessagesManager(boolean setMessagesManager) { - this.setMessagesManager = setMessagesManager; + lang.setMessagesManager(setMessagesManager); return this; } @@ -198,7 +210,7 @@ public Builder setMessagesManager(boolean setMessagesManager) { * @return this builder */ public Builder registerMessages(boolean registerMessages) { - this.registerMessages = registerMessages; + lang.registerMessages(registerMessages); return this; } @@ -209,7 +221,7 @@ public Builder registerMessages(boolean registerMessages) { * @return this builder */ public Builder addMagicUtilsMessages(boolean addMagicUtilsMessages) { - this.addMagicUtilsMessages = addMagicUtilsMessages; + lang.addMagicUtilsMessages(addMagicUtilsMessages); return this; } @@ -220,7 +232,7 @@ public Builder addMagicUtilsMessages(boolean addMagicUtilsMessages) { * @return this builder */ public Builder bindClientLocaleSync(boolean bindClientLocaleSync) { - this.bindClientLocaleSync = bindClientLocaleSync; + lang.bindClientLocaleSync(bindClientLocaleSync); return this; } @@ -231,7 +243,7 @@ public Builder bindClientLocaleSync(boolean bindClientLocaleSync) { * @return this builder */ public Builder translations(Consumer translations) { - this.translations = translations; + lang.translations(translations); return this; } @@ -361,10 +373,7 @@ public RuntimeResult buildRuntime() { .component(ProxyServer.class, proxy) .build(); - if (bindClientLocaleSync) { - runtime.manage("language.clientLocaleSync", - prepared.languageManager().bindClientLocaleSync(prepared.platform())); - } + lang.bindClientLocaleSync(runtime, prepared.platform(), prepared.languageManager()); if (prepared.commandRegistry() != null) { runtime.putComponent(CommandRegistry.class, prepared.commandRegistry()); @@ -379,16 +388,7 @@ public RuntimeResult buildRuntime() { BungeeMessagingSupport.install( runtime, proxy, plugin, pluginName, messagingRedis, messagingConfigurer); } - if (registerMessages) { - runtime.onClose("messages.scope", () -> Messages.unregister(pluginName)); - } - if (setMessagesManager) { - runtime.onClose("messages.default", () -> { - if (Messages.getLanguageManager() == prepared.languageManager()) { - Messages.setLanguageManager(null); - } - }); - } + lang.installMessagesCloseHooks(runtime, pluginName, prepared.languageManager()); return new RuntimeResult(runtime, prepared.platform(), prepared.configManager(), prepared.logger(), prepared.languageManager(), prepared.commandRegistry()); @@ -408,24 +408,7 @@ private Prepared prepare() { ? languageManager : new LanguageManager(resolvedPlatform, resolvedConfigManager); - if (initLanguage) { - resolvedLanguageManager.init(language); - } - if (translations != null) { - translations.accept(resolvedLanguageManager); - } - if (addMagicUtilsMessages) { - resolvedLanguageManager.addMagicUtilsMessages(); - } - if (registerMessages) { - Messages.register(pluginName, resolvedLanguageManager); - } - if (setMessagesManager) { - Messages.setLanguageManager(resolvedLanguageManager); - } - if (bindLoggerLanguage) { - resolvedLogger.setLanguageManager(resolvedLanguageManager); - } + lang.apply(pluginName, resolvedLanguageManager, resolvedLogger); CommandRegistry registry = null; if (enableCommands) { diff --git a/platform-velocity/src/main/java/dev/ua/theroer/magicutils/bootstrap/VelocityBootstrap.java b/platform-velocity/src/main/java/dev/ua/theroer/magicutils/bootstrap/VelocityBootstrap.java index b7de3aa2..83d86637 100644 --- a/platform-velocity/src/main/java/dev/ua/theroer/magicutils/bootstrap/VelocityBootstrap.java +++ b/platform-velocity/src/main/java/dev/ua/theroer/magicutils/bootstrap/VelocityBootstrap.java @@ -58,20 +58,13 @@ public static final class Builder { private final ProxyServer proxy; private final Object plugin; private final String pluginName; + private final LanguageBootstrap lang = new LanguageBootstrap(); private Path dataDirectory; private Logger slf4j; private Platform platform; private ConfigManager configManager; private LoggerCore logger; private LanguageManager languageManager; - private String language = "en"; - private boolean initLanguage = true; - private boolean bindLoggerLanguage = true; - private boolean setMessagesManager = true; - private boolean registerMessages = true; - private boolean addMagicUtilsMessages = true; - private boolean bindClientLocaleSync = true; - private Consumer translations; private boolean enableCommands; private String permissionPrefix; private Executor asyncExecutor; @@ -155,6 +148,28 @@ public Builder languageManager(LanguageManager languageManager) { return this; } + /** + * Applies the recommended language defaults (every flag enabled). This + * is already the initial state; call it to make intent explicit. + * + * @return builder + */ + public Builder withRecommendedDefaults() { + lang.withRecommendedDefaults(); + return this; + } + + /** + * Disables all automatic language/messages wiring for callers that + * manage localization themselves. + * + * @return builder + */ + public Builder minimal() { + lang.minimal(); + return this; + } + /** * Sets the default language code to initialize. * @@ -162,9 +177,7 @@ public Builder languageManager(LanguageManager languageManager) { * @return builder */ public Builder language(String language) { - if (language != null && !language.isBlank()) { - this.language = language; - } + lang.language(language); return this; } @@ -175,7 +188,7 @@ public Builder language(String language) { * @return builder */ public Builder initLanguage(boolean initLanguage) { - this.initLanguage = initLanguage; + lang.initLanguage(initLanguage); return this; } @@ -186,7 +199,7 @@ public Builder initLanguage(boolean initLanguage) { * @return builder */ public Builder bindLoggerLanguage(boolean bindLoggerLanguage) { - this.bindLoggerLanguage = bindLoggerLanguage; + lang.bindLoggerLanguage(bindLoggerLanguage); return this; } @@ -197,7 +210,7 @@ public Builder bindLoggerLanguage(boolean bindLoggerLanguage) { * @return builder */ public Builder setMessagesManager(boolean setMessagesManager) { - this.setMessagesManager = setMessagesManager; + lang.setMessagesManager(setMessagesManager); return this; } @@ -208,7 +221,7 @@ public Builder setMessagesManager(boolean setMessagesManager) { * @return builder */ public Builder registerMessages(boolean registerMessages) { - this.registerMessages = registerMessages; + lang.registerMessages(registerMessages); return this; } @@ -219,7 +232,7 @@ public Builder registerMessages(boolean registerMessages) { * @return builder */ public Builder addMagicUtilsMessages(boolean addMagicUtilsMessages) { - this.addMagicUtilsMessages = addMagicUtilsMessages; + lang.addMagicUtilsMessages(addMagicUtilsMessages); return this; } @@ -230,7 +243,7 @@ public Builder addMagicUtilsMessages(boolean addMagicUtilsMessages) { * @return builder */ public Builder bindClientLocaleSync(boolean bindClientLocaleSync) { - this.bindClientLocaleSync = bindClientLocaleSync; + lang.bindClientLocaleSync(bindClientLocaleSync); return this; } @@ -241,7 +254,7 @@ public Builder bindClientLocaleSync(boolean bindClientLocaleSync) { * @return builder */ public Builder translations(Consumer translations) { - this.translations = translations; + lang.translations(translations); return this; } @@ -370,10 +383,7 @@ public RuntimeResult buildRuntime() { .component(ProxyServer.class, proxy) .build(); - if (bindClientLocaleSync) { - runtime.manage("language.clientLocaleSync", - prepared.languageManager().bindClientLocaleSync(prepared.platform())); - } + lang.bindClientLocaleSync(runtime, prepared.platform(), prepared.languageManager()); if (prepared.commandRegistry() != null) { runtime.putComponent(CommandRegistry.class, prepared.commandRegistry()); @@ -388,16 +398,7 @@ public RuntimeResult buildRuntime() { VelocityMessagingSupport.install( runtime, proxy, plugin, pluginName, messagingRedis, messagingConfigurer); } - if (registerMessages) { - runtime.onClose("messages.scope", () -> Messages.unregister(pluginName)); - } - if (setMessagesManager) { - runtime.onClose("messages.default", () -> { - if (Messages.getLanguageManager() == prepared.languageManager()) { - Messages.setLanguageManager(null); - } - }); - } + lang.installMessagesCloseHooks(runtime, pluginName, prepared.languageManager()); // Register this plugin in the shared-runtime consumer registry so the // standalone velocity-bundle command can list it @@ -473,24 +474,7 @@ private Prepared prepare() { ? languageManager : new LanguageManager(resolvedPlatform, resolvedConfigManager); - if (initLanguage) { - resolvedLanguageManager.init(language); - } - if (translations != null) { - translations.accept(resolvedLanguageManager); - } - if (addMagicUtilsMessages) { - resolvedLanguageManager.addMagicUtilsMessages(); - } - if (registerMessages) { - Messages.register(pluginName, resolvedLanguageManager); - } - if (setMessagesManager) { - Messages.setLanguageManager(resolvedLanguageManager); - } - if (bindLoggerLanguage) { - resolvedLogger.setLanguageManager(resolvedLanguageManager); - } + lang.apply(pluginName, resolvedLanguageManager, resolvedLogger); CommandRegistry registry = null; if (enableCommands) { From a0b0aa120848a2f48c99842c87a0845fc285cc91 Mon Sep 17 00:00:00 2001 From: THEROER Date: Fri, 10 Jul 2026 05:33:53 +0300 Subject: [PATCH 06/39] test(bootstrap): cover LanguageBootstrap presets and apply/close wiring Verifies withRecommendedDefaults()/minimal() flag state and that apply() plus installMessagesCloseHooks() register/clear the scoped and global Messages manager and logger binding, driven against real LanguageManager/Messages. --- .../bootstrap/LanguageBootstrapTest.java | 189 ++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 core/src/test/java/dev/ua/theroer/magicutils/bootstrap/LanguageBootstrapTest.java diff --git a/core/src/test/java/dev/ua/theroer/magicutils/bootstrap/LanguageBootstrapTest.java b/core/src/test/java/dev/ua/theroer/magicutils/bootstrap/LanguageBootstrapTest.java new file mode 100644 index 00000000..9c799f65 --- /dev/null +++ b/core/src/test/java/dev/ua/theroer/magicutils/bootstrap/LanguageBootstrapTest.java @@ -0,0 +1,189 @@ +package dev.ua.theroer.magicutils.bootstrap; + +import dev.ua.theroer.magicutils.config.ConfigManager; +import dev.ua.theroer.magicutils.lang.LanguageManager; +import dev.ua.theroer.magicutils.lang.Messages; +import dev.ua.theroer.magicutils.logger.LoggerCore; +import dev.ua.theroer.magicutils.platform.Audience; +import dev.ua.theroer.magicutils.platform.Platform; +import dev.ua.theroer.magicutils.platform.PlatformLogger; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collection; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class LanguageBootstrapTest { + private static final String SCOPE = "LanguageBootstrapTest"; + + @TempDir + Path tempDir; + + private final List openManagers = new java.util.ArrayList<>(); + + @AfterEach + void resetGlobalState() { + // apply()/hooks mutate the global Messages singleton; keep tests isolated. + Messages.unregister(SCOPE); + Messages.setLanguageManager(null); + // Release config watchers/handles so @TempDir cleanup can delete the dir. + for (ConfigManager manager : openManagers) { + manager.shutdown(); + } + openManagers.clear(); + } + + @Test + void recommendedDefaultsKeepEveryFlagEnabled() { + LanguageBootstrap lang = new LanguageBootstrap().withRecommendedDefaults(); + + assertTrue(lang.bindsClientLocaleSync()); + assertTrue(lang.registersMessages()); + assertTrue(lang.setsMessagesManager()); + } + + @Test + void minimalDisablesEveryFlag() { + LanguageBootstrap lang = new LanguageBootstrap().minimal(); + + assertFalse(lang.bindsClientLocaleSync()); + assertFalse(lang.registersMessages()); + assertFalse(lang.setsMessagesManager()); + } + + @Test + void applyWithDefaultsRegistersScopeAndGlobalManager() throws IOException { + Fixture fixture = newFixture("defaults"); + LanguageManager manager = fixture.languageManager(); + + new LanguageBootstrap().apply(SCOPE, manager, fixture.logger()); + + assertSame(manager, Messages.getLanguageManager(SCOPE)); + assertSame(manager, Messages.getLanguageManager()); + assertSame(manager, fixture.logger().getLanguageManager()); + } + + @Test + void applyMinimalSkipsMessagesAndLoggerWiring() throws IOException { + Fixture fixture = newFixture("minimal"); + LanguageManager manager = fixture.languageManager(); + + new LanguageBootstrap().minimal().apply(SCOPE, manager, fixture.logger()); + + assertNull(Messages.getLanguageManager(SCOPE)); + assertNull(Messages.getLanguageManager()); + } + + @Test + void closeHooksRunWhenFlagsEnabled() throws IOException { + Fixture fixture = newFixture("close-hooks"); + LanguageManager manager = fixture.languageManager(); + LanguageBootstrap lang = new LanguageBootstrap(); + lang.apply(SCOPE, manager, fixture.logger()); + + try (MagicRuntime runtime = MagicRuntime.builder( + fixture.platform(), fixture.configManager(), fixture.logger()) + .manageConfigManager(false) + .autoRegisterShutdown(false) + .build()) { + lang.installMessagesCloseHooks(runtime, SCOPE, manager); + + assertSame(manager, Messages.getLanguageManager(SCOPE)); + runtime.close(); + + assertNull(Messages.getLanguageManager(SCOPE)); + assertNull(Messages.getLanguageManager()); + } + } + + private Fixture newFixture(String name) throws IOException { + Path directory = Files.createDirectories(tempDir.resolve(name)); + TestPlatform platform = new TestPlatform(directory); + ConfigManager configManager = new ConfigManager(platform); + openManagers.add(configManager); + LoggerCore logger = new LoggerCore(platform, configManager, this, SCOPE); + LanguageManager manager = new LanguageManager(platform, configManager); + return new Fixture(platform, configManager, logger, manager); + } + + private record Fixture(TestPlatform platform, + ConfigManager configManager, + LoggerCore logger, + LanguageManager languageManager) { + } + + private static final class TestPlatform implements Platform { + private final Path configDir; + private final PlatformLogger logger = new NoopPlatformLogger(); + + private TestPlatform(Path configDir) { + this.configDir = configDir; + } + + @Override + public Path configDir() { + return configDir; + } + + @Override + public PlatformLogger logger() { + return logger; + } + + @Override + public Audience console() { + return null; + } + + @Override + public Collection onlinePlayers() { + return List.of(); + } + + @Override + public void runOnMain(Runnable task) { + if (task != null) { + task.run(); + } + } + + @Override + public boolean isMainThread() { + return true; + } + } + + private static final class NoopPlatformLogger implements PlatformLogger { + @Override + public void info(String message) { + } + + @Override + public void warn(String message) { + } + + @Override + public void warn(String message, Throwable throwable) { + } + + @Override + public void error(String message) { + } + + @Override + public void error(String message, Throwable throwable) { + } + + @Override + public void debug(String message) { + } + } +} From d9b57998a3279e50bb0e718421773509117a83de Mon Sep 17 00:00:00 2001 From: THEROER Date: Fri, 10 Jul 2026 05:35:39 +0300 Subject: [PATCH 07/39] feat(bootstrap): add metadata-derived forPlugin overloads for proxies Velocity gains forPlugin(proxy, plugin) and Bungee gains forPlugin(plugin), deriving the plugin name (and Velocity's data directory as plugins/) from the plugin's @Plugin/description metadata. The verbose overloads that take an explicit name/dataDirectory are kept and marked @Deprecated for source compatibility. This makes the entry points consistent across platforms: Bukkit/Bungee forPlugin(plugin), Velocity forPlugin(proxy, plugin), Fabric/NeoForge forMod(id, supplier). --- .../magicutils/bootstrap/BungeeBootstrap.java | 22 +++++++++++- .../bootstrap/VelocityBootstrap.java | 36 ++++++++++++++++++- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/platform-bungee/src/main/java/dev/ua/theroer/magicutils/bootstrap/BungeeBootstrap.java b/platform-bungee/src/main/java/dev/ua/theroer/magicutils/bootstrap/BungeeBootstrap.java index 61053e52..2bc06765 100644 --- a/platform-bungee/src/main/java/dev/ua/theroer/magicutils/bootstrap/BungeeBootstrap.java +++ b/platform-bungee/src/main/java/dev/ua/theroer/magicutils/bootstrap/BungeeBootstrap.java @@ -30,12 +30,32 @@ private BungeeBootstrap() { } /** - * Creates a bootstrap builder for a Bungee plugin. + * Creates a bootstrap builder for a Bungee plugin, resolving the plugin + * name from the plugin description. + * + *

This is the recommended entry point. The plugin name is taken from + * {@code plugin.getDescription().getName()}; the data directory is the + * plugin's data folder. + * + * @param plugin Bungee plugin instance + * @return bootstrap builder + */ + public static Builder forPlugin(Plugin plugin) { + Objects.requireNonNull(plugin, "plugin"); + String pluginName = plugin.getDescription() != null ? plugin.getDescription().getName() : null; + return new Builder(plugin, pluginName); + } + + /** + * Creates a bootstrap builder for a Bungee plugin with an explicit name. * * @param plugin Bungee plugin instance * @param pluginName logical plugin name for logger/messages * @return bootstrap builder + * @deprecated prefer {@link #forPlugin(Plugin)}, which derives the name from + * the plugin description */ + @Deprecated public static Builder forPlugin(Plugin plugin, String pluginName) { return new Builder(plugin, pluginName); } diff --git a/platform-velocity/src/main/java/dev/ua/theroer/magicutils/bootstrap/VelocityBootstrap.java b/platform-velocity/src/main/java/dev/ua/theroer/magicutils/bootstrap/VelocityBootstrap.java index 83d86637..036cee02 100644 --- a/platform-velocity/src/main/java/dev/ua/theroer/magicutils/bootstrap/VelocityBootstrap.java +++ b/platform-velocity/src/main/java/dev/ua/theroer/magicutils/bootstrap/VelocityBootstrap.java @@ -39,14 +39,48 @@ private VelocityBootstrap() { } /** - * Creates a bootstrap builder for a Velocity plugin. + * Creates a bootstrap builder for a Velocity plugin, resolving the plugin + * name and data directory from the plugin's {@code @Plugin} metadata. + * + *

This is the recommended entry point. The plugin name is taken from the + * registered {@link PluginDescription} (name, falling back to id); the data + * directory defaults to {@code plugins/}. Use + * {@link Builder#dataDirectory(Path)} to override, for example when the + * plugin injects a {@code @DataDirectory Path}. + * + * @param proxy Velocity proxy server + * @param plugin plugin instance used for event registration and metadata + * @return bootstrap builder + */ + public static Builder forPlugin(ProxyServer proxy, Object plugin) { + Objects.requireNonNull(proxy, "proxy"); + Objects.requireNonNull(plugin, "plugin"); + Optional description = proxy.getPluginManager() + .fromInstance(plugin) + .map(container -> container.getDescription()); + String id = description.map(PluginDescription::getId).filter(s -> !s.isBlank()).orElse("magicutils"); + String name = description + .flatMap(PluginDescription::getName) + .filter(s -> !s.isBlank()) + .orElse(id); + Path dataDirectory = Path.of("plugins", id); + return new Builder(proxy, plugin, name, dataDirectory); + } + + /** + * Creates a bootstrap builder for a Velocity plugin with an explicit name + * and data directory. * * @param proxy Velocity proxy server * @param plugin plugin instance used for event registration * @param pluginName logical plugin name for logger/messages * @param dataDirectory plugin data directory * @return bootstrap builder + * @deprecated prefer {@link #forPlugin(ProxyServer, Object)}, which derives + * the name and data directory from the plugin metadata; override the + * data directory with {@link Builder#dataDirectory(Path)} when needed */ + @Deprecated public static Builder forPlugin(ProxyServer proxy, Object plugin, String pluginName, Path dataDirectory) { return new Builder(proxy, plugin, pluginName, dataDirectory); } From 6511dcb96b028042b06eeedd4793ddd7c35ca668 Mon Sep 17 00:00:00 2001 From: THEROER Date: Fri, 10 Jul 2026 05:46:59 +0300 Subject: [PATCH 08/39] refactor(logger): self-type LogBuilderCore to drop covariant override duplication Each platform LogBuilder re-declared ~10 covariant overrides of the shared fluent methods (to/target/toAll/toConsole/noPrefix/prefixMode/args/ placeholders/withResolvers/toAudiences) purely to keep the chain typed as the platform builder rather than LogBuilderCore. Make LogBuilderCore return SELF from those methods so subclasses inherit them already correctly typed. Platform builders now hold only their player/command-source overloads (Bukkit -66 lines, Fabric/NeoForge -~70 each). --- .../theroer/magicutils/logger/LogBuilder.java | 114 ++---------------- .../theroer/magicutils/logger/LogBuilder.java | 82 ++----------- .../magicutils/logger/LogBuilderCore.java | 59 +++++---- .../theroer/magicutils/logger/LogBuilder.java | 70 +---------- 4 files changed, 62 insertions(+), 263 deletions(-) diff --git a/commands-neoforge/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilder.java b/commands-neoforge/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilder.java index d9b5bc76..b3af468a 100644 --- a/commands-neoforge/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilder.java +++ b/commands-neoforge/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilder.java @@ -1,18 +1,20 @@ package dev.ua.theroer.magicutils.logger; import dev.ua.theroer.magicutils.Logger; -import dev.ua.theroer.magicutils.platform.Audience; -import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; import net.minecraft.commands.CommandSourceStack; import net.minecraft.server.level.ServerPlayer; import java.util.Collection; -import java.util.Map; /** - * NeoForge-specific log builder with player helpers. + * NeoForge-specific log builder with player and command-source helpers. + * + *

The shared fluent methods ({@code to(Audience)}, {@code target}, + * {@code noPrefix}, ...) are inherited from {@link LogBuilderCore} and already + * return {@code LogBuilder} thanks to its self type; this class only adds the + * NeoForge-typed recipient overloads. */ -public class LogBuilder extends LogBuilderCore { +public class LogBuilder extends LogBuilderCore { private final Logger logger; /** @@ -26,24 +28,6 @@ public LogBuilder(Logger logger, LogLevel level) { this.logger = logger; } - /** - * {@inheritDoc} - */ - @Override - public LogBuilder to(Audience audience) { - super.to(audience); - return this; - } - - /** - * {@inheritDoc} - */ - @Override - public LogBuilder target(LogTarget target) { - super.target(target); - return this; - } - /** * Sends the log to a specific player. * @@ -58,7 +42,7 @@ public LogBuilder to(ServerPlayer player) { /** * Sends the log to multiple players. * - * @param players player recipients + * @param players target players * @return this builder */ public LogBuilder to(ServerPlayer... players) { @@ -75,7 +59,7 @@ public LogBuilder to(ServerPlayer... players) { /** * Sends the log to a collection of players. * - * @param players player recipients + * @param players target players * @return this builder */ public LogBuilder to(Collection players) { @@ -92,7 +76,7 @@ public LogBuilder to(Collection players) { /** * Adds a player as a recipient. * - * @param player player recipient + * @param player target player * @return this builder */ public LogBuilder recipient(ServerPlayer player) { @@ -101,7 +85,7 @@ public LogBuilder recipient(ServerPlayer player) { } /** - * Sends the log to a command source. + * Sets command source as the direct audience. * * @param source command source * @return this builder @@ -112,7 +96,7 @@ public LogBuilder to(CommandSourceStack source) { } /** - * Sends the log to a command source with optional op broadcast. + * Sets command source as the direct audience, optionally broadcasting to ops. * * @param source command source * @param broadcastToOps whether to broadcast to ops @@ -124,7 +108,7 @@ public LogBuilder to(CommandSourceStack source, boolean broadcastToOps) { } /** - * Sends the log to a command source in error mode. + * Sets command source as the error audience. * * @param source command source * @return this builder @@ -155,76 +139,4 @@ public LogBuilder recipientError(CommandSourceStack source) { super.recipient(logger.wrapErrorAudience(source)); return this; } - - /** - * {@inheritDoc} - */ - @Override - public LogBuilder toAudiences(Collection audiences) { - super.toAudiences(audiences); - return this; - } - - /** - * {@inheritDoc} - */ - @Override - public LogBuilder toAll() { - super.toAll(); - return this; - } - - /** - * {@inheritDoc} - */ - @Override - public LogBuilder toConsole() { - super.toConsole(); - return this; - } - - /** - * {@inheritDoc} - */ - @Override - public LogBuilder noPrefix() { - super.noPrefix(); - return this; - } - - /** - * {@inheritDoc} - */ - @Override - public LogBuilder prefixMode(PrefixMode mode) { - super.prefixMode(mode); - return this; - } - - /** - * {@inheritDoc} - */ - @Override - public LogBuilder args(Object... args) { - super.args(args); - return this; - } - - /** - * {@inheritDoc} - */ - @Override - public LogBuilder placeholders(Map placeholders) { - super.placeholders(placeholders); - return this; - } - - /** - * {@inheritDoc} - */ - @Override - public LogBuilder withResolvers(TagResolver... resolvers) { - super.withResolvers(resolvers); - return this; - } } diff --git a/logger-fabric/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilder.java b/logger-fabric/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilder.java index e7d33dd5..bb111f5d 100644 --- a/logger-fabric/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilder.java +++ b/logger-fabric/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilder.java @@ -1,17 +1,19 @@ package dev.ua.theroer.magicutils.logger; import dev.ua.theroer.magicutils.Logger; -import dev.ua.theroer.magicutils.platform.Audience; -import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; import net.minecraft.commands.CommandSourceStack; import net.minecraft.server.level.ServerPlayer; import java.util.Collection; -import java.util.Map; /** - * Fabric-specific log builder with player helpers. + * Fabric-specific log builder with player and command-source helpers. + * + *

The shared fluent methods ({@code to(Audience)}, {@code target}, + * {@code noPrefix}, ...) are inherited from {@link LogBuilderCore} and already + * return {@code LogBuilder} thanks to its self type; this class only adds the + * Fabric-typed recipient overloads. */ -public class LogBuilder extends LogBuilderCore { +public class LogBuilder extends LogBuilderCore { private final Logger logger; /** @@ -25,20 +27,6 @@ public LogBuilder(Logger logger, LogLevel level) { this.logger = logger; } - /** {@inheritDoc} */ - @Override - public LogBuilder to(Audience audience) { - super.to(audience); - return this; - } - - /** {@inheritDoc} */ - @Override - public LogBuilder target(LogTarget target) { - super.target(target); - return this; - } - /** * Adds a player recipient. * @@ -150,60 +138,4 @@ public LogBuilder recipientError(CommandSourceStack source) { super.recipient(logger.wrapErrorAudience(source)); return this; } - - /** {@inheritDoc} */ - @Override - public LogBuilder toAudiences(Collection audiences) { - super.toAudiences(audiences); - return this; - } - - /** {@inheritDoc} */ - @Override - public LogBuilder toAll() { - super.toAll(); - return this; - } - - /** {@inheritDoc} */ - @Override - public LogBuilder toConsole() { - super.toConsole(); - return this; - } - - /** {@inheritDoc} */ - @Override - public LogBuilder noPrefix() { - super.noPrefix(); - return this; - } - - /** {@inheritDoc} */ - @Override - public LogBuilder prefixMode(PrefixMode mode) { - super.prefixMode(mode); - return this; - } - - /** {@inheritDoc} */ - @Override - public LogBuilder args(Object... args) { - super.args(args); - return this; - } - - /** {@inheritDoc} */ - @Override - public LogBuilder placeholders(Map placeholders) { - super.placeholders(placeholders); - return this; - } - - /** {@inheritDoc} */ - @Override - public LogBuilder withResolvers(TagResolver... resolvers) { - super.withResolvers(resolvers); - return this; - } } diff --git a/logger/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilderCore.java b/logger/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilderCore.java index cacf28f1..c072f3e8 100644 --- a/logger/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilderCore.java +++ b/logger/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilderCore.java @@ -12,8 +12,16 @@ /** * Fluent API builder for logging messages with advanced options. + * + *

Uses a self-referential type parameter so the shared fluent methods return + * the concrete platform builder type. Platform subclasses declare + * {@code class LogBuilder extends LogBuilderCore} and only add their + * player/command-source overloads; they no longer need to re-declare covariant + * overrides of the shared methods just to keep the chain typed. + * + * @param concrete builder type returned by the fluent methods */ -public class LogBuilderCore { +public class LogBuilderCore> { /** Logger core used to dispatch messages. */ protected final LoggerCore logger; /** Log level associated with this builder. */ @@ -46,6 +54,11 @@ public LogBuilderCore(LoggerCore logger, LogLevel level) { this.tagResolvers = new ArrayList<>(); } + @SuppressWarnings("unchecked") + private SELF self() { + return (SELF) this; + } + /** * Returns a snapshot of queued recipients. * @@ -88,9 +101,9 @@ public Map getPlaceholders() { * @param target log target * @return this builder */ - public LogBuilderCore target(LogTarget target) { + public SELF target(LogTarget target) { this.target = target; - return this; + return self(); } /** @@ -99,9 +112,9 @@ public LogBuilderCore target(LogTarget target) { * @param audience target audience * @return this builder */ - public LogBuilderCore to(Audience audience) { + public SELF to(Audience audience) { this.audience = audience; - return this; + return self(); } /** @@ -110,11 +123,11 @@ public LogBuilderCore to(Audience audience) { * @param audiences recipient collection * @return this builder */ - public LogBuilderCore toAudiences(Collection audiences) { + public SELF toAudiences(Collection audiences) { if (audiences != null) { this.recipients.addAll(audiences); } - return this; + return self(); } /** @@ -123,11 +136,11 @@ public LogBuilderCore toAudiences(Collection audiences) { * @param audience recipient * @return this builder */ - public LogBuilderCore recipient(Audience audience) { + public SELF recipient(Audience audience) { if (audience != null) { this.recipients.add(audience); } - return this; + return self(); } /** @@ -135,9 +148,9 @@ public LogBuilderCore recipient(Audience audience) { * * @return this builder */ - public LogBuilderCore toAll() { + public SELF toAll() { this.broadcast = true; - return this; + return self(); } /** @@ -145,9 +158,9 @@ public LogBuilderCore toAll() { * * @return this builder */ - public LogBuilderCore toConsole() { + public SELF toConsole() { this.target = LogTarget.CONSOLE; - return this; + return self(); } /** @@ -155,10 +168,10 @@ public LogBuilderCore toConsole() { * * @return this builder */ - public LogBuilderCore noPrefix() { + public SELF noPrefix() { this.noPrefix = true; this.prefixOverride = PrefixMode.NONE; - return this; + return self(); } /** @@ -167,9 +180,9 @@ public LogBuilderCore noPrefix() { * @param mode prefix mode override * @return this builder */ - public LogBuilderCore prefixMode(PrefixMode mode) { + public SELF prefixMode(PrefixMode mode) { this.prefixOverride = mode; - return this; + return self(); } /** @@ -178,9 +191,9 @@ public LogBuilderCore prefixMode(PrefixMode mode) { * @param args placeholder arguments * @return this builder */ - public LogBuilderCore args(Object... args) { + public SELF args(Object... args) { this.args = args != null ? args.clone() : null; - return this; + return self(); } /** @@ -189,9 +202,9 @@ public LogBuilderCore args(Object... args) { * @param placeholders placeholder map * @return this builder */ - public LogBuilderCore placeholders(Map placeholders) { + public SELF placeholders(Map placeholders) { this.placeholders = placeholders != null ? new HashMap<>(placeholders) : null; - return this; + return self(); } /** @@ -200,7 +213,7 @@ public LogBuilderCore placeholders(Map placeholders) { * @param resolvers tag resolvers * @return this builder */ - public LogBuilderCore withResolvers(TagResolver... resolvers) { + public SELF withResolvers(TagResolver... resolvers) { if (resolvers != null) { for (TagResolver resolver : resolvers) { if (resolver != null) { @@ -208,7 +221,7 @@ public LogBuilderCore withResolvers(TagResolver... resolvers) { } } } - return this; + return self(); } /** diff --git a/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilder.java b/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilder.java index 2169f167..020f0f50 100644 --- a/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilder.java +++ b/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilder.java @@ -1,19 +1,21 @@ package dev.ua.theroer.magicutils.logger; import dev.ua.theroer.magicutils.Logger; -import dev.ua.theroer.magicutils.platform.Audience; -import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; import java.util.Collection; -import java.util.Map; /** * Bukkit-specific log builder with Player/CommandSender helpers. + * + *

The shared fluent methods ({@code to(Audience)}, {@code target}, + * {@code noPrefix}, ...) are inherited from {@link LogBuilderCore} and already + * return {@code LogBuilder} thanks to its self type; this class only adds the + * Bukkit-typed recipient overloads. */ @SuppressWarnings("doclint:missing") -public class LogBuilder extends LogBuilderCore { +public class LogBuilder extends LogBuilderCore { private final Logger logger; public LogBuilder(Logger logger, LogLevel level) { @@ -21,18 +23,6 @@ public LogBuilder(Logger logger, LogLevel level) { this.logger = logger; } - @Override - public LogBuilder to(Audience audience) { - super.to(audience); - return this; - } - - @Override - public LogBuilder target(LogTarget target) { - super.target(target); - return this; - } - public LogBuilder to(Player player) { super.to(logger.wrapAudience(player)); return this; @@ -80,52 +70,4 @@ public LogBuilder toSenders(Collection senders) { } return this; } - - @Override - public LogBuilder toAudiences(Collection audiences) { - super.toAudiences(audiences); - return this; - } - - @Override - public LogBuilder toAll() { - super.toAll(); - return this; - } - - @Override - public LogBuilder toConsole() { - super.toConsole(); - return this; - } - - @Override - public LogBuilder noPrefix() { - super.noPrefix(); - return this; - } - - @Override - public LogBuilder prefixMode(PrefixMode mode) { - super.prefixMode(mode); - return this; - } - - @Override - public LogBuilder args(Object... args) { - super.args(args); - return this; - } - - @Override - public LogBuilder placeholders(Map placeholders) { - super.placeholders(placeholders); - return this; - } - - @Override - public LogBuilder withResolvers(TagResolver... resolvers) { - super.withResolvers(resolvers); - return this; - } } From e4edbc53c149dfd0d388d7a42e98a1ae3dfa3f4d Mon Sep 17 00:00:00 2001 From: THEROER Date: Fri, 10 Jul 2026 05:49:45 +0300 Subject: [PATCH 09/39] feat(velocity): add typed Logger facade for parity with other platforms Velocity previously exposed only the raw LoggerCore. Add a Velocity Logger (plus PrefixedLogger and LogBuilder) mirroring Bukkit/Fabric/NeoForge: generated info/warn/error/success/debug/trace methods, Player-typed overloads, prefixed sub-loggers, and a fluent LogBuilder. VelocityBootstrap now builds and returns this Logger (RuntimeResult/Result.logger type LoggerCore -> Logger; safe, no external consumer uses it). VelocityAudience is made public so the facade can wrap players. --- .../dev/ua/theroer/magicutils/Logger.java | 153 ++++++++++++++++++ .../bootstrap/VelocityBootstrap.java | 32 ++-- .../theroer/magicutils/logger/LogBuilder.java | 84 ++++++++++ .../magicutils/logger/PrefixedLogger.java | 96 +++++++++++ .../platform/velocity/VelocityAudience.java | 9 +- 5 files changed, 356 insertions(+), 18 deletions(-) create mode 100644 platform-velocity/src/main/java/dev/ua/theroer/magicutils/Logger.java create mode 100644 platform-velocity/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilder.java create mode 100644 platform-velocity/src/main/java/dev/ua/theroer/magicutils/logger/PrefixedLogger.java diff --git a/platform-velocity/src/main/java/dev/ua/theroer/magicutils/Logger.java b/platform-velocity/src/main/java/dev/ua/theroer/magicutils/Logger.java new file mode 100644 index 00000000..d26e9927 --- /dev/null +++ b/platform-velocity/src/main/java/dev/ua/theroer/magicutils/Logger.java @@ -0,0 +1,153 @@ +package dev.ua.theroer.magicutils; + +import com.velocitypowered.api.proxy.Player; +import dev.ua.theroer.magicutils.config.ConfigManager; +import dev.ua.theroer.magicutils.logger.LogBuilder; +import dev.ua.theroer.magicutils.logger.LogLevel; +import dev.ua.theroer.magicutils.logger.LogMethods; +import dev.ua.theroer.magicutils.logger.LogTarget; +import dev.ua.theroer.magicutils.logger.LoggerAdapter; +import dev.ua.theroer.magicutils.logger.LoggerCore; +import dev.ua.theroer.magicutils.logger.PrefixedLogger; +import dev.ua.theroer.magicutils.logger.PrefixedLoggerCore; +import dev.ua.theroer.magicutils.platform.Audience; +import dev.ua.theroer.magicutils.platform.Platform; +import dev.ua.theroer.magicutils.platform.velocity.VelocityAudience; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; + +/** + * Velocity logger adapter backed by {@link LoggerCore}. + * + *

Gives Velocity the same typed logger facade as Bukkit/Fabric/NeoForge: + * generated {@code info/warn/error/success/debug/trace} methods, player-typed + * overloads keyed on {@link Player}, prefixed sub-loggers, and a fluent + * {@link LogBuilder}. Console routing reuses the platform's structured console + * audience. + */ +@LogMethods(staticMethods = false, audienceType = "com.velocitypowered.api.proxy.Player") +public final class Logger extends LoggerMethods implements LoggerAdapter { + private final LoggerCore core; + private final Map prefixedLoggers = new HashMap<>(); + + /** + * Creates a Velocity logger instance. + * + * @param platform platform adapter + * @param manager config manager + * @param placeholderOwner placeholder owner key + * @param pluginName plugin name for the logger prefix + */ + public Logger(Platform platform, ConfigManager manager, Object placeholderOwner, String pluginName) { + this.core = new LoggerCore(platform, manager, placeholderOwner, pluginName); + } + + @Override + public LoggerCore getCore() { + return core; + } + + @Override + public Map getPrefixedLoggers() { + return prefixedLoggers; + } + + @Override + public PrefixedLogger buildPrefixedLogger(PrefixedLoggerCore prefixedCore) { + return new PrefixedLogger(this, prefixedCore); + } + + /** + * Creates an INFO level log builder. + * + * @return log builder + */ + public LogBuilder log() { + return new LogBuilder(this, LogLevel.INFO); + } + + /** + * Creates an INFO level log builder with the prefix disabled. + * + * @return log builder + */ + public LogBuilder noPrefix() { + return new LogBuilder(this, LogLevel.INFO).noPrefix(); + } + + /** + * Creates an INFO level log builder. + * + * @return log builder + */ + public LogBuilder info() { + return new LogBuilder(this, LogLevel.INFO); + } + + /** + * Creates a WARN level log builder. + * + * @return log builder + */ + public LogBuilder warn() { + return new LogBuilder(this, LogLevel.WARN); + } + + /** + * Creates an ERROR level log builder. + * + * @return log builder + */ + public LogBuilder error() { + return new LogBuilder(this, LogLevel.ERROR); + } + + /** + * Creates a DEBUG level log builder. + * + * @return log builder + */ + public LogBuilder debug() { + return new LogBuilder(this, LogLevel.DEBUG); + } + + /** + * Creates a SUCCESS level log builder. + * + * @return log builder + */ + public LogBuilder success() { + return new LogBuilder(this, LogLevel.SUCCESS); + } + + @Override + protected void send(LogLevel level, Object message) { + send(level, message, null, null, getDefaultTarget(), false); + } + + @Override + protected void send(LogLevel level, Object message, Player player) { + send(level, message, player, null, LogTarget.CHAT, false); + } + + @Override + protected void send(LogLevel level, Object message, Player player, boolean all) { + send(level, message, player, null, getDefaultTarget(), all); + } + + @Override + protected void sendToConsole(LogLevel level, Object message) { + send(level, message, null, null, LogTarget.CONSOLE, false); + } + + @Override + protected void sendToPlayers(LogLevel level, Object message, Collection players) { + send(level, message, null, players, LogTarget.CHAT, false); + } + + @Override + public Audience wrapAudience(Player player) { + return player != null ? new VelocityAudience(player) : null; + } +} diff --git a/platform-velocity/src/main/java/dev/ua/theroer/magicutils/bootstrap/VelocityBootstrap.java b/platform-velocity/src/main/java/dev/ua/theroer/magicutils/bootstrap/VelocityBootstrap.java index 036cee02..3b628fc5 100644 --- a/platform-velocity/src/main/java/dev/ua/theroer/magicutils/bootstrap/VelocityBootstrap.java +++ b/platform-velocity/src/main/java/dev/ua/theroer/magicutils/bootstrap/VelocityBootstrap.java @@ -2,6 +2,7 @@ import com.velocitypowered.api.plugin.PluginDescription; import com.velocitypowered.api.proxy.ProxyServer; +import dev.ua.theroer.magicutils.Logger; import dev.ua.theroer.magicutils.bootstrap.MagicUtilsConsumerPayloads; import dev.ua.theroer.magicutils.bootstrap.MagicUtilsConsumerViews; import dev.ua.theroer.magicutils.commands.CommandRegistry; @@ -11,7 +12,6 @@ import dev.ua.theroer.magicutils.diagnostics.DiagnosticsService; import dev.ua.theroer.magicutils.diagnostics.DiagnosticsSupport; import dev.ua.theroer.magicutils.lang.Messages; -import dev.ua.theroer.magicutils.logger.LoggerCore; import dev.ua.theroer.magicutils.messaging.MessagingService; import dev.ua.theroer.magicutils.messaging.redis.RedisConfig; import dev.ua.theroer.magicutils.messaging.velocity.VelocityMessagingSupport; @@ -19,7 +19,6 @@ import dev.ua.theroer.magicutils.platform.Platform; import dev.ua.theroer.magicutils.platform.velocity.VelocityMagicUtilsConsumerRegistry; import dev.ua.theroer.magicutils.platform.velocity.VelocityPlatformProvider; -import org.slf4j.Logger; import java.nio.file.Path; import java.time.Instant; @@ -94,10 +93,10 @@ public static final class Builder { private final String pluginName; private final LanguageBootstrap lang = new LanguageBootstrap(); private Path dataDirectory; - private Logger slf4j; + private org.slf4j.Logger slf4j; private Platform platform; private ConfigManager configManager; - private LoggerCore logger; + private Logger logger; private LanguageManager languageManager; private boolean enableCommands; private String permissionPrefix; @@ -144,7 +143,7 @@ public Builder dataDirectory(Path dataDirectory) { * @param slf4j logger to use * @return builder */ - public Builder slf4j(Logger slf4j) { + public Builder slf4j(org.slf4j.Logger slf4j) { this.slf4j = slf4j; return this; } @@ -161,12 +160,12 @@ public Builder configManager(ConfigManager configManager) { } /** - * Overrides the logger core instance. + * Overrides the logger instance. * - * @param logger logger core to use + * @param logger logger to use * @return builder */ - public Builder logger(LoggerCore logger) { + public Builder logger(Logger logger) { this.logger = logger; return this; } @@ -410,11 +409,12 @@ public RuntimeResult buildRuntime() { MagicRuntime runtime = MagicRuntime.builder( prepared.platform(), prepared.configManager(), - prepared.logger() + prepared.logger().getCore() ) .languageManager(prepared.languageManager()) .manageConfigManager(configManager == null) .component(ProxyServer.class, proxy) + .component(Logger.class, prepared.logger()) .build(); lang.bindClientLocaleSync(runtime, prepared.platform(), prepared.languageManager()); @@ -501,19 +501,19 @@ private Prepared prepare() { ConfigManager resolvedConfigManager = configManager != null ? configManager : new ConfigManager(resolvedPlatform); - LoggerCore resolvedLogger = logger != null + Logger resolvedLogger = logger != null ? logger - : new LoggerCore(resolvedPlatform, resolvedConfigManager, plugin, pluginName); + : new Logger(resolvedPlatform, resolvedConfigManager, plugin, pluginName); LanguageManager resolvedLanguageManager = languageManager != null ? languageManager : new LanguageManager(resolvedPlatform, resolvedConfigManager); - lang.apply(pluginName, resolvedLanguageManager, resolvedLogger); + lang.apply(pluginName, resolvedLanguageManager, resolvedLogger.getCore()); CommandRegistry registry = null; if (enableCommands) { String prefix = permissionPrefix != null ? permissionPrefix : pluginName; - registry = CommandRegistry.create(proxy, plugin, prefix, resolvedLogger, asyncExecutor); + registry = CommandRegistry.create(proxy, plugin, prefix, resolvedLogger.getCore(), asyncExecutor); if (commandConfigurer != null) { commandConfigurer.accept(registry); } @@ -532,7 +532,7 @@ private static String normalizePluginName(String pluginName) { private record Prepared( Platform platform, ConfigManager configManager, - LoggerCore logger, + Logger logger, LanguageManager languageManager, CommandRegistry commandRegistry ) { @@ -551,7 +551,7 @@ private record Prepared( public record Result( Platform platform, ConfigManager configManager, - LoggerCore logger, + Logger logger, LanguageManager languageManager, CommandRegistry commandRegistry ) { @@ -571,7 +571,7 @@ public record RuntimeResult( MagicRuntime runtime, Platform platform, ConfigManager configManager, - LoggerCore logger, + Logger logger, LanguageManager languageManager, CommandRegistry commandRegistry ) { diff --git a/platform-velocity/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilder.java b/platform-velocity/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilder.java new file mode 100644 index 00000000..1a14eade --- /dev/null +++ b/platform-velocity/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilder.java @@ -0,0 +1,84 @@ +package dev.ua.theroer.magicutils.logger; + +import com.velocitypowered.api.proxy.Player; +import dev.ua.theroer.magicutils.Logger; +import java.util.Collection; + +/** + * Velocity-specific log builder with player helpers. + * + *

The shared fluent methods ({@code to(Audience)}, {@code target}, + * {@code noPrefix}, ...) are inherited from {@link LogBuilderCore} and already + * return {@code LogBuilder} thanks to its self type; this class only adds the + * Velocity-typed recipient overloads. + */ +public class LogBuilder extends LogBuilderCore { + private final Logger logger; + + /** + * Creates a log builder for the specified level. + * + * @param logger parent logger + * @param level log level + */ + public LogBuilder(Logger logger, LogLevel level) { + super(logger.getCore(), level); + this.logger = logger; + } + + /** + * Adds a player recipient. + * + * @param player target player + * @return this builder + */ + public LogBuilder to(Player player) { + super.to(logger.wrapAudience(player)); + return this; + } + + /** + * Adds multiple player recipients. + * + * @param players target players + * @return this builder + */ + public LogBuilder to(Player... players) { + if (players != null) { + for (Player player : players) { + if (player != null) { + super.recipient(logger.wrapAudience(player)); + } + } + } + return this; + } + + /** + * Adds a collection of player recipients. + * + * @param players target players + * @return this builder + */ + public LogBuilder to(Collection players) { + if (players != null) { + for (Player player : players) { + if (player != null) { + super.recipient(logger.wrapAudience(player)); + } + } + } + return this; + } + + /** + * Adds a player as a recipient. + * + * @param player target player + * @return this builder + */ + public LogBuilder recipient(Player player) { + super.recipient(logger.wrapAudience(player)); + return this; + } +} diff --git a/platform-velocity/src/main/java/dev/ua/theroer/magicutils/logger/PrefixedLogger.java b/platform-velocity/src/main/java/dev/ua/theroer/magicutils/logger/PrefixedLogger.java new file mode 100644 index 00000000..0f9a7e48 --- /dev/null +++ b/platform-velocity/src/main/java/dev/ua/theroer/magicutils/logger/PrefixedLogger.java @@ -0,0 +1,96 @@ +package dev.ua.theroer.magicutils.logger; + +import com.velocitypowered.api.proxy.Player; +import dev.ua.theroer.magicutils.Logger; +import java.util.Collection; + +/** + * Logger instance with a custom prefix. + * All log methods are generated by the annotation processor. + */ +@LogMethods(staticMethods = false, audienceType = "com.velocitypowered.api.proxy.Player") +public class PrefixedLogger extends PrefixedLoggerMethods implements PrefixedLoggerAdapter { + private final Logger logger; + private final PrefixedLoggerCore core; + + /** + * Creates a prefixed logger wrapper. + * + * @param logger parent logger + * @param core prefixed core instance + */ + public PrefixedLogger(Logger logger, PrefixedLoggerCore core) { + this.logger = logger; + this.core = core; + } + + @Override + public PrefixedLoggerCore getCore() { + return core; + } + + @Override + public LoggerAdapter getLoggerAdapter() { + return logger; + } + + @Override + public LogBuilder buildLogBuilder(LogLevel level) { + return new PrefixedLogBuilder(level); + } + + @Override + public void send(LogLevel level, Object message) { + PrefixedLoggerAdapter.super.send(level, message); + } + + @Override + public void send(LogLevel level, Object message, Player player) { + PrefixedLoggerAdapter.super.send(level, message, player); + } + + @Override + public void send(LogLevel level, Object message, Player player, boolean all) { + PrefixedLoggerAdapter.super.send(level, message, player, all); + } + + @Override + public void sendToConsole(LogLevel level, Object message) { + PrefixedLoggerAdapter.super.sendToConsole(level, message); + } + + @Override + public void sendToPlayers(LogLevel level, Object message, Collection players) { + PrefixedLoggerAdapter.super.sendToPlayers(level, message, players); + } + + private class PrefixedLogBuilder extends LogBuilder { + PrefixedLogBuilder(LogLevel level) { + super(logger, level); + } + + @Override + protected void performSend(Object message, Object... placeholders) { + if (!core.isEnabled()) { + return; + } + LogTarget finalTarget = getTarget() != null ? getTarget() : logger.getCore().getDefaultTarget(); + Collection audienceRecipients = null; + if (!getRecipients().isEmpty()) { + audienceRecipients = getRecipients(); + } + logger.getCore().send( + level, + message, + getAudience(), + audienceRecipients, + finalTarget, + isBroadcast(), + new ConsoleMessageMetadata(level, core.getName()), + core.getPrefix(), + getPrefixOverride(), + placeholders + ); + } + } +} diff --git a/platform-velocity/src/main/java/dev/ua/theroer/magicutils/platform/velocity/VelocityAudience.java b/platform-velocity/src/main/java/dev/ua/theroer/magicutils/platform/velocity/VelocityAudience.java index 67522216..f70bb5de 100644 --- a/platform-velocity/src/main/java/dev/ua/theroer/magicutils/platform/velocity/VelocityAudience.java +++ b/platform-velocity/src/main/java/dev/ua/theroer/magicutils/platform/velocity/VelocityAudience.java @@ -10,11 +10,16 @@ /** * Wraps a Velocity command source as a MagicUtils audience. */ -final class VelocityAudience implements Audience { +public final class VelocityAudience implements Audience { private final CommandSource source; private final UUID id; - VelocityAudience(CommandSource source) { + /** + * Wraps a Velocity command source (player or console) as an audience. + * + * @param source command source to wrap + */ + public VelocityAudience(CommandSource source) { this.source = source; this.id = source instanceof Player player ? player.getUniqueId() : null; } From 1dcd62dabcf66970c2d8de299b371eeb04ef7f82 Mon Sep 17 00:00:00 2001 From: THEROER Date: Fri, 10 Jul 2026 14:09:49 +0300 Subject: [PATCH 10/39] feat(bungee): add typed Logger facade for parity with other platforms Mirror the Velocity change on BungeeCord: add a Bungee Logger (plus PrefixedLogger and LogBuilder) with generated log methods, ProxiedPlayer-typed overloads, prefixed sub-loggers, and a fluent LogBuilder. BungeeBootstrap now builds and returns this Logger (RuntimeResult/Result.logger type LoggerCore -> Logger; safe, no external consumer uses it). BungeeAudience is made public so the facade can wrap players. All five platforms now expose the same typed Logger facade. --- .../dev/ua/theroer/magicutils/Logger.java | 153 ++++++++++++++++++ .../magicutils/bootstrap/BungeeBootstrap.java | 32 ++-- .../theroer/magicutils/logger/LogBuilder.java | 84 ++++++++++ .../magicutils/logger/PrefixedLogger.java | 96 +++++++++++ .../platform/bungee/BungeeAudience.java | 9 +- 5 files changed, 356 insertions(+), 18 deletions(-) create mode 100644 platform-bungee/src/main/java/dev/ua/theroer/magicutils/Logger.java create mode 100644 platform-bungee/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilder.java create mode 100644 platform-bungee/src/main/java/dev/ua/theroer/magicutils/logger/PrefixedLogger.java diff --git a/platform-bungee/src/main/java/dev/ua/theroer/magicutils/Logger.java b/platform-bungee/src/main/java/dev/ua/theroer/magicutils/Logger.java new file mode 100644 index 00000000..c5677aa1 --- /dev/null +++ b/platform-bungee/src/main/java/dev/ua/theroer/magicutils/Logger.java @@ -0,0 +1,153 @@ +package dev.ua.theroer.magicutils; + +import dev.ua.theroer.magicutils.config.ConfigManager; +import dev.ua.theroer.magicutils.logger.LogBuilder; +import dev.ua.theroer.magicutils.logger.LogLevel; +import dev.ua.theroer.magicutils.logger.LogMethods; +import dev.ua.theroer.magicutils.logger.LogTarget; +import dev.ua.theroer.magicutils.logger.LoggerAdapter; +import dev.ua.theroer.magicutils.logger.LoggerCore; +import dev.ua.theroer.magicutils.logger.PrefixedLogger; +import dev.ua.theroer.magicutils.logger.PrefixedLoggerCore; +import dev.ua.theroer.magicutils.platform.Audience; +import dev.ua.theroer.magicutils.platform.Platform; +import dev.ua.theroer.magicutils.platform.bungee.BungeeAudience; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import net.md_5.bungee.api.connection.ProxiedPlayer; + +/** + * BungeeCord logger adapter backed by {@link LoggerCore}. + * + *

Gives BungeeCord the same typed logger facade as Bukkit/Fabric/NeoForge: + * generated {@code info/warn/error/success/debug/trace} methods, player-typed + * overloads keyed on {@link ProxiedPlayer}, prefixed sub-loggers, and a fluent + * {@link LogBuilder}. Console routing reuses the platform's structured console + * audience. + */ +@LogMethods(staticMethods = false, audienceType = "net.md_5.bungee.api.connection.ProxiedPlayer") +public final class Logger extends LoggerMethods implements LoggerAdapter { + private final LoggerCore core; + private final Map prefixedLoggers = new HashMap<>(); + + /** + * Creates a BungeeCord logger instance. + * + * @param platform platform adapter + * @param manager config manager + * @param placeholderOwner placeholder owner key + * @param pluginName plugin name for the logger prefix + */ + public Logger(Platform platform, ConfigManager manager, Object placeholderOwner, String pluginName) { + this.core = new LoggerCore(platform, manager, placeholderOwner, pluginName); + } + + @Override + public LoggerCore getCore() { + return core; + } + + @Override + public Map getPrefixedLoggers() { + return prefixedLoggers; + } + + @Override + public PrefixedLogger buildPrefixedLogger(PrefixedLoggerCore prefixedCore) { + return new PrefixedLogger(this, prefixedCore); + } + + /** + * Creates an INFO level log builder. + * + * @return log builder + */ + public LogBuilder log() { + return new LogBuilder(this, LogLevel.INFO); + } + + /** + * Creates an INFO level log builder with the prefix disabled. + * + * @return log builder + */ + public LogBuilder noPrefix() { + return new LogBuilder(this, LogLevel.INFO).noPrefix(); + } + + /** + * Creates an INFO level log builder. + * + * @return log builder + */ + public LogBuilder info() { + return new LogBuilder(this, LogLevel.INFO); + } + + /** + * Creates a WARN level log builder. + * + * @return log builder + */ + public LogBuilder warn() { + return new LogBuilder(this, LogLevel.WARN); + } + + /** + * Creates an ERROR level log builder. + * + * @return log builder + */ + public LogBuilder error() { + return new LogBuilder(this, LogLevel.ERROR); + } + + /** + * Creates a DEBUG level log builder. + * + * @return log builder + */ + public LogBuilder debug() { + return new LogBuilder(this, LogLevel.DEBUG); + } + + /** + * Creates a SUCCESS level log builder. + * + * @return log builder + */ + public LogBuilder success() { + return new LogBuilder(this, LogLevel.SUCCESS); + } + + @Override + protected void send(LogLevel level, Object message) { + send(level, message, null, null, getDefaultTarget(), false); + } + + @Override + protected void send(LogLevel level, Object message, ProxiedPlayer player) { + send(level, message, player, null, LogTarget.CHAT, false); + } + + @Override + protected void send(LogLevel level, Object message, ProxiedPlayer player, boolean all) { + send(level, message, player, null, getDefaultTarget(), all); + } + + @Override + protected void sendToConsole(LogLevel level, Object message) { + send(level, message, null, null, LogTarget.CONSOLE, false); + } + + @Override + protected void sendToPlayers(LogLevel level, Object message, Collection players) { + send(level, message, null, players, LogTarget.CHAT, false); + } + + @Override + public Audience wrapAudience(ProxiedPlayer player) { + return player != null ? new BungeeAudience(player) : null; + } +} diff --git a/platform-bungee/src/main/java/dev/ua/theroer/magicutils/bootstrap/BungeeBootstrap.java b/platform-bungee/src/main/java/dev/ua/theroer/magicutils/bootstrap/BungeeBootstrap.java index 2bc06765..078241d1 100644 --- a/platform-bungee/src/main/java/dev/ua/theroer/magicutils/bootstrap/BungeeBootstrap.java +++ b/platform-bungee/src/main/java/dev/ua/theroer/magicutils/bootstrap/BungeeBootstrap.java @@ -6,7 +6,7 @@ import dev.ua.theroer.magicutils.diagnostics.DiagnosticRegistry; import dev.ua.theroer.magicutils.diagnostics.DiagnosticsService; import dev.ua.theroer.magicutils.diagnostics.DiagnosticsSupport; -import dev.ua.theroer.magicutils.logger.LoggerCore; +import dev.ua.theroer.magicutils.Logger; import dev.ua.theroer.magicutils.messaging.MessagingService; import dev.ua.theroer.magicutils.messaging.bungee.BungeeMessagingSupport; import dev.ua.theroer.magicutils.messaging.redis.RedisConfig; @@ -19,7 +19,6 @@ import java.util.Objects; import java.util.concurrent.Executor; import java.util.function.Consumer; -import java.util.logging.Logger; import org.jetbrains.annotations.Nullable; /** @@ -69,10 +68,10 @@ public static final class Builder { private final String pluginName; private final LanguageBootstrap lang = new LanguageBootstrap(); private Path dataDirectory; - private Logger jul; + private java.util.logging.Logger jul; private Platform platform; private ConfigManager configManager; - private LoggerCore logger; + private Logger logger; private LanguageManager languageManager; private boolean enableCommands; private String permissionPrefix; @@ -119,7 +118,7 @@ public Builder dataDirectory(Path dataDirectory) { * @param jul logger instance * @return this builder */ - public Builder logger(Logger jul) { + public Builder logger(java.util.logging.Logger jul) { this.jul = jul; return this; } @@ -136,12 +135,12 @@ public Builder configManager(ConfigManager configManager) { } /** - * Sets the custom logger core. + * Sets the custom logger. * - * @param logger logger core + * @param logger logger to use * @return this builder */ - public Builder loggerCore(LoggerCore logger) { + public Builder loggerCore(Logger logger) { this.logger = logger; return this; } @@ -385,12 +384,13 @@ public RuntimeResult buildRuntime() { MagicRuntime runtime = MagicRuntime.builder( prepared.platform(), prepared.configManager(), - prepared.logger() + prepared.logger().getCore() ) .languageManager(prepared.languageManager()) .manageConfigManager(configManager == null) .component(Plugin.class, plugin) .component(ProxyServer.class, proxy) + .component(Logger.class, prepared.logger()) .build(); lang.bindClientLocaleSync(runtime, prepared.platform(), prepared.languageManager()); @@ -421,19 +421,19 @@ private Prepared prepare() { ConfigManager resolvedConfigManager = configManager != null ? configManager : new ConfigManager(resolvedPlatform); - LoggerCore resolvedLogger = logger != null + Logger resolvedLogger = logger != null ? logger - : new LoggerCore(resolvedPlatform, resolvedConfigManager, plugin, pluginName); + : new Logger(resolvedPlatform, resolvedConfigManager, plugin, pluginName); LanguageManager resolvedLanguageManager = languageManager != null ? languageManager : new LanguageManager(resolvedPlatform, resolvedConfigManager); - lang.apply(pluginName, resolvedLanguageManager, resolvedLogger); + lang.apply(pluginName, resolvedLanguageManager, resolvedLogger.getCore()); CommandRegistry registry = null; if (enableCommands) { String prefix = permissionPrefix != null ? permissionPrefix : pluginName; - registry = CommandRegistry.create(proxy, plugin, prefix, resolvedLogger, asyncExecutor); + registry = CommandRegistry.create(proxy, plugin, prefix, resolvedLogger.getCore(), asyncExecutor); if (commandConfigurer != null) { commandConfigurer.accept(registry); } @@ -452,7 +452,7 @@ private static String normalizePluginName(String pluginName) { private record Prepared( Platform platform, ConfigManager configManager, - LoggerCore logger, + Logger logger, LanguageManager languageManager, CommandRegistry commandRegistry ) { @@ -471,7 +471,7 @@ private record Prepared( public record Result( Platform platform, ConfigManager configManager, - LoggerCore logger, + Logger logger, LanguageManager languageManager, CommandRegistry commandRegistry ) { @@ -491,7 +491,7 @@ public record RuntimeResult( MagicRuntime runtime, Platform platform, ConfigManager configManager, - LoggerCore logger, + Logger logger, LanguageManager languageManager, CommandRegistry commandRegistry ) { diff --git a/platform-bungee/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilder.java b/platform-bungee/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilder.java new file mode 100644 index 00000000..b22c75ef --- /dev/null +++ b/platform-bungee/src/main/java/dev/ua/theroer/magicutils/logger/LogBuilder.java @@ -0,0 +1,84 @@ +package dev.ua.theroer.magicutils.logger; + +import dev.ua.theroer.magicutils.Logger; +import java.util.Collection; +import net.md_5.bungee.api.connection.ProxiedPlayer; + +/** + * BungeeCord-specific log builder with player helpers. + * + *

The shared fluent methods ({@code to(Audience)}, {@code target}, + * {@code noPrefix}, ...) are inherited from {@link LogBuilderCore} and already + * return {@code LogBuilder} thanks to its self type; this class only adds the + * Bungee-typed recipient overloads. + */ +public class LogBuilder extends LogBuilderCore { + private final Logger logger; + + /** + * Creates a log builder for the specified level. + * + * @param logger parent logger + * @param level log level + */ + public LogBuilder(Logger logger, LogLevel level) { + super(logger.getCore(), level); + this.logger = logger; + } + + /** + * Adds a player recipient. + * + * @param player target player + * @return this builder + */ + public LogBuilder to(ProxiedPlayer player) { + super.to(logger.wrapAudience(player)); + return this; + } + + /** + * Adds multiple player recipients. + * + * @param players target players + * @return this builder + */ + public LogBuilder to(ProxiedPlayer... players) { + if (players != null) { + for (ProxiedPlayer player : players) { + if (player != null) { + super.recipient(logger.wrapAudience(player)); + } + } + } + return this; + } + + /** + * Adds a collection of player recipients. + * + * @param players target players + * @return this builder + */ + public LogBuilder to(Collection players) { + if (players != null) { + for (ProxiedPlayer player : players) { + if (player != null) { + super.recipient(logger.wrapAudience(player)); + } + } + } + return this; + } + + /** + * Adds a player as a recipient. + * + * @param player target player + * @return this builder + */ + public LogBuilder recipient(ProxiedPlayer player) { + super.recipient(logger.wrapAudience(player)); + return this; + } +} diff --git a/platform-bungee/src/main/java/dev/ua/theroer/magicutils/logger/PrefixedLogger.java b/platform-bungee/src/main/java/dev/ua/theroer/magicutils/logger/PrefixedLogger.java new file mode 100644 index 00000000..64de7957 --- /dev/null +++ b/platform-bungee/src/main/java/dev/ua/theroer/magicutils/logger/PrefixedLogger.java @@ -0,0 +1,96 @@ +package dev.ua.theroer.magicutils.logger; + +import dev.ua.theroer.magicutils.Logger; +import java.util.Collection; +import net.md_5.bungee.api.connection.ProxiedPlayer; + +/** + * Logger instance with a custom prefix. + * All log methods are generated by the annotation processor. + */ +@LogMethods(staticMethods = false, audienceType = "net.md_5.bungee.api.connection.ProxiedPlayer") +public class PrefixedLogger extends PrefixedLoggerMethods implements PrefixedLoggerAdapter { + private final Logger logger; + private final PrefixedLoggerCore core; + + /** + * Creates a prefixed logger wrapper. + * + * @param logger parent logger + * @param core prefixed core instance + */ + public PrefixedLogger(Logger logger, PrefixedLoggerCore core) { + this.logger = logger; + this.core = core; + } + + @Override + public PrefixedLoggerCore getCore() { + return core; + } + + @Override + public LoggerAdapter getLoggerAdapter() { + return logger; + } + + @Override + public LogBuilder buildLogBuilder(LogLevel level) { + return new PrefixedLogBuilder(level); + } + + @Override + public void send(LogLevel level, Object message) { + PrefixedLoggerAdapter.super.send(level, message); + } + + @Override + public void send(LogLevel level, Object message, ProxiedPlayer player) { + PrefixedLoggerAdapter.super.send(level, message, player); + } + + @Override + public void send(LogLevel level, Object message, ProxiedPlayer player, boolean all) { + PrefixedLoggerAdapter.super.send(level, message, player, all); + } + + @Override + public void sendToConsole(LogLevel level, Object message) { + PrefixedLoggerAdapter.super.sendToConsole(level, message); + } + + @Override + public void sendToPlayers(LogLevel level, Object message, Collection players) { + PrefixedLoggerAdapter.super.sendToPlayers(level, message, players); + } + + private class PrefixedLogBuilder extends LogBuilder { + PrefixedLogBuilder(LogLevel level) { + super(logger, level); + } + + @Override + protected void performSend(Object message, Object... placeholders) { + if (!core.isEnabled()) { + return; + } + LogTarget finalTarget = getTarget() != null ? getTarget() : logger.getCore().getDefaultTarget(); + Collection audienceRecipients = null; + if (!getRecipients().isEmpty()) { + audienceRecipients = getRecipients(); + } + logger.getCore().send( + level, + message, + getAudience(), + audienceRecipients, + finalTarget, + isBroadcast(), + new ConsoleMessageMetadata(level, core.getName()), + core.getPrefix(), + getPrefixOverride(), + placeholders + ); + } + } +} diff --git a/platform-bungee/src/main/java/dev/ua/theroer/magicutils/platform/bungee/BungeeAudience.java b/platform-bungee/src/main/java/dev/ua/theroer/magicutils/platform/bungee/BungeeAudience.java index 00ab8158..cc1d8ff0 100644 --- a/platform-bungee/src/main/java/dev/ua/theroer/magicutils/platform/bungee/BungeeAudience.java +++ b/platform-bungee/src/main/java/dev/ua/theroer/magicutils/platform/bungee/BungeeAudience.java @@ -10,11 +10,16 @@ /** * Wraps a Bungee command sender as a MagicUtils audience. */ -final class BungeeAudience implements Audience { +public final class BungeeAudience implements Audience { private final CommandSender source; private final UUID id; - BungeeAudience(CommandSender source) { + /** + * Wraps a Bungee command sender (player or console) as an audience. + * + * @param source command sender to wrap + */ + public BungeeAudience(CommandSender source) { this.source = source; this.id = source instanceof ProxiedPlayer player ? player.getUniqueId() : null; } From ef9c236e41c635daf8d3777278a11c30a142d63b Mon Sep 17 00:00:00 2001 From: THEROER Date: Fri, 10 Jul 2026 14:12:13 +0300 Subject: [PATCH 11/39] fix(bundles): adapt proxy bundles to Logger-typed bootstrap.logger() VelocityBootstrap/BungeeBootstrap now return the typed Logger facade instead of LoggerCore. The standalone velocity-bundle and bungee-bundle plugins read bootstrap.logger() as a LoggerCore, so call .getCore() to unwrap it. --- .../theroer/magicutils/bungee/MagicUtilsBungeeBundlePlugin.java | 2 +- .../magicutils/velocity/MagicUtilsVelocityBundlePlugin.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bungee-bundle/src/main/java/dev/ua/theroer/magicutils/bungee/MagicUtilsBungeeBundlePlugin.java b/bungee-bundle/src/main/java/dev/ua/theroer/magicutils/bungee/MagicUtilsBungeeBundlePlugin.java index 98685ec7..0ff495b5 100644 --- a/bungee-bundle/src/main/java/dev/ua/theroer/magicutils/bungee/MagicUtilsBungeeBundlePlugin.java +++ b/bungee-bundle/src/main/java/dev/ua/theroer/magicutils/bungee/MagicUtilsBungeeBundlePlugin.java @@ -58,7 +58,7 @@ public void onEnable() { .buildRuntime(); runtime = bootstrap.runtime(); - LoggerCore loggerCore = bootstrap.logger(); + LoggerCore loggerCore = bootstrap.logger().getCore(); CommandRegistry commandRegistry = bootstrap.commandRegistry(); if (commandRegistry != null) { // Register the diagnostics suite-name parser so diff --git a/velocity-bundle/src/main/java/dev/ua/theroer/magicutils/velocity/MagicUtilsVelocityBundlePlugin.java b/velocity-bundle/src/main/java/dev/ua/theroer/magicutils/velocity/MagicUtilsVelocityBundlePlugin.java index 79dc8697..e6c0bc20 100644 --- a/velocity-bundle/src/main/java/dev/ua/theroer/magicutils/velocity/MagicUtilsVelocityBundlePlugin.java +++ b/velocity-bundle/src/main/java/dev/ua/theroer/magicutils/velocity/MagicUtilsVelocityBundlePlugin.java @@ -86,7 +86,7 @@ public void onProxyInitialize(ProxyInitializeEvent event) { .buildRuntime(); runtime = bootstrap.runtime(); - LoggerCore loggerCore = bootstrap.logger(); + LoggerCore loggerCore = bootstrap.logger().getCore(); CommandRegistry commandRegistry = bootstrap.commandRegistry(); if (commandRegistry != null) { // Register the diagnostics suite-name parser so From 50051e86d1a547921911e69d23b46001d5990467 Mon Sep 17 00:00:00 2001 From: THEROER Date: Fri, 10 Jul 2026 14:17:01 +0300 Subject: [PATCH 12/39] docs(readme): update quickstart for unified bootstrap API Show the consistent metadata-derived entry points (forPlugin(this) on Bukkit/ Bungee, forPlugin(proxy, this) on Velocity, forMod on Fabric/NeoForge), document the withRecommendedDefaults()/minimal() presets, note NeoForge has a bootstrap (not manual wiring), and state that bootstrap.logger() returns a typed Logger facade on every platform. --- README.md | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 2dabd7c1..0904f382 100644 --- a/README.md +++ b/README.md @@ -103,24 +103,32 @@ dependencies { ## Quickstart -Bootstrap helpers are the recommended platform entry points: +Bootstrap helpers are the recommended platform entry points. The name and data +directory are derived from the plugin/mod metadata, so the entry points are +consistent across platforms: - Bukkit/Paper: `BukkitBootstrap.forPlugin(this)` -- BungeeCord: `BungeeBootstrap.forPlugin(this, "MyPlugin")` +- BungeeCord: `BungeeBootstrap.forPlugin(this)` +- Velocity: `VelocityBootstrap.forPlugin(proxy, this)` - Fabric: `FabricBootstrap.forMod("mymod", () -> server)` -- Velocity: `VelocityBootstrap.forPlugin(proxy, this, "MyPlugin", dataDir)` -- NeoForge: manual wiring with `NeoForgePlatformProvider` + - `CommandRegistry.create(...)` +- NeoForge: `NeoForgeBootstrap.forMod("mymod", () -> server)` + +Every builder exposes `.withRecommendedDefaults()` (the default, every language +and messages feature on) and `.minimal()` (opt out of the automatic language and +messages wiring when you manage localization yourself), so you configure intent +with one call instead of a wall of boolean toggles. If your project has a shared `common` module, let the platform layer create the runtime and pass `MagicRuntime` into the shared services instead of calling the bootstrap helpers from common code. `buildRuntime()` returns a managed `MagicRuntime` container with the platform, -config manager, logger, language manager, and optional command registry. -Enable diagnostics with `.enableDiagnostics()` and fetch the service from -`runtime.requireComponent(DiagnosticsService.class)` or the bootstrap runtime -result accessor. +config manager, logger, language manager, and optional command registry. On +every platform, `bootstrap.logger()` returns a typed `Logger` facade with +`info/warn/error/...` methods, player-typed overloads, and a fluent +`Logger#log()` builder. Enable diagnostics with `.enableDiagnostics()` and fetch +the service from `runtime.requireComponent(DiagnosticsService.class)` or the +bootstrap runtime result accessor. See the full setup guide in the docs: https://magicutils.theroer.dev/getting-started/quickstart/ From 9200f5e2d349d1b017f348190f2d8e77f8c4c26a Mon Sep 17 00:00:00 2001 From: THEROER Date: Fri, 10 Jul 2026 14:49:51 +0300 Subject: [PATCH 13/39] feat(logger): expose setEscapePlaceholders on the Logger facade setEscapePlaceholders existed on LoggerCore but was the one LoggerCore method missing from LoggerAdapter, forcing callers to drop to getCore(). Delegate it from the adapter so it is available on the typed Logger facade on every platform. --- .../ua/theroer/magicutils/logger/LoggerAdapter.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/logger/src/main/java/dev/ua/theroer/magicutils/logger/LoggerAdapter.java b/logger/src/main/java/dev/ua/theroer/magicutils/logger/LoggerAdapter.java index 8d93b1d4..a8b919de 100644 --- a/logger/src/main/java/dev/ua/theroer/magicutils/logger/LoggerAdapter.java +++ b/logger/src/main/java/dev/ua/theroer/magicutils/logger/LoggerAdapter.java @@ -93,6 +93,17 @@ default void setExternalPlaceholderEngine(ExternalPlaceholderEngine engine) { getCore().setExternalPlaceholderEngine(engine); } + /** + * Controls whether external placeholders are escaped before MiniMessage + * parsing. Disabled by default; enable it when placeholder values may + * contain untrusted MiniMessage-like input. + * + * @param escapePlaceholders true to escape placeholder output + */ + default void setEscapePlaceholders(boolean escapePlaceholders) { + getCore().setEscapePlaceholders(escapePlaceholders); + } + /** * Returns language manager used for localization. * From e4701ffb5b29d520f4124a3f7de0c7ac69af3754 Mon Sep 17 00:00:00 2001 From: THEROER Date: Fri, 10 Jul 2026 14:50:14 +0300 Subject: [PATCH 14/39] docs(logger): richer class JavaDoc and drop doclint:missing suppressions Give every platform Logger facade a class-level JavaDoc that frames it as the single output surface (console log line + player chat via the same level methods), with a short usage example and a note that the fluent log() builder is only for composite cases. Remove the @SuppressWarnings("doclint:missing") suppressions on the logger facades, LoggerCore, LogBuilder, PrefixedLogger, and FabricExternalPlaceholderEngine, documenting the few members that needed it so javadoc stays green without hiding gaps. --- .../dev/ua/theroer/magicutils/Logger.java | 27 +++++++- .../dev/ua/theroer/magicutils/Logger.java | 27 +++++++- .../FabricExternalPlaceholderEngine.java | 1 - .../theroer/magicutils/logger/LoggerCore.java | 1 - .../dev/ua/theroer/magicutils/Logger.java | 69 ++++++++++++++++++- .../theroer/magicutils/logger/LogBuilder.java | 43 +++++++++++- .../magicutils/logger/PrefixedLogger.java | 7 +- .../dev/ua/theroer/magicutils/Logger.java | 32 +++++++-- .../dev/ua/theroer/magicutils/Logger.java | 32 +++++++-- 9 files changed, 219 insertions(+), 20 deletions(-) diff --git a/commands-neoforge/src/main/java/dev/ua/theroer/magicutils/Logger.java b/commands-neoforge/src/main/java/dev/ua/theroer/magicutils/Logger.java index f9acc84d..a434810a 100644 --- a/commands-neoforge/src/main/java/dev/ua/theroer/magicutils/Logger.java +++ b/commands-neoforge/src/main/java/dev/ua/theroer/magicutils/Logger.java @@ -36,7 +36,32 @@ import java.util.Map; /** - * NeoForge logger adapter backed by {@link LoggerCore}. + * NeoForge logger: the single output surface for a mod. + * + *

Despite the name, this is not only for console logs. The same level methods + * send to the console (a log line) and to players (a chat message), so one call + * site covers both. Text is authored once with MiniMessage or legacy {@code &} + * codes and rendered correctly on every platform. + * + *

Everyday use is the short level methods; reach for the fluent + * {@link #log()} builder only when you need several recipients, tag resolvers, + * or a per-message prefix override: + * + *

{@code
+ * // Console log line:
+ * logger.info("Ready, %d loaded", count);
+ *
+ * // Message a player (same levels, different target):
+ * logger.info(player, "Teleported");
+ *
+ * // Composite case -> builder:
+ * logger.warn().to(player).toConsole().send("Low on funds");
+ * }
+ * + *

Backed by {@link LoggerCore}; obtain a prefixed sub-logger with + * {@link #create(String)}. + * + * @see LoggerAdapter */ @LogMethods(staticMethods = false, audienceType = "net.minecraft.server.level.ServerPlayer") public final class Logger extends LoggerMethods implements LoggerAdapter { diff --git a/logger-fabric/src/main/java/dev/ua/theroer/magicutils/Logger.java b/logger-fabric/src/main/java/dev/ua/theroer/magicutils/Logger.java index d8a1a3fc..a14ca934 100644 --- a/logger-fabric/src/main/java/dev/ua/theroer/magicutils/Logger.java +++ b/logger-fabric/src/main/java/dev/ua/theroer/magicutils/Logger.java @@ -35,7 +35,32 @@ import net.minecraft.server.level.ServerPlayer; /** - * Fabric logger adapter backed by {@link LoggerCore}. + * Fabric logger: the single output surface for a mod. + * + *

Despite the name, this is not only for console logs. The same level methods + * send to the console (a log line) and to players (a chat message), so one call + * site covers both. Text is authored once with MiniMessage or legacy {@code &} + * codes and rendered correctly on every platform. + * + *

Everyday use is the short level methods; reach for the fluent + * {@link #log()} builder only when you need several recipients, tag resolvers, + * or a per-message prefix override: + * + *

{@code
+ * // Console log line:
+ * logger.info("Ready, %d loaded", count);
+ *
+ * // Message a player (same levels, different target):
+ * logger.info(player, "Teleported");
+ *
+ * // Composite case -> builder:
+ * logger.warn().to(player).toConsole().send("Low on funds");
+ * }
+ * + *

Backed by {@link LoggerCore}; obtain a prefixed sub-logger with + * {@link #create(String)}. + * + * @see LoggerAdapter */ @LogMethods(staticMethods = false, audienceType = "net.minecraft.server.level.ServerPlayer") public final class Logger extends LoggerMethods implements LoggerAdapter { diff --git a/logger-fabric/src/main/java/dev/ua/theroer/magicutils/platform/fabric/FabricExternalPlaceholderEngine.java b/logger-fabric/src/main/java/dev/ua/theroer/magicutils/platform/fabric/FabricExternalPlaceholderEngine.java index 03563db5..5739eab6 100644 --- a/logger-fabric/src/main/java/dev/ua/theroer/magicutils/platform/fabric/FabricExternalPlaceholderEngine.java +++ b/logger-fabric/src/main/java/dev/ua/theroer/magicutils/platform/fabric/FabricExternalPlaceholderEngine.java @@ -23,7 +23,6 @@ /** * Fabric-specific external placeholder engine adapter. */ -@SuppressWarnings("doclint:missing") public final class FabricExternalPlaceholderEngine implements ExternalPlaceholderEngine { private static final Pattern PB4_PERCENT_PATTERN = Pattern .compile("(?[^%]+)[%]"); diff --git a/logger/src/main/java/dev/ua/theroer/magicutils/logger/LoggerCore.java b/logger/src/main/java/dev/ua/theroer/magicutils/logger/LoggerCore.java index 0f96cdc2..a77ec3a9 100644 --- a/logger/src/main/java/dev/ua/theroer/magicutils/logger/LoggerCore.java +++ b/logger/src/main/java/dev/ua/theroer/magicutils/logger/LoggerCore.java @@ -29,7 +29,6 @@ * Platform-agnostic logger core with configurable formatting and placeholders. */ @LogMethods(staticMethods = false, audienceType = "dev.ua.theroer.magicutils.platform.Audience") -@SuppressWarnings("doclint:missing") public class LoggerCore extends LoggerCoreMethods { private static final String LOGGER_DIR_PLACEHOLDER = "logger_dir"; private static final String LOGGER_DIR_DEFAULT = "."; diff --git a/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/Logger.java b/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/Logger.java index fd10fe9c..947ff35b 100644 --- a/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/Logger.java +++ b/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/Logger.java @@ -24,10 +24,34 @@ import java.util.Map; /** - * Bukkit/Paper logger adapter backed by {@link LoggerCore}. + * Bukkit/Paper logger: the single output surface for a plugin. + * + *

Despite the name, this is not only for console logs. The same level methods + * send to the console (a log line) and to players (a chat message), so one call + * site covers both. Text is authored once with MiniMessage or legacy {@code &} + * codes and rendered correctly on every platform. + * + *

Everyday use is the short level methods; reach for the fluent + * {@link #log()} builder only when you need several recipients, tag resolvers, + * or a per-message prefix override: + * + *

{@code
+ * // Console log line:
+ * logger.info("Ready, %d loaded", count);
+ *
+ * // Message a player (same levels, different target):
+ * logger.info(player, "Teleported");
+ *
+ * // Composite case -> builder:
+ * logger.warn().to(player).toConsole().send("Low on funds");
+ * }
+ * + *

Backed by {@link LoggerCore}; obtain a prefixed sub-logger with + * {@link #create(String)}. + * + * @see LoggerAdapter */ @LogMethods(staticMethods = false, audienceType = "org.bukkit.entity.Player") -@SuppressWarnings("doclint:missing") public final class Logger extends LoggerMethods implements LoggerAdapter { @Getter private final LoggerCore core; @@ -64,30 +88,65 @@ public Audience wrapAudience(Player player) { return wrapAudience((CommandSender) player); } + /** + * Starts an INFO-level fluent {@link LogBuilder}. + * + * @return a new INFO log builder + */ public LogBuilder log() { return new LogBuilder(this, LogLevel.INFO); } + /** + * Starts an INFO-level fluent {@link LogBuilder} with the prefix disabled. + * + * @return a new prefix-less INFO log builder + */ public LogBuilder noPrefix() { return new LogBuilder(this, LogLevel.INFO).noPrefix(); } + /** + * Starts an INFO-level fluent {@link LogBuilder}. + * + * @return a new INFO log builder + */ public LogBuilder info() { return new LogBuilder(this, LogLevel.INFO); } + /** + * Starts a WARN-level fluent {@link LogBuilder}. + * + * @return a new WARN log builder + */ public LogBuilder warn() { return new LogBuilder(this, LogLevel.WARN); } + /** + * Starts an ERROR-level fluent {@link LogBuilder}. + * + * @return a new ERROR log builder + */ public LogBuilder error() { return new LogBuilder(this, LogLevel.ERROR); } + /** + * Starts a DEBUG-level fluent {@link LogBuilder}. + * + * @return a new DEBUG log builder + */ public LogBuilder debug() { return new LogBuilder(this, LogLevel.DEBUG); } + /** + * Starts a SUCCESS-level fluent {@link LogBuilder}. + * + * @return a new SUCCESS log builder + */ public LogBuilder success() { return new LogBuilder(this, LogLevel.SUCCESS); } @@ -117,6 +176,12 @@ protected void sendToPlayers(LogLevel level, Object message, Collection { private final Logger logger; + /** + * Creates a log builder for the given level. + * + * @param logger parent logger + * @param level log level + */ public LogBuilder(Logger logger, LogLevel level) { super(logger.getCore(), level); this.logger = logger; } + /** + * Sets a player as the direct audience. + * + * @param player target player + * @return this builder + */ public LogBuilder to(Player player) { super.to(logger.wrapAudience(player)); return this; } + /** + * Adds several players as recipients. + * + * @param players target players + * @return this builder + */ public LogBuilder to(Player... players) { if (players != null) { for (Player player : players) { @@ -39,6 +56,12 @@ public LogBuilder to(Player... players) { return this; } + /** + * Adds a collection of players as recipients. + * + * @param players target players + * @return this builder + */ public LogBuilder to(Collection players) { if (players != null) { for (Player player : players) { @@ -50,16 +73,34 @@ public LogBuilder to(Collection players) { return this; } + /** + * Adds a command sender (player or console) as a recipient. + * + * @param sender command sender + * @return this builder + */ public LogBuilder to(CommandSender sender) { super.recipient(logger.wrapAudience(sender)); return this; } + /** + * Adds a command sender as a recipient. + * + * @param sender command sender + * @return this builder + */ public LogBuilder recipient(CommandSender sender) { super.recipient(logger.wrapAudience(sender)); return this; } + /** + * Adds a collection of command senders as recipients. + * + * @param senders command senders + * @return this builder + */ public LogBuilder toSenders(Collection senders) { if (senders != null) { for (CommandSender sender : senders) { diff --git a/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/logger/PrefixedLogger.java b/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/logger/PrefixedLogger.java index 0893bc96..c8274a69 100644 --- a/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/logger/PrefixedLogger.java +++ b/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/logger/PrefixedLogger.java @@ -10,11 +10,16 @@ * All log methods are generated by annotation processor. */ @LogMethods(staticMethods = false, audienceType = "org.bukkit.entity.Player") -@SuppressWarnings("doclint:missing") public class PrefixedLogger extends PrefixedLoggerMethods implements PrefixedLoggerAdapter { private final Logger logger; private final PrefixedLoggerCore core; + /** + * Creates a prefixed logger wrapping a prefixed core. + * + * @param logger parent logger + * @param core prefixed core instance + */ public PrefixedLogger(Logger logger, PrefixedLoggerCore core) { this.logger = logger; this.core = core; diff --git a/platform-bungee/src/main/java/dev/ua/theroer/magicutils/Logger.java b/platform-bungee/src/main/java/dev/ua/theroer/magicutils/Logger.java index c5677aa1..3472fc08 100644 --- a/platform-bungee/src/main/java/dev/ua/theroer/magicutils/Logger.java +++ b/platform-bungee/src/main/java/dev/ua/theroer/magicutils/Logger.java @@ -18,13 +18,33 @@ import net.md_5.bungee.api.connection.ProxiedPlayer; /** - * BungeeCord logger adapter backed by {@link LoggerCore}. + * BungeeCord logger: the single output surface for a plugin. * - *

Gives BungeeCord the same typed logger facade as Bukkit/Fabric/NeoForge: - * generated {@code info/warn/error/success/debug/trace} methods, player-typed - * overloads keyed on {@link ProxiedPlayer}, prefixed sub-loggers, and a fluent - * {@link LogBuilder}. Console routing reuses the platform's structured console - * audience. + *

Despite the name, this is not only for console logs. The same level methods + * send to the console (a log line) and to players (a chat message), so one call + * site covers both. Text is authored once with MiniMessage or legacy {@code &} + * codes and rendered correctly on every platform. + * + *

Everyday use is the short level methods; reach for the fluent + * {@link #log()} builder only when you need several recipients, tag resolvers, + * or a per-message prefix override: + * + *

{@code
+ * // Console log line:
+ * logger.info("Ready, %d loaded", count);
+ *
+ * // Message a player (same levels, different target):
+ * logger.info(player, "Connected");
+ *
+ * // Composite case -> builder:
+ * logger.warn().to(player).toConsole().send("Backend down");
+ * }
+ * + *

Backed by {@link LoggerCore}; obtain a prefixed sub-logger with + * {@link #create(String)}. Console routing reuses the platform's structured + * console audience. + * + * @see LoggerAdapter */ @LogMethods(staticMethods = false, audienceType = "net.md_5.bungee.api.connection.ProxiedPlayer") public final class Logger extends LoggerMethods implements LoggerAdapter { diff --git a/platform-velocity/src/main/java/dev/ua/theroer/magicutils/Logger.java b/platform-velocity/src/main/java/dev/ua/theroer/magicutils/Logger.java index d26e9927..f462f30c 100644 --- a/platform-velocity/src/main/java/dev/ua/theroer/magicutils/Logger.java +++ b/platform-velocity/src/main/java/dev/ua/theroer/magicutils/Logger.java @@ -18,13 +18,33 @@ import java.util.Map; /** - * Velocity logger adapter backed by {@link LoggerCore}. + * Velocity logger: the single output surface for a plugin. * - *

Gives Velocity the same typed logger facade as Bukkit/Fabric/NeoForge: - * generated {@code info/warn/error/success/debug/trace} methods, player-typed - * overloads keyed on {@link Player}, prefixed sub-loggers, and a fluent - * {@link LogBuilder}. Console routing reuses the platform's structured console - * audience. + *

Despite the name, this is not only for console logs. The same level methods + * send to the console (a log line) and to players (a chat message), so one call + * site covers both. Text is authored once with MiniMessage or legacy {@code &} + * codes and rendered correctly on every platform. + * + *

Everyday use is the short level methods; reach for the fluent + * {@link #log()} builder only when you need several recipients, tag resolvers, + * or a per-message prefix override: + * + *

{@code
+ * // Console log line:
+ * logger.info("Ready, %d loaded", count);
+ *
+ * // Message a player (same levels, different target):
+ * logger.info(player, "Connected");
+ *
+ * // Composite case -> builder:
+ * logger.warn().to(player).toConsole().send("Backend down");
+ * }
+ * + *

Backed by {@link LoggerCore}; obtain a prefixed sub-logger with + * {@link #create(String)}. Console routing reuses the platform's structured + * console audience. + * + * @see LoggerAdapter */ @LogMethods(staticMethods = false, audienceType = "com.velocitypowered.api.proxy.Player") public final class Logger extends LoggerMethods implements LoggerAdapter { From 45706f25be24d4a3e0e122a8e3f2f76392fa71af Mon Sep 17 00:00:00 2001 From: THEROER Date: Fri, 10 Jul 2026 14:50:32 +0300 Subject: [PATCH 15/39] =?UTF-8?q?fix(logger):=20clean=20up=20chat=20output?= =?UTF-8?q?=20=E2=80=94=20brand-only=20prefix,=20level=20shown=20by=20colo?= =?UTF-8?q?ur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Players saw the internal log level spelled out in chat as [Plugin SUCCESS] / [Plugin WARN]. Two changes to LogMessageFormatter: - Drop the level suffix from the chat prefix. The prefix is rendered into the chat message only (the console line already carries the level via the platform logger), so chat now shows just the plugin brand [Plugin]. - Decouple the level colour from gradients. The success/warn/error signal is now conveyed by colour and applied to every chat message; useGradientChat only chooses between a two-stop gradient and a single solid colour, so the signal survives even with gradients disabled. Adds tests: chat keeps the brand but not the level word, and SUCCESS vs ERROR carry distinct colours with gradients off. --- .../logger/LogMessageFormatter.java | 31 +++++--- .../magicutils/logger/LoggerCoreTest.java | 78 +++++++++++++++++++ 2 files changed, 99 insertions(+), 10 deletions(-) diff --git a/logger/src/main/java/dev/ua/theroer/magicutils/logger/LogMessageFormatter.java b/logger/src/main/java/dev/ua/theroer/magicutils/logger/LogMessageFormatter.java index 0438001e..55ccd773 100644 --- a/logger/src/main/java/dev/ua/theroer/magicutils/logger/LogMessageFormatter.java +++ b/logger/src/main/java/dev/ua/theroer/magicutils/logger/LogMessageFormatter.java @@ -91,8 +91,10 @@ static FormattedMessage formatDetailed( // Build console text: message only (no prefixes — logger name carries that info) String consoleText = processed; - boolean chatNeedsMini = prefixRender.useGradient() || hasMiniMessageTags(chatText) - || hasExternalResolver(externalResolver); + // Chat always goes through MiniMessage: attachPrefix injects the level + // colour (single colour or gradient) into every chat message, so the + // success/warn/error signal is present even when gradients are disabled. + boolean chatNeedsMini = true; boolean consoleNeedsMini = hasMiniMessageTags(consoleText) || hasExternalResolver(externalResolver); Component chatComponent = deserializeComponent(logger, engine, targetAudience, @@ -245,7 +247,7 @@ private static PrefixRender buildPrefixRender(LoggerCore logger, LogLevel level, : (target == LogTarget.CONSOLE || target == LogTarget.BOTH) ? logger.getConsolePrefixMode() : logger.getChatPrefixMode(); - String prefix = buildPrefix(logger, level, mode); + String prefix = buildPrefix(logger, mode); boolean useGradient = !prefix.isEmpty() && shouldUseGradient(logger, target); return new PrefixRender(prefix, useGradient); } @@ -255,12 +257,19 @@ private static String attachPrefix(PrefixRender prefixRender, LoggerCore logger, LogLevel level, LogTarget target) { - if (prefixRender.useGradient()) { - String[] colors = logger.resolveColorsForLevel(level, target == LogTarget.CONSOLE); + // The level colour is the message's signal of success/warn/error, so it + // is applied whether or not gradients are enabled. useGradient() only + // decides between a two-stop gradient and a single solid colour. + String[] colors = logger.resolveColorsForLevel(level, target == LogTarget.CONSOLE); + if (colors == null || colors.length == 0) { + return "" + combined; + } + if (prefixRender.useGradient() && colors.length >= 2) { String gradientTag = ColorUtils.createGradientTag(colors); return "" + gradientTag + combined + ""; } - return "" + combined; + String color = colors[0]; + return "<" + color + ">" + combined + ""; } private static String combinePrefix(String prefix, String message) { @@ -291,7 +300,7 @@ private record PrefixRender(String text, boolean useGradient) { static record FormattedMessage(Component chatComponent, Component consoleComponent, String prefixText) { } - private static String buildPrefix(LoggerCore logger, LogLevel level, PrefixMode mode) { + private static String buildPrefix(LoggerCore logger, PrefixMode mode) { if (mode == PrefixMode.NONE) { return ""; } @@ -304,9 +313,11 @@ private static String buildPrefix(LoggerCore logger, LogLevel level, PrefixMode default -> ""; }; - if (level != LogLevel.INFO) { - prefixText = prefixText + " " + level.name(); - } + // The prefix is rendered into the chat message only (the console line + // carries the level via the platform logger already). Players do not + // need the log level spelled out as "[Plugin SUCCESS]" — the level is + // conveyed by the message colour instead, so keep the chat prefix to the + // plugin brand only. return "[" + prefixText + "]"; } diff --git a/logger/src/test/java/dev/ua/theroer/magicutils/logger/LoggerCoreTest.java b/logger/src/test/java/dev/ua/theroer/magicutils/logger/LoggerCoreTest.java index b8b3becd..41a0b0cd 100644 --- a/logger/src/test/java/dev/ua/theroer/magicutils/logger/LoggerCoreTest.java +++ b/logger/src/test/java/dev/ua/theroer/magicutils/logger/LoggerCoreTest.java @@ -111,6 +111,84 @@ void structuredConsoleAudienceKeepsLevelAndSubLoggerWithoutPlainTextParsing() th } } + @Test + void chatPrefixCarriesBrandButNotTheLogLevel() throws Exception { + TestPlatform platform = new TestPlatform(tempDir); + ConfigManager configManager = new ConfigManager(platform); + try { + LoggerCore core = new LoggerCore(platform, configManager, new Object(), "TestPlugin"); + configManager.shutdown(); + + // Chat defaults to FULL, so the brand appears; the level must not. + for (LogLevel level : new LogLevel[] {LogLevel.SUCCESS, LogLevel.WARN, LogLevel.ERROR, LogLevel.INFO}) { + LogMessageFormatter.FormattedMessage formatted = LogMessageFormatter.formatDetailed( + core, "hello", level, LogTarget.CHAT, null, null, null); + String chat = PlainTextComponentSerializer.plainText().serialize(formatted.chatComponent()); + + assertTrue(chat.contains("[TestPlugin]"), + "Chat prefix should keep the plugin brand for " + level + ", was: " + chat); + assertTrue(!chat.contains(level.name()), + "Chat prefix should not spell out the log level " + level + ", was: " + chat); + assertTrue(chat.contains("hello"), "Chat message content should be present for " + level); + } + } finally { + configManager.shutdown(); + platform.shutdown(); + } + } + + @Test + void chatCarriesLevelColourEvenWithGradientsDisabled() throws Exception { + TestPlatform platform = new TestPlatform(tempDir); + ConfigManager configManager = new ConfigManager(platform); + try { + LoggerCore core = new LoggerCore(platform, configManager, new Object(), "TestPlugin"); + configManager.shutdown(); + setChatGradient(core, false); + + String success = chatDownsampledColour(core, LogLevel.SUCCESS); + String error = chatDownsampledColour(core, LogLevel.ERROR); + + // With gradients off, a single solid level colour must still be applied, + // and SUCCESS must not read as ERROR. + assertNotNull(success, "SUCCESS chat message should carry a colour"); + assertNotNull(error, "ERROR chat message should carry a colour"); + assertTrue(!success.equals(error), + "SUCCESS and ERROR chat messages should not share the same colour"); + } finally { + configManager.shutdown(); + platform.shutdown(); + } + } + + private static String chatDownsampledColour(LoggerCore core, LogLevel level) { + LogMessageFormatter.FormattedMessage formatted = LogMessageFormatter.formatDetailed( + core, "hello", level, LogTarget.CHAT, null, null, null); + return findFirstColour(formatted.chatComponent()); + } + + private static String findFirstColour(Component component) { + if (component.color() != null) { + return component.color().asHexString(); + } + for (Component child : component.children()) { + String childColour = findFirstColour(child); + if (childColour != null) { + return childColour; + } + } + return null; + } + + private static void setChatGradient(LoggerCore core, boolean enabled) throws Exception { + Field prefixField = core.getConfig().getClass().getDeclaredField("prefix"); + prefixField.setAccessible(true); + Object prefixSettings = prefixField.get(core.getConfig()); + Field gradientField = prefixSettings.getClass().getDeclaredField("useGradientChat"); + gradientField.setAccessible(true); + gradientField.setBoolean(prefixSettings, enabled); + } + private static void setDebugPlaceholders(LoggerCore core, boolean enabled) throws Exception { Field field = core.getConfig().getClass().getDeclaredField("debugPlaceholders"); field.setAccessible(true); From 5c0e3a5a6bb8aec05fa2e3852e59085c78e64b8b Mon Sep 17 00:00:00 2001 From: THEROER Date: Fri, 10 Jul 2026 15:23:32 +0300 Subject: [PATCH 16/39] feat(fabric): add Redis messaging parity for mods Fabric mods can now enable cross-server messaging, matching the Bukkit/Bungee/ Velocity backends. Adds FabricMessagingSupport.install (Redis transport via the shared messaging module; hostsPlayer via Platform.playerById) and the messaging surface on FabricBootstrap (enableMessaging/messagingRedis/configureMessaging + buildRuntime wiring). No default plugin-messaging transport: riding the proxy's vanilla channel from a mod needs the platform networking API on modern MC, so without Redis the bus falls back to the in-process loopback transport (logged). Compiles on mc12110 and mc1201. --- commands-fabric/build.gradle.kts | 1 + .../magicutils/bootstrap/FabricBootstrap.java | 48 ++++++++++++ .../fabric/FabricMessagingSupport.java | 76 +++++++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 commands-fabric/src/main/java/dev/ua/theroer/magicutils/messaging/fabric/FabricMessagingSupport.java diff --git a/commands-fabric/build.gradle.kts b/commands-fabric/build.gradle.kts index eb34b332..03d0aae2 100644 --- a/commands-fabric/build.gradle.kts +++ b/commands-fabric/build.gradle.kts @@ -11,6 +11,7 @@ magicutilsPublish { dependencies { api(project(":commands-brigadier")) api(project(":diagnostics")) + api(project(":messaging")) api(project(":platform-fabric")) api(project(":logger-fabric")) diff --git a/commands-fabric/src/main/java/dev/ua/theroer/magicutils/bootstrap/FabricBootstrap.java b/commands-fabric/src/main/java/dev/ua/theroer/magicutils/bootstrap/FabricBootstrap.java index 63c9562c..db54cc9c 100644 --- a/commands-fabric/src/main/java/dev/ua/theroer/magicutils/bootstrap/FabricBootstrap.java +++ b/commands-fabric/src/main/java/dev/ua/theroer/magicutils/bootstrap/FabricBootstrap.java @@ -8,6 +8,9 @@ import dev.ua.theroer.magicutils.diagnostics.DiagnosticsService; import dev.ua.theroer.magicutils.diagnostics.DiagnosticsSupport; import dev.ua.theroer.magicutils.lang.Messages; +import dev.ua.theroer.magicutils.messaging.MessagingService; +import dev.ua.theroer.magicutils.messaging.fabric.FabricMessagingSupport; +import dev.ua.theroer.magicutils.messaging.redis.RedisConfig; import dev.ua.theroer.magicutils.platform.MagicUtilsConsumerRegistry; import dev.ua.theroer.magicutils.platform.Platform; import dev.ua.theroer.magicutils.platform.fabric.FabricPlatformProvider; @@ -64,6 +67,9 @@ public static final class Builder { private Consumer commandConfigurer; private boolean enableDiagnostics; private Consumer diagnosticsConfigurer; + private boolean enableMessaging; + private RedisConfig.Redis messagingRedis; + private Consumer messagingConfigurer; private Builder(String modName, Supplier serverSupplier) { this.modName = normalizeModName(modName); @@ -310,6 +316,45 @@ public Builder configureDiagnostics(Consumer diagnosticsConf return this; } + /** + * Enables cross-server messaging. On Fabric only the Redis transport + * reaches other servers; supply Redis settings via + * {@link #messagingRedis(RedisConfig.Redis)}. Without Redis the bus falls + * back to an in-process loopback transport. + * + * @return builder + */ + public Builder enableMessaging() { + this.enableMessaging = true; + return this; + } + + /** + * Supplies Redis settings for messaging and enables messaging. When + * {@code enabled}, the Redis transport is used for cross-server delivery. + * + * @param redis redis settings + * @return builder + */ + public Builder messagingRedis(RedisConfig.Redis redis) { + this.messagingRedis = redis; + this.enableMessaging = true; + return this; + } + + /** + * Allows configuring the messaging service builder before it is built, + * and enables messaging. + * + * @param messagingConfigurer messaging builder callback + * @return builder + */ + public Builder configureMessaging(Consumer messagingConfigurer) { + this.messagingConfigurer = messagingConfigurer; + this.enableMessaging = true; + return this; + } + /** * Builds the bootstrap result without exposing the runtime wrapper. * @@ -348,6 +393,9 @@ public RuntimeResult buildRuntime() { if (enableDiagnostics) { DiagnosticsSupport.install(runtime, diagnosticsConfigurer); } + if (enableMessaging) { + FabricMessagingSupport.install(runtime, modName, messagingRedis, messagingConfigurer); + } lang.installMessagesCloseHooks(runtime, modName, prepared.languageManager()); // Register this mod in the shared-runtime consumer registry so the diff --git a/commands-fabric/src/main/java/dev/ua/theroer/magicutils/messaging/fabric/FabricMessagingSupport.java b/commands-fabric/src/main/java/dev/ua/theroer/magicutils/messaging/fabric/FabricMessagingSupport.java new file mode 100644 index 00000000..2f903a2c --- /dev/null +++ b/commands-fabric/src/main/java/dev/ua/theroer/magicutils/messaging/fabric/FabricMessagingSupport.java @@ -0,0 +1,76 @@ +package dev.ua.theroer.magicutils.messaging.fabric; + +import java.util.UUID; +import java.util.function.Consumer; + +import dev.ua.theroer.magicutils.bootstrap.MagicRuntime; +import dev.ua.theroer.magicutils.messaging.MessageSource; +import dev.ua.theroer.magicutils.messaging.MessagingService; +import dev.ua.theroer.magicutils.messaging.redis.RedisConfig; +import dev.ua.theroer.magicutils.platform.Platform; +import dev.ua.theroer.magicutils.platform.PlatformLogger; +import org.jetbrains.annotations.Nullable; + +/** + * Wires a {@link MessagingService} into a Fabric mod's {@link MagicRuntime}. + * + *

Fabric mods use the platform-agnostic Redis transport for cross-server + * messaging. Unlike the Bukkit/Bungee/Velocity backends there is no default + * plugin-messaging transport here: riding the proxy's vanilla channel from a mod + * would require registering a foreign custom-payload id, which modern Minecraft + * only allows through the platform networking API. So no {@code defaultTransport} + * is supplied; when Redis is disabled {@link MessagingService} falls back to an + * in-process loopback transport (see {@code MessagingService.resolveTransport}), + * which keeps the bus usable but does not reach other servers. Enable Redis for + * actual cross-server delivery.

+ */ +public final class FabricMessagingSupport { + private FabricMessagingSupport() { + } + + /** + * Installs the messaging service on the runtime. + * + * @param runtime mod runtime + * @param modName owning mod id, used to scope the message source + * @param redis redis settings, or null to leave Redis disabled + * @param configurer optional callback to tweak the service builder + * @return the installed service + */ + public static MessagingService install( + MagicRuntime runtime, + String modName, + @Nullable RedisConfig.Redis redis, + @Nullable Consumer configurer) { + Platform platform = runtime.platform(); + PlatformLogger logger = platform.logger(); + + MessageSource self = MessageSource.backend( + modName + ":" + UUID.randomUUID(), + null); // server name is not known to a backend until the proxy tells it + + MessagingService.Builder builder = MessagingService.builder(self) + .logger(logger) + .redis(redis) + .hostsPlayer(id -> hostsPlayer(platform, id)); + + if (configurer != null) { + configurer.accept(builder); + } + + MessagingService service = builder.build(); + runtime.putComponent(MessagingService.class, service); + runtime.manage("messaging.service", service); + if (service.transportName().equals("loopback")) { + logger.info("MagicUtils messaging enabled (transport: loopback) — " + + "enable Redis for cross-server delivery on Fabric"); + } else { + logger.info("MagicUtils messaging enabled (transport: " + service.transportName() + ")"); + } + return service; + } + + private static boolean hostsPlayer(Platform platform, UUID id) { + return id != null && platform.playerById(id) != null; + } +} From ade74f12b84b8c3879d5d422a8d95159992a1c21 Mon Sep 17 00:00:00 2001 From: THEROER Date: Fri, 10 Jul 2026 15:26:52 +0300 Subject: [PATCH 17/39] feat(neoforge): add Redis messaging parity for mods Mirrors the Fabric change on NeoForge: NeoForgeMessagingSupport.install (Redis transport via the shared messaging module) plus the messaging surface on NeoForgeBootstrap (enableMessaging/messagingRedis/configureMessaging + buildRuntime wiring). hostsPlayer resolves via the MinecraftServer supplier (server.getPlayerList().getPlayer(id)) since the NeoForge platform provider does not override Platform.playerById. No default plugin-messaging transport; without Redis the bus falls back to loopback (logged). Compiles on mc12110 and mc262 (NeoForge is disabled on the 1.20.x targets). --- commands-neoforge/build.gradle.kts | 1 + .../bootstrap/NeoForgeBootstrap.java | 49 +++++++++++ .../neoforge/NeoForgeMessagingSupport.java | 85 +++++++++++++++++++ 3 files changed, 135 insertions(+) create mode 100644 commands-neoforge/src/main/java/dev/ua/theroer/magicutils/messaging/neoforge/NeoForgeMessagingSupport.java diff --git a/commands-neoforge/build.gradle.kts b/commands-neoforge/build.gradle.kts index 96396583..416fbc05 100644 --- a/commands-neoforge/build.gradle.kts +++ b/commands-neoforge/build.gradle.kts @@ -32,6 +32,7 @@ tasks.named("shadowJar") { dependencies { api(project(":commands-brigadier")) api(project(":diagnostics")) + api(project(":messaging")) api(project(":platform-neoforge")) implementation(libs.kyori.adventure.text.serializer.plain) compileOnly(libs.brigadier) diff --git a/commands-neoforge/src/main/java/dev/ua/theroer/magicutils/bootstrap/NeoForgeBootstrap.java b/commands-neoforge/src/main/java/dev/ua/theroer/magicutils/bootstrap/NeoForgeBootstrap.java index a8066459..d559516f 100644 --- a/commands-neoforge/src/main/java/dev/ua/theroer/magicutils/bootstrap/NeoForgeBootstrap.java +++ b/commands-neoforge/src/main/java/dev/ua/theroer/magicutils/bootstrap/NeoForgeBootstrap.java @@ -7,6 +7,9 @@ import dev.ua.theroer.magicutils.diagnostics.DiagnosticRegistry; import dev.ua.theroer.magicutils.diagnostics.DiagnosticsService; import dev.ua.theroer.magicutils.diagnostics.DiagnosticsSupport; +import dev.ua.theroer.magicutils.messaging.MessagingService; +import dev.ua.theroer.magicutils.messaging.neoforge.NeoForgeMessagingSupport; +import dev.ua.theroer.magicutils.messaging.redis.RedisConfig; import dev.ua.theroer.magicutils.platform.MagicUtilsConsumerRegistry; import dev.ua.theroer.magicutils.platform.Platform; import dev.ua.theroer.magicutils.platform.neoforge.NeoForgePlatformProvider; @@ -63,6 +66,9 @@ public static final class Builder { private Consumer commandConfigurer; private boolean enableDiagnostics; private Consumer diagnosticsConfigurer; + private boolean enableMessaging; + private RedisConfig.Redis messagingRedis; + private Consumer messagingConfigurer; private Builder(String modName, Supplier serverSupplier) { this.modName = normalizeModName(modName); @@ -309,6 +315,45 @@ public Builder configureDiagnostics(Consumer diagnosticsConf return this; } + /** + * Enables cross-server messaging. On NeoForge only the Redis transport + * reaches other servers; supply Redis settings via + * {@link #messagingRedis(RedisConfig.Redis)}. Without Redis the bus falls + * back to an in-process loopback transport. + * + * @return this builder + */ + public Builder enableMessaging() { + this.enableMessaging = true; + return this; + } + + /** + * Supplies Redis settings for messaging and enables messaging. When + * {@code enabled}, the Redis transport is used for cross-server delivery. + * + * @param redis redis settings + * @return this builder + */ + public Builder messagingRedis(RedisConfig.Redis redis) { + this.messagingRedis = redis; + this.enableMessaging = true; + return this; + } + + /** + * Allows configuring the messaging service builder before it is built, + * and enables messaging. + * + * @param messagingConfigurer messaging builder callback + * @return this builder + */ + public Builder configureMessaging(Consumer messagingConfigurer) { + this.messagingConfigurer = messagingConfigurer; + this.enableMessaging = true; + return this; + } + /** * Builds the bootstrap result without exposing the runtime wrapper. * @@ -347,6 +392,10 @@ public RuntimeResult buildRuntime() { if (enableDiagnostics) { DiagnosticsSupport.install(runtime, diagnosticsConfigurer); } + if (enableMessaging) { + NeoForgeMessagingSupport.install( + runtime, modName, serverSupplier, messagingRedis, messagingConfigurer); + } lang.installMessagesCloseHooks(runtime, modName, prepared.languageManager()); // Publish this mod into the shared-runtime registry so the bundle's diff --git a/commands-neoforge/src/main/java/dev/ua/theroer/magicutils/messaging/neoforge/NeoForgeMessagingSupport.java b/commands-neoforge/src/main/java/dev/ua/theroer/magicutils/messaging/neoforge/NeoForgeMessagingSupport.java new file mode 100644 index 00000000..76e8d192 --- /dev/null +++ b/commands-neoforge/src/main/java/dev/ua/theroer/magicutils/messaging/neoforge/NeoForgeMessagingSupport.java @@ -0,0 +1,85 @@ +package dev.ua.theroer.magicutils.messaging.neoforge; + +import java.util.UUID; +import java.util.function.Consumer; +import java.util.function.Supplier; + +import dev.ua.theroer.magicutils.bootstrap.MagicRuntime; +import dev.ua.theroer.magicutils.messaging.MessageSource; +import dev.ua.theroer.magicutils.messaging.MessagingService; +import dev.ua.theroer.magicutils.messaging.redis.RedisConfig; +import dev.ua.theroer.magicutils.platform.Platform; +import dev.ua.theroer.magicutils.platform.PlatformLogger; +import net.minecraft.server.MinecraftServer; +import org.jetbrains.annotations.Nullable; + +/** + * Wires a {@link MessagingService} into a NeoForge mod's {@link MagicRuntime}. + * + *

NeoForge mods use the platform-agnostic Redis transport for cross-server + * messaging. Unlike the Bukkit/Bungee/Velocity backends there is no default + * plugin-messaging transport here: riding the proxy's vanilla channel from a mod + * would require registering a foreign custom-payload id through the NeoForge + * networking registrar. So no {@code defaultTransport} is supplied; when Redis is + * disabled {@link MessagingService} falls back to an in-process loopback + * transport (see {@code MessagingService.resolveTransport}), which keeps the bus + * usable but does not reach other servers. Enable Redis for actual cross-server + * delivery.

+ */ +public final class NeoForgeMessagingSupport { + private NeoForgeMessagingSupport() { + } + + /** + * Installs the messaging service on the runtime. + * + * @param runtime mod runtime + * @param modName owning mod id, used to scope the message source + * @param serverSupplier supplier of the current Minecraft server, used to + * check whether a player is online + * @param redis redis settings, or null to leave Redis disabled + * @param configurer optional callback to tweak the service builder + * @return the installed service + */ + public static MessagingService install( + MagicRuntime runtime, + String modName, + Supplier serverSupplier, + @Nullable RedisConfig.Redis redis, + @Nullable Consumer configurer) { + Platform platform = runtime.platform(); + PlatformLogger logger = platform.logger(); + + MessageSource self = MessageSource.backend( + modName + ":" + UUID.randomUUID(), + null); // server name is not known to a backend until the proxy tells it + + MessagingService.Builder builder = MessagingService.builder(self) + .logger(logger) + .redis(redis) + .hostsPlayer(id -> hostsPlayer(serverSupplier, id)); + + if (configurer != null) { + configurer.accept(builder); + } + + MessagingService service = builder.build(); + runtime.putComponent(MessagingService.class, service); + runtime.manage("messaging.service", service); + if (service.transportName().equals("loopback")) { + logger.info("MagicUtils messaging enabled (transport: loopback) — " + + "enable Redis for cross-server delivery on NeoForge"); + } else { + logger.info("MagicUtils messaging enabled (transport: " + service.transportName() + ")"); + } + return service; + } + + private static boolean hostsPlayer(Supplier serverSupplier, UUID id) { + if (id == null || serverSupplier == null) { + return false; + } + MinecraftServer server = serverSupplier.get(); + return server != null && server.getPlayerList().getPlayer(id) != null; + } +} From 2ad3cc520eb7e9805bad8783763026cb413e7af0 Mon Sep 17 00:00:00 2001 From: THEROER Date: Fri, 10 Jul 2026 15:32:36 +0300 Subject: [PATCH 18/39] fix(neoforge-bundle): ship messaging module and Jackson in the standalone jar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The NeoForge bundle adds project deps non-transitively, so the newly-added :commands-neoforge messaging code pulled neither the :messaging module nor Jackson into the jar — MessageCodec would NoClassDefFoundError at runtime. Add :messaging to bundleContents and the jackson-databind/core/annotations artifacts explicitly by coordinate (mirroring the Adventure handling). This also closes a pre-existing gap where Jackson was absent entirely, so the config module now has its runtime dependency on the NeoForge bundle too. Verified the jar now contains the messaging classes, RedisMessageTransport, and Jackson. --- .../module/MagicUtilsNeoForgeBundlePlugin.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsNeoForgeBundlePlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsNeoForgeBundlePlugin.kt index ad513d11..0ce2b7eb 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsNeoForgeBundlePlugin.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsNeoForgeBundlePlugin.kt @@ -47,6 +47,7 @@ class MagicUtilsNeoForgeBundlePlugin : Plugin { ":core", ":diagnostics", ":http-client", + ":messaging", ":platform-neoforge", ":commands-neoforge", ).forEach(::addBundleProject) @@ -88,6 +89,23 @@ class MagicUtilsNeoForgeBundlePlugin : Plugin { dependencies.add("bundleContents", coord) } + // The messaging module's MessageCodec (and the config module) need + // Jackson at runtime. Project deps above are non-transitive, so add + // jackson-databind explicitly, mirroring the Adventure handling. + val jacksonVersion = project.extensions + .getByType(org.gradle.api.artifacts.VersionCatalogsExtension::class.java) + .named("libs") + .findVersion("jackson") + .get() + .requiredVersion + listOf( + "com.fasterxml.jackson.core:jackson-databind", + "com.fasterxml.jackson.core:jackson-core", + "com.fasterxml.jackson.core:jackson-annotations", + ).forEach { coord -> + dependencies.add("bundleContents", "$coord:$jacksonVersion") + } + tasks.named("jar", Jar::class.java).configure { jarTask -> jarTask.archiveBaseName.set(moduleName) jarTask.duplicatesStrategy = DuplicatesStrategy.EXCLUDE From 8f3b6385075e85d03fb35be790180f98f62b2bc8 Mon Sep 17 00:00:00 2001 From: THEROER Date: Fri, 10 Jul 2026 17:34:40 +0300 Subject: [PATCH 19/39] fix(release): smokeTest polls the java-suffixed coordinate, not a bare X.Y.Z The library ships only per-Java coordinates (X.Y.Z+java); a bare X.Y.Z POM is never published, so smokeTest polled a URL that 404s forever. Build the URL from the default target's Java level (java21 on mc12110) and percent-encode the '+'. Factor the '+java' format into one pure javaSuffixedCoordinate() reused by publishedVersion, the Modrinth bundle file name, and the smoke URL. --- .../build/matrix/MagicUtilsMatrixRootPlugin.kt | 7 ++++++- .../build/release/MagicUtilsModrinthTasks.kt | 3 ++- .../build/release/MagicUtilsReleaseModel.kt | 15 ++++++++++++--- .../build/release/MagicUtilsReleaseTasks.kt | 15 +++++++++++++-- .../build/target/MagicUtilsTargetConventions.kt | 10 +++++++++- .../src/test/kotlin/MagicUtilsReleaseModelTest.kt | 11 ++++++++--- 6 files changed, 50 insertions(+), 11 deletions(-) diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixRootPlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixRootPlugin.kt index 6ebc585b..93b2acfc 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixRootPlugin.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixRootPlugin.kt @@ -46,7 +46,12 @@ class MagicUtilsMatrixRootPlugin : Plugin { registerAggregatedJavadocTask(project, resolvedContext) registerDevServerAggregateTasks(project) registerPublishCategoryTasks(project) - registerReleaseTasks(project, publishingSpec) + val defaultTargetJava = resolveMagicUtilsTargetSpec( + targetsFile = project.rootProject.file(resolvedContext.definition.targetsFile), + defaultTarget = resolvedContext.definition.defaultTarget, + explicitTarget = resolvedContext.definition.defaultTarget, + ).java + registerReleaseTasks(project, publishingSpec, defaultTargetJava) @Suppress("UNCHECKED_CAST") val smokeSpecs = project.gradle.extensions.extraProperties.properties["magicutilsSmokeSpecs"] diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsModrinthTasks.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsModrinthTasks.kt index 40856698..ac6c3aa0 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsModrinthTasks.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsModrinthTasks.kt @@ -2,6 +2,7 @@ package dev.ua.theroer.magicutils.build.release import dev.ua.theroer.magicutils.build.smoke.SmokePlatformSpec import dev.ua.theroer.magicutils.build.smoke.expandVersionsFull +import dev.ua.theroer.magicutils.build.target.javaSuffixedCoordinate import dev.ua.theroer.magicutils.build.target.resolveMagicUtilsTargetSpec import org.gradle.api.DefaultTask @@ -127,7 +128,7 @@ private fun modrinthArtifactsFromMatrix( // is `+java`), so all Minecraft versions sharing a Java level // map to one jar. Merge their advertised game versions into ONE // Modrinth version instead of re-uploading the same file per MC. - val fileName = "magicutils-${platform.name}-bundle-$ver+java$java.jar" + val fileName = "magicutils-${platform.name}-bundle-${javaSuffixedCoordinate(ver, java)}.jar" val gameVersions = entries.flatMap { it.versions.expandVersionsFull() }.distinct() // Stable, valid Modrinth file part, unique per jar. val key = "${platform.name}-java$java" diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseModel.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseModel.kt index b00c438f..0c24927b 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseModel.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseModel.kt @@ -58,10 +58,19 @@ internal fun bumpGradleVersion(gradlePropertiesText: String, version: SemanticVe return regex.replaceFirst(gradlePropertiesText, "version=$version") } -/** URL of the POM smoke-tested after a publish, per [MagicUtilsPublishingSpec]. */ -internal fun MagicUtilsPublishingSpec.smokeArtifactUrl(version: SemanticVersion): String { +/** + * URL of the POM smoke-tested after a publish, per [MagicUtilsPublishingSpec]. + * + * [versionCoordinate] is the FULL published coordinate, including the target + * suffix the target plugin appends to `project.version` (e.g. `1.26.0+java21`). + * Passing a bare `X.Y.Z` here would point at a POM that is never published — the + * library only ships per-target coordinates — so the smoke poll would 404 + * forever. The `+` is percent-encoded for the HTTP request. + */ +internal fun MagicUtilsPublishingSpec.smokeArtifactUrl(versionCoordinate: String): String { val groupPath = group.replace('.', '/') - return "$repoUrl/$groupPath/$smokeArtifact/$version/$smokeArtifact-$version.pom" + val encoded = versionCoordinate.replace("+", "%2B") + return "$repoUrl/$groupPath/$smokeArtifact/$encoded/$smokeArtifact-$encoded.pom" } /** diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseTasks.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseTasks.kt index 7f9d6ab0..d4f849fb 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseTasks.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseTasks.kt @@ -1,6 +1,7 @@ package dev.ua.theroer.magicutils.build.release import dev.ua.theroer.magicutils.build.publish.* +import dev.ua.theroer.magicutils.build.target.javaSuffixedCoordinate import org.gradle.api.DefaultTask import org.gradle.api.Project @@ -33,7 +34,11 @@ import javax.inject.Inject private const val RELEASE_GROUP = "release" -internal fun registerReleaseTasks(project: Project, publishingSpec: MagicUtilsPublishingSpec) { +internal fun registerReleaseTasks( + project: Project, + publishingSpec: MagicUtilsPublishingSpec, + defaultTargetJava: Int, +) { val gradlePropertiesFile = project.rootProject.file("gradle.properties") // Read the raw -Pversion from the start parameter, not project.version: the // target plugin overwrites project.version with the + suffix @@ -70,7 +75,13 @@ internal fun registerReleaseTasks(project: Project, publishingSpec: MagicUtilsPu project.tasks.register("smokeTest", SmokeTestTask::class.java) { task -> task.group = RELEASE_GROUP task.description = "Poll the published POM for -Pversion until it appears (or times out)." - task.artifactUrl.set(project.provider { publishingSpec.smokeArtifactUrl(SemanticVersion.parse(versionProvider.get())) }) + // The library ships only per-Java coordinates (`X.Y.Z+java`); a bare + // X.Y.Z POM is never published. Poll the default target's Java level as + // the canonical "Maven is up" signal. + task.artifactUrl.set(project.provider { + val base = SemanticVersion.parse(versionProvider.get()).toString() + publishingSpec.smokeArtifactUrl(javaSuffixedCoordinate(base, defaultTargetJava)) + }) task.notCompatibleWithConfigurationCache("Performs network polling.") } diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/target/MagicUtilsTargetConventions.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/target/MagicUtilsTargetConventions.kt index 415bb5cd..5c5b72de 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/target/MagicUtilsTargetConventions.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/target/MagicUtilsTargetConventions.kt @@ -51,7 +51,15 @@ val MagicUtilsTargetExtension.mcClassifier: String * not through the Maven coordinate. */ fun MagicUtilsTargetExtension.publishedVersion(baseVersion: String): String = - "$baseVersion+java${java.get()}" + javaSuffixedCoordinate(baseVersion, java.get()) + +/** + * Pure formatter for the `+java` published coordinate. The `+java` + * suffix format lives here as one function so no caller (publishedVersion, the + * Modrinth bundle file name, the release smoke URL) re-spells it by hand. + */ +fun javaSuffixedCoordinate(baseVersion: String, javaLevel: Int): String = + "$baseVersion+java$javaLevel" /** Loom plugin id for the target: no-remap on deobfuscated, remapping otherwise. */ val MagicUtilsTargetExtension.loomPluginId: String diff --git a/build-logic/src/test/kotlin/MagicUtilsReleaseModelTest.kt b/build-logic/src/test/kotlin/MagicUtilsReleaseModelTest.kt index a1754e9a..0393553e 100644 --- a/build-logic/src/test/kotlin/MagicUtilsReleaseModelTest.kt +++ b/build-logic/src/test/kotlin/MagicUtilsReleaseModelTest.kt @@ -6,6 +6,7 @@ import org.junit.jupiter.api.Test import dev.ua.theroer.magicutils.build.release.* import dev.ua.theroer.magicutils.build.publish.* +import dev.ua.theroer.magicutils.build.target.javaSuffixedCoordinate class MagicUtilsReleaseModelTest { @@ -46,15 +47,19 @@ class MagicUtilsReleaseModelTest { } @Test - fun `smokeArtifactUrl builds POM url`() { + fun `smokeArtifactUrl percent-encodes the java-suffixed coordinate`() { val spec = MagicUtilsPublishingSpec( group = "dev.ua.theroer", repoUrl = "https://maven.theroer.dev/releases", smokeArtifact = "magicutils-core", ) + // The library ships only per-Java coordinates; the smoke URL must target + // one of them (`+` percent-encoded), not a bare X.Y.Z POM that is never + // published (which would 404 forever). assertEquals( - "https://maven.theroer.dev/releases/dev/ua/theroer/magicutils-core/1.21.5/magicutils-core-1.21.5.pom", - spec.smokeArtifactUrl(SemanticVersion(1, 21, 5)), + "https://maven.theroer.dev/releases/dev/ua/theroer/magicutils-core/" + + "1.26.0%2Bjava21/magicutils-core-1.26.0%2Bjava21.pom", + spec.smokeArtifactUrl(javaSuffixedCoordinate("1.26.0", 21)), ) } From 91a79f56ca9280ff969b66b6319c6d04bb026f5d Mon Sep 17 00:00:00 2001 From: THEROER Date: Fri, 10 Jul 2026 17:39:31 +0300 Subject: [PATCH 20/39] feat(release): add verifyReleaseConsistency local task Cross-checks -Pversion across gradle.properties, the git tag, Reposilite Maven (HEAD on the java-suffixed POM, same coordinate as smokeTest) and Modrinth. Strict fail (non-zero) when the required three disagree; Modrinth is advisory (published manually, may lag) and is skipped without a token. -Preport prints the report without failing. Comparison logic (evaluateReleaseConsistency) and the Modrinth version-list parse (parseModrinthVersionIds, factored out of the publish task) live in the model and are unit-tested; the task is a thin git/HTTP wrapper. --- .../matrix/MagicUtilsMatrixRootPlugin.kt | 6 +- .../build/release/MagicUtilsModrinthTasks.kt | 12 +- .../build/release/MagicUtilsReleaseModel.kt | 94 ++++++++++++++++ .../build/release/MagicUtilsReleaseTasks.kt | 103 ++++++++++++++++++ .../test/kotlin/MagicUtilsReleaseModelTest.kt | 47 ++++++++ 5 files changed, 248 insertions(+), 14 deletions(-) diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixRootPlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixRootPlugin.kt index 93b2acfc..7f4edbfc 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixRootPlugin.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixRootPlugin.kt @@ -51,7 +51,9 @@ class MagicUtilsMatrixRootPlugin : Plugin { defaultTarget = resolvedContext.definition.defaultTarget, explicitTarget = resolvedContext.definition.defaultTarget, ).java - registerReleaseTasks(project, publishingSpec, defaultTargetJava) + val modrinthSpec = project.gradle.extensions.extraProperties.properties["magicutilsModrinthSpec"] + as? ModrinthReleaseSpec + registerReleaseTasks(project, publishingSpec, defaultTargetJava, modrinthSpec?.projectId) @Suppress("UNCHECKED_CAST") val smokeSpecs = project.gradle.extensions.extraProperties.properties["magicutilsSmokeSpecs"] @@ -65,8 +67,6 @@ class MagicUtilsMatrixRootPlugin : Plugin { ) registerReleaseMatrixTask(project, resolvedContext, smokeSpecs) - val modrinthSpec = project.gradle.extensions.extraProperties.properties["magicutilsModrinthSpec"] - as? ModrinthReleaseSpec val targetsFile = project.rootProject.file(resolvedContext.definition.targetsFile) registerModrinthTasks(project, modrinthSpec, smokeSpecs, resolvedContext.definition.defaultTarget, targetsFile) } diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsModrinthTasks.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsModrinthTasks.kt index ac6c3aa0..21a87acc 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsModrinthTasks.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsModrinthTasks.kt @@ -234,17 +234,7 @@ abstract class ModrinthPublishTask : DefaultTask() { if (response.statusCode() !in 200..299) { throw GradleException("Failed to list Modrinth versions (HTTP ${response.statusCode()}): ${response.body()}") } - // Proper JSON parse: a regex can't pair id↔version_number because nested - // file/dependency objects also carry "id". Later duplicates overwrite so - // the map holds the newest id per version_number. - @Suppress("UNCHECKED_CAST") - val versions = groovy.json.JsonSlurper().parseText(response.body()) as? List> - ?: return emptyMap() - return versions.mapNotNull { v -> - val id = v["id"] as? String - val num = v["version_number"] as? String - if (id != null && num != null) num to id else null - }.toMap() + return parseModrinthVersionIds(response.body()) } private fun uploadVersion( diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseModel.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseModel.kt index 0c24927b..fbbf31ac 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseModel.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseModel.kt @@ -73,6 +73,25 @@ internal fun MagicUtilsPublishingSpec.smokeArtifactUrl(versionCoordinate: String return "$repoUrl/$groupPath/$smokeArtifact/$encoded/$smokeArtifact-$encoded.pom" } +/** + * Parse a Modrinth `GET /project/{id}/version` response body into a map of + * `version_number` -> version `id`. A regex can't pair id<->version_number + * because nested file/dependency objects also carry `"id"`, so this does a + * proper JSON parse. Later duplicates overwrite, so the map holds the newest id + * per version_number. Kept here (pure) so both the Modrinth publish task and the + * release-consistency check parse the response the same way. + */ +internal fun parseModrinthVersionIds(responseBody: String): Map { + @Suppress("UNCHECKED_CAST") + val versions = groovy.json.JsonSlurper().parseText(responseBody) as? List> + ?: return emptyMap() + return versions.mapNotNull { v -> + val id = v["id"] as? String + val num = v["version_number"] as? String + if (id != null && num != null) num to id else null + }.toMap() +} + /** * Validate a requested release version against the current gradle.properties * version and the latest already-released version. Returns nothing; throws @@ -94,3 +113,78 @@ internal fun validateReleaseVersion( throw GradleException("Version $requested must be greater than latest release $latestReleased.") } } + +/** Presence of a release version across the sources verifyReleaseConsistency checks. */ +enum class SourceState { PRESENT, ABSENT, SKIPPED } + +/** One line of the consistency report: a source name, its state, and detail. */ +data class ReleaseSourceStatus(val source: String, val state: SourceState, val detail: String) + +/** + * Result of comparing a release version across gradle.properties, the git tag, + * Reposilite Maven, and Modrinth. [consistent] is the strict verdict: all + * required sources (everything except Modrinth, which publishes manually and may + * legitimately lag) must agree, or [verifyReleaseConsistency]'s task fails. + */ +data class ReleaseConsistencyReport( + val version: SemanticVersion, + val statuses: List, + val consistent: Boolean, + val problems: List, +) + +/** + * Compare a release [version] across its four publishing surfaces. Each input is + * the already-gathered fact for that source (I/O stays in the Gradle task; this + * is the pure, testable comparison): + * + * - [gradlePropertiesVersion]: the version in gradle.properties. + * - [tagExists]: whether a `vX.Y.Z` git tag exists. + * - [mavenPublished]: whether the java-suffixed POM is reachable on Reposilite. + * - [modrinthPublished]: whether Modrinth has the version; null = check skipped + * (no token / offline), which is a warning, never a strict failure. + * + * Modrinth is advisory because it is published by a separate manual command; the + * other three must agree for the release to be considered consistent. + */ +fun evaluateReleaseConsistency( + version: SemanticVersion, + gradlePropertiesVersion: SemanticVersion, + tagExists: Boolean, + mavenPublished: Boolean, + modrinthPublished: Boolean?, +): ReleaseConsistencyReport { + val statuses = mutableListOf() + val problems = mutableListOf() + + val gradleMatches = gradlePropertiesVersion == version + statuses += ReleaseSourceStatus( + "gradle.properties", + if (gradleMatches) SourceState.PRESENT else SourceState.ABSENT, + if (gradleMatches) "version=$version" else "version=$gradlePropertiesVersion (expected $version)", + ) + if (!gradleMatches) problems += "gradle.properties is at $gradlePropertiesVersion, not $version." + + statuses += ReleaseSourceStatus( + "git tag", + if (tagExists) SourceState.PRESENT else SourceState.ABSENT, + if (tagExists) "v$version present" else "v$version missing", + ) + if (!tagExists) problems += "Git tag v$version does not exist." + + statuses += ReleaseSourceStatus( + "Maven (Reposilite)", + if (mavenPublished) SourceState.PRESENT else SourceState.ABSENT, + if (mavenPublished) "POM published" else "POM not found", + ) + if (!mavenPublished) problems += "Maven POM for $version is not published on Reposilite." + + statuses += when (modrinthPublished) { + true -> ReleaseSourceStatus("Modrinth", SourceState.PRESENT, "version $version published") + false -> ReleaseSourceStatus("Modrinth", SourceState.ABSENT, "version $version not published (publish manually with publishToModrinth)") + null -> ReleaseSourceStatus("Modrinth", SourceState.SKIPPED, "not checked (no MODRINTH_TOKEN or unreachable)") + } + + // Strict verdict excludes Modrinth: it lags legitimately until the manual publish. + return ReleaseConsistencyReport(version, statuses, problems.isEmpty(), problems) +} diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseTasks.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseTasks.kt index d4f849fb..ffad002a 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseTasks.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseTasks.kt @@ -38,6 +38,7 @@ internal fun registerReleaseTasks( project: Project, publishingSpec: MagicUtilsPublishingSpec, defaultTargetJava: Int, + modrinthProjectId: String?, ) { val gradlePropertiesFile = project.rootProject.file("gradle.properties") // Read the raw -Pversion from the start parameter, not project.version: the @@ -85,6 +86,23 @@ internal fun registerReleaseTasks( task.notCompatibleWithConfigurationCache("Performs network polling.") } + project.tasks.register("verifyReleaseConsistency", VerifyReleaseConsistencyTask::class.java) { task -> + task.group = RELEASE_GROUP + task.description = "Verify -Pversion is consistent across gradle.properties, git tag, Maven and Modrinth " + + "(strict fail unless -Preport)." + task.requestedVersion.set(versionProvider) + task.gradlePropertiesText.set(project.provider { gradlePropertiesFile.readText() }) + // Same java-suffixed coordinate the smoke poll uses (the only one that is + // actually published), so the two checks agree on what "Maven has it" means. + task.mavenPomUrl.set(project.provider { + val base = SemanticVersion.parse(versionProvider.get()).toString() + publishingSpec.smokeArtifactUrl(javaSuffixedCoordinate(base, defaultTargetJava)) + }) + task.modrinthProjectId.set(project.provider { modrinthProjectId }) + task.reportOnly.set(project.provider { project.hasProperty("report") }) + task.notCompatibleWithConfigurationCache("Queries git and the network.") + } + project.tasks.register("release") { task -> task.group = RELEASE_GROUP task.description = "Full client release: preflight -> bump -> dispatch (-Pversion=X.Y.Z)." @@ -197,3 +215,88 @@ abstract class SmokeTestTask : DefaultTask() { ) } } + +abstract class VerifyReleaseConsistencyTask @Inject constructor( + private val execOps: ExecOperations, +) : DefaultTask() { + @get:Input abstract val requestedVersion: Property + @get:Input abstract val gradlePropertiesText: Property + @get:Input abstract val mavenPomUrl: Property + @get:[Input Optional] abstract val modrinthProjectId: Property + @get:Input abstract val reportOnly: Property + + @TaskAction + fun run() { + val requested = SemanticVersion.parse(requestedVersion.get()) + val gradleVersion = readGradleVersion(gradlePropertiesText.get()) + + val tags = runCatching { execOps.capture("git", "tag", "--list", "v*") } + .getOrDefault("") + .lineSequence().map(String::trim).filter(String::isNotEmpty).toSet() + val tagExists = "v$requested" in tags + + val client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(15)).build() + val mavenPublished = headOk(client, mavenPomUrl.get()) + val modrinthPublished = modrinthVersionPresent(client, modrinthProjectId.orNull, requested.toString()) + + val report = evaluateReleaseConsistency( + version = requested, + gradlePropertiesVersion = gradleVersion, + tagExists = tagExists, + mavenPublished = mavenPublished, + modrinthPublished = modrinthPublished, + ) + + logger.lifecycle("Release consistency for $requested:") + for (status in report.statuses) { + val mark = when (status.state) { + SourceState.PRESENT -> "OK " + SourceState.ABSENT -> "FAIL" + SourceState.SKIPPED -> "SKIP" + } + logger.lifecycle(" [$mark] ${status.source}: ${status.detail}") + } + + if (report.consistent) { + logger.lifecycle("All required sources agree.") + return + } + val summary = "Release $requested is inconsistent:\n - " + report.problems.joinToString("\n - ") + if (reportOnly.get()) { + logger.warn(summary) + } else { + throw org.gradle.api.GradleException("$summary\n(Run with -Preport to print without failing.)") + } + } + + /** True if a HEAD on [url] returns 200 (artifact present), false otherwise. */ + private fun headOk(client: HttpClient, url: String): Boolean { + val request = HttpRequest.newBuilder(URI.create(url)) + .method("HEAD", HttpRequest.BodyPublishers.noBody()) + .timeout(Duration.ofSeconds(15)) + .build() + return runCatching { + client.send(request, HttpResponse.BodyHandlers.discarding()).statusCode() == 200 + }.getOrDefault(false) + } + + /** + * Whether Modrinth already has [versionNumber] for [projectId]. Returns null + * (check skipped, a warning not a failure) when no project is configured or + * the request fails — Modrinth is published manually and may legitimately lag. + * A public project's version list needs no token; MODRINTH_TOKEN is sent when + * present so private/draft projects also resolve. + */ + private fun modrinthVersionPresent(client: HttpClient, projectId: String?, versionNumber: String): Boolean? { + if (projectId.isNullOrBlank()) return null + val builder = HttpRequest.newBuilder( + URI.create("https://api.modrinth.com/v3/project/$projectId/version?include_changelog=false") + ).timeout(Duration.ofSeconds(20)).GET() + System.getenv("MODRINTH_TOKEN")?.takeIf { it.isNotBlank() }?.let { builder.header("Authorization", it) } + return runCatching { + val response = client.send(builder.build(), HttpResponse.BodyHandlers.ofString()) + if (response.statusCode() !in 200..299) return null + versionNumber in parseModrinthVersionIds(response.body()).keys + }.getOrNull() + } +} diff --git a/build-logic/src/test/kotlin/MagicUtilsReleaseModelTest.kt b/build-logic/src/test/kotlin/MagicUtilsReleaseModelTest.kt index 0393553e..138ab155 100644 --- a/build-logic/src/test/kotlin/MagicUtilsReleaseModelTest.kt +++ b/build-logic/src/test/kotlin/MagicUtilsReleaseModelTest.kt @@ -1,7 +1,9 @@ import org.gradle.api.GradleException import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import dev.ua.theroer.magicutils.build.release.* @@ -81,4 +83,49 @@ class MagicUtilsReleaseModelTest { validateReleaseVersion(SemanticVersion(1, 21, 6), current, SemanticVersion(1, 21, 6), emptySet()) } } + + @Test + fun `parseModrinthVersionIds pairs version_number to id and ignores nested ids`() { + val json = """ + [ + {"id":"aaa111","version_number":"1.26.0","files":[{"id":"nested-file-id"}]}, + {"id":"bbb222","version_number":"1.25.0"} + ] + """.trimIndent() + val map = parseModrinthVersionIds(json) + assertEquals(mapOf("1.26.0" to "aaa111", "1.25.0" to "bbb222"), map) + } + + @Test + fun `evaluateReleaseConsistency passes when required sources agree and Modrinth may lag`() { + val v = SemanticVersion(1, 26, 0) + // Maven + tag + gradle.properties agree; Modrinth not yet published (manual) — still consistent. + val report = evaluateReleaseConsistency( + version = v, + gradlePropertiesVersion = v, + tagExists = true, + mavenPublished = true, + modrinthPublished = false, + ) + assertTrue(report.consistent) + assertTrue(report.problems.isEmpty()) + // Modrinth-absent still surfaces as an ABSENT status line for visibility. + assertEquals(SourceState.ABSENT, report.statuses.single { it.source == "Modrinth" }.state) + } + + @Test + fun `evaluateReleaseConsistency fails when a required source disagrees`() { + val v = SemanticVersion(1, 26, 0) + val report = evaluateReleaseConsistency( + version = v, + gradlePropertiesVersion = SemanticVersion(1, 25, 0), + tagExists = false, + mavenPublished = false, + modrinthPublished = null, + ) + assertFalse(report.consistent) + // gradle.properties, tag, Maven each contribute a problem; Modrinth (null) does not. + assertEquals(3, report.problems.size) + assertEquals(SourceState.SKIPPED, report.statuses.single { it.source == "Modrinth" }.state) + } } From f0a9dd81b6c20559ce29b487934a69e0c1f24f12 Mon Sep 17 00:00:00 2001 From: THEROER Date: Fri, 10 Jul 2026 17:48:10 +0300 Subject: [PATCH 21/39] chore(build-logic): bump plugins to 0.1.8 Ships the smokeTest java-suffix fix and the new verifyReleaseConsistency task to consumers. Published to Reposilite by publish-maven.yml alongside the library. --- build-logic/gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build-logic/gradle.properties b/build-logic/gradle.properties index 72f70472..af1b528a 100644 --- a/build-logic/gradle.properties +++ b/build-logic/gradle.properties @@ -12,4 +12,4 @@ neoForgeModdevVersion=2.0.139 # Coordinates for publishing the build-logic plugins as a reusable tool. # Independent of the MagicUtils library version (root gradle.properties). pluginsGroup=dev.ua.theroer.magicutils.build -pluginsVersion=0.1.7 \ No newline at end of file +pluginsVersion=0.1.8 \ No newline at end of file From 46320917dd62b9d2bdef21a1ef287c3fdd0d136c Mon Sep 17 00:00:00 2001 From: THEROER Date: Fri, 10 Jul 2026 18:11:44 +0300 Subject: [PATCH 22/39] refactor(build-logic): extract per-target Exec fan-out helper The 'one Gradle invocation per Minecraft target' mechanism (wrapper name, isolated child Gradle home, -Ptarget) was inline in registerAllTargetsTask. Extract it into registerMagicUtilsFanout so the upcoming local Maven and Modrinth release fan-outs reuse it instead of copying the machinery. Adds a dryRun mode that prints the command per target instead of spawning a child build. buildAllTargets --dry-run is unchanged (same buildTarget tasks). --- .../build/matrix/MagicUtilsFanout.kt | 86 +++++++++++++++++++ .../matrix/MagicUtilsMatrixRootPlugin.kt | 51 +++++------ 2 files changed, 107 insertions(+), 30 deletions(-) create mode 100644 build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsFanout.kt diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsFanout.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsFanout.kt new file mode 100644 index 00000000..b2344e47 --- /dev/null +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsFanout.kt @@ -0,0 +1,86 @@ +package dev.ua.theroer.magicutils.build.matrix + +import org.gradle.api.Project +import org.gradle.api.Task +import org.gradle.api.tasks.Exec +import org.gradle.api.tasks.TaskProvider + +/** + * Shared per-target Gradle fan-out. + * + * A single Gradle build graph is pinned to one Minecraft target (resolved in + * `settings.gradle`), so covering every target is inherently N separate Gradle + * invocations. Every feature that must run "the same task, once per target" + * (`buildAllTargets`, the local Maven release, the Modrinth bundle build) shells + * out to the project's own `gradlew` wrapper with `-Ptarget=mcXXXX`. This is the + * single place that mechanism lives, so no caller re-derives the wrapper name, + * the child Gradle home, or the dry-run printing. + * + * A nested `GradleBuild` task can't be used: Gradle forbids a nested build over a + * root that itself `includeBuild`s another project (build-logic), which is + * exactly this layout. Shelling out to `gradlew` is also what CI does per target, + * so the two paths stay behaviourally identical. + */ + +/** One per-target invocation: the extra `gradlew` args to run for [target]. */ +data class MagicUtilsFanoutInvocation( + val target: String, + /** Gradle arguments after the wrapper (tasks + `-P` flags), excluding `-Ptarget`. */ + val args: List, +) + +/** + * Registers one task per [invocations] entry (named ``) and + * returns the providers so the caller can build an aggregate that depends on + * them. + * + * A real run is an [Exec] shelling out to the wrapper; each child runs against a + * dedicated Gradle user home (`build/`) so its daemon registry, + * caches and lock files never collide with the parent build that spawned it (the + * parent still holds the root project's locks while this runs). Without this the + * child intermittently fails its cold compile racing the parent for the daemon. + * + * When [dryRun] is true each entry is a plain task that just prints the command + * it would run, so a release can be previewed without spawning any child build. + */ +internal fun registerMagicUtilsFanout( + project: Project, + taskPrefix: String, + taskGroup: String, + invocations: List, + childHomeSubdir: String, + dryRun: Boolean, + describe: (MagicUtilsFanoutInvocation) -> String, +): List> { + val rootDir = project.rootProject.projectDir + val isWindows = System.getProperty("os.name").orEmpty().lowercase().contains("win") + val wrapper = if (isWindows) "gradlew.bat" else "./gradlew" + val childGradleHome = project.layout.buildDirectory.dir(childHomeSubdir).get().asFile + + fun commandLine(invocation: MagicUtilsFanoutInvocation): List = buildList { + add(wrapper) + addAll(invocation.args) + // -Ptarget pins the whole include graph in settings.gradle; the child + // Gradle home isolates the nested invocation from the parent's locks. + add("-Ptarget=${invocation.target}") + add("--gradle-user-home=${childGradleHome.absolutePath}") + } + + return invocations.map { invocation -> + val taskName = "$taskPrefix${invocation.target.replaceFirstChar(Char::titlecase)}" + if (dryRun) { + project.tasks.register(taskName) { task -> + task.group = taskGroup + task.description = "[dry-run] ${describe(invocation)}" + task.doLast { task.logger.lifecycle("[dry-run] ${commandLine(invocation).joinToString(" ")}") } + } + } else { + project.tasks.register(taskName, Exec::class.java) { task -> + task.group = taskGroup + task.description = describe(invocation) + task.workingDir = rootDir + task.commandLine(commandLine(invocation)) + } + } + } +} diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixRootPlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixRootPlugin.kt index 7f4edbfc..3045ad83 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixRootPlugin.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixRootPlugin.kt @@ -291,37 +291,28 @@ private fun registerAllTargetsTask( return } - val rootDir = project.rootProject.projectDir - val isWindows = System.getProperty("os.name").orEmpty().lowercase().contains("win") - val wrapper = if (isWindows) "gradlew.bat" else "./gradlew" - // Child builds run against a dedicated Gradle user home so their daemon - // registry, caches and lock files never collide with the parent build that - // spawned them (the parent is still holding the root project's locks while - // this task executes). Without this the child intermittently fails its cold - // compile racing the parent for the same daemon/locks. - val childGradleHome = project.layout.buildDirectory.dir("all-targets-gradle-home").get().asFile - - val perTargetTasks = resolvedTargets.map { targetName -> - project.tasks.register( - "buildTarget${targetName.replaceFirstChar(Char::titlecase)}", - org.gradle.api.tasks.Exec::class.java, - ) { task -> - task.group = "matrix" - task.description = "Run '${effectiveSpec.taskType.gradleTask}' for target $targetName." - task.workingDir = rootDir - // Each target is a fresh Gradle invocation; -Ptarget pins its whole - // include graph in settings.gradle. Scenario limits which platforms - // that run builds (defaults to the matrix default scenario when null). - val args = mutableListOf( - wrapper, - effectiveSpec.taskType.gradleTask, - "-Ptarget=$targetName", - "--gradle-user-home=${childGradleHome.absolutePath}", + // Each target is a fresh Gradle invocation; -Ptarget pins its whole include + // graph in settings.gradle. Scenario limits which platforms that run builds + // (defaults to the matrix default scenario when null). The per-target Exec + // mechanism itself lives in registerMagicUtilsFanout so it is shared with the + // release publish/modrinth fan-outs. + val perTargetTasks = registerMagicUtilsFanout( + project = project, + taskPrefix = "buildTarget", + taskGroup = "matrix", + invocations = resolvedTargets.map { targetName -> + MagicUtilsFanoutInvocation( + target = targetName, + args = buildList { + add(effectiveSpec.taskType.gradleTask) + effectiveSpec.scenario?.let { add("-Pscenario=$it") } + }, ) - effectiveSpec.scenario?.let { args += "-Pscenario=$it" } - task.commandLine(args) - } - } + }, + childHomeSubdir = "all-targets-gradle-home", + dryRun = false, + describe = { "Run '${effectiveSpec.taskType.gradleTask}' for target ${it.target}." }, + ) project.tasks.register("buildAllTargets") { task -> task.group = "matrix" From 2e6d65637017933e7e07f2bded3a2f9bd08f26a7 Mon Sep 17 00:00:00 2001 From: THEROER Date: Fri, 10 Jul 2026 18:14:46 +0300 Subject: [PATCH 23/39] feat(build-logic): release spec model + release{} DSL + settings wiring MagicUtilsReleaseSpec makes every release step (validate/bump/tag/push/ maven/modrinth/javadoc/verify) individually optional, so a consumer builds exactly the pipeline they want. releasePlan() gives the fixed run order; applyReleaseOverrides() merges -Prelease.=true|false over the DSL. Wired as magicMatrix { release { ... } } into magicutilsReleaseSpec. No task consumes the spec yet. --- .../matrix/MagicUtilsMatrixSettingsPlugin.kt | 11 +++ .../build/matrix/MagicUtilsReleaseDsl.kt | 43 ++++++++++ .../build/release/MagicUtilsReleaseSpec.kt | 86 +++++++++++++++++++ .../test/kotlin/MagicUtilsReleaseModelTest.kt | 35 ++++++++ 4 files changed, 175 insertions(+) create mode 100644 build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsReleaseDsl.kt create mode 100644 build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseSpec.kt diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixSettingsPlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixSettingsPlugin.kt index c8ce1b43..c9f69d7f 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixSettingsPlugin.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixSettingsPlugin.kt @@ -65,6 +65,16 @@ open class MagicUtilsMatrixSettingsExtension { internal fun toAllTargetsSpec(): MagicUtilsAllTargetsSpec = allTargetsDsl.toSpec() + private val releaseDsl = MagicUtilsReleaseDsl() + + /** Shapes the `release` orchestrator (which steps run: maven/modrinth/tag/javadoc/...). */ + fun release(action: org.gradle.api.Action) { + action.execute(releaseDsl) + } + + internal fun toReleaseSpec(): dev.ua.theroer.magicutils.build.release.MagicUtilsReleaseSpec = + releaseDsl.toSpec() + fun commonProject(path: String) { commonProjectPaths += normalizeProjectPath(path) } @@ -386,6 +396,7 @@ class MagicUtilsMatrixSettingsPlugin : Plugin { settings.gradle.extensions.extraProperties.set("magicutilsSmokeGate", extension.smokeGate()) settings.gradle.extensions.extraProperties.set("magicutilsModrinthSpec", extension.toModrinthSpec()) settings.gradle.extensions.extraProperties.set("magicutilsAllTargetsSpec", extension.toAllTargetsSpec()) + settings.gradle.extensions.extraProperties.set("magicutilsReleaseSpec", extension.toReleaseSpec()) resolvedContext.includedProjects.forEach { projectPath -> settings.include(projectPath.removePrefix(":")) diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsReleaseDsl.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsReleaseDsl.kt new file mode 100644 index 00000000..7c750b12 --- /dev/null +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsReleaseDsl.kt @@ -0,0 +1,43 @@ +package dev.ua.theroer.magicutils.build.matrix + +import dev.ua.theroer.magicutils.build.release.MagicUtilsReleaseSpec + +/** + * Consumer-facing DSL for the `release` orchestrator, e.g.: + * + * magicMatrix { + * release { + * // publishMaven = true // default + * publishModrinth = false // this plugin has no Modrinth page + * push = true // push the tag to origin as part of release + * } + * } + * + * Every axis is optional; omitting the block entirely means a full library + * release (validate, bump, tag locally, publish Maven + Modrinth + Javadoc, + * verify). Each step is also overridable per invocation with + * `-Prelease.=true|false` without editing settings.gradle. + */ +open class MagicUtilsReleaseDsl { + var validateVersion: Boolean = true + var validateBuild: Boolean = true + var bump: Boolean = true + var tag: Boolean = true + var push: Boolean = false + var publishMaven: Boolean = true + var publishModrinth: Boolean = true + var publishJavadoc: Boolean = true + var verify: Boolean = true + + internal fun toSpec(): MagicUtilsReleaseSpec = MagicUtilsReleaseSpec( + validateVersion = validateVersion, + validateBuild = validateBuild, + bump = bump, + tag = tag, + push = push, + publishMaven = publishMaven, + publishModrinth = publishModrinth, + publishJavadoc = publishJavadoc, + verify = verify, + ) +} diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseSpec.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseSpec.kt new file mode 100644 index 00000000..3ab7246e --- /dev/null +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseSpec.kt @@ -0,0 +1,86 @@ +package dev.ua.theroer.magicutils.build.release + +/** + * Which steps the `release` orchestrator runs, and how. Every step is optional so + * a consumer builds exactly the release pipeline they want (a plugin with no + * Modrinth page turns `publishModrinth` off; a fork that tags out-of-band turns + * `tag` off). Defaults describe a full library release: validate, bump, tag + * locally (no push), publish Maven + Modrinth + Javadoc, verify afterwards. + * + * Declared in `magicMatrix { release { ... } }` and overridable per invocation + * with `-Prelease.=true|false` (see [applyReleaseOverrides]). Pure data so + * the plan is unit-testable without a Gradle runtime. + */ +data class MagicUtilsReleaseSpec( + /** Validate the requested version against gradle.properties + tags (releasePreflight). */ + val validateVersion: Boolean = true, + /** Run the build/tests gate before publishing (heavy; disable with -Prelease.validate=false). */ + val validateBuild: Boolean = true, + /** Bump gradle.properties to the requested version and commit it. */ + val bump: Boolean = true, + /** Create the vX.Y.Z git tag. */ + val tag: Boolean = true, + /** Push the tag to origin (off by default: local tag only until you opt in). */ + val push: Boolean = false, + /** Publish the full module matrix + build-logic to the Maven repo. */ + val publishMaven: Boolean = true, + /** Build the bundles and publish them to Modrinth. */ + val publishModrinth: Boolean = true, + /** Generate and upload the aggregated Javadoc. */ + val publishJavadoc: Boolean = true, + /** Verify version consistency across all sources after publishing. */ + val verify: Boolean = true, +) + +/** A step of the release, in run order, with whether the spec enabled it. */ +data class MagicUtilsReleaseStep(val name: String, val enabled: Boolean) + +/** + * The ordered release plan for [spec]. Order is fixed and meaningful: validate + * and build gate before any mutation; bump/tag before publishing so the tag + * exists when Maven/Modrinth go out; verify last. Callers wire the aggregate + * task's dependsOn/mustRunAfter from this list, and print it so a dry run shows + * exactly what a real run would do. + */ +fun releasePlan(spec: MagicUtilsReleaseSpec): List = listOf( + MagicUtilsReleaseStep("releasePreflight", spec.validateVersion), + MagicUtilsReleaseStep("releaseValidateBuild", spec.validateBuild), + MagicUtilsReleaseStep("bumpVersion", spec.bump), + MagicUtilsReleaseStep("releaseTag", spec.tag), + MagicUtilsReleaseStep("releaseMavenAll", spec.publishMaven), + MagicUtilsReleaseStep("releaseModrinth", spec.publishModrinth), + MagicUtilsReleaseStep("releaseJavadoc", spec.publishJavadoc), + MagicUtilsReleaseStep("verifyReleaseConsistency", spec.verify), +) + +/** + * Applies `-Prelease.=true|false` overrides on top of the DSL [spec]. A + * property whose value isn't a clean boolean is ignored (the DSL default stands) + * rather than failing the build, so a typo never blocks a release. [properties] + * is the raw Gradle project-properties map (property name -> string value). + */ +fun applyReleaseOverrides( + spec: MagicUtilsReleaseSpec, + properties: Map, +): MagicUtilsReleaseSpec { + fun override(key: String, current: Boolean): Boolean = + properties["release.$key"]?.trim()?.lowercase()?.let { + when (it) { + "true" -> true + "false" -> false + else -> current // ignore garbage, keep the DSL value + } + } ?: current + + return spec.copy( + validateVersion = override("validateVersion", spec.validateVersion), + validateBuild = override("validate", spec.validateBuild), + bump = override("bump", spec.bump), + tag = override("tag", spec.tag), + push = override("push", spec.push), + publishMaven = override("maven", spec.publishMaven), + publishModrinth = override("modrinth", spec.publishModrinth), + publishJavadoc = override("javadoc", spec.publishJavadoc), + verify = override("verify", spec.verify), + ) +} diff --git a/build-logic/src/test/kotlin/MagicUtilsReleaseModelTest.kt b/build-logic/src/test/kotlin/MagicUtilsReleaseModelTest.kt index 138ab155..04531a85 100644 --- a/build-logic/src/test/kotlin/MagicUtilsReleaseModelTest.kt +++ b/build-logic/src/test/kotlin/MagicUtilsReleaseModelTest.kt @@ -128,4 +128,39 @@ class MagicUtilsReleaseModelTest { assertEquals(3, report.problems.size) assertEquals(SourceState.SKIPPED, report.statuses.single { it.source == "Modrinth" }.state) } + + @Test + fun `releasePlan lists steps in fixed order with enabled flags from the spec`() { + val spec = MagicUtilsReleaseSpec(publishModrinth = false, push = true) + val plan = releasePlan(spec) + assertEquals( + listOf( + "releasePreflight", "releaseValidateBuild", "bumpVersion", "releaseTag", + "releaseMavenAll", "releaseModrinth", "releaseJavadoc", "verifyReleaseConsistency", + ), + plan.map { it.name }, + ) + assertFalse(plan.single { it.name == "releaseModrinth" }.enabled) + assertTrue(plan.single { it.name == "releaseMavenAll" }.enabled) + } + + @Test + fun `applyReleaseOverrides merges -Prelease flags over the DSL spec`() { + val spec = MagicUtilsReleaseSpec() // all defaults (modrinth on, push off) + val merged = applyReleaseOverrides( + spec, + mapOf("release.modrinth" to "false", "release.push" to "true", "release.maven" to "FALSE"), + ) + assertFalse(merged.publishModrinth) + assertTrue(merged.push) + assertFalse(merged.publishMaven) // case-insensitive + assertTrue(merged.publishJavadoc) // untouched default + } + + @Test + fun `applyReleaseOverrides ignores non-boolean values and keeps the DSL default`() { + val spec = MagicUtilsReleaseSpec(publishModrinth = true) + val merged = applyReleaseOverrides(spec, mapOf("release.modrinth" to "maybe")) + assertTrue(merged.publishModrinth) // garbage ignored, default stands + } } From 3d8d4991c162aa3f47618729dea8b1786c442237 Mon Sep 17 00:00:00 2001 From: THEROER Date: Fri, 10 Jul 2026 18:17:29 +0300 Subject: [PATCH 24/39] feat(build-logic): local Maven release fan-out (releaseMavenAll) releaseMavenAll publishes the full module matrix to the Maven repo locally, one Gradle invocation per publish unit (one representative target per Java level, from the shared publishUnits), plus publishBuildLogic for the plugins. Reuses registerMagicUtilsFanout; the dry-run command set matches printPublishMatrix unit-for-unit. Factors the gradlew wrapper name into magicUtilsGradleWrapperName so it is spelled once. Actions-free equivalent of publish-maven.yml. --- .../build/matrix/MagicUtilsFanout.kt | 7 +- .../matrix/MagicUtilsMatrixRootPlugin.kt | 6 ++ .../release/MagicUtilsReleasePublishTasks.kt | 99 +++++++++++++++++++ 3 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleasePublishTasks.kt diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsFanout.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsFanout.kt index b2344e47..f2d90fd3 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsFanout.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsFanout.kt @@ -22,6 +22,10 @@ import org.gradle.api.tasks.TaskProvider * so the two paths stay behaviourally identical. */ +/** The project's Gradle wrapper script name for the current OS. */ +internal fun magicUtilsGradleWrapperName(): String = + if (System.getProperty("os.name").orEmpty().lowercase().contains("win")) "gradlew.bat" else "./gradlew" + /** One per-target invocation: the extra `gradlew` args to run for [target]. */ data class MagicUtilsFanoutInvocation( val target: String, @@ -53,8 +57,7 @@ internal fun registerMagicUtilsFanout( describe: (MagicUtilsFanoutInvocation) -> String, ): List> { val rootDir = project.rootProject.projectDir - val isWindows = System.getProperty("os.name").orEmpty().lowercase().contains("win") - val wrapper = if (isWindows) "gradlew.bat" else "./gradlew" + val wrapper = magicUtilsGradleWrapperName() val childGradleHome = project.layout.buildDirectory.dir(childHomeSubdir).get().asFile fun commandLine(invocation: MagicUtilsFanoutInvocation): List = buildList { diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixRootPlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixRootPlugin.kt index 3045ad83..296e0660 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixRootPlugin.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixRootPlugin.kt @@ -54,6 +54,12 @@ class MagicUtilsMatrixRootPlugin : Plugin { val modrinthSpec = project.gradle.extensions.extraProperties.properties["magicutilsModrinthSpec"] as? ModrinthReleaseSpec registerReleaseTasks(project, publishingSpec, defaultTargetJava, modrinthSpec?.projectId) + registerReleaseMavenTasks( + project, + publishingSpec, + resolvedContext.definition, + project.rootProject.file(resolvedContext.definition.targetsFile), + ) @Suppress("UNCHECKED_CAST") val smokeSpecs = project.gradle.extensions.extraProperties.properties["magicutilsSmokeSpecs"] diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleasePublishTasks.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleasePublishTasks.kt new file mode 100644 index 00000000..b35fff7a --- /dev/null +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleasePublishTasks.kt @@ -0,0 +1,99 @@ +package dev.ua.theroer.magicutils.build.release + +import dev.ua.theroer.magicutils.build.matrix.MagicUtilsFanoutInvocation +import dev.ua.theroer.magicutils.build.matrix.MagicUtilsMatrixDefinition +import dev.ua.theroer.magicutils.build.matrix.magicUtilsGradleWrapperName +import dev.ua.theroer.magicutils.build.matrix.publishUnits +import dev.ua.theroer.magicutils.build.matrix.registerMagicUtilsFanout +import dev.ua.theroer.magicutils.build.publish.MagicUtilsPublishingSpec +import dev.ua.theroer.magicutils.build.target.loadAllTargetNames +import dev.ua.theroer.magicutils.build.target.resolveMagicUtilsTargetSpec + +import org.gradle.api.Project +import org.gradle.api.tasks.Exec +import java.io.File + +private const val RELEASE_GROUP = "release" + +/** + * Registers the local Maven release fan-out: `releaseMavenAll` publishes the full + * module matrix into the configured Maven repo, one Gradle invocation per publish + * unit (one representative target per Java level, from [publishUnits]), plus a + * separate `publishBuildLogic` for the independently-versioned plugins. This is + * the local, actions-free equivalent of the former publish-maven.yml. + * + * The publish repo URL comes from the publishing spec; credentials are read by + * the module publish plugin from `PUBLISH_USER`/`PUBLISH_TOKEN` (env) or + * `-Ppublish_user/password`. `-Prelease.dryRun=true` prints every command instead + * of running it, so a release can be previewed without publishing anything. + */ +internal fun registerReleaseMavenTasks( + project: Project, + publishingSpec: MagicUtilsPublishingSpec, + definition: MagicUtilsMatrixDefinition, + targetsFile: File, +) { + val dryRun = project.hasProperty("release.dryRun") + val repoUrl = publishingSpec.repoUrl + + // Same per-unit resolution printPublishMatrix uses (one target per Java level, + // with its publishDefaultMatrix [+ publishFabricMatrix] tasks), so the local + // release and the CI matrix publish byte-identical coordinates. + val units = definition.publishUnits(loadAllTargetNames(targetsFile)) { target -> + resolveMagicUtilsTargetSpec( + targetsFile = targetsFile, + defaultTarget = definition.defaultTarget, + explicitTarget = target, + ).java + } + + val perUnitTasks = registerMagicUtilsFanout( + project = project, + taskPrefix = "releaseMaven", + taskGroup = RELEASE_GROUP, + invocations = units.map { unit -> + MagicUtilsFanoutInvocation( + target = unit.target, + // clean + --rerun-tasks so each fresh target invocation republishes + // from scratch (the immutable releases repo rejects stale reuploads). + args = buildList { + add("clean") + unit.publishTasks.forEach { add(it) } + add("--rerun-tasks") + add("-Ppublish_repo=$repoUrl") + add("-Pskip_shadow_publish=true") + }, + ) + }, + childHomeSubdir = "release-maven-gradle-home", + dryRun = dryRun, + describe = { "Publish ${it.target} to $repoUrl (${it.args.filterNot { a -> a.startsWith("-") || a == "clean" }.joinToString(" ")})." }, + ) + + // build-logic is a single, target-independent publish (no fan-out), mirroring + // publish-maven.yml's publish-plugins job. + val buildLogicTask = if (dryRun) { + project.tasks.register("publishBuildLogic") { task -> + task.group = RELEASE_GROUP + task.description = "[dry-run] Publish the build-logic plugins to $repoUrl." + task.doLast { + task.logger.lifecycle("[dry-run] ./gradlew -p build-logic publish -Ppublish_repo=$repoUrl") + } + } + } else { + project.tasks.register("publishBuildLogic", Exec::class.java) { task -> + task.group = RELEASE_GROUP + task.description = "Publish the build-logic plugins to $repoUrl." + task.workingDir = project.rootProject.projectDir + task.commandLine(magicUtilsGradleWrapperName(), "-p", "build-logic", "publish", "-Ppublish_repo=$repoUrl") + } + } + + project.tasks.register("releaseMavenAll") { task -> + task.group = RELEASE_GROUP + task.description = "Publish the full module matrix + build-logic to $repoUrl " + + "(one invocation per Java level; -Prelease.dryRun to preview)." + task.dependsOn(perUnitTasks) + task.dependsOn(buildLogicTask) + } +} From 3c79d347e82a0be097bb49ceea3cf89a26913327 Mon Sep 17 00:00:00 2001 From: THEROER Date: Fri, 10 Jul 2026 20:08:37 +0300 Subject: [PATCH 25/39] feat(build-logic): local git tag/push task (releaseTag) releaseTag creates the vX.Y.Z tag locally and only pushes to origin when push is enabled (-Prelease.push=true), matching the release spec's push=false default. Idempotent: an existing local tag is left in place. Replaces the tagging that lived in release.yml so the tag step runs without actions. --- .../build/release/MagicUtilsReleaseTasks.kt | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseTasks.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseTasks.kt index ffad002a..1573bf26 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseTasks.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseTasks.kt @@ -103,6 +103,17 @@ internal fun registerReleaseTasks( task.notCompatibleWithConfigurationCache("Queries git and the network.") } + project.tasks.register("releaseTag", ReleaseTagTask::class.java) { task -> + task.group = RELEASE_GROUP + task.description = "Create the vX.Y.Z git tag locally; push to origin only with -Prelease.push=true." + task.requestedVersion.set(versionProvider) + // Default off: a local tag until you opt into publishing it, matching the + // release spec's push=false default. -Prelease.push=true (or release { push = true }) + // turns the push on. + task.push.set(project.provider { project.findProperty("release.push")?.toString()?.toBoolean() ?: false }) + task.notCompatibleWithConfigurationCache("Runs git tag/push.") + } + project.tasks.register("release") { task -> task.group = RELEASE_GROUP task.description = "Full client release: preflight -> bump -> dispatch (-Pversion=X.Y.Z)." @@ -124,6 +135,36 @@ private fun ExecOperations.capture(vararg args: String): String { return out.toString().trim() } +abstract class ReleaseTagTask @Inject constructor( + private val execOps: ExecOperations, +) : DefaultTask() { + @get:Input abstract val requestedVersion: Property + @get:Input abstract val push: Property + + @TaskAction + fun run() { + val requested = SemanticVersion.parse(requestedVersion.get()) + val tag = "v$requested" + + val localTags = runCatching { execOps.capture("git", "tag", "--list", tag) } + .getOrDefault("") + .lineSequence().map(String::trim).filter(String::isNotEmpty).toSet() + if (tag in localTags) { + logger.lifecycle("Tag $tag already exists locally — not re-tagging.") + } else { + execOps.capture("git", "tag", tag) + logger.lifecycle("Created tag $tag.") + } + + if (push.get()) { + execOps.capture("git", "push", "origin", tag) + logger.lifecycle("Pushed $tag to origin.") + } else { + logger.lifecycle("Not pushing $tag (pass -Prelease.push=true to push to origin).") + } + } +} + abstract class ReleasePreflightTask @Inject constructor( private val execOps: ExecOperations, ) : DefaultTask() { From ff6028d4087e7990274d671de5ce9241a9eba664 Mon Sep 17 00:00:00 2001 From: THEROER Date: Fri, 10 Jul 2026 20:11:43 +0300 Subject: [PATCH 26/39] feat(build-logic): local Modrinth release (releaseModrinth) releaseModrinth builds the platform bundle jars for every Java level (one representative target per level, reusing the shared publishUnits + fan-out) and then runs the existing matrix-driven publishToModrinth. The +java bundle jars accumulate side by side per platform, so one publishToModrinth finds all of them. Factors the unit resolution into resolvePublishUnits so the Maven and Modrinth fan-outs share it. Actions-free Modrinth publish. --- .../matrix/MagicUtilsMatrixRootPlugin.kt | 3 + .../release/MagicUtilsReleasePublishTasks.kt | 77 ++++++++++++++++--- 2 files changed, 69 insertions(+), 11 deletions(-) diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixRootPlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixRootPlugin.kt index 296e0660..5f8edd64 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixRootPlugin.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixRootPlugin.kt @@ -75,6 +75,9 @@ class MagicUtilsMatrixRootPlugin : Plugin { val targetsFile = project.rootProject.file(resolvedContext.definition.targetsFile) registerModrinthTasks(project, modrinthSpec, smokeSpecs, resolvedContext.definition.defaultTarget, targetsFile) + // After publishToModrinth exists: the local release wraps it with a + // per-Java-level bundle build fan-out. + registerReleaseModrinthTask(project, resolvedContext.definition, targetsFile) } } diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleasePublishTasks.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleasePublishTasks.kt index b35fff7a..f393241d 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleasePublishTasks.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleasePublishTasks.kt @@ -35,17 +35,7 @@ internal fun registerReleaseMavenTasks( ) { val dryRun = project.hasProperty("release.dryRun") val repoUrl = publishingSpec.repoUrl - - // Same per-unit resolution printPublishMatrix uses (one target per Java level, - // with its publishDefaultMatrix [+ publishFabricMatrix] tasks), so the local - // release and the CI matrix publish byte-identical coordinates. - val units = definition.publishUnits(loadAllTargetNames(targetsFile)) { target -> - resolveMagicUtilsTargetSpec( - targetsFile = targetsFile, - defaultTarget = definition.defaultTarget, - explicitTarget = target, - ).java - } + val units = resolvePublishUnits(definition, targetsFile) val perUnitTasks = registerMagicUtilsFanout( project = project, @@ -97,3 +87,68 @@ internal fun registerReleaseMavenTasks( task.dependsOn(buildLogicTask) } } + +/** + * The publish units (one representative target per Java level, with the publish + * tasks it runs). Shared by the Maven and Modrinth release fan-outs and matching + * printPublishMatrix, so all three agree on which targets represent which Java + * level. The `+java` coordinate is byte-identical across targets of a level, + * so one representative per level is enough. + */ +internal fun resolvePublishUnits(definition: MagicUtilsMatrixDefinition, targetsFile: File) = + definition.publishUnits(loadAllTargetNames(targetsFile)) { target -> + resolveMagicUtilsTargetSpec( + targetsFile = targetsFile, + defaultTarget = definition.defaultTarget, + explicitTarget = target, + ).java + } + +/** + * Registers the local Modrinth release: `releaseModrinth` builds the platform + * bundle jars for every Java level (one representative target per level, fanned + * out) and then runs the existing `publishToModrinth` (matrix-driven, idempotent, + * `MODRINTH_TOKEN`). The per-level bundle jars carry the `+java` suffix in + * their file names, so they accumulate side by side in each `-bundle/ + * build/libs` and publishToModrinth finds all of them. Actions-free equivalent of + * a Modrinth publish job. + */ +internal fun registerReleaseModrinthTask( + project: Project, + definition: MagicUtilsMatrixDefinition, + targetsFile: File, +) { + val dryRun = project.hasProperty("release.dryRun") + val units = resolvePublishUnits(definition, targetsFile) + + val bundleTasks = registerMagicUtilsFanout( + project = project, + taskPrefix = "releaseModrinthBundles", + taskGroup = RELEASE_GROUP, + invocations = units.map { unit -> + // Build the full workspace scenario so every bundle platform available + // on this representative target produces its +java jar. + MagicUtilsFanoutInvocation(unit.target, listOf("build", "-Pscenario=workspace")) + }, + childHomeSubdir = "release-modrinth-gradle-home", + dryRun = dryRun, + describe = { "Build platform bundles for ${it.target} (Modrinth artifacts)." }, + ) + + project.tasks.register("releaseModrinth") { task -> + task.group = RELEASE_GROUP + task.description = "Build the bundles for every Java level and publish them to Modrinth " + + "(-Prelease.dryRun / -PmodrinthDryRun to preview)." + task.dependsOn(bundleTasks) + // publishToModrinth is matrix-driven and idempotent; run it after the jars + // for all Java levels exist. + task.dependsOn("publishToModrinth") + } + + // Order publishToModrinth after every bundle build. Configured outside the + // register block above: Gradle forbids configuring another task from within a + // task-creation action. + project.tasks.named("publishToModrinth").configure { publish -> + bundleTasks.forEach { publish.mustRunAfter(it) } + } +} From 30eb3d72a244375e3bd730298d351ca6942a1e0d Mon Sep 17 00:00:00 2001 From: THEROER Date: Fri, 10 Jul 2026 20:14:35 +0300 Subject: [PATCH 27/39] feat(build-logic): javadoc upload task (releaseJavadoc) releaseJavadoc runs aggregatedJavadocZip and PUTs the zip to the publish repo at the stable latest path plus a versioned copy, replacing the curl upload in publish-javadoc.yml. URL builders (javadocLatestUrl/javadocVersionUrl) are pure and unit-tested. Credentials reuse the shared findMagicUtilsPublishSecret (now internal). The versioned URL uses the raw -Pversion, not the +java project.version. -Prelease.dryRun prints the target URLs. --- .../matrix/MagicUtilsMatrixRootPlugin.kt | 1 + .../build/release/MagicUtilsReleaseModel.kt | 15 ++++ .../release/MagicUtilsReleasePublishTasks.kt | 90 +++++++++++++++++++ .../support/MagicUtilsProjectExtensions.kt | 7 +- .../test/kotlin/MagicUtilsReleaseModelTest.kt | 13 +++ 5 files changed, 125 insertions(+), 1 deletion(-) diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixRootPlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixRootPlugin.kt index 5f8edd64..5d206ec6 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixRootPlugin.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixRootPlugin.kt @@ -60,6 +60,7 @@ class MagicUtilsMatrixRootPlugin : Plugin { resolvedContext.definition, project.rootProject.file(resolvedContext.definition.targetsFile), ) + registerReleaseJavadocTask(project, publishingSpec) @Suppress("UNCHECKED_CAST") val smokeSpecs = project.gradle.extensions.extraProperties.properties["magicutilsSmokeSpecs"] diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseModel.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseModel.kt index fbbf31ac..4d6e378f 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseModel.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseModel.kt @@ -92,6 +92,21 @@ internal fun parseModrinthVersionIds(responseBody: String): Map }.toMap() } +/** Maven artifactId the aggregated Javadoc zip is uploaded under. */ +private const val JAVADOC_ARTIFACT = "magicutils-javadoc" + +/** + * Upload URL for the stable "latest" Javadoc zip on the publish repo. The docs + * site always fetches this path, so every release overwrites it. [group] is the + * publishing group (e.g. `dev.ua.theroer`), [repoUrl] the repo base. + */ +internal fun javadocLatestUrl(repoUrl: String, group: String): String = + "${repoUrl.trimEnd('/')}/${group.replace('.', '/')}/$JAVADOC_ARTIFACT/latest/$JAVADOC_ARTIFACT.zip" + +/** Upload URL for the versioned Javadoc zip copy, kept for reference/rollback. */ +internal fun javadocVersionUrl(repoUrl: String, group: String, version: String): String = + "${repoUrl.trimEnd('/')}/${group.replace('.', '/')}/$JAVADOC_ARTIFACT/$version/$JAVADOC_ARTIFACT.zip" + /** * Validate a requested release version against the current gradle.properties * version and the latest already-released version. Returns nothing; throws diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleasePublishTasks.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleasePublishTasks.kt index f393241d..1581e8dd 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleasePublishTasks.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleasePublishTasks.kt @@ -6,12 +6,26 @@ import dev.ua.theroer.magicutils.build.matrix.magicUtilsGradleWrapperName import dev.ua.theroer.magicutils.build.matrix.publishUnits import dev.ua.theroer.magicutils.build.matrix.registerMagicUtilsFanout import dev.ua.theroer.magicutils.build.publish.MagicUtilsPublishingSpec +import dev.ua.theroer.magicutils.build.support.findMagicUtilsPublishSecret import dev.ua.theroer.magicutils.build.target.loadAllTargetNames import dev.ua.theroer.magicutils.build.target.resolveMagicUtilsTargetSpec +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException import org.gradle.api.Project +import org.gradle.api.provider.Property import org.gradle.api.tasks.Exec +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.TaskAction import java.io.File +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.nio.file.Path +import java.time.Duration +import java.util.Base64 private const val RELEASE_GROUP = "release" @@ -152,3 +166,79 @@ internal fun registerReleaseModrinthTask( bundleTasks.forEach { publish.mustRunAfter(it) } } } + +/** + * Registers `releaseJavadoc`: generate the aggregated Javadoc zip (via the + * existing `aggregatedJavadocZip`) and upload it to the publish repo at the + * stable `latest` path plus a versioned copy. Actions-free equivalent of + * publish-javadoc.yml. `-Prelease.dryRun=true` prints the target URLs instead of + * uploading. + */ +internal fun registerReleaseJavadocTask( + project: Project, + publishingSpec: MagicUtilsPublishingSpec, +) { + val dryRun = project.hasProperty("release.dryRun") + project.tasks.register("releaseJavadoc", UploadJavadocTask::class.java) { task -> + task.group = RELEASE_GROUP + task.description = "Generate and upload the aggregated Javadoc to ${publishingSpec.repoUrl} " + + "(-Prelease.dryRun to preview)." + task.dependsOn("aggregatedJavadocZip") + task.zipPath.set(project.layout.buildDirectory.file("docs/$JAVADOC_ZIP_NAME").map { it.asFile.toPath() }) + task.latestUrl.set(javadocLatestUrl(publishingSpec.repoUrl, publishingSpec.group)) + task.versionUrl.set(project.provider { + // The raw -Pversion, not project.version: the target plugin overwrites + // project.version with the +java suffix, which is not a release version. + project.gradle.startParameter.projectProperties["version"]?.trim()?.takeIf { it.isNotEmpty() } + ?.let { javadocVersionUrl(publishingSpec.repoUrl, publishingSpec.group, it) } + }) + task.username.set(project.provider { project.findMagicUtilsPublishSecret("publish_user", "PUBLISH_USER") }) + task.password.set(project.provider { project.findMagicUtilsPublishSecret("publish_password", "PUBLISH_TOKEN") }) + task.dryRun.set(dryRun) + task.notCompatibleWithConfigurationCache("Performs a network upload.") + } +} + +/** The aggregated Javadoc zip file name produced by aggregatedJavadocZip. */ +private const val JAVADOC_ZIP_NAME = "magicutils-javadoc.zip" + +abstract class UploadJavadocTask : DefaultTask() { + @get:Input abstract val zipPath: Property + @get:Input abstract val latestUrl: Property + @get:[Input Optional] abstract val versionUrl: Property + @get:[Input Optional] abstract val username: Property + @get:[Input Optional] abstract val password: Property + @get:Input abstract val dryRun: Property + + @TaskAction + fun run() { + val zip = zipPath.get().toFile() + val targets = listOfNotNull(latestUrl.get(), versionUrl.orNull) + + if (dryRun.get()) { + logger.lifecycle("[dry-run] would upload ${zip.name} to:") + targets.forEach { logger.lifecycle(" $it") } + logger.lifecycle(" credentials: ${if (username.orNull != null && password.orNull != null) "present" else "absent"}") + return + } + + if (!zip.isFile) throw GradleException("Javadoc zip not found at ${zip.absolutePath} (did aggregatedJavadocZip run?).") + val client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(15)).build() + val authHeader = username.orNull?.let { user -> + password.orNull?.let { pass -> + "Basic " + Base64.getEncoder().encodeToString("$user:$pass".toByteArray()) + } + } + for (url in targets) { + val builder = HttpRequest.newBuilder(URI.create(url)) + .timeout(Duration.ofMinutes(5)) + .PUT(HttpRequest.BodyPublishers.ofFile(zip.toPath())) + authHeader?.let { builder.header("Authorization", it) } + val response = client.send(builder.build(), HttpResponse.BodyHandlers.ofString()) + if (response.statusCode() !in 200..299) { + throw GradleException("Javadoc upload to $url failed (HTTP ${response.statusCode()}): ${response.body()}") + } + logger.lifecycle("Uploaded ${zip.name} -> $url") + } + } +} diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/support/MagicUtilsProjectExtensions.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/support/MagicUtilsProjectExtensions.kt index fea6974e..ea61750c 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/support/MagicUtilsProjectExtensions.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/support/MagicUtilsProjectExtensions.kt @@ -49,7 +49,12 @@ internal fun Project.magicUtilsPublishRepository(publishing: PublishingExtension } } -private fun Project.findMagicUtilsPublishSecret(propertyName: String, envName: String): String? = +/** + * A publish secret from a Gradle property or an environment variable, in that + * order. Shared by the module publish repo and the Javadoc upload task so the + * `publish_user`/`PUBLISH_USER` (and password/token) resolution lives once. + */ +internal fun Project.findMagicUtilsPublishSecret(propertyName: String, envName: String): String? = (findProperty(propertyName) as? String)?.trim()?.takeIf(String::isNotEmpty) ?: System.getenv(envName)?.trim()?.takeIf(String::isNotEmpty) diff --git a/build-logic/src/test/kotlin/MagicUtilsReleaseModelTest.kt b/build-logic/src/test/kotlin/MagicUtilsReleaseModelTest.kt index 04531a85..ec881d0a 100644 --- a/build-logic/src/test/kotlin/MagicUtilsReleaseModelTest.kt +++ b/build-logic/src/test/kotlin/MagicUtilsReleaseModelTest.kt @@ -157,6 +157,19 @@ class MagicUtilsReleaseModelTest { assertTrue(merged.publishJavadoc) // untouched default } + @Test + fun `javadoc urls build the latest and versioned coordinate`() { + val repo = "https://maven.theroer.dev/releases" + assertEquals( + "https://maven.theroer.dev/releases/dev/ua/theroer/magicutils-javadoc/latest/magicutils-javadoc.zip", + javadocLatestUrl(repo, "dev.ua.theroer"), + ) + assertEquals( + "https://maven.theroer.dev/releases/dev/ua/theroer/magicutils-javadoc/1.27.0/magicutils-javadoc.zip", + javadocVersionUrl(repo, "dev.ua.theroer", "1.27.0"), + ) + } + @Test fun `applyReleaseOverrides ignores non-boolean values and keeps the DSL default`() { val spec = MagicUtilsReleaseSpec(publishModrinth = true) From 59788309f74ca8b3c775fbae9d5166fb44ffdc9c Mon Sep 17 00:00:00 2001 From: THEROER Date: Fri, 10 Jul 2026 20:23:17 +0300 Subject: [PATCH 28/39] feat(build-logic): configurable release orchestrator release now runs exactly the steps enabled in release { } (validate/bump/ tag/maven/modrinth/javadoc/verify), in the fixed plan order with a mustRunAfter chain, and prints the plan. -Prelease.=true|false overrides the DSL per invocation. Adds releaseValidateBuild (non-Fabric build gate). dispatchRelease is dropped from the default release (release is now fully local) but kept as a standalone task for the CI-trigger path. The orchestrator is registered after every step task so the chain resolves. --- .../matrix/MagicUtilsMatrixRootPlugin.kt | 8 ++- .../build/release/MagicUtilsReleaseTasks.kt | 52 +++++++++++++++---- 2 files changed, 49 insertions(+), 11 deletions(-) diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixRootPlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixRootPlugin.kt index 5d206ec6..0418c1a3 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixRootPlugin.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixRootPlugin.kt @@ -53,7 +53,8 @@ class MagicUtilsMatrixRootPlugin : Plugin { ).java val modrinthSpec = project.gradle.extensions.extraProperties.properties["magicutilsModrinthSpec"] as? ModrinthReleaseSpec - registerReleaseTasks(project, publishingSpec, defaultTargetJava, modrinthSpec?.projectId) + val releaseSpec = project.gradle.extensions.extraProperties.properties["magicutilsReleaseSpec"] + as? MagicUtilsReleaseSpec ?: MagicUtilsReleaseSpec() registerReleaseMavenTasks( project, publishingSpec, @@ -79,6 +80,11 @@ class MagicUtilsMatrixRootPlugin : Plugin { // After publishToModrinth exists: the local release wraps it with a // per-Java-level bundle build fan-out. registerReleaseModrinthTask(project, resolvedContext.definition, targetsFile) + + // The orchestrator last: every step task it chains (releaseMavenAll, + // releaseModrinth, releaseJavadoc, ...) is registered by now, so the + // release aggregate can wire its mustRunAfter chain directly. + registerReleaseTasks(project, publishingSpec, defaultTargetJava, modrinthSpec?.projectId, releaseSpec) } } diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseTasks.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseTasks.kt index 1573bf26..1b498dac 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseTasks.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseTasks.kt @@ -39,6 +39,7 @@ internal fun registerReleaseTasks( publishingSpec: MagicUtilsPublishingSpec, defaultTargetJava: Int, modrinthProjectId: String?, + releaseSpec: MagicUtilsReleaseSpec, ) { val gradlePropertiesFile = project.rootProject.file("gradle.properties") // Read the raw -Pversion from the start parameter, not project.version: the @@ -103,27 +104,58 @@ internal fun registerReleaseTasks( task.notCompatibleWithConfigurationCache("Queries git and the network.") } + // The effective spec: DSL defaults with -Prelease.=... overrides applied. + val effectiveSpec = applyReleaseOverrides(releaseSpec, stringGradleProperties(project)) + project.tasks.register("releaseTag", ReleaseTagTask::class.java) { task -> task.group = RELEASE_GROUP - task.description = "Create the vX.Y.Z git tag locally; push to origin only with -Prelease.push=true." + task.description = "Create the vX.Y.Z git tag locally; push to origin only when push is enabled." task.requestedVersion.set(versionProvider) - // Default off: a local tag until you opt into publishing it, matching the - // release spec's push=false default. -Prelease.push=true (or release { push = true }) - // turns the push on. - task.push.set(project.provider { project.findProperty("release.push")?.toString()?.toBoolean() ?: false }) + // Push follows the resolved spec (DSL push, overridable with -Prelease.push). + task.push.set(effectiveSpec.push) task.notCompatibleWithConfigurationCache("Runs git tag/push.") } + // Build/tests gate before publishing: the non-Fabric scenario build (Fabric is + // covered by the matrix ci). Left as a real Exec so --dry-run skips it. + project.tasks.register("releaseValidateBuild", org.gradle.api.tasks.Exec::class.java) { task -> + task.group = RELEASE_GROUP + task.description = "Build the non-Fabric platforms as a pre-publish gate." + task.workingDir = project.rootProject.projectDir + task.commandLine( + dev.ua.theroer.magicutils.build.matrix.magicUtilsGradleWrapperName(), + "buildScenario", "-PincludePlatforms=bukkit,bungee,velocity,neoforge", + ) + } + + // Configurable orchestrator: only the steps the spec enables run, in the fixed + // plan order, each after the previous. dispatchRelease is intentionally NOT in + // the default release anymore (the release is now fully local); it stays as a + // standalone task for anyone who still wants to trigger the CI workflow. + val enabledSteps = releasePlan(effectiveSpec).filter { it.enabled } project.tasks.register("release") { task -> task.group = RELEASE_GROUP - task.description = "Full client release: preflight -> bump -> dispatch (-Pversion=X.Y.Z)." - task.dependsOn("releasePreflight", "bumpVersion", "dispatchRelease") - // Enforce ordering; Gradle runs dependencies in declared order when chained. - project.tasks.named("bumpVersion").configure { it.mustRunAfter("releasePreflight") } - project.tasks.named("dispatchRelease").configure { it.mustRunAfter("bumpVersion") } + task.description = "Local release: runs the steps enabled in release { } (-Pversion=X.Y.Z, -Prelease.)." + enabledSteps.forEach { task.dependsOn(it.name) } + task.doFirst { + task.logger.lifecycle("Release plan (${enabledSteps.size} step(s)): ${enabledSteps.joinToString(" -> ") { it.name }}") + } + } + // Chain the enabled steps in plan order so publishing never races the tag/bump. + // registerReleaseTasks runs after every step task is registered, so named() here + // resolves. Configured outside the register block: Gradle forbids configuring + // another task from within a task-creation action. + enabledSteps.map { it.name }.zipWithNext().forEach { (before, after) -> + project.tasks.named(after).configure { it.mustRunAfter(before) } } } +/** Gradle project properties as a plain String map (for applyReleaseOverrides). */ +private fun stringGradleProperties(project: Project): Map = + project.properties.entries + .mapNotNull { (key, value) -> (value as? String)?.let { key to it } } + .toMap() + /** Runs a command, returning trimmed stdout; throws on non-zero exit. */ private fun ExecOperations.capture(vararg args: String): String { val out = ByteArrayOutputStream() From 880bce2b2f5425b4b70ba6505f25b5dbbc5d6101 Mon Sep 17 00:00:00 2001 From: THEROER Date: Fri, 10 Jul 2026 20:39:34 +0300 Subject: [PATCH 29/39] feat(build-logic): resolve Modrinth token from property or env The Modrinth token was env-only (MODRINTH_TOKEN) while the Maven secrets accepted a property or env. Make it symmetric: findMagicUtilsModrinthToken reads modrinth_token property or MODRINTH_TOKEN env, so a consumer configures every secret one way (e.g. all in ~/.gradle/gradle.properties). The token is carried as an @Internal task input (never in the up-to-date hash), resolved in the configuration phase, in both publishToModrinth and verifyReleaseConsistency. --- .../build/release/MagicUtilsModrinthDsl.kt | 3 ++- .../build/release/MagicUtilsModrinthTasks.kt | 18 ++++++++++++++---- .../build/release/MagicUtilsReleaseTasks.kt | 12 +++++++++--- .../support/MagicUtilsProjectExtensions.kt | 9 +++++++++ 4 files changed, 34 insertions(+), 8 deletions(-) diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsModrinthDsl.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsModrinthDsl.kt index 4bf3b04a..942d7b60 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsModrinthDsl.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsModrinthDsl.kt @@ -17,7 +17,8 @@ import org.gradle.api.Action * } * } * - * The token is read from the MODRINTH_TOKEN env var at publish time, never here. + * The token is read from the `modrinth_token` property or MODRINTH_TOKEN env at + * publish time, never here. */ open class MagicUtilsModrinthDsl { var projectId: String = "" diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsModrinthTasks.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsModrinthTasks.kt index 21a87acc..c58732f2 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsModrinthTasks.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsModrinthTasks.kt @@ -1,5 +1,6 @@ package dev.ua.theroer.magicutils.build.release +import dev.ua.theroer.magicutils.build.support.findMagicUtilsModrinthToken import dev.ua.theroer.magicutils.build.smoke.SmokePlatformSpec import dev.ua.theroer.magicutils.build.smoke.expandVersionsFull import dev.ua.theroer.magicutils.build.target.javaSuffixedCoordinate @@ -23,7 +24,8 @@ import java.time.Duration * Kotlin replacement for the reusable part of verified-plugin's * `publish_to_modrinth.sh`: uploads one Modrinth version per declared artifact * (idempotent — skips a version_number that already exists). The token comes - * from the MODRINTH_TOKEN environment variable. + * from the `modrinth_token` Gradle property or the MODRINTH_TOKEN env var, the + * same property-or-env resolution as the Maven publish secrets. */ // v3 for native per-version `environment`; body shape is otherwise the same as v2. private const val MODRINTH_API = "https://api.modrinth.com/v3" @@ -37,7 +39,7 @@ internal fun registerModrinthTasks( ) { project.tasks.register("publishToModrinth", ModrinthPublishTask::class.java) { task -> task.group = "publishing" - task.description = "Upload artifacts to Modrinth (-Pversion=X.Y.Z; MODRINTH_TOKEN env). " + + task.description = "Upload artifacts to Modrinth (-Pversion=X.Y.Z; modrinth_token property or MODRINTH_TOKEN env). " + "Artifacts come from the smoke matrix unless the modrinth {} block lists them." // When the modrinth {} block declares no artifacts, synthesise them from // the smoke matrix — the single source of truth (same rows printReleaseMatrix @@ -60,6 +62,9 @@ internal fun registerModrinthTasks( ?: throw GradleException("Pass the release version via -Pversion=X.Y.Z.") }) task.rootDir.set(project.rootDir.absolutePath) + // Resolved in the configuration phase (property-or-env), so the token + // source is uniform with the Maven secrets. Optional: a dry run needs none. + task.token.set(project.provider { project.findMagicUtilsModrinthToken() }) task.notCompatibleWithConfigurationCache("Performs network uploads.") } } @@ -151,6 +156,11 @@ abstract class ModrinthPublishTask : DefaultTask() { @get:Input abstract val baseVersion: Property @get:Input abstract val rootDir: Property + // @Internal, not @Input: a secret must never enter the up-to-date hash or the + // build cache. Optional so a dry run (which returns before needing it) works + // without a token configured. + @get:Internal abstract val token: Property + @TaskAction fun run() { val spec = releaseSpec.orNull @@ -181,8 +191,8 @@ abstract class ModrinthPublishTask : DefaultTask() { return } - val token = System.getenv("MODRINTH_TOKEN")?.takeIf { it.isNotBlank() } - ?: throw GradleException("MODRINTH_TOKEN environment variable is not set.") + val token = token.orNull?.takeIf { it.isNotBlank() } + ?: throw GradleException("Modrinth token is not set (modrinth_token property or MODRINTH_TOKEN env).") val client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(15)).build() val existing = fetchExistingVersions(client, token, spec.projectId) diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseTasks.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseTasks.kt index 1b498dac..8b87d1ce 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseTasks.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseTasks.kt @@ -1,12 +1,14 @@ package dev.ua.theroer.magicutils.build.release import dev.ua.theroer.magicutils.build.publish.* +import dev.ua.theroer.magicutils.build.support.findMagicUtilsModrinthToken import dev.ua.theroer.magicutils.build.target.javaSuffixedCoordinate import org.gradle.api.DefaultTask import org.gradle.api.Project import org.gradle.api.provider.Property import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Internal import org.gradle.api.tasks.Optional import org.gradle.api.tasks.TaskAction import org.gradle.process.ExecOperations @@ -100,6 +102,7 @@ internal fun registerReleaseTasks( publishingSpec.smokeArtifactUrl(javaSuffixedCoordinate(base, defaultTargetJava)) }) task.modrinthProjectId.set(project.provider { modrinthProjectId }) + task.modrinthToken.set(project.provider { project.findMagicUtilsModrinthToken() }) task.reportOnly.set(project.provider { project.hasProperty("report") }) task.notCompatibleWithConfigurationCache("Queries git and the network.") } @@ -296,6 +299,9 @@ abstract class VerifyReleaseConsistencyTask @Inject constructor( @get:Input abstract val gradlePropertiesText: Property @get:Input abstract val mavenPomUrl: Property @get:[Input Optional] abstract val modrinthProjectId: Property + // @Internal: a secret must not enter the up-to-date hash. Optional: a public + // project's version list needs no token. + @get:Internal abstract val modrinthToken: Property @get:Input abstract val reportOnly: Property @TaskAction @@ -310,7 +316,7 @@ abstract class VerifyReleaseConsistencyTask @Inject constructor( val client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(15)).build() val mavenPublished = headOk(client, mavenPomUrl.get()) - val modrinthPublished = modrinthVersionPresent(client, modrinthProjectId.orNull, requested.toString()) + val modrinthPublished = modrinthVersionPresent(client, modrinthProjectId.orNull, modrinthToken.orNull, requested.toString()) val report = evaluateReleaseConsistency( version = requested, @@ -360,12 +366,12 @@ abstract class VerifyReleaseConsistencyTask @Inject constructor( * A public project's version list needs no token; MODRINTH_TOKEN is sent when * present so private/draft projects also resolve. */ - private fun modrinthVersionPresent(client: HttpClient, projectId: String?, versionNumber: String): Boolean? { + private fun modrinthVersionPresent(client: HttpClient, projectId: String?, token: String?, versionNumber: String): Boolean? { if (projectId.isNullOrBlank()) return null val builder = HttpRequest.newBuilder( URI.create("https://api.modrinth.com/v3/project/$projectId/version?include_changelog=false") ).timeout(Duration.ofSeconds(20)).GET() - System.getenv("MODRINTH_TOKEN")?.takeIf { it.isNotBlank() }?.let { builder.header("Authorization", it) } + token?.takeIf { it.isNotBlank() }?.let { builder.header("Authorization", it) } return runCatching { val response = client.send(builder.build(), HttpResponse.BodyHandlers.ofString()) if (response.statusCode() !in 200..299) return null diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/support/MagicUtilsProjectExtensions.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/support/MagicUtilsProjectExtensions.kt index ea61750c..a35801bb 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/support/MagicUtilsProjectExtensions.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/support/MagicUtilsProjectExtensions.kt @@ -58,6 +58,15 @@ internal fun Project.findMagicUtilsPublishSecret(propertyName: String, envName: (findProperty(propertyName) as? String)?.trim()?.takeIf(String::isNotEmpty) ?: System.getenv(envName)?.trim()?.takeIf(String::isNotEmpty) +/** + * The Modrinth API token: the `modrinth_token` Gradle property or the + * `MODRINTH_TOKEN` env var. Same property-or-env resolution as the Maven + * publish secrets, so a consumer configures every secret one way (e.g. all in + * ~/.gradle/gradle.properties). Null when neither is set. + */ +internal fun Project.findMagicUtilsModrinthToken(): String? = + findMagicUtilsPublishSecret("modrinth_token", "MODRINTH_TOKEN") + /** * Drops the `` node from the generated POM. Bundle publications * ship a fat/jar-in-jar artifact whose dependencies are already inside the jar, From c8ff6bff090bdf80185d6544f70e84be17ad6dff Mon Sep 17 00:00:00 2001 From: THEROER Date: Fri, 10 Jul 2026 20:44:53 +0300 Subject: [PATCH 30/39] chore(release): bump version to 1.27.0 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 22adb235..1b0c41d0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,6 @@ org.gradle.caching=true org.gradle.parallel=true org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 -version=1.26.0 +version=1.27.0 # Minecraft version is now managed by gradle/targets.properties # You can set it via -Ptarget=... when running Gradle From f12366f08611558edb8569541729a99b4a1282d2 Mon Sep 17 00:00:00 2001 From: THEROER Date: Fri, 10 Jul 2026 20:49:15 +0300 Subject: [PATCH 31/39] fix(build-logic): put fan-out child Gradle home under .gradle, not build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Maven release fan-out runs 'clean' in each child invocation to republish from scratch. With the child Gradle home under build/ (release-maven-gradle-home), that clean deleted build/ mid-run — including the child's own daemon working directory — failing with 'could not setcwd (errno 2)'. Move the child home to .gradle/, which survives clean. Applies to every fan-out (buildAllTargets too), so none can self-destruct. --- .../ua/theroer/magicutils/build/matrix/MagicUtilsFanout.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsFanout.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsFanout.kt index f2d90fd3..763ea8b4 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsFanout.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsFanout.kt @@ -58,7 +58,11 @@ internal fun registerMagicUtilsFanout( ): List> { val rootDir = project.rootProject.projectDir val wrapper = magicUtilsGradleWrapperName() - val childGradleHome = project.layout.buildDirectory.dir(childHomeSubdir).get().asFile + // Under .gradle/, NOT build/: a child invocation may run `clean` (the Maven + // release does, to republish from scratch), which would delete build/ — and + // with it the child's own Gradle home if it lived there, killing the daemon + // mid-run ("could not setcwd"). .gradle/ survives clean. + val childGradleHome = rootDir.resolve(".gradle").resolve(childHomeSubdir) fun commandLine(invocation: MagicUtilsFanoutInvocation): List = buildList { add(wrapper) From c821b4ccd87e2f6af2cf82b4a34de460a78f1f9b Mon Sep 17 00:00:00 2001 From: THEROER Date: Fri, 10 Jul 2026 20:57:54 +0300 Subject: [PATCH 32/39] fix(build-logic): make release preflight idempotent for resume A release can fail mid-way (Maven publish dies on one target) and must be re-runnable. But preflight rejected the requested version once its tag existed or gradle.properties was already bumped to it, making a resume impossible. Treat requested == current as a resume: skip the tag/monotonicity checks (a prior run already cut them). A fresh release (version not yet bumped to) is still strictly validated. --- .../build/release/MagicUtilsReleaseModel.kt | 13 +++++++++++++ .../src/test/kotlin/MagicUtilsReleaseModelTest.kt | 14 ++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseModel.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseModel.kt index 4d6e378f..2d1158f7 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseModel.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseModel.kt @@ -111,6 +111,12 @@ internal fun javadocVersionUrl(repoUrl: String, group: String, version: String): * Validate a requested release version against the current gradle.properties * version and the latest already-released version. Returns nothing; throws * [GradleException] with an actionable message on any violation. + * + * Idempotent by design: a release can fail partway (e.g. Maven publish dies on + * one target) and be re-run. On a re-run the bump already happened + * (`requested == current`) and the tag already exists, which is NOT an error — + * it is exactly a resume. Only a fresh release (a version not yet bumped to) + * must be strictly greater than everything and untagged. */ internal fun validateReleaseVersion( requested: SemanticVersion, @@ -118,6 +124,13 @@ internal fun validateReleaseVersion( latestReleased: SemanticVersion?, existingTags: Set, ) { + // Resume: gradle.properties is already at the requested version. A prior run + // bumped it, so the tag/monotonicity checks below (which assume a not-yet-cut + // release) must not fire — otherwise a re-run after a mid-release failure is + // impossible. + if (requested == current) { + return + } if (requested < current) { throw GradleException("Version $requested must not be lower than gradle.properties $current.") } diff --git a/build-logic/src/test/kotlin/MagicUtilsReleaseModelTest.kt b/build-logic/src/test/kotlin/MagicUtilsReleaseModelTest.kt index ec881d0a..f0817267 100644 --- a/build-logic/src/test/kotlin/MagicUtilsReleaseModelTest.kt +++ b/build-logic/src/test/kotlin/MagicUtilsReleaseModelTest.kt @@ -84,6 +84,20 @@ class MagicUtilsReleaseModelTest { } } + @Test + fun `validateReleaseVersion allows a resume when gradle_properties is already at the version`() { + // A prior release run bumped to 1.21.6 and tagged v1.21.6, then failed + // during publish. Re-running must NOT reject the existing tag / version — + // requested == current signals a resume, not a fresh release. + val current = SemanticVersion(1, 21, 6) + validateReleaseVersion( + requested = SemanticVersion(1, 21, 6), + current = current, + latestReleased = SemanticVersion(1, 21, 6), + existingTags = setOf("v1.21.6"), + ) // does not throw + } + @Test fun `parseModrinthVersionIds pairs version_number to id and ignores nested ids`() { val json = """ From 38ae64fbf579654908f3188b6ba3ccc18a8482a4 Mon Sep 17 00:00:00 2001 From: THEROER Date: Fri, 10 Jul 2026 21:10:21 +0300 Subject: [PATCH 33/39] feat(build-logic): idempotent publish via -Pskip_existing (resumable release) A release can fail mid-publish; re-running then 409'd against the immutable releases repo on every coordinate a prior run already uploaded (e.g. the build-logic plugins). -Pskip_existing HEADs each POM and skips the PublishToMavenRepository task when it is already present, so a resumed release publishes only what is missing. releaseMavenAll and publishBuildLogic pass it by default, making the whole Maven release resumable (Modrinth already is, by version_number). --- build-logic/build.gradle.kts | 26 ++++++++++++++++ .../release/MagicUtilsReleasePublishTasks.kt | 7 +++-- .../support/MagicUtilsProjectExtensions.kt | 31 +++++++++++++++++++ 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index 33493853..e9ed0232 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -1,5 +1,7 @@ import org.gradle.api.tasks.compile.JavaCompile import org.gradle.jvm.toolchain.JavaLanguageVersion +import java.net.HttpURLConnection +import java.net.URI plugins { kotlin("jvm") version "2.2.0" @@ -44,6 +46,30 @@ publishing { } } +// -Pskip_existing makes publishing idempotent: skip a PublishToMavenRepository +// task whose POM is already in the (immutable) repo, so a resumed release does +// not 409 on the plugins that a prior run already uploaded. HEAD is cheap and +// runs at execution time (skip decided per task), not at configuration. +if (project.hasProperty("skip_existing") && project.hasProperty("publish_repo")) { + val repoBase = (project.property("publish_repo") as String).trimEnd('/') + tasks.withType().configureEach { + onlyIf { + val pub = publication as org.gradle.api.publish.maven.MavenPublication + val path = "${pub.groupId.replace('.', '/')}/${pub.artifactId}/${pub.version}/" + + "${pub.artifactId}-${pub.version}.pom" + val url = URI("$repoBase/$path").toURL() + val exists = runCatching { + (url.openConnection() as HttpURLConnection).run { + requestMethod = "HEAD"; connectTimeout = 10000; readTimeout = 10000 + val code = responseCode; disconnect(); code == 200 + } + }.getOrDefault(false) + if (exists) logger.lifecycle("skip_existing: ${pub.artifactId}:${pub.version} already published, skipping.") + !exists + } + } +} + dependencies { diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleasePublishTasks.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleasePublishTasks.kt index 1581e8dd..f46871a6 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleasePublishTasks.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleasePublishTasks.kt @@ -59,13 +59,16 @@ internal fun registerReleaseMavenTasks( MagicUtilsFanoutInvocation( target = unit.target, // clean + --rerun-tasks so each fresh target invocation republishes - // from scratch (the immutable releases repo rejects stale reuploads). + // from scratch. skip_existing keeps it resumable: a coordinate a + // prior (failed) run already uploaded is skipped instead of 409ing + // against the immutable releases repo. args = buildList { add("clean") unit.publishTasks.forEach { add(it) } add("--rerun-tasks") add("-Ppublish_repo=$repoUrl") add("-Pskip_shadow_publish=true") + add("-Pskip_existing") }, ) }, @@ -89,7 +92,7 @@ internal fun registerReleaseMavenTasks( task.group = RELEASE_GROUP task.description = "Publish the build-logic plugins to $repoUrl." task.workingDir = project.rootProject.projectDir - task.commandLine(magicUtilsGradleWrapperName(), "-p", "build-logic", "publish", "-Ppublish_repo=$repoUrl") + task.commandLine(magicUtilsGradleWrapperName(), "-p", "build-logic", "publish", "-Ppublish_repo=$repoUrl", "-Pskip_existing") } } diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/support/MagicUtilsProjectExtensions.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/support/MagicUtilsProjectExtensions.kt index a35801bb..3304cec6 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/support/MagicUtilsProjectExtensions.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/support/MagicUtilsProjectExtensions.kt @@ -47,6 +47,37 @@ internal fun Project.magicUtilsPublishRepository(publishing: PublishingExtension } } } + + // -Pskip_existing makes publishing idempotent so a resumed release does not + // 409 on modules a prior run already uploaded to the immutable repo. Each + // PublishToMavenRepository task HEADs its POM and skips if already present. + if (hasProperty("skip_existing")) { + val repoBase = repoUrl.trimEnd('/') + tasks.withType(org.gradle.api.publish.maven.tasks.PublishToMavenRepository::class.java).configureEach { task -> + val pub = task.publication as org.gradle.api.publish.maven.MavenPublication + task.onlyIf { !magicUtilsPomAlreadyPublished(repoBase, pub.groupId, pub.artifactId, pub.version, task.logger) } + } + } +} + +/** HEAD the POM coordinate; true if the repo already has it (200). */ +internal fun magicUtilsPomAlreadyPublished( + repoBase: String, + groupId: String, + artifactId: String, + version: String, + logger: org.gradle.api.logging.Logger, +): Boolean { + val path = "${groupId.replace('.', '/')}/$artifactId/$version/$artifactId-$version.pom" + val url = java.net.URI.create("$repoBase/$path").toURL() + val exists = runCatching { + (url.openConnection() as java.net.HttpURLConnection).run { + requestMethod = "HEAD"; connectTimeout = 10000; readTimeout = 10000 + val code = responseCode; disconnect(); code == 200 + } + }.getOrDefault(false) + if (exists) logger.lifecycle("skip_existing: $artifactId:$version already published, skipping.") + return exists } /** From 67dc93cebd472b9603d4526b721bec7691d01491 Mon Sep 17 00:00:00 2001 From: THEROER Date: Fri, 10 Jul 2026 21:16:10 +0300 Subject: [PATCH 34/39] fix(build-logic): forward publish creds to fan-out children via env The Maven release fan-out runs each target in a child Gradle with an isolated --gradle-user-home, so the child cannot read ~/.gradle/gradle.properties and its publish 401'd. Forward the parent-resolved publish_user/publish_password to the child as PUBLISH_USER/PUBLISH_TOKEN env (never as -P, so they stay out of ps). MagicUtilsFanoutInvocation gains an env map for this. --- .../build/matrix/MagicUtilsFanout.kt | 11 +++++++++++ .../release/MagicUtilsReleasePublishTasks.kt | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsFanout.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsFanout.kt index 763ea8b4..035a12a5 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsFanout.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsFanout.kt @@ -31,6 +31,14 @@ data class MagicUtilsFanoutInvocation( val target: String, /** Gradle arguments after the wrapper (tasks + `-P` flags), excluding `-Ptarget`. */ val args: List, + /** + * Extra environment for the child process. The child runs with an isolated + * `--gradle-user-home`, so it does NOT read the caller's + * ~/.gradle/gradle.properties. Secrets the caller resolved (publish creds, + * Modrinth token) must be passed here as env so the child's publish can + * authenticate. Passed as env, not `-P` args, so they never appear in `ps`. + */ + val env: Map = emptyMap(), ) /** @@ -87,6 +95,9 @@ internal fun registerMagicUtilsFanout( task.description = describe(invocation) task.workingDir = rootDir task.commandLine(commandLine(invocation)) + // The child has an isolated Gradle home; pass resolved secrets as + // env so its publish can authenticate (it can't read ~/.gradle). + invocation.env.forEach { (key, value) -> task.environment(key, value) } } } } diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleasePublishTasks.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleasePublishTasks.kt index f46871a6..749b51af 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleasePublishTasks.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleasePublishTasks.kt @@ -50,6 +50,10 @@ internal fun registerReleaseMavenTasks( val dryRun = project.hasProperty("release.dryRun") val repoUrl = publishingSpec.repoUrl val units = resolvePublishUnits(definition, targetsFile) + // Each child runs with an isolated Gradle home and cannot read the caller's + // ~/.gradle/gradle.properties, so the publish creds the parent resolved are + // forwarded as env (the child's publish helper reads PUBLISH_USER/PUBLISH_TOKEN). + val publishEnv = magicUtilsPublishEnv(project) val perUnitTasks = registerMagicUtilsFanout( project = project, @@ -70,6 +74,7 @@ internal fun registerReleaseMavenTasks( add("-Pskip_shadow_publish=true") add("-Pskip_existing") }, + env = publishEnv, ) }, childHomeSubdir = "release-maven-gradle-home", @@ -93,6 +98,7 @@ internal fun registerReleaseMavenTasks( task.description = "Publish the build-logic plugins to $repoUrl." task.workingDir = project.rootProject.projectDir task.commandLine(magicUtilsGradleWrapperName(), "-p", "build-logic", "publish", "-Ppublish_repo=$repoUrl", "-Pskip_existing") + publishEnv.forEach { (key, value) -> task.environment(key, value) } } } @@ -105,6 +111,18 @@ internal fun registerReleaseMavenTasks( } } +/** + * Publish secrets the parent resolved (property-or-env), as an env map to forward + * to child fan-out invocations. The children run with an isolated + * `--gradle-user-home` and cannot read ~/.gradle/gradle.properties, so without + * this their publish would 401. Empty entries are omitted so we never set a blank + * env var. Passed as env (not `-P`) so secrets never appear in `ps`. + */ +private fun magicUtilsPublishEnv(project: Project): Map = buildMap { + project.findMagicUtilsPublishSecret("publish_user", "PUBLISH_USER")?.let { put("PUBLISH_USER", it) } + project.findMagicUtilsPublishSecret("publish_password", "PUBLISH_TOKEN")?.let { put("PUBLISH_TOKEN", it) } +} + /** * The publish units (one representative target per Java level, with the publish * tasks it runs). Shared by the Maven and Modrinth release fan-outs and matching From 6e0177d8ffe7afcd6a3cf9dc7ba779df39588ab8 Mon Sep 17 00:00:00 2001 From: THEROER Date: Fri, 10 Jul 2026 21:30:34 +0300 Subject: [PATCH 35/39] fix(build-logic): Modrinth bundle build uses per-target platforms releaseModrinth built each representative with -Pscenario=workspace, but the java17 representative (mc1201, a 1.20.x target) has no neoforge, so the child failed with 'target does not support platforms: neoforge'. Use the target's availablePlatformsFor set as -PincludePlatforms instead, so each Java level builds exactly the bundles it supports (neoforge only on java21/java25). --- .../build/release/MagicUtilsReleasePublishTasks.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleasePublishTasks.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleasePublishTasks.kt index 749b51af..d0f9e6d1 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleasePublishTasks.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleasePublishTasks.kt @@ -2,6 +2,7 @@ package dev.ua.theroer.magicutils.build.release import dev.ua.theroer.magicutils.build.matrix.MagicUtilsFanoutInvocation import dev.ua.theroer.magicutils.build.matrix.MagicUtilsMatrixDefinition +import dev.ua.theroer.magicutils.build.matrix.availablePlatformsFor import dev.ua.theroer.magicutils.build.matrix.magicUtilsGradleWrapperName import dev.ua.theroer.magicutils.build.matrix.publishUnits import dev.ua.theroer.magicutils.build.matrix.registerMagicUtilsFanout @@ -161,9 +162,12 @@ internal fun registerReleaseModrinthTask( taskPrefix = "releaseModrinthBundles", taskGroup = RELEASE_GROUP, invocations = units.map { unit -> - // Build the full workspace scenario so every bundle platform available - // on this representative target produces its +java jar. - MagicUtilsFanoutInvocation(unit.target, listOf("build", "-Pscenario=workspace")) + // Build only the platforms this representative target actually supports + // (e.g. mc1201/1.20.x has no neoforge) — a fixed workspace scenario would + // fail with "target does not support platforms: neoforge". Each produces + // its +java bundle jar. + val platforms = definition.availablePlatformsFor(unit.target).sorted().joinToString(",") + MagicUtilsFanoutInvocation(unit.target, listOf("build", "-PincludePlatforms=$platforms")) }, childHomeSubdir = "release-modrinth-gradle-home", dryRun = dryRun, From 31b32ba7c6aae2be939abe468ee9d715708c093e Mon Sep 17 00:00:00 2001 From: THEROER Date: Fri, 10 Jul 2026 21:50:22 +0300 Subject: [PATCH 36/39] fix(build-logic): tolerate 409 on javadoc upload (resumable) The javadoc zip uploads to a stable latest/ path plus a versioned path. The immutable releases repo returns 409 when latest/ already holds a prior release's zip, which failed the whole release on a step that is effectively done. Treat 409 as a skip (warn, continue) on every javadoc target, so a resumed release does not die on javadoc and the versioned path still uploads. --- .../build/release/MagicUtilsReleasePublishTasks.kt | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleasePublishTasks.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleasePublishTasks.kt index d0f9e6d1..fe759019 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleasePublishTasks.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleasePublishTasks.kt @@ -260,10 +260,16 @@ abstract class UploadJavadocTask : DefaultTask() { .PUT(HttpRequest.BodyPublishers.ofFile(zip.toPath())) authHeader?.let { builder.header("Authorization", it) } val response = client.send(builder.build(), HttpResponse.BodyHandlers.ofString()) - if (response.statusCode() !in 200..299) { - throw GradleException("Javadoc upload to $url failed (HTTP ${response.statusCode()}): ${response.body()}") + when { + response.statusCode() in 200..299 -> logger.lifecycle("Uploaded ${zip.name} -> $url") + // The immutable releases repo rejects overwriting an existing path + // with 409. The stable `latest` path already holds a prior release's + // zip, and a re-run re-uploads the versioned path; neither is fatal — + // the docs site keeps serving what is there. Warn and carry on so a + // resumed release does not fail on javadoc that is effectively done. + response.statusCode() == 409 -> logger.warn("Javadoc path already published (HTTP 409, skipping overwrite): $url") + else -> throw GradleException("Javadoc upload to $url failed (HTTP ${response.statusCode()}): ${response.body()}") } - logger.lifecycle("Uploaded ${zip.name} -> $url") } } } From 8aaac109b287499ff519e951257abcb3d31c57cd Mon Sep 17 00:00:00 2001 From: THEROER Date: Sat, 11 Jul 2026 01:38:08 +0300 Subject: [PATCH 37/39] fix(fabric-bundle): inline messaging module on all branches The messaging package was absent from the 26.x (java25) fabric bundle: it was never listed in the bundle plugin and reached the <26 bundle only transitively via commands-fabric's api(:messaging) through Loom's namedElements fat jar. The 26.x bundle is built from bundleShadow alone (the jiJRemap path drops transitive api project deps), so messaging silently vanished from the shipped java25 jar. Add :messaging to bundleLibProjects so it is inlined on every branch, matching how the neoforge/jvm bundles list it explicitly. --- .../build/module/MagicUtilsFabricBundlePlugin.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsFabricBundlePlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsFabricBundlePlugin.kt index fff35d0e..1e065c56 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsFabricBundlePlugin.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsFabricBundlePlugin.kt @@ -44,6 +44,15 @@ class MagicUtilsFabricBundlePlugin : Plugin { project(":commands-brigadier"), project(":core"), project(":diagnostics"), + // Platform-neutral messaging runtime (MessageBus + plugin-messaging + // transport). Must be listed explicitly: on <26 it used to arrive + // only transitively via commands-fabric's `api(":messaging")` through + // Loom's namedElements fat jar, but the 26.x bundle is built from + // `bundleShadow` alone (the jiJRemap path drops transitive api project + // deps), so the messaging package silently vanished from the 26.x + // (java25) bundle. Adding it here inlines it on every branch, the same + // way the neoforge/jvm bundles list `:messaging` explicitly. + project(":messaging"), // Jackson (its only external dep) is already bundled via config-yaml/toml. project(":http-client") ) From dae0bb9824bb050f7af2b82ae64b82305789a9b1 Mon Sep 17 00:00:00 2001 From: THEROER Date: Sat, 11 Jul 2026 01:43:10 +0300 Subject: [PATCH 38/39] feat(build-logic): publish common modules bare, +java only for bundles Plain library modules are byte-identical across Java levels (the per-level diffs are javac codegen artifacts, not behaviour), so publishing three near-duplicate +java17/21/25 copies was redundant. Only bundles genuinely differ per level (their shaded dependency set tracks the Minecraft branch). Introduce magicUtilsModuleIsBundle (artifact id ends in -bundle) and magicUtilsPublishedModuleVersion: bundles get +java, plain modules get the bare . The root build stamps project.version per module via this rule, and every consumer plugin resolves the same coordinate shape, so publish and consume agree by construction. A resumed release with -Pskip_existing skips the already-uploaded bare module on the second and third Java-level representatives. The release smoke/verify poll the smoke artifact's real coordinate (magicutils-core, now bare) instead of a java-suffixed one that would 404. --- .../consumer/MagicUtilsConsumerExtension.kt | 11 ++-- .../MagicUtilsConsumerFabricPlugin.kt | 2 +- .../MagicUtilsConsumerNeoForgePlugin.kt | 2 +- .../build/release/MagicUtilsReleaseModel.kt | 11 ++-- .../build/release/MagicUtilsReleaseTasks.kt | 19 ++++--- .../target/MagicUtilsTargetConventions.kt | 57 ++++++++++++------- .../test/kotlin/MagicUtilsReleaseModelTest.kt | 29 +++++++++- build.gradle.kts | 32 ++++++++--- 8 files changed, 114 insertions(+), 49 deletions(-) diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerExtension.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerExtension.kt index 4af0eb1c..987a52a2 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerExtension.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerExtension.kt @@ -394,7 +394,7 @@ internal fun magicUtilsModuleCoordinate( module: String, version: String, target: MagicUtilsTargetExtension, -): String = "dev.ua.theroer:$module:${target.publishedVersion(version)}" +): String = "dev.ua.theroer:$module:${target.publishedVersion(module, version)}" /** * Exposes the resolved target's facts as extra properties so consumer build @@ -408,8 +408,9 @@ internal fun magicUtilsModuleCoordinate( * - `magicutilsIsDeobfuscated` — Boolean * - `magicutilsClassifier` — e.g. `mc26` * - `magicutilsPublishedVersion` — the base `magicutils_version` with the - * target's `+` suffix (e.g. `1.22.0+26.2`), for the rare coordinate - * a script must build by hand (a classifier-less bundle jar). + * bundle's `+java` suffix (e.g. `1.27.1+java25`), for the rare coordinate a + * script must build by hand (a classifier-less bundle jar). Bundles keep the + * suffix; plain modules are published bare, so this fact is bundle-shaped. */ internal fun Project.exposeMagicUtilsTargetFacts(target: MagicUtilsTargetExtension) { val base = magicUtilsConsumerExtension().magicutilsVersion.get() @@ -418,7 +419,9 @@ internal fun Project.exposeMagicUtilsTargetFacts(target: MagicUtilsTargetExtensi extensions.extraProperties.set("magicutilsJavaVersion", target.java.get()) extensions.extraProperties.set("magicutilsIsDeobfuscated", target.isDeobfuscated) extensions.extraProperties.set("magicutilsClassifier", target.mcClassifier) - extensions.extraProperties.set("magicutilsPublishedVersion", target.publishedVersion(base)) + // This fact is consumed only for bundle jars, which retain the +java + // coordinate; pass a bundle-shaped name so the discriminator keeps the suffix. + extensions.extraProperties.set("magicutilsPublishedVersion", target.publishedVersion("magicutils-bundle", base)) // Optional per-platform facts (not every targets.properties defines them). target.neoforge.orNull?.let { extensions.extraProperties.set("magicutilsNeoforgeVersion", it) } } diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerFabricPlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerFabricPlugin.kt index 3ad3b583..40f77b6c 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerFabricPlugin.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerFabricPlugin.kt @@ -64,7 +64,7 @@ class MagicUtilsConsumerFabricPlugin : Plugin { // for compile/dev-runtime; on deobfuscated 26.x there is no remap, so the // classifier-less jar is itself the fat, self-contained artifact used for // both publish and dev runtime. - val version = target.publishedVersion(consumer.magicutilsVersion.get()) + val version = target.publishedVersion("magicutils-fabric-bundle", consumer.magicutilsVersion.get()) val module = "dev.ua.theroer:magicutils-fabric-bundle:$version" val shippedDep = module val fatDep = if (target.isDeobfuscated) shippedDep else "$module:dev" diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerNeoForgePlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerNeoForgePlugin.kt index 2aaa6fd5..2c069f6c 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerNeoForgePlugin.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerNeoForgePlugin.kt @@ -68,7 +68,7 @@ class MagicUtilsConsumerNeoForgePlugin : Plugin { val embed = resolveEmbedMode(consumer.embedMode.get(), ConsumerLoader.NEOFORGE) if (embed == EmbedMode.JAR_IN_JAR) { val bundleCoordinate = project.provider { - val version = target.publishedVersion(consumer.magicutilsVersion.get()) + val version = target.publishedVersion("magicutils-neoforge-bundle", consumer.magicutilsVersion.get()) project.dependencies.create("dev.ua.theroer:magicutils-neoforge-bundle:$version") } // moddev registers `jarJar` at apply time, but hook it via diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseModel.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseModel.kt index 2d1158f7..192f8a0b 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseModel.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseModel.kt @@ -61,11 +61,12 @@ internal fun bumpGradleVersion(gradlePropertiesText: String, version: SemanticVe /** * URL of the POM smoke-tested after a publish, per [MagicUtilsPublishingSpec]. * - * [versionCoordinate] is the FULL published coordinate, including the target - * suffix the target plugin appends to `project.version` (e.g. `1.26.0+java21`). - * Passing a bare `X.Y.Z` here would point at a POM that is never published — the - * library only ships per-target coordinates — so the smoke poll would 404 - * forever. The `+` is percent-encoded for the HTTP request. + * [versionCoordinate] is the smoke artifact's ACTUAL published coordinate: + * `X.Y.Z` for a plain library module (which now ships bare) or `X.Y.Z+java` + * for a bundle. The caller applies the module-vs-bundle rule + * (magicUtilsPublishedModuleVersion) before calling, so this points at a POM that + * is really published and the smoke poll does not 404 forever. The `+` (if any) + * is percent-encoded for the HTTP request. */ internal fun MagicUtilsPublishingSpec.smokeArtifactUrl(versionCoordinate: String): String { val groupPath = group.replace('.', '/') diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseTasks.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseTasks.kt index 8b87d1ce..bc5774a4 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseTasks.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseTasks.kt @@ -2,7 +2,7 @@ package dev.ua.theroer.magicutils.build.release import dev.ua.theroer.magicutils.build.publish.* import dev.ua.theroer.magicutils.build.support.findMagicUtilsModrinthToken -import dev.ua.theroer.magicutils.build.target.javaSuffixedCoordinate +import dev.ua.theroer.magicutils.build.target.magicUtilsPublishedModuleVersion import org.gradle.api.DefaultTask import org.gradle.api.Project @@ -79,12 +79,14 @@ internal fun registerReleaseTasks( project.tasks.register("smokeTest", SmokeTestTask::class.java) { task -> task.group = RELEASE_GROUP task.description = "Poll the published POM for -Pversion until it appears (or times out)." - // The library ships only per-Java coordinates (`X.Y.Z+java`); a bare - // X.Y.Z POM is never published. Poll the default target's Java level as - // the canonical "Maven is up" signal. + // Poll the smoke artifact's actual published coordinate. It is a plain + // library module (magicutils-core), which now ships bare (`X.Y.Z`); a + // bundle would instead carry `+java`. magicUtilsPublishedModuleVersion + // applies that rule so the poll targets a POM that is really published. task.artifactUrl.set(project.provider { val base = SemanticVersion.parse(versionProvider.get()).toString() - publishingSpec.smokeArtifactUrl(javaSuffixedCoordinate(base, defaultTargetJava)) + val coordinate = magicUtilsPublishedModuleVersion(publishingSpec.smokeArtifact, base, defaultTargetJava) + publishingSpec.smokeArtifactUrl(coordinate) }) task.notCompatibleWithConfigurationCache("Performs network polling.") } @@ -95,11 +97,12 @@ internal fun registerReleaseTasks( "(strict fail unless -Preport)." task.requestedVersion.set(versionProvider) task.gradlePropertiesText.set(project.provider { gradlePropertiesFile.readText() }) - // Same java-suffixed coordinate the smoke poll uses (the only one that is - // actually published), so the two checks agree on what "Maven has it" means. + // Same coordinate the smoke poll uses (the one actually published for the + // smoke artifact), so the two checks agree on what "Maven has it" means. task.mavenPomUrl.set(project.provider { val base = SemanticVersion.parse(versionProvider.get()).toString() - publishingSpec.smokeArtifactUrl(javaSuffixedCoordinate(base, defaultTargetJava)) + val coordinate = magicUtilsPublishedModuleVersion(publishingSpec.smokeArtifact, base, defaultTargetJava) + publishingSpec.smokeArtifactUrl(coordinate) }) task.modrinthProjectId.set(project.provider { modrinthProjectId }) task.modrinthToken.set(project.provider { project.findMagicUtilsModrinthToken() }) diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/target/MagicUtilsTargetConventions.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/target/MagicUtilsTargetConventions.kt index 5c5b72de..1b943ffb 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/target/MagicUtilsTargetConventions.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/target/MagicUtilsTargetConventions.kt @@ -32,31 +32,50 @@ val MagicUtilsTargetExtension.mcClassifier: String get() = "mc${libraryMinecraft.get().substringBeforeLast('.')}" /** - * Published MagicUtils version for [baseVersion] on this target: - * `+java` (e.g. `1.25.0+java21`). + * Bundle artifact ids end in `-bundle` (`magicutils-fabric-bundle`, + * `magicutils-bukkit-bundle`, ...). Only the five bundle plugins produce them, + * so this suffix is the reliable discriminator — on both the publish side (the + * project's artifact id) and the consumer side (the requested module name) — + * between a bundle and a plain library module, without either side importing the + * publish-category extension. + */ +fun magicUtilsModuleIsBundle(moduleName: String): Boolean = moduleName.endsWith("-bundle") + +/** + * Published MagicUtils coordinate version for [moduleName] built at [javaLevel]. * - * Empirically MagicUtils' compiled bytecode depends only on the Java level, not - * on the Minecraft version: the whole library (core/config/commands/lang, the - * platform modules, even the Fabric mod's classes) is byte-identical between two - * targets that share a Java level (e.g. +1.21.10 vs +1.21.11 differed in 0 - * classes) and differs wholesale only across Java levels (major 65 vs 69). So a - * per-Minecraft coordinate published five near-duplicate copies where three real - * variants exist — one per Java level (17 / 21 / 25). obf/deobf is not a second - * axis: it is a function of the Java level (26.x is Java 25 + deobfuscated). + * Two kinds of module, two coordinate shapes: + * - **Bundles** (`*-bundle`) are fat jars whose shaded dependency set genuinely + * differs per Java level / Minecraft branch (the 1.20.x, 1.21.x and 26.x + * bundles are not byte-identical), so they keep the `+java` coordinate + * — one real variant per Java level. + * - **Plain library modules** (core/config/commands/lang, the platform and + * fabric modules) are byte-identical across Java levels once the class-file + * version word is normalized: the per-level diffs are pure javac codegen + * artifacts, not behaviour. Publishing three near-duplicate `+java17/21/25` + * copies was redundant, so they now publish once under the **bare** base + * version. A `+java17`-compiled class loads fine on any JRE >= 17, so a + * consumer on Java 21/25 resolving the bare coordinate runs it unchanged. * - * Consumers pass the bare base version (`magicutils_version=1.25.0`); the - * consumer plugins add `+java` from the resolved target's Java level, so the - * coordinate a consumer resolves always matches one that was published. Fabric - * mods pin their runtime Minecraft through `fabric.mod.json` (a version range), - * not through the Maven coordinate. + * Consumers pass the bare base version (`magicutils_version=1.27.1`); this is the + * single place that decides whether the resolved coordinate carries `+java`, + * so the publish side and every consumer plugin agree by construction. + */ +fun magicUtilsPublishedModuleVersion(moduleName: String, baseVersion: String, javaLevel: Int): String = + if (magicUtilsModuleIsBundle(moduleName)) javaSuffixedCoordinate(baseVersion, javaLevel) else baseVersion + +/** + * Published MagicUtils version for a [moduleName] on this target. Thin wrapper + * over [magicUtilsPublishedModuleVersion] that supplies the target's Java level; + * consumer plugins call this so the module-vs-bundle rule lives in one place. */ -fun MagicUtilsTargetExtension.publishedVersion(baseVersion: String): String = - javaSuffixedCoordinate(baseVersion, java.get()) +fun MagicUtilsTargetExtension.publishedVersion(moduleName: String, baseVersion: String): String = + magicUtilsPublishedModuleVersion(moduleName, baseVersion, java.get()) /** * Pure formatter for the `+java` published coordinate. The `+java` - * suffix format lives here as one function so no caller (publishedVersion, the - * Modrinth bundle file name, the release smoke URL) re-spells it by hand. + * suffix format lives here as one function so no caller (the module-version rule, + * the Modrinth bundle file name, the release smoke URL) re-spells it by hand. */ fun javaSuffixedCoordinate(baseVersion: String, javaLevel: Int): String = "$baseVersion+java$javaLevel" diff --git a/build-logic/src/test/kotlin/MagicUtilsReleaseModelTest.kt b/build-logic/src/test/kotlin/MagicUtilsReleaseModelTest.kt index f0817267..5678e4db 100644 --- a/build-logic/src/test/kotlin/MagicUtilsReleaseModelTest.kt +++ b/build-logic/src/test/kotlin/MagicUtilsReleaseModelTest.kt @@ -9,6 +9,8 @@ import org.junit.jupiter.api.Test import dev.ua.theroer.magicutils.build.release.* import dev.ua.theroer.magicutils.build.publish.* import dev.ua.theroer.magicutils.build.target.javaSuffixedCoordinate +import dev.ua.theroer.magicutils.build.target.magicUtilsModuleIsBundle +import dev.ua.theroer.magicutils.build.target.magicUtilsPublishedModuleVersion class MagicUtilsReleaseModelTest { @@ -55,9 +57,8 @@ class MagicUtilsReleaseModelTest { repoUrl = "https://maven.theroer.dev/releases", smokeArtifact = "magicutils-core", ) - // The library ships only per-Java coordinates; the smoke URL must target - // one of them (`+` percent-encoded), not a bare X.Y.Z POM that is never - // published (which would 404 forever). + // A bundle coordinate carries `+java`; the smoke URL percent-encodes the + // `+` for the HTTP request. assertEquals( "https://maven.theroer.dev/releases/dev/ua/theroer/magicutils-core/" + "1.26.0%2Bjava21/magicutils-core-1.26.0%2Bjava21.pom", @@ -65,6 +66,28 @@ class MagicUtilsReleaseModelTest { ) } + @Test + fun `only -bundle modules are treated as bundles`() { + assertTrue(magicUtilsModuleIsBundle("magicutils-fabric-bundle")) + assertTrue(magicUtilsModuleIsBundle("magicutils-bukkit-bundle")) + assertTrue(magicUtilsModuleIsBundle("magicutils-neoforge-bundle")) + assertFalse(magicUtilsModuleIsBundle("magicutils-core")) + assertFalse(magicUtilsModuleIsBundle("magicutils-commands")) + // A bundle-like substring that is not the suffix must not match. + assertFalse(magicUtilsModuleIsBundle("magicutils-bundle-utils")) + } + + @Test + fun `published module version bare for libraries, java-suffixed for bundles`() { + // Plain library modules ship one bare coordinate across all Java levels. + assertEquals("1.27.1", magicUtilsPublishedModuleVersion("magicutils-core", "1.27.1", 17)) + assertEquals("1.27.1", magicUtilsPublishedModuleVersion("magicutils-core", "1.27.1", 21)) + assertEquals("1.27.1", magicUtilsPublishedModuleVersion("magicutils-core", "1.27.1", 25)) + // Bundles keep the +java coordinate, one real variant per Java level. + assertEquals("1.27.1+java17", magicUtilsPublishedModuleVersion("magicutils-fabric-bundle", "1.27.1", 17)) + assertEquals("1.27.1+java25", magicUtilsPublishedModuleVersion("magicutils-fabric-bundle", "1.27.1", 25)) + } + @Test fun `validateReleaseVersion enforces monotonic increase and no dup tag`() { val current = SemanticVersion(1, 21, 5) diff --git a/build.gradle.kts b/build.gradle.kts index 42d3fa82..3274188a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,22 +7,38 @@ plugins { } val baseVersion = (project.properties["version"] as String?) ?: "0.0.0" -// Version per Java level: +java (e.g. 1.25.0+java21). MagicUtils' -// compiled bytecode depends only on the Java level, not the Minecraft version, -// so several Minecraft targets share one coordinate (mc1205/mc12110/mc12111 are -// all +java21). The consumer plugins mirror this exact suffix from their -// resolved target's Java level (see publishedVersion), so downstream builds keep -// declaring the bare base version and always resolve a coordinate that exists. +// Coordinate version is module-aware (see magicUtilsPublishedModuleVersion): +// - bundles (`*-bundle`) keep `+java` — their shaded dependency set +// genuinely differs per Java level / Minecraft branch; +// - plain library modules publish the BARE `` — they are byte-identical +// across Java levels (the per-level diffs are javac codegen artifacts), so a +// single coordinate replaces the former three near-duplicate `+java17/21/25` +// copies. A resumed release with -Pskip_existing HEADs each POM, so the second +// and third Java-level representatives skip the already-uploaded bare module. +// The consumer plugins mirror this exact rule from their resolved target's Java +// level (see publishedVersion), so downstream builds keep declaring the bare base +// version and always resolve a coordinate that exists. val resolvedContext = gradle.extensions.extraProperties.get("magicutilsMatrixResolved") as dev.ua.theroer.magicutils.build.matrix.MagicUtilsMatrixResolvedContext -val targetVersion = "$baseVersion+java${resolvedContext.target.java}" +val javaLevel = resolvedContext.target.java + +val namingSpec = gradle.extensions.extraProperties + .let { if (it.has("magicutilsModuleNaming")) it.get("magicutilsModuleNaming") else null } + as? dev.ua.theroer.magicutils.build.module.MagicUtilsModuleNamingSpec + ?: dev.ua.theroer.magicutils.build.module.MagicUtilsModuleNamingSpec() val publishingSpec = gradle.extensions.extraProperties.get("magicutilsPublishingSpec") as MagicUtilsPublishingSpec allprojects { group = publishingSpec.group - version = targetVersion + // Keyed on the project's published artifact id so `-bundle` projects keep the + // Java suffix and library modules go bare. The root project has no artifact id + // of its own; its version is unused for publication but set for consistency. + val artifactId = namingSpec.moduleName(name) + version = dev.ua.theroer.magicutils.build.target.magicUtilsPublishedModuleVersion( + artifactId, baseVersion, javaLevel, + ) } val reflectionPatterns = listOf( From 39be032277cf92d6217ff27a6c9fb754dfa30891 Mon Sep 17 00:00:00 2001 From: THEROER Date: Sat, 11 Jul 2026 01:43:26 +0300 Subject: [PATCH 39/39] feat(build-logic): gate releases to the release branch (default main) Publishing off a feature branch pins the git tag and the immutable Maven coordinate to a commit that may never land on the release branch as written, so the release history diverges from it. releasePreflight now checks the current branch against a configurable releaseBranch (default main; null disables the gate) and fails fast before any tag or upload. Configurable via release { releaseBranch = ... }, overridable per invocation with -Prelease.branch=, and bypassable once for a genuine off-branch hotfix with -Prelease.allowAnyBranch=true. releaseBranchViolation is a pure function so the gate is unit-tested without a git checkout. --- .../build/matrix/MagicUtilsReleaseDsl.kt | 8 ++++ .../build/release/MagicUtilsReleaseSpec.kt | 43 +++++++++++++++++++ .../build/release/MagicUtilsReleaseTasks.kt | 30 +++++++++++-- .../test/kotlin/MagicUtilsReleaseModelTest.kt | 27 ++++++++++++ 4 files changed, 104 insertions(+), 4 deletions(-) diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsReleaseDsl.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsReleaseDsl.kt index 7c750b12..039c6d75 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsReleaseDsl.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsReleaseDsl.kt @@ -29,6 +29,13 @@ open class MagicUtilsReleaseDsl { var publishJavadoc: Boolean = true var verify: Boolean = true + /** + * Branch a release may run from (releasePreflight gate). Defaults to `main`; + * set to `null` to release from any branch. Override per invocation with + * `-Prelease.branch=`, or bypass once with `-Prelease.allowAnyBranch=true`. + */ + var releaseBranch: String? = "main" + internal fun toSpec(): MagicUtilsReleaseSpec = MagicUtilsReleaseSpec( validateVersion = validateVersion, validateBuild = validateBuild, @@ -39,5 +46,6 @@ open class MagicUtilsReleaseDsl { publishModrinth = publishModrinth, publishJavadoc = publishJavadoc, verify = verify, + releaseBranch = releaseBranch, ) } diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseSpec.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseSpec.kt index 3ab7246e..b919822b 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseSpec.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseSpec.kt @@ -30,8 +30,41 @@ data class MagicUtilsReleaseSpec( val publishJavadoc: Boolean = true, /** Verify version consistency across all sources after publishing. */ val verify: Boolean = true, + /** + * Branch a release is allowed to run from (checked in releasePreflight). + * Publishing off a feature branch means the tag and the immutable Maven + * coordinate point at a commit that may never land on the release branch as + * written, so the release history diverges from it. Defaults to `main`; set + * to `null` to disable the gate (a fork with a different default branch, or + * one that intentionally releases from anywhere). Overridable per invocation + * with `-Prelease.branch=`, and bypassable once with + * `-Prelease.allowAnyBranch=true` for a genuine off-branch hotfix. + */ + val releaseBranch: String? = "main", ) +/** + * Validates that a release may run from [currentBranch]. + * + * Returns null when allowed, or a human-readable reason when blocked. Pure so the + * gate is unit-testable without a git checkout. The gate is a no-op when + * [requiredBranch] is null (disabled), when [allowAnyBranch] is set (explicit + * bypass), or when the current branch cannot be determined (detached HEAD / no + * git) — in those two last cases the caller logs a warning rather than guessing. + */ +fun releaseBranchViolation( + requiredBranch: String?, + currentBranch: String?, + allowAnyBranch: Boolean, +): String? { + if (requiredBranch == null || allowAnyBranch) return null + if (currentBranch.isNullOrBlank()) return null + if (currentBranch == requiredBranch) return null + return "Release must run from branch '$requiredBranch' but the current branch is " + + "'$currentBranch'. Merge to '$requiredBranch' first, or pass " + + "-Prelease.allowAnyBranch=true to override (e.g. an off-branch hotfix)." +} + /** A step of the release, in run order, with whether the spec enabled it. */ data class MagicUtilsReleaseStep(val name: String, val enabled: Boolean) @@ -72,6 +105,15 @@ fun applyReleaseOverrides( } } ?: current + // -Prelease.branch= overrides the release branch; passing it empty + // (`-Prelease.branch=`) disables the gate (parity with a null DSL default). + // Absent property keeps the DSL value; present-but-empty means null. + val branch = if (properties.containsKey("release.branch")) { + properties["release.branch"]?.trim()?.ifEmpty { null } + } else { + spec.releaseBranch + } + return spec.copy( validateVersion = override("validateVersion", spec.validateVersion), validateBuild = override("validate", spec.validateBuild), @@ -82,5 +124,6 @@ fun applyReleaseOverrides( publishModrinth = override("modrinth", spec.publishModrinth), publishJavadoc = override("javadoc", spec.publishJavadoc), verify = override("verify", spec.verify), + releaseBranch = branch, ) } diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseTasks.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseTasks.kt index bc5774a4..10c99d3b 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseTasks.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseTasks.kt @@ -52,11 +52,18 @@ internal fun registerReleaseTasks( ?: throw org.gradle.api.GradleException("Pass the release version via -Pversion=X.Y.Z.") } + // The effective spec: DSL defaults with -Prelease.=... overrides applied. + // Computed up front so the preflight branch gate can read the release branch. + val effectiveSpec = applyReleaseOverrides(releaseSpec, stringGradleProperties(project)) + val allowAnyBranch = project.hasProperty("release.allowAnyBranch") + project.tasks.register("releasePreflight", ReleasePreflightTask::class.java) { task -> task.group = RELEASE_GROUP task.description = "Validate a release version against gradle.properties and existing tags (no changes)." task.requestedVersion.set(versionProvider) task.gradlePropertiesText.set(project.provider { gradlePropertiesFile.readText() }) + effectiveSpec.releaseBranch?.let { task.releaseBranch.set(it) } + task.allowAnyBranch.set(allowAnyBranch) task.notCompatibleWithConfigurationCache("Queries git/gh for existing tags.") } @@ -110,9 +117,6 @@ internal fun registerReleaseTasks( task.notCompatibleWithConfigurationCache("Queries git and the network.") } - // The effective spec: DSL defaults with -Prelease.=... overrides applied. - val effectiveSpec = applyReleaseOverrides(releaseSpec, stringGradleProperties(project)) - project.tasks.register("releaseTag", ReleaseTagTask::class.java) { task -> task.group = RELEASE_GROUP task.description = "Create the vX.Y.Z git tag locally; push to origin only when push is enabled." @@ -208,19 +212,37 @@ abstract class ReleasePreflightTask @Inject constructor( ) : DefaultTask() { @get:Input abstract val requestedVersion: Property @get:Input abstract val gradlePropertiesText: Property + /** Branch a release must run from; unset (Optional) disables the gate. */ + @get:[Input Optional] abstract val releaseBranch: Property + /** -Prelease.allowAnyBranch bypass. */ + @get:Input abstract val allowAnyBranch: Property @TaskAction fun run() { val requested = SemanticVersion.parse(requestedVersion.get()) val current = readGradleVersion(gradlePropertiesText.get()) + // Branch gate: publishing off the wrong branch pins the tag and the + // immutable Maven coordinate to a commit that may never land on the + // release branch as written. Checked before the version gate so an + // off-branch attempt fails fast without touching tags. + val currentBranch = runCatching { execOps.capture("git", "rev-parse", "--abbrev-ref", "HEAD").trim() } + .getOrNull() + ?.takeIf { it.isNotEmpty() && it != "HEAD" } + releaseBranchViolation(releaseBranch.orNull, currentBranch, allowAnyBranch.get()) + ?.let { throw org.gradle.api.GradleException(it) } + if (currentBranch == null && releaseBranch.orNull != null && !allowAnyBranch.get()) { + logger.warn("Release branch gate: could not determine current branch (detached HEAD?); skipping the check.") + } + val localTags = runCatching { execOps.capture("git", "tag", "--list", "v*") } .getOrDefault("") .lineSequence().map(String::trim).filter(String::isNotEmpty).toSet() val released = localTags.mapNotNull(SemanticVersion::fromTag).maxOrNull() validateReleaseVersion(requested, current, released, localTags) - logger.lifecycle("Preflight OK: $requested (current $current, latest released ${released ?: "none"}).") + val branchNote = releaseBranch.orNull?.let { " on branch '$it'" } ?: "" + logger.lifecycle("Preflight OK: $requested$branchNote (current $current, latest released ${released ?: "none"}).") } } diff --git a/build-logic/src/test/kotlin/MagicUtilsReleaseModelTest.kt b/build-logic/src/test/kotlin/MagicUtilsReleaseModelTest.kt index 5678e4db..1fecb739 100644 --- a/build-logic/src/test/kotlin/MagicUtilsReleaseModelTest.kt +++ b/build-logic/src/test/kotlin/MagicUtilsReleaseModelTest.kt @@ -1,6 +1,7 @@ import org.gradle.api.GradleException import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertTrue @@ -88,6 +89,32 @@ class MagicUtilsReleaseModelTest { assertEquals("1.27.1+java25", magicUtilsPublishedModuleVersion("magicutils-fabric-bundle", "1.27.1", 25)) } + @Test + fun `release branch gate blocks off-branch and allows main`() { + // On the required branch: allowed. + assertNull(releaseBranchViolation("main", "main", allowAnyBranch = false)) + // Off branch: blocked with a reason. + assertNotNull(releaseBranchViolation("main", "feature/x", allowAnyBranch = false)) + // Explicit bypass: allowed even off-branch. + assertNull(releaseBranchViolation("main", "feature/x", allowAnyBranch = true)) + // Gate disabled (null required branch): allowed anywhere. + assertNull(releaseBranchViolation(null, "feature/x", allowAnyBranch = false)) + // Unknown branch (detached HEAD / no git): not blocked (caller warns). + assertNull(releaseBranchViolation("main", null, allowAnyBranch = false)) + } + + @Test + fun `release branch override - absent keeps default, empty disables, value replaces`() { + val spec = MagicUtilsReleaseSpec() + assertEquals("main", spec.releaseBranch) + // Absent property -> keeps DSL default. + assertEquals("main", applyReleaseOverrides(spec, emptyMap()).releaseBranch) + // Present-but-empty -> disables the gate. + assertNull(applyReleaseOverrides(spec, mapOf("release.branch" to "")).releaseBranch) + // Named value -> replaces. + assertEquals("release/2.x", applyReleaseOverrides(spec, mapOf("release.branch" to "release/2.x")).releaseBranch) + } + @Test fun `validateReleaseVersion enforces monotonic increase and no dup tag`() { val current = SemanticVersion(1, 21, 5)