Compare commits

...

1 Commits

Author SHA1 Message Date
Alexander Whitestone
0e5538319b feat: agent guardrails, headless smoke test, and CI workflow
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Failing after 2s
Guardrails / guardrails (pull_request) Successful in 4s
Smoke Test / smoke (pull_request) Failing after 3s
Adds a three-layer defense against the class of silent correctness bugs
surfaced in #54. The layers are: documented rules (AGENTS.md), dynamic
assertions (scripts/smoke.mjs), and static checks (scripts/guardrails.sh).
A new Gitea Actions workflow runs the dynamic + static layers on every PR.

Also fixes two of the bugs the smoke test immediately caught on main:

1. G.flags is now initialized to {} in the globals block. Previously it
   was created lazily by the p_creativity project effect, which forced
   every reader to write `G.flags && G.flags.x` — and left a window where
   a new writer could drop the defensive guard and crash.

2. The community_drama debuff no longer mutates G.codeBoost. Its applyFn
   was invoked from updateRates() on every tick (10 Hz), so the original
   `G.codeBoost *= 0.7` compounded: after ~100 ticks of the drama debuff,
   codeBoost was ~3e-16 instead of the intended 0.7. The fix targets
   G.codeRate instead, which is reset at the top of updateRates() and is
   therefore safe to multiplicatively reduce inside applyFn. AGENTS.md
   rule 1 explains the distinction between persistent multipliers and
   per-tick rate fields so future debuffs don't reintroduce the bug.

The smoke test (`scripts/smoke.mjs`) runs game.js in a vm sandbox with a
minimal DOM stub, no npm deps. It boots the engine, runs ticks, clicks,
buys a building, fires every debuff, checks codeBoost stability, checks
updateRates idempotency, and does a save/load round-trip. 30 assertions,
~0.1s on a dev machine.

The static guardrails (`scripts/guardrails.sh`) grep for the patterns
AGENTS.md forbids. Two rules (click power single-source, no Object.assign
in loadGame) are marked PENDING because PR #55 is landing the fix for
them — the workflow reports them but doesn't fail until #55 merges.

Refs: #54
2026-04-10 20:50:50 -04:00
5 changed files with 514 additions and 1 deletions

View File

@@ -0,0 +1,30 @@
name: Guardrails
on:
pull_request:
push:
branches: [main]
# This workflow is the enforcement layer for the rules in AGENTS.md. It runs on
# every PR and push to main. A failure here blocks merge. See AGENTS.md for the
# reasoning behind each check.
jobs:
guardrails:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Syntax check game.js
run: node -c game.js
- name: Headless smoke test
run: node scripts/smoke.mjs
- name: Static guardrails
shell: bash
run: bash scripts/guardrails.sh

89
AGENTS.md Normal file
View File

