Verification and CI
Underroot has no unit-test harness. Instead, three headless gates run on every push and pull request, and you can run all three locally. They catch the failure classes GDScript and the Godot engine swallow silently: parse errors that only surface at runtime, JSON data mistakes that render nothing, and save/offline-sim regressions. Runtime feel still needs a visual check in the editor — these gates only prove the code compiles and the invariants hold.
The three gates
CI is defined in .github/workflows/ci.yml (job checks, ubuntu-latest, Godot 4.6.2). It runs, in order: parse-check, data validation, smoke test.
| Gate | Tool | Catches |
|---|---|---|
| Parse-check | tools/parse_check.ps1 |
Syntax, type, and identifier errors across every autoload and what it references. |
| Data validation | tools/validate_data.gd |
JSON strictness (BOMs, parse errors), referential integrity, unknown recipe categories, missing balance keys. |
| Smoke test | tools/smoke_test.gd |
Save round-trip and offline-sim invariants over the real autoload stack. |
The Godot console exe requirement
Local runs need the Godot console executable, not the GUI one. The GUI exe detaches from the terminal and its output is lost, so a parse-check reading that output would always look clean. The official Windows zip unpacks to a folder containing both; tools/parse_check.ps1 defaults to:
%USERPROFILE%\Downloads\Godot_v4.6.2-stable_win64.exe\Godot_v4.6.2-stable_win64_console.exe
Override the location with the GODOT_CONSOLE environment variable. The examples below write <godot console exe> for that path.
Gate 1 — Parse-check
Boots the Godot editor headless, which compiles every autoload (and whatever it references), then greps the boot log for error lines.
powershell -ExecutionPolicy Bypass -File tools\parse_check.ps1
Exit codes: 0 = clean, 1 = script/parse errors found, 2 = Godot exe not found. It scans the log for SCRIPT ERROR and Parse Error lines; any match fails the run and the offending lines are printed. A clean run prints Parse-check clean.
In CI the equivalent step runs the editor directly and greps the same two patterns:
godot --headless --path . --editor --quit-after 40 2>&1 | tee parse.log
grep -qE "SCRIPT ERROR|Parse Error" parse.log && exit 1
Note this compiles autoloads only. Scenes not reached by an autoload are not parsed; verifying those uses the temp-autoload trick (see below).
Gate 2 — Data validation
Run this after any data/*.json edit. tools/validate_data.gd is a SceneTree script that needs no autoloads:
<godot console exe> --headless --path . --script res://tools/validate_data.gd
It prints one VALIDATE: line per failure and ends with either DATA OK (exit 0) or DATA FAIL: N error(s) (exit 1). CI gates on the DATA OK line being present.
What it checks, among others:
- JSON strictness — every
data/*.jsonfile must exist, parse to an object, and not start with a UTF-8 BOM (Godot tolerates a BOM but strict parsers reject it). - Recipe categories — a recipe's
categorymust be one oftools,structures,machines,components,processing,village(VALID_RECIPE_CATEGORIES). Any other value renders nowhere in the UI. - Referential integrity — recipe inputs/outputs, code rewards, building costs, discovery layers, and trade materials must all reference ids that exist in the other data files.
- Codes — every key in
codes.jsonmust be a lowercase 64-char SHA-256 hex digest, so plaintext codes can never ship. - Apothecary tuning — fuel choices must be real
is_fuelmaterials, and the three dose tiers must have sanesave_chancevalues in(0.0, 1.0].
REQUIRED_BALANCE_KEYS
The validator holds REQUIRED_BALANCE_KEYS, a dictionary of every balance.json section and key the game code reads. A missing key means the game silently runs on a hardcoded fallback that can drift from the JSON — the exact bug class this list exists to catch.
When a script starts reading a new
balance.jsonkey, add it toREQUIRED_BALANCE_KEYSintools/validate_data.gd. Otherwise the validator will not notice if that key later goes missing.
Gate 3 — Smoke test
tools/smoke_test.gd exercises the real autoload stack: it creates a save, mutates state across several managers, round-trips it through disk, and asserts the values survived — then runs offline-sim windows and checks invariants (the Maw never retreats backward or breaches during a 10-minute window, the digger never starves while a lodge is hauling food, sawmill fuel banks at its wood target, a too-large village rations rather than killing the digger, breach retreat leaves debris, and so on). It also covers config migration, profile scoping, and save-code round-trips.
It is not a permanent autoload. It must load last, so every manager exists before it runs. The temp-autoload trick appends it to project.godot's [autoload] section, runs the game headless, gates on the SMOKE PASS line, then restores project.godot:
cp project.godot /tmp/project.godot.bak
printf '\n[autoload]\n\nZZSmokeTest="*res://tools/smoke_test.gd"\n' >> project.godot
<godot console exe> --headless --path . # expect SMOKE PASS
cp /tmp/project.godot.bak project.godot
The ZZ prefix keeps it alphabetically last. It uses the first free save slot (of MAX_SLOTS = 12) and deletes it when done; if every slot is occupied it prints SMOKE SKIP: all save slots occupied — refusing to touch real saves and refuses to run. On a machine with real saves present it also prints a SMOKE NOTE and skips the destructive profile tests (delete-with-saves, profile reset), which only run in CI's clean user dir. On assertion failure it prints SMOKE FAIL: N assertion(s) failed and exits 1.
Restore
project.godotwith a file copy, notgit checkout -- project.godot. That file carries uncommitted local tweaks, and checking it out discards them along with the appended autoload line. See Contributor Workflow for the backup rule. Restore it immediately after the run, too — a temp autoload left in place has been baked into a real export before now, and the game quit at boot.
Config isolation — the harness never touches account data
The smoke test writes synthetic configs, creates scratch profiles, and exercises the corruption-recovery paths. It used to do all of that against the real user://config.json, snapshotting and restoring it around the run. That is a lost-update race with a running game: config.json holds profiles, scores, cosmetics, redeemed codes, tip flags and lifetime stats, and writers like mark_nudge_seen() read-modify-write the whole file. A dev running the gate while playing would have their in-game writes silently reverted — it ate one-time tutorial-nudge flags exactly that way, and a card the player had long since dismissed came back every session.
SaveManager.CONFIG_PATH is therefore a static var, not a const — solely so the harness can repoint it. Nothing in the game ever reassigns it. smoke_test.gd switches to user://smoke_config.json before its first assertion and deletes it (plus its .tmp/.bak/.corrupt.json siblings) afterwards. Starting from an empty scratch config also makes a local run match CI, which has always had a clean user dir.
Two traps when working in this area:
WARNING: [SaveManager] profile recovered from …json.tmp/.bakon a normal run is the harness's own corruption-recovery assertions doing their job, not evidence of a broken write. After isolation these namesmoke_config.json, which is a useful proof the real file was never opened.- An intermittent
SMOKE FAIL: profile-scoped write stickswas contention with a running game, not flakiness. Do not dismiss that assertion as a flake.
To prove isolation after touching this code, hash the real config either side of a run:
md5sum "$APPDATA/Godot/app_userdata/Underroot/config.json" # before and after — must match
Verifying scenes, not just autoloads
Parse-check compiles autoloads only, so a scene script reached only at runtime can carry type errors past it. tools/tmp_compile_check.gd force-loads a list of such scripts inside a real headless run, which triggers full GDScript type analysis:
cp project.godot /tmp/project.godot.bak
printf '\n[autoload]\n\nZZCompileCheck="*res://tools/tmp_compile_check.gd"\n' >> project.godot
<godot console exe> --headless --path . # expect COMPILE CHECK DONE ok=true
cp /tmp/project.godot.bak project.godot
Add the script you are working on to its _SCRIPTS list for the run. Both temp harnesses bail unless DisplayServer.get_name() == "headless", so neither can run — or quit the game — if one is ever left registered in a real build.
Running all three
A typical pre-PR local pass on Windows:
powershell -ExecutionPolicy Bypass -File tools\parse_check.ps1
<godot console exe> --headless --path . --script res://tools/validate_data.gd
cp project.godot /tmp/project.godot.bak
printf '\n[autoload]\n\nZZSmokeTest="*res://tools/smoke_test.gd"\n' >> project.godot
<godot console exe> --headless --path .
cp /tmp/project.godot.bak project.godot
Expect Parse-check clean., DATA OK, and SMOKE PASS.
Key files
.github/workflows/ci.yml— the three-gate CI job.tools/parse_check.ps1— headless parse-check; requires the console exe.tools/validate_data.gd— data-file validator;VALID_RECIPE_CATEGORIES,REQUIRED_BALANCE_KEYS.tools/smoke_test.gd— save round-trip + offline-sim invariant test; temp autoload; sandboxesSaveManager.CONFIG_PATH.tools/tmp_compile_check.gd— force-loads runtime-only scene scripts for full type analysis.