Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ vanniktech-maven-publish = "0.37.0"
# (org.parchmentmc.data:parchment-<mc>:<date>), 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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 `<projectDir>/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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,13 +129,26 @@ public class ModkitMetadataPlugin : Plugin<Project> {
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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand All @@ -25,8 +22,14 @@ internal abstract class ValidateModMetadataTask : DefaultTask() {
@get:[Input Optional]
abstract val license: Property<String>

@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<Boolean>
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -25,10 +26,15 @@ internal object ModuleBlocks {
fun render(plan: ScaffoldPlan): List<String> {
val lines = mutableListOf<String>()

// 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)) {
Expand All @@ -45,4 +51,32 @@ internal object ModuleBlocks {
*/
fun pluginIds(plan: ScaffoldPlan): List<String> =
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<String> {
val loaders = plan.nodes.map { it.loader }.distinct()
val lines = mutableListOf<String>()

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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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

/**
Expand All @@ -27,7 +29,39 @@ internal object Naming {
.filter { it.isNotEmpty() }
.joinToString("") { part -> part.replaceFirstChar { it.uppercaseChar() } }

/** Fully-qualified example mod entry class, `<basePackage>.<ClassName>`. */
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 (`<base>.fabric` / `<base>.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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading