diff --git a/AGENTS.md b/AGENTS.md index 76bdda9..b9f449a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index 343d051..5c00e55 100644 --- a/README.md +++ b/README.md @@ -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 ` 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 diff --git a/src/main/kotlin/config/WurstProjectConfig.kt b/src/main/kotlin/config/WurstProjectConfig.kt index 3d7d88b..2142a06 100644 --- a/src/main/kotlin/config/WurstProjectConfig.kt +++ b/src/main/kotlin/config/WurstProjectConfig.kt @@ -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 {} @@ -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 } @@ -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 { + 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?) { diff --git a/src/main/kotlin/file/CLICommand.kt b/src/main/kotlin/file/CLICommand.kt index 2067966..9b017b5 100644 --- a/src/main/kotlin/file/CLICommand.kt +++ b/src/main/kotlin/file/CLICommand.kt @@ -10,7 +10,8 @@ enum class CLICommand { GENERATE, TEST, TYPECHECK, - OUTDATED, + OUTDATED, + PATCH, BUILD, EXPORTOBJECTS, SELF_UPDATE diff --git a/src/main/kotlin/file/CoreJassProvider.kt b/src/main/kotlin/file/CoreJassProvider.kt index 793717f..4a72bed 100644 --- a/src/main/kotlin/file/CoreJassProvider.kt +++ b/src/main/kotlin/file/CoreJassProvider.kt @@ -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) @@ -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 { val buildFolder = projectRoot.resolve("_build") Files.createDirectories(buildFolder) @@ -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 { val versionListUrl = "$JASS_HISTORY_RAW/$JASS_HISTORY_REF/$VERSION_LIST_FILE" return try { diff --git a/src/main/kotlin/file/SetupApp.kt b/src/main/kotlin/file/SetupApp.kt index 87fa2d5..2ffc434 100644 --- a/src/main/kotlin/file/SetupApp.kt +++ b/src/main/kotlin/file/SetupApp.kt @@ -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 @@ -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 @@ -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 + )!! } when { @@ -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 Build the project using the given map archive or folder | exportobjects Export object editor data to Wurst source | @@ -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 Add a curated dependency (repeatable; ids: ${CuratedDependencies.ids.joinToString(", ")}) + | + |Patch options: + | --wc3-path 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() @@ -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 { @@ -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()) { @@ -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 `.") + 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( @@ -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 { diff --git a/src/main/kotlin/file/Wc3ClientDetector.kt b/src/main/kotlin/file/Wc3ClientDetector.kt index cae1cb6..292f795 100644 --- a/src/main/kotlin/file/Wc3ClientDetector.kt +++ b/src/main/kotlin/file/Wc3ClientDetector.kt @@ -15,13 +15,18 @@ object Wc3ClientDetector { data class ClientInfo( val root: Path, + val installationRoot: Path, val executable: Path, val kind: ClientKind?, + val version: String?, + val patchTarget: String?, ) private val exeCandidates = listOf( Paths.get("_retail_", "x86_64", "Warcraft III.exe"), Paths.get("_retail_", "x86", "Warcraft III.exe"), + Paths.get("_ptr_", "x86_64", "Warcraft III.exe"), + Paths.get("_ptr_", "x86", "Warcraft III.exe"), Paths.get("x86_64", "Warcraft III.exe"), Paths.get("x86", "Warcraft III.exe"), Paths.get("Warcraft III.exe"), @@ -42,12 +47,16 @@ object Wc3ClientDetector { } val normalizedRoot = root.toAbsolutePath().normalize() val executable = findExecutable(normalizedRoot) ?: return null - val installRoot = if (Files.isRegularFile(normalizedRoot)) { - installationRootForExecutable(executable) - } else { - normalizedRoot - } - return ClientInfo(installRoot, executable, classifyExecutable(executable)) + val installRoot = installationRootForExecutable(executable) + val version = readBuildInfoVersion(installRoot, productForExecutable(executable)) + return ClientInfo( + clientRootForExecutable(executable, installRoot), + installRoot, + executable, + classifyExecutable(executable), + version, + CoreJassProvider.patchTargetForClientVersion(version) + ) } fun describe(info: ClientInfo?): String { @@ -55,7 +64,8 @@ object Wc3ClientDetector { return "not found" } val kind = info.kind?.let { describeKind(it) } ?: "unknown patch family" - return "${info.root} ($kind)" + val version = info.version?.let { ", version $it" }.orEmpty() + return "${info.root} ($kind$version)" } fun projectKind(patch: String?): ClientKind? { @@ -70,6 +80,11 @@ object Wc3ClientDetector { fun mismatchMessage(projectPatch: String?, clientInfo: ClientInfo?): String? { val projectKind = projectKind(projectPatch) ?: return null val clientKind = clientInfo?.kind ?: return null + val projectPatchTarget = CoreJassProvider.patchLine(projectPatch) + val clientPatchTarget = clientInfo.patchTarget + if (projectPatchTarget != null && clientPatchTarget != null && projectPatchTarget != clientPatchTarget) { + return "Selected Warcraft III client is $clientPatchTarget (${clientInfo.version}), but the project targets $projectPatchTarget. Run `grill patch align` to migrate the project." + } if (projectKind == clientKind) { return null } @@ -121,6 +136,59 @@ object Wc3ClientDetector { return parent } + private fun clientRootForExecutable(executable: Path, installRoot: Path): Path { + val executableDirectory = executable.toAbsolutePath().normalize().parent ?: return installRoot + val channelDirectory = if ( + executableDirectory.fileName?.toString()?.equals("x86", ignoreCase = true) == true || + executableDirectory.fileName?.toString()?.equals("x86_64", ignoreCase = true) == true + ) { + executableDirectory.parent + } else { + executableDirectory + } + return channelDirectory?.takeIf { + it.fileName?.toString()?.equals("_retail_", ignoreCase = true) == true || + it.fileName?.toString()?.equals("_ptr_", ignoreCase = true) == true + } ?: installRoot + } + + private fun productForExecutable(executable: Path): String? { + val path = executable.toAbsolutePath().normalize().toString().replace('\\', '/').lowercase(Locale.ROOT) + return when { + path.contains("/_ptr_/") -> "w3t" + path.contains("/_retail_/") -> "w3" + else -> null + } + } + + private fun readBuildInfoVersion(root: Path, selectedProduct: String?): String? { + val buildInfo = root.resolve(".build.info") + if (!Files.isRegularFile(buildInfo)) { + return null + } + return try { + val lines = Files.readAllLines(buildInfo).filter(String::isNotBlank) + val headers = lines.firstOrNull()?.split('|')?.map { it.substringBefore('!') } ?: return null + val versionIndex = headers.indexOf("Version") + val productIndex = headers.indexOf("Product") + val activeIndex = headers.indexOf("Active") + if (versionIndex < 0 || productIndex < 0 || activeIndex < 0) return null + + lines.drop(1) + .map { it.split('|') } + .firstOrNull { values -> + values.size > versionIndex && + values.getOrNull(productIndex).equals(selectedProduct ?: "w3", ignoreCase = true) && + values.getOrNull(activeIndex) == "1" + } + ?.getOrNull(versionIndex) + ?.trim() + ?.takeIf(String::isNotBlank) + } catch (_: Exception) { + null + } + } + private fun describeKind(kind: ClientKind): String { return when (kind) { ClientKind.PRE_129 -> "pre-1.29" diff --git a/src/main/kotlin/file/YamlHelper.kt b/src/main/kotlin/file/YamlHelper.kt index 43b8229..c3afe12 100644 --- a/src/main/kotlin/file/YamlHelper.kt +++ b/src/main/kotlin/file/YamlHelper.kt @@ -39,11 +39,13 @@ object YamlHelper { } - fun loadProjectConfig(path: Path): WurstProjectConfigData { + fun loadProjectConfig(path: Path, persistRecovery: Boolean = true): WurstProjectConfigData { val content = Files.readString(path) if (isEffectivelyEmptyYaml(content)) { val fallback = fallbackConfig(path) - persistRecoveredConfig(path, fallback, backupOriginal = false) + if (persistRecovery) { + persistRecoveredConfig(path, fallback, backupOriginal = false) + } return fallback } @@ -54,7 +56,9 @@ object YamlHelper { } catch (e: Exception) { log.warn("The project's wurst.build file could not be read. Recovering with defaults.", e) val fallback = fallbackConfig(path) - persistRecoveredConfig(path, fallback, backupOriginal = true) + if (persistRecovery) { + persistRecoveredConfig(path, fallback, backupOriginal = true) + } fallback } } diff --git a/src/test/kotlin/GenerateTests.kt b/src/test/kotlin/GenerateTests.kt index d284117..f5c53cc 100644 --- a/src/test/kotlin/GenerateTests.kt +++ b/src/test/kotlin/GenerateTests.kt @@ -148,6 +148,7 @@ class GenerateTests { fun testStdlibDependencyFollowsPatchEra() { val pre124Stdlib = "https://github.com/wurstscript/wurstStdlib2:pre1.24" val legacyStdlib = "https://github.com/wurstscript/wurstStdlib2:pre1.29" + val preV3Stdlib = "https://github.com/wurstscript/wurstStdlib2:v2.0" val currentStdlib = "https://github.com/wurstscript/wurstStdlib2" for (patch in CoreJassProvider.supportedPatches) { @@ -155,7 +156,8 @@ class GenerateTests { val expected = when { minor != null && minor < 24 -> pre124Stdlib minor != null && minor < 29 -> legacyStdlib - else -> currentStdlib + patch == "v3.0" -> currentStdlib + else -> preV3Stdlib } Assert.assertEquals(SetupApp.stdlibDependencyForPatch(patch), expected, "stdlib dependency for $patch") } @@ -163,7 +165,15 @@ class GenerateTests { Assert.assertEquals(SetupApp.stdlibDependencyForPatch("v1.23a"), pre124Stdlib) Assert.assertEquals(SetupApp.stdlibDependencyForPatch("TFT-v1.27b-ru"), legacyStdlib) Assert.assertEquals(SetupApp.stdlibDependencyForPatch("pre1.29"), legacyStdlib) - Assert.assertEquals(SetupApp.stdlibDependencyForPatch("v1.29"), currentStdlib) + Assert.assertEquals(SetupApp.stdlibDependencyForPatch("v1.29"), preV3Stdlib) + Assert.assertEquals( + SetupApp.stdlibDependencyForPatch("Reforged-v3.1.0.25000-w3-deadbeef"), + currentStdlib + ) + Assert.assertEquals( + SetupApp.stdlibDependencyForPatch("Reforged-v2.0.4.23745-w3-deadbeef"), + preV3Stdlib + ) } @Test(priority = 10) diff --git a/src/test/kotlin/PatchAlignmentTests.kt b/src/test/kotlin/PatchAlignmentTests.kt new file mode 100644 index 0000000..e177c0f --- /dev/null +++ b/src/test/kotlin/PatchAlignmentTests.kt @@ -0,0 +1,170 @@ +import config.newProjectConfig +import config.WurstProjectConfig +import file.CoreJassProvider +import file.CLICommand +import file.SetupApp +import file.SetupMain +import org.testng.Assert +import org.testng.annotations.Test +import java.nio.file.Files +import java.nio.file.Paths + +class PatchAlignmentTests { + @Test + fun testParsesPatchAlignWithExplicitGamePath() { + val setup = SetupMain() + val gamePath = Paths.get("C:\\Games\\Warcraft III") + + setup.parseArgs(listOf("patch", "align", "--wc3-path", gamePath.toString())) + + Assert.assertEquals(setup.command, CLICommand.PATCH) + Assert.assertEquals(setup.commandArg, "align") + Assert.assertEquals(setup.gamePath, gamePath) + } + + @Test + fun testMapsClientVersionsToSupportedPatchLines() { + Assert.assertEquals(CoreJassProvider.patchTargetForClientVersion("3.0.0.24268"), "v3.0") + Assert.assertEquals(CoreJassProvider.patchTargetForClientVersion("2.0.4.23745"), "v2.0") + Assert.assertEquals(CoreJassProvider.patchTargetForClientVersion("1.36.1.20719"), "v1.36") + Assert.assertNull(CoreJassProvider.patchTargetForClientVersion("4.0.0.1")) + } + + @Test + fun testV2ProjectsUseTheMaintenanceStdlibBranch() { + Assert.assertEquals( + SetupApp.stdlibDependencyForPatch("v2.0"), + "https://github.com/wurstscript/wurstStdlib2:v2.0" + ) + Assert.assertEquals( + SetupApp.stdlibDependencyForPatch("v3.0"), + "https://github.com/wurstscript/wurstStdlib2" + ) + Assert.assertEquals( + SetupApp.stdlibDependencyForPatch("v1.36"), + "https://github.com/wurstscript/wurstStdlib2:v2.0" + ) + } + + @Test + fun testAlignmentUpdatesPatchAndOfficialStdlibTogether() { + val config = newProjectConfig( + projectName = "migration-test", + dependencies = listOf( + "https://github.com/wurstscript/wurstStdlib2:v2.0", + "https://github.com/example/custom-library" + ), + wc3Patch = "v2.0" + ) + + val aligned = SetupApp.alignedProjectConfig(config, "v3.0") + + Assert.assertEquals(aligned.wc3Patch, "v3.0") + Assert.assertEquals( + aligned.dependencies, + listOf( + "https://github.com/wurstscript/wurstStdlib2", + "https://github.com/example/custom-library" + ) + ) + } + + @Test + fun testAlignmentDoesNotRewriteCustomStdlibForks() { + val customFork = "https://github.com/example/wurstStdlib2:custom" + val config = newProjectConfig( + projectName = "custom-stdlib", + dependencies = listOf(customFork), + wc3Patch = "v2.0" + ) + + val aligned = SetupApp.alignedProjectConfig(config, "v3.0") + + Assert.assertEquals(aligned.dependencies, listOf(customFork)) + } + + @Test + fun testAlignmentRecognizesOfficialStdlibGitUrls() { + val config = newProjectConfig( + projectName = "git-url", + dependencies = listOf( + "https://github.com/wurstscript/wurstStdlib2.git", + "https://github.com/wurstscript/wurstStdlib2.git:master" + ), + wc3Patch = "v3.0" + ) + + val aligned = SetupApp.alignedProjectConfig(config, "v2.0") + + Assert.assertEquals( + aligned.dependencies, + listOf("https://github.com/wurstscript/wurstStdlib2:v2.0") + ) + } + + @Test + fun testExistingV2TargetPinsStdlibBeforeDependencyInstall() { + val config = newProjectConfig( + projectName = "safe-install", + dependencies = listOf("https://github.com/wurstscript/wurstStdlib2"), + wc3Patch = "v2.0" + ) + + val aligned = SetupApp.alignOfficialStdlibDependency(config, "v2.0") + + Assert.assertEquals(aligned.wc3Patch, "v2.0") + Assert.assertEquals( + aligned.dependencies, + listOf("https://github.com/wurstscript/wurstStdlib2:v2.0") + ) + } + + @Test + fun testManagedCoreJassDetectsStalePatchAndInvalidFiles() { + val projectRoot = Files.createTempDirectory("wurstsetup-core-jass-alignment") + val buildFolder = Files.createDirectories(projectRoot.resolve("_build")) + Files.writeString(buildFolder.resolve("core-jass.provenance"), "wc3Patch: v2.0\n") + Files.writeString(buildFolder.resolve("common.j"), "x".repeat(2048)) + Files.writeString(buildFolder.resolve("blizzard.j"), "x".repeat(2048)) + + Assert.assertTrue(CoreJassProvider.managedFilesNeedRefresh(projectRoot, "v3.0")) + Assert.assertFalse(CoreJassProvider.managedFilesNeedRefresh(projectRoot, "v2.0")) + + Files.writeString(buildFolder.resolve("common.j"), "invalid") + Assert.assertTrue(CoreJassProvider.managedFilesNeedRefresh(projectRoot, "v2.0")) + } + + @Test + fun testConfiguredGamePathSupportsJsonc() { + val projectRoot = Files.createTempDirectory("wurstsetup-jsonc-settings") + val vscodeFolder = Files.createDirectories(projectRoot.resolve(".vscode")) + Files.writeString( + vscodeFolder.resolve("settings.json"), + """ + { + // Warcraft III installation used by the Wurst extension. + "wurst.wc3path": "C:\\Games\\Warcraft III", + } + """.trimIndent() + ) + + Assert.assertEquals( + WurstProjectConfig.configuredGamePath(projectRoot), + Paths.get("C:\\Games\\Warcraft III") + ) + } + + @Test + fun testReadOnlyProjectLoadDoesNotRepairMalformedBuildFile() { + val projectRoot = Files.createTempDirectory("wurstsetup-read-only-patch") + val buildFile = projectRoot.resolve("wurst.build") + val malformedConfig = ":\n - [broken" + Files.writeString(buildFile, malformedConfig) + + val loaded = WurstProjectConfig.loadProject(buildFile, persistRecovery = false) + + Assert.assertNotNull(loaded) + Assert.assertEquals(Files.readString(buildFile), malformedConfig) + Assert.assertFalse(Files.exists(projectRoot.resolve("wurst.build.bak"))) + } +} diff --git a/src/test/kotlin/Wc3ClientDetectorTests.kt b/src/test/kotlin/Wc3ClientDetectorTests.kt index ea1caf8..0a489eb 100644 --- a/src/test/kotlin/Wc3ClientDetectorTests.kt +++ b/src/test/kotlin/Wc3ClientDetectorTests.kt @@ -13,7 +13,8 @@ class Wc3ClientDetectorTests { val info = Wc3ClientDetector.inspectGameRoot(root)!! Assert.assertEquals(info.kind, Wc3ClientDetector.ClientKind.REFORGED) - Assert.assertEquals(info.root, root.toAbsolutePath().normalize()) + Assert.assertEquals(info.root, root.resolve("_retail_").toAbsolutePath().normalize()) + Assert.assertEquals(info.installationRoot, root.toAbsolutePath().normalize()) } @Test @@ -58,6 +59,7 @@ class Wc3ClientDetectorTests { Assert.assertEquals(info.kind, Wc3ClientDetector.ClientKind.PRE_129) Assert.assertEquals(info.root, root.toAbsolutePath().normalize()) + Assert.assertEquals(info.installationRoot, root.toAbsolutePath().normalize()) } @Test @@ -71,4 +73,140 @@ class Wc3ClientDetectorTests { Assert.assertNotNull(warning) Assert.assertTrue(warning!!.contains("project targets Reforged")) } + + @Test + fun testReadsExactReforgedVersionFromBuildInfo() { + val root = Files.createTempDirectory("wc3-versioned-reforged") + val exe = Files.createDirectories(root.resolve("_retail_").resolve("x86_64")).resolve("Warcraft III.exe") + Files.writeString(exe, "") + Files.writeString( + root.resolve(".build.info"), + "Branch!STRING:0|Active!DEC:1|Version!STRING:0|Product!STRING:0\n" + + "eu|1|3.0.0.24268|w3\n" + ) + + val info = Wc3ClientDetector.inspectGameRoot(root)!! + + Assert.assertEquals(info.version, "3.0.0.24268") + Assert.assertEquals(info.patchTarget, "v3.0") + Assert.assertNull(Wc3ClientDetector.mismatchMessage("v3.0", info)) + Assert.assertTrue(Wc3ClientDetector.mismatchMessage("v2.0", info)!!.contains("grill patch align")) + } + + @Test + fun testFindsBuildInfoWhenConfiguredPathIsExecutableDirectory() { + val root = Files.createTempDirectory("wc3-configured-bin") + val executableDirectory = Files.createDirectories(root.resolve("_retail_").resolve("x86_64")) + Files.writeString(executableDirectory.resolve("Warcraft III.exe"), "") + Files.writeString( + root.resolve(".build.info"), + "Active!DEC:1|Version!STRING:0|Product!STRING:0\n" + + "1|3.0.0.24268|w3\n" + ) + + val info = Wc3ClientDetector.inspectGameRoot(executableDirectory)!! + + Assert.assertEquals(info.root, root.resolve("_retail_").toAbsolutePath().normalize()) + Assert.assertEquals(info.installationRoot, root.toAbsolutePath().normalize()) + Assert.assertEquals(info.patchTarget, "v3.0") + } + + @Test + fun testIgnoresInactiveAndNonWarcraftBuildInfoRows() { + val root = Files.createTempDirectory("wc3-multi-product") + val exe = Files.createDirectories(root.resolve("_retail_").resolve("x86_64")).resolve("Warcraft III.exe") + Files.writeString(exe, "") + Files.writeString( + root.resolve(".build.info"), + "Active!DEC:1|Version!STRING:0|Product!STRING:0\n" + + "1|99.0.0.1|other\n" + + "0|2.0.4.23745|w3\n" + + "1|3.0.0.24268|w3\n" + ) + + val info = Wc3ClientDetector.inspectGameRoot(root)!! + + Assert.assertEquals(info.version, "3.0.0.24268") + Assert.assertEquals(info.patchTarget, "v3.0") + } + + @Test + fun testRejectsBuildInfoWithoutProductHeader() { + val root = Files.createTempDirectory("wc3-build-info-no-product") + val exe = Files.createDirectories(root.resolve("_retail_").resolve("x86_64")).resolve("Warcraft III.exe") + Files.writeString(exe, "") + Files.writeString( + root.resolve(".build.info"), + "Active!DEC:1|Version!STRING:0\n" + + "1|3.0.0.24268\n" + ) + + val info = Wc3ClientDetector.inspectGameRoot(root)!! + + Assert.assertNull(info.version) + Assert.assertNull(info.patchTarget) + } + + @Test + fun testRejectsBuildInfoWithoutActiveHeader() { + val root = Files.createTempDirectory("wc3-build-info-no-active") + val exe = Files.createDirectories(root.resolve("_retail_").resolve("x86_64")).resolve("Warcraft III.exe") + Files.writeString(exe, "") + Files.writeString( + root.resolve(".build.info"), + "Version!STRING:0|Product!STRING:0\n" + + "3.0.0.24268|w3\n" + ) + + val info = Wc3ClientDetector.inspectGameRoot(root)!! + + Assert.assertNull(info.version) + Assert.assertNull(info.patchTarget) + } + + @Test + fun testSelectsPtrBuildInfoRowForExplicitPtrPath() { + val root = Files.createTempDirectory("wc3-retail-ptr") + val retailExe = Files.createDirectories(root.resolve("_retail_").resolve("x86_64")).resolve("Warcraft III.exe") + val ptrDirectory = Files.createDirectories(root.resolve("_ptr_").resolve("x86_64")) + Files.writeString(retailExe, "") + Files.writeString(ptrDirectory.resolve("Warcraft III.exe"), "") + Files.writeString( + root.resolve(".build.info"), + "Active!DEC:1|Version!STRING:0|Product!STRING:0\n" + + "1|3.0.0.24268|w3\n" + + "1|2.0.4.23745|w3t\n" + ) + + val info = Wc3ClientDetector.inspectGameRoot(ptrDirectory)!! + + Assert.assertEquals(info.executable, ptrDirectory.resolve("Warcraft III.exe")) + Assert.assertEquals(info.root, ptrDirectory.parent) + Assert.assertEquals(info.installationRoot, root.toAbsolutePath().normalize()) + Assert.assertEquals(info.version, "2.0.4.23745") + Assert.assertEquals(info.patchTarget, "v2.0") + } + + @Test + fun testSelectsRetailBuildInfoRowForInstallationRoot() { + val root = Files.createTempDirectory("wc3-retail-ptr-root") + val retailDirectory = Files.createDirectories(root.resolve("_retail_").resolve("x86_64")) + val ptrDirectory = Files.createDirectories(root.resolve("_ptr_").resolve("x86_64")) + Files.writeString(retailDirectory.resolve("Warcraft III.exe"), "") + Files.writeString(ptrDirectory.resolve("Warcraft III.exe"), "") + Files.writeString( + root.resolve(".build.info"), + "Active!DEC:1|Version!STRING:0|Product!STRING:0\n" + + "1|2.0.4.23745|w3t\n" + + "1|3.0.0.24268|w3\n" + ) + + val info = Wc3ClientDetector.inspectGameRoot(root)!! + + Assert.assertEquals(info.executable, retailDirectory.resolve("Warcraft III.exe")) + Assert.assertEquals(info.root, retailDirectory.parent) + Assert.assertEquals(info.installationRoot, root.toAbsolutePath().normalize()) + Assert.assertEquals(info.version, "3.0.0.24268") + Assert.assertEquals(info.patchTarget, "v3.0") + } }