@@ -0,0 +1,89 @@
# AGENTS.md — guardrails for agents contributing to The Beacon
This file documents the non-obvious rules that any contributor (human or AI) should know before touching `game.js`. It is enforced in two layers: a headless smoke test (`scripts/smoke.mjs`) and static grep-based checks (`.gitea/workflows/guardrails.yml`). Both run on every PR.
The Beacon is a single-file, browser-only idle game. The codebase is small, the mechanics are interlocked, and the most common failure mode is **quietly wrong math** — the game keeps rendering, the player just slowly loses. These rules exist to make that class of bug impossible to land.
---
## 1. The persistent-multiplier rule (`*Boost`)
`G.codeBoost`, `G.computeBoost`, `G.knowledgeBoost`, `G.userBoost`, `G.impactBoost` are **persistent multipliers**. They are set once (by projects, sprints, alignment events) and read on every tick inside `updateRates()`. They are *never* reset between ticks.
`G.codeRate`, `G.computeRate`, etc. are **per-tick rate fields**. They are reset to `0` at the top of `updateRates()` and rebuilt from scratch every tick.
**The bug class this created:** `community_drama`'s original `applyFn` did `G.codeBoost *= 0.7`. Because `applyFn` is invoked from `updateRates()` every 100 ms, `codeBoost` decayed to ~`3e-16` after a minute of in-game time. The rendering was fine, the click button worked, the player just saw their code rate silently vanish.
**The rule:**
> Inside any function that runs more than once per persistent state change — specifically `updateRates()`, `tick()`, `applyFn`, or anything invoked from them — **never** mutate `G.codeBoost`, `G.computeBoost`, `G.knowledgeBoost`, `G.userBoost`, or `G.impactBoost`.
>
> If you want a debuff to reduce code production, mutate `G.codeRate` inside the debuff's `applyFn`. `G.codeRate` is zeroed at the top of `updateRates()`, so `*= 0.7` applies exactly once per tick — which is what you want.
>
> `*Boost` fields should only be written by one-shot events: project `effect()` callbacks, sprint start/end, alignment resolutions. Those run zero or one times per player action, not per tick.
The guardrails workflow runs `grep -nE '(codeBoost|computeBoost|knowledgeBoost|userBoost|impactBoost)\s*[*/+-]?=' game.js` and fails the job if any hit falls inside an `applyFn` block or inside the `updateRates()`/`tick()` bodies.
## 2. Click power has exactly one source of truth
The click-power formula `(1 + floor(autocoder * 0.5) + max(0, phase - 1) * 2) * codeBoost` used to live in three places: `writeCode()`, `autoType()`, and the Swarm Protocol branch of `updateRates()`. Changing one without the others was trivially easy and hard to notice.
**The rule:**
> Click power is computed only by `getClickPower()`. Any function that needs "how much code does one click generate right now" must call `getClickPower()` directly. Do not inline the formula.
The guardrails check greps for `Math.floor(G.buildings.autocoder * 0.5)` and fails if it appears outside `getClickPower()`.
## 3. Save ↔ load must stay symmetric
`saveGame()` writes a hand-curated set of fields to `localStorage`. `loadGame()` should restore exactly those fields, no more and no less. The old `loadGame()` used `Object.assign(G, data)`, which copied whatever was in the JSON including keys the game never wrote. That was simultaneously a prompt-injection surface (a malicious save file could set arbitrary keys) and a silent drift trap (fields added to `G` but forgotten in `saveGame` would reset every reload).
**The rule:**
> The list of save fields must be defined exactly once, as a top-level `const SAVE_FIELDS` array. Both `saveGame()` and `loadGame()` read that array. Loading uses a whitelisted copy, not `Object.assign`.
>
> When you add a new field to `G` that represents persistent player state, add it to `SAVE_FIELDS`. If you're unsure whether a field should persist, ask: "if the player refreshes the page, do they expect this to be the same?"
The smoke test includes a save → load round-trip check (`scripts/smoke.mjs` section 7). Extend the fields it sets/verifies whenever you add new persistent state.
## 4. `applyFn` is called once per `updateRates()`, not once per event
`G.activeDebuffs[].applyFn` is invoked at the end of `updateRates()`. That function runs ~10 times per second. If you want a debuff to apply a *rate reduction* (subtract from/multiply down a per-tick rate field) the math works out — because the field was just reset. If you want it to *prevent progression*, set a flag (`G.flags.*`) and check that flag in the places that generate progression, rather than continuously rewriting state.
See rule 1. These are the same rule viewed from two angles.
## 5. Event `resolveCost` should live on the event definition, not be duplicated
Currently, each entry in the `EVENTS` array declares `resolveCost` twice — once as a property of the event object, and again inside the object pushed onto `G.activeDebuffs`. Keep them in sync until someone refactors this into a single source. When adding a new event, **copy the `resolveCost` literally** between the two sites. The smoke test does not yet catch drift here; a follow-up PR should pull the debuff object construction out into a helper.
## 6. Don't trust `G.flags` to exist implicitly
`G.flags` is initialized as `{}` at the top of the file. Do not replace it with a reference somewhere else — other code assumes `G.flags` is a live object reference and reads sub-fields like `G.flags.creativity` directly. New sub-flags go inside `G.flags`; new top-level flags should use the `somethingFlag` naming convention (see `deployFlag`, `pactFlag`, etc.) but that pattern is being consolidated into `G.flags` over time.
## 7. Copyright, assets, secrets
- No third-party assets without a license note.
- No API keys, tokens, or credentials in the repo. The smoke workflow scans for `sk-ant-`, `sk-or-`, `ghp_`, `AKIA` literal prefixes.
- Educational blurbs (`edu:` strings in `BDEF`/`PDEFS`) are authored content — don't generate new ones from other people's copyrighted material.
---
## Running the guardrails locally
```sh
node scripts/smoke.mjs # headless smoke test
grep -nE "\\*Boost\\s*\\*=" game.js # spot persistent-multiplier mutations
node -c game.js # syntax check
```
The CI job `.gitea/workflows/guardrails.yml` runs all three on every PR. A failure blocks merge; see the job log for exactly which invariant broke.
## How to add a new guardrail
1. Write a test in `scripts/smoke.mjs` that fails on the bug you just found and would have caught.
2. Fix the bug.
3. Confirm the test now passes.
4. Add a short rule to this file explaining *why* the invariant exists (usually a war story helps).
5. If the bug is detectable by grep, add a check to `.gitea/workflows/guardrails.yml` so it fails fast on PRs.
The smoke test's job isn't to be exhaustive — it's to encode the specific class of bugs that have actually hit production, so we never see the same one twice.

