Compare commits
4 Commits
perplexity
...
burn/fix-b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b819fc068a | ||
| bfc30c535e | |||
| 76c3f06232 | |||
| 33788a54a5 |
16
js/engine.js
16
js/engine.js
@@ -77,13 +77,15 @@ function updateRates() {
|
||||
G.userRate += 5 * timmyCount * (timmyMult - 1);
|
||||
}
|
||||
|
||||
// Bilbo randomness: 10% chance of massive creative burst
|
||||
if (G.buildings.bilbo > 0 && Math.random() < CONFIG.BILBO_BURST_CHANCE) {
|
||||
G.creativityRate += 50 * G.buildings.bilbo;
|
||||
}
|
||||
// Bilbo vanishing: 5% chance of zero creativity this tick
|
||||
if (G.buildings.bilbo > 0 && Math.random() < CONFIG.BILBO_VANISH_CHANCE) {
|
||||
G.creativityRate = 0;
|
||||
// Bilbo randomness: flags are set per-tick in tick(), not here
|
||||
// updateRates() is called from many non-tick contexts (buy, resolve, sprint)
|
||||
if (G.buildings.bilbo > 0) {
|
||||
if (G.bilboBurstActive) {
|
||||
G.creativityRate += 50 * G.buildings.bilbo;
|
||||
}
|
||||
if (G.bilboVanishActive) {
|
||||
G.creativityRate = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Allegro requires trust
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user