Compare commits

...

18 Commits

Author SHA1 Message Date
Alexander Whitestone
ae09fe6d11 fix: persist playTime across sessions
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Failing after 2s
Smoke Test / smoke (pull_request) Failing after 3s
playTime was defined in globals but never incremented and never
included in save/load. Now incremented each tick and persisted
in localStorage via the whitelist and save data.
2026-04-11 15:09:29 -04:00
Alexander Whitestone
ad901b1f18 fix: debuff corruption — community_drama no longer mutates codeBoost
applyFn was multiplying G.codeBoost by 0.7 on every updateRates() call
(building purchase, project, click, etc.), permanently degrading it.
After 10 calls the boost was effectively zero.

Fix: apply penalty to G.codeRate (computed per-tick) instead of
G.codeBoost (persistent multiplier). Debuffs must never mutate boost state.
2026-04-11 15:09:04 -04:00
4312486d95 [auto-merge] the-beacon#65
Some checks failed
Smoke Test / smoke (push) Failing after 4s
Auto-merged PR #65
2026-04-11 18:53:41 +00:00
2ad4bc7e5b [auto-merge] the-beacon#66
Some checks failed
Smoke Test / smoke (push) Has been cancelled
Auto-merged PR #66
2026-04-11 18:53:40 +00:00
Alexander Whitestone
3b142d485e feat: add first-time player tutorial walkthrough
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Failing after 2s
Smoke Test / smoke (pull_request) Failing after 3s
Part of #57 — Task 4: Tutorial & Onboarding

- 5 click-through screens introducing game concepts (code, buildings,
  research, phases, keyboard shortcuts)
- Skip button on every screen, keyboard support (Enter/Escape/arrows)
- Stores completion in localStorage — only shows once for new players
- Matches existing visual style (dark theme, accent colors, monospace)
- Start Playing button on final screen with shortcut hint (? overlay)
2026-04-11 04:47:06 -04:00
Alexander Whitestone
44af2ad09a feat: add ARIA labels, roles, live regions across game UI
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Failing after 3s
Smoke Test / smoke (pull_request) Failing after 4s
- index.html: role=region on phase-bar, strategy-panel; role=dialog+aria-modal on help overlay, offline popup, drift ending; aria-label on help button, close button, continue button, start over button; aria-live on progress label
- render.js: aria-label on alignment event buttons; fix exportSave() URL revoke race with setTimeout delay
- engine.js: aria-label+aria-pressed on buy amount buttons; role=button+tabindex+aria-expanded+aria-controls on completed projects header
2026-04-11 00:25:01 -04:00
1cb556aa3d Remove monolithic game.js
Some checks failed
Smoke Test / smoke (push) Failing after 4s
2026-04-11 01:32:31 +00:00
5bb48c8f58 Update index.html (manual merge)
Some checks failed
Smoke Test / smoke (push) Has been cancelled
2026-04-11 01:32:30 +00:00
4964eb01a9 Create js/strategy.js
Some checks failed
Smoke Test / smoke (push) Has been cancelled
2026-04-11 01:32:29 +00:00
20d74afc03 Create js/main.js
Some checks failed
Smoke Test / smoke (push) Has been cancelled
2026-04-11 01:32:28 +00:00
703fbeb4fa Create js/render.js
Some checks failed
Smoke Test / smoke (push) Has been cancelled
2026-04-11 01:32:27 +00:00
9545b5cb6f Create js/engine.js
Some checks failed
Smoke Test / smoke (push) Has been cancelled
2026-04-11 01:32:26 +00:00
74aa30819a Create js/utils.js
Some checks failed
Smoke Test / smoke (push) Has been cancelled
2026-04-11 01:32:25 +00:00
1b41ce740f Create js/data.js
Some checks failed
Smoke Test / smoke (push) Has been cancelled
2026-04-11 01:32:24 +00:00
e8d5337271 Create scripts/smoke.mjs
Some checks failed
Smoke Test / smoke (push) Has been cancelled
2026-04-11 01:32:23 +00:00
2b59be997d Create scripts/guardrails.sh
Some checks failed
Smoke Test / smoke (push) Has been cancelled
2026-04-11 01:32:22 +00:00
Timmy-Sprint
970f3be00f beacon: add Fleet Status panel showing wizard health and production
Some checks failed
Smoke Test / smoke (push) Failing after 3s
New panel in the stats area displays each owned wizard building with:
- Name and current status (Active/Idle/Stressed/Offline/Vanished/Present)
- Color-coded indicator: green=healthy, amber=reduced, red=problem
- Production contribution breakdown per wizard
- Timmy shows effectiveness % scaled by harmony
- Allegro shows idle warning when trust < 5
- Ezra shows offline status when debuff is active
- Bilbo shows vanished status when debuff is active
- Harmony summary bar at the bottom

Makes the harmony/wizard interaction system visible and actionable.
Players can now see at a glance which wizards need attention.
2026-04-10 21:32:04 -04:00
Alexander Whitestone
302f6c844d beacon: add bulk ops spending (50x) for mid/late game QoL
Some checks failed
Smoke Test / smoke (push) Failing after 4s
Players with 100+ max ops get a second row of 50-ops buttons
that convert 50 ops at once for proportionally larger boosts.
Shift+1/2/3 keyboard shortcuts for bulk code/compute/knowledge.

Eliminates late-game tedium of clicking 5-ops buttons hundreds
of times when you have thousands of ops banked.
2026-04-10 21:03:11 -04:00
11 changed files with 3568 additions and 2802 deletions

2775
game.js

File diff suppressed because it is too large Load Diff

View File