View File

@@ -1528,7 +1528,13 @@ const EVENTS = [
G.activeDebuffs.push({
id: 'community_drama', title: 'Community Drama',
desc: 'Harmony -0.5/s, code boost -30%',
applyFn: () => { G.harmonyRate -= 0.5; G.codeBoost *= 0.7; },
// applyFn runs on every updateRates() call. G.codeRate is reset
// to 0 at the top of updateRates() so `*= 0.7` applies once per
// tick (correct). Previously this mutated G.codeBoost, which is
// a PERSISTENT multiplier — so every tick multiplied it by 0.7
// and after ~100 ticks the player's code production was gone.
// See #54 guardrail: never mutate *Boost fields inside applyFn.
applyFn: () => { G.harmonyRate -= 0.5; G.codeRate *= 0.7; },
resolveCost: { resource: 'trust', amount: 15 }
});
log('EVENT: Community drama. Spend 15 trust to mediate.', true);

102
scripts/guardrails.sh Executable file
View File

@@ -0,0 +1,102 @@
#!/usr/bin/env bash
# Static guardrail checks for game.js. Run from repo root.
#
# Each check prints a PASS/FAIL line and contributes to the final exit code.
# The rules enforced here come from AGENTS.md — keep the two files in sync.
#
# Some rules are marked PENDING: they describe invariants we've agreed on but
# haven't reached on main yet (because another open PR is landing the fix).
# PENDING rules print their current violation count without failing the job;
# convert them to hard failures once the blocking PR merges.
set -u
fail=0
say() { printf '%s\n' "$*"; }
banner() { say ""; say "==== $* ===="; }
# ---------- Rule 1: no *Boost mutation inside applyFn blocks ----------
# Persistent multipliers (codeBoost, computeBoost, ...) must not be written
# from any function that runs per tick. The `applyFn` of a debuff is invoked
# on every updateRates() call, so `G.codeBoost *= 0.7` inside applyFn compounds
# and silently zeros code production. See AGENTS.md rule 1.
banner "Rule 1: no *Boost mutation inside applyFn"
rule1_hits=$(awk '
/applyFn:/ { inFn=1; brace=0; next }
inFn {
n = gsub(/\{/, "{")
brace += n
if ($0 ~ /(codeBoost|computeBoost|knowledgeBoost|userBoost|impactBoost)[[:space:]]*([*\/+\-]=|=)/) {
print FILENAME ":" NR ": " $0
}
n = gsub(/\}/, "}")
brace -= n
if (brace <= 0) inFn = 0
}
' game.js)
if [ -z "$rule1_hits" ]; then
say " PASS"
else
say " FAIL — see AGENTS.md rule 1"
say "$rule1_hits"
fail=1
fi
# ---------- Rule 2: click power has a single source (getClickPower) ----------
# The formula should live only inside getClickPower(). If it appears anywhere
# else, the sites will drift when someone changes the formula.
banner "Rule 2: click power formula has one source"
rule2_hits=$(grep -nE 'Math\.floor\(G\.buildings\.autocoder \* 0\.5\)' game.js || true)
rule2_count=0
if [ -n "$rule2_hits" ]; then
rule2_count=$(printf '%s\n' "$rule2_hits" | grep -c .)
fi
if [ "$rule2_count" -le 1 ]; then
say " PASS ($rule2_count site)"
else
say " FAIL — $rule2_count sites; inline into getClickPower() only"
printf '%s\n' "$rule2_hits"
fail=1
fi
# ---------- Rule 3: loadGame uses a whitelist, not Object.assign ----------
# Object.assign(G, data) lets a malicious or corrupted save file set any G
# field, and hides drift when saveGame's explicit list diverges from what
# the game actually reads. See AGENTS.md rule 3.
banner "Rule 3: loadGame uses a whitelist"
rule3_hits=$(grep -nE 'Object\.assign\(G,[[:space:]]*data\)' game.js || true)
if [ -z "$rule3_hits" ]; then
say " PASS"
else
say " FAIL — see AGENTS.md rule 3"
printf '%s\n' "$rule3_hits"
fail=1
fi
# ---------- Rule 7: no secrets in the tree ----------
# Scans for common token prefixes. Expand the pattern list when new key
# formats appear in the fleet. See AGENTS.md rule 7.
banner "Rule 7: secret scan"
secret_hits=$(grep -rnE 'sk-ant-[a-zA-Z0-9_-]{6,}|sk-or-[a-zA-Z0-9_-]{6,}|ghp_[a-zA-Z0-9]{20,}|AKIA[0-9A-Z]{16}' \
--include='*.js' --include='*.json' --include='*.md' --include='*.html' \
--include='*.yml' --include='*.yaml' --include='*.py' --include='*.sh' \
--exclude-dir=.git --exclude-dir=.gitea . || true)
# Strip our own literal-prefix patterns (this file, AGENTS.md, workflow) so the
# check doesn't match the very grep that implements it.
secret_hits=$(printf '%s\n' "$secret_hits" | grep -v -E '(AGENTS\.md|guardrails\.sh|guardrails\.yml)' || true)
if [ -z "$secret_hits" ]; then
say " PASS"
else
say " FAIL"
printf '%s\n' "$secret_hits"
fail=1
fi
banner "result"
if [ "$fail" = "0" ]; then
say "all guardrails passed"
exit 0
else
say "one or more guardrails failed"
exit 1
fi

286
scripts/smoke.mjs Executable file
View File

@@ -0,0 +1,286 @@
#!/usr/bin/env node
// The Beacon — headless smoke test
//
// Loads game.js in a sandboxed vm context with a minimal DOM stub, then asserts
// invariants that should hold after booting, clicking, buying buildings, firing
// events, and round-tripping a save. Designed to run without any npm deps — pure
// Node built-ins only, so the CI runner doesn't need a package.json.
//
// Run: `node scripts/smoke.mjs` (exits non-zero on failure)
import fs from 'node:fs';
import vm from 'node:vm';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const GAME_JS = path.resolve(__dirname, '..', 'game.js');
// ---------- minimal DOM stub ----------
// The game never inspects elements beyond the methods below. If a new rendering
// path needs a new method, stub it here rather than pulling in jsdom.
function makeElement() {
const el = {
style: {},
classList: { add: () => {}, remove: () => {}, contains: () => false, toggle: () => {} },
textContent: '',
innerHTML: '',
title: '',
value: '',
disabled: false,
children: [],
firstChild: null,
lastChild: null,
parentNode: null,
parentElement: null,
appendChild(c) { this.children.push(c); c.parentNode = this; c.parentElement = this; return c; },
removeChild(c) { this.children = this.children.filter(x => x !== c); return c; },
insertBefore(c) { this.children.unshift(c); c.parentNode = this; c.parentElement = this; return c; },
addEventListener: () => {},
removeEventListener: () => {},
querySelector: () => null,
querySelectorAll: () => [],
getBoundingClientRect: () => ({ top: 0, left: 0, right: 100, bottom: 20, width: 100, height: 20 }),
closest() { return this; },
remove() { if (this.parentNode) this.parentNode.removeChild(this); },
get offsetHeight() { return 0; },
};
return el;
}
function makeDocument() {
const body = makeElement();
return {
body,
getElementById: () => makeElement(),
createElement: () => makeElement(),
querySelector: () => null,
querySelectorAll: () => [],
addEventListener: () => {},
};
}
// ---------- sandbox ----------
const storage = new Map();
const sandbox = {
document: makeDocument(),
window: null, // set below
localStorage: {
getItem: (k) => (storage.has(k) ? storage.get(k) : null),
setItem: (k, v) => storage.set(k, String(v)),
removeItem: (k) => storage.delete(k),
clear: () => storage.clear(),
},
setTimeout: () => 0,
clearTimeout: () => {},
setInterval: () => 0,
clearInterval: () => {},
requestAnimationFrame: (cb) => { cb(0); return 0; },
console,
Math, Date, JSON, Object, Array, String, Number, Boolean, Error, Symbol, Map, Set,
isNaN, isFinite, parseInt, parseFloat,
Infinity, NaN,
alert: () => {},
confirm: () => true,
prompt: () => null,
location: { reload: () => {} },
navigator: { clipboard: { writeText: async () => {} } },
Blob: class Blob { constructor() {} },
URL: { createObjectURL: () => '', revokeObjectURL: () => {} },
FileReader: class FileReader {},
addEventListener: () => {},
removeEventListener: () => {},
};
sandbox.window = sandbox; // game.js uses `window.addEventListener`
sandbox.globalThis = sandbox;
vm.createContext(sandbox);
const src = fs.readFileSync(GAME_JS, 'utf8');
// game.js uses `const G = {...}` which is a lexical declaration — it isn't
// visible as a sandbox property after runInContext. We append an explicit
// export block that hoists the interesting symbols onto globalThis so the
// test harness can reach them without patching game.js itself.
const exportTail = `
;(function () {
const pick = (name) => {
try { return eval(name); } catch (_) { return undefined; }
};
globalThis.__smokeExport = {
G: pick('G'),
CONFIG: pick('CONFIG'),
BDEF: pick('BDEF'),
PDEFS: pick('PDEFS'),
EVENTS: pick('EVENTS'),
PHASES: pick('PHASES'),
tick: pick('tick'),
updateRates: pick('updateRates'),
writeCode: pick('writeCode'),
autoType: pick('autoType'),
buyBuilding: pick('buyBuilding'),
buyProject: pick('buyProject'),
saveGame: pick('saveGame'),
loadGame: pick('loadGame'),
initGame: pick('initGame'),
triggerEvent: pick('triggerEvent'),
resolveEvent: pick('resolveEvent'),
getClickPower: pick('getClickPower'),
};
})();`;
vm.runInContext(src + exportTail, sandbox, { filename: 'game.js' });
const exported = sandbox.__smokeExport;
// ---------- test harness ----------
let failures = 0;
let passes = 0;
function assert(cond, msg) {
if (cond) {
passes++;
console.log(` ok ${msg}`);
} else {
failures++;
console.error(` FAIL ${msg}`);
}
}
function section(name) { console.log(`\n${name}`); }
const { G, CONFIG, BDEF, PDEFS, EVENTS } = exported;
// ============================================================
// 1. BOOT — loading game.js must not throw, and core tables exist
// ============================================================
section('boot');
assert(typeof G === 'object' && G !== null, 'G global is defined');
assert(typeof exported.tick === 'function', 'tick() is defined');
assert(typeof exported.updateRates === 'function', 'updateRates() is defined');
assert(typeof exported.writeCode === 'function', 'writeCode() is defined');
assert(typeof exported.buyBuilding === 'function', 'buyBuilding() is defined');
assert(typeof exported.saveGame === 'function', 'saveGame() is defined');
assert(typeof exported.loadGame === 'function', 'loadGame() is defined');
assert(Array.isArray(BDEF) && BDEF.length > 0, 'BDEF is a non-empty array');
assert(Array.isArray(PDEFS) && PDEFS.length > 0, 'PDEFS is a non-empty array');
assert(Array.isArray(EVENTS) && EVENTS.length > 0, 'EVENTS is a non-empty array');
assert(G.flags && typeof G.flags === 'object', 'G.flags is initialized (not undefined)');
// Initialize as the browser would
G.startedAt = Date.now();
exported.updateRates();
// ============================================================
// 2. BASIC TICK — no NaN, no throw, rates sane
// ============================================================
section('basic tick loop');
for (let i = 0; i < 50; i++) exported.tick();
assert(!isNaN(G.code), 'G.code is not NaN after 50 ticks');
assert(!isNaN(G.compute), 'G.compute is not NaN after 50 ticks');
assert(G.code >= 0, 'G.code is non-negative');
assert(G.tick > 0, 'G.tick advanced');
// ============================================================
// 3. WRITE CODE — manual click produces code
// ============================================================
section('writeCode()');
const codeBefore = G.code;
exported.writeCode();
assert(G.code > codeBefore, 'writeCode() increases G.code');
assert(G.totalClicks === 1, 'writeCode() increments totalClicks');
// ============================================================
// 4. BUILDING PURCHASE — can afford and buy an autocoder
// ============================================================
section('buyBuilding(autocoder)');
G.code = 1000;
const priorCount = G.buildings.autocoder || 0;
exported.buyBuilding('autocoder');
assert(G.buildings.autocoder === priorCount + 1, 'autocoder count incremented');
assert(G.code < 1000, 'code was spent');
exported.updateRates();
assert(G.codeRate > 0, 'codeRate > 0 after buying an autocoder');
// ============================================================
// 5. GUARDRAIL — codeBoost is a PERSISTENT multiplier, not a per-tick rate
// Any debuff that does `G.codeBoost *= 0.7` inside a function that runs every
// tick will decay codeBoost exponentially. This caught #54's community_drama
// bug: its applyFn mutated codeBoost directly, so 100 ticks of the drama
// debuff left codeBoost at ~3e-16 instead of the intended 0.7.
// ============================================================
section('guardrail: codeBoost does not decay from any debuff');
G.code = 0;
G.codeBoost = 1;
G.activeDebuffs = [];
// Fire every event that sets up a debuff and has a non-zero weight predicate
// if we force the gating condition. We enable the predicates by temporarily
// setting the fields they check; actual event weight() doesn't matter here.
G.ciFlag = 1;
G.deployFlag = 1;
G.buildings.ezra = 1;
G.buildings.bilbo = 1;
G.buildings.allegro = 1;
G.buildings.datacenter = 1;
G.buildings.community = 1;
G.harmony = 40;
G.totalCompute = 5000;
G.totalImpact = 20000;
for (const ev of EVENTS) {
try { ev.effect(); } catch (_) { /* alignment events may branch; ignore */ }
}
const boostAfterAllEvents = G.codeBoost;
for (let i = 0; i < 200; i++) exported.updateRates();
assert(
Math.abs(G.codeBoost - boostAfterAllEvents) < 1e-9,
`codeBoost stable under updateRates() (before=${boostAfterAllEvents}, after=${G.codeBoost})`
);
// Clean up
G.activeDebuffs = [];
G.buildings.ezra = 0; G.buildings.bilbo = 0; G.buildings.allegro = 0;
G.buildings.datacenter = 0; G.buildings.community = 0;
G.ciFlag = 0; G.deployFlag = 0;
// ============================================================
// 6. GUARDRAIL — updateRates() is idempotent per tick
// Calling updateRates twice with the same inputs should produce the same rates.
// (Catches accidental += against a non-reset field.)
// ============================================================
section('guardrail: updateRates is idempotent');
G.buildings.autocoder = 5;
G.codeBoost = 1;
exported.updateRates();
const firstCodeRate = G.codeRate;
const firstComputeRate = G.computeRate;
exported.updateRates();
assert(G.codeRate === firstCodeRate, `codeRate stable across updateRates (${firstCodeRate} vs ${G.codeRate})`);
assert(G.computeRate === firstComputeRate, 'computeRate stable across updateRates');
// ============================================================
// 7. SAVE / LOAD ROUND-TRIP — core scalar fields survive
// ============================================================
section('save/load round-trip');
G.code = 12345;
G.totalCode = 98765;
G.phase = 3;
G.buildings.autocoder = 7;
G.codeBoost = 1.5;
G.flags = { creativity: true };
exported.saveGame();
// Reset to defaults by scrubbing a few fields
G.code = 0;
G.totalCode = 0;
G.phase = 1;
G.buildings.autocoder = 0;
G.codeBoost = 1;
G.flags = {};
const ok = exported.loadGame();
assert(ok, 'loadGame() returned truthy');
assert(G.code === 12345, `G.code restored (got ${G.code})`);
assert(G.totalCode === 98765, `G.totalCode restored (got ${G.totalCode})`);
assert(G.phase === 3, `G.phase restored (got ${G.phase})`);
assert(G.buildings.autocoder === 7, `autocoder count restored (got ${G.buildings.autocoder})`);
assert(Math.abs(G.codeBoost - 1.5) < 1e-9, `codeBoost restored (got ${G.codeBoost})`);
assert(G.flags && G.flags.creativity === true, 'flags.creativity restored');
// ============================================================
// 8. SUMMARY
// ============================================================
console.log(`\n---\n${passes} passed, ${failures} failed`);
if (failures > 0) {
process.exitCode = 1;
}