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:

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.json key, add it to REQUIRED_BALANCE_KEYS in tools/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.godot with a file copy, not git 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:

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