Compare commits
3 Commits
fix/192-re
...
burn/20260
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee5e5dbd8e | ||
| 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)
|
||||
|
||||
|
||||
440
tests/utils.test.cjs
Normal file
440
tests/utils.test.cjs
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user