# 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:

```text
%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.

```text
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:

```text
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:

```text
<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/*.json` file 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 `category` must be one of `tools`, `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.json` must be a lowercase 64-char SHA-256 hex digest, so plaintext codes can never ship.
- **Apothecary tuning** — fuel choices must be real `is_fuel` materials, and the three dose tiers must have sane `save_chance` values 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.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`:

```text
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](/docs/underroot/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/.bak` on a normal run is the harness's **own** corruption-recovery assertions doing their job, not evidence of a broken write. After isolation these name `smoke_config.json`, which is a useful proof the real file was never opened.
- An intermittent `SMOKE FAIL: profile-scoped write sticks` was 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:

```text
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:

```text
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:

```text
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; sandboxes `SaveManager.CONFIG_PATH`.
- `tools/tmp_compile_check.gd` — force-loads runtime-only scene scripts for full type analysis.

## Related

- [Contributor Workflow](/docs/underroot/contributor-workflow)
- [Save System and Migration](/docs/underroot/save-system-and-migration)
- [DataRegistry and the Data Files](/docs/underroot/dataregistry-and-the-data-files)
- [Data Schemas Reference](/docs/underroot/data-schemas-reference)
- [Idle and Offline Simulation](/docs/underroot/idle-and-offline-simulation)