Skip to content
Merged
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: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ This repo builds the Grill CLI and project setup tooling. The generated map-proj

- Build/typecheck should not require parsing the installed Warcraft executable when `wc3Patch` is pinned.
- Run/launch is different: the selected WC3 executable controls launch arguments. If the client family and project patch target differ, warn and allow choosing another WC3 folder.
- `grill patch` is a read-only exact-version check. `grill patch align` is the explicit mutation boundary: update `wc3Patch`, the official stdlib branch, and Grill-owned core JASS together, and preserve custom stdlib forks.
- Exact Reforged patch detection comes from the active Warcraft III row in Blizzard's `.build.info`. If it is missing or cannot map to a supported target, alignment must leave the project unchanged.
- Keep compiler-facing patch behavior tested in the WurstScript repo as well; Grill and compiler can diverge if only one side is tested.

## Test Commands
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,24 @@ The command exits with code `0` when dependencies are up to date and `1` when up
> grill outdated
```

### Aligning with the installed Warcraft III patch

Use `patch` to compare the project target with the exact installed Warcraft III version. This is read-only:

```cmd
> grill patch
```

When Grill reports a mismatch, migrate the project configuration, official stdlib branch, and cached core JASS together:

```cmd
> grill patch align
```

Grill reads the configured VS Code game path first and falls back to automatic detection. Pass `--wc3-path <dir>` to select a different installation. Alignment creates `wurst.build.bak` and makes no changes if an exact supported client patch cannot be detected.

`grill install` also keeps the official stdlib dependency pinned to the branch required by the project's existing `wc3Patch`, and only suggests migration when it detects a different client patch.


### Building the project

Expand Down
30 changes: 26 additions & 4 deletions src/main/kotlin/config/WurstProjectConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ import java.nio.file.StandardOpenOption
*/

object WurstProjectConfig {
private val MAPPER = JsonMapper.builder().enable(JsonReadFeature.ALLOW_TRAILING_COMMA).build()
private val MAPPER = JsonMapper.builder()
.enable(JsonReadFeature.ALLOW_TRAILING_COMMA)
.enable(JsonReadFeature.ALLOW_JAVA_COMMENTS)
.enable(JsonReadFeature.ALLOW_YAML_COMMENTS)
.build()

private val schema by lazy { javaClass.classLoader.getResource("wbschema.json") }
private val log = KotlinLogging.logger {}
Expand All @@ -42,14 +46,16 @@ object WurstProjectConfig {
}

@Throws(IOException::class)
fun loadProject(buildFile: Path): WurstProjectConfigData? {
fun loadProject(buildFile: Path, persistRecovery: Boolean = true): WurstProjectConfigData? {
Log.println("Loading project..")
if (Files.exists(buildFile) && buildFile.fileName.toString().equals(CONFIG_FILE_NAME, ignoreCase = true)) {
val config = YamlHelper.loadProjectConfig(buildFile)
val config = YamlHelper.loadProjectConfig(buildFile, persistRecovery)
val projectRoot = buildFile.parent
if (config.projectName.isBlank()) {
val namedConfig = config.withProjectName(projectRoot?.fileName?.toString() ?: "unnamed")
saveProjectConfig(projectRoot, namedConfig)
if (persistRecovery) {
saveProjectConfig(projectRoot, namedConfig)
}
Log.print("done\n")
return namedConfig
}
Expand Down Expand Up @@ -145,6 +151,22 @@ object WurstProjectConfig {
Files.write(projectRoot.resolve(CONFIG_FILE_NAME), projectYaml.toByteArray())
}

fun configuredGamePath(projectRoot: Path): Path? {
val settings = projectRoot.resolve(".vscode").resolve("settings.json")
if (!Files.isRegularFile(settings)) {
return null
}
return try {
Comment thread
Frotty marked this conversation as resolved.
MAPPER.readTree(Files.readString(settings))
?.get("wurst.wc3path")
?.asText()
?.takeIf(String::isNotBlank)
?.let(Paths::get)
} catch (_: Exception) {
null
}
}


@Throws(IOException::class)
private fun setupVSCode(projectRoot: Path?, gamePath: Path?) {
Expand Down
3 changes: 2 additions & 1 deletion src/main/kotlin/file/CLICommand.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ enum class CLICommand {
GENERATE,
TEST,
TYPECHECK,
OUTDATED,
OUTDATED,
PATCH,
BUILD,
EXPORTOBJECTS,
SELF_UPDATE
Expand Down
31 changes: 31 additions & 0 deletions src/main/kotlin/file/CoreJassProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,21 @@ object CoreJassProvider {
return withPrefix
}

fun patchTargetForClientVersion(version: String?): String? {
val parts = version?.trim()?.split('.') ?: return null
if (parts.size < 2 || parts[0].toIntOrNull() == null || parts[1].toIntOrNull() == null) {
return null
}
val target = "v${parts[0].toInt()}.${parts[1].toInt()}"
return target.takeIf(PATCH_TO_JASS_HISTORY_FOLDER::containsKey)
}

fun patchLine(patch: String?): String? {
val normalized = normalizePatchInput(patch)
val version = Wc3PatchTarget.parse(normalized).orElse(null)?.gameVersion() ?: return null
return patchTargetForClientVersion(version)
}

fun isPre129Patch(input: String?): Boolean {
val patch = normalizePatchInput(input)
return Wc3PatchTarget.parse(patch)
Expand All @@ -179,6 +194,13 @@ object CoreJassProvider {
.orElse(false)
}

fun isV3OrLaterPatch(input: String?): Boolean {
val patch = normalizePatchInput(input)
val target = Wc3PatchTarget.parse(patch).orElse(null) ?: return false
return target.kind() == Wc3PatchTarget.Kind.REFORGED &&
compareVersionStrings(target.gameVersion(), "3.0") >= 0
}

fun ensureFiles(projectRoot: Path, wc3Patch: String?): List<Path> {
val buildFolder = projectRoot.resolve("_build")
Files.createDirectories(buildFolder)
Expand Down Expand Up @@ -209,6 +231,15 @@ object CoreJassProvider {
return materializedFiles.map { it.path }
}

fun managedFilesNeedRefresh(projectRoot: Path, wc3Patch: String?): Boolean {
val buildFolder = projectRoot.resolve("_build")
val previousPatch = readProvenance(buildFolder) ?: return false
val patch = resolveSupportedPatch(wc3Patch)
return previousPatch != patch ||
!isValidCoreJassFile(buildFolder.resolve("common.j")) ||
!isValidCoreJassFile(buildFolder.resolve("blizzard.j"))
}

fun fetchJassHistoryVersions(): List<String> {
val versionListUrl = "$JASS_HISTORY_RAW/$JASS_HISTORY_REF/$VERSION_LIST_FILE"
return try {
Expand Down
113 changes: 109 additions & 4 deletions src/main/kotlin/file/SetupApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import config.WurstProjectBuildMapData
import config.WurstProjectConfigData
import config.newProjectConfig
import config.withAddedDependency
import config.withDependencies
import config.withRemovedDependency
import config.withWc3Patch
import global.InstallationManager
Expand All @@ -21,6 +22,7 @@ import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.nio.file.StandardCopyOption
import java.util.*
import java.util.concurrent.TimeUnit
import javax.swing.JOptionPane
Expand Down Expand Up @@ -136,7 +138,10 @@ object SetupApp {
val configFile = setup.projectRoot.resolve(CONFIG_FILE_NAME)
var configData: WurstProjectConfigData? = null
if (Files.exists(configFile)) {
configData = WurstProjectConfig.loadProject(configFile)!!
configData = WurstProjectConfig.loadProject(
configFile,
persistRecovery = setup.command != CLICommand.PATCH
)!!
Comment on lines +141 to +144

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Abort alignment when project parsing falls back

When wurst.build is malformed, loadProject(..., false) still returns a synthesized default configuration; because this mode also applies to patch align, a supported client then causes handleUpdate to replace the active configuration with that fallback, discarding dependencies, build metadata, and any custom stdlib fork. The backup makes recovery possible but does not prevent the destructive migration, so alignment should distinguish a parse failure and abort rather than treating the fallback as the project configuration.

AGENTS.md reference: AGENTS.md:L51-L51

Useful? React with 👍 / 👎.

}

when {
Expand All @@ -155,6 +160,7 @@ object SetupApp {
| test [filter] Run unit tests, optionally filtered by package/function name
| typecheck Typecheck the project without building a map
| outdated Check whether project dependencies are up to date
| patch [align] Compare project/client patches; align config and stdlib on request
| build <mapfile|map-folder> Build the project using the given map archive or folder
| exportobjects <mapfile|folder> Export object editor data to Wurst source
|
Expand All @@ -173,12 +179,16 @@ object SetupApp {
| --with-agents / --no-agents Include AGENTS.md (default: no)
| --with-ci / --no-ci Include GitHub Actions workflow (default: no)
| --with-dep <id> Add a curated dependency (repeatable; ids: ${CuratedDependencies.ids.joinToString(", ")})
|
|Patch options:
| --wc3-path <dir> Warcraft III install folder to inspect
""".trimMargin())
}
setup.command == CLICommand.INSTALL -> {
if (setup.commandArg.isBlank()) {
if (configData != null) {
configData = ensureProjectPatchRecorded(configData)
suggestPatchAlignment(configData)
handleUpdateProject(configData)
} else {
missingProject()
Expand All @@ -191,6 +201,7 @@ object SetupApp {
if (configData != null) {
configData = handleInstallDep(configData)
configData = ensureProjectPatchRecorded(configData)
suggestPatchAlignment(configData)
WurstProjectConfig.saveProjectConfig(setup.projectRoot, configData)
handleUpdateProject(configData)
} else {
Expand Down Expand Up @@ -261,6 +272,13 @@ object SetupApp {
}
checkProjectOutdated(configData)
}
setup.command == CLICommand.PATCH -> {
if (configData == null) {
missingProject()
} else {
handlePatchAlignment(configData)
}
}
setup.command == CLICommand.BUILD -> {
progress("🔨 Building project...")
val mapArg = if (setup.commandArg.isBlank()) {
Expand Down Expand Up @@ -741,10 +759,96 @@ object SetupApp {
return when {
CoreJassProvider.isPre124(wc3Patch) -> "https://github.com/wurstscript/wurstStdlib2:pre1.24"
CoreJassProvider.isPre129Patch(wc3Patch) -> "https://github.com/wurstscript/wurstStdlib2:pre1.29"
else -> "https://github.com/wurstscript/wurstStdlib2"
CoreJassProvider.isV3OrLaterPatch(wc3Patch) -> "https://github.com/wurstscript/wurstStdlib2"
else -> "https://github.com/wurstscript/wurstStdlib2:v2.0"
}
}

internal fun alignedProjectConfig(configData: WurstProjectConfigData, patchTarget: String): WurstProjectConfigData {
return alignOfficialStdlibDependency(configData.withWc3Patch(patchTarget), patchTarget)
}

internal fun alignOfficialStdlibDependency(configData: WurstProjectConfigData, patchTarget: String): WurstProjectConfigData {
val expectedStdlib = stdlibDependencyForPatch(patchTarget)
val dependencies = configData.dependencies.map { dependency ->
if (isOfficialStdlibDependency(dependency)) expectedStdlib else dependency
}.distinct()
return configData.withDependencies(dependencies)
}

private fun isOfficialStdlibDependency(dependency: String): Boolean {
val prefix = "https://github.com/wurstscript/wurstStdlib2"
if (!dependency.startsWith(prefix, ignoreCase = true)) {
return false
}
val suffix = dependency.substring(prefix.length)
val normalizedSuffix = if (suffix.startsWith(".git", ignoreCase = true)) suffix.substring(4) else suffix
return normalizedSuffix.isBlank() || normalizedSuffix.startsWith(":")
}

private fun handlePatchAlignment(configData: WurstProjectConfigData) {
val action = setup.commandArg.trim().lowercase()
if (action.isNotBlank() && action != "align") {
fail("Unknown patch action: ${setup.commandArg}. Use `grill patch` or `grill patch align`.")
ExitHandler.exit(1)
return
}

val configuredPath = WurstProjectConfig.configuredGamePath(setup.projectRoot)
val gameRoot = setup.gamePath ?: configuredPath ?: Wc3ClientDetector.detectGameRoot()
val clientInfo = Wc3ClientDetector.inspectGameRoot(gameRoot)
if (clientInfo == null) {
fail("No Warcraft III installation was found. Use `grill patch --wc3-path <dir>`.")
ExitHandler.exit(1)
return
}

val detectedPatch = clientInfo.patchTarget
val currentPatch = CoreJassProvider.patchLine(configData.wc3Patch)
log.info("Project patch: ${currentPatch ?: configData.wc3Patch ?: "not configured"}")
log.info("Detected client: ${Wc3ClientDetector.describe(clientInfo)}")
if (detectedPatch == null) {
fail("The exact Warcraft III patch could not be mapped to a supported target. No files were changed.")
ExitHandler.exit(1)
return
}

val alignedConfig = alignedProjectConfig(configData, detectedPatch)
val configNeedsAlignment = alignedConfig != configData
val coreJassNeedsRefresh = CoreJassProvider.managedFilesNeedRefresh(setup.projectRoot, detectedPatch)
if (!configNeedsAlignment && !coreJassNeedsRefresh) {
pass("Project is already aligned with Warcraft III $detectedPatch.")
return
}
if (action != "align") {
if (configNeedsAlignment) {
log.info("Alignment available: ${currentPatch ?: "unconfigured"} -> $detectedPatch")
}
if (coreJassNeedsRefresh) {
log.info("Managed core JASS needs to be refreshed for $detectedPatch.")
}
log.info("Run `grill patch align` to update wurst.build, stdlib, and core JASS.")
return
}

ensureCoreJassFiles(setup.projectRoot, detectedPatch)
if (configNeedsAlignment) {
val buildFile = setup.projectRoot.resolve(CONFIG_FILE_NAME)
Files.copy(buildFile, buildFile.resolveSibling("$CONFIG_FILE_NAME.bak"), StandardCopyOption.REPLACE_EXISTING)
WurstProjectConfig.handleUpdate(setup.projectRoot, clientInfo.root, alignedConfig)
pass("Aligned project with Warcraft III $detectedPatch. Previous config: $CONFIG_FILE_NAME.bak")
} else {
pass("Refreshed managed core JASS for Warcraft III $detectedPatch.")
}
}

private fun suggestPatchAlignment(configData: WurstProjectConfigData) {
val configuredPath = WurstProjectConfig.configuredGamePath(setup.projectRoot)
val gameRoot = setup.gamePath ?: configuredPath ?: Wc3ClientDetector.detectGameRoot()
val clientInfo = Wc3ClientDetector.inspectGameRoot(gameRoot) ?: return
Wc3ClientDetector.mismatchMessage(configData.wc3Patch, clientInfo)?.let(log::warn)
}

internal fun generatedBuildMapData(projectName: String): WurstProjectBuildMapData {
val mapName = projectName.trim().ifBlank { "Unnamed" }
return WurstProjectBuildMapData(
Expand Down Expand Up @@ -805,15 +909,16 @@ object SetupApp {
if (currentPatch.isNullOrBlank()) {
val selectedPatch = selectPatchVersionForInstall()
log.info("WC3 patch recorded in wurst.build: $selectedPatch")
return configData.withWc3Patch(selectedPatch)
return alignOfficialStdlibDependency(configData.withWc3Patch(selectedPatch), selectedPatch)
}

val normalizedPatch = CoreJassProvider.normalizePatchInput(currentPatch)
return if (normalizedPatch != currentPatch) {
val normalizedConfig = if (normalizedPatch != currentPatch) {
configData.withWc3Patch(normalizedPatch)
} else {
configData
}
return alignOfficialStdlibDependency(normalizedConfig, normalizedPatch)
}

internal fun selectPatchVersionForInstall(): String {
Expand Down
Loading
Loading