From 9c79487ca7af66f360873a15dcd0dd652433ad96 Mon Sep 17 00:00:00 2001 From: THEROER Date: Sat, 4 Jul 2026 14:00:26 +0300 Subject: [PATCH 01/17] fix(ci): declare the version input on publish-maven release.yml dispatches publish-maven.yml with -f version=..., but the workflow declared no inputs, so the dispatch failed with HTTP 422. Declare an optional version input (informational; the published version is resolved from the checked-out gradle.properties). --- .github/workflows/publish-maven.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish-maven.yml b/.github/workflows/publish-maven.yml index 4ff31b49..7094fd49 100644 --- a/.github/workflows/publish-maven.yml +++ b/.github/workflows/publish-maven.yml @@ -3,8 +3,15 @@ name: publish-maven on: # Dispatched by release.yml after the tag is created (documentation moved to # the separate MagicUtilsWebsite project, so there is no docs workflow to chain - # from anymore). Also runnable manually. + # from anymore). Also runnable manually. The version is resolved from the + # checked-out gradle.properties; the input exists only so release.yml can pass + # it through for traceability without a dispatch error. workflow_dispatch: + inputs: + version: + description: "Release version being published (informational)" + required: false + default: "" # Publishing targets the self-hosted Reposilite (maven.theroer.dev) over HTTPS, # so no repository write access to this GitHub repo is needed anymore. From 4b3fd0f0348883e31558c536de953098a4d34915 Mon Sep 17 00:00:00 2001 From: THEROER Date: Sun, 5 Jul 2026 23:01:50 +0300 Subject: [PATCH 02/17] refactor(build-logic): move plugins into dev.ua.theroer.magicutils.build packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reorganise the build-logic sources from the flat top-level Kotlin package into namespaced packages (matrix, module, release, smoke, support, target, publish), add the module-naming and publishing spec helpers, and update the build-logic build script/properties accordingly. No behaviour change — the applied plugin ids are unchanged. --- build-logic/build.gradle.kts | 71 ++- build-logic/gradle.properties | 10 +- .../src/main/kotlin/MagicUtilsMatrixModel.kt | 219 -------- .../main/kotlin/MagicUtilsMatrixRootPlugin.kt | 226 --------- .../src/main/kotlin/MagicUtilsSmokeModel.kt | 132 ----- .../build/matrix/MagicUtilsMatrixModel.kt | 102 ++++ .../matrix/MagicUtilsMatrixRootPlugin.kt | 467 ++++++++++++++++++ .../matrix}/MagicUtilsMatrixSettingsPlugin.kt | 11 + .../build/matrix}/MagicUtilsTargetPlugin.kt | 23 +- .../MagicUtilsAnnotationProcessingPlugin.kt | 2 + .../module}/MagicUtilsBukkitBundlePlugin.kt | 5 + .../build/module}/MagicUtilsCommonPlugin.kt | 4 + .../module}/MagicUtilsFabricBundlePlugin.kt | 61 ++- .../module}/MagicUtilsFabricModulePlugin.kt | 45 +- .../module}/MagicUtilsJavaLibraryPlugin.kt | 31 +- .../module/MagicUtilsModuleNamingSpec.kt | 15 + .../module}/MagicUtilsNeoForgeBundlePlugin.kt | 62 ++- .../module}/MagicUtilsShadedModulePlugin.kt | 2 + .../build/module}/MagicUtilsShadowPlugin.kt | 2 + .../publish}/MagicUtilsPublishingPlugin.kt | 30 +- .../build/publish/MagicUtilsPublishingSpec.kt | 33 ++ .../build/release}/MagicUtilsModrinthDsl.kt | 2 + .../build/release}/MagicUtilsModrinthModel.kt | 2 + .../build/release}/MagicUtilsModrinthTasks.kt | 2 + .../build/release}/MagicUtilsReleaseModel.kt | 4 + .../build/release}/MagicUtilsReleaseTasks.kt | 4 + .../build/smoke}/MagicUtilsSmokeDsl.kt | 19 +- .../build/smoke/MagicUtilsSmokeModel.kt | 237 +++++++++ .../build/smoke}/MagicUtilsSmokeTasks.kt | 71 ++- .../support}/MagicUtilsProjectExtensions.kt | 4 + .../support}/MagicUtilsRepositoriesPlugin.kt | 2 + .../target}/MagicUtilsPublishCategory.kt | 4 + 32 files changed, 1234 insertions(+), 670 deletions(-) delete mode 100644 build-logic/src/main/kotlin/MagicUtilsMatrixModel.kt delete mode 100644 build-logic/src/main/kotlin/MagicUtilsMatrixRootPlugin.kt delete mode 100644 build-logic/src/main/kotlin/MagicUtilsSmokeModel.kt create mode 100644 build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixModel.kt create mode 100644 build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixRootPlugin.kt rename build-logic/src/main/kotlin/{ => dev/ua/theroer/magicutils/build/matrix}/MagicUtilsMatrixSettingsPlugin.kt (97%) rename build-logic/src/main/kotlin/{ => dev/ua/theroer/magicutils/build/matrix}/MagicUtilsTargetPlugin.kt (76%) rename build-logic/src/main/kotlin/{ => dev/ua/theroer/magicutils/build/module}/MagicUtilsAnnotationProcessingPlugin.kt (95%) rename build-logic/src/main/kotlin/{ => dev/ua/theroer/magicutils/build/module}/MagicUtilsBukkitBundlePlugin.kt (97%) rename build-logic/src/main/kotlin/{ => dev/ua/theroer/magicutils/build/module}/MagicUtilsCommonPlugin.kt (93%) rename build-logic/src/main/kotlin/{ => dev/ua/theroer/magicutils/build/module}/MagicUtilsFabricBundlePlugin.kt (77%) rename build-logic/src/main/kotlin/{ => dev/ua/theroer/magicutils/build/module}/MagicUtilsFabricModulePlugin.kt (69%) rename build-logic/src/main/kotlin/{ => dev/ua/theroer/magicutils/build/module}/MagicUtilsJavaLibraryPlugin.kt (66%) create mode 100644 build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsModuleNamingSpec.kt rename build-logic/src/main/kotlin/{ => dev/ua/theroer/magicutils/build/module}/MagicUtilsNeoForgeBundlePlugin.kt (53%) rename build-logic/src/main/kotlin/{ => dev/ua/theroer/magicutils/build/module}/MagicUtilsShadedModulePlugin.kt (95%) rename build-logic/src/main/kotlin/{ => dev/ua/theroer/magicutils/build/module}/MagicUtilsShadowPlugin.kt (94%) rename build-logic/src/main/kotlin/{ => dev/ua/theroer/magicutils/build/publish}/MagicUtilsPublishingPlugin.kt (51%) create mode 100644 build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/publish/MagicUtilsPublishingSpec.kt rename build-logic/src/main/kotlin/{ => dev/ua/theroer/magicutils/build/release}/MagicUtilsModrinthDsl.kt (97%) rename build-logic/src/main/kotlin/{ => dev/ua/theroer/magicutils/build/release}/MagicUtilsModrinthModel.kt (97%) rename build-logic/src/main/kotlin/{ => dev/ua/theroer/magicutils/build/release}/MagicUtilsModrinthTasks.kt (99%) rename build-logic/src/main/kotlin/{ => dev/ua/theroer/magicutils/build/release}/MagicUtilsReleaseModel.kt (97%) rename build-logic/src/main/kotlin/{ => dev/ua/theroer/magicutils/build/release}/MagicUtilsReleaseTasks.kt (98%) rename build-logic/src/main/kotlin/{ => dev/ua/theroer/magicutils/build/smoke}/MagicUtilsSmokeDsl.kt (73%) create mode 100644 build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/smoke/MagicUtilsSmokeModel.kt rename build-logic/src/main/kotlin/{ => dev/ua/theroer/magicutils/build/smoke}/MagicUtilsSmokeTasks.kt (74%) rename build-logic/src/main/kotlin/{ => dev/ua/theroer/magicutils/build/support}/MagicUtilsProjectExtensions.kt (95%) rename build-logic/src/main/kotlin/{ => dev/ua/theroer/magicutils/build/support}/MagicUtilsRepositoriesPlugin.kt (97%) rename build-logic/src/main/kotlin/{ => dev/ua/theroer/magicutils/build/target}/MagicUtilsPublishCategory.kt (94%) diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index 69601af8..ef0fb615 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -57,6 +57,23 @@ dependencies { implementation("fabric-loom:fabric-loom.gradle.plugin:${project.property("fabricLoomVersion")}") implementation("net.fabricmc.fabric-loom:net.fabricmc.fabric-loom.gradle.plugin:${project.property("fabricLoomVersion")}") + // jpenilla run-* task plugins, applied by the consumer-* plugins only when a + // consumer opts into `devServer {}`. run-paper carries the Paper+Folia + // runners; run-velocity/run-waterfall carry the proxy runners. Applied via + // their plugin *marker* artifacts (same mechanism as Loom above) so + // `pluginManager.apply(id)` resolves them; their task types are configured + // by name, with no compile-time API import into build-logic. + val runPaperVersion = project.property("runPaperVersion") + val runProxyVersion = project.property("runProxyVersion") + implementation("xyz.jpenilla.run-paper:xyz.jpenilla.run-paper.gradle.plugin:$runPaperVersion") + implementation("xyz.jpenilla.run-velocity:xyz.jpenilla.run-velocity.gradle.plugin:$runPaperVersion") + implementation("xyz.jpenilla.run-waterfall:xyz.jpenilla.run-waterfall.gradle.plugin:$runProxyVersion") + + // ModDevGradle marker, applied by consumer-neoforge via pluginManager.apply(id). + // Its API is not imported into build-logic; the neoForge extension is reached + // reflectively so only the marker (not moddev's types) is needed here. + implementation("net.neoforged.moddev:net.neoforged.moddev.gradle.plugin:${project.property("neoForgeModdevVersion")}") + testImplementation(platform("org.junit:junit-bom:5.11.3")) testImplementation("org.junit.jupiter:junit-jupiter") testRuntimeOnly("org.junit.platform:junit-platform-launcher") @@ -82,59 +99,85 @@ gradlePlugin { plugins { register("magicutilsTarget") { id = "magicutils.target" - implementationClass = "MagicUtilsTargetPlugin" + implementationClass = "dev.ua.theroer.magicutils.build.matrix.MagicUtilsTargetPlugin" } register("magicutilsMatrixSettings") { id = "magicutils.matrix-settings" - implementationClass = "MagicUtilsMatrixSettingsPlugin" + implementationClass = "dev.ua.theroer.magicutils.build.matrix.MagicUtilsMatrixSettingsPlugin" } register("magicutilsMatrixRoot") { id = "magicutils.matrix-root" - implementationClass = "MagicUtilsMatrixRootPlugin" + implementationClass = "dev.ua.theroer.magicutils.build.matrix.MagicUtilsMatrixRootPlugin" } register("magicutilsRepositories") { id = "magicutils.repositories" - implementationClass = "MagicUtilsRepositoriesPlugin" + implementationClass = "dev.ua.theroer.magicutils.build.support.MagicUtilsRepositoriesPlugin" } register("magicutilsCommon") { id = "magicutils.common" - implementationClass = "MagicUtilsCommonPlugin" + implementationClass = "dev.ua.theroer.magicutils.build.module.MagicUtilsCommonPlugin" } register("magicutilsJavaLibrary") { id = "magicutils.java-library" - implementationClass = "MagicUtilsJavaLibraryPlugin" + implementationClass = "dev.ua.theroer.magicutils.build.module.MagicUtilsJavaLibraryPlugin" } register("magicutilsAnnotationProcessing") { id = "magicutils.annotation-processing" - implementationClass = "MagicUtilsAnnotationProcessingPlugin" + implementationClass = "dev.ua.theroer.magicutils.build.module.MagicUtilsAnnotationProcessingPlugin" } register("magicutilsShadow") { id = "magicutils.shadow" - implementationClass = "MagicUtilsShadowPlugin" + implementationClass = "dev.ua.theroer.magicutils.build.module.MagicUtilsShadowPlugin" } register("magicutilsShadedModule") { id = "magicutils.shaded-module" - implementationClass = "MagicUtilsShadedModulePlugin" + implementationClass = "dev.ua.theroer.magicutils.build.module.MagicUtilsShadedModulePlugin" } register("magicutilsPublishing") { id = "magicutils.publishing" - implementationClass = "MagicUtilsPublishingPlugin" + implementationClass = "dev.ua.theroer.magicutils.build.publish.MagicUtilsPublishingPlugin" } register("magicutilsFabricModule") { id = "magicutils.fabric-module" - implementationClass = "MagicUtilsFabricModulePlugin" + implementationClass = "dev.ua.theroer.magicutils.build.module.MagicUtilsFabricModulePlugin" } register("magicutilsFabricBundle") { id = "magicutils.fabric-bundle" - implementationClass = "MagicUtilsFabricBundlePlugin" + implementationClass = "dev.ua.theroer.magicutils.build.module.MagicUtilsFabricBundlePlugin" } register("magicutilsBukkitBundle") { id = "magicutils.bukkit-bundle" - implementationClass = "MagicUtilsBukkitBundlePlugin" + implementationClass = "dev.ua.theroer.magicutils.build.module.MagicUtilsBukkitBundlePlugin" + } + // Consumer-facing plugins for downstream plugins/mods (not MagicUtils' + // own library modules): hide Loom/obf/classifier/toolchain selection. + register("magicutilsConsumerCommon") { + id = "magicutils.consumer-common" + implementationClass = "dev.ua.theroer.magicutils.build.consumer.MagicUtilsConsumerCommonPlugin" + } + register("magicutilsConsumerBukkit") { + id = "magicutils.consumer-bukkit" + implementationClass = "dev.ua.theroer.magicutils.build.consumer.MagicUtilsConsumerBukkitPlugin" + } + register("magicutilsConsumerFabric") { + id = "magicutils.consumer-fabric" + implementationClass = "dev.ua.theroer.magicutils.build.consumer.MagicUtilsConsumerFabricPlugin" + } + register("magicutilsConsumerVelocity") { + id = "magicutils.consumer-velocity" + implementationClass = "dev.ua.theroer.magicutils.build.consumer.MagicUtilsConsumerVelocityPlugin" + } + register("magicutilsConsumerBungee") { + id = "magicutils.consumer-bungee" + implementationClass = "dev.ua.theroer.magicutils.build.consumer.MagicUtilsConsumerBungeePlugin" + } + register("magicutilsConsumerNeoForge") { + id = "magicutils.consumer-neoforge" + implementationClass = "dev.ua.theroer.magicutils.build.consumer.MagicUtilsConsumerNeoForgePlugin" } register("magicutilsNeoForgeBundle") { id = "magicutils.neoforge-bundle" - implementationClass = "MagicUtilsNeoForgeBundlePlugin" + implementationClass = "dev.ua.theroer.magicutils.build.module.MagicUtilsNeoForgeBundlePlugin" } } } diff --git a/build-logic/gradle.properties b/build-logic/gradle.properties index 3ee71dc5..b8cc3ca5 100644 --- a/build-logic/gradle.properties +++ b/build-logic/gradle.properties @@ -1,7 +1,15 @@ shadowVersion=9.3.1 fabricLoomVersion=1.17.13 +# jpenilla run-* task plugins, applied by the consumer-* plugins on devServer{} +# opt-in. run-paper/run-velocity share a version line; run-waterfall lags. +runPaperVersion=3.0.2 +runProxyVersion=2.3.1 + +# ModDevGradle, applied by consumer-neoforge (moddev provides the NeoForge run). +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.0 \ No newline at end of file +pluginsVersion=0.1.3 \ No newline at end of file diff --git a/build-logic/src/main/kotlin/MagicUtilsMatrixModel.kt b/build-logic/src/main/kotlin/MagicUtilsMatrixModel.kt deleted file mode 100644 index 38347b1a..00000000 --- a/build-logic/src/main/kotlin/MagicUtilsMatrixModel.kt +++ /dev/null @@ -1,219 +0,0 @@ -import org.gradle.api.GradleException -import java.io.File -import java.util.Properties - -data class MagicUtilsTargetSpec( - val name: String, - val minecraft: String, - val java: Int, - val yarn: String?, - val loader: String?, - val fabricApi: String?, - val paper: String?, - val miniplaceholdersApi: String?, - val pb4PlaceholderApi: String?, - val neoforge: String?, -) - -data class MagicUtilsPlatformSpec( - val name: String, - val projects: Set, - val disabledTargetPrefixes: Set = emptySet(), -) { - fun isEnabledFor(targetName: String): Boolean = - disabledTargetPrefixes.none(targetName::startsWith) -} - -data class MagicUtilsScenarioSpec( - val name: String, - val platforms: Set, - val description: String = "", -) - -data class MagicUtilsMatrixDefinition( - val targetsFile: String, - val defaultTarget: String, - val commonProjects: Set, - val platforms: Map, - val scenarios: Map, -) - -data class MagicUtilsMatrixResolvedContext( - val definition: MagicUtilsMatrixDefinition, - val target: MagicUtilsTargetSpec, - val availablePlatforms: Set, - val selectedPlatforms: Set, - val includedProjects: Set, - val selectedScenario: String?, - val requestedTaskNames: List, -) - -data class MagicUtilsPublishingSpec( - val group: String, - val repoUrl: String, - val smokeArtifact: String, -) - -/** - * Maps a Gradle subproject name to its published artifact id. By default the - * artifact id is [prefix] + projectName; [overrides] pins specific projects to - * an explicit name. Consumers configure this via the matrix DSL, so the plugin - * core carries no project-specific naming. - */ -data class MagicUtilsModuleNamingSpec( - val prefix: String = "", - val overrides: Map = emptyMap(), -) { - fun moduleName(projectName: String): String = - overrides[projectName] ?: (prefix + projectName) -} - -internal fun normalizeProjectPath(path: String): String = - path.trim().removeSuffix(":").let { - when { - it.isEmpty() -> throw GradleException("Project path must not be empty.") - it.startsWith(":") -> it - else -> ":$it" - } - } - -internal fun normalizeTargetName(rawTargetName: String): String = - rawTargetName.trim().let { - when { - it.isEmpty() -> throw GradleException("Target name must not be empty.") - it.startsWith("mc") -> it - else -> "mc$it" - } - } - -internal fun loadTargetProperties(targetsFile: File): Properties { - if (!targetsFile.isFile) { - throw GradleException("Missing targets file: ${targetsFile.absolutePath}") - } - - return Properties().also { properties -> - targetsFile.inputStream().use(properties::load) - } -} - -/** - * Every target declared in [targetsFile], in file order. A target is any - * `mcXXXX` prefix that has a `.minecraft` value (the one required key — - * see [resolveMagicUtilsTargetSpec]). This is the single source of truth - * the CI build/publish matrices are generated from, so no target list is - * ever duplicated in a workflow. - */ -internal fun loadAllTargetNames(targetsFile: File): List { - val properties = loadTargetProperties(targetsFile) - return properties.stringPropertyNames() - .mapNotNull { key -> key.removeSuffix(".minecraft").takeIf { it != key } } - .distinct() - // Properties has no defined order; sort for stable, reproducible output. - .sorted() -} - -/** One (target, publish-tasks) unit for the CI publish matrix. */ -data class MagicUtilsPublishUnit( - val target: String, - val publishTasks: List, - val suffix: Boolean, -) - -/** - * Which platforms a target supports, honouring each platform's - * [MagicUtilsPlatformSpec.isEnabledFor] (disabled-prefix) rules. - */ -internal fun MagicUtilsMatrixDefinition.availablePlatformsFor(target: String): Set = - 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. - */ -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) - } - } - -/** Minimal JSON array serializer for the matrix outputs (no dependency needed). */ -internal fun List.toMatrixJson(): String = - joinToString(prefix = "[", postfix = "]", separator = ",") { unit -> - """{"target":"${unit.target}",""" + - """"tasks":"${unit.publishTasks.joinToString(" ")}",""" + - """"suffix":${unit.suffix}}""" - } - -internal fun loadPublishingSpec(publishingFile: File): MagicUtilsPublishingSpec { - if (!publishingFile.isFile) { - throw GradleException("Missing publishing file: ${publishingFile.absolutePath}") - } - - val properties = Properties().also { properties -> - publishingFile.inputStream().use(properties::load) - } - - fun requireValue(key: String): String = - properties.getProperty(key)?.trim()?.takeIf(String::isNotEmpty) - ?: throw GradleException( - "Missing or empty '$key' in ${publishingFile.absolutePath}" - ) - - return MagicUtilsPublishingSpec( - group = requireValue("group"), - repoUrl = requireValue("repo.url").trimEnd('/'), - smokeArtifact = requireValue("smoke.artifact"), - ) -} - -internal fun resolveMagicUtilsTargetSpec( - targetsFile: File, - defaultTarget: String, - explicitTarget: String?, -): MagicUtilsTargetSpec { - val properties = loadTargetProperties(targetsFile) - val fallbackTarget = properties.getProperty("target", defaultTarget) - val targetName = normalizeTargetName(explicitTarget ?: fallbackTarget) - - fun requireValue(suffix: String): String = - properties.getProperty("$targetName.$suffix") - ?: throw GradleException( - "Missing '$targetName.$suffix' in ${targetsFile.absolutePath}" - ) - - return MagicUtilsTargetSpec( - name = targetName, - minecraft = requireValue("minecraft"), - java = requireValue("java").toInt(), - yarn = properties.getProperty("$targetName.yarn"), - loader = properties.getProperty("$targetName.loader"), - fabricApi = properties.getProperty("$targetName.fabric_api"), - paper = properties.getProperty("$targetName.paper"), - miniplaceholdersApi = properties.getProperty("$targetName.miniplaceholders_api"), - pb4PlaceholderApi = properties.getProperty("$targetName.pb4_placeholder_api"), - neoforge = properties.getProperty("$targetName.neoforge"), - ) -} - -internal fun capitalizeScenarioToken(value: String): String = - value.split('-', '_', ' ') - .filter { it.isNotBlank() } - .joinToString("") { token -> - token.replaceFirstChar { char -> - if (char.isLowerCase()) { - char.titlecase() - } else { - char.toString() - } - } - } diff --git a/build-logic/src/main/kotlin/MagicUtilsMatrixRootPlugin.kt b/build-logic/src/main/kotlin/MagicUtilsMatrixRootPlugin.kt deleted file mode 100644 index c71ef35a..00000000 --- a/build-logic/src/main/kotlin/MagicUtilsMatrixRootPlugin.kt +++ /dev/null @@ -1,226 +0,0 @@ -import org.gradle.api.GradleException -import org.gradle.api.Plugin -import org.gradle.api.Project - -class MagicUtilsMatrixRootPlugin : Plugin { - override fun apply(project: Project) { - require(project == project.rootProject) { - "magicutils.matrix-root must be applied only to the root project." - } - - val resolvedContext = project.gradle.extensions.extraProperties.properties["magicutilsMatrixResolved"] - as? MagicUtilsMatrixResolvedContext - ?: throw GradleException( - "MagicUtils matrix context is missing. Apply magicutils.matrix-settings in settings.gradle." - ) - - val publishingSpec = project.gradle.extensions.extraProperties.properties["magicutilsPublishingSpec"] - as? MagicUtilsPublishingSpec - ?: throw GradleException( - "MagicUtils publishing spec is missing. Apply magicutils.matrix-settings in settings.gradle." - ) - - registerListBuildMatrixTask(project, resolvedContext) - registerMatrixJsonTasks(project, resolvedContext) - registerScenarioAggregateTasks(project, resolvedContext) - registerSelectedScenarioTasks(project, resolvedContext) - registerPublishCategoryTasks(project) - registerReleaseTasks(project, publishingSpec) - - @Suppress("UNCHECKED_CAST") - val smokeSpecs = project.gradle.extensions.extraProperties.properties["magicutilsSmokeSpecs"] - as? List ?: emptyList() - registerSmokeTasks(project, smokeSpecs.toSmokeCases()) - - val modrinthSpec = project.gradle.extensions.extraProperties.properties["magicutilsModrinthSpec"] - as? ModrinthReleaseSpec - registerModrinthTasks(project, modrinthSpec) - } -} - -/** - * Machine-readable matrix outputs consumed by CI (`fromJson` in a GitHub - * Actions `strategy.matrix`). Both print a single JSON line to stdout so - * they can be captured with `./gradlew -q print...`. - * - * - `printBuildMatrix`: every declared target, for build+check fan-out. - * - `printPublishMatrix`: every target with its publish tasks + classifier - * suffix flag, for the publish fan-out. - * - * The target list and per-target platform availability come from - * `targets.properties` via [loadAllTargetNames]/[MagicUtilsMatrixDefinition.publishUnits], - * so workflows never hardcode a target list. - */ -private fun registerMatrixJsonTasks( - project: Project, - resolvedContext: MagicUtilsMatrixResolvedContext, -) { - val definition = resolvedContext.definition - val targetsFile = project.rootProject.file(definition.targetsFile) - - project.tasks.register("printPublishMatrix") { task -> - task.group = "help" - task.description = "Print the publish matrix (target + publish tasks) as JSON for CI." - task.doLast { - val units = definition.publishUnits(loadAllTargetNames(targetsFile)) - println(units.toMatrixJson()) - } - } - - project.tasks.register("printBuildMatrix") { task -> - task.group = "help" - task.description = "Print the build matrix (list of targets) as JSON for CI." - task.doLast { - val targets = loadAllTargetNames(targetsFile) - println(targets.joinToString(prefix = "[", postfix = "]", separator = ",") { "\"$it\"" }) - } - } -} - -private fun registerPublishCategoryTasks(project: Project) { - fun aggregate(taskName: String, description: String, categories: Set) { - project.tasks.register(taskName) { task -> - task.group = "publishing" - task.description = description - task.dependsOn(project.provider { - project.rootProject.subprojects - .filter { it.magicUtilsPublishCategory() in categories } - .map { "${it.path}:publish" } - }) - } - } - - aggregate( - taskName = "publishDefaultMatrix", - description = "Publish every publishable module on the default Minecraft target.", - categories = setOf( - MagicUtilsPublishCategory.DEFAULT_ONLY, - MagicUtilsPublishCategory.COMMON_MATRIX, - MagicUtilsPublishCategory.FABRIC_MATRIX, - ), - ) - aggregate( - taskName = "publishCommonMatrix", - description = "Publish modules whose category is COMMON_MATRIX (use with -Ptarget=mcXXXX).", - categories = setOf(MagicUtilsPublishCategory.COMMON_MATRIX), - ) - aggregate( - taskName = "publishFabricMatrix", - description = "Publish modules whose category is FABRIC_MATRIX (use with -Ptarget=mcXXXX).", - categories = setOf(MagicUtilsPublishCategory.FABRIC_MATRIX), - ) -} - -private fun registerListBuildMatrixTask( - project: Project, - resolvedContext: MagicUtilsMatrixResolvedContext, -) { - project.tasks.register("listBuildMatrix") { task -> - task.group = "help" - task.description = "Print the resolved MagicUtils target, platforms, scenarios, and included projects." - task.doLast { - println("MagicUtils build matrix") - println(" target: ${resolvedContext.target.name}") - println(" minecraft: ${resolvedContext.target.minecraft}") - println(" java: ${resolvedContext.target.java}") - println(" available platforms: ${resolvedContext.availablePlatforms.joinToString(", ")}") - println(" selected scenario: ${resolvedContext.selectedScenario ?: "workspace"}") - println(" selected platforms: ${resolvedContext.selectedPlatforms.joinToString(", ")}") - println(" included projects: ${resolvedContext.includedProjects.joinToString(", ")}") - println(" scenarios:") - for (scenario in resolvedContext.definition.scenarios.values) { - val descriptionSuffix = scenario.description.takeIf(String::isNotBlank)?.let { " - $it" } ?: "" - println(" ${scenario.name}: ${scenario.platforms.joinToString(", ")}$descriptionSuffix") - } - } - } -} - -private fun registerScenarioAggregateTasks( - project: Project, - resolvedContext: MagicUtilsMatrixResolvedContext, -) { - for (scenario in resolvedContext.definition.scenarios.values) { - val scenarioPlatforms = scenario.platforms.intersect(resolvedContext.availablePlatforms) - if (scenarioPlatforms.isEmpty()) { - continue - } - - val scenarioProjects = scenarioPlatforms - .flatMap { platformName -> resolvedContext.definition.platforms.getValue(platformName).projects } - .mapNotNull(project.rootProject::findProject) - .distinctBy(Project::getPath) - if (scenarioProjects.isEmpty()) { - continue - } - - val taskSuffix = capitalizeScenarioToken(scenario.name) - registerAggregateTask( - project = project, - taskName = "build$taskSuffix", - description = "Build the ${scenario.name} scenario modules.", - targetTaskName = "build", - scenarioProjects = scenarioProjects, - ) - registerAggregateTask( - project = project, - taskName = "check$taskSuffix", - description = "Run checks for the ${scenario.name} scenario modules.", - targetTaskName = "check", - scenarioProjects = scenarioProjects, - ) - registerAggregateTask( - project = project, - taskName = "publishToMavenLocal$taskSuffix", - description = "Publish the ${scenario.name} scenario modules to Maven Local.", - targetTaskName = "publishToMavenLocal", - scenarioProjects = scenarioProjects, - ) - } -} - -private fun registerSelectedScenarioTasks( - project: Project, - resolvedContext: MagicUtilsMatrixResolvedContext, -) { - val selectedScenarioProjects = resolvedContext.selectedPlatforms - .flatMap { platformName -> resolvedContext.definition.platforms.getValue(platformName).projects } - .mapNotNull(project.rootProject::findProject) - .distinctBy(Project::getPath) - - registerAggregateTask( - project = project, - taskName = "buildScenario", - description = "Build the modules resolved for the current MagicUtils scenario selection.", - targetTaskName = "build", - scenarioProjects = selectedScenarioProjects, - ) - registerAggregateTask( - project = project, - taskName = "checkScenario", - description = "Run checks for the modules resolved for the current MagicUtils scenario selection.", - targetTaskName = "check", - scenarioProjects = selectedScenarioProjects, - ) - registerAggregateTask( - project = project, - taskName = "publishScenarioToMavenLocal", - description = "Publish the modules resolved for the current MagicUtils scenario selection to Maven Local.", - targetTaskName = "publishToMavenLocal", - scenarioProjects = selectedScenarioProjects, - ) -} - -private fun registerAggregateTask( - project: Project, - taskName: String, - description: String, - targetTaskName: String, - scenarioProjects: List, -) { - project.tasks.register(taskName) { task -> - task.group = "matrix" - task.description = description - task.dependsOn(scenarioProjects.map { scenarioProject -> "${scenarioProject.path}:$targetTaskName" }) - } -} diff --git a/build-logic/src/main/kotlin/MagicUtilsSmokeModel.kt b/build-logic/src/main/kotlin/MagicUtilsSmokeModel.kt deleted file mode 100644 index 85248fb6..00000000 --- a/build-logic/src/main/kotlin/MagicUtilsSmokeModel.kt +++ /dev/null @@ -1,132 +0,0 @@ -import org.gradle.api.GradleException - -/** - * Compatibility smoke matrix — the reusable, MC-version-range part of what the - * former Python `release_support.py` did (Modrinth/catalog integration is - * consumer-specific and intentionally NOT ported here). - * - * A consumer declares platforms and version_matrix entries; the model expands - * numeric version ranges into concrete smoke values and produces [SmokeCase]s - * that the smoke task runs: launch a server (`gradleCommand`), wait for - * `successPattern`, run `diagnosticsCommand`, read the exported report, and - * gate on the diagnostics verdict. - */ - -/** One concrete server-smoke case for a single Minecraft version. */ -data class SmokeCase( - val id: String, - val platform: String, - val minecraftVersion: String, - val gradleCommand: String, - val successPattern: String, - val timeoutSeconds: Int, - val diagnosticsRequired: Boolean, - val diagnosticsCommand: String, - val diagnosticsTimeoutSeconds: Int, - val diagnosticsFailOnWarn: Boolean, -) - -/** A version_matrix entry before expansion into per-version cases. */ -data class SmokeMatrixEntry( - val id: String, - val versions: List, - val smokeValues: List = emptyList(), - val gradleProperties: Map = emptyMap(), - val smokeGradleProperties: Map> = emptyMap(), - val successPattern: String? = null, -) - -/** Per-platform smoke config: how to launch the server + diagnostics settings. */ -data class SmokePlatformSpec( - val name: String, - /** Gradle task(s) that launch the server, e.g. ":bukkit-bundle:runServer --args='nogui'". */ - val runTask: String, - val defaultSuccessPattern: String, - val defaultTimeoutSeconds: Int = 300, - val diagnosticsRequired: Boolean = true, - val diagnosticsCommand: String = "magicutils diagnostics export", - val diagnosticsTimeoutSeconds: Int = 60, - val diagnosticsFailOnWarn: Boolean = false, - val versionMatrix: List = emptyList(), -) - -private val VERSION_KEY = Regex("""\d+(?:\.\d+)*""") - -/** Parses a dotted version into comparable int parts, or null if not numeric. */ -internal fun parseVersionKey(value: String): List? { - val trimmed = value.trim() - if (!VERSION_KEY.matches(trimmed)) return null - return trimmed.split('.').map(String::toInt) -} - -/** - * Expands a numeric range like `"1.20-1.20.4"` into the endpoints - * `["1.20", "1.20.4"]` (we smoke-test the boundaries of a range, not every - * patch — matches the Python default of first+last). A plain version returns - * itself. Non-range, non-numeric tokens return themselves unchanged. - */ -internal fun expandVersionRange(token: String): List { - val trimmed = token.trim() - val dash = trimmed.indexOf('-') - if (dash <= 0) return listOf(trimmed) - val low = trimmed.substring(0, dash).trim() - val high = trimmed.substring(dash + 1).trim() - // Only treat as a range when both ends parse as numeric versions. - if (parseVersionKey(low) == null || parseVersionKey(high) == null) { - return listOf(trimmed) - } - return if (low == high) listOf(low) else listOf(low, high) -} - -private fun List.expandAll(): List = - flatMap(::expandVersionRange).distinct() - -/** - * The concrete smoke values for an entry: explicit [SmokeMatrixEntry.smokeValues] - * if given, else the expanded endpoints of [SmokeMatrixEntry.versions]. - */ -internal fun SmokeMatrixEntry.resolvedSmokeValues(): List = - if (smokeValues.isNotEmpty()) smokeValues.expandAll() else versions.expandAll() - -/** Builds the -P flags string from a properties map (stable, sorted order). */ -private fun gradlePropertyFlags(props: Map): String = - props.entries.sortedBy { it.key }.joinToString(" ") { "-P${it.key}=${it.value}" } - -/** - * Expands a platform spec into concrete [SmokeCase]s — one per resolved smoke - * value across all its matrix entries. Per-version overrides in - * [SmokeMatrixEntry.smokeGradleProperties] are merged over the entry defaults. - */ -internal fun SmokePlatformSpec.toSmokeCases(): List { - if (runTask.isBlank()) { - throw GradleException("Smoke platform '$name' must declare a runTask.") - } - return versionMatrix.flatMap { entry -> - entry.resolvedSmokeValues().map { version -> - val props = LinkedHashMap(entry.gradleProperties) - entry.smokeGradleProperties[version]?.let(props::putAll) - val flags = gradlePropertyFlags(props) - val command = buildString { - append("./gradlew --refresh-dependencies ") - append(runTask) - if (flags.isNotEmpty()) append(' ').append(flags) - } - SmokeCase( - id = "$name-${entry.id}-$version", - platform = name, - minecraftVersion = version, - gradleCommand = command, - successPattern = entry.successPattern ?: defaultSuccessPattern, - timeoutSeconds = defaultTimeoutSeconds, - diagnosticsRequired = diagnosticsRequired, - diagnosticsCommand = diagnosticsCommand, - diagnosticsTimeoutSeconds = diagnosticsTimeoutSeconds, - diagnosticsFailOnWarn = diagnosticsFailOnWarn, - ) - } - } -} - -/** All smoke cases for a set of platform specs. */ -internal fun List.toSmokeCases(): List = - flatMap { it.toSmokeCases() } 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 new file mode 100644 index 00000000..c24cd520 --- /dev/null +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixModel.kt @@ -0,0 +1,102 @@ +package dev.ua.theroer.magicutils.build.matrix + +import dev.ua.theroer.magicutils.build.target.* + +import org.gradle.api.GradleException + +data class MagicUtilsPlatformSpec( + val name: String, + val projects: Set, + val disabledTargetPrefixes: Set = emptySet(), +) { + fun isEnabledFor(targetName: String): Boolean = + disabledTargetPrefixes.none(targetName::startsWith) +} + +data class MagicUtilsScenarioSpec( + val name: String, + val platforms: Set, + val description: String = "", +) + +data class MagicUtilsMatrixDefinition( + val targetsFile: String, + val defaultTarget: String, + val commonProjects: Set, + val platforms: Map, + val scenarios: Map, +) + +data class MagicUtilsMatrixResolvedContext( + val definition: MagicUtilsMatrixDefinition, + val target: MagicUtilsTargetSpec, + val availablePlatforms: Set, + val selectedPlatforms: Set, + val includedProjects: Set, + val selectedScenario: String?, + val requestedTaskNames: List, +) + +internal fun normalizeProjectPath(path: String): String = + path.trim().removeSuffix(":").let { + when { + it.isEmpty() -> throw GradleException("Project path must not be empty.") + it.startsWith(":") -> it + else -> ":$it" + } + } + +/** One (target, publish-tasks) unit for the CI publish matrix. */ +data class MagicUtilsPublishUnit( + val target: String, + val publishTasks: List, + val suffix: Boolean, +) + +/** + * Which platforms a target supports, honouring each platform's + * [MagicUtilsPlatformSpec.isEnabledFor] (disabled-prefix) rules. + */ +internal fun MagicUtilsMatrixDefinition.availablePlatformsFor(target: String): Set = + 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. + */ +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) + } + } + +/** Minimal JSON array serializer for the matrix outputs (no dependency needed). */ +internal fun List.toMatrixJson(): String = + joinToString(prefix = "[", postfix = "]", separator = ",") { unit -> + """{"target":"${unit.target}",""" + + """"tasks":"${unit.publishTasks.joinToString(" ")}",""" + + """"suffix":${unit.suffix}}""" + } + +internal fun capitalizeScenarioToken(value: String): String = + value.split('-', '_', ' ') + .filter { it.isNotBlank() } + .joinToString("") { token -> + token.replaceFirstChar { char -> + if (char.isLowerCase()) { + char.titlecase() + } else { + char.toString() + } + } + } 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 new file mode 100644 index 00000000..c37207ee --- /dev/null +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixRootPlugin.kt @@ -0,0 +1,467 @@ +package dev.ua.theroer.magicutils.build.matrix + +import dev.ua.theroer.magicutils.build.publish.* +import dev.ua.theroer.magicutils.build.release.* +import dev.ua.theroer.magicutils.build.smoke.* +import dev.ua.theroer.magicutils.build.target.* + +import org.gradle.api.GradleException +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.tasks.SourceSetContainer +import org.gradle.api.tasks.bundling.Zip +import org.gradle.api.tasks.javadoc.Javadoc +import org.gradle.external.javadoc.StandardJavadocDocletOptions +import org.gradle.jvm.toolchain.JavaLanguageVersion +import org.gradle.jvm.toolchain.JavaToolchainService +import dev.ua.theroer.magicutils.build.module.MAGICUTILS_BUILD_JDK + +class MagicUtilsMatrixRootPlugin : Plugin { + override fun apply(project: Project) { + require(project == project.rootProject) { + "magicutils.matrix-root must be applied only to the root project." + } + + val resolvedContext = project.gradle.extensions.extraProperties.properties["magicutilsMatrixResolved"] + as? MagicUtilsMatrixResolvedContext + ?: throw GradleException( + "MagicUtils matrix context is missing. Apply magicutils.matrix-settings in settings.gradle." + ) + + val publishingSpec = project.gradle.extensions.extraProperties.properties["magicutilsPublishingSpec"] + as? MagicUtilsPublishingSpec + ?: throw GradleException( + "MagicUtils publishing spec is missing. Apply magicutils.matrix-settings in settings.gradle." + ) + + registerMagicUtilsHelpTask(project, resolvedContext) + registerListBuildMatrixTask(project, resolvedContext) + registerMatrixJsonTasks(project, resolvedContext) + registerScenarioAggregateTasks(project, resolvedContext) + registerSelectedScenarioTasks(project, resolvedContext) + registerAggregatedJavadocTask(project, resolvedContext) + registerDevServerAggregateTasks(project) + registerPublishCategoryTasks(project) + registerReleaseTasks(project, publishingSpec) + + @Suppress("UNCHECKED_CAST") + val smokeSpecs = project.gradle.extensions.extraProperties.properties["magicutilsSmokeSpecs"] + as? List ?: emptyList() + val defaultGate = project.gradle.extensions.extraProperties.properties["magicutilsSmokeGate"] + as? SmokeGate ?: SmokeGate.STRICT + registerSmokeTasks( + project, + smokeSpecs.toSmokeCases(resolvedContext.definition.defaultTarget), + defaultGate, + ) + registerReleaseMatrixTask(project, resolvedContext, smokeSpecs) + + val modrinthSpec = project.gradle.extensions.extraProperties.properties["magicutilsModrinthSpec"] + as? ModrinthReleaseSpec + registerModrinthTasks(project, modrinthSpec) + } +} + +/** + * Machine-readable matrix outputs consumed by CI (`fromJson` in a GitHub + * Actions `strategy.matrix`). Both print a single JSON line to stdout so + * they can be captured with `./gradlew -q print...`. + * + * - `printBuildMatrix`: every declared target, for build+check fan-out. + * - `printPublishMatrix`: every target with its publish tasks + classifier + * suffix flag, for the publish fan-out. + * + * The target list and per-target platform availability come from + * `targets.properties` via [loadAllTargetNames]/[MagicUtilsMatrixDefinition.publishUnits], + * so workflows never hardcode a target list. + */ +private fun registerMatrixJsonTasks( + project: Project, + resolvedContext: MagicUtilsMatrixResolvedContext, +) { + val definition = resolvedContext.definition + val targetsFile = project.rootProject.file(definition.targetsFile) + + project.tasks.register("printPublishMatrix") { task -> + task.group = "help" + task.description = "Print the publish matrix (target + publish tasks) as JSON for CI." + task.doLast { + val units = definition.publishUnits(loadAllTargetNames(targetsFile)) + println(units.toMatrixJson()) + } + } + + project.tasks.register("printBuildMatrix") { task -> + task.group = "help" + task.description = "Print the build matrix (list of targets) as JSON for CI." + task.doLast { + val targets = loadAllTargetNames(targetsFile) + println(targets.joinToString(prefix = "[", postfix = "]", separator = ",") { "\"$it\"" }) + } + } +} + +/** + * `printReleaseMatrix`: one JSON row per (platform × smoke entry), for a + * consumer's release fan-out. Each row carries the platform, the build target, + * its published-artifact classifier, and the entry's declared Minecraft version + * ranges (the Modrinth game-version source). This is the reusable, tool-side + * half of what the former Python `release_support.py release-json` produced; + * the consumer's workflow turns each row into a build command + dist file name + * (those names are consumer-specific and intentionally not encoded here). + * + * Target per entry comes from `gradleProperties["target"]`, falling back to the + * matrix default — the same resolution the smoke run uses, so the release build + * and the smoke gate always agree on the target. + */ +private fun registerReleaseMatrixTask( + project: Project, + resolvedContext: MagicUtilsMatrixResolvedContext, + smokeSpecs: List, +) { + val definition = resolvedContext.definition + val targetsFile = project.rootProject.file(definition.targetsFile) + + project.tasks.register("printReleaseMatrix") { task -> + task.group = "help" + task.description = "Print the release matrix (platform, target, classifier, game versions) as JSON for CI." + task.doLast { + fun jsonArray(values: List): String = + values.joinToString(prefix = "[", postfix = "]", separator = ",") { "\"$it\"" } + + val rows = smokeSpecs.flatMap { platform -> + val primaries = platform.versionMatrix.count { it.primary } + if (primaries > 1) { + throw GradleException( + "Smoke platform '${platform.name}' marks $primaries entries primary; at most one allowed." + ) + } + platform.versionMatrix.map { entry -> + val spec = resolveMagicUtilsTargetSpec( + targetsFile = targetsFile, + defaultTarget = definition.defaultTarget, + explicitTarget = entry.resolvedTarget(definition.defaultTarget), + ) + """{"platform":"${platform.name}","entry":"${entry.id}",""" + + """"target":"${spec.name}","classifier":"${spec.mcClassifier()}",""" + + """"primary":${entry.primary},""" + + """"gameVersions":${jsonArray(entry.versions.expandVersionsFull())}}""" + } + } + println(rows.joinToString(prefix = "[", postfix = "]", separator = ",")) + } + } +} + +private fun registerPublishCategoryTasks(project: Project) { + fun aggregate(taskName: String, description: String, categories: Set) { + project.tasks.register(taskName) { task -> + task.group = "publishing" + task.description = description + task.dependsOn(project.provider { + project.rootProject.subprojects + .filter { it.magicUtilsPublishCategory() in categories } + .map { "${it.path}:publish" } + }) + } + } + + aggregate( + taskName = "publishDefaultMatrix", + description = "Publish every publishable module on the default Minecraft target.", + categories = setOf( + MagicUtilsPublishCategory.DEFAULT_ONLY, + MagicUtilsPublishCategory.COMMON_MATRIX, + MagicUtilsPublishCategory.FABRIC_MATRIX, + ), + ) + aggregate( + taskName = "publishCommonMatrix", + description = "Publish modules whose category is COMMON_MATRIX (use with -Ptarget=mcXXXX).", + categories = setOf(MagicUtilsPublishCategory.COMMON_MATRIX), + ) + aggregate( + taskName = "publishFabricMatrix", + description = "Publish modules whose category is FABRIC_MATRIX (use with -Ptarget=mcXXXX).", + categories = setOf(MagicUtilsPublishCategory.FABRIC_MATRIX), + ) +} + +private fun registerListBuildMatrixTask( + project: Project, + resolvedContext: MagicUtilsMatrixResolvedContext, +) { + project.tasks.register("listBuildMatrix") { task -> + task.group = "help" + task.description = "Print the resolved MagicUtils target, platforms, scenarios, and included projects." + task.doLast { + println("MagicUtils build matrix") + println(" target: ${resolvedContext.target.name}") + println(" minecraft: ${resolvedContext.target.minecraft}") + if (resolvedContext.target.libraryMinecraft != resolvedContext.target.minecraft) { + println(" library minecraft: ${resolvedContext.target.libraryMinecraft}") + } + println(" java: ${resolvedContext.target.java}") + println(" available platforms: ${resolvedContext.availablePlatforms.joinToString(", ")}") + println(" selected scenario: ${resolvedContext.selectedScenario ?: "workspace"}") + println(" selected platforms: ${resolvedContext.selectedPlatforms.joinToString(", ")}") + println(" included projects: ${resolvedContext.includedProjects.joinToString(", ")}") + println(" scenarios:") + for (scenario in resolvedContext.definition.scenarios.values) { + val descriptionSuffix = scenario.description.takeIf(String::isNotBlank)?.let { " - $it" } ?: "" + println(" ${scenario.name}: ${scenario.platforms.joinToString(", ")}$descriptionSuffix") + } + } + } +} + +private fun registerScenarioAggregateTasks( + project: Project, + resolvedContext: MagicUtilsMatrixResolvedContext, +) { + for (scenario in resolvedContext.definition.scenarios.values) { + val scenarioPlatforms = scenario.platforms.intersect(resolvedContext.availablePlatforms) + if (scenarioPlatforms.isEmpty()) { + continue + } + + val scenarioProjects = scenarioPlatforms + .flatMap { platformName -> resolvedContext.definition.platforms.getValue(platformName).projects } + .mapNotNull(project.rootProject::findProject) + .distinctBy(Project::getPath) + if (scenarioProjects.isEmpty()) { + continue + } + + val taskSuffix = capitalizeScenarioToken(scenario.name) + registerAggregateTask( + project = project, + taskName = "build$taskSuffix", + description = "Build the ${scenario.name} scenario modules.", + targetTaskName = "build", + scenarioProjects = scenarioProjects, + ) + registerAggregateTask( + project = project, + taskName = "check$taskSuffix", + description = "Run checks for the ${scenario.name} scenario modules.", + targetTaskName = "check", + scenarioProjects = scenarioProjects, + ) + registerAggregateTask( + project = project, + taskName = "publishToMavenLocal$taskSuffix", + description = "Publish the ${scenario.name} scenario modules to Maven Local.", + targetTaskName = "publishToMavenLocal", + scenarioProjects = scenarioProjects, + ) + } +} + +private fun registerSelectedScenarioTasks( + project: Project, + resolvedContext: MagicUtilsMatrixResolvedContext, +) { + val selectedScenarioProjects = resolvedContext.selectedPlatforms + .flatMap { platformName -> resolvedContext.definition.platforms.getValue(platformName).projects } + .mapNotNull(project.rootProject::findProject) + .distinctBy(Project::getPath) + + registerAggregateTask( + project = project, + taskName = "buildScenario", + description = "Build the modules resolved for the current MagicUtils scenario selection.", + targetTaskName = "build", + scenarioProjects = selectedScenarioProjects, + ) + registerAggregateTask( + project = project, + taskName = "checkScenario", + description = "Run checks for the modules resolved for the current MagicUtils scenario selection.", + targetTaskName = "check", + scenarioProjects = selectedScenarioProjects, + ) + registerAggregateTask( + project = project, + taskName = "publishScenarioToMavenLocal", + description = "Publish the modules resolved for the current MagicUtils scenario selection to Maven Local.", + targetTaskName = "publishToMavenLocal", + scenarioProjects = selectedScenarioProjects, + ) +} + +/** + * Registers root-level convenience run tasks that delegate into the platform + * subproject's runner: `runPaper`/`runFolia` -> `:bukkit:runServer`/`:runFolia`, + * `runFabric` -> `:fabric:runServer`, `runVelocity` -> `:velocity:runVelocity`. + * + * The root task is registered whenever the conventional subproject exists; the + * delegate is referenced lazily via a `TaskProvider` obtained from the + * subproject, so wiring does not depend on whether the delegate has been created + * yet at root-configuration time (it is created by the consumer-* plugin only on + * `devServer {}` opt-in, in the subproject's own configuration). If the opt-in + * never happens the delegate provider stays unrealised and the root task simply + * has nothing to run. Consumers using non-conventional subproject names keep + * wiring their own aggregate tasks. + */ +private fun registerDevServerAggregateTasks(project: Project) { + data class RootRunner( + val rootTaskName: String, + val subprojectName: String, + val delegateTaskName: String, + val description: String, + ) + + val runners = listOf( + RootRunner("runPaper", "bukkit", "runServer", "Run a Paper dev server with this plugin."), + RootRunner("runFolia", "bukkit", "runFolia", "Run a Folia dev server with this plugin."), + RootRunner("runFabric", "fabric", "runServer", "Run a Fabric dev server with this mod."), + RootRunner("runVelocity", "velocity", "runVelocity", "Run a Velocity proxy with this plugin."), + ) + + for (runner in runners) { + val subproject = project.rootProject.findProject(runner.subprojectName) ?: continue + if (project.tasks.findByName(runner.rootTaskName) != null) continue + project.tasks.register(runner.rootTaskName) { task -> + task.group = "run" + task.description = runner.description + // Resolve the delegate lazily: the provider is evaluated during task + // graph construction, after every subproject's afterEvaluate has run, + // so it does not matter whether the delegate exists yet here. If the + // module never opted into devServer the delegate is absent and the + // root task fails only if actually invoked. + task.dependsOn( + project.provider { + val delegate = subproject.tasks.findByName(runner.delegateTaskName) + ?: error( + "Task '${runner.rootTaskName}' requires ${subproject.path}:${runner.delegateTaskName}, " + + "which is created by `magicutilsConsumer { devServer { } }`. Add that to " + + "${subproject.path}'s build script.", + ) + delegate + }, + ) + } + } +} + +/** + * `aggregatedJavadoc`: one Javadoc HTML tree covering the common (platform-neutral) + * modules on the resolved default target, themed for the MagicUtils docs site. + * + * The common modules all live under the single `dev.ua.theroer` package tree and + * compile against one Minecraft target, so a single Javadoc invocation over their + * combined `main` source sets produces a clean, dedup-free reference. Platform + * adapters are intentionally excluded: they exist per Minecraft version and would + * introduce duplicate classes across targets. + * + * `processor` (annotation processor internals) and `diagnostics-testkit` (test + * helpers) are excluded — they are not part of the consumer-facing API. + * + * Output: `build/docs/aggregatedJavadoc/`. Sources/classpaths are wired lazily so + * the task is created at root-configuration time regardless of subproject eval + * order, and the JDK 25 toolchain is used to match the build toolchain. + */ +private fun registerAggregatedJavadocTask( + project: Project, + resolvedContext: MagicUtilsMatrixResolvedContext, +) { + // commonProjects stores normalized `:name` paths (see normalizeProjectPath). + val excluded = setOf(":processor", ":diagnostics-testkit") + val javadocProjectNames = resolvedContext.definition.commonProjects - excluded + + // The root project only applies `base`, so the toolchain service (normally + // contributed by the `java` plugin) is not present. Apply the lightweight + // `jvm-toolchains` plugin to get it without pulling in a full java setup. + project.pluginManager.apply("jvm-toolchains") + val toolchains = project.extensions.getByType(JavaToolchainService::class.java) + val javadocTool = toolchains.javadocToolFor { spec -> + spec.languageVersion.set(JavaLanguageVersion.of(MAGICUTILS_BUILD_JDK)) + } + + val themeDir = project.rootProject.file("gradle/javadoc") + + project.tasks.register("aggregatedJavadoc", Javadoc::class.java) { task -> + task.group = "documentation" + task.description = + "Generate one themed Javadoc tree for the common MagicUtils modules (magicutils.theroer.dev/javadoc)." + + task.javadocTool.set(javadocTool) + task.setDestinationDir(project.layout.buildDirectory.dir("docs/aggregatedJavadoc").get().asFile) + + // Resolve the common subprojects' main source sets + compile classpaths + // lazily, after every subproject has been evaluated. + val javadocProjects = javadocProjectNames.mapNotNull(project.rootProject::findProject) + task.dependsOn(project.provider { + javadocProjects.mapNotNull { it.tasks.findByName("compileJava") } + }) + + task.source(project.provider { + javadocProjects.mapNotNull { subproject -> + val sourceSets = subproject.extensions.findByType(SourceSetContainer::class.java) ?: return@mapNotNull null + sourceSets.findByName("main")?.allJava + } + }) + + task.classpath = project.files(project.provider { + javadocProjects.mapNotNull { subproject -> + val sourceSets = subproject.extensions.findByType(SourceSetContainer::class.java) ?: return@mapNotNull null + val main = sourceSets.findByName("main") ?: return@mapNotNull null + main.compileClasspath + main.output + } + }) + + val options = task.options as StandardJavadocDocletOptions + options.encoding = "UTF-8" + options.docEncoding = "UTF-8" + options.charSet = "UTF-8" + options.windowTitle = "MagicUtils API" + options.docTitle = "MagicUtils API" + options.author(false) + options.use(true) + options.splitIndex(true) + // Reference-only: do not fail the build on missing @param/@return etc. + options.addStringOption("Xdoclint:none", "-quiet") + // Brand skin + persistent "back to docs" banner on every page. + // --add-stylesheet APPENDS our brand skin after Javadoc's default + // stylesheet (which keeps all the base layout), so our overrides win by + // cascade order without discarding the default. -stylesheetfile would + // REPLACE the default and break the layout, so it is deliberately avoided. + options.addStringOption("-add-stylesheet", themeDir.resolve("theme.css").absolutePath) + // Single-dash standard options: addStringOption(key) emits "-key". The + // -top HTML is injected at the top of every generated page. + options.addStringOption("top", themeDir.resolve("top.html").readTextOrEmpty()) + // No external -links: a fetch failure (offline CI, moved package-list) + // is a hard Javadoc error. Cross-links to Adventure are not worth + // making generation network-dependent. + } + + // Package the generated tree as a single zip for delivery. CI publishes this + // to Reposilite; the docs Docker build downloads and unzips it into + // `public/javadoc/` (the docs image is bun-only and cannot run Javadoc). + project.tasks.register("aggregatedJavadocZip", Zip::class.java) { task -> + task.group = "documentation" + task.description = "Package the aggregated Javadoc tree as a zip for delivery to the docs site." + task.dependsOn("aggregatedJavadoc") + task.from(project.layout.buildDirectory.dir("docs/aggregatedJavadoc")) + task.archiveFileName.set("magicutils-javadoc.zip") + task.destinationDirectory.set(project.layout.buildDirectory.dir("docs")) + } +} + +private fun java.io.File.readTextOrEmpty(): String = + if (exists()) readText(Charsets.UTF_8) else "" + +private fun registerAggregateTask( + project: Project, + taskName: String, + description: String, + targetTaskName: String, + scenarioProjects: List, +) { + project.tasks.register(taskName) { task -> + task.group = "matrix" + task.description = description + task.dependsOn(scenarioProjects.map { scenarioProject -> "${scenarioProject.path}:$targetTaskName" }) + } +} diff --git a/build-logic/src/main/kotlin/MagicUtilsMatrixSettingsPlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixSettingsPlugin.kt similarity index 97% rename from build-logic/src/main/kotlin/MagicUtilsMatrixSettingsPlugin.kt rename to build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixSettingsPlugin.kt index b12a3f6d..347da2bb 100644 --- a/build-logic/src/main/kotlin/MagicUtilsMatrixSettingsPlugin.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixSettingsPlugin.kt @@ -1,3 +1,11 @@ +package dev.ua.theroer.magicutils.build.matrix + +import dev.ua.theroer.magicutils.build.module.* +import dev.ua.theroer.magicutils.build.publish.* +import dev.ua.theroer.magicutils.build.release.* +import dev.ua.theroer.magicutils.build.smoke.* +import dev.ua.theroer.magicutils.build.target.* + import org.gradle.api.Plugin import org.gradle.api.initialization.Settings import org.gradle.api.GradleException @@ -37,6 +45,8 @@ open class MagicUtilsMatrixSettingsExtension { internal fun toSmokeSpecs(): List = smokeDsl.toSpecs() + internal fun smokeGate(): SmokeGate = smokeDsl.gate + private val modrinthDsl = MagicUtilsModrinthDsl() /** Declares the Modrinth release (project + per-artifact loaders/game_versions). */ @@ -364,6 +374,7 @@ class MagicUtilsMatrixSettingsPlugin : Plugin { settings.gradle.extensions.extraProperties.set("magicutilsPublishingSpec", publishingSpec) settings.gradle.extensions.extraProperties.set("magicutilsModuleNaming", extension.toModuleNamingSpec()) settings.gradle.extensions.extraProperties.set("magicutilsSmokeSpecs", extension.toSmokeSpecs()) + settings.gradle.extensions.extraProperties.set("magicutilsSmokeGate", extension.smokeGate()) settings.gradle.extensions.extraProperties.set("magicutilsModrinthSpec", extension.toModrinthSpec()) resolvedContext.includedProjects.forEach { projectPath -> diff --git a/build-logic/src/main/kotlin/MagicUtilsTargetPlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsTargetPlugin.kt similarity index 76% rename from build-logic/src/main/kotlin/MagicUtilsTargetPlugin.kt rename to build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsTargetPlugin.kt index 76c02445..e156cf0a 100644 --- a/build-logic/src/main/kotlin/MagicUtilsTargetPlugin.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsTargetPlugin.kt @@ -1,21 +1,11 @@ +package dev.ua.theroer.magicutils.build.matrix + +import dev.ua.theroer.magicutils.build.target.* + import org.gradle.api.Plugin import org.gradle.api.Project -import org.gradle.api.provider.Property import java.io.File -abstract class MagicUtilsTargetExtension { - abstract val name: Property - abstract val minecraft: Property - abstract val java: Property - abstract val yarn: Property - abstract val loader: Property - abstract val fabric_api: Property - abstract val paper: Property - abstract val miniplaceholders_api: Property - abstract val pb4_placeholder_api: Property - abstract val neoforge: Property -} - class MagicUtilsTargetPlugin : Plugin { override fun apply(project: Project) { with(project) { @@ -23,6 +13,7 @@ class MagicUtilsTargetPlugin : Plugin { val resolvedContext = gradle.extensions.extraProperties.properties["magicutilsMatrixResolved"] as? MagicUtilsMatrixResolvedContext + val defaultTarget = resolvedContext?.definition?.defaultTarget ?: "mc12110" val targetSpec = if (resolvedContext != null) { resolvedContext.target } else { @@ -34,13 +25,15 @@ class MagicUtilsTargetPlugin : Plugin { } resolveMagicUtilsTargetSpec( targetsFile = File(rootDir, "gradle/targets.properties"), - defaultTarget = "mc12110", + defaultTarget = defaultTarget, explicitTarget = explicitTarget, ) } extension.name.set(targetSpec.name) + extension.defaultTarget.set(targetSpec.name == defaultTarget) extension.minecraft.set(targetSpec.minecraft) + extension.libraryMinecraft.set(targetSpec.libraryMinecraft) extension.java.set(targetSpec.java) extension.yarn.set(targetSpec.yarn) extension.loader.set(targetSpec.loader) diff --git a/build-logic/src/main/kotlin/MagicUtilsAnnotationProcessingPlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsAnnotationProcessingPlugin.kt similarity index 95% rename from build-logic/src/main/kotlin/MagicUtilsAnnotationProcessingPlugin.kt rename to build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsAnnotationProcessingPlugin.kt index 1aa46ab2..7a7f4377 100644 --- a/build-logic/src/main/kotlin/MagicUtilsAnnotationProcessingPlugin.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsAnnotationProcessingPlugin.kt @@ -1,3 +1,5 @@ +package dev.ua.theroer.magicutils.build.module + import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.tasks.compile.JavaCompile diff --git a/build-logic/src/main/kotlin/MagicUtilsBukkitBundlePlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsBukkitBundlePlugin.kt similarity index 97% rename from build-logic/src/main/kotlin/MagicUtilsBukkitBundlePlugin.kt rename to build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsBukkitBundlePlugin.kt index 310c5cfa..df949945 100644 --- a/build-logic/src/main/kotlin/MagicUtilsBukkitBundlePlugin.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsBukkitBundlePlugin.kt @@ -1,3 +1,8 @@ +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 diff --git a/build-logic/src/main/kotlin/MagicUtilsCommonPlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsCommonPlugin.kt similarity index 93% rename from build-logic/src/main/kotlin/MagicUtilsCommonPlugin.kt rename to build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsCommonPlugin.kt index 0e4cdf67..a10bcf39 100644 --- a/build-logic/src/main/kotlin/MagicUtilsCommonPlugin.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsCommonPlugin.kt @@ -1,3 +1,7 @@ +package dev.ua.theroer.magicutils.build.module + +import dev.ua.theroer.magicutils.build.target.* + import org.gradle.api.Plugin import org.gradle.api.Project diff --git a/build-logic/src/main/kotlin/MagicUtilsFabricBundlePlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsFabricBundlePlugin.kt similarity index 77% rename from build-logic/src/main/kotlin/MagicUtilsFabricBundlePlugin.kt rename to build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsFabricBundlePlugin.kt index 01ad25fe..72ff128c 100644 --- a/build-logic/src/main/kotlin/MagicUtilsFabricBundlePlugin.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsFabricBundlePlugin.kt @@ -1,3 +1,8 @@ +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 @@ -6,7 +11,6 @@ import org.gradle.api.tasks.SourceSetContainer import org.gradle.api.tasks.bundling.Jar import org.gradle.kotlin.dsl.* import org.gradle.language.jvm.tasks.ProcessResources -import net.fabricmc.loom.api.LoomGradleExtensionAPI import net.fabricmc.loom.task.RemapJarTask import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar @@ -15,16 +19,14 @@ class MagicUtilsFabricBundlePlugin : Plugin { project.pluginManager.apply("magicutils.target") val target = project.extensions.getByType(MagicUtilsTargetExtension::class.java) - // 26.x is deobfuscated: new no-remap Loom id, no mappings, `jar` is the - // artifact. Older obfuscated targets keep classic remapping. - val isDeobfuscated = target.minecraft.get().substringBefore('.').toInt() >= 26 + // Obfuscation boundary / Loom flavour / classifier from shared conventions. + val isDeobfuscated = target.isDeobfuscated project.pluginManager.apply("magicutils.java-library") - project.pluginManager.apply(if (isDeobfuscated) "net.fabricmc.fabric-loom" else "fabric-loom") + project.pluginManager.apply(target.loomPluginId) project.pluginManager.apply("magicutils.common") val magicutilsTarget = project.extensions.getByType(MagicUtilsTargetExtension::class.java) - val loom = project.extensions.getByType(LoomGradleExtensionAPI::class.java) val moduleName = project.magicUtilsModuleName() with(project) { @@ -37,7 +39,8 @@ class MagicUtilsFabricBundlePlugin : Plugin { val bundleLibProjects = listOf( project(":platform-api"), project(":commands-brigadier"), - project(":core") + project(":core"), + project(":diagnostics") ) val bundleModProjects = listOf( @@ -67,12 +70,13 @@ class MagicUtilsFabricBundlePlugin : Plugin { project(":placeholders-fabric") ) - project.dependencies.add("minecraft", "com.mojang:minecraft:${magicutilsTarget.minecraft.get()}") - if (!isDeobfuscated) { - project.dependencies.add("mappings", loom.officialMojangMappings()) - } - val modImpl = if (isDeobfuscated) "implementation" else "modImplementation" - project.dependencies.add(modImpl, "net.fabricmc:fabric-loader:${magicutilsTarget.loader.get()}") + // Minecraft + Mojang mappings (obfuscated only); the runnable bundle + // takes the loader on the implementation configuration. + applyMinecraftAndMappings(project, magicutilsTarget) + project.dependencies.add( + magicutilsTarget.implementationConfiguration, + "net.fabricmc:fabric-loader:${magicutilsTarget.loader.get()}", + ) project.dependencies.add("include", "net.kyori:adventure-api:4.24.0") project.dependencies.add("include", "net.kyori:adventure-key:4.24.0") @@ -103,6 +107,26 @@ class MagicUtilsFabricBundlePlugin : Plugin { bundleLibProjects.forEach { dep -> project.dependencies.add("bundleShadow", project(dep.path)) } + if (isDeobfuscated) { + // 26.x publishes the shadow jar directly (no remapJar) and runs it + // from the dev launcher, which does not explode JiJ'd libraries. + // Shade adventure + diagnostics into the classifier jar so it is + // self-contained for both publish and dev runtime (on <26 these + // reach the fat `:dev` jar transitively via `namedElements`). + listOf( + "net.kyori:adventure-api:4.24.0", + "net.kyori:adventure-key:4.24.0", + "net.kyori:adventure-text-minimessage:4.24.0", + "net.kyori:adventure-text-serializer-plain:4.24.0", + "net.kyori:adventure-text-serializer-gson:4.24.0", + "net.kyori:adventure-text-serializer-ansi:4.24.0", + "net.kyori:adventure-text-serializer-json:4.24.0", + "net.kyori:ansi:1.1.1", + "net.kyori:examination-api:1.3.0", + "net.kyori:examination-string:1.3.0", + "net.kyori:option:1.1.0", + ).forEach { project.dependencies.add("bundleShadow", it) } + } bundleShadedProjects.forEach { dep -> val depProject = project(dep.path) val depDependency = project.dependencies.add("bundleShadow", depProject) as org.gradle.api.artifacts.ProjectDependency @@ -118,14 +142,13 @@ class MagicUtilsFabricBundlePlugin : Plugin { } val shadowJar = tasks.named("shadowJar", ShadowJar::class.java) - val classifier = "mc${magicutilsTarget.minecraft.get().substringBeforeLast('.')}" + // Branch is in the version, so the shipped bundle jar is classifier-less. if (isDeobfuscated) { - // No remap on 26.x: the shadow jar is the published artifact and - // carries the classifier directly. + // No remap on 26.x: the shadow jar is the shipped artifact. shadowJar.configure { shadowJarTask -> shadowJarTask.archiveBaseName.set(moduleName) - shadowJarTask.archiveClassifier.set(classifier) + shadowJarTask.archiveClassifier.set("") shadowJarTask.configurations.set( setOf(project.configurations.getByName("bundleShadow")) ) @@ -133,8 +156,10 @@ class MagicUtilsFabricBundlePlugin : Plugin { shadowJarTask.mergeServiceFiles() } } else { + // remapJar is shipped (classifier-less); the fat `dev` shadow jar is + // the named compile/dev-runtime variant. tasks.named("remapJar", RemapJarTask::class.java).configure { remapJarTask -> - remapJarTask.archiveClassifier.set(classifier) + remapJarTask.archiveClassifier.set("") remapJarTask.archiveBaseName.set(moduleName) remapJarTask.inputFile.set(shadowJar.get().archiveFile) } diff --git a/build-logic/src/main/kotlin/MagicUtilsFabricModulePlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsFabricModulePlugin.kt similarity index 69% rename from build-logic/src/main/kotlin/MagicUtilsFabricModulePlugin.kt rename to build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsFabricModulePlugin.kt index dd5c02e9..d844ffe5 100644 --- a/build-logic/src/main/kotlin/MagicUtilsFabricModulePlugin.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsFabricModulePlugin.kt @@ -1,9 +1,13 @@ +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.publish.maven.MavenPublication import org.gradle.api.tasks.bundling.Jar import org.gradle.kotlin.dsl.* -import net.fabricmc.loom.api.LoomGradleExtensionAPI import net.fabricmc.loom.task.RemapJarTask class MagicUtilsFabricModulePlugin : Plugin { @@ -13,13 +17,12 @@ class MagicUtilsFabricModulePlugin : Plugin { project.pluginManager.apply("magicutils.target") val target = project.extensions.getByType(MagicUtilsTargetExtension::class.java) - // 26.x ships a deobfuscated jar: use the new no-remap Loom plugin id, - // drop mappings, and let `jar` be the artifact (no remapJar). Older, - // obfuscated targets keep the classic remapping `fabric-loom` path. - val isDeobfuscated = target.minecraft.get().substringBefore('.').toInt() >= 26 + // Obfuscation boundary, Loom flavour and classifier come from the shared + // target conventions (MagicUtilsTargetConventions) — see there. + val isDeobfuscated = target.isDeobfuscated project.pluginManager.apply("magicutils.java-library") - project.pluginManager.apply(if (isDeobfuscated) "net.fabricmc.fabric-loom" else "fabric-loom") + project.pluginManager.apply(target.loomPluginId) project.pluginManager.apply("magicutils.common") project.pluginManager.apply("magicutils.shadow") @@ -30,35 +33,37 @@ class MagicUtilsFabricModulePlugin : Plugin { shadowRuntimeClasspath.isCanBeConsumed = false val magicutilsTarget = project.extensions.getByType(MagicUtilsTargetExtension::class.java) - val loom = project.extensions.getByType(LoomGradleExtensionAPI::class.java) val moduleName = project.magicUtilsModuleName() - // On deobfuscated (26.x) targets the new Loom plugin does not remap, so - // there is no remapJar task and no mappings — `jar` is the artifact and - // dependencies use plain (implementation/compileOnly) configurations. - val classifier = "mc${magicutilsTarget.minecraft.get().substringBeforeLast('.')}" - val compileOnlyConfig = if (isDeobfuscated) "compileOnly" else "modCompileOnly" - // Name of the task producing the primary (published, JiJ) artifact. - val mainJarTaskName = if (isDeobfuscated) "jar" else "remapJar" + // Config selection / primary-jar task from shared conventions. + val compileOnlyConfig = magicutilsTarget.compileOnlyConfiguration + val mainJarTaskName = magicutilsTarget.mainJarTaskName with(project) { - project.dependencies.add("minecraft", "com.mojang:minecraft:${magicutilsTarget.minecraft.get()}") - if (!isDeobfuscated) { - project.dependencies.add("mappings", loom.officialMojangMappings()) - } + // Minecraft + Mojang mappings (obfuscated only); modules take the + // loader compile-only. + applyMinecraftAndMappings(project, magicutilsTarget) project.dependencies.add(compileOnlyConfig, "net.fabricmc:fabric-loader:${magicutilsTarget.loader.get()}") project.dependencies.add(compileOnlyConfig, "eu.pb4:placeholder-api:${magicutilsTarget.pb4_placeholder_api.get()}") project.dependencies.add(compileOnlyConfig, "io.github.miniplaceholders:miniplaceholders-api:${magicutilsTarget.miniplaceholders_api.get()}") + // The published jar carries no classifier (branch is in the version). if (isDeobfuscated) { + // No remap: `jar` is the shipped artifact. tasks.named("jar", Jar::class.java).configure { jarTask -> jarTask.archiveBaseName.set(moduleName) - jarTask.archiveClassifier.set(classifier) + jarTask.archiveClassifier.set("") } } else { + // Loom: `remapJar` is shipped (classifier-less); the unmapped `jar` + // keeps a `dev` classifier so the two never collide on disk. + tasks.named("jar", Jar::class.java).configure { jarTask -> + jarTask.archiveBaseName.set(moduleName) + jarTask.archiveClassifier.set("dev") + } tasks.named("remapJar", RemapJarTask::class.java).configure { remapJarTask -> remapJarTask.archiveBaseName.set(moduleName) - remapJarTask.archiveClassifier.set(classifier) + remapJarTask.archiveClassifier.set("") remapJarTask.dependsOn(tasks.named("jar", Jar::class.java)) } } diff --git a/build-logic/src/main/kotlin/MagicUtilsJavaLibraryPlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsJavaLibraryPlugin.kt similarity index 66% rename from build-logic/src/main/kotlin/MagicUtilsJavaLibraryPlugin.kt rename to build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsJavaLibraryPlugin.kt index a7e6ade0..07da8948 100644 --- a/build-logic/src/main/kotlin/MagicUtilsJavaLibraryPlugin.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsJavaLibraryPlugin.kt @@ -1,3 +1,8 @@ +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.plugins.JavaPluginExtension @@ -42,6 +47,23 @@ class MagicUtilsJavaLibraryPlugin : Plugin { javaCompileTask.options.compilerArgs.addAll(listOf("-Xlint:all,-processing", "-parameters")) } + // Bytecode is compiled with `options.release = target.java`, but Gradle + // derives the published `org.gradle.jvm.version` metadata attribute from + // the *toolchain* (MAGICUTILS_BUILD_JDK = 25), not from `release`. That + // mismatch makes consumers on the target's own Java (e.g. 21 for 1.21.x) + // reject the artifact as "only compatible with JVM 25". Pin the + // TargetJvmVersion attribute on the consumable JVM variants to the + // target's Java so metadata matches the actual bytecode level. + val targetJvm = magicutilsTarget.java.get() + listOf("apiElements", "runtimeElements").forEach { configName -> + project.configurations.matching { it.name == configName }.configureEach { configuration -> + configuration.attributes.attribute( + org.gradle.api.attributes.java.TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE, + targetJvm, + ) + } + } + project.tasks.withType(Test::class.java).configureEach { testTask -> testTask.useJUnitPlatform() } @@ -55,14 +77,11 @@ class MagicUtilsJavaLibraryPlugin : Plugin { val moduleName = project.magicUtilsModuleName() + // The Minecraft branch lives in the published version (`+`), + // so the main jar carries no classifier — same as fabric-api. Classifiers + // are reserved for genuinely different jars (`all`, `dev`, sources). project.tasks.withType(Jar::class.java).configureEach { jarTask -> jarTask.archiveBaseName.set(moduleName) } - - project.tasks.named("jar", Jar::class.java).configure { jarTask -> - if (project.name != "processor" && !project.plugins.hasPlugin("fabric-loom")) { - jarTask.archiveClassifier.set("mc${magicutilsTarget.minecraft.get().substringBeforeLast('.')}") - } - } } } diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsModuleNamingSpec.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsModuleNamingSpec.kt new file mode 100644 index 00000000..829e5424 --- /dev/null +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsModuleNamingSpec.kt @@ -0,0 +1,15 @@ +package dev.ua.theroer.magicutils.build.module + +/** + * Maps a Gradle subproject name to its published artifact id. By default the + * artifact id is [prefix] + projectName; [overrides] pins specific projects to + * an explicit name. Consumers configure this via the matrix DSL, so the plugin + * core carries no project-specific naming. + */ +data class MagicUtilsModuleNamingSpec( + val prefix: String = "", + val overrides: Map = emptyMap(), +) { + fun moduleName(projectName: String): String = + overrides[projectName] ?: (prefix + projectName) +} diff --git a/build-logic/src/main/kotlin/MagicUtilsNeoForgeBundlePlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsNeoForgeBundlePlugin.kt similarity index 53% rename from build-logic/src/main/kotlin/MagicUtilsNeoForgeBundlePlugin.kt rename to build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsNeoForgeBundlePlugin.kt index a77a2c2e..00a223c2 100644 --- a/build-logic/src/main/kotlin/MagicUtilsNeoForgeBundlePlugin.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsNeoForgeBundlePlugin.kt @@ -1,3 +1,7 @@ +package dev.ua.theroer.magicutils.build.module + +import dev.ua.theroer.magicutils.build.support.* + import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.artifacts.ProjectDependency @@ -56,6 +60,34 @@ class MagicUtilsNeoForgeBundlePlugin : Plugin { addBundleProject(path, "shadedElements") } + // NeoForge ships no Kyori Adventure, so the bundle must carry it (the + // component serializers the logger/command output need). Bundle the + // Adventure API + serializers explicitly, since the project deps above + // are added non-transitively to keep NeoForge itself out of the jar. + val kyoriAdventure = project.extensions + .getByType(org.gradle.api.artifacts.VersionCatalogsExtension::class.java) + .named("libs") + .findVersion("kyoriAdventure") + .get() + .requiredVersion + listOf( + "net.kyori:adventure-api", + "net.kyori:adventure-key", + "net.kyori:adventure-text-serializer-gson", + "net.kyori:adventure-text-serializer-json", + "net.kyori:adventure-text-serializer-plain", + "net.kyori:adventure-text-minimessage", + ).forEach { coord -> + dependencies.add("bundleContents", "$coord:$kyoriAdventure") + } + listOf( + "net.kyori:examination-api:1.3.0", + "net.kyori:examination-string:1.3.0", + "net.kyori:option:1.1.0", + ).forEach { coord -> + dependencies.add("bundleContents", coord) + } + tasks.named("jar", Jar::class.java).configure { jarTask -> jarTask.archiveBaseName.set(moduleName) jarTask.duplicatesStrategy = DuplicatesStrategy.EXCLUDE @@ -74,8 +106,18 @@ class MagicUtilsNeoForgeBundlePlugin : Plugin { "META-INF/*.RSA", "META-INF/*.SF", "fabric.mod.json", - "module-info.class", - "net/kyori/**", + // Strip every module-info (top-level AND multi-release, e.g. + // snakeyaml's META-INF/versions/9/module-info.class). Left in, they + // put the bundled libs into a JPMS module when NeoForge loads the + // bundle as a standalone JiJ mod, which breaks class loading. + "**/module-info.class", + // Gson is provided by Minecraft/NeoForge on the game classloader. + // Shipping a second, non-relocated com.google.gson in the JiJ bundle + // clashes with it: MC's DetectedVersion parses version.json via gson, + // and the duplicate silently breaks that, leaving the game version + // unset ("Game version not set") before the client even starts. + // Adventure's gson serializer then binds to MC's gson instead. + "com/google/gson/**", ) } @@ -86,6 +128,22 @@ class MagicUtilsNeoForgeBundlePlugin : Plugin { } } + // The real fat jar for this bundle is the `jar` task (it inlines + // bundleContents), not `shadowJar`. The shadow plugin comes in via + // magicutils.java-library and registers a `shadowRuntimeElements` variant + // (the 36M `:all` archive that also drags in NeoForge/Minecraft) into the + // `java` component. Unlike the shaded modules, this bundle never wants that + // variant published, so drop it from the component unconditionally rather + // than gating on skip_shadow_publish. Must run after the shadow plugin has + // added the variant, hence afterEvaluate. + project.afterEvaluate { + (project.components.findByName("java") as? org.gradle.api.component.AdhocComponentWithVariants)?.let { java -> + project.configurations.findByName("shadowRuntimeElements")?.let { shadow -> + java.withVariantsFromConfiguration(shadow) { it.skip() } + } + } + } + project.extensions.configure(org.gradle.api.publish.PublishingExtension::class.java) { publishing -> publishing.publications.create("mavenJava", MavenPublication::class.java) { publication -> publication.artifactId = moduleName diff --git a/build-logic/src/main/kotlin/MagicUtilsShadedModulePlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsShadedModulePlugin.kt similarity index 95% rename from build-logic/src/main/kotlin/MagicUtilsShadedModulePlugin.kt rename to build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsShadedModulePlugin.kt index 8d2abffa..2c9aace1 100644 --- a/build-logic/src/main/kotlin/MagicUtilsShadedModulePlugin.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsShadedModulePlugin.kt @@ -1,3 +1,5 @@ +package dev.ua.theroer.magicutils.build.module + import org.gradle.api.Plugin import org.gradle.api.Project import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar diff --git a/build-logic/src/main/kotlin/MagicUtilsShadowPlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsShadowPlugin.kt similarity index 94% rename from build-logic/src/main/kotlin/MagicUtilsShadowPlugin.kt rename to build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsShadowPlugin.kt index f616df35..4ed46cbe 100644 --- a/build-logic/src/main/kotlin/MagicUtilsShadowPlugin.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/module/MagicUtilsShadowPlugin.kt @@ -1,3 +1,5 @@ +package dev.ua.theroer.magicutils.build.module + import org.gradle.api.Plugin import org.gradle.api.Project import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar diff --git a/build-logic/src/main/kotlin/MagicUtilsPublishingPlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/publish/MagicUtilsPublishingPlugin.kt similarity index 51% rename from build-logic/src/main/kotlin/MagicUtilsPublishingPlugin.kt rename to build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/publish/MagicUtilsPublishingPlugin.kt index bf7e8ba0..988587b7 100644 --- a/build-logic/src/main/kotlin/MagicUtilsPublishingPlugin.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/publish/MagicUtilsPublishingPlugin.kt @@ -1,10 +1,14 @@ +package dev.ua.theroer.magicutils.build.publish + +import dev.ua.theroer.magicutils.build.support.* + import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.publish.maven.MavenPublication import org.gradle.api.publish.maven.plugins.MavenPublishPlugin import org.gradle.api.publish.maven.tasks.PublishToMavenLocal +import org.gradle.api.component.AdhocComponentWithVariants import org.gradle.kotlin.dsl.* -import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar class MagicUtilsPublishingPlugin : Plugin { override fun apply(project: Project) { @@ -13,19 +17,29 @@ class MagicUtilsPublishingPlugin : Plugin { val moduleName = project.magicUtilsModuleName() - val skipShadowPublish = project.hasProperty("skip_shadow_publish") + // Production publishing skips the fat build (it exists for local dev only); + // hide the shadow variant from the component so `:all` is not published. + // Runs after the shadow plugin has added shadowRuntimeElements to the + // component (withVariantsFromConfiguration must follow addVariants...). + if (project.hasProperty("skip_shadow_publish")) { + project.afterEvaluate { + (project.components.findByName("java") as? AdhocComponentWithVariants)?.let { java -> + project.configurations.findByName("shadowRuntimeElements")?.let { shadow -> + java.withVariantsFromConfiguration(shadow) { it.skip() } + } + } + } + } project.extensions.configure(org.gradle.api.publish.PublishingExtension::class.java) { publishing -> publishing.publications.create("mavenJava", MavenPublication::class.java) { publication -> publication.artifactId = moduleName + // The `java` component carries the main jar (classifier-less) plus, + // unless skipped above, the shadow jar under the `all` classifier — + // one module, variants by classifier, mirroring the Fabric bundle's + // `dev`. No separate `-all` artifactId. publication.from(project.components.getByName("java")) } - if (project.name != "processor" && !skipShadowPublish) { - publishing.publications.create("mavenShadow", MavenPublication::class.java) { publication -> - publication.artifactId = "${moduleName}-all" - publication.artifact(project.tasks.named("shadowJar", ShadowJar::class.java).get()) - } - } } project.extensions.configure(org.gradle.api.publish.PublishingExtension::class.java) { publishing -> diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/publish/MagicUtilsPublishingSpec.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/publish/MagicUtilsPublishingSpec.kt new file mode 100644 index 00000000..73ffaa86 --- /dev/null +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/publish/MagicUtilsPublishingSpec.kt @@ -0,0 +1,33 @@ +package dev.ua.theroer.magicutils.build.publish + +import org.gradle.api.GradleException +import java.io.File +import java.util.Properties + +data class MagicUtilsPublishingSpec( + val group: String, + val repoUrl: String, + val smokeArtifact: String, +) + +internal fun loadPublishingSpec(publishingFile: File): MagicUtilsPublishingSpec { + if (!publishingFile.isFile) { + throw GradleException("Missing publishing file: ${publishingFile.absolutePath}") + } + + val properties = Properties().also { properties -> + publishingFile.inputStream().use(properties::load) + } + + fun requireValue(key: String): String = + properties.getProperty(key)?.trim()?.takeIf(String::isNotEmpty) + ?: throw GradleException( + "Missing or empty '$key' in ${publishingFile.absolutePath}" + ) + + return MagicUtilsPublishingSpec( + group = requireValue("group"), + repoUrl = requireValue("repo.url").trimEnd('/'), + smokeArtifact = requireValue("smoke.artifact"), + ) +} diff --git a/build-logic/src/main/kotlin/MagicUtilsModrinthDsl.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsModrinthDsl.kt similarity index 97% rename from build-logic/src/main/kotlin/MagicUtilsModrinthDsl.kt rename to build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsModrinthDsl.kt index b0d13ed0..4d8972ff 100644 --- a/build-logic/src/main/kotlin/MagicUtilsModrinthDsl.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsModrinthDsl.kt @@ -1,3 +1,5 @@ +package dev.ua.theroer.magicutils.build.release + import org.gradle.api.Action /** diff --git a/build-logic/src/main/kotlin/MagicUtilsModrinthModel.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsModrinthModel.kt similarity index 97% rename from build-logic/src/main/kotlin/MagicUtilsModrinthModel.kt rename to build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsModrinthModel.kt index c23cba76..298f4d0e 100644 --- a/build-logic/src/main/kotlin/MagicUtilsModrinthModel.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsModrinthModel.kt @@ -1,3 +1,5 @@ +package dev.ua.theroer.magicutils.build.release + /** * Modrinth publishing model. Ports the reusable upload mechanism of the former * verified-plugin `publish_to_modrinth.sh` (project/token/channel + one version diff --git a/build-logic/src/main/kotlin/MagicUtilsModrinthTasks.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsModrinthTasks.kt similarity index 99% rename from build-logic/src/main/kotlin/MagicUtilsModrinthTasks.kt rename to build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsModrinthTasks.kt index 70363297..443f757d 100644 --- a/build-logic/src/main/kotlin/MagicUtilsModrinthTasks.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsModrinthTasks.kt @@ -1,3 +1,5 @@ +package dev.ua.theroer.magicutils.build.release + import org.gradle.api.DefaultTask import org.gradle.api.GradleException import org.gradle.api.Project diff --git a/build-logic/src/main/kotlin/MagicUtilsReleaseModel.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseModel.kt similarity index 97% rename from build-logic/src/main/kotlin/MagicUtilsReleaseModel.kt rename to build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseModel.kt index 8777b1ea..b00c438f 100644 --- a/build-logic/src/main/kotlin/MagicUtilsReleaseModel.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseModel.kt @@ -1,3 +1,7 @@ +package dev.ua.theroer.magicutils.build.release + +import dev.ua.theroer.magicutils.build.publish.* + import org.gradle.api.GradleException /** diff --git a/build-logic/src/main/kotlin/MagicUtilsReleaseTasks.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseTasks.kt similarity index 98% rename from build-logic/src/main/kotlin/MagicUtilsReleaseTasks.kt rename to build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseTasks.kt index 8f604512..3fcab99e 100644 --- a/build-logic/src/main/kotlin/MagicUtilsReleaseTasks.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/release/MagicUtilsReleaseTasks.kt @@ -1,3 +1,7 @@ +package dev.ua.theroer.magicutils.build.release + +import dev.ua.theroer.magicutils.build.publish.* + import org.gradle.api.DefaultTask import org.gradle.api.Project import org.gradle.api.provider.Property diff --git a/build-logic/src/main/kotlin/MagicUtilsSmokeDsl.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/smoke/MagicUtilsSmokeDsl.kt similarity index 73% rename from build-logic/src/main/kotlin/MagicUtilsSmokeDsl.kt rename to build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/smoke/MagicUtilsSmokeDsl.kt index e6b5e2c7..147cb01b 100644 --- a/build-logic/src/main/kotlin/MagicUtilsSmokeDsl.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/smoke/MagicUtilsSmokeDsl.kt @@ -1,3 +1,5 @@ +package dev.ua.theroer.magicutils.build.smoke + import org.gradle.api.Action /** @@ -9,8 +11,9 @@ import org.gradle.api.Action * runTask = ":bukkit-bundle:runServer --args='nogui'" * successPattern = "Done (" * entry("paper-121x") { - * versions = listOf("1.21-1.21.11") - * smokeValues = listOf("1.21", "1.21.11") + * versions = listOf("1.21-1.21.11") // advertised on release + * smokeValues = listOf("1.21", "1.21.11") // gated on these + * target = "mc12110" // built as this jar * } * } * } @@ -19,6 +22,9 @@ import org.gradle.api.Action open class MagicUtilsSmokeDsl { private val platforms = linkedMapOf() + /** Default gate for failed sub-ranges; overridable with -Psmoke_gate=... . */ + var gate: SmokeGate = SmokeGate.STRICT + fun platform(name: String, action: Action) { val builder = platforms.getOrPut(name) { MagicUtilsSmokePlatformBuilder(name) } action.execute(builder) @@ -58,8 +64,15 @@ open class MagicUtilsSmokePlatformBuilder(private val name: String) { } open class MagicUtilsSmokeEntryBuilder(private val id: String) { + /** Full Minecraft range this build covers — advertised on the release. */ var versions: List = emptyList() + /** Representative versions actually launched to gate the range. */ var smokeValues: List = emptyList() + /** Build variant (its jar) the whole range ships as; null = matrix default. */ + var target: String? = null + /** Platform's primary sub-range (update-service default); at most one per platform. */ + var primary: Boolean = false + /** Extra -P flags for rare cases; `target` above is the idiomatic way to pick one. */ var gradleProperties: Map = emptyMap() var smokeGradleProperties: Map> = emptyMap() var successPattern: String? = null @@ -68,6 +81,8 @@ open class MagicUtilsSmokeEntryBuilder(private val id: String) { id = id, versions = versions, smokeValues = smokeValues, + target = target, + primary = primary, gradleProperties = gradleProperties, smokeGradleProperties = smokeGradleProperties, successPattern = successPattern, diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/smoke/MagicUtilsSmokeModel.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/smoke/MagicUtilsSmokeModel.kt new file mode 100644 index 00000000..46b9749b --- /dev/null +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/smoke/MagicUtilsSmokeModel.kt @@ -0,0 +1,237 @@ +package dev.ua.theroer.magicutils.build.smoke + +import org.gradle.api.GradleException + +/** + * Compatibility smoke matrix — the reusable, MC-version-range part of what the + * former Python `release_support.py` did (Modrinth/catalog integration is + * consumer-specific and intentionally NOT ported here). + * + * A consumer declares platforms and version_matrix entries; the model expands + * numeric version ranges into concrete smoke values and produces [SmokeCase]s + * that the smoke task runs: launch a server (`gradleCommand`), wait for + * `successPattern`, run `diagnosticsCommand`, read the exported report, and + * gate on the diagnostics verdict. + */ + +/** + * How a failed sub-range affects the run: + * - STRICT any failed entry fails the whole task (fail-closed default). + * - PARTIAL green entries pass, red ones are skipped; the task succeeds. + * - APPROVAL run everything, then exit for manual sign-off if anything failed. + */ +enum class SmokeGate { STRICT, PARTIAL, APPROVAL; + companion object { + fun from(value: String?): SmokeGate = + entries.firstOrNull { it.name.equals(value?.trim(), ignoreCase = true) } ?: STRICT + } +} + +/** One concrete server-smoke case for a single Minecraft version. */ +data class SmokeCase( + val id: String, + val entryId: String, + val platform: String, + val minecraftVersion: String, + val gradleCommand: String, + val successPattern: String, + val timeoutSeconds: Int, + val diagnosticsRequired: Boolean, + val diagnosticsCommand: String, + val diagnosticsTimeoutSeconds: Int, + val diagnosticsFailOnWarn: Boolean, +) + +/** + * A version_matrix entry before expansion into per-version cases. One entry is + * one published sub-range: [versions] is the full Minecraft range this build + * covers (advertised on the release), [smokeValues] the representative versions + * actually launched to gate it, and [target] the build variant (its jar) the + * whole range ships as. The gate and the publish therefore agree by construction + * — the jar smoke-tested on [smokeValues] is the one released for [versions]. + */ +data class SmokeMatrixEntry( + val id: String, + val versions: List, + val smokeValues: List = emptyList(), + /** Build variant for this sub-range; falls back to the matrix default target. */ + val target: String? = null, + /** + * Marks the platform's primary sub-range — the one an update service hands + * out by default. Exactly one entry per platform should set it; a consumer + * that does not use the primary concept can leave them all false. + */ + val primary: Boolean = false, + val gradleProperties: Map = emptyMap(), + val smokeGradleProperties: Map> = emptyMap(), + val successPattern: String? = null, +) { + /** The target this entry builds/gates on, or [default] when unset. */ + fun resolvedTarget(default: String): String = target ?: gradleProperties["target"] ?: default + + /** + * Gradle -P flags for this entry: the resolved target plus any extra + * properties, target-first and de-duplicated so `target` is never doubled. + */ + internal fun targetGradleProperties(default: String): Map = + linkedMapOf("target" to resolvedTarget(default)) + gradleProperties +} + +/** Per-platform smoke config: how to launch the server + diagnostics settings. */ +data class SmokePlatformSpec( + val name: String, + /** Gradle task(s) that launch the server, e.g. ":bukkit-bundle:runServer --args='nogui'". */ + val runTask: String, + val defaultSuccessPattern: String, + val defaultTimeoutSeconds: Int = 300, + val diagnosticsRequired: Boolean = true, + val diagnosticsCommand: String = "magicutils diagnostics export", + val diagnosticsTimeoutSeconds: Int = 60, + val diagnosticsFailOnWarn: Boolean = false, + val versionMatrix: List = emptyList(), +) + +private val VERSION_KEY = Regex("""\d+(?:\.\d+)*""") + +/** Parses a dotted version into comparable int parts, or null if not numeric. */ +internal fun parseVersionKey(value: String): List? { + val trimmed = value.trim() + if (!VERSION_KEY.matches(trimmed)) return null + return trimmed.split('.').map(String::toInt) +} + +/** + * Expands a numeric range like `"1.20-1.20.4"` into the endpoints + * `["1.20", "1.20.4"]` (we smoke-test the boundaries of a range, not every + * patch — matches the Python default of first+last). A plain version returns + * itself. Non-range, non-numeric tokens return themselves unchanged. + */ +internal fun expandVersionRange(token: String): List { + val trimmed = token.trim() + val dash = trimmed.indexOf('-') + if (dash <= 0) return listOf(trimmed) + val low = trimmed.substring(0, dash).trim() + val high = trimmed.substring(dash + 1).trim() + // Only treat as a range when both ends parse as numeric versions. + if (parseVersionKey(low) == null || parseVersionKey(high) == null) { + return listOf(trimmed) + } + return if (low == high) listOf(low) else listOf(low, high) +} + +private fun List.expandAll(): List = + flatMap(::expandVersionRange).distinct() + +/** + * Fully expands a numeric range into *every* Minecraft version between the + * endpoints — `"1.20-1.20.4"` -> `["1.20","1.20.1","1.20.2","1.20.3","1.20.4"]` — + * not just the endpoints [expandVersionRange] returns. This is the version set a + * release advertises (e.g. a Modrinth file marked for each supported game + * version). Pure integer-component arithmetic, no version catalog needed: it + * assumes every patch in the interval exists, which holds for Minecraft. + * + * Handles three shapes, mirroring the former Python release tooling: + * - cross-branch (minor bumps by one, 3-component start): `start`, the next + * minor, then that minor's patches 1..end. `1.20.6-1.21` -> 1.20.6, 1.21. + * - same length: increments the last component. `1.20-1.20.4` -> 1.20..1.20.4. + * - one deeper (`start` == `end` without its last part): `start` then patches + * 1..end. `1.21-1.21.3` handled by same-length; `1.21-1.21` -> [1.21]. + * A plain version or non-numeric/unsupported range returns itself unchanged. + */ +internal fun expandVersionRangeFull(token: String): List { + val trimmed = token.trim() + val dash = trimmed.indexOf('-') + if (dash <= 0) return listOf(trimmed) + val startStr = trimmed.substring(0, dash).trim() + val endStr = trimmed.substring(dash + 1).trim() + val start = parseVersionKey(startStr) ?: return listOf(trimmed) + val end = parseVersionKey(endStr) ?: return listOf(trimmed) + + fun fmt(parts: List) = parts.joinToString(".") + + // Cross-branch: minor + 1 within the same higher components. + if (start.size == end.size && start.size >= 2 && + start.dropLast(2) == end.dropLast(2) && + end[end.size - 2] == start[start.size - 2] + 1 + ) { + if (start.size == 2) return listOf(fmt(start), fmt(end)) + if (start.size == 3) { + val nextRelease = end.dropLast(1) + return listOf(fmt(start), fmt(nextRelease)) + + (1..end.last()).map { fmt(nextRelease + it) } + } + } + + // Same length, same prefix: increment the last component. + if (start.size == end.size) { + if (start.dropLast(1) != end.dropLast(1) || end.last() < start.last()) { + return listOf(trimmed) + } + val prefix = start.dropLast(1) + return (start.last()..end.last()).map { fmt(prefix + it) } + } + + // One component deeper: start, then its patches 1..end. + if (start.size + 1 == end.size && start == end.dropLast(1)) { + return listOf(fmt(start)) + (1..end.last()).map { fmt(start + it) } + } + + return listOf(trimmed) +} + +/** Fully expands each token (see [expandVersionRangeFull]), de-duplicated. */ +internal fun List.expandVersionsFull(): List = + flatMap(::expandVersionRangeFull).distinct() + +/** + * The concrete smoke values for an entry: explicit [SmokeMatrixEntry.smokeValues] + * if given, else the expanded endpoints of [SmokeMatrixEntry.versions]. + */ +internal fun SmokeMatrixEntry.resolvedSmokeValues(): List = + if (smokeValues.isNotEmpty()) smokeValues.expandAll() else versions.expandAll() + +/** Builds the -P flags string from a properties map (stable, sorted order). */ +private fun gradlePropertyFlags(props: Map): String = + props.entries.sortedBy { it.key }.joinToString(" ") { "-P${it.key}=${it.value}" } + +/** + * Expands a platform spec into concrete [SmokeCase]s — one per resolved smoke + * value across all its matrix entries. The entry's target is applied as + * `-Ptarget=...` (so the smoke run and the release build resolve the identical + * jar), then per-version overrides in [SmokeMatrixEntry.smokeGradleProperties] + * are merged over the entry defaults. + */ +internal fun SmokePlatformSpec.toSmokeCases(defaultTarget: String): List { + if (runTask.isBlank()) { + throw GradleException("Smoke platform '$name' must declare a runTask.") + } + return versionMatrix.flatMap { entry -> + entry.resolvedSmokeValues().map { version -> + val props = LinkedHashMap(entry.targetGradleProperties(defaultTarget)) + entry.smokeGradleProperties[version]?.let(props::putAll) + val flags = gradlePropertyFlags(props) + val command = buildString { + append("./gradlew --refresh-dependencies ") + append(runTask) + if (flags.isNotEmpty()) append(' ').append(flags) + } + SmokeCase( + id = "$name-${entry.id}-$version", + entryId = "$name-${entry.id}", + platform = name, + minecraftVersion = version, + gradleCommand = command, + successPattern = entry.successPattern ?: defaultSuccessPattern, + timeoutSeconds = defaultTimeoutSeconds, + diagnosticsRequired = diagnosticsRequired, + diagnosticsCommand = diagnosticsCommand, + diagnosticsTimeoutSeconds = diagnosticsTimeoutSeconds, + diagnosticsFailOnWarn = diagnosticsFailOnWarn, + ) + } + } +} + +/** All smoke cases for a set of platform specs. */ +internal fun List.toSmokeCases(defaultTarget: String): List = + flatMap { it.toSmokeCases(defaultTarget) } diff --git a/build-logic/src/main/kotlin/MagicUtilsSmokeTasks.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/smoke/MagicUtilsSmokeTasks.kt similarity index 74% rename from build-logic/src/main/kotlin/MagicUtilsSmokeTasks.kt rename to build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/smoke/MagicUtilsSmokeTasks.kt index 0b807795..b10f524b 100644 --- a/build-logic/src/main/kotlin/MagicUtilsSmokeTasks.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/smoke/MagicUtilsSmokeTasks.kt @@ -1,3 +1,5 @@ +package dev.ua.theroer.magicutils.build.smoke + import org.gradle.api.DefaultTask import org.gradle.api.GradleException import org.gradle.api.Project @@ -30,12 +32,15 @@ private val FAILURE_PATTERNS = listOf( private val DIAGNOSTICS_EXPORT_PATTERN = Regex("""Saved diagnostics report to\s+(.+)""") private const val POST_SUCCESS_GRACE_MS = 10_000L private const val DIAGNOSTICS_QUEUE_DELAY_MS = 10_000L +private const val GATE_REPORT_NAME = "gate-report.json" -internal fun registerSmokeTasks(project: Project, cases: List) { +internal fun registerSmokeTasks(project: Project, cases: List, defaultGate: SmokeGate) { project.tasks.register("runCompatibilitySmoke", MagicUtilsSmokeTask::class.java) { task -> task.group = "verification" - task.description = "Launch each smoke case's server, run diagnostics, and gate on the verdict." + task.description = "Launch each smoke case's server, run diagnostics, and gate per sub-range " + + "(-Psmoke_gate=strict|partial|approval)." task.cases.set(cases) + task.defaultGate.set(defaultGate) task.notCompatibleWithConfigurationCache("Spawns server processes and reads their console I/O.") } project.tasks.register("listSmokeMatrix") { task -> @@ -54,6 +59,9 @@ abstract class MagicUtilsSmokeTask : DefaultTask() { @get:Internal abstract val cases: ListProperty + @get:Internal + abstract val defaultGate: org.gradle.api.provider.Property + @TaskAction fun run() { val all = cases.get() @@ -70,16 +78,65 @@ abstract class MagicUtilsSmokeTask : DefaultTask() { val logDir = project.layout.buildDirectory.dir("smoke-logs").get().asFile logDir.mkdirs() - val failures = mutableListOf() + + val caseFailure = linkedMapOf() for (case in selected) { logger.lifecycle("==> ${case.id}: ${case.gradleCommand}") runCatching { runCase(case, logDir) } - .onFailure { failures += "${case.id}: ${it.message}" } + .onFailure { caseFailure[case.id] = it.message ?: "failed" } + } + + val gateOverride = project.findProperty("smoke_gate") as? String + val gate = if (gateOverride.isNullOrBlank()) defaultGate.get() else SmokeGate.from(gateOverride) + val entryIds = selected.map(SmokeCase::entryId).distinct() + val failedEntries = entryIds.filter { entry -> + selected.any { it.entryId == entry && it.id in caseFailure } + } + writeGateReport(logDir, entryIds, failedEntries, caseFailure) + + val passedEntries = entryIds - failedEntries.toSet() + if (failedEntries.isEmpty()) { + logger.lifecycle("Smoke passed: ${entryIds.size} sub-range(s), ${selected.size} case(s).") + return } - if (failures.isNotEmpty()) { - throw GradleException("Smoke failed:\n" + failures.joinToString("\n")) + + val detail = failedEntries.joinToString("\n") { entry -> + val reasons = selected.filter { it.entryId == entry && it.id in caseFailure } + .joinToString("; ") { "${it.minecraftVersion}: ${caseFailure[it.id]}" } + " $entry ($reasons)" + } + when (gate) { + SmokeGate.STRICT -> throw GradleException( + "Smoke gate=strict: ${failedEntries.size} sub-range(s) failed, nothing publishes:\n$detail" + ) + SmokeGate.PARTIAL -> logger.warn( + "Smoke gate=partial: publishing ${passedEntries.size} passed sub-range(s); " + + "skipping ${failedEntries.size} failed:\n$detail" + ) + SmokeGate.APPROVAL -> throw GradleException( + "Smoke gate=approval: ${passedEntries.size} passed, ${failedEntries.size} need sign-off " + + "(see ${logDir.resolve(GATE_REPORT_NAME)}):\n$detail" + ) + } + } + + private fun writeGateReport( + logDir: File, + entryIds: List, + failedEntries: List, + caseFailure: Map, + ) { + val passed = entryIds - failedEntries.toSet() + val json = buildString { + append("""{"passedEntries":""") + append(passed.joinToString(prefix = "[", postfix = "]") { "\"$it\"" }) + append(""","failedEntries":""") + append(failedEntries.joinToString(prefix = "[", postfix = "]") { "\"$it\"" }) + append(""","failedCases":""") + append(caseFailure.keys.joinToString(prefix = "[", postfix = "]") { "\"$it\"" }) + append("}") } - logger.lifecycle("Smoke passed: ${selected.size} case(s).") + logDir.resolve(GATE_REPORT_NAME).writeText(json) } private fun runCase(case: SmokeCase, logDir: File) { diff --git a/build-logic/src/main/kotlin/MagicUtilsProjectExtensions.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/support/MagicUtilsProjectExtensions.kt similarity index 95% rename from build-logic/src/main/kotlin/MagicUtilsProjectExtensions.kt rename to build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/support/MagicUtilsProjectExtensions.kt index 9a3a5d31..6ea8016b 100644 --- a/build-logic/src/main/kotlin/MagicUtilsProjectExtensions.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/support/MagicUtilsProjectExtensions.kt @@ -1,3 +1,7 @@ +package dev.ua.theroer.magicutils.build.support + +import dev.ua.theroer.magicutils.build.module.* + import org.gradle.api.Project import org.gradle.api.publish.PublishingExtension diff --git a/build-logic/src/main/kotlin/MagicUtilsRepositoriesPlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/support/MagicUtilsRepositoriesPlugin.kt similarity index 97% rename from build-logic/src/main/kotlin/MagicUtilsRepositoriesPlugin.kt rename to build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/support/MagicUtilsRepositoriesPlugin.kt index 6ec0f831..1b2d0285 100644 --- a/build-logic/src/main/kotlin/MagicUtilsRepositoriesPlugin.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/support/MagicUtilsRepositoriesPlugin.kt @@ -1,3 +1,5 @@ +package dev.ua.theroer.magicutils.build.support + import org.gradle.api.Plugin import org.gradle.api.Project diff --git a/build-logic/src/main/kotlin/MagicUtilsPublishCategory.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/target/MagicUtilsPublishCategory.kt similarity index 94% rename from build-logic/src/main/kotlin/MagicUtilsPublishCategory.kt rename to build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/target/MagicUtilsPublishCategory.kt index 24b4e2f2..dda00aac 100644 --- a/build-logic/src/main/kotlin/MagicUtilsPublishCategory.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/target/MagicUtilsPublishCategory.kt @@ -1,3 +1,7 @@ +package dev.ua.theroer.magicutils.build.target + +import dev.ua.theroer.magicutils.build.module.* + import org.gradle.api.Project import org.gradle.api.provider.Property From d2f0ba5003921251b1646692179d36346e826867 Mon Sep 17 00:00:00 2001 From: THEROER Date: Mon, 6 Jul 2026 11:18:52 +0300 Subject: [PATCH 03/17] ci: publish aggregated themed Javadoc on release Add publish-javadoc.yml and the shared Javadoc theme (theme.css, top.html); the release workflow now dispatches the Javadoc publish alongside publish-maven. --- .github/workflows/publish-javadoc.yml | 68 ++++++++++ .github/workflows/release.yml | 11 ++ gradle/javadoc/theme.css | 175 ++++++++++++++++++++++++++ gradle/javadoc/top.html | 1 + 4 files changed, 255 insertions(+) create mode 100644 .github/workflows/publish-javadoc.yml create mode 100644 gradle/javadoc/theme.css create mode 100644 gradle/javadoc/top.html diff --git a/.github/workflows/publish-javadoc.yml b/.github/workflows/publish-javadoc.yml new file mode 100644 index 00000000..5daeac61 --- /dev/null +++ b/.github/workflows/publish-javadoc.yml @@ -0,0 +1,68 @@ +name: publish-javadoc + +on: + # Dispatched by release.yml after the tag is created (alongside publish-maven), + # and runnable manually. Generates the aggregated, themed Javadoc for the + # common modules and uploads it to the self-hosted Reposilite as a single zip + # at a stable, version-independent path. The docs site (MagicUtilsWebsite) + # downloads that zip during its Docker build and serves it under /javadoc. + workflow_dispatch: + inputs: + version: + description: "Release version being published (informational)" + required: false + default: "" + +permissions: + contents: read + +concurrency: + group: publish-javadoc + cancel-in-progress: true + +jobs: + publish-javadoc: + runs-on: ubuntu-latest + env: + PUBLISH_USER: ${{ secrets.MAVEN_PUBLISH_USER }} + PUBLISH_TOKEN: ${{ secrets.MAVEN_PUBLISH_TOKEN }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + - uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '25' + cache: 'gradle' + - uses: gradle/actions/setup-gradle@v3 + + - name: Generate aggregated Javadoc zip + run: | + chmod +x ./gradlew + ./gradlew aggregatedJavadocZip + + - name: Resolve upload URL + id: coords + run: | + REPO_URL=$(grep -E '^repo\.url=' gradle/publishing.properties | head -n1 | cut -d= -f2- | tr -d '[:space:]') + # Stable path: the docs build always fetches the latest published tree. + # A versioned copy is kept too, for reference/rollback. + echo "latest_url=${REPO_URL}/dev/ua/theroer/magicutils-javadoc/latest/magicutils-javadoc.zip" >> "$GITHUB_OUTPUT" + VERSION="${{ inputs.version }}" + if [ -n "$VERSION" ]; then + echo "version_url=${REPO_URL}/dev/ua/theroer/magicutils-javadoc/${VERSION}/magicutils-javadoc.zip" >> "$GITHUB_OUTPUT" + fi + + - name: Upload to Reposilite + run: | + ZIP=build/docs/magicutils-javadoc.zip + test -f "$ZIP" || { echo "Javadoc zip not found at $ZIP"; exit 1; } + echo "Uploading to ${{ steps.coords.outputs.latest_url }}" + curl --fail-with-body -sS -u "$PUBLISH_USER:$PUBLISH_TOKEN" \ + -T "$ZIP" "${{ steps.coords.outputs.latest_url }}" + if [ -n "${{ steps.coords.outputs.version_url }}" ]; then + echo "Uploading versioned copy to ${{ steps.coords.outputs.version_url }}" + curl --fail-with-body -sS -u "$PUBLISH_USER:$PUBLISH_TOKEN" \ + -T "$ZIP" "${{ steps.coords.outputs.version_url }}" + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 45c5723a..bd211e7f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -101,3 +101,14 @@ jobs: gh workflow run publish-maven.yml --repo "$GITHUB_REPOSITORY" --ref "$TAG" \ -f version="$VERSION" echo "Dispatched publish-maven.yml for $TAG" + - name: Dispatch Javadoc publish + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION="${{ needs.resolve.outputs.version }}" + TAG="${{ needs.resolve.outputs.tag }}" + # Aggregated, themed Javadoc for the common modules is generated and + # uploaded to Reposilite; the docs site pulls it in at Docker build time. + gh workflow run publish-javadoc.yml --repo "$GITHUB_REPOSITORY" --ref "$TAG" \ + -f version="$VERSION" + echo "Dispatched publish-javadoc.yml for $TAG" diff --git a/gradle/javadoc/theme.css b/gradle/javadoc/theme.css new file mode 100644 index 00000000..7d479c41 --- /dev/null +++ b/gradle/javadoc/theme.css @@ -0,0 +1,175 @@ +/* + * MagicUtils Javadoc theme. + * + * Standalone brand skin for the aggregated Javadoc, mirroring the palette of the + * MagicUtils docs site (magicutils.theroer.dev). Javadoc cannot import the + * @leavepulse/ui kit, so the token values below are copied from the kit's + * tokens.css and must be kept in sync with it if the brand palette changes. + * + * Dark-first (the docs default), with a light prefers-color-scheme fallback + * derived from the same brand colour. + */ + +:root { + /* mirrors @leavepulse/ui tokens.css (dark) */ + --mu-surface: #080b0d; + --mu-surface-raised: #12171d; + --mu-surface-soft: #172027; + --mu-ink: #eef5f1; + --mu-muted: #9aa8a2; + --mu-muted-strong: #c7d2cc; + --mu-brand: #00bcff; + --mu-brand-hover: #19c6ff; + --mu-line: rgba(255, 255, 255, 0.1); + --mu-line-strong: rgba(255, 255, 255, 0.18); + + --mu-font-sans: "Inter", "Segoe UI", system-ui, sans-serif; + --mu-font-mono: "JetBrains Mono", "SF Mono", ui-monospace, monospace; +} + +@media (prefers-color-scheme: light) { + :root { + --mu-surface: #f6f8f7; + --mu-surface-raised: #ffffff; + --mu-surface-soft: #eef2f0; + --mu-ink: #10201a; + --mu-muted: #55635d; + --mu-muted-strong: #33413b; + --mu-brand: #0091c8; + --mu-brand-hover: #00a5e0; + --mu-line: rgba(0, 0, 0, 0.1); + --mu-line-strong: rgba(0, 0, 0, 0.18); + } +} + +/* ── base ──────────────────────────────────────── */ +body { + background: var(--mu-surface); + color: var(--mu-ink); + font-family: var(--mu-font-sans); + -webkit-font-smoothing: antialiased; + font-size: 15px; + line-height: 1.6; +} + +a, a:link, a:visited { + color: var(--mu-brand); + text-decoration: none; +} +a:hover, a:focus { + color: var(--mu-brand-hover); + text-decoration: underline; +} + +pre, code, tt, .code { + font-family: var(--mu-font-mono); +} + +/* ── MagicUtils site header banner ─────────────── */ +.mu-topbar { + position: sticky; + top: 0; + z-index: 100; + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.55rem 1.1rem; + background: var(--mu-surface-raised); + border-bottom: 1px solid var(--mu-line-strong); + font-family: var(--mu-font-sans); +} +.mu-topbar a.mu-brand { + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-weight: 680; + color: var(--mu-ink); + letter-spacing: -0.01em; +} +.mu-topbar a.mu-brand:hover { + text-decoration: none; + color: var(--mu-brand); +} +.mu-topbar .mu-spacer { flex: 1; } +.mu-topbar a.mu-back { + color: var(--mu-muted-strong); + font-size: 0.9rem; +} +.mu-topbar a.mu-back:hover { color: var(--mu-brand); } +.mu-topbar .mu-tag { + font-family: var(--mu-font-mono); + font-size: 0.78rem; + color: var(--mu-muted); + padding: 0.1rem 0.45rem; + border: 1px solid var(--mu-line); + border-radius: 6px; +} + +/* ── standard Javadoc chrome ───────────────────── */ +.top-nav, .bottom-nav, .sub-nav { + background: var(--mu-surface-raised); + border-color: var(--mu-line); +} +.top-nav a, .bottom-nav a, .sub-nav a { color: var(--mu-muted-strong); } +.nav-bar-cell1-rev, .skip-nav { + background: var(--mu-brand-soft, rgba(0, 188, 255, 0.16)); + color: var(--mu-brand); +} +.title, h1, h2, h3, h4 { + color: var(--mu-ink); + font-family: var(--mu-font-sans); + letter-spacing: -0.01em; +} +.header .title { color: var(--mu-ink); } + +/* content panels */ +.summary section, .details section, +.class-description, .block, .contentContainer { + color: var(--mu-ink); +} + +/* tables */ +.summary-table, .memberSummary, table.striped { + border-color: var(--mu-line); +} +.summary-table .table-header, .table-tab, caption { + background: var(--mu-surface-soft); + color: var(--mu-ink); +} +.summary-table .col-first, .summary-table .col-second, +.summary-table .col-last, .summary-table .col-summary-item-name { + border-color: var(--mu-line); +} +.summary-table .even-row-color { background: var(--mu-surface); } +.summary-table .odd-row-color { background: var(--mu-surface-raised); } + +/* code / signatures */ +pre, .source, .member-signature, code, tt { + background: var(--mu-surface-soft); + color: var(--mu-ink); + border-radius: 6px; +} +.member-signature { + border: 1px solid var(--mu-line); + padding: 0.5rem 0.7rem; +} + +/* inline code inside prose should not get a heavy box */ +p code, li code, td code, .block code { + background: color-mix(in srgb, var(--mu-surface-soft) 70%, transparent); + padding: 0.05rem 0.3rem; +} + +/* deprecation / notes */ +.deprecation-block, .notes { + border-color: var(--mu-line-strong); + background: var(--mu-surface-soft); +} + +/* search box */ +.search-input, input.ui-autocomplete-input { + background: var(--mu-surface); + color: var(--mu-ink); + border: 1px solid var(--mu-line-strong); + border-radius: 6px; +} diff --git a/gradle/javadoc/top.html b/gradle/javadoc/top.html new file mode 100644 index 00000000..0ec7d25f --- /dev/null +++ b/gradle/javadoc/top.html @@ -0,0 +1 @@ + From 2daad550f7ac48ff4a48864d477db63ab9143873 Mon Sep 17 00:00:00 2001 From: THEROER Date: Mon, 6 Jul 2026 11:19:21 +0300 Subject: [PATCH 04/17] build: migrate to Kotlin DSL with a target matrix and consumer API Convert every build.gradle/settings.gradle to Kotlin DSL and introduce a target matrix (gradle/targets.properties) selected via -Ptarget=mcXXXX, adding the mc262 target (Minecraft 26.2, Java 25). Add build-logic consumer-* plugins and a MagicUtils consumer registry/info/payloads API so downstream projects resolve the right per-target artifacts, and wire the platform providers through it. Refresh the reflection allowlist line numbers. --- build-logic/gradle.properties | 2 +- .../MagicUtilsConsumerBukkitPlugin.kt | 73 ++++ .../MagicUtilsConsumerBungeePlugin.kt | 66 +++ .../MagicUtilsConsumerCommonPlugin.kt | 45 +++ .../consumer/MagicUtilsConsumerExtension.kt | 376 +++++++++++++++++ .../MagicUtilsConsumerFabricPlugin.kt | 106 +++++ .../MagicUtilsConsumerNeoForgePlugin.kt | 90 +++++ .../MagicUtilsConsumerVelocityPlugin.kt | 67 ++++ .../build/consumer/MagicUtilsDevServer.kt | 378 ++++++++++++++++++ .../build/matrix/MagicUtilsHelpTask.kt | 64 +++ build.gradle | 106 ----- build.gradle.kts | 117 ++++++ .../{build.gradle => build.gradle.kts} | 21 +- .../bukkit/MagicUtilsBukkitBundlePlugin.java | 22 +- commands-brigadier/build.gradle | 17 - commands-brigadier/build.gradle.kts | 19 + commands-fabric/build.gradle | 20 - commands-fabric/build.gradle.kts | 22 + .../magicutils/bootstrap/FabricBootstrap.java | 48 +++ commands-neoforge/build.gradle | 49 --- commands-neoforge/build.gradle.kts | 47 +++ .../bootstrap/NeoForgeBootstrap.java | 68 ++++ commands/build.gradle | 18 - commands/build.gradle.kts | 20 + config-toml/build.gradle | 17 - config-toml/build.gradle.kts | 19 + config-yaml/build.gradle | 17 - config-yaml/build.gradle.kts | 19 + config/build.gradle | 21 - config/build.gradle.kts | 23 ++ .../magicutils/config/ConfigManager.java | 76 +++- .../magicutils/config/ConfigSerializer.java | 182 ++++----- .../config/ConfigManagerRegressionTest.java | 69 ++++ core/build.gradle | 23 -- core/build.gradle.kts | 25 ++ .../bootstrap/MagicUtilsConsumerPayloads.java | 78 ++++ diagnostics-testkit/build.gradle | 19 - diagnostics-testkit/build.gradle.kts | 21 + diagnostics/build.gradle | 19 - diagnostics/build.gradle.kts | 21 + .../diagnostics}/MagicUtilsBundleCommand.java | 133 +++--- .../{build.gradle => build.gradle.kts} | 63 +-- .../fabric/MagicUtilsFabricBundleCommand.java | 99 ----- .../fabric/MagicUtilsFabricBundleMod.java | 20 +- .../src/main/resources/fabric.mod.json | 2 +- gradle/reflection-allowlist.txt | 18 +- http-client/build.gradle | 20 - http-client/build.gradle.kts | 22 + lang/build.gradle | 20 - lang/build.gradle.kts | 22 + logger-fabric/build.gradle | 22 - logger-fabric/build.gradle.kts | 24 ++ logger/build.gradle | 22 - logger/build.gradle.kts | 24 ++ neoforge-bundle/build.gradle | 7 - neoforge-bundle/build.gradle.kts | 45 +++ .../neoforge/MagicUtilsNeoForgeBundleMod.java | 102 +++++ .../resources/META-INF/neoforge.mods.toml | 25 ++ placeholders-fabric/build.gradle | 19 - placeholders-fabric/build.gradle.kts | 21 + placeholders/build.gradle | 18 - placeholders/build.gradle.kts | 20 + platform-api/build.gradle | 18 - platform-api/build.gradle.kts | 20 + .../platform/MagicUtilsConsumerInfo.java | 170 ++++++++ .../platform/MagicUtilsConsumerRegistry.java | 64 +++ .../theroer/magicutils/platform/Platform.java | 45 +++ platform-bukkit/build.gradle | 28 -- platform-bukkit/build.gradle.kts | 30 ++ .../BukkitMagicUtilsConsumerRegistry.java | 246 ++---------- .../bukkit/BukkitPlatformProvider.java | 19 + .../bukkit/FoliaPlatformProvider.java | 11 + platform-bungee/build.gradle | 23 -- platform-bungee/build.gradle.kts | 25 ++ platform-fabric/build.gradle | 20 - platform-fabric/build.gradle.kts | 22 + .../fabric/FabricPlatformProvider.java | 21 + .../MagicUtilsFabricConsumerRegistry.java | 37 ++ platform-neoforge/build.gradle | 48 --- platform-neoforge/build.gradle.kts | 46 +++ .../neoforge/NeoForgeComponentSerializer.java | 62 +-- platform-velocity/build.gradle | 22 - platform-velocity/build.gradle.kts | 24 ++ .../velocity/VelocityPlatformProvider.java | 16 + processor/build.gradle | 25 -- processor/build.gradle.kts | 27 ++ settings.gradle | 105 ----- settings.gradle.kts | 105 +++++ 88 files changed, 3238 insertions(+), 1319 deletions(-) create mode 100644 build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerBukkitPlugin.kt create mode 100644 build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerBungeePlugin.kt create mode 100644 build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerCommonPlugin.kt create mode 100644 build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerExtension.kt create mode 100644 build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerFabricPlugin.kt create mode 100644 build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerNeoForgePlugin.kt create mode 100644 build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerVelocityPlugin.kt create mode 100644 build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsDevServer.kt create mode 100644 build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsHelpTask.kt delete mode 100644 build.gradle create mode 100644 build.gradle.kts rename bukkit-bundle/{build.gradle => build.gradle.kts} (52%) delete mode 100644 commands-brigadier/build.gradle create mode 100644 commands-brigadier/build.gradle.kts delete mode 100644 commands-fabric/build.gradle create mode 100644 commands-fabric/build.gradle.kts delete mode 100644 commands-neoforge/build.gradle create mode 100644 commands-neoforge/build.gradle.kts delete mode 100644 commands/build.gradle create mode 100644 commands/build.gradle.kts delete mode 100644 config-toml/build.gradle create mode 100644 config-toml/build.gradle.kts delete mode 100644 config-yaml/build.gradle create mode 100644 config-yaml/build.gradle.kts delete mode 100644 config/build.gradle create mode 100644 config/build.gradle.kts delete mode 100644 core/build.gradle create mode 100644 core/build.gradle.kts create mode 100644 core/src/main/java/dev/ua/theroer/magicutils/bootstrap/MagicUtilsConsumerPayloads.java delete mode 100644 diagnostics-testkit/build.gradle create mode 100644 diagnostics-testkit/build.gradle.kts delete mode 100644 diagnostics/build.gradle create mode 100644 diagnostics/build.gradle.kts rename {bukkit-bundle/src/main/java/dev/ua/theroer/magicutils/bukkit => diagnostics/src/main/java/dev/ua/theroer/magicutils/diagnostics}/MagicUtilsBundleCommand.java (58%) rename fabric-bundle/{build.gradle => build.gradle.kts} (61%) delete mode 100644 fabric-bundle/src/main/java/dev/ua/theroer/magicutils/fabric/MagicUtilsFabricBundleCommand.java delete mode 100644 http-client/build.gradle create mode 100644 http-client/build.gradle.kts delete mode 100644 lang/build.gradle create mode 100644 lang/build.gradle.kts delete mode 100644 logger-fabric/build.gradle create mode 100644 logger-fabric/build.gradle.kts delete mode 100644 logger/build.gradle create mode 100644 logger/build.gradle.kts delete mode 100644 neoforge-bundle/build.gradle create mode 100644 neoforge-bundle/build.gradle.kts create mode 100644 neoforge-bundle/src/main/java/dev/ua/theroer/magicutils/neoforge/MagicUtilsNeoForgeBundleMod.java create mode 100644 neoforge-bundle/src/main/resources/META-INF/neoforge.mods.toml delete mode 100644 placeholders-fabric/build.gradle create mode 100644 placeholders-fabric/build.gradle.kts delete mode 100644 placeholders/build.gradle create mode 100644 placeholders/build.gradle.kts delete mode 100644 platform-api/build.gradle create mode 100644 platform-api/build.gradle.kts create mode 100644 platform-api/src/main/java/dev/ua/theroer/magicutils/platform/MagicUtilsConsumerInfo.java create mode 100644 platform-api/src/main/java/dev/ua/theroer/magicutils/platform/MagicUtilsConsumerRegistry.java delete mode 100644 platform-bukkit/build.gradle create mode 100644 platform-bukkit/build.gradle.kts delete mode 100644 platform-bungee/build.gradle create mode 100644 platform-bungee/build.gradle.kts delete mode 100644 platform-fabric/build.gradle create mode 100644 platform-fabric/build.gradle.kts create mode 100644 platform-fabric/src/main/java/dev/ua/theroer/magicutils/platform/fabric/MagicUtilsFabricConsumerRegistry.java delete mode 100644 platform-neoforge/build.gradle create mode 100644 platform-neoforge/build.gradle.kts delete mode 100644 platform-velocity/build.gradle create mode 100644 platform-velocity/build.gradle.kts delete mode 100644 processor/build.gradle create mode 100644 processor/build.gradle.kts delete mode 100644 settings.gradle create mode 100644 settings.gradle.kts diff --git a/build-logic/gradle.properties b/build-logic/gradle.properties index b8cc3ca5..ddcfe396 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.3 \ No newline at end of file +pluginsVersion=0.1.4 \ 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 new file mode 100644 index 00000000..754df09a --- /dev/null +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerBukkitPlugin.kt @@ -0,0 +1,73 @@ +package dev.ua.theroer.magicutils.build.consumer + +import dev.ua.theroer.magicutils.build.target.* + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.plugins.JavaPluginExtension +import org.gradle.api.tasks.compile.JavaCompile +import org.gradle.jvm.toolchain.JavaLanguageVersion +import org.gradle.kotlin.dsl.* + +/** + * Consumer plugin for the Bukkit/Paper module of a downstream plugin. + * + * Bukkit's API is stable across the whole range, so there is no obf/deobf + * split here — one artifact covers 1.21.x .. 26.2. The plugin applies + * `java-library` + shadow, resolves the active target, pins the toolchain to + * the target's Java, and adds the Paper API (compileOnly, version from the + * target). MagicUtils modules are declared with [magicUtils]; the classifier is + * derived from the target. + */ +class MagicUtilsConsumerBukkitPlugin : Plugin { + override fun apply(project: Project) { + project.pluginManager.apply("java-library") + project.pluginManager.apply("com.gradleup.shadow") + project.pluginManager.apply("magicutils.target") + + val target = project.extensions.getByType(MagicUtilsTargetExtension::class.java) + project.magicUtilsConsumerExtension() + + project.extensions.configure(JavaPluginExtension::class.java) { java -> + java.toolchain.languageVersion.set(JavaLanguageVersion.of(target.java.get())) + java.withSourcesJar() + } + + project.tasks.withType(JavaCompile::class.java).configureEach { task -> + task.options.release.set(target.java.get()) + task.options.compilerArgs.add("-parameters") + } + + // Paper API for the active target (compileOnly — provided by the server). + project.dependencies.add("compileOnly", "io.papermc.paper:paper-api:${target.paper.get()}") + + project.exposeMagicUtilsTargetFacts(target) + + // MagicUtils modules the consumer declared (plain api/impl — Bukkit has + // no remap). + project.addConsumerMagicUtilsModules(target, "api", "implementation") + + // Local dev server (runPaper/runFolia), only when the consumer opted in + // with `magicutilsConsumer { devServer { ... } }`. The Folia runner is + // registered because a MagicUtils-based plugin is Folia-compatible by + // construction — running it there verifies that locally. The plugin jar + // loaded is the consumer's shadow jar; consumers needing the standalone + // MagicUtils bukkit bundle as a separate server plugin add it via + // `devServerExtraPlugins`. + project.afterEvaluate { + val consumer = project.magicUtilsConsumerExtension() + val spec = consumer.devServerSpec.orNull ?: return@afterEvaluate + MagicUtilsDevServer.configureBukkit( + project = project, + spec = spec, + targetMinecraftVersion = target.minecraft.get(), + mcClassifier = target.mcClassifier, + pluginArtifact = { + project.tasks.named("shadowJar", org.gradle.api.tasks.bundling.AbstractArchiveTask::class.java) + .flatMap { it.archiveFile } + }, + extraPlugins = emptyList(), + ) + } + } +} diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerBungeePlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerBungeePlugin.kt new file mode 100644 index 00000000..9a1bdcbe --- /dev/null +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerBungeePlugin.kt @@ -0,0 +1,66 @@ +package dev.ua.theroer.magicutils.build.consumer + +import dev.ua.theroer.magicutils.build.target.* + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.plugins.JavaPluginExtension +import org.gradle.api.tasks.compile.JavaCompile +import org.gradle.jvm.toolchain.JavaLanguageVersion +import org.gradle.kotlin.dsl.* + +/** + * Consumer plugin for the BungeeCord/Waterfall module of a downstream plugin. + * + * Like Velocity, the BungeeCord API is decoupled from the Minecraft version, so + * there is no per-target API: the version comes from the `bungeeApiVersion` + * gradle property (default [DEFAULT_BUNGEE_API]). Applies `java-library` + + * shadow, pins the toolchain to the active target, adds the BungeeCord API + * (compileOnly), wires the consumer's MagicUtils modules, and — on `devServer {}` + * opt-in — the `runWaterfall` runner. + */ +class MagicUtilsConsumerBungeePlugin : Plugin { + override fun apply(project: Project) { + project.pluginManager.apply("java-library") + project.pluginManager.apply("com.gradleup.shadow") + project.pluginManager.apply("magicutils.target") + project.pluginManager.apply("xyz.jpenilla.run-waterfall") + + 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())) + java.withSourcesJar() + } + project.tasks.withType(JavaCompile::class.java).configureEach { task -> + task.options.release.set(target.java.get()) + task.options.compilerArgs.add("-parameters") + } + + val bungeeApiVersion = project.providers.gradleProperty("bungeeApiVersion") + .orElse(DEFAULT_BUNGEE_API).get() + project.dependencies.add("compileOnly", "net.md-5:bungeecord-api:$bungeeApiVersion") + + project.exposeMagicUtilsTargetFacts(target) + project.addConsumerMagicUtilsModules(target, "api", "implementation") + + project.afterEvaluate { + val spec = consumer.devServerSpec.orNull ?: return@afterEvaluate + MagicUtilsDevServer.configureWaterfall( + project = project, + spec = spec, + waterfallVersion = bungeeApiVersion.substringBefore("-"), + mcClassifier = target.mcClassifier, + pluginArtifact = { + project.tasks.named("shadowJar", org.gradle.api.tasks.bundling.AbstractArchiveTask::class.java) + .flatMap { it.archiveFile } + }, + ) + } + } + + companion object { + const val DEFAULT_BUNGEE_API = "1.20-R0.1" + } +} diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerCommonPlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerCommonPlugin.kt new file mode 100644 index 00000000..e08b7411 --- /dev/null +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerCommonPlugin.kt @@ -0,0 +1,45 @@ +package dev.ua.theroer.magicutils.build.consumer + +import dev.ua.theroer.magicutils.build.target.* + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.plugins.JavaPluginExtension +import org.gradle.api.tasks.compile.JavaCompile +import org.gradle.jvm.toolchain.JavaLanguageVersion +import org.gradle.kotlin.dsl.* + +/** + * Consumer plugin for a platform-agnostic (common) module of a downstream + * plugin/mod that depends on MagicUtils. + * + * Applies `java-library`, resolves the active MagicUtils target + * (magicutils.target: Minecraft/Java/loader from gradle/targets.properties + + * -Ptarget), pins the Java toolchain to the target's Java, and exposes the + * `magicutilsConsumer { }` extension. The consumer declares MagicUtils modules + * with [magicUtils], so the mc classifier is never written by hand. + */ +class MagicUtilsConsumerCommonPlugin : Plugin { + override fun apply(project: Project) { + project.pluginManager.apply("java-library") + project.pluginManager.apply("magicutils.target") + + val target = project.extensions.getByType(MagicUtilsTargetExtension::class.java) + project.magicUtilsConsumerExtension() + + project.extensions.configure(JavaPluginExtension::class.java) { java -> + java.toolchain.languageVersion.set(JavaLanguageVersion.of(target.java.get())) + java.withSourcesJar() + } + + project.tasks.withType(JavaCompile::class.java).configureEach { task -> + task.options.release.set(target.java.get()) + task.options.compilerArgs.add("-parameters") + } + + // Add the MagicUtils modules the consumer declared (plain api/impl — + // common modules are platform-agnostic, no remap). + project.exposeMagicUtilsTargetFacts(target) + project.addConsumerMagicUtilsModules(target, "api", "implementation") + } +} 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 new file mode 100644 index 00000000..7adb8d6c --- /dev/null +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerExtension.kt @@ -0,0 +1,376 @@ +package dev.ua.theroer.magicutils.build.consumer + +import dev.ua.theroer.magicutils.build.target.* + +import org.gradle.api.Action +import org.gradle.api.Project +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.MapProperty +import org.gradle.api.provider.Property + +/** + * Consumer-facing configuration for the `magicutils.consumer-*` plugins. + * + * A downstream plugin/mod only needs to state which MagicUtils library version + * to depend on (default read from the `magicutils_version` gradle property, or + * the current library version) and which MagicUtils modules it uses. Everything + * else — Loom flavour, mappings, mc classifier, Java toolchain — is + * derived from the active target and applied by the plugin, so consumer build + * scripts stay declarative and never spell out a classifier. + */ +abstract class MagicUtilsConsumerExtension { + /** MagicUtils library version consumed for the active target. */ + abstract val magicutilsVersion: Property + + /** + * Whether to jar-in-jar the MagicUtils bundle into the consumer mod as a + * nested mod — Loom `include` of magicutils-fabric-bundle on Fabric, or + * ModDevGradle `jarJar` of magicutils-neoforge-bundle on NeoForge. Ignored by + * the bukkit/proxy plugins (no jar-in-jar there). Default true; set false when + * the consumer bundles MagicUtils another way (e.g. shaded into a fat jar). + */ + abstract val embedMagicUtils: Property + + /** + * MagicUtils modules to add on the `api` configuration (e.g. + * `magicutils-config`, `magicutils-commands`). The plugin resolves each to + * the active target's classifier. Fabric consumers list *extra* modules + * here; the fabric bundle itself is wired automatically. + */ + abstract val apiModules: ListProperty + + /** MagicUtils modules to add on the `implementation` configuration. */ + abstract val implementationModules: ListProperty + + /** + * Third-party coordinates to jar-in-jar (`include`) into the mod AND put on + * the (mod-aware) compile+runtime classpath — e.g. `eu.pb4:sgui:...`, + * `com.zaxxer:HikariCP:...`. Fabric only. The plugin picks the right + * mod/plain configuration for the target so consumers never do. + */ + abstract val bundledLibraryCoordinates: ListProperty + + /** Declares MagicUtils [modules] on the `api` configuration. */ + fun api(vararg modules: String) { + apiModules.addAll(modules.toList()) + } + + /** Declares MagicUtils [modules] on the `implementation` configuration. */ + fun implementation(vararg modules: String) { + implementationModules.addAll(modules.toList()) + } + + /** Declares third-party [coordinates] to bundle (JiJ) into the Fabric mod. */ + fun bundledLibraries(vararg coordinates: String) { + bundledLibraryCoordinates.addAll(coordinates.toList()) + } + + /** + * Nested spec for the local dev server. Non-null only after [devServer] has + * been called at least once — its presence is the opt-in signal that makes + * the consumer plugin register run tasks (and apply the jpenilla run-* Gradle + * plugin). Consumers that never call [devServer] get no run tasks and no + * extra plugins, so nothing is imposed. + */ + abstract val devServerSpec: Property + + /** + * Opt-in: enable and configure the local dev server run tasks + * (`runPaper`/`runFolia` on bukkit, `runFabric` on fabric, plus the proxy + * runners). Everything is defaulted from the active target and the plugin + * name, so the empty form `devServer {}` already works. + */ + fun devServer(action: Action) { + val spec = devServerSpec.orNull ?: objects.newInstance(MagicUtilsDevServerSpec::class.java) + action.execute(spec) + devServerSpec.set(spec) + } + + /** No-arg form: enable the dev server with all defaults. */ + fun devServer() = devServer(Action { }) + + /** Injected by Gradle so [devServer] can instantiate the managed nested spec. */ + @get:javax.inject.Inject + protected abstract val objects: org.gradle.api.model.ObjectFactory +} + +/** + * Configuration of the local dev server, shared by every platform runner. All + * values are optional; the consumer plugin fills unset ones from the active + * target (versions) and the plugin name (motd). `{pluginName}` in [motd] is + * substituted with the resolved plugin display name at task-configuration time. + */ +abstract class MagicUtilsDevServerSpec { + /** Server port written to `server.properties`. Default `25565`. */ + abstract val port: Property + + /** + * MOTD written to `server.properties`. The tokens `{pluginName}` and + * `{platform}` are substituted (the latter with the runner's platform, e.g. + * `Paper`, `Folia`, `Fabric`). Default `"{pluginName} {platform} dev"`. + */ + abstract val motd: Property + + /** `online-mode` written to `server.properties`. Default `false`. */ + abstract val onlineMode: Property + + /** + * Human-readable plugin name used for the MOTD default and for cleaning up + * stale dev artifacts. Default: the `pluginDisplayName` gradle property, or + * the root project name if that property is absent. + */ + abstract val pluginName: Property + + /** + * Minecraft version the Paper/Folia dev server boots. Default: the active + * target's Minecraft version. + */ + abstract val paperVersion: Property + + /** Minecraft version the Folia dev server boots. Default: [paperVersion]. */ + abstract val foliaVersion: Property + + /** + * Whether to register the `runFolia` task on bukkit consumers. Default + * `true` — a MagicUtils-based plugin is Folia-compatible by construction, so + * the Folia runner is the way to actually verify that locally. + */ + abstract val folia: Property + + /** + * Modrinth dependencies to load into the dev server (LuckPerms, PAPI, ...). + * Each is declared once with a logical [modrinth] id and per-platform + * versions; the consumer plugin picks the version for the active platform and + * loads it the platform-native way (run-task `downloadPlugins` on Paper/proxy, + * Loom `modLocalRuntime` on Fabric). A dependency with no version for the + * active platform is skipped, so one declaration serves every module. + */ + abstract val modrinthDependencies: ListProperty + + /** + * Declares a Modrinth dependency by its project [id] (slug), configuring the + * per-platform version in [action], e.g. + * `modrinth("luckperms") { paper = "v5.5.17-bukkit"; fabric = "v5.5.17-fabric" }`. + */ + fun modrinth(id: String, action: Action) { + val dep = objects.newInstance(MagicUtilsModrinthDependency::class.java) + dep.id.set(id) + action.execute(dep) + modrinthDependencies.add(dep) + } + + @get:javax.inject.Inject + protected abstract val objects: org.gradle.api.model.ObjectFactory +} + +/** + * A single Modrinth dependency for the dev server. Per platform the version can + * be given three ways, all through the same per-platform map keyed by target: + * - a fixed id for every target (key [ANY_TARGET]); + * - a per-target id (key = the target's `mcClassifier`, e.g. `mc1.21`, `mc26`), + * for deps whose Modrinth build differs between Minecraft versions; + * - [AUTO], meaning the consumer plugin queries the Modrinth API at + * configuration time for the latest version matching the active MC + loader. + * + * A platform with no entry for the active target (and no [ANY_TARGET] fallback) + * is simply not loaded there, so one declaration serves every module/target. + */ +abstract class MagicUtilsModrinthDependency { + /** Modrinth project id / slug (e.g. `luckperms`). */ + abstract val id: Property + + /** Per-target Paper/Folia versions. Key: `mcClassifier` or [ANY_TARGET]. */ + abstract val paperVersions: MapProperty + + /** Per-target Fabric versions. */ + abstract val fabricVersions: MapProperty + + /** Per-target Velocity versions. */ + abstract val velocityVersions: MapProperty + + /** Per-target Waterfall/Bungee versions. */ + abstract val waterfallVersions: MapProperty + + /** Per-target NeoForge versions. */ + abstract val neoforgeVersions: MapProperty + + /** Paper/Folia: one version id for every target. */ + fun paper(version: String) = paperVersions.put(ANY_TARGET, version) + + /** Paper/Folia: pick the latest matching version via the Modrinth API. */ + fun paperAuto() = paperVersions.put(ANY_TARGET, AUTO) + + /** Paper/Folia: distinct version ids per target `mcClassifier`. */ + fun paper(vararg byTarget: Pair) = paperVersions.putAll(byTarget.toMap()) + + /** Fabric: one version id for every target. */ + fun fabric(version: String) = fabricVersions.put(ANY_TARGET, version) + + /** Fabric: pick the latest matching version via the Modrinth API. */ + fun fabricAuto() = fabricVersions.put(ANY_TARGET, AUTO) + + /** Fabric: distinct version ids per target `mcClassifier`. */ + fun fabric(vararg byTarget: Pair) = fabricVersions.putAll(byTarget.toMap()) + + /** Velocity: one version id for every target. */ + fun velocity(version: String) = velocityVersions.put(ANY_TARGET, version) + + /** Waterfall/Bungee: one version id for every target. */ + fun waterfall(version: String) = waterfallVersions.put(ANY_TARGET, version) + + /** NeoForge: one version id for every target. */ + fun neoforge(version: String) = neoforgeVersions.put(ANY_TARGET, version) + + /** NeoForge: pick the latest matching version via the Modrinth API. */ + fun neoforgeAuto() = neoforgeVersions.put(ANY_TARGET, AUTO) + + /** NeoForge: distinct version ids per target `mcClassifier`. */ + fun neoforge(vararg byTarget: Pair) = neoforgeVersions.putAll(byTarget.toMap()) + + companion object { + /** Map key meaning "this version applies to every target". */ + const val ANY_TARGET = "*" + + /** Version value meaning "resolve the latest from the Modrinth API". */ + const val AUTO = "auto" + } +} + +internal fun Project.magicUtilsConsumerExtension(): MagicUtilsConsumerExtension { + val existing = extensions.findByType(MagicUtilsConsumerExtension::class.java) + if (existing != null) return existing + val ext = extensions.create("magicutilsConsumer", MagicUtilsConsumerExtension::class.java) + ext.magicutilsVersion.convention( + providers.gradleProperty("magicutils_version").orElse(project.version.toString()), + ) + ext.embedMagicUtils.convention( + providers.gradleProperty("magicutils_embed").map { it.toBoolean() }.orElse(true), + ) + return ext +} + +/** + * Applies conventions to a dev-server spec that don't depend on the active + * target: port, motd template, online-mode, folia toggle, and the plugin name + * (from the `pluginDisplayName` property, else the root project name). Version + * defaults are target-derived and are applied by the platform plugin, which has + * the target in hand. + */ +internal fun Project.applyDevServerConventions(spec: MagicUtilsDevServerSpec) { + spec.port.convention(25565) + spec.motd.convention("{pluginName} {platform} dev") + spec.onlineMode.convention(false) + spec.folia.convention(true) + spec.pluginName.convention( + providers.gradleProperty("pluginDisplayName").orElse(rootProject.name), + ) +} + +/** + * Resolves [MagicUtilsDevServerSpec.motd] with `{pluginName}` and `{platform}` + * substituted. [platform] is the runner's platform label (e.g. `Paper`, + * `Folia`, `Fabric`), supplied by the platform plugin that owns the run task. + */ +internal fun MagicUtilsDevServerSpec.resolvedMotd(platform: String): String = + motd.get() + .replace("{pluginName}", pluginName.get()) + .replace("{platform}", platform) + +/** A resolved Modrinth dependency: its id and the version to load (or [AUTO]). */ +internal data class ResolvedModrinth(val id: String, val version: String) + +/** + * Modrinth dependencies to load for [platform] on the target identified by + * [mcClassifier] (e.g. `mc1.21`, `mc26`). For each declared dependency the + * version is looked up target-first, then the [ANY_TARGET] fallback; a + * dependency with neither is skipped. A version of [AUTO] is passed through for + * the caller to resolve against the Modrinth API. `folia` reuses `paper`. + */ +internal fun MagicUtilsDevServerSpec.modrinthFor( + platform: String, + mcClassifier: String, +): List = + modrinthDependencies.get().mapNotNull { dep -> + val versions = when (platform) { + "paper", "folia" -> dep.paperVersions + "fabric" -> dep.fabricVersions + "velocity" -> dep.velocityVersions + "waterfall" -> dep.waterfallVersions + "neoforge" -> dep.neoforgeVersions + else -> return@mapNotNull null + }.get() + val version = versions[mcClassifier] + ?: versions[MagicUtilsModrinthDependency.ANY_TARGET] + ?: return@mapNotNull null + ResolvedModrinth(dep.id.get(), version) + } + +/** + * Maven coordinate of a MagicUtils module for the active target. The consumer + * passes the bare base [version] (e.g. `1.22.0`); the target's Fabric-style + * `+` suffix is added here. No classifier: the branch is in the + * version, and the module's main jar is published classifier-less (fabric-api + * style). + */ +internal fun magicUtilsModuleCoordinate( + module: String, + version: String, + target: MagicUtilsTargetExtension, +): String = "dev.ua.theroer:$module:${target.publishedVersion(version)}" + +/** + * Exposes the resolved target's facts as extra properties so consumer build + * scripts can reference them without importing the internal target extension + * type. Called by every consumer-* platform plugin (one place, no duplication). + * + * - `magicutilsMinecraftVersion` — e.g. `26.2` + * - `magicutilsLoaderVersion` — Fabric loader (empty if the target has none) + * - `magicutilsNeoforgeVersion` — NeoForge version (only set if the target defines it) + * - `magicutilsJavaVersion` — Int Java level + * - `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). + */ +internal fun Project.exposeMagicUtilsTargetFacts(target: MagicUtilsTargetExtension) { + val base = magicUtilsConsumerExtension().magicutilsVersion.get() + extensions.extraProperties.set("magicutilsMinecraftVersion", target.minecraft.get()) + extensions.extraProperties.set("magicutilsLoaderVersion", target.loader.getOrElse("")) + 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)) + // Optional per-platform facts (not every targets.properties defines them). + target.neoforge.orNull?.let { extensions.extraProperties.set("magicutilsNeoforgeVersion", it) } +} + +/** + * Registers the modules declared on the consumer extension on the given + * configurations, resolved to the active target's classifier. Uses lazy + * `addAllLater` providers rather than `afterEvaluate` + `add`, so the modules + * are contributed even after Loom eagerly observes the mod configurations + * (adding late would fail with "configuration was resolved"). Fabric consumers + * pass mod-aware configuration names; bukkit/common pass plain ones. + */ +internal fun Project.addConsumerMagicUtilsModules( + target: MagicUtilsTargetExtension, + apiConfiguration: String, + implementationConfiguration: String, +) { + val consumer = magicUtilsConsumerExtension() + + fun register(configurationName: String, modules: org.gradle.api.provider.ListProperty) { + val deps = consumer.magicutilsVersion.flatMap { version -> + modules.map { list -> + list.map { module -> + dependencies.create(magicUtilsModuleCoordinate(module, version, target)) + } + } + } + configurations.named(configurationName).configure { it.dependencies.addAllLater(deps) } + } + + register(apiConfiguration, consumer.apiModules) + register(implementationConfiguration, consumer.implementationModules) +} 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 new file mode 100644 index 00000000..43b9315d --- /dev/null +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerFabricPlugin.kt @@ -0,0 +1,106 @@ +package dev.ua.theroer.magicutils.build.consumer + +import dev.ua.theroer.magicutils.build.target.* + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.plugins.JavaPluginExtension +import org.gradle.api.tasks.compile.JavaCompile +import org.gradle.jvm.toolchain.JavaLanguageVersion +import org.gradle.kotlin.dsl.* + +/** + * Consumer plugin for the Fabric module of a downstream mod. + * + * This is the plugin that hides the hard part. For the active MagicUtils target + * it: applies the right Loom flavour (remap on obfuscated <26, no-remap on + * deobfuscated 26.x), adds Minecraft + Mojang mappings (obfuscated only) + + * Fabric loader + Fabric API from the target, pins the Java toolchain, and + * wires the `magicutils-fabric-bundle` dependency with the correct classifier + * and configuration (jar-in-jar `include` + mod compile/runtime on obfuscated, + * or plain otherwise). The consumer only applies `magicutils.consumer-fabric` + * and declares its own project dependencies. + * + * Additional MagicUtils modules (e.g. `magicutils-http-client`) are declared + * with [magicUtilsFabric], which returns the coordinate with the right + * classifier so the consumer never spells it out. + */ +class MagicUtilsConsumerFabricPlugin : Plugin { + override fun apply(project: Project) { + project.pluginManager.apply("java-library") + project.pluginManager.apply("magicutils.target") + + val target = project.extensions.getByType(MagicUtilsTargetExtension::class.java) + // Applying the Loom plugin inside a binary plugin registers the `loom` + // extension synchronously (unlike `apply false` + imperative apply in a + // consumer .kts), so mappings/configs resolve cleanly below. + project.pluginManager.apply(target.loomPluginId) + + val consumer = project.magicUtilsConsumerExtension() + + project.exposeMagicUtilsTargetFacts(target) + + project.extensions.configure(JavaPluginExtension::class.java) { java -> + java.toolchain.languageVersion.set(JavaLanguageVersion.of(target.java.get())) + java.withSourcesJar() + } + project.tasks.withType(JavaCompile::class.java).configureEach { task -> + task.options.release.set(target.java.get()) + task.options.compilerArgs.add("-parameters") + } + + // Minecraft + Mojang mappings (obfuscated only). Loader/fabric-api use + // the target's mod-aware implementation configuration. + applyMinecraftAndMappings(project, target) + val impl = target.implementationConfiguration + val compileOnly = target.compileOnlyConfiguration + val runtimeOnly = target.runtimeOnlyConfiguration + project.dependencies.add(impl, "net.fabricmc:fabric-loader:${target.loader.get()}") + project.dependencies.add(impl, "net.fabricmc.fabric-api:fabric-api:${target.fabric_api.get()}") + + // MagicUtils fabric bundle. The shipped jar is classifier-less (the branch + // is in the + version). On obfuscated targets Loom ships a + // remapped jar (thin, JiJ'd) and a fat `:dev` jar (named, adventure inline) + // 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 module = "dev.ua.theroer:magicutils-fabric-bundle:$version" + val shippedDep = module + val fatDep = if (target.isDeobfuscated) shippedDep else "$module:dev" + + project.dependencies.add(compileOnly, fatDep) + if (consumer.embedMagicUtils.get()) { + project.dependencies.add("include", shippedDep) + } + // Dev runtime needs the fat jar on the classpath (JiJ is not exploded by + // the dev launcher). Added as a plain runtime dependency, not a loadable + // dev mod, so it does not re-declare the bundle's own hard-deps. + project.dependencies.add(runtimeOnly, fatDep) + + // Extra MagicUtils modules the consumer declared (e.g. http-client), + // resolved to the target classifier on the mod-aware configuration. + project.addConsumerMagicUtilsModules(target, apiConfiguration = impl, implementationConfiguration = impl) + + // Third-party libraries the consumer wants jar-in-jar'd: `include` (JiJ + // into the published mod) + the mod-aware implementation config (compile + // + dev runtime). Lazy so it survives Loom's early configuration observe. + val bundledLibs = consumer.bundledLibraryCoordinates.map { coords -> + coords.map { project.dependencies.create(it) } + } + project.configurations.named("include").configure { it.dependencies.addAllLater(bundledLibs) } + project.configurations.named(impl).configure { it.dependencies.addAllLater(bundledLibs) } + + // Modrinth dev mods on modLocalRuntime, wired here in the main phase (not + // afterEvaluate) with a lazy provider so it survives Loom's early observe. + MagicUtilsDevServer.fabricModLocalRuntime(project, consumer, target.minecraft.get(), target.mcClassifier) + + // Local dev server (Loom's runServer), only on `devServer {}` opt-in. Loom + // already registers runServer for the active target; here we only stamp + // the run directory's server.properties with the resolved MOTD/port. + project.afterEvaluate { + val spec = consumer.devServerSpec.orNull ?: return@afterEvaluate + MagicUtilsDevServer.configureFabric(project, spec, target.minecraft.get()) + } + } +} 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 new file mode 100644 index 00000000..f08c8666 --- /dev/null +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerNeoForgePlugin.kt @@ -0,0 +1,90 @@ +package dev.ua.theroer.magicutils.build.consumer + +import dev.ua.theroer.magicutils.build.target.* + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.plugins.JavaPluginExtension +import org.gradle.api.tasks.compile.JavaCompile +import org.gradle.jvm.toolchain.JavaLanguageVersion +import org.gradle.kotlin.dsl.* + +/** + * Consumer plugin for the NeoForge module of a downstream mod. + * + * NeoForge is driven by ModDevGradle (`net.neoforged.moddev`), not the jpenilla + * run-* plugins: there is no `pluginJars`/`downloadPlugins`, the mod is loaded by + * copying its jar into the run directory's `mods/` folder. This plugin applies + * moddev, pins the toolchain and the NeoForge version to the active target, + * wires the consumer's MagicUtils modules, and, on `devServer {}` opt-in, + * feeds the consumer-declared run with the jar-sync into `run/mods`. + * + * The moddev `neoForge {}` extension is configured reflectively so build-logic + * does not compile against moddev's API (moddev is applied by id, resolved from + * the consumer's plugin repositories). + */ +class MagicUtilsConsumerNeoForgePlugin : Plugin { + override fun apply(project: Project) { + project.pluginManager.apply("java-library") + project.pluginManager.apply("magicutils.target") + project.pluginManager.apply("net.neoforged.moddev") + + 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())) + java.withSourcesJar() + } + project.tasks.withType(JavaCompile::class.java).configureEach { task -> + task.options.release.set(target.java.get()) + task.options.compilerArgs.add("-parameters") + } + + // neoForge { version = } via the moddev extension, + // reached reflectively to keep moddev off build-logic's compile classpath. + // Use the String setter (setVersion) rather than getVersion().set(...): + // in current ModDevGradle getVersion() throws "Mod development has not + // been enabled yet" until a version is set, so reading the property first + // is not allowed. setVersion is what enables the workflow. + val neoForge = project.extensions.getByName("neoForge") + neoForge.javaClass.methods + .first { it.name == "setVersion" && it.parameterCount == 1 && it.parameterTypes[0] == String::class.java } + .invoke(neoForge, target.neoforge.get()) + + project.exposeMagicUtilsTargetFacts(target) + project.addConsumerMagicUtilsModules(target, "api", "implementation") + + // Embed the MagicUtils NeoForge bundle as a nested mod (jar-in-jar via + // ModDevGradle's `jarJar` configuration), symmetric to the Fabric plugin's + // `include` of magicutils-fabric-bundle. NeoForge loads the bundle under + // its own mod — mixins/entrypoint/metadata intact — so the consumer never + // spells out the coordinate or the JiJ wiring. The bundle jar is + // classifier-less (the branch is in the + version). Opt out + // with `magicutils_embed=false` / `magicutilsConsumer { embedMagicUtils = + // false }` when the consumer bundles MagicUtils some other way (e.g. + // shaded via its own embeddedRuntime), as verified-plugin does. + if (consumer.embedMagicUtils.get()) { + val bundleCoordinate = project.provider { + val version = target.publishedVersion(consumer.magicutilsVersion.get()) + project.dependencies.create("dev.ua.theroer:magicutils-neoforge-bundle:$version") + } + // moddev registers `jarJar` at apply time, but hook it via + // matching/configureEach (not `named`) so a late-created configuration + // is still fed, mirroring the Fabric plugin's defensive `include` wiring. + project.configurations.matching { it.name == "jarJar" }.configureEach { + it.dependencies.addLater(bundleCoordinate) + } + } + + project.afterEvaluate { + val spec = consumer.devServerSpec.orNull ?: return@afterEvaluate + MagicUtilsDevServer.configureNeoForge( + project = project, + spec = spec, + targetMinecraftVersion = target.minecraft.get(), + mcClassifier = target.mcClassifier, + ) + } + } +} 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 new file mode 100644 index 00000000..a8874142 --- /dev/null +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerVelocityPlugin.kt @@ -0,0 +1,67 @@ +package dev.ua.theroer.magicutils.build.consumer + +import dev.ua.theroer.magicutils.build.target.* + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.plugins.JavaPluginExtension +import org.gradle.api.tasks.compile.JavaCompile +import org.gradle.jvm.toolchain.JavaLanguageVersion +import org.gradle.kotlin.dsl.* + +/** + * Consumer plugin for the Velocity module of a downstream plugin. + * + * Velocity's API is decoupled from the Minecraft version, so unlike bukkit/fabric + * there is no per-target API here: the version comes from the `velocityApiVersion` + * gradle property (default [DEFAULT_VELOCITY_API]). The plugin applies + * `java-library` + shadow, pins the Java toolchain to the active target, adds the + * Velocity API (compileOnly + annotationProcessor), wires the consumer's + * MagicUtils modules, and — on `devServer {}` opt-in — the `runVelocity` runner. + */ +class MagicUtilsConsumerVelocityPlugin : Plugin { + override fun apply(project: Project) { + project.pluginManager.apply("java-library") + project.pluginManager.apply("com.gradleup.shadow") + project.pluginManager.apply("magicutils.target") + project.pluginManager.apply("xyz.jpenilla.run-velocity") + + 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())) + java.withSourcesJar() + } + project.tasks.withType(JavaCompile::class.java).configureEach { task -> + task.options.release.set(target.java.get()) + task.options.compilerArgs.add("-parameters") + } + + val velocityApiVersion = project.providers.gradleProperty("velocityApiVersion") + .orElse(DEFAULT_VELOCITY_API).get() + project.dependencies.add("compileOnly", "com.velocitypowered:velocity-api:$velocityApiVersion") + project.dependencies.add("annotationProcessor", "com.velocitypowered:velocity-api:$velocityApiVersion") + + project.exposeMagicUtilsTargetFacts(target) + project.addConsumerMagicUtilsModules(target, "api", "implementation") + + project.afterEvaluate { + val spec = consumer.devServerSpec.orNull ?: return@afterEvaluate + MagicUtilsDevServer.configureVelocity( + project = project, + spec = spec, + velocityVersion = velocityApiVersion, + mcClassifier = target.mcClassifier, + pluginArtifact = { + project.tasks.named("shadowJar", org.gradle.api.tasks.bundling.AbstractArchiveTask::class.java) + .flatMap { it.archiveFile } + }, + ) + } + } + + companion object { + const val DEFAULT_VELOCITY_API = "3.1.1" + } +} diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsDevServer.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsDevServer.kt new file mode 100644 index 00000000..8464f1fa --- /dev/null +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsDevServer.kt @@ -0,0 +1,378 @@ +package dev.ua.theroer.magicutils.build.consumer + +import dev.ua.theroer.magicutils.build.target.* + +import java.net.URI +import org.gradle.api.Project +import xyz.jpenilla.runpaper.RunPaperExtension +import xyz.jpenilla.runpaper.RunPaperPlugin +import xyz.jpenilla.runpaper.task.RunServer +import xyz.jpenilla.runvelocity.task.RunVelocity +import xyz.jpenilla.runwaterfall.task.RunWaterfall + +/** + * Resolves [MagicUtilsModrinthDependency.AUTO] version markers to a concrete + * Modrinth version id by querying the Modrinth API for the newest version of the + * project that matches the active Minecraft version and loader. Fixed version + * ids pass through untouched. A failed lookup drops the dependency with a warning + * rather than failing the build, so an offline `runServer` still starts. + */ +internal object MagicUtilsModrinthResolver { + fun resolve( + project: Project, + deps: List, + loader: String, + minecraftVersion: String, + ): List = deps.mapNotNull { dep -> + if (dep.version != MagicUtilsModrinthDependency.AUTO) return@mapNotNull dep + val resolved = queryLatest(dep.id, loader, minecraftVersion) + if (resolved == null) { + project.logger.warn( + "MagicUtils devServer: could not auto-resolve Modrinth '${dep.id}' for " + + "$loader $minecraftVersion; skipping it.", + ) + null + } else { + ResolvedModrinth(dep.id, resolved) + } + } + + private fun queryLatest(id: String, loader: String, minecraftVersion: String): String? = runCatching { + val url = "https://api.modrinth.com/v2/project/$id/version" + + "?loaders=%5B%22$loader%22%5D&game_versions=%5B%22$minecraftVersion%22%5D" + val body = URI.create(url).toURL().openStream().use { it.readBytes().decodeToString() } + // Newest first; take the first object's "id" field without a JSON lib. + Regex("\"id\"\\s*:\\s*\"([^\"]+)\"").find(body)?.groupValues?.get(1) + }.getOrNull() +} + +/** + * Registration of the local dev-server run tasks, shared by the consumer-* + * platform plugins. Invoked only when the consumer opted into `devServer {}`. + * + * build-logic depends on the jpenilla run-* plugins directly (see build.gradle + * .kts), so this configures their tasks with typed access — the plugins are + * applied unconditionally here anyway, so there is nothing to gain from the + * no-import reflection used for the optional Loom plugin, and typed access is + * far less brittle. + */ +internal object MagicUtilsDevServer { + + /** + * Wires the Paper + Folia runners on a bukkit consumer. [pluginArtifact] + * yields the plugin jar to load (the consumer's shadow jar); [extraPlugins] + * are additional server-plugin jars to drop in (e.g. the shared MagicUtils + * jar). All are resolved lazily so we never force task realisation here. + */ + fun configureBukkit( + project: Project, + spec: MagicUtilsDevServerSpec, + targetMinecraftVersion: String, + mcClassifier: String, + pluginArtifact: () -> Any, + extraPlugins: List<() -> Any>, + ) { + project.pluginManager.apply(RunPaperPlugin::class.java) + project.applyDevServerConventions(spec) + spec.paperVersion.convention(targetMinecraftVersion) + spec.foliaVersion.convention(spec.paperVersion) + + val runPaper = project.extensions.getByType(RunPaperExtension::class.java) + // We pin plugin jars explicitly (below), so turn off run-paper's shadow + // auto-detection, which can grab the wrong artifact in a multi-jar module. + runPaper.disablePluginJarDetection() + + val registerFolia = spec.folia.get() + if (registerFolia) { + val foliaVersion = spec.foliaVersion.get() + runPaper.folia { folia -> + folia.registerTask { runTask -> + runTask.minecraftVersion(foliaVersion) + runTask.runDirectory.set(project.layout.projectDirectory.dir("run-folia")) + } + } + } + + configureRunTask(project, spec, "runServer", "Paper", mcClassifier, pluginArtifact, extraPlugins) + if (registerFolia) { + configureRunTask(project, spec, "runFolia", "Folia", mcClassifier, pluginArtifact, extraPlugins) + } + } + + /** + * Wires the Fabric dev server. Loom already registers `runServer` and points + * it at the `run/` directory, so we only stamp server.properties (MOTD/port/ + * online-mode) into that directory — the Fabric server reads it the same way + * Paper does. No plugin-jar pinning: Loom loads the mod from the dev source + * set. The `runClient` task is left untouched. + */ + /** + * Stamps the Fabric dev server's server.properties (MOTD/port/online-mode). + * Loom already registers `runServer` and points it at `run/`; mod loading is + * wired separately by [fabricModLocalRuntime], which must run in the main + * configuration phase (not afterEvaluate) to survive Loom's early observe of + * its mod configurations. + */ + fun configureFabric( + project: Project, + spec: MagicUtilsDevServerSpec, + targetMinecraftVersion: String, + ) { + project.applyDevServerConventions(spec) + spec.paperVersion.convention(targetMinecraftVersion) + spec.foliaVersion.convention(spec.paperVersion) + + val runServer = project.tasks.findByName("runServer") ?: return + val motd = spec.resolvedMotd("Fabric") + val port = spec.port.get() + val onlineMode = spec.onlineMode.get() + val pluginName = spec.pluginName.get() + runServer.doFirst { + prepareRunDirectory(project, "run", motd, port, onlineMode, pluginName) + } + } + + /** + * Registers the consumer's Modrinth Fabric mods on Loom's `modLocalRuntime` + * (dev runtime only, never published) via a lazy `addAllLater` provider — the + * same pattern the bundled-library wiring uses to survive Loom eagerly + * observing the mod configurations. The provider resolves the per-target + * version (and any [MagicUtilsModrinthDependency.AUTO] lookups) when the + * configuration is realised. Must be called from the main configuration phase. + */ + fun fabricModLocalRuntime( + project: Project, + consumer: MagicUtilsConsumerExtension, + targetMinecraftVersion: String, + mcClassifier: String, + ) { + val deps = consumer.devServerSpec.map { spec -> + val resolved = MagicUtilsModrinthResolver.resolve( + project, spec.modrinthFor("fabric", mcClassifier), "fabric", targetMinecraftVersion, + ) + resolved.map { project.dependencies.create("maven.modrinth:${it.id}:${it.version}") } + }.orElse(emptyList()) + + // The Modrinth Maven is only needed when there is at least one such mod; + // registering it unconditionally is harmless and keeps this lazy. + project.repositories.maven { repo -> + repo.name = "modrinth" + repo.setUrl("https://api.modrinth.com/maven") + repo.content { it.includeGroup("maven.modrinth") } + } + // Loom registers `modLocalRuntime` lazily — it may not exist yet at apply + // time — so hook it via matching/configureEach, which also fires for a + // configuration created later, rather than `named` which throws if absent. + project.configurations.matching { it.name == "modLocalRuntime" }.configureEach { + it.dependencies.addAllLater(deps) + } + } + + /** + * Wires the Velocity runner (`runVelocity`). Proxies have no MOTD/Folia; we + * pin the proxy version, the consumer's shadow jar, and any Modrinth plugins. + * The Velocity API version is not target-derived, so it is passed in. + */ + fun configureVelocity( + project: Project, + spec: MagicUtilsDevServerSpec, + velocityVersion: String, + mcClassifier: String, + pluginArtifact: () -> Any, + ) { + project.applyDevServerConventions(spec) + val modrinth = MagicUtilsModrinthResolver.resolve( + project, spec.modrinthFor("velocity", mcClassifier), "velocity", velocityVersion, + ) + project.tasks.withType(RunVelocity::class.java).configureEach { task -> + task.velocityVersion(velocityVersion) + task.pluginJars(project.provider { pluginArtifact() }) + task.downloadPlugins { d -> modrinth.forEach { d.modrinth(it.id, it.version) } } + } + } + + /** Wires the Waterfall/Bungee runner (`runWaterfall`). See [configureVelocity]. */ + fun configureWaterfall( + project: Project, + spec: MagicUtilsDevServerSpec, + waterfallVersion: String, + mcClassifier: String, + pluginArtifact: () -> Any, + ) { + project.applyDevServerConventions(spec) + val modrinth = MagicUtilsModrinthResolver.resolve( + project, spec.modrinthFor("waterfall", mcClassifier), "waterfall", waterfallVersion, + ) + project.tasks.withType(RunWaterfall::class.java).configureEach { task -> + task.waterfallVersion(waterfallVersion) + task.pluginJars(project.provider { pluginArtifact() }) + task.downloadPlugins { d -> modrinth.forEach { d.modrinth(it.id, it.version) } } + } + } + + /** + * Wires the NeoForge dev server. ModDevGradle has no `pluginJars`/ + * `downloadPlugins`: a mod is loaded by placing its jar in the run + * directory's `mods/` folder. The consumer declares the run itself + * (`neoForge { runs { server() } }`, with its own game directory and args); + * here we only feed that run's `mods/` folder by syncing the consumer's own + * jar and any Modrinth mods into `run/mods`, wiring the sync ahead of the + * `runServer` task. Modrinth mods are pulled from the Modrinth Maven via a + * dedicated resolvable configuration. + */ + fun configureNeoForge( + project: Project, + spec: MagicUtilsDevServerSpec, + targetMinecraftVersion: String, + mcClassifier: String, + ) { + project.applyDevServerConventions(spec) + + val modsDir = project.layout.projectDirectory.dir("run/mods") + + // The consumer's own built mod jar. + val jarTask = project.tasks.named("jar", org.gradle.api.tasks.bundling.AbstractArchiveTask::class.java) + + // Modrinth mods resolved for the neoforge platform, pulled from the + // Modrinth Maven into a dedicated resolvable configuration. + val modrinth = MagicUtilsModrinthResolver.resolve( + project, spec.modrinthFor("neoforge", mcClassifier), "neoforge", targetMinecraftVersion, + ) + val modrinthMods = project.configurations.create("magicutilsNeoForgeDevMods") { + it.isCanBeConsumed = false + it.isCanBeResolved = true + it.isTransitive = false + } + if (modrinth.isNotEmpty()) { + project.repositories.maven { repo -> + repo.name = "modrinth" + repo.setUrl("https://api.modrinth.com/maven") + repo.content { it.includeGroup("maven.modrinth") } + } + modrinth.forEach { dep -> + project.dependencies.add(modrinthMods.name, "maven.modrinth:${dep.id}:${dep.version}") + } + } + + val syncDevMods = project.tasks.register("syncNeoForgeDevMods", org.gradle.api.tasks.Copy::class.java) { task -> + task.group = "run" + task.description = "Copy this mod and its Modrinth dev mods into the NeoForge run/mods folder." + task.dependsOn(jarTask) + task.from(jarTask.flatMap { it.archiveFile }) + task.from(modrinthMods) + task.into(modsDir) + } + + // Make the moddev-registered runServer depend on the mod sync so the run + // directory is populated before launch. Match by name so we do not import + // moddev's task types. + project.tasks.matching { it.name == "runServer" || it.name == "runServerServer" }.configureEach { + it.dependsOn(syncDevMods) + } + } + + private fun configureRunTask( + project: Project, + spec: MagicUtilsDevServerSpec, + taskName: String, + platform: String, + mcClassifier: String, + pluginArtifact: () -> Any, + extraPlugins: List<() -> Any>, + ) { + val isFolia = platform == "Folia" + val runVersion = if (isFolia) spec.foliaVersion.get() else spec.paperVersion.get() + val motd = spec.resolvedMotd(platform) + val port = spec.port.get() + val onlineMode = spec.onlineMode.get() + val pluginName = spec.pluginName.get() + val runDirName = if (isFolia) "run-folia" else "run-paper-$runVersion" + + val loader = if (isFolia) "folia" else "paper" + val modrinth = MagicUtilsModrinthResolver.resolve( + project, spec.modrinthFor(loader, mcClassifier), loader, runVersion, + ) + + project.tasks.named(taskName, RunServer::class.java).configure { task -> + task.minecraftVersion(runVersion) + task.pluginJars(project.provider { pluginArtifact() }) + extraPlugins.forEach { extra -> task.pluginJars(project.provider { extra() }) } + task.downloadPlugins { downloads -> + modrinth.forEach { downloads.modrinth(it.id, it.version) } + } + if (!isFolia) { + task.runDirectory.set(project.layout.projectDirectory.dir(runDirName)) + } + task.doFirst { + prepareRunDirectory(project, runDirName, motd, port, onlineMode, pluginName) + } + } + } + + /** + * Writes eula.txt (accepted) and stamps the resolved MOTD/port/online-mode + * into server.properties on every run (updating those three keys in place and + * preserving any others the server wrote), then clears stale plugin jars from + * prior runs so an old build of this plugin (or MagicUtils) never shadows the + * freshly built one on the dev server. + */ + private fun prepareRunDirectory( + project: Project, + runDirName: String, + motd: String, + port: Int, + onlineMode: Boolean, + pluginName: String, + ) { + val runDir = project.layout.projectDirectory.dir(runDirName).asFile + runDir.mkdirs() + + runDir.resolve("eula.txt").writeText("#Accepted via MagicUtils devServer\neula=true\n") + + writeServerProperties(runDir.resolve("server.properties"), motd, port, onlineMode) + + // Drop stale artifacts of this plugin and of MagicUtils so the dev server + // loads only the freshly built jars pinned via pluginJars. + val pluginsDir = runDir.resolve("plugins") + if (pluginsDir.isDirectory) { + val stalePrefixes = listOf( + pluginName.lowercase().replace(' ', '-'), + "magicutils", + ) + pluginsDir.listFiles()?.forEach { file -> + val lower = file.name.lowercase() + if (file.extension == "jar" && stalePrefixes.any { lower.startsWith(it) }) { + file.delete() + } + } + } + } + + /** + * Sets `motd`, `online-mode` and `server-port` in [file], overwriting those + * keys if present and appending them otherwise, while leaving every other + * line the server wrote untouched. Creating the file from scratch is just the + * empty-existing case. + */ + private fun writeServerProperties(file: java.io.File, motd: String, port: Int, onlineMode: Boolean) { + val managed = linkedMapOf( + "motd" to motd, + "online-mode" to onlineMode.toString(), + "server-port" to port.toString(), + ) + val existing = if (file.exists()) file.readLines() else emptyList() + val seen = mutableSetOf() + val rewritten = existing.map { line -> + val key = line.substringBefore('=', "").trim() + val replacement = managed[key] + if (replacement != null && !line.trimStart().startsWith("#")) { + seen += key + "$key=$replacement" + } else { + line + } + } + val appended = managed.entries.filter { it.key !in seen }.map { "${it.key}=${it.value}" } + file.writeText((rewritten + appended).joinToString("\n") + "\n") + } +} diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsHelpTask.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsHelpTask.kt new file mode 100644 index 00000000..a845a297 --- /dev/null +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsHelpTask.kt @@ -0,0 +1,64 @@ +package dev.ua.theroer.magicutils.build.matrix + +import dev.ua.theroer.magicutils.build.target.* + +import org.gradle.api.Project + +/** + * Registers `magicutilsHelp` — a single, human-readable overview of everything + * the MagicUtils build-logic adds to a consumer project: the properties it + * reads (targets, embed, scenario) and the tasks it contributes (matrix, smoke, + * release). So nobody has to remember `-Ptarget=...` or dig through `tasks`. + */ +internal fun registerMagicUtilsHelpTask( + project: Project, + resolvedContext: MagicUtilsMatrixResolvedContext, +) { + project.tasks.register("magicutilsHelp") { task -> + task.group = "help" + task.description = "Show MagicUtils build parameters and tasks." + task.doLast { + val definition = resolvedContext.definition + val targetsFile = project.rootProject.file(definition.targetsFile) + val allTargets = runCatching { loadAllTargetNames(targetsFile) }.getOrDefault(emptyList()) + + val out = buildString { + appendLine("MagicUtils build help") + appendLine("=====================") + appendLine() + val libraryMc = resolvedContext.target.libraryMinecraft + val mcLine = if (libraryMc == resolvedContext.target.minecraft) { + "Minecraft ${resolvedContext.target.minecraft}" + } else { + "Minecraft ${resolvedContext.target.minecraft} runtime, library $libraryMc" + } + appendLine("Active target : ${resolvedContext.target.name} " + + "($mcLine, Java ${resolvedContext.target.java})") + appendLine("Platforms : ${resolvedContext.selectedPlatforms.joinToString(", ")}") + appendLine() + appendLine("Parameters (-P...)") + appendLine(" -Ptarget= Build for a specific target. Available: " + + allTargets.joinToString(", ").ifEmpty { "(see ${definition.targetsFile})" }) + appendLine(" -Pscenario= Limit to a platform scenario: " + + definition.scenarios.keys.joinToString(", ")) + appendLine(" -PincludePlatforms=a,b Build only these platforms.") + appendLine(" -Pmagicutils_embed= Fabric/NeoForge: jar-in-jar the MagicUtils bundle (default true).") + appendLine(" -PsmokeCase= Run a single compatibility smoke case.") + appendLine(" -Pversion=X.Y.Z Release version for the release tasks.") + appendLine() + appendLine("Tasks") + appendLine(" Build matrix : listBuildMatrix, buildScenario, checkScenario") + appendLine(" printBuildMatrix, printPublishMatrix, printReleaseMatrix (CI JSON)") + appendLine(" Smoke : listSmokeMatrix, runCompatibilitySmoke") + appendLine(" Release : releasePreflight, bumpVersion, dispatchRelease, smokeTest, release") + appendLine(" Publish : publishDefaultMatrix, publishCommonMatrix, publishFabricMatrix") + appendLine() + appendLine("Examples") + appendLine(" ./gradlew build -Ptarget=mc262") + appendLine(" ./gradlew runCompatibilitySmoke -PsmokeCase=fabric-121x") + appendLine(" ./gradlew listBuildMatrix") + } + println(out) + } + } +} diff --git a/build.gradle b/build.gradle deleted file mode 100644 index 51e6aba2..00000000 --- a/build.gradle +++ /dev/null @@ -1,106 +0,0 @@ -plugins { - id 'base' - id 'magicutils.matrix-root' - id 'magicutils.target' -} - -def baseVersion = project.properties.get('version') ?: '0.0.0' -def publishTargetSuffix = (findProperty('publish_target_suffix')?.toString()?.toBoolean()) ?: false -def targetVersion = publishTargetSuffix - ? "${baseVersion}-${project.extensions.extraProperties.get('magicutilsTargetName')}" - : baseVersion - -def publishingSpec = gradle.extensions.extraProperties.get('magicutilsPublishingSpec') as MagicUtilsPublishingSpec - -allprojects { - group = publishingSpec.group - version = targetVersion -} - -def reflectionPatterns = [ - 'Class.forName(', - '.getMethod(', - '.getDeclaredMethod(', - '.getField(', - '.getDeclaredField(' -] - -def reflectionAllowlistFile = rootProject.file('gradle/reflection-allowlist.txt') - -def collectReflectionOccurrences = { - def projectRoot = rootProject.rootDir.toPath() - def occurrences = [] - fileTree(projectRoot.toFile()) { - include '**/src/main/java/**/*.java' - exclude '**/build/**' - exclude '**/.gradle/**' - exclude '**/.venv/**' - exclude '**/out/**' - }.each { File source -> - def relative = projectRoot.relativize(source.toPath()).toString().replace('\\', '/') - def lines = source.readLines('UTF-8') - lines.eachWithIndex { line, index -> - reflectionPatterns.each { marker -> - if (line.contains(marker)) { - occurrences << "${relative}:${index + 1}:${marker}" - } - } - } - } - return occurrences.toSorted().unique() -} - -tasks.register('refreshReflectionAllowlist') { - group = 'verification' - description = 'Rebuilds gradle/reflection-allowlist.txt from current source tree.' - - doLast { - def occurrences = collectReflectionOccurrences() - reflectionAllowlistFile.parentFile.mkdirs() - reflectionAllowlistFile.setText( - "# Auto-generated by ./gradlew refreshReflectionAllowlist\n" + - "# Each line format: ::\n" + - "# Do not edit manually unless you are intentionally curating this list.\n\n" + - occurrences.join('\n') + '\n', - 'UTF-8' - ) - logger.lifecycle("Reflection allowlist refreshed: ${occurrences.size()} entries -> ${reflectionAllowlistFile}") - } -} - -tasks.register('verifyReflectionBoundaries') { - group = 'verification' - description = 'Fails when new raw reflection markers appear outside the recorded allowlist.' - - doLast { - if (!reflectionAllowlistFile.exists()) { - throw new GradleException( - "Missing ${reflectionAllowlistFile}. Run ./gradlew refreshReflectionAllowlist first." - ) - } - - def allowlist = reflectionAllowlistFile.readLines('UTF-8') - .collect { it.trim() } - .findAll { it && !it.startsWith('#') } as Set - def current = collectReflectionOccurrences() as Set - - def newlyAdded = (current - allowlist).toSorted() - def stale = (allowlist - current).toSorted() - - if (!stale.isEmpty()) { - logger.warn("Reflection allowlist contains stale entries (consider refresh):\n - ${stale.join('\n - ')}") - } - - if (!newlyAdded.isEmpty()) { - def details = "\n - " + newlyAdded.join('\n - ') - throw new GradleException( - "New raw reflection markers detected.${details}\n" + - "Review them and run ./gradlew refreshReflectionAllowlist if accepted." - ) - } - } -} - -tasks.matching { it.name == 'check' }.configureEach { - dependsOn(tasks.named('verifyReflectionBoundaries')) -} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 00000000..de71d207 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,117 @@ +import dev.ua.theroer.magicutils.build.publish.* + +plugins { + id("base") + id("magicutils.matrix-root") + id("magicutils.target") +} + +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. +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 publishingSpec = gradle.extensions.extraProperties.get("magicutilsPublishingSpec") + as MagicUtilsPublishingSpec + +allprojects { + group = publishingSpec.group + version = targetVersion +} + +val reflectionPatterns = listOf( + "Class.forName(", + ".getMethod(", + ".getDeclaredMethod(", + ".getField(", + ".getDeclaredField(", +) + +val reflectionAllowlistFile = rootProject.file("gradle/reflection-allowlist.txt") + +val collectReflectionOccurrences: () -> List = { + val projectRoot = rootProject.rootDir.toPath() + val occurrences = mutableListOf() + fileTree(projectRoot.toFile()) { + include("**/src/main/java/**/*.java") + exclude("**/build/**") + exclude("**/.gradle/**") + exclude("**/.venv/**") + exclude("**/out/**") + }.forEach { source -> + val relative = projectRoot.relativize(source.toPath()).toString().replace('\\', '/') + source.readLines(Charsets.UTF_8).forEachIndexed { index, line -> + reflectionPatterns.forEach { marker -> + if (line.contains(marker)) { + occurrences.add("$relative:${index + 1}:$marker") + } + } + } + } + occurrences.sorted().distinct() +} + +tasks.register("refreshReflectionAllowlist") { + group = "verification" + description = "Rebuilds gradle/reflection-allowlist.txt from current source tree." + + doLast { + val occurrences = collectReflectionOccurrences() + reflectionAllowlistFile.parentFile.mkdirs() + reflectionAllowlistFile.writeText( + "# Auto-generated by ./gradlew refreshReflectionAllowlist\n" + + "# Each line format: ::\n" + + "# Do not edit manually unless you are intentionally curating this list.\n\n" + + occurrences.joinToString("\n") + "\n", + Charsets.UTF_8, + ) + logger.lifecycle("Reflection allowlist refreshed: ${occurrences.size} entries -> $reflectionAllowlistFile") + } +} + +tasks.register("verifyReflectionBoundaries") { + group = "verification" + description = "Fails when new raw reflection markers appear outside the recorded allowlist." + + doLast { + if (!reflectionAllowlistFile.exists()) { + throw GradleException( + "Missing $reflectionAllowlistFile. Run ./gradlew refreshReflectionAllowlist first." + ) + } + + val allowlist = reflectionAllowlistFile.readLines(Charsets.UTF_8) + .map { it.trim() } + .filter { it.isNotEmpty() && !it.startsWith("#") } + .toSet() + val current = collectReflectionOccurrences().toSet() + + val newlyAdded = (current - allowlist).sorted() + val stale = (allowlist - current).sorted() + + if (stale.isNotEmpty()) { + logger.warn("Reflection allowlist contains stale entries (consider refresh):\n - ${stale.joinToString("\n - ")}") + } + + if (newlyAdded.isNotEmpty()) { + val details = "\n - " + newlyAdded.joinToString("\n - ") + throw GradleException( + "New raw reflection markers detected.$details\n" + + "Review them and run ./gradlew refreshReflectionAllowlist if accepted." + ) + } + } +} + +tasks.matching { it.name == "check" }.configureEach { + dependsOn(tasks.named("verifyReflectionBoundaries")) +} diff --git a/bukkit-bundle/build.gradle b/bukkit-bundle/build.gradle.kts similarity index 52% rename from bukkit-bundle/build.gradle rename to bukkit-bundle/build.gradle.kts index fad74a00..70c319e4 100644 --- a/bukkit-bundle/build.gradle +++ b/bukkit-bundle/build.gradle.kts @@ -1,8 +1,11 @@ +import dev.ua.theroer.magicutils.build.target.* + plugins { - id 'magicutils.bukkit-bundle' + id("magicutils.bukkit-bundle") // Local-only dev runner. Not part of the published build; provides `runServer`. - // 3.x uses the PaperMC v3 download API (v2 is gone / HTTP 410). - id 'xyz.jpenilla.run-paper' version '3.0.2' + // 3.x uses the PaperMC v3 download API (v2 is gone / HTTP 410). No version: + // run-paper is on the classpath via build-logic, which pins its version. + id("xyz.jpenilla.run-paper") } magicutilsPublish { @@ -12,14 +15,14 @@ magicutilsPublish { // `./gradlew :bukkit-bundle:runServer -Pscenario=bukkit` // Downloads Paper for the default target (1.21.10) and starts it with the // freshly built MagicUtils plugin jar (the classifier-less shadowJar). -tasks.named('runServer', xyz.jpenilla.runpaper.task.RunServer) { - minecraftVersion('1.21.10') - pluginJars(tasks.named('shadowJar').flatMap { it.archiveFile }) +tasks.named("runServer") { + minecraftVersion("1.21.10") + pluginJars(tasks.named("shadowJar").flatMap { it.archiveFile }) // Auto-accept the Minecraft EULA for unattended smoke runs (dev/test only). - def runDir = layout.projectDirectory.dir('run') + val runDir = layout.projectDirectory.dir("run") doFirst { - def dir = runDir.asFile + val dir = runDir.asFile dir.mkdirs() - new File(dir, 'eula.txt').text = 'eula=true\n' + dir.resolve("eula.txt").writeText("eula=true\n") } } diff --git a/bukkit-bundle/src/main/java/dev/ua/theroer/magicutils/bukkit/MagicUtilsBukkitBundlePlugin.java b/bukkit-bundle/src/main/java/dev/ua/theroer/magicutils/bukkit/MagicUtilsBukkitBundlePlugin.java index a88bcfc7..fd695dc1 100644 --- a/bukkit-bundle/src/main/java/dev/ua/theroer/magicutils/bukkit/MagicUtilsBukkitBundlePlugin.java +++ b/bukkit-bundle/src/main/java/dev/ua/theroer/magicutils/bukkit/MagicUtilsBukkitBundlePlugin.java @@ -6,8 +6,9 @@ import dev.ua.theroer.magicutils.commands.CommandRegistry; import dev.ua.theroer.magicutils.diagnostics.DiagnosticsCommandSupport; import dev.ua.theroer.magicutils.diagnostics.DiagnosticsService; +import dev.ua.theroer.magicutils.diagnostics.MagicUtilsBundleCommand; import dev.ua.theroer.magicutils.platform.bukkit.BukkitMagicUtilsConsumerRegistry; -import dev.ua.theroer.magicutils.platform.bukkit.BukkitMagicUtilsConsumerRegistry.ConsumerInfo; +import dev.ua.theroer.magicutils.platform.MagicUtilsConsumerInfo; import java.util.ArrayList; import java.util.Comparator; import java.util.List; @@ -22,7 +23,7 @@ */ public final class MagicUtilsBukkitBundlePlugin extends JavaPlugin { private MagicRuntime runtime; - private final Map sharedRuntimeConsumers = new ConcurrentHashMap<>(); + private final Map sharedRuntimeConsumers = new ConcurrentHashMap<>(); /** * Creates a new instance of the MagicUtils bundle plugin. @@ -51,9 +52,12 @@ public void onEnable() { // Register the diagnostics suite-name parser so `/magicutils diagnostics suite ` resolves. DiagnosticsCommandSupport.registerTypeParsers(commandRegistry.commandManager()); commandRegistry.registerCommand(new MagicUtilsBundleCommand( - this, logger != null ? logger.getCore() : null, - this::diagnosticsService)); + getPluginMeta().getVersion(), + this::snapshotSharedRuntimeConsumers, + this::findSharedRuntimeConsumer, + this::diagnosticsService, + commandRegistry::commandManager)); } getLogger().info("MagicUtils Bukkit bundle loaded."); } @@ -71,7 +75,7 @@ public void registerSharedRuntimeConsumer(JavaPlugin plugin, Map if (plugin == null || plugin == this || payload == null) { return; } - ConsumerInfo consumerInfo = BukkitMagicUtilsConsumerRegistry.consumerInfo(plugin, payload); + MagicUtilsConsumerInfo consumerInfo = BukkitMagicUtilsConsumerRegistry.consumerInfo(plugin, payload); sharedRuntimeConsumers.put(normalizeKey(consumerInfo.pluginName()), consumerInfo); } @@ -82,9 +86,9 @@ public void unregisterSharedRuntimeConsumer(JavaPlugin plugin) { sharedRuntimeConsumers.remove(normalizeKey(plugin.getName())); } - public List snapshotSharedRuntimeConsumers() { - List consumers = new ArrayList<>(sharedRuntimeConsumers.values()); - consumers.sort(Comparator.comparing(ConsumerInfo::pluginName, String.CASE_INSENSITIVE_ORDER)); + public List snapshotSharedRuntimeConsumers() { + List consumers = new ArrayList<>(sharedRuntimeConsumers.values()); + consumers.sort(Comparator.comparing(MagicUtilsConsumerInfo::pluginName, String.CASE_INSENSITIVE_ORDER)); return List.copyOf(consumers); } @@ -101,7 +105,7 @@ public List snapshotSharedRuntimeConsumers() { return current.findComponent(DiagnosticsService.class).orElse(null); } - public @Nullable ConsumerInfo findSharedRuntimeConsumer(String pluginName) { + public @Nullable MagicUtilsConsumerInfo findSharedRuntimeConsumer(String pluginName) { if (pluginName == null || pluginName.isBlank()) { return null; } diff --git a/commands-brigadier/build.gradle b/commands-brigadier/build.gradle deleted file mode 100644 index ca3107f9..00000000 --- a/commands-brigadier/build.gradle +++ /dev/null @@ -1,17 +0,0 @@ -plugins { - id 'magicutils.java-library' - id 'magicutils.publishing' -} - -magicutilsPublish { - category = MagicUtilsPublishCategory.COMMON_MATRIX -} - -dependencies { - api project(':commands') - compileOnly libs.brigadier - compileOnly libs.jetbrains.annotations - testImplementation libs.brigadier - testImplementation libs.junit.jupiter - testRuntimeOnly libs.junit.platform.launcher -} diff --git a/commands-brigadier/build.gradle.kts b/commands-brigadier/build.gradle.kts new file mode 100644 index 00000000..6c748cea --- /dev/null +++ b/commands-brigadier/build.gradle.kts @@ -0,0 +1,19 @@ +import dev.ua.theroer.magicutils.build.target.* + +plugins { + id("magicutils.java-library") + id("magicutils.publishing") +} + +magicutilsPublish { + category = MagicUtilsPublishCategory.COMMON_MATRIX +} + +dependencies { + api(project(":commands")) + compileOnly(libs.brigadier) + compileOnly(libs.jetbrains.annotations) + testImplementation(libs.brigadier) + testImplementation(libs.junit.jupiter) + testRuntimeOnly(libs.junit.platform.launcher) +} diff --git a/commands-fabric/build.gradle b/commands-fabric/build.gradle deleted file mode 100644 index 1a2577e7..00000000 --- a/commands-fabric/build.gradle +++ /dev/null @@ -1,20 +0,0 @@ -plugins { - id 'magicutils.fabric-module' -} - -magicutilsPublish { - category = MagicUtilsPublishCategory.FABRIC_MATRIX -} - -dependencies { - api project(':commands-brigadier') - api project(':diagnostics') - api project(':platform-fabric') - api project(':logger-fabric') - - compileOnly libs.jetbrains.annotations - compileOnly libs.projectlombok.lombok - annotationProcessor libs.projectlombok.lombok - testImplementation libs.junit.jupiter - testRuntimeOnly libs.junit.platform.launcher -} diff --git a/commands-fabric/build.gradle.kts b/commands-fabric/build.gradle.kts new file mode 100644 index 00000000..eb34b332 --- /dev/null +++ b/commands-fabric/build.gradle.kts @@ -0,0 +1,22 @@ +import dev.ua.theroer.magicutils.build.target.* + +plugins { + id("magicutils.fabric-module") +} + +magicutilsPublish { + category = MagicUtilsPublishCategory.FABRIC_MATRIX +} + +dependencies { + api(project(":commands-brigadier")) + api(project(":diagnostics")) + api(project(":platform-fabric")) + api(project(":logger-fabric")) + + compileOnly(libs.jetbrains.annotations) + compileOnly(libs.projectlombok.lombok) + annotationProcessor(libs.projectlombok.lombok) + testImplementation(libs.junit.jupiter) + testRuntimeOnly(libs.junit.platform.launcher) +} 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 59186c4e..e61de39a 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,12 +8,21 @@ 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.MagicUtilsConsumerInfo; import dev.ua.theroer.magicutils.platform.Platform; import dev.ua.theroer.magicutils.platform.fabric.FabricPlatformProvider; +import dev.ua.theroer.magicutils.platform.fabric.MagicUtilsFabricConsumerRegistry; +import net.fabricmc.loader.api.FabricLoader; +import net.fabricmc.loader.api.metadata.ModMetadata; +import net.fabricmc.loader.api.metadata.Person; import net.minecraft.server.MinecraftServer; import org.slf4j.LoggerFactory; import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; import java.util.function.Consumer; import java.util.function.Supplier; import org.jetbrains.annotations.Nullable; @@ -340,10 +349,49 @@ public RuntimeResult buildRuntime() { }); } + // Register this mod in the shared-runtime consumer registry so the + // standalone bundle command can list it (/magicutils mods|mod ), + // mirroring the Bukkit bundle's plugins/plugin sub-commands. + registerSharedRuntimeConsumer(runtime, prepared.commandRegistry()); + runtime.onClose("magicutils.consumerRegistry", + () -> MagicUtilsFabricConsumerRegistry.unregister(modName)); + return new RuntimeResult(runtime, prepared.platform(), prepared.configManager(), prepared.logger(), prepared.languageManager(), prepared.commandRegistry()); } + private void registerSharedRuntimeConsumer(MagicRuntime runtime, @Nullable CommandRegistry commandRegistry) { + boolean commandsEnabled = commandRegistry != null; + int rootCommandCount = commandsEnabled && commandRegistry.commandManager() != null + ? commandRegistry.commandManager().getAll().size() + : 0; + String prefix = commandsEnabled + ? (permissionPrefix != null ? permissionPrefix : modName) + : null; + boolean diagnosticsEnabled = runtime.findComponent(DiagnosticsService.class).isPresent(); + + Optional metadata = FabricLoader.getInstance() + .getModContainer(modName) + .map(container -> container.getMetadata()); + String version = metadata.map(m -> m.getVersion().getFriendlyString()).orElse("unknown"); + String name = metadata.map(ModMetadata::getName).filter(s -> !s.isBlank()).orElse(modName); + String description = metadata.map(ModMetadata::getDescription).filter(s -> !s.isBlank()).orElse(null); + List authors = new ArrayList<>(); + metadata.ifPresent(m -> { + for (Person person : m.getAuthors()) { + authors.add(person.getName()); + } + }); + + var payload = MagicUtilsConsumerPayloads.runtimePayload( + runtime, name, version, + // Fabric mods have no single "main class"; use the mod id. + modName, description, null, authors, + commandsEnabled, prefix, rootCommandCount, diagnosticsEnabled, Instant.now()); + MagicUtilsFabricConsumerRegistry.register( + MagicUtilsConsumerInfo.fromPayload(payload, name, version, modName, description, null, authors)); + } + private Prepared prepare() { Platform resolvedPlatform = platform != null ? platform diff --git a/commands-neoforge/build.gradle b/commands-neoforge/build.gradle deleted file mode 100644 index 347f7a96..00000000 --- a/commands-neoforge/build.gradle +++ /dev/null @@ -1,49 +0,0 @@ -plugins { - id 'magicutils.java-library' - id 'magicutils.annotation-processing' - id 'magicutils.publishing' - id 'net.neoforged.moddev' version '2.0.139' -} - -magicutilsPublish { - category = MagicUtilsPublishCategory.DEFAULT_ONLY -} - -def target = project.extensions.getByType(MagicUtilsTargetExtension) - -neoForge { - version = target.neoforge.get() -} - -configurations { - shadowRuntimeClasspath { - canBeResolved = true - canBeConsumed = false - extendsFrom runtimeClasspath - } -} - -configurations.shadowRuntimeClasspath { - exclude group: "net.neoforged" -} - -tasks.named("shadowJar", com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { - configurations = [project.configurations.shadowRuntimeClasspath] -} - -dependencies { - api project(':commands-brigadier') - api project(':diagnostics') - api project(':platform-neoforge') - implementation libs.kyori.adventure.text.serializer.plain - compileOnly libs.brigadier - compileOnly libs.jetbrains.annotations - compileOnly libs.projectlombok.lombok - annotationProcessor libs.projectlombok.lombok - compileOnly libs.slf4j.api - compileOnly(libs.neoforge) { - version { - strictly magicutilsTarget.neoforge.get() - } - } -} diff --git a/commands-neoforge/build.gradle.kts b/commands-neoforge/build.gradle.kts new file mode 100644 index 00000000..96396583 --- /dev/null +++ b/commands-neoforge/build.gradle.kts @@ -0,0 +1,47 @@ +import dev.ua.theroer.magicutils.build.target.* +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar + +plugins { + id("magicutils.java-library") + id("magicutils.annotation-processing") + id("magicutils.publishing") + id("net.neoforged.moddev") +} + +magicutilsPublish { + category = MagicUtilsPublishCategory.COMMON_MATRIX +} + +val target = project.extensions.getByType(MagicUtilsTargetExtension::class.java) + +neoForge { + version = target.neoforge.get() +} + +val shadowRuntimeClasspath by configurations.creating { + isCanBeResolved = true + isCanBeConsumed = false + extendsFrom(configurations["runtimeClasspath"]) + exclude(group = "net.neoforged") +} + +tasks.named("shadowJar") { + configurations = listOf(shadowRuntimeClasspath) +} + +dependencies { + api(project(":commands-brigadier")) + api(project(":diagnostics")) + api(project(":platform-neoforge")) + implementation(libs.kyori.adventure.text.serializer.plain) + compileOnly(libs.brigadier) + compileOnly(libs.jetbrains.annotations) + compileOnly(libs.projectlombok.lombok) + annotationProcessor(libs.projectlombok.lombok) + compileOnly(libs.slf4j.api) + compileOnly(libs.neoforge) { + version { + strictly(target.neoforge.get()) + } + } +} 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 c3e71ca7..2a3b1ff9 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 @@ -8,12 +8,23 @@ 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.MagicUtilsConsumerInfo; +import dev.ua.theroer.magicutils.platform.MagicUtilsConsumerRegistry; import dev.ua.theroer.magicutils.platform.Platform; import dev.ua.theroer.magicutils.platform.neoforge.NeoForgePlatformProvider; import net.minecraft.server.MinecraftServer; +import net.neoforged.fml.ModContainer; +import net.neoforged.fml.ModList; +import net.neoforged.neoforgespi.language.IModInfo; import org.slf4j.LoggerFactory; import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; import java.util.function.Consumer; import java.util.function.Supplier; import org.jetbrains.annotations.Nullable; @@ -340,10 +351,67 @@ public RuntimeResult buildRuntime() { }); } + // Publish this mod into the shared-runtime registry so the bundle's + // `/magicutils mods` lists it (mirrors FabricBootstrap). + registerSharedRuntimeConsumer(runtime, prepared.commandRegistry()); + runtime.onClose("sharedRuntimeConsumer", + () -> MagicUtilsConsumerRegistry.unregister(modName)); + return new RuntimeResult(runtime, prepared.platform(), prepared.configManager(), prepared.logger(), prepared.languageManager(), prepared.commandRegistry()); } + private void registerSharedRuntimeConsumer(MagicRuntime runtime, @Nullable CommandRegistry commandRegistry) { + boolean commandsEnabled = commandRegistry != null; + int rootCommandCount = commandsEnabled && commandRegistry.commandManager() != null + ? commandRegistry.commandManager().getAll().size() + : 0; + String prefix = commandsEnabled + ? (permissionPrefix != null ? permissionPrefix : modName) + : null; + boolean diagnosticsEnabled = runtime.findComponent(DiagnosticsService.class).isPresent(); + + // Look up the mod's metadata by its id. `modName` is the logical/display + // name ("MagicUtils"); NeoForge keys containers by lowercase mod id + // ("magicutils"), so try the id form first, then the raw name. + // Reading IModInfo#getVersion can resolve a maven range against the game + // version and throw "Game version not set" if FML isn't fully ready yet — + // guard the whole read so it can never abort mod construction. + Optional modInfo = Optional.empty(); + String version = "unknown"; + String name = modName; + String description = null; + try { + ModList modList = ModList.get(); + if (modList != null) { + Optional container = + modList.getModContainerById(modName.toLowerCase(Locale.ROOT)); + if (container.isEmpty()) { + container = modList.getModContainerById(modName); + } + modInfo = container.map(ModContainer::getModInfo); + } + version = modInfo.map(m -> m.getVersion().toString()).orElse("unknown"); + name = modInfo.map(m -> m.getDisplayName()).filter(s -> !s.isBlank()).orElse(modName); + description = modInfo.map(m -> m.getDescription()).filter(s -> !s.isBlank()).orElse(null); + } catch (RuntimeException ex) { + // FML not ready (e.g. called during mod construction) — fall back to + // the logical name and let `/magicutils mods` show "unknown" version. + version = "unknown"; + name = modName; + description = null; + } + List authors = new ArrayList<>(); + + Map payload = MagicUtilsConsumerPayloads.runtimePayload( + runtime, name, version, + // NeoForge mods have no single "main class"; use the mod id. + modName, description, null, authors, + commandsEnabled, prefix, rootCommandCount, diagnosticsEnabled, Instant.now()); + MagicUtilsConsumerRegistry.register( + MagicUtilsConsumerInfo.fromPayload(payload, name, version, modName, description, null, authors)); + } + private Prepared prepare() { Platform resolvedPlatform = platform != null ? platform diff --git a/commands/build.gradle b/commands/build.gradle deleted file mode 100644 index e0b8ebd8..00000000 --- a/commands/build.gradle +++ /dev/null @@ -1,18 +0,0 @@ -plugins { - id 'magicutils.java-library' - id 'magicutils.annotation-processing' - id 'magicutils.publishing' -} - -magicutilsPublish { - category = MagicUtilsPublishCategory.COMMON_MATRIX -} - -dependencies { - api project(':logger') - compileOnly libs.jetbrains.annotations - compileOnly libs.projectlombok.lombok - annotationProcessor libs.projectlombok.lombok - testImplementation libs.junit.jupiter - testRuntimeOnly libs.junit.platform.launcher -} diff --git a/commands/build.gradle.kts b/commands/build.gradle.kts new file mode 100644 index 00000000..3cd6f1e0 --- /dev/null +++ b/commands/build.gradle.kts @@ -0,0 +1,20 @@ +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(":logger")) + compileOnly(libs.jetbrains.annotations) + compileOnly(libs.projectlombok.lombok) + annotationProcessor(libs.projectlombok.lombok) + testImplementation(libs.junit.jupiter) + testRuntimeOnly(libs.junit.platform.launcher) +} diff --git a/config-toml/build.gradle b/config-toml/build.gradle deleted file mode 100644 index b07ea94d..00000000 --- a/config-toml/build.gradle +++ /dev/null @@ -1,17 +0,0 @@ -plugins { - id 'magicutils.java-library' - id 'magicutils.annotation-processing' - id 'magicutils.publishing' - id 'magicutils.shaded-module' -} - -magicutilsPublish { - category = MagicUtilsPublishCategory.COMMON_MATRIX -} - -dependencies { - api project(':config') - implementation libs.jackson.dataformat.toml - compileOnly libs.projectlombok.lombok - annotationProcessor libs.projectlombok.lombok -} \ No newline at end of file diff --git a/config-toml/build.gradle.kts b/config-toml/build.gradle.kts new file mode 100644 index 00000000..53512b80 --- /dev/null +++ b/config-toml/build.gradle.kts @@ -0,0 +1,19 @@ +import dev.ua.theroer.magicutils.build.target.* + +plugins { + id("magicutils.java-library") + id("magicutils.annotation-processing") + id("magicutils.publishing") + id("magicutils.shaded-module") +} + +magicutilsPublish { + category = MagicUtilsPublishCategory.COMMON_MATRIX +} + +dependencies { + api(project(":config")) + implementation(libs.jackson.dataformat.toml) + compileOnly(libs.projectlombok.lombok) + annotationProcessor(libs.projectlombok.lombok) +} \ No newline at end of file diff --git a/config-yaml/build.gradle b/config-yaml/build.gradle deleted file mode 100644 index 6afd471e..00000000 --- a/config-yaml/build.gradle +++ /dev/null @@ -1,17 +0,0 @@ -plugins { - id 'magicutils.java-library' - id 'magicutils.annotation-processing' - id 'magicutils.publishing' - id 'magicutils.shaded-module' -} - -magicutilsPublish { - category = MagicUtilsPublishCategory.COMMON_MATRIX -} - -dependencies { - api project(':config') - implementation libs.jackson.dataformat.yaml - compileOnly libs.projectlombok.lombok - annotationProcessor libs.projectlombok.lombok -} \ No newline at end of file diff --git a/config-yaml/build.gradle.kts b/config-yaml/build.gradle.kts new file mode 100644 index 00000000..cf914762 --- /dev/null +++ b/config-yaml/build.gradle.kts @@ -0,0 +1,19 @@ +import dev.ua.theroer.magicutils.build.target.* + +plugins { + id("magicutils.java-library") + id("magicutils.annotation-processing") + id("magicutils.publishing") + id("magicutils.shaded-module") +} + +magicutilsPublish { + category = MagicUtilsPublishCategory.COMMON_MATRIX +} + +dependencies { + api(project(":config")) + implementation(libs.jackson.dataformat.yaml) + compileOnly(libs.projectlombok.lombok) + annotationProcessor(libs.projectlombok.lombok) +} \ No newline at end of file diff --git a/config/build.gradle b/config/build.gradle deleted file mode 100644 index 8ca8ad2e..00000000 --- a/config/build.gradle +++ /dev/null @@ -1,21 +0,0 @@ -plugins { - id 'magicutils.java-library' - id 'magicutils.annotation-processing' - id 'magicutils.publishing' - id 'magicutils.shaded-module' -} - -magicutilsPublish { - category = MagicUtilsPublishCategory.COMMON_MATRIX -} - -dependencies { - api project(':platform-api') - implementation libs.jackson.databind - compileOnly libs.jetbrains.annotations - compileOnly libs.projectlombok.lombok - annotationProcessor libs.projectlombok.lombok - testImplementation libs.junit.jupiter - testRuntimeOnly libs.junit.platform.launcher - testRuntimeOnly project(':config-toml') -} diff --git a/config/build.gradle.kts b/config/build.gradle.kts new file mode 100644 index 00000000..696b3872 --- /dev/null +++ b/config/build.gradle.kts @@ -0,0 +1,23 @@ +import dev.ua.theroer.magicutils.build.target.* + +plugins { + id("magicutils.java-library") + id("magicutils.annotation-processing") + id("magicutils.publishing") + id("magicutils.shaded-module") +} + +magicutilsPublish { + category = MagicUtilsPublishCategory.COMMON_MATRIX +} + +dependencies { + api(project(":platform-api")) + implementation(libs.jackson.databind) + compileOnly(libs.jetbrains.annotations) + compileOnly(libs.projectlombok.lombok) + annotationProcessor(libs.projectlombok.lombok) + testImplementation(libs.junit.jupiter) + testRuntimeOnly(libs.junit.platform.launcher) + testRuntimeOnly(project(":config-toml")) +} diff --git a/config/src/main/java/dev/ua/theroer/magicutils/config/ConfigManager.java b/config/src/main/java/dev/ua/theroer/magicutils/config/ConfigManager.java index 59a9e7b7..17523fc2 100644 --- a/config/src/main/java/dev/ua/theroer/magicutils/config/ConfigManager.java +++ b/config/src/main/java/dev/ua/theroer/magicutils/config/ConfigManager.java @@ -439,6 +439,7 @@ private void createDefaultConfig(T instance, File configFile, ConfigMetadata writeDefaults(instance, document, instance.getClass(), ""); document.save(configFile); + flushExternalFields(instance, instance.getClass(), ""); logger.info("Created default config: " + configFile.getName()); } @@ -451,6 +452,10 @@ private void writeDefaults(Object instance, ConfigDocument document, Class cl String path = getFieldPath(field, prefix); + if (field.getAnnotation(SaveTo.class) != null) { + continue; + } + ConfigSection section = field.getAnnotation(ConfigSection.class); if (section != null) { Object sectionInstance = field.get(instance); @@ -520,7 +525,14 @@ private void processFields(Object instance, ConfigDocument document, Class cl ConfigValue configValue = field.getAnnotation(ConfigValue.class); if (configValue != null) { - loadFieldValue(instance, field, document, path); + SaveTo saveTo = field.getAnnotation(SaveTo.class); + ConfigDocument source = document; + if (saveTo != null) { + File file = new ConfigMetadata(saveTo.value(), true, null, platform.configDir()) + .resolveFile(platform.configDir()); + source = ConfigDocument.load(file); + } + loadFieldValue(instance, field, source, path); } } } @@ -676,6 +688,9 @@ private void loadFieldValue(Object instance, Field field, ConfigDocument documen } } + // Clamp @MinValue/@MaxValue on plain fields too (no-op without bounds). + value = ConfigSerializer.validateNumericBounds(logger, field, value); + try { field.set(instance, cloneIfNeeded(value)); } catch (IllegalArgumentException e) { @@ -1437,8 +1452,7 @@ private void saveFields(Object instance, ConfigDocument document, Class clazz value = cloneIfNeeded(value); field.set(instance, value); - SaveTo saveTo = field.getAnnotation(SaveTo.class); - if (saveTo != null) { + if (field.getAnnotation(SaveTo.class) != null) { continue; } @@ -1465,6 +1479,61 @@ private void saveConfigToFile(Object instance, ConfigMetadata metadata, String s File file = metadata.resolveFile(platform.configDir()); ensureParentDirectory(file); document.save(file); + flushExternalFields(instance, instance.getClass(), ""); + } + + /** + * Writes every {@link SaveTo} field to its own file. Fields sharing a + * {@code @SaveTo} path go into one document, keyed by the field's config path. + */ + private void flushExternalFields(Object instance, Class clazz, String prefix) throws Exception { + Map externalDocs = new LinkedHashMap<>(); + collectExternalFields(instance, clazz, prefix, externalDocs); + for (Map.Entry entry : externalDocs.entrySet()) { + File file = new ConfigMetadata(entry.getKey(), true, null, platform.configDir()) + .resolveFile(platform.configDir()); + ensureParentDirectory(file); + entry.getValue().save(file); + } + } + + private void collectExternalFields(Object instance, Class clazz, String prefix, + Map externalDocs) throws Exception { + for (Field field : getConfigFields(clazz)) { + field.setAccessible(true); + + if (!isConfigField(field)) + continue; + + String path = getFieldPath(field, prefix); + + SaveTo saveTo = field.getAnnotation(SaveTo.class); + if (saveTo != null) { + Object value = field.get(instance); + if (value == null) { + value = getDefaultValue(field); + } + if (value == null && field.getType().isPrimitive()) { + value = getPrimitiveDefault(field.getType()); + } + if (value == null) { + continue; + } + ConfigDocument document = + externalDocs.computeIfAbsent(saveTo.value(), k -> ConfigDocument.empty()); + document.set(path, prepareForConfig(value, field), + commentLines(field.getAnnotation(Comment.class))); + continue; + } + + ConfigSection section = field.getAnnotation(ConfigSection.class); + if (section != null) { + Object sectionInstance = field.get(instance); + if (sectionInstance != null) { + collectExternalFields(sectionInstance, field.getType(), "", externalDocs); + } + } + } } /** @@ -1741,6 +1810,7 @@ private void saveEntry(ConfigEntry entry) { ensureParentDirectory(entry.file); document.save(entry.file); + flushExternalFields(entry.instance, entry.instance.getClass(), ""); entry.refreshLastModified(); } catch (Exception e) { logger.error("Failed to save config " + entry.key.configClass.getName() + " (" + entry.metadata.getFilePath() diff --git a/config/src/main/java/dev/ua/theroer/magicutils/config/ConfigSerializer.java b/config/src/main/java/dev/ua/theroer/magicutils/config/ConfigSerializer.java index 9b225cfb..3a92b891 100644 --- a/config/src/main/java/dev/ua/theroer/magicutils/config/ConfigSerializer.java +++ b/config/src/main/java/dev/ua/theroer/magicutils/config/ConfigSerializer.java @@ -29,9 +29,9 @@ private ConfigSerializer() { * @param obj the object to serialize * @return the serialized map representation */ - public static Map serialize(Object obj) { - if (obj == null) - return null; + public static Map serialize(Object obj) { + if (obj == null) + return null; Map result = new LinkedHashMap<>(); Class clazz = obj.getClass(); @@ -41,8 +41,8 @@ public static Map serialize(Object obj) { boolean includeNulls = serializable != null && serializable.includeNulls(); // Process all fields - for (Field field : getSerializableFields(clazz)) { - field.setAccessible(true); + for (Field field : getSerializableFields(clazz)) { + field.setAccessible(true); // Skip transient fields if (Modifier.isTransient(field.getModifiers())) { @@ -105,7 +105,7 @@ public static Map serialize(Object obj) { * @throws SecurityException if the class is not marked as @ConfigSerializable */ @SuppressWarnings("unchecked") - public static T deserialize(PlatformLogger logger, Map data, Class clazz) { + public static T deserialize(PlatformLogger logger, Map data, Class clazz) { // Security check: only allow deserialization of classes marked with @ConfigSerializable if (!clazz.isAnnotationPresent(ConfigSerializable.class)) { throw new SecurityException( @@ -126,23 +126,23 @@ public static T deserialize(PlatformLogger logger, Map data, ); } - try { - T instance = clazz.getDeclaredConstructor().newInstance(); - - for (Field field : getSerializableFields(clazz)) { - field.setAccessible(true); + try { + T instance = clazz.getDeclaredConstructor().newInstance(); + + for (Field field : getSerializableFields(clazz)) { + field.setAccessible(true); String key = resolveKey(field); if (!data.containsKey(key)) continue; - Object value = data.get(key); - if (value == null) { - if (!field.getType().isPrimitive()) { - field.set(instance, null); - } - continue; - } + Object value = data.get(key); + if (value == null) { + if (!field.getType().isPrimitive()) { + field.set(instance, null); + } + continue; + } try { Class fieldType = field.getType(); @@ -150,35 +150,35 @@ public static T deserialize(PlatformLogger logger, Map data, // Handle different types ConfigValueAdapter adapter = ConfigAdapters.get(fieldType); - if (adapter != null) { - ConfigValueAdapter typed = (ConfigValueAdapter) adapter; - deserializedValue = typed.deserialize(value); - } else if (isPrimitiveOrWrapper(fieldType) || fieldType == String.class) { - deserializedValue = convertValue(value, fieldType); - } else if (List.class.isAssignableFrom(fieldType) && value instanceof List) { - Class elementType = extractListElementType(field); - deserializedValue = deserializeList(logger, (List) value, elementType); - } else if (Map.class.isAssignableFrom(fieldType) && value instanceof Map) { - Class valueType = extractMapValueType(field); - deserializedValue = deserializeMap(logger, (Map) value, valueType); - } else if (fieldType.isAnnotationPresent(ConfigSerializable.class) && value instanceof Map) { - // Recursive deserialization - security check is already in deserialize method - deserializedValue = deserialize(logger, (Map) value, fieldType); + if (adapter != null) { + ConfigValueAdapter typed = (ConfigValueAdapter) adapter; + deserializedValue = typed.deserialize(value); + } else if (isPrimitiveOrWrapper(fieldType) || fieldType == String.class) { + deserializedValue = convertValue(value, fieldType); + } else if (List.class.isAssignableFrom(fieldType) && value instanceof List) { + Class elementType = extractListElementType(field); + deserializedValue = deserializeList(logger, (List) value, elementType); + } else if (Map.class.isAssignableFrom(fieldType) && value instanceof Map) { + Class valueType = extractMapValueType(field); + deserializedValue = deserializeMap(logger, (Map) value, valueType); + } else if (fieldType.isAnnotationPresent(ConfigSerializable.class) && value instanceof Map) { + // Recursive deserialization - security check is already in deserialize method + deserializedValue = deserialize(logger, (Map) value, fieldType); } // Validate numeric bounds before setting if (deserializedValue != null) { deserializedValue = validateNumericBounds(logger, field, deserializedValue); } - - field.set(instance, deserializedValue); - } catch (ReflectiveOperationException | ClassCastException | IllegalArgumentException e) { - if (logger != null) { - logger.warn("Failed to deserialize field '" + field.getName() - + "' in " + clazz.getName() + ". Keeping current/default value.", e); - } - } - } + + field.set(instance, deserializedValue); + } catch (ReflectiveOperationException | ClassCastException | IllegalArgumentException e) { + if (logger != null) { + logger.warn("Failed to deserialize field '" + field.getName() + + "' in " + clazz.getName() + ". Keeping current/default value.", e); + } + } + } return instance; @@ -221,24 +221,24 @@ private static List serializeList(List list, Class elementType) { * Deserializes a list. */ @SuppressWarnings("unchecked") - private static List deserializeList(PlatformLogger logger, List data, Class elementType) { - List result = new ArrayList<>(); - - ConfigValueAdapter adapter = elementType != null ? ConfigAdapters.get(elementType) : null; - - for (Object item : data) { - if (item == null) { - result.add(null); - } else if (adapter != null) { - ConfigValueAdapter typed = (ConfigValueAdapter) adapter; - result.add((T) typed.deserialize(item)); - } else if (elementType != null && (isPrimitiveOrWrapper(elementType) || elementType == String.class)) { - result.add((T) convertValue(item, elementType)); - } else if (elementType != null && elementType.isAnnotationPresent(ConfigSerializable.class) && item instanceof Map) { - // Recursive deserialization - security check is already in deserialize method - result.add(deserialize(logger, (Map) item, elementType)); - } else { - result.add((T) item); + private static List deserializeList(PlatformLogger logger, List data, Class elementType) { + List result = new ArrayList<>(); + + ConfigValueAdapter adapter = elementType != null ? ConfigAdapters.get(elementType) : null; + + for (Object item : data) { + if (item == null) { + result.add(null); + } else if (adapter != null) { + ConfigValueAdapter typed = (ConfigValueAdapter) adapter; + result.add((T) typed.deserialize(item)); + } else if (elementType != null && (isPrimitiveOrWrapper(elementType) || elementType == String.class)) { + result.add((T) convertValue(item, elementType)); + } else if (elementType != null && elementType.isAnnotationPresent(ConfigSerializable.class) && item instanceof Map) { + // Recursive deserialization - security check is already in deserialize method + result.add(deserialize(logger, (Map) item, elementType)); + } else { + result.add((T) item); } } @@ -339,10 +339,10 @@ private static String resolveKey(Field field) { * Deserializes a map using adapters/serializable types for values. */ @SuppressWarnings("unchecked") - private static Map deserializeMap(PlatformLogger logger, Map data, Class valueType) { - Map result = new LinkedHashMap<>(); - - ConfigValueAdapter adapter = valueType != null ? ConfigAdapters.get(valueType) : null; + private static Map deserializeMap(PlatformLogger logger, Map data, Class valueType) { + Map result = new LinkedHashMap<>(); + + ConfigValueAdapter adapter = valueType != null ? ConfigAdapters.get(valueType) : null; for (Map.Entry entry : data.entrySet()) { String key = String.valueOf(entry.getKey()); @@ -358,33 +358,33 @@ private static Map deserializeMap(PlatformLogger logger, Map) raw, valueType)); - } else { - if (valueType != null) { // Only warn if a specific type was expected - if (logger != null) { - logger.warn("Unknown or unhandled map value type '" + valueType.getName() - + "' for key '" + key + "'. Falling back to raw value. Value: " + raw); - } - } - result.put(key, raw); - } - } - - return result; - } - - private static List getSerializableFields(Class clazz) { - List> hierarchy = new ArrayList<>(); - for (Class current = clazz; current != null && current != Object.class; current = current.getSuperclass()) { - hierarchy.add(current); - } - Collections.reverse(hierarchy); - - List fields = new ArrayList<>(); - for (Class current : hierarchy) { - fields.addAll(Arrays.asList(current.getDeclaredFields())); - } - return fields; - } + } else { + if (valueType != null) { // Only warn if a specific type was expected + if (logger != null) { + logger.warn("Unknown or unhandled map value type '" + valueType.getName() + + "' for key '" + key + "'. Falling back to raw value. Value: " + raw); + } + } + result.put(key, raw); + } + } + + return result; + } + + private static List getSerializableFields(Class clazz) { + List> hierarchy = new ArrayList<>(); + for (Class current = clazz; current != null && current != Object.class; current = current.getSuperclass()) { + hierarchy.add(current); + } + Collections.reverse(hierarchy); + + List fields = new ArrayList<>(); + for (Class current : hierarchy) { + fields.addAll(Arrays.asList(current.getDeclaredFields())); + } + return fields; + } /** * Converts value to target type. @@ -423,7 +423,7 @@ private static Object convertValue(Object value, Class targetType) { * @param value the value to validate * @return the clamped value, or the original if no clamping is needed */ - private static Object validateNumericBounds(PlatformLogger logger, Field field, Object value) { + static Object validateNumericBounds(PlatformLogger logger, Field field, Object value) { if (value == null) { return null; } diff --git a/config/src/test/java/dev/ua/theroer/magicutils/config/ConfigManagerRegressionTest.java b/config/src/test/java/dev/ua/theroer/magicutils/config/ConfigManagerRegressionTest.java index 89c1b9ac..bc22b526 100644 --- a/config/src/test/java/dev/ua/theroer/magicutils/config/ConfigManagerRegressionTest.java +++ b/config/src/test/java/dev/ua/theroer/magicutils/config/ConfigManagerRegressionTest.java @@ -4,6 +4,9 @@ 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 dev.ua.theroer.magicutils.config.annotations.MaxValue; +import dev.ua.theroer.magicutils.config.annotations.MinValue; +import dev.ua.theroer.magicutils.config.annotations.SaveTo; import dev.ua.theroer.magicutils.platform.Audience; import dev.ua.theroer.magicutils.platform.ListenerSubscription; import dev.ua.theroer.magicutils.platform.Platform; @@ -219,6 +222,51 @@ void reloadRetriesTransientConcurrentModificationForLists() throws IOException { } } + @Test + void clampsMinMaxOnPlainConfigValueFields() throws IOException { + TestPlatform platform = new TestPlatform(tempDir); + ConfigManager manager = new ConfigManager(platform); + try { + BoundedConfig config = manager.register(BoundedConfig.class); + + Files.writeString(tempDir.resolve("bounded.json"), + "{\n \"volume\" : 250,\n \"depth\" : -5\n}\n"); + + manager.reload(config); + + assertEquals(100, config.volume); + assertEquals(0, config.depth); + } finally { + manager.shutdown(); + platform.shutdown(); + } + } + + @Test + void writesAndReadsSaveToSideFile() throws IOException { + TestPlatform platform = new TestPlatform(tempDir); + ConfigManager manager = new ConfigManager(platform); + try { + SaveToConfig config = manager.register(SaveToConfig.class); + + String main = Files.readString(tempDir.resolve("saveto-main.json")); + assertTrue(main.contains("\"inline\""), main); + assertFalse(main.contains("\"secret\""), main); + + Path sideFile = tempDir.resolve("secrets.json"); + assertTrue(Files.exists(sideFile), "side file should be created"); + assertTrue(Files.readString(sideFile).contains("\"secret\"")); + + Files.writeString(sideFile, "{\n \"secret\" : \"from-side-file\"\n}\n"); + manager.reload(config); + + assertEquals("from-side-file", config.secret); + } finally { + manager.shutdown(); + platform.shutdown(); + } + } + private static class BaseConfig { @ConfigValue("base") String base = "base-default"; @@ -274,6 +322,27 @@ static final class RetryListConfig { List items = new ArrayList<>(List.of("default")); } + @ConfigFile("bounded.json") + static final class BoundedConfig { + @ConfigValue("volume") + @MaxValue(100) + int volume = 50; + + @ConfigValue("depth") + @MinValue(0) + int depth = 10; + } + + @ConfigFile("saveto-main.json") + static final class SaveToConfig { + @ConfigValue("inline") + String inline = "in-main"; + + @ConfigValue("secret") + @SaveTo("secrets.json") + String secret = "secret-default"; + } + private static final class FailOnceMap extends LinkedHashMap { private static final long serialVersionUID = 1L; private final AtomicInteger remainingFailures = new AtomicInteger(1); diff --git a/core/build.gradle b/core/build.gradle deleted file mode 100644 index 7895b244..00000000 --- a/core/build.gradle +++ /dev/null @@ -1,23 +0,0 @@ -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') - api project(':lang') - api project(':logger') - api project(':commands') - api project(':placeholders') - compileOnly libs.jetbrains.annotations - compileOnly libs.projectlombok.lombok - annotationProcessor libs.projectlombok.lombok - testImplementation libs.junit.jupiter - testRuntimeOnly libs.junit.platform.launcher -} diff --git a/core/build.gradle.kts b/core/build.gradle.kts new file mode 100644 index 00000000..0872d878 --- /dev/null +++ b/core/build.gradle.kts @@ -0,0 +1,25 @@ +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")) + api(project(":lang")) + api(project(":logger")) + api(project(":commands")) + api(project(":placeholders")) + compileOnly(libs.jetbrains.annotations) + compileOnly(libs.projectlombok.lombok) + annotationProcessor(libs.projectlombok.lombok) + testImplementation(libs.junit.jupiter) + testRuntimeOnly(libs.junit.platform.launcher) +} diff --git a/core/src/main/java/dev/ua/theroer/magicutils/bootstrap/MagicUtilsConsumerPayloads.java b/core/src/main/java/dev/ua/theroer/magicutils/bootstrap/MagicUtilsConsumerPayloads.java new file mode 100644 index 00000000..6e05b723 --- /dev/null +++ b/core/src/main/java/dev/ua/theroer/magicutils/bootstrap/MagicUtilsConsumerPayloads.java @@ -0,0 +1,78 @@ +package dev.ua.theroer.magicutils.bootstrap; + +import dev.ua.theroer.magicutils.platform.MagicUtilsConsumerInfo; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.jetbrains.annotations.Nullable; + +/** + * Builds the platform-independent shared-runtime registry payload from a + * {@link MagicRuntime}. Both the Bukkit and Fabric registries call this so the + * component/count/platform-label extraction is written once. Platform-specific + * bits (plugin/mod metadata, whether diagnostics/commands are on) are passed in + * by the caller, keeping core free of Bukkit/Fabric and diagnostics types. + */ +public final class MagicUtilsConsumerPayloads { + private MagicUtilsConsumerPayloads() { + } + + /** + * Assembles the registry payload for a consumer runtime. Keys match + * {@link MagicUtilsConsumerInfo}'s {@code KEY_*} constants so the bundle side + * can decode it with {@link MagicUtilsConsumerInfo#fromPayload}. + */ + public static Map runtimePayload( + MagicRuntime runtime, + String pluginName, + String version, + String mainClass, + @Nullable String description, + @Nullable String website, + List authors, + boolean commandsEnabled, + @Nullable String permissionPrefix, + int rootCommandCount, + boolean diagnosticsEnabled, + Instant connectedAt + ) { + Map, Object> typedComponents = runtime.components(); + Map namedComponents = runtime.namedComponents(); + List namedComponentNames = new ArrayList<>(namedComponents.keySet()); + Collections.sort(namedComponentNames, String.CASE_INSENSITIVE_ORDER); + + Map payload = new LinkedHashMap<>(); + payload.put(MagicUtilsConsumerInfo.KEY_PLUGIN_NAME, pluginName); + payload.put(MagicUtilsConsumerInfo.KEY_VERSION, version); + payload.put(MagicUtilsConsumerInfo.KEY_MAIN_CLASS, mainClass); + payload.put(MagicUtilsConsumerInfo.KEY_DESCRIPTION, description); + payload.put(MagicUtilsConsumerInfo.KEY_WEBSITE, website); + payload.put(MagicUtilsConsumerInfo.KEY_AUTHORS, List.copyOf(authors)); + payload.put(MagicUtilsConsumerInfo.KEY_PLATFORM_TYPE, platformTypeLabel(runtime)); + payload.put(MagicUtilsConsumerInfo.KEY_COMMANDS_ENABLED, commandsEnabled); + payload.put(MagicUtilsConsumerInfo.KEY_PERMISSION_PREFIX, permissionPrefix); + payload.put(MagicUtilsConsumerInfo.KEY_ROOT_COMMAND_COUNT, rootCommandCount); + payload.put(MagicUtilsConsumerInfo.KEY_DIAGNOSTICS_ENABLED, diagnosticsEnabled); + payload.put(MagicUtilsConsumerInfo.KEY_CLOSED, runtime.isClosed()); + payload.put(MagicUtilsConsumerInfo.KEY_TYPED_COMPONENT_COUNT, typedComponents.size()); + payload.put(MagicUtilsConsumerInfo.KEY_NAMED_COMPONENT_COUNT, namedComponents.size()); + payload.put(MagicUtilsConsumerInfo.KEY_NAMED_COMPONENT_NAMES, List.copyOf(namedComponentNames)); + payload.put(MagicUtilsConsumerInfo.KEY_CONNECTED_AT_EPOCH_MILLIS, connectedAt.toEpochMilli()); + return payload; + } + + /** Human-readable adapter label derived from the runtime's platform class. */ + public static String platformTypeLabel(MagicRuntime runtime) { + String simpleName = runtime.platform().getClass().getSimpleName(); + if (simpleName.endsWith("PlatformProvider")) { + simpleName = simpleName.substring(0, simpleName.length() - "PlatformProvider".length()); + } + if (simpleName.endsWith("Platform")) { + simpleName = simpleName.substring(0, simpleName.length() - "Platform".length()); + } + return simpleName; + } +} diff --git a/diagnostics-testkit/build.gradle b/diagnostics-testkit/build.gradle deleted file mode 100644 index fc2e28de..00000000 --- a/diagnostics-testkit/build.gradle +++ /dev/null @@ -1,19 +0,0 @@ -plugins { - id 'magicutils.java-library' - id 'magicutils.publishing' -} - -magicutilsPublish { - category = MagicUtilsPublishCategory.COMMON_MATRIX -} - -dependencies { - api project(':diagnostics') - api project(':platform-api') - api project(':core') - api project(':config') - api project(':logger') - compileOnly libs.jetbrains.annotations - testImplementation libs.junit.jupiter - testRuntimeOnly libs.junit.platform.launcher -} diff --git a/diagnostics-testkit/build.gradle.kts b/diagnostics-testkit/build.gradle.kts new file mode 100644 index 00000000..11950180 --- /dev/null +++ b/diagnostics-testkit/build.gradle.kts @@ -0,0 +1,21 @@ +import dev.ua.theroer.magicutils.build.target.* + +plugins { + id("magicutils.java-library") + id("magicutils.publishing") +} + +magicutilsPublish { + category = MagicUtilsPublishCategory.COMMON_MATRIX +} + +dependencies { + api(project(":diagnostics")) + api(project(":platform-api")) + api(project(":core")) + api(project(":config")) + api(project(":logger")) + compileOnly(libs.jetbrains.annotations) + testImplementation(libs.junit.jupiter) + testRuntimeOnly(libs.junit.platform.launcher) +} diff --git a/diagnostics/build.gradle b/diagnostics/build.gradle deleted file mode 100644 index c02afa98..00000000 --- a/diagnostics/build.gradle +++ /dev/null @@ -1,19 +0,0 @@ -plugins { - id 'magicutils.java-library' - id 'magicutils.annotation-processing' - id 'magicutils.publishing' -} - -magicutilsPublish { - category = MagicUtilsPublishCategory.COMMON_MATRIX -} - -dependencies { - api project(':core') - implementation libs.jackson.databind - compileOnly libs.jetbrains.annotations - compileOnly libs.projectlombok.lombok - annotationProcessor libs.projectlombok.lombok - testImplementation libs.junit.jupiter - testRuntimeOnly libs.junit.platform.launcher -} diff --git a/diagnostics/build.gradle.kts b/diagnostics/build.gradle.kts new file mode 100644 index 00000000..4963cd26 --- /dev/null +++ b/diagnostics/build.gradle.kts @@ -0,0 +1,21 @@ +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(":core")) + implementation(libs.jackson.databind) + compileOnly(libs.jetbrains.annotations) + compileOnly(libs.projectlombok.lombok) + annotationProcessor(libs.projectlombok.lombok) + testImplementation(libs.junit.jupiter) + testRuntimeOnly(libs.junit.platform.launcher) +} diff --git a/bukkit-bundle/src/main/java/dev/ua/theroer/magicutils/bukkit/MagicUtilsBundleCommand.java b/diagnostics/src/main/java/dev/ua/theroer/magicutils/diagnostics/MagicUtilsBundleCommand.java similarity index 58% rename from bukkit-bundle/src/main/java/dev/ua/theroer/magicutils/bukkit/MagicUtilsBundleCommand.java rename to diagnostics/src/main/java/dev/ua/theroer/magicutils/diagnostics/MagicUtilsBundleCommand.java index d32cc237..699ebe47 100644 --- a/bukkit-bundle/src/main/java/dev/ua/theroer/magicutils/bukkit/MagicUtilsBundleCommand.java +++ b/diagnostics/src/main/java/dev/ua/theroer/magicutils/diagnostics/MagicUtilsBundleCommand.java @@ -1,41 +1,49 @@ -package dev.ua.theroer.magicutils.bukkit; +package dev.ua.theroer.magicutils.diagnostics; import dev.ua.theroer.magicutils.annotations.CommandInfo; import dev.ua.theroer.magicutils.annotations.ParamName; import dev.ua.theroer.magicutils.annotations.Sender; import dev.ua.theroer.magicutils.annotations.SubCommand; import dev.ua.theroer.magicutils.annotations.Suggest; +import dev.ua.theroer.magicutils.commands.CommandManager; import dev.ua.theroer.magicutils.commands.CommandResult; +import dev.ua.theroer.magicutils.commands.HelpCommandSupport; import dev.ua.theroer.magicutils.commands.MagicCommand; import dev.ua.theroer.magicutils.commands.MagicPermissionDefault; import dev.ua.theroer.magicutils.commands.MagicSender; import dev.ua.theroer.magicutils.commands.SubCommandSpec; -import dev.ua.theroer.magicutils.diagnostics.DiagnosticsCommandSupport; -import dev.ua.theroer.magicutils.diagnostics.DiagnosticsService; import dev.ua.theroer.magicutils.logger.LogBuilderCore; import dev.ua.theroer.magicutils.logger.LogLevel; import dev.ua.theroer.magicutils.logger.LogTarget; import dev.ua.theroer.magicutils.logger.LoggerCore; import dev.ua.theroer.magicutils.logger.MessageParser; import dev.ua.theroer.magicutils.platform.Audience; -import dev.ua.theroer.magicutils.platform.bukkit.BukkitMagicUtilsConsumerRegistry.ConsumerInfo; +import dev.ua.theroer.magicutils.platform.MagicUtilsConsumerInfo; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.Locale; -import java.util.Objects; +import java.util.function.Function; import java.util.function.Supplier; import org.jetbrains.annotations.Nullable; /** - * Bundle command that exposes information about shared-runtime MagicUtils consumers. + * Loader-agnostic {@code /magicutils} bundle command shared by every platform + * bundle (Bukkit, Fabric, NeoForge, …). It renders the bundle status, lists the + * shared-runtime consumers, and wires the diagnostics + help sub-commands. + * + *

Everything platform-specific is injected as plain functions: the bundle + * version, where to read the connected consumers from, and how to look one up by + * name. Platforms with no shared-runtime consumer registry (e.g. NeoForge) just + * pass empty suppliers and still get the status/diagnostics/help surface. This + * replaces the three near-identical per-loader command classes.

*/ @CommandInfo( name = "magicutils", aliases = {"mu"}, - description = "Show MagicUtils bundle status and connected plugins", + description = "Show MagicUtils bundle status and connected mods", permissionDefault = MagicPermissionDefault.OP ) public final class MagicUtilsBundleCommand extends MagicCommand { @@ -43,20 +51,35 @@ public final class MagicUtilsBundleCommand extends MagicCommand { DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss z", Locale.ROOT) .withZone(ZoneId.systemDefault()); - private final MagicUtilsBukkitBundlePlugin bundlePlugin; private final @Nullable LoggerCore logger; - - public MagicUtilsBundleCommand(MagicUtilsBukkitBundlePlugin bundlePlugin, @Nullable LoggerCore logger) { - this(bundlePlugin, logger, null); - } - + private final String bundleVersion; + private final Supplier> consumersSupplier; + private final Function consumerLookup; + + /** + * Creates the shared bundle command. + * + * @param logger logger core used to render chat output (nullable) + * @param bundleVersion the bundle's own version string + * @param consumersSupplier source of shared-runtime consumers (may return empty) + * @param consumerLookup lookup of one consumer by name (may return null) + * @param diagnosticsServiceSupplier diagnostics service source, enables the + * {@code diagnostics} sub-commands when non-null + * @param commandManagerSupplier command manager source, enables {@code help} + * when non-null + */ public MagicUtilsBundleCommand( - MagicUtilsBukkitBundlePlugin bundlePlugin, @Nullable LoggerCore logger, - @Nullable Supplier diagnosticsServiceSupplier + @Nullable String bundleVersion, + @Nullable Supplier> consumersSupplier, + @Nullable Function consumerLookup, + @Nullable Supplier diagnosticsServiceSupplier, + @Nullable Supplier> commandManagerSupplier ) { - this.bundlePlugin = Objects.requireNonNull(bundlePlugin, "bundlePlugin"); this.logger = logger; + this.bundleVersion = bundleVersion != null && !bundleVersion.isBlank() ? bundleVersion : "unknown"; + this.consumersSupplier = consumersSupplier != null ? consumersSupplier : List::of; + this.consumerLookup = consumerLookup != null ? consumerLookup : name -> null; if (diagnosticsServiceSupplier != null) { // Adds `/magicutils diagnostics`, `/magicutils diagnostics export` // and `/magicutils diagnostics suite `. @@ -65,6 +88,11 @@ public MagicUtilsBundleCommand( addSubCommand(spec); } } + // `/magicutils help [subcommand]` — the same help renderer consumer mods + // wire onto their own root command. + if (commandManagerSupplier != null) { + addSubCommand(HelpCommandSupport.createHelpSubCommand(logger, commandManagerSupplier)); + } } public CommandResult execute(@Sender MagicSender sender) { @@ -72,42 +100,40 @@ public CommandResult execute(@Sender MagicSender sender) { return CommandResult.success(false, ""); } - @SubCommand(name = "plugins", aliases = {"list"}, description = "List plugins using the shared MagicUtils runtime") - public CommandResult plugins(@Sender MagicSender sender) { - sendLines(sender, pluginListLines()); + @SubCommand(name = "mods", aliases = {"list", "plugins"}, description = "List mods using the shared MagicUtils runtime") + public CommandResult mods(@Sender MagicSender sender) { + sendLines(sender, modListLines()); return CommandResult.success(false, ""); } - @SubCommand(name = "plugin", aliases = {"info"}, description = "Show details for a plugin using the shared MagicUtils runtime") - public CommandResult plugin( + @SubCommand(name = "mod", aliases = {"info", "plugin"}, description = "Show details for a mod using the shared MagicUtils runtime") + public CommandResult mod( @Sender MagicSender sender, - @ParamName("plugin") @Suggest("getSharedRuntimePluginSuggestions") String pluginName + @ParamName("mod") @Suggest("getSharedRuntimeModSuggestions") String modName ) { - if (pluginName == null || pluginName.isBlank()) { + if (modName == null || modName.isBlank()) { sendLines(sender, List.of( headerLine(), - "Usage: /magicutils plugin " + "Usage: /magicutils mod " )); return CommandResult.failure(false); } - - ConsumerInfo info = bundlePlugin.findSharedRuntimeConsumer(pluginName); + MagicUtilsConsumerInfo info = consumerLookup.apply(modName); if (info == null) { sendLines(sender, List.of( headerLine(), - "Shared-runtime plugin not found: " + escape(pluginName) + "", - "Only plugins using the shared MagicUtils runtime are listed." + "Shared-runtime mod not found: " + escape(modName) + "", + "Only mods using the shared MagicUtils runtime are listed." )); return CommandResult.failure(false); } - - sendLines(sender, pluginDetailLines(info)); + sendLines(sender, modDetailLines(info)); return CommandResult.success(false, ""); } - public List getSharedRuntimePluginSuggestions() { + public List getSharedRuntimeModSuggestions() { List suggestions = new ArrayList<>(); - for (ConsumerInfo info : bundlePlugin.snapshotSharedRuntimeConsumers()) { + for (MagicUtilsConsumerInfo info : externalConsumers()) { suggestions.add(info.pluginName()); } suggestions.sort(String.CASE_INSENSITIVE_ORDER); @@ -115,33 +141,29 @@ public List getSharedRuntimePluginSuggestions() { } private List summaryLines() { - List consumers = externalConsumers(); + List consumers = externalConsumers(); List lines = new ArrayList<>(); lines.add(headerLine()); - lines.add("Bundle plugin: " + escape(bundlePlugin.getName()) + ""); - lines.add("Bundle version: " + escape(bundlePlugin.getPluginMeta().getVersion()) + ""); - lines.add("Shared-runtime consumers: " + consumers.size() + ""); - if (consumers.isEmpty()) { - lines.add("No connected shared-runtime plugins found."); - } else { + lines.add("Bundle: MagicUtils"); + lines.add("Version: " + escape(bundleVersion) + ""); + lines.add("Shared-runtime mods: " + consumers.size() + ""); + if (!consumers.isEmpty()) { lines.add("Connected: " + escape(joinConsumerLabels(consumers)) + ""); } - lines.add("Only plugins using the shared MagicUtils runtime are listed here."); - lines.add("Try: /magicutils plugins or /magicutils plugin "); + lines.add("Try: /magicutils diagnostics or /magicutils help"); return List.copyOf(lines); } - private List pluginListLines() { - List consumers = externalConsumers(); + private List modListLines() { + List consumers = externalConsumers(); List lines = new ArrayList<>(); lines.add(headerLine()); - lines.add("Shared-runtime consumers: " + consumers.size() + ""); + lines.add("Shared-runtime mods: " + consumers.size() + ""); if (consumers.isEmpty()) { - lines.add("No connected shared-runtime plugins found."); + lines.add("No connected shared-runtime mods found."); return List.copyOf(lines); } - - for (ConsumerInfo info : consumers) { + for (MagicUtilsConsumerInfo info : consumers) { lines.add("- " + escape(info.pluginName()) + " v" + escape(info.version()) + " (" + escape(info.capabilitiesSummary()) + ")"); @@ -149,10 +171,10 @@ private List pluginListLines() { return List.copyOf(lines); } - private List pluginDetailLines(ConsumerInfo info) { + private List modDetailLines(MagicUtilsConsumerInfo info) { List lines = new ArrayList<>(); lines.add(headerLine()); - lines.add("Plugin: " + escape(info.pluginName()) + ""); + lines.add("Mod: " + escape(info.pluginName()) + ""); lines.add("Version: " + escape(info.version()) + ""); lines.add("Main class: " + escape(info.mainClass()) + ""); lines.add("Platform: " + escape(info.platformType()) + ""); @@ -180,21 +202,20 @@ private List pluginDetailLines(ConsumerInfo info) { } if (!info.namedComponentNames().isEmpty()) { lines.add("Named components: " - + escape(String.join(", ", info.namedComponentNames())) - + ""); + + escape(String.join(", ", info.namedComponentNames())) + ""); } return List.copyOf(lines); } - private List externalConsumers() { - List consumers = new ArrayList<>(bundlePlugin.snapshotSharedRuntimeConsumers()); - consumers.sort(Comparator.comparing(ConsumerInfo::pluginName, String.CASE_INSENSITIVE_ORDER)); + private List externalConsumers() { + List consumers = new ArrayList<>(consumersSupplier.get()); + consumers.sort(Comparator.comparing(MagicUtilsConsumerInfo::pluginName, String.CASE_INSENSITIVE_ORDER)); return consumers; } - private String joinConsumerLabels(List consumers) { + private String joinConsumerLabels(List consumers) { List labels = new ArrayList<>(); - for (ConsumerInfo info : consumers) { + for (MagicUtilsConsumerInfo info : consumers) { labels.add(info.pluginName() + " v" + info.version()); } return String.join(", ", labels); diff --git a/fabric-bundle/build.gradle b/fabric-bundle/build.gradle.kts similarity index 61% rename from fabric-bundle/build.gradle rename to fabric-bundle/build.gradle.kts index c8c91357..7cb553bd 100644 --- a/fabric-bundle/build.gradle +++ b/fabric-bundle/build.gradle.kts @@ -1,11 +1,15 @@ +import dev.ua.theroer.magicutils.build.target.* + plugins { - id 'magicutils.fabric-bundle' + id("magicutils.fabric-bundle") } magicutilsPublish { category = MagicUtilsPublishCategory.FABRIC_MATRIX } +val magicutilsTarget = project.extensions.getByType(MagicUtilsTargetExtension::class.java) + // --------------------------------------------------------------------------- // Standalone Fabric mod entrypoint. // @@ -18,35 +22,34 @@ magicutilsPublish { // (26.x) targets it uses plain `implementation`. The bundle plugin already adds // `minecraft` and `fabric-loader`, so we only need fabric-api + project deps. // --------------------------------------------------------------------------- -def isDeobfuscated = (magicutilsTarget.minecraft.get().split('\\.')[0]).toInteger() >= 26 -def modImpl = isDeobfuscated ? 'implementation' : 'modImplementation' +val isDeobfuscated = magicutilsTarget.minecraft.get().split(".")[0].toInt() >= 26 +val modImpl = if (isDeobfuscated) "implementation" else "modImplementation" // Fabric API version comes from the resolved target (gradle/targets.properties // `mcXXXX.fabric_api`). MagicUtils' own fabric modules don't depend on it, but // the standalone bundle mod does (ModInitializer/lifecycle/command callbacks). // Override for an unlisted target with -Pfabric_api_version=... -def fabricApiVersion = findProperty('fabric_api_version') - ?: magicutilsTarget.fabric_api.getOrNull() -if (fabricApiVersion == null) { - throw new GradleException("No fabric_api for target '${magicutilsTarget.name.get()}' in " + - "gradle/targets.properties; add mcXXXX.fabric_api=... or pass -Pfabric_api_version=...") -} +val fabricApiVersion = (findProperty("fabric_api_version") as String?) + ?: magicutilsTarget.fabric_api.orNull + ?: throw GradleException( + "No fabric_api for target '${magicutilsTarget.name.get()}' in " + + "gradle/targets.properties; add mcXXXX.fabric_api=... or pass -Pfabric_api_version=..." + ) dependencies { // Fabric API: ModInitializer, CommandRegistrationCallback, ServerLifecycleEvents. - add(modImpl, "net.fabricmc.fabric-api:fabric-api:${fabricApiVersion}") + add(modImpl, "net.fabricmc.fabric-api:fabric-api:$fabricApiVersion") // MagicUtils types used by the entrypoint. These live in the runtime bundle // (jar-in-jar) already, so compileOnly is enough for the entrypoint sources. - compileOnly project(':core') // MagicRuntime - compileOnly project(':commands') // MagicCommand, MagicSender, CommandResult, annotations - compileOnly project(':commands-brigadier') // BrigadierCommandRegistry base (commandManager/registerAllCommands) - compileOnly project(':commands-fabric') // FabricBootstrap, CommandRegistry (Fabric) - compileOnly project(':logger') // LoggerCore, LogBuilderCore, MessageParser - compileOnly project(':logger-fabric') // Logger#getCore() - compileOnly project(':platform-api') // Audience, Platform - compileOnly project(':diagnostics') // DiagnosticsCommandSupport, DiagnosticsService - + compileOnly(project(":core")) // MagicRuntime + compileOnly(project(":commands")) // MagicCommand, MagicSender, CommandResult, annotations + compileOnly(project(":commands-brigadier")) // BrigadierCommandRegistry base (commandManager/registerAllCommands) + compileOnly(project(":commands-fabric")) // FabricBootstrap, CommandRegistry (Fabric) + compileOnly(project(":logger")) // LoggerCore, LogBuilderCore, MessageParser + compileOnly(project(":logger-fabric")) // Logger#getCore() + compileOnly(project(":platform-api")) // Audience, Platform + compileOnly(project(":diagnostics")) // DiagnosticsCommandSupport, DiagnosticsService } // `runServer` (the compatibility smoke) runs the mod from the dev source set, @@ -64,11 +67,13 @@ dependencies { // fails every project-wide configuration (release, preflight) with "Failed to // compute checksum". Only add it when this project's runServer is the build's // start task, and gate on the property so normal builds/releases stay clean. -def bundleJarProvider = isDeobfuscated - ? tasks.named('shadowJar').flatMap { it.archiveFile } // 26.x: no remap - : tasks.named('remapJar').flatMap { it.archiveFile } // <26: remapped jar -def devRuntimeConfig = isDeobfuscated ? 'localRuntime' : 'modLocalRuntime' -def wantsRun = gradle.startParameter.taskNames.any { it.endsWith('runServer') || it.endsWith('runClient') } +val bundleJarProvider = if (isDeobfuscated) { + tasks.named("shadowJar").flatMap { it.archiveFile } // 26.x: no remap +} else { + tasks.named("remapJar").flatMap { it.archiveFile } // <26: remapped jar +} +val devRuntimeConfig = if (isDeobfuscated) "localRuntime" else "modLocalRuntime" +val wantsRun = gradle.startParameter.taskNames.any { it.endsWith("runServer") || it.endsWith("runClient") } if (wantsRun) { dependencies { add(devRuntimeConfig, files(bundleJarProvider)) @@ -80,12 +85,12 @@ if (wantsRun) { // this). Depend on the bundle jar so it's built first, and auto-accept the // Minecraft EULA in the run dir so the unattended smoke server boots // (dev/test only). Loom's default run dir is `run/`. -tasks.named('runServer') { +tasks.named("runServer") { dependsOn(bundleJarProvider) - def runDir = layout.projectDirectory.dir('run') + val runDir = layout.projectDirectory.dir("run") doFirst { - def dir = runDir.asFile + val dir = runDir.asFile dir.mkdirs() - new File(dir, 'eula.txt').text = 'eula=true\n' + dir.resolve("eula.txt").writeText("eula=true\n") } -} \ No newline at end of file +} diff --git a/fabric-bundle/src/main/java/dev/ua/theroer/magicutils/fabric/MagicUtilsFabricBundleCommand.java b/fabric-bundle/src/main/java/dev/ua/theroer/magicutils/fabric/MagicUtilsFabricBundleCommand.java deleted file mode 100644 index 58b934ca..00000000 --- a/fabric-bundle/src/main/java/dev/ua/theroer/magicutils/fabric/MagicUtilsFabricBundleCommand.java +++ /dev/null @@ -1,99 +0,0 @@ -package dev.ua.theroer.magicutils.fabric; - -import dev.ua.theroer.magicutils.annotations.CommandInfo; -import dev.ua.theroer.magicutils.annotations.Sender; -import dev.ua.theroer.magicutils.commands.CommandResult; -import dev.ua.theroer.magicutils.commands.MagicCommand; -import dev.ua.theroer.magicutils.commands.MagicPermissionDefault; -import dev.ua.theroer.magicutils.commands.MagicSender; -import dev.ua.theroer.magicutils.commands.SubCommandSpec; -import dev.ua.theroer.magicutils.diagnostics.DiagnosticsCommandSupport; -import dev.ua.theroer.magicutils.diagnostics.DiagnosticsService; -import dev.ua.theroer.magicutils.logger.LogBuilderCore; -import dev.ua.theroer.magicutils.logger.LogLevel; -import dev.ua.theroer.magicutils.logger.LogTarget; -import dev.ua.theroer.magicutils.logger.LoggerCore; -import dev.ua.theroer.magicutils.logger.MessageParser; -import dev.ua.theroer.magicutils.platform.Audience; -import java.util.ArrayList; -import java.util.List; -import java.util.function.Supplier; -import org.jetbrains.annotations.Nullable; - -/** - * Standalone MagicUtils bundle command for Fabric. Prints the bundle status and - * exposes the diagnostics sub-commands (unlike the Bukkit bundle, there is no - * shared-runtime consumer registry on Fabric, so this stays intentionally small). - */ -@CommandInfo( - name = "magicutils", - aliases = {"mu"}, - description = "Show MagicUtils Fabric bundle status and run diagnostics", - permissionDefault = MagicPermissionDefault.OP -) -public final class MagicUtilsFabricBundleCommand extends MagicCommand { - private final @Nullable LoggerCore logger; - private final String bundleVersion; - - public MagicUtilsFabricBundleCommand( - @Nullable LoggerCore logger, - String bundleVersion, - @Nullable Supplier diagnosticsServiceSupplier - ) { - this.logger = logger; - this.bundleVersion = bundleVersion != null && !bundleVersion.isBlank() ? bundleVersion : "unknown"; - if (diagnosticsServiceSupplier != null) { - // Adds `/magicutils diagnostics`, `/magicutils diagnostics export` - // and `/magicutils diagnostics suite `. - for (SubCommandSpec spec : - DiagnosticsCommandSupport.createDiagnosticsSubCommands(logger, diagnosticsServiceSupplier)) { - addSubCommand(spec); - } - } - } - - public CommandResult execute(@Sender MagicSender sender) { - sendLines(sender, summaryLines()); - return CommandResult.success(false, ""); - } - - private List summaryLines() { - List lines = new ArrayList<>(); - lines.add(headerLine()); - lines.add("Bundle: MagicUtils Fabric Bundle"); - lines.add("Version: " + escape(bundleVersion) + ""); - lines.add("Try: /magicutils diagnostics"); - return List.copyOf(lines); - } - - private String headerLine() { - return "[MagicUtils] Fabric bundle"; - } - - private void sendLines(@Nullable MagicSender sender, List lines) { - if (sender == null || lines == null || lines.isEmpty()) { - return; - } - Audience audience = sender.audience(); - for (String line : lines) { - if (logger != null) { - new LogBuilderCore(logger, LogLevel.INFO) - .noPrefix() - .target(LogTarget.CHAT) - .to(audience) - .send(line); - } else if (audience != null) { - audience.send(MessageParser.parseSmart(line)); - } - } - } - - private static String escape(@Nullable String value) { - if (value == null || value.isBlank()) { - return ""; - } - return value.replace("&", "&") - .replace("<", "<") - .replace(">", ">"); - } -} diff --git a/fabric-bundle/src/main/java/dev/ua/theroer/magicutils/fabric/MagicUtilsFabricBundleMod.java b/fabric-bundle/src/main/java/dev/ua/theroer/magicutils/fabric/MagicUtilsFabricBundleMod.java index 945a1eaf..a740024a 100644 --- a/fabric-bundle/src/main/java/dev/ua/theroer/magicutils/fabric/MagicUtilsFabricBundleMod.java +++ b/fabric-bundle/src/main/java/dev/ua/theroer/magicutils/fabric/MagicUtilsFabricBundleMod.java @@ -6,7 +6,9 @@ import dev.ua.theroer.magicutils.commands.CommandRegistry; import dev.ua.theroer.magicutils.diagnostics.DiagnosticsCommandSupport; import dev.ua.theroer.magicutils.diagnostics.DiagnosticsService; +import dev.ua.theroer.magicutils.diagnostics.MagicUtilsBundleCommand; import dev.ua.theroer.magicutils.logger.LoggerCore; +import dev.ua.theroer.magicutils.platform.fabric.MagicUtilsFabricConsumerRegistry; import net.fabricmc.api.ModInitializer; import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; @@ -32,11 +34,16 @@ public final class MagicUtilsFabricBundleMod implements ModInitializer { @Override public void onInitialize() { FabricBootstrap.RuntimeResult bootstrap = FabricBootstrap.forMod(MOD_NAME, serverRef::get) - .initLanguage(false) + .initLanguage(true) + // Load the bundled MagicUtils translations into this mod's own + // language scope and register that scope, so the bundle command's + // `@magicutils.*` descriptions (e.g. help) resolve instead of + // showing the raw key. The global Messages manager is left alone + // (setMessagesManager stays off) so consumer mods keep theirs. + .registerMessages(true) + .addMagicUtilsMessages(true) .bindLoggerLanguage(false) .setMessagesManager(false) - .registerMessages(false) - .addMagicUtilsMessages(false) .enableCommands() .enableDiagnostics() .permissionPrefix("magicutils") @@ -68,10 +75,13 @@ public void onInitialize() { // are enabled, before the callback ever runs. DiagnosticsCommandSupport.registerTypeParsers(commandRegistry.commandManager()); CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) -> - commandRegistry.registerAllCommands(dispatcher, new MagicUtilsFabricBundleCommand( + commandRegistry.registerAllCommands(dispatcher, new MagicUtilsBundleCommand( loggerCore, bundleVersion, - this::diagnosticsService))); + MagicUtilsFabricConsumerRegistry::snapshot, + MagicUtilsFabricConsumerRegistry::find, + this::diagnosticsService, + commandRegistry::commandManager))); } LOG.info("MagicUtils Fabric bundle loaded."); diff --git a/fabric-bundle/src/main/resources/fabric.mod.json b/fabric-bundle/src/main/resources/fabric.mod.json index 29cd83d0..e49ff5cf 100644 --- a/fabric-bundle/src/main/resources/fabric.mod.json +++ b/fabric-bundle/src/main/resources/fabric.mod.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "magicutils-fabric-bundle", "version": "${version}", - "name": "MagicUtils Fabric Bundle", + "name": "MagicUtils", "description": "Bundled MagicUtils modules for Fabric (jar-in-jar).", "authors": [ "THEROER" diff --git a/gradle/reflection-allowlist.txt b/gradle/reflection-allowlist.txt index 9550ec1c..c39bdbe9 100644 --- a/gradle/reflection-allowlist.txt +++ b/gradle/reflection-allowlist.txt @@ -11,13 +11,13 @@ commands/src/main/java/dev/ua/theroer/magicutils/commands/ComparisonUtils.java:1 commands/src/main/java/dev/ua/theroer/magicutils/commands/ComparisonUtils.java:30:.getMethod( commands/src/main/java/dev/ua/theroer/magicutils/commands/ComparisonUtils.java:41:.getMethod( commands/src/main/java/dev/ua/theroer/magicutils/commands/TypeParserRegistry.java:322:.getMethod( -config/src/main/java/dev/ua/theroer/magicutils/config/ConfigManager.java:2812:Class.forName( -config/src/main/java/dev/ua/theroer/magicutils/config/ConfigManager.java:2850:Class.forName( -config/src/main/java/dev/ua/theroer/magicutils/config/ConfigManager.java:2868:Class.forName( -config/src/main/java/dev/ua/theroer/magicutils/config/ConfigManager.java:2878:.getMethod( -config/src/main/java/dev/ua/theroer/magicutils/config/ConfigManager.java:2889:.getMethod( -config/src/main/java/dev/ua/theroer/magicutils/config/ConfigManager.java:2901:Class.forName( -config/src/main/java/dev/ua/theroer/magicutils/config/ConfigManager.java:2903:.getMethod( +config/src/main/java/dev/ua/theroer/magicutils/config/ConfigManager.java:2882:Class.forName( +config/src/main/java/dev/ua/theroer/magicutils/config/ConfigManager.java:2920:Class.forName( +config/src/main/java/dev/ua/theroer/magicutils/config/ConfigManager.java:2938:Class.forName( +config/src/main/java/dev/ua/theroer/magicutils/config/ConfigManager.java:2948:.getMethod( +config/src/main/java/dev/ua/theroer/magicutils/config/ConfigManager.java:2959:.getMethod( +config/src/main/java/dev/ua/theroer/magicutils/config/ConfigManager.java:2971:Class.forName( +config/src/main/java/dev/ua/theroer/magicutils/config/ConfigManager.java:2973:.getMethod( core/src/main/java/dev/ua/theroer/magicutils/reflect/ReflectiveAccess.java:109:Class.forName( core/src/main/java/dev/ua/theroer/magicutils/reflect/ReflectiveAccess.java:182:.getMethod( core/src/main/java/dev/ua/theroer/magicutils/reflect/ReflectiveAccess.java:277:.getField( @@ -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:153:.getMethod( +platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/BukkitMagicUtilsConsumerRegistry.java:127:.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( @@ -56,7 +56,5 @@ platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/BukkitTh platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/BukkitThreading.java:198:.getMethod( platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/BukkitThreading.java:210:Class.forName( platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/BukkitThreading.java:211:.getMethod( -platform-neoforge/src/main/java/dev/ua/theroer/magicutils/platform/neoforge/NeoForgeComponentSerializer.java:78:Class.forName( -platform-neoforge/src/main/java/dev/ua/theroer/magicutils/platform/neoforge/NeoForgeComponentSerializer.java:87:.getMethod( platform-neoforge/src/main/java/dev/ua/theroer/magicutils/platform/neoforge/NeoForgePlatformProvider.java:450:.getMethod( platform-neoforge/src/main/java/dev/ua/theroer/magicutils/platform/neoforge/NeoForgePlatformProvider.java:463:.getMethod( diff --git a/http-client/build.gradle b/http-client/build.gradle deleted file mode 100644 index 748b33b6..00000000 --- a/http-client/build.gradle +++ /dev/null @@ -1,20 +0,0 @@ -plugins { - id 'magicutils.java-library' - id 'magicutils.annotation-processing' - id 'magicutils.publishing' -} - -magicutilsPublish { - category = MagicUtilsPublishCategory.COMMON_MATRIX -} - -dependencies { - api project(':core') - api project(':config') - implementation libs.jackson.databind - compileOnly libs.jetbrains.annotations - compileOnly libs.projectlombok.lombok - annotationProcessor libs.projectlombok.lombok - testImplementation libs.junit.jupiter - testRuntimeOnly libs.junit.platform.launcher -} diff --git a/http-client/build.gradle.kts b/http-client/build.gradle.kts new file mode 100644 index 00000000..1ef46cba --- /dev/null +++ b/http-client/build.gradle.kts @@ -0,0 +1,22 @@ +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(":core")) + api(project(":config")) + implementation(libs.jackson.databind) + compileOnly(libs.jetbrains.annotations) + compileOnly(libs.projectlombok.lombok) + annotationProcessor(libs.projectlombok.lombok) + testImplementation(libs.junit.jupiter) + testRuntimeOnly(libs.junit.platform.launcher) +} diff --git a/lang/build.gradle b/lang/build.gradle deleted file mode 100644 index c019551e..00000000 --- a/lang/build.gradle +++ /dev/null @@ -1,20 +0,0 @@ -plugins { - id 'magicutils.java-library' - id 'magicutils.annotation-processing' - id 'magicutils.publishing' - id 'magicutils.shaded-module' -} - -magicutilsPublish { - category = MagicUtilsPublishCategory.COMMON_MATRIX -} - -dependencies { - api project(':config') - implementation libs.jackson.databind - implementation libs.kyori.adventure.text.minimessage - compileOnly libs.projectlombok.lombok - annotationProcessor libs.projectlombok.lombok - testImplementation libs.junit.jupiter - testRuntimeOnly libs.junit.platform.launcher -} diff --git a/lang/build.gradle.kts b/lang/build.gradle.kts new file mode 100644 index 00000000..906607b5 --- /dev/null +++ b/lang/build.gradle.kts @@ -0,0 +1,22 @@ +import dev.ua.theroer.magicutils.build.target.* + +plugins { + id("magicutils.java-library") + id("magicutils.annotation-processing") + id("magicutils.publishing") + id("magicutils.shaded-module") +} + +magicutilsPublish { + category = MagicUtilsPublishCategory.COMMON_MATRIX +} + +dependencies { + api(project(":config")) + implementation(libs.jackson.databind) + implementation(libs.kyori.adventure.text.minimessage) + compileOnly(libs.projectlombok.lombok) + annotationProcessor(libs.projectlombok.lombok) + testImplementation(libs.junit.jupiter) + testRuntimeOnly(libs.junit.platform.launcher) +} diff --git a/logger-fabric/build.gradle b/logger-fabric/build.gradle deleted file mode 100644 index f13c9fe9..00000000 --- a/logger-fabric/build.gradle +++ /dev/null @@ -1,22 +0,0 @@ -plugins { - id 'magicutils.fabric-module' - id 'magicutils.annotation-processing' -} - -magicutilsPublish { - category = MagicUtilsPublishCategory.FABRIC_MATRIX -} - -dependencies { - api project(':logger') - api project(':platform-fabric') - api project(':placeholders-fabric') - - implementation libs.kyori.adventure.text.minimessage - implementation libs.kyori.adventure.text.serializer.ansi - implementation libs.kyori.adventure.text.serializer.plain - - compileOnly libs.jetbrains.annotations - compileOnly libs.projectlombok.lombok - annotationProcessor libs.projectlombok.lombok -} \ No newline at end of file diff --git a/logger-fabric/build.gradle.kts b/logger-fabric/build.gradle.kts new file mode 100644 index 00000000..5b9b21a5 --- /dev/null +++ b/logger-fabric/build.gradle.kts @@ -0,0 +1,24 @@ +import dev.ua.theroer.magicutils.build.target.* + +plugins { + id("magicutils.fabric-module") + id("magicutils.annotation-processing") +} + +magicutilsPublish { + category = MagicUtilsPublishCategory.FABRIC_MATRIX +} + +dependencies { + api(project(":logger")) + api(project(":platform-fabric")) + api(project(":placeholders-fabric")) + + implementation(libs.kyori.adventure.text.minimessage) + implementation(libs.kyori.adventure.text.serializer.ansi) + implementation(libs.kyori.adventure.text.serializer.plain) + + compileOnly(libs.jetbrains.annotations) + compileOnly(libs.projectlombok.lombok) + annotationProcessor(libs.projectlombok.lombok) +} \ No newline at end of file diff --git a/logger/build.gradle b/logger/build.gradle deleted file mode 100644 index dc7ec75f..00000000 --- a/logger/build.gradle +++ /dev/null @@ -1,22 +0,0 @@ -plugins { - id 'magicutils.java-library' - id 'magicutils.annotation-processing' - id 'magicutils.publishing' -} - -magicutilsPublish { - category = MagicUtilsPublishCategory.COMMON_MATRIX -} - -dependencies { - api project(':config') - api project(':lang') - api project(':placeholders') - api libs.kyori.adventure.text.minimessage - implementation libs.kyori.adventure.text.serializer.plain - compileOnly libs.jetbrains.annotations - compileOnly libs.projectlombok.lombok - annotationProcessor libs.projectlombok.lombok - testImplementation libs.junit.jupiter - testRuntimeOnly libs.junit.platform.launcher -} diff --git a/logger/build.gradle.kts b/logger/build.gradle.kts new file mode 100644 index 00000000..805e538f --- /dev/null +++ b/logger/build.gradle.kts @@ -0,0 +1,24 @@ +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(":config")) + api(project(":lang")) + api(project(":placeholders")) + api(libs.kyori.adventure.text.minimessage) + implementation(libs.kyori.adventure.text.serializer.plain) + compileOnly(libs.jetbrains.annotations) + compileOnly(libs.projectlombok.lombok) + annotationProcessor(libs.projectlombok.lombok) + testImplementation(libs.junit.jupiter) + testRuntimeOnly(libs.junit.platform.launcher) +} diff --git a/neoforge-bundle/build.gradle b/neoforge-bundle/build.gradle deleted file mode 100644 index 1cb4d685..00000000 --- a/neoforge-bundle/build.gradle +++ /dev/null @@ -1,7 +0,0 @@ -plugins { - id 'magicutils.neoforge-bundle' -} - -magicutilsPublish { - category = MagicUtilsPublishCategory.DEFAULT_ONLY -} diff --git a/neoforge-bundle/build.gradle.kts b/neoforge-bundle/build.gradle.kts new file mode 100644 index 00000000..34bfca31 --- /dev/null +++ b/neoforge-bundle/build.gradle.kts @@ -0,0 +1,45 @@ +import dev.ua.theroer.magicutils.build.target.* +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar + +plugins { + id("magicutils.neoforge-bundle") + // The standalone entrypoint (@Mod class) compiles against the NeoForge API, + // so this module needs ModDevGradle just like commands-neoforge does. The + // magicutils.neoforge-bundle plugin only wires the jar-in-jar bundle content. + id("net.neoforged.moddev") +} + +magicutilsPublish { + category = MagicUtilsPublishCategory.COMMON_MATRIX +} + +val target = project.extensions.getByType(MagicUtilsTargetExtension::class.java) + +neoForge { + version = target.neoforge.get() +} + +// moddev puts the whole NeoForge jar on the runtime classpath; the shadowJar +// task (from magicutils.java-library) would then try to bundle it and blow past +// the 64k-entry zip limit. NeoForge is provided by the loader at runtime, so +// keep it out of the shadow archive — the same exclusion commands-neoforge uses. +val shadowRuntimeClasspath by configurations.creating { + isCanBeResolved = true + isCanBeConsumed = false + extendsFrom(configurations["runtimeClasspath"]) + exclude(group = "net.neoforged") +} + +tasks.named("shadowJar") { + configurations = listOf(shadowRuntimeClasspath) +} + +// Expand ${version} in the mod metadata (like the fabric bundle does for +// fabric.mod.json) so the published mod carries the real version. +tasks.named("processResources") { + val props = mapOf("version" to project.version.toString()) + inputs.properties(props) + filesMatching("META-INF/neoforge.mods.toml") { + expand(props) + } +} diff --git a/neoforge-bundle/src/main/java/dev/ua/theroer/magicutils/neoforge/MagicUtilsNeoForgeBundleMod.java b/neoforge-bundle/src/main/java/dev/ua/theroer/magicutils/neoforge/MagicUtilsNeoForgeBundleMod.java new file mode 100644 index 00000000..530d65df --- /dev/null +++ b/neoforge-bundle/src/main/java/dev/ua/theroer/magicutils/neoforge/MagicUtilsNeoForgeBundleMod.java @@ -0,0 +1,102 @@ +package dev.ua.theroer.magicutils.neoforge; + +import dev.ua.theroer.magicutils.Logger; +import dev.ua.theroer.magicutils.bootstrap.MagicRuntime; +import dev.ua.theroer.magicutils.bootstrap.NeoForgeBootstrap; +import dev.ua.theroer.magicutils.commands.CommandRegistry; +import dev.ua.theroer.magicutils.diagnostics.DiagnosticsCommandSupport; +import dev.ua.theroer.magicutils.diagnostics.DiagnosticsService; +import dev.ua.theroer.magicutils.diagnostics.MagicUtilsBundleCommand; +import dev.ua.theroer.magicutils.platform.MagicUtilsConsumerRegistry; +import dev.ua.theroer.magicutils.logger.LoggerCore; +import net.neoforged.bus.api.IEventBus; +import net.neoforged.fml.ModContainer; +import net.neoforged.fml.common.Mod; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.event.RegisterCommandsEvent; +import net.neoforged.neoforge.event.server.ServerStoppedEvent; +import net.neoforged.neoforge.server.ServerLifecycleHooks; + +/** + * Standalone MagicUtils NeoForge mod. Boots a shared {@link MagicRuntime} via + * {@link NeoForgeBootstrap}, enables diagnostics and registers the + * {@code /magicutils} (alias {@code mu}) command with diagnostics sub-commands — + * the NeoForge counterpart of the Bukkit and Fabric bundles. + * + *

Shipped as its own mod (own {@code neoforge.mods.toml}) so consumer mods can + * jar-in-jar it and get the shared runtime + command without shading MagicUtils + * into their own classes.

+ */ +@Mod(MagicUtilsNeoForgeBundleMod.MOD_ID) +public final class MagicUtilsNeoForgeBundleMod { + public static final String MOD_ID = "magicutils"; + private static final String MOD_NAME = "MagicUtils"; + + private MagicRuntime runtime; + + public MagicUtilsNeoForgeBundleMod(IEventBus modEventBus, ModContainer modContainer) { + NeoForgeBootstrap.RuntimeResult bootstrap = NeoForgeBootstrap + .forMod(MOD_NAME, ServerLifecycleHooks::getCurrentServer) + // Own language scope so the command's `@magicutils.*` descriptions + // resolve, without touching the global Messages manager consumer + // mods rely on (mirrors the Fabric bundle's settings). + .initLanguage(true) + .registerMessages(true) + .addMagicUtilsMessages(true) + .bindLoggerLanguage(false) + .setMessagesManager(false) + .enableCommands() + .enableDiagnostics() + .permissionPrefix("magicutils") + .buildRuntime(); + this.runtime = bootstrap.runtime(); + + Logger logger = bootstrap.logger(); + LoggerCore loggerCore = logger != null ? logger.getCore() : null; + CommandRegistry commandRegistry = bootstrap.commandRegistry(); + final String bundleVersion = resolveBundleVersion(modContainer); + + if (commandRegistry != null) { + // Register the diagnostics suite-name parser once (so + // `/magicutils diagnostics suite ` resolves). The command + // manager exists as soon as commands are enabled. + DiagnosticsCommandSupport.registerTypeParsers(commandRegistry.commandManager()); + NeoForge.EVENT_BUS.addListener((RegisterCommandsEvent event) -> + CommandRegistry.registerAll(MOD_NAME, event.getDispatcher(), + new MagicUtilsBundleCommand( + loggerCore, + bundleVersion, + MagicUtilsConsumerRegistry::snapshot, + MagicUtilsConsumerRegistry::find, + this::diagnosticsService, + commandRegistry::commandManager))); + } + + NeoForge.EVENT_BUS.addListener((ServerStoppedEvent event) -> { + MagicRuntime current = runtime; + if (current != null) { + current.close(); + runtime = null; + } + }); + } + + private static String resolveBundleVersion(ModContainer modContainer) { + try { + return modContainer.getModInfo().getVersion().toString(); + } catch (RuntimeException versionError) { + // Defensive: reading the version can resolve a maven range against the + // game version, which is unavailable this early on some setups. Fall back + // rather than aborting the mod constructor. + return "unknown"; + } + } + + private DiagnosticsService diagnosticsService() { + MagicRuntime current = runtime; + if (current == null) { + return null; + } + return current.findComponent(DiagnosticsService.class).orElse(null); + } +} diff --git a/neoforge-bundle/src/main/resources/META-INF/neoforge.mods.toml b/neoforge-bundle/src/main/resources/META-INF/neoforge.mods.toml new file mode 100644 index 00000000..a106dc6a --- /dev/null +++ b/neoforge-bundle/src/main/resources/META-INF/neoforge.mods.toml @@ -0,0 +1,25 @@ +modLoader = "javafml" +loaderVersion = "[3,)" +license = "MIT" +issueTrackerURL = "https://github.com/theroer/MagicUtils/issues" + +[[mods]] +modId = "magicutils" +version = "${version}" +displayName = "MagicUtils" +authors = "THEROER" +description = '''Shared MagicUtils runtime for NeoForge (jar-in-jar). Boots the shared MagicRuntime and registers the /magicutils command with diagnostics.''' + +[[dependencies.magicutils]] + modId = "neoforge" + type = "required" + versionRange = "[21,)" + ordering = "NONE" + side = "BOTH" + +[[dependencies.magicutils]] + modId = "minecraft" + type = "required" + versionRange = "[1.21,)" + ordering = "NONE" + side = "BOTH" diff --git a/placeholders-fabric/build.gradle b/placeholders-fabric/build.gradle deleted file mode 100644 index 2a8b9c50..00000000 --- a/placeholders-fabric/build.gradle +++ /dev/null @@ -1,19 +0,0 @@ -plugins { - id 'magicutils.fabric-module' -} - -magicutilsPublish { - category = MagicUtilsPublishCategory.FABRIC_MATRIX -} - -dependencies { - api project(':placeholders') - api project(':platform-fabric') - api project(':logger') - - implementation libs.kyori.adventure.text.minimessage - - compileOnly libs.jetbrains.annotations - compileOnly libs.projectlombok.lombok - annotationProcessor libs.projectlombok.lombok -} \ No newline at end of file diff --git a/placeholders-fabric/build.gradle.kts b/placeholders-fabric/build.gradle.kts new file mode 100644 index 00000000..c91b3510 --- /dev/null +++ b/placeholders-fabric/build.gradle.kts @@ -0,0 +1,21 @@ +import dev.ua.theroer.magicutils.build.target.* + +plugins { + id("magicutils.fabric-module") +} + +magicutilsPublish { + category = MagicUtilsPublishCategory.FABRIC_MATRIX +} + +dependencies { + api(project(":placeholders")) + api(project(":platform-fabric")) + api(project(":logger")) + + implementation(libs.kyori.adventure.text.minimessage) + + compileOnly(libs.jetbrains.annotations) + compileOnly(libs.projectlombok.lombok) + annotationProcessor(libs.projectlombok.lombok) +} \ No newline at end of file diff --git a/placeholders/build.gradle b/placeholders/build.gradle deleted file mode 100644 index 52e8f01f..00000000 --- a/placeholders/build.gradle +++ /dev/null @@ -1,18 +0,0 @@ -plugins { - id 'magicutils.java-library' - id 'magicutils.annotation-processing' - id 'magicutils.publishing' -} - -magicutilsPublish { - category = MagicUtilsPublishCategory.COMMON_MATRIX -} - -dependencies { - api project(':platform-api') - compileOnly libs.jetbrains.annotations - compileOnly libs.projectlombok.lombok - annotationProcessor libs.projectlombok.lombok - testImplementation libs.junit.jupiter - testRuntimeOnly libs.junit.platform.launcher -} diff --git a/placeholders/build.gradle.kts b/placeholders/build.gradle.kts new file mode 100644 index 00000000..8a24d697 --- /dev/null +++ b/placeholders/build.gradle.kts @@ -0,0 +1,20 @@ +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")) + compileOnly(libs.jetbrains.annotations) + compileOnly(libs.projectlombok.lombok) + annotationProcessor(libs.projectlombok.lombok) + testImplementation(libs.junit.jupiter) + testRuntimeOnly(libs.junit.platform.launcher) +} diff --git a/platform-api/build.gradle b/platform-api/build.gradle deleted file mode 100644 index 0c84183e..00000000 --- a/platform-api/build.gradle +++ /dev/null @@ -1,18 +0,0 @@ -plugins { - id 'magicutils.java-library' - id 'magicutils.annotation-processing' - id 'magicutils.publishing' -} - -magicutilsPublish { - category = MagicUtilsPublishCategory.COMMON_MATRIX -} - -dependencies { - api libs.kyori.adventure.api - compileOnly libs.jetbrains.annotations - compileOnly libs.projectlombok.lombok - annotationProcessor libs.projectlombok.lombok - testImplementation libs.junit.jupiter - testRuntimeOnly libs.junit.platform.launcher -} diff --git a/platform-api/build.gradle.kts b/platform-api/build.gradle.kts new file mode 100644 index 00000000..f3f1fe3d --- /dev/null +++ b/platform-api/build.gradle.kts @@ -0,0 +1,20 @@ +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(libs.kyori.adventure.api) + compileOnly(libs.jetbrains.annotations) + compileOnly(libs.projectlombok.lombok) + annotationProcessor(libs.projectlombok.lombok) + testImplementation(libs.junit.jupiter) + testRuntimeOnly(libs.junit.platform.launcher) +} diff --git a/platform-api/src/main/java/dev/ua/theroer/magicutils/platform/MagicUtilsConsumerInfo.java b/platform-api/src/main/java/dev/ua/theroer/magicutils/platform/MagicUtilsConsumerInfo.java new file mode 100644 index 00000000..a6ef3463 --- /dev/null +++ b/platform-api/src/main/java/dev/ua/theroer/magicutils/platform/MagicUtilsConsumerInfo.java @@ -0,0 +1,170 @@ +package dev.ua.theroer.magicutils.platform; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.jetbrains.annotations.Nullable; + +/** + * Immutable, platform-independent snapshot of a plugin/mod that is using the + * shared MagicUtils runtime. Both the Bukkit and Fabric bundle registries build + * and expose these, so the record and the payload-decoding helpers live here in + * platform-api rather than being duplicated per platform. + * + * @param pluginName consumer display name + * @param version consumer version + * @param mainClass consumer main class / entrypoint + * @param description consumer description + * @param website consumer website / contact + * @param authors consumer authors + * @param platformType resolved MagicUtils platform adapter type (e.g. Bukkit, Fabric) + * @param commandsEnabled whether MagicUtils commands are enabled + * @param permissionPrefix command permission prefix when commands are enabled + * @param rootCommandCount number of registered root commands + * @param diagnosticsEnabled whether the diagnostics service is present + * @param closed whether the runtime is closed + * @param typedComponentCount typed component count + * @param namedComponentCount named component count + * @param namedComponentNames named component keys + * @param connectedAt timestamp of the last registration + */ +public record MagicUtilsConsumerInfo( + String pluginName, + String version, + String mainClass, + @Nullable String description, + @Nullable String website, + List authors, + String platformType, + boolean commandsEnabled, + @Nullable String permissionPrefix, + int rootCommandCount, + boolean diagnosticsEnabled, + boolean closed, + int typedComponentCount, + int namedComponentCount, + List namedComponentNames, + Instant connectedAt +) { + // Payload keys shared by the registry bridges on every platform. + public static final String KEY_PLUGIN_NAME = "pluginName"; + public static final String KEY_VERSION = "version"; + public static final String KEY_MAIN_CLASS = "mainClass"; + public static final String KEY_DESCRIPTION = "description"; + public static final String KEY_WEBSITE = "website"; + public static final String KEY_AUTHORS = "authors"; + public static final String KEY_PLATFORM_TYPE = "platformType"; + public static final String KEY_COMMANDS_ENABLED = "commandsEnabled"; + public static final String KEY_PERMISSION_PREFIX = "permissionPrefix"; + public static final String KEY_ROOT_COMMAND_COUNT = "rootCommandCount"; + public static final String KEY_DIAGNOSTICS_ENABLED = "diagnosticsEnabled"; + public static final String KEY_CLOSED = "closed"; + public static final String KEY_TYPED_COMPONENT_COUNT = "typedComponentCount"; + public static final String KEY_NAMED_COMPONENT_COUNT = "namedComponentCount"; + public static final String KEY_NAMED_COMPONENT_NAMES = "namedComponentNames"; + public static final String KEY_CONNECTED_AT_EPOCH_MILLIS = "connectedAtEpochMillis"; + + /** Returns true when this consumer matches [pluginName] ignoring case. */ + public boolean matchesPlugin(@Nullable String pluginName) { + return pluginName != null && this.pluginName.equalsIgnoreCase(pluginName.trim()); + } + + /** Compact capabilities string for list output. */ + public String capabilitiesSummary() { + Map fields = new LinkedHashMap<>(); + fields.put("platform", platformType); + fields.put("commands", commandsEnabled ? rootCommandCount + " roots" : "off"); + fields.put("diagnostics", diagnosticsEnabled ? "on" : "off"); + fields.put("components", typedComponentCount + "/" + namedComponentCount); + List parts = new ArrayList<>(); + for (Map.Entry entry : fields.entrySet()) { + parts.add(entry.getKey() + "=" + entry.getValue()); + } + return String.join(", ", parts); + } + + /** + * Rebuilds an immutable consumer snapshot from a bridge [payload]. The + * fallbacks apply when a key is absent or of the wrong type, so a partial + * payload still yields a usable record. + */ + public static MagicUtilsConsumerInfo fromPayload( + Map payload, + String fallbackName, + String fallbackVersion, + String fallbackMainClass, + @Nullable String fallbackDescription, + @Nullable String fallbackWebsite, + List fallbackAuthors + ) { + return new MagicUtilsConsumerInfo( + stringValue(payload, KEY_PLUGIN_NAME, fallbackName), + stringValue(payload, KEY_VERSION, fallbackVersion), + stringValue(payload, KEY_MAIN_CLASS, fallbackMainClass), + nullableStringValue(payload, KEY_DESCRIPTION, fallbackDescription), + nullableStringValue(payload, KEY_WEBSITE, fallbackWebsite), + stringListValue(payload, KEY_AUTHORS, fallbackAuthors), + stringValue(payload, KEY_PLATFORM_TYPE, "Unknown"), + booleanValue(payload, KEY_COMMANDS_ENABLED, false), + nullableStringValue(payload, KEY_PERMISSION_PREFIX, null), + intValue(payload, KEY_ROOT_COMMAND_COUNT, 0), + booleanValue(payload, KEY_DIAGNOSTICS_ENABLED, false), + booleanValue(payload, KEY_CLOSED, false), + intValue(payload, KEY_TYPED_COMPONENT_COUNT, 0), + intValue(payload, KEY_NAMED_COMPONENT_COUNT, 0), + stringListValue(payload, KEY_NAMED_COMPONENT_NAMES, List.of()), + instantValue(payload, KEY_CONNECTED_AT_EPOCH_MILLIS, Instant.now()) + ); + } + + private static String stringValue(Map payload, String key, String fallback) { + Object value = payload.get(key); + return value instanceof String stringValue && !stringValue.isBlank() ? stringValue : fallback; + } + + private static @Nullable String nullableStringValue(Map payload, String key, @Nullable String fallback) { + Object value = payload.get(key); + return value instanceof String stringValue && !stringValue.isBlank() ? stringValue : fallback; + } + + private static int intValue(Map payload, String key, int fallback) { + Object value = payload.get(key); + return value instanceof Number numberValue ? numberValue.intValue() : fallback; + } + + private static boolean booleanValue(Map payload, String key, boolean fallback) { + Object value = payload.get(key); + return value instanceof Boolean booleanValue ? booleanValue : fallback; + } + + private static Instant instantValue(Map payload, String key, Instant fallback) { + Object value = payload.get(key); + if (value instanceof Number numberValue) { + return Instant.ofEpochMilli(numberValue.longValue()); + } + if (value instanceof String stringValue && !stringValue.isBlank()) { + try { + return Instant.parse(stringValue); + } catch (RuntimeException ignored) { + return fallback; + } + } + return fallback; + } + + private static List stringListValue(Map payload, String key, List fallback) { + Object value = payload.get(key); + if (!(value instanceof Iterable iterable)) { + return List.copyOf(fallback); + } + List strings = new ArrayList<>(); + for (Object entry : iterable) { + if (entry instanceof String stringValue && !stringValue.isBlank()) { + strings.add(stringValue); + } + } + return List.copyOf(strings); + } +} diff --git a/platform-api/src/main/java/dev/ua/theroer/magicutils/platform/MagicUtilsConsumerRegistry.java b/platform-api/src/main/java/dev/ua/theroer/magicutils/platform/MagicUtilsConsumerRegistry.java new file mode 100644 index 00000000..b611ab0f --- /dev/null +++ b/platform-api/src/main/java/dev/ua/theroer/magicutils/platform/MagicUtilsConsumerRegistry.java @@ -0,0 +1,64 @@ +package dev.ua.theroer.magicutils.platform; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.jetbrains.annotations.Nullable; + +/** + * Process-wide registry of mods/plugins currently using the shared MagicUtils + * runtime. On loaders where every consumer shares one classloader (Fabric, + * NeoForge) this is a plain static holder: the platform bootstrap registers each + * consuming mod here on startup, and the standalone bundle command reads + * snapshots from it. Bukkit keeps its own registry (separate plugin classloaders + * + a reflective bridge to the bundle plugin), but shares the {@link + * MagicUtilsConsumerInfo} snapshot type. + * + *

This is the single implementation the per-loader registries delegate to, so + * the holder logic is not duplicated across platforms.

+ */ +public final class MagicUtilsConsumerRegistry { + private static final Map CONSUMERS = new ConcurrentHashMap<>(); + private static final String BUNDLE_NAME = "MagicUtils"; + + private MagicUtilsConsumerRegistry() { + } + + /** Registers or refreshes {@code info} for its mod, keyed by name (case-insensitive). */ + public static void register(MagicUtilsConsumerInfo info) { + if (info == null || BUNDLE_NAME.equalsIgnoreCase(info.pluginName().trim())) { + return; + } + CONSUMERS.put(normalizeKey(info.pluginName()), info); + } + + /** Removes the consumer registered under {@code modName}, if any. */ + public static void unregister(@Nullable String modName) { + if (modName == null || modName.isBlank()) { + return; + } + CONSUMERS.remove(normalizeKey(modName)); + } + + /** Immutable, name-sorted snapshot of the registered consumers. */ + public static List snapshot() { + List consumers = new ArrayList<>(CONSUMERS.values()); + consumers.sort(Comparator.comparing(MagicUtilsConsumerInfo::pluginName, String.CASE_INSENSITIVE_ORDER)); + return List.copyOf(consumers); + } + + /** Consumer matching {@code modName} ignoring case, or null. */ + public static @Nullable MagicUtilsConsumerInfo find(@Nullable String modName) { + if (modName == null || modName.isBlank()) { + return null; + } + return CONSUMERS.get(normalizeKey(modName)); + } + + private static String normalizeKey(String modName) { + return modName.trim().toLowerCase(Locale.ROOT); + } +} diff --git a/platform-api/src/main/java/dev/ua/theroer/magicutils/platform/Platform.java b/platform-api/src/main/java/dev/ua/theroer/magicutils/platform/Platform.java index 368cebe1..abbb73cb 100644 --- a/platform-api/src/main/java/dev/ua/theroer/magicutils/platform/Platform.java +++ b/platform-api/src/main/java/dev/ua/theroer/magicutils/platform/Platform.java @@ -2,6 +2,9 @@ import java.nio.file.Path; import java.util.Collection; +import java.util.UUID; + +import org.jetbrains.annotations.Nullable; /** * Abstraction over runtime platform (Bukkit, Fabric, NeoForge, etc.). @@ -35,6 +38,48 @@ public interface Platform { */ Collection onlinePlayers(); + /** + * Finds an online player audience by exact, case-insensitive name. + * + *

The default implementation scans {@link #onlinePlayers()}; platforms + * with a direct registry lookup may override for efficiency.

+ * + * @param name player name (case-insensitive) + * @return the matching audience, or {@code null} if no such player is online + */ + default @Nullable Audience playerByName(String name) { + if (name == null || name.isEmpty()) { + return null; + } + for (Audience audience : onlinePlayers()) { + if (audience != null && name.equalsIgnoreCase(audience.name())) { + return audience; + } + } + return null; + } + + /** + * Finds an online player audience by UUID. + * + *

The default implementation scans {@link #onlinePlayers()}; platforms + * with a direct registry lookup may override for efficiency.

+ * + * @param id player UUID + * @return the matching audience, or {@code null} if no such player is online + */ + default @Nullable Audience playerById(UUID id) { + if (id == null) { + return null; + } + for (Audience audience : onlinePlayers()) { + if (audience != null && id.equals(audience.id())) { + return audience; + } + } + return null; + } + /** * Executes a task on the main thread. * diff --git a/platform-bukkit/build.gradle b/platform-bukkit/build.gradle deleted file mode 100644 index 3baa62fa..00000000 --- a/platform-bukkit/build.gradle +++ /dev/null @@ -1,28 +0,0 @@ -plugins { - id 'magicutils.java-library' - id 'magicutils.annotation-processing' - id 'magicutils.publishing' - id 'magicutils.target' -} - -magicutilsPublish { - category = MagicUtilsPublishCategory.COMMON_MATRIX -} - -def target = project.extensions.getByType(MagicUtilsTargetExtension) - -dependencies { - api project(':core') - api project(':diagnostics') - compileOnly("io.papermc.paper:paper-api:${target.paper.get()}") - compileOnly libs.jetbrains.annotations - compileOnly libs.projectlombok.lombok - compileOnly libs.clip.placeholderapi - - annotationProcessor libs.projectlombok.lombok - - testImplementation libs.junit.jupiter - testImplementation libs.mockito.core - testImplementation("io.papermc.paper:paper-api:${target.paper.get()}") - testRuntimeOnly libs.junit.platform.launcher -} diff --git a/platform-bukkit/build.gradle.kts b/platform-bukkit/build.gradle.kts new file mode 100644 index 00000000..75a8a4f9 --- /dev/null +++ b/platform-bukkit/build.gradle.kts @@ -0,0 +1,30 @@ +import dev.ua.theroer.magicutils.build.target.* + +plugins { + id("magicutils.java-library") + id("magicutils.annotation-processing") + id("magicutils.publishing") + id("magicutils.target") +} + +magicutilsPublish { + category = MagicUtilsPublishCategory.COMMON_MATRIX +} + +val target = project.extensions.getByType(MagicUtilsTargetExtension::class.java) + +dependencies { + api(project(":core")) + api(project(":diagnostics")) + compileOnly("io.papermc.paper:paper-api:${target.paper.get()}") + compileOnly(libs.jetbrains.annotations) + compileOnly(libs.projectlombok.lombok) + compileOnly(libs.clip.placeholderapi) + + annotationProcessor(libs.projectlombok.lombok) + + testImplementation(libs.junit.jupiter) + testImplementation(libs.mockito.core) + testImplementation("io.papermc.paper:paper-api:${target.paper.get()}") + testRuntimeOnly(libs.junit.platform.launcher) +} diff --git a/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/BukkitMagicUtilsConsumerRegistry.java b/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/BukkitMagicUtilsConsumerRegistry.java index d4d955f4..ff88e0db 100644 --- a/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/BukkitMagicUtilsConsumerRegistry.java +++ b/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/BukkitMagicUtilsConsumerRegistry.java @@ -1,15 +1,13 @@ package dev.ua.theroer.magicutils.platform.bukkit; import dev.ua.theroer.magicutils.bootstrap.MagicRuntime; +import dev.ua.theroer.magicutils.bootstrap.MagicUtilsConsumerPayloads; import dev.ua.theroer.magicutils.commands.CommandRegistry; import dev.ua.theroer.magicutils.diagnostics.DiagnosticsService; +import dev.ua.theroer.magicutils.platform.MagicUtilsConsumerInfo; import java.lang.reflect.Method; import java.time.Instant; -import java.util.ArrayList; -import java.util.Collections; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.Objects; import org.bukkit.plugin.Plugin; @@ -17,28 +15,16 @@ import org.jetbrains.annotations.Nullable; /** - * Tracks Bukkit/Paper plugins that are currently using the shared MagicUtils runtime. + * Tracks Bukkit/Paper plugins that are currently using the shared MagicUtils + * runtime. The registry snapshot type ({@link MagicUtilsConsumerInfo}) and the + * payload encode/decode helpers are shared with the Fabric registry + * (platform-api + core); only the Bukkit plugin lookup and the reflective bridge + * to the bundle plugin live here. */ public final class BukkitMagicUtilsConsumerRegistry { private static final String BUNDLE_PLUGIN_NAME = "MagicUtils"; private static final String REGISTER_METHOD = "registerSharedRuntimeConsumer"; private static final String UNREGISTER_METHOD = "unregisterSharedRuntimeConsumer"; - private static final String KEY_PLUGIN_NAME = "pluginName"; - private static final String KEY_VERSION = "version"; - private static final String KEY_MAIN_CLASS = "mainClass"; - private static final String KEY_DESCRIPTION = "description"; - private static final String KEY_WEBSITE = "website"; - private static final String KEY_AUTHORS = "authors"; - private static final String KEY_PLATFORM_TYPE = "platformType"; - private static final String KEY_COMMANDS_ENABLED = "commandsEnabled"; - private static final String KEY_PERMISSION_PREFIX = "permissionPrefix"; - private static final String KEY_ROOT_COMMAND_COUNT = "rootCommandCount"; - private static final String KEY_DIAGNOSTICS_ENABLED = "diagnosticsEnabled"; - private static final String KEY_CLOSED = "closed"; - private static final String KEY_TYPED_COMPONENT_COUNT = "typedComponentCount"; - private static final String KEY_NAMED_COMPONENT_COUNT = "namedComponentCount"; - private static final String KEY_NAMED_COMPONENT_NAMES = "namedComponentNames"; - private static final String KEY_CONNECTED_AT_EPOCH_MILLIS = "connectedAtEpochMillis"; private BukkitMagicUtilsConsumerRegistry() { } @@ -101,40 +87,28 @@ public static void unregister(JavaPlugin plugin) { } /** - * Rebuilds an immutable consumer snapshot from a bridge payload. + * Rebuilds an immutable consumer snapshot from a bridge payload, filling + * gaps from the plugin's own metadata. * * @param plugin plugin instance * @param payload registry payload emitted by the consumer runtime * @return immutable consumer info */ - public static ConsumerInfo consumerInfo(JavaPlugin plugin, Map payload) { + public static MagicUtilsConsumerInfo consumerInfo(JavaPlugin plugin, Map payload) { Objects.requireNonNull(plugin, "plugin"); Objects.requireNonNull(payload, "payload"); var pluginMeta = plugin.getPluginMeta(); - return new ConsumerInfo( - stringValue(payload, KEY_PLUGIN_NAME, plugin.getName()), - stringValue(payload, KEY_VERSION, pluginMeta.getVersion()), - stringValue(payload, KEY_MAIN_CLASS, pluginMeta.getMainClass()), - nullableStringValue(payload, KEY_DESCRIPTION, pluginMeta.getDescription()), - nullableStringValue(payload, KEY_WEBSITE, pluginMeta.getWebsite()), - stringListValue(payload, KEY_AUTHORS, pluginMeta.getAuthors()), - stringValue(payload, KEY_PLATFORM_TYPE, "Unknown"), - booleanValue(payload, KEY_COMMANDS_ENABLED, false), - nullableStringValue(payload, KEY_PERMISSION_PREFIX, null), - intValue(payload, KEY_ROOT_COMMAND_COUNT, 0), - booleanValue(payload, KEY_DIAGNOSTICS_ENABLED, false), - booleanValue(payload, KEY_CLOSED, false), - intValue(payload, KEY_TYPED_COMPONENT_COUNT, 0), - intValue(payload, KEY_NAMED_COMPONENT_COUNT, 0), - stringListValue(payload, KEY_NAMED_COMPONENT_NAMES, List.of()), - instantValue(payload, KEY_CONNECTED_AT_EPOCH_MILLIS, Instant.now()) + return MagicUtilsConsumerInfo.fromPayload( + payload, + plugin.getName(), + pluginMeta.getVersion(), + pluginMeta.getMainClass(), + pluginMeta.getDescription(), + pluginMeta.getWebsite(), + List.copyOf(pluginMeta.getAuthors()) ); } - private static String normalizeKey(String pluginName) { - return pluginName.trim().toLowerCase(Locale.ROOT); - } - private static boolean isBundlePlugin(JavaPlugin plugin) { return BUNDLE_PLUGIN_NAME.equalsIgnoreCase(plugin.getName()); } @@ -161,180 +135,24 @@ private static Map snapshotPayload( Instant connectedAt ) { var pluginMeta = plugin.getPluginMeta(); - Map, Object> typedComponents = runtime.components(); - Map namedComponents = runtime.namedComponents(); - List namedComponentNames = new ArrayList<>(namedComponents.keySet()); - Collections.sort(namedComponentNames, String.CASE_INSENSITIVE_ORDER); boolean commandsEnabled = commandRegistry != null; int rootCommandCount = commandsEnabled ? commandRegistry.commandManager().getAll().size() : 0; String permissionPrefix = commandsEnabled ? commandRegistry.permissionPrefix() : null; boolean diagnosticsEnabled = runtime.findComponent(DiagnosticsService.class).isPresent(); - Map payload = new LinkedHashMap<>(); - payload.put(KEY_PLUGIN_NAME, plugin.getName()); - payload.put(KEY_VERSION, pluginMeta.getVersion()); - payload.put(KEY_MAIN_CLASS, pluginMeta.getMainClass()); - payload.put(KEY_DESCRIPTION, pluginMeta.getDescription()); - payload.put(KEY_WEBSITE, pluginMeta.getWebsite()); - payload.put(KEY_AUTHORS, List.copyOf(pluginMeta.getAuthors())); - payload.put(KEY_PLATFORM_TYPE, platformTypeLabel(runtime)); - payload.put(KEY_COMMANDS_ENABLED, commandsEnabled); - payload.put(KEY_PERMISSION_PREFIX, permissionPrefix); - payload.put(KEY_ROOT_COMMAND_COUNT, rootCommandCount); - payload.put(KEY_DIAGNOSTICS_ENABLED, diagnosticsEnabled); - payload.put(KEY_CLOSED, runtime.isClosed()); - payload.put(KEY_TYPED_COMPONENT_COUNT, typedComponents.size()); - payload.put(KEY_NAMED_COMPONENT_COUNT, namedComponents.size()); - payload.put(KEY_NAMED_COMPONENT_NAMES, List.copyOf(namedComponentNames)); - payload.put(KEY_CONNECTED_AT_EPOCH_MILLIS, connectedAt.toEpochMilli()); - return payload; - } - - private static String platformTypeLabel(MagicRuntime runtime) { - String simpleName = runtime.platform().getClass().getSimpleName(); - if (simpleName.endsWith("PlatformProvider")) { - simpleName = simpleName.substring(0, simpleName.length() - "PlatformProvider".length()); - } - if (simpleName.endsWith("Platform")) { - simpleName = simpleName.substring(0, simpleName.length() - "Platform".length()); - } - return simpleName; - } - - private static String stringValue(Map payload, String key, String fallback) { - Object value = payload.get(key); - if (value instanceof String stringValue && !stringValue.isBlank()) { - return stringValue; - } - return fallback; - } - - private static @Nullable String nullableStringValue( - Map payload, - String key, - @Nullable String fallback - ) { - Object value = payload.get(key); - if (value instanceof String stringValue && !stringValue.isBlank()) { - return stringValue; - } - return fallback; - } - - private static int intValue(Map payload, String key, int fallback) { - Object value = payload.get(key); - if (value instanceof Number numberValue) { - return numberValue.intValue(); - } - return fallback; - } - - private static boolean booleanValue(Map payload, String key, boolean fallback) { - Object value = payload.get(key); - if (value instanceof Boolean booleanValue) { - return booleanValue; - } - return fallback; - } - - private static Instant instantValue(Map payload, String key, Instant fallback) { - Object value = payload.get(key); - if (value instanceof Number numberValue) { - return Instant.ofEpochMilli(numberValue.longValue()); - } - if (value instanceof String stringValue && !stringValue.isBlank()) { - try { - return Instant.parse(stringValue); - } catch (RuntimeException ignored) { - return fallback; - } - } - return fallback; - } - - private static List stringListValue( - Map payload, - String key, - List fallback - ) { - Object value = payload.get(key); - if (!(value instanceof Iterable iterable)) { - return List.copyOf(fallback); - } - - List strings = new ArrayList<>(); - for (Object entry : iterable) { - if (entry instanceof String stringValue && !stringValue.isBlank()) { - strings.add(stringValue); - } - } - return List.copyOf(strings); - } - - /** - * Immutable snapshot of a shared-runtime consumer. - * - * @param pluginName plugin display name - * @param version plugin version - * @param mainClass plugin main class - * @param description plugin description - * @param website plugin website - * @param authors plugin authors - * @param platformType resolved Bukkit platform adapter type - * @param commandsEnabled whether MagicUtils commands are enabled - * @param permissionPrefix command permission prefix when commands are enabled - * @param rootCommandCount number of registered root commands - * @param diagnosticsEnabled whether diagnostics service is present - * @param closed whether the runtime is closed - * @param typedComponentCount typed component count - * @param namedComponentCount named component count - * @param namedComponentNames named component keys - * @param connectedAt timestamp of the last registration - */ - public record ConsumerInfo( - String pluginName, - String version, - String mainClass, - @Nullable String description, - @Nullable String website, - List authors, - String platformType, - boolean commandsEnabled, - @Nullable String permissionPrefix, - int rootCommandCount, - boolean diagnosticsEnabled, - boolean closed, - int typedComponentCount, - int namedComponentCount, - List namedComponentNames, - Instant connectedAt - ) { - /** - * Returns true when this consumer matches the provided plugin name ignoring case. - * - * @param pluginName plugin name to compare - * @return true when names match - */ - public boolean matchesPlugin(String pluginName) { - return pluginName != null && this.pluginName.equalsIgnoreCase(pluginName.trim()); - } - - /** - * Returns a compact capabilities string for list output. - * - * @return human-readable capabilities summary - */ - public String capabilitiesSummary() { - Map fields = new LinkedHashMap<>(); - fields.put("platform", platformType); - fields.put("commands", commandsEnabled ? rootCommandCount + " roots" : "off"); - fields.put("diagnostics", diagnosticsEnabled ? "on" : "off"); - fields.put("components", typedComponentCount + "/" + namedComponentCount); - List parts = new ArrayList<>(); - for (Map.Entry entry : fields.entrySet()) { - parts.add(entry.getKey() + "=" + entry.getValue()); - } - return String.join(", ", parts); - } + return MagicUtilsConsumerPayloads.runtimePayload( + runtime, + plugin.getName(), + pluginMeta.getVersion(), + pluginMeta.getMainClass(), + pluginMeta.getDescription(), + pluginMeta.getWebsite(), + List.copyOf(pluginMeta.getAuthors()), + commandsEnabled, + permissionPrefix, + rootCommandCount, + diagnosticsEnabled, + connectedAt + ); } } diff --git a/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/BukkitPlatformProvider.java b/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/BukkitPlatformProvider.java index fada058a..35e3639c 100644 --- a/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/BukkitPlatformProvider.java +++ b/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/BukkitPlatformProvider.java @@ -36,6 +36,7 @@ import java.nio.file.Path; import java.util.Collection; import java.util.List; +import java.util.UUID; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; @@ -100,6 +101,24 @@ public Collection onlinePlayers() { .collect(Collectors.toList()); } + @Override + public Audience playerByName(String name) { + if (name == null || name.isEmpty()) { + return null; + } + Player player = Bukkit.getPlayerExact(name); + return player != null ? wrap(player) : null; + } + + @Override + public Audience playerById(UUID id) { + if (id == null) { + return null; + } + Player player = Bukkit.getPlayer(id); + return player != null ? wrap(player) : null; + } + @Override public void runOnMain(Runnable task) { if (isMainThread()) { diff --git a/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/FoliaPlatformProvider.java b/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/FoliaPlatformProvider.java index 86785fe1..869b4af9 100644 --- a/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/FoliaPlatformProvider.java +++ b/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/FoliaPlatformProvider.java @@ -12,6 +12,7 @@ import java.nio.file.Path; import java.util.Collection; import java.util.Objects; +import java.util.UUID; import org.bukkit.plugin.java.JavaPlugin; /** @@ -51,6 +52,16 @@ public Collection onlinePlayers() { return delegate.onlinePlayers(); } + @Override + public Audience playerByName(String name) { + return delegate.playerByName(name); + } + + @Override + public Audience playerById(UUID id) { + return delegate.playerById(id); + } + @Override public void runOnMain(Runnable task) { BukkitThreading.runGlobal(plugin, task); diff --git a/platform-bungee/build.gradle b/platform-bungee/build.gradle deleted file mode 100644 index 5bb1196f..00000000 --- a/platform-bungee/build.gradle +++ /dev/null @@ -1,23 +0,0 @@ -plugins { - id 'magicutils.java-library' - id 'magicutils.annotation-processing' - id 'magicutils.publishing' -} - -magicutilsPublish { - category = MagicUtilsPublishCategory.DEFAULT_ONLY -} - -dependencies { - api project(':core') - api project(':diagnostics') - compileOnly libs.bungeecord.api - api libs.kyori.adventure.text.serializer.legacy - implementation libs.kyori.adventure.text.serializer.plain - compileOnly libs.jetbrains.annotations - compileOnly libs.projectlombok.lombok - annotationProcessor libs.projectlombok.lombok - testImplementation libs.junit.jupiter - testImplementation libs.bungeecord.api - testRuntimeOnly libs.junit.platform.launcher -} diff --git a/platform-bungee/build.gradle.kts b/platform-bungee/build.gradle.kts new file mode 100644 index 00000000..f5831de4 --- /dev/null +++ b/platform-bungee/build.gradle.kts @@ -0,0 +1,25 @@ +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(":core")) + api(project(":diagnostics")) + compileOnly(libs.bungeecord.api) + api(libs.kyori.adventure.text.serializer.legacy) + implementation(libs.kyori.adventure.text.serializer.plain) + compileOnly(libs.jetbrains.annotations) + compileOnly(libs.projectlombok.lombok) + annotationProcessor(libs.projectlombok.lombok) + testImplementation(libs.junit.jupiter) + testImplementation(libs.bungeecord.api) + testRuntimeOnly(libs.junit.platform.launcher) +} diff --git a/platform-fabric/build.gradle b/platform-fabric/build.gradle deleted file mode 100644 index 64b2ff47..00000000 --- a/platform-fabric/build.gradle +++ /dev/null @@ -1,20 +0,0 @@ -plugins { - id 'magicutils.fabric-module' -} - -magicutilsPublish { - category = MagicUtilsPublishCategory.FABRIC_MATRIX -} - -dependencies { - api project(':core') - - implementation libs.kyori.adventure.text.minimessage - implementation libs.kyori.adventure.text.serializer.gson - implementation libs.kyori.adventure.text.serializer.plain - - compileOnly libs.slf4j.api - compileOnly libs.jetbrains.annotations - compileOnly libs.projectlombok.lombok - annotationProcessor libs.projectlombok.lombok -} \ No newline at end of file diff --git a/platform-fabric/build.gradle.kts b/platform-fabric/build.gradle.kts new file mode 100644 index 00000000..81ebefa9 --- /dev/null +++ b/platform-fabric/build.gradle.kts @@ -0,0 +1,22 @@ +import dev.ua.theroer.magicutils.build.target.* + +plugins { + id("magicutils.fabric-module") +} + +magicutilsPublish { + category = MagicUtilsPublishCategory.FABRIC_MATRIX +} + +dependencies { + api(project(":core")) + + implementation(libs.kyori.adventure.text.minimessage) + implementation(libs.kyori.adventure.text.serializer.gson) + implementation(libs.kyori.adventure.text.serializer.plain) + + compileOnly(libs.slf4j.api) + compileOnly(libs.jetbrains.annotations) + compileOnly(libs.projectlombok.lombok) + annotationProcessor(libs.projectlombok.lombok) +} \ No newline at end of file diff --git a/platform-fabric/src/main/java/dev/ua/theroer/magicutils/platform/fabric/FabricPlatformProvider.java b/platform-fabric/src/main/java/dev/ua/theroer/magicutils/platform/fabric/FabricPlatformProvider.java index 072ff057..fbe8997c 100644 --- a/platform-fabric/src/main/java/dev/ua/theroer/magicutils/platform/fabric/FabricPlatformProvider.java +++ b/platform-fabric/src/main/java/dev/ua/theroer/magicutils/platform/fabric/FabricPlatformProvider.java @@ -34,6 +34,7 @@ import java.util.Collections; import java.util.List; import java.util.Set; +import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.atomic.AtomicBoolean; @@ -135,6 +136,26 @@ public Collection onlinePlayers() { .collect(Collectors.toList()); } + @Override + public Audience playerByName(String name) { + MinecraftServer server = server(); + if (server == null || name == null || name.isEmpty()) { + return null; + } + ServerPlayer player = server.getPlayerList().getPlayerByName(name); + return player != null ? wrap(player) : null; + } + + @Override + public Audience playerById(UUID id) { + MinecraftServer server = server(); + if (server == null || id == null) { + return null; + } + ServerPlayer player = server.getPlayerList().getPlayer(id); + return player != null ? wrap(player) : null; + } + @Override public void runOnMain(Runnable task) { if (task == null) { diff --git a/platform-fabric/src/main/java/dev/ua/theroer/magicutils/platform/fabric/MagicUtilsFabricConsumerRegistry.java b/platform-fabric/src/main/java/dev/ua/theroer/magicutils/platform/fabric/MagicUtilsFabricConsumerRegistry.java new file mode 100644 index 00000000..3fc5836a --- /dev/null +++ b/platform-fabric/src/main/java/dev/ua/theroer/magicutils/platform/fabric/MagicUtilsFabricConsumerRegistry.java @@ -0,0 +1,37 @@ +package dev.ua.theroer.magicutils.platform.fabric; + +import dev.ua.theroer.magicutils.platform.MagicUtilsConsumerInfo; +import dev.ua.theroer.magicutils.platform.MagicUtilsConsumerRegistry; +import java.util.List; +import org.jetbrains.annotations.Nullable; + +/** + * Fabric-facing view of the shared-runtime consumer registry. Fabric mods share + * one classloader, so the registry is just the common {@link + * MagicUtilsConsumerRegistry}; this class stays as a thin, stable entry point for + * {@code FabricBootstrap} and keeps the holder logic un-duplicated. + */ +public final class MagicUtilsFabricConsumerRegistry { + private MagicUtilsFabricConsumerRegistry() { + } + + /** @see MagicUtilsConsumerRegistry#register(MagicUtilsConsumerInfo) */ + public static void register(MagicUtilsConsumerInfo info) { + MagicUtilsConsumerRegistry.register(info); + } + + /** @see MagicUtilsConsumerRegistry#unregister(String) */ + public static void unregister(@Nullable String modName) { + MagicUtilsConsumerRegistry.unregister(modName); + } + + /** @see MagicUtilsConsumerRegistry#snapshot() */ + public static List snapshot() { + return MagicUtilsConsumerRegistry.snapshot(); + } + + /** @see MagicUtilsConsumerRegistry#find(String) */ + public static @Nullable MagicUtilsConsumerInfo find(@Nullable String modName) { + return MagicUtilsConsumerRegistry.find(modName); + } +} diff --git a/platform-neoforge/build.gradle b/platform-neoforge/build.gradle deleted file mode 100644 index 7e8805c5..00000000 --- a/platform-neoforge/build.gradle +++ /dev/null @@ -1,48 +0,0 @@ -plugins { - id 'magicutils.java-library' - id 'magicutils.annotation-processing' - id 'magicutils.publishing' - id 'net.neoforged.moddev' version '2.0.139' -} - -magicutilsPublish { - category = MagicUtilsPublishCategory.DEFAULT_ONLY -} - -def target = project.extensions.getByType(MagicUtilsTargetExtension) - -neoForge { - version = target.neoforge.get() -} - -configurations { - shadowRuntimeClasspath { - canBeResolved = true - canBeConsumed = false - extendsFrom runtimeClasspath - } -} - -configurations.shadowRuntimeClasspath { - exclude group: "net.neoforged" -} - -tasks.named("shadowJar", com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { - configurations = [project.configurations.shadowRuntimeClasspath] -} - -dependencies { - api project(':core') - implementation libs.kyori.adventure.text.serializer.gson - implementation libs.kyori.adventure.text.serializer.plain - compileOnly libs.jetbrains.annotations - compileOnly libs.projectlombok.lombok - annotationProcessor libs.projectlombok.lombok - - compileOnly libs.slf4j.api - compileOnly(libs.neoforge) { - version { - strictly magicutilsTarget.neoforge.get() - } - } -} diff --git a/platform-neoforge/build.gradle.kts b/platform-neoforge/build.gradle.kts new file mode 100644 index 00000000..2b60373f --- /dev/null +++ b/platform-neoforge/build.gradle.kts @@ -0,0 +1,46 @@ +import dev.ua.theroer.magicutils.build.target.* +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar + +plugins { + id("magicutils.java-library") + id("magicutils.annotation-processing") + id("magicutils.publishing") + id("net.neoforged.moddev") +} + +magicutilsPublish { + category = MagicUtilsPublishCategory.COMMON_MATRIX +} + +val target = project.extensions.getByType(MagicUtilsTargetExtension::class.java) + +neoForge { + version = target.neoforge.get() +} + +val shadowRuntimeClasspath by configurations.creating { + isCanBeResolved = true + isCanBeConsumed = false + extendsFrom(configurations["runtimeClasspath"]) + exclude(group = "net.neoforged") +} + +tasks.named("shadowJar") { + configurations = listOf(shadowRuntimeClasspath) +} + +dependencies { + api(project(":core")) + implementation(libs.kyori.adventure.text.serializer.gson) + implementation(libs.kyori.adventure.text.serializer.plain) + compileOnly(libs.jetbrains.annotations) + compileOnly(libs.projectlombok.lombok) + annotationProcessor(libs.projectlombok.lombok) + + compileOnly(libs.slf4j.api) + compileOnly(libs.neoforge) { + version { + strictly(target.neoforge.get()) + } + } +} diff --git a/platform-neoforge/src/main/java/dev/ua/theroer/magicutils/platform/neoforge/NeoForgeComponentSerializer.java b/platform-neoforge/src/main/java/dev/ua/theroer/magicutils/platform/neoforge/NeoForgeComponentSerializer.java index 178cfe1a..7b404241 100644 --- a/platform-neoforge/src/main/java/dev/ua/theroer/magicutils/platform/neoforge/NeoForgeComponentSerializer.java +++ b/platform-neoforge/src/main/java/dev/ua/theroer/magicutils/platform/neoforge/NeoForgeComponentSerializer.java @@ -1,11 +1,11 @@ package dev.ua.theroer.magicutils.platform.neoforge; import com.google.gson.JsonElement; +import com.mojang.serialization.JsonOps; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer; import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; - -import java.lang.reflect.Method; +import net.minecraft.network.chat.ComponentSerialization; /** * Utility for converting Adventure components to NeoForge chat components. @@ -14,10 +14,6 @@ public final class NeoForgeComponentSerializer { private static final GsonComponentSerializer GSON = GsonComponentSerializer.gson(); private static final PlainTextComponentSerializer PLAIN = PlainTextComponentSerializer.plainText(); - private static volatile boolean serializerChecked; - private static volatile Method fromJsonElement; - private static volatile Method fromJsonString; - private NeoForgeComponentSerializer() { } @@ -46,49 +42,17 @@ public static net.minecraft.network.chat.Component toNative(Component component) return net.minecraft.network.chat.Component.literal(plain); } + /** + * Decodes an Adventure-produced JSON tree into a native component via the + * vanilla {@link ComponentSerialization#CODEC}. Since MC 1.20.5 the old + * reflective {@code Component$Serializer.fromJson(...)} no longer exists — + * decoding goes through the component codec, so colours/formatting/hover + * events survive instead of being flattened to plain text. + */ private static net.minecraft.network.chat.Component decode(JsonElement tree) { - ensureSerializer(); - if (fromJsonElement != null) { - try { - Object res = fromJsonElement.invoke(null, tree); - if (res instanceof net.minecraft.network.chat.Component) { - return (net.minecraft.network.chat.Component) res; - } - } catch (ReflectiveOperationException | RuntimeException ignored) { - } - } - if (fromJsonString != null) { - try { - Object res = fromJsonString.invoke(null, tree.toString()); - if (res instanceof net.minecraft.network.chat.Component) { - return (net.minecraft.network.chat.Component) res; - } - } catch (ReflectiveOperationException | RuntimeException ignored) { - } - } - return null; - } - - private static void ensureSerializer() { - if (serializerChecked) { - return; - } - serializerChecked = true; - try { - Class serializerClass = Class.forName("net.minecraft.network.chat.Component$Serializer"); - fromJsonElement = findMethod(serializerClass, JsonElement.class); - fromJsonString = findMethod(serializerClass, String.class); - } catch (ClassNotFoundException ignored) { - } - } - - private static Method findMethod(Class serializerClass, Class argType) { - try { - Method method = serializerClass.getMethod("fromJson", argType); - method.setAccessible(true); - return method; - } catch (ReflectiveOperationException ignored) { - return null; - } + return ComponentSerialization.CODEC + .parse(JsonOps.INSTANCE, tree) + .result() + .orElse(null); } } diff --git a/platform-velocity/build.gradle b/platform-velocity/build.gradle deleted file mode 100644 index 61fb3718..00000000 --- a/platform-velocity/build.gradle +++ /dev/null @@ -1,22 +0,0 @@ -plugins { - id 'magicutils.java-library' - id 'magicutils.annotation-processing' - id 'magicutils.publishing' -} - -magicutilsPublish { - category = MagicUtilsPublishCategory.DEFAULT_ONLY -} - -dependencies { - api project(':core') - api project(':diagnostics') - compileOnly libs.velocity.api - compileOnly libs.slf4j.api - compileOnly libs.jetbrains.annotations - compileOnly libs.projectlombok.lombok - annotationProcessor libs.projectlombok.lombok - testImplementation libs.junit.jupiter - testImplementation libs.velocity.api - testRuntimeOnly libs.junit.platform.launcher -} diff --git a/platform-velocity/build.gradle.kts b/platform-velocity/build.gradle.kts new file mode 100644 index 00000000..febc7cde --- /dev/null +++ b/platform-velocity/build.gradle.kts @@ -0,0 +1,24 @@ +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(":core")) + api(project(":diagnostics")) + compileOnly(libs.velocity.api) + compileOnly(libs.slf4j.api) + compileOnly(libs.jetbrains.annotations) + compileOnly(libs.projectlombok.lombok) + annotationProcessor(libs.projectlombok.lombok) + testImplementation(libs.junit.jupiter) + testImplementation(libs.velocity.api) + testRuntimeOnly(libs.junit.platform.launcher) +} diff --git a/platform-velocity/src/main/java/dev/ua/theroer/magicutils/platform/velocity/VelocityPlatformProvider.java b/platform-velocity/src/main/java/dev/ua/theroer/magicutils/platform/velocity/VelocityPlatformProvider.java index 4f2bfee7..ec15cc2d 100644 --- a/platform-velocity/src/main/java/dev/ua/theroer/magicutils/platform/velocity/VelocityPlatformProvider.java +++ b/platform-velocity/src/main/java/dev/ua/theroer/magicutils/platform/velocity/VelocityPlatformProvider.java @@ -156,6 +156,22 @@ public Collection onlinePlayers() { .collect(Collectors.toList()); } + @Override + public Audience playerByName(String name) { + if (proxy == null || name == null || name.isEmpty()) { + return null; + } + return proxy.getPlayer(name).map(this::wrap).orElse(null); + } + + @Override + public Audience playerById(UUID id) { + if (proxy == null || id == null) { + return null; + } + return proxy.getPlayer(id).map(this::wrap).orElse(null); + } + /** * {@inheritDoc} */ diff --git a/processor/build.gradle b/processor/build.gradle deleted file mode 100644 index 96c6faa1..00000000 --- a/processor/build.gradle +++ /dev/null @@ -1,25 +0,0 @@ -plugins { - id 'java-library' // Keep direct application of java-library - id 'magicutils.repositories' - id 'magicutils.publishing' - id 'magicutils.shadow' // Shadow plugin is applied in this project to disable shadowJar -} - -magicutilsPublish { - category = MagicUtilsPublishCategory.COMMON_MATRIX -} - -java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 -} - -tasks.withType(JavaCompile).configureEach { - options.release = 11 -} - -dependencies { -} \ No newline at end of file diff --git a/processor/build.gradle.kts b/processor/build.gradle.kts new file mode 100644 index 00000000..b1df435b --- /dev/null +++ b/processor/build.gradle.kts @@ -0,0 +1,27 @@ +import dev.ua.theroer.magicutils.build.target.* + +plugins { + id("java-library") // Keep direct application of java-library + id("magicutils.repositories") + id("magicutils.publishing") + id("magicutils.shadow") // Shadow plugin is applied in this project to disable shadowJar +} + +magicutilsPublish { + category = MagicUtilsPublishCategory.COMMON_MATRIX +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} + +tasks.withType().configureEach { + options.release = 11 +} + +dependencies { +} diff --git a/settings.gradle b/settings.gradle deleted file mode 100644 index 17e9de3e..00000000 --- a/settings.gradle +++ /dev/null @@ -1,105 +0,0 @@ -pluginManagement { - includeBuild("build-logic") - repositories { - gradlePluginPortal() - maven { url = 'https://maven.fabricmc.net/' } - maven { url = 'https://maven.neoforged.net/releases' } - } -} - -plugins { - // Auto-provision the build JDK (see MAGICUTILS_BUILD_JDK) when absent. - id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0' - id 'magicutils.matrix-settings' -} - -rootProject.name = 'MagicUtils' - -magicMatrix { - targetsFile = 'gradle/targets.properties' - defaultTarget = 'mc12110' - - // Artifact ids are `magicutils-` except the platform-* projects. - moduleNamePrefix 'magicutils-' - moduleName 'platform-api', 'magicutils-api' - moduleName 'platform-bukkit', 'magicutils-bukkit' - moduleName 'platform-bungee', 'magicutils-bungee' - moduleName 'platform-velocity', 'magicutils-velocity' - moduleName 'platform-fabric', 'magicutils-fabric' - moduleName 'platform-neoforge', 'magicutils-neoforge' - - commonProjects( - 'platform-api', - 'core', - 'config', - 'lang', - 'logger', - 'commands', - 'commands-brigadier', - 'placeholders', - 'http-client', - 'config-yaml', - 'config-toml', - 'diagnostics', - 'diagnostics-testkit', - 'processor', - ) - - platform 'bukkit', ['platform-bukkit', 'bukkit-bundle'] - platform 'bungee', ['platform-bungee'] - platform 'velocity', ['platform-velocity'] - platform 'fabric', ['platform-fabric', 'commands-fabric', 'logger-fabric', 'placeholders-fabric', 'fabric-bundle'] - platform 'neoforge', ['platform-neoforge', 'commands-neoforge', 'neoforge-bundle'], ['mc1201'] - - scenario 'workspace', ['bukkit', 'bungee', 'velocity', 'fabric', 'neoforge'], 'Full multi-platform workspace' - scenario 'bukkit', ['bukkit'], 'Bukkit and Paper modules' - scenario 'bungee', ['bungee'], 'BungeeCord modules' - scenario 'velocity', ['velocity'], 'Velocity modules' - scenario 'fabric', ['fabric'], 'Fabric modules' - scenario 'neoforge', ['neoforge'], 'NeoForge modules' - - // Compatibility smoke: launch the standalone MagicUtils bundle on a real - // server per MC version and gate on the runtime diagnostics verdict. Both the - // Bukkit and Fabric bundles are runnable with the `/magicutils` command + - // diagnostics wired up. Fabric's server prints the same `Done (` line as Paper. - smoke { smoke -> - smoke.platform('bukkit') { p -> - p.runTask = ':bukkit-bundle:runServer -Pscenario=bukkit --args=nogui' - p.successPattern = 'Done (' - p.entry('paper-121x') { e -> - e.versions = ['1.21.10'] - e.smokeValues = ['1.21.10'] - } - } - smoke.platform('fabric') { p -> - // Loom's runServer downloads/boots a Fabric server with the built - // bundle as a dev mod; fabric-bundle/build.gradle auto-accepts EULA. - p.runTask = ':fabric-bundle:runServer' - p.successPattern = 'Done (' - p.entry('fabric-121x') { e -> - e.versions = ['1.21.10'] - e.smokeValues = ['1.21.10'] - } - } - } - - // Modrinth release (opt-in). Uploads one version per declared artifact via - // `./gradlew publishToModrinth -Pversion=X.Y.Z` (token from MODRINTH_TOKEN). - // Left commented out here because MagicUtils itself has no Modrinth project; - // this is the reference DSL consumers copy. Enable by setting a real - // projectId and pointing `file` at the built bundle jar for the target. - // modrinth { m -> - // m.projectId = 'AbCdEf12' - // m.channel = 'release' - // m.artifact('bukkit') { a -> - // a.file = 'bukkit-bundle/build/libs/magicutils-bukkit-bundle-${version}-mc1.21.jar' - // a.loaders = ['bukkit', 'paper'] - // a.gameVersions = ['1.21.10'] - // } - // m.artifact('fabric') { a -> - // a.file = 'fabric-bundle/build/libs/magicutils-fabric-bundle-${version}-mc1.21.jar' - // a.loaders = ['fabric'] - // a.gameVersions = ['1.21.10'] - // } - // } -} diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 00000000..ca543e0d --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,105 @@ +pluginManagement { + includeBuild("build-logic") + repositories { + gradlePluginPortal() + maven { url = uri("https://maven.fabricmc.net/") } + maven { url = uri("https://maven.neoforged.net/releases") } + } +} + +plugins { + // Auto-provision the build JDK (see MAGICUTILS_BUILD_JDK) when absent. + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" + id("magicutils.matrix-settings") +} + +rootProject.name = "MagicUtils" + +magicMatrix { + targetsFile = "gradle/targets.properties" + defaultTarget = "mc12110" + + // Artifact ids are `magicutils-` except the platform-* projects. + moduleNamePrefix("magicutils-") + moduleName("platform-api", "magicutils-api") + moduleName("platform-bukkit", "magicutils-bukkit") + moduleName("platform-bungee", "magicutils-bungee") + moduleName("platform-velocity", "magicutils-velocity") + moduleName("platform-fabric", "magicutils-fabric") + moduleName("platform-neoforge", "magicutils-neoforge") + + commonProjects( + "platform-api", + "core", + "config", + "lang", + "logger", + "commands", + "commands-brigadier", + "placeholders", + "http-client", + "config-yaml", + "config-toml", + "diagnostics", + "diagnostics-testkit", + "processor", + ) + + platform("bukkit", listOf("platform-bukkit", "bukkit-bundle")) + platform("bungee", listOf("platform-bungee")) + platform("velocity", listOf("platform-velocity")) + platform("fabric", listOf("platform-fabric", "commands-fabric", "logger-fabric", "placeholders-fabric", "fabric-bundle")) + platform("neoforge", listOf("platform-neoforge", "commands-neoforge", "neoforge-bundle"), listOf("mc1201")) + + scenario("workspace", listOf("bukkit", "bungee", "velocity", "fabric", "neoforge"), "Full multi-platform workspace") + scenario("bukkit", listOf("bukkit"), "Bukkit and Paper modules") + scenario("bungee", listOf("bungee"), "BungeeCord modules") + scenario("velocity", listOf("velocity"), "Velocity modules") + scenario("fabric", listOf("fabric"), "Fabric modules") + scenario("neoforge", listOf("neoforge"), "NeoForge modules") + + // Compatibility smoke: launch the standalone MagicUtils bundle on a real + // server per MC version and gate on the runtime diagnostics verdict. Both the + // Bukkit and Fabric bundles are runnable with the `/magicutils` command + + // diagnostics wired up. Fabric's server prints the same `Done (` line as Paper. + smoke { + platform("bukkit") { + runTask = ":bukkit-bundle:runServer -Pscenario=bukkit --args=nogui" + successPattern = "Done (" + entry("paper-121x") { + versions = listOf("1.21.10") + smokeValues = listOf("1.21.10") + } + } + platform("fabric") { + // Loom's runServer downloads/boots a Fabric server with the built + // bundle as a dev mod; fabric-bundle/build.gradle auto-accepts EULA. + runTask = ":fabric-bundle:runServer" + successPattern = "Done (" + entry("fabric-121x") { + versions = listOf("1.21.10") + smokeValues = listOf("1.21.10") + } + } + } + + // Modrinth release (opt-in). Uploads one version per declared artifact via + // `./gradlew publishToModrinth -Pversion=X.Y.Z` (token from MODRINTH_TOKEN). + // Left commented out here because MagicUtils itself has no Modrinth project; + // this is the reference DSL consumers copy. Enable by setting a real + // projectId and pointing `file` at the built bundle jar for the target. + // modrinth { + // projectId = "AbCdEf12" + // channel = "release" + // artifact("bukkit") { + // file = "bukkit-bundle/build/libs/magicutils-bukkit-bundle-${version}.jar" + // loaders = listOf("bukkit", "paper") + // gameVersions = listOf("1.21.10") + // } + // artifact("fabric") { + // file = "fabric-bundle/build/libs/magicutils-fabric-bundle-${version}.jar" + // loaders = listOf("fabric") + // gameVersions = listOf("1.21.10") + // } + // } +} From 67d115b61864068772b4ba6c2645046dfd96c33c Mon Sep 17 00:00:00 2001 From: THEROER Date: Mon, 6 Jul 2026 17:04:12 +0300 Subject: [PATCH 05/17] build(build-logic): unify consumer embed modes across loaders Replace the boolean embedMagicUtils flag with an EmbedMode enum (AUTO, JAR_IN_JAR, SHADED, EXTERNAL) so the same vocabulary applies on every loader. resolveEmbedMode() maps AUTO to the loader's native technique (JAR_IN_JAR on Fabric/NeoForge, SHADED on Bukkit) and fails fast with a helpful message when a mode is unsupported there (e.g. JAR_IN_JAR on Bukkit, SHADED on Fabric/NeoForge). Bukkit now honours the mode: SHADED (the default) keeps the modules on api/implementation for the shadow jar, EXTERNAL moves them to compileOnly and strips the MagicUtils and jackson packages (including multi-release copies) from the shadow jar, yielding a thin plugin that expects a standalone MagicUtils at runtime. Fabric/NeoForge keep JiJ on JAR_IN_JAR and drop it on EXTERNAL. The -Pmagicutils_embed property maps true to AUTO and false to EXTERNAL, so existing consumers keep working. --- .../MagicUtilsConsumerBukkitPlugin.kt | 52 ++++++++++- .../consumer/MagicUtilsConsumerExtension.kt | 90 +++++++++++++++++-- .../MagicUtilsConsumerFabricPlugin.kt | 5 +- .../MagicUtilsConsumerNeoForgePlugin.kt | 10 ++- .../build/matrix/MagicUtilsHelpTask.kt | 6 +- .../test/kotlin/MagicUtilsEmbedModeTest.kt | 57 ++++++++++++ 6 files changed, 202 insertions(+), 18 deletions(-) create mode 100644 build-logic/src/test/kotlin/MagicUtilsEmbedModeTest.kt 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 754df09a..fbdb37aa 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,7 @@ class MagicUtilsConsumerBukkitPlugin : Plugin { project.pluginManager.apply("magicutils.target") val target = project.extensions.getByType(MagicUtilsTargetExtension::class.java) - project.magicUtilsConsumerExtension() + val consumer = project.magicUtilsConsumerExtension() project.extensions.configure(JavaPluginExtension::class.java) { java -> java.toolchain.languageVersion.set(JavaLanguageVersion.of(target.java.get())) @@ -43,9 +43,53 @@ class MagicUtilsConsumerBukkitPlugin : Plugin { project.exposeMagicUtilsTargetFacts(target) - // MagicUtils modules the consumer declared (plain api/impl — Bukkit has - // no remap). - project.addConsumerMagicUtilsModules(target, "api", "implementation") + // 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/**") + } + } + } // 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 7adb8d6c..d46c109f 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 @@ -8,6 +8,69 @@ import org.gradle.api.provider.ListProperty import org.gradle.api.provider.MapProperty import org.gradle.api.provider.Property +/** + * How a downstream consumer ships MagicUtils. The set of *techniques* is the + * same vocabulary on every loader, but not every technique is valid on every + * loader (there is no jar-in-jar on Bukkit, and shading the mod bundle breaks + * Fabric/NeoForge). [AUTO] picks the native technique for the loader; a + * consumer only names an explicit mode to deviate. See + * [MagicUtilsConsumerExtension.embedMode] and [resolveEmbedMode]. + */ +enum class EmbedMode { + /** + * Use the loader's native embedding: [JAR_IN_JAR] on Fabric/NeoForge, + * [SHADED] on Bukkit. The default, so most consumers never set this. + */ + AUTO, + + /** + * Ship MagicUtils as a nested mod/artifact inside the consumer jar — Loom + * `include` on Fabric, ModDevGradle `jarJar` on NeoForge. Invalid on Bukkit + * (no jar-in-jar there). + */ + JAR_IN_JAR, + + /** + * Relocate the MagicUtils classes into the consumer's fat jar (shadow). + * Valid on Bukkit. Not yet supported on Fabric/NeoForge, where the bundle + * carries mixins/entrypoint metadata that shading into the consumer mod + * would break. + */ + SHADED, + + /** + * Do not package MagicUtils; it is provided beside the consumer at runtime + * (a separately installed server plugin / nested mod). Valid on every loader. + */ + EXTERNAL, +} + +/** The loader a consumer plugin targets, for [resolveEmbedMode] validation. */ +internal enum class ConsumerLoader(val label: String, val native: EmbedMode, val allowed: Set) { + FABRIC("Fabric", EmbedMode.JAR_IN_JAR, setOf(EmbedMode.JAR_IN_JAR, EmbedMode.EXTERNAL)), + NEOFORGE("NeoForge", EmbedMode.JAR_IN_JAR, setOf(EmbedMode.JAR_IN_JAR, EmbedMode.EXTERNAL)), + BUKKIT("Bukkit", EmbedMode.SHADED, setOf(EmbedMode.SHADED, EmbedMode.EXTERNAL)), +} + +/** + * Resolves the consumer's requested [EmbedMode] to a concrete technique for the + * given [loader]: [EmbedMode.AUTO] becomes the loader's native mode, and an + * explicit mode is validated against what the loader supports. An unsupported + * combination (e.g. [EmbedMode.JAR_IN_JAR] on Bukkit) fails the build with a + * message that names the alternatives, rather than silently doing something else. + */ +internal fun resolveEmbedMode(requested: EmbedMode, loader: ConsumerLoader): EmbedMode { + val resolved = if (requested == EmbedMode.AUTO) loader.native else requested + if (resolved !in loader.allowed) { + val alternatives = loader.allowed.joinToString(" or ") { it.name } + throw org.gradle.api.GradleException( + "MagicUtils embedMode ${requested.name} is not supported on ${loader.label}. " + + "Use $alternatives (or AUTO for the ${loader.label} default, ${loader.native.name})." + ) + } + return resolved +} + /** * Consumer-facing configuration for the `magicutils.consumer-*` plugins. * @@ -23,13 +86,20 @@ abstract class MagicUtilsConsumerExtension { abstract val magicutilsVersion: Property /** - * Whether to jar-in-jar the MagicUtils bundle into the consumer mod as a - * nested mod — Loom `include` of magicutils-fabric-bundle on Fabric, or - * ModDevGradle `jarJar` of magicutils-neoforge-bundle on NeoForge. Ignored by - * the bukkit/proxy plugins (no jar-in-jar there). Default true; set false when - * the consumer bundles MagicUtils another way (e.g. shaded into a fat jar). + * How the consumer ships MagicUtils. See [EmbedMode] for the techniques and + * [resolveEmbedMode] for which are valid on each loader. + * + * Defaults to [EmbedMode.AUTO], which each consumer plugin resolves to its + * loader's native technique ([EmbedMode.JAR_IN_JAR] on Fabric/NeoForge, + * [EmbedMode.SHADED] on Bukkit), so most consumers never set this. Name an + * explicit mode only to deviate — e.g. [EmbedMode.EXTERNAL] when several + * MagicUtils consumers share one server and MagicUtils is installed + * standalone. An explicit mode a loader does not support fails the build. + * + * The `-Pmagicutils_embed` gradle property is a one-flag CLI override: `true` + * ⇒ [EmbedMode.AUTO], `false` ⇒ [EmbedMode.EXTERNAL]. */ - abstract val embedMagicUtils: Property + abstract val embedMode: Property /** * MagicUtils modules to add on the `api` configuration (e.g. @@ -243,8 +313,12 @@ internal fun Project.magicUtilsConsumerExtension(): MagicUtilsConsumerExtension ext.magicutilsVersion.convention( providers.gradleProperty("magicutils_version").orElse(project.version.toString()), ) - ext.embedMagicUtils.convention( - providers.gradleProperty("magicutils_embed").map { it.toBoolean() }.orElse(true), + // -Pmagicutils_embed=false selects EXTERNAL (true selects AUTO, i.e. the + // loader's native embedding); with no property the default is AUTO. + ext.embedMode.convention( + providers.gradleProperty("magicutils_embed") + .map { if (it.toBoolean()) EmbedMode.AUTO else EmbedMode.EXTERNAL } + .orElse(EmbedMode.AUTO), ) return ext } 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 43b9315d..3ad3b583 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 @@ -70,7 +70,10 @@ class MagicUtilsConsumerFabricPlugin : Plugin { val fatDep = if (target.isDeobfuscated) shippedDep else "$module:dev" project.dependencies.add(compileOnly, fatDep) - if (consumer.embedMagicUtils.get()) { + // AUTO → JAR_IN_JAR on Fabric; SHADED is rejected here (fail-fast). Only + // JAR_IN_JAR actually JiJ's the bundle; EXTERNAL leaves it to the runtime. + val embed = resolveEmbedMode(consumer.embedMode.get(), ConsumerLoader.FABRIC) + if (embed == EmbedMode.JAR_IN_JAR) { project.dependencies.add("include", shippedDep) } // Dev runtime needs the fat jar on the classpath (JiJ is not exploded by 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 f08c8666..2aaa6fd5 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 @@ -61,10 +61,12 @@ class MagicUtilsConsumerNeoForgePlugin : Plugin { // its own mod — mixins/entrypoint/metadata intact — so the consumer never // spells out the coordinate or the JiJ wiring. The bundle jar is // classifier-less (the branch is in the + version). Opt out - // with `magicutils_embed=false` / `magicutilsConsumer { embedMagicUtils = - // false }` when the consumer bundles MagicUtils some other way (e.g. - // shaded via its own embeddedRuntime), as verified-plugin does. - if (consumer.embedMagicUtils.get()) { + // with `magicutils_embed=false` / `magicutilsConsumer { embedMode = + // EmbedMode.EXTERNAL }` when the consumer bundles MagicUtils some other way + // (e.g. shaded via its own embeddedRuntime), as verified-plugin does. + // AUTO → JAR_IN_JAR on NeoForge; SHADED is rejected here (fail-fast). + 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()) project.dependencies.create("dev.ua.theroer:magicutils-neoforge-bundle:$version") diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsHelpTask.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsHelpTask.kt index a845a297..2f40413b 100644 --- a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsHelpTask.kt +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsHelpTask.kt @@ -42,12 +42,16 @@ internal fun registerMagicUtilsHelpTask( appendLine(" -Pscenario= Limit to a platform scenario: " + definition.scenarios.keys.joinToString(", ")) appendLine(" -PincludePlatforms=a,b Build only these platforms.") - appendLine(" -Pmagicutils_embed= Fabric/NeoForge: jar-in-jar the MagicUtils bundle (default true).") + appendLine(" -Pmagicutils_embed= Consumers: bundle MagicUtils into the artifact (default true); false expects it installed separately.") appendLine(" -PsmokeCase= Run a single compatibility smoke case.") appendLine(" -Pversion=X.Y.Z Release version for the release tasks.") + appendLine(" -PallTargets.targets=mcA,mcB buildAllTargets: only these targets.") + appendLine(" -PallTargets.scenario= buildAllTargets: platforms of this scenario.") + appendLine(" -PallTargets.taskType= buildAllTargets: build | check | publishToMavenLocal.") appendLine() appendLine("Tasks") appendLine(" Build matrix : listBuildMatrix, buildScenario, checkScenario") + appendLine(" All targets : buildAllTargets (fan out over every version; -PallTargets.*)") appendLine(" printBuildMatrix, printPublishMatrix, printReleaseMatrix (CI JSON)") appendLine(" Smoke : listSmokeMatrix, runCompatibilitySmoke") appendLine(" Release : releasePreflight, bumpVersion, dispatchRelease, smokeTest, release") diff --git a/build-logic/src/test/kotlin/MagicUtilsEmbedModeTest.kt b/build-logic/src/test/kotlin/MagicUtilsEmbedModeTest.kt new file mode 100644 index 00000000..8d33ec0f --- /dev/null +++ b/build-logic/src/test/kotlin/MagicUtilsEmbedModeTest.kt @@ -0,0 +1,57 @@ +import org.junit.jupiter.api.Assertions.assertEquals +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.consumer.ConsumerLoader +import dev.ua.theroer.magicutils.build.consumer.EmbedMode +import dev.ua.theroer.magicutils.build.consumer.resolveEmbedMode +import org.gradle.api.GradleException + +class MagicUtilsEmbedModeTest { + + @Test + fun `AUTO resolves to each loader's native technique`() { + assertEquals(EmbedMode.JAR_IN_JAR, resolveEmbedMode(EmbedMode.AUTO, ConsumerLoader.FABRIC)) + assertEquals(EmbedMode.JAR_IN_JAR, resolveEmbedMode(EmbedMode.AUTO, ConsumerLoader.NEOFORGE)) + assertEquals(EmbedMode.SHADED, resolveEmbedMode(EmbedMode.AUTO, ConsumerLoader.BUKKIT)) + } + + @Test + fun `explicit supported modes pass through unchanged`() { + assertEquals(EmbedMode.JAR_IN_JAR, resolveEmbedMode(EmbedMode.JAR_IN_JAR, ConsumerLoader.FABRIC)) + assertEquals(EmbedMode.EXTERNAL, resolveEmbedMode(EmbedMode.EXTERNAL, ConsumerLoader.FABRIC)) + assertEquals(EmbedMode.SHADED, resolveEmbedMode(EmbedMode.SHADED, ConsumerLoader.BUKKIT)) + assertEquals(EmbedMode.EXTERNAL, resolveEmbedMode(EmbedMode.EXTERNAL, ConsumerLoader.BUKKIT)) + } + + @Test + fun `EXTERNAL is valid on every loader`() { + ConsumerLoader.values().forEach { loader -> + assertEquals(EmbedMode.EXTERNAL, resolveEmbedMode(EmbedMode.EXTERNAL, loader)) + } + } + + @Test + fun `JAR_IN_JAR is rejected on Bukkit with a helpful message`() { + val error = assertThrows(GradleException::class.java) { + resolveEmbedMode(EmbedMode.JAR_IN_JAR, ConsumerLoader.BUKKIT) + } + assertTrue(error.message!!.contains("JAR_IN_JAR"), error.message) + assertTrue(error.message!!.contains("Bukkit"), error.message) + // points the consumer at the supported alternatives + assertTrue(error.message!!.contains("SHADED"), error.message) + assertTrue(error.message!!.contains("EXTERNAL"), error.message) + } + + @Test + fun `SHADED is rejected on Fabric and NeoForge`() { + listOf(ConsumerLoader.FABRIC, ConsumerLoader.NEOFORGE).forEach { loader -> + val error = assertThrows(GradleException::class.java) { + resolveEmbedMode(EmbedMode.SHADED, loader) + } + assertTrue(error.message!!.contains("SHADED"), error.message) + assertTrue(error.message!!.contains("JAR_IN_JAR"), error.message) + } + } +} From 3b83055db25a9d8e9aeb90340e4ffdacc3f76c8a Mon Sep 17 00:00:00 2001 From: THEROER Date: Mon, 6 Jul 2026 17:05:25 +0300 Subject: [PATCH 06/17] feat(platform-bukkit): warn when MagicUtils is loaded more than once On Paper each plugin has its own classloader, so a plugin that shades MagicUtils carries a private copy: two shaded consumers run two isolated runtimes (separate config, consumer registry and /mu commands) that the server owner never sees. Since the copies share no static state, every MagicUtils instance now records its host in System.getProperties() (one table per process, visible to every classloader) at bootstrap. When more than one distinct host is present, a single warning names the hosts (bundled/standalone plus versions) and tells the owner to install one standalone MagicUtils and rebuild the consumers with magicutils_embed=false. The detector's core takes plain values so it is unit-tested without a running server. --- .../BukkitMagicUtilsConsumerRegistry.java | 7 + .../bukkit/MagicUtilsDuplicationDetector.java | 163 ++++++++++++++++++ .../MagicUtilsDuplicationDetectorTest.java | 105 +++++++++++ 3 files changed, 275 insertions(+) create mode 100644 platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/MagicUtilsDuplicationDetector.java create mode 100644 platform-bukkit/src/test/java/dev/ua/theroer/magicutils/platform/bukkit/MagicUtilsDuplicationDetectorTest.java diff --git a/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/BukkitMagicUtilsConsumerRegistry.java b/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/BukkitMagicUtilsConsumerRegistry.java index ff88e0db..421089f0 100644 --- a/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/BukkitMagicUtilsConsumerRegistry.java +++ b/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/BukkitMagicUtilsConsumerRegistry.java @@ -39,6 +39,13 @@ private BukkitMagicUtilsConsumerRegistry() { public static void register(JavaPlugin plugin, MagicRuntime runtime, @Nullable CommandRegistry commandRegistry) { Objects.requireNonNull(plugin, "plugin"); Objects.requireNonNull(runtime, "runtime"); + + // Record this MagicUtils copy in the JVM-global host registry and warn if + // the server ends up running more than one. Done for every host (standalone + // and shaded) before the short-circuits below, since a shaded consumer with + // no standalone bundle returns early yet must still be counted. + MagicUtilsDuplicationDetector.record(plugin); + if (isBundlePlugin(plugin)) { return; } diff --git a/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/MagicUtilsDuplicationDetector.java b/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/MagicUtilsDuplicationDetector.java new file mode 100644 index 00000000..f9d49724 --- /dev/null +++ b/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/MagicUtilsDuplicationDetector.java @@ -0,0 +1,163 @@ +package dev.ua.theroer.magicutils.platform.bukkit; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Properties; +import java.util.logging.Logger; +import org.bukkit.plugin.java.JavaPlugin; + +/** + * Detects when the MagicUtils runtime is loaded more than once on a single + * Bukkit/Paper server and warns the server owner, with instructions. + * + *

On Paper every plugin has its own isolated classloader. A plugin that + * shades MagicUtils therefore carries a private copy of the runtime: + * its classes are physically distinct from any other plugin's copy and from a + * standalone MagicUtils plugin. Two shaded consumers (say {@code AliasCreator} + * and {@code DonateMenu}) thus run two independent MagicUtils instances - + * separate consumer registries, configs and {@code /mu} commands - which the + * server owner never asked for and cannot see from the plugin list. The + * developer may ship only a shaded build, or ship both shaded and external + * without explaining the difference, so the owner has no other signal.

+ * + *

This detector gives them one. Because the isolated copies share no static + * state, the rendezvous point is the JVM-global {@link System#getProperties()} + * (one properties table per server process, visible to every classloader). + * Every MagicUtils instance records its host here at startup; when more than one + * distinct host is present, a single loud warning is logged naming the hosts and + * telling the owner to rebuild the consumers in {@code external} mode and install + * one standalone MagicUtils.

+ * + *

The warning is advisory: the server still boots (each copy works in + * isolation), the owner is just told how to collapse them into one shared + * runtime.

+ */ +final class MagicUtilsDuplicationDetector { + + /** JVM-global property holding the recorded hosts, one per {@code ;}. */ + private static final String HOSTS_PROPERTY = "magicutils.bukkit.hosts"; + + /** JVM-global latch so the multi-instance warning is logged only once. */ + private static final String WARNED_PROPERTY = "magicutils.bukkit.duplicationWarned"; + + /** Host name a standalone MagicUtils plugin records itself under. */ + private static final String STANDALONE_PLUGIN_NAME = "MagicUtils"; + + private MagicUtilsDuplicationDetector() { + } + + /** + * Records the MagicUtils host {@code plugin} in the JVM-global registry and, + * if this brings the number of distinct hosts above one, logs a single + * warning describing the duplication and how to fix it. + * + *

Called once per MagicUtils instance at bootstrap, for both standalone + * and shaded hosts, before any of the consumer-registry short-circuits.

+ * + * @param plugin the plugin whose classloader carries this MagicUtils copy + */ + static void record(JavaPlugin plugin) { + record(plugin.getName(), plugin.getPluginMeta().getVersion(), plugin.getLogger()); + } + + /** + * Core of {@link #record(JavaPlugin)} in terms of plain values, so it is + * testable without constructing a Bukkit {@link JavaPlugin} (whose + * constructor requires a running server). Package-private for the test. + * + * @param name host plugin name + * @param version host plugin version (may be {@code null}/blank) + * @param logger the host plugin's logger, used for the warning + */ + static void record(String name, String version, Logger logger) { + boolean standalone = STANDALONE_PLUGIN_NAME.equalsIgnoreCase(name); + + // System.getProperties() is the one object every isolated plugin + // classloader shares, so synchronize on it to make read-modify-write of + // the host list atomic across the copies loading concurrently. + Properties properties = System.getProperties(); + Map hosts; + boolean alreadyWarned; + synchronized (properties) { + hosts = parseHosts(properties.getProperty(HOSTS_PROPERTY)); + // Keyed by plugin name: re-registering the same host (e.g. a reload) + // updates rather than duplicates it, so the count reflects distinct + // hosts, not startups. + hosts.put(name, new Host(name, version, standalone)); + properties.setProperty(HOSTS_PROPERTY, renderHosts(hosts)); + alreadyWarned = Boolean.parseBoolean(properties.getProperty(WARNED_PROPERTY)); + if (hosts.size() > 1 && !alreadyWarned) { + properties.setProperty(WARNED_PROPERTY, "true"); + } + } + + if (hosts.size() > 1 && !alreadyWarned) { + warn(logger, hosts.values()); + } + } + + private static void warn(Logger logger, Iterable hosts) { + StringBuilder message = new StringBuilder(); + message.append("MagicUtils is loaded more than once on this server:"); + for (Host host : hosts) { + message.append("\n - ") + .append(host.standalone ? "standalone plugin " : "bundled in ") + .append(host.name); + if (host.version != null && !host.version.isBlank()) { + message.append(' ').append(host.version); + } + } + message.append("\nEach copy is isolated (separate config, consumer registry and /mu commands),") + .append(" so plugins do not actually share one MagicUtils.") + .append("\nTo run a single shared MagicUtils: install the standalone MagicUtils plugin,") + .append(" and rebuild the plugins above with -Pmagicutils_embed=false (embed mode external)."); + logger.warning(message.toString()); + } + + private static Map parseHosts(String raw) { + Map hosts = new LinkedHashMap<>(); + if (raw == null || raw.isBlank()) { + return hosts; + } + for (String entry : raw.split(";")) { + if (entry.isBlank()) { + continue; + } + String[] parts = entry.split("\\|", 3); + String name = parts[0]; + String version = parts.length > 1 ? parts[1] : ""; + boolean standalone = parts.length > 2 && Boolean.parseBoolean(parts[2]); + hosts.put(name, new Host(name, version, standalone)); + } + return hosts; + } + + private static String renderHosts(Map hosts) { + StringBuilder rendered = new StringBuilder(); + for (Host host : hosts.values()) { + if (rendered.length() > 0) { + rendered.append(';'); + } + // name|version|standalone. Names/versions from plugin metadata; strip + // the field separators defensively so a stray '|'/';' cannot corrupt + // the encoding. + rendered.append(sanitize(host.name)) + .append('|') + .append(sanitize(host.version)) + .append('|') + .append(host.standalone); + } + return rendered.toString(); + } + + private static String sanitize(String value) { + if (value == null) { + return ""; + } + return value.replace('|', '_').replace(';', '_'); + } + + /** A single MagicUtils host: the plugin carrying a copy of the runtime. */ + private record Host(String name, String version, boolean standalone) { + } +} diff --git a/platform-bukkit/src/test/java/dev/ua/theroer/magicutils/platform/bukkit/MagicUtilsDuplicationDetectorTest.java b/platform-bukkit/src/test/java/dev/ua/theroer/magicutils/platform/bukkit/MagicUtilsDuplicationDetectorTest.java new file mode 100644 index 00000000..745ad6cf --- /dev/null +++ b/platform-bukkit/src/test/java/dev/ua/theroer/magicutils/platform/bukkit/MagicUtilsDuplicationDetectorTest.java @@ -0,0 +1,105 @@ +package dev.ua.theroer.magicutils.platform.bukkit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Verifies the JVM-global duplication detector: it stays quiet for a single + * MagicUtils host and warns once, naming the hosts, when a second appears. + */ +class MagicUtilsDuplicationDetectorTest { + + private static final String HOSTS_PROPERTY = "magicutils.bukkit.hosts"; + private static final String WARNED_PROPERTY = "magicutils.bukkit.duplicationWarned"; + + @BeforeEach + @AfterEach + void clearGlobalState() { + System.clearProperty(HOSTS_PROPERTY); + System.clearProperty(WARNED_PROPERTY); + } + + @Test + void singleHostDoesNotWarn() { + CapturingLogger logger = new CapturingLogger(); + MagicUtilsDuplicationDetector.record("AliasCreator", "2.1", logger.logger()); + + assertTrue(logger.warnings().isEmpty(), "a single MagicUtils host must not warn"); + } + + @Test + void secondHostWarnsOnceAndNamesBothHosts() { + CapturingLogger first = new CapturingLogger(); + CapturingLogger second = new CapturingLogger(); + CapturingLogger third = new CapturingLogger(); + + MagicUtilsDuplicationDetector.record("AliasCreator", "2.1", first.logger()); + MagicUtilsDuplicationDetector.record("DonateMenu", "1.4", second.logger()); + MagicUtilsDuplicationDetector.record("MagicUtils", "1.22.1", third.logger()); + + assertTrue(first.warnings().isEmpty(), "the first host cannot yet see a duplicate"); + assertEquals(1, second.warnings().size(), "the second host warns exactly once"); + assertTrue(third.warnings().isEmpty(), "later hosts stay quiet once warned"); + + String warning = second.warnings().get(0); + assertTrue(warning.contains("AliasCreator 2.1"), warning); + assertTrue(warning.contains("DonateMenu 1.4"), warning); + assertTrue(warning.contains("bundled in DonateMenu"), warning); + assertTrue(warning.contains("-Pmagicutils_embed=false"), warning); + } + + @Test + void standaloneHostIsLabelledStandalone() { + MagicUtilsDuplicationDetector.record("MagicUtils", "1.22.1", new CapturingLogger().logger()); + CapturingLogger consumer = new CapturingLogger(); + MagicUtilsDuplicationDetector.record("AliasCreator", "2.1", consumer.logger()); + + String warning = consumer.warnings().get(0); + assertTrue(warning.contains("standalone plugin MagicUtils"), warning); + } + + /** A JUL logger with an attached handler that records WARNING messages. */ + private static final class CapturingLogger { + private final Logger logger; + private final List warnings = new ArrayList<>(); + + CapturingLogger() { + this.logger = Logger.getAnonymousLogger(); + this.logger.setUseParentHandlers(false); + this.logger.addHandler(new Handler() { + @Override + public void publish(LogRecord record) { + if (record.getLevel() == Level.WARNING) { + warnings.add(record.getMessage()); + } + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + }); + } + + Logger logger() { + return logger; + } + + List warnings() { + return warnings; + } + } +} From 9914157fabb8a3b11a4bab56e77f4f84a8717fc5 Mon Sep 17 00:00:00 2001 From: THEROER Date: Mon, 6 Jul 2026 17:31:23 +0300 Subject: [PATCH 07/17] feat(registry): serve /magicutils mods from a live consumer view The consumer registry now keeps a live runtime view per consumer instead of a frozen snapshot: command and component counts change after a consumer registers (commands are wired on server start, components while the runtime builds), so snapshot()/find() rebuild the info from the view at call time. On Bukkit the reflective bridge hands the bundle plugin a Supplier that rebuilds the payload on demand, crossing the separate-classloader boundary with JDK types only. Keeps /magicutils mods honest rather than showing an early snapshot. --- .../bootstrap/MagicUtilsConsumerViews.java | 67 +++++++++ .../platform/MagicUtilsConsumerInfo.java | 44 +++++- .../platform/MagicUtilsConsumerRegistry.java | 129 ++++++++++++++++- .../MagicUtilsConsumerRuntimeView.java | 38 +++++ .../MagicUtilsConsumerRegistryTest.java | 136 ++++++++++++++++++ .../BukkitMagicUtilsConsumerRegistry.java | 17 ++- .../MagicUtilsFabricConsumerRegistry.java | 6 + 7 files changed, 426 insertions(+), 11 deletions(-) create mode 100644 core/src/main/java/dev/ua/theroer/magicutils/bootstrap/MagicUtilsConsumerViews.java create mode 100644 platform-api/src/main/java/dev/ua/theroer/magicutils/platform/MagicUtilsConsumerRuntimeView.java create mode 100644 platform-api/src/test/java/dev/ua/theroer/magicutils/platform/MagicUtilsConsumerRegistryTest.java diff --git a/core/src/main/java/dev/ua/theroer/magicutils/bootstrap/MagicUtilsConsumerViews.java b/core/src/main/java/dev/ua/theroer/magicutils/bootstrap/MagicUtilsConsumerViews.java new file mode 100644 index 00000000..0e2a87b9 --- /dev/null +++ b/core/src/main/java/dev/ua/theroer/magicutils/bootstrap/MagicUtilsConsumerViews.java @@ -0,0 +1,67 @@ +package dev.ua.theroer.magicutils.bootstrap; + +import dev.ua.theroer.magicutils.platform.MagicUtilsConsumerRuntimeView; +import java.util.ArrayList; +import java.util.List; +import java.util.function.BooleanSupplier; +import java.util.function.IntSupplier; + +/** + * Builds live {@link MagicUtilsConsumerRuntimeView}s from a consumer's {@link + * MagicRuntime}. Both the Fabric and NeoForge bootstraps register a view here so + * the component/count extraction is written once. The dynamic bits that live + * outside core (root command count from the command registry, whether the + * diagnostics service is present) are passed in as suppliers so core stays free + * of the commands/diagnostics wiring while still reading them live. + */ +public final class MagicUtilsConsumerViews { + private MagicUtilsConsumerViews() { + } + + /** + * Live view backed by {@code runtime}. Every accessor reads through to the + * runtime (or the supplied suppliers) at call time, so the registry always + * reports the consumer's current state. + * + * @param runtime the consumer runtime to read components/closed from + * @param rootCommandCount live root command count (0 when commands are off) + * @param diagnosticsEnabled live diagnostics-present check + */ + public static MagicUtilsConsumerRuntimeView liveView( + MagicRuntime runtime, + IntSupplier rootCommandCount, + BooleanSupplier diagnosticsEnabled + ) { + return new MagicUtilsConsumerRuntimeView() { + @Override + public int rootCommandCount() { + return rootCommandCount.getAsInt(); + } + + @Override + public int typedComponentCount() { + return runtime.components().size(); + } + + @Override + public int namedComponentCount() { + return runtime.namedComponents().size(); + } + + @Override + public List namedComponentNames() { + return new ArrayList<>(runtime.namedComponents().keySet()); + } + + @Override + public boolean diagnosticsEnabled() { + return diagnosticsEnabled.getAsBoolean(); + } + + @Override + public boolean closed() { + return runtime.isClosed(); + } + }; + } +} diff --git a/platform-api/src/main/java/dev/ua/theroer/magicutils/platform/MagicUtilsConsumerInfo.java b/platform-api/src/main/java/dev/ua/theroer/magicutils/platform/MagicUtilsConsumerInfo.java index a6ef3463..2ce110cd 100644 --- a/platform-api/src/main/java/dev/ua/theroer/magicutils/platform/MagicUtilsConsumerInfo.java +++ b/platform-api/src/main/java/dev/ua/theroer/magicutils/platform/MagicUtilsConsumerInfo.java @@ -77,7 +77,7 @@ public String capabilitiesSummary() { fields.put("platform", platformType); fields.put("commands", commandsEnabled ? rootCommandCount + " roots" : "off"); fields.put("diagnostics", diagnosticsEnabled ? "on" : "off"); - fields.put("components", typedComponentCount + "/" + namedComponentCount); + fields.put("components", typedComponentCount + "t/" + namedComponentCount + "n"); List parts = new ArrayList<>(); for (Map.Entry entry : fields.entrySet()) { parts.add(entry.getKey() + "=" + entry.getValue()); @@ -85,6 +85,48 @@ public String capabilitiesSummary() { return String.join(", ", parts); } + /** + * Builds a point-in-time snapshot from static metadata plus a live {@code + * view}. The static fields never change after registration; the dynamic + * fields (command/component counts, diagnostics, closed) are read from the + * view at call time, so each snapshot reflects the consumer's current state. + * A {@code null} view yields a consumer with zeroed dynamic fields. + */ + public static MagicUtilsConsumerInfo fromStatic( + String pluginName, + String version, + String mainClass, + @Nullable String description, + @Nullable String website, + List authors, + String platformType, + boolean commandsEnabled, + @Nullable String permissionPrefix, + Instant connectedAt, + @Nullable MagicUtilsConsumerRuntimeView view + ) { + List namedNames = view != null ? new ArrayList<>(view.namedComponentNames()) : new ArrayList<>(); + namedNames.sort(String.CASE_INSENSITIVE_ORDER); + return new MagicUtilsConsumerInfo( + pluginName, + version, + mainClass, + description, + website, + List.copyOf(authors), + platformType, + commandsEnabled, + permissionPrefix, + commandsEnabled && view != null ? view.rootCommandCount() : 0, + view != null && view.diagnosticsEnabled(), + view != null && view.closed(), + view != null ? view.typedComponentCount() : 0, + view != null ? view.namedComponentCount() : 0, + List.copyOf(namedNames), + connectedAt + ); + } + /** * Rebuilds an immutable consumer snapshot from a bridge [payload]. The * fallbacks apply when a key is absent or of the wrong type, so a partial diff --git a/platform-api/src/main/java/dev/ua/theroer/magicutils/platform/MagicUtilsConsumerRegistry.java b/platform-api/src/main/java/dev/ua/theroer/magicutils/platform/MagicUtilsConsumerRegistry.java index b611ab0f..da2fdb10 100644 --- a/platform-api/src/main/java/dev/ua/theroer/magicutils/platform/MagicUtilsConsumerRegistry.java +++ b/platform-api/src/main/java/dev/ua/theroer/magicutils/platform/MagicUtilsConsumerRegistry.java @@ -1,5 +1,6 @@ package dev.ua.theroer.magicutils.platform; +import java.time.Instant; import java.util.ArrayList; import java.util.Comparator; import java.util.List; @@ -17,22 +18,97 @@ * + a reflective bridge to the bundle plugin), but shares the {@link * MagicUtilsConsumerInfo} snapshot type. * + *

Registrations keep a live {@link MagicUtilsConsumerRuntimeView} rather than a + * frozen {@link MagicUtilsConsumerInfo}. The consumer's command/component counts + * change after registration (commands are wired on server start, components while + * the runtime is built), so {@link #snapshot()} / {@link #find(String)} rebuild a + * fresh {@code MagicUtilsConsumerInfo} from the view at call time. This keeps + * {@code /magicutils mods} honest instead of showing an early snapshot.

+ * *

This is the single implementation the per-loader registries delegate to, so * the holder logic is not duplicated across platforms.

*/ public final class MagicUtilsConsumerRegistry { - private static final Map CONSUMERS = new ConcurrentHashMap<>(); + private static final Map CONSUMERS = new ConcurrentHashMap<>(); private static final String BUNDLE_NAME = "MagicUtils"; private MagicUtilsConsumerRegistry() { } - /** Registers or refreshes {@code info} for its mod, keyed by name (case-insensitive). */ + /** + * Immutable static metadata of a consumer, captured once at registration. + * The mutable state (commands, components, diagnostics, closed) is read + * separately from a {@link MagicUtilsConsumerRuntimeView}. + */ + public record StaticMeta( + String pluginName, + String version, + String mainClass, + @Nullable String description, + @Nullable String website, + List authors, + String platformType, + boolean commandsEnabled, + @Nullable String permissionPrefix, + Instant connectedAt + ) { + public StaticMeta { + authors = List.copyOf(authors); + } + } + + private record Registration(StaticMeta meta, MagicUtilsConsumerRuntimeView view) { + MagicUtilsConsumerInfo toInfo() { + return MagicUtilsConsumerInfo.fromStatic( + meta.pluginName(), + meta.version(), + meta.mainClass(), + meta.description(), + meta.website(), + meta.authors(), + meta.platformType(), + meta.commandsEnabled(), + meta.permissionPrefix(), + meta.connectedAt(), + view); + } + } + + /** + * Registers or refreshes a consumer from its static {@code meta} plus a live + * {@code view}. The view is read lazily on every {@link #snapshot()} / + * {@link #find(String)}, so callers should pass a view backed by the live + * runtime rather than a captured snapshot. + */ + public static void register(StaticMeta meta, MagicUtilsConsumerRuntimeView view) { + if (meta == null || view == null || BUNDLE_NAME.equalsIgnoreCase(meta.pluginName().trim())) { + return; + } + CONSUMERS.put(normalizeKey(meta.pluginName()), new Registration(meta, view)); + } + + /** + * Registers or refreshes a consumer from an already-built {@code info}. The + * dynamic fields are frozen at the value they held in {@code info}; prefer + * {@link #register(StaticMeta, MagicUtilsConsumerRuntimeView)} where a live + * view is available. Kept for bridges that can only produce a snapshot. + */ public static void register(MagicUtilsConsumerInfo info) { if (info == null || BUNDLE_NAME.equalsIgnoreCase(info.pluginName().trim())) { return; } - CONSUMERS.put(normalizeKey(info.pluginName()), info); + StaticMeta meta = new StaticMeta( + info.pluginName(), + info.version(), + info.mainClass(), + info.description(), + info.website(), + info.authors(), + info.platformType(), + info.commandsEnabled(), + info.permissionPrefix(), + info.connectedAt()); + CONSUMERS.put(normalizeKey(info.pluginName()), new Registration(meta, frozenView(info))); } /** Removes the consumer registered under {@code modName}, if any. */ @@ -43,22 +119,61 @@ public static void unregister(@Nullable String modName) { CONSUMERS.remove(normalizeKey(modName)); } - /** Immutable, name-sorted snapshot of the registered consumers. */ + /** Immutable, name-sorted snapshot of the registered consumers, rebuilt from live views. */ public static List snapshot() { - List consumers = new ArrayList<>(CONSUMERS.values()); + List consumers = new ArrayList<>(CONSUMERS.size()); + for (Registration registration : CONSUMERS.values()) { + consumers.add(registration.toInfo()); + } consumers.sort(Comparator.comparing(MagicUtilsConsumerInfo::pluginName, String.CASE_INSENSITIVE_ORDER)); return List.copyOf(consumers); } - /** Consumer matching {@code modName} ignoring case, or null. */ + /** Consumer matching {@code modName} ignoring case, rebuilt from its live view, or null. */ public static @Nullable MagicUtilsConsumerInfo find(@Nullable String modName) { if (modName == null || modName.isBlank()) { return null; } - return CONSUMERS.get(normalizeKey(modName)); + Registration registration = CONSUMERS.get(normalizeKey(modName)); + return registration != null ? registration.toInfo() : null; } private static String normalizeKey(String modName) { return modName.trim().toLowerCase(Locale.ROOT); } + + /** A view returning the dynamic fields frozen at the value held by {@code info}. */ + private static MagicUtilsConsumerRuntimeView frozenView(MagicUtilsConsumerInfo info) { + return new MagicUtilsConsumerRuntimeView() { + @Override + public int rootCommandCount() { + return info.rootCommandCount(); + } + + @Override + public int typedComponentCount() { + return info.typedComponentCount(); + } + + @Override + public int namedComponentCount() { + return info.namedComponentCount(); + } + + @Override + public List namedComponentNames() { + return info.namedComponentNames(); + } + + @Override + public boolean diagnosticsEnabled() { + return info.diagnosticsEnabled(); + } + + @Override + public boolean closed() { + return info.closed(); + } + }; + } } diff --git a/platform-api/src/main/java/dev/ua/theroer/magicutils/platform/MagicUtilsConsumerRuntimeView.java b/platform-api/src/main/java/dev/ua/theroer/magicutils/platform/MagicUtilsConsumerRuntimeView.java new file mode 100644 index 00000000..8f64c8db --- /dev/null +++ b/platform-api/src/main/java/dev/ua/theroer/magicutils/platform/MagicUtilsConsumerRuntimeView.java @@ -0,0 +1,38 @@ +package dev.ua.theroer.magicutils.platform; + +import java.util.List; + +/** + * Live view of a shared-runtime consumer's mutable state. Unlike the static + * metadata captured at registration (name, version, authors, …), these values + * change as the consumer initializes: commands are registered on server start, + * components are added while the runtime is built, and the runtime may later be + * closed. The registry keeps a reference to this view and reads it lazily when a + * {@link MagicUtilsConsumerInfo} snapshot is requested, so {@code /magicutils + * mods} always reflects the current state rather than a frozen early snapshot. + * + *

On loaders where every consumer shares one classloader (Fabric, NeoForge) + * the view simply closes over the consumer's {@code MagicRuntime} and command + * registry. On Bukkit, where the bundle plugin and consumers live in separate + * classloaders, the reflective bridge supplies an equivalent view that rebuilds + * the payload on demand.

+ */ +public interface MagicUtilsConsumerRuntimeView { + /** Number of registered root commands right now (0 when commands are disabled). */ + int rootCommandCount(); + + /** Typed component count in the runtime right now. */ + int typedComponentCount(); + + /** Named component count in the runtime right now. */ + int namedComponentCount(); + + /** Named component keys right now (order-independent; callers sort for display). */ + List namedComponentNames(); + + /** Whether the diagnostics service is present right now. */ + boolean diagnosticsEnabled(); + + /** Whether the runtime is closed right now. */ + boolean closed(); +} diff --git a/platform-api/src/test/java/dev/ua/theroer/magicutils/platform/MagicUtilsConsumerRegistryTest.java b/platform-api/src/test/java/dev/ua/theroer/magicutils/platform/MagicUtilsConsumerRegistryTest.java new file mode 100644 index 00000000..3818d2e0 --- /dev/null +++ b/platform-api/src/test/java/dev/ua/theroer/magicutils/platform/MagicUtilsConsumerRegistryTest.java @@ -0,0 +1,136 @@ +package dev.ua.theroer.magicutils.platform; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Instant; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class MagicUtilsConsumerRegistryTest { + + private static final String MOD = "TestMod"; + + @AfterEach + void cleanup() { + MagicUtilsConsumerRegistry.unregister(MOD); + } + + private static MagicUtilsConsumerRegistry.StaticMeta meta() { + return new MagicUtilsConsumerRegistry.StaticMeta( + MOD, "1.0.0", "test.Main", null, null, List.of(), + "Fabric", true, "testmod", Instant.now()); + } + + /** The registry rebuilds the info from the live view on every read, so a + * change in the view (e.g. commands registered after startup) is reflected + * without re-registering. This is the bug that showed commands=0. */ + @Test + void snapshotReflectsLiveViewChanges() { + AtomicInteger rootCommands = new AtomicInteger(0); + AtomicInteger typed = new AtomicInteger(0); + MagicUtilsConsumerRegistry.register(meta(), view(rootCommands, typed, false)); + + MagicUtilsConsumerInfo before = single(); + assertEquals(0, before.rootCommandCount()); + assertEquals(0, before.typedComponentCount()); + + // Simulate the consumer registering its commands/components later. + rootCommands.set(3); + typed.set(8); + + MagicUtilsConsumerInfo after = single(); + assertEquals(3, after.rootCommandCount()); + assertEquals(8, after.typedComponentCount()); + } + + @Test + void findRebuildsFromLiveView() { + AtomicInteger rootCommands = new AtomicInteger(1); + MagicUtilsConsumerRegistry.register(meta(), view(rootCommands, new AtomicInteger(0), false)); + + MagicUtilsConsumerInfo found = MagicUtilsConsumerRegistry.find("testmod"); + assertNotNull(found); + assertEquals(1, found.rootCommandCount()); + + rootCommands.set(5); + assertEquals(5, MagicUtilsConsumerRegistry.find(MOD).rootCommandCount()); + } + + @Test + void unregisterRemovesConsumer() { + MagicUtilsConsumerRegistry.register(meta(), view(new AtomicInteger(0), new AtomicInteger(0), false)); + assertNotNull(MagicUtilsConsumerRegistry.find(MOD)); + MagicUtilsConsumerRegistry.unregister(MOD); + assertNull(MagicUtilsConsumerRegistry.find(MOD)); + } + + @Test + void closedFlagIsReadLive() { + AtomicBoolean closed = new AtomicBoolean(false); + MagicUtilsConsumerRegistry.register(meta(), + new SimpleView(new AtomicInteger(2), new AtomicInteger(0), closed)); + assertTrue(!single().closed()); + closed.set(true); + assertTrue(single().closed()); + } + + private static MagicUtilsConsumerInfo single() { + List all = MagicUtilsConsumerRegistry.snapshot().stream() + .filter(info -> info.pluginName().equals(MOD)) + .toList(); + assertEquals(1, all.size()); + return all.get(0); + } + + private static MagicUtilsConsumerRuntimeView view(AtomicInteger rootCommands, AtomicInteger typed, boolean closed) { + return new SimpleView(rootCommands, typed, new AtomicBoolean(closed)); + } + + private static final class SimpleView implements MagicUtilsConsumerRuntimeView { + private final AtomicInteger rootCommands; + private final AtomicInteger typed; + private final AtomicBoolean closedFlag; + + private SimpleView(AtomicInteger rootCommands, AtomicInteger typed, AtomicBoolean closedFlag) { + this.rootCommands = rootCommands; + this.typed = typed; + this.closedFlag = closedFlag; + } + + @Override + public int rootCommandCount() { + return rootCommands.get(); + } + + @Override + public int typedComponentCount() { + return typed.get(); + } + + @Override + public int namedComponentCount() { + return 0; + } + + @Override + public List namedComponentNames() { + return List.of(); + } + + @Override + public boolean diagnosticsEnabled() { + return false; + } + + @Override + public boolean closed() { + return closedFlag.get(); + } + } +} diff --git a/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/BukkitMagicUtilsConsumerRegistry.java b/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/BukkitMagicUtilsConsumerRegistry.java index 421089f0..a4128566 100644 --- a/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/BukkitMagicUtilsConsumerRegistry.java +++ b/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/platform/bukkit/BukkitMagicUtilsConsumerRegistry.java @@ -10,6 +10,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.function.Supplier; import org.bukkit.plugin.Plugin; import org.bukkit.plugin.java.JavaPlugin; import org.jetbrains.annotations.Nullable; @@ -30,7 +31,14 @@ private BukkitMagicUtilsConsumerRegistry() { } /** - * Registers or refreshes a plugin using the shared MagicUtils runtime. + * Registers a plugin using the shared MagicUtils runtime. Instead of pushing + * a frozen payload, this hands the bundle plugin a {@link Supplier} that + * rebuilds the payload from the live {@code runtime}/{@code commandRegistry} + * on demand. The bundle calls it when {@code /magicutils mods} runs, so the + * command/component counts always reflect the consumer's current state rather + * than whatever was true the instant it registered (before commands were even + * wired up). Because the supplier and the bundle share only JDK types, it + * crosses the separate-classloader reflective bridge cleanly. * * @param plugin plugin instance * @param runtime runtime container @@ -55,13 +63,16 @@ public static void register(JavaPlugin plugin, MagicRuntime runtime, @Nullable C return; } + Instant connectedAt = Instant.now(); + Supplier> payloadSupplier = + () -> snapshotPayload(plugin, runtime, commandRegistry, connectedAt); try { invokeBundleMethod( bundlePlugin, REGISTER_METHOD, - new Class[]{JavaPlugin.class, Map.class}, + new Class[]{JavaPlugin.class, Supplier.class}, plugin, - snapshotPayload(plugin, runtime, commandRegistry, Instant.now()) + payloadSupplier ); } catch (ReflectiveOperationException exception) { plugin.getLogger().warning("Failed to register shared MagicUtils consumer: " + exception.getMessage()); diff --git a/platform-fabric/src/main/java/dev/ua/theroer/magicutils/platform/fabric/MagicUtilsFabricConsumerRegistry.java b/platform-fabric/src/main/java/dev/ua/theroer/magicutils/platform/fabric/MagicUtilsFabricConsumerRegistry.java index 3fc5836a..58c539cc 100644 --- a/platform-fabric/src/main/java/dev/ua/theroer/magicutils/platform/fabric/MagicUtilsFabricConsumerRegistry.java +++ b/platform-fabric/src/main/java/dev/ua/theroer/magicutils/platform/fabric/MagicUtilsFabricConsumerRegistry.java @@ -2,6 +2,7 @@ import dev.ua.theroer.magicutils.platform.MagicUtilsConsumerInfo; import dev.ua.theroer.magicutils.platform.MagicUtilsConsumerRegistry; +import dev.ua.theroer.magicutils.platform.MagicUtilsConsumerRuntimeView; import java.util.List; import org.jetbrains.annotations.Nullable; @@ -20,6 +21,11 @@ public static void register(MagicUtilsConsumerInfo info) { MagicUtilsConsumerRegistry.register(info); } + /** @see MagicUtilsConsumerRegistry#register(MagicUtilsConsumerRegistry.StaticMeta, MagicUtilsConsumerRuntimeView) */ + public static void register(MagicUtilsConsumerRegistry.StaticMeta meta, MagicUtilsConsumerRuntimeView view) { + MagicUtilsConsumerRegistry.register(meta, view); + } + /** @see MagicUtilsConsumerRegistry#unregister(String) */ public static void unregister(@Nullable String modName) { MagicUtilsConsumerRegistry.unregister(modName); From 64f083abd08c97e13fd75139ff6067f8b0354a2a Mon Sep 17 00:00:00 2001 From: THEROER Date: Mon, 6 Jul 2026 17:31:46 +0300 Subject: [PATCH 08/17] refactor(logger): share MiniMessage attribute escaping, make /mu mods rows clickable Hoist the MiniMessage attribute escaper into MessageParser.escapeAttribute so HelpCommandSupport and the bundle command reuse one implementation instead of each carrying a private copy. Use it to wrap every /magicutils mods row in a click/hover so selecting a mod runs /magicutils mod . --- .../commands/HelpCommandSupport.java | 14 ++------------ .../diagnostics/MagicUtilsBundleCommand.java | 15 +++++++++++++-- .../magicutils/logger/MessageParser.java | 18 ++++++++++++++++++ 3 files changed, 33 insertions(+), 14 deletions(-) diff --git a/commands/src/main/java/dev/ua/theroer/magicutils/commands/HelpCommandSupport.java b/commands/src/main/java/dev/ua/theroer/magicutils/commands/HelpCommandSupport.java index 7a34cc7b..6991b0fc 100644 --- a/commands/src/main/java/dev/ua/theroer/magicutils/commands/HelpCommandSupport.java +++ b/commands/src/main/java/dev/ua/theroer/magicutils/commands/HelpCommandSupport.java @@ -821,8 +821,8 @@ private static List> normalizeManagers(List> } private static String clickable(String text, String hover, String command, HelpStyle style) { - String hoverText = escapeMiniAttribute(hover); - String clickCommand = escapeMiniAttribute(command); + String hoverText = MessageParser.escapeAttribute(hover); + String clickCommand = MessageParser.escapeAttribute(command); return "" + "" + color(style.textTag(), text) @@ -1353,16 +1353,6 @@ private static String escapeMiniText(String text) { return escaped; } - private static String escapeMiniAttribute(String text) { - if (text == null || text.isEmpty()) { - return ""; - } - String escaped = text.replace("\\", "\\\\"); - escaped = escaped.replace("\"", "\\\""); - escaped = escaped.replace("<", "\\<").replace(">", "\\>"); - return escaped; - } - private static List splitSubCommandPath(String raw) { if (raw == null) { return List.of(); diff --git a/diagnostics/src/main/java/dev/ua/theroer/magicutils/diagnostics/MagicUtilsBundleCommand.java b/diagnostics/src/main/java/dev/ua/theroer/magicutils/diagnostics/MagicUtilsBundleCommand.java index 699ebe47..9c40c581 100644 --- a/diagnostics/src/main/java/dev/ua/theroer/magicutils/diagnostics/MagicUtilsBundleCommand.java +++ b/diagnostics/src/main/java/dev/ua/theroer/magicutils/diagnostics/MagicUtilsBundleCommand.java @@ -164,9 +164,10 @@ private List modListLines() { return List.copyOf(lines); } for (MagicUtilsConsumerInfo info : consumers) { - lines.add("- " + escape(info.pluginName()) + " v" + String body = "- " + escape(info.pluginName()) + " v" + escape(info.version()) + " (" + escape(info.capabilitiesSummary()) - + ")"); + + ")"; + lines.add(clickableMod(info.pluginName(), body)); } return List.copyOf(lines); } @@ -251,4 +252,14 @@ private static String escape(@Nullable String value) { .replace("<", "<") .replace(">", ">"); } + + /** Wraps a list line so clicking it runs {@code /magicutils mod }. */ + private String clickableMod(String modName, String body) { + String command = "/magicutils mod " + modName; + return "" + + "Click for details of " + MessageParser.escapeAttribute(modName) + + "\">" + + body + + ""; + } } diff --git a/logger/src/main/java/dev/ua/theroer/magicutils/logger/MessageParser.java b/logger/src/main/java/dev/ua/theroer/magicutils/logger/MessageParser.java index 49c2a684..4093998a 100644 --- a/logger/src/main/java/dev/ua/theroer/magicutils/logger/MessageParser.java +++ b/logger/src/main/java/dev/ua/theroer/magicutils/logger/MessageParser.java @@ -37,4 +37,22 @@ public static Component parseSmart(String input) { } return comp; } + + /** + * Escapes a value for use inside a MiniMessage tag attribute (e.g. the target + * of {@code } or {@code }), so + * quotes and tag brackets in the value don't break the surrounding tag. + * + * @param value raw attribute value (null yields empty string) + * @return escaped value safe to embed in a quoted MiniMessage attribute + */ + public static String escapeAttribute(String value) { + if (value == null || value.isEmpty()) { + return ""; + } + return value.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("<", "\\<") + .replace(">", "\\>"); + } } From 9c776156adff9e88d1ac3261100f310b0c5daa9b Mon Sep 17 00:00:00 2001 From: THEROER Date: Mon, 6 Jul 2026 17:32:29 +0300 Subject: [PATCH 09/17] build(build-logic): add a configurable buildAllTargets fan-out Introduce an allTargets DSL and model so buildAllTargets can be shaped from settings: a targets subset, a platform scenario, and the task type (build / check / publishToMavenLocal). The matrix root plugin reads the resolved spec and registers the fan-out task accordingly. --- .../build/matrix/MagicUtilsAllTargetsDsl.kt | 45 +++++++ .../build/matrix/MagicUtilsAllTargetsModel.kt | 74 ++++++++++++ .../matrix/MagicUtilsMatrixRootPlugin.kt | 111 ++++++++++++++++++ .../matrix/MagicUtilsMatrixSettingsPlugin.kt | 10 ++ .../kotlin/MagicUtilsAllTargetsModelTest.kt | 78 ++++++++++++ 5 files changed, 318 insertions(+) create mode 100644 build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsAllTargetsDsl.kt create mode 100644 build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsAllTargetsModel.kt create mode 100644 build-logic/src/test/kotlin/MagicUtilsAllTargetsModelTest.kt diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsAllTargetsDsl.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsAllTargetsDsl.kt new file mode 100644 index 00000000..753d4f4a --- /dev/null +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsAllTargetsDsl.kt @@ -0,0 +1,45 @@ +package dev.ua.theroer.magicutils.build.matrix + +import dev.ua.theroer.magicutils.build.target.* + +/** + * Consumer-facing DSL for the "build everything, for every version" fan-out, e.g.: + * + * magicMatrix { + * allTargets { + * // targets(...) // default: every target in targets.properties + * // targets("mc12110", "mc262") + * scenario = "workspace" // default: matrix default scenario + * taskType = "build" // build | check | publishToMavenLocal + * } + * } + * + * Every axis is optional; a bare `allTargets { }` (or omitting the block entirely) + * means "run `build` over the default scenario for every declared target". The + * task itself (`buildAllTargets`) is always registered — the block only shapes it. + */ +open class MagicUtilsAllTargetsDsl { + private val explicitTargets = linkedSetOf() + + /** Scenario whose platforms each per-target run builds; null = matrix default. */ + var scenario: String? = null + + /** Per-target task: "build" (default), "check", or "publishToMavenLocal". */ + var taskType: String = "build" + + /** Restrict the fan-out to these targets (normalized to `mcXXXX`). */ + fun targets(vararg names: String) { + names.forEach { explicitTargets += normalizeTargetName(it) } + } + + /** Restrict the fan-out to these targets (normalized to `mcXXXX`). */ + fun targets(names: Iterable) { + names.forEach { explicitTargets += normalizeTargetName(it) } + } + + internal fun toSpec(): MagicUtilsAllTargetsSpec = MagicUtilsAllTargetsSpec( + targets = explicitTargets.toList(), + scenario = scenario?.trim()?.lowercase()?.takeIf { it.isNotEmpty() }, + taskType = MagicUtilsAllTargetsTaskType.fromToken(taskType), + ) +} diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsAllTargetsModel.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsAllTargetsModel.kt new file mode 100644 index 00000000..c0d7d534 --- /dev/null +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsAllTargetsModel.kt @@ -0,0 +1,74 @@ +package dev.ua.theroer.magicutils.build.matrix + +import dev.ua.theroer.magicutils.build.target.* + +import org.gradle.api.GradleException +import java.io.File + +/** + * A single Gradle task run over every declared target — the wrapper the + * `buildAllTargets` task fans out into. One Gradle build graph can only cover + * one Minecraft target (the target is pinned in `settings.gradle`), so building + * "everything for every advertised version" is inherently N Gradle invocations: + * one per target from `targets.properties`, filtered and shaped by this spec. + * + * Configured by the consumer via `magicMatrix { allTargets { ... } }`; every axis + * has a sensible default so a bare `allTargets { }` (or no block at all) means + * "run `build`, over the `workspace` scenario, for every target in the file". + */ +data class MagicUtilsAllTargetsSpec( + /** + * Explicit target subset (normalized `mcXXXX` names). Empty means every + * target declared in `targets.properties`, in file order. + */ + val targets: List, + /** + * Scenario whose platforms each per-target run builds. Null falls back to + * the matrix default (`workspace` when present), the same as a bare build. + */ + val scenario: String?, + /** The per-target task to run: `build` (default), `check`, `publishToMavenLocal`. */ + val taskType: MagicUtilsAllTargetsTaskType, +) + +enum class MagicUtilsAllTargetsTaskType(val gradleTask: String) { + BUILD("build"), + CHECK("check"), + PUBLISH_TO_MAVEN_LOCAL("publishToMavenLocal"); + + companion object { + fun fromToken(raw: String): MagicUtilsAllTargetsTaskType { + val normalized = raw.trim().lowercase().replace("-", "").replace("_", "") + return when (normalized) { + "build" -> BUILD + "check" -> CHECK + "publish", "publishtomavenlocal", "maven", "mavenlocal" -> PUBLISH_TO_MAVEN_LOCAL + else -> throw GradleException( + "Unknown allTargets task type '$raw'. Use one of: build, check, publishToMavenLocal." + ) + } + } + } +} + +/** + * Resolves the concrete, ordered list of targets this spec should fan out over. + * Validates that any explicit subset actually exists in [targetsFile], so a typo + * fails fast at configuration time rather than silently building nothing. + */ +internal fun MagicUtilsAllTargetsSpec.resolveTargets(targetsFile: File): List { + val declared = loadAllTargetNames(targetsFile) + if (targets.isEmpty()) { + return declared + } + val declaredSet = declared.toSet() + val unknown = targets.filter { it !in declaredSet } + if (unknown.isNotEmpty()) { + throw GradleException( + "allTargets requested unknown targets: ${unknown.joinToString(", ")}. " + + "Declared targets: ${declared.joinToString(", ")}." + ) + } + // Preserve the consumer's requested order, deduplicated. + return targets.distinct() +} 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 c37207ee..f0778a84 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 @@ -39,6 +39,10 @@ class MagicUtilsMatrixRootPlugin : Plugin { registerMatrixJsonTasks(project, resolvedContext) registerScenarioAggregateTasks(project, resolvedContext) registerSelectedScenarioTasks(project, resolvedContext) + + val allTargetsSpec = project.gradle.extensions.extraProperties.properties["magicutilsAllTargetsSpec"] + as? MagicUtilsAllTargetsSpec ?: MagicUtilsAllTargetsSpec(emptyList(), null, MagicUtilsAllTargetsTaskType.BUILD) + registerAllTargetsTask(project, resolvedContext, allTargetsSpec) registerAggregatedJavadocTask(project, resolvedContext) registerDevServerAggregateTasks(project) registerPublishCategoryTasks(project) @@ -215,6 +219,113 @@ private fun registerListBuildMatrixTask( } } +/** + * `buildAllTargets`: the "build everything, for every advertised version" 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. This registers one [org.gradle.api.tasks.Exec] per target — each + * shells out to the project's own `gradlew` wrapper with `-Ptarget=mcXXXX` — and + * an aggregate `buildAllTargets` that depends on them all. The default + * `./gradlew build` is left untouched (single default target); this is the opt-in + * "all versions" task. + * + * A nested `GradleBuild` task can't be used here: 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. + * + * Shaped by `magicMatrix { allTargets { ... } }` (targets subset, scenario, task + * type) and overridable per-invocation with: + * -PallTargets.targets=mc12110,mc262 (comma list; overrides the DSL subset) + * -PallTargets.scenario=fabric (overrides the DSL/default scenario) + * -PallTargets.taskType=publishToMavenLocal + */ +private fun registerAllTargetsTask( + project: Project, + resolvedContext: MagicUtilsMatrixResolvedContext, + spec: MagicUtilsAllTargetsSpec, +) { + val definition = resolvedContext.definition + val targetsFile = project.rootProject.file(definition.targetsFile) + + fun stringProperty(name: String): String? = + (project.findProperty(name) as? String)?.trim()?.takeIf { it.isNotEmpty() } + + // CLI overrides win over the DSL config, so a consumer can retarget a one-off + // run without editing settings.gradle. + val overrideTargets = stringProperty("allTargets.targets") + ?.split(',') + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + ?.map(::normalizeTargetName) + ?: emptyList() + val effectiveSpec = spec.copy( + targets = overrideTargets.ifEmpty { spec.targets }, + scenario = stringProperty("allTargets.scenario")?.lowercase() ?: spec.scenario, + taskType = stringProperty("allTargets.taskType") + ?.let(MagicUtilsAllTargetsTaskType::fromToken) + ?: spec.taskType, + ) + + val resolvedTargets = runCatching { effectiveSpec.resolveTargets(targetsFile) }.getOrElse { error -> + // Defer the failure to task execution so plain configuration (IDE sync, + // `tasks`) never breaks just because a subset typo exists in the DSL. + project.tasks.register("buildAllTargets") { task -> + task.group = "matrix" + task.description = "Build every declared target (misconfigured — see error on run)." + task.doFirst { throw GradleException(error.message ?: "Invalid allTargets configuration.") } + } + 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}", + ) + effectiveSpec.scenario?.let { args += "-Pscenario=$it" } + task.commandLine(args) + } + } + + project.tasks.register("buildAllTargets") { task -> + task.group = "matrix" + task.description = "Run '${effectiveSpec.taskType.gradleTask}' for every declared target " + + "(${resolvedTargets.joinToString(", ")})." + task.dependsOn(perTargetTasks) + task.doLast { + println( + "buildAllTargets: '${effectiveSpec.taskType.gradleTask}' completed for " + + "${resolvedTargets.size} targets (${resolvedTargets.joinToString(", ")})" + + (effectiveSpec.scenario?.let { ", scenario=$it" } ?: "") + ) + } + } +} + private fun registerScenarioAggregateTasks( project: Project, resolvedContext: MagicUtilsMatrixResolvedContext, 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 347da2bb..c8ce1b43 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 @@ -56,6 +56,15 @@ open class MagicUtilsMatrixSettingsExtension { internal fun toModrinthSpec(): ModrinthReleaseSpec? = modrinthDsl.toSpec() + private val allTargetsDsl = MagicUtilsAllTargetsDsl() + + /** Shapes the `buildAllTargets` fan-out (targets subset, scenario, task type). */ + fun allTargets(action: org.gradle.api.Action) { + action.execute(allTargetsDsl) + } + + internal fun toAllTargetsSpec(): MagicUtilsAllTargetsSpec = allTargetsDsl.toSpec() + fun commonProject(path: String) { commonProjectPaths += normalizeProjectPath(path) } @@ -376,6 +385,7 @@ class MagicUtilsMatrixSettingsPlugin : Plugin { settings.gradle.extensions.extraProperties.set("magicutilsSmokeSpecs", extension.toSmokeSpecs()) settings.gradle.extensions.extraProperties.set("magicutilsSmokeGate", extension.smokeGate()) settings.gradle.extensions.extraProperties.set("magicutilsModrinthSpec", extension.toModrinthSpec()) + settings.gradle.extensions.extraProperties.set("magicutilsAllTargetsSpec", extension.toAllTargetsSpec()) resolvedContext.includedProjects.forEach { projectPath -> settings.include(projectPath.removePrefix(":")) diff --git a/build-logic/src/test/kotlin/MagicUtilsAllTargetsModelTest.kt b/build-logic/src/test/kotlin/MagicUtilsAllTargetsModelTest.kt new file mode 100644 index 00000000..338516c6 --- /dev/null +++ b/build-logic/src/test/kotlin/MagicUtilsAllTargetsModelTest.kt @@ -0,0 +1,78 @@ +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.nio.file.Path + +import dev.ua.theroer.magicutils.build.matrix.MagicUtilsAllTargetsSpec +import dev.ua.theroer.magicutils.build.matrix.MagicUtilsAllTargetsTaskType +import dev.ua.theroer.magicutils.build.matrix.resolveTargets + +class MagicUtilsAllTargetsModelTest { + + private fun writeTargets(dir: Path): File { + val file = dir.resolve("targets.properties").toFile() + file.writeText( + """ + target=mc12110 + + mc1201.minecraft=1.20.1 + mc1201.java=17 + + mc12110.minecraft=1.21.10 + mc12110.java=21 + + mc262.minecraft=26.2 + mc262.java=25 + """.trimIndent() + ) + return file + } + + private fun spec( + targets: List = emptyList(), + scenario: String? = null, + taskType: MagicUtilsAllTargetsTaskType = MagicUtilsAllTargetsTaskType.BUILD, + ) = MagicUtilsAllTargetsSpec(targets, scenario, taskType) + + @Test + fun `empty target subset resolves to every declared target`(@TempDir dir: Path) { + assertEquals(listOf("mc1201", "mc12110", "mc262"), spec().resolveTargets(writeTargets(dir))) + } + + @Test + fun `explicit subset preserves requested order and dedupes`(@TempDir dir: Path) { + val resolved = spec(targets = listOf("mc262", "mc12110", "mc262")).resolveTargets(writeTargets(dir)) + assertEquals(listOf("mc262", "mc12110"), resolved) + } + + @Test + fun `unknown target in subset fails fast`(@TempDir dir: Path) { + val file = writeTargets(dir) + assertThrows(org.gradle.api.GradleException::class.java) { + spec(targets = listOf("mc999")).resolveTargets(file) + } + } + + @Test + fun `task type tokens map to gradle tasks`() { + assertEquals("build", MagicUtilsAllTargetsTaskType.fromToken("build").gradleTask) + assertEquals("check", MagicUtilsAllTargetsTaskType.fromToken("CHECK").gradleTask) + assertEquals( + "publishToMavenLocal", + MagicUtilsAllTargetsTaskType.fromToken("publish-to-maven-local").gradleTask, + ) + assertEquals( + "publishToMavenLocal", + MagicUtilsAllTargetsTaskType.fromToken("publishToMavenLocal").gradleTask, + ) + } + + @Test + fun `unknown task type token fails fast`() { + assertThrows(org.gradle.api.GradleException::class.java) { + MagicUtilsAllTargetsTaskType.fromToken("assemble") + } + } +} From 5c4603d4f44b755620b3544a4164b66bff8a6c57 Mon Sep 17 00:00:00 2001 From: THEROER Date: Mon, 6 Jul 2026 17:33:12 +0300 Subject: [PATCH 10/17] feat(bukkit-bundle): ship as a Paper plugin serving live consumer payloads Add a paper-plugin.yml (with folia-supported) so the bundle loads as the standalone MagicUtils Paper plugin, and expand the bundle build to expand both plugin.yml and paper-plugin.yml. The bundle stores each shared-runtime consumer as a Supplier of its payload rather than a frozen map, so /magicutils mods reflects the consumer's current state at query time. --- .../module/MagicUtilsBukkitBundlePlugin.kt | 2 +- .../bukkit/MagicUtilsBukkitBundlePlugin.java | 31 ++++++++++++++----- .../src/main/resources/paper-plugin.yml | 8 +++++ 3 files changed, 33 insertions(+), 8 deletions(-) create mode 100644 bukkit-bundle/src/main/resources/paper-plugin.yml 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 index df949945..1da01f79 100644 --- 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 @@ -69,7 +69,7 @@ class MagicUtilsBukkitBundlePlugin : Plugin { tasks.named("processResources", ProcessResources::class.java).configure { resources -> resources.inputs.property("version", version) resources.inputs.property("apiVersion", apiVersion) - resources.filesMatching("plugin.yml") { details -> + resources.filesMatching(listOf("plugin.yml", "paper-plugin.yml")) { details -> details.expand( mapOf( "version" to version, diff --git a/bukkit-bundle/src/main/java/dev/ua/theroer/magicutils/bukkit/MagicUtilsBukkitBundlePlugin.java b/bukkit-bundle/src/main/java/dev/ua/theroer/magicutils/bukkit/MagicUtilsBukkitBundlePlugin.java index fd695dc1..99ff9a0b 100644 --- a/bukkit-bundle/src/main/java/dev/ua/theroer/magicutils/bukkit/MagicUtilsBukkitBundlePlugin.java +++ b/bukkit-bundle/src/main/java/dev/ua/theroer/magicutils/bukkit/MagicUtilsBukkitBundlePlugin.java @@ -15,6 +15,7 @@ import java.util.Locale; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; import org.bukkit.plugin.java.JavaPlugin; import org.jetbrains.annotations.Nullable; @@ -23,7 +24,20 @@ */ public final class MagicUtilsBukkitBundlePlugin extends JavaPlugin { private MagicRuntime runtime; - private final Map sharedRuntimeConsumers = new ConcurrentHashMap<>(); + + /** + * A registered consumer: the plugin plus a supplier that rebuilds its + * registry payload from the live runtime on demand. Kept instead of a frozen + * {@link MagicUtilsConsumerInfo} so {@code /magicutils mods} reads the + * consumer's current command/component state at query time. + */ + private record ConsumerRegistration(JavaPlugin plugin, Supplier> payloadSupplier) { + MagicUtilsConsumerInfo toInfo() { + return BukkitMagicUtilsConsumerRegistry.consumerInfo(plugin, payloadSupplier.get()); + } + } + + private final Map sharedRuntimeConsumers = new ConcurrentHashMap<>(); /** * Creates a new instance of the MagicUtils bundle plugin. @@ -71,12 +85,11 @@ public void onDisable() { } } - public void registerSharedRuntimeConsumer(JavaPlugin plugin, Map payload) { - if (plugin == null || plugin == this || payload == null) { + public void registerSharedRuntimeConsumer(JavaPlugin plugin, Supplier> payloadSupplier) { + if (plugin == null || plugin == this || payloadSupplier == null) { return; } - MagicUtilsConsumerInfo consumerInfo = BukkitMagicUtilsConsumerRegistry.consumerInfo(plugin, payload); - sharedRuntimeConsumers.put(normalizeKey(consumerInfo.pluginName()), consumerInfo); + sharedRuntimeConsumers.put(normalizeKey(plugin.getName()), new ConsumerRegistration(plugin, payloadSupplier)); } public void unregisterSharedRuntimeConsumer(JavaPlugin plugin) { @@ -87,7 +100,10 @@ public void unregisterSharedRuntimeConsumer(JavaPlugin plugin) { } public List snapshotSharedRuntimeConsumers() { - List consumers = new ArrayList<>(sharedRuntimeConsumers.values()); + List consumers = new ArrayList<>(sharedRuntimeConsumers.size()); + for (ConsumerRegistration registration : sharedRuntimeConsumers.values()) { + consumers.add(registration.toInfo()); + } consumers.sort(Comparator.comparing(MagicUtilsConsumerInfo::pluginName, String.CASE_INSENSITIVE_ORDER)); return List.copyOf(consumers); } @@ -109,7 +125,8 @@ public List snapshotSharedRuntimeConsumers() { if (pluginName == null || pluginName.isBlank()) { return null; } - return sharedRuntimeConsumers.get(normalizeKey(pluginName)); + ConsumerRegistration registration = sharedRuntimeConsumers.get(normalizeKey(pluginName)); + return registration != null ? registration.toInfo() : null; } private static String normalizeKey(String pluginName) { diff --git a/bukkit-bundle/src/main/resources/paper-plugin.yml b/bukkit-bundle/src/main/resources/paper-plugin.yml new file mode 100644 index 00000000..93a7ee48 --- /dev/null +++ b/bukkit-bundle/src/main/resources/paper-plugin.yml @@ -0,0 +1,8 @@ +name: MagicUtils +main: dev.ua.theroer.magicutils.bukkit.MagicUtilsBukkitBundlePlugin +version: ${version} +api-version: "${apiVersion}" +author: THEROER +description: Shared MagicUtils runtime for Bukkit/Paper. +website: https://magicutils.theroer.dev/ +folia-supported: true From bb69482cd97f2c14cc951890bd318d4ea047d4e2 Mon Sep 17 00:00:00 2001 From: THEROER Date: Mon, 6 Jul 2026 17:33:39 +0300 Subject: [PATCH 11/17] refactor(bootstrap): register Fabric/NeoForge consumers via the live registry Replace the hand-built consumer payload in the Fabric and NeoForge bootstraps with a registration on MagicUtilsConsumerRegistry, so the shared-classloader loaders feed the same live view that /magicutils mods reads instead of a frozen snapshot captured at startup. --- .../magicutils/bootstrap/FabricBootstrap.java | 29 ++++++++++++------- .../bootstrap/NeoForgeBootstrap.java | 28 ++++++++++-------- 2 files changed, 34 insertions(+), 23 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 e61de39a..0285ff5f 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,7 +8,7 @@ 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.MagicUtilsConsumerInfo; +import dev.ua.theroer.magicutils.platform.MagicUtilsConsumerRegistry; import dev.ua.theroer.magicutils.platform.Platform; import dev.ua.theroer.magicutils.platform.fabric.FabricPlatformProvider; import dev.ua.theroer.magicutils.platform.fabric.MagicUtilsFabricConsumerRegistry; @@ -362,13 +362,9 @@ public RuntimeResult buildRuntime() { private void registerSharedRuntimeConsumer(MagicRuntime runtime, @Nullable CommandRegistry commandRegistry) { boolean commandsEnabled = commandRegistry != null; - int rootCommandCount = commandsEnabled && commandRegistry.commandManager() != null - ? commandRegistry.commandManager().getAll().size() - : 0; String prefix = commandsEnabled ? (permissionPrefix != null ? permissionPrefix : modName) : null; - boolean diagnosticsEnabled = runtime.findComponent(DiagnosticsService.class).isPresent(); Optional metadata = FabricLoader.getInstance() .getModContainer(modName) @@ -383,13 +379,24 @@ private void registerSharedRuntimeConsumer(MagicRuntime runtime, @Nullable Comma } }); - var payload = MagicUtilsConsumerPayloads.runtimePayload( - runtime, name, version, + // Static metadata is captured once; the dynamic state (command and + // component counts, diagnostics, closed) is read live from the + // runtime whenever /magicutils mods rebuilds a snapshot. This avoids + // freezing counts at buildRuntime() time, before the consumer has + // registered its commands (Fabric wires those on server start). + var meta = new MagicUtilsConsumerRegistry.StaticMeta( + name, version, // Fabric mods have no single "main class"; use the mod id. - modName, description, null, authors, - commandsEnabled, prefix, rootCommandCount, diagnosticsEnabled, Instant.now()); - MagicUtilsFabricConsumerRegistry.register( - MagicUtilsConsumerInfo.fromPayload(payload, name, version, modName, description, null, authors)); + modName, description, null, List.copyOf(authors), + MagicUtilsConsumerPayloads.platformTypeLabel(runtime), + commandsEnabled, prefix, Instant.now()); + var view = MagicUtilsConsumerViews.liveView( + runtime, + () -> commandsEnabled && commandRegistry.commandManager() != null + ? commandRegistry.commandManager().getAll().size() + : 0, + () -> runtime.findComponent(DiagnosticsService.class).isPresent()); + MagicUtilsFabricConsumerRegistry.register(meta, view); } private Prepared prepare() { 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 2a3b1ff9..64591f90 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 @@ -8,7 +8,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.platform.MagicUtilsConsumerInfo; import dev.ua.theroer.magicutils.platform.MagicUtilsConsumerRegistry; import dev.ua.theroer.magicutils.platform.Platform; import dev.ua.theroer.magicutils.platform.neoforge.NeoForgePlatformProvider; @@ -23,7 +22,6 @@ import java.util.ArrayList; import java.util.List; import java.util.Locale; -import java.util.Map; import java.util.Optional; import java.util.function.Consumer; import java.util.function.Supplier; @@ -363,13 +361,9 @@ public RuntimeResult buildRuntime() { private void registerSharedRuntimeConsumer(MagicRuntime runtime, @Nullable CommandRegistry commandRegistry) { boolean commandsEnabled = commandRegistry != null; - int rootCommandCount = commandsEnabled && commandRegistry.commandManager() != null - ? commandRegistry.commandManager().getAll().size() - : 0; String prefix = commandsEnabled ? (permissionPrefix != null ? permissionPrefix : modName) : null; - boolean diagnosticsEnabled = runtime.findComponent(DiagnosticsService.class).isPresent(); // Look up the mod's metadata by its id. `modName` is the logical/display // name ("MagicUtils"); NeoForge keys containers by lowercase mod id @@ -403,13 +397,23 @@ private void registerSharedRuntimeConsumer(MagicRuntime runtime, @Nullable Comma } List authors = new ArrayList<>(); - Map payload = MagicUtilsConsumerPayloads.runtimePayload( - runtime, name, version, + // Static metadata is captured once; the dynamic state (command and + // component counts, diagnostics, closed) is read live from the runtime + // whenever /magicutils mods rebuilds a snapshot, rather than frozen at + // buildRuntime() time before commands/components are wired up. + var meta = new MagicUtilsConsumerRegistry.StaticMeta( + name, version, // NeoForge mods have no single "main class"; use the mod id. - modName, description, null, authors, - commandsEnabled, prefix, rootCommandCount, diagnosticsEnabled, Instant.now()); - MagicUtilsConsumerRegistry.register( - MagicUtilsConsumerInfo.fromPayload(payload, name, version, modName, description, null, authors)); + modName, description, null, List.copyOf(authors), + MagicUtilsConsumerPayloads.platformTypeLabel(runtime), + commandsEnabled, prefix, Instant.now()); + var view = MagicUtilsConsumerViews.liveView( + runtime, + () -> commandsEnabled && commandRegistry.commandManager() != null + ? commandRegistry.commandManager().getAll().size() + : 0, + () -> runtime.findComponent(DiagnosticsService.class).isPresent()); + MagicUtilsConsumerRegistry.register(meta, view); } private Prepared prepare() { From 7e3c428cdb83495cc31db14621f891c6cc0bde86 Mon Sep 17 00:00:00 2001 From: THEROER Date: Mon, 6 Jul 2026 17:33:57 +0300 Subject: [PATCH 12/17] build(build-logic): bundle http-client into the fabric bundle Add :http-client to the fabric bundle's library projects; its only external dependency (jackson) is already bundled via config-yaml/toml. --- .../magicutils/build/module/MagicUtilsFabricBundlePlugin.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 72ff128c..73c2ef77 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 @@ -40,7 +40,9 @@ class MagicUtilsFabricBundlePlugin : Plugin { project(":platform-api"), project(":commands-brigadier"), project(":core"), - project(":diagnostics") + project(":diagnostics"), + // Jackson (its only external dep) is already bundled via config-yaml/toml. + project(":http-client") ) val bundleModProjects = listOf( From 41dd5bd740afcc2db1240a3a8ea3ef3fcbdfd54a Mon Sep 17 00:00:00 2001 From: THEROER Date: Mon, 6 Jul 2026 17:34:25 +0300 Subject: [PATCH 13/17] refactor(bukkit): register the consumer once now the registry is live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Bukkit bootstrap re-pushed the consumer registration on every runtime state and command change to keep the frozen payload fresh. With the live payload supplier a single registration suffices — the bundle re-reads the runtime when /magicutils mods runs — so drop the refresh wiring. Update the reflection allowlist line number for the bridge getMethod call. --- gradle/reflection-allowlist.txt | 2 +- .../theroer/magicutils/bootstrap/BukkitBootstrap.java | 11 ++++------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/gradle/reflection-allowlist.txt b/gradle/reflection-allowlist.txt index c39bdbe9..c2283ebd 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:127:.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/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/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 1014e6e8..f2d68f99 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 @@ -291,13 +291,10 @@ public RuntimeResult buildRuntime() { if (enableDiagnostics) { DiagnosticsSupport.install(runtime, diagnosticsConfigurer); } - Runnable refreshConsumerRegistration = - () -> BukkitMagicUtilsConsumerRegistry.register(plugin, runtime, prepared.commandRegistry()); - runtime.onStateChanged(refreshConsumerRegistration); - if (prepared.commandRegistry() != null) { - prepared.commandRegistry().onCommandsChanged(refreshConsumerRegistration); - } - refreshConsumerRegistration.run(); + // 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. + BukkitMagicUtilsConsumerRegistry.register(plugin, runtime, prepared.commandRegistry()); runtime.onClose("magicutils.consumerRegistry", () -> BukkitMagicUtilsConsumerRegistry.unregister(plugin)); if (registerMessages) { runtime.onClose("messages.scope", () -> Messages.unregister(plugin.getName())); From 4abeeca40a3756e847a12dc0a85e5e0cae223c07 Mon Sep 17 00:00:00 2001 From: THEROER Date: Mon, 6 Jul 2026 17:34:57 +0300 Subject: [PATCH 14/17] test(build-logic): update matrix/release/smoke tests for the current models Adjust imports and call the smoke model's toSmokeCases with the defaultTarget parameter it now takes. --- build-logic/src/test/kotlin/MagicUtilsMatrixModelTest.kt | 3 +++ build-logic/src/test/kotlin/MagicUtilsReleaseModelTest.kt | 3 +++ build-logic/src/test/kotlin/MagicUtilsSmokeModelTest.kt | 6 ++++-- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/build-logic/src/test/kotlin/MagicUtilsMatrixModelTest.kt b/build-logic/src/test/kotlin/MagicUtilsMatrixModelTest.kt index 5cef0af4..534f8c6b 100644 --- a/build-logic/src/test/kotlin/MagicUtilsMatrixModelTest.kt +++ b/build-logic/src/test/kotlin/MagicUtilsMatrixModelTest.kt @@ -6,6 +6,9 @@ import org.junit.jupiter.api.io.TempDir import java.io.File import java.nio.file.Path +import dev.ua.theroer.magicutils.build.matrix.* +import dev.ua.theroer.magicutils.build.target.loadAllTargetNames + class MagicUtilsMatrixModelTest { private fun writeTargets(dir: Path): File { diff --git a/build-logic/src/test/kotlin/MagicUtilsReleaseModelTest.kt b/build-logic/src/test/kotlin/MagicUtilsReleaseModelTest.kt index 9b7e1631..a1754e9a 100644 --- a/build-logic/src/test/kotlin/MagicUtilsReleaseModelTest.kt +++ b/build-logic/src/test/kotlin/MagicUtilsReleaseModelTest.kt @@ -4,6 +4,9 @@ import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Test +import dev.ua.theroer.magicutils.build.release.* +import dev.ua.theroer.magicutils.build.publish.* + class MagicUtilsReleaseModelTest { @Test diff --git a/build-logic/src/test/kotlin/MagicUtilsSmokeModelTest.kt b/build-logic/src/test/kotlin/MagicUtilsSmokeModelTest.kt index 8a890f24..b7d47514 100644 --- a/build-logic/src/test/kotlin/MagicUtilsSmokeModelTest.kt +++ b/build-logic/src/test/kotlin/MagicUtilsSmokeModelTest.kt @@ -2,6 +2,8 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test +import dev.ua.theroer.magicutils.build.smoke.* + class MagicUtilsSmokeModelTest { @Test @@ -48,7 +50,7 @@ class MagicUtilsSmokeModelTest { ), ), ) - val cases = spec.toSmokeCases() + val cases = spec.toSmokeCases(defaultTarget = "mc12110") assertEquals(2, cases.size) val first = cases.first { it.minecraftVersion == "1.21" } val last = cases.first { it.minecraftVersion == "1.21.11" } @@ -72,6 +74,6 @@ class MagicUtilsSmokeModelTest { SmokeMatrixEntry(id = "nf", versions = listOf("1.21"), successPattern = "Done ("), ), ) - assertEquals("Done (", spec.toSmokeCases().single().successPattern) + assertEquals("Done (", spec.toSmokeCases(defaultTarget = "mc12110").single().successPattern) } } From 4c2c9786212648cc2b18bc6273f96b5c02302d80 Mon Sep 17 00:00:00 2001 From: THEROER Date: Mon, 6 Jul 2026 17:42:41 +0300 Subject: [PATCH 15/17] build(build-logic): bump plugins to 0.1.5 for the EmbedMode API The consumer plugins gained the EmbedMode enum (replacing the boolean embed flag), so publish the build-logic tool under a new version that downstream consumers can pin to. --- 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 ddcfe396..876bbf79 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.4 \ No newline at end of file +pluginsVersion=0.1.5 \ No newline at end of file From 77509516e9838857e33f205a255301b55f89dc11 Mon Sep 17 00:00:00 2001 From: THEROER Date: Mon, 6 Jul 2026 17:52:11 +0300 Subject: [PATCH 16/17] ci: stop double-caching Gradle across setup-java and setup-gradle Both actions/setup-java (cache: gradle) and gradle/actions/setup-gradle restored ~/.gradle, layering stale cross-branch Kotlin incremental state over the checkout and producing bogus unresolved-reference/type-mismatch errors in build-logic that never reproduce on a clean build. Let setup-gradle own the cache, and clean build-logic before its test. --- .github/workflows/ci-dev.yml | 2 -- .github/workflows/ci.yml | 15 ++++++++++----- .github/workflows/publish-javadoc.yml | 1 - .github/workflows/publish-maven.yml | 3 --- .github/workflows/release.yml | 1 - 5 files changed, 10 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci-dev.yml b/.github/workflows/ci-dev.yml index 0147a7bf..5b102faa 100644 --- a/.github/workflows/ci-dev.yml +++ b/.github/workflows/ci-dev.yml @@ -18,7 +18,6 @@ jobs: with: distribution: 'temurin' java-version: '25' - cache: 'gradle' - uses: gradle/actions/setup-gradle@v3 - id: matrix run: | @@ -38,7 +37,6 @@ jobs: with: distribution: 'temurin' java-version: '25' - cache: 'gradle' - uses: gradle/actions/setup-gradle@v3 - run: chmod +x ./gradlew - run: ./gradlew buildScenario checkScenario -Ptarget=${{ matrix.target }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1739d0c..bbc6981a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,14 +18,19 @@ jobs: with: distribution: 'temurin' java-version: '25' - cache: 'gradle' + # Gradle caching is owned by setup-gradle alone. Letting setup-java also + # cache 'gradle' had both actions restore ~/.gradle, layering a stale + # Kotlin incremental/compiled-classes state from other branches over this + # checkout — which surfaced as bogus "unresolved reference"/type-mismatch + # errors in build-logic that never reproduce on a clean build. - uses: gradle/actions/setup-gradle@v3 - run: chmod +x ./gradlew # Run inside the included build so only build-logic is configured — running # `:build-logic:test` from the root configures every module (incl. the # Fabric/Loom ones), which needlessly fetches Minecraft and flakes on the - # Loom cache/checksum in CI. - - run: ./gradlew -p build-logic test + # Loom cache/checksum in CI. `clean` first so no cross-branch incremental + # Kotlin state is reused. + - run: ./gradlew -p build-logic clean test # Single source of truth: the target list comes from targets.properties # via printBuildMatrix — never hardcoded here. @@ -39,7 +44,7 @@ jobs: with: distribution: 'temurin' java-version: '25' - cache: 'gradle' + # Gradle caching is owned by setup-gradle alone (see build-logic-test). - uses: gradle/actions/setup-gradle@v3 - id: matrix run: | @@ -59,7 +64,7 @@ jobs: with: distribution: 'temurin' java-version: '25' - cache: 'gradle' + # Gradle caching is owned by setup-gradle alone (see build-logic-test). - uses: gradle/actions/setup-gradle@v3 - run: chmod +x ./gradlew - run: ./gradlew buildScenario checkScenario -Ptarget=${{ matrix.target }} diff --git a/.github/workflows/publish-javadoc.yml b/.github/workflows/publish-javadoc.yml index 5daeac61..96d62e96 100644 --- a/.github/workflows/publish-javadoc.yml +++ b/.github/workflows/publish-javadoc.yml @@ -34,7 +34,6 @@ jobs: with: distribution: 'temurin' java-version: '25' - cache: 'gradle' - uses: gradle/actions/setup-gradle@v3 - name: Generate aggregated Javadoc zip diff --git a/.github/workflows/publish-maven.yml b/.github/workflows/publish-maven.yml index 7094fd49..60453ffb 100644 --- a/.github/workflows/publish-maven.yml +++ b/.github/workflows/publish-maven.yml @@ -39,7 +39,6 @@ jobs: with: distribution: 'temurin' java-version: '25' - cache: 'gradle' - uses: gradle/actions/setup-gradle@v3 - id: matrix run: | @@ -72,7 +71,6 @@ jobs: with: distribution: 'temurin' java-version: '25' - cache: 'gradle' - uses: gradle/actions/setup-gradle@v3 - name: Publish ${{ matrix.unit.target }} run: | @@ -99,7 +97,6 @@ jobs: with: distribution: 'temurin' java-version: '25' - cache: 'gradle' - uses: gradle/actions/setup-gradle@v3 - name: Publish build-logic plugins run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bd211e7f..503b0840 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,6 @@ jobs: with: distribution: 'temurin' java-version: '25' - cache: 'gradle' - uses: gradle/actions/setup-gradle@v3 From 435eb669bdd87c9611a71fba0959b095edb0de8a Mon Sep 17 00:00:00 2001 From: THEROER Date: Mon, 6 Jul 2026 17:59:02 +0300 Subject: [PATCH 17/17] build(build-logic): track the target source package hidden by gitignore The Maven `target/` ignore rule also matched the build-logic source package build/target/, so MagicUtilsTargetExtension/Conventions/Model were never committed. CI (a clean checkout) had no target types, failing every consumer/module plugin with unresolved references to MagicUtilsTargetExtension. Anchor an exception for that package and add the missing sources. --- .gitignore | 3 + .../target/MagicUtilsTargetConventions.kt | 82 ++++++++++++++ .../build/target/MagicUtilsTargetExtension.kt | 24 ++++ .../build/target/MagicUtilsTargetModel.kt | 106 ++++++++++++++++++ 4 files changed, 215 insertions(+) create mode 100644 build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/target/MagicUtilsTargetConventions.kt create mode 100644 build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/target/MagicUtilsTargetExtension.kt create mode 100644 build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/target/MagicUtilsTargetModel.kt diff --git a/.gitignore b/.gitignore index 09e82c32..6e831122 100644 --- a/.gitignore +++ b/.gitignore @@ -123,6 +123,9 @@ venv/ # Maven (if used alongside Gradle) target/ +# but not the build-logic source package that happens to be named target/ +!build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/target/ +!build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/target/** pom.xml.tag pom.xml.releaseBackup pom.xml.versionsBackup 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 new file mode 100644 index 00000000..1bd3e676 --- /dev/null +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/target/MagicUtilsTargetConventions.kt @@ -0,0 +1,82 @@ +package dev.ua.theroer.magicutils.build.target + +import org.gradle.api.Project +import net.fabricmc.loom.api.LoomGradleExtensionAPI + +/** + * Single source of truth for the target-derived Fabric/publishing conventions. + * + * The obfuscation boundary (Minecraft 26 became deobfuscated), the published + * `mc` classifier, and the Loom flavour selection are the same + * facts everywhere they're needed — the MagicUtils library's own fabric + * modules/bundle AND every consumer plugin. They live here so no build script + * (ours or a consumer's) re-derives them by hand. + */ + +/** + * Minecraft 26+ ships a deobfuscated jar: Loom does not remap, there are no + * mappings, and `jar` is the artifact. Older targets are obfuscated and use the + * classic remapping Loom path. Keyed on the *library* Minecraft (the published + * coordinate's branch), which is what determines how MagicUtils itself was + * built — not the runtime Minecraft, which may differ. + */ +val MagicUtilsTargetExtension.isDeobfuscated: Boolean + get() = libraryMinecraft.get().substringBefore('.').toInt() >= 26 + +/** + * Published artifact classifier for the target, e.g. `mc1.21`, `mc26`. Derived + * from the *library* Minecraft, so a target whose runtime Minecraft differs + * (e.g. Paper 1.20.6 on the `+1.20.1` library) still resolves the right jar. + */ +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]). + */ +fun MagicUtilsTargetExtension.publishedVersion(baseVersion: String): String = + "$baseVersion+${libraryMinecraft.get()}" + +/** Loom plugin id for the target: no-remap on deobfuscated, remapping otherwise. */ +val MagicUtilsTargetExtension.loomPluginId: String + get() = if (isDeobfuscated) "net.fabricmc.fabric-loom" else "fabric-loom" + +/** Gradle dependency configuration for compile-only deps (mod-aware on remap). */ +val MagicUtilsTargetExtension.compileOnlyConfiguration: String + get() = if (isDeobfuscated) "compileOnly" else "modCompileOnly" + +/** Gradle dependency configuration for implementation deps (mod-aware on remap). */ +val MagicUtilsTargetExtension.implementationConfiguration: String + get() = if (isDeobfuscated) "implementation" else "modImplementation" + +/** Gradle dependency configuration for runtime-only deps (mod-aware on remap). */ +val MagicUtilsTargetExtension.runtimeOnlyConfiguration: String + get() = if (isDeobfuscated) "runtimeOnly" else "modRuntimeOnly" + +/** Task producing the primary (published, jar-in-jar) artifact for the target. */ +val MagicUtilsTargetExtension.mainJarTaskName: String + get() = if (isDeobfuscated) "jar" else "remapJar" + +/** + * Adds the Minecraft dependency and official Mojang mappings (obfuscated + * targets only) to [project] for the resolved [target]. Uses the *runtime* + * Minecraft ([MagicUtilsTargetExtension.minecraft]) — this is the game Loom + * compiles/runs against, independent of the published library coordinate. The + * Fabric loader is intentionally NOT added here — callers pick the configuration + * themselves (compileOnly for modules, implementation for runnable bundles/mods). + */ +fun applyMinecraftAndMappings(project: Project, target: MagicUtilsTargetExtension) { + project.dependencies.add("minecraft", "com.mojang:minecraft:${target.minecraft.get()}") + if (!target.isDeobfuscated) { + val loom = project.extensions.getByType(LoomGradleExtensionAPI::class.java) + project.dependencies.add("mappings", loom.officialMojangMappings()) + } +} diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/target/MagicUtilsTargetExtension.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/target/MagicUtilsTargetExtension.kt new file mode 100644 index 00000000..179ee502 --- /dev/null +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/target/MagicUtilsTargetExtension.kt @@ -0,0 +1,24 @@ +package dev.ua.theroer.magicutils.build.target + +import org.gradle.api.provider.Property + +abstract class MagicUtilsTargetExtension { + abstract val name: Property + /** Whether this is the matrix default target (published without a version suffix). */ + abstract val defaultTarget: Property + abstract val minecraft: Property + /** + * Minecraft version for the published library coordinate (`+` suffix, + * `mc` classifier, obfuscation boundary). Equals [minecraft] + * unless the target overrides `library_minecraft` — see [MagicUtilsTargetSpec]. + */ + abstract val libraryMinecraft: Property + abstract val java: Property + abstract val yarn: Property + abstract val loader: Property + abstract val fabric_api: Property + abstract val paper: Property + abstract val miniplaceholders_api: Property + abstract val pb4_placeholder_api: Property + abstract val neoforge: Property +} diff --git a/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/target/MagicUtilsTargetModel.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/target/MagicUtilsTargetModel.kt new file mode 100644 index 00000000..74c59d70 --- /dev/null +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/target/MagicUtilsTargetModel.kt @@ -0,0 +1,106 @@ +package dev.ua.theroer.magicutils.build.target + +import org.gradle.api.GradleException +import java.io.File +import java.util.Properties + +data class MagicUtilsTargetSpec( + val name: String, + val minecraft: String, + /** + * Minecraft version used for the *published library coordinate* — the + * `+` version suffix, the `mc` classifier, and the + * obfuscation boundary. Defaults to [minecraft]; set it explicitly (via + * `.library_minecraft`) only when the runtime Minecraft differs + * from the MagicUtils artifact's coordinate. Example: a target running + * Paper 1.20.6 (Java 21) still resolves the `+1.20.1` / `mc1.20` artifacts, + * because MagicUtils publishes the whole 1.20.x branch under one coordinate. + */ + val libraryMinecraft: String, + val java: Int, + val yarn: String?, + val loader: String?, + val fabricApi: String?, + val paper: String?, + val miniplaceholdersApi: String?, + val pb4PlaceholderApi: String?, + val neoforge: String?, +) + +internal fun normalizeTargetName(rawTargetName: String): String = + rawTargetName.trim().let { + when { + it.isEmpty() -> throw GradleException("Target name must not be empty.") + it.startsWith("mc") -> it + else -> "mc$it" + } + } + +internal fun loadTargetProperties(targetsFile: File): Properties { + if (!targetsFile.isFile) { + throw GradleException("Missing targets file: ${targetsFile.absolutePath}") + } + + return Properties().also { properties -> + targetsFile.inputStream().use(properties::load) + } +} + +/** + * Every target declared in [targetsFile], in file order. A target is any + * `mcXXXX` prefix that has a `.minecraft` value (the one required key — + * see [resolveMagicUtilsTargetSpec]). This is the single source of truth + * the CI build/publish matrices are generated from, so no target list is + * ever duplicated in a workflow. + */ +internal fun loadAllTargetNames(targetsFile: File): List { + val properties = loadTargetProperties(targetsFile) + return properties.stringPropertyNames() + .mapNotNull { key -> key.removeSuffix(".minecraft").takeIf { it != key } } + .distinct() + // Properties has no defined order; sort for stable, reproducible output. + .sorted() +} + +internal fun resolveMagicUtilsTargetSpec( + targetsFile: File, + defaultTarget: String, + explicitTarget: String?, +): MagicUtilsTargetSpec { + val properties = loadTargetProperties(targetsFile) + val fallbackTarget = properties.getProperty("target", defaultTarget) + val targetName = normalizeTargetName(explicitTarget ?: fallbackTarget) + + fun requireValue(suffix: String): String = + properties.getProperty("$targetName.$suffix") + ?: throw GradleException( + "Missing '$targetName.$suffix' in ${targetsFile.absolutePath}" + ) + + val minecraft = requireValue("minecraft") + + return MagicUtilsTargetSpec( + name = targetName, + minecraft = minecraft, + // Optional: falls back to the runtime Minecraft when the published + // library coordinate matches it (the common case). + libraryMinecraft = properties.getProperty("$targetName.library_minecraft") ?: minecraft, + java = requireValue("java").toInt(), + yarn = properties.getProperty("$targetName.yarn"), + loader = properties.getProperty("$targetName.loader"), + fabricApi = properties.getProperty("$targetName.fabric_api"), + paper = properties.getProperty("$targetName.paper"), + miniplaceholdersApi = properties.getProperty("$targetName.miniplaceholders_api"), + pb4PlaceholderApi = properties.getProperty("$targetName.pb4_placeholder_api"), + neoforge = properties.getProperty("$targetName.neoforge"), + ) +} + +/** + * Published artifact classifier for a target spec, e.g. `mc1.21`, `mc26`. + * Derived from the library Minecraft — the same formula as the extension-side + * [mcClassifier], kept here so matrix/release JSON tasks can resolve it for a + * target by name without a live extension. + */ +internal fun MagicUtilsTargetSpec.mcClassifier(): String = + "mc${libraryMinecraft.substringBeforeLast('.')}"