// ============================================================ // 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 rawDt = (ts - lastTick) / 16; // Guard against tab-switch: if tab was hidden, dt could be huge const dt = Math.min(rawDt, 3); lastTick = ts; // If tab was hidden for too long (>5s), skip this frame to prevent teleporting if (rawDt > 300) { animFrameId = requestAnimationFrame(animate); return; } // 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 = `