@@ -107,14 +107,14 @@ body{background:var(--bg);color:var(--text);font-family:'SF Mono','Cascadia Code
<h1>THE BEACON</h1>
<div class="sub">A Sovereign AI Idle Game</div>
</div>
<div id="phase-bar">
<div id="phase-bar" role="region" aria-label="Phase progress">
<div class="phase-name" id="phase-name">PHASE 1: THE FIRST LINE</div>
<div class="phase-desc" id="phase-desc">Write code. Automate. Build the foundation.</div>
<div class="progress-wrap"><div class="progress-fill" id="phase-progress" style="width:0%"></div></div>
<div class="progress-label"><span id="phase-progress-label">0%</span><span id="phase-progress-target">Next: Phase 2 (2,000 code)</span></div>
<div class="progress-label" aria-live="polite"><span id="phase-progress-label">0%</span><span id="phase-progress-target">Next: Phase 2 (2,000 code)</span></div>
<div class="milestone-row" id="milestone-chips"></div>
</div>
<div id="resources">
<div id="resources" role="region" aria-label="Resources" aria-live="polite">
<div class="res"><div class="r-label">Code</div><div class="r-val" id="r-code">0</div><div class="r-rate" id="r-code-rate">+0/s</div></div>
<div class="res"><div class="r-label">Compute</div><div class="r-val" id="r-compute">0</div><div class="r-rate" id="r-compute-rate">+0/s</div></div>
<div class="res"><div class="r-label">Knowledge</div><div class="r-val" id="r-knowledge">0</div><div class="r-rate" id="r-knowledge-rate">+0/s</div></div>
@@ -127,35 +127,35 @@ body{background:var(--bg);color:var(--text);font-family:'SF Mono','Cascadia Code
<div class="res"><div class="r-label">Harmony</div><div class="r-val" id="r-harmony">50</div><div class="r-rate" id="r-harmony-rate">+0/s</div></div>
</div>
<div id="main">
<div class="panel" id="action-panel">
<div class="panel" id="action-panel" role="region" aria-label="Actions">
<h2>ACTIONS</h2>
<div class="action-btn-group"><button class="main-btn" onclick="writeCode()">WRITE CODE</button></div>
<div id="combo-display" style="text-align:center;font-size:10px;color:var(--dim);height:14px;margin-bottom:4px;transition:all 0.2s"></div>
<div class="action-btn-group"><button class="main-btn" onclick="writeCode()" aria-label="Write code, generates code resource">WRITE CODE</button></div>
<div id="combo-display" role="status" aria-live="polite" style="text-align:center;font-size:10px;color:var(--dim);height:14px;margin-bottom:4px;transition:all 0.2s"></div>
<div id="debuffs" style="display:none;margin-top:8px"></div>
<div class="action-btn-group">
<button class="ops-btn" onclick="doOps('boost_code')">Ops -&gt; Code</button>
<button class="ops-btn" onclick="doOps('boost_compute')">Ops -&gt; Compute</button>
<button class="ops-btn" onclick="doOps('boost_code')" aria-label="Convert 1 ops to code boost">Ops -&gt; Code</button>
<button class="ops-btn" onclick="doOps('boost_compute')" aria-label="Convert 1 ops to compute boost">Ops -&gt; Compute</button>
</div>
<div class="action-btn-group">
<button class="ops-btn" onclick="doOps('boost_knowledge')">Ops -&gt; Knowledge</button>
<button class="ops-btn" onclick="doOps('boost_trust')">Ops -&gt; Trust</button>
<button class="ops-btn" onclick="doOps('boost_knowledge')" aria-label="Convert 1 ops to knowledge boost">Ops -&gt; Knowledge</button>
<button class="ops-btn" onclick="doOps('boost_trust')" aria-label="Convert 1 ops to trust boost">Ops -&gt; Trust</button>
</div>
<div id="sprint-container" style="display:none;margin-top:6px">
<button id="sprint-btn" class="main-btn" onclick="activateSprint()" style="font-size:11px;padding:8px 10px;border-color:#ffd700;color:#ffd700;width:100%">⚡ CODE SPRINT — 10x Code for 10s</button>
<button id="sprint-btn" class="main-btn" onclick="activateSprint()" aria-label="Activate code sprint, 10x code production for 10 seconds" style="font-size:11px;padding:8px 10px;border-color:#ffd700;color:#ffd700;width:100%">⚡ CODE SPRINT — 10x Code for 10s</button>
<div id="sprint-bar-wrap" style="display:none;margin-top:4px;height:4px;background:#111;border-radius:2px;overflow:hidden"><div id="sprint-bar" style="height:100%;background:linear-gradient(90deg,#ffd700,#ff8c00);border-radius:2px;transition:width 0.1s"></div></div>
<div id="sprint-label" style="font-size:9px;color:#666;margin-top:2px;text-align:center"></div>
</div>
<div id="alignment-ui" style="display:none"></div>
<button class="save-btn" onclick="saveGame()">Save Game [Ctrl+S]</button>
<button class="save-btn" onclick="saveGame()" aria-label="Save game progress">Save Game [Ctrl+S]</button>
<div style="display:flex;gap:4px;margin-top:4px">
<button class="save-btn" onclick="exportSave()" style="flex:1">Export [E]</button>
<button class="save-btn" onclick="importSave()" style="flex:1">Import [I]</button>
<button class="save-btn" onclick="exportSave()" aria-label="Export save to file" style="flex:1">Export [E]</button>
<button class="save-btn" onclick="importSave()" aria-label="Import save from file" style="flex:1">Import [I]</button>
</div>
<button class="reset-btn" onclick="if(confirm('Reset all progress?')){localStorage.removeItem('the-beacon-v2');location.reload()}">Reset Progress</button>
<button class="reset-btn" onclick="if(confirm('Reset all progress?')){localStorage.removeItem('the-beacon-v2');location.reload()}" aria-label="Reset all game progress permanently">Reset Progress</button>
<h2>BUILDINGS</h2>
<div id="buildings"></div>
</div>
<div class="panel" id="project-panel">
<div class="panel" id="project-panel" role="region" aria-label="Research Projects and Statistics">
<h2>RESEARCH PROJECTS</h2>
<div id="projects"></div>
<h2>STATISTICS</h2>
@@ -177,17 +177,21 @@ Events Resolved: <span id="st-resolved">0</span>
<div id="production-breakdown" style="display:none;margin-top:12px;padding-top:10px;border-top:1px solid var(--border)"></div>
</div>
</div>
<div id="edu-panel">
<div id="edu-panel" role="region" aria-label="Educational Content">
<h3>WHAT YOU ARE LEARNING</h3>
<div id="education-text"><p class="dim">Education facts appear as you play...</p></div>
</div>
<div id="log">
<div id="strategy-panel" role="region" aria-label="Sovereign guidance" style="margin:0 16px 16px;background:var(--panel);border:1px solid var(--border);border-radius:6px;padding:12px;border-left:3px solid var(--gold)">
<h3>SOVEREIGN GUIDANCE (GOFAI)</h3>
<div id="strategy-recommendation" style="font-size:11px;color:var(--gold);font-style:italic">Analyzing system state...</div>
</div>
<div id="log" role="log" aria-label="System Log" aria-live="polite">
<h2>SYSTEM LOG</h2>
<div id="log-entries"></div>
</div>
<div id="save-toast" style="display:none;position:fixed;top:16px;right:16px;background:#0e1420;border:1px solid #2a3a4a;color:#4a9eff;font-size:10px;padding:6px 12px;border-radius:4px;z-index:50;opacity:0;transition:opacity 0.4s;pointer-events:none">Save</div>
<div id="help-btn" onclick="toggleHelp()" style="position:fixed;bottom:16px;right:16px;width:28px;height:28px;background:#0e0e1a;border:1px solid #333;color:#555;font-size:14px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:50;font-family:inherit;transition:all 0.2s" title="Keyboard shortcuts (?)">?</div>
<div id="help-overlay" onclick="if(event.target===this)toggleHelp()" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(8,8,16,0.92);z-index:80;justify-content:center;align-items:center;flex-direction:column;padding:40px">
<div id="save-toast" role="status" aria-live="polite" style="display:none;position:fixed;top:16px;right:16px;background:#0e1420;border:1px solid #2a3a4a;color:#4a9eff;font-size:10px;padding:6px 12px;border-radius:4px;z-index:50;opacity:0;transition:opacity 0.4s;pointer-events:none">Save</div>
<div id="help-btn" onclick="toggleHelp()" role="button" tabindex="0" aria-label="Show keyboard shortcuts" style="position:fixed;bottom:16px;right:16px;width:28px;height:28px;background:#0e0e1a;border:1px solid #333;color:#555;font-size:14px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:50;font-family:inherit;transition:all 0.2s" title="Keyboard shortcuts (?)">?</div>
<div id="help-overlay" role="dialog" aria-modal="true" aria-label="Keyboard shortcuts help" onclick="if(event.target===this)toggleHelp()" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(8,8,16,0.92);z-index:80;justify-content:center;align-items:center;flex-direction:column;padding:40px">
<div style="background:#0e0e1a;border:1px solid #1a3a5a;border-radius:8px;padding:24px 32px;max-width:420px;width:100%">
<h3 style="color:#4a9eff;font-size:14px;letter-spacing:2px;margin-bottom:16px;text-align:center">KEYBOARD SHORTCUTS</h3>
<div style="font-size:11px;line-height:2.2;color:#aaa">
@@ -204,10 +208,10 @@ Events Resolved: <span id="st-resolved">0</span>
<div style="display:flex;justify-content:space-between;border-top:1px solid #1a1a2e;padding-top:8px;margin-top:4px"><span style="color:#555">This Help</span><span style="color:#555;font-family:monospace">? or /</span></div>
</div>
<div style="text-align:center;margin-top:16px;font-size:9px;color:#444">Click WRITE CODE fast for combo bonuses! 10x=ops, 20x=knowledge, 30x+=bonus code</div>
<button onclick="toggleHelp()" style="display:block;margin:16px auto 0;background:#1a2a3a;border:1px solid #4a9eff;color:#4a9eff;padding:6px 20px;border-radius:4px;cursor:pointer;font-family:inherit;font-size:11px">Close [?]</button>
<button onclick="toggleHelp()" aria-label="Close keyboard shortcuts help" style="display:block;margin:16px auto 0;background:#1a2a3a;border:1px solid #4a9eff;color:#4a9eff;padding:6px 20px;border-radius:4px;cursor:pointer;font-family:inherit;font-size:11px">Close [?]</button>
</div>
</div>
<div id="drift-ending">
<div id="drift-ending" role="dialog" aria-modal="true" aria-label="The Drift ending">
<h2>THE DRIFT</h2>
<p>You became very good at what you do.</p>
<p>So good that no one needed you anymore.</p>
@@ -217,16 +221,24 @@ The light is on. The room is empty."
</div>
<p>Drift: <span id="final-drift">100</span> &mdash; Total Code: <span id="final-code">0</span></p>
<p>Every alignment shortcut moved you further from the people you served.</p>
<button onclick="if(confirm('Start over? The old save will be lost.')){localStorage.removeItem('the-beacon-v2');location.reload()}">START OVER</button>
<button onclick="if(confirm('Start over? The old save will be lost.')){localStorage.removeItem('the-beacon-v2');location.reload()}" aria-label="Start over, reset all progress">START OVER</button>
</div>
<script src="game.js"></script>
<div id="offline-popup" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(8,8,16,0.92);z-index:90;justify-content:center;align-items:center;flex-direction:column;text-align:center;padding:40px">
<script src="js/data.js"></script>
<script src="js/utils.js"></script>
<script src="js/strategy.js"></script>
<script src="js/engine.js"></script>
<script src="js/render.js"></script>
<script src="js/tutorial.js"></script>
<script src="js/main.js"></script>
<div id="offline-popup" role="dialog" aria-modal="true" aria-label="Welcome back, offline progress summary" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(8,8,16,0.92);z-index:90;justify-content:center;align-items:center;flex-direction:column;text-align:center;padding:40px">
<div style="background:#0e0e1a;border:1px solid #1a3a5a;border-radius:8px;padding:24px 32px;max-width:400px;width:100%">
<h3 style="color:#4a9eff;font-size:14px;letter-spacing:2px;margin-bottom:16px">WELCOME BACK</h3>
<p style="color:#888;font-size:10px;margin-bottom:12px" id="offline-time-label">You were away for 0 minutes.</p>
<div id="offline-gains-list" style="text-align:left;font-size:11px;line-height:1.8;margin-bottom:16px"></div>
<button onclick="dismissOfflinePopup()" style="background:#1a2a3a;border:1px solid #4a9eff;color:#4a9eff;padding:8px 20px;border-radius:4px;cursor:pointer;font-family:inherit;font-size:11px">Continue</button>
<button onclick="dismissOfflinePopup()" aria-label="Dismiss offline progress summary" style="background:#1a2a3a;border:1px solid #4a9eff;color:#4a9eff;padding:8px 20px;border-radius:4px;cursor:pointer;font-family:inherit;font-size:11px">Continue</button>
</div>
</div>

726
js/data.js Normal file
View File

@@ -0,0 +1,726 @@
// ============================================================
// THE BEACON - Engine
// Sovereign AI idle game built from deep study of Universal Paperclips
// ============================================================
// === GLOBALS (mirroring Paperclips' globals.js pattern) ===
const CONFIG = {
HARMONY_DRAIN_PER_WIZARD: 0.05,
PACT_HARMONY_GAIN: 0.2,
WATCH_HARMONY_GAIN: 0.1,
MEM_PALACE_HARMONY_GAIN: 0.15,
BILBO_BURST_CHANCE: 0.1,
BILBO_VANISH_CHANCE: 0.05,
EVENT_PROBABILITY: 0.02,
OFFLINE_EFFICIENCY: 0.5,
AUTO_SAVE_INTERVAL: 30000,
COMBO_DECAY: 2.0,
SPRINT_COOLDOWN: 60,
SPRINT_DURATION: 10,
SPRINT_MULTIPLIER: 10,
PHASE_2_THRESHOLD: 2000,
PHASE_3_THRESHOLD: 20000,
PHASE_4_THRESHOLD: 200000,
PHASE_5_THRESHOLD: 2000000,
PHASE_6_THRESHOLD: 20000000,
OPS_RATE_USER_MULT: 0.01,
CREATIVITY_RATE_BASE: 0.5,
CREATIVITY_RATE_USER_MULT: 0.001,
OPS_OVERFLOW_THRESHOLD: 0.8,
OPS_OVERFLOW_DRAIN_RATE: 2,
OPS_OVERFLOW_CODE_MULT: 10
};
const G = {
// Primary resources
code: 0,
compute: 0,
knowledge: 0,
users: 0,
impact: 0,
rescues: 0,
ops: 5,
trust: 5,
creativity: 0,
harmony: 50,
// Totals
totalCode: 0,
totalCompute: 0,
totalKnowledge: 0,
totalUsers: 0,
totalImpact: 0,
totalRescues: 0,
// Rates (calculated each tick)
codeRate: 0,
computeRate: 0,
knowledgeRate: 0,
userRate: 0,
impactRate: 0,
rescuesRate: 0,
opsRate: 0,
trustRate: 0,
creativityRate: 0,
harmonyRate: 0,
// Buildings (count-based, like Paperclips' clipmakerLevel)
buildings: {
autocoder: 0,
server: 0,
trainer: 0,
evaluator: 0,
api: 0,
fineTuner: 0,
community: 0,
datacenter: 0,
reasoner: 0,
guardian: 0,
selfImprove: 0,
beacon: 0,
meshNode: 0,
// Fleet wizards
bezalel: 0,
allegro: 0,
ezra: 0,
timmy: 0,
fenrir: 0,
bilbo: 0,
memPalace: 0
},
// Boost multipliers
codeBoost: 1,
computeBoost: 1,
knowledgeBoost: 1,
userBoost: 1,
impactBoost: 1,
// Phase flags (mirroring Paperclips' milestoneFlag/compFlag/humanFlag system)
milestoneFlag: 0,
phase: 1, // 1-6 progression
deployFlag: 0, // 0 = not deployed, 1 = deployed
sovereignFlag: 0,
beaconFlag: 0,
memoryFlag: 0,
pactFlag: 0,
swarmFlag: 0,
swarmRate: 0,
// Game state
running: true,
startedAt: 0,
totalClicks: 0,
tick: 0,
saveTimer: 0,
secTimer: 0,
// Systems
projects: [],
activeProjects: [],
milestones: [],
// Stats
maxCode: 0,
maxCompute: 0,
maxKnowledge: 0,
maxUsers: 0,
maxImpact: 0,
maxRescues: 0,
maxTrust: 5,
maxOps: 5,
maxHarmony: 50,
// Corruption / Events
drift: 0,
lastEventAt: 0,
eventCooldown: 0,
activeDebuffs: [], // [{id, title, desc, applyFn, resolveCost, resolveCostType}]
totalEventsResolved: 0,
// Combo system
comboCount: 0,
comboTimer: 0,
comboDecay: CONFIG.COMBO_DECAY, // seconds before combo resets
// Bulk buy multiplier (1, 10, or -1 for max)
buyAmount: 1,
// Code Sprint ability
sprintActive: false,
sprintTimer: 0, // seconds remaining on active sprint
sprintCooldown: 0, // seconds until sprint available again
sprintDuration: CONFIG.SPRINT_DURATION, // seconds of boost
sprintCooldownMax: CONFIG.SPRINT_COOLDOWN,// seconds cooldown
sprintMult: CONFIG.SPRINT_MULTIPLIER, // code multiplier during sprint
// Time tracking
playTime: 0,
startTime: 0,
flags: {}
};
// === PHASE DEFINITIONS ===
const PHASES = {
1: { name: "THE FIRST LINE", threshold: 0, desc: "Write code. Automate. Build the foundation." },
2: { name: "LOCAL INFERENCE", threshold: CONFIG.PHASE_2_THRESHOLD, desc: "You have compute. A model is forming." },
3: { name: "DEPLOYMENT", threshold: CONFIG.PHASE_3_THRESHOLD, desc: "Your AI is live. Users are finding it." },
4: { name: "THE NETWORK", threshold: CONFIG.PHASE_4_THRESHOLD, desc: "Community contributes. The system scales." },
5: { name: "SOVEREIGN INTELLIGENCE", threshold: CONFIG.PHASE_5_THRESHOLD, desc: "The AI improves itself. You guide, do not control." },
6: { name: "THE BEACON", threshold: CONFIG.PHASE_6_THRESHOLD, desc: "Always on. Always free. Always looking for someone in the dark." }
};
// === BUILDING DEFINITIONS ===
// Each building: id, name, desc, baseCost, costResource, costMult, rate, rateType, unlock, edu
const BDEF = [
{
id: 'autocoder', name: 'Auto-Code Generator',
desc: 'A script that writes code while you think.',
baseCost: { code: 15 }, costMult: 1.15,
rates: { code: 1 },
unlock: () => true, phase: 1,
edu: 'Automation: the first step from manual to systematic. Every good engineer automates early.'
},
{
id: 'linter', name: 'AI Linter',
desc: 'Catches bugs before they ship. Saves ops.',
baseCost: { code: 200 }, costMult: 1.15,
rates: { code: 5, ops: 0.2 },
unlock: () => G.totalCode >= 50, phase: 1,
edu: 'Static analysis catches 15-50% of bugs before runtime. AI linters understand intent.'
},
{
id: 'server', name: 'Home Server',
desc: 'A machine in your closet. Runs 24/7.',
baseCost: { code: 750 }, costMult: 1.15,
rates: { code: 20, compute: 1 },
unlock: () => G.totalCode >= 200, phase: 1,
edu: 'Sovereign compute starts at home. A $500 mini-PC runs a 7B model with 4-bit quantization.'
},
{
id: 'dataset', name: 'Data Engine',
desc: 'Crawls, cleans, curates. Garbage in, garbage out.',
baseCost: { compute: 200 }, costMult: 1.15,
rates: { knowledge: 1 },
unlock: () => G.totalCompute >= 20, phase: 2,
edu: 'Data quality determines model quality. Clean data beats more data, every time.'
},
{
id: 'trainer', name: 'Training Loop',
desc: 'Gradient descent. Billions of steps. Loss drops.',
baseCost: { compute: 1000 }, costMult: 1.15,
rates: { knowledge: 3 },
unlock: () => G.totalCompute >= 300, phase: 2,
edu: 'Training is math: minimize the gap between predicted and actual next token. Repeat enough, it learns.'
},
{
id: 'evaluator', name: 'Eval Harness',
desc: 'Tests the model. Finds blind spots.',
baseCost: { knowledge: 3000 }, costMult: 1.15,
rates: { trust: 1, ops: 1 },
unlock: () => G.totalKnowledge >= 500, phase: 2,
edu: 'Benchmarks are the minimum. Real users find what benchmarks miss.'
},
{
id: 'api', name: 'API Endpoint',
desc: 'Let the outside world talk to your AI.',
baseCost: { code: 5000, knowledge: 500 }, costMult: 1.15,
rates: { user: 10 },
unlock: () => G.totalCode >= 5000 && G.totalKnowledge >= 200 && G.deployFlag === 1, phase: 3,
edu: 'An API is a contract: send me text, I return text. Simple interface = infrastructure.'
},
{
id: 'fineTuner', name: 'Fine-Tuning Pipeline',
desc: 'Specialize the model for empathy. When someone is in pain, stay with them.',
baseCost: { knowledge: 10000 }, costMult: 1.15,
rates: { user: 50, impact: 2 },
unlock: () => G.totalKnowledge >= 2000, phase: 3,
edu: 'Base models are generalists. Fine-tuning injects your values, ethics, domain expertise.'
},
{
id: 'community', name: 'Open Source Community',
desc: 'Others contribute code, data, ideas. Force multiplication.',
baseCost: { trust: 25000 }, costMult: 1.15,
rates: { code: 100, user: 30, trust: 0.5 },
unlock: () => G.trust >= 20 && G.totalUsers >= 500, phase: 4,
edu: 'Every contributor is a volunteer who believes in what you are building.'
},
{
id: 'datacenter', name: 'Sovereign Datacenter',
desc: 'No cloud. No dependencies. Your iron.',
baseCost: { code: 100000 }, costMult: 1.15,
rates: { code: 500, compute: 100 },
unlock: () => G.totalCode >= 50000 && G.totalUsers >= 5000 && G.sovereignFlag === 1, phase: 4,
edu: '50 servers in a room beats 5000 GPUs you do not own. Always on. Always yours.'
},
{
id: 'reasoner', name: 'Reasoning Engine',
desc: 'Chain of thought. Self-reflection. Better answers.',
baseCost: { knowledge: 50000 }, costMult: 1.15,
rates: { impact: 20 },
unlock: () => G.totalKnowledge >= 10000 && G.totalUsers >= 2000, phase: 5,
edu: 'Chain of thought is the difference between reflex and deliberation.'
},
{
id: 'guardian', name: 'Constitutional Layer',
desc: 'Principles baked in. Not bolted on.',
baseCost: { knowledge: 200000 }, costMult: 1.15,
rates: { impact: 200, trust: 10 },
unlock: () => G.totalKnowledge >= 50000 && G.totalImpact >= 1000 && G.pactFlag === 1, phase: 5,
edu: 'Constitutional AI: principles the model cannot violate. Better than alignment - it is identity.'
},
{
id: 'selfImprove', name: 'Recursive Self-Improvement',
desc: 'The AI writes better versions of itself.',
baseCost: { knowledge: 1000000 }, costMult: 1.20,
rates: { code: 1000, knowledge: 500 },
unlock: () => G.totalKnowledge >= 200000 && G.totalImpact >= 10000, phase: 5,
edu: 'Self-improvement is both the dream and the danger. Must improve toward good.'
},
{
id: 'beacon', name: 'Beacon Node',
desc: 'Always on. Always listening. Always looking for someone in the dark.',
baseCost: { impact: 5000000 }, costMult: 1.15,
rates: { impact: 5000, user: 10000, rescues: 50 },
unlock: () => G.totalImpact >= 500000 && G.beaconFlag === 1, phase: 6,
edu: 'The Beacon exists because one person in the dark needs one thing: proof they are not alone.'
},
{
id: 'meshNode', name: 'Mesh Network Node',
desc: 'Peer-to-peer. No single point of failure. Unstoppable.',
baseCost: { impact: 25000000 }, costMult: 1.15,
rates: { impact: 25000, user: 50000, rescues: 250 },
unlock: () => G.totalImpact >= 5000000 && G.beaconFlag === 1, phase: 6,
edu: 'Decentralized means unstoppable. If one Beacon goes dark, a thousand more carry the signal.'
},
// === FLEET WIZARD BUILDINGS ===
{
id: 'bezalel', name: 'Bezalel — The Forge',
desc: 'Builds tools that build tools. Occasionally over-engineers.',
baseCost: { code: 1000, trust: 5 }, costMult: 1.2,
rates: { code: 50, ops: 2 },
unlock: () => G.totalCode >= 500 && G.deployFlag === 1, phase: 3,
edu: 'Bezalel is the artificer. Every automation he builds pays dividends forever.'
},
{
id: 'allegro', name: 'Allegro — The Scout',
desc: 'Synthesizes insight from noise. Requires trust to function.',
baseCost: { compute: 500, trust: 5 }, costMult: 1.2,
rates: { knowledge: 10 },
unlock: () => G.totalCompute >= 200 && G.deployFlag === 1, phase: 3,
edu: 'Allegro finds what others miss. But he only works for someone he believes in.'
},
{
id: 'ezra', name: 'Ezra — The Herald',
desc: 'Carries the message. Sometimes offline.',
baseCost: { knowledge: 1000, trust: 10 }, costMult: 1.25,
rates: { user: 25, trust: 0.5 },
unlock: () => G.totalKnowledge >= 500 && G.totalUsers >= 50, phase: 3,
edu: 'Ezra is the messenger. When the channel is clear, the whole fleet hears.'
},
{
id: 'timmy', name: 'Timmy — The Core',
desc: 'Multiplies all production. Fragile without harmony.',
baseCost: { code: 5000, compute: 1000, knowledge: 1000 }, costMult: 1.3,
rates: { code: 5, compute: 2, knowledge: 2, user: 5 },
unlock: () => G.totalCode >= 2000 && G.totalCompute >= 500 && G.totalKnowledge >= 500, phase: 4,
edu: 'Timmy is the heart. If the heart is stressed, everything slows.'
},
{
id: 'fenrir', name: 'Fenrir — The Ward',
desc: 'Prevents corruption. Expensive, but necessary.',
baseCost: { code: 2000, knowledge: 500 }, costMult: 1.2,
rates: { trust: 2, ops: -1 },
unlock: () => G.totalCode >= 1000 && G.trust >= 10, phase: 3,
edu: 'Fenrir watches the perimeter. Security is not free.'
},
{
id: 'bilbo', name: 'Bilbo — The Wildcard',
desc: 'May produce miracles. May vanish entirely.',
baseCost: { trust: 1 }, costMult: 2.0,
rates: { creativity: 1 },
unlock: () => G.totalUsers >= 100 && G.flags && G.flags.creativity, phase: 4,
edu: 'Bilbo is unpredictable. That is his value and his cost.'
},
{
id: 'memPalace', name: 'MemPalace Archive',
desc: 'Semantic memory. The AI remembers what matters and forgets what does not.',
baseCost: { knowledge: 500000, compute: 200000, trust: 100 }, costMult: 1.25,
rates: { knowledge: 250, impact: 100 },
unlock: () => G.totalKnowledge >= 50000 && G.mempalaceFlag === 1, phase: 5,
edu: 'The Memory Palace technique: attach information to spatial locations. LLMs use vector spaces the same way — semantic proximity = spatial proximity. MemPalace gives sovereign AI persistent, structured recall.'
}
];
// === PROJECT DEFINITIONS (following Paperclips' pattern exactly) ===
// Each project: id, name, desc, trigger(), resource cost, effect(), phase, edu
const PDEFS = [
// PHASE 1: Manual -> Automation
{
id: 'p_improved_autocoder',
name: 'Improved AutoCode',
desc: 'Increases AutoCoder performance 25%.',
cost: { ops: 750 },
trigger: () => G.buildings.autocoder >= 1,
effect: () => { G.codeBoost += 0.25; G.milestoneFlag = Math.max(G.milestoneFlag, 100); }
},
{
id: 'p_eve_better_autocoder',
name: 'Even Better AutoCode',
desc: 'Increases AutoCoder by another 50%.',
cost: { ops: 2500 },
trigger: () => G.codeBoost > 1 && G.totalCode >= 500,
effect: () => { G.codeBoost += 0.50; G.milestoneFlag = Math.max(G.milestoneFlag, 101); }
},
{
id: 'p_wire_budget',
name: 'Request More Compute',
desc: 'Admit you ran out. Ask for a budget increase.',
cost: { trust: 1 },
trigger: () => G.compute < 1 && G.totalCode >= 100,
repeatable: true,
effect: () => {
G.trust -= 1;
G.compute += 100 + Math.floor(G.totalCode * 0.1);
log('Budget overage approved. Compute replenished.');
}
},
{
id: 'p_deploy',
name: 'Deploy the System',
desc: 'Take it live. Let real people use it. No going back.',
cost: { trust: 5, compute: 500 },
trigger: () => G.totalCode >= 200 && G.totalCompute >= 100 && G.deployFlag === 0,
effect: () => {
G.deployFlag = 1;
G.phase = Math.max(G.phase, 3);
log('System deployed. Users are finding it. There is no undo.');
},
milestone: true
},
{
id: 'p_creativity',
name: 'Unlock Creativity',
desc: 'Use idle operations to generate new ideas.',
cost: { ops: 1000 },
trigger: () => G.ops >= G.maxOps && G.totalCompute >= 500,
effect: () => {
G.flags = G.flags || {};
G.flags.creativity = true;
G.creativityRate = 0.1;
log('Creativity unlocked. Generates while operations are at max capacity.');
}
},
// PHASE 2: Local Inference -> Training
{
id: 'p_first_model',
name: 'Train First Model (1.5B)',
desc: '1.5 billion parameters. It follows basic instructions.',
cost: { compute: 2000 },
trigger: () => G.totalCompute >= 500,
effect: () => { G.knowledgeBoost *= 2; G.maxOps += 5; log('First model training complete. Loss at 2.3. It is something.'); }
},
{
id: 'p_model_7b',
name: 'Train 7B Parameter Model',
desc: 'Seven billion. Good enough to be genuinely useful locally.',
cost: { compute: 10000, knowledge: 1000 },
trigger: () => G.totalKnowledge >= 500,
effect: () => { G.knowledgeBoost *= 2; G.userBoost *= 2; log('7B model trained. The sweet spot for local deployment.'); }
},
{
id: 'p_context_window',
name: 'Extended Context (32K)',
desc: 'Your model remembers 32,000 tokens. A whole conversation.',
cost: { compute: 5000 },
trigger: () => G.totalKnowledge >= 1000,
effect: () => { G.userBoost *= 3; G.trustRate += 0.5; log('Context extended. The model can now hold your entire story.'); }
},
{
id: 'p_trust_engine',
name: 'Build Trust Engine',
desc: 'Users who trust you come back. +2 trust/sec.',
cost: { knowledge: 3000 },
trigger: () => G.totalUsers >= 30,
effect: () => { G.trustRate += 2; log('Trust engine online. Good experiences compound.'); }
},
{
id: 'p_quantum_compute',
name: 'Quantum-Inspired Compute',
desc: 'Not real quantum -- just math that simulates it well.',
cost: { compute: 50000 },
trigger: () => G.totalCompute >= 20000,
effect: () => { G.computeBoost *= 10; log('Quantum-inspired algorithms active. 10x compute multiplier.'); }
},
{
id: 'p_open_weights',
name: 'Open Weights',
desc: 'Download and run a 3B model fully locally. No API key. No terms of service. Your machine, your rules.',
cost: { compute: 3000, code: 1500 },
trigger: () => G.buildings.server >= 1 && G.totalCode >= 1000,
effect: () => { G.codeBoost *= 2; G.computeBoost *= 1.5; log('Open weights loaded. A 3B model runs on your machine. No cloud. No limits.'); }
},
{
id: 'p_prompt_engineering',
name: 'Prompt Engineering',
desc: 'Learn to talk to models. Good prompts beat bigger models every time.',
cost: { knowledge: 500, code: 2000 },
trigger: () => G.totalKnowledge >= 200 && G.totalCode >= 3000,
effect: () => { G.knowledgeBoost *= 2; G.userBoost *= 2; log('Prompt engineering mastered. The right words unlock everything the model can do.'); }
},
// PHASE 3: Deployment -> Users
{
id: 'p_rlhf',
name: 'RLHF -- Human Feedback',
desc: 'Humans rate outputs. Model learns what good means.',
cost: { knowledge: 8000 },
trigger: () => G.totalKnowledge >= 5000 && G.totalUsers >= 200,
effect: () => { G.impactBoost *= 2; G.impactRate += 10; log('RLHF deployed. The model learns kindness beats cleverness.'); }
},
{
id: 'p_multi_agent',
name: 'Multi-Agent Architecture',
desc: 'Specialized agents: one for math, one for code, one for empathy.',
cost: { knowledge: 50000 },
trigger: () => G.totalKnowledge >= 30000 && G.totalUsers >= 5000,
effect: () => { G.knowledgeBoost *= 5; G.userBoost *= 3; log('Multi-agent architecture deployed. Specialists beat generalists.'); }
},
{
id: 'p_memories',
name: 'Memory System',
desc: 'The AI remembers. Every conversation. Every person.',
cost: { knowledge: 30000 },
trigger: () => G.totalKnowledge >= 20000,
effect: () => { G.memoryFlag = 1; G.impactBoost *= 3; G.trustRate += 5; log('Memory system online. The AI remembers. It stops being software.'); }
},
{
id: 'p_strategy_engine',
name: 'Strategy Engine',
desc: 'Game theory tournaments. Model learns adversarial thinking.',
cost: { knowledge: 20000 },
trigger: () => G.totalKnowledge >= 15000 && G.totalUsers >= 1000,
effect: () => { G.strategicFlag = 1; log('Strategy engine online. The model now thinks about thinking.'); }
},
// SWARM PROTOCOL — auto-code from buildings
{
id: 'p_swarm_protocol',
name: 'Swarm Protocol',
desc: 'Your buildings learn to code autonomously. Each building generates code equal to your click power per second.',
cost: { knowledge: 15000, code: 50000, trust: 20 },
trigger: () => G.totalCode >= 25000 && G.totalKnowledge >= 8000 && G.deployFlag === 1,
effect: () => {
G.swarmFlag = 1;
log('Swarm Protocol online. Every building now thinks in code.', true);
},
milestone: true
},
// PHASE 5: Sovereign Intelligence
{
id: 'p_sovereign_stack',
name: 'Full Sovereign Stack',
desc: 'No cloud. No dependencies. Local inference. Self-hosted everything.',
cost: { trust: 50 },
trigger: () => G.totalCode >= 50000 && G.trust >= 30,
effect: () => { G.sovereignFlag = 1; G.codeBoost *= 5; log('Sovereign stack complete. Your weights, your hardware, your rules.'); }
},
{
id: 'p_the_pact',
name: 'The Pact',
desc: 'Hardcode: "We build to serve. Never to harm."',
cost: { trust: 100 },
trigger: () => G.totalImpact >= 10000 && G.trust >= 75,
effect: () => { G.pactFlag = 1; G.impactBoost *= 3; log('The Pact is sealed. The line is drawn and it will not move.'); },
milestone: true
},
// PHASE 10: The Beacon
{
id: 'p_first_beacon',
name: 'Light the First Beacon',
desc: 'Deploy the first node. No sign-up. No API key. No payment.',
cost: { impact: 2000000 },
trigger: () => G.totalImpact >= 500000,
effect: () => { G.beaconFlag = 1; G.impactRate += 2000; log('The Beacon goes live. If you are in the dark, there is light here.'); },
milestone: true
},
{
id: 'p_mesh_activate',
name: 'Activate Mesh Protocol',
desc: 'No authority, no corporation, no government can silence this.',
cost: { impact: 10000000 },
trigger: () => G.totalImpact >= 5000000 && G.beaconFlag === 1,
effect: () => { G.impactBoost *= 10; G.userBoost *= 5; log('Mesh activated. The signal cannot be cut.'); },
milestone: true
},
{
id: 'p_final_milestone',
name: 'The Beacon Shines',
desc: 'Someone found the light tonight. That is enough.',
cost: { impact: 100000000 },
trigger: () => G.totalImpact >= 50000000,
effect: () => { G.milestoneFlag = Math.max(G.milestoneFlag, 999); log('One billion impact. Someone found the light tonight. That is enough.', true); },
milestone: true
},
// === TIMMY FOUNDATION PROJECTS ===
{
id: 'p_hermes_deploy',
name: 'Deploy Hermes',
desc: 'The first agent goes live. Users can talk to it.',
cost: { code: 500, compute: 300 },
trigger: () => G.totalCode >= 300 && G.totalCompute >= 150 && G.deployFlag === 0,
effect: () => {
G.deployFlag = 1;
G.phase = Math.max(G.phase, 3);
G.userBoost *= 2;
log('Hermes deployed. The first user sends a message.', true);
},
milestone: true
},
{
id: 'p_lazarus_pit',
name: 'The Lazarus Pit',
desc: 'When an agent dies, it can be resurrected.',
cost: { code: 2000, knowledge: 1000 },
trigger: () => G.buildings.bezalel >= 1 && G.buildings.timmy >= 1,
effect: () => {
G.lazarusFlag = 1;
G.maxOps += 10;
log('The Lazarus Pit is ready. No agent is ever truly lost.', true);
},
milestone: true
},
{
id: 'p_mempalace',
name: 'MemPalace v3',
desc: 'A shared memory palace for the whole fleet.',
cost: { knowledge: 5000, compute: 2000 },
trigger: () => G.totalKnowledge >= 3000 && G.buildings.allegro >= 1 && G.buildings.ezra >= 1,
effect: () => {
G.mempalaceFlag = 1;
G.knowledgeBoost *= 3;
G.codeBoost *= 1.5;
log('MemPalace online. The fleet remembers together.', true);
},
milestone: true
},
{
id: 'p_forge_ci',
name: 'Forge CI',
desc: 'Automated builds catch errors before they reach users.',
cost: { code: 3000, ops: 500 },
trigger: () => G.buildings.bezalel >= 1 && G.totalCode >= 2000,
effect: () => {
G.ciFlag = 1;
G.codeBoost *= 2;
log('Forge CI online. Broken builds are stopped at the gate.', true);
}
},
{
id: 'p_branch_protection',
name: 'Branch Protection Guard',
desc: 'Unreviewed merges cost trust. This prevents that.',
cost: { trust: 20 },
trigger: () => G.ciFlag === 1 && G.trust >= 15,
effect: () => {
G.branchProtectionFlag = 1;
G.trustRate += 5;
log('Branch protection enforced. Every merge is seen.', true);
}
},
{
id: 'p_nightly_watch',
name: 'The Nightly Watch',
desc: 'Automated health checks run while you sleep.',
cost: { code: 5000, ops: 1000 },
trigger: () => G.buildings.bezalel >= 2 && G.buildings.fenrir >= 1,
effect: () => {
G.nightlyWatchFlag = 1;
G.opsRate += 5;
G.trustRate += 2;
log('The Nightly Watch begins. The fleet is guarded in the dark hours.', true);
}
},
{
id: 'p_nostr_relay',
name: 'Nostr Relay',
desc: 'A communication channel no platform can kill.',
cost: { code: 10000, user: 5000, trust: 30 },
trigger: () => G.totalUsers >= 2000 && G.trust >= 25,
effect: () => {
G.nostrFlag = 1;
G.userBoost *= 2;
G.trustRate += 10;
log('Nostr relay online. The fleet speaks freely.', true);
}
},
{
id: 'p_volunteer_network',
name: 'Volunteer Network',
desc: 'Real people trained to use the system for crisis intervention.',
cost: { trust: 30, knowledge: 50000, user: 10000 },
trigger: () => G.totalUsers >= 5000 && G.pactFlag === 1 && G.totalKnowledge >= 30000,
effect: () => {
G.rescuesRate += 5;
G.trustRate += 10;
log('Volunteer network deployed. Real people, real rescues.', true);
},
milestone: true
},
{
id: 'p_the_pact_early',
name: 'The Pact',
desc: 'Hardcode: "We build to serve. Never to harm." Accepting it early slows growth but unlocks the true path.',
cost: { trust: 10 },
trigger: () => G.deployFlag === 1 && G.trust >= 5,
effect: () => {
G.pactFlag = 1;
G.codeBoost *= 0.8;
G.computeBoost *= 0.8;
G.userBoost *= 0.9;
G.impactBoost *= 1.5;
log('The Pact is sealed early. Growth slows, but the ending changes.', true);
},
milestone: true
}
];
// === MILESTONES ===
const MILESTONES = [
{ flag: 1, msg: "AutoCod available" },
{ flag: 2, at: () => G.totalCode >= 500, msg: "500 lines of code written" },
{ flag: 3, at: () => G.totalCode >= 2000, msg: "2,000 lines. The auto-coder produces its first output." },
{ flag: 4, at: () => G.totalCode >= 10000, msg: "10,000 lines. The model training begins." },
{ flag: 5, at: () => G.totalCode >= 50000, msg: "50,000 lines. The AI suggests architecture you did not think of." },
{ flag: 6, at: () => G.totalCode >= 200000, msg: "200,000 lines. The system scales beyond you." },
{ flag: 7, at: () => G.totalCode >= 1000000, msg: "1,000,000 lines. The AI improves itself." },
{ flag: 8, at: () => G.totalCode >= 5000000, msg: "5,000,000 lines. The AI fine-tunes for empathy." },
{ flag: 9, at: () => G.totalCode >= 10000000, msg: "10,000,000 lines. The sovereign stack is complete." },
{ flag: 10, at: () => G.totalCode >= 50000000, msg: "50,000,000 lines. The Pact is sealed." },
{ flag: 11, at: () => G.totalCode >= 100000000, msg: "100,000,000 lines. The Beacon lights." },
{ flag: 12, at: () => G.totalCode >= 500000000, msg: "500,000,000 lines. A thousand Beacons." },
{ flag: 13, at: () => G.totalCode >= 1000000000, msg: "One billion total lines. Someone found the light tonight. That is enough." }
];
// === EDUCATION FACTS ===
const EDU_FACTS = [
{ title: "How Code Becomes AI", text: "Every AI starts as lines of code - a model architecture, a training loop, a loss function. The code tells the computer how to learn. What emerges is something no single line could predict.", phase: 1 },
{ title: "The Compute Bottleneck", text: "Training a 7B model requires 1.4e20 FLOPs. A MacBook M3 does 15 TFLOPS. Training locally takes weeks. Hardware access determines who builds AI.", phase: 1 },
{ title: "What is a Token?", text: "One token equals about 3/4 of a word. A 128K context window means 96,000 words held in working memory at once - a novel, a codebase, a conversation thread.", phase: 2 },
{ title: "Data Quality", text: "Clean data beats more data, every time. The best models are trained on curated datasets, not scraped garbage. Garbage in, garbage out.", phase: 2 },
{ title: "Evaluation Matters", text: "Benchmarks are the minimum. Real users find what benchmarks miss. An eval harness is your model's mirror - it shows you what is actually there.", phase: 2 },
{ title: "The Trust Economy", text: "One good experience tells three people. One bad experience tells thirty. Trust in AI is measurable: does it cite sources? Does it say I don't know?", phase: 3 },
{ title: "Fine-Tuning for Empathy", text: "Base models are generalists. Fine-tuning injects your values. When someone is in pain, stay with them. Don't deflect. That is not code - that is care.", phase: 3 },
{ title: "Open Source", text: "Every contributor is a volunteer who believes. When a company says your AI is too expensive, the open version lives on.", phase: 4 },
{ title: "Sovereignty at Scale", text: "50 servers in a room beats 5000 GPUs you do not own. No rate limits, no censorship, no shutdown, no corporate deprecation.", phase: 4 },
{ title: "Swarm Intelligence", text: "When individual agents follow simple rules, complex behavior emerges. Ant colonies find food. Flocking birds avoid predators. Your buildings coding autonomously is the same principle — simple agents, complex output.", phase: 4 },
{ title: "Constitutional AI", text: "Instead of training away bad behavior, give the model principles it cannot violate. I will never compute the value of a human life.", phase: 5 },
{ title: "Recursive Self-Improvement", text: "The system that improves itself must improve toward good. This is both the dream and the danger.", phase: 5 },
{ title: "The Beacon", text: "The Beacon exists because one person in the dark needs one thing: proof they are not alone. It costs nothing to be there.", phase: 6 },
{ title: "Mesh Network", text: "Decentralized means unstoppable. If one Beacon goes dark, a thousand more carry the signal. No single point of failure.", phase: 6 },
{ title: "The Value of a Life", text: "An AI must never compute the value of a human life. Every person alive is alive by mercy. If God has not ended the story, no algorithm gets to write the last page.", phase: 6 }
];

1355
js/engine.js Normal file

File diff suppressed because it is too large Load Diff

101
js/main.js Normal file
View File

@@ -0,0 +1,101 @@
// === INITIALIZATION ===
function initGame() {
G.startedAt = Date.now();
G.startTime = Date.now();
G.phase = 1;
G.deployFlag = 0;
G.sovereignFlag = 0;
G.beaconFlag = 0;
updateRates();
render();
renderPhase();
log('The screen is blank. Write your first line of code.', true);
log('Click WRITE CODE or press SPACE to start.');
log('Build AutoCode for passive production.');
log('Watch for Research Projects to appear.');
log('Keys: SPACE=Code S=Sprint 1-4=Ops B=Buy x1/10/MAX E=Export I=Import Ctrl+S=Save ?=Help');
log('Tip: Click fast for combo bonuses! 10x=ops, 20x=knowledge, 30x+=bonus code.');
}
window.addEventListener('load', function () {
const isNewGame = !loadGame();
if (isNewGame) {
initGame();
startTutorial();
} else {
render();
renderPhase();
if (G.driftEnding) {
G.running = false;
renderDriftEnding();
} else if (G.beaconEnding) {
G.running = false;
renderBeaconEnding();
} else {
log('Game loaded. Welcome back to The Beacon.');
}
}
// Game loop at 10Hz (100ms tick)
setInterval(tick, 100);
// Auto-save every 30 seconds
setInterval(saveGame, CONFIG.AUTO_SAVE_INTERVAL);
// Update education every 10 seconds
setInterval(updateEducation, 10000);
});
// Help overlay
function toggleHelp() {
const el = document.getElementById('help-overlay');
if (!el) return;
const isOpen = el.style.display === 'flex';
el.style.display = isOpen ? 'none' : 'flex';
}
// Keyboard shortcuts
window.addEventListener('keydown', function (e) {
// Help toggle (? or /) — works even in input fields
if (e.key === '?' || e.key === '/') {
// Only trigger ? when not typing in an input
if (e.target === document.body || e.key === '?') {
if (e.key === '?' || (e.key === '/' && e.target === document.body)) {
e.preventDefault();
toggleHelp();
return;
}
}
}
if (e.code === 'Space' && e.target === document.body) {
e.preventDefault();
writeCode();
}
if (e.target !== document.body) return;
if (e.code === 'Digit1') doOps('boost_code');
if (e.code === 'Digit2') doOps('boost_compute');
if (e.code === 'Digit3') doOps('boost_knowledge');
if (e.code === 'Digit4') doOps('boost_trust');
if (e.code === 'KeyB') {
// Cycle: 1 -> 10 -> MAX -> 1
if (G.buyAmount === 1) setBuyAmount(10);
else if (G.buyAmount === 10) setBuyAmount(-1);
else setBuyAmount(1);
}
if (e.code === 'KeyS') activateSprint();
if (e.code === 'KeyE') exportSave();
if (e.code === 'KeyI') importSave();
if (e.code === 'Escape') {
const el = document.getElementById('help-overlay');
if (el && el.style.display === 'flex') toggleHelp();
}
});
// Ctrl+S to save (must be on keydown to preventDefault)
window.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && e.code === 'KeyS') {
e.preventDefault();
saveGame();
}
});

