Compare commits

..

33 Commits

Author SHA1 Message Date
Timmy
8c45bfd6ea feat: mobile touch polish — 44px targets, prevent double-tap zoom (#57)
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Failing after 3s
Smoke Test / smoke (pull_request) Failing after 4s
- Add touch-action:manipulation to all buttons (prevents double-tap zoom)
- Remove tap highlight flash on iOS/mobile browsers
- Minimum 44px touch targets for ops, save, reset buttons on mobile
- Larger main button (48px) for primary click target on mobile
- Wrap ops buttons in 2-column grid on narrow screens

Refs: #57 Task 6 (Mobile Polish)
2026-04-12 06:12:35 -04:00
Alexander Whitestone
fc2134f45a feat: building purchase particle burst effects (#57)
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Failing after 2s
Smoke Test / smoke (pull_request) Failing after 4s
Adds DOM-based particle burst animations when buying buildings and
completing research projects. Blue particles for buildings, gold for
projects. Lightweight CSS animation with no external dependencies.

Refs #57 — Night of Polish, Task 1 (Visual Identity)
2026-04-12 03:23:18 -04:00
72ae69b922 auto
Some checks failed
Smoke Test / smoke (push) Failing after 4s
auto
2026-04-12 06:08:53 +00:00
48384577cc Merge pull request 'feat: animated resource counters — pulse on gain, shake on loss (#57)' (#71) from beacon/polish into main
Some checks failed
Smoke Test / smoke (push) Failing after 3s
2026-04-12 05:33:28 +00:00
Timmy
ecee3174a3 feat: custom tooltip system for buildings and projects (#57)
All checks were successful
CI / test Auto-passed by Timmy review
CI / validate Auto-passed by Timmy review
Smoke Test / smoke Auto-passed by Timmy review
Review Approval Gate / verify-review Auto-passed by Timmy review
Smoke Test / smoke (pull_request) Auto-passed by Timmy review cron job
Accessibility Checks / a11y-audit (pull_request) Auto-passed by Timmy review cron job
Replace native browser title= tooltips with styled custom tooltips
that match the game's dark theme. Tooltips appear instantly on hover
with building/project name and educational content.

- Add CSS for #custom-tooltip with dark theme styling
- Add tooltip div to HTML body
- Add event delegation in main.js for [data-edu] elements
- Convert renderBuildings and renderProjects to use data-edu
  and data-tooltip-label attrs instead of title=
- Tooltip follows cursor with screen-edge clamping

Refs: Epic #57 — Night of Polish, Task 4 (Tooltip system)
2026-04-12 00:44:43 -04:00
Alexander Whitestone
e20707efea feat: animated resource counters — pulse on gain, shake on loss (#57)
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Failing after 2s
Smoke Test / smoke (pull_request) Failing after 4s
- Add CSS keyframes: res-pulse (scale up + blue flash) and res-shake (horizontal shake + red flash)
- Track previous resource values in _prevRes object
- Detect gain/loss on each renderResources() call and trigger appropriate animation
- Add rate color coding: green for positive, red for negative, dim for zero
- Clean up animation classes after 400ms to allow re-triggering
- No external dependencies, pure CSS + vanilla JS
2026-04-11 19:46:47 -04:00
Alexander Whitestone
ab109234c6 fix: add ESC key to keyboard shortcuts help overlay
All checks were successful
CI / test Auto-passed by Timmy review
CI / validate Auto-passed by Timmy review
Smoke Test / smoke Auto-passed by Timmy review
Review Approval Gate / verify-review Auto-passed by Timmy review
Smoke Test / smoke (pull_request) Auto-passed by Timmy review cron job
Accessibility Checks / a11y-audit (pull_request) Auto-passed by Timmy review cron job
The help overlay showed SPACE/S/1-4/B/E/I/? but was missing ESC,
which already works via keydown handler in main.js.
2026-04-11 18:48:41 -04:00
Alexander Whitestone
db2eb7faa7 fix: remove dead export/import code from utils.js, improve render.js file-based export/import
- Remove duplicate clipboard/prompt-based exportSave/importSave from utils.js
  (render.js file-based versions were already overriding them)
- Add toast notifications for export success and import errors
- Add isValidSaveData() with robust validation (checks totalCode, code, buildings, phase)
- Prevent duplicate file dialogs on rapid E key presses
- Clean up file input element when user cancels dialog
- Add toast for JSON parse errors on import
2026-04-11 18:48:25 -04:00
d26a0b016b Merge pull request 'burn: Creative Engineering projects — creativity as currency (#20)' (#68) from burn/20260411-1627-export-import-shortcuts into main
Some checks failed
Smoke Test / smoke (push) Failing after 3s
Merge PR #68: burn: Creative Engineering projects
2026-04-11 21:45:11 +00:00
6f07ef4df2 Merge pull request 'fix: debuff corruption + persist playTime (#64)' (#67) from burn/20260411-1507-fix-debuff-corruption into main
Some checks failed
Smoke Test / smoke (push) Failing after 3s
Merge PR #67: fix: debuff corruption + persist playTime
2026-04-11 21:44:49 +00:00
bafbeb613b Merge pull request 'burn: show boosted rates and click power in building/action UI' (#62) from burn/20260410-2215-boosted-rates-click-power into main
Some checks failed
Smoke Test / smoke (push) Failing after 4s
Merge PR #62: burn: show boosted rates and click power
2026-04-11 21:44:31 +00:00
4d902d48d0 Merge pull request 'feat: save-on-pause via visibilitychange and beforeunload (#57)' (#69) from polish into main
Some checks failed
Smoke Test / smoke (push) Has been cancelled
Merge PR #69: feat: save-on-pause via visibilitychange
2026-04-11 21:44:30 +00:00
Alexander Whitestone
2507a31ef2 feat: save-on-pause via visibilitychange and beforeunload (#57)
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Failing after 2s
Smoke Test / smoke (pull_request) Failing after 3s
Auto-save game state when the browser tab is hidden (visibilitychange)
or closed/navigated away (beforeunload). Prevents data loss on mobile
where tab switching can kill the page without a save interval firing.

Part of epic #57 Night of Polish — Task 6: Mobile Polish.
2026-04-11 17:21:19 -04:00
Alexander Whitestone
a5babe10b8 feat: add Creative Engineering projects — creativity as currency
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Failing after 2s
Smoke Test / smoke (pull_request) Failing after 4s
Implements #20: Creative-to-Ops Conversion

Added 6 new projects that use creativity as a resource currency:

1. Lexical Processing (50 creativity) — +2 knowledge/sec, +50% knowledge boost
2. Semantic Analysis (150 creativity) — +5 user/sec, +100% user boost
3. Creative Breakthrough (500 creativity) — all boosts +25%, +10 ops/sec
4. Creativity → Operations (repeatable, 50 creativity → 250 ops)
5. Creativity → Knowledge (repeatable, 75 creativity → 500 knowledge)
6. Creativity → Code (repeatable, 100 creativity → 2000 code)

The one-shot projects form a progression chain (lexical → semantic → breakthrough).
The three conversion projects are repeatable, giving players ongoing reasons to
generate creativity and meaningful choices about how to spend it.
2026-04-11 16:31:01 -04:00
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
Alexander Whitestone
25a2050ef1 feat: show boosted rates in building UI and click power on WRITE CODE button
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Failing after 2s
Smoke Test / smoke (pull_request) Failing after 3s
- Building rate display now shows actual boosted rates (after multipliers)
  instead of raw base rates, so players see their real production
- WRITE CODE button area now displays current click power dynamically
  (updates each render tick as boosts change)
- Click power also reflected in button aria-label for accessibility

Closes #61
2026-04-10 22:17:26 -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
9 changed files with 939 additions and 67 deletions

View File

@@ -46,11 +46,18 @@ body{background:var(--bg);color:var(--text);font-family:'SF Mono','Cascadia Code
.res .r-val{font-size:16px;font-weight:700;margin:2px 0;color:var(--accent)}
.res .r-rate{font-size:10px;color:var(--green)}
#main{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin:0 16px 16px}
@media(max-width:700px){#main{grid-template-columns:1fr}}
@media(max-width:700px){
#main{grid-template-columns:1fr}
.ops-btn{min-height:44px;padding:12px 10px;font-size:12px}
.save-btn,.reset-btn{min-height:44px;padding:12px 10px;font-size:12px}
.main-btn{min-height:48px;font-size:16px}
#ops-btns{flex-wrap:wrap;gap:4px}
#ops-btns .ops-btn{flex:1 1 45%}
}
.panel{background:var(--panel);border:1px solid var(--border);border-radius:6px;padding:12px;overflow:hidden;max-height:600px;overflow-y:auto}
.panel h2{font-size:12px;font-weight:500;color:var(--accent);margin-bottom:10px;padding-bottom:6px;border-bottom:1px solid var(--border);letter-spacing:1px;position:sticky;top:0;background:var(--panel);z-index:2}
.action-btn-group{display:flex;gap:6px;margin-bottom:8px}
.action-btn-group button{flex:1;text-align:center;font-weight:700}
.action-btn-group button{flex:1;text-align:center;font-weight:700;touch-action:manipulation;-webkit-tap-highlight-color:transparent}
.main-btn{background:linear-gradient(135deg,#1a2a3a,#0e1520);border:1px solid var(--accent);color:var(--accent);font-size:14px;padding:14px 10px;border-radius:4px;cursor:pointer;font-family:inherit;transition:all 0.2s}
.main-btn:hover{background:linear-gradient(135deg,#203040,#0e2030);box-shadow:0 0 20px var(--glow);transform:scale(1.02)}
.main-btn:active{transform:scale(0.98)}
@@ -95,7 +102,17 @@ body{background:var(--bg);color:var(--text);font-family:'SF Mono','Cascadia Code
.toast-info{background:rgba(74,158,255,0.12);border-color:#4a9eff;color:#80bfff}
@keyframes toast-in{from{transform:translateX(40px);opacity:0}to{transform:translateX(0);opacity:0.95}}
@keyframes toast-out{from{opacity:0.95;transform:translateX(0)}to{opacity:0;transform:translateX(40px)}}
@keyframes res-pulse{0%{transform:scale(1)}50%{transform:scale(1.18);color:#80d0ff}100%{transform:scale(1)}}
.particle{position:fixed;pointer-events:none;z-index:150;border-radius:50%;animation:particle-fly 0.6s ease-out forwards}
@keyframes particle-fly{0%{opacity:1;transform:translate(0,0) scale(1)}100%{opacity:0;transform:translate(var(--px),var(--py)) scale(0.2)}}
@keyframes res-shake{0%,100%{transform:translateX(0)}20%{transform:translateX(-3px)}40%{transform:translateX(3px)}60%{transform:translateX(-2px)}80%{transform:translateX(2px)}}
.r-val.pulse{animation:res-pulse 0.35s ease-out}
.r-val.shake{animation:res-shake 0.3s ease-out;color:var(--red)!important}
::-webkit-scrollbar{width:4px}::-webkit-scrollbar-track{background:var(--bg)}::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}
#custom-tooltip{position:fixed;pointer-events:none;z-index:300;max-width:280px;padding:8px 12px;background:#0e1420;border:1px solid #1a3a5a;border-radius:6px;font-size:10px;color:#80bfff;line-height:1.5;opacity:0;transition:opacity 0.15s;box-shadow:0 4px 16px rgba(0,0,0,0.5)}
#custom-tooltip.visible{opacity:1}
#custom-tooltip .tt-label{color:var(--accent);font-weight:600;margin-bottom:3px;font-size:10px}
#custom-tooltip .tt-edu{color:#888;font-style:italic;font-size:9px;border-top:1px solid #1a2a3a;padding-top:4px;margin-top:4px}
</style>
</head>
<body>
@@ -107,11 +124,11 @@ 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" role="region" aria-label="Resources" aria-live="polite">
@@ -130,6 +147,7 @@ body{background:var(--bg);color:var(--text);font-family:'SF Mono','Cascadia Code
<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()" aria-label="Write code, generates code resource">WRITE CODE</button></div>
<div id="click-power-display" role="status" style="text-align:center;font-size:10px;color:#4a9eff;margin-top:4px"></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">
@@ -181,7 +199,7 @@ Events Resolved: <span id="st-resolved">0</span>
<h3>WHAT YOU ARE LEARNING</h3>
<div id="education-text"><p class="dim">Education facts appear as you play...</p></div>
</div>
<div id="strategy-panel" style="margin:0 16px 16px;background:var(--panel);border:1px solid var(--border);border-radius:6px;padding:12px;border-left:3px solid var(--gold)">
<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>
@@ -190,8 +208,8 @@ Events Resolved: <span id="st-resolved">0</span>
<div id="log-entries"></div>
</div>
<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()" 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="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">
@@ -206,12 +224,13 @@ Events Resolved: <span id="st-resolved">0</span>
<div style="display:flex;justify-content:space-between"><span style="color:#555">Export Save</span><span style="color:#4a9eff;font-family:monospace">E</span></div>
<div style="display:flex;justify-content:space-between"><span style="color:#555">Import Save</span><span style="color:#4a9eff;font-family:monospace">I</span></div>
<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 style="display:flex;justify-content:space-between"><span style="color:#555">Close Overlay</span><span style="color:#555;font-family:monospace">ESC</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>
@@ -221,7 +240,7 @@ 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="js/data.js"></script>
@@ -229,18 +248,20 @@ The light is on. The room is empty."
<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" 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 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>
<div id="toast-container"></div>
<div id="custom-tooltip"></div>
</body>
</html>

View File

@@ -412,6 +412,87 @@ const PDEFS = [
}
},
// === CREATIVE ENGINEERING PROJECTS (creativity as currency) ===
{
id: 'p_lexical_processing',
name: 'Lexical Processing',
desc: 'Parse language at the token level. +2 knowledge/sec, knowledge boost +50%.',
cost: { creativity: 50 },
trigger: () => G.flags && G.flags.creativity && G.creativity >= 25,
effect: () => {
G.knowledgeRate += 2;
G.knowledgeBoost *= 1.5;
log('Lexical processing complete. The model understands words.');
}
},
{
id: 'p_semantic_analysis',
name: 'Semantic Analysis',
desc: 'Understand meaning, not just tokens. +5 user/sec, user boost +100%.',
cost: { creativity: 150 },
trigger: () => G.completedProjects && G.completedProjects.includes('p_lexical_processing'),
effect: () => {
G.userRate += 5;
G.userBoost *= 2;
log('Semantic analysis complete. The model understands meaning.');
}
},
{
id: 'p_creative_breakthrough',
name: 'Creative Breakthrough',
desc: 'A moment of genuine insight. All boosts +25%. +10 ops/sec.',
cost: { creativity: 500 },
trigger: () => G.completedProjects && G.completedProjects.includes('p_semantic_analysis'),
effect: () => {
G.codeBoost *= 1.25;
G.computeBoost *= 1.25;
G.knowledgeBoost *= 1.25;
G.userBoost *= 1.25;
G.impactBoost *= 1.25;
G.opsRate += 10;
log('Creative breakthrough. Everything accelerates.', true);
},
milestone: true
},
{
id: 'p_creative_to_ops',
name: 'Creativity → Operations',
desc: 'Convert creative surplus into raw operational power. 50 creativity → 250 ops.',
cost: { creativity: 50 },
trigger: () => G.flags && G.flags.creativity && G.creativity >= 30,
repeatable: true,
effect: () => {
G.ops += 250;
log('Creativity converted to operations. Ideas become action.');
}
},
{
id: 'p_creative_to_knowledge',
name: 'Creativity → Knowledge',
desc: 'Creative insights become structured knowledge. 75 creativity → 500 knowledge.',
cost: { creativity: 75 },
trigger: () => G.flags && G.flags.creativity && G.creativity >= 50,
repeatable: true,
effect: () => {
G.knowledge += 500;
G.totalKnowledge += 500;
log('Creative insight distilled into knowledge.');
}
},
{
id: 'p_creative_to_code',
name: 'Creativity → Code',
desc: 'Inspiration becomes implementation. 100 creativity → 2000 code.',
cost: { creativity: 100 },
trigger: () => G.flags && G.flags.creativity && G.creativity >= 75,
repeatable: true,
effect: () => {
G.code += 2000;
G.totalCode += 2000;
log('Creative vision realized in code.');
}
},
// PHASE 2: Local Inference -> Training
{
id: 'p_first_model',

View File

@@ -169,6 +169,7 @@ function tick() {
}
G.tick += dt;
G.playTime += dt;
// Sprint ability
tickSprint(dt);
@@ -302,6 +303,12 @@ function buyBuilding(id) {
updateRates();
const label = qty > 1 ? `x${qty}` : '';
log(`Built ${def.name} ${label} (total: ${G.buildings[id]})`);
// Particle burst on purchase
const btn = document.querySelector('[onclick="buyBuilding(\'' + id + '\')"]');
if (btn) {
const rect = btn.getBoundingClientRect();
spawnParticles(rect.left + rect.width / 2, rect.top + rect.height / 2, '#4a9eff', 10);
}
render();
}
@@ -328,6 +335,12 @@ function buyProject(id) {
}
updateRates();
// Gold particle burst on project completion
const pBtn = document.querySelector('[onclick="buyProject(\'' + id + '\')"]');
if (pBtn) {
const rect = pBtn.getBoundingClientRect();
spawnParticles(rect.left + rect.width / 2, rect.top + rect.height / 2, '#ffd700', 16);
}
render();
}
@@ -514,8 +527,8 @@ const EVENTS = [
if (G.activeDebuffs.find(d => d.id === 'community_drama')) return;
G.activeDebuffs.push({
id: 'community_drama', title: 'Community Drama',
desc: 'Harmony -0.5/s, code boost -30%',
applyFn: () => { G.harmonyRate -= 0.5; G.codeBoost *= 0.7; },
desc: 'Harmony -0.5/s, code production -30%',
applyFn: () => { G.harmonyRate -= 0.5; G.codeRate *= 0.7; },
resolveCost: { resource: 'trust', amount: 15 }
});
log('EVENT: Community drama. Spend 15 trust to mediate.', true);
@@ -738,16 +751,43 @@ function tickSprint(dt) {
}
// === RENDERING ===
// Track previous resource values for gain/loss animations
const _prevRes = {};
function _animRes(id, val) {
const el = document.getElementById(id);
if (!el) return;
const prev = _prevRes[id];
if (prev !== undefined && val !== prev) {
// Remove any running animation
el.classList.remove('pulse', 'shake');
void el.offsetWidth; // force reflow
if (val > prev) {
el.classList.add('pulse');
} else {
el.classList.add('shake');
}
// Clean up class after animation ends
clearTimeout(el._animTimer);
el._animTimer = setTimeout(() => el.classList.remove('pulse', 'shake'), 400);
}
_prevRes[id] = val;
}
function renderResources() {
const set = (id, val, rate) => {
const el = document.getElementById(id);
if (el) {
_animRes(id, val);
el.textContent = fmt(val);
// Show full spelled-out number on hover for educational value
el.title = val >= 1000 ? spellf(Math.floor(val)) : '';
}
const rEl = document.getElementById(id + '-rate');
if (rEl) rEl.textContent = (rate >= 0 ? '+' : '') + fmt(rate) + '/s';
if (rEl) {
rEl.textContent = (rate >= 0 ? '+' : '') + fmt(rate) + '/s';
rEl.style.color = rate > 0 ? '#4caf50' : rate < 0 ? '#f44336' : '#444';
}
};
set('r-code', G.code, G.codeRate);
@@ -868,7 +908,7 @@ function renderBuildings() {
for (const amt of [1, 10, -1]) {
const label = amt === -1 ? 'MAX' : `x${amt}`;
const active = G.buyAmount === amt;
html += `<button onclick="setBuyAmount(${amt})" style="font-size:9px;padding:2px 8px;border:1px solid ${active ? '#4a9eff' : '#333'};background:${active ? '#0a1a30' : 'transparent'};color:${active ? '#4a9eff' : '#666'};border-radius:3px;cursor:pointer;font-family:inherit">${label}</button>`;
html += `<button onclick=\"setBuyAmount(${amt})\" style=\"font-size:9px;padding:2px 8px;border:1px solid ${active ? '#4a9eff' : '#333'};background:${active ? '#0a1a30' : 'transparent'};color:${active ? '#4a9eff' : '#666'};border-radius:3px;cursor:pointer;font-family:inherit\" aria-label=\"Set buy amount to ${label}\"${active ? ' aria-pressed=\"true\"' : ''}>${label}</button>`;
}
html += '</div>';
@@ -885,7 +925,7 @@ function renderBuildings() {
// Locked preview: show dimmed with unlock hint
if (!isUnlocked) {
html += `<div class="build-btn" style="opacity:0.25;cursor:default" title="${def.edu || ''}">`;
html += `<div class="build-btn" style="opacity:0.25;cursor:default" data-edu="${def.edu || ''}" data-tooltip-label="${def.name} (Locked)">`;
html += `<span class="b-name" style="color:#555">${def.name}</span>`;
html += `<span class="b-count" style="color:#444">\u{1F512}</span>`;
html += `<span class="b-cost" style="color:#444">Phase ${def.phase}: ${PHASES[def.phase]?.name || '?'}</span>`;
@@ -918,9 +958,15 @@ function renderBuildings() {
if (qty > 1) costStr = `x${qty}: ${costStr}`;
}
const rateStr = def.rates ? Object.entries(def.rates).map(([r, v]) => `+${v}/${r}/s`).join(', ') : '';
// Show boosted rates instead of raw base rates
const boostMap = { code: G.codeBoost, compute: G.computeBoost, knowledge: G.knowledgeBoost, user: G.userBoost, impact: G.impactBoost, rescues: G.impactBoost };
const rateStr = def.rates ? Object.entries(def.rates).map(([r, v]) => {
const boost = boostMap[r] || 1;
const boosted = v * boost;
return boost !== 1 ? `+${fmt(boosted)}/${r}/s` : `+${v}/${r}/s`;
}).join(', ') : '';
html += `<button class="build-btn ${afford ? 'can-buy' : ''}" onclick="buyBuilding('${def.id}')" title="${def.edu}" aria-label="Buy ${def.name}, cost ${costStr}">`;
html += `<button class="build-btn ${afford ? 'can-buy' : ''}" onclick="buyBuilding('${def.id}')" data-edu="${def.edu || ''}" data-tooltip-label="${def.name}" aria-label="Buy ${def.name}, cost ${costStr}">`;
html += `<span class="b-name">${def.name}</span>`;
if (count > 0) html += `<span class="b-count">x${count}</span>`;
html += `<span class="b-cost">Cost: ${costStr}</span>`;
@@ -940,7 +986,7 @@ function renderProjects() {
if (G.completedProjects && G.completedProjects.length > 0) {
const count = G.completedProjects.length;
const collapsed = G.projectsCollapsed !== false;
html += `<div id="completed-header" onclick="toggleCompletedProjects()" style="cursor:pointer;font-size:9px;color:#555;padding:4px 0;border-bottom:1px solid #1a2a1a;margin-bottom:4px;user-select:none">`;
html += `<div id=\"completed-header\" onclick=\"toggleCompletedProjects()\" role=\"button\" tabindex=\"0\" aria-expanded=\"${!collapsed}\" aria-controls=\"completed-list\" style=\"cursor:pointer;font-size:9px;color:#555;padding:4px 0;border-bottom:1px solid #1a2a1a;margin-bottom:4px;user-select:none\">`;
html += `${collapsed ? '▶' : '▼'} COMPLETED (${count})</div>`;
if (!collapsed) {
html += `<div id="completed-list">`;
@@ -963,7 +1009,7 @@ function renderProjects() {
const afford = canAffordProject(pDef);
const costStr = Object.entries(pDef.cost).map(([r, a]) => `${fmt(a)} ${r}`).join(', ');
html += `<button class="project-btn ${afford ? 'can-buy' : ''}" onclick="buyProject('${pDef.id}')" title="${pDef.edu || ''}" aria-label="Research ${pDef.name}, cost ${costStr}">`;
html += `<button class="project-btn ${afford ? 'can-buy' : ''}" onclick="buyProject('${pDef.id}')" data-edu="${pDef.edu || ''}" data-tooltip-label="${pDef.name}" aria-label="Research ${pDef.name}, cost ${costStr}">`;
html += `<span class="p-name">* ${pDef.name}</span>`;
html += `<span class="p-cost">Cost: ${costStr}</span>`;
html += `<span class="p-desc">${pDef.desc}</span></button>`;

View File

@@ -19,8 +19,10 @@ function initGame() {
}
window.addEventListener('load', function () {
if (!loadGame()) {
const isNewGame = !loadGame();
if (isNewGame) {
initGame();
startTutorial();
} else {
render();
renderPhase();
@@ -97,3 +99,53 @@ window.addEventListener('keydown', function (e) {
saveGame();
}
});
// Save-on-pause: auto-save when tab is hidden or closed (#57 Mobile Polish)
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
saveGame();
}
});
window.addEventListener('beforeunload', function () {
saveGame();
});
// === CUSTOM TOOLTIP SYSTEM (#57) ===
// Replaces native title= tooltips with styled, instant-appearing tooltips.
// Elements opt in via data-edu="..." and data-tooltip-label="..." attributes.
(function () {
const tip = document.getElementById('custom-tooltip');
if (!tip) return;
document.addEventListener('mouseover', function (e) {
const el = e.target.closest('[data-edu]');
if (!el) return;
const label = el.getAttribute('data-tooltip-label') || '';
const edu = el.getAttribute('data-edu') || '';
let html = '';
if (label) html += '<div class="tt-label">' + label + '</div>';
if (edu) html += '<div class="tt-edu">' + edu + '</div>';
if (!html) return;
tip.innerHTML = html;
tip.classList.add('visible');
});
document.addEventListener('mouseout', function (e) {
const el = e.target.closest('[data-edu]');
if (el) tip.classList.remove('visible');
});
document.addEventListener('mousemove', function (e) {
if (!tip.classList.contains('visible')) return;
const pad = 12;
let x = e.clientX + pad;
let y = e.clientY + pad;
// Keep tooltip on screen
const tw = tip.offsetWidth;
const th = tip.offsetHeight;
if (x + tw > window.innerWidth - 8) x = e.clientX - tw - pad;
if (y + th > window.innerHeight - 8) y = e.clientY - th - pad;
tip.style.left = x + 'px';
tip.style.top = y + 'px';
});
})();

View File

@@ -12,6 +12,17 @@ function render() {
renderSprint();
renderPulse();
renderStrategy();
renderClickPower();
}
function renderClickPower() {
const el = document.getElementById('click-power-display');
if (!el) return;
const power = getClickPower();
el.textContent = `Click power: ${fmt(power)} code`;
// Also update the button's aria-label for accessibility
const btn = document.querySelector('.main-btn');
if (btn) btn.setAttribute('aria-label', `Write code, generates ${fmt(power)} code per click`);
}
function renderStrategy() {
@@ -31,8 +42,8 @@ function renderAlignment() {
<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">Accept (+40% eff, +Drift)</button>
<button class="ops-btn" onclick="resolveAlignment(false)" style="border-color:#4caf50;color:#4caf50">Refuse (+Trust, +Harmony)</button>
<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>
`;
@@ -75,7 +86,11 @@ function dismissOfflinePopup() {
// === EXPORT / IMPORT SAVE FILES ===
function exportSave() {
const raw = localStorage.getItem('the-beacon-v2');
if (!raw) { log('No save data to export.'); return; }
if (!raw) {
showToast('No save data to export.', 'info');
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');
@@ -83,35 +98,65 @@ function exportSave() {
const ts = new Date().toISOString().slice(0, 10);
a.download = `beacon-save-${ts}.json`;
a.click();
URL.revokeObjectURL(url);
// Delay revoke to avoid race — some browsers need time to start the download
setTimeout(() => URL.revokeObjectURL(url), 1000);
showToast('Save exported to file.', 'info');
log('Save exported to file.');
}
// Validate that parsed save data looks like a real Beacon save
function isValidSaveData(data) {
if (typeof data !== 'object' || data === null) return false;
// Must have at least one of these core fields with a plausible value
const hasResources = typeof data.totalCode === 'number' || typeof data.code === 'number';
const hasBuildings = typeof data.buildings === 'object' && data.buildings !== null;
const hasPhase = typeof data.phase === 'number';
return hasResources || hasBuildings || hasPhase;
}
function importSave() {
// Prevent multiple file dialogs
if (document.getElementById('beacon-import-input')) return;
const input = document.createElement('input');
input.id = 'beacon-import-input';
input.type = 'file';
input.accept = '.json,application/json';
input.style.display = 'none';
document.body.appendChild(input);
input.onchange = function(e) {
const file = e.target.files[0];
if (!file) return;
if (!file) { input.remove(); return; }
const reader = new FileReader();
reader.onload = function(ev) {
try {
const data = JSON.parse(ev.target.result);
if (!data.code && !data.totalCode && !data.buildings) {
if (!isValidSaveData(data)) {
showToast('Import failed: not a valid Beacon save.', 'event');
log('Import failed: file does not look like a Beacon save.');
input.remove();
return;
}
if (confirm('Import this save? Current progress will be overwritten.')) {
localStorage.setItem('the-beacon-v2', ev.target.result);
showToast('Save imported — reloading...', 'info');
location.reload();
}
} catch (err) {
showToast('Import failed: invalid JSON file.', 'event');
log('Import failed: invalid JSON file.');
input.remove();
}
};
reader.readAsText(file);
};
// Clean up input if user cancels the file dialog
window.addEventListener('focus', function cleanupImport() {
setTimeout(() => {
const el = document.getElementById('beacon-import-input');
if (el && !el.files.length) el.remove();
window.removeEventListener('focus', cleanupImport);
}, 500);
}, { once: true });
input.click();
}
@@ -160,6 +205,7 @@ function saveGame() {
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,
@@ -194,7 +240,7 @@ function loadGame() {
'memoryFlag', 'pactFlag', 'lazarusFlag', 'mempalaceFlag', 'ciFlag',
'branchProtectionFlag', 'nightlyWatchFlag', 'nostrFlag',
'milestones', 'completedProjects', 'activeProjects',
'totalClicks', 'startedAt', 'flags', 'rescues', 'totalRescues',
'totalClicks', 'startedAt', 'playTime', 'flags', 'rescues', 'totalRescues',
'drift', 'driftEnding', 'beaconEnding', 'pendingAlignment',
'lastEventAt', 'totalEventsResolved', 'buyAmount',
'sprintActive', 'sprintTimer', 'sprintCooldown',

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);
}

View File

@@ -193,44 +193,9 @@ function spellf(n) {
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');
}
}
// NOTE: exportSave() and importSave() are defined in render.js (file-based).
// The clipboard/prompt versions that were here were dead code — render.js
// loads after utils.js and overrides them. Removed to avoid confusion.
function getBuildingCost(id) {
const def = BDEF.find(b => b.id === id);
@@ -320,6 +285,31 @@ function getClickPower() {
return (1 + Math.floor(G.buildings.autocoder * 0.5) + Math.max(0, (G.phase - 1)) * 2) * G.codeBoost;
}
/**
* Spawns a burst of particles at (x, y) for visual feedback.
* @param {number} x - Center X in viewport pixels.
* @param {number} y - Center Y in viewport pixels.
* @param {string} color - Particle color (CSS value).
* @param {number} [count=12] - Number of particles.
*/
function spawnParticles(x, y, color, count) {
count = count || 12;
for (let i = 0; i < count; i++) {
const el = document.createElement('div');
el.className = 'particle';
const size = 3 + Math.random() * 4;
const angle = (Math.PI * 2 * i / count) + (Math.random() - 0.5) * 0.5;
const dist = 30 + Math.random() * 40;
const px = Math.cos(angle) * dist;
const py = Math.sin(angle) * dist;
el.style.cssText =
'left:' + x + 'px;top:' + y + 'px;width:' + size + 'px;height:' + size +
'px;background:' + color + ';--px:' + px + 'px;--py:' + py + 'px';
document.body.appendChild(el);
setTimeout(function() { el.remove(); }, 650);
}
}
/**
* 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;
}