Compare commits

...

3 Commits

Author SHA1 Message Date
Timmy
ee5e5dbd8e burn: add comprehensive unit tests for utils.js (45 tests)
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Successful in 5s
Smoke Test / smoke (pull_request) Failing after 13s
Covers fmt(), spellf(), getScaleName(), getBuildingCost(), getMaxBuyable(),
getBulkCost(), canAffordBuilding(), spendBuilding(), canAffordProject(),
spendProject(), and getClickPower(). Uses VM sandbox with DOM mocks
to test browser-dependent code in Node.js test runner.
2026-04-20 11:42:17 -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 459 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)

440
tests/utils.test.cjs Normal file
View File

@@ -0,0 +1,440 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const vm = require('node:vm');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.resolve(__dirname, '..');
// Minimal DOM/document mock for showToast and spawnParticles
class Element {
constructor(tagName = 'div') {
this.tagName = String(tagName).toUpperCase();
this.id = '';
this.style = {};
this.children = [];
this.parentNode = null;
this.previousElementSibling = null;
this.innerHTML = '';
this.textContent = '';
this.className = '';
this.dataset = {};
this.attributes = {};
this.classList = {
add: (...names) => {
const set = new Set(this.className.split(/\s+/).filter(Boolean));
names.forEach((name) => set.add(name));
this.className = Array.from(set).join(' ');
},
remove: (...names) => {
const remove = new Set(names);
this.className = this.className.split(/\s+/).filter(n => !remove.has(n)).join(' ');
}
};
}
appendChild(child) {
this.children.push(child);
child.parentNode = this;
}
removeChild(child) {
this.children = this.children.filter(c => c !== child);
}
remove() {
if (this.parentNode) this.parentNode.removeChild(this);
}
querySelector() { return null; }
setAttribute() {}
getAttribute() { return null; }
}
// Create a sandbox with game globals
function createSandbox() {
const container = new Element();
container.id = 'toast-container';
const document = {
createElement: (tag) => new Element(tag),
getElementById: (id) => id === 'toast-container' ? container : null,
body: new Element('body')
};
const sandbox = {
console,
Math,
Infinity,
NaN,
isNaN,
parseFloat,
parseInt,
document,
setTimeout: (fn, ms) => { /* no-op in test */ },
G: {
buildings: {},
code: 0,
compute: 0,
knowledge: 0,
users: 0,
impact: 0,
ops: 5,
trust: 5,
creativity: 0,
harmony: 50,
phase: 1,
buyAmount: 1,
codeBoost: 1,
isLoading: false,
tutorialDone: true
},
BDEF: [
{
id: 'autocoder',
baseCost: { code: 10 },
costMult: 1.15,
prod: { code: 1 }
},
{
id: 'server',
baseCost: { code: 50 },
costMult: 1.14,
prod: { compute: 1 }
}
],
render: () => {}
};
vm.createContext(sandbox);
// Load utils.js into sandbox
const utilsCode = fs.readFileSync(path.join(ROOT, 'js', 'utils.js'), 'utf8');
vm.runInContext(utilsCode, sandbox);
return sandbox;
}
// ===================== fmt() tests =====================
test('fmt: returns "0" for null, undefined, NaN', () => {
const s = createSandbox();
assert.equal(s.fmt(null), '0');
assert.equal(s.fmt(undefined), '0');
assert.equal(s.fmt(NaN), '0');
});
test('fmt: returns infinity symbols', () => {
const s = createSandbox();
assert.equal(s.fmt(Infinity), '\u221E');
assert.equal(s.fmt(-Infinity), '-\u221E');
});
test('fmt: formats small numbers with locale string', () => {
const s = createSandbox();
assert.equal(s.fmt(0), '0');
assert.equal(s.fmt(42), '42');
assert.equal(s.fmt(999), '999');
});
test('fmt: formats negative numbers', () => {
const s = createSandbox();
const result = s.fmt(-500);
assert.ok(result.startsWith('-'), 'negative should start with dash');
assert.ok(result.includes('500'), 'should contain 500');
});
test('fmt: abbreviates thousands with K', () => {
const s = createSandbox();
const result = s.fmt(1000);
assert.ok(result.includes('K'), 'should contain K for thousands');
assert.ok(result.startsWith('1.0'), 'should start with 1.0');
});
test('fmt: abbreviates millions with M', () => {
const s = createSandbox();
const result = s.fmt(1500000);
assert.ok(result.includes('M'), 'should contain M for millions');
});
test('fmt: abbreviates billions with B', () => {
const s = createSandbox();
const result = s.fmt(2500000000);
assert.ok(result.includes('B'), 'should contain B for billions');
});
test('fmt: uses spellf for numbers >= 10^36 (undecillion)', () => {
const s = createSandbox();
const result = s.fmt(1e36);
// Should be spelled out, not just abbreviated
assert.ok(!result.includes('UDc'), 'should use word form, not abbreviation');
assert.ok(result.length > 3, 'should be a word, not abbreviation');
});
test('fmt: falls back to exponential for numbers beyond scale table', () => {
const s = createSandbox();
const result = s.fmt(1e306);
// Should be exponential notation since it's beyond centillion
assert.ok(result.length > 0, 'should produce a result');
});
// ===================== spellf() tests =====================
test('spellf: returns "zero" for null, undefined, NaN', () => {
const s = createSandbox();
assert.equal(s.spellf(null), 'zero');
assert.equal(s.spellf(undefined), 'zero');
assert.equal(s.spellf(NaN), 'zero');
});
test('spellf: returns "zero" for 0', () => {
const s = createSandbox();
assert.equal(s.spellf(0), 'zero');
});
test('spellf: returns "infinity" for Infinity', () => {
const s = createSandbox();
assert.equal(s.spellf(Infinity), 'infinity');
assert.equal(s.spellf(-Infinity), 'negative infinity');
});
test('spellf: spells small numbers', () => {
const s = createSandbox();
assert.equal(s.spellf(1), 'one');
assert.equal(s.spellf(5), 'five');
assert.equal(s.spellf(13), 'thirteen');
assert.equal(s.spellf(42), 'forty two');
assert.equal(s.spellf(100), 'one hundred');
assert.equal(s.spellf(999), 'nine hundred ninety nine');
});
test('spellf: spells thousands', () => {
const s = createSandbox();
const result = s.spellf(1500);
assert.ok(result.includes('thousand'), 'should include "thousand"');
assert.ok(result.includes('one'), 'should include "one"');
});
test('spellf: spells millions', () => {
const s = createSandbox();
const result = s.spellf(2500000);
assert.ok(result.includes('million'), 'should include "million"');
});
test('spellf: handles negative numbers', () => {
const s = createSandbox();
const result = s.spellf(-42);
assert.ok(result.startsWith('negative'), 'should start with "negative"');
assert.ok(result.includes('forty'), 'should include the number words');
});
test('spellf: spells billions', () => {
const s = createSandbox();
const result = s.spellf(1e9);
assert.ok(result.includes('billion'), 'should include "billion"');
});
test('spellf: spells decillion', () => {
const s = createSandbox();
const result = s.spellf(1e33);
assert.ok(result.includes('decillion'), 'should include "decillion"');
});
// ===================== getScaleName() tests =====================
test('getScaleName: returns empty for small numbers', () => {
const s = createSandbox();
assert.equal(s.getScaleName(0), '');
assert.equal(s.getScaleName(500), '');
});
test('getScaleName: returns "thousand" for 1000s', () => {
const s = createSandbox();
assert.equal(s.getScaleName(1000), 'thousand');
assert.equal(s.getScaleName(5000), 'thousand');
});
test('getScaleName: returns "million" for millions', () => {
const s = createSandbox();
assert.equal(s.getScaleName(1e6), 'million');
});
test('getScaleName: returns "billion" for billions', () => {
const s = createSandbox();
assert.equal(s.getScaleName(1e9), 'billion');
});
test('getScaleName: returns "trillion" for trillions', () => {
const s = createSandbox();
assert.equal(s.getScaleName(1e12), 'trillion');
});
test('getScaleName: returns "quadrillion" for quadrillions', () => {
const s = createSandbox();
assert.equal(s.getScaleName(1e15), 'quadrillion');
});
test('getScaleName: returns "centillion" for 10^303', () => {
const s = createSandbox();
assert.equal(s.getScaleName(1e303), 'centillion');
});
// ===================== getBuildingCost() tests =====================
test('getBuildingCost: returns empty object for unknown building', () => {
const s = createSandbox();
const cost = s.getBuildingCost('nonexistent');
assert.equal(Object.keys(cost).length, 0, 'should return empty object');
});
test('getBuildingCost: returns base cost for first building', () => {
const s = createSandbox();
const cost = s.getBuildingCost('autocoder');
assert.equal(cost.code, 10);
});
test('getBuildingCost: scales cost with existing buildings', () => {
const s = createSandbox();
s.G.buildings.autocoder = 5;
const cost = s.getBuildingCost('autocoder');
// cost = floor(10 * 1.15^5) = floor(20.113...) = 20
assert.equal(cost.code, 20);
assert.ok(cost.code > 10, 'cost should increase with building count');
});
// ===================== getMaxBuyable() tests =====================
test('getMaxBuyable: returns 0 for unknown building', () => {
const s = createSandbox();
assert.equal(s.getMaxBuyable('nonexistent'), 0);
});
test('getMaxBuyable: returns 0 when no resources', () => {
const s = createSandbox();
s.G.code = 0;
assert.equal(s.getMaxBuyable('autocoder'), 0);
});
test('getMaxBuyable: returns correct count for available resources', () => {
const s = createSandbox();
s.G.code = 1000;
s.G.buildings.autocoder = 0;
const max = s.getMaxBuyable('autocoder');
assert.ok(max > 0, 'should be able to buy at least one');
assert.ok(max < 200, 'should be bounded');
});
// ===================== getBulkCost() tests =====================
test('getBulkCost: returns empty for invalid input', () => {
const s = createSandbox();
assert.equal(Object.keys(s.getBulkCost('nonexistent', 5)).length, 0, 'unknown building should be empty');
assert.equal(Object.keys(s.getBulkCost('autocoder', 0)).length, 0, 'qty=0 should be empty');
assert.equal(Object.keys(s.getBulkCost('autocoder', -1)).length, 0, 'qty=-1 should be empty');
});
test('getBulkCost: returns correct total for qty=1', () => {
const s = createSandbox();
const singleCost = s.getBuildingCost('autocoder');
const bulkCost = s.getBulkCost('autocoder', 1);
assert.deepEqual(bulkCost, singleCost);
});
test('getBulkCost: accumulates cost for multiple purchases', () => {
const s = createSandbox();
s.G.buildings.autocoder = 0;
const bulk2 = s.getBulkCost('autocoder', 2);
const cost0 = Math.floor(10 * Math.pow(1.15, 0)); // 10
const cost1 = Math.floor(10 * Math.pow(1.15, 1)); // 11
assert.equal(bulk2.code, cost0 + cost1);
});
// ===================== canAffordBuilding() tests =====================
test('canAffordBuilding: false when cannot afford', () => {
const s = createSandbox();
s.G.code = 0;
assert.equal(s.canAffordBuilding('autocoder'), false);
});
test('canAffordBuilding: true when can afford', () => {
const s = createSandbox();
s.G.code = 100;
assert.equal(s.canAffordBuilding('autocoder'), true);
});
test('canAffordBuilding: false for unknown building', () => {
const s = createSandbox();
// Unknown building has no cost, so empty cost object means all conditions pass
// This is actually a design quirk — no resources needed = can always afford
const result = s.canAffordBuilding('nonexistent');
assert.equal(result, true, 'empty cost means always affordable');
});
// ===================== spendBuilding() tests =====================
test('spendBuilding: deducts resources', () => {
const s = createSandbox();
s.G.code = 100;
s.spendBuilding('autocoder');
assert.equal(s.G.code, 90);
});
// ===================== canAffordProject() / spendProject() tests =====================
test('canAffordProject: checks project cost', () => {
const s = createSandbox();
s.G.code = 100;
assert.equal(s.canAffordProject({ cost: { code: 50 } }), true);
assert.equal(s.canAffordProject({ cost: { code: 200 } }), false);
});
test('spendProject: deducts project cost', () => {
const s = createSandbox();
s.G.code = 100;
s.spendProject({ cost: { code: 30 } });
assert.equal(s.G.code, 70);
});
// ===================== getClickPower() tests =====================
test('getClickPower: returns 1 at base state (phase 1, no autocoder)', () => {
const s = createSandbox();
s.G.phase = 1;
s.G.buildings.autocoder = 0;
s.G.codeBoost = 1;
// (1 + floor(0 * 0.5) + max(0, 0) * 2) * 1 = 1
assert.equal(s.getClickPower(), 1);
});
test('getClickPower: scales with autocoder buildings', () => {
const s = createSandbox();
s.G.phase = 1;
s.G.buildings.autocoder = 4;
s.G.codeBoost = 1;
// (1 + floor(4 * 0.5) + 0) * 1 = 3
assert.equal(s.getClickPower(), 3);
});
test('getClickPower: scales with phase', () => {
const s = createSandbox();
s.G.phase = 3;
s.G.buildings.autocoder = 0;
s.G.codeBoost = 1;
// (1 + 0 + max(0, 2) * 2) * 1 = 5
assert.equal(s.getClickPower(), 5);
});
test('getClickPower: scales with codeBoost', () => {
const s = createSandbox();
s.G.phase = 1;
s.G.buildings.autocoder = 0;
s.G.codeBoost = 3;
// (1 + 0 + 0) * 3 = 3
assert.equal(s.getClickPower(), 3);
});
test('getClickPower: combines all multipliers', () => {
const s = createSandbox();
s.G.phase = 2;
s.G.buildings.autocoder = 6;
s.G.codeBoost = 2;
// (1 + floor(6 * 0.5) + max(0, 1) * 2) * 2 = (1 + 3 + 2) * 2 = 12
assert.equal(s.getClickPower(), 12);
});