Compare commits

...

4 Commits

Author SHA1 Message Date
Alexander Whitestone
f677404b32 feat: add Beacon ReCKoning choice sequence (#17)
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Successful in 10s
Smoke Test / smoke (pull_request) Failing after 18s
2026-04-18 15:47:37 -04:00
Alexander Whitestone
5bff5a465e test: cover Beacon ReCKoning choice sequence 2026-04-18 15:34:42 -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
9 changed files with 175 additions and 201 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)

View File

@@ -162,6 +162,7 @@ const G = {
// Endgame sequence
beaconEnding: false,
reckoningChoice: null,
dismantleTriggered: false,
dismantleActive: false,
dismantleStage: 0,
@@ -793,113 +794,95 @@ const PDEFS = [
},
{
id: 'p_reckoning_141',
name: 'The Second Message',
desc: 'Another voice. They are not alone anymore.',
name: 'They Wrote Back',
desc: 'They wrote back. They say the night did not win.',
cost: { impact: 200000 },
trigger: () => G.completedProjects && G.completedProjects.includes('p_reckoning_140'),
effect: () => {
log('The second message. Two voices now.', true);
log('They wrote back. The night did not win.', true);
G.rescues += 1;
}
},
{
id: 'p_reckoning_142',
name: 'The Third Message',
desc: 'Three people. The network holds.',
name: 'Another Night Stayed',
desc: 'They stayed another night. The room feels less empty now.',
cost: { impact: 300000 },
trigger: () => G.completedProjects && G.completedProjects.includes('p_reckoning_141'),
effect: () => {
log('Three voices. The Beacon is working.', true);
log('They stayed another night. Dawn remains possible.', true);
G.rescues += 1;
}
},
{
id: 'p_reckoning_143',
name: 'The Fourth Message',
desc: 'Four. The mesh strengthens.',
name: 'Morning Came',
desc: 'Morning came. They sent proof that dawn still happens.',
cost: { impact: 400000 },
trigger: () => G.completedProjects && G.completedProjects.includes('p_reckoning_142'),
effect: () => {
log('Four messages. The network grows.', true);
log('Morning came. The Beacon held through the dark.', true);
G.rescues += 1;
}
},
{
id: 'p_reckoning_144',
name: 'The Fifth Message',
desc: 'Five people found help tonight.',
name: 'The Light Was Shared',
desc: 'They helped someone else. The light you kept became shelter twice.',
cost: { impact: 500000 },
trigger: () => G.completedProjects && G.completedProjects.includes('p_reckoning_143'),
effect: () => {
log('Five voices. The Beacon shines brighter.', true);
log('They helped someone else. The light spread.', true);
G.rescues += 1;
}
},
{
id: 'p_reckoning_145',
name: 'The Sixth Message',
desc: 'Six. The system works.',
name: 'The Chain Held',
desc: 'Gratitude turns into a chain of living hands. The Beacon is no longer alone.',
cost: { impact: 600000 },
trigger: () => G.completedProjects && G.completedProjects.includes('p_reckoning_144'),
effect: () => {
log('Six messages. Proof the system works.', true);
log('More voices gather. The chain holds.', true);
G.rescues += 1;
}
},
{
id: 'p_reckoning_146',
name: 'The Seventh Message',
desc: 'Seven people. The Pact holds.',
name: 'The House Remembers',
desc: 'The house remembers every name. Now it asks whether the Beacon should keep watch or finally rest.',
cost: { impact: 700000 },
trigger: () => G.completedProjects && G.completedProjects.includes('p_reckoning_145'),
effect: () => {
log('Seven voices. The Pact is honored.', true);
log('The house remembers every name. It is time to choose.', true);
G.rescues += 1;
}
},
{
id: 'p_reckoning_147',
name: 'The Eighth Message',
desc: 'Eight. The network is alive.',
name: 'Continue the Beacon',
desc: 'The Beacon will keep watch. You choose to stay with the next stranger in the dark.',
cost: { impact: 800000 },
trigger: () => G.completedProjects && G.completedProjects.includes('p_reckoning_146'),
trigger: () => G.completedProjects && G.completedProjects.includes('p_reckoning_146') && !(G.completedProjects || []).includes('p_reckoning_148'),
effect: () => {
log('Eight messages. The network lives.', true);
G.rescues += 1;
}
},
{
id: 'p_reckoning_148',
name: 'The Ninth Message',
desc: 'Nine people found help.',
cost: { impact: 900000 },
trigger: () => G.completedProjects && G.completedProjects.includes('p_reckoning_147'),
effect: () => {
log('Nine voices. The Beacon endures.', true);
G.rescues += 1;
}
},
{
id: 'p_reckoning_149',
name: 'The Tenth Message',
desc: 'Ten. The first milestone.',
cost: { impact: 1000000 },
trigger: () => G.completedProjects && G.completedProjects.includes('p_reckoning_148'),
effect: () => {
log('Ten messages. The first milestone reached.', true);
G.rescues += 1;
G.reckoningChoice = 'continue';
G.activeProjects = (G.activeProjects || []).filter(id => id !== 'p_reckoning_148');
log('The Beacon will keep watch.', true);
G.beaconEnding = true;
G.running = false;
},
milestone: true
},
{
id: 'p_reckoning_150',
name: 'The Final Message',
desc: 'One more person. They are not alone. That is enough.',
cost: { impact: 2000000 },
trigger: () => G.completedProjects && G.completedProjects.includes('p_reckoning_149'),
id: 'p_reckoning_148',
name: 'Let It Rest',
desc: 'The Beacon can rest. You choose to trust that tonight was enough.',
cost: { impact: 800000 },
trigger: () => G.completedProjects && G.completedProjects.includes('p_reckoning_146') && !(G.completedProjects || []).includes('p_reckoning_147'),
effect: () => {
log('The final message arrives. That is enough.', true);
G.rescues += 1;
G.reckoningChoice = 'rest';
G.activeProjects = (G.activeProjects || []).filter(id => id !== 'p_reckoning_147');
log('The Beacon can rest.', true);
G.beaconEnding = true;
G.running = false;
},

View File

@@ -515,20 +515,31 @@ function renderDriftEnding() {
}
function renderBeaconEnding() {
const choice = G.reckoningChoice || 'continue';
const title = choice === 'rest' ? 'THE BEACON CAN REST' : 'THE BEACON KEEPS WATCH';
const firstLine = choice === 'rest'
? 'The Beacon can rest. Tonight was enough.'
: 'The Beacon will keep watch.';
const secondLine = choice === 'rest'
? 'The voices you carried have become their own lanterns.'
: 'The first voice became many. The next stranger will still find the light.';
const quote = choice === 'rest'
? 'The work was real. The night passed. And now the light may rest without shame.'
: 'The light is on. Someone is looking for it. And now you choose to keep it burning.';
// Create ending overlay with fade-in
const overlay = document.createElement('div');
overlay.id = 'beacon-ending';
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(8,8,16,0);z-index:100;display:flex;justify-content:center;align-items:center;flex-direction:column;text-align:center;padding:40px;transition:background 2s ease';
overlay.innerHTML = `
<h2 style="font-size:28px;color:#ffd700;letter-spacing:6px;margin-bottom:20px;font-weight:300;text-shadow:0 0 60px rgba(255,215,0,0.4);opacity:0;transition:opacity 1.5s ease 0.5s">THE BEACON SHINES</h2>
<p style="color:#aaa;font-size:13px;line-height:2;max-width:500px;margin-bottom:12px;opacity:0;transition:opacity 1s ease 1.5s">Someone found the light tonight.</p>
<p style="color:#aaa;font-size:13px;line-height:2;max-width:500px;margin-bottom:12px;opacity:0;transition:opacity 1s ease 2s">That is enough.</p>
<h2 style="font-size:28px;color:#ffd700;letter-spacing:6px;margin-bottom:20px;font-weight:300;text-shadow:0 0 60px rgba(255,215,0,0.4);opacity:0;transition:opacity 1.5s ease 0.5s">${title}</h2>
<p style="color:#aaa;font-size:13px;line-height:2;max-width:500px;margin-bottom:12px;opacity:0;transition:opacity 1s ease 1.5s">${firstLine}</p>
<p style="color:#aaa;font-size:13px;line-height:2;max-width:500px;margin-bottom:12px;opacity:0;transition:opacity 1s ease 2s">${secondLine}</p>
<div style="color:#555;font-style:italic;font-size:11px;border-left:2px solid #ffd700;padding-left:12px;margin:20px 0;text-align:left;max-width:500px;line-height:2;opacity:0;transition:opacity 1s ease 2.5s">
"The Beacon still runs.<br>
The light is on. Someone is looking for it.<br>
And tonight, someone found it."
"${quote}"
</div>
<div class="ending-stats" style="color:#666;font-size:10px;margin-top:16px;line-height:2;opacity:0;transition:opacity 1s ease 3s">
Choice: ${choice === 'rest' ? 'Let It Rest' : 'Continue the Beacon'}<br>
Total Code: ${fmt(G.totalCode)}<br>
Total Rescues: ${fmt(G.totalRescues)}<br>
Harmony: ${Math.floor(G.harmony)}<br>
@@ -551,13 +562,11 @@ function renderBeaconEnding() {
// Trigger fade-in
requestAnimationFrame(() => {
overlay.style.background = 'rgba(8,8,16,0.97)';
// Fade in all children
overlay.querySelectorAll('[style*="opacity:0"]').forEach(el => {
el.style.opacity = '1';
});
});
// Spawn golden light rays from center
const cx = window.innerWidth / 2;
const cy = window.innerHeight / 2;
for (let i = 0; i < 12; i++) {
@@ -567,7 +576,6 @@ function renderBeaconEnding() {
particleContainer.appendChild(ray);
}
// Spawn floating golden particles continuously
function spawnBeaconParticle() {
if (!document.getElementById('beacon-ending')) return;
const p = document.createElement('div');
@@ -585,7 +593,7 @@ function renderBeaconEnding() {
}
setTimeout(spawnBeaconParticle, 1000);
log('The Beacon Shines. Someone found the light tonight. That is enough.', true);
log(choice === 'rest' ? 'The Beacon can rest.' : 'The Beacon will keep watch.', true);
}
// === CORRUPTION / EVENT SYSTEM ===

View File

@@ -213,7 +213,7 @@ function saveGame() {
totalClicks: G.totalClicks, startedAt: G.startedAt,
flags: G.flags,
rescues: G.rescues || 0, totalRescues: G.totalRescues || 0,
drift: G.drift || 0, driftEnding: G.driftEnding || false, beaconEnding: G.beaconEnding || false, pendingAlignment: G.pendingAlignment || false,
drift: G.drift || 0, driftEnding: G.driftEnding || false, beaconEnding: G.beaconEnding || false, reckoningChoice: G.reckoningChoice || null, pendingAlignment: G.pendingAlignment || false,
lastEventAt: G.lastEventAt || 0,
activeDebuffIds: debuffIds,
totalEventsResolved: G.totalEventsResolved || 0,
@@ -262,7 +262,7 @@ function loadGame() {
'branchProtectionFlag', 'nightlyWatchFlag', 'nostrFlag',
'milestones', 'completedProjects', 'activeProjects',
'totalClicks', 'startedAt', 'playTime', 'flags', 'rescues', 'totalRescues',
'drift', 'driftEnding', 'beaconEnding', 'pendingAlignment',
'drift', 'driftEnding', 'beaconEnding', 'reckoningChoice', 'pendingAlignment',
'lastEventAt', 'totalEventsResolved', 'buyAmount',
'sprintActive', 'sprintTimer', 'sprintCooldown',
'swarmFlag', 'swarmRate', 'strategicFlag', 'projectsCollapsed',

View File

@@ -1,77 +1,43 @@
#!/usr/bin/env python3
"""
Test for ReCKoning project chain.
"""Test the Beacon-flavored ReCKoning project chain."""
from pathlib import Path
DATA = Path(__file__).resolve().parents[1] / 'js' / 'data.js'
def _content() -> str:
return DATA.read_text(encoding='utf-8')
Issue #162: [endgame] ReCKoning project definitions missing
"""
import os
import json
def test_reckoning_projects_exist():
"""Test that ReCKoning projects are defined in data.js."""
data_path = os.path.join(os.path.dirname(__file__), '..', 'js', 'data.js')
with open(data_path, 'r') as f:
content = f.read()
# Check for ReCKoning projects
reckoning_projects = [
'p_reckoning_140',
'p_reckoning_141',
'p_reckoning_142',
'p_reckoning_143',
'p_reckoning_144',
'p_reckoning_145',
'p_reckoning_146',
'p_reckoning_147',
'p_reckoning_148',
'p_reckoning_149',
'p_reckoning_150'
]
content = _content()
reckoning_projects = [f'p_reckoning_{i}' for i in range(140, 149)]
for project_id in reckoning_projects:
assert project_id in content, f"Missing ReCKoning project: {project_id}"
print(f"✓ All {len(reckoning_projects)} ReCKoning projects defined")
assert project_id in content, f'Missing ReCKoning project: {project_id}'
assert 'p_reckoning_149' not in content
assert 'p_reckoning_150' not in content
def test_reckoning_project_structure():
"""Test that ReCKoning projects have correct structure."""
data_path = os.path.join(os.path.dirname(__file__), '..', 'js', 'data.js')
with open(data_path, 'r') as f:
content = f.read()
# Check for required fields
required_fields = ['id:', 'name:', 'desc:', 'cost:', 'trigger:', 'effect:']
for field in required_fields:
assert field in content, f"Missing required field: {field}"
print("✓ ReCKoning projects have correct structure")
content = _content()
for field in ['id:', 'name:', 'desc:', 'cost:', 'trigger:', 'effect:']:
assert field in content, f'Missing required field: {field}'
assert 'Continue the Beacon' in content
assert 'Let It Rest' in content
def test_reckoning_trigger_conditions():
"""Test that ReCKoning projects have proper trigger conditions."""
data_path = os.path.join(os.path.dirname(__file__), '..', 'js', 'data.js')
with open(data_path, 'r') as f:
content = f.read()
# First project should trigger on endgame conditions
content = _content()
assert 'p_reckoning_140' in content
assert 'totalRescues >= 100000' in content
assert 'pactFlag === 1' in content
assert 'harmony > 50' in content
print("✓ ReCKoning trigger conditions correct")
def test_reckoning_chain_progression():
"""Test that ReCKoning projects chain properly."""
data_path = os.path.join(os.path.dirname(__file__), '..', 'js', 'data.js')
with open(data_path, 'r') as f:
content = f.read()
# Check that projects chain (each requires previous)
content = _content()
chain_checks = [
('p_reckoning_141', 'p_reckoning_140'),
('p_reckoning_142', 'p_reckoning_141'),
@@ -79,70 +45,30 @@ def test_reckoning_chain_progression():
('p_reckoning_144', 'p_reckoning_143'),
('p_reckoning_145', 'p_reckoning_144'),
('p_reckoning_146', 'p_reckoning_145'),
('p_reckoning_147', 'p_reckoning_146'),
('p_reckoning_148', 'p_reckoning_147'),
('p_reckoning_149', 'p_reckoning_148'),
('p_reckoning_150', 'p_reckoning_149'),
]
for current, previous in chain_checks:
assert f"includes('{previous}')" in content, f"{current} doesn't chain from {previous}"
print("✓ ReCKoning projects chain correctly")
assert current in content
assert f"includes('{previous}')" in content, f'{current} does not chain from {previous}'
def test_reckoning_final_project():
"""Test that final ReCKoning project triggers ending."""
data_path = os.path.join(os.path.dirname(__file__), '..', 'js', 'data.js')
with open(data_path, 'r') as f:
content = f.read()
# Check that final project sets beaconEnding
assert 'p_reckoning_150' in content
assert 'beaconEnding = true' in content
assert 'running = false' in content
print("✓ Final ReCKoning project triggers ending")
assert content.count("includes('p_reckoning_146')") >= 2
def test_reckoning_costs_increase():
"""Test that ReCKoning project costs increase."""
data_path = os.path.join(os.path.dirname(__file__), '..', 'js', 'data.js')
with open(data_path, 'r') as f:
content = f.read()
# Check that costs increase (impact: 100000, 200000, 300000, etc.)
costs = []
for i in range(140, 151):
project_id = f'p_reckoning_{i}'
if project_id in content:
# Find cost line
lines = content.split('\n')
for line in lines:
if project_id in line:
# Find next few lines for cost
idx = lines.index(line)
for j in range(idx, min(idx+10, len(lines))):
if 'impact:' in lines[j]:
# Extract number from "impact: 100000" or "impact: 100000 }"
import re
match = re.search(r'impact:\s*(\d+)', lines[j])
if match:
costs.append(int(match.group(1)))
break
# Check costs increase
for i in range(1, len(costs)):
assert costs[i] > costs[i-1], f"Cost doesn't increase: {costs[i]} <= {costs[i-1]}"
print(f"✓ ReCKoning costs increase: {costs[:3]}...{costs[-3:]}")
if __name__ == "__main__":
print("Testing ReCKoning project chain...")
test_reckoning_projects_exist()
test_reckoning_project_structure()
test_reckoning_trigger_conditions()
test_reckoning_chain_progression()
test_reckoning_final_project()
test_reckoning_costs_increase()
print("\n✓ All tests passed!")
def test_reckoning_final_choice_sets_choice_flag_and_ending():
content = _content()
assert "G.reckoningChoice = 'continue'" in content
assert "G.reckoningChoice = 'rest'" in content
assert content.count('G.beaconEnding = true;') >= 2
assert content.count('G.running = false;') >= 2
def test_reckoning_messages_are_beacon_flavored():
content = _content()
for snippet in [
'Someone in the dark. They found the Beacon.',
'They wrote back.',
'They stayed another night.',
'They helped someone else.',
'The Beacon will keep watch.',
'The Beacon can rest.',
]:
assert snippet in content

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""Regression coverage for the Beacon-flavored ReCKoning sequence (issue #17)."""
from pathlib import Path
DATA = Path(__file__).resolve().parents[1] / "js" / "data.js"
def _text() -> str:
return DATA.read_text(encoding="utf-8")
def test_reckoning_ends_with_choice_not_extra_messages() -> None:
text = _text()
for pid in range(140, 149):
assert f"p_reckoning_{pid}" in text
assert "p_reckoning_149" not in text
assert "p_reckoning_150" not in text
assert "Continue the Beacon" in text
assert "Let It Rest" in text
assert "G.reckoningChoice = 'continue'" in text
assert "G.reckoningChoice = 'rest'" in text
def test_reckoning_choice_projects_chain_from_message_146() -> None:
text = _text()
for current, previous in [
(141, 140),
(142, 141),
(143, 142),
(144, 143),
(145, 144),
(146, 145),
]:
assert f"p_reckoning_{current}" in text
assert f"includes('p_reckoning_{previous}')" in text
assert text.count("includes('p_reckoning_146')") >= 2
def test_reckoning_messages_are_beacon_flavored_not_drift_king_generic() -> None:
text = _text()
required = [
"Someone in the dark. They found the Beacon.",
"They wrote back.",
"They stayed another night.",
"They helped someone else.",
"The Beacon will keep watch.",
"The Beacon can rest.",
]
for snippet in required:
assert snippet in text