Compare commits

...

15 Commits

Author SHA1 Message Date
Timmy
9a829584b0 feat: canvas-based combat visualization (#21)
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Failing after 4s
Smoke Test / smoke (pull_request) Failing after 3s
Implements Reasoning Battles — a Paperclips-inspired canvas combat system
where structured reasoning (blue) fights adversarial testing (red) using
boid flocking (cohesion, aggression, separation) on a 310x150 grid.

Features:
- Boid flocking AI for ship movement
- Grid-based combat resolution with OODA loop speed bonus
- Napoleonic War battle names
- Auto-trigger battles scaled to drift and phase
- Battle history log
- Rewards: structured wins = knowledge, adversarial wins = code
- Unlocks at Phase 3
- Integrated into tick loop and render pipeline
2026-04-13 03:19:21 -04:00
020c003d45 Merge pull request 'fix: Bilbo randomness — roll once per tick (#88)' (#97) from burn/fix-bilbo-randomness into main
Some checks failed
Smoke Test / smoke (push) Failing after 4s
Auto-merge #97
2026-04-13 07:15:35 +00:00
610252b597 Merge pull request 'fix: add missing mute, contrast, and tooltip UI elements (#57)' (#102) from beacon/polish into main
Some checks failed
Smoke Test / smoke (push) Has been cancelled
Auto-merge #102
2026-04-13 07:15:32 +00:00
Hermes Agent
04f869c70d fix: add missing mute, contrast, and tooltip UI elements (#57)
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Failing after 2s
Smoke Test / smoke (pull_request) Failing after 5s
The JS toggleMute(), toggleContrast(), and custom tooltip system were
fully implemented but their HTML elements were missing from index.html,
causing silent failures on M/C keys and hover tooltips.

- Add #mute-btn and #contrast-btn to header bar
- Add #custom-tooltip element for hover tooltips
- Add high-contrast CSS mode with bold borders and vivid colors
- Add header-btn and tooltip styles

Refs: epic #57 tasks 2 (Sound toggle), 4 (Tooltips), 5 (Accessibility)
2026-04-13 03:05:41 -04:00
bbcce1f064 Merge PR #96: fix: QA bug sweep — 5 small fixes
Some checks failed
Smoke Test / smoke (push) Failing after 3s
Reviewed, patched click-counter regression, verified smoke locally, and merged.
2026-04-13 06:22:27 +00:00
Alexander Whitestone
a2f345593c fix: restore manual click counting in QA bug sweep
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Failing after 3s
Smoke Test / smoke (pull_request) Failing after 4s
2026-04-13 02:21:39 -04:00
Alexander Whitestone
b819fc068a fix: Bilbo randomness — roll once per tick (#88)
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Failing after 2s
Smoke Test / smoke (pull_request) Failing after 3s
2026-04-13 02:11:50 -04:00
Alexander Whitestone
8e006897a4 fix: QA bug sweep — 5 fixes (closes #95)
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Failing after 2s
Smoke Test / smoke (pull_request) Failing after 3s
1. Memory Leak toast: "trust draining" → "compute draining"
2. Harmony tooltip: remove 10× multiplier (values already per-second)
3. autoType(): track as totalAutoClicks instead of totalClicks
4. The Pact (late): guard trigger with pactFlag !== 1
5. Typo: "AutoCod" → "AutoCoder"
2026-04-13 02:02:59 -04:00
ff9c1b1864 Merge pull request 'feat: offline progress calculation (closes #11)' (#94) from feat/offline-progress into main
Some checks failed
Smoke Test / smoke (push) Failing after 4s
2026-04-13 04:34:57 +00:00
9fd70fa942 feat: add offline progress calculation (closes #11)
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Failing after 3s
Smoke Test / smoke (pull_request) Failing after 4s
Saves lastSaveTime timestamp. On load, calculates elapsed time
and awards 50% efficiency production. Shows summary toast.
Min 30 seconds away to trigger.
2026-04-13 04:34:33 +00:00
c714061bd8 fix: load tutorial.js before main.js, remove dead game.js (#92)
Some checks failed
Smoke Test / smoke (push) Failing after 4s
Co-authored-by: Alexander Whitestone <alexander@alexanderwhitestone.com>
Co-committed-by: Alexander Whitestone <alexander@alexanderwhitestone.com>
2026-04-13 03:11:03 +00:00
220fc44c6a fix: Bilbo randomness, drone balance, screen reader (#88, #89, #90) (#93)
Some checks failed
Smoke Test / smoke (push) Failing after 3s
2026-04-13 03:10:39 +00:00
26bb33c5eb QA: Comprehensive Playtest Bug Report (19 issues)
Some checks failed
Smoke Test / smoke (push) Failing after 4s
Merge PR #85: QA: Comprehensive Playtest Bug Report (19 issues)
2026-04-13 03:00:25 +00:00
954a6c4111 Merge pull request 'fix: critical bugs from QA (#86, #87, #89)' (#91) from burn/fix-critical-bugs into main
Some checks failed
Smoke Test / smoke (push) Has been cancelled
Merge PR #91: fix: critical bugs from QA (#86, #87, #89)
2026-04-13 02:56:28 +00:00
QA Agent
74575929af QA: Add comprehensive playtest bug report (19 issues found)
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Failing after 2s
Smoke Test / smoke (pull_request) Failing after 5s
Critical: duplicate const declarations, malformed BDEF array, drone balance
Functional: resource naming, Bilbo tick randomness, memory leak toast
Accessibility: missing mute/contrast buttons, tutorial focus trap
Balance: drone rates (26M/s!), community trust cost (25K)
Save/Load: debuff restoration logging
2026-04-12 22:34:20 -04:00
8 changed files with 700 additions and 23 deletions

View File

@@ -96,10 +96,32 @@ 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}
</style>
</head>
<body>
<div id="header">
<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 +136,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 +152,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 -&gt; Code</button>
@@ -185,11 +207,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%">
@@ -226,10 +254,12 @@ The light is on. The room is empty."
<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>
@@ -243,5 +273,6 @@ The light is on. The room is empty."
</div>
<div id="toast-container"></div>
<div id="custom-tooltip"></div>
</body>
</html>

351
js/combat.js Normal file
View 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 };
})();

View File

@@ -111,6 +111,7 @@ const G = {
running: true,
startedAt: 0,
totalClicks: 0,
totalAutoClicks: 0,
tick: 0,
saveTimer: 0,
secTimer: 0,
@@ -612,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
},
@@ -771,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." },

View File

@@ -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,14 @@ 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
@@ -194,6 +204,9 @@ function tick() {
}
}
// Combat: tick battle simulation
Combat.tickBattle(dt);
// Check milestones
checkMilestones();
@@ -657,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');
}
},
{
@@ -741,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;
@@ -780,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) {
@@ -969,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)}%`);

View File

@@ -39,6 +39,9 @@ window.addEventListener('load', function () {
}
}
// Initialize combat canvas
if (typeof Combat !== 'undefined') Combat.init();
// Game loop at 10Hz (100ms tick)
setInterval(tick, 100);

View File

@@ -13,6 +13,7 @@ function render() {
renderPulse();
renderStrategy();
renderClickPower();
Combat.renderCombatPanel();
}
function renderClickPower() {
@@ -206,6 +207,7 @@ function saveGame() {
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,

280
qa_beacon.md Normal file
View 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

View File

@@ -57,10 +57,6 @@ check("js/data.js exists", () => {
if (!existsSync(join(ROOT, "js/data.js"))) throw new Error("Missing");
});
check("game.js exists", () => {
if (!existsSync(join(ROOT, "game.js"))) throw new Error("Missing");
});
// 4. No banned providers
console.log("\n[Policy]");
check("No Anthropic references", () => {