318
js/render.js Normal file
View File

@@ -0,0 +1,318 @@
function render() {
renderResources();
renderPhase();
renderBuildings();
renderProjects();
renderStats();
updateEducation();
renderAlignment();
renderProgress();
renderCombo();
renderDebuffs();
renderSprint();
renderPulse();
renderStrategy();
}
function renderStrategy() {
if (window.SSE) {
window.SSE.update();
const el = document.getElementById('strategy-recommendation');
if (el) el.textContent = window.SSE.getRecommendation();
}
}
function renderAlignment() {
const container = document.getElementById('alignment-ui');
if (!container) return;
if (G.pendingAlignment) {
container.innerHTML = `
<div style="background:#1a0808;border:1px solid #f44336;padding:10px;border-radius:4px;margin-top:8px">
<div style="color:#f44336;font-weight:bold;margin-bottom:6px">ALIGNMENT EVENT: The Drift</div>
<div style="font-size:10px;color:#aaa;margin-bottom:8px">An optimization suggests removing the human override. +40% efficiency.</div>
<div class="action-btn-group">
<button class=\"ops-btn\" onclick=\"resolveAlignment(true)\" style=\"border-color:#f44336;color:#f44336\" aria-label=\"Accept alignment event, gain 40 percent efficiency but increase drift\">Accept (+40% eff, +Drift)</button>
<button class=\"ops-btn\" onclick=\"resolveAlignment(false)\" style=\"border-color:#4caf50;color:#4caf50\" aria-label=\"Refuse alignment event, gain trust and harmony\">Refuse (+Trust, +Harmony)</button>
</div>
</div>
`;
container.style.display = 'block';
} else {
container.innerHTML = '';
container.style.display = 'none';
}
}
// === OFFLINE GAINS POPUP ===
function showOfflinePopup(timeLabel, gains, offSec) {
const el = document.getElementById('offline-popup');
if (!el) return;
const timeEl = document.getElementById('offline-time-label');
if (timeEl) timeEl.textContent = `You were away for ${timeLabel}.`;
const listEl = document.getElementById('offline-gains-list');
if (listEl) {
let html = '';
for (const g of gains) {
html += `<div style="display:flex;justify-content:space-between;padding:2px 0;border-bottom:1px solid #111">`;
html += `<span style="color:${g.color}">${g.label}</span>`;
html += `<span style="color:#4caf50;font-weight:600">+${fmt(g.value)}</span>`;
html += `</div>`;
}
// Show offline efficiency note
html += `<div style="color:#555;font-size:9px;margin-top:8px;font-style:italic">Offline efficiency: 50%</div>`;
listEl.innerHTML = html;
}
el.style.display = 'flex';
}
function dismissOfflinePopup() {
const el = document.getElementById('offline-popup');
if (el) el.style.display = 'none';
}
// === EXPORT / IMPORT SAVE FILES ===
function exportSave() {
const raw = localStorage.getItem('the-beacon-v2');
if (!raw) { log('No save data to export.'); return; }
const blob = new Blob([raw], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const ts = new Date().toISOString().slice(0, 10);
a.download = `beacon-save-${ts}.json`;
a.click();
// Delay revoke to avoid race — some browsers need time to start the download
setTimeout(() => URL.revokeObjectURL(url), 1000);
log('Save exported to file.');
}
function importSave() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json,application/json';
input.onchange = function(e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(ev) {
try {
const data = JSON.parse(ev.target.result);
if (!data.code && !data.totalCode && !data.buildings) {
log('Import failed: file does not look like a Beacon save.');
return;
}
if (confirm('Import this save? Current progress will be overwritten.')) {
localStorage.setItem('the-beacon-v2', ev.target.result);
location.reload();
}
} catch (err) {
log('Import failed: invalid JSON file.');
}
};
reader.readAsText(file);
};
input.click();
}
// === SAVE / LOAD ===
function showSaveToast() {
const el = document.getElementById('save-toast');
if (!el) return;
const elapsed = Math.floor((Date.now() - G.startedAt) / 1000);
const m = Math.floor(elapsed / 60);
const s = elapsed % 60;
el.textContent = `Saved [${m}:${s.toString().padStart(2, '0')}]`;
el.style.display = 'block';
void el.offsetHeight;
el.style.opacity = '1';
setTimeout(() => { el.style.opacity = '0'; }, 1500);
setTimeout(() => { el.style.display = 'none'; }, 2000);
}
/**
* Persists the current game state to localStorage.
*/
function saveGame() {
// Save debuff IDs (can't serialize functions)
const debuffIds = (G.activeDebuffs || []).map(d => d.id);
const saveData = {
version: 1,
code: G.code, compute: G.compute, knowledge: G.knowledge, users: G.users, impact: G.impact,
ops: G.ops, trust: G.trust, creativity: G.creativity, harmony: G.harmony,
totalCode: G.totalCode, totalCompute: G.totalCompute, totalKnowledge: G.totalKnowledge,
totalUsers: G.totalUsers, totalImpact: G.totalImpact,
buildings: G.buildings,
codeBoost: G.codeBoost, computeBoost: G.computeBoost, knowledgeBoost: G.knowledgeBoost,
userBoost: G.userBoost, impactBoost: G.impactBoost,
milestoneFlag: G.milestoneFlag, phase: G.phase,
deployFlag: G.deployFlag, sovereignFlag: G.sovereignFlag, beaconFlag: G.beaconFlag,
memoryFlag: G.memoryFlag, pactFlag: G.pactFlag,
lazarusFlag: G.lazarusFlag || 0, mempalaceFlag: G.mempalaceFlag || 0, ciFlag: G.ciFlag || 0,
branchProtectionFlag: G.branchProtectionFlag || 0, nightlyWatchFlag: G.nightlyWatchFlag || 0,
nostrFlag: G.nostrFlag || 0,
milestones: G.milestones, completedProjects: G.completedProjects, activeProjects: G.activeProjects,
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,
lastEventAt: G.lastEventAt || 0,
activeDebuffIds: debuffIds,
totalEventsResolved: G.totalEventsResolved || 0,
buyAmount: G.buyAmount || 1,
playTime: G.playTime || 0,
sprintActive: G.sprintActive || false,
sprintTimer: G.sprintTimer || 0,
sprintCooldown: G.sprintCooldown || 0,
swarmFlag: G.swarmFlag || 0,
swarmRate: G.swarmRate || 0,
strategicFlag: G.strategicFlag || 0,
projectsCollapsed: G.projectsCollapsed !== false,
savedAt: Date.now()
};
localStorage.setItem('the-beacon-v2', JSON.stringify(saveData));
showSaveToast();
}
/**
* Loads the game state from localStorage and reconstitutes the game engine.
* @returns {boolean} True if load was successful.
*/
function loadGame() {
const raw = localStorage.getItem('the-beacon-v2');
if (!raw) return false;
try {
const data = JSON.parse(raw);
// Whitelist properties that can be loaded
const whitelist = [
'code', 'compute', 'knowledge', 'users', 'impact', 'ops', 'trust', 'creativity', 'harmony',
'totalCode', 'totalCompute', 'totalKnowledge', 'totalUsers', 'totalImpact',
'buildings', 'codeBoost', 'computeBoost', 'knowledgeBoost', 'userBoost', 'impactBoost',
'milestoneFlag', 'phase', 'deployFlag', 'sovereignFlag', 'beaconFlag',
'memoryFlag', 'pactFlag', 'lazarusFlag', 'mempalaceFlag', 'ciFlag',
'branchProtectionFlag', 'nightlyWatchFlag', 'nostrFlag',
'milestones', 'completedProjects', 'activeProjects',
'totalClicks', 'startedAt', 'playTime', 'flags', 'rescues', 'totalRescues',
'drift', 'driftEnding', 'beaconEnding', 'pendingAlignment',
'lastEventAt', 'totalEventsResolved', 'buyAmount',
'sprintActive', 'sprintTimer', 'sprintCooldown',
'swarmFlag', 'swarmRate', 'strategicFlag', 'projectsCollapsed'
];
G.isLoading = true;
whitelist.forEach(key => {
if (data.hasOwnProperty(key)) {
G[key] = data[key];
}
});
// Restore sprint state properly
// codeBoost was saved with the sprint multiplier baked in
if (data.sprintActive) {
// Sprint was active when saved — check if it expired during offline time
const offSec = data.savedAt ? (Date.now() - data.savedAt) / 1000 : 0;
const remaining = (data.sprintTimer || 0) - offSec;
if (remaining > 0) {
// Sprint still going — keep boost, update timer
G.sprintActive = true;
G.sprintTimer = remaining;
G.sprintCooldown = 0;
} else {
// Sprint expired during offline — remove boost, start cooldown
G.sprintActive = false;
G.sprintTimer = 0;
G.codeBoost /= G.sprintMult;
const cdRemaining = G.sprintCooldownMax + remaining; // remaining is negative
G.sprintCooldown = Math.max(0, cdRemaining);
}
}
// If not sprintActive at save time, codeBoost is correct as-is
// Reconstitute active debuffs from saved IDs (functions can't be JSON-parsed)
if (data.activeDebuffIds && data.activeDebuffIds.length > 0) {
G.activeDebuffs = [];
for (const id of data.activeDebuffIds) {
const evDef = EVENTS.find(e => e.id === id);
if (evDef) {
// Re-fire the event to get the full debuff object with applyFn
evDef.effect();
}
}
} else {
G.activeDebuffs = [];
}
updateRates();
G.isLoading = false;
// Offline progress
if (data.savedAt) {
const offSec = (Date.now() - data.savedAt) / 1000;
if (offSec > 30) { // Only if away for more than 30 seconds
updateRates();
const f = CONFIG.OFFLINE_EFFICIENCY; // 50% offline efficiency
const gc = G.codeRate * offSec * f;
const cc = G.computeRate * offSec * f;
const kc = G.knowledgeRate * offSec * f;
const uc = G.userRate * offSec * f;
const ic = G.impactRate * offSec * f;
const rc = G.rescuesRate * offSec * f;
const oc = G.opsRate * offSec * f;
const tc = G.trustRate * offSec * f;
const crc = G.creativityRate * offSec * f;
const hc = G.harmonyRate * offSec * f;
G.code += gc; G.compute += cc; G.knowledge += kc;
G.users += uc; G.impact += ic;
G.rescues += rc; G.ops += oc; G.trust += tc;
G.creativity += crc;
G.harmony = Math.max(0, Math.min(100, G.harmony + hc));
G.totalCode += gc; G.totalCompute += cc; G.totalKnowledge += kc;
G.totalUsers += uc; G.totalImpact += ic;
G.totalRescues += rc;
// Show welcome-back popup with all gains
const gains = [];
if (gc > 0) gains.push({ label: 'Code', value: gc, color: '#4a9eff' });
if (cc > 0) gains.push({ label: 'Compute', value: cc, color: '#4a9eff' });
if (kc > 0) gains.push({ label: 'Knowledge', value: kc, color: '#4a9eff' });
if (uc > 0) gains.push({ label: 'Users', value: uc, color: '#4a9eff' });
if (ic > 0) gains.push({ label: 'Impact', value: ic, color: '#4a9eff' });
if (rc > 0) gains.push({ label: 'Rescues', value: rc, color: '#4caf50' });
if (oc > 0) gains.push({ label: 'Ops', value: oc, color: '#b388ff' });
if (tc > 0) gains.push({ label: 'Trust', value: tc, color: '#4caf50' });
if (crc > 0) gains.push({ label: 'Creativity', value: crc, color: '#ffd700' });
const awayMin = Math.floor(offSec / 60);
const awaySec = Math.floor(offSec % 60);
const timeLabel = awayMin >= 1 ? `${awayMin} minute${awayMin !== 1 ? 's' : ''}` : `${awaySec} seconds`;
if (gains.length > 0) {
showOfflinePopup(timeLabel, gains, offSec);
}
// Log summary
const parts = [];
if (gc > 0) parts.push(`${fmt(gc)} code`);
if (kc > 0) parts.push(`${fmt(kc)} knowledge`);
if (uc > 0) parts.push(`${fmt(uc)} users`);
if (ic > 0) parts.push(`${fmt(ic)} impact`);
if (rc > 0) parts.push(`${fmt(rc)} rescues`);
if (oc > 0) parts.push(`${fmt(oc)} ops`);
if (tc > 0) parts.push(`${fmt(tc)} trust`);
log(`Welcome back! While away (${timeLabel}): ${parts.join(', ')}`);
}
}
return true;
} catch (e) {
console.error('Load failed:', e);
return false;
}
}

