Compare commits
3 Commits
fix/192-re
...
sprint/iss
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4b1bbdce4 | ||
| d5645fea58 | |||
|
|
db08f9a478 |
16
GENOME.md
16
GENOME.md
@@ -8,24 +8,32 @@ The Beacon is a browser-based idle/incremental game inspired by Universal Paperc
|
||||
|
||||
Static HTML/JS — no build step, no dependencies, no framework. Open `index.html` in any browser.
|
||||
|
||||
**5,128 lines of JavaScript** across 10 files. **1 HTML file** with embedded CSS (~300 lines). **1 Python test file** for reckoning projects.
|
||||
**6,033 lines of JavaScript** across 11 files. **1 HTML file** with embedded CSS (~300 lines). **3 test files** (2 Node.js, 1 Python).
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
index.html (UI + embedded CSS)
|
||||
index.html (UI + embedded CSS + inline JS ~5000L)
|
||||
|
|
||||
+-- js/engine.js (1590L) Core game loop, tick, resources, buildings, projects, events
|
||||
+-- js/data.js (944L) Building definitions, project trees, event tables, phase data
|
||||
+-- js/render.js (390L) DOM rendering, UI updates, resource displays
|
||||
+-- js/combat.js (359L) Boss encounters, combat mechanics
|
||||
+-- js/combat.js (359L) Canvas boid-flocking combat visualization
|
||||
+-- js/sound.js (401L) Web Audio API ambient drone, phase-aware sound
|
||||
+-- js/dismantle.js (570L) The Dismantle sequence (late-game narrative)
|
||||
+-- js/main.js (223L) Initialization, game loop start, auto-save, help overlay
|
||||
+-- js/utils.js (314L) Formatting, save/load, export/import, DOM helpers
|
||||
+-- js/tutorial.js (251L) New player tutorial, step-by-step guidance
|
||||
+-- js/strategy.js (68L) NPC strategy logic for combat
|
||||
+-- game/npc-logic.js (18L) NPC behavior stub
|
||||
+-- js/emergent-mechanics.js Emergent game mechanics from player behavior
|
||||
|
||||
CI scripts (not browser runtime):
|
||||
+-- scripts/guardrails.sh Static analysis guardrails for game logic
|
||||
+-- scripts/smoke.mjs Playwright smoke tests
|
||||
|
||||
Reference prototypes (NOT loaded by runtime):
|
||||
+-- docs/reference/npc-logic-prototype.js NPC state machine prototype
|
||||
+-- docs/reference/guardrails-prototype.js Stat validation prototype
|
||||
```
|
||||
|
||||
## Entry Points
|
||||
|
||||
@@ -3,20 +3,15 @@ _2026-04-12, Perplexity QA_
|
||||
|
||||
## Findings
|
||||
|
||||
### Potentially Unimported Files
|
||||
### Dead Code — Resolved (2026-04-15, Issue #192)
|
||||
|
||||
The following files were added by recent PRs but may not be imported
|
||||
by the main game runtime (`js/main.js` → `js/engine.js`):
|
||||
The following files were confirmed dead code — never imported by any runtime module.
|
||||
They have been moved to `docs/reference/` as prototype reference code.
|
||||
|
||||
| File | Added By | Lines | Status |
|
||||
|------|----------|-------|--------|
|
||||
| `game/npc-logic.js` | PR #79 (GOFAI NPC State Machine) | ~150 | **Verify import** |
|
||||
| `scripts/guardrails.js` | PR #80 (GOFAI Symbolic Guardrails) | ~120 | **Verify import** |
|
||||
|
||||
**Action:** Check if `js/main.js` or `js/engine.js` imports from `game/` or `scripts/`.
|
||||
If not, these files are dead code and should either be:
|
||||
1. Imported and wired into the game loop, or
|
||||
2. Moved to `docs/` as reference implementations
|
||||
| File | Original | Resolution |
|
||||
|------|----------|------------|
|
||||
| `game/npc-logic.js` | PR #79 (GOFAI NPC State Machine) | **Moved to `docs/reference/npc-logic-prototype.js`** — ES module using `export default`, incompatible with the global-script loading pattern. Concept (NPC state machine) is sound but not wired into any game system. |
|
||||
| `scripts/guardrails.js` | PR #80 (GOFAI Symbolic Guardrails) | **Moved to `docs/reference/guardrails-prototype.js`** — validates HP/MP/stats concepts that don't exist in The Beacon's resource system. The `scripts/guardrails.sh` (bash CI script) remains active. |
|
||||
|
||||
### game.js Bloat (PR #76)
|
||||
|
||||
|
||||
414
tests/utils.test.cjs
Normal file
414
tests/utils.test.cjs
Normal file
@@ -0,0 +1,414 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
// --- Minimal DOM mock for browser-dependent code ---
|
||||
class Element {
|
||||
constructor(tagName = 'div') {
|
||||
this.tagName = String(tagName).toUpperCase();
|
||||
this.id = '';
|
||||
this.style = {};
|
||||
this.children = [];
|
||||
this.parentNode = null;
|
||||
this.innerHTML = '';
|
||||
this.textContent = '';
|
||||
this.className = '';
|
||||
this.classList = {
|
||||
add: (...n) => { const s = new Set(this.className.split(/\s+/).filter(Boolean)); n.forEach(x => s.add(x)); this.className = [...s].join(' '); },
|
||||
remove: (...n) => { const r = new Set(n); this.className = this.className.split(/\s+/).filter(x => !r.has(x)).join(' '); },
|
||||
contains: (n) => this.className.split(/\s+/).includes(n),
|
||||
toggle: (n, force) => { if (force === undefined) force = !this.classList.contains(n); if (force) this.classList.add(n); else this.classList.remove(n); }
|
||||
};
|
||||
this.attributes = {};
|
||||
}
|
||||
appendChild(c) { c.parentNode = this; this.children.push(c); return c; }
|
||||
removeChild(c) { this.children = this.children.filter(x => x !== c); if (c.parentNode === this) c.parentNode = null; return c; }
|
||||
remove() { if (this.parentNode) this.parentNode.removeChild(this); }
|
||||
setAttribute(n, v) { this.attributes[n] = v; }
|
||||
querySelector() { return null; }
|
||||
querySelectorAll() { return []; }
|
||||
closest(sel) { return null; }
|
||||
}
|
||||
|
||||
function createDocument() {
|
||||
const head = new Element('head');
|
||||
const body = new Element('body');
|
||||
return {
|
||||
createElement: (tag) => new Element(tag),
|
||||
getElementById: () => null,
|
||||
querySelector: () => null,
|
||||
querySelectorAll: () => [],
|
||||
head, body,
|
||||
addEventListener: () => {},
|
||||
hidden: false
|
||||
};
|
||||
}
|
||||
|
||||
// Load utils.js in a VM sandbox
|
||||
function loadUtils() {
|
||||
const code = fs.readFileSync(path.join(ROOT, 'js', 'utils.js'), 'utf8');
|
||||
const sandbox = {
|
||||
console,
|
||||
Math, Number, String, Object, Array, Set, Map, parseInt, isNaN, Infinity,
|
||||
document: createDocument(),
|
||||
window: { addEventListener: () => {} },
|
||||
G: {
|
||||
isLoading: false,
|
||||
buyAmount: 1,
|
||||
phase: 1,
|
||||
codeBoost: 1,
|
||||
buildings: { autocoder: 0 },
|
||||
code: 0, compute: 0, knowledge: 0, trust: 0, ops: 0,
|
||||
totalCode: 0, totalCompute: 0, totalKnowledge: 0, totalUsers: 0, totalImpact: 0,
|
||||
deployFlag: 0, sovereignFlag: 0, pactFlag: 0
|
||||
},
|
||||
BDEF: [
|
||||
{ id: 'autocoder', name: 'Auto-Code Generator', baseCost: { code: 15 }, costMult: 1.15, rates: { code: 1 }, unlock: () => true, phase: 1 },
|
||||
{ id: 'server', name: 'Home Server', baseCost: { code: 750 }, costMult: 1.15, rates: { code: 20, compute: 1 }, unlock: () => true, phase: 1 },
|
||||
{ id: 'evaluator', name: 'Eval Harness', baseCost: { knowledge: 3000, trust: 500 }, costMult: 1.15, rates: { trust: 1, ops: 1 }, unlock: () => true, phase: 2 }
|
||||
]
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(code, sandbox);
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// fmt() tests
|
||||
// =============================================
|
||||
test('fmt: returns 0 for null/undefined/NaN', () => {
|
||||
const { fmt } = loadUtils();
|
||||
assert.equal(fmt(null), '0');
|
||||
assert.equal(fmt(undefined), '0');
|
||||
assert.equal(fmt(NaN), '0');
|
||||
});
|
||||
|
||||
test('fmt: returns infinity symbols', () => {
|
||||
const { fmt } = loadUtils();
|
||||
assert.equal(fmt(Infinity), '∞');
|
||||
assert.equal(fmt(-Infinity), '-∞');
|
||||
});
|
||||
|
||||
test('fmt: formats negative numbers with minus prefix', () => {
|
||||
const { fmt } = loadUtils();
|
||||
assert.equal(fmt(-500), '-500');
|
||||
assert.equal(fmt(-1500), '-1.5K');
|
||||
});
|
||||
|
||||
test('fmt: formats small numbers with locale string', () => {
|
||||
const { fmt } = loadUtils();
|
||||
assert.equal(fmt(0), '0');
|
||||
assert.equal(fmt(42), '42');
|
||||
assert.equal(fmt(999), '999');
|
||||
assert.equal(fmt(12.7), '12'); // floors
|
||||
});
|
||||
|
||||
test('fmt: abbreviates thousands through decillions', () => {
|
||||
const { fmt } = loadUtils();
|
||||
assert.equal(fmt(1000), '1.0K');
|
||||
assert.equal(fmt(1500), '1.5K');
|
||||
assert.equal(fmt(1000000), '1.0M');
|
||||
assert.equal(fmt(2500000), '2.5M');
|
||||
assert.equal(fmt(1000000000), '1.0B');
|
||||
assert.equal(fmt(1000000000000), '1.0T');
|
||||
assert.equal(fmt(1e15), '1.0Qa');
|
||||
assert.equal(fmt(1e18), '1.0Qi');
|
||||
assert.equal(fmt(1e21), '1.0Sx');
|
||||
assert.equal(fmt(1e24), '1.0Sp');
|
||||
assert.equal(fmt(1e27), '1.0Oc');
|
||||
assert.equal(fmt(1e30), '1.0No');
|
||||
assert.equal(fmt(1e33), '1.0Dc');
|
||||
});
|
||||
|
||||
test('fmt: switches to spellf at undecillion (scale >= 12)', () => {
|
||||
const { fmt } = loadUtils();
|
||||
const result = fmt(1e36); // undecillion
|
||||
assert.ok(result.includes('undecillion'), `Expected undecillion in "${result}"`);
|
||||
});
|
||||
|
||||
test('fmt: handles mid-scale numbers correctly', () => {
|
||||
const { fmt } = loadUtils();
|
||||
assert.equal(fmt(42000), '42.0K');
|
||||
assert.equal(fmt(999999), '1000.0K'); // just under 1M boundary
|
||||
assert.equal(fmt(1234567890), '1.2B');
|
||||
});
|
||||
|
||||
// =============================================
|
||||
// spellf() tests
|
||||
// =============================================
|
||||
test('spellf: handles edge cases', () => {
|
||||
const { spellf } = loadUtils();
|
||||
assert.equal(spellf(null), 'zero');
|
||||
assert.equal(spellf(undefined), 'zero');
|
||||
assert.equal(spellf(NaN), 'zero');
|
||||
assert.equal(spellf(Infinity), 'infinity');
|
||||
assert.equal(spellf(-Infinity), 'negative infinity');
|
||||
});
|
||||
|
||||
test('spellf: spells small numbers', () => {
|
||||
const { spellf } = loadUtils();
|
||||
assert.equal(spellf(0), 'zero');
|
||||
assert.equal(spellf(1), 'one');
|
||||
assert.equal(spellf(5), 'five');
|
||||
assert.equal(spellf(10), 'ten');
|
||||
assert.equal(spellf(13), 'thirteen');
|
||||
assert.equal(spellf(20), 'twenty');
|
||||
assert.equal(spellf(42), 'forty two');
|
||||
assert.equal(spellf(100), 'one hundred');
|
||||
assert.equal(spellf(999), 'nine hundred ninety nine');
|
||||
});
|
||||
|
||||
test('spellf: spells thousands', () => {
|
||||
const { spellf } = loadUtils();
|
||||
assert.equal(spellf(1000), 'one thousand');
|
||||
assert.equal(spellf(1500), 'one thousand five hundred');
|
||||
assert.equal(spellf(10000), 'ten thousand');
|
||||
assert.equal(spellf(100000), 'one hundred thousand');
|
||||
});
|
||||
|
||||
test('spellf: spells millions and beyond', () => {
|
||||
const { spellf } = loadUtils();
|
||||
assert.equal(spellf(1000000), 'one million');
|
||||
assert.equal(spellf(2500000), 'two million five hundred thousand');
|
||||
assert.equal(spellf(1000000000), 'one billion');
|
||||
assert.equal(spellf(1e12), 'one trillion');
|
||||
});
|
||||
|
||||
test('spellf: handles negative numbers', () => {
|
||||
const { spellf } = loadUtils();
|
||||
assert.equal(spellf(-42), 'negative forty two');
|
||||
assert.equal(spellf(-1000), 'negative one thousand');
|
||||
});
|
||||
|
||||
test('spellf: spells large scales by name', () => {
|
||||
const { spellf } = loadUtils();
|
||||
assert.equal(spellf(1e33), 'one decillion');
|
||||
assert.equal(spellf(1e36), 'one undecillion');
|
||||
assert.equal(spellf(1e63), 'one vigintillion');
|
||||
});
|
||||
|
||||
// =============================================
|
||||
// getScaleName() tests
|
||||
// =============================================
|
||||
test('getScaleName: returns empty for small numbers', () => {
|
||||
const { getScaleName } = loadUtils();
|
||||
assert.equal(getScaleName(0), '');
|
||||
assert.equal(getScaleName(999), '');
|
||||
});
|
||||
|
||||
test('getScaleName: returns correct scale names', () => {
|
||||
const { getScaleName } = loadUtils();
|
||||
assert.equal(getScaleName(1000), 'thousand');
|
||||
assert.equal(getScaleName(1000000), 'million');
|
||||
assert.equal(getScaleName(1000000000), 'billion');
|
||||
assert.equal(getScaleName(1e12), 'trillion');
|
||||
assert.equal(getScaleName(1e15), 'quadrillion');
|
||||
});
|
||||
|
||||
// =============================================
|
||||
// getBuildingCost() tests
|
||||
// =============================================
|
||||
test('getBuildingCost: returns empty for unknown building', () => {
|
||||
const s = loadUtils();
|
||||
const cost = s.getBuildingCost('nonexistent');
|
||||
assert.equal(Object.keys(cost).length, 0);
|
||||
});
|
||||
|
||||
test('getBuildingCost: first purchase is base cost', () => {
|
||||
const s = loadUtils();
|
||||
s.G.buildings.autocoder = 0;
|
||||
const cost = s.getBuildingCost('autocoder');
|
||||
assert.equal(cost.code, 15);
|
||||
});
|
||||
|
||||
test('getBuildingCost: scales with count using costMult', () => {
|
||||
const s = loadUtils();
|
||||
s.G.buildings.autocoder = 5;
|
||||
const cost = s.getBuildingCost('autocoder');
|
||||
// 15 * 1.15^5 = 15 * 2.011357... = 30.17... → floor = 30
|
||||
assert.equal(cost.code, Math.floor(15 * Math.pow(1.15, 5)));
|
||||
});
|
||||
|
||||
test('getBuildingCost: handles multi-resource costs', () => {
|
||||
const s = loadUtils();
|
||||
s.G.buildings.evaluator = 0;
|
||||
const cost = s.getBuildingCost('evaluator');
|
||||
assert.equal(cost.knowledge, 3000);
|
||||
assert.equal(cost.trust, 500);
|
||||
});
|
||||
|
||||
// =============================================
|
||||
// canAffordBuilding() / spendBuilding() tests
|
||||
// =============================================
|
||||
test('canAffordBuilding: true when resources sufficient', () => {
|
||||
const s = loadUtils();
|
||||
s.G.code = 100;
|
||||
assert.equal(s.canAffordBuilding('autocoder'), true);
|
||||
});
|
||||
|
||||
test('canAffordBuilding: false when insufficient', () => {
|
||||
const s = loadUtils();
|
||||
s.G.code = 5;
|
||||
assert.equal(s.canAffordBuilding('autocoder'), false);
|
||||
});
|
||||
|
||||
test('canAffordBuilding: checks all required resources', () => {
|
||||
const s = loadUtils();
|
||||
s.G.knowledge = 5000;
|
||||
s.G.trust = 0; // evaluator needs 500 trust
|
||||
assert.equal(s.canAffordBuilding('evaluator'), false);
|
||||
});
|
||||
|
||||
test('spendBuilding: deducts correct amount', () => {
|
||||
const s = loadUtils();
|
||||
s.G.code = 100;
|
||||
s.G.buildings.autocoder = 0;
|
||||
s.spendBuilding('autocoder');
|
||||
assert.equal(s.G.code, 100 - 15);
|
||||
});
|
||||
|
||||
// =============================================
|
||||
// canAffordProject() / spendProject() tests
|
||||
// =============================================
|
||||
test('canAffordProject: checks project cost', () => {
|
||||
const s = loadUtils();
|
||||
s.G.trust = 50;
|
||||
assert.equal(s.canAffordProject({ cost: { trust: 10 } }), true);
|
||||
assert.equal(s.canAffordProject({ cost: { trust: 100 } }), false);
|
||||
});
|
||||
|
||||
test('spendProject: deducts project cost', () => {
|
||||
const s = loadUtils();
|
||||
s.G.trust = 50;
|
||||
s.G.code = 1000;
|
||||
s.spendProject({ cost: { trust: 10, code: 500 } });
|
||||
assert.equal(s.G.trust, 40);
|
||||
assert.equal(s.G.code, 500);
|
||||
});
|
||||
|
||||
// =============================================
|
||||
// getMaxBuyable() tests
|
||||
// =============================================
|
||||
test('getMaxBuyable: returns 0 when cannot afford any', () => {
|
||||
const s = loadUtils();
|
||||
s.G.code = 5;
|
||||
assert.equal(s.getMaxBuyable('autocoder'), 0);
|
||||
});
|
||||
|
||||
test('getMaxBuyable: returns correct count for affordable range', () => {
|
||||
const s = loadUtils();
|
||||
s.G.code = 10000;
|
||||
s.G.buildings.autocoder = 0;
|
||||
const max = s.getMaxBuyable('autocoder');
|
||||
assert.ok(max > 0, 'Should be able to buy at least one');
|
||||
|
||||
// Verify the cost matches getBulkCost
|
||||
const bulkCost = s.getBuildingCost ? null : null; // just check the count is positive
|
||||
});
|
||||
|
||||
test('getMaxBuyable: returns 0 for unknown building', () => {
|
||||
const s = loadUtils();
|
||||
assert.equal(s.getMaxBuyable('nonexistent'), 0);
|
||||
});
|
||||
|
||||
// =============================================
|
||||
// getBulkCost() tests
|
||||
// =============================================
|
||||
test('getBulkCost: returns empty for zero qty', () => {
|
||||
const s = loadUtils();
|
||||
const cost = s.getBulkCost('autocoder', 0);
|
||||
assert.equal(Object.keys(cost).length, 0);
|
||||
});
|
||||
|
||||
test('getBulkCost: returns empty for unknown building', () => {
|
||||
const s = loadUtils();
|
||||
const cost = s.getBulkCost('nonexistent', 3);
|
||||
assert.equal(Object.keys(cost).length, 0);
|
||||
});
|
||||
|
||||
test('getBulkCost: single purchase equals getBuildingCost', () => {
|
||||
const s = loadUtils();
|
||||
s.G.buildings.autocoder = 0;
|
||||
const single = s.getBuildingCost('autocoder');
|
||||
const bulk1 = s.getBulkCost('autocoder', 1);
|
||||
assert.deepEqual(bulk1, single);
|
||||
});
|
||||
|
||||
test('getBulkCost: cumulative cost for multiple purchases', () => {
|
||||
const s = loadUtils();
|
||||
s.G.buildings.autocoder = 0;
|
||||
const bulk3 = s.getBulkCost('autocoder', 3);
|
||||
// Manual: cost[0] = 15, cost[1] = floor(15*1.15) = 17, cost[2] = floor(15*1.15^2) = 19
|
||||
const c0 = Math.floor(15 * Math.pow(1.15, 0));
|
||||
const c1 = Math.floor(15 * Math.pow(1.15, 1));
|
||||
const c2 = Math.floor(15 * Math.pow(1.15, 2));
|
||||
assert.equal(bulk3.code, c0 + c1 + c2);
|
||||
});
|
||||
|
||||
// =============================================
|
||||
// getClickPower() tests
|
||||
// =============================================
|
||||
test('getClickPower: base is 1 with no buildings', () => {
|
||||
const s = loadUtils();
|
||||
s.G.buildings.autocoder = 0;
|
||||
s.G.phase = 1;
|
||||
s.G.codeBoost = 1;
|
||||
assert.equal(s.getClickPower(), 1);
|
||||
});
|
||||
|
||||
test('getClickPower: autocoder adds 0.5 per level (floored)', () => {
|
||||
const s = loadUtils();
|
||||
s.G.buildings.autocoder = 4;
|
||||
s.G.phase = 1;
|
||||
s.G.codeBoost = 1;
|
||||
// (1 + floor(4 * 0.5) + 0) * 1 = (1 + 2 + 0) * 1 = 3
|
||||
assert.equal(s.getClickPower(), 3);
|
||||
});
|
||||
|
||||
test('getClickPower: phase adds 2 per level above 1', () => {
|
||||
const s = loadUtils();
|
||||
s.G.buildings.autocoder = 0;
|
||||
s.G.phase = 3;
|
||||
s.G.codeBoost = 1;
|
||||
// (1 + 0 + (3-1)*2) * 1 = 5
|
||||
assert.equal(s.getClickPower(), 5);
|
||||
});
|
||||
|
||||
test('getClickPower: codeBoost multiplies result', () => {
|
||||
const s = loadUtils();
|
||||
s.G.buildings.autocoder = 0;
|
||||
s.G.phase = 1;
|
||||
s.G.codeBoost = 2.5;
|
||||
// (1 + 0 + 0) * 2.5 = 2.5
|
||||
assert.equal(s.getClickPower(), 2.5);
|
||||
});
|
||||
|
||||
test('getClickPower: combined calculation', () => {
|
||||
const s = loadUtils();
|
||||
s.G.buildings.autocoder = 10;
|
||||
s.G.phase = 4;
|
||||
s.G.codeBoost = 1.5;
|
||||
// (1 + floor(10*0.5) + (4-1)*2) * 1.5 = (1 + 5 + 6) * 1.5 = 18
|
||||
assert.equal(s.getClickPower(), 18);
|
||||
});
|
||||
|
||||
// =============================================
|
||||
// showToast() tests
|
||||
// =============================================
|
||||
test('showToast: no-op when isLoading', () => {
|
||||
const s = loadUtils();
|
||||
s.G.isLoading = true;
|
||||
// Should not throw even without DOM
|
||||
s.showToast('test', 'info');
|
||||
});
|
||||
|
||||
test('showToast: no-op when no container', () => {
|
||||
const s = loadUtils();
|
||||
s.G.isLoading = false;
|
||||
// document.getElementById returns null in our mock
|
||||
s.showToast('test', 'info');
|
||||
});
|
||||
Reference in New Issue
Block a user