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
120 changes: 103 additions & 17 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-06-10"
internal const val AGENTS_TEMPLATE_VERSION = "2026-06-22"
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 @@ -90,12 +90,16 @@ object SetupApp {
}

private fun fail(message: String) {
log.error(message)
if (setup.quiet) {
System.err.println(message)
} else {
log.error(message)
}
}

private fun detail(message: String) {
if (setup.quiet) {
println(message)
System.err.println(message)
} else {
log.info(message)
}
Expand Down Expand Up @@ -366,13 +370,15 @@ object SetupApp {
if (printPjassFailure(result.output)) {
return
}
fail("❌ Wurst $commandName failed.")
detail("Exit code: ${result.exitCode}")
if (setup.quiet) {
detail("Next: rerun without `--quiet` only for the failed file/test.")
} else {
detail("Try: rerun with `--quiet` for a shorter error log, or `--debug` for troubleshooting details.")
val diagnostics = quietCompilerFailureOutput(result.output, setup.debug)
diagnostics.forEach { System.err.println(it) }
fail("❌ Wurst $commandName failed. (Errors: ${quietCompilerErrorCount(result.output, diagnostics)})")
return
}
fail("❌ Wurst $commandName failed.")
detail("Exit code: ${result.exitCode}")
detail("Try: rerun with `--quiet` for a shorter error log, or `--debug` for troubleshooting details.")
}

private fun printPjassFailure(output: List<String>): Boolean {
Expand Down Expand Up @@ -402,14 +408,99 @@ object SetupApp {
return true
}

private fun isImportantCompilerLine(line: String): Boolean {
return line.contains("error", ignoreCase = true) ||
line.contains("warning", ignoreCase = true) ||
line.contains("FAILED", ignoreCase = true) ||
internal fun quietCompilerFailureOutput(output: List<String>, debug: Boolean): List<String> {
return if (debug) output else quietCompilerDiagnostics(output)
}

internal fun quietCompilerDiagnostics(output: List<String>): List<String> {
val diagnostics = ArrayList<String>()
var pendingVerboseError: MatchResult? = null
var preservingTestFailureDetails = false

for (rawLine in output) {
val line = rawLine.trimEnd()
if (line.isBlank() || isNoisyCompilerVersionLine(line) || isQuietCompilerNoiseLine(line)) {
continue
}

val verboseError = Regex("""^Error in File (.+):(\d+):\s*$""").find(line.trim())
if (verboseError != null) {
pendingVerboseError = verboseError
continue
}

if (pendingVerboseError != null) {
diagnostics.add(
"Error ${pendingVerboseError.groupValues[1]}:${pendingVerboseError.groupValues[2]}: ${line.trim()}"
)
pendingVerboseError = null
continue
}

if (preservingTestFailureDetails && isQuietTestFailureDetailLine(line)) {
diagnostics.add(line)
continue
}

if (isQuietCompilerDiagnosticLine(line)) {
diagnostics.add(line)
if (isQuietTestFailureHeader(line)) {
preservingTestFailureDetails = true
}
}
}

return diagnostics.distinct()
}

internal fun quietCompilerErrorCount(
output: List<String>,
diagnostics: List<String> = quietCompilerDiagnostics(output)
): Int {
output.asSequence()
.map { Regex("""^Errors:\s*(\d+)\s*$""").find(it.trim()) }
.filterNotNull()
.firstOrNull()
?.let { return it.groupValues[1].toIntOrNull() ?: diagnostics.size.coerceAtLeast(1) }

return diagnostics.count {
it.startsWith("Error ", ignoreCase = true) ||
it.startsWith("FAILED ", ignoreCase = true) ||
it.contains(" exception", ignoreCase = true) ||
it.contains("Pjass", ignoreCase = true)
}.coerceAtLeast(1)
}

private fun isQuietCompilerDiagnosticLine(line: String): Boolean {
return line.startsWith("Error ", ignoreCase = true) ||
line.startsWith("FAILED ", ignoreCase = true) ||
Comment thread
Frotty marked this conversation as resolved.
line.contains(" assertion", ignoreCase = true) ||
line.contains("Exception", ignoreCase = true) ||
line.contains("Pjass", ignoreCase = true)
}

private fun isQuietTestFailureHeader(line: String): Boolean {
return line.trim().equals("FAILED assertion:", ignoreCase = true)
}

private fun isQuietTestFailureDetailLine(line: String): Boolean {
val trimmed = line.trim()
return trimmed.startsWith("Test failed:", ignoreCase = true) ||
trimmed.contains(" inside call ", ignoreCase = true) ||
trimmed.contains(" when calling ", ignoreCase = true)
}

private fun isQuietCompilerNoiseLine(line: String): Boolean {
val trimmed = line.trim()
return trimmed.startsWith("Warning", ignoreCase = true) ||
trimmed.matches(Regex("""^Errors:\s*\d+\s*$""")) ||
trimmed.matches(Regex("""^Warnings:\s*\d+\s*$""")) ||
trimmed.matches(Regex("""^Tests:\s*\d+/\d+\s+passed\s*$""", RegexOption.IGNORE_CASE)) ||
trimmed.startsWith("compilation finished", ignoreCase = true) ||
trimmed.startsWith("Running tests", ignoreCase = true) ||
trimmed.startsWith("Finished running tests", ignoreCase = true)
}

private fun isNoisyCompilerVersionLine(line: String): Boolean {
val trimmed = line.trim()
return trimmed == "Warning: Ignoring unknown wc3Patch in wurst.build: ${CoreJassProvider.DEFAULT_PATCH}" ||
Expand Down Expand Up @@ -960,11 +1051,6 @@ object SetupApp {
}
}
val exitCode = p.waitFor()
if (setup.quiet && exitCode != 0) {
val printableOutput = if (setup.debug) output else output.filterNot(::isNoisyCompilerVersionLine)
val linesToPrint = if (compactFallback) printableOutput.filter(::isImportantCompilerLine) else printableOutput
linesToPrint.forEach { println(it) }
}
return WurstProcessResult(exitCode, output)
}

Expand Down
88 changes: 88 additions & 0 deletions src/test/kotlin/GenerateTests.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import file.SetupMain
import org.testng.Assert
import org.testng.annotations.Test
import java.nio.file.Files
import java.nio.file.Paths
import java.util.Comparator

private class ExitException2(val code: Int) : RuntimeException("exit $code")
Expand Down Expand Up @@ -251,6 +252,12 @@ class GenerateTests {
}
@Test(priority = 10)
fun testAgentsTemplateMarkerAndWarnings() {
val templateFirstLine = Files.readAllLines(Paths.get("templates", "AGENTS.md")).first()
Assert.assertEquals(
templateFirstLine,
"<!-- WURST_AGENTS_TEMPLATE_VERSION: ${SetupApp.AGENTS_TEMPLATE_VERSION} -->"
)

val marked = SetupApp.withAgentsTemplateMarker("# AGENTS.md\n")
Assert.assertTrue(marked.startsWith("<!-- WURST_AGENTS_TEMPLATE_VERSION: ${SetupApp.AGENTS_TEMPLATE_VERSION} -->"))
Assert.assertNull(SetupApp.agentsTemplateWarning(marked))
Expand Down Expand Up @@ -278,6 +285,87 @@ class GenerateTests {
Assert.assertTrue(setup.quiet)
}

@Test(priority = 10)
fun testQuietCompilerDiagnosticsSuppressGeneratedJassNoise() {
val output = listOf(
"Warnings: 3",
"Warning: Error: e:Could not find variable silverGladeCounter.",
"Warning: Error: e:Could not find a function with name eg",
"Error Broken.wurst:12: Could not find variable realUserTypo.",
"compilation finished (errors: 1, warnings: 3)",
"Errors: 1"
)

Assert.assertEquals(
SetupApp.quietCompilerDiagnostics(output),
listOf("Error Broken.wurst:12: Could not find variable realUserTypo.")
)
Assert.assertEquals(SetupApp.quietCompilerErrorCount(output), 1)
}

@Test(priority = 10)
fun testQuietCompilerDiagnosticsKeepFailedTestDetails() {
val output = listOf(
"Running tests",
"Tests: 1/2 passed",
"FAILED MyPkg.testExplodes",
"\tFAILED assertion:",
"\tTest failed: expected 1 but got 2",
"\t ╚ MyTest.wurst:9 inside call assertEquals(1, 2)",
"\t... when calling MyPkg.testExplodes(MyTest.wurst:12)",
"Errors: 1",
"Error MyTest.wurst:9: expected 1 but got 2",
"Finished running tests"
)

Assert.assertEquals(
SetupApp.quietCompilerDiagnostics(output),
listOf(
"FAILED MyPkg.testExplodes",
"\tFAILED assertion:",
"\tTest failed: expected 1 but got 2",
"\t ╚ MyTest.wurst:9 inside call assertEquals(1, 2)",
"\t... when calling MyPkg.testExplodes(MyTest.wurst:12)",
"Error MyTest.wurst:9: expected 1 but got 2"
)
)
Assert.assertEquals(SetupApp.quietCompilerErrorCount(output), 1)
}

@Test(priority = 10)
fun testQuietCompilerDiagnosticsNormalizeVerboseFallbackErrors() {
val output = listOf(
"Error in File Broken.wurst:12:",
" Could not find variable realUserTypo.",
"Warning in File war3map.j:44:",
" Error: e:Could not find variable silverGladeCounter."
)

Assert.assertEquals(
SetupApp.quietCompilerDiagnostics(output),
listOf("Error Broken.wurst:12: Could not find variable realUserTypo.")
)
}

@Test(priority = 10)
fun testQuietDebugCompilerFailureOutputBypassesFilter() {
val output = listOf(
"Error Broken.wurst:12: Could not find variable realUserTypo.",
"java.lang.IllegalStateException: extra debug context",
"\tat de.peeeq.wurstio.Main.main(Main.java:1)",
"Warning: Error: e:Could not find variable generatedNoise."
)

Assert.assertEquals(SetupApp.quietCompilerFailureOutput(output, debug = true), output)
Assert.assertEquals(
SetupApp.quietCompilerFailureOutput(output, debug = false),
listOf(
"Error Broken.wurst:12: Could not find variable realUserTypo.",
"java.lang.IllegalStateException: extra debug context"
)
)
}

@Test(priority = 10)
fun testDevBuildFlag() {
val setup = SetupMain()
Expand Down
46 changes: 44 additions & 2 deletions templates/AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<!-- WURST_AGENTS_TEMPLATE_VERSION: 2026-06-10 -->
<!-- WURST_AGENTS_TEMPLATE_VERSION: 2026-06-22 -->
# AGENTS.md - WurstScript Map Project Notes

WurstScript Warcraft III map project notes for editing `.wurst` code, dependencies, generated objects, tests, or map build logic.
Expand All @@ -8,11 +8,42 @@ WurstScript Warcraft III map project notes for editing `.wurst` code, dependenci
- Prefer simple, maintainable code. Fix root causes; avoid brittle workarounds, duplicated branches, and special-case patches.
- Keep packages focused and below ~500 lines; split by feature, responsibility, or data type.
- Make changes in the source package, not generated output. Do not edit `_build/` or `_build/dependencies/` as source-of-truth.
- Prefer Wurst standard-library wrappers and project helpers over raw `common.j`/Jass-style calls.
- Use Wurst stdlib/library APIs and project helpers; never call a raw `common.j`/Jass native when a wrapper exists, and do not reinvent what stdlib already provides. See **Stdlib-First** below.
- When unsure about Wurst syntax or local APIs, inspect nearby working code before guessing.
- Keep tests narrow. Add/update tests for behavior, parsing, compiletime generation, or shared utilities.
- Avoid broad refactors unless they directly reduce risk or complexity for the requested change.

## Stdlib-First: No Raw JASS Natives (Mandatory)

The most important coding rule: high-level Wurst packages must use the WurstScript stdlib and library APIs, never ported JASS. The goal is clean, reusable Wurst — not a JASS transliteration.

- Never call a raw `common.j` / `Blizzard.j` native when a Wurst wrapper or extension function exists. There is one for almost every native (on `unit`, `player`, `group`, `string`, `rect`, ...). Grep the stdlib (`_build/dependencies/wurstStdlib2/wurst/`) before writing a native call.
- The only bar for a raw native is that you searched and confirmed no wrapper exists — then add a one-line comment saying so.
- "It compiles" is not enough. Code that reads like JASS (manual handle juggling, native calls, global trigger callbacks, op-limit chunking) is wrong here; rewrite it idiomatically.

Use the stdlib API, not a raw native, for at least:

- Timers → `ClosureTimers` (`doAfter`, `doPeriodically`); never `CreateTimer`/`TimerStart`/`PauseTimer`/`DestroyTimer`.
- Printing → `print` / `printTimed` / `p.print`; never `DisplayText*ToPlayer`/`...ToForce`.
- Player state → `Player` extensions (`p.addGold`, `p.getGold`, `p.getId`, ...); prefer the `players[i]` array over `Player(i)`.
- Unit inspection → `Unit` extensions (`u.getTypeId()`, `u.getOwner()`, `u.getAbilityLevel(id)`, ...).
- Hashtables → `Hashtable` extensions (`ht.saveInt`/`loadInt`/`flushChild`/...).
- Group iteration → `ClosureForGroups` (`forUnitsInRange`, `forUnitsInRect`) + `GroupUtils` (`getGroup()` / `group.release()`), not `GroupEnum*` + `ForGroup` globals.

The `CreateTrigger()..register...()..addAction() ->` cascade is the accepted idiom and is fine.

### Do Not Reinvent Stdlib Infrastructure (Mandatory)

Keep custom engine-level infrastructure to a minimum. Stdlib packages are battle-tested and handle the WC3 edge cases (recycling, op-limits, cleanup, desync) that hand-rolled versions get wrong. Grep for an existing system before building one; do not ship a parallel implementation of something stdlib provides:

- Dummy spell casting → `DummyCaster` / `InstantDummyCaster` (unit pooling: `DummyRecycler`).
- Triggered damage → `DummyDamage` to deal, `DamageEvent` to detect/modify.
- Events → `ClosureEvents` (`EventListener.add(...)`) / `RegisterEvents`; no custom global-trigger dispatcher or event bus.
- Knockback / FX / sound / interpolation / orders → `Knockback3`, `Fx`, `SoundUtils`/`Sounds`, `Interpolation`, `Orders`/`OrderStringFactory`.
- Collections → `LinkedList`, `HashMap`, `HashList`.

If stdlib almost fits, prefer a thin wrapper around the stdlib type over a from-scratch system and note why in a comment. Reinventing this is treated as a defect even if it compiles and passes tests, because it reintroduces solved bugs.

## Agent Workflow

Install dependencies:
Expand Down Expand Up @@ -43,6 +74,8 @@ For build changes:
grill build ExampleMap.w3x --quiet
```

Builds default to production mode, so compiletime `isProductionBuild()` returns `true`. Use `grill build ExampleMap.w3x --dev --quiet` only when validating run/development-mode behavior where `isProductionBuild()` must be `false`; `typecheck` and `test` do not need this flag.

Done means relevant errors/warnings are fixed or explicitly explained.

## Project Configuration
Expand Down Expand Up @@ -142,6 +175,15 @@ let label = count == 1 ? "unit" : "units"

These are recurring real-world Wurst/Warcraft III failure modes. Treat this section as a pre-edit checklist for any non-trivial Wurst change.

### Integer overflow

WC3 `int` is 32-bit signed and wraps silently at ~2.1 billion (`2^31 - 1`) — no exception, just a negative/garbage value that poisons every downstream comparison and division. Easy to hit when multiplying or summing large game quantities (gold/worth, army totals, damage products, accumulated stats).

- Promote to `real` BEFORE multiplying two large quantities: `a.toReal() * b`, never `(a * b).toReal()` (the latter already overflowed).
- Same for running sums of products: `total += worth.toReal() * count * mult`.
- Watch `worth * worth`, `count * worth`, `total * total` in scoring/stats; aggregate worths routinely exceed ~46k (the square root of int-max), so their product overflows in ordinary large games.
- Wurst `/` is real division even for two ints (use `div` for integer division), so division itself does not overflow — but its operands still can. Prefer `real` for any accumulator that fans in many large terms.

### Closure capture is by value

Wurst closures capture locals by value. If a closure assigns to a local from an outer scope, the outer local is not updated.
Expand Down
Loading