diff --git a/AGENTS.md b/AGENTS.md index 34a0ef731..db7ed68ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -244,7 +244,50 @@ This pipeline handles: --- -## 8. Backend Parity and Lua Guardrails +## 8. Shared Project Config, Patch Targets, and Run Behavior + +Recent Grill/compiler integration work moved `wurst.build` parsing rules into a tiny shared dependency. Keep compiler behavior aligned with that shared model. + +### Shared project config dependency + +* `de.peeeq.wurstscript/build.gradle` depends on `com.github.wurstscript:wurst-project-config`. +* `de.peeeq.wurstio.languageserver.WurstBuildConfig` is a compiler adapter around the shared model, not a second config DAO. +* Do not duplicate YAML parsing rules, patch aliases, or script-mode behavior in compiler-only code unless it is truly compiler-specific. +* Preserve exact `wc3Patch` names for cache invalidation and diagnostics. Broad patch kind is useful for behavior choices, but not enough for hashes. + +### Patch target rules + +* Use the shared `Wc3PatchTarget` parser for `wc3Patch`. +* Patch family boundaries: + * below `1.29` => pre-1.29 behavior + * `1.29` through `1.31` => classic + * `1.32+`, `1.36`, `2.0`, and `Reforged-*` => Reforged +* Friendly names and jass-history dump names should resolve through shared config. Do not add one-off aliases in compiler code. +* If jass-history has a broken folder name, fix `wurstscript/jass-history` instead of compensating here. + +### Build vs run + +* Build/typecheck should prefer pinned `wc3Patch` from `wurst.build` and should not parse the installed Warcraft executable just to decide target patch data. +* Config injection should use the pinned project patch when available, not the locally installed game patch. +* User-facing executable version parsing failures must stay short. Do not print PE parser stack traces unless explicit debug logging is requested. +* Run/launch is different from build: the selected Warcraft executable controls launch arguments and map placement. +* When project patch family and selected client family differ, warn and allow the user to choose a different Warcraft III folder. +* If launch folder selection changes the client, all launch decisions must use that selected `W3InstallationData`, not stale request-level `w3data`. +* Legacy clients that need install-dir map placement must copy to the selected launch install's `Maps/Test` folder. + +### Focused tests + +For config and run-pipeline changes, prefer these focused checks before broader test runs: + +``` +./gradlew test --tests tests.wurstscript.tests.WurstBuildConfigTests +./gradlew test --tests tests.wurstscript.tests.MapRequestPatchTargetTests +./gradlew make_for_userdir +``` + +--- + +## 9. Backend Parity and Lua Guardrails Recent fixes established additional rules for backend work. Follow these for all future changes: @@ -288,7 +331,7 @@ Recent fixes established additional rules for backend work. Follow these for all --- -## 9. Virtual Slot Binding and Determinism (New Generics + Lua) +## 10. Virtual Slot Binding and Determinism (New Generics + Lua) Recent regressions showed that virtual-slot binding can silently degrade to base/no-op implementations in generated Lua while still compiling. Follow these rules for all related changes: diff --git a/de.peeeq.wurstscript/build.gradle b/de.peeeq.wurstscript/build.gradle index 90e58dfe9..9a55173fa 100644 --- a/de.peeeq.wurstscript/build.gradle +++ b/de.peeeq.wurstscript/build.gradle @@ -110,6 +110,7 @@ dependencies { implementation 'org.xerial:sqlite-jdbc:3.46.1.3' implementation 'com.github.inwc3:jmpq3:e28f6999c0' implementation 'com.github.inwc3:wc3libs:a69318d921' + implementation 'com.github.wurstscript:wurst-project-config:2c7ccd1a5f' implementation('com.github.wurstscript:wurstsetup:393cf5ea39') { exclude group: 'org.eclipse.jgit', module: 'org.eclipse.jgit' exclude group: 'org.eclipse.jgit', module: 'org.eclipse.jgit.ssh.apache' diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/ProjectConfigBuilder.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/ProjectConfigBuilder.java index ac8088e24..12670bc7d 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/ProjectConfigBuilder.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/ProjectConfigBuilder.java @@ -180,7 +180,7 @@ private static String calculateProjectConfigHash(WurstProjectConfigData projectC .append("\n"); WurstBuildConfig buildConfig = buildConfigFromBuildDir(buildDir); sb.append("scriptMode:").append(buildConfig.scriptMode()).append("\n"); - sb.append("wc3Patch:").append(buildConfig.wc3Patch()).append("\n"); + sb.append("wc3Patch:").append(buildConfig.wc3PatchName()).append("\n"); return ImportFile.calculateHash(sb.toString().getBytes(StandardCharsets.UTF_8)); } catch (Exception e) { @@ -200,21 +200,29 @@ private static void applyBuildMapData(WurstProjectConfigData projectConfig, File try (FileInputStream inputStream = new FileInputStream(mapScript)) { StringWriter sw = new StringWriter(); - if (w3data.getWc3PatchVersion().isPresent()) { - w3I.injectConfigsInJassScript(inputStream, sw, w3data.getWc3PatchVersion().get()); + WurstBuildConfig buildConfig = buildConfigFromBuildDir(buildDir); + GameVersion version = effectiveConfigInjectionVersion(buildDir, w3data); + if (buildConfig.configuredGameVersion().isPresent()) { + WLogger.info("Using wurst.build patch target for map config injection: " + version); + } else if (w3data.getWc3PatchVersion().isPresent()) { + WLogger.info("Using detected game version for map config injection: " + version); } else { - GameVersion version = buildConfigFromBuildDir(buildDir).fallbackGameVersion(); - WLogger.info( - "Failed to determine installed game version. Falling back to wurst.build patch target: " + version - ); - w3I.injectConfigsInJassScript(inputStream, sw, version); + WLogger.info("Failed to determine installed game version. Falling back to default patch target: " + version); } + w3I.injectConfigsInJassScript(inputStream, sw, version); byte[] scriptBytes = sw.toString().getBytes(StandardCharsets.UTF_8); Files.write(scriptBytes, result.script); } } + private static GameVersion effectiveConfigInjectionVersion(File buildDir, W3InstallationData w3data) { + WurstBuildConfig buildConfig = buildConfigFromBuildDir(buildDir); + return buildConfig.configuredGameVersion() + .or(() -> w3data.getWc3PatchVersion()) + .orElseGet(buildConfig::fallbackGameVersion); + } + private static WurstBuildConfig buildConfigFromBuildDir(File buildDir) { java.nio.file.Path projectRoot = buildDir.toPath().getParent(); if (projectRoot == null) { diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/WurstBuildConfig.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/WurstBuildConfig.java index c76717c00..732851a5c 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/WurstBuildConfig.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/WurstBuildConfig.java @@ -3,14 +3,12 @@ import config.WurstProjectConfigData; import de.peeeq.wurstscript.WLogger; import net.moonlightflower.wc3libs.port.GameVersion; +import org.wurstscript.projectconfig.Wc3PatchTarget; import java.io.IOException; import java.lang.reflect.Method; -import java.nio.file.Files; import java.nio.file.Path; -import java.util.ArrayList; import java.util.List; -import java.util.Locale; import java.util.Optional; import static de.peeeq.wurstio.languageserver.ProjectConfigBuilder.FILE_NAME; @@ -24,181 +22,133 @@ public enum ScriptMode { public enum Wc3Patch { REFORGED, + CLASSIC, PRE_129 } - private final Optional scriptMode; - private final Optional wc3Patch; + private final org.wurstscript.projectconfig.WurstBuildConfig sharedConfig; - private WurstBuildConfig(Optional scriptMode, Optional wc3Patch) { - this.scriptMode = scriptMode; - this.wc3Patch = wc3Patch; + private WurstBuildConfig(org.wurstscript.projectconfig.WurstBuildConfig sharedConfig) { + this.sharedConfig = sharedConfig == null ? org.wurstscript.projectconfig.WurstBuildConfig.empty() : sharedConfig; } public static WurstBuildConfig empty() { - return new WurstBuildConfig(Optional.empty(), Optional.empty()); + return new WurstBuildConfig(org.wurstscript.projectconfig.WurstBuildConfig.empty()); } public static WurstBuildConfig fromWorkspaceRoot(WFile workspaceRoot) { + if (workspaceRoot == null) { + return empty(); + } return fromBuildFile(Path.of(workspaceRoot.toString(), FILE_NAME)); } public static WurstBuildConfig fromProject(WurstProjectConfigData projectConfig, WFile workspaceRoot) { WurstBuildConfig fileConfig = fromWorkspaceRoot(workspaceRoot); - Optional scriptMode = readEnumGetter(projectConfig, "getScriptMode", ScriptMode::valueOf) - .or(fileConfig::scriptMode); - Optional wc3Patch = readEnumGetter(projectConfig, "getWc3Patch", WurstBuildConfig::parsePatchName) - .or(fileConfig::wc3Patch); - return new WurstBuildConfig(scriptMode, wc3Patch); + if (projectConfig == null) { + return fileConfig; + } + Optional scriptMode = readStringGetter(projectConfig, "getScriptMode") + .flatMap(WurstBuildConfig::parseSharedScriptMode) + .or(fileConfig.sharedConfig::scriptMode); + Optional wc3Patch = readStringGetter(projectConfig, "getWc3Patch") + .flatMap(Wc3PatchTarget::parse) + .or(fileConfig.sharedConfig::wc3Patch); + return new WurstBuildConfig(new org.wurstscript.projectconfig.WurstBuildConfig(scriptMode, wc3Patch)); } static WurstBuildConfig fromBuildFile(Path buildFile) { - if (!Files.exists(buildFile)) { - return empty(); - } - Optional scriptMode = Optional.empty(); - Optional wc3Patch = Optional.empty(); try { - for (String rawLine : Files.readAllLines(buildFile)) { - String line = stripComment(rawLine).trim(); - if (line.isEmpty() || Character.isWhitespace(rawLine.charAt(0))) { - continue; - } - int colon = line.indexOf(':'); - if (colon < 0) { - continue; - } - String key = line.substring(0, colon).trim(); - String value = normalizeScalar(line.substring(colon + 1).trim()); - if (key.equals("scriptMode")) { - scriptMode = parseScriptMode(value); - } else if (key.equals("wc3Patch")) { - wc3Patch = parsePatch(value); - } - } + return new WurstBuildConfig(org.wurstscript.projectconfig.WurstBuildConfig.fromBuildFile(buildFile)); } catch (IOException e) { WLogger.warning("Could not read " + buildFile + " for build settings", e); + return empty(); } - return new WurstBuildConfig(scriptMode, wc3Patch); } public Optional scriptMode() { - return scriptMode; + return sharedConfig.scriptMode().map(mode -> ScriptMode.valueOf(mode.name())); } public Optional wc3Patch() { - return wc3Patch; + return sharedConfig.wc3Patch().map(WurstBuildConfig::patchKind); + } + + public Optional wc3PatchName() { + return sharedConfig.wc3Patch().map(Wc3PatchTarget::name); + } + + public Optional configuredGameVersion() { + return sharedConfig.wc3Patch() + .map(Wc3PatchTarget::gameVersion) + .map(GameVersion::new); } public Wc3Patch wc3PatchOrReforged() { - return wc3Patch.orElse(Wc3Patch.REFORGED); + return wc3Patch().orElse(Wc3Patch.REFORGED); } public GameVersion fallbackGameVersion() { - if (wc3PatchOrReforged() == Wc3Patch.PRE_129) { - return new GameVersion("1.28"); - } - return GameVersion.VERSION_1_32; + return configuredGameVersion().orElse(GameVersion.VERSION_1_32); } public List applyToCompileArgs(List compileArgs) { - if (!scriptMode.isPresent()) { - return compileArgs; - } - List result = new ArrayList<>(); - for (String arg : compileArgs) { - if (!"-lua".equals(arg)) { - result.add(arg); - } - } - if (scriptMode.get() == ScriptMode.LUA) { - result.add("-lua"); - } - return result; + return sharedConfig.applyToCompileArgs(compileArgs); } public boolean shouldUseReforgedLaunchArgs(Optional detectedVersion) { - return detectedVersion - .map(version -> version.compareTo(GameVersion.VERSION_1_32) >= 0) - .orElse(wc3PatchOrReforged() == Wc3Patch.REFORGED); + return sharedConfig.shouldUseReforgedLaunchArgs(versionString(detectedVersion)); } public boolean shouldUseClassicWindowArg(Optional detectedVersion) { - return detectedVersion - .map(version -> version.compareTo(GameVersion.VERSION_1_31) < 0) - .orElse(wc3PatchOrReforged() == Wc3Patch.PRE_129); + return sharedConfig.shouldUseClassicWindowArg(versionString(detectedVersion)); } public boolean shouldCopyRunMapToWarcraftMapDir(Optional detectedVersion) { - return detectedVersion - .map(version -> version.compareTo(GameVersion.VERSION_1_32) < 0) - .orElse(wc3PatchOrReforged() == Wc3Patch.PRE_129); + return sharedConfig.shouldCopyRunMapToWarcraftMapDir(versionString(detectedVersion)); } public boolean shouldUseInstallDirForMaps(Optional detectedVersion) { - return detectedVersion.orElseGet(this::fallbackGameVersion) - .compareTo(new GameVersion("1.27.9")) <= 0; + Optional effectiveVersion = detectedVersion == null ? Optional.empty() : detectedVersion; + return effectiveVersion.or(this::configuredGameVersion) + .map(version -> version.compareTo(new GameVersion("1.27.9")) <= 0) + .orElse(false); } - private static String stripComment(String line) { - int commentStart = line.indexOf('#'); - return commentStart >= 0 ? line.substring(0, commentStart) : line; - } - - private static String normalizeScalar(String value) { - String result = value; - if ((result.startsWith("\"") && result.endsWith("\"")) - || (result.startsWith("'") && result.endsWith("'"))) { - result = result.substring(1, result.length() - 1); - } - return result.trim(); + private static Optional versionString(Optional version) { + return version == null ? Optional.empty() : version.map(GameVersion::toString); } - private static Optional parseScriptMode(String value) { - String normalized = value.toUpperCase(Locale.ROOT); + private static Optional parseSharedScriptMode(String value) { try { - return Optional.of(ScriptMode.valueOf(normalized)); - } catch (IllegalArgumentException e) { - WLogger.warning("Ignoring unknown scriptMode in wurst.build: " + value); + return Optional.of(org.wurstscript.projectconfig.ScriptMode.valueOf(value.trim().toUpperCase())); + } catch (IllegalArgumentException | NullPointerException e) { return Optional.empty(); } } - private static Optional parsePatch(String value) { - try { - return Optional.of(parsePatchName(value)); - } catch (IllegalArgumentException e) { - WLogger.warning("Ignoring unknown wc3Patch in wurst.build: " + value); - return Optional.empty(); + private static Wc3Patch patchKind(Wc3PatchTarget target) { + if (target.kind() == Wc3PatchTarget.Kind.PRE_129) { + return Wc3Patch.PRE_129; } - } - - private static Wc3Patch parsePatchName(String value) { - String normalized = value.toUpperCase(Locale.ROOT) - .replace(".", "_") - .replace("-", "_"); - if (normalized.equals("PRE1_29")) { - normalized = "PRE_129"; + if (target.kind() == Wc3PatchTarget.Kind.CLASSIC) { + return Wc3Patch.CLASSIC; } - return Wc3Patch.valueOf(normalized); - } - - private interface EnumParser { - T parse(String value); + return Wc3Patch.REFORGED; } - private static Optional readEnumGetter(WurstProjectConfigData projectConfig, String getterName, EnumParser parser) { + private static Optional readStringGetter(WurstProjectConfigData projectConfig, String getterName) { try { Method getter = projectConfig.getClass().getMethod(getterName); Object value = getter.invoke(projectConfig); if (value == null) { return Optional.empty(); } - return Optional.of(parser.parse(value.toString())); + return Optional.of(value.toString()); } catch (NoSuchMethodException ignored) { return Optional.empty(); } catch (Exception e) { - WLogger.warning("Could not read " + getterName + " from wurst.build config", e); + WLogger.debug("Could not read " + getterName + " from wurst.build config: " + e); return Optional.empty(); } } diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/MapRequest.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/MapRequest.java index 9259ee486..fe8916aa6 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/MapRequest.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/MapRequest.java @@ -10,6 +10,7 @@ import de.peeeq.wurstio.languageserver.ModelManager; import de.peeeq.wurstio.languageserver.ProjectConfigBuilder; import de.peeeq.wurstio.languageserver.WFile; +import de.peeeq.wurstio.languageserver.WurstBuildConfig; import de.peeeq.wurstio.languageserver.WurstLanguageServer; import de.peeeq.wurstio.map.importer.ImportFile; import de.peeeq.wurstio.mpq.MpqEditor; @@ -62,6 +63,7 @@ public abstract class MapRequest extends UserRequest { protected final WFile workspaceRoot; protected final RunArgs runArgs; protected final Optional wc3Path; + protected final WurstBuildConfig buildConfig; protected final W3InstallationData w3data; protected final TimeTaker timeTaker; @@ -104,8 +106,12 @@ public MapRequest(WurstLanguageServer langServer, Optional map, List configuredVersion = this instanceof RunMap + ? Optional.empty() + : buildConfig.configuredGameVersion(); + this.w3data = new W3InstallationData(Optional.of(new File(gameExePath.get())), configuredVersion); } else { this.w3data = getBestW3InstallationData(); } @@ -929,21 +935,31 @@ protected File renameJhcrOutput(File buildDir) throws IOException { } private W3InstallationData getBestW3InstallationData() throws RequestFailedException { + Optional configuredVersion = buildConfig.configuredGameVersion(); + boolean needsGameExe = this instanceof RunMap && !runArgs.isHotReload(); + if (configuredVersion.isPresent()) { + WLogger.info("Using wurst.build wc3Patch " + buildConfig.wc3PatchName().orElse(configuredVersion.get().toString()) + + " (" + configuredVersion.get() + ")."); + } if (Orient.isLinuxSystem()) { // no Warcraft installation supported on Linux - return new W3InstallationData(Optional.empty(), Optional.empty()); + return new W3InstallationData(Optional.empty(), configuredVersion); + } + if (!needsGameExe && configuredVersion.isPresent()) { + return new W3InstallationData(Optional.empty(), configuredVersion); } + Optional versionForDiscovery = needsGameExe ? Optional.empty() : configuredVersion; if (wc3Path.isPresent() && StringUtils.isNotBlank(wc3Path.get())) { W3InstallationData w3data = new W3InstallationData(langServer, new File(wc3Path.get()), - this instanceof RunMap && !runArgs.isHotReload()); - if (w3data.getWc3PatchVersion().isEmpty()) { + needsGameExe, versionForDiscovery); + if (w3data.getWc3PatchVersion().isEmpty() && !configuredVersion.isPresent()) { WLogger.warning("Could not determine Warcraft III version at specified path: " + wc3Path - + ". Falling back to wurst.build patch target."); + + ". Falling back to default launch behavior."); } return w3data; } else { - return new W3InstallationData(langServer, this instanceof RunMap && !runArgs.isHotReload()); + return new W3InstallationData(langServer, needsGameExe, versionForDiscovery); } } diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/RunMap.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/RunMap.java index 6f604af08..a69bcebaf 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/RunMap.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/RunMap.java @@ -5,10 +5,11 @@ import config.WurstProjectConfig; import config.WurstProjectConfigData; import de.peeeq.wurstio.gui.WurstGuiImpl; -import de.peeeq.wurstio.languageserver.WurstBuildConfig; import de.peeeq.wurstio.languageserver.ModelManager; import de.peeeq.wurstio.languageserver.WFile; +import de.peeeq.wurstio.languageserver.WurstBuildConfig; import de.peeeq.wurstio.languageserver.WurstLanguageServer; +import de.peeeq.wurstio.utils.W3InstallationData; import de.peeeq.wurstscript.WLogger; import de.peeeq.wurstscript.attributes.CompileError; import de.peeeq.wurstscript.gui.WurstGui; @@ -23,6 +24,7 @@ import javax.swing.*; import javax.swing.filechooser.FileSystemView; +import java.awt.GraphicsEnvironment; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; @@ -41,7 +43,6 @@ public class RunMap extends MapRequest { private @Nullable File customTarget = null; - private @Nullable WurstBuildConfig buildConfig = null; public RunMap(WurstLanguageServer langServer, WFile workspaceRoot, Optional wc3Path, Optional map, @@ -68,8 +69,6 @@ public Object execute(ModelManager modelManager) throws IOException { throw new RequestFailedException(MessageType.Error, FILE_NAME + " file doesn't exist or is invalid. " + "Please install your project using grill or the wurst setup tool."); } - buildConfig = WurstBuildConfig.fromProject(projectConfig, workspaceRoot); - // TODO use normal compiler for this, avoid code duplication WurstGui gui = new WurstGuiImpl(getWorkspaceAbsolute()); try { @@ -140,10 +139,13 @@ private void startGame(WurstGui gui, CompilationResult result) throws Exception gui.sendProgress("Starting Warcraft 3..."); File mapCopy = cachedMapFile.get(); - WurstBuildConfig buildConfig = getBuildConfig(); - Optional detectedGameVersion = w3data.getWc3PatchVersion(); + W3InstallationData launchData = resolveLaunchData(); + if (launchData == null) { + throw new RequestFailedException(MessageType.Info, "Run canceled."); + } + Optional detectedGameVersion = launchData.getWc3PatchVersion(); if (buildConfig.shouldCopyRunMapToWarcraftMapDir(detectedGameVersion)) { - mapCopy = copyToWarcraftMapDir(cachedMapFile.get()); + mapCopy = copyToWarcraftMapDir(cachedMapFile.get(), launchData); } @@ -158,7 +160,7 @@ private void startGame(WurstGui gui, CompilationResult result) throws Exception if (!path.isEmpty()) { // now start the map - File gameExe = w3data.getGameExe() + File gameExe = launchData.getGameExe() .orElseThrow(() -> new RequestFailedException(MessageType.Error, wc3Path + " does not exist.")); List cmd = Lists.newArrayList(gameExe.getAbsolutePath()); Optional wc3RunArgs = langServer.getConfigProvider().getWc3RunArgs(); @@ -191,6 +193,104 @@ private void startGame(WurstGui gui, CompilationResult result) throws Exception } } + private W3InstallationData resolveLaunchData() { + W3InstallationData launchData = w3data; + while (shouldWarnClientPatchMismatch(launchData)) { + String projectTarget = buildConfig.wc3PatchName().orElse("configured patch"); + String clientTarget = launchData.getWc3PatchVersion() + .map(RunMap::describeClientVersion) + .orElse("unknown Warcraft III version"); + String message = "This project targets " + projectTarget + ", but the selected Warcraft III client looks like " + + clientTarget + ". The map may not start correctly."; + WLogger.warning(message); + + MismatchChoice choice = chooseMismatchAction(message); + if (choice == MismatchChoice.CANCEL) { + return null; + } + if (choice == MismatchChoice.CONTINUE) { + return launchData; + } + Optional selected = chooseAlternateGamePath(); + if (selected.isEmpty()) { + return null; + } + launchData = selected.get(); + } + if (buildConfig.wc3Patch().isPresent() && launchData.getWc3PatchVersion().isEmpty()) { + WLogger.warning("Could not determine Warcraft III client version. If the map does not start, select a matching Warcraft III installation."); + } + return launchData; + } + + private boolean shouldWarnClientPatchMismatch(W3InstallationData launchData) { + Optional projectKind = buildConfig.wc3Patch(); + Optional clientVersion = launchData.getWc3PatchVersion(); + if (projectKind.isEmpty() || clientVersion.isEmpty()) { + return false; + } + return projectKind.get() != kindForVersion(clientVersion.get()); + } + + private static WurstBuildConfig.Wc3Patch kindForVersion(GameVersion version) { + if (version.compareTo(new GameVersion("1.29")) < 0) { + return WurstBuildConfig.Wc3Patch.PRE_129; + } + if (version.compareTo(GameVersion.VERSION_1_32) < 0) { + return WurstBuildConfig.Wc3Patch.CLASSIC; + } + return WurstBuildConfig.Wc3Patch.REFORGED; + } + + private static String describeClientVersion(GameVersion version) { + return kindForVersion(version) + " (" + version + ")"; + } + + private MismatchChoice chooseMismatchAction(String message) { + if (GraphicsEnvironment.isHeadless()) { + return MismatchChoice.CONTINUE; + } + Object[] options = {"Continue", "Choose Warcraft III folder", "Cancel"}; + int result = JOptionPane.showOptionDialog( + null, + message, + "Warcraft III version mismatch", + JOptionPane.DEFAULT_OPTION, + JOptionPane.WARNING_MESSAGE, + null, + options, + options[1] + ); + if (result == 1) { + return MismatchChoice.CHOOSE_OTHER; + } + if (result == 2 || result == JOptionPane.CLOSED_OPTION) { + return MismatchChoice.CANCEL; + } + return MismatchChoice.CONTINUE; + } + + private Optional chooseAlternateGamePath() { + if (GraphicsEnvironment.isHeadless()) { + return Optional.empty(); + } + JFileChooser fileChooser = new JFileChooser(FileSystemView.getFileSystemView().getHomeDirectory()); + fileChooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY); + fileChooser.setDialogTitle("Select Warcraft III installation folder"); + int result = fileChooser.showOpenDialog(null); + if (result != JFileChooser.APPROVE_OPTION) { + return Optional.empty(); + } + File selectedFolder = fileChooser.getSelectedFile(); + return Optional.of(new W3InstallationData(langServer, selectedFolder, true, Optional.empty())); + } + + private enum MismatchChoice { + CONTINUE, + CHOOSE_OTHER, + CANCEL + } + private void callJhcrUpdate(File mapScript) throws IOException, InterruptedException { File mapScriptFolder = mapScript.getParentFile(); @@ -253,7 +353,7 @@ private String getWorkspaceAbsolute() { *

* This directory depends on warcraft version and whether we are on windows or wine is used. */ - private File copyToWarcraftMapDir(File testMap) throws IOException { + private File copyToWarcraftMapDir(File testMap, W3InstallationData launchData) throws IOException { String testMapName = "WurstTestMap.w3x"; for (String arg : compileArgs) { if (arg.startsWith("-runmapTarget")) { @@ -271,7 +371,7 @@ private File copyToWarcraftMapDir(File testMap) throws IOException { } File myDocumentsFolder = FileSystemView.getFileSystemView().getDefaultDirectory(); - Optional documentPath = findMapDocumentPath(testMapName, myDocumentsFolder); + Optional documentPath = findMapDocumentPath(testMapName, myDocumentsFolder, launchData); // copy the map to the appropriate directory Optional testFolder = documentPath.map(path -> new File(path, "Maps" + File.separator + "Test")); @@ -304,7 +404,7 @@ private File copyToWarcraftMapDir(File testMap) throws IOException { return null; } - private Optional findMapDocumentPath(String testMapName, File myDocumentsFolder) { + private Optional findMapDocumentPath(String testMapName, File myDocumentsFolder, W3InstallationData launchData) { Optional documentPath = Optional.of( langServer.getConfigProvider().getMapDocumentPath().orElseGet( () -> myDocumentsFolder.getAbsolutePath() + File.separator + "Warcraft III")); @@ -325,13 +425,13 @@ private Optional findMapDocumentPath(String testMapName, File myDocument } } - if (getBuildConfig().shouldUseInstallDirForMaps(w3data.getWc3PatchVersion())) { + if (buildConfig.shouldUseInstallDirForMaps(launchData.getWc3PatchVersion())) { // 1.27 and lower compat WLogger.info("Version 1.27 or lower detected, changing file location"); - documentPath = wc3Path; + documentPath = mapInstallDirectoryForLegacyLaunch(launchData, wc3Path); } else { // For 1.28+ the wc3/maps/test folder must not contain a map of the same name - Optional oldFile = wc3Path.map( + Optional oldFile = installRootForLaunchData(launchData).map(File::getAbsolutePath).or(() -> wc3Path).map( w3p -> new File(w3p, "Maps" + File.separator + "Test" + File.separator + testMapName)); if (oldFile.isPresent() && oldFile.get().exists()) { if (!oldFile.get().delete()) { @@ -342,11 +442,31 @@ private Optional findMapDocumentPath(String testMapName, File myDocument return documentPath; } - private WurstBuildConfig getBuildConfig() { - if (buildConfig == null) { - buildConfig = WurstBuildConfig.fromWorkspaceRoot(workspaceRoot); + private static Optional mapInstallDirectoryForLegacyLaunch(W3InstallationData launchData, Optional fallbackWc3Path) { + Optional launchRoot = installRootForLaunchData(launchData); + if (launchRoot.isPresent()) { + return launchRoot.map(File::getAbsolutePath); } - return buildConfig; + return fallbackWc3Path; + } + + private static Optional installRootForLaunchData(W3InstallationData launchData) { + return launchData.getGameExe().map(RunMap::installRootForExecutable); } + private static File installRootForExecutable(File executable) { + File parent = executable.getAbsoluteFile().getParentFile(); + if (parent == null) { + return executable.getAbsoluteFile(); + } + if (parent.getName().equalsIgnoreCase("x86") || parent.getName().equalsIgnoreCase("x86_64")) { + File maybeRetail = parent.getParentFile(); + if (maybeRetail != null && (maybeRetail.getName().equalsIgnoreCase("_retail_") || maybeRetail.getName().equalsIgnoreCase("_ptr_"))) { + File installRoot = maybeRetail.getParentFile(); + return installRoot != null ? installRoot : maybeRetail; + } + return maybeRetail != null ? maybeRetail : parent; + } + return parent; + } } diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/utils/W3InstallationData.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/utils/W3InstallationData.java index b85636f6c..e364e4488 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/utils/W3InstallationData.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/utils/W3InstallationData.java @@ -19,6 +19,8 @@ public class W3InstallationData { private Optional version = Optional.empty(); + private boolean versionHeuristic = false; + private File selectedFolder; private boolean shouldAskForPath = false; @@ -32,24 +34,35 @@ public class W3InstallationData { public W3InstallationData(Optional gameExe, Optional version) { this.languageServer = null; - this.gameExe = gameExe; - this.version = version; + this.gameExe = gameExe == null ? Optional.empty() : gameExe; + this.version = version == null ? Optional.empty() : version; if (!this.version.isPresent() && this.gameExe.isPresent()) { try { this.version = Optional.ofNullable(GameExe.getVersion(this.gameExe.get())); + this.versionHeuristic = false; WLogger.info("Parsed game version from configured executable: " + this.version); } catch (IOException | RuntimeException e) { - WLogger.warning("Could not parse game version from configured executable", e); + WLogger.warning("Could not parse game version from configured executable. Continuing without detected game version."); + WLogger.debug("Game version parser failed: " + e); + inferVersionFromExecutablePath(); } } } /** Evaluates the game path and version by discovering the system environment. */ public W3InstallationData(WurstLanguageServer languageServer, boolean shouldAskForPath) { + this(languageServer, shouldAskForPath, Optional.empty()); + } + + /** Evaluates the game path and uses a configured version instead of probing the executable when supplied. */ + public W3InstallationData(WurstLanguageServer languageServer, boolean shouldAskForPath, Optional configuredVersion) { this.languageServer = languageServer; this.shouldAskForPath = shouldAskForPath; + this.version = configuredVersion == null ? Optional.empty() : configuredVersion; discoverExePath(); - discoverVersion(); + if (!this.version.isPresent()) { + discoverVersion(); + } } /** @@ -57,21 +70,33 @@ public W3InstallationData(WurstLanguageServer languageServer, boolean shouldAskF * system environment. */ public W3InstallationData(WurstLanguageServer languageServer, File wc3Path, boolean shouldAskForPath) { + this(languageServer, wc3Path, shouldAskForPath, Optional.empty()); + } + + public W3InstallationData(WurstLanguageServer languageServer, File wc3Path, boolean shouldAskForPath, + Optional configuredVersion) { this.languageServer = languageServer; this.shouldAskForPath = shouldAskForPath; + this.version = configuredVersion == null ? Optional.empty() : configuredVersion; if (Orient.isWindowsSystem() || Orient.isMacSystem()) { - loadFromPath(wc3Path); + loadFromPath(wc3Path, !this.version.isPresent()); } if (!gameExe.isPresent()) { WLogger.warning("The provided wc3 path wasn't suitable. Falling back to discovery."); discoverExePath(); - discoverVersion(); + if (!this.version.isPresent()) { + discoverVersion(); + } } } private void loadFromPath(File wc3Path) { + loadFromPath(wc3Path, !version.isPresent()); + } + + private void loadFromPath(File wc3Path, boolean shouldParseVersion) { try { if (Orient.isWindowsSystem()) { gameExe = Optional.ofNullable(WinGameExeFinder.fromDirIgnoreVersion(wc3Path)); @@ -87,22 +112,75 @@ private void loadFromPath(File wc3Path) { } } } catch (NotFoundException e) { - WLogger.severe(e); + WLogger.warning("Could not find Warcraft III executable in configured path: " + wc3Path); + WLogger.debug("Warcraft III executable lookup failed: " + e); } WLogger.info("Game Executable from path: " + gameExe); + if (!shouldParseVersion) { + WLogger.info("Using configured game version: " + version.map(Object::toString).orElse("unknown")); + return; + } + version = gameExe.flatMap(exe -> { try { return Optional.ofNullable(GameExe.getVersion(exe)); } catch (IOException | RuntimeException e) { - WLogger.warning("Could not parse game version from executable", e); + WLogger.warning("Could not parse game version from executable. Continuing without detected game version."); + WLogger.debug("Game version parser failed: " + e); } return Optional.empty(); }); + if (!version.isPresent()) { + inferVersionFromExecutablePath(); + } else { + versionHeuristic = false; + } WLogger.info("Parsed custom game version from executable: " + version); } + private void inferVersionFromExecutablePath() { + if (!gameExe.isPresent()) { + return; + } + Optional inferred = inferVersionFromExecutablePath(gameExe.get()); + if (inferred.isPresent()) { + version = inferred; + versionHeuristic = true; + WLogger.info("Inferred Warcraft III client family from executable path: " + version.get()); + } + } + + public static Optional inferVersionFromExecutablePath(File exe) { + if (exe == null) { + return Optional.empty(); + } + String normalized = exe.getAbsolutePath().replace('\\', '/').toLowerCase(); + String fileName = exe.getName().toLowerCase(); + if (normalized.contains("/_retail_/") || normalized.contains("/_ptr_/")) { + return Optional.of(GameVersion.VERSION_1_32); + } + if (fileName.equals("war3.exe") || fileName.equals("frozen throne.exe")) { + return Optional.of(new GameVersion("1.28")); + } + if (fileName.equals("warcraft iii.exe")) { + File parent = exe.getParentFile(); + if (parent != null) { + String parentName = parent.getName().toLowerCase(); + if (parentName.equals("x86") || parentName.equals("x86_64")) { + File installDir = parent.getParentFile(); + if (installDir != null && new File(installDir, "_retail_").exists()) { + return Optional.of(GameVersion.VERSION_1_32); + } + return Optional.of(GameVersion.VERSION_1_31); + } + } + return Optional.of(new GameVersion("1.29")); + } + return Optional.empty(); + } + private void discoverExePath() { try { gameExe = Optional.ofNullable(new StdGameExeFinder().get()); @@ -132,22 +210,29 @@ private void showFileChooser() { } }); + if (selectedFolder == null) { + return; + } + loadFromPath(selectedFolder); if (gameExe.isPresent()) { languageServer.getRemoteEndpoint().notify("wurst/updateGamePath", selectedFolder.getAbsolutePath()); } } catch (InterruptedException | InvocationTargetException ex) { - WLogger.warning("Choosing game path failed", ex); + WLogger.warning("Choosing game path failed."); + WLogger.debug("Choosing game path failed: " + ex); } } private void discoverVersion() { try { version = Optional.ofNullable(new StdGameVersionFinder().get()); + versionHeuristic = false; WLogger.info("Parsed game version: " + version); } catch (NotFoundException e) { - WLogger.warning("Wurst compiler failed to determine game version", e); + WLogger.warning("Game version detection failed. Pin wc3Patch in wurst.build if launch arguments look wrong."); + WLogger.debug("Game version detection failed: " + e); } catch (UnsupportedPlatformException e) { WLogger.warning("Wurst compiler cannot determine game version: " + e.getMessage()); } @@ -160,6 +245,10 @@ public Optional getWc3PatchVersion() { return version; } + public boolean isVersionHeuristic() { + return versionHeuristic; + } + /** * @return The wc3 path or empty if none has been found */ diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/MapRequestPatchTargetTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/MapRequestPatchTargetTests.java new file mode 100644 index 000000000..83818f714 --- /dev/null +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/MapRequestPatchTargetTests.java @@ -0,0 +1,186 @@ +package tests.wurstscript.tests; + +import de.peeeq.wurstio.languageserver.ModelManager; +import de.peeeq.wurstio.languageserver.WFile; +import de.peeeq.wurstio.languageserver.requests.MapRequest; +import de.peeeq.wurstio.languageserver.requests.RunMap; +import de.peeeq.wurstio.utils.W3InstallationData; +import net.moonlightflower.wc3libs.port.GameVersion; +import org.testng.annotations.Test; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.PrintStream; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertTrue; + +public class MapRequestPatchTargetTests { + + @Test + public void pinnedBuildUsesWurstBuildPatchWithoutWarcraftExeDiscovery() throws Exception { + Path project = projectWithPatch("v2.0"); + + TestMapRequest request = new TestMapRequest( + project, + Optional.of("Z:\\Definitely\\Not\\Warcraft"), + Optional.empty() + ); + + assertEquals(request.detectedVersion().orElseThrow(), new GameVersion("2.0")); + assertFalse(request.gameExe().isPresent(), "Pinned build requests should not need a Warcraft executable"); + } + + @Test + public void pinnedExplicitGameExeUsesPatchVersionWithoutParsingExecutable() throws Exception { + Path project = projectWithPatch("TFT-v1.31.1.12173"); + Path fakeExe = Files.writeString(Files.createTempFile("not-warcraft", ".exe"), "not a PE file"); + + TestMapRequest request = new TestMapRequest( + project, + Optional.empty(), + Optional.of(fakeExe.toString()) + ); + + assertEquals(request.detectedVersion().orElseThrow(), new GameVersion("1.31")); + assertEquals(request.gameExe().orElseThrow().getAbsoluteFile(), fakeExe.toFile().getAbsoluteFile()); + } + + @Test + public void pinnedRunMapUsesPatchVersionWithoutParsingExecutable() throws Exception { + Path project = projectWithPatch("v1.27b"); + Path fakeExe = Files.writeString(Files.createTempFile("not-warcraft-run", ".exe"), "not a PE file"); + + TestRunMap request = new TestRunMap(project, fakeExe.toString()); + + assertFalse(request.detectedVersion().isPresent()); + assertEquals(request.gameExe().orElseThrow().getAbsoluteFile(), fakeExe.toFile().getAbsoluteFile()); + } + + @Test + public void runMapInfersReforgedClientFromRetailExecutablePath() throws Exception { + Path project = projectWithPatch("v1.27b"); + Path install = Files.createTempDirectory("warcraft-reforged"); + Path exe = Files.createDirectories(install.resolve("_retail_").resolve("x86_64")).resolve("Warcraft III.exe"); + Files.writeString(exe, "not a PE file"); + + TestRunMap request = new TestRunMap(project, exe.toString()); + + assertEquals(request.detectedVersion().orElseThrow(), GameVersion.VERSION_1_32); + } + + @Test + public void runMapInfersClassicClientFromWar3ExecutablePath() throws Exception { + Path project = projectWithPatch("v2.0"); + Path exe = Files.writeString(Files.createTempDirectory("warcraft-classic").resolve("war3.exe"), "not a PE file"); + + TestRunMap request = new TestRunMap(project, exe.toString()); + + assertEquals(request.detectedVersion().orElseThrow(), new GameVersion("1.28")); + } + + @Test + public void legacyRunMapPlacementUsesSelectedLaunchInstall() throws Exception { + Path selectedInstall = Files.createTempDirectory("warcraft-selected-legacy"); + Path exe = Files.writeString(selectedInstall.resolve("war3.exe"), "not a PE file"); + Path staleFallback = Files.createTempDirectory("warcraft-stale-fallback"); + W3InstallationData launchData = new W3InstallationData( + Optional.of(exe.toFile()), + Optional.of(new GameVersion("1.27")) + ); + + Method placementRoot = RunMap.class.getDeclaredMethod( + "mapInstallDirectoryForLegacyLaunch", + W3InstallationData.class, + Optional.class + ); + placementRoot.setAccessible(true); + + @SuppressWarnings("unchecked") + Optional result = (Optional) placementRoot.invoke(null, launchData, Optional.of(staleFallback.toString())); + + assertEquals(result.orElseThrow(), selectedInstall.toFile().getAbsolutePath()); + } + + @Test + public void configuredVersionSurvivesManualPathLoad() throws Exception { + W3InstallationData data = new W3InstallationData(Optional.empty(), Optional.of(new GameVersion("2.0"))); + Method loadFromPath = W3InstallationData.class.getDeclaredMethod("loadFromPath", File.class); + loadFromPath.setAccessible(true); + + loadFromPath.invoke(data, Files.createTempDirectory("not-warcraft-folder").toFile()); + + assertEquals(data.getWc3PatchVersion().orElseThrow(), new GameVersion("2.0")); + } + + @Test + public void gameVersionParseFailurePrintsShortMessageOnly() throws Exception { + Path fakeExe = Files.writeString(Files.createTempFile("bad-warcraft", ".exe"), "not a PE file"); + ByteArrayOutputStream stdout = new ByteArrayOutputStream(); + PrintStream previousOut = System.out; + try { + System.setOut(new PrintStream(stdout, true, StandardCharsets.UTF_8)); + W3InstallationData data = new W3InstallationData(Optional.of(fakeExe.toFile()), Optional.empty()); + + assertFalse(data.getWc3PatchVersion().isPresent()); + } finally { + System.setOut(previousOut); + } + + String output = stdout.toString(StandardCharsets.UTF_8); + assertTrue(output.contains("Could not parse game version from configured executable")); + assertFalse(output.contains("VersionExtractionException"), output); + assertFalse(output.contains("dorkbox.peParser"), output); + assertFalse(output.contains("\tat "), output); + } + + private static Path projectWithPatch(String patch) throws Exception { + Path project = Files.createTempDirectory("wurst-map-request-patch"); + Files.createDirectories(project.resolve("wurst")); + Files.writeString(project.resolve("wurst.build"), """ + projectName: Test + wc3Patch: %s + """.formatted(patch)); + return project; + } + + private static final class TestMapRequest extends MapRequest { + TestMapRequest(Path projectRoot, Optional wc3Path, Optional gameExePath) { + super(null, Optional.empty(), List.of(), WFile.create(projectRoot), wc3Path, gameExePath); + } + + Optional detectedVersion() { + return w3data.getWc3PatchVersion(); + } + + Optional gameExe() { + return w3data.getGameExe(); + } + + @Override + public Object execute(ModelManager modelManager) { + return null; + } + } + + private static final class TestRunMap extends RunMap { + TestRunMap(Path projectRoot, String gameExePath) { + super(null, WFile.create(projectRoot), Optional.empty(), Optional.empty(), List.of(), Optional.of(gameExePath)); + } + + Optional detectedVersion() { + return w3data.getWc3PatchVersion(); + } + + Optional gameExe() { + return w3data.getGameExe(); + } + } +} diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/WurstBuildConfigTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/WurstBuildConfigTests.java index 0c6d0588f..98fc8146f 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/WurstBuildConfigTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/WurstBuildConfigTests.java @@ -1,11 +1,16 @@ package tests.wurstscript.tests; +import config.WurstProjectConfigData; import de.peeeq.wurstio.languageserver.WFile; +import de.peeeq.wurstio.languageserver.ProjectConfigBuilder; import de.peeeq.wurstio.languageserver.WurstBuildConfig; import de.peeeq.wurstio.languageserver.WurstCommands; +import de.peeeq.wurstio.utils.W3InstallationData; import net.moonlightflower.wc3libs.port.GameVersion; import org.testng.annotations.Test; +import java.io.File; +import java.lang.reflect.Method; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; @@ -13,6 +18,7 @@ import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNotEquals; import static org.testng.Assert.assertTrue; public class WurstBuildConfigTests { @@ -53,6 +59,114 @@ public void installDirMapPathIsOnlyForDetected127OrLower() throws Exception { assertFalse(config.shouldUseInstallDirForMaps(Optional.empty())); } + @Test + public void understandsFriendlyAndJassHistoryPatchNames() throws Exception { + Path reforgedProject = Files.createTempDirectory("wurst-build-config-reforged"); + Files.writeString(reforgedProject.resolve("wurst.build"), """ + projectName: Test + wc3Patch: v2.0 + """); + WurstBuildConfig reforged = WurstBuildConfig.fromWorkspaceRoot(WFile.create(reforgedProject.toFile())); + + assertEquals(reforged.wc3Patch().orElseThrow(), WurstBuildConfig.Wc3Patch.REFORGED); + assertEquals(reforged.wc3PatchName().orElseThrow(), "v2.0"); + assertEquals(reforged.configuredGameVersion().orElseThrow(), new GameVersion("2.0")); + assertTrue(reforged.shouldUseReforgedLaunchArgs(Optional.empty())); + + Path classicProject = Files.createTempDirectory("wurst-build-config-classic"); + Files.writeString(classicProject.resolve("wurst.build"), """ + projectName: Test + wc3Patch: TFT-v1.31.1.12173 + """); + WurstBuildConfig classic = WurstBuildConfig.fromWorkspaceRoot(WFile.create(classicProject.toFile())); + + assertEquals(classic.wc3Patch().orElseThrow(), WurstBuildConfig.Wc3Patch.CLASSIC); + assertEquals(classic.configuredGameVersion().orElseThrow(), new GameVersion("1.31")); + assertFalse(classic.shouldUseReforgedLaunchArgs(Optional.empty())); + assertTrue(classic.shouldCopyRunMapToWarcraftMapDir(Optional.empty())); + + Path legacyProject = Files.createTempDirectory("wurst-build-config-legacy"); + Files.writeString(legacyProject.resolve("wurst.build"), """ + projectName: Test + wc3Patch: v1.27b + """); + WurstBuildConfig legacy = WurstBuildConfig.fromWorkspaceRoot(WFile.create(legacyProject.toFile())); + + assertEquals(legacy.wc3Patch().orElseThrow(), WurstBuildConfig.Wc3Patch.PRE_129); + assertEquals(legacy.configuredGameVersion().orElseThrow(), new GameVersion("1.27")); + assertTrue(legacy.shouldUseClassicWindowArg(Optional.empty())); + assertTrue(legacy.shouldUseInstallDirForMaps(Optional.empty())); + } + + @Test + public void classifiesJassHistoryPatchTargetsByVersionBoundary() throws Exception { + assertPatchTarget("v1.26", WurstBuildConfig.Wc3Patch.PRE_129, "1.26"); + assertPatchTarget("TFT-v1.27b-ru", WurstBuildConfig.Wc3Patch.PRE_129, "1.27"); + assertPatchTarget("ROC-v1.28.5.7680", WurstBuildConfig.Wc3Patch.PRE_129, "1.28"); + assertPatchTarget("Beta-TFT-v1.28.0.7205", WurstBuildConfig.Wc3Patch.PRE_129, "1.28"); + assertPatchTarget("v1.29", WurstBuildConfig.Wc3Patch.CLASSIC, "1.29"); + assertPatchTarget("TFT-v1.31.1.12173", WurstBuildConfig.Wc3Patch.CLASSIC, "1.31"); + assertPatchTarget("v1.32", WurstBuildConfig.Wc3Patch.REFORGED, "1.32"); + assertPatchTarget("Reforged-v1.36.1.20719-w3-51d40ee", WurstBuildConfig.Wc3Patch.REFORGED, "1.36"); + assertPatchTarget("Reforged-v2.0.4.23745", WurstBuildConfig.Wc3Patch.REFORGED, "2.0"); + } + + @Test + public void acceptsOlderPatchSpellingAndAliases() throws Exception { + Path numericProject = Files.createTempDirectory("wurst-build-config-numeric"); + Files.writeString(numericProject.resolve("wurst.build"), """ + projectName: Test + wc3Patch: 1.36 + """); + WurstBuildConfig numeric = WurstBuildConfig.fromWorkspaceRoot(WFile.create(numericProject.toFile())); + + assertEquals(numeric.wc3Patch().orElseThrow(), WurstBuildConfig.Wc3Patch.REFORGED); + assertEquals(numeric.configuredGameVersion().orElseThrow(), new GameVersion("1.36")); + + Path classicProject = Files.createTempDirectory("wurst-build-config-classic-alias"); + Files.writeString(classicProject.resolve("wurst.build"), """ + projectName: Test + wc3Patch: classic + """); + WurstBuildConfig classic = WurstBuildConfig.fromWorkspaceRoot(WFile.create(classicProject.toFile())); + + assertEquals(classic.wc3Patch().orElseThrow(), WurstBuildConfig.Wc3Patch.CLASSIC); + assertEquals(classic.configuredGameVersion().orElseThrow(), new GameVersion("1.31")); + } + + @Test + public void ignoresUnknownLegacyBuildSettings() throws Exception { + Path project = Files.createTempDirectory("wurst-build-config-unknown"); + Files.writeString(project.resolve("wurst.build"), """ + projectName: Test + oldSetting: whatever + scriptMode: not-a-mode + wc3Patch: some-old-custom-value + buildMapData: + staleNestedThing: true + """); + + WurstBuildConfig config = WurstBuildConfig.fromWorkspaceRoot(WFile.create(project.toFile())); + + assertFalse(config.scriptMode().isPresent()); + assertFalse(config.wc3Patch().isPresent()); + assertEquals(config.fallbackGameVersion(), GameVersion.VERSION_1_32); + } + + @Test + public void preservesCommentsInsideQuotedPatchValues() throws Exception { + Path project = Files.createTempDirectory("wurst-build-config-quoted-comment"); + Files.writeString(project.resolve("wurst.build"), """ + scriptMode: "lua#not-a-comment" + wc3Patch: "1.36#not-a-comment" + """); + + WurstBuildConfig config = WurstBuildConfig.fromWorkspaceRoot(WFile.create(project.toFile())); + + assertFalse(config.scriptMode().isPresent()); + assertEquals(config.configuredGameVersion().orElseThrow(), new GameVersion("1.36")); + } + @Test public void compileArgsFollowConfiguredScriptMode() throws Exception { Path project = Files.createTempDirectory("wurst-build-config-args"); @@ -67,4 +181,85 @@ public void compileArgsFollowConfiguredScriptMode() throws Exception { assertTrue(args.contains("-runcompiletimefunctions")); assertFalse(args.contains("-lua")); } + + @Test + public void projectConfigHashIncludesExactPatchTarget() throws Exception { + Path project132 = Files.createTempDirectory("wurst-build-config-hash-132"); + Files.createDirectories(project132.resolve("_build")); + Files.writeString(project132.resolve("wurst.build"), """ + projectName: Test + wc3Patch: v1.32 + """); + + Path project20 = Files.createTempDirectory("wurst-build-config-hash-20"); + Files.createDirectories(project20.resolve("_build")); + Files.writeString(project20.resolve("wurst.build"), """ + projectName: Test + wc3Patch: v2.0 + """); + + String hash132 = calculateProjectConfigHash(project132.resolve("_build").toFile()); + String hash20 = calculateProjectConfigHash(project20.resolve("_build").toFile()); + + assertNotEquals(hash132, hash20); + } + + @Test + public void configInjectionPrefersPinnedPatchOverDetectedInstallVersion() throws Exception { + Path project = Files.createTempDirectory("wurst-build-config-inject-version"); + Files.createDirectories(project.resolve("_build")); + Files.writeString(project.resolve("wurst.build"), """ + projectName: Test + wc3Patch: v1.27b + """); + W3InstallationData detectedReforged = new W3InstallationData( + Optional.empty(), + Optional.of(new GameVersion("2.0")) + ); + + assertEquals( + effectiveConfigInjectionVersion(project.resolve("_build").toFile(), detectedReforged), + new GameVersion("1.27") + ); + } + + private static String calculateProjectConfigHash(File buildDir) throws Exception { + Method method = ProjectConfigBuilder.class.getDeclaredMethod( + "calculateProjectConfigHash", + WurstProjectConfigData.class, + File.class + ); + method.setAccessible(true); + return (String) method.invoke(null, new WurstProjectConfigData(), buildDir); + } + + private static GameVersion effectiveConfigInjectionVersion( + File buildDir, + W3InstallationData w3data + ) throws Exception { + Method method = ProjectConfigBuilder.class.getDeclaredMethod( + "effectiveConfigInjectionVersion", + File.class, + W3InstallationData.class + ); + method.setAccessible(true); + return (GameVersion) method.invoke(null, buildDir, w3data); + } + + private static void assertPatchTarget( + String patch, + WurstBuildConfig.Wc3Patch expectedKind, + String expectedGameVersion + ) throws Exception { + Path project = Files.createTempDirectory("wurst-build-config-patch-target"); + Files.writeString(project.resolve("wurst.build"), """ + projectName: Test + wc3Patch: %s + """.formatted(patch)); + + WurstBuildConfig config = WurstBuildConfig.fromWorkspaceRoot(WFile.create(project.toFile())); + + assertEquals(config.wc3Patch().orElseThrow(), expectedKind); + assertEquals(config.configuredGameVersion().orElseThrow(), new GameVersion(expectedGameVersion)); + } }