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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ This repo builds the Grill CLI and project setup tooling. The generated map-proj
- Local helpers in `config/ProjectConfigModels.kt` should stay thin: typealiases plus immutable copy helpers for shared records.
- `YamlHelper.dumpProjectConfig` intentionally serializes a pruned YAML map instead of the shared records directly. This preserves the old user-facing `wurst.build` behavior by omitting null/default nested fields.
- `wbschema.json` should stay lenient and aligned with the shared config parser, especially for `scriptMode`, `wc3Patch`, and nullable legacy fields.
- Compiler-owned agent references belong in `~/.wurst/wurst-compiler/agent-docs/`. Generated project notes should prefer those version-matched local files when present and retain an online fallback until compiler releases ship them.

## WC3 Patch And Core JASS

Expand Down
18 changes: 9 additions & 9 deletions src/main/kotlin/config/WurstProjectConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ object WurstProjectConfig {
private val schema by lazy { javaClass.classLoader.getResource("wbschema.json") }
private val log = KotlinLogging.logger {}

fun handleCreate(projectRoot: Path, gameRoot: Path?, projectConfig: WurstProjectConfigData) {
fun handleCreate(projectRoot: Path, gameRoot: Path?, projectConfig: WurstProjectConfigData, templateBranch: String = "master") {
try {
createProject(projectRoot, gameRoot, projectConfig)
createProject(projectRoot, gameRoot, projectConfig, templateBranch)
} catch (e: Exception) {
if (DependencyManager.debug) {
e.printStackTrace()
Expand Down Expand Up @@ -60,7 +60,7 @@ object WurstProjectConfig {
}

@Throws(Exception::class)
private fun createProject(projectRoot: Path, gameRoot: Path?, projectConfig: WurstProjectConfigData) {
private fun createProject(projectRoot: Path, gameRoot: Path?, projectConfig: WurstProjectConfigData, templateBranch: String) {
Log.print("Creating project root..")
if (Files.exists(projectRoot) && Files.list(projectRoot).filter { !Files.isDirectory(it) }.findAny().isPresent) {
log.error("Project root already exists and contains files")
Expand All @@ -71,21 +71,21 @@ object WurstProjectConfig {

Log.print("Download template..")
log.info("⏬ Downloading template..")
Download.downloadBareboneProject {
extractDownload(it, projectRoot, gameRoot, projectConfig)
Download.downloadBareboneProject(templateBranch) {
extractDownload(it, projectRoot, gameRoot, projectConfig, templateBranch)
}
}
}

private fun extractDownload(it: Path, projectRoot: Path, gameRoot: Path?, projectConfig: WurstProjectConfigData) {
private fun extractDownload(it: Path, projectRoot: Path, gameRoot: Path?, projectConfig: WurstProjectConfigData, templateBranch: String) {
Log.println(" done.")

Log.print("Extracting template..")
val extractSuccess = ZipArchiveExtractor.extractArchive(it, projectRoot)
Files.delete(it)
if (extractSuccess) {
Log.print("done\n")
cleanupDownload(projectRoot)
cleanupDownload(projectRoot, templateBranch)
normalizeGeneratedTemplate(projectRoot)
} else {
Log.print("error\n")
Expand All @@ -97,9 +97,9 @@ object WurstProjectConfig {
log.info("✔ Project generated.")
}

private fun cleanupDownload(projectRoot: Path) {
private fun cleanupDownload(projectRoot: Path, templateBranch: String) {
Log.print("Clean up..")
val folder = projectRoot.resolve("wurst-project-template-master")
val folder = projectRoot.resolve("wurst-project-template-$templateBranch")
copyFolder(folder, projectRoot)
Files.walk(folder).sorted { a, b -> b.compareTo(a) }.forEach { p ->
try {
Expand Down
12 changes: 12 additions & 0 deletions src/main/kotlin/file/CLICommand.kt
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,18 @@ enum class GlobalOptions(val optionName: String = "", val argCount: Int = 0) {
setupMain.wc3Patch = CoreJassProvider.normalizePatchInput(args[0])
}
},
MAP_FORMAT("--map-format", 1) {
override fun runOption(setupMain: SetupMain, args: List<String>) {
val format = MapFormat.parse(args[0])
if (format == null) {
log.error("❌ Unknown map format: ${args[0]}. Use archive or folder.")
ExitHandler.exit(1)
} else {
setupMain.mapFormat = format
setupMain.mapFormatExplicit = true
}
}
},
WC3_PATH("--wc3-path", 1) {
override fun runOption(setupMain: SetupMain, args: List<String>) {
setupMain.gamePath = java.nio.file.Paths.get(args[0])
Expand Down
7 changes: 7 additions & 0 deletions src/main/kotlin/file/CoreJassProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,13 @@ object CoreJassProvider {
.orElse(false)
}

fun isReforgedPatch(input: String?): Boolean {
val patch = normalizePatchInput(input)
return Wc3PatchTarget.parse(patch)
.map { it.kind() == Wc3PatchTarget.Kind.REFORGED }
.orElse(false)
}

fun ensureFiles(projectRoot: Path, wc3Patch: String?): List<Path> {
val buildFolder = projectRoot.resolve("_build")
Files.createDirectories(buildFolder)
Expand Down
8 changes: 4 additions & 4 deletions src/main/kotlin/file/Download.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ object Download {
private val log = KotlinLogging.logger {}

private const val compilerReleaseBaseUrl = "https://github.com/wurstscript/WurstScript/releases/download/nightly/"
private const val bareboneUrl = "github.com/wurstscript/wurst-project-template/archive/master.zip"
private const val bareboneBaseUrl = "github.com/wurstscript/wurst-project-template/archive"

@Throws(IOException::class)
fun downloadSetup(callback: (Path) -> Unit) {
Expand All @@ -28,13 +28,13 @@ object Download {
}

@Throws(IOException::class)
fun downloadBareboneProject(callback: (Path) -> Unit) {
fun downloadBareboneProject(templateBranch: String = "master", callback: (Path) -> Unit) {
try {
downloadDirect("https://$bareboneUrl", callback)
downloadDirect("https://${bareboneBaseUrl}/${templateBranch}.zip", callback)
} catch (e: Exception) {
log.warn("downloadBareboneProject Exception caught", e)
Log.println("Https error, falling back to unsafe http.")
downloadDirect("http://$bareboneUrl", callback)
downloadDirect("http://${bareboneBaseUrl}/${templateBranch}.zip", callback)
}
}

Expand Down
18 changes: 18 additions & 0 deletions src/main/kotlin/file/MapFormat.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package file

/** The storage format of the starter map included in a generated project. */
enum class MapFormat(
val cliName: String,
val templateBranch: String,
val label: String,
) {
ARCHIVE("archive", "master", "map archive (.w3x file)"),
FOLDER("folder", "map-folder", "map folder (.w3x directory)"),
;

companion object {
fun parse(value: String): MapFormat? = values().firstOrNull {
it.cliName == value.trim().lowercase() || it.name.lowercase() == value.trim().lowercase()
}
}
}
47 changes: 42 additions & 5 deletions src/main/kotlin/file/SetupApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ object SetupApp {

private data class WurstProcessResult(val exitCode: Int, val output: List<String>)

internal const val AGENTS_TEMPLATE_VERSION = "2026-08-05"
internal const val AGENTS_TEMPLATE_VERSION = "2026-08-08"
Comment thread
Frotty marked this conversation as resolved.
private const val AGENTS_TEMPLATE_MARKER_PREFIX = "<!-- WURST_AGENTS_TEMPLATE_VERSION:"
private const val AGENTS_TEMPLATE_MARKER = "<!-- WURST_AGENTS_TEMPLATE_VERSION: $AGENTS_TEMPLATE_VERSION -->"
private const val AGENTS_TEMPLATE_SOURCE_HINT = "WurstScript Warcraft III map project notes"
Expand Down Expand Up @@ -149,7 +149,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
| build <mapfile> Build the project using the given input map
| build <mapfile|map-folder> Build the project using the given map archive or folder
| exportobjects <mapfile|folder> Export object editor data to Wurst source
|
|Global options:
Expand All @@ -162,6 +162,7 @@ object SetupApp {
|Generate options:
| --script-mode lua|jass Script mode (default: lua)
| --wc3-patch <patch> WC3 patch target: reforged, pre1.29, or jass-history version
| --map-format archive|folder Starter map storage (default: folder for Reforged)
| --wc3-path <dir> Warcraft III install folder for VS Code/run
| --with-agents / --no-agents Include AGENTS.md (default: no)
| --with-ci / --no-ci Include GitHub Actions workflow (default: no)
Expand Down Expand Up @@ -220,12 +221,12 @@ object SetupApp {
wc3Patch = setup.wc3Patch
)
val gameRoot = resolveGenerateGamePath(setup, projectConfig.wc3Patch)
WurstProjectConfig.handleCreate(projectDir, gameRoot, projectConfig)
WurstProjectConfig.handleCreate(projectDir, gameRoot, projectConfig, setup.mapFormat.templateBranch)
ensureCoreJassFiles(projectDir, projectConfig.wc3Patch)
if (Files.exists(projectDir)) {
if (setup.addAgents) downloadAgentsMd(projectDir)
if (setup.addGithubWorkflow) writeCiWorkflow(projectDir)
printGenerateNextSteps(projectDir, projectConfig, setup.addAgents, setup.addGithubWorkflow, gameRoot)
printGenerateNextSteps(projectDir, projectConfig, setup.mapFormat, setup.addAgents, setup.addGithubWorkflow, gameRoot)
}
}
setup.command == CLICommand.TEST -> {
Expand Down Expand Up @@ -315,7 +316,7 @@ object SetupApp {

private fun missingMap(requestedMap: String? = null): Nothing {
if (requestedMap == null) {
log.error("❌ No input map specified and no .w3x/.w3m file was found in the project root.")
log.error("❌ No input map specified and no .w3x/.w3m archive or map folder was found in the project root.")
log.info("Try: put a map in the project root, or run `grill build YourMap.w3x`.")
} else {
log.error("❌ Map not found: $requestedMap")
Expand Down Expand Up @@ -343,6 +344,7 @@ object SetupApp {
private fun printGenerateNextSteps(
projectDir: Path,
projectConfig: WurstProjectConfigData,
mapFormat: MapFormat,
addAgents: Boolean,
addGithubWorkflow: Boolean,
gameRoot: Path?
Expand All @@ -355,6 +357,7 @@ object SetupApp {
|Choices:
| Script mode: ${(projectConfig.scriptMode ?: ScriptMode.LUA).name.lowercase()}
| WC3 patch: ${CoreJassProvider.describePatch(projectConfig.wc3Patch ?: CoreJassProvider.DEFAULT_PATCH)}
| Map storage: ${if (mapFormat == MapFormat.FOLDER) "folder (.w3x directory)" else "archive (.w3x file)"}
| Warcraft III: ${gameRoot?.toAbsolutePath()?.normalize() ?: "not configured"}
| Stdlib: ${if (projectConfig.dependencies.any { it.endsWith(":pre1.29") }) "pre1.29" else "current"}
| Curated dependencies: $curatedSummary
Expand All @@ -366,6 +369,7 @@ object SetupApp {
""".trimMargin())
}


private fun resolveGenerateGamePath(setup: SetupMain, wc3Patch: String?): Path? {
if (setup.gamePathOptedOut) {
log.info("Warcraft III path: not configured by choice.")
Expand Down Expand Up @@ -595,6 +599,10 @@ object SetupApp {
useInteractiveMenus = useInteractiveMenus,
currentPatch = setup.wc3Patch
)
if (!setup.mapFormatExplicit) {
setup.mapFormat = recommendedMapFormat(setup.wc3Patch)
}
setup.mapFormat = selectMapFormat(prompt, setup.mapFormat, useInteractiveMenus)
setup.gamePathOptedOut = false
setup.gamePath = selectGamePath(setup, prompt, setup.wc3Patch, setup.gamePath)

Expand All @@ -608,6 +616,24 @@ object SetupApp {

setup.curatedDependencyIds = selectCuratedDependencies(prompt, setup.curatedDependencyIds).toMutableList()
}
internal fun recommendedMapFormat(wc3Patch: String?): MapFormat =
if (CoreJassProvider.isReforgedPatch(wc3Patch)) MapFormat.FOLDER else MapFormat.ARCHIVE

private fun selectMapFormat(
prompt: (String, String?) -> String?,
current: MapFormat,
useInteractiveMenus: Boolean,
): MapFormat {
val choices = listOf(
TerminalMenu.Choice(MapFormat.FOLDER, "${MapFormat.FOLDER.label} (recommended for Reforged)"),
TerminalMenu.Choice(MapFormat.ARCHIVE, "${MapFormat.ARCHIVE.label} (recommended for classic/legacy)"),
)
if (useInteractiveMenus) {
TerminalMenu.choose("Map storage format:", choices, choices.indexOfFirst { it.value == current })?.let { return it }
}
val answer = prompt("Map storage (archive/folder)", current.cliName)?.lowercase()
return MapFormat.parse(answer.orEmpty()) ?: current
}

private fun selectCuratedDependencies(
prompt: (String, String?) -> String?,
Expand Down Expand Up @@ -936,6 +962,17 @@ object SetupApp {
if (markerLine == AGENTS_TEMPLATE_MARKER) {
return null
}
if (markerLine != null) {
val markerVersion = markerLine
.removePrefix(AGENTS_TEMPLATE_MARKER_PREFIX)
.removeSuffix("-->")
.trim()
// Template versions are ISO dates, so lexical ordering is chronological. A newer
// downloaded template is valid even when this older Grill binary cannot recognize it.
if (markerVersion > AGENTS_TEMPLATE_VERSION) {
return null
}
}
if (markerLine != null) {
return "AGENTS.md was generated from an older WurstSetup template ($markerLine). Consider refreshing it from templates/AGENTS.md and re-applying project-local notes."
}
Expand Down
4 changes: 3 additions & 1 deletion src/main/kotlin/file/SetupMain.kt
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,13 @@ class SetupMain {

var debug = false

// Generate wizard options (defaults: non-interactive, Lua, Reforged, no extras)
// Generate wizard options (defaults: non-interactive, Lua, Reforged, Reforged folder, no extras)
var addAgents: Boolean = false
var addGithubWorkflow: Boolean = false
var scriptMode: ScriptMode = ScriptMode.LUA
var wc3Patch: String = CoreJassProvider.DEFAULT_PATCH
var mapFormat: MapFormat = MapFormat.FOLDER
var mapFormatExplicit: Boolean = false

/** Ids of curated dependencies (see [CuratedDependencies]) to seed into the generated project. */
var curatedDependencyIds: MutableList<String> = mutableListOf()
Expand Down
34 changes: 33 additions & 1 deletion src/main/kotlin/global/InstallationManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import net.NetStatus
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.nio.file.StandardCopyOption
import java.util.jar.JarFile
import java.util.regex.Pattern


Expand All @@ -19,6 +21,7 @@ object InstallationManager {
private val log = KotlinLogging.logger {}
private const val FOLDER_PATH = ".wurst"
private const val COMPILER_FILE_NAME = "wurstscript.jar"
private const val LANGUAGE_AGENT_DOC_ENTRY = "agent-docs/WURST_LANGUAGE.md"
private const val GRILL_JAR_NAME = "grill.jar"
private const val LEGACY_GRILL_JAR_NAME = "WurstSetup.jar"

Expand Down Expand Up @@ -49,6 +52,7 @@ object InstallationManager {
log.info("verifyInstallation: detectedCompilerJar=$detectedCompilerJar exists=${detectedCompilerJar?.let { Files.exists(it) }}")
if (detectedCompilerJar != null) {
log.info("Found installation at $detectedCompilerJar")
ensureCompilerAgentDocs(detectedCompilerJar)
status = InstallationStatus.INSTALLED_UNKNOWN
try {
if (!Files.isWritable(detectedCompilerJar)) {
Expand Down Expand Up @@ -94,9 +98,11 @@ object InstallationManager {
log.info("\t📦 Extracting..")
ZipArchiveExtractor.extractArchive(it, installDir)
Files.delete(it)
if (detectCompilerJar() == null) {
val compilerJar = detectCompilerJar()
if (compilerJar == null) {
log.error("❌ Compiler not found after extraction.")
} else {
ensureCompilerAgentDocs(compilerJar)
if (isFreshInstall) { wurstConfig = WurstConfigData() }
ensureGrillJarInstalled()
setLaunchersExecutable()
Expand Down Expand Up @@ -193,6 +199,32 @@ object InstallationManager {
}
}

private fun ensureCompilerAgentDocs(compilerJar: Path) {
try {
JarFile(compilerJar.toFile()).use { jar ->
val docsDir = compilerDir.resolve("agent-docs")
val docsFile = docsDir.resolve("WURST_LANGUAGE.md")
val entry = jar.getJarEntry(LANGUAGE_AGENT_DOC_ENTRY)
if (entry == null) {
// A compiler downgrade may remove the resource. Do not leave docs from the old compiler
// in place, or generated projects could use language guidance for the wrong version.
Files.deleteIfExists(docsFile)
return
}
Files.createDirectories(docsDir)
jar.getInputStream(entry).use { input ->
Files.copy(
input,
docsFile,
StandardCopyOption.REPLACE_EXISTING
)
}
}
} catch (e: Exception) {
log.warn("Could not extract compiler agent docs: ${e.message}")
}
}

private fun resolveOwnJar(): Path? {
return try {
val url = InstallationManager::class.java.protectionDomain.codeSource.location
Expand Down
37 changes: 37 additions & 0 deletions src/test/kotlin/AgentsTemplateTests.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import file.SetupApp
import org.testng.Assert
import org.testng.annotations.Test
import java.nio.file.Files
import java.nio.file.Paths

class AgentsTemplateTests {
private val templatePath = Paths.get("templates", "AGENTS.md")

@Test
fun testTemplateStaysTokenLean() {
val content = Files.readString(templatePath)
val wordCount = Regex("""\S+""").findAll(content).count()

Assert.assertTrue(wordCount <= 900, "AGENTS template grew to $wordCount words (limit: 900)")
Assert.assertTrue(content.length <= 7000, "AGENTS template grew to ${content.length} characters (limit: 7000)")
}

@Test
fun testLanguageDocsPreferCompilerMatchedLocalReference() {
val content = Files.readString(templatePath)
val localReference = "~/.wurst/wurst-compiler/agent-docs/WURST_LANGUAGE.md"
val onlineFallback = "https://wurstlang.org/manual.html"
val localIndex = content.indexOf(localReference)
val onlineIndex = content.indexOf(onlineFallback)

Assert.assertTrue(localIndex >= 0, "Missing compiler-matched local language reference")
Assert.assertTrue(onlineIndex > localIndex, "Online manual must remain a fallback after the local reference")
}

@Test
fun testNewerTemplateDoesNotLookStaleToOlderGrill() {
val newerMarked = "<!-- WURST_AGENTS_TEMPLATE_VERSION: 2099-01-01 -->\n# AGENTS.md\n"

Assert.assertNull(SetupApp.agentsTemplateWarning(newerMarked))
}
}
Loading
Loading