Compare commits

..

1 Commits

Author SHA1 Message Date
Alexander Whitestone
5164d6506d feat: Implement Creative-to-Ops Conversion (Creativity Engineering)
Implements issue #20: Creative-to-Ops Conversion system.

New features:
- Creativity Engineering project: unlocks conversion between Creativity and Ops
- Creative Catalyst project: boosts conversion efficiency by 50%
- Inspiration Engine project: automated creativity synthesis from fleet harmony
- Creative -> Ops button: converts creativity into ops + knowledge
- Ops -> Creative button: converts ops back into creativity (bidirectional)
- Harmony bonus: high harmony (>60) improves conversion yields
- New creativityBoost multiplier tracked in save/load
- Education fact about Creativity Engineering

The system creates a meaningful resource loop: creativity (generated by Bilbo
and idle ops) can now be channeled into operational infrastructure, while ops
can feed back into creativity. High harmony amplifies the conversion,
rewarding players who maintain fleet health.
2026-04-09 21:04:09 -04:00
8 changed files with 1267 additions and 2081 deletions

View File

@@ -1,27 +0,0 @@
name: Accessibility Checks
on:
pull_request:
branches: [main]
jobs:
a11y-audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate ARIA Attributes in game.js
run: |
echo "Checking game.js for ARIA attributes..."
grep -q "aria-label" game.js || (echo "ERROR: aria-label missing from game.js" && exit 1)
grep -q "aria-valuenow" game.js || (echo "ERROR: aria-valuenow missing from game.js" && exit 1)
grep -q "aria-pressed" game.js || (echo "ERROR: aria-pressed missing from game.js" && exit 1)
- name: Validate ARIA Roles in index.html
run: |
echo "Checking index.html for ARIA roles..."
grep -q "role=" index.html || (echo "ERROR: No ARIA roles found in index.html" && exit 1)
- name: Syntax Check JS
run: |
node -c game.js

View File

@@ -1,30 +0,0 @@
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

View File

@@ -1,24 +0,0 @@
name: Smoke Test
on:
pull_request:
push:
branches: [main]
jobs:
smoke:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Parse check
run: |
find . -name '*.yml' -o -name '*.yaml' | grep -v .gitea | xargs -r python3 -c "import sys,yaml; [yaml.safe_load(open(f)) for f in sys.argv[1:]]"
find . -name '*.json' | xargs -r python3 -m json.tool > /dev/null
find . -name '*.py' | xargs -r python3 -m py_compile
find . -name '*.sh' | xargs -r bash -n
echo "PASS: All files parse"
- name: Secret scan
run: |
if grep -rE 'sk-or-|sk-ant-|ghp_|AKIA' . --include='*.yml' --include='*.py' --include='*.sh' 2>/dev/null | grep -v .gitea; then exit 1; fi
echo "PASS: No secrets"

View File

@@ -1,89 +0,0 @@
# 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.

1594
game.js

File diff suppressed because it is too large Load Diff

1196
index.html

File diff suppressed because it is too large Load Diff

View File

@@ -1,102 +0,0 @@
#!/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

View File

@@ -1,286 +0,0 @@
#!/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;
}