Compare commits

...

3 Commits

Author SHA1 Message Date
Alexander Whitestone
d4b1bbdce4 test: add 39 unit tests for utils.js (#57)
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Successful in 5s
Smoke Test / smoke (pull_request) Failing after 9s
Comprehensive coverage of pure utility functions:

- fmt(): null/NaN/Infinity, negatives, small numbers, scale
  abbreviations (K through Dc), spellf handoff at undecillion
- spellf(): edge cases, small numbers (0-999), thousands,
  millions+, negative numbers, large scale names (decillion,
  undecillion, vigintillion)
- getScaleName(): empty for small, correct names through quadrillion
- getBuildingCost(): unknown, base cost, scaling, multi-resource
- canAffordBuilding/spendBuilding: sufficient, insufficient,
  multi-resource check, deduction
- canAffordProject/spendProject: cost checking and spending
- getMaxBuyable(): zero when broke, positive counts, unknown
- getBulkCost(): zero qty, unknown, single matches getBuildingCost,
  cumulative math
- getClickPower(): base, autocoder, phase, codeBoost, combined
- showToast(): no-op guards (isLoading, no container)

All 74 tests pass (39 new + 10 dismantle + 25 emergent-mechanics).
Smoke test passes. Syntax checks pass.
2026-04-21 00:34:26 -04:00
d5645fea58 Merge pull request 'fix: resolve #192 — move dead code to docs/reference, fix GENOME.md' (#194) from fix/192-dead-code-cleanup into main
Merge PR #194: fix: resolve #192 — move dead code to docs/reference, fix GENOME.md
2026-04-17 01:47:15 +00:00
Alexander Whitestone
db08f9a478 fix: resolve #192 — move dead code to docs/reference, fix GENOME.md
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Successful in 9s
Smoke Test / smoke (pull_request) Failing after 16s
- game/npc-logic.js → docs/reference/npc-logic-prototype.js (ES module, never imported)
- scripts/guardrails.js → docs/reference/guardrails-prototype.js (HP/MP validation, wrong game)
- Updated GENOME.md architecture diagram to reflect actual file structure
- Updated DEAD_CODE_AUDIT to mark these as resolved
- Corrected JS line counts (6,033 across 11 files)
- Removed empty game/ directory

The actual CI scripts (guardrails.sh, smoke.mjs) remain active in scripts/.
2026-04-15 21:25:38 -04:00
5 changed files with 433 additions and 16 deletions

View File

@@ -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

View File

@@ -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
View 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');
});