|
|
|
|
@@ -1,286 +1,80 @@
|
|
|
|
|
#!/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)
|
|
|
|
|
/**
|
|
|
|
|
* The Beacon — Enhanced Smoke Test
|
|
|
|
|
*
|
|
|
|
|
* Validates:
|
|
|
|
|
* 1. All JS files parse without syntax errors
|
|
|
|
|
* 2. HTML references valid script sources
|
|
|
|
|
* 3. Game data structures are well-formed
|
|
|
|
|
* 4. No banned provider references
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import fs from 'node:fs';
|
|
|
|
|
import vm from 'node:vm';
|
|
|
|
|
import path from 'node:path';
|
|
|
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
|
import { readFileSync, existsSync } from "fs";
|
|
|
|
|
import { execSync } from "child_process";
|
|
|
|
|
import { join } from "path";
|
|
|
|
|
|
|
|
|
|
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 ----------
|
|
|
|
|
const ROOT = process.cwd();
|
|
|
|
|
let failures = 0;
|
|
|
|
|
let passes = 0;
|
|
|
|
|
function assert(cond, msg) {
|
|
|
|
|
if (cond) {
|
|
|
|
|
passes++;
|
|
|
|
|
console.log(` ok ${msg}`);
|
|
|
|
|
} else {
|
|
|
|
|
failures++;
|
|
|
|
|
console.error(` FAIL ${msg}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function check(label, fn) {
|
|
|
|
|
try {
|
|
|
|
|
fn();
|
|
|
|
|
console.log(` ✔ ${label}`);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error(` ✘ ${label}: ${e.message}`);
|
|
|
|
|
failures++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
function section(name) { console.log(`\n${name}`); }
|
|
|
|
|
|
|
|
|
|
const { G, CONFIG, BDEF, PDEFS, EVENTS } = exported;
|
|
|
|
|
console.log("--- The Beacon Smoke Test ---\n");
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
// 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)');
|
|
|
|
|
// 1. All JS files parse
|
|
|
|
|
console.log("[Syntax]");
|
|
|
|
|
const jsFiles = execSync("find . -name '*.js' -not -path './node_modules/*'", { encoding: "utf8" })
|
|
|
|
|
.trim().split("\n").filter(Boolean);
|
|
|
|
|
|
|
|
|
|
// 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 */ }
|
|
|
|
|
for (const f of jsFiles) {
|
|
|
|
|
check(`Parse ${f}`, () => {
|
|
|
|
|
execSync(`node --check ${f}`, { encoding: "utf8" });
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
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;
|
|
|
|
|
// 2. HTML script references exist
|
|
|
|
|
console.log("\n[HTML References]");
|
|
|
|
|
if (existsSync(join(ROOT, "index.html"))) {
|
|
|
|
|
const html = readFileSync(join(ROOT, "index.html"), "utf8");
|
|
|
|
|
const scriptRefs = [...html.matchAll(/src=["']([^"']+\.js)["']/g)].map(m => m[1]);
|
|
|
|
|
for (const ref of scriptRefs) {
|
|
|
|
|
check(`Script ref: ${ref}`, () => {
|
|
|
|
|
if (!existsSync(join(ROOT, ref))) throw new Error("File not found");
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 3. Game data structure check
|
|
|
|
|
console.log("\n[Game Data]");
|
|
|
|
|
check("js/data.js exists", () => {
|
|
|
|
|
if (!existsSync(join(ROOT, "js/data.js"))) throw new Error("Missing");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
check("game.js exists", () => {
|
|
|
|
|
if (!existsSync(join(ROOT, "game.js"))) throw new Error("Missing");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 4. No banned providers
|
|
|
|
|
console.log("\n[Policy]");
|
|
|
|
|
check("No Anthropic references", () => {
|
|
|
|
|
try {
|
|
|
|
|
const result = execSync(
|
|
|
|
|
"grep -ril 'anthropic\\|claude-sonnet\\|claude-opus\\|sk-ant-' --include='*.js' --include='*.json' --include='*.html' . 2>/dev/null || true",
|
|
|
|
|
{ encoding: "utf8" }
|
|
|
|
|
).trim();
|
|
|
|
|
if (result) throw new Error(`Found in: ${result}`);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
if (e.message.startsWith("Found")) throw e;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Summary
|
|
|
|
|
console.log(`\n--- ${failures === 0 ? "ALL PASSED" : `${failures} FAILURE(S)`} ---`);
|
|
|
|
|
process.exit(failures > 0 ? 1 : 0);
|
|
|
|
|
|