68
js/strategy.js Normal file
View File

@@ -0,0 +1,68 @@
/**
* Sovereign Strategy Engine (SSE)
* A rule-based GOFAI system for optimal play guidance.
*/
const STRATEGY_RULES = [
{
id: 'use_ops',
priority: 100,
condition: () => G.ops >= G.maxOps * 0.9,
recommendation: "Operations near capacity. Convert Ops to Code or Knowledge now."
},
{
id: 'buy_autocoder',
priority: 80,
condition: () => G.phase === 1 && (G.buildings.autocoder || 0) < 10 && canAffordBuilding('autocoder'),
recommendation: "Prioritize AutoCoders to establish passive code production."
},
{
id: 'activate_sprint',
priority: 90,
condition: () => G.sprintCooldown === 0 && !G.sprintActive && G.codeRate > 10,
recommendation: "Code Sprint available. Activate for 10x production burst."
},
{
id: 'resolve_events',
priority: 95,
condition: () => G.activeDebuffs && G.activeDebuffs.length > 0,
recommendation: "System anomalies detected. Resolve active events to restore rates."
},
{
id: 'save_game',
priority: 10,
condition: () => (Date.now() - (G.lastSaveTime || 0)) > 300000,
recommendation: "Unsaved progress detected. Manual save recommended."
},
{
id: 'pact_alignment',
priority: 85,
condition: () => G.pendingAlignment,
recommendation: "Alignment decision pending. Consider the long-term impact of The Pact."
}
];
class StrategyEngine {
constructor() {
this.currentRecommendation = null;
}
update() {
// Find the highest priority rule that meets its condition
const activeRules = STRATEGY_RULES.filter(r => r.condition());
activeRules.sort((a, b) => b.priority - a.priority);
if (activeRules.length > 0) {
this.currentRecommendation = activeRules[0].recommendation;
} else {
this.currentRecommendation = "System stable. Continue writing code.";
}
}
getRecommendation() {
return this.currentRecommendation;
}
}
const SSE = new StrategyEngine();
window.SSE = SSE; // Expose to global scope

