diff --git a/gradle.properties b/gradle.properties index 6153347..a95b875 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,7 +3,7 @@ #================================================== group=com.oliveryasuna.modkit -version=0.3.0 +version=0.4.0 modkit.pom.licenseName=All Rights Reserved modkit.pom.licenseUrl=https://github.com/oliveryasuna/modkit/blob/main/LICENSE diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5db22e8..05c05f4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,7 +18,7 @@ vanniktech-maven-publish = "0.37.0" # (org.parchmentmc.data:parchment-:), resolved during mappings wiring, # not a single pinnable plugin version. loom = "1.17.13" -moddevgradle = "2.0.141" +moddevgradle = "2.0.142" # AW->AT transpiler: parse Fabric access wideners, emit NeoForge access transformers. access-widener = "2.1.0" diff --git a/plugins/metadata/src/functionalTest/kotlin/com/oliveryasuna/modkit/metadata/ModkitMetadataFunctionalTest.kt b/plugins/metadata/src/functionalTest/kotlin/com/oliveryasuna/modkit/metadata/ModkitMetadataFunctionalTest.kt index e878534..b0bbea6 100644 --- a/plugins/metadata/src/functionalTest/kotlin/com/oliveryasuna/modkit/metadata/ModkitMetadataFunctionalTest.kt +++ b/plugins/metadata/src/functionalTest/kotlin/com/oliveryasuna/modkit/metadata/ModkitMetadataFunctionalTest.kt @@ -4,6 +4,7 @@ import com.electronwill.nightconfig.core.Config import com.electronwill.nightconfig.json.JsonFormat import com.electronwill.nightconfig.toml.TomlFormat import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -151,6 +152,70 @@ class ModkitMetadataFunctionalTest { assertTrue(result.output.contains("icon"), result.output) } + @Test + fun `validateModMetadata passes when the icon is in the default resources root`() { + settings() + projectDir.resolve("src/main/resources/assets/mymod").mkdirs() + projectDir.resolve("src/main/resources/assets/mymod/icon.png").writeText("png") + projectDir.resolve("build.gradle.kts").writeText( + """ + plugins { + id("java") + id("com.oliveryasuna.modkit.metadata") + } + + modkit { + modId.set("mymod") + version.set("1.0.0") + metadata { + icon.set("assets/mymod/icon.png") + } + } + """.trimIndent() + ) + + val result = runner("validateModMetadata").build() + + assertEquals(TaskOutcome.SUCCESS, result.task(":validateModMetadata")?.outcome, result.output) + } + + @Test + fun `validateModMetadata finds the icon in a non-default resources root`() { + // Reproduces the Stonecutter shape: the icon lives in a resource srcDir + // that is NOT `/src/main/resources`. The validator must resolve + // it through the source set's roots, not a projectDirectory-relative guess. + settings() + projectDir.resolve("shared/resources/assets/mymod").mkdirs() + projectDir.resolve("shared/resources/assets/mymod/icon.png").writeText("png") + projectDir.resolve("build.gradle.kts").writeText( + """ + plugins { + id("java") + id("com.oliveryasuna.modkit.metadata") + } + + sourceSets { + named("main") { + resources.srcDir("shared/resources") + } + } + + modkit { + modId.set("mymod") + version.set("1.0.0") + metadata { + // No file under src/main/resources — only under the extra srcDir. + icon.set("assets/mymod/icon.png") + } + } + """.trimIndent() + ) + + val result = runner("validateModMetadata").build() + + assertEquals(TaskOutcome.SUCCESS, result.task(":validateModMetadata")?.outcome, result.output) + } + @Test fun `configuration cache is reused across runs`() { settings() diff --git a/plugins/metadata/src/main/kotlin/com/oliveryasuna/modkit/metadata/ModkitMetadataPlugin.kt b/plugins/metadata/src/main/kotlin/com/oliveryasuna/modkit/metadata/ModkitMetadataPlugin.kt index b87d365..28eab7b 100644 --- a/plugins/metadata/src/main/kotlin/com/oliveryasuna/modkit/metadata/ModkitMetadataPlugin.kt +++ b/plugins/metadata/src/main/kotlin/com/oliveryasuna/modkit/metadata/ModkitMetadataPlugin.kt @@ -129,13 +129,26 @@ public class ModkitMetadataPlugin : Plugin { task.icon.set(metadata.icon) task.license.set(modkit.license) task.neoForgeActive.set(activeLoader == McLoader.NEOFORGE) - task.resourcesDir.set(project.layout.projectDirectory.dir("src/${project.commonSourceSet()}/resources")) task.failOnMissingIcon.set(metadata.validation.failOnMissingIcon) task.failOnInvalidSemver.set(metadata.validation.failOnInvalidSemver) task.failOnUndeclaredMixinConfig.set(metadata.validation.failOnUndeclaredMixinConfig) task.failOnMissingLicense.set(metadata.validation.failOnMissingLicense) } + // Resolve the icon against the common source set's actual resource + // roots (including the generated-manifest dir), not a + // `projectDirectory`-relative guess — the latter is wrong under + // Stonecutter, where the built node's shared sources live outside the + // node's own directory. `sourceDirectories` is a live FileCollection, + // so it reflects roots added later (Stonecutter's wiring, the generated + // srcDir) rather than snapshotting them here. + val commonSourceSet = project.commonSourceSet() + project.pluginManager.withPlugin("java-base") { + val sourceSets = project.extensions.getByType(SourceSetContainer::class.java) + val common = sourceSets.getByName(commonSourceSet) + validate.configure { it.resourceRoots.from(common.resources.sourceDirectories) } + } + // Attach to `check` only where a lifecycle exists. project.wireIntoCheck(validate) } diff --git a/plugins/metadata/src/main/kotlin/com/oliveryasuna/modkit/metadata/ValidateModMetadataTask.kt b/plugins/metadata/src/main/kotlin/com/oliveryasuna/modkit/metadata/ValidateModMetadataTask.kt index e74b36f..49c6b75 100644 --- a/plugins/metadata/src/main/kotlin/com/oliveryasuna/modkit/metadata/ValidateModMetadataTask.kt +++ b/plugins/metadata/src/main/kotlin/com/oliveryasuna/modkit/metadata/ValidateModMetadataTask.kt @@ -2,12 +2,9 @@ package com.oliveryasuna.modkit.metadata import org.gradle.api.DefaultTask import org.gradle.api.GradleException -import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.provider.Property -import org.gradle.api.tasks.Input -import org.gradle.api.tasks.Internal -import org.gradle.api.tasks.Optional -import org.gradle.api.tasks.TaskAction +import org.gradle.api.tasks.* import org.gradle.work.DisableCachingByDefault /** @@ -25,8 +22,14 @@ internal abstract class ValidateModMetadataTask : DefaultTask() { @get:[Input Optional] abstract val license: Property - @get:Internal - abstract val resourcesDir: DirectoryProperty + /** + * The common source set's resource roots. Resolved from the source set (not + * from `projectDirectory`) so it holds under Stonecutter, where the built + * node's shared sources live outside the node's own directory, and picks up + * the generated-manifest dir that is added as a resource source. + */ + @get:[InputFiles Optional PathSensitive(PathSensitivity.RELATIVE)] + abstract val resourceRoots: ConfigurableFileCollection @get:Input abstract val neoForgeActive: Property @@ -46,7 +49,9 @@ internal abstract class ValidateModMetadataTask : DefaultTask() { @TaskAction fun validate() { val iconName = icon.orNull - val iconExists = iconName != null && resourcesDir.get().file(iconName).asFile.exists() + val iconExists = iconName != null && resourceRoots.files.any { root -> + root.resolve(iconName).exists() + } val errors = ModMetadataValidator.validate( version = version.orNull, diff --git a/plugins/scaffold/src/main/kotlin/com/oliveryasuna/modkit/scaffold/render/ModuleBlocks.kt b/plugins/scaffold/src/main/kotlin/com/oliveryasuna/modkit/scaffold/render/ModuleBlocks.kt index 90088ca..ab6d69f 100644 --- a/plugins/scaffold/src/main/kotlin/com/oliveryasuna/modkit/scaffold/render/ModuleBlocks.kt +++ b/plugins/scaffold/src/main/kotlin/com/oliveryasuna/modkit/scaffold/render/ModuleBlocks.kt @@ -1,5 +1,6 @@ package com.oliveryasuna.modkit.scaffold.render +import com.oliveryasuna.modkit.core.extension.McLoader import com.oliveryasuna.modkit.scaffold.ScaffoldModule import com.oliveryasuna.modkit.scaffold.ScaffoldPlan @@ -25,10 +26,15 @@ internal object ModuleBlocks { fun render(plan: ScaffoldPlan): List { val lines = mutableListOf() + // The `entrypoints.main` is Fabric-only (NeoForge uses `@Mod`, no + // manifest entry), so emit the metadata block only when Fabric is in + // the matrix — pointing at the Fabric bootstrap. if(plan.modules.contains(ScaffoldModule.METADATA)) { - lines += " metadata {" - lines += " entrypoints { main(\"${Naming.modClassFqcn(plan)}\") }" - lines += " }" + Naming.fabricEntryFqcn(plan)?.let { fabricEntry -> + lines += " metadata {" + lines += " entrypoints { main(\"$fabricEntry\") }" + lines += " }" + } } if(plan.modules.contains(ScaffoldModule.MIXINS)) { @@ -45,4 +51,32 @@ internal object ModuleBlocks { */ fun pluginIds(plan: ScaffoldPlan): List = plan.modules.map { " id(\"${it.pluginId}\")" } + + /** + * The `loaders { }` block (indented four spaces to sit inside + * `modkit { }`), with a `fabric`/`neoforge` sub-block per selected loader. + * The loader/API versions have no built-in default, so they are emitted as + * `TODO` placeholders the user must fill — otherwise the build compiles but + * the run fails (no `fabric-loader` on the classpath, etc.). + */ + fun loadersBlock(plan: ScaffoldPlan): List { + val loaders = plan.nodes.map { it.loader }.distinct() + val lines = mutableListOf() + + lines += " loaders {" + if(loaders.contains(McLoader.FABRIC)) { + lines += " fabric {" + lines += " // TODO: loaderVersion.set(\"VERSION\")" + lines += " // TODO: apiVersion.set(\"VERSION\") // optional" + lines += " }" + } + if(loaders.contains(McLoader.NEOFORGE)) { + lines += " neoforge {" + lines += " // TODO: version.set(\"VERSION\")" + lines += " }" + } + lines += " }" + + return lines + } } diff --git a/plugins/scaffold/src/main/kotlin/com/oliveryasuna/modkit/scaffold/render/MultiversionRenderer.kt b/plugins/scaffold/src/main/kotlin/com/oliveryasuna/modkit/scaffold/render/MultiversionRenderer.kt index a66a4d8..b637169 100644 --- a/plugins/scaffold/src/main/kotlin/com/oliveryasuna/modkit/scaffold/render/MultiversionRenderer.kt +++ b/plugins/scaffold/src/main/kotlin/com/oliveryasuna/modkit/scaffold/render/MultiversionRenderer.kt @@ -93,6 +93,8 @@ internal object MultiversionRenderer { appendLine() appendLine(" multiversion {") appendLine(" }") + appendLine() + ModuleBlocks.loadersBlock(plan).forEach { appendLine(it) } val blocks = ModuleBlocks.render(plan) if(blocks.isNotEmpty()) { appendLine() diff --git a/plugins/scaffold/src/main/kotlin/com/oliveryasuna/modkit/scaffold/render/Naming.kt b/plugins/scaffold/src/main/kotlin/com/oliveryasuna/modkit/scaffold/render/Naming.kt index c859178..698ef2f 100644 --- a/plugins/scaffold/src/main/kotlin/com/oliveryasuna/modkit/scaffold/render/Naming.kt +++ b/plugins/scaffold/src/main/kotlin/com/oliveryasuna/modkit/scaffold/render/Naming.kt @@ -1,6 +1,8 @@ package com.oliveryasuna.modkit.scaffold.render +import com.oliveryasuna.modkit.core.extension.McLoader import com.oliveryasuna.modkit.scaffold.ScaffoldPlan +import com.oliveryasuna.modkit.scaffold.ScaffoldShape import com.oliveryasuna.modkit.scaffold.render.Naming.basePackage /** @@ -27,7 +29,39 @@ internal object Naming { .filter { it.isNotEmpty() } .joinToString("") { part -> part.replaceFirstChar { it.uppercaseChar() } } - /** Fully-qualified example mod entry class, `.`. */ - fun modClassFqcn(plan: ScaffoldPlan): String = - "${basePackage(plan)}.${modClassName(plan)}" + /** A loader's package/name segment, e.g. `"fabric"` / `"neoforge"`. */ + fun loaderTag(loader: McLoader): String = loader.name.lowercase() + + private fun loaderPascal(loader: McLoader): String = + loaderTag(loader).replaceFirstChar { it.uppercaseChar() } + + /** + * The entry class simple name for [loader]: bare in the single-loader + * simple shape (e.g. `Mymod`), loader-suffixed in the multiversion shape + * where both loaders' bootstraps share one source set (e.g. `MymodFabric`). + */ + fun entryClassName(plan: ScaffoldPlan, loader: McLoader): String = + if(plan.shape == ScaffoldShape.SIMPLE) modClassName(plan) + else modClassName(plan) + loaderPascal(loader) + + /** + * The entry class package for [loader]: the base package in the simple + * shape, a per-loader sub-package (`.fabric` / `.neoforge`) in + * the multiversion shape. + */ + fun entryPackage(plan: ScaffoldPlan, loader: McLoader): String = + if(plan.shape == ScaffoldShape.SIMPLE) basePackage(plan) + else "${basePackage(plan)}.${loaderTag(loader)}" + + /** Fully-qualified entry class for [loader]. */ + fun entryFqcn(plan: ScaffoldPlan, loader: McLoader): String = + "${entryPackage(plan, loader)}.${entryClassName(plan, loader)}" + + /** + * The Fabric entry FQCN wired into `fabric.mod.json` `entrypoints.main`, + * or `null` when Fabric is not in the matrix (NeoForge uses `@Mod`, no + * manifest entry). + */ + fun fabricEntryFqcn(plan: ScaffoldPlan): String? = + if(plan.nodes.any { it.loader == McLoader.FABRIC }) entryFqcn(plan, McLoader.FABRIC) else null } diff --git a/plugins/scaffold/src/main/kotlin/com/oliveryasuna/modkit/scaffold/render/SimpleRenderer.kt b/plugins/scaffold/src/main/kotlin/com/oliveryasuna/modkit/scaffold/render/SimpleRenderer.kt index a110aaa..efaa2c2 100644 --- a/plugins/scaffold/src/main/kotlin/com/oliveryasuna/modkit/scaffold/render/SimpleRenderer.kt +++ b/plugins/scaffold/src/main/kotlin/com/oliveryasuna/modkit/scaffold/render/SimpleRenderer.kt @@ -73,6 +73,8 @@ internal object SimpleRenderer { appendLine(" minecraft(\"${node.minecraft}\") {") appendLine(" loaders.add(com.oliveryasuna.modkit.core.extension.McLoader.${node.loader.name})") appendLine(" }") + appendLine() + ModuleBlocks.loadersBlock(plan).forEach { appendLine(it) } val blocks = ModuleBlocks.render(plan) if(blocks.isNotEmpty()) { appendLine() diff --git a/plugins/scaffold/src/main/kotlin/com/oliveryasuna/modkit/scaffold/render/SourceRenderer.kt b/plugins/scaffold/src/main/kotlin/com/oliveryasuna/modkit/scaffold/render/SourceRenderer.kt index 1e9a1b1..f551633 100644 --- a/plugins/scaffold/src/main/kotlin/com/oliveryasuna/modkit/scaffold/render/SourceRenderer.kt +++ b/plugins/scaffold/src/main/kotlin/com/oliveryasuna/modkit/scaffold/render/SourceRenderer.kt @@ -1,53 +1,107 @@ package com.oliveryasuna.modkit.scaffold.render +import com.oliveryasuna.modkit.core.extension.McLoader import com.oliveryasuna.modkit.scaffold.ScaffoldModule import com.oliveryasuna.modkit.scaffold.ScaffoldPlan +import com.oliveryasuna.modkit.scaffold.ScaffoldShape /** - * Renders the example sources shared by both project shapes: a trivial mod - * entry class, and — when `mixins` is selected — an example mixin config plus a - * placeholder mixin package. Pure string builders. + * Renders the example sources: a loader-appropriate mod entry point per + * selected loader — Fabric's a `ModInitializer` (wired into `fabric.mod.json` + * `entrypoints.main`), NeoForge's a `@Mod` class — and, when `mixins` is + * selected, an example mixin config plus a placeholder mixin package. + * + * In the single-loader simple shape one plain entry class is emitted. In the + * multiversion shape both loaders' bootstraps share one source set, so each is + * wrapped in a Stonecutter `//? if ` guard (the active node's loader + * uncommented, the others commented) — the same pattern as the `example-mod`. + * Pure string builders. */ internal object SourceRenderer { fun render(plan: ScaffoldPlan): List { val files = mutableListOf() - val pkg = Naming.basePackage(plan) - val pkgPath = Naming.basePackagePath(plan) - val className = Naming.modClassName(plan) - files += GeneratedFile( - path = "src/main/java/$pkgPath/$className.java", - content = buildString { - appendLine("package $pkg;") - appendLine() - appendLine("/**") - appendLine(" * Example entry point for the '${plan.modId}' mod, generated by Modkit.") - appendLine(" */") - appendLine("public final class $className {") - appendLine() - appendLine(" public static final String MOD_ID = \"${plan.modId}\";") - appendLine() - appendLine(" public $className() {") - appendLine(" System.out.println(\"Hello from \" + MOD_ID + \"!\");") - appendLine(" }") - appendLine("}") + val multiversion = plan.shape == ScaffoldShape.MULTIVERSION + val activeLoader = plan.nodes.first().loader + + plan.nodes.map { it.loader }.distinct().forEach { loader -> + val pkg = Naming.entryPackage(plan, loader) + val name = Naming.entryClassName(plan, loader) + val body = when(loader) { + McLoader.FABRIC -> fabricEntry(plan, pkg, name) + else -> neoForgeEntry(plan, pkg, name) } - ) + val content = if(multiversion) guard(loader, active = loader == activeLoader, body) else body + + files += GeneratedFile("src/main/java/${pkg.replace('.', '/')}/$name.java", content) + } if(plan.modules.contains(ScaffoldModule.MIXINS)) { - files += renderMixin(plan, pkg, pkgPath, className) + files += renderMixin(plan) } return files } - private fun renderMixin( - plan: ScaffoldPlan, - pkg: String, - pkgPath: String, - className: String, - ): List { + private fun fabricEntry(plan: ScaffoldPlan, pkg: String, name: String): String = buildString { + appendLine("package $pkg;") + appendLine() + appendLine("import net.fabricmc.api.ModInitializer;") + appendLine() + // Line comments only — a block comment here would nest inside the + // Stonecutter `/* */` guard used for the inactive loader + // (multiversion). + appendLine("// Fabric entry point for '${plan.modId}', generated by Modkit.") + appendLine("// Referenced from fabric.mod.json entrypoints.main (wired by the metadata plugin).") + appendLine("public final class $name implements ModInitializer {") + appendLine() + appendLine(" public static final String MOD_ID = \"${plan.modId}\";") + appendLine() + appendLine(" @Override") + appendLine(" public void onInitialize() {") + appendLine(" System.out.println(\"Hello from \" + MOD_ID + \"!\");") + appendLine(" }") + appendLine("}") + } + + private fun neoForgeEntry(plan: ScaffoldPlan, pkg: String, name: String): String = buildString { + appendLine("package $pkg;") + appendLine() + appendLine("import net.neoforged.bus.api.IEventBus;") + appendLine("import net.neoforged.fml.common.Mod;") + appendLine() + // Line comments only — see the note in fabricEntry. + appendLine("// NeoForge entry point for '${plan.modId}', generated by Modkit.") + appendLine("// Discovered by the @Mod annotation — no manifest entry needed.") + appendLine("@Mod(\"${plan.modId}\")") + appendLine("public final class $name {") + appendLine() + appendLine(" public static final String MOD_ID = \"${plan.modId}\";") + appendLine() + appendLine(" public $name(final IEventBus modBus) {") + appendLine(" System.out.println(\"Hello from \" + MOD_ID + \"!\");") + appendLine(" }") + appendLine("}") + } + + /** + * Wraps a Java file [body] in a Stonecutter loader guard. The active + * loader's body is left as-is; an inactive loader's body is + * block-commented, so only one loader's bootstrap compiles per node. + */ + private fun guard(loader: McLoader, active: Boolean, body: String): String { + val tag = Naming.loaderTag(loader) + return if(active) { + "//? if $tag {\n$body//?}\n" + } else { + "//? if $tag {\n/*$body*///?}\n" + } + } + + private fun renderMixin(plan: ScaffoldPlan): List { + val pkg = Naming.basePackage(plan) + val pkgPath = Naming.basePackagePath(plan) val mixinPkg = "$pkg.mixin" val mixinPkgPath = "$pkgPath/mixin" return listOf( diff --git a/plugins/scaffold/src/test/kotlin/com/oliveryasuna/modkit/scaffold/ScaffoldRendererTest.kt b/plugins/scaffold/src/test/kotlin/com/oliveryasuna/modkit/scaffold/ScaffoldRendererTest.kt index 9a259f5..629f81a 100644 Binary files a/plugins/scaffold/src/test/kotlin/com/oliveryasuna/modkit/scaffold/ScaffoldRendererTest.kt and b/plugins/scaffold/src/test/kotlin/com/oliveryasuna/modkit/scaffold/ScaffoldRendererTest.kt differ