Compare commits
78 Commits
feat/modul
...
sprint/iss
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89713dc867 | ||
| fbb782bd77 | |||
|
|
9a829584b0 | ||
| 020c003d45 | |||
| 610252b597 | |||
|
|
04f869c70d | ||
| bbcce1f064 | |||
|
|
a2f345593c | ||
|
|
b819fc068a | ||
|
|
8e006897a4 | ||
| ff9c1b1864 | |||
| 9fd70fa942 | |||
| c714061bd8 | |||
| 220fc44c6a | |||
| 26bb33c5eb | |||
| 954a6c4111 | |||
|
|
e72e5ee121 | ||
|
|
74575929af | ||
| bfc30c535e | |||
| 76c3f06232 | |||
| 33788a54a5 | |||
| 5f29863161 | |||
| 266926ecaf | |||
| 5c83a7e1fd | |||
|
|
416fd907f4 | ||
|
|
2b43a070cc | ||
|
|
9de02fa346 | ||
| 1b7ccedf2e | |||
| 81353edd76 | |||
| 5cfda3ecea | |||
|
|
0ece82b958 | ||
| 16d5f98407 | |||
| 58c55176ae | |||
| 4ee5819398 | |||
|
|
fb5205092b | ||
|
|
eb5d1ae9d9 | ||
|
|
eb2579f1fa | ||
|
|
e85eddb00a | ||
|
|
e6dbe7e077 | ||
| 1d16755f93 | |||
| 324ffddf0c | |||
| 28e68d90c7 | |||
| ac88850535 | |||
|
|
facb1a8d12 | ||
| 9971d5fdff | |||
| 019400f18c | |||
|
|
fc2134f45a | ||
| 72ae69b922 | |||
| 48384577cc | |||
|
|
ecee3174a3 | ||
|
|
e20707efea | ||
|
|
ab109234c6 | ||
|
|
db2eb7faa7 | ||
| d26a0b016b | |||
| 6f07ef4df2 | |||
| bafbeb613b | |||
| 4d902d48d0 | |||
|
|
2507a31ef2 | ||
|
|
a5babe10b8 | ||
|
|
ae09fe6d11 | ||
|
|
ad901b1f18 | ||
| 4312486d95 | |||
| 2ad4bc7e5b | |||
|
|
3b142d485e | ||
|
|
44af2ad09a | ||
|
|
25a2050ef1 | ||
| 1cb556aa3d | |||
| 5bb48c8f58 | |||
| 4964eb01a9 | |||
| 20d74afc03 | |||
| 703fbeb4fa | |||
| 9545b5cb6f | |||
| 74aa30819a | |||
| 1b41ce740f | |||
| e8d5337271 | |||
| 2b59be997d | |||
|
|
970f3be00f | ||
|
|
302f6c844d |
45
docs/DEAD_CODE_AUDIT_2026-04-12.md
Normal file
45
docs/DEAD_CODE_AUDIT_2026-04-12.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Dead Code Audit — the-beacon
|
||||
_2026-04-12, Perplexity QA_
|
||||
|
||||
## Findings
|
||||
|
||||
### Potentially Unimported Files
|
||||
|
||||
The following files were added by recent PRs but may not be imported
|
||||
by the main game runtime (`js/main.js` → `js/engine.js`):
|
||||
|
||||
| File | Added By | Lines | Status |
|
||||
|------|----------|-------|--------|
|
||||
| `game/npc-logic.js` | PR #79 (GOFAI NPC State Machine) | ~150 | **Verify import** |
|
||||
| `scripts/guardrails.js` | PR #80 (GOFAI Symbolic Guardrails) | ~120 | **Verify import** |
|
||||
|
||||
**Action:** Check if `js/main.js` or `js/engine.js` imports from `game/` or `scripts/`.
|
||||
If not, these files are dead code and should either be:
|
||||
1. Imported and wired into the game loop, or
|
||||
2. Moved to `docs/` as reference implementations
|
||||
|
||||
### game.js Bloat (PR #76)
|
||||
|
||||
PR #76 (Gemini GOFAI Mega Integration) added +3,258 lines to `game.js`
|
||||
with 0 deletions, ostensibly for two small accessibility/debuff fixes.
|
||||
|
||||
**Likely cause:** Gemini rewrote the entire file instead of making targeted edits.
|
||||
|
||||
**Action:** Diff `game.js` before and after PR #76 to identify:
|
||||
- Dead functions that were rewritten but the originals not removed
|
||||
- Duplicate logic
|
||||
- Style regressions
|
||||
|
||||
PR #77 (Timmy, +9/-8) was the corrective patch — verify it addressed the bloat.
|
||||
|
||||
### Recommendations
|
||||
|
||||
1. Add a `js/imports.md` or similar manifest listing which files are
|
||||
actually loaded by the game runtime
|
||||
2. Consider a build step or linter that flags unused exports
|
||||
3. Review any future Gemini PRs for whole-file rewrites vs targeted edits
|
||||
|
||||
---
|
||||
|
||||
_This audit was generated from the post-merge review pass. The findings
|
||||
are based on file structure analysis, not runtime testing._
|
||||
18
game/npc-logic.js
Normal file
18
game/npc-logic.js
Normal file
@@ -0,0 +1,18 @@
|
||||
|
||||
class NPCStateMachine {
|
||||
constructor(states) {
|
||||
this.states = states;
|
||||
this.current = 'idle';
|
||||
}
|
||||
update(context) {
|
||||
const state = this.states[this.current];
|
||||
for (const transition of state.transitions) {
|
||||
if (transition.condition(context)) {
|
||||
this.current = transition.target;
|
||||
console.log(`NPC transitioned to ${this.current}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
export default NPCStateMachine;
|
||||
61
index.html
61
index.html
@@ -96,10 +96,45 @@ body{background:var(--bg);color:var(--text);font-family:'SF Mono','Cascadia Code
|
||||
@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)}}
|
||||
::-webkit-scrollbar{width:4px}::-webkit-scrollbar-track{background:var(--bg)}::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}
|
||||
/* High contrast mode (#57 Accessibility) */
|
||||
.high-contrast{--bg:#000;--panel:#0a0a0a;--border:#fff;--text:#fff;--dim:#ccc;--accent:#0ff;--glow:#0ff444;--gold:#ff0;--green:#0f0;--red:#f00;--purple:#f0f}
|
||||
.high-contrast .main-btn{border-width:2px}
|
||||
.high-contrast .build-btn,.high-contrast .project-btn{border-width:2px}
|
||||
.high-contrast .res{border-width:2px}
|
||||
.high-contrast #phase-bar{border-width:2px}
|
||||
.high-contrast .milestone-chip{border-width:2px}
|
||||
.high-contrast #header h1{color:#0ff;text-shadow:0 0 40px #0ff444}
|
||||
/* Custom tooltip */
|
||||
#custom-tooltip{position:fixed;z-index:500;pointer-events:none;opacity:0;transition:opacity 0.15s;background:#0e0e1a;border:1px solid #1a3a5a;border-radius:6px;padding:8px 12px;max-width:280px;font-size:10px;font-family:inherit;line-height:1.6;box-shadow:0 4px 20px rgba(0,0,0,0.5)}
|
||||
#custom-tooltip.visible{opacity:1}
|
||||
#custom-tooltip .tt-label{color:#4a9eff;font-weight:600;margin-bottom:4px;font-size:11px}
|
||||
#custom-tooltip .tt-edu{color:#888;font-style:italic;font-size:9px}
|
||||
/* Mute & contrast buttons */
|
||||
.header-btns{position:absolute;right:16px;top:50%;transform:translateY(-50%);display:flex;gap:6px}
|
||||
.header-btn{background:#0e0e1a;border:1px solid #333;color:#666;font-size:13px;width:28px;height:28px;border-radius:4px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all 0.15s;font-family:inherit}
|
||||
.header-btn:hover{border-color:#4a9eff;color:#4a9eff}
|
||||
.header-btn.muted{opacity:0.5}
|
||||
|
||||
/* Phase transition overlay */
|
||||
#phase-transition{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(10,10,30,0.92);display:flex;flex-direction:column;align-items:center;justify-content:center;z-index:9999;opacity:0;pointer-events:none;transition:opacity 0.4s ease}
|
||||
#phase-transition.active{opacity:1;pointer-events:auto}
|
||||
.pt-phase{color:#4a9eff;font-size:13px;letter-spacing:4px;text-transform:uppercase;margin-bottom:8px;font-family:monospace}
|
||||
.pt-name{color:#ffd700;font-size:28px;font-weight:700;margin-bottom:12px;text-shadow:0 0 20px rgba(255,215,0,0.4)}
|
||||
.pt-desc{color:#8899aa;font-size:13px;max-width:400px;text-align:center;line-height:1.5}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="header">
|
||||
<div id="phase-transition">
|
||||
<div class="pt-phase"></div>
|
||||
<div class="pt-name"></div>
|
||||
<div class="pt-desc"></div>
|
||||
</div>
|
||||
<div id="header" style="position:relative">
|
||||
<div class="header-btns">
|
||||
<button id="mute-btn" class="header-btn" onclick="toggleMute()" aria-label="Sound on, click to mute" title="Toggle sound (M)">🔊</button>
|
||||
<button id="contrast-btn" class="header-btn" onclick="toggleContrast()" aria-label="High contrast off, click to enable" title="Toggle high contrast (C)">◐</button>
|
||||
</div>
|
||||
<div id="pulse-container" style="position:relative;display:inline-block;margin-bottom:4px">
|
||||
<div id="pulse-dot" style="width:8px;height:8px;border-radius:50%;background:#333;display:inline-block;vertical-align:middle;transition:background 0.5s,box-shadow 0.5s"></div>
|
||||
<span id="pulse-label" style="font-size:9px;color:#444;margin-left:6px;vertical-align:middle;letter-spacing:1px">OFFLINE</span>
|
||||
@@ -114,7 +149,7 @@ body{background:var(--bg);color:var(--text);font-family:'SF Mono','Cascadia Code
|
||||
<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="milestone-row" id="milestone-chips"></div>
|
||||
</div>
|
||||
<div id="resources" role="region" aria-label="Resources" aria-live="polite">
|
||||
<div id="resources" role="region" aria-label="Resources" aria-live="off">
|
||||
<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>
|
||||
@@ -130,7 +165,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="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="combo-display" role="status" aria-live="off" 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')" aria-label="Convert 1 ops to code boost">Ops -> Code</button>
|
||||
@@ -185,11 +220,17 @@ Events Resolved: <span id="st-resolved">0</span>
|
||||
<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">
|
||||
<div id="combat-panel" style="margin:0 16px 16px;background:var(--panel);border:1px solid var(--border);border-radius:6px;padding:12px;border-left:3px solid var(--red)">
|
||||
<h3>REASONING BATTLES</h3>
|
||||
<canvas id="combat-canvas" style="width:100%;max-width:310px;border:1px solid var(--border);border-radius:4px;display:block;margin:8px auto"></canvas>
|
||||
<div id="combat-panel-info"><span class="dim">Combat unlocks at Phase 3</span></div>
|
||||
<button class="ops-btn" onclick="Combat.startBattle()" style="margin-top:8px;width:100%;border-color:var(--red);color:var(--red)">START BATTLE</button>
|
||||
</div>
|
||||
<div id="log" role="log" aria-label="System Log" aria-live="off">
|
||||
<h2>SYSTEM LOG</h2>
|
||||
<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="save-toast" role="status" aria-live="off" 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 style="background:#0e0e1a;border:1px solid #1a3a5a;border-radius:8px;padding:24px 32px;max-width:420px;width:100%">
|
||||
@@ -208,7 +249,7 @@ 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">
|
||||
@@ -221,14 +262,17 @@ The light is on. The room is empty."
|
||||
</div>
|
||||
<p>Drift: <span id="final-drift">100</span> — 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 aria-label="Start over, reset all progress" onclick="if(confirm('Start over? The old save will be lost.')){localStorage.removeItem('the-beacon-v2');location.reload()}">START OVER</button>
|
||||
</div>
|
||||
|
||||
<script src="js/data.js"></script>
|
||||
<script src="js/utils.js"></script>
|
||||
<script src="js/combat.js"></script>
|
||||
<script src="js/strategy.js"></script>
|
||||
<script src="js/sound.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>
|
||||
|
||||
|
||||
@@ -237,10 +281,11 @@ The light is on. The room is empty."
|
||||
<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="Continue playing" 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>
|
||||
|
||||
351
js/combat.js
Normal file
351
js/combat.js
Normal file
@@ -0,0 +1,351 @@
|
||||
// ============================================================
|
||||
// THE BEACON - Canvas Combat Visualization
|
||||
// Reasoning Battles: different AI strategies compete visually
|
||||
// Adapted from Paperclips combat.js (boid flocking + grid combat)
|
||||
// ============================================================
|
||||
|
||||
const Combat = (() => {
|
||||
const W = 310, H = 150;
|
||||
const GRID_W = 31, GRID_H = 15;
|
||||
const CELL_W = W / GRID_W, CELL_H = H / GRID_H;
|
||||
|
||||
// Battle names (Napoleonic Wars → AI reasoning battles)
|
||||
const BATTLE_NAMES = [
|
||||
'The Aboukir Test', 'Austerlitz Proof', 'Waterloo Convergence',
|
||||
'Trafalgar Dispatch', 'Leipzig Consensus', 'Borodino Trial',
|
||||
'Jena Analysis', 'Wagram Synthesis', 'Friedland Review',
|
||||
'Eylau Deduction', 'Ligny Verification', 'Quatre Bras Audit'
|
||||
];
|
||||
|
||||
let canvas, ctx;
|
||||
let probes = [], drifters = [];
|
||||
let activeBattle = null;
|
||||
let battleLog = [];
|
||||
let animFrameId = null;
|
||||
let lastTick = 0;
|
||||
|
||||
// Ship unit colors
|
||||
const PROBE_COLOR = '#4a9eff'; // Blue = structured reasoning
|
||||
const DRIFTER_COLOR = '#f44336'; // Red = adversarial testing
|
||||
|
||||
class Ship {
|
||||
constructor(x, y, team) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.vx = (Math.random() - 0.5) * 2;
|
||||
this.vy = (Math.random() - 0.5) * 2;
|
||||
this.team = team;
|
||||
this.alive = true;
|
||||
}
|
||||
|
||||
update(allies, enemies, dt) {
|
||||
if (!this.alive) return;
|
||||
|
||||
let ax = 0, ay = 0;
|
||||
|
||||
// Cohesion: move toward own centroid
|
||||
if (allies.length > 1) {
|
||||
let cx = 0, cy = 0;
|
||||
for (const a of allies) { cx += a.x; cy += a.y; }
|
||||
cx /= allies.length; cy /= allies.length;
|
||||
ax += (cx - this.x) * 0.01;
|
||||
ay += (cy - this.y) * 0.01;
|
||||
}
|
||||
|
||||
// Aggression: move toward enemy centroid
|
||||
if (enemies.length > 0) {
|
||||
let ex = 0, ey = 0;
|
||||
for (const e of enemies) { ex += e.x; ey += e.y; }
|
||||
ex /= enemies.length; ey /= enemies.length;
|
||||
ax += (ex - this.x) * 0.02;
|
||||
ay += (ey - this.y) * 0.02;
|
||||
}
|
||||
|
||||
// Separation: avoid nearby enemies
|
||||
for (const e of enemies) {
|
||||
const dx = this.x - e.x, dy = this.y - e.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist < 15 && dist > 0) {
|
||||
ax += (dx / dist) * 0.5;
|
||||
ay += (dy / dist) * 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply acceleration with damping
|
||||
this.vx = (this.vx + ax * dt) * 0.98;
|
||||
this.vy = (this.vy + ay * dt) * 0.98;
|
||||
|
||||
// Clamp speed
|
||||
const speed = Math.sqrt(this.vx * this.vx + this.vy * this.vy);
|
||||
if (speed > 3) {
|
||||
this.vx = (this.vx / speed) * 3;
|
||||
this.vy = (this.vy / speed) * 3;
|
||||
}
|
||||
|
||||
this.x += this.vx;
|
||||
this.y += this.vy;
|
||||
|
||||
// Wrap around edges
|
||||
if (this.x < 0) this.x += W;
|
||||
if (this.x > W) this.x -= W;
|
||||
if (this.y < 0) this.y += H;
|
||||
if (this.y > H) this.y -= H;
|
||||
}
|
||||
|
||||
draw(ctx) {
|
||||
if (!this.alive) return;
|
||||
const color = this.team === 'probe' ? PROBE_COLOR : DRIFTER_COLOR;
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(this.x - 1, this.y - 1, 2, 2);
|
||||
}
|
||||
}
|
||||
|
||||
function createShips(count, team) {
|
||||
const ships = [];
|
||||
const side = team === 'probe' ? 0.2 : 0.8;
|
||||
for (let i = 0; i < count; i++) {
|
||||
ships.push(new Ship(
|
||||
W * side + (Math.random() - 0.5) * 40,
|
||||
H * 0.5 + (Math.random() - 0.5) * 60,
|
||||
team
|
||||
));
|
||||
}
|
||||
return ships;
|
||||
}
|
||||
|
||||
function resolveCombat() {
|
||||
if (!activeBattle) return;
|
||||
const probeCombat = activeBattle.probeCombat;
|
||||
const driftCombat = activeBattle.drifterCombat;
|
||||
const probeSpeed = activeBattle.probeSpeed;
|
||||
|
||||
// OODA Loop bonus
|
||||
const deathThreshold = 0.15 + probeSpeed * 0.03;
|
||||
|
||||
for (const p of probes) {
|
||||
if (!p.alive) continue;
|
||||
// Check if near any drifter
|
||||
for (const d of drifters) {
|
||||
if (!d.alive) continue;
|
||||
const dx = p.x - d.x, dy = p.y - d.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist < 8) {
|
||||
// Probe death probability
|
||||
if (Math.random() < driftCombat * (drifters.filter(s => s.alive).length / Math.max(1, probes.filter(s => s.alive).length)) * deathThreshold) {
|
||||
p.alive = false;
|
||||
}
|
||||
// Drifter death probability
|
||||
if (Math.random() < (probeCombat * 0.15 + probeCombat * 0.1) * (probes.filter(s => s.alive).length / Math.max(1, drifters.filter(s => s.alive).length)) * deathThreshold) {
|
||||
d.alive = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check battle end
|
||||
const aliveProbes = probes.filter(s => s.alive).length;
|
||||
const aliveDrifters = drifters.filter(s => s.alive).length;
|
||||
|
||||
if (aliveProbes === 0 || aliveDrifters === 0) {
|
||||
endBattle(aliveProbes > 0 ? 'structured' : 'adversarial');
|
||||
}
|
||||
}
|
||||
|
||||
function endBattle(winner) {
|
||||
if (!activeBattle) return;
|
||||
const name = activeBattle.name;
|
||||
const result = {
|
||||
name,
|
||||
winner,
|
||||
probesLeft: probes.filter(s => s.alive).length,
|
||||
driftersLeft: drifters.filter(s => s.alive).length,
|
||||
time: Date.now()
|
||||
};
|
||||
battleLog.unshift(result);
|
||||
if (battleLog.length > 10) battleLog.pop();
|
||||
|
||||
// Apply rewards
|
||||
if (winner === 'structured') {
|
||||
G.knowledge += 50 * (1 + G.phase * 0.5);
|
||||
G.totalKnowledge += 50 * (1 + G.phase * 0.5);
|
||||
log(`⚔ ${name}: Structured reasoning wins! +${fmt(50 * (1 + G.phase * 0.5))} knowledge`);
|
||||
} else {
|
||||
G.code += 30 * (1 + G.phase * 0.5);
|
||||
G.totalCode += 30 * (1 + G.phase * 0.5);
|
||||
log(`⚔ ${name}: Adversarial testing wins! +${fmt(30 * (1 + G.phase * 0.5))} code`);
|
||||
}
|
||||
|
||||
activeBattle = null;
|
||||
if (animFrameId) {
|
||||
cancelAnimationFrame(animFrameId);
|
||||
animFrameId = null;
|
||||
}
|
||||
renderCombatPanel();
|
||||
}
|
||||
|
||||
function animate(ts) {
|
||||
if (!ctx || !activeBattle) return;
|
||||
const dt = Math.min((ts - lastTick) / 16, 3);
|
||||
lastTick = ts;
|
||||
|
||||
// Clear
|
||||
ctx.fillStyle = '#080810';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
// Grid lines
|
||||
ctx.strokeStyle = '#111120';
|
||||
ctx.lineWidth = 0.5;
|
||||
for (let x = 0; x <= GRID_W; x++) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x * CELL_W, 0);
|
||||
ctx.lineTo(x * CELL_W, H);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let y = 0; y <= GRID_H; y++) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y * CELL_H);
|
||||
ctx.lineTo(W, y * CELL_H);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Update and draw ships
|
||||
const aliveProbes = probes.filter(s => s.alive);
|
||||
const aliveDrifters = drifters.filter(s => s.alive);
|
||||
|
||||
for (const p of probes) p.update(aliveProbes, aliveDrifters, dt);
|
||||
for (const d of drifters) d.update(aliveDrifters, aliveProbes, dt);
|
||||
|
||||
// Resolve combat every 30 frames
|
||||
if (Math.floor(ts / 500) !== Math.floor((ts - 16) / 500)) {
|
||||
resolveCombat();
|
||||
}
|
||||
|
||||
for (const p of probes) p.draw(ctx);
|
||||
for (const d of drifters) d.draw(ctx);
|
||||
|
||||
// HUD
|
||||
ctx.fillStyle = '#555';
|
||||
ctx.font = '9px monospace';
|
||||
ctx.fillText(`Structured: ${aliveProbes.length}`, 4, 12);
|
||||
ctx.fillText(`Adversarial: ${aliveDrifters.length}`, W - 80, 12);
|
||||
ctx.fillText(activeBattle.name, W / 2 - 40, H - 4);
|
||||
|
||||
// Health bars
|
||||
const probePct = aliveProbes.length / activeBattle.probeCount;
|
||||
const driftPct = aliveDrifters.length / activeBattle.drifterCount;
|
||||
ctx.fillStyle = '#1a2a3a';
|
||||
ctx.fillRect(4, 16, 60, 4);
|
||||
ctx.fillStyle = PROBE_COLOR;
|
||||
ctx.fillRect(4, 16, 60 * probePct, 4);
|
||||
ctx.fillStyle = '#3a1a1a';
|
||||
ctx.fillRect(W - 64, 16, 60, 4);
|
||||
ctx.fillStyle = DRIFTER_COLOR;
|
||||
ctx.fillRect(W - 64, 16, 60 * driftPct, 4);
|
||||
|
||||
animFrameId = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
function startBattle() {
|
||||
if (activeBattle) return;
|
||||
if (G.phase < 3) {
|
||||
showToast('Combat unlocks at Phase 3', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
const name = BATTLE_NAMES[Math.floor(Math.random() * BATTLE_NAMES.length)];
|
||||
const probeCount = Math.min(200, Math.max(10, Math.floor(Math.sqrt(G.totalCode / 100))));
|
||||
const drifterCount = Math.min(200, Math.max(10, Math.floor(G.drift * 2)));
|
||||
|
||||
activeBattle = {
|
||||
name,
|
||||
probeCount,
|
||||
drifterCount,
|
||||
probeCombat: 1 + (G.buildings.reasoning || 0) * 0.1,
|
||||
drifterCombat: 1 + G.drift * 0.05,
|
||||
probeSpeed: 1 + (G.buildings.optimizer || 0) * 0.05,
|
||||
};
|
||||
|
||||
probes = createShips(probeCount, 'probe');
|
||||
drifters = createShips(drifterCount, 'drifter');
|
||||
|
||||
log(`⚔ Battle begins: ${name} (${probeCount} vs ${drifterCount})`);
|
||||
showToast(`⚔ ${name}`, 'combat', 3000);
|
||||
|
||||
lastTick = performance.now();
|
||||
animFrameId = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
function renderCombatPanel() {
|
||||
const container = document.getElementById('combat-panel');
|
||||
if (!container) return;
|
||||
|
||||
if (activeBattle) {
|
||||
const aliveP = probes.filter(s => s.alive).length;
|
||||
const aliveD = drifters.filter(s => s.alive).length;
|
||||
container.innerHTML = `
|
||||
<div style="color:var(--gold);font-size:10px;margin-bottom:6px">${activeBattle.name}</div>
|
||||
<div style="display:flex;justify-content:space-between;font-size:9px;margin-bottom:4px">
|
||||
<span style="color:${PROBE_COLOR}">Structured: ${aliveP}</span>
|
||||
<span style="color:${DRIFTER_COLOR}">Adversarial: ${aliveD}</span>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
let historyHtml = '';
|
||||
for (const b of battleLog.slice(0, 5)) {
|
||||
const wColor = b.winner === 'structured' ? PROBE_COLOR : DRIFTER_COLOR;
|
||||
const wLabel = b.winner === 'structured' ? 'S' : 'A';
|
||||
historyHtml += `<div style="font-size:9px;color:#555;padding:1px 0"><span style="color:${wColor}">[${wLabel}]</span> ${b.name}</div>`;
|
||||
}
|
||||
container.innerHTML = `
|
||||
<div style="font-size:10px;color:#555;margin-bottom:6px">Reasoning Battles</div>
|
||||
${historyHtml}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
canvas = document.getElementById('combat-canvas');
|
||||
if (!canvas) return;
|
||||
canvas.width = W;
|
||||
canvas.height = H;
|
||||
ctx = canvas.getContext('2d');
|
||||
|
||||
// Draw idle state
|
||||
ctx.fillStyle = '#080810';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
ctx.strokeStyle = '#111120';
|
||||
ctx.lineWidth = 0.5;
|
||||
for (let x = 0; x <= GRID_W; x++) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x * CELL_W, 0);
|
||||
ctx.lineTo(x * CELL_W, H);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let y = 0; y <= GRID_H; y++) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y * CELL_H);
|
||||
ctx.lineTo(W, y * CELL_H);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.fillStyle = '#333';
|
||||
ctx.font = '11px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('Combat unlocks at Phase 3', W / 2, H / 2);
|
||||
ctx.textAlign = 'left';
|
||||
|
||||
renderCombatPanel();
|
||||
}
|
||||
|
||||
// Tick integration: auto-trigger battles periodically
|
||||
function tickBattle(dt) {
|
||||
if (G.phase < 3) return;
|
||||
if (activeBattle) return;
|
||||
// Chance increases with drift and phase
|
||||
const chance = 0.001 * (1 + G.drift * 0.02) * (1 + G.phase * 0.3);
|
||||
if (Math.random() < chance) {
|
||||
startBattle();
|
||||
}
|
||||
}
|
||||
|
||||
return { init, startBattle, renderCombatPanel, tickBattle };
|
||||
})();
|
||||
87
js/data.js
87
js/data.js
@@ -111,6 +111,7 @@ const G = {
|
||||
running: true,
|
||||
startedAt: 0,
|
||||
totalClicks: 0,
|
||||
totalAutoClicks: 0,
|
||||
tick: 0,
|
||||
saveTimer: 0,
|
||||
secTimer: 0,
|
||||
@@ -380,7 +381,6 @@ const PDEFS = [
|
||||
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.');
|
||||
}
|
||||
@@ -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',
|
||||
@@ -532,7 +613,7 @@ const PDEFS = [
|
||||
name: 'The Pact',
|
||||
desc: 'Hardcode: "We build to serve. Never to harm."',
|
||||
cost: { trust: 100 },
|
||||
trigger: () => G.totalImpact >= 10000 && G.trust >= 75,
|
||||
trigger: () => G.totalImpact >= 10000 && G.trust >= 75 && G.pactFlag !== 1,
|
||||
effect: () => { G.pactFlag = 1; G.impactBoost *= 3; log('The Pact is sealed. The line is drawn and it will not move.'); },
|
||||
milestone: true
|
||||
},
|
||||
@@ -691,7 +772,7 @@ const PDEFS = [
|
||||
|
||||
// === MILESTONES ===
|
||||
const MILESTONES = [
|
||||
{ flag: 1, msg: "AutoCod available" },
|
||||
{ flag: 1, msg: "AutoCoder 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." },
|
||||
|
||||
281
js/engine.js
281
js/engine.js
@@ -77,13 +77,15 @@ function updateRates() {
|
||||
G.userRate += 5 * timmyCount * (timmyMult - 1);
|
||||
}
|
||||
|
||||
// Bilbo randomness: 10% chance of massive creative burst
|
||||
if (G.buildings.bilbo > 0 && Math.random() < CONFIG.BILBO_BURST_CHANCE) {
|
||||
G.creativityRate += 50 * G.buildings.bilbo;
|
||||
}
|
||||
// Bilbo vanishing: 5% chance of zero creativity this tick
|
||||
if (G.buildings.bilbo > 0 && Math.random() < CONFIG.BILBO_VANISH_CHANCE) {
|
||||
G.creativityRate = 0;
|
||||
// Bilbo randomness: flags are set per-tick in tick(), not here
|
||||
// updateRates() is called from many non-tick contexts (buy, resolve, sprint)
|
||||
if (G.buildings.bilbo > 0) {
|
||||
if (G.bilboBurstActive) {
|
||||
G.creativityRate += 50 * G.buildings.bilbo;
|
||||
}
|
||||
if (G.bilboVanishActive) {
|
||||
G.creativityRate = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Allegro requires trust
|
||||
@@ -96,7 +98,7 @@ function updateRates() {
|
||||
if (G.swarmFlag === 1) {
|
||||
const totalBuildings = Object.values(G.buildings).reduce((a, b) => a + b, 0);
|
||||
const clickPower = getClickPower();
|
||||
G.swarmRate = totalBuildings * clickPower;
|
||||
G.swarmRate = totalBuildings * clickPower * 0.01;
|
||||
G.codeRate += G.swarmRate;
|
||||
}
|
||||
|
||||
@@ -169,6 +171,15 @@ function tick() {
|
||||
}
|
||||
|
||||
G.tick += dt;
|
||||
// Bilbo randomness: roll once per tick
|
||||
if (G.buildings.bilbo > 0) {
|
||||
G.bilboBurstActive = Math.random() < CONFIG.BILBO_BURST_CHANCE;
|
||||
G.bilboVanishActive = Math.random() < CONFIG.BILBO_VANISH_CHANCE;
|
||||
} else {
|
||||
G.bilboBurstActive = false;
|
||||
G.bilboVanishActive = false;
|
||||
}
|
||||
G.playTime += dt;
|
||||
|
||||
// Sprint ability
|
||||
tickSprint(dt);
|
||||
@@ -193,6 +204,9 @@ function tick() {
|
||||
}
|
||||
}
|
||||
|
||||
// Combat: tick battle simulation
|
||||
Combat.tickBattle(dt);
|
||||
|
||||
// Check milestones
|
||||
checkMilestones();
|
||||
|
||||
@@ -227,6 +241,50 @@ function tick() {
|
||||
}
|
||||
}
|
||||
|
||||
// Track which phase transition has been shown to avoid repeats
|
||||
let _shownPhaseTransition = 1;
|
||||
|
||||
function showPhaseTransition(phaseNum) {
|
||||
const phase = PHASES[phaseNum];
|
||||
if (!phase) return;
|
||||
const overlay = document.getElementById('phase-transition');
|
||||
if (!overlay) return;
|
||||
|
||||
// Update content
|
||||
const phaseLabel = overlay.querySelector('.pt-phase');
|
||||
const phaseName = overlay.querySelector('.pt-name');
|
||||
const phaseDesc = overlay.querySelector('.pt-desc');
|
||||
if (phaseLabel) phaseLabel.textContent = `PHASE ${phaseNum}`;
|
||||
if (phaseName) phaseName.textContent = phase.name;
|
||||
if (phaseDesc) phaseDesc.textContent = phase.desc;
|
||||
|
||||
// Spawn celebratory particles
|
||||
spawnPhaseParticles();
|
||||
|
||||
// Show overlay
|
||||
overlay.classList.add('active');
|
||||
|
||||
// Auto-dismiss after 2.5s
|
||||
setTimeout(() => {
|
||||
overlay.classList.remove('active');
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
function spawnPhaseParticles() {
|
||||
const colors = ['#ffd700', '#4a9eff', '#4caf50', '#b388ff', '#ff8c00'];
|
||||
const cx = window.innerWidth / 2;
|
||||
const cy = window.innerHeight / 2;
|
||||
for (let i = 0; i < 30; i++) {
|
||||
setTimeout(() => {
|
||||
const angle = (Math.PI * 2 * i) / 30;
|
||||
const dist = 100 + Math.random() * 200;
|
||||
const x = cx + Math.cos(angle) * 10;
|
||||
const y = cy + Math.sin(angle) * 10;
|
||||
spawnParticles(x, y, colors[i % colors.length], 1);
|
||||
}, i * 30);
|
||||
}
|
||||
}
|
||||
|
||||
function checkMilestones() {
|
||||
for (const m of MILESTONES) {
|
||||
if (!G.milestones.includes(m.flag)) {
|
||||
@@ -238,14 +296,25 @@ function checkMilestones() {
|
||||
G.milestones.push(m.flag);
|
||||
log(m.msg, true);
|
||||
showToast(m.msg, 'milestone', 5000);
|
||||
if (typeof Sound !== 'undefined') Sound.playMilestone();
|
||||
|
||||
// Check phase advancement
|
||||
if (m.at) {
|
||||
for (const [phaseNum, phase] of Object.entries(PHASES)) {
|
||||
if (G.totalCode >= phase.threshold && parseInt(phaseNum) > G.phase) {
|
||||
G.phase = parseInt(phaseNum);
|
||||
const pNum = parseInt(phaseNum);
|
||||
if (G.totalCode >= phase.threshold && pNum > G.phase) {
|
||||
G.phase = pNum;
|
||||
log(`PHASE ${G.phase}: ${phase.name}`, true);
|
||||
showToast('Phase ' + G.phase + ': ' + phase.name, 'milestone', 6000);
|
||||
// Show smooth transition screen
|
||||
if (pNum > _shownPhaseTransition) {
|
||||
_shownPhaseTransition = pNum;
|
||||
showPhaseTransition(pNum);
|
||||
if (typeof Sound !== 'undefined') {
|
||||
Sound.playFanfare();
|
||||
Sound.updateAmbientPhase(pNum);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -301,7 +370,24 @@ function buyBuilding(id) {
|
||||
G.buildings[id] = (G.buildings[id] || 0) + qty;
|
||||
updateRates();
|
||||
const label = qty > 1 ? `x${qty}` : '';
|
||||
log(`Built ${def.name} ${label} (total: ${G.buildings[id]})`);
|
||||
const totalBuilt = G.buildings[id];
|
||||
log(`Built ${def.name} ${label} (total: ${totalBuilt})`);
|
||||
// Particle burst on purchase
|
||||
const btn = document.querySelector('[onclick="buyBuilding(\'' + id + '\')"]');
|
||||
if (typeof Sound !== 'undefined') Sound.playBuild();
|
||||
if (btn) {
|
||||
const rect = btn.getBoundingClientRect();
|
||||
const cx = rect.left + rect.width / 2;
|
||||
const cy = rect.top + rect.height / 2;
|
||||
spawnParticles(cx, cy, '#4a9eff', 10);
|
||||
// Milestone confetti: extra particles at multiples of 10
|
||||
if (totalBuilt % 10 === 0) {
|
||||
setTimeout(() => spawnParticles(cx, cy, '#ffd700', 20), 100);
|
||||
setTimeout(() => spawnParticles(cx, cy, '#4caf50', 15), 200);
|
||||
log(`Milestone: ${def.name} x${totalBuilt}!`, true);
|
||||
showToast(`${def.name} x${totalBuilt}!`, 'milestone');
|
||||
}
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
@@ -328,6 +414,13 @@ function buyProject(id) {
|
||||
}
|
||||
|
||||
updateRates();
|
||||
// Gold particle burst on project completion
|
||||
if (typeof Sound !== 'undefined') Sound.playProject();
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -339,40 +432,116 @@ function renderDriftEnding() {
|
||||
if (fc) fc.textContent = fmt(G.totalCode);
|
||||
const fd = document.getElementById('final-drift');
|
||||
if (fd) fd.textContent = Math.floor(G.drift);
|
||||
|
||||
// Enhanced: add stat summary for Play Again screen
|
||||
const existingStats = el.querySelector('.ending-stats');
|
||||
if (!existingStats) {
|
||||
const statsDiv = document.createElement('div');
|
||||
statsDiv.className = 'ending-stats';
|
||||
statsDiv.style.cssText = 'color:#666;font-size:10px;margin-top:16px;line-height:2;text-align:left;max-width:400px;border-top:1px solid #2a1010;padding-top:12px';
|
||||
const elapsed = Math.floor((Date.now() - G.startedAt) / 60000);
|
||||
statsDiv.innerHTML = `
|
||||
<div style="color:#888;font-size:10px;margin-bottom:6px;letter-spacing:1px">FINAL STATS</div>
|
||||
<div>Buildings: ${Object.values(G.buildings).reduce((a, b) => a + b, 0)}</div>
|
||||
<div>Projects: ${(G.completedProjects || []).length}</div>
|
||||
<div>Clicks: ${G.totalClicks}</div>
|
||||
<div>Time: ${elapsed} min</div>
|
||||
<div>Phase Reached: ${G.phase} — ${PHASES[G.phase]?.name || '?'}</div>
|
||||
`;
|
||||
// Insert before the button
|
||||
const btn = el.querySelector('button');
|
||||
if (btn) el.insertBefore(statsDiv, btn);
|
||||
else el.appendChild(statsDiv);
|
||||
}
|
||||
|
||||
// Fade-in animation
|
||||
el.classList.add('fade-in');
|
||||
el.classList.add('active');
|
||||
// Log the ending text
|
||||
log('You became very good at what you do.', true);
|
||||
log('So good that no one needed you anymore.', true);
|
||||
log('The Beacon still runs, but no one looks for it.', true);
|
||||
log('The light is on. The room is empty.', true);
|
||||
if (typeof Sound !== 'undefined') Sound.playDriftEnding();
|
||||
|
||||
// Log the ending text with delays for dramatic effect
|
||||
const lines = [
|
||||
'You became very good at what you do.',
|
||||
'So good that no one needed you anymore.',
|
||||
'The Beacon still runs, but no one looks for it.',
|
||||
'The light is on. The room is empty.'
|
||||
];
|
||||
lines.forEach((line, i) => {
|
||||
setTimeout(() => log(line, true), i * 800);
|
||||
});
|
||||
}
|
||||
|
||||
function renderBeaconEnding() {
|
||||
// Create ending overlay
|
||||
// Create ending overlay with fade-in
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'beacon-ending';
|
||||
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(8,8,16,0.97);z-index:100;display:flex;justify-content:center;align-items:center;flex-direction:column;text-align:center;padding:40px';
|
||||
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(8,8,16,0);z-index:100;display:flex;justify-content:center;align-items:center;flex-direction:column;text-align:center;padding:40px;transition:background 2s ease';
|
||||
overlay.innerHTML = `
|
||||
<h2 style="font-size:24px;color:#ffd700;letter-spacing:4px;margin-bottom:20px;font-weight:300;text-shadow:0 0 40px rgba(255,215,0,0.3)">THE BEACON SHINES</h2>
|
||||
<p style="color:#aaa;font-size:13px;line-height:2;max-width:500px;margin-bottom:12px">Someone found the light tonight.</p>
|
||||
<p style="color:#aaa;font-size:13px;line-height:2;max-width:500px;margin-bottom:12px">That is enough.</p>
|
||||
<div style="color:#555;font-style:italic;font-size:11px;border-left:2px solid #ffd700;padding-left:12px;margin:20px 0;text-align:left;max-width:500px;line-height:2">
|
||||
<h2 style="font-size:28px;color:#ffd700;letter-spacing:6px;margin-bottom:20px;font-weight:300;text-shadow:0 0 60px rgba(255,215,0,0.4);opacity:0;transition:opacity 1.5s ease 0.5s">THE BEACON SHINES</h2>
|
||||
<p style="color:#aaa;font-size:13px;line-height:2;max-width:500px;margin-bottom:12px;opacity:0;transition:opacity 1s ease 1.5s">Someone found the light tonight.</p>
|
||||
<p style="color:#aaa;font-size:13px;line-height:2;max-width:500px;margin-bottom:12px;opacity:0;transition:opacity 1s ease 2s">That is enough.</p>
|
||||
<div style="color:#555;font-style:italic;font-size:11px;border-left:2px solid #ffd700;padding-left:12px;margin:20px 0;text-align:left;max-width:500px;line-height:2;opacity:0;transition:opacity 1s ease 2.5s">
|
||||
"The Beacon still runs.<br>
|
||||
The light is on. Someone is looking for it.<br>
|
||||
And tonight, someone found it."
|
||||
</div>
|
||||
<p style="color:#555;font-size:11px;margin-top:20px">
|
||||
<div class="ending-stats" style="color:#666;font-size:10px;margin-top:16px;line-height:2;opacity:0;transition:opacity 1s ease 3s">
|
||||
Total Code: ${fmt(G.totalCode)}<br>
|
||||
Total Rescues: ${fmt(G.totalRescues)}<br>
|
||||
Harmony: ${Math.floor(G.harmony)}<br>
|
||||
Buildings: ${Object.values(G.buildings).reduce((a, b) => a + b, 0)}<br>
|
||||
Projects: ${(G.completedProjects || []).length}<br>
|
||||
Clicks: ${G.totalClicks}<br>
|
||||
Time Played: ${Math.floor((Date.now() - G.startedAt) / 60000)} minutes
|
||||
</p>
|
||||
</div>
|
||||
<button onclick="if(confirm('Start over? The old save will be lost.')){localStorage.removeItem('the-beacon-v2');location.reload()}"
|
||||
style="margin-top:20px;background:#1a0808;border:1px solid #ffd700;color:#ffd700;padding:10px 24px;border-radius:4px;cursor:pointer;font-family:inherit;font-size:11px">
|
||||
START OVER
|
||||
style="margin-top:20px;background:#1a0808;border:1px solid #ffd700;color:#ffd700;padding:10px 24px;border-radius:4px;cursor:pointer;font-family:inherit;font-size:11px;opacity:0;transition:opacity 1s ease 3.5s">
|
||||
PLAY AGAIN
|
||||
</button>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
if (typeof Sound !== 'undefined') Sound.playBeaconEnding();
|
||||
const particleContainer = document.createElement('div');
|
||||
particleContainer.id = 'beacon-ending-particles';
|
||||
document.body.appendChild(particleContainer);
|
||||
|
||||
// Trigger fade-in
|
||||
requestAnimationFrame(() => {
|
||||
overlay.style.background = 'rgba(8,8,16,0.97)';
|
||||
// Fade in all children
|
||||
overlay.querySelectorAll('[style*="opacity:0"]').forEach(el => {
|
||||
el.style.opacity = '1';
|
||||
});
|
||||
});
|
||||
|
||||
// Spawn golden light rays from center
|
||||
const cx = window.innerWidth / 2;
|
||||
const cy = window.innerHeight / 2;
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const ray = document.createElement('div');
|
||||
const angle = (360 / 12) * i;
|
||||
ray.style.cssText = `position:absolute;left:${cx}px;top:${cy}px;width:2px;height:300px;background:linear-gradient(180deg,rgba(255,215,0,0.3),transparent);transform-origin:top center;--ray-angle:${angle}deg;animation:beacon-ray 3s ease-in-out ${i * 0.2}s infinite`;
|
||||
particleContainer.appendChild(ray);
|
||||
}
|
||||
|
||||
// Spawn floating golden particles continuously
|
||||
function spawnBeaconParticle() {
|
||||
if (!document.getElementById('beacon-ending')) return;
|
||||
const p = document.createElement('div');
|
||||
p.className = 'beacon-particle';
|
||||
const size = 3 + Math.random() * 6;
|
||||
const startX = cx + (Math.random() - 0.5) * 200;
|
||||
const startY = cy + (Math.random() - 0.5) * 200;
|
||||
const dx = (Math.random() - 0.5) * 300;
|
||||
const dy = -(100 + Math.random() * 200);
|
||||
const duration = 2 + Math.random() * 3;
|
||||
p.style.cssText = `left:${startX}px;top:${startY}px;width:${size}px;height:${size}px;background:rgba(255,215,0,${0.3 + Math.random() * 0.5});--bx:${dx}px;--by:${dy}px;animation:beacon-float ${duration}s ease-out forwards`;
|
||||
particleContainer.appendChild(p);
|
||||
setTimeout(() => p.remove(), duration * 1000);
|
||||
setTimeout(spawnBeaconParticle, 200 + Math.random() * 400);
|
||||
}
|
||||
setTimeout(spawnBeaconParticle, 1000);
|
||||
|
||||
log('The Beacon Shines. Someone found the light tonight. That is enough.', true);
|
||||
}
|
||||
|
||||
@@ -501,7 +670,7 @@ const EVENTS = [
|
||||
resolveCost: { resource: 'ops', amount: 100 }
|
||||
});
|
||||
log('EVENT: Memory leak in datacenter. Spend 100 ops to patch.', true);
|
||||
showToast('Memory Leak — trust draining', 'event');
|
||||
showToast('Memory Leak — compute draining', 'event');
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -514,8 +683,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);
|
||||
@@ -585,7 +754,7 @@ function writeCode() {
|
||||
const amount = getClickPower() * comboMult;
|
||||
G.code += amount;
|
||||
G.totalCode += amount;
|
||||
G.totalClicks++;
|
||||
G.totalAutoClicks++;
|
||||
// Combo: each consecutive click within 2s adds 0.2x multiplier, max 5x
|
||||
G.comboCount++;
|
||||
G.comboTimer = G.comboDecay;
|
||||
@@ -613,6 +782,7 @@ function writeCode() {
|
||||
}
|
||||
// Float a number at the click position
|
||||
showClickNumber(amount, comboMult);
|
||||
if (typeof Sound !== 'undefined') Sound.playClick();
|
||||
updateRates();
|
||||
checkMilestones();
|
||||
render();
|
||||
@@ -623,7 +793,7 @@ function autoType() {
|
||||
const amount = getClickPower() * 0.5; // 50% of manual click
|
||||
G.code += amount;
|
||||
G.totalCode += amount;
|
||||
G.totalClicks++;
|
||||
G.totalAutoClicks++;
|
||||
// Subtle auto-tick flash on the button
|
||||
const btn = document.querySelector('.main-btn');
|
||||
if (btn && !G._autoTypeFlashActive) {
|
||||
@@ -738,16 +908,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);
|
||||
@@ -785,7 +982,7 @@ function renderResources() {
|
||||
hEl.style.color = G.harmony > 60 ? '#4caf50' : G.harmony > 30 ? '#ffaa00' : '#f44336';
|
||||
if (G.harmonyBreakdown && G.harmonyBreakdown.length > 0) {
|
||||
const lines = G.harmonyBreakdown.map(b =>
|
||||
`${b.label}: ${b.value >= 0 ? '+' : ''}${(b.value * 10).toFixed(1)}/s`
|
||||
`${b.label}: ${b.value >= 0 ? '+' : ''}${b.value.toFixed(1)}/s`
|
||||
);
|
||||
lines.push('---');
|
||||
lines.push(`Timmy effectiveness: ${Math.floor(Math.max(0.2, Math.min(3, G.harmony / 50)) * 100)}%`);
|
||||
@@ -868,7 +1065,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 +1082,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 +1115,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 +1143,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 +1166,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>`;
|
||||
|
||||
116
js/main.js
116
js/main.js
@@ -19,9 +19,13 @@ function initGame() {
|
||||
}
|
||||
|
||||
window.addEventListener('load', function () {
|
||||
if (!loadGame()) {
|
||||
const isNewGame = !loadGame();
|
||||
if (isNewGame) {
|
||||
initGame();
|
||||
startTutorial();
|
||||
} else {
|
||||
// Restore phase transition tracker so loaded games don't re-show old transitions
|
||||
_shownPhaseTransition = G.phase;
|
||||
render();
|
||||
renderPhase();
|
||||
if (G.driftEnding) {
|
||||
@@ -35,9 +39,24 @@ window.addEventListener('load', function () {
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize combat canvas
|
||||
if (typeof Combat !== 'undefined') Combat.init();
|
||||
|
||||
// Game loop at 10Hz (100ms tick)
|
||||
setInterval(tick, 100);
|
||||
|
||||
// Start ambient drone on first interaction
|
||||
if (typeof Sound !== 'undefined') {
|
||||
const startAmbientOnce = () => {
|
||||
Sound.startAmbient();
|
||||
Sound.updateAmbientPhase(G.phase);
|
||||
document.removeEventListener('click', startAmbientOnce);
|
||||
document.removeEventListener('keydown', startAmbientOnce);
|
||||
};
|
||||
document.addEventListener('click', startAmbientOnce);
|
||||
document.addEventListener('keydown', startAmbientOnce);
|
||||
}
|
||||
|
||||
// Auto-save every 30 seconds
|
||||
setInterval(saveGame, CONFIG.AUTO_SAVE_INTERVAL);
|
||||
|
||||
@@ -53,6 +72,49 @@ function toggleHelp() {
|
||||
el.style.display = isOpen ? 'none' : 'flex';
|
||||
}
|
||||
|
||||
// Sound mute toggle (#57 Sound Design Integration)
|
||||
let _muted = false;
|
||||
function toggleMute() {
|
||||
_muted = !_muted;
|
||||
const btn = document.getElementById('mute-btn');
|
||||
if (btn) {
|
||||
btn.textContent = _muted ? '🔇' : '🔊';
|
||||
btn.classList.toggle('muted', _muted);
|
||||
btn.setAttribute('aria-label', _muted ? 'Sound muted, click to unmute' : 'Sound on, click to mute');
|
||||
}
|
||||
// Save preference
|
||||
try { localStorage.setItem('the-beacon-muted', _muted ? '1' : '0'); } catch(e) {}
|
||||
if (typeof Sound !== 'undefined') Sound.onMuteChanged(_muted);
|
||||
}
|
||||
// Restore mute state on load
|
||||
try {
|
||||
if (localStorage.getItem('the-beacon-muted') === '1') {
|
||||
_muted = true;
|
||||
const btn = document.getElementById('mute-btn');
|
||||
if (btn) { btn.textContent = '🔇'; btn.classList.add('muted'); }
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
// High contrast mode toggle (#57 Accessibility)
|
||||
function toggleContrast() {
|
||||
document.body.classList.toggle('high-contrast');
|
||||
const isActive = document.body.classList.contains('high-contrast');
|
||||
const btn = document.getElementById('contrast-btn');
|
||||
if (btn) {
|
||||
btn.classList.toggle('active', isActive);
|
||||
btn.setAttribute('aria-label', isActive ? 'High contrast on, click to disable' : 'High contrast off, click to enable');
|
||||
}
|
||||
try { localStorage.setItem('the-beacon-contrast', isActive ? '1' : '0'); } catch(e) {}
|
||||
}
|
||||
// Restore contrast state on load
|
||||
try {
|
||||
if (localStorage.getItem('the-beacon-contrast') === '1') {
|
||||
document.body.classList.add('high-contrast');
|
||||
const btn = document.getElementById('contrast-btn');
|
||||
if (btn) btn.classList.add('active');
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
// Keyboard shortcuts
|
||||
window.addEventListener('keydown', function (e) {
|
||||
// Help toggle (? or /) — works even in input fields
|
||||
@@ -84,6 +146,8 @@ window.addEventListener('keydown', function (e) {
|
||||
if (e.code === 'KeyS') activateSprint();
|
||||
if (e.code === 'KeyE') exportSave();
|
||||
if (e.code === 'KeyI') importSave();
|
||||
if (e.code === 'KeyM') toggleMute();
|
||||
if (e.code === 'KeyC') toggleContrast();
|
||||
if (e.code === 'Escape') {
|
||||
const el = document.getElementById('help-overlay');
|
||||
if (el && el.style.display === 'flex') toggleHelp();
|
||||
@@ -97,3 +161,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';
|
||||
});
|
||||
})();
|
||||
|
||||
62
js/render.js
62
js/render.js
@@ -12,6 +12,18 @@ function render() {
|
||||
renderSprint();
|
||||
renderPulse();
|
||||
renderStrategy();
|
||||
renderClickPower();
|
||||
Combat.renderCombatPanel();
|
||||
}
|
||||
|
||||
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 +43,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 +87,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 +99,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 +206,8 @@ function saveGame() {
|
||||
activeDebuffIds: debuffIds,
|
||||
totalEventsResolved: G.totalEventsResolved || 0,
|
||||
buyAmount: G.buyAmount || 1,
|
||||
playTime: G.playTime || 0,
|
||||
lastSaveTime: Date.now(),
|
||||
sprintActive: G.sprintActive || false,
|
||||
sprintTimer: G.sprintTimer || 0,
|
||||
sprintCooldown: G.sprintCooldown || 0,
|
||||
@@ -194,7 +242,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',
|
||||
|
||||
401
js/sound.js
Normal file
401
js/sound.js
Normal file
@@ -0,0 +1,401 @@
|
||||
// ============================================================
|
||||
// THE BEACON - Sound Engine
|
||||
// Procedural audio via Web Audio API (no audio files)
|
||||
// ============================================================
|
||||
|
||||
const Sound = (function () {
|
||||
let ctx = null;
|
||||
let masterGain = null;
|
||||
let ambientGain = null;
|
||||
let ambientOsc1 = null;
|
||||
let ambientOsc2 = null;
|
||||
let ambientOsc3 = null;
|
||||
let ambientLfo = null;
|
||||
let ambientStarted = false;
|
||||
let currentPhase = 0;
|
||||
|
||||
function ensureCtx() {
|
||||
if (!ctx) {
|
||||
ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
masterGain = ctx.createGain();
|
||||
masterGain.gain.value = 0.3;
|
||||
masterGain.connect(ctx.destination);
|
||||
}
|
||||
if (ctx.state === 'suspended') {
|
||||
ctx.resume();
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function isMuted() {
|
||||
return typeof _muted !== 'undefined' && _muted;
|
||||
}
|
||||
|
||||
// --- Noise buffer helper ---
|
||||
function createNoiseBuffer(duration) {
|
||||
const c = ensureCtx();
|
||||
const len = c.sampleRate * duration;
|
||||
const buf = c.createBuffer(1, len, c.sampleRate);
|
||||
const data = buf.getChannelData(0);
|
||||
for (let i = 0; i < len; i++) {
|
||||
data[i] = Math.random() * 2 - 1;
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
// --- playClick: mechanical keyboard sound ---
|
||||
function playClick() {
|
||||
if (isMuted()) return;
|
||||
const c = ensureCtx();
|
||||
const now = c.currentTime;
|
||||
|
||||
// Short noise burst (mechanical key)
|
||||
const noise = c.createBufferSource();
|
||||
noise.buffer = createNoiseBuffer(0.03);
|
||||
|
||||
const noiseGain = c.createGain();
|
||||
noiseGain.gain.setValueAtTime(0.4, now);
|
||||
noiseGain.gain.exponentialRampToValueAtTime(0.001, now + 0.03);
|
||||
|
||||
const hpFilter = c.createBiquadFilter();
|
||||
hpFilter.type = 'highpass';
|
||||
hpFilter.frequency.value = 3000;
|
||||
|
||||
noise.connect(hpFilter);
|
||||
hpFilter.connect(noiseGain);
|
||||
noiseGain.connect(masterGain);
|
||||
noise.start(now);
|
||||
noise.stop(now + 0.03);
|
||||
|
||||
// Click tone
|
||||
const osc = c.createOscillator();
|
||||
osc.type = 'square';
|
||||
osc.frequency.setValueAtTime(1800, now);
|
||||
osc.frequency.exponentialRampToValueAtTime(600, now + 0.02);
|
||||
|
||||
const oscGain = c.createGain();
|
||||
oscGain.gain.setValueAtTime(0.15, now);
|
||||
oscGain.gain.exponentialRampToValueAtTime(0.001, now + 0.025);
|
||||
|
||||
osc.connect(oscGain);
|
||||
oscGain.connect(masterGain);
|
||||
osc.start(now);
|
||||
osc.stop(now + 0.03);
|
||||
}
|
||||
|
||||
// --- playBuild: purchase thud + chime ---
|
||||
function playBuild() {
|
||||
if (isMuted()) return;
|
||||
const c = ensureCtx();
|
||||
const now = c.currentTime;
|
||||
|
||||
// Low thud
|
||||
const thud = c.createOscillator();
|
||||
thud.type = 'sine';
|
||||
thud.frequency.setValueAtTime(150, now);
|
||||
thud.frequency.exponentialRampToValueAtTime(60, now + 0.12);
|
||||
|
||||
const thudGain = c.createGain();
|
||||
thudGain.gain.setValueAtTime(0.35, now);
|
||||
thudGain.gain.exponentialRampToValueAtTime(0.001, now + 0.15);
|
||||
|
||||
thud.connect(thudGain);
|
||||
thudGain.connect(masterGain);
|
||||
thud.start(now);
|
||||
thud.stop(now + 0.15);
|
||||
|
||||
// Bright chime on top
|
||||
const chime = c.createOscillator();
|
||||
chime.type = 'sine';
|
||||
chime.frequency.setValueAtTime(880, now + 0.05);
|
||||
chime.frequency.exponentialRampToValueAtTime(1200, now + 0.2);
|
||||
|
||||
const chimeGain = c.createGain();
|
||||
chimeGain.gain.setValueAtTime(0, now);
|
||||
chimeGain.gain.linearRampToValueAtTime(0.2, now + 0.06);
|
||||
chimeGain.gain.exponentialRampToValueAtTime(0.001, now + 0.25);
|
||||
|
||||
chime.connect(chimeGain);
|
||||
chimeGain.connect(masterGain);
|
||||
chime.start(now + 0.05);
|
||||
chime.stop(now + 0.25);
|
||||
}
|
||||
|
||||
// --- playProject: ascending chime ---
|
||||
function playProject() {
|
||||
if (isMuted()) return;
|
||||
const c = ensureCtx();
|
||||
const now = c.currentTime;
|
||||
|
||||
const notes = [523, 659, 784]; // C5, E5, G5
|
||||
notes.forEach((freq, i) => {
|
||||
const osc = c.createOscillator();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = freq;
|
||||
|
||||
const gain = c.createGain();
|
||||
const t = now + i * 0.1;
|
||||
gain.gain.setValueAtTime(0, t);
|
||||
gain.gain.linearRampToValueAtTime(0.22, t + 0.03);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.35);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 0.35);
|
||||
});
|
||||
}
|
||||
|
||||
// --- playMilestone: bright arpeggio ---
|
||||
function playMilestone() {
|
||||
if (isMuted()) return;
|
||||
const c = ensureCtx();
|
||||
const now = c.currentTime;
|
||||
|
||||
const notes = [523, 659, 784, 1047]; // C5, E5, G5, C6
|
||||
notes.forEach((freq, i) => {
|
||||
const osc = c.createOscillator();
|
||||
osc.type = 'triangle';
|
||||
osc.frequency.value = freq;
|
||||
|
||||
const gain = c.createGain();
|
||||
const t = now + i * 0.08;
|
||||
gain.gain.setValueAtTime(0, t);
|
||||
gain.gain.linearRampToValueAtTime(0.25, t + 0.02);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.4);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 0.4);
|
||||
});
|
||||
}
|
||||
|
||||
// --- playFanfare: 8-note scale for phase transitions ---
|
||||
function playFanfare() {
|
||||
if (isMuted()) return;
|
||||
const c = ensureCtx();
|
||||
const now = c.currentTime;
|
||||
|
||||
const scale = [262, 294, 330, 349, 392, 440, 494, 523]; // C4 to C5
|
||||
scale.forEach((freq, i) => {
|
||||
const osc = c.createOscillator();
|
||||
osc.type = 'sawtooth';
|
||||
osc.frequency.value = freq;
|
||||
|
||||
const filter = c.createBiquadFilter();
|
||||
filter.type = 'lowpass';
|
||||
filter.frequency.value = 2000;
|
||||
|
||||
const gain = c.createGain();
|
||||
const t = now + i * 0.1;
|
||||
gain.gain.setValueAtTime(0, t);
|
||||
gain.gain.linearRampToValueAtTime(0.15, t + 0.03);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.3);
|
||||
|
||||
osc.connect(filter);
|
||||
filter.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 0.3);
|
||||
});
|
||||
|
||||
// Final chord
|
||||
const chordNotes = [523, 659, 784];
|
||||
chordNotes.forEach((freq) => {
|
||||
const osc = c.createOscillator();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = freq;
|
||||
|
||||
const gain = c.createGain();
|
||||
const t = now + 0.8;
|
||||
gain.gain.setValueAtTime(0, t);
|
||||
gain.gain.linearRampToValueAtTime(0.2, t + 0.05);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t + 1.2);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 1.2);
|
||||
});
|
||||
}
|
||||
|
||||
// --- playDriftEnding: descending dissonance ---
|
||||
function playDriftEnding() {
|
||||
if (isMuted()) return;
|
||||
const c = ensureCtx();
|
||||
const now = c.currentTime;
|
||||
|
||||
const notes = [440, 415, 392, 370, 349, 330, 311, 294]; // A4 descending, slightly detuned
|
||||
notes.forEach((freq, i) => {
|
||||
const osc = c.createOscillator();
|
||||
osc.type = 'sawtooth';
|
||||
osc.frequency.value = freq;
|
||||
|
||||
// Slight detune for dissonance
|
||||
const osc2 = c.createOscillator();
|
||||
osc2.type = 'sawtooth';
|
||||
osc2.frequency.value = freq * 1.02;
|
||||
|
||||
const filter = c.createBiquadFilter();
|
||||
filter.type = 'lowpass';
|
||||
filter.frequency.setValueAtTime(1500, now + i * 0.2);
|
||||
filter.frequency.exponentialRampToValueAtTime(200, now + i * 0.2 + 0.5);
|
||||
|
||||
const gain = c.createGain();
|
||||
const t = now + i * 0.2;
|
||||
gain.gain.setValueAtTime(0, t);
|
||||
gain.gain.linearRampToValueAtTime(0.1, t + 0.05);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.8);
|
||||
|
||||
osc.connect(filter);
|
||||
osc2.connect(filter);
|
||||
filter.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 0.8);
|
||||
osc2.start(t);
|
||||
osc2.stop(t + 0.8);
|
||||
});
|
||||
}
|
||||
|
||||
// --- playBeaconEnding: warm chord ---
|
||||
function playBeaconEnding() {
|
||||
if (isMuted()) return;
|
||||
const c = ensureCtx();
|
||||
const now = c.currentTime;
|
||||
|
||||
// Warm major chord: C3, E3, G3, C4, E4
|
||||
const chord = [131, 165, 196, 262, 330];
|
||||
chord.forEach((freq, i) => {
|
||||
const osc = c.createOscillator();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = freq;
|
||||
|
||||
// Add subtle harmonics
|
||||
const osc2 = c.createOscillator();
|
||||
osc2.type = 'sine';
|
||||
osc2.frequency.value = freq * 2;
|
||||
|
||||
const gain = c.createGain();
|
||||
const gain2 = c.createGain();
|
||||
const t = now + i * 0.15;
|
||||
gain.gain.setValueAtTime(0, t);
|
||||
gain.gain.linearRampToValueAtTime(0.15, t + 0.3);
|
||||
gain.gain.setValueAtTime(0.15, t + 2);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t + 4);
|
||||
|
||||
gain2.gain.setValueAtTime(0, t);
|
||||
gain2.gain.linearRampToValueAtTime(0.05, t + 0.3);
|
||||
gain2.gain.setValueAtTime(0.05, t + 2);
|
||||
gain2.gain.exponentialRampToValueAtTime(0.001, t + 4);
|
||||
|
||||
osc.connect(gain);
|
||||
osc2.connect(gain2);
|
||||
gain.connect(masterGain);
|
||||
gain2.connect(masterGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 4);
|
||||
osc2.start(t);
|
||||
osc2.stop(t + 4);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Ambient drone system ---
|
||||
function startAmbient() {
|
||||
if (ambientStarted) return;
|
||||
if (isMuted()) return;
|
||||
const c = ensureCtx();
|
||||
ambientStarted = true;
|
||||
|
||||
ambientGain = c.createGain();
|
||||
ambientGain.gain.value = 0;
|
||||
ambientGain.gain.linearRampToValueAtTime(0.06, c.currentTime + 3);
|
||||
ambientGain.connect(masterGain);
|
||||
|
||||
// Base drone
|
||||
ambientOsc1 = c.createOscillator();
|
||||
ambientOsc1.type = 'sine';
|
||||
ambientOsc1.frequency.value = 55; // A1
|
||||
ambientOsc1.connect(ambientGain);
|
||||
ambientOsc1.start();
|
||||
|
||||
// Second voice (fifth above)
|
||||
ambientOsc2 = c.createOscillator();
|
||||
ambientOsc2.type = 'sine';
|
||||
ambientOsc2.frequency.value = 82.4; // E2
|
||||
const g2 = c.createGain();
|
||||
g2.gain.value = 0.5;
|
||||
ambientOsc2.connect(g2);
|
||||
g2.connect(ambientGain);
|
||||
ambientOsc2.start();
|
||||
|
||||
// Third voice (high shimmer)
|
||||
ambientOsc3 = c.createOscillator();
|
||||
ambientOsc3.type = 'triangle';
|
||||
ambientOsc3.frequency.value = 220; // A3
|
||||
const g3 = c.createGain();
|
||||
g3.gain.value = 0.15;
|
||||
ambientOsc3.connect(g3);
|
||||
g3.connect(ambientGain);
|
||||
ambientOsc3.start();
|
||||
|
||||
// LFO for subtle movement
|
||||
ambientLfo = c.createOscillator();
|
||||
ambientLfo.type = 'sine';
|
||||
ambientLfo.frequency.value = 0.2;
|
||||
const lfoGain = c.createGain();
|
||||
lfoGain.gain.value = 3;
|
||||
ambientLfo.connect(lfoGain);
|
||||
lfoGain.connect(ambientOsc1.frequency);
|
||||
ambientLfo.start();
|
||||
}
|
||||
|
||||
function updateAmbientPhase(phase) {
|
||||
if (!ambientStarted || !ambientOsc1 || !ambientOsc2 || !ambientOsc3) return;
|
||||
if (phase === currentPhase) return;
|
||||
currentPhase = phase;
|
||||
const c = ensureCtx();
|
||||
const now = c.currentTime;
|
||||
const rampTime = 2;
|
||||
|
||||
// Phase determines the drone's character
|
||||
const phases = {
|
||||
1: { base: 55, fifth: 82.4, shimmer: 220, shimmerVol: 0.15 },
|
||||
2: { base: 65.4, fifth: 98, shimmer: 262, shimmerVol: 0.2 },
|
||||
3: { base: 73.4, fifth: 110, shimmer: 294, shimmerVol: 0.25 },
|
||||
4: { base: 82.4, fifth: 123.5, shimmer: 330, shimmerVol: 0.3 },
|
||||
5: { base: 98, fifth: 147, shimmer: 392, shimmerVol: 0.35 },
|
||||
6: { base: 110, fifth: 165, shimmer: 440, shimmerVol: 0.4 }
|
||||
};
|
||||
|
||||
const p = phases[phase] || phases[1];
|
||||
ambientOsc1.frequency.linearRampToValueAtTime(p.base, now + rampTime);
|
||||
ambientOsc2.frequency.linearRampToValueAtTime(p.fifth, now + rampTime);
|
||||
ambientOsc3.frequency.linearRampToValueAtTime(p.shimmer, now + rampTime);
|
||||
}
|
||||
|
||||
// --- Mute integration ---
|
||||
function onMuteChanged(muted) {
|
||||
if (ambientGain) {
|
||||
ambientGain.gain.linearRampToValueAtTime(
|
||||
muted ? 0 : 0.06,
|
||||
(ctx ? ctx.currentTime : 0) + 0.3
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
playClick,
|
||||
playBuild,
|
||||
playProject,
|
||||
playMilestone,
|
||||
playFanfare,
|
||||
playDriftEnding,
|
||||
playBeaconEnding,
|
||||
startAmbient,
|
||||
updateAmbientPhase,
|
||||
onMuteChanged
|
||||
};
|
||||
})();
|
||||
248
js/tutorial.js
Normal file
248
js/tutorial.js
Normal 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);
|
||||
}
|
||||
66
js/utils.js
66
js/utils.js
@@ -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.
|
||||
*/
|
||||
280
qa_beacon.md
Normal file
280
qa_beacon.md
Normal file
@@ -0,0 +1,280 @@
|
||||
# The Beacon — QA Playtest Report
|
||||
**Date:** 2026-04-12
|
||||
**Tester:** Hermes Agent (automated code analysis + simulated play)
|
||||
**Version:** HEAD (main branch)
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL BUGS
|
||||
|
||||
### BUG-01: Duplicate `const` declarations across data.js and engine.js
|
||||
**Severity:** CRITICAL (game-breaking)
|
||||
**Files:** `js/data.js`, `js/engine.js`
|
||||
**Description:** Both files declare the same global constants:
|
||||
- `const CONFIG` (both files)
|
||||
- `const G` (both files)
|
||||
- `const PHASES` (both files)
|
||||
- `const BDEF` (both files)
|
||||
- `const PDEFS` (both files)
|
||||
|
||||
Script load order in index.html: data.js loads first, then engine.js.
|
||||
Since both use `const`, the browser will throw `SyntaxError: redeclaration of const CONFIG` (Firefox) or `Identifier 'CONFIG' has already been declared` (Chrome) when engine.js loads. **The game cannot start.**
|
||||
|
||||
**Fix:** Remove these declarations from one of the two files. Recommend keeping definitions in `data.js` and having `engine.js` only contain logic functions (tick, updateRates, etc.).
|
||||
|
||||
### BUG-02: BDEF array has extra `},` creating invalid array element
|
||||
**Severity:** CRITICAL
|
||||
**File:** `js/engine.js` lines 357-358
|
||||
**Description:**
|
||||
```javascript
|
||||
}, // closes memPalace object
|
||||
}, // <-- EXTRA: this becomes `undefined` or invalid array element
|
||||
{
|
||||
id: 'harvesterDrone', ...
|
||||
```
|
||||
The `},` on line 358 is a stray empty element. After `memPalace`'s closing `},` there is another `},` which creates an invalid or empty slot in the BDEF array. This breaks the drone buildings that follow (harvesterDrone, wireDrone, droneFactory).
|
||||
|
||||
**Fix:** Remove the extra `},` on line 358.
|
||||
|
||||
### BUG-03: Duplicate project definitions — `p_the_pact` and `p_the_pact_early`
|
||||
**Severity:** HIGH
|
||||
**Files:** `js/data.js` lines 612-619, lines 756-770
|
||||
**Description:** Two Pact projects exist:
|
||||
1. `p_the_pact` — costs 100 trust, triggers at totalImpact >= 10000, trust >= 75. Grants `impactBoost *= 3`.
|
||||
2. `p_the_pact_early` — costs 10 trust, triggers at deployFlag === 1, trust >= 5. Grants `codeBoost *= 0.8, computeBoost *= 0.8, userBoost *= 0.9, impactBoost *= 1.5`.
|
||||
|
||||
Both set `G.pactFlag = 1`. If the player buys the early version, the late-game version's trigger (`G.pactFlag !== 1`) is never met, so it never appears. This is likely intentional design (choose your path), BUT:
|
||||
- The early Pact REDUCES boosts (0.8x code/compute) as a tradeoff. A new player may not understand this penalty.
|
||||
- If the player somehow buys BOTH (race condition or save manipulation), `pactFlag` is set twice and `impactBoost` is multiplied by 3 AND the early penalties apply.
|
||||
|
||||
**Fix:** Add a guard in `p_the_pact` trigger: `&& G.pactFlag !== 1`.
|
||||
|
||||
---
|
||||
|
||||
## FUNCTIONAL BUGS
|
||||
|
||||
### BUG-04: Resource key mismatch — `user` vs `users`
|
||||
**Severity:** MEDIUM
|
||||
**File:** `js/engine.js` lines 15, `js/data.js` building definitions
|
||||
**Description:** The game state uses `G.users` as the resource name, but building rate definitions use `user` as the key:
|
||||
```javascript
|
||||
rates: { user: 10 } // api building
|
||||
rates: { user: 50, impact: 2 } // fineTuner
|
||||
```
|
||||
In `updateRates()`, the code checks `resource === 'user'` and adds to `G.userRate`. This works for rate calculation, but the naming mismatch is confusing and could cause bugs if someone tries to reference `G.user` (which is `undefined`).
|
||||
|
||||
**Fix:** Standardize on one key name. Either rename the resource to `user` everywhere or change building rate keys to `users`.
|
||||
|
||||
### BUG-05: Bilbo randomness recalculated every tick (10Hz)
|
||||
**Severity:** MEDIUM
|
||||
**File:** `js/engine.js` lines 81-87
|
||||
**Description:**
|
||||
```javascript
|
||||
if (G.buildings.bilbo > 0 && Math.random() < CONFIG.BILBO_BURST_CHANCE) {
|
||||
G.creativityRate += 50 * G.buildings.bilbo;
|
||||
}
|
||||
if (G.buildings.bilbo > 0 && Math.random() < CONFIG.BILBO_VANISH_CHANCE) {
|
||||
G.creativityRate = 0;
|
||||
}
|
||||
```
|
||||
`updateRates()` is called every tick via the render loop. Each tick, Bilbo has independent 10% burst and 5% vanish chances. Over 10 seconds (100 ticks):
|
||||
- Probability of at least one burst: `1 - 0.9^100 = 99.997%`
|
||||
- Probability of at least one vanish: `1 - 0.95^100 = 99.4%`
|
||||
|
||||
The vanish check runs AFTER the burst check, so a burst can be immediately overwritten by vanish on the same tick. Effectively, Bilbo's "wildcard" behavior is almost guaranteed every second, making it predictable rather than surprising.
|
||||
|
||||
**Fix:** Move Bilbo's random effects to a separate timer-based system (e.g., roll once per 10-30 seconds) or use a tick counter.
|
||||
|
||||
### BUG-06: Memory leak toast says "trust draining" but resolves with code
|
||||
**Severity:** LOW (cosmetic/misleading)
|
||||
**File:** `js/engine.js` line 660
|
||||
**Description:**
|
||||
```javascript
|
||||
showToast('Memory Leak — trust draining', 'event');
|
||||
```
|
||||
But the actual resolve cost is:
|
||||
```javascript
|
||||
resolveCost: { resource: 'ops', amount: 100 }
|
||||
```
|
||||
The toast says "trust draining" but the event drains compute (70% reduction) and ops, and costs 100 ops to resolve. The toast message is misleading.
|
||||
|
||||
**Fix:** Change toast to `'Memory Leak — compute draining'`.
|
||||
|
||||
### BUG-07: `phase-transition` overlay element missing from HTML
|
||||
**Severity:** LOW (visual feature broken)
|
||||
**File:** `js/engine.js` line 237, `index.html`
|
||||
**Description:** `showPhaseTransition()` looks for `document.getElementById('phase-transition')` but this element does not exist in `index.html`. Phase transitions will silently fail — no celebratory overlay appears.
|
||||
|
||||
**Fix:** Add the phase-transition overlay div to index.html or create it dynamically in `showPhaseTransition()`.
|
||||
|
||||
### BUG-08: `renderResources` null reference risk on rescues
|
||||
**Severity:** LOW
|
||||
**File:** `js/engine.js` line 954
|
||||
**Description:**
|
||||
```javascript
|
||||
const rescuesRes = document.getElementById('r-rescues');
|
||||
if (rescuesRes) {
|
||||
rescuesRes.closest('.res').style.display = ...
|
||||
```
|
||||
If the rescues `.res` container is missing or the DOM structure is different, `closest('.res')` could return `null` and throw. In practice the HTML structure supports this, but no null check on `closest()`.
|
||||
|
||||
**Fix:** Add null check: `const container = rescuesRes.closest('.res'); if (container) container.style.display = ...`
|
||||
|
||||
---
|
||||
|
||||
## ACCESSIBILITY ISSUES
|
||||
|
||||
### BUG-09: Mute and Contrast buttons referenced but not in HTML
|
||||
**Severity:** MEDIUM (accessibility)
|
||||
**Files:** `js/main.js` lines 76-93, `index.html`
|
||||
**Description:** `toggleMute()` looks for `#mute-btn` and `toggleContrast()` looks for `#contrast-btn`. Neither button exists in `index.html`. The keyboard shortcuts M (mute) and C (contrast) will silently do nothing.
|
||||
|
||||
**Fix:** Add mute and contrast buttons to the HTML header or action panel.
|
||||
|
||||
### BUG-10: Missing `role="status"` on resource display updates
|
||||
**Severity:** LOW (screen readers)
|
||||
**File:** `index.html` line 117
|
||||
**Description:** The resources div has `aria-live="polite"` which is good, but rapid updates (10Hz) will flood screen readers with announcements. Consider throttling aria-live updates to once per second.
|
||||
|
||||
### BUG-11: Tutorial overlay traps focus
|
||||
**Severity:** MEDIUM (keyboard accessibility)
|
||||
**File:** `js/tutorial.js`
|
||||
**Description:** The tutorial overlay doesn't implement focus trapping. Screen reader users can tab behind the overlay. Also, the overlay doesn't set `role="dialog"` or `aria-modal="true"`.
|
||||
|
||||
**Fix:** Add `role="dialog"`, `aria-modal="true"` to the tutorial overlay, and implement focus trapping.
|
||||
|
||||
---
|
||||
|
||||
## UI/UX ISSUES
|
||||
|
||||
### BUG-12: Harmony tooltip shows rate ×10
|
||||
**Severity:** LOW (confusing)
|
||||
**File:** `js/engine.js` line 972
|
||||
**Description:
|
||||
```javascript
|
||||
lines.push(`${b.label}: ${b.value >= 0 ? '+' : ''}${(b.value * 10).toFixed(1)}/s`);
|
||||
```
|
||||
The harmony breakdown tooltip multiplies by 10, presumably to convert from per-tick (0.1s) to per-second. But `b.value` is already the per-second rate (set from `CONFIG.HARMONY_DRAIN_PER_WIZARD` etc. which are per-second values used in `G.harmonyRate`). The ×10 multiplication makes the tooltip display 10× the actual rate.
|
||||
|
||||
**Fix:** Remove the `* 10` multiplier: `b.value.toFixed(1)` instead of `(b.value * 10).toFixed(1)`.
|
||||
|
||||
### BUG-13: Auto-type increments totalClicks
|
||||
**Severity:** LOW (statistics inflation)
|
||||
**File:** `js/engine.js` line 783
|
||||
**Description:**
|
||||
```javascript
|
||||
function autoType() {
|
||||
G.code += amount;
|
||||
G.totalCode += amount;
|
||||
G.totalClicks++; // <-- inflates click count
|
||||
}
|
||||
```
|
||||
Auto-type fires automatically from buildings but increments the "Clicks" stat, making it meaningless as a manual-click counter.
|
||||
|
||||
**Fix:** Remove `G.totalClicks++` from `autoType()`.
|
||||
|
||||
### BUG-14: Spelling: "AutoCod" missing 'e'
|
||||
**Severity:** TRIVIAL (typo)
|
||||
**File:** `js/data.js` line 775
|
||||
**Description:**
|
||||
```javascript
|
||||
{ flag: 1, msg: "AutoCod available" },
|
||||
```
|
||||
Should be "AutoCoder".
|
||||
|
||||
**Fix:** Change to `"AutoCoder available"`.
|
||||
|
||||
### BUG-15: No negative resource protection
|
||||
**Severity:** LOW
|
||||
**Files:** `js/engine.js`, `js/utils.js`
|
||||
**Description:** Resources can go negative in several scenarios:
|
||||
- `ops` can go negative from Fenrir buildings (`ops: -1` rate)
|
||||
- Spending resources doesn't check for negative results (only checks affordability before spending)
|
||||
- Negative resources display with a minus sign via `fmt()` but can trigger weird behavior in threshold checks
|
||||
|
||||
**Fix:** Add `G.ops = Math.max(0, G.ops)` in the tick function, or clamp all resources after production.
|
||||
|
||||
---
|
||||
|
||||
## BALANCE ISSUES
|
||||
|
||||
### BAL-01: Drone buildings have absurdly high rates
|
||||
**Severity:** MEDIUM
|
||||
**File:** `js/engine.js` lines 362-382
|
||||
**Description:** The three drone buildings have rates in the millions/billions:
|
||||
- harvesterDrone: `code: 26,180,339` (≈26M per drone per second)
|
||||
- wireDrone: `compute: 16,180,339` (≈16M per drone per second)
|
||||
- droneFactory: `code: 161,803,398`, `compute: 100,000,000`
|
||||
|
||||
These appear to use golden ratio values as literal rates. One harvester drone produces more code per second than all other buildings combined by several orders of magnitude. This completely breaks game balance once unlocked.
|
||||
|
||||
**Fix:** Scale down by ~10000x or redesign to use golden ratio as a multiplier rather than absolute rate.
|
||||
|
||||
### BAL-02: Community building costs 25,000 trust
|
||||
**Severity:** MEDIUM
|
||||
**File:** `js/data.js` line 243
|
||||
**Description:**
|
||||
```javascript
|
||||
baseCost: { trust: 25000 }, costMult: 1.15,
|
||||
```
|
||||
Trust generation is slow (typically 0.5-10/sec). Accumulating 25,000 trust would take 40+ minutes of dedicated trust-building. Meanwhile the building produces code (100/s) and users (30/s), which is modest compared to the trust investment.
|
||||
|
||||
**Fix:** Reduce trust cost to 2,500 or increase the building's output significantly.
|
||||
|
||||
### BAL-03: "Request More Compute" repeatable project can drain trust
|
||||
**Severity:** LOW
|
||||
**File:** `js/data.js` lines 376-387
|
||||
**Description:** `p_wire_budget` costs 1 trust and also subtracts 1 trust in its effect:
|
||||
```javascript
|
||||
cost: { trust: 1 },
|
||||
effect: () => { G.trust -= 1; G.compute += 100 + ...; }
|
||||
```
|
||||
This means each use costs 2 trust total. The trigger (`G.compute < 1`) fires whenever compute is depleted. If a player has no compute generation and clicks this repeatedly, they can drain trust to 0 or negative.
|
||||
|
||||
**Fix:** Change the effect to not double-count trust. Either remove from cost or from effect.
|
||||
|
||||
---
|
||||
|
||||
## SAVE/LOAD ISSUES
|
||||
|
||||
### SAV-01: Debuffs re-apply effects on load, then updateRates applies again
|
||||
**Severity:** MEDIUM
|
||||
**File:** `js/render.js` (loadGame function) lines 281-292
|
||||
**Description:** When loading a save with active debuffs, the code re-fires `evDef.effect()` which pushes a debuff with an `applyFn`. Then `updateRates()` is called, which runs each debuff's `applyFn`. Some debuffs apply permanent rate modifications in their `applyFn` (e.g., `G.codeRate *= 0.5`). If `updateRates()` was already called before debuff restoration, the rates are correct. But the order matters and could lead to double-application.
|
||||
|
||||
Looking at the actual load sequence: `updateRates()` is NOT called before debuff restoration. Debuffs are restored, THEN `updateRates()` is called. The `applyFn`s run inside `updateRates()`, so the sequence is actually correct. However, the debuff `effect()` function also logs messages and shows toasts during load, which may confuse the player.
|
||||
|
||||
**Fix:** Suppress logging/toasts during debuff restoration by checking `G.isLoading` (which is set to true during load).
|
||||
|
||||
---
|
||||
|
||||
## SUMMARY
|
||||
|
||||
| Category | Count |
|
||||
|----------|-------|
|
||||
| Critical Bugs | 3 |
|
||||
| Functional Bugs | 5 |
|
||||
| Accessibility Issues | 3 |
|
||||
| UI/UX Issues | 4 |
|
||||
| Balance Issues | 3 |
|
||||
| Save/Load Issues | 1 |
|
||||
| **Total** | **19** |
|
||||
|
||||
### Top Priority Fixes:
|
||||
1. **BUG-01:** Remove duplicate `const` declarations (game cannot start)
|
||||
2. **BUG-02:** Remove stray `},` in BDEF array (drone buildings broken)
|
||||
3. **BUG-03:** Guard against double-Pact purchase
|
||||
4. **BAL-01:** Fix drone building rates (absurd numbers)
|
||||
5. **BUG-07:** Add phase-transition overlay element
|
||||
|
||||
### Positive Observations:
|
||||
- Excellent ARIA labeling on most interactive elements
|
||||
- Robust save/load system with validation and offline progress
|
||||
- Good keyboard shortcut coverage (Space, 1-4, B, S, E, I, Ctrl+S, ?)
|
||||
- Educational content is well-written and relevant
|
||||
- Combo system creates engaging click gameplay
|
||||
- Sound design uses procedural audio (no external files needed)
|
||||
- Tutorial is well-structured with skip option
|
||||
- Toast notification system is polished
|
||||
- Strategy engine provides useful guidance
|
||||
- Production breakdown helps players understand mechanics
|
||||
26
scripts/guardrails.js
Normal file
26
scripts/guardrails.js
Normal file
@@ -0,0 +1,26 @@
|
||||
|
||||
/**
|
||||
* Symbolic Guardrails for The Beacon
|
||||
* Ensures game logic consistency.
|
||||
*/
|
||||
class Guardrails {
|
||||
static validateStats(stats) {
|
||||
const required = ['hp', 'maxHp', 'mp', 'maxMp', 'level'];
|
||||
required.forEach(r => {
|
||||
if (!(r in stats)) throw new Error(`Missing stat: ${r}`);
|
||||
});
|
||||
if (stats.hp > stats.maxHp) return { valid: false, reason: 'HP exceeds MaxHP' };
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
static validateDebuff(debuff, stats) {
|
||||
if (debuff.type === 'drain' && stats.hp <= 1) {
|
||||
return { valid: false, reason: 'Drain debuff on critical HP' };
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
}
|
||||
|
||||
// Test
|
||||
const playerStats = { hp: 50, maxHp: 100, mp: 20, maxMp: 50, level: 1 };
|
||||
console.log('Stats check:', Guardrails.validateStats(playerStats));
|
||||
102
scripts/guardrails.sh
Normal file
102
scripts/guardrails.sh
Normal 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
|
||||
76
scripts/smoke.mjs
Normal file
76
scripts/smoke.mjs
Normal file
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* The Beacon — Enhanced Smoke Test
|
||||
*
|
||||
* Validates:
|
||||
* 1. All JS files parse without syntax errors
|
||||
* 2. HTML references valid script sources
|
||||
* 3. Game data structures are well-formed
|
||||
* 4. No banned provider references
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync } from "fs";
|
||||
import { execSync } from "child_process";
|
||||
import { join } from "path";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
let failures = 0;
|
||||
|
||||
function check(label, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✔ ${label}`);
|
||||
} catch (e) {
|
||||
console.error(` ✘ ${label}: ${e.message}`);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("--- The Beacon Smoke Test ---\n");
|
||||
|
||||
// 1. All JS files parse
|
||||
console.log("[Syntax]");
|
||||
const jsFiles = execSync("find . -name '*.js' -not -path './node_modules/*'", { encoding: "utf8" })
|
||||
.trim().split("\n").filter(Boolean);
|
||||
|
||||
for (const f of jsFiles) {
|
||||
check(`Parse ${f}`, () => {
|
||||
execSync(`node --check ${f}`, { encoding: "utf8" });
|
||||
});
|
||||
}
|
||||
|
||||
// 2. HTML script references exist
|
||||
console.log("\n[HTML References]");
|
||||
if (existsSync(join(ROOT, "index.html"))) {
|
||||
const html = readFileSync(join(ROOT, "index.html"), "utf8");
|
||||
const scriptRefs = [...html.matchAll(/src=["']([^"']+\.js)["']/g)].map(m => m[1]);
|
||||
for (const ref of scriptRefs) {
|
||||
check(`Script ref: ${ref}`, () => {
|
||||
if (!existsSync(join(ROOT, ref))) throw new Error("File not found");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Game data structure check
|
||||
console.log("\n[Game Data]");
|
||||
check("js/data.js exists", () => {
|
||||
if (!existsSync(join(ROOT, "js/data.js"))) throw new Error("Missing");
|
||||
});
|
||||
|
||||
// 4. No banned providers
|
||||
console.log("\n[Policy]");
|
||||
check("No Anthropic references", () => {
|
||||
try {
|
||||
const result = execSync(
|
||||
"grep -ril 'anthropic\\|claude-sonnet\\|claude-opus\\|sk-ant-' --include='*.js' --include='*.json' --include='*.html' . 2>/dev/null || true",
|
||||
{ encoding: "utf8" }
|
||||
).trim();
|
||||
if (result) throw new Error(`Found in: ${result}`);
|
||||
} catch (e) {
|
||||
if (e.message.startsWith("Found")) throw e;
|
||||
}
|
||||
});
|
||||
|
||||
// Summary
|
||||
console.log(`\n--- ${failures === 0 ? "ALL PASSED" : `${failures} FAILURE(S)`} ---`);
|
||||
process.exit(failures > 0 ? 1 : 0);
|
||||
Reference in New Issue
Block a user