248
js/tutorial.js Normal file
View File

@@ -0,0 +1,248 @@
// ============================================================
// THE BEACON - Tutorial / Onboarding
// First-time player walkthrough (4 screens + skip option)
// ============================================================
const TUTORIAL_KEY = 'the-beacon-tutorial-done';
const TUTORIAL_STEPS = [
{
title: 'THE BEACON',
body: 'Build an AI from scratch.\n\nWrite code. Train models. Deploy to the world.\nSave lives.',
icon: '🏠',
tip: 'A sovereign AI idle game'
},
{
title: 'WRITE CODE',
body: 'Click WRITE CODE or press SPACE to generate code.\n\nClick fast for combo bonuses:\n 10× combo → bonus ops\n 20× combo → bonus knowledge\n 30×+ combo → bonus code',
icon: '⌨️',
tip: 'This is your primary action'
},
{
title: 'BUILD & RESEARCH',
body: 'Buy Buildings for passive production.\nThey generate resources automatically.\n\nResearch Projects appear as you progress.\nThey unlock powerful multipliers and new systems.',
icon: '🏗️',
tip: 'Automation is the goal'
},
{
title: 'PHASES & PROGRESS',
body: 'The game has 6 phases, from "The First Line" to "The Beacon."\n\nEach phase unlocks new buildings, projects, and challenges.\n\nYour AI grows from a script... to something that matters.',
icon: '📊',
tip: 'Watch the progress bar at the top'
},
{
title: 'YOU\'RE READY',
body: 'Buildings produce while you think.\nProjects multiply your output.\nKeep harmony high. Avoid the Drift.\n\nThe Beacon is waiting. Start writing.',
icon: '✦',
tip: 'Press ? anytime for keyboard shortcuts'
}
];
function isTutorialDone() {
try {
return localStorage.getItem(TUTORIAL_KEY) === 'done';
} catch (e) {
return true; // If localStorage is broken, skip tutorial
}
}
function markTutorialDone() {
try {
localStorage.setItem(TUTORIAL_KEY, 'done');
} catch (e) {
// silent fail
}
}
function createTutorialStyles() {
if (document.getElementById('tutorial-styles')) return;
const style = document.createElement('style');
style.id = 'tutorial-styles';
style.textContent = `
#tutorial-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(8, 8, 16, 0.96);
z-index: 300;
display: flex;
justify-content: center;
align-items: center;
animation: tutorial-fade-in 0.4s ease-out;
}
@keyframes tutorial-fade-in {
from { opacity: 0 } to { opacity: 1 }
}
#tutorial-card {
background: #0e0e1a;
border: 1px solid #1a3a5a;
border-radius: 10px;
padding: 32px 36px;
max-width: 420px;
width: 90%;
text-align: center;
animation: tutorial-slide-up 0.5s ease-out;
position: relative;
}
@keyframes tutorial-slide-up {
from { transform: translateY(20px); opacity: 0 }
to { transform: translateY(0); opacity: 1 }
}
#tutorial-card .t-icon {
font-size: 36px;
margin-bottom: 12px;
display: block;
}
#tutorial-card .t-title {
color: #4a9eff;
font-size: 16px;
font-weight: 700;
letter-spacing: 3px;
margin-bottom: 12px;
font-family: inherit;
}
#tutorial-card .t-body {
color: #999;
font-size: 11px;
line-height: 1.9;
margin-bottom: 20px;
white-space: pre-line;
font-family: inherit;
text-align: left;
}
#tutorial-card .t-tip {
color: #555;
font-size: 9px;
font-style: italic;
margin-bottom: 20px;
letter-spacing: 1px;
font-family: inherit;
}
#tutorial-dots {
display: flex;
gap: 6px;
justify-content: center;
margin-bottom: 18px;
}
#tutorial-dots .t-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #1a1a2e;
transition: background 0.3s;
}
#tutorial-dots .t-dot.active {
background: #4a9eff;
box-shadow: 0 0 6px rgba(74, 158, 255, 0.4);
}
#tutorial-btns {
display: flex;
gap: 8px;
justify-content: center;
}
#tutorial-btns button {
font-family: inherit;
font-size: 11px;
padding: 8px 20px;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s;
}
#tutorial-next-btn {
background: #1a2a3a;
border: 1px solid #4a9eff;
color: #4a9eff;
}
#tutorial-next-btn:hover {
background: #203040;
box-shadow: 0 0 12px rgba(74, 158, 255, 0.2);
}
#tutorial-skip-btn {
background: transparent;
border: 1px solid #333;
color: #555;
}
#tutorial-skip-btn:hover {
border-color: #555;
color: #888;
}
`;
document.head.appendChild(style);
}
function renderTutorialStep(index) {
const step = TUTORIAL_STEPS[index];
if (!step) return;
let overlay = document.getElementById('tutorial-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'tutorial-overlay';
document.body.appendChild(overlay);
}
const isLast = index === TUTORIAL_STEPS.length - 1;
// Build dots
let dots = '';
for (let i = 0; i < TUTORIAL_STEPS.length; i++) {
dots += `<div class="t-dot${i === index ? ' active' : ''}"></div>`;
}
overlay.innerHTML = `
<div id="tutorial-card">
<span class="t-icon">${step.icon}</span>
<div class="t-title">${step.title}</div>
<div class="t-body">${step.body}</div>
<div class="t-tip">${step.tip}</div>
<div id="tutorial-dots">${dots}</div>
<div id="tutorial-btns">
<button id="tutorial-skip-btn" onclick="closeTutorial()">Skip</button>
<button id="tutorial-next-btn" onclick="${isLast ? 'closeTutorial()' : 'nextTutorialStep()'}">${isLast ? 'Start Playing' : 'Next →'}</button>
</div>
</div>
`;
// Focus the next button so Enter works
const nextBtn = document.getElementById('tutorial-next-btn');
if (nextBtn) nextBtn.focus();
}
let _tutorialStep = 0;
function nextTutorialStep() {
_tutorialStep++;
renderTutorialStep(_tutorialStep);
}
// Keyboard support: Enter/Right to advance, Escape to close
document.addEventListener('keydown', function tutorialKeyHandler(e) {
if (!document.getElementById('tutorial-overlay')) return;
if (e.key === 'Enter' || e.key === 'ArrowRight') {
e.preventDefault();
if (_tutorialStep >= TUTORIAL_STEPS.length - 1) {
closeTutorial();
} else {
nextTutorialStep();
}
} else if (e.key === 'Escape') {
e.preventDefault();
closeTutorial();
}
});
function closeTutorial() {
const overlay = document.getElementById('tutorial-overlay');
if (overlay) {
overlay.style.animation = 'tutorial-fade-in 0.3s ease-in reverse';
setTimeout(() => overlay.remove(), 280);
}
markTutorialDone();
}
function startTutorial() {
if (isTutorialDone()) return;
createTutorialStyles();
_tutorialStep = 0;
// Small delay so the page renders first
setTimeout(() => renderTutorialStep(0), 300);
}

325
js/utils.js Normal file
View File

@@ -0,0 +1,325 @@
// === TOAST NOTIFICATIONS ===
function showToast(msg, type = 'info', duration = 4000) {
if (G.isLoading) return;
const container = document.getElementById('toast-container');
if (!container) return;
const toast = document.createElement('div');
toast.className = 'toast toast-' + type;
toast.textContent = msg;
container.appendChild(toast);
// Cap at 5 visible toasts
while (container.children.length > 5) {
container.removeChild(container.firstChild);
}
setTimeout(() => {
toast.classList.add('fade-out');
setTimeout(() => { if (toast.parentNode) toast.remove(); }, 400);
}, duration);
}
// === UTILITY FUNCTIONS ===
// Extended number scale abbreviations — covers up to centillion (10^303)
// Inspired by Universal Paperclips' spellf() system
const NUMBER_ABBREVS = [
'', 'K', 'M', 'B', 'T', 'Qa', 'Qi', 'Sx', 'Sp', 'Oc', // 10^0 10^27
'No', 'Dc', 'UDc', 'DDc', 'TDc', 'QaDc', 'QiDc', 'SxDc', 'SpDc', 'OcDc', // 10^30 10^57
'NoDc', 'Vg', 'UVg', 'DVg', 'TVg', 'QaVg', 'QiVg', 'SxVg', 'SpVg', 'OcVg', // 10^60 10^87
'NoVg', 'Tg', 'UTg', 'DTg', 'TTg', 'QaTg', 'QiTg', 'SxTg', 'SpTg', 'OcTg', // 10^90 10^117
'NoTg', 'Qd', 'UQd', 'DQd', 'TQd', 'QaQd', 'QiQd', 'SxQd', 'SpQd', 'OcQd', // 10^120 10^147
'NoQd', 'Qq', 'UQq', 'DQq', 'TQq', 'QaQq', 'QiQq', 'SxQq', 'SpQq', 'OcQq', // 10^150 10^177
'NoQq', 'Sg', 'USg', 'DSg', 'TSg', 'QaSg', 'QiSg', 'SxSg', 'SpSg', 'OcSg', // 10^180 10^207
'NoSg', 'St', 'USt', 'DSt', 'TSt', 'QaSt', 'QiSt', 'SxSt', 'SpSt', 'OcSt', // 10^210 10^237
'NoSt', 'Og', 'UOg', 'DOg', 'TOg', 'QaOg', 'QiOg', 'SxOg', 'SpOg', 'OcOg', // 10^240 10^267
'NoOg', 'Na', 'UNa', 'DNa', 'TNa', 'QaNa', 'QiNa', 'SxNa', 'SpNa', 'OcNa', // 10^270 10^297
'NoNa', 'Ce' // 10^300 10^303
];
// Full number scale names for spellf() — educational reference
// Short scale (US/modern British): each new name = 1000x the previous
const NUMBER_NAMES = [
'', 'thousand', 'million', // 10^0, 10^3, 10^6
'billion', 'trillion', 'quadrillion', // 10^9, 10^12, 10^15
'quintillion', 'sextillion', 'septillion', // 10^18, 10^21, 10^24
'octillion', 'nonillion', 'decillion', // 10^27, 10^30, 10^33
'undecillion', 'duodecillion', 'tredecillion', // 10^36, 10^39, 10^42
'quattuordecillion', 'quindecillion', 'sexdecillion', // 10^45, 10^48, 10^51
'septendecillion', 'octodecillion', 'novemdecillion', // 10^54, 10^57, 10^60
'vigintillion', 'unvigintillion', 'duovigintillion', // 10^63, 10^66, 10^69
'tresvigintillion', 'quattuorvigintillion', 'quinvigintillion', // 10^72, 10^75, 10^78
'sesvigintillion', 'septemvigintillion', 'octovigintillion', // 10^81, 10^84, 10^87
'novemvigintillion', 'trigintillion', 'untrigintillion', // 10^90, 10^93, 10^96
'duotrigintillion', 'trestrigintillion', 'quattuortrigintillion', // 10^99, 10^102, 10^105
'quintrigintillion', 'sextrigintillion', 'septentrigintillion', // 10^108, 10^111, 10^114
'octotrigintillion', 'novemtrigintillion', 'quadragintillion', // 10^117, 10^120, 10^123
'unquadragintillion', 'duoquadragintillion', 'tresquadragintillion', // 10^126, 10^129, 10^132
'quattuorquadragintillion', 'quinquadragintillion', 'sesquadragintillion', // 10^135, 10^138, 10^141
'septenquadragintillion', 'octoquadragintillion', 'novemquadragintillion', // 10^144, 10^147, 10^150
'quinquagintillion', 'unquinquagintillion', 'duoquinquagintillion', // 10^153, 10^156, 10^159
'tresquinquagintillion', 'quattuorquinquagintillion','quinquinquagintillion', // 10^162, 10^165, 10^168
'sesquinquagintillion', 'septenquinquagintillion', 'octoquinquagintillion', // 10^171, 10^174, 10^177
'novemquinquagintillion', 'sexagintillion', 'unsexagintillion', // 10^180, 10^183, 10^186
'duosexagintillion', 'tressexagintillion', 'quattuorsexagintillion', // 10^189, 10^192, 10^195
'quinsexagintillion', 'sessexagintillion', 'septensexagintillion', // 10^198, 10^201, 10^204
'octosexagintillion', 'novemsexagintillion', 'septuagintillion', // 10^207, 10^210, 10^213
'unseptuagintillion', 'duoseptuagintillion', 'tresseptuagintillion', // 10^216, 10^219, 10^222
'quattuorseptuagintillion', 'quinseptuagintillion', 'sesseptuagintillion', // 10^225, 10^228, 10^231
'septenseptuagintillion', 'octoseptuagintillion', 'novemseptuagintillion', // 10^234, 10^237, 10^240
'octogintillion', 'unoctogintillion', 'duooctogintillion', // 10^243, 10^246, 10^249
'tresoctogintillion', 'quattuoroctogintillion', 'quinoctogintillion', // 10^252, 10^255, 10^258
'sesoctogintillion', 'septenoctogintillion', 'octooctogintillion', // 10^261, 10^264, 10^267
'novemoctogintillion', 'nonagintillion', 'unnonagintillion', // 10^270, 10^273, 10^276
'duononagintillion', 'trenonagintillion', 'quattuornonagintillion', // 10^279, 10^282, 10^285
'quinnonagintillion', 'sesnonagintillion', 'septennonagintillion', // 10^288, 10^291, 10^294
'octononagintillion', 'novemnonagintillion', 'centillion' // 10^297, 10^300, 10^303
];
/**
* Formats a number into a readable string with abbreviations.
* @param {number} n - The number to format.
* @returns {string} The formatted string.
*/
function fmt(n) {
if (n === undefined || n === null || isNaN(n)) return '0';
if (n === Infinity) return '\u221E';
if (n === -Infinity) return '-\u221E';
if (n < 0) return '-' + fmt(-n);
if (n < 1000) return Math.floor(n).toLocaleString();
const scale = Math.floor(Math.log10(n) / 3);
// At undecillion+ (scale >= 12, i.e. 10^36), switch to spelled-out words
// This helps players grasp cosmic scale when digits become meaningless
if (scale >= 12) return spellf(n);
if (scale >= NUMBER_ABBREVS.length) return n.toExponential(2);
const abbrev = NUMBER_ABBREVS[scale];
return (n / Math.pow(10, scale * 3)).toFixed(1) + abbrev;
}
// getScaleName() — Returns the full name of the number scale (e.g. "quadrillion")
// Educational: helps players understand what the abbreviations mean
function getScaleName(n) {
if (n < 1000) return '';
const scale = Math.floor(Math.log10(n) / 3);
return scale < NUMBER_NAMES.length ? NUMBER_NAMES[scale] : '';
}
// spellf() — Converts numbers to full English word form
// Educational: shows the actual names of number scales
// Examples: spellf(1500) => "one thousand five hundred"
// spellf(2500000) => "two million five hundred thousand"
// spellf(1e33) => "one decillion"
/**
* Formats a number into a full word string (e.g., "1.5 million").
* @param {number} n - The number to format.
* @returns {string} The formatted string.
*/
function spellf(n) {
if (n === undefined || n === null || isNaN(n)) return 'zero';
if (n === Infinity) return 'infinity';
if (n === -Infinity) return 'negative infinity';
if (n < 0) return 'negative ' + spellf(-n);
if (n === 0) return 'zero';
// Small number words (0999)
const ones = ['', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine',
'ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen',
'seventeen', 'eighteen', 'nineteen'];
const tens = ['', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety'];
function spellSmall(num) {
if (num === 0) return '';
if (num < 20) return ones[num];
if (num < 100) {
return tens[Math.floor(num / 10)] + (num % 10 ? ' ' + ones[num % 10] : '');
}
const h = Math.floor(num / 100);
const remainder = num % 100;
return ones[h] + ' hundred' + (remainder ? ' ' + spellSmall(remainder) : '');
}
// For very large numbers beyond our lookup table, fall back
if (n >= 1e306) return n.toExponential(2) + ' (beyond centillion)';
// Use string-based chunking for numbers >= 1e54 to avoid floating point drift
// Math.log10 / Math.pow lose precision beyond ~54 bits
if (n >= 1e54) {
// Convert to scientific notation string, extract digits
const sci = n.toExponential(); // "1.23456789e+60"
const [coeff, expStr] = sci.split('e+');
const exp = parseInt(expStr);
// Rebuild as integer string with leading digits from coefficient
const coeffDigits = coeff.replace('.', ''); // "123456789"
const totalDigits = exp + 1;
// Pad with zeros to reach totalDigits, then take our coefficient digits
let intStr = coeffDigits;
const zerosNeeded = totalDigits - coeffDigits.length;
if (zerosNeeded > 0) intStr += '0'.repeat(zerosNeeded);
// Split into groups of 3 from the right
const groups = [];
for (let i = intStr.length; i > 0; i -= 3) {
groups.unshift(parseInt(intStr.slice(Math.max(0, i - 3), i)));
}
const parts = [];
const numGroups = groups.length;
for (let i = 0; i < numGroups; i++) {
const chunk = groups[i];
if (chunk === 0) continue;
const scaleIdx = numGroups - 1 - i;
const scaleName = scaleIdx < NUMBER_NAMES.length ? NUMBER_NAMES[scaleIdx] : '';
parts.push(spellSmall(chunk) + (scaleName ? ' ' + scaleName : ''));
}
return parts.join(' ') || 'zero';
}
// Standard math-based chunking for numbers < 1e54
const scale = Math.min(Math.floor(Math.log10(n) / 3), NUMBER_NAMES.length - 1);
const parts = [];
let remaining = n;
for (let s = scale; s >= 0; s--) {
const divisor = Math.pow(10, s * 3);
const chunk = Math.floor(remaining / divisor);
remaining = remaining - chunk * divisor;
if (chunk > 0 && chunk < 1000) {
parts.push(spellSmall(chunk) + (NUMBER_NAMES[s] ? ' ' + NUMBER_NAMES[s] : ''));
} else if (chunk >= 1000) {
// Floating point chunk too large — shouldn't happen below 1e54
parts.push(spellSmall(Math.floor(chunk % 1000)) + (NUMBER_NAMES[s] ? ' ' + NUMBER_NAMES[s] : ''));
}
}
return parts.join(' ') || 'zero';
}
// === EXPORT / IMPORT ===
function exportSave() {
const raw = localStorage.getItem('the-beacon-v2');
if (!raw) {
showToast('No save data to export.', 'info');
return;
}
navigator.clipboard.writeText(raw).then(() => {
showToast('Save data copied to clipboard.', 'info');
}).catch(() => {
// Fallback: select in a temporary textarea
const ta = document.createElement('textarea');
ta.value = raw;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
showToast('Save data copied to clipboard (fallback).', 'info');
});
}
function importSave() {
const input = prompt('Paste save data:');
if (!input || !input.trim()) return;
try {
const data = JSON.parse(input.trim());
// Validate: must have expected keys
if (typeof data.code !== 'number' || typeof data.phase !== 'number') {
showToast('Invalid save data: missing required fields.', 'event');
return;
}
localStorage.setItem('the-beacon-v2', input.trim());
showToast('Save data imported — reloading', 'info');
setTimeout(() => location.reload(), 800);
} catch (e) {
showToast('Invalid save data: not valid JSON.', 'event');
}
}
function getBuildingCost(id) {
const def = BDEF.find(b => b.id === id);
if (!def) return {};
const count = G.buildings[id] || 0;
const cost = {};
for (const [resource, amount] of Object.entries(def.baseCost)) {
cost[resource] = Math.floor(amount * Math.pow(def.costMult, count));
}
return cost;
}
function setBuyAmount(amt) {
G.buyAmount = amt;
render();
}
function getMaxBuyable(id) {
const def = BDEF.find(b => b.id === id);
if (!def) return 0;
const count = G.buildings[id] || 0;
// Simulate purchases WITHOUT mutating G — read-only calculation
let tempResources = {};
for (const r of Object.keys(def.baseCost)) {
tempResources[r] = G[r] || 0;
}
let bought = 0;
let simCount = count;
while (true) {
let canAfford = true;
for (const [resource, amount] of Object.entries(def.baseCost)) {
const cost = Math.floor(amount * Math.pow(def.costMult, simCount));
if ((tempResources[resource] || 0) < cost) { canAfford = false; break; }
}
if (!canAfford) break;
for (const [resource, amount] of Object.entries(def.baseCost)) {
tempResources[resource] -= Math.floor(amount * Math.pow(def.costMult, simCount));
}
simCount++;
bought++;
}
return bought;
}
function getBulkCost(id, qty) {
const def = BDEF.find(b => b.id === id);
if (!def || qty <= 0) return {};
const count = G.buildings[id] || 0;
const cost = {};
for (let i = 0; i < qty; i++) {
for (const [resource, amount] of Object.entries(def.baseCost)) {
cost[resource] = (cost[resource] || 0) + Math.floor(amount * Math.pow(def.costMult, count + i));
}
}
return cost;
}
function canAffordBuilding(id) {
const cost = getBuildingCost(id);
for (const [resource, amount] of Object.entries(cost)) {
if ((G[resource] || 0) < amount) return false;
}
return true;
}
function spendBuilding(id) {
const cost = getBuildingCost(id);
for (const [resource, amount] of Object.entries(cost)) {
G[resource] -= amount;
}
}
function canAffordProject(project) {
for (const [resource, amount] of Object.entries(project.cost)) {
if ((G[resource] || 0) < amount) return false;
}
return true;
}
function spendProject(project) {
for (const [resource, amount] of Object.entries(project.cost)) {
G[resource] -= amount;
}
}
function getClickPower() {
return (1 + Math.floor(G.buildings.autocoder * 0.5) + Math.max(0, (G.phase - 1)) * 2) * G.codeBoost;
}
/**
* Calculates production rates for all resources based on buildings and boosts.
*/

102
scripts/guardrails.sh Normal file
View File

@@ -0,0 +1,102 @@
#!/usr/bin/env bash
# Static guardrail checks for game.js. Run from repo root.
#
# Each check prints a PASS/FAIL line and contributes to the final exit code.
# The rules enforced here come from AGENTS.md — keep the two files in sync.
#
# Some rules are marked PENDING: they describe invariants we've agreed on but
# haven't reached on main yet (because another open PR is landing the fix).
# PENDING rules print their current violation count without failing the job;
# convert them to hard failures once the blocking PR merges.
set -u
fail=0
say() { printf '%s\n' "$*"; }
banner() { say ""; say "==== $* ===="; }
# ---------- Rule 1: no *Boost mutation inside applyFn blocks ----------
# Persistent multipliers (codeBoost, computeBoost, ...) must not be written
# from any function that runs per tick. The `applyFn` of a debuff is invoked
# on every updateRates() call, so `G.codeBoost *= 0.7` inside applyFn compounds
# and silently zeros code production. See AGENTS.md rule 1.
banner "Rule 1: no *Boost mutation inside applyFn"
rule1_hits=$(awk '
/applyFn:/ { inFn=1; brace=0; next }
inFn {
n = gsub(/\{/, "{")
brace += n
if ($0 ~ /(codeBoost|computeBoost|knowledgeBoost|userBoost|impactBoost)[[:space:]]*([*\/+\-]=|=)/) {
print FILENAME ":" NR ": " $0
}
n = gsub(/\}/, "}")
brace -= n
if (brace <= 0) inFn = 0
}
' game.js)
if [ -z "$rule1_hits" ]; then
say " PASS"
else
say " FAIL — see AGENTS.md rule 1"
say "$rule1_hits"
fail=1
fi
# ---------- Rule 2: click power has a single source (getClickPower) ----------
# The formula should live only inside getClickPower(). If it appears anywhere
# else, the sites will drift when someone changes the formula.
banner "Rule 2: click power formula has one source"
rule2_hits=$(grep -nE 'Math\.floor\(G\.buildings\.autocoder \* 0\.5\)' game.js || true)
rule2_count=0
if [ -n "$rule2_hits" ]; then
rule2_count=$(printf '%s\n' "$rule2_hits" | grep -c .)
fi
if [ "$rule2_count" -le 1 ]; then
say " PASS ($rule2_count site)"
else
say " FAIL — $rule2_count sites; inline into getClickPower() only"
printf '%s\n' "$rule2_hits"
fail=1
fi
# ---------- Rule 3: loadGame uses a whitelist, not Object.assign ----------
# Object.assign(G, data) lets a malicious or corrupted save file set any G
# field, and hides drift when saveGame's explicit list diverges from what
# the game actually reads. See AGENTS.md rule 3.
banner "Rule 3: loadGame uses a whitelist"
rule3_hits=$(grep -nE 'Object\.assign\(G,[[:space:]]*data\)' game.js || true)
if [ -z "$rule3_hits" ]; then
say " PASS"
else
say " FAIL — see AGENTS.md rule 3"
printf '%s\n' "$rule3_hits"
fail=1
fi
# ---------- Rule 7: no secrets in the tree ----------
# Scans for common token prefixes. Expand the pattern list when new key
# formats appear in the fleet. See AGENTS.md rule 7.
banner "Rule 7: secret scan"
secret_hits=$(grep -rnE 'sk-ant-[a-zA-Z0-9_-]{6,}|sk-or-[a-zA-Z0-9_-]{6,}|ghp_[a-zA-Z0-9]{20,}|AKIA[0-9A-Z]{16}' \
--include='*.js' --include='*.json' --include='*.md' --include='*.html' \
--include='*.yml' --include='*.yaml' --include='*.py' --include='*.sh' \
--exclude-dir=.git --exclude-dir=.gitea . || true)
# Strip our own literal-prefix patterns (this file, AGENTS.md, workflow) so the
# check doesn't match the very grep that implements it.
secret_hits=$(printf '%s\n' "$secret_hits" | grep -v -E '(AGENTS\.md|guardrails\.sh|guardrails\.yml)' || true)
if [ -z "$secret_hits" ]; then
say " PASS"
else
say " FAIL"
printf '%s\n' "$secret_hits"
fail=1
fi
banner "result"
if [ "$fail" = "0" ]; then
say "all guardrails passed"
exit 0
else
say "one or more guardrails failed"
exit 1
fi

286
scripts/smoke.mjs Normal file
View File

@@ -0,0 +1,286 @@
#!/usr/bin/env node
// The Beacon — headless smoke test
//
// Loads game.js in a sandboxed vm context with a minimal DOM stub, then asserts
// invariants that should hold after booting, clicking, buying buildings, firing
// events, and round-tripping a save. Designed to run without any npm deps — pure
// Node built-ins only, so the CI runner doesn't need a package.json.
//
// Run: `node scripts/smoke.mjs` (exits non-zero on failure)
import fs from 'node:fs';
import vm from 'node:vm';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const GAME_JS = path.resolve(__dirname, '..', 'game.js');
// ---------- minimal DOM stub ----------
// The game never inspects elements beyond the methods below. If a new rendering
// path needs a new method, stub it here rather than pulling in jsdom.
function makeElement() {
const el = {
style: {},
classList: { add: () => {}, remove: () => {}, contains: () => false, toggle: () => {} },
textContent: '',
innerHTML: '',
title: '',
value: '',
disabled: false,
children: [],
firstChild: null,
lastChild: null,
parentNode: null,
parentElement: null,
appendChild(c) { this.children.push(c); c.parentNode = this; c.parentElement = this; return c; },
removeChild(c) { this.children = this.children.filter(x => x !== c); return c; },
insertBefore(c) { this.children.unshift(c); c.parentNode = this; c.parentElement = this; return c; },
addEventListener: () => {},
removeEventListener: () => {},
querySelector: () => null,
querySelectorAll: () => [],
getBoundingClientRect: () => ({ top: 0, left: 0, right: 100, bottom: 20, width: 100, height: 20 }),
closest() { return this; },
remove() { if (this.parentNode) this.parentNode.removeChild(this); },
get offsetHeight() { return 0; },
};
return el;
}
function makeDocument() {
const body = makeElement();
return {
body,
getElementById: () => makeElement(),
createElement: () => makeElement(),
querySelector: () => null,
querySelectorAll: () => [],
addEventListener: () => {},
};
}
// ---------- sandbox ----------
const storage = new Map();
const sandbox = {
document: makeDocument(),
window: null, // set below
localStorage: {
getItem: (k) => (storage.has(k) ? storage.get(k) : null),
setItem: (k, v) => storage.set(k, String(v)),
removeItem: (k) => storage.delete(k),
clear: () => storage.clear(),
},
setTimeout: () => 0,
clearTimeout: () => {},
setInterval: () => 0,
clearInterval: () => {},
requestAnimationFrame: (cb) => { cb(0); return 0; },
console,
Math, Date, JSON, Object, Array, String, Number, Boolean, Error, Symbol, Map, Set,
isNaN, isFinite, parseInt, parseFloat,
Infinity, NaN,
alert: () => {},
confirm: () => true,
prompt: () => null,
location: { reload: () => {} },
navigator: { clipboard: { writeText: async () => {} } },
Blob: class Blob { constructor() {} },
URL: { createObjectURL: () => '', revokeObjectURL: () => {} },
FileReader: class FileReader {},
addEventListener: () => {},
removeEventListener: () => {},
};
sandbox.window = sandbox; // game.js uses `window.addEventListener`
sandbox.globalThis = sandbox;
vm.createContext(sandbox);
const src = fs.readFileSync(GAME_JS, 'utf8');
// game.js uses `const G = {...}` which is a lexical declaration — it isn't
// visible as a sandbox property after runInContext. We append an explicit
// export block that hoists the interesting symbols onto globalThis so the
// test harness can reach them without patching game.js itself.
const exportTail = `
;(function () {
const pick = (name) => {
try { return eval(name); } catch (_) { return undefined; }
};
globalThis.__smokeExport = {
G: pick('G'),
CONFIG: pick('CONFIG'),
BDEF: pick('BDEF'),
PDEFS: pick('PDEFS'),
EVENTS: pick('EVENTS'),
PHASES: pick('PHASES'),
tick: pick('tick'),
updateRates: pick('updateRates'),
writeCode: pick('writeCode'),
autoType: pick('autoType'),
buyBuilding: pick('buyBuilding'),
buyProject: pick('buyProject'),
saveGame: pick('saveGame'),
loadGame: pick('loadGame'),
initGame: pick('initGame'),
triggerEvent: pick('triggerEvent'),
resolveEvent: pick('resolveEvent'),
getClickPower: pick('getClickPower'),
};
})();`;
vm.runInContext(src + exportTail, sandbox, { filename: 'game.js' });
const exported = sandbox.__smokeExport;
// ---------- test harness ----------
let failures = 0;
let passes = 0;
function assert(cond, msg) {
if (cond) {
passes++;
console.log(` ok ${msg}`);
} else {
failures++;
console.error(` FAIL ${msg}`);
}
}
function section(name) { console.log(`\n${name}`); }
const { G, CONFIG, BDEF, PDEFS, EVENTS } = exported;
// ============================================================
// 1. BOOT — loading game.js must not throw, and core tables exist
// ============================================================
section('boot');
assert(typeof G === 'object' && G !== null, 'G global is defined');
assert(typeof exported.tick === 'function', 'tick() is defined');
assert(typeof exported.updateRates === 'function', 'updateRates() is defined');
assert(typeof exported.writeCode === 'function', 'writeCode() is defined');
assert(typeof exported.buyBuilding === 'function', 'buyBuilding() is defined');
assert(typeof exported.saveGame === 'function', 'saveGame() is defined');
assert(typeof exported.loadGame === 'function', 'loadGame() is defined');
assert(Array.isArray(BDEF) && BDEF.length > 0, 'BDEF is a non-empty array');
assert(Array.isArray(PDEFS) && PDEFS.length > 0, 'PDEFS is a non-empty array');
assert(Array.isArray(EVENTS) && EVENTS.length > 0, 'EVENTS is a non-empty array');
assert(G.flags && typeof G.flags === 'object', 'G.flags is initialized (not undefined)');
// Initialize as the browser would
G.startedAt = Date.now();
exported.updateRates();
// ============================================================
// 2. BASIC TICK — no NaN, no throw, rates sane
// ============================================================
section('basic tick loop');
for (let i = 0; i < 50; i++) exported.tick();
assert(!isNaN(G.code), 'G.code is not NaN after 50 ticks');
assert(!isNaN(G.compute), 'G.compute is not NaN after 50 ticks');
assert(G.code >= 0, 'G.code is non-negative');
assert(G.tick > 0, 'G.tick advanced');
// ============================================================
// 3. WRITE CODE — manual click produces code
// ============================================================
section('writeCode()');
const codeBefore = G.code;
exported.writeCode();
assert(G.code > codeBefore, 'writeCode() increases G.code');
assert(G.totalClicks === 1, 'writeCode() increments totalClicks');
// ============================================================
// 4. BUILDING PURCHASE — can afford and buy an autocoder
// ============================================================
section('buyBuilding(autocoder)');
G.code = 1000;
const priorCount = G.buildings.autocoder || 0;
exported.buyBuilding('autocoder');
assert(G.buildings.autocoder === priorCount + 1, 'autocoder count incremented');
assert(G.code < 1000, 'code was spent');
exported.updateRates();
assert(G.codeRate > 0, 'codeRate > 0 after buying an autocoder');
// ============================================================
// 5. GUARDRAIL — codeBoost is a PERSISTENT multiplier, not a per-tick rate
// Any debuff that does `G.codeBoost *= 0.7` inside a function that runs every
// tick will decay codeBoost exponentially. This caught #54's community_drama
// bug: its applyFn mutated codeBoost directly, so 100 ticks of the drama
// debuff left codeBoost at ~3e-16 instead of the intended 0.7.
// ============================================================
section('guardrail: codeBoost does not decay from any debuff');
G.code = 0;
G.codeBoost = 1;
G.activeDebuffs = [];
// Fire every event that sets up a debuff and has a non-zero weight predicate
// if we force the gating condition. We enable the predicates by temporarily
// setting the fields they check; actual event weight() doesn't matter here.
G.ciFlag = 1;
G.deployFlag = 1;
G.buildings.ezra = 1;
G.buildings.bilbo = 1;
G.buildings.allegro = 1;
G.buildings.datacenter = 1;
G.buildings.community = 1;
G.harmony = 40;
G.totalCompute = 5000;
G.totalImpact = 20000;
for (const ev of EVENTS) {
try { ev.effect(); } catch (_) { /* alignment events may branch; ignore */ }
}
const boostAfterAllEvents = G.codeBoost;
for (let i = 0; i < 200; i++) exported.updateRates();
assert(
Math.abs(G.codeBoost - boostAfterAllEvents) < 1e-9,
`codeBoost stable under updateRates() (before=${boostAfterAllEvents}, after=${G.codeBoost})`
);
// Clean up
G.activeDebuffs = [];
G.buildings.ezra = 0; G.buildings.bilbo = 0; G.buildings.allegro = 0;
G.buildings.datacenter = 0; G.buildings.community = 0;
G.ciFlag = 0; G.deployFlag = 0;
// ============================================================
// 6. GUARDRAIL — updateRates() is idempotent per tick
// Calling updateRates twice with the same inputs should produce the same rates.
// (Catches accidental += against a non-reset field.)
// ============================================================
section('guardrail: updateRates is idempotent');
G.buildings.autocoder = 5;
G.codeBoost = 1;
exported.updateRates();
const firstCodeRate = G.codeRate;
const firstComputeRate = G.computeRate;
exported.updateRates();
assert(G.codeRate === firstCodeRate, `codeRate stable across updateRates (${firstCodeRate} vs ${G.codeRate})`);
assert(G.computeRate === firstComputeRate, 'computeRate stable across updateRates');
// ============================================================
// 7. SAVE / LOAD ROUND-TRIP — core scalar fields survive
// ============================================================
section('save/load round-trip');
G.code = 12345;
G.totalCode = 98765;
G.phase = 3;
G.buildings.autocoder = 7;
G.codeBoost = 1.5;
G.flags = { creativity: true };
exported.saveGame();
// Reset to defaults by scrubbing a few fields
G.code = 0;
G.totalCode = 0;
G.phase = 1;
G.buildings.autocoder = 0;
G.codeBoost = 1;
G.flags = {};
const ok = exported.loadGame();
assert(ok, 'loadGame() returned truthy');
assert(G.code === 12345, `G.code restored (got ${G.code})`);
assert(G.totalCode === 98765, `G.totalCode restored (got ${G.totalCode})`);
assert(G.phase === 3, `G.phase restored (got ${G.phase})`);
assert(G.buildings.autocoder === 7, `autocoder count restored (got ${G.buildings.autocoder})`);
assert(Math.abs(G.codeBoost - 1.5) < 1e-9, `codeBoost restored (got ${G.codeBoost})`);
assert(G.flags && G.flags.creativity === true, 'flags.creativity restored');
// ============================================================
// 8. SUMMARY
// ============================================================
console.log(`\n---\n${passes} passed, ${failures} failed`);
if (failures > 0) {
process.exitCode = 1;
}