#!/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; }