diff --git a/.github/workflows/ci-dev.yml b/.github/workflows/ci-dev.yml index 0147a7bf3..5b102faae 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 d1739d0c8..bbc6981a1 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 new file mode 100644 index 000000000..96d62e968 --- /dev/null +++ b/.github/workflows/publish-javadoc.yml @@ -0,0 +1,67 @@ +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' + - 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/publish-maven.yml b/.github/workflows/publish-maven.yml index 4ff31b497..60453ffb9 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. @@ -32,7 +39,6 @@ jobs: with: distribution: 'temurin' java-version: '25' - cache: 'gradle' - uses: gradle/actions/setup-gradle@v3 - id: matrix run: | @@ -65,7 +71,6 @@ jobs: with: distribution: 'temurin' java-version: '25' - cache: 'gradle' - uses: gradle/actions/setup-gradle@v3 - name: Publish ${{ matrix.unit.target }} run: | @@ -92,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 45c5723a3..503b08407 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 @@ -101,3 +100,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/.gitignore b/.gitignore index 09e82c326..6e831122e 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/build.gradle.kts b/build-logic/build.gradle.kts index 69601af81..ef0fb6155 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 3ee71dc5e..876bbf79e 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.5 \ 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 38347b1a1..000000000 --- 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 c71ef35a2..000000000 --- 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 85248fb6e..000000000 --- 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/consumer/MagicUtilsConsumerBukkitPlugin.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerBukkitPlugin.kt new file mode 100644 index 000000000..fbdb37aa8 --- /dev/null +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerBukkitPlugin.kt @@ -0,0 +1,117 @@ +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) + 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") + } + + // 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. 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 + // 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 000000000..9a1bdcbe8 --- /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 000000000..e08b7411b --- /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 000000000..d46c109f3 --- /dev/null +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerExtension.kt @@ -0,0 +1,450 @@ +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 + +/** + * 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. + * + * 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 + + /** + * 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 embedMode: 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()), + ) + // -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 +} + +/** + * 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 000000000..3ad3b583d --- /dev/null +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerFabricPlugin.kt @@ -0,0 +1,109 @@ +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) + // 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 + // 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 000000000..2aaa6fd51 --- /dev/null +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/consumer/MagicUtilsConsumerNeoForgePlugin.kt @@ -0,0 +1,92 @@ +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 { 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") + } + // 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 000000000..a8874142a --- /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 000000000..8464f1fab --- /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/MagicUtilsAllTargetsDsl.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsAllTargetsDsl.kt new file mode 100644 index 000000000..753d4f4aa --- /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 000000000..c0d7d534e --- /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/MagicUtilsHelpTask.kt b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsHelpTask.kt new file mode 100644 index 000000000..2f40413b2 --- /dev/null +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsHelpTask.kt @@ -0,0 +1,68 @@ +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= 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") + 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-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 000000000..c24cd520e --- /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 000000000..f0778a84c --- /dev/null +++ b/build-logic/src/main/kotlin/dev/ua/theroer/magicutils/build/matrix/MagicUtilsMatrixRootPlugin.kt @@ -0,0 +1,578 @@ +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) + + 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) + 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") + } + } + } +} + +/** + * `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, +) { + 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 94% 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 b12a3f6d4..c8ce1b431 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). */ @@ -46,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) } @@ -364,7 +383,9 @@ 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()) + settings.gradle.extensions.extraProperties.set("magicutilsAllTargetsSpec", extension.toAllTargetsSpec()) resolvedContext.includedProjects.forEach { projectPath -> settings.include(projectPath.removePrefix(":")) 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 76c024455..e156cf0a9 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 1aa46ab25..7a7f43776 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 95% 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 310c5cfa5..1da01f791 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 @@ -64,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/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 0e4cdf678..a10bcf392 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 76% 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 01ad25fe8..73c2ef779 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,10 @@ class MagicUtilsFabricBundlePlugin : Plugin { val bundleLibProjects = listOf( project(":platform-api"), project(":commands-brigadier"), - project(":core") + project(":core"), + project(":diagnostics"), + // Jackson (its only external dep) is already bundled via config-yaml/toml. + project(":http-client") ) val bundleModProjects = listOf( @@ -67,12 +72,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 +109,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 +144,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 +158,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 dd5c02e92..d844ffe5b 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 a7e6ade01..07da89482 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 000000000..829e54249 --- /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 a77a2c2e8..00a223c2c 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 8d2abffa3..2c9aace14 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 f616df35f..4ed46cbeb 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 bf7e8ba0b..988587b7d 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 000000000..73ffaa869 --- /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 b0d13ed01..4d8972ffe 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 c23cba764..298f4d0e5 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 703632978..443f757db 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 8777b1ea2..b00c438f5 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 8f6045127..3fcab99eb 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 e6b5e2c77..147cb01b5 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 000000000..46b9749ba --- /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 0b8077958..b10f524bf 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 9a3a5d315..6ea8016b0 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 6ec0f831f..1b2d02858 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 24b4e2f24..dda00aaca 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 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 000000000..1bd3e6760 --- /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 000000000..179ee5022 --- /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 000000000..74c59d70c --- /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('.')}" diff --git a/build-logic/src/test/kotlin/MagicUtilsAllTargetsModelTest.kt b/build-logic/src/test/kotlin/MagicUtilsAllTargetsModelTest.kt new file mode 100644 index 000000000..338516c66 --- /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") + } + } +} diff --git a/build-logic/src/test/kotlin/MagicUtilsEmbedModeTest.kt b/build-logic/src/test/kotlin/MagicUtilsEmbedModeTest.kt new file mode 100644 index 000000000..8d33ec0f8 --- /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) + } + } +} diff --git a/build-logic/src/test/kotlin/MagicUtilsMatrixModelTest.kt b/build-logic/src/test/kotlin/MagicUtilsMatrixModelTest.kt index 5cef0af45..534f8c6ba 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 9b7e16318..a1754e9ae 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 8a890f247..b7d475145 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) } } diff --git a/build.gradle b/build.gradle deleted file mode 100644 index 51e6aba21..000000000 --- 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 000000000..de71d207a --- /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 fad74a00f..70c319e4b 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 a88bcfc7b..99ff9a0bc 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,14 +6,16 @@ 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; 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; @@ -22,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. @@ -51,9 +66,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."); } @@ -67,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; } - ConsumerInfo 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) { @@ -82,9 +99,12 @@ 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.size()); + for (ConsumerRegistration registration : sharedRuntimeConsumers.values()) { + consumers.add(registration.toInfo()); + } + consumers.sort(Comparator.comparing(MagicUtilsConsumerInfo::pluginName, String.CASE_INSENSITIVE_ORDER)); return List.copyOf(consumers); } @@ -101,11 +121,12 @@ 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; } - 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 000000000..93a7ee487 --- /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 diff --git a/commands-brigadier/build.gradle b/commands-brigadier/build.gradle deleted file mode 100644 index ca3107f98..000000000 --- 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 000000000..6c748ceaf --- /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 1a2577e72..000000000 --- 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 000000000..eb34b3326 --- /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 59186c4e7..0285ff5fc 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.MagicUtilsConsumerRegistry; 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,56 @@ 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; + String prefix = commandsEnabled + ? (permissionPrefix != null ? permissionPrefix : modName) + : null; + + 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()); + } + }); + + // 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, 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() { Platform resolvedPlatform = platform != null ? platform diff --git a/commands-neoforge/build.gradle b/commands-neoforge/build.gradle deleted file mode 100644 index 347f7a962..000000000 --- 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 000000000..963965834 --- /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 c3e71ca7e..64591f907 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,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.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.Optional; import java.util.function.Consumer; import java.util.function.Supplier; import org.jetbrains.annotations.Nullable; @@ -340,10 +349,73 @@ 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; + String prefix = commandsEnabled + ? (permissionPrefix != null ? permissionPrefix : modName) + : null; + + // 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<>(); + + // 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, 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() { Platform resolvedPlatform = platform != null ? platform diff --git a/commands/build.gradle b/commands/build.gradle deleted file mode 100644 index e0b8ebd8b..000000000 --- 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 000000000..3cd6f1e0f --- /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/commands/src/main/java/dev/ua/theroer/magicutils/commands/HelpCommandSupport.java b/commands/src/main/java/dev/ua/theroer/magicutils/commands/HelpCommandSupport.java index 7a34cc7b4..6991b0fcb 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/config-toml/build.gradle b/config-toml/build.gradle deleted file mode 100644 index b07ea94d6..000000000 --- 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 000000000..53512b80f --- /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 6afd471e8..000000000 --- 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 000000000..cf914762e --- /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 8ca8ad2e0..000000000 --- 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 000000000..696b38724 --- /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 59a9e7b78..17523fc26 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 9b225cfbc..3a92b891f 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 89c1b9ac8..bc22b5263 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 7895b2441..000000000 --- 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 000000000..0872d8783 --- /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 000000000..6e05b7238 --- /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/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 000000000..0e2a87b9a --- /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/diagnostics-testkit/build.gradle b/diagnostics-testkit/build.gradle deleted file mode 100644 index fc2e28dee..000000000 --- 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 000000000..11950180e --- /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 c02afa989..000000000 --- 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 000000000..4963cd267 --- /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 55% 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 d32cc237d..9c40c5813 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; + private final String bundleVersion; + private final Supplier> consumersSupplier; + private final Function consumerLookup; - public MagicUtilsBundleCommand(MagicUtilsBukkitBundlePlugin bundlePlugin, @Nullable LoggerCore logger) { - this(bundlePlugin, logger, null); - } - + /** + * 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,44 +141,41 @@ 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) { - lines.add("- " + escape(info.pluginName()) + " v" + for (MagicUtilsConsumerInfo info : consumers) { + String body = "- " + escape(info.pluginName()) + " v" + escape(info.version()) + " (" + escape(info.capabilitiesSummary()) - + ")"); + + ")"; + lines.add(clickableMod(info.pluginName(), body)); } 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 +203,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); @@ -230,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/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 c8c913576..7cb553bd2 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 58b934ca1..000000000 --- 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 945a1eaf8..a740024a1 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 29cd83d00..e49ff5cf5 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/javadoc/theme.css b/gradle/javadoc/theme.css new file mode 100644 index 000000000..7d479c414 --- /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 000000000..0ec7d25fc --- /dev/null +++ b/gradle/javadoc/top.html @@ -0,0 +1 @@ + diff --git a/gradle/reflection-allowlist.txt b/gradle/reflection-allowlist.txt index 9550ec1c4..c2283ebd7 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: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( @@ -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 748b33b6b..000000000 --- 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 000000000..1ef46cbac --- /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 c019551e6..000000000 --- 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 000000000..906607b55 --- /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 f13c9fe9f..000000000 --- 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 000000000..5b9b21a55 --- /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 dc7ec75fe..000000000 --- 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 000000000..805e538fb --- /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/logger/src/main/java/dev/ua/theroer/magicutils/logger/MessageParser.java b/logger/src/main/java/dev/ua/theroer/magicutils/logger/MessageParser.java index 49c2a684a..4093998a4 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(">", "\\>"); + } } diff --git a/neoforge-bundle/build.gradle b/neoforge-bundle/build.gradle deleted file mode 100644 index 1cb4d685b..000000000 --- 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 000000000..34bfca315 --- /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 000000000..530d65dfa --- /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 000000000..a106dc6a7 --- /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 2a8b9c50a..000000000 --- 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 000000000..c91b3510f --- /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 52e8f01f6..000000000 --- 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 000000000..8a24d6976 --- /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 0c84183e9..000000000 --- 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 000000000..f3f1fe3d8 --- /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 000000000..2ce110cd0 --- /dev/null +++ b/platform-api/src/main/java/dev/ua/theroer/magicutils/platform/MagicUtilsConsumerInfo.java @@ -0,0 +1,212 @@ +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 + "t/" + namedComponentCount + "n"); + List parts = new ArrayList<>(); + for (Map.Entry entry : fields.entrySet()) { + parts.add(entry.getKey() + "=" + entry.getValue()); + } + 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 + * 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 000000000..da2fdb102 --- /dev/null +++ b/platform-api/src/main/java/dev/ua/theroer/magicutils/platform/MagicUtilsConsumerRegistry.java @@ -0,0 +1,179 @@ +package dev.ua.theroer.magicutils.platform; + +import java.time.Instant; +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. + * + *

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 String BUNDLE_NAME = "MagicUtils"; + + private MagicUtilsConsumerRegistry() { + } + + /** + * 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; + } + 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. */ + 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, rebuilt from live views. */ + public static List snapshot() { + 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, rebuilt from its live view, or null. */ + public static @Nullable MagicUtilsConsumerInfo find(@Nullable String modName) { + if (modName == null || modName.isBlank()) { + return null; + } + 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 000000000..8f64c8dbc --- /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/main/java/dev/ua/theroer/magicutils/platform/Platform.java b/platform-api/src/main/java/dev/ua/theroer/magicutils/platform/Platform.java index 368cebe1f..abbb73cbf 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-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 000000000..3818d2e09 --- /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/build.gradle b/platform-bukkit/build.gradle deleted file mode 100644 index 3baa62fa4..000000000 --- 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 000000000..75a8a4f9c --- /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/bootstrap/BukkitBootstrap.java b/platform-bukkit/src/main/java/dev/ua/theroer/magicutils/bootstrap/BukkitBootstrap.java index 1014e6e8c..f2d68f99f 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())); 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 d4d955f4f..a4128566e 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,50 +1,44 @@ 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 java.util.function.Supplier; import org.bukkit.plugin.Plugin; import org.bukkit.plugin.java.JavaPlugin; 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() { } /** - * 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 @@ -53,6 +47,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; } @@ -62,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()); @@ -101,40 +105,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 +153,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 fada058aa..35e3639c0 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 86785fe14..869b4af9e 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-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 000000000..f9d49724d --- /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 000000000..745ad6cfc --- /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; + } + } +} diff --git a/platform-bungee/build.gradle b/platform-bungee/build.gradle deleted file mode 100644 index 5bb1196f7..000000000 --- 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 000000000..f5831de4b --- /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 64b2ff476..000000000 --- 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 000000000..81ebefa98 --- /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 072ff0576..fbe8997c1 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 000000000..58c539cc6 --- /dev/null +++ b/platform-fabric/src/main/java/dev/ua/theroer/magicutils/platform/fabric/MagicUtilsFabricConsumerRegistry.java @@ -0,0 +1,43 @@ +package dev.ua.theroer.magicutils.platform.fabric; + +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; + +/** + * 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#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); + } + + /** @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 7e8805c50..000000000 --- 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 000000000..2b60373f3 --- /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 178cfe1ad..7b4042416 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 61fb37181..000000000 --- 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 000000000..febc7cde1 --- /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 4f2bfee78..ec15cc2d8 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 96c6faa14..000000000 --- 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 000000000..b1df435b0 --- /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 17e9de3e3..000000000 --- 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 000000000..ca543e0d5 --- /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") + // } + // } +}