diff --git a/frontend/src/components/FightViewer.vue b/frontend/src/components/FightViewer.vue index 700397f..46d88d0 100644 --- a/frontend/src/components/FightViewer.vue +++ b/frontend/src/components/FightViewer.vue @@ -84,11 +84,14 @@ function mapHp(hp: number, winnerId: string | null, botId: string | undefined): return Math.round((hp / 200) * 100) } -onUnmounted(() => { scene?.destroy(); scene = null }) +onUnmounted(() => { + if (scene) { scene.destroy(); scene = null } +}) async function initScene() { if (!canvasRef.value || !props.fight.botA || !props.fight.botB) return - if (scene) { scene.k.go('fight'); return } + // Destroy previous scene to avoid "KAPLAY already initialized" warning + if (scene) { scene.destroy(); scene = null } const container = canvasRef.value.parentElement if (container) { diff --git a/frontend/src/game/FightScene.ts b/frontend/src/game/FightScene.ts index 13e0508..54e8cb6 100644 --- a/frontend/src/game/FightScene.ts +++ b/frontend/src/game/FightScene.ts @@ -194,6 +194,21 @@ function pickChoreography(challengeType: string, isCritical: boolean, _round: nu } } +// Convert any CSS color string to a kaplay Color object safely +function safeColor(k: ReturnType, color: string) { + if (color.startsWith('#')) { + try { return k.Color.fromHex(color) } catch { /* fall through */ } + } + // HSL / rgb / named color — render to a 1px canvas to extract RGB + const cv = document.createElement('canvas') + cv.width = 1; cv.height = 1 + const cx = cv.getContext('2d')! + cx.fillStyle = color + cx.fillRect(0, 0, 1, 1) + const [r, g, b] = cx.getImageData(0, 0, 1, 1).data + return k.Color.fromArray([r, g, b]) +} + export async function createFightScene(config: FightSceneConfig) { const { canvas, botA, botB, arena } = config const theme = ARENA_THEMES[arena] || ARENA_THEMES.localhost @@ -258,7 +273,7 @@ export async function createFightScene(config: FightSceneConfig) { const spark = k.add([ k.rect(size, size), k.pos(x, y), - k.color(k.Color.fromHex(color)), + k.color(safeColor(k,color)), k.opacity(1), k.z(20), k.rotate(Math.random() * 360), @@ -279,7 +294,7 @@ export async function createFightScene(config: FightSceneConfig) { const hole = k.add([ k.circle(3 + Math.random() * 3), k.pos(x + (Math.random() - 0.5) * 40, y + (Math.random() - 0.5) * 60), - k.color(k.Color.fromHex('#000000')), + k.color(safeColor(k,'#000000')), k.opacity(0.8), k.z(9), // behind fighters ]) @@ -301,7 +316,7 @@ export async function createFightScene(config: FightSceneConfig) { const p = k.add([ k.circle(3 + Math.random() * 5), k.pos(x + (Math.random() - 0.5) * 15, y), - k.color(k.Color.fromHex(Math.random() > 0.5 ? '#ff6600' : '#ffcc00')), + k.color(safeColor(k,Math.random() > 0.5 ? '#ff6600' : '#ffcc00')), k.opacity(0.8), k.z(8), ]) @@ -321,7 +336,7 @@ export async function createFightScene(config: FightSceneConfig) { const proj = k.add([ k.circle(size), k.pos(fromX, fromY), - k.color(k.Color.fromHex(color)), + k.color(safeColor(k,color)), k.opacity(1), k.z(15), ]) @@ -330,7 +345,7 @@ export async function createFightScene(config: FightSceneConfig) { const t = k.add([ k.circle(size * 0.6), k.pos(proj.pos.x, proj.pos.y), - k.color(k.Color.fromHex(color)), + k.color(safeColor(k,color)), k.opacity(0.5), k.z(14), ]) @@ -360,7 +375,7 @@ export async function createFightScene(config: FightSceneConfig) { const bullet = k.add([ k.rect(6, 2), k.pos(fromX, fromY), - k.color(k.Color.fromHex('#ffee00')), + k.color(safeColor(k,'#ffee00')), k.opacity(1), k.z(15), k.rotate(Math.atan2(toY - fromY, toX - fromX) * 180 / Math.PI), @@ -382,7 +397,7 @@ export async function createFightScene(config: FightSceneConfig) { const wave = k.add([ k.circle(5), k.pos(x, y), - k.color(k.Color.fromHex(color)), + k.color(safeColor(k,color)), k.opacity(0.7), k.z(5), k.scale(1), @@ -402,7 +417,7 @@ export async function createFightScene(config: FightSceneConfig) { for (const l of layers) { const overlay = k.add([ k.rect(W, H), k.pos(l.dx, l.dy), - k.color(k.Color.fromHex(l.color)), k.opacity(0.12), k.z(55), + k.color(safeColor(k,l.color)), k.opacity(0.12), k.z(55), ]) overlay.onUpdate(() => { overlay.pos.x = l.dx + (Math.random() - 0.5) * 4 @@ -421,7 +436,7 @@ export async function createFightScene(config: FightSceneConfig) { const lh = 1 + Math.random() * 3 const line = k.add([ k.rect(W, lh), k.pos(0, ly), - k.color(k.Color.fromHex(Math.random() > 0.5 ? '#ffffff' : '#000000')), + k.color(safeColor(k,Math.random() > 0.5 ? '#ffffff' : '#000000')), k.opacity(0.15 + Math.random() * 0.2), k.z(54), ]) line.onUpdate(() => { @@ -442,7 +457,7 @@ export async function createFightScene(config: FightSceneConfig) { const bh = 10 + Math.random() * 30 const band = k.add([ k.rect(W, bh), k.pos(0, by), - k.color(k.Color.fromHex(theme.accent)), k.opacity(0.06), k.z(53), + k.color(safeColor(k,theme.accent)), k.opacity(0.06), k.z(53), ]) band.onUpdate(() => { band.pos.x = Math.sin(k.time() * 20 + i * 3) * 15 @@ -459,11 +474,11 @@ export async function createFightScene(config: FightSceneConfig) { let idx = 0 const overlay = k.add([ k.rect(W, H), k.pos(0, 0), - k.color(k.Color.fromHex(colors[0])), k.opacity(0.08), k.z(1), + k.color(safeColor(k,colors[0])), k.opacity(0.08), k.z(1), ]) const interval = setInterval(() => { idx = (idx + 1) % colors.length - overlay.color = k.Color.fromHex(colors[idx]) + overlay.color = safeColor(k,colors[idx]) overlay.opacity = 0.05 + Math.random() * 0.06 }, 80) setTimeout(() => { clearInterval(interval); if (overlay.exists()) overlay.destroy() }, duration * 1000) @@ -502,7 +517,7 @@ export async function createFightScene(config: FightSceneConfig) { const len = 30 + Math.random() * 60 const line = k.add([ k.rect(len, 1.5), k.pos(sx, sy), - k.color(k.Color.fromHex('#ffffff')), k.opacity(0.3), k.z(52), + k.color(safeColor(k,'#ffffff')), k.opacity(0.3), k.z(52), k.rotate(angle * 180 / Math.PI + 180), ]) line.onUpdate(() => { @@ -526,7 +541,7 @@ export async function createFightScene(config: FightSceneConfig) { const flash = k.add([ k.rect(W, H), k.pos(0, 0), - k.color(k.Color.fromHex(color)), + k.color(safeColor(k,color)), k.opacity(0.4), k.z(50), ]) @@ -548,7 +563,7 @@ export async function createFightScene(config: FightSceneConfig) { const eyeY = cy - 35 * sz const eyeWhite = k.add([ k.circle(7 * sz), k.pos(eyeX, eyeY), - k.color(k.Color.fromHex('#ffffdd')), k.opacity(0.85), k.z(32), + k.color(safeColor(k,'#ffffdd')), k.opacity(0.85), k.z(32), ]) grotesqueObjects.push(eyeWhite) @@ -558,7 +573,7 @@ export async function createFightScene(config: FightSceneConfig) { const vLen = 4 * sz + Math.random() * 3 * sz const vein = k.add([ k.rect(vLen, 0.8 * sz), k.pos(eyeX, eyeY), - k.color(k.Color.fromHex('#cc2222')), k.opacity(0.7), k.z(33), + k.color(safeColor(k,'#cc2222')), k.opacity(0.7), k.z(33), k.rotate(angle * 180 / Math.PI), ]) grotesqueObjects.push(vein) @@ -567,7 +582,7 @@ export async function createFightScene(config: FightSceneConfig) { // Pupil — twitchy const pupil = k.add([ k.circle(3 * sz), k.pos(eyeX + dir * 2 * sz, eyeY), - k.color(k.Color.fromHex('#111111')), k.opacity(0.9), k.z(34), + k.color(safeColor(k,'#111111')), k.opacity(0.9), k.z(34), ]) pupil.onUpdate(() => { pupil.pos.x = eyeX + dir * 2 * sz + Math.sin(k.time() * 12) * sz @@ -580,11 +595,11 @@ export async function createFightScene(config: FightSceneConfig) { const eye2Y = cy - 33 * sz const eye2 = k.add([ k.circle(5 * sz), k.pos(eye2X, eye2Y), - k.color(k.Color.fromHex('#ffffcc')), k.opacity(0.8), k.z(32), + k.color(safeColor(k,'#ffffcc')), k.opacity(0.8), k.z(32), ]) const pupil2 = k.add([ k.circle(2.5 * sz), k.pos(eye2X - dir * sz, eye2Y + sz), - k.color(k.Color.fromHex('#111111')), k.opacity(0.85), k.z(34), + k.color(safeColor(k,'#111111')), k.opacity(0.85), k.z(34), ]) pupil2.onUpdate(() => { pupil2.pos.x = eye2X - dir * sz + Math.sin(k.time() * 15 + 1) * 0.8 * sz @@ -599,7 +614,7 @@ export async function createFightScene(config: FightSceneConfig) { const tooth = k.add([ k.rect(3 * sz, 4 * sz + Math.random() * 2 * sz), k.pos(tx, teethY), - k.color(k.Color.fromHex(Math.random() > 0.3 ? '#ffffcc' : '#cccc88')), + k.color(safeColor(k,Math.random() > 0.3 ? '#ffffcc' : '#cccc88')), k.opacity(0.8), k.z(33), ]) grotesqueObjects.push(tooth) @@ -612,7 +627,7 @@ export async function createFightScene(config: FightSceneConfig) { const vein = k.add([ k.rect(8 * sz + Math.random() * 6 * sz, 0.7 * sz), k.pos(vx, vy), - k.color(k.Color.fromHex('#6633aa')), + k.color(safeColor(k,'#6633aa')), k.opacity(0.4), k.z(31), k.rotate(-20 + Math.random() * 40), ]) @@ -628,7 +643,7 @@ export async function createFightScene(config: FightSceneConfig) { const sy = cy - 40 * sz - Math.random() * 10 * sz const drop = k.add([ k.circle(1.5 * sz), k.pos(sx, sy), - k.color(k.Color.fromHex('#88ccff')), k.opacity(0.7), k.z(35), + k.color(safeColor(k,'#88ccff')), k.opacity(0.7), k.z(35), ]) drop.onUpdate(() => { drop.pos.y += 40 * sz * k.dt() @@ -644,7 +659,7 @@ export async function createFightScene(config: FightSceneConfig) { const nx = cx + (n === 0 ? -2 : 2) * sz * dir const nostril = k.add([ k.circle(1.8 * sz), k.pos(nx, nostrilY), - k.color(k.Color.fromHex('#331111')), k.opacity(0.6), k.z(33), + k.color(safeColor(k,'#331111')), k.opacity(0.6), k.z(33), k.scale(1), ]) nostril.onUpdate(() => { @@ -661,12 +676,12 @@ export async function createFightScene(config: FightSceneConfig) { // RAGE FACE — eyebrows angled down, mouth wide open, steam from ears const browL = k.add([ k.rect(8 * sz, 1.5 * sz), k.pos(eyeX - 4 * sz, eyeY - 6 * sz), - k.color(k.Color.fromHex('#442200')), k.opacity(0.8), k.z(35), + k.color(safeColor(k,'#442200')), k.opacity(0.8), k.z(35), k.rotate(dir > 0 ? 25 : -25), ]) const browR = k.add([ k.rect(8 * sz, 1.5 * sz), k.pos(eye2X - 4 * sz, eye2Y - 5 * sz), - k.color(k.Color.fromHex('#442200')), k.opacity(0.8), k.z(35), + k.color(safeColor(k,'#442200')), k.opacity(0.8), k.z(35), k.rotate(dir > 0 ? -25 : 25), ]) grotesqueObjects.push(browL, browR) @@ -675,7 +690,7 @@ export async function createFightScene(config: FightSceneConfig) { const earX = cx + dir * 18 * sz const puff = k.add([ k.circle(2 * sz + s * sz), k.pos(earX, cy - 30 * sz - s * 5 * sz), - k.color(k.Color.fromHex('#cccccc')), k.opacity(0.5), k.z(36), + k.color(safeColor(k,'#cccccc')), k.opacity(0.5), k.z(36), ]) puff.onUpdate(() => { puff.pos.y -= 15 * sz * k.dt() @@ -690,7 +705,7 @@ export async function createFightScene(config: FightSceneConfig) { const tongueY = cy - 14 * sz const tongue = k.add([ k.rect(5 * sz, 10 * sz, { radius: 3 * sz }), k.pos(tongueX, tongueY), - k.color(k.Color.fromHex('#ff6688')), k.opacity(0.8), k.z(34), + k.color(safeColor(k,'#ff6688')), k.opacity(0.8), k.z(34), ]) tongue.onUpdate(() => { tongue.pos.x = tongueX + Math.sin(k.time() * 6) * sz @@ -700,7 +715,7 @@ export async function createFightScene(config: FightSceneConfig) { // Drool const drool = k.add([ k.circle(1.2 * sz), k.pos(tongueX + 2 * sz, tongueY + 10 * sz), - k.color(k.Color.fromHex('#88ccff')), k.opacity(0.6), k.z(35), + k.color(safeColor(k,'#88ccff')), k.opacity(0.6), k.z(35), ]) drool.onUpdate(() => { drool.pos.y += 25 * sz * k.dt() @@ -726,7 +741,7 @@ export async function createFightScene(config: FightSceneConfig) { for (let t = 0; t < 3; t++) { const tear = k.add([ k.circle(1.2 * sz), k.pos(tearBaseX, tearBaseY), - k.color(k.Color.fromHex('#4488ff')), k.opacity(0.7), k.z(36), + k.color(safeColor(k,'#4488ff')), k.opacity(0.7), k.z(36), ]) const startDelay = t * 0.3 let elapsed = -startDelay @@ -743,7 +758,7 @@ export async function createFightScene(config: FightSceneConfig) { // Quivering lower lip const lip = k.add([ k.rect(10 * sz, 2 * sz, { radius: sz }), k.pos(cx - 5 * sz, cy - 16 * sz), - k.color(k.Color.fromHex('#cc4466')), k.opacity(0.6), k.z(34), + k.color(safeColor(k,'#cc4466')), k.opacity(0.6), k.z(34), ]) lip.onUpdate(() => { lip.pos.y = cy - 16 * sz + Math.sin(k.time() * 20) * 0.5 * sz }) grotesqueObjects.push(lip) @@ -757,7 +772,7 @@ export async function createFightScene(config: FightSceneConfig) { // Giant open mouth const mouth = k.add([ k.circle(6 * sz), k.pos(cx, cy - 16 * sz), - k.color(k.Color.fromHex('#110000')), k.opacity(0.7), k.z(33), + k.color(safeColor(k,'#110000')), k.opacity(0.7), k.z(33), k.scale(1), ]) mouth.onUpdate(() => { @@ -770,16 +785,16 @@ export async function createFightScene(config: FightSceneConfig) { // Eyelids (half cover the eyes) const lidL = k.add([ k.rect(16 * sz, 5 * sz), k.pos(eyeX - 8 * sz, eyeY - 6 * sz), - k.color(k.Color.fromHex('#886644')), k.opacity(0.5), k.z(35), + k.color(safeColor(k,'#886644')), k.opacity(0.5), k.z(35), ]) const lidR = k.add([ k.rect(12 * sz, 4 * sz), k.pos(eye2X - 6 * sz, eye2Y - 5 * sz), - k.color(k.Color.fromHex('#886644')), k.opacity(0.5), k.z(35), + k.color(safeColor(k,'#886644')), k.opacity(0.5), k.z(35), ]) // Wide smirk const smirk = k.add([ k.rect(14 * sz, 2 * sz, { radius: sz }), k.pos(cx - 3 * sz * dir, cy - 19 * sz), - k.color(k.Color.fromHex('#cc3344')), k.opacity(0.7), k.z(34), + k.color(safeColor(k,'#cc3344')), k.opacity(0.7), k.z(34), k.rotate(dir > 0 ? 10 : -10), ]) grotesqueObjects.push(lidL, lidR, smirk) @@ -799,261 +814,605 @@ export async function createFightScene(config: FightSceneConfig) { const acc = theme.accent // Parallax stars/particles in the sky - for (let i = 0; i < 30; i++) { + for (let i = 0; i < 40; i++) { const star = k.add([ k.rect(1 + Math.random() * 2, 1 + Math.random() * 2), k.pos(Math.random() * W, Math.random() * (GROUND_Y - 20)), - k.color(k.Color.fromHex(acc)), - k.opacity(0.1 + Math.random() * 0.2), + k.color(safeColor(k,i % 5 === 0 ? '#ffffff' : acc)), + k.opacity(0.08 + Math.random() * 0.2), k.z(1), ]) - // Twinkle const speed = 0.5 + Math.random() * 1.5 const baseOp = star.opacity star.onUpdate(() => { - star.opacity = baseOp + Math.sin(k.time() * speed + i) * 0.1 + star.opacity = baseOp + Math.sin(k.time() * speed + i) * 0.08 }) } if (a === 'datacenter' || a === 'localhost') { - // Blinking server rack lights - for (let row = 0; row < 3; row++) { - for (let col = 0; col < 8; col++) { - const lx = 30 + col * 95 - const ly = 40 + row * 60 - // Rack body - k.add([k.rect(70, 50), k.pos(lx, ly), k.color(k.Color.fromHex('#0a0a15')), k.opacity(0.5), k.z(1)]) - // Blinking LED - const led = k.add([ - k.rect(4, 4), k.pos(lx + 5 + col * 3, ly + 10 + row * 8), - k.color(k.Color.fromHex(Math.random() > 0.5 ? '#00ff41' : '#ff2d2d')), - k.opacity(0.6), k.z(2), - ]) - led.onUpdate(() => { led.opacity = Math.random() > 0.95 ? 0.1 : 0.6 }) + // Server rack wall — multiple rows of racks with blinking LEDs + for (let row = 0; row < 4; row++) { + for (let col = 0; col < 10; col++) { + const lx = 15 + col * (W / 10) + const ly = 15 + row * 55 + const rackW = W / 10 - 8 + const rackH = 48 + // Rack body with border + k.add([k.rect(rackW, rackH), k.pos(lx, ly), k.color(safeColor(k,'#06060f')), k.opacity(0.6), k.z(1)]) + k.add([k.rect(rackW, 1), k.pos(lx, ly), k.color(safeColor(k,'#1a1a3a')), k.opacity(0.4), k.z(1)]) + k.add([k.rect(rackW, 1), k.pos(lx, ly + rackH), k.color(safeColor(k,'#1a1a3a')), k.opacity(0.4), k.z(1)]) + k.add([k.rect(1, rackH), k.pos(lx, ly), k.color(safeColor(k,'#1a1a3a')), k.opacity(0.3), k.z(1)]) + // Drive slots (horizontal lines) + for (let s = 0; s < 5; s++) { + k.add([k.rect(rackW - 6, 1), k.pos(lx + 3, ly + 8 + s * 8), k.color(safeColor(k,'#111122')), k.opacity(0.5), k.z(1)]) + } + // Multiple blinking LEDs per rack + for (let led = 0; led < 3; led++) { + const ledEl = k.add([ + k.rect(2, 2), k.pos(lx + 4 + led * 6, ly + 4), + k.color(safeColor(k,Math.random() > 0.3 ? '#00ff41' : '#ff2d2d')), + k.opacity(0.7), k.z(2), + ]) + const blinkRate = 0.02 + Math.random() * 0.08 + ledEl.onUpdate(() => { ledEl.opacity = Math.random() > blinkRate ? 0.7 : 0.15 }) + } + // Activity LED (flashing amber) + if (Math.random() < 0.4) { + const actLed = k.add([ + k.rect(2, 2), k.pos(lx + rackW - 8, ly + 4), + k.color(safeColor(k,'#ffaa00')), k.opacity(0.6), k.z(2), + ]) + actLed.onUpdate(() => { actLed.opacity = Math.random() > 0.5 ? 0.7 : 0.2 }) + } } } + // Cable bundles running across ceiling + for (let c = 0; c < 4; c++) { + const cy = 5 + c * 3 + k.add([k.rect(W, 2), k.pos(0, cy), k.color(safeColor(k,c % 2 === 0 ? '#111133' : '#0a0a22')), k.opacity(0.5), k.z(1)]) + } } else if (a === 'gpu_graveyard') { - // Floating circuit board fragments - for (let i = 0; i < 12; i++) { + // Floating circuit board fragments with traces and solder points + for (let i = 0; i < 16; i++) { const cx = Math.random() * W const cy = 20 + Math.random() * (GROUND_Y - 60) + const cw = 15 + Math.random() * 25 + const ch = 10 + Math.random() * 18 const chip = k.add([ - k.rect(15 + Math.random() * 20, 10 + Math.random() * 15), - k.pos(cx, cy), - k.color(k.Color.fromHex('#1a2a1a')), - k.opacity(0.3), - k.z(1), - k.rotate(Math.random() * 360), + k.rect(cw, ch), k.pos(cx, cy), + k.color(safeColor(k,Math.random() > 0.5 ? '#1a2a1a' : '#0f1a0f')), + k.opacity(0.35), k.z(1), k.rotate(Math.random() * 45 - 22), ]) - // Trace lines - k.add([k.rect(Math.random() * 30, 1), k.pos(cx, cy + 5), k.color(k.Color.fromHex('#76b900')), k.opacity(0.15), k.z(1)]) + // Trace lines on each chip + for (let t = 0; t < 3; t++) { + k.add([k.rect(cw * 0.6, 1), k.pos(cx + 2, cy + 3 + t * 4), k.color(safeColor(k,'#76b900')), k.opacity(0.2), k.z(1)]) + } + // Solder point + k.add([k.circle(1.5), k.pos(cx + cw / 2, cy + ch / 2), k.color(safeColor(k,'#aabb44')), k.opacity(0.25), k.z(1)]) const baseY = cy chip.onUpdate(() => { chip.pos.y = baseY + Math.sin(k.time() * 0.3 + i) * 5 }) } - } else if (a === 'prompt_dungeon') { - // Glowing runes on the walls - for (let i = 0; i < 8; i++) { - const rx = 40 + i * (W / 8) - const ry = 30 + Math.random() * 80 - const rune = k.add([ - k.circle(8 + Math.random() * 6), - k.pos(rx, ry), - k.color(k.Color.fromHex('#b83dff')), - k.opacity(0.15), - k.z(1), - ]) - rune.onUpdate(() => { rune.opacity = 0.1 + Math.sin(k.time() * 0.8 + i * 0.7) * 0.1 }) + // Dead GPU fan silhouettes + for (const fx of [W * 0.15, W * 0.5, W * 0.85]) { + const fy = GROUND_Y - 30 - Math.random() * 40 + k.add([k.circle(18), k.pos(fx, fy), k.anchor('center'), k.color(safeColor(k,'#0a0a0a')), k.opacity(0.4), k.z(1)]) + k.add([k.circle(5), k.pos(fx, fy), k.anchor('center'), k.color(safeColor(k,'#1a1a1a')), k.opacity(0.3), k.z(1)]) + // Fan blades (static, dead) + for (let b = 0; b < 4; b++) { + const ba = b * Math.PI / 2 + k.add([k.rect(14, 2), k.pos(fx, fy), k.anchor('center'), k.color(safeColor(k,'#222222')), k.opacity(0.3), k.z(1), k.rotate(ba * 180 / Math.PI)]) + } } - // Fog at ground level - for (let i = 0; i < 6; i++) { + } else if (a === 'prompt_dungeon') { + // Stone wall texture + for (let row = 0; row < 5; row++) { + for (let col = 0; col < 12; col++) { + const brickW = W / 12 + (Math.random() - 0.5) * 10 + const brickH = GROUND_Y / 5 + const bx = col * (W / 12) + (row % 2 === 0 ? 0 : W / 24) + k.add([k.rect(brickW - 2, brickH - 2), k.pos(bx, row * brickH), k.color(safeColor(k,'#1a1520')), k.opacity(0.3), k.z(1)]) + } + } + // Glowing runes scattered on walls + const runeSymbols = ['*', '+', 'o', '~', '^'] + for (let i = 0; i < 10; i++) { + const rx = 30 + Math.random() * (W - 60) + const ry = 20 + Math.random() * (GROUND_Y - 60) + const rune = k.add([ + k.circle(6 + Math.random() * 8), k.pos(rx, ry), + k.color(safeColor(k,'#b83dff')), k.opacity(0.12), k.z(1), + ]) + // Rune glow halo + k.add([k.circle(12 + Math.random() * 8), k.pos(rx, ry), k.color(safeColor(k,'#6622aa')), k.opacity(0.06), k.z(0)]) + rune.onUpdate(() => { rune.opacity = 0.08 + Math.sin(k.time() * 0.8 + i * 0.7) * 0.08 }) + } + // Torches on walls + for (const tx of [W * 0.1, W * 0.35, W * 0.65, W * 0.9]) { + const ty = GROUND_Y * 0.3 + k.add([k.rect(4, 20), k.pos(tx, ty), k.color(safeColor(k,'#553311')), k.opacity(0.5), k.z(1)]) + const flame = k.add([k.circle(6), k.pos(tx + 2, ty - 3), k.anchor('center'), k.color(safeColor(k,'#ff6600')), k.opacity(0.5), k.z(2)]) + const flameGlow = k.add([k.circle(15), k.pos(tx + 2, ty - 3), k.anchor('center'), k.color(safeColor(k,'#ff4400')), k.opacity(0.08), k.z(1)]) + flame.onUpdate(() => { + flame.opacity = 0.4 + Math.sin(k.time() * 6 + tx) * 0.15 + flame.pos.y = ty - 3 + Math.sin(k.time() * 8 + tx) * 1.5 + flameGlow.opacity = 0.05 + Math.sin(k.time() * 6 + tx) * 0.04 + }) + } + // Ground fog + for (let i = 0; i < 8; i++) { const fog = k.add([ - k.circle(40 + Math.random() * 30), - k.pos(Math.random() * W, GROUND_Y - 10), - k.color(k.Color.fromHex('#1f1a2a')), - k.opacity(0.3), - k.z(3), + k.circle(35 + Math.random() * 30), k.pos(Math.random() * W, GROUND_Y - 8), + k.color(safeColor(k,'#1f1a2a')), k.opacity(0.25), k.z(3), ]) const baseX = fog.pos.x - fog.onUpdate(() => { fog.pos.x = baseX + Math.sin(k.time() * 0.2 + i) * 20 }) + fog.onUpdate(() => { fog.pos.x = baseX + Math.sin(k.time() * 0.2 + i) * 18 }) } } else if (a === 'the_cloud') { - // Floating cloud shapes - for (let i = 0; i < 5; i++) { - const cx = Math.random() * W - const cy = 30 + Math.random() * 80 - for (let j = 0; j < 3; j++) { - const cloud = k.add([ - k.circle(20 + Math.random() * 15), - k.pos(cx + j * 18, cy + (Math.random() - 0.5) * 10), - k.color(k.Color.fromHex('#1a1f2a')), - k.opacity(0.4), - k.z(1), - ]) - const bx = cloud.pos.x - cloud.onUpdate(() => { cloud.pos.x = bx + Math.sin(k.time() * 0.15 + i) * 10 }) + // Layered cloud formations + for (let layer = 0; layer < 3; layer++) { + for (let i = 0; i < 4; i++) { + const cx = Math.random() * W + const cy = 20 + layer * 50 + Math.random() * 30 + const cloudOp = 0.25 - layer * 0.05 + // Multi-blob cloud + for (let j = 0; j < 4 + Math.floor(Math.random() * 3); j++) { + const cloud = k.add([ + k.circle(15 + Math.random() * 20), + k.pos(cx + j * 14 - 20, cy + (Math.random() - 0.5) * 12), + k.color(safeColor(k,layer === 0 ? '#1a2030' : '#151a28')), + k.opacity(cloudOp), k.z(1), + ]) + const bx = cloud.pos.x + cloud.onUpdate(() => { cloud.pos.x = bx + Math.sin(k.time() * (0.08 + layer * 0.04) + i) * (8 + layer * 4) }) + } } } + // Data streams (vertical lines falling like rain) + for (let i = 0; i < 10; i++) { + const stream = k.add([ + k.rect(1, 8 + Math.random() * 15), k.pos(Math.random() * W, Math.random() * GROUND_Y), + k.color(safeColor(k,'#4488ff')), k.opacity(0.12), k.z(2), + ]) + stream.onUpdate(() => { + stream.pos.y += 30 * k.dt() + if (stream.pos.y > GROUND_Y) stream.pos.y = -20 + }) + } } else if (a === 'the_singularity') { - // Swirling vortex in the background - for (let i = 0; i < 20; i++) { - const angle = (i / 20) * Math.PI * 2 - const dist = 40 + i * 8 - const sx = W / 2 + Math.cos(angle) * dist - const sy = GROUND_Y * 0.4 + Math.sin(angle) * dist * 0.5 - const dot = k.add([ - k.circle(2 + Math.random() * 3), - k.pos(sx, sy), - k.color(k.Color.fromHex('#ff00ff')), - k.opacity(0.2), - k.z(1), - ]) - dot.onUpdate(() => { - const a2 = angle + k.time() * 0.5 - const d2 = dist + Math.sin(k.time() + i) * 10 - dot.pos.x = W / 2 + Math.cos(a2) * d2 - dot.pos.y = GROUND_Y * 0.4 + Math.sin(a2) * d2 * 0.5 - }) - } - } else if (a === 'silicon_valley_dojo') { - // Bamboo/pillars on sides - for (const side of [0.05, 0.1, 0.88, 0.93]) { - const px = W * side - k.add([k.rect(6, GROUND_Y - 20), k.pos(px, 20), k.color(k.Color.fromHex('#1a3a1a')), k.opacity(0.4), k.z(1)]) - // Leaves - for (let j = 0; j < 3; j++) { - k.add([ - k.circle(8), - k.pos(px + (Math.random() - 0.5) * 20, 30 + j * 40), - k.color(k.Color.fromHex('#00ff41')), - k.opacity(0.15), - k.z(1), + // Central swirling vortex with multiple rings + for (let ring = 0; ring < 3; ring++) { + const ringDots = 12 + ring * 6 + for (let i = 0; i < ringDots; i++) { + const angle = (i / ringDots) * Math.PI * 2 + const baseDist = 30 + ring * 35 + const dot = k.add([ + k.circle(1.5 + ring * 0.5), k.pos(W / 2, GROUND_Y * 0.4), + k.color(safeColor(k,ring === 0 ? '#ff44ff' : ring === 1 ? '#ff00ff' : '#aa00aa')), + k.opacity(0.25 - ring * 0.05), k.z(1), ]) + dot.onUpdate(() => { + const a2 = angle + k.time() * (0.6 - ring * 0.15) + const d2 = baseDist + Math.sin(k.time() * 0.8 + i) * 12 + dot.pos.x = W / 2 + Math.cos(a2) * d2 + dot.pos.y = GROUND_Y * 0.4 + Math.sin(a2) * d2 * 0.5 + }) } } + // Central core glow + const core = k.add([k.circle(12), k.pos(W / 2, GROUND_Y * 0.4), k.anchor('center'), k.color(safeColor(k,'#ffffff')), k.opacity(0.15), k.z(1)]) + const coreHalo = k.add([k.circle(30), k.pos(W / 2, GROUND_Y * 0.4), k.anchor('center'), k.color(safeColor(k,'#ff00ff')), k.opacity(0.06), k.z(0)]) + core.onUpdate(() => { core.opacity = 0.1 + Math.sin(k.time() * 2) * 0.08 }) + } else if (a === 'silicon_valley_dojo') { + // Bamboo stalks with segments and leaves + for (const side of [0.04, 0.08, 0.12, 0.86, 0.91, 0.96]) { + const px = W * side + const segments = 5 + Math.floor(Math.random() * 3) + const segH = (GROUND_Y - 15) / segments + for (let s = 0; s < segments; s++) { + k.add([k.rect(5, segH - 2), k.pos(px, 15 + s * segH), k.color(safeColor(k,'#1a3a1a')), k.opacity(0.45), k.z(1)]) + // Segment node + k.add([k.rect(7, 2), k.pos(px - 1, 15 + s * segH), k.color(safeColor(k,'#2a4a2a')), k.opacity(0.4), k.z(1)]) + } + // Leaves at various heights + for (let l = 0; l < 3; l++) { + const ly = 20 + l * (GROUND_Y / 4) + const dir = Math.random() > 0.5 ? 1 : -1 + k.add([k.rect(18, 3), k.pos(px + dir * 4, ly), k.color(safeColor(k,'#22aa33')), k.opacity(0.2), k.z(1), k.rotate(dir * 25)]) + k.add([k.rect(14, 2), k.pos(px + dir * 8, ly + 4), k.color(safeColor(k,'#1a8822')), k.opacity(0.15), k.z(1), k.rotate(dir * 35)]) + } + } + // Zen garden raked lines on ground + for (let i = 0; i < 8; i++) { + const gy = GROUND_Y + 5 + i * 8 + k.add([k.rect(W * 0.6, 1), k.pos(W * 0.2, gy), k.color(safeColor(k,'#00ff41')), k.opacity(0.06), k.z(0)]) + } } else if (a === 'hacker_news' || a === 'stackoverflow_ruins') { - // Floating text-like blocks (simulating code/posts) - for (let i = 0; i < 10; i++) { - const bw = 30 + Math.random() * 50 - k.add([ - k.rect(bw, 4 + Math.random() * 3), - k.pos(20 + Math.random() * (W - 80), 20 + Math.random() * (GROUND_Y - 60)), - k.color(k.Color.fromHex(acc)), - k.opacity(0.06 + Math.random() * 0.06), - k.z(1), - ]) - } - } else if (a === 'beach') { - // Ocean waves at horizon, sun, palm trees - const sun = k.add([ - k.circle(25), k.pos(W * 0.8, 40), - k.color(k.Color.fromHex('#ffcc00')), k.opacity(0.5), k.z(1), - ]) - sun.onUpdate(() => { sun.opacity = 0.4 + Math.sin(k.time() * 0.5) * 0.1 }) - // Waves - for (let i = 0; i < 6; i++) { - const wave = k.add([ - k.rect(W * 0.4, 3), k.pos(Math.random() * W, GROUND_Y - 15 - i * 6), - k.color(k.Color.fromHex('#2266aa')), k.opacity(0.2), k.z(2), - ]) - const baseX = wave.pos.x - wave.onUpdate(() => { wave.pos.x = baseX + Math.sin(k.time() * 0.8 + i) * 20 }) - } - // Palm tree silhouettes - for (const px of [W * 0.08, W * 0.92]) { - k.add([k.rect(6, 80), k.pos(px, GROUND_Y - 80), k.color(k.Color.fromHex('#2a1a00')), k.opacity(0.4), k.z(1)]) - for (let l = 0; l < 4; l++) { - k.add([ - k.rect(30, 4), k.pos(px - 15, GROUND_Y - 82 - l * 3), - k.color(k.Color.fromHex('#1a5500')), k.opacity(0.3), k.z(1), - k.rotate(-30 + l * 20), - ]) + // Code blocks / post fragments floating + for (let i = 0; i < 14; i++) { + const bw = 25 + Math.random() * 60 + const bh = 3 + Math.random() * 4 + const bx = 15 + Math.random() * (W - 50) + const by = 15 + Math.random() * (GROUND_Y - 50) + k.add([k.rect(bw, bh), k.pos(bx, by), k.color(safeColor(k,acc)), k.opacity(0.06 + Math.random() * 0.06), k.z(1)]) + // Indent markers + if (Math.random() < 0.4) { + k.add([k.rect(2, bh), k.pos(bx - 4, by), k.color(safeColor(k,acc)), k.opacity(0.1), k.z(1)]) } } - } else if (a === 'desert') { - // Sand dunes, cacti, heat shimmer + // Vote arrows + for (let i = 0; i < 5; i++) { + const ax = 20 + Math.random() * (W - 40) + const ay = 30 + Math.random() * (GROUND_Y - 70) + // Upvote triangle + k.add([k.rect(4, 6), k.pos(ax, ay), k.color(safeColor(k,a === 'stackoverflow_ruins' ? '#f48024' : '#ff6600')), k.opacity(0.12), k.z(1)]) + } + } else if (a === 'beach') { + // Sun with rays + const sunX = W * 0.8, sunY = 35 + k.add([k.circle(30), k.pos(sunX, sunY), k.anchor('center'), k.color(safeColor(k,'#ffdd44')), k.opacity(0.12), k.z(0)]) + const sun = k.add([k.circle(18), k.pos(sunX, sunY), k.anchor('center'), k.color(safeColor(k,'#ffcc00')), k.opacity(0.5), k.z(1)]) + sun.onUpdate(() => { sun.opacity = 0.4 + Math.sin(k.time() * 0.5) * 0.1 }) + // Sun rays + for (let r = 0; r < 8; r++) { + const angle = r * Math.PI / 4 + k.add([k.rect(40, 2), k.pos(sunX, sunY), k.anchor('left'), k.color(safeColor(k,'#ffcc00')), k.opacity(0.08), k.z(0), k.rotate(angle * 180 / Math.PI)]) + } + // Ocean horizon with layered waves + for (let layer = 0; layer < 3; layer++) { + for (let i = 0; i < 4; i++) { + const wave = k.add([ + k.rect(W * 0.35, 3 + layer), k.pos(Math.random() * W, GROUND_Y - 18 - layer * 8 - i * 3), + k.color(safeColor(k,layer === 0 ? '#3377bb' : '#2266aa')), k.opacity(0.2 - layer * 0.04), k.z(2), + ]) + const baseX = wave.pos.x + wave.onUpdate(() => { wave.pos.x = baseX + Math.sin(k.time() * (0.6 + layer * 0.2) + i * 1.5) * (15 + layer * 5) }) + } + } + // Palm trees with trunks and fronds + for (const px of [W * 0.06, W * 0.93]) { + const lean = px < W / 2 ? 8 : -8 + // Trunk segments + for (let s = 0; s < 8; s++) { + const sy = GROUND_Y - 10 - s * 12 + k.add([k.rect(7 - s * 0.5, 13), k.pos(px + s * lean / 8, sy), k.color(safeColor(k,'#3a2510')), k.opacity(0.5), k.z(1)]) + } + // Fronds + const topX = px + lean, topY = GROUND_Y - 106 + for (let f = 0; f < 6; f++) { + const angle = -60 + f * 24 + k.add([k.rect(35, 3), k.pos(topX, topY), k.anchor('left'), k.color(safeColor(k,'#1a6600')), k.opacity(0.35), k.z(1), k.rotate(angle)]) + } + } + // Seagulls (v-shapes in sky) for (let i = 0; i < 3; i++) { - const dx = W * (0.2 + i * 0.3) - k.add([k.circle(60 + i * 20), k.pos(dx, GROUND_Y + 10), k.color(k.Color.fromHex('#886630')), k.opacity(0.3), k.z(1)]) + const bx = W * 0.3 + Math.random() * W * 0.4 + const by = 15 + Math.random() * 30 + k.add([k.rect(6, 1), k.pos(bx, by), k.color(safeColor(k,'#333333')), k.opacity(0.3), k.z(1), k.rotate(-15)]) + k.add([k.rect(6, 1), k.pos(bx + 5, by), k.color(safeColor(k,'#333333')), k.opacity(0.3), k.z(1), k.rotate(15)]) } - // Cacti - for (const cx of [W * 0.12, W * 0.55, W * 0.88]) { - k.add([k.rect(8, 40), k.pos(cx, GROUND_Y - 40), k.color(k.Color.fromHex('#226622')), k.opacity(0.4), k.z(1)]) - k.add([k.rect(15, 6), k.pos(cx - 8, GROUND_Y - 55), k.color(k.Color.fromHex('#226622')), k.opacity(0.35), k.z(1)]) + } else if (a === 'desert') { + // Layered sand dunes + for (let d = 0; d < 5; d++) { + const dx = W * (0.1 + d * 0.2) + (Math.random() - 0.5) * 40 + const dr = 50 + d * 15 + Math.random() * 20 + k.add([k.circle(dr), k.pos(dx, GROUND_Y + dr * 0.6), k.anchor('center'), k.color(safeColor(k,'#aa8844')), k.opacity(0.15 + d * 0.03), k.z(1)]) + } + // Cacti with arms + for (const cx of [W * 0.1, W * 0.5, W * 0.88]) { + const ch = 35 + Math.random() * 20 + // Main trunk + k.add([k.rect(7, ch), k.pos(cx, GROUND_Y - ch), k.color(safeColor(k,'#226622')), k.opacity(0.45), k.z(1)]) + // Arms + const armY = GROUND_Y - ch * 0.6 + k.add([k.rect(12, 5), k.pos(cx - 12, armY), k.color(safeColor(k,'#226622')), k.opacity(0.4), k.z(1)]) + k.add([k.rect(5, 15), k.pos(cx - 12, armY - 15), k.color(safeColor(k,'#226622')), k.opacity(0.4), k.z(1)]) + if (Math.random() > 0.3) { + const armY2 = GROUND_Y - ch * 0.4 + k.add([k.rect(10, 5), k.pos(cx + 7, armY2), k.color(safeColor(k,'#226622')), k.opacity(0.4), k.z(1)]) + k.add([k.rect(5, 12), k.pos(cx + 12, armY2 - 12), k.color(safeColor(k,'#226622')), k.opacity(0.4), k.z(1)]) + } + } + // Tumbleweed + const tumble = k.add([k.circle(8), k.pos(-20, GROUND_Y - 10), k.color(safeColor(k,'#886644')), k.opacity(0.3), k.z(2)]) + tumble.onUpdate(() => { tumble.pos.x = ((tumble.pos.x + 20 * k.dt()) % (W + 40)) - 20 }) + // Heat shimmer bands + for (let i = 0; i < 3; i++) { + const shimmer = k.add([k.rect(W, 1), k.pos(0, GROUND_Y - 3 - i * 3), k.color(safeColor(k,'#ff8800')), k.opacity(0.06), k.z(2)]) + shimmer.onUpdate(() => { shimmer.opacity = 0.03 + Math.sin(k.time() * 3 + i * 2) * 0.03 }) } - // Heat shimmer - const shimmer = k.add([k.rect(W, 2), k.pos(0, GROUND_Y - 5), k.color(k.Color.fromHex('#ff8800')), k.opacity(0.08), k.z(2)]) - shimmer.onUpdate(() => { shimmer.opacity = 0.05 + Math.sin(k.time() * 3) * 0.04 }) } else if (a === 'forest') { - // Trees, undergrowth, fireflies + // Layered trees — back layer (small/faded), front layer (larger/darker) + for (let layer = 0; layer < 2; layer++) { + const treeCount = layer === 0 ? 12 : 6 + const treeOp = layer === 0 ? 0.2 : 0.4 + for (let i = 0; i < treeCount; i++) { + const tx = 15 + (i / treeCount) * (W - 30) + (Math.random() - 0.5) * 20 + const th = (30 + Math.random() * 30) * (layer === 0 ? 0.7 : 1) + const trunkW = layer === 0 ? 5 : 8 + // Trunk + k.add([k.rect(trunkW, th), k.pos(tx, GROUND_Y - th), k.color(safeColor(k,layer === 0 ? '#0f1a08' : '#1a2a10')), k.opacity(treeOp), k.z(1 + layer)]) + // Canopy — multiple overlapping circles + for (let c = 0; c < 3; c++) { + const cr = (12 + Math.random() * 12) * (layer === 0 ? 0.7 : 1) + k.add([ + k.circle(cr), k.pos(tx + trunkW / 2 + (Math.random() - 0.5) * 10, GROUND_Y - th - cr * 0.5 + c * 4), + k.anchor('center'), k.color(safeColor(k,layer === 0 ? '#0a1a08' : '#1a3a10')), k.opacity(treeOp * 0.8), k.z(1 + layer), + ]) + } + } + } + // Undergrowth (bushes at ground level) for (let i = 0; i < 8; i++) { - const tx = 20 + i * (W / 8) - const th = 50 + Math.random() * 40 - k.add([k.rect(8, th), k.pos(tx, GROUND_Y - th), k.color(k.Color.fromHex('#1a2a10')), k.opacity(0.4), k.z(1)]) - k.add([k.circle(20 + Math.random() * 15), k.pos(tx, GROUND_Y - th - 10), k.color(k.Color.fromHex('#1a3a10')), k.opacity(0.3), k.z(1)]) + k.add([k.circle(8 + Math.random() * 6), k.pos(Math.random() * W, GROUND_Y - 4), k.anchor('bot'), k.color(safeColor(k,'#1a3a10')), k.opacity(0.3), k.z(3)]) } // Fireflies - for (let i = 0; i < 8; i++) { + for (let i = 0; i < 12; i++) { const ff = k.add([ - k.circle(2), k.pos(Math.random() * W, 30 + Math.random() * (GROUND_Y - 50)), - k.color(k.Color.fromHex('#aaff44')), k.opacity(0), k.z(2), + k.circle(1.5), k.pos(Math.random() * W, 30 + Math.random() * (GROUND_Y - 50)), + k.color(safeColor(k,'#ccff44')), k.opacity(0), k.z(3), ]) + const bx = ff.pos.x, by = ff.pos.y ff.onUpdate(() => { - ff.opacity = Math.max(0, Math.sin(k.time() * 2 + i * 1.5) * 0.4) - ff.pos.x += Math.sin(k.time() + i) * 10 * k.dt() - ff.pos.y += Math.cos(k.time() * 0.8 + i) * 8 * k.dt() + ff.opacity = Math.max(0, Math.sin(k.time() * 1.5 + i * 1.8) * 0.5) + ff.pos.x = bx + Math.sin(k.time() * 0.5 + i) * 15 + ff.pos.y = by + Math.cos(k.time() * 0.4 + i) * 10 }) } - } else if (a === 'jungle') { - // Dense vines, hanging leaves, misty - for (let i = 0; i < 6; i++) { - const vx = 30 + i * (W / 6) - const vineLen = 40 + Math.random() * 60 - k.add([k.rect(2, vineLen), k.pos(vx, 0), k.color(k.Color.fromHex('#1a4a10')), k.opacity(0.3), k.z(1)]) - // Leaf at bottom - k.add([k.circle(6), k.pos(vx, vineLen), k.color(k.Color.fromHex('#22aa22')), k.opacity(0.25), k.z(1)]) - } - // Jungle mist + // Light rays through canopy for (let i = 0; i < 4; i++) { + const rx = W * (0.2 + i * 0.2) + (Math.random() - 0.5) * 30 + k.add([k.rect(6, GROUND_Y * 0.6), k.pos(rx, 0), k.color(safeColor(k,'#aaff44')), k.opacity(0.03), k.z(2), k.rotate(5 - Math.random() * 10)]) + } + } else if (a === 'jungle') { + // Dense vine canopy hanging from top + for (let i = 0; i < 10; i++) { + const vx = 20 + i * (W / 10) + (Math.random() - 0.5) * 15 + const vineLen = 30 + Math.random() * 80 + // Vine strand (wavy) + for (let s = 0; s < vineLen; s += 4) { + k.add([k.rect(2, 5), k.pos(vx + Math.sin(s * 0.15) * 4, s), k.color(safeColor(k,'#1a4a10')), k.opacity(0.3), k.z(1)]) + } + // Leaf clusters at bottom + for (let l = 0; l < 2; l++) { + k.add([k.circle(5 + Math.random() * 4), k.pos(vx + (Math.random() - 0.5) * 8, vineLen + l * 6), k.color(safeColor(k,'#22aa22')), k.opacity(0.22), k.z(1)]) + } + } + // Large tropical leaves in foreground edges + for (const side of [0, W - 30]) { + for (let l = 0; l < 3; l++) { + const ly = 20 + l * (GROUND_Y / 4) + k.add([k.rect(30, 8), k.pos(side, ly), k.color(safeColor(k,'#1a5510')), k.opacity(0.25), k.z(3), k.rotate(side === 0 ? 15 : -15)]) + } + } + // Thick ground fog + for (let i = 0; i < 8; i++) { const mist = k.add([ - k.circle(50 + Math.random() * 30), k.pos(Math.random() * W, GROUND_Y - 20), - k.color(k.Color.fromHex('#1a3818')), k.opacity(0.2), k.z(3), + k.circle(40 + Math.random() * 35), k.pos(Math.random() * W, GROUND_Y - 12), + k.color(safeColor(k,'#1a3818')), k.opacity(0.22), k.z(3), ]) const bx = mist.pos.x - mist.onUpdate(() => { mist.pos.x = bx + Math.sin(k.time() * 0.15 + i) * 15 }) + mist.onUpdate(() => { mist.pos.x = bx + Math.sin(k.time() * 0.12 + i) * 18 }) } } else if (a === 'outer_space') { - // Deep star field, nebula, floating asteroids - for (let i = 0; i < 60; i++) { + // Dense star field with color variety + for (let i = 0; i < 80; i++) { const star = k.add([ k.circle(0.5 + Math.random() * 1.5), k.pos(Math.random() * W, Math.random() * H), - k.color(k.Color.fromHex(Math.random() > 0.8 ? '#aaaaff' : '#ffffff')), - k.opacity(0.2 + Math.random() * 0.4), k.z(1), + k.color(safeColor(k,i % 8 === 0 ? '#ffccaa' : i % 5 === 0 ? '#aaaaff' : '#ffffff')), + k.opacity(0.15 + Math.random() * 0.4), k.z(1), ]) - star.onUpdate(() => { star.opacity = 0.15 + Math.sin(k.time() * (1 + Math.random()) + i) * 0.15 }) + star.onUpdate(() => { star.opacity = 0.12 + Math.sin(k.time() * (0.8 + Math.random()) + i) * 0.15 }) } - // Nebula glow - for (let i = 0; i < 3; i++) { - k.add([ - k.circle(60 + Math.random() * 40), - k.pos(W * (0.2 + i * 0.3), H * 0.3 + Math.random() * 50), - k.color(k.Color.fromHex(['#4400aa', '#aa0066', '#0044aa'][i])), - k.opacity(0.08), k.z(1), - ]) + // Nebula clouds — large, colorful, layered + const nebulaColors = ['#4400aa', '#aa0066', '#0044aa', '#006644', '#660044'] + for (let i = 0; i < 5; i++) { + for (let blob = 0; blob < 3; blob++) { + k.add([ + k.circle(30 + Math.random() * 50), + k.pos(W * (0.1 + i * 0.2) + blob * 20, H * 0.25 + Math.random() * 60 + blob * 15), + k.color(safeColor(k,nebulaColors[i % nebulaColors.length])), + k.opacity(0.05 + blob * 0.015), k.z(0), + ]) + } } - // Asteroids - for (let i = 0; i < 4; i++) { + // Floating asteroids with rotation + for (let i = 0; i < 5; i++) { const ast = k.add([ - k.circle(5 + Math.random() * 8), - k.pos(Math.random() * W, 20 + Math.random() * (GROUND_Y - 40)), - k.color(k.Color.fromHex('#444444')), k.opacity(0.3), k.z(1), + k.circle(4 + Math.random() * 10), + k.pos(Math.random() * W, 15 + Math.random() * (GROUND_Y - 30)), + k.color(safeColor(k,Math.random() > 0.5 ? '#555555' : '#3a3a3a')), k.opacity(0.35), k.z(1), ]) + // Crater dot + k.add([k.circle(2), k.pos(ast.pos.x + 2, ast.pos.y - 1), k.color(safeColor(k,'#2a2a2a')), k.opacity(0.2), k.z(1)]) const baseX = ast.pos.x, baseY = ast.pos.y ast.onUpdate(() => { - ast.pos.x = baseX + Math.sin(k.time() * 0.2 + i * 2) * 15 - ast.pos.y = baseY + Math.cos(k.time() * 0.15 + i) * 10 + ast.pos.x = baseX + Math.sin(k.time() * 0.15 + i * 2) * 18 + ast.pos.y = baseY + Math.cos(k.time() * 0.12 + i) * 12 }) } + // Distant planet + const planetX = W * 0.75, planetY = GROUND_Y * 0.25 + k.add([k.circle(20), k.pos(planetX, planetY), k.anchor('center'), k.color(safeColor(k,'#334466')), k.opacity(0.2), k.z(0)]) + k.add([k.rect(44, 3), k.pos(planetX - 22, planetY - 1), k.color(safeColor(k,'#556688')), k.opacity(0.12), k.z(0), k.rotate(15)]) + } else if (a === 'colosseum') { + // Roman arches along the back + for (let i = 0; i < 8; i++) { + const ax = 20 + i * (W / 8) + const aw = W / 8 - 8 + // Pillar left + k.add([k.rect(6, GROUND_Y * 0.6), k.pos(ax, GROUND_Y * 0.2), k.color(safeColor(k,'#3a2a18')), k.opacity(0.4), k.z(1)]) + // Pillar right + k.add([k.rect(6, GROUND_Y * 0.6), k.pos(ax + aw - 6, GROUND_Y * 0.2), k.color(safeColor(k,'#3a2a18')), k.opacity(0.4), k.z(1)]) + // Arch top + k.add([k.rect(aw, 6), k.pos(ax, GROUND_Y * 0.2), k.color(safeColor(k,'#4a3a20')), k.opacity(0.4), k.z(1)]) + // Dark interior + k.add([k.rect(aw - 12, GROUND_Y * 0.5), k.pos(ax + 6, GROUND_Y * 0.25), k.color(safeColor(k,'#0a0800')), k.opacity(0.3), k.z(0)]) + } + // Crumbled stone debris + for (let i = 0; i < 6; i++) { + k.add([ + k.rect(5 + Math.random() * 10, 3 + Math.random() * 6), + k.pos(Math.random() * W, GROUND_Y - 5 - Math.random() * 10), + k.color(safeColor(k,'#5a4a30')), k.opacity(0.25), k.z(2), k.rotate(Math.random() * 40 - 20), + ]) + } + } else if (a === 'haunted_mansion') { + // Gothic windows + for (let i = 0; i < 5; i++) { + const wx = W * (0.1 + i * 0.2) + const wy = GROUND_Y * 0.2 + k.add([k.rect(20, 35), k.pos(wx, wy), k.color(safeColor(k,'#0a0510')), k.opacity(0.4), k.z(1)]) + // Window frame + k.add([k.rect(22, 1), k.pos(wx - 1, wy), k.color(safeColor(k,'#2a1830')), k.opacity(0.4), k.z(1)]) + k.add([k.rect(22, 1), k.pos(wx - 1, wy + 35), k.color(safeColor(k,'#2a1830')), k.opacity(0.4), k.z(1)]) + // Moonlight through window + const glow = k.add([k.rect(16, 30), k.pos(wx + 2, wy + 3), k.color(safeColor(k,'#8844cc')), k.opacity(0.06), k.z(1)]) + glow.onUpdate(() => { glow.opacity = 0.04 + Math.sin(k.time() * 0.5 + i) * 0.03 }) + } + // Cobwebs in corners + for (const cx of [15, W - 35]) { + for (let strand = 0; strand < 4; strand++) { + k.add([k.rect(20 + strand * 5, 1), k.pos(cx, 5 + strand * 5), k.color(safeColor(k,'#666666')), k.opacity(0.12), k.z(1), k.rotate(cx < W / 2 ? 30 + strand * 5 : -30 - strand * 5)]) + } + } + // Floating ghost particles + for (let i = 0; i < 6; i++) { + const ghost = k.add([ + k.circle(3 + Math.random() * 3), k.pos(Math.random() * W, Math.random() * GROUND_Y), + k.color(safeColor(k,'#aabbcc')), k.opacity(0), k.z(2), + ]) + const gx = ghost.pos.x, gy = ghost.pos.y + ghost.onUpdate(() => { + ghost.opacity = Math.max(0, Math.sin(k.time() * 0.6 + i * 2) * 0.15) + ghost.pos.x = gx + Math.sin(k.time() * 0.3 + i) * 20 + ghost.pos.y = gy + Math.cos(k.time() * 0.25 + i) * 15 + }) + } + } else if (a === 'concert_hall') { + // Stage lights + const lightColors = ['#ff2288', '#22ff88', '#4488ff', '#ffaa22', '#ff44ff', '#44ffff'] + for (let i = 0; i < 6; i++) { + const lx = W * (0.1 + i * 0.15) + // Light cone from ceiling + const cone = k.add([ + k.rect(30, GROUND_Y * 0.7), k.pos(lx, 10), k.anchor('top'), + k.color(safeColor(k,lightColors[i])), k.opacity(0.04), k.z(2), + ]) + // Light source dot + k.add([k.circle(4), k.pos(lx + 15, 8), k.anchor('center'), k.color(safeColor(k,lightColors[i])), k.opacity(0.4), k.z(2)]) + cone.onUpdate(() => { cone.opacity = 0.02 + Math.sin(k.time() * 1.5 + i * 1.2) * 0.025 }) + } + // Speaker stacks on sides + for (const sx of [15, W - 35]) { + for (let s = 0; s < 3; s++) { + k.add([k.rect(20, 18), k.pos(sx, GROUND_Y - 20 - s * 20), k.color(safeColor(k,'#111111')), k.opacity(0.5), k.z(1)]) + k.add([k.circle(6), k.pos(sx + 10, GROUND_Y - 11 - s * 20), k.anchor('center'), k.color(safeColor(k,'#222222')), k.opacity(0.4), k.z(1)]) + } + } + } else if (a === 'colosseum' || a === 'sports_arena') { + // Stadium seating tiers + for (let tier = 0; tier < 4; tier++) { + const ty = GROUND_Y * 0.15 + tier * (GROUND_Y * 0.15) + k.add([k.rect(W, GROUND_Y * 0.12), k.pos(0, ty), k.color(safeColor(k,tier % 2 === 0 ? '#1a2a4a' : '#152040')), k.opacity(0.3), k.z(1)]) + } + } else if (a === 'kitchen_stadium') { + // Kitchen equipment silhouettes + // Stove + k.add([k.rect(40, 30), k.pos(W * 0.08, GROUND_Y - 30), k.color(safeColor(k,'#1a1010')), k.opacity(0.4), k.z(1)]) + for (let b = 0; b < 4; b++) { + const flame = k.add([k.circle(4), k.pos(W * 0.08 + 6 + b * 9, GROUND_Y - 32), k.anchor('center'), k.color(safeColor(k,'#ff4400')), k.opacity(0.4), k.z(2)]) + flame.onUpdate(() => { flame.opacity = 0.3 + Math.sin(k.time() * 5 + b) * 0.15 }) + } + // Hanging pots + for (let i = 0; i < 5; i++) { + const px = W * (0.2 + i * 0.15) + k.add([k.rect(1, 15 + Math.random() * 10), k.pos(px, 5), k.color(safeColor(k,'#333333')), k.opacity(0.3), k.z(1)]) + k.add([k.circle(8), k.pos(px, 20 + Math.random() * 10), k.anchor('center'), k.color(safeColor(k,'#444444')), k.opacity(0.25), k.z(1)]) + } + } else if (a === 'junkyard') { + // Piles of scrap + for (let pile = 0; pile < 6; pile++) { + const px = Math.random() * W + const py = GROUND_Y - 10 + for (let j = 0; j < 5; j++) { + k.add([ + k.rect(8 + Math.random() * 15, 5 + Math.random() * 10), + k.pos(px + (Math.random() - 0.5) * 20, py - j * 8 - Math.random() * 5), + k.color(safeColor(k,['#554433', '#665544', '#443322', '#776655', '#332211'][j % 5])), + k.opacity(0.3), k.z(1), k.rotate(Math.random() * 30 - 15), + ]) + } + } + } else if (a === 'meme_dimension') { + // Floating emoji-like shapes + for (let i = 0; i < 8; i++) { + const emoji = k.add([ + k.circle(8 + Math.random() * 5), k.pos(Math.random() * W, Math.random() * GROUND_Y), + k.color(safeColor(k,'#ffcc00')), k.opacity(0.15), k.z(1), + ]) + const bx = emoji.pos.x, by = emoji.pos.y + emoji.onUpdate(() => { + emoji.pos.x = bx + Math.sin(k.time() * 0.4 + i) * 15 + emoji.pos.y = by + Math.cos(k.time() * 0.3 + i) * 10 + }) + } + // Glitch bands + for (let i = 0; i < 5; i++) { + const band = k.add([ + k.rect(W, 2 + Math.random() * 3), k.pos(0, Math.random() * GROUND_Y), + k.color(safeColor(k,Math.random() > 0.5 ? '#ff44ff' : '#44ffff')), k.opacity(0), k.z(2), + ]) + band.onUpdate(() => { band.opacity = Math.random() > 0.97 ? 0.15 : 0 }) + } + } else if (a === 'space_station') { + // Structural beams + for (let i = 0; i < 6; i++) { + const bx = W * (0.15 + i * 0.15) + k.add([k.rect(4, GROUND_Y), k.pos(bx, 0), k.color(safeColor(k,'#1a1a30')), k.opacity(0.3), k.z(1)]) + // Cross beams + k.add([k.rect(W * 0.15, 3), k.pos(bx, GROUND_Y * 0.3), k.color(safeColor(k,'#1a1a30')), k.opacity(0.25), k.z(1)]) + } + // Viewport (window to space) + const vpX = W * 0.5, vpY = GROUND_Y * 0.3 + k.add([k.circle(35), k.pos(vpX, vpY), k.anchor('center'), k.color(safeColor(k,'#000020')), k.opacity(0.5), k.z(0)]) + k.add([k.circle(37), k.pos(vpX, vpY), k.anchor('center'), k.color(safeColor(k,'#2a2a50')), k.opacity(0.3), k.z(0)]) + // Stars through viewport + for (let s = 0; s < 8; s++) { + k.add([ + k.circle(1), k.pos(vpX + (Math.random() - 0.5) * 50, vpY + (Math.random() - 0.5) * 50), + k.color(safeColor(k,'#ffffff')), k.opacity(0.3), k.z(0), + ]) + } + } else if (a === 'savanna') { + // Acacia tree silhouettes + for (const tx of [W * 0.12, W * 0.7]) { + k.add([k.rect(5, 50), k.pos(tx, GROUND_Y - 50), k.color(safeColor(k,'#2a1a08')), k.opacity(0.4), k.z(1)]) + // Flat canopy + k.add([k.rect(40, 5), k.pos(tx - 17, GROUND_Y - 52), k.color(safeColor(k,'#2a3a10')), k.opacity(0.3), k.z(1)]) + k.add([k.rect(30, 4), k.pos(tx - 12, GROUND_Y - 57), k.color(safeColor(k,'#2a3a10')), k.opacity(0.25), k.z(1)]) + } + // Setting sun + k.add([k.circle(25), k.pos(W * 0.85, GROUND_Y * 0.25), k.anchor('center'), k.color(safeColor(k,'#dd6622')), k.opacity(0.25), k.z(0)]) + k.add([k.circle(35), k.pos(W * 0.85, GROUND_Y * 0.25), k.anchor('center'), k.color(safeColor(k,'#dd6622')), k.opacity(0.08), k.z(0)]) + } else if (a === 'server_room') { + // Server rack rows (perspective) + for (let row = 0; row < 3; row++) { + for (let col = 0; col < 6; col++) { + const rx = 30 + col * (W / 6) + const ry = 25 + row * 50 + const rw = W / 6 - 12 + k.add([k.rect(rw, 42), k.pos(rx, ry), k.color(safeColor(k,'#0a1a10')), k.opacity(0.4), k.z(1)]) + // Status LEDs + for (let led = 0; led < 4; led++) { + const ledEl = k.add([ + k.rect(2, 2), k.pos(rx + 3 + led * 5, ry + 3), + k.color(safeColor(k,'#00ff88')), k.opacity(0.5), k.z(2), + ]) + ledEl.onUpdate(() => { ledEl.opacity = Math.random() > 0.04 ? 0.5 : 0.1 }) + } + } + } } // === SPECTATORS — multi-row pixel-art crowd === @@ -1087,7 +1446,7 @@ export async function createFightScene(config: FightSceneConfig) { const torsoH = Math.round(9 * sc) const torso = k.add([ k.rect(torsoW, torsoH), k.pos(sx, sy - torsoH), - k.color(k.Color.fromHex(shirt)), k.opacity(op), k.z(row.z), + k.color(safeColor(k,shirt)), k.opacity(op), k.z(row.z), ]) // Head @@ -1095,7 +1454,7 @@ export async function createFightScene(config: FightSceneConfig) { const head = k.add([ k.circle(headR), k.pos(sx + torsoW / 2, sy - torsoH - headR + 1), k.anchor('center'), - k.color(k.Color.fromHex(skin)), k.opacity(op), k.z(row.z), + k.color(safeColor(k,skin)), k.opacity(op), k.z(row.z), ]) // Hair or hat (70% chance) @@ -1107,7 +1466,7 @@ export async function createFightScene(config: FightSceneConfig) { accessory = k.add([ k.rect(Math.round(10 * sc), Math.round(3 * sc)), k.pos(sx + torsoW / 2 - Math.round(5 * sc), sy - torsoH - headR * 2), - k.color(k.Color.fromHex(hatC)), k.opacity(op), k.z(row.z + 0.1), + k.color(safeColor(k,hatC)), k.opacity(op), k.z(row.z + 0.1), ]) } else { // Hair tuft @@ -1115,7 +1474,7 @@ export async function createFightScene(config: FightSceneConfig) { accessory = k.add([ k.rect(Math.round(6 * sc), Math.round(4 * sc)), k.pos(sx + torsoW / 2 - Math.round(3 * sc), sy - torsoH - headR * 2 + Math.round(sc)), - k.color(k.Color.fromHex(hairC)), k.opacity(op * 0.9), k.z(row.z + 0.1), + k.color(safeColor(k,hairC)), k.opacity(op * 0.9), k.z(row.z + 0.1), ]) } } @@ -1125,11 +1484,11 @@ export async function createFightScene(config: FightSceneConfig) { const armH = Math.round(6 * sc) const armL = k.add([ k.rect(armW, armH), k.pos(sx - armW, sy - torsoH + Math.round(2 * sc)), - k.color(k.Color.fromHex(shirt)), k.opacity(op * 0.9), k.z(row.z - 0.1), + k.color(safeColor(k,shirt)), k.opacity(op * 0.9), k.z(row.z - 0.1), ]) const armR = k.add([ k.rect(armW, armH), k.pos(sx + torsoW, sy - torsoH + Math.round(2 * sc)), - k.color(k.Color.fromHex(shirt)), k.opacity(op * 0.9), k.z(row.z - 0.1), + k.color(safeColor(k,shirt)), k.opacity(op * 0.9), k.z(row.z - 0.1), ]) // Hands (skin-colored dots at arm tips) @@ -1137,13 +1496,13 @@ export async function createFightScene(config: FightSceneConfig) { k.circle(Math.max(1, Math.round(1.5 * sc))), k.pos(sx - armW + Math.round(sc), sy - torsoH + Math.round(2 * sc) + armH), k.anchor('center'), - k.color(k.Color.fromHex(skin)), k.opacity(op * 0.8), k.z(row.z - 0.1), + k.color(safeColor(k,skin)), k.opacity(op * 0.8), k.z(row.z - 0.1), ]) const handR = k.add([ k.circle(Math.max(1, Math.round(1.5 * sc))), k.pos(sx + torsoW + Math.round(sc), sy - torsoH + Math.round(2 * sc) + armH), k.anchor('center'), - k.color(k.Color.fromHex(skin)), k.opacity(op * 0.8), k.z(row.z - 0.1), + k.color(safeColor(k,skin)), k.opacity(op * 0.8), k.z(row.z - 0.1), ]) // Signs (10% of front row spectators) @@ -1156,14 +1515,14 @@ export async function createFightScene(config: FightSceneConfig) { const signC = signColors[Math.floor(Math.random() * signColors.length)] sign = k.add([ k.rect(signW, signH), k.pos(sx + torsoW / 2 - signW / 2, sy - torsoH - headR * 2 - signH - 2), - k.color(k.Color.fromHex(signC)), k.opacity(op * 0.8), k.z(row.z + 0.2), + k.color(safeColor(k,signC)), k.opacity(op * 0.8), k.z(row.z + 0.2), ]) const texts = ['GO!', 'KO!', 'WIN', '!!!', '#1', 'LOL', 'GG', 'WOW', 'BOT'] signText = k.add([ k.text(texts[Math.floor(Math.random() * texts.length)], { size: Math.round(6 * sc) }), k.pos(sx + torsoW / 2, sy - torsoH - headR * 2 - signH / 2 - 2), k.anchor('center'), - k.color(k.Color.fromHex('#111111')), k.opacity(op * 0.7), k.z(row.z + 0.3), + k.color(safeColor(k,'#111111')), k.opacity(op * 0.7), k.z(row.z + 0.3), ]) } @@ -1224,26 +1583,26 @@ export async function createFightScene(config: FightSceneConfig) { const brighten = band * 3 k.add([ k.rect(W, bandH + 1), k.pos(0, band * bandH), - k.color(k.Color.fromHex(bgBase)), + k.color(safeColor(k,bgBase)), k.opacity(1 - band * 0.04), ]) } // Subtle horizon glow — wide soft band just above ground k.add([ k.rect(W, 20), k.pos(0, GROUND_Y - 20), - k.color(k.Color.fromHex(theme.accent)), k.opacity(0.06), + k.color(safeColor(k,theme.accent)), k.opacity(0.06), ]) // === GROUND === // Main ground fill - k.add([k.rect(W, H - GROUND_Y + 2), k.pos(0, GROUND_Y), k.color(k.Color.fromHex(theme.ground))]) + k.add([k.rect(W, H - GROUND_Y + 2), k.pos(0, GROUND_Y), k.color(safeColor(k,theme.ground))]) // Bright edge line at the surface - k.add([k.rect(W, 2), k.pos(0, GROUND_Y), k.color(k.Color.fromHex(theme.accent)), k.opacity(0.7)]) + k.add([k.rect(W, 2), k.pos(0, GROUND_Y), k.color(safeColor(k,theme.accent)), k.opacity(0.7)]) // Secondary highlight - k.add([k.rect(W, 1), k.pos(0, GROUND_Y + 2), k.color(k.Color.fromHex(theme.accent)), k.opacity(0.3)]) + k.add([k.rect(W, 1), k.pos(0, GROUND_Y + 2), k.color(safeColor(k,theme.accent)), k.opacity(0.3)]) // Ground gradient — fades deeper for (let i = 1; i <= 12; i++) { - k.add([k.rect(W, 2), k.pos(0, GROUND_Y + 3 + i * 6), k.color(k.Color.fromHex(theme.accent)), k.opacity(0.1 - i * 0.007)]) + k.add([k.rect(W, 2), k.pos(0, GROUND_Y + 3 + i * 6), k.color(safeColor(k,theme.accent)), k.opacity(0.1 - i * 0.007)]) } // Perspective grid on the floor — converging lines for 3D depth const vanishX = W / 2 @@ -1255,24 +1614,24 @@ export async function createFightScene(config: FightSceneConfig) { const topX = vanishX + dx * 0.3 k.add([ k.rect(1, GROUND_Y * 0.4 + 10), k.pos(x, GROUND_Y), - k.color(k.Color.fromHex(theme.accent)), k.opacity(0.05), + k.color(safeColor(k,theme.accent)), k.opacity(0.05), ]) } // Horizontal depth lines on ground for (let i = 0; i < 6; i++) { const gy = GROUND_Y + 8 + i * i * 3 if (gy < H) { - k.add([k.rect(W, 1), k.pos(0, gy), k.color(k.Color.fromHex(theme.accent)), k.opacity(0.08 - i * 0.012)]) + k.add([k.rect(W, 1), k.pos(0, gy), k.color(safeColor(k,theme.accent)), k.opacity(0.08 - i * 0.012)]) } } // === SIDE PILLARS / FRAME === // Subtle dark pillars on edges for framing - k.add([k.rect(8, GROUND_Y), k.pos(0, 0), k.color(k.Color.fromHex('#000000')), k.opacity(0.3)]) - k.add([k.rect(8, GROUND_Y), k.pos(W - 8, 0), k.color(k.Color.fromHex('#000000')), k.opacity(0.3)]) + k.add([k.rect(8, GROUND_Y), k.pos(0, 0), k.color(safeColor(k,'#000000')), k.opacity(0.3)]) + k.add([k.rect(8, GROUND_Y), k.pos(W - 8, 0), k.color(safeColor(k,'#000000')), k.opacity(0.3)]) // Accent trim on pillars - k.add([k.rect(1, GROUND_Y), k.pos(8, 0), k.color(k.Color.fromHex(theme.accent)), k.opacity(0.15)]) - k.add([k.rect(1, GROUND_Y), k.pos(W - 9, 0), k.color(k.Color.fromHex(theme.accent)), k.opacity(0.15)]) + k.add([k.rect(1, GROUND_Y), k.pos(8, 0), k.color(safeColor(k,theme.accent)), k.opacity(0.15)]) + k.add([k.rect(1, GROUND_Y), k.pos(W - 9, 0), k.color(safeColor(k,theme.accent)), k.opacity(0.15)]) // === AMBIENT PARTICLES === // Floating dust/embers in the air (subtle, always present) @@ -1280,7 +1639,7 @@ export async function createFightScene(config: FightSceneConfig) { const particle = k.add([ k.circle(0.5 + Math.random() * 1.5), k.pos(Math.random() * W, Math.random() * GROUND_Y), - k.color(k.Color.fromHex(theme.accent)), + k.color(safeColor(k,theme.accent)), k.opacity(0.08 + Math.random() * 0.12), k.z(2), ]) @@ -1301,17 +1660,17 @@ export async function createFightScene(config: FightSceneConfig) { // Umpire chair (center-back, behind fighters) const CHAIR_X = W / 2 const CHAIR_SEAT_Y = GROUND_Y * 0.38 - k.add([k.rect(4, GROUND_Y - CHAIR_SEAT_Y + 15), k.pos(CHAIR_X - 22, CHAIR_SEAT_Y - 8), k.color(k.Color.fromHex('#554433')), k.opacity(0.7), k.z(3)]) - k.add([k.rect(4, GROUND_Y - CHAIR_SEAT_Y + 15), k.pos(CHAIR_X + 18, CHAIR_SEAT_Y - 8), k.color(k.Color.fromHex('#554433')), k.opacity(0.7), k.z(3)]) + k.add([k.rect(4, GROUND_Y - CHAIR_SEAT_Y + 15), k.pos(CHAIR_X - 22, CHAIR_SEAT_Y - 8), k.color(safeColor(k,'#554433')), k.opacity(0.7), k.z(3)]) + k.add([k.rect(4, GROUND_Y - CHAIR_SEAT_Y + 15), k.pos(CHAIR_X + 18, CHAIR_SEAT_Y - 8), k.color(safeColor(k,'#554433')), k.opacity(0.7), k.z(3)]) for (let i = 0; i < 6; i++) { const rungY = CHAIR_SEAT_Y + 15 + i * ((GROUND_Y - CHAIR_SEAT_Y - 15) / 6) - k.add([k.rect(44, 3), k.pos(CHAIR_X - 22, rungY), k.color(k.Color.fromHex('#443322')), k.opacity(0.6), k.z(3)]) + k.add([k.rect(44, 3), k.pos(CHAIR_X - 22, rungY), k.color(safeColor(k,'#443322')), k.opacity(0.6), k.z(3)]) } - k.add([k.rect(54, 5), k.pos(CHAIR_X - 27, CHAIR_SEAT_Y - 2), k.color(k.Color.fromHex('#665544')), k.opacity(0.8), k.z(3)]) - k.add([k.rect(54, 1), k.pos(CHAIR_X - 27, CHAIR_SEAT_Y - 2), k.color(k.Color.fromHex(theme.accent)), k.opacity(0.3), k.z(3)]) - k.add([k.rect(48, 4), k.pos(CHAIR_X - 24, CHAIR_SEAT_Y - 10), k.color(k.Color.fromHex('#665544')), k.opacity(0.8), k.z(3)]) - k.add([k.rect(3, 12), k.pos(CHAIR_X - 27, CHAIR_SEAT_Y - 10), k.color(k.Color.fromHex('#554433')), k.opacity(0.7), k.z(3)]) - k.add([k.rect(3, 12), k.pos(CHAIR_X + 24, CHAIR_SEAT_Y - 10), k.color(k.Color.fromHex('#554433')), k.opacity(0.7), k.z(3)]) + k.add([k.rect(54, 5), k.pos(CHAIR_X - 27, CHAIR_SEAT_Y - 2), k.color(safeColor(k,'#665544')), k.opacity(0.8), k.z(3)]) + k.add([k.rect(54, 1), k.pos(CHAIR_X - 27, CHAIR_SEAT_Y - 2), k.color(safeColor(k,theme.accent)), k.opacity(0.3), k.z(3)]) + k.add([k.rect(48, 4), k.pos(CHAIR_X - 24, CHAIR_SEAT_Y - 10), k.color(safeColor(k,'#665544')), k.opacity(0.8), k.z(3)]) + k.add([k.rect(3, 12), k.pos(CHAIR_X - 27, CHAIR_SEAT_Y - 10), k.color(safeColor(k,'#554433')), k.opacity(0.7), k.z(3)]) + k.add([k.rect(3, 12), k.pos(CHAIR_X + 24, CHAIR_SEAT_Y - 10), k.color(safeColor(k,'#554433')), k.opacity(0.7), k.z(3)]) k.add([ k.sprite('judge', { anim: 'idle' }), k.pos(CHAIR_X, CHAIR_SEAT_Y), @@ -1548,28 +1907,36 @@ export async function createFightScene(config: FightSceneConfig) { } async function gunBurst(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { - // Attacker pulls out a "gun" (just plays special anim) atk.play('special') - await k.wait(0.2) - const bulletCount = isCritical ? 8 : 4 - const gunX = atk.pos.x + dir * 25 + // Spawn visible pixel gun prop + const gunX = atk.pos.x + dir * 15 const gunY = atk.pos.y - 40 + const gunDef = PROPS.gun + const gunObjs = spawnProp('gun', gunX, gunY, dir < 0, 18) + await k.wait(0.15) + const bulletCount = isCritical ? 8 : 4 + const muzzleX = gunX + dir * 30 // Fire bullets in rapid succession for (let i = 0; i < bulletCount; i++) { sfxGunshot() + // Muzzle flash + const flash = k.add([k.circle(5), k.pos(muzzleX, gunY), k.color(k.Color.fromHex('#ffee00')), k.opacity(0.9), k.z(19), k.anchor('center')]) + setTimeout(() => { if (flash.exists()) flash.destroy() }, 35) const targetY = def.pos.y - 20 - Math.random() * 50 - spawnBullet(gunX, gunY, def.pos.x, targetY) - // Small recoil + spawnBullet(muzzleX, gunY, def.pos.x, targetY) + // Recoil: bounce gun back + if (gunDef) moveProp(gunObjs, gunDef, gunX - dir * 4, gunY, dir < 0) atk.pos.x -= dir * 3 - await k.wait(0.06) + await k.wait(0.04) + if (gunDef) moveProp(gunObjs, gunDef, gunX, gunY, dir < 0) atk.pos.x += dir * 3 sfxBulletHit() - // Flash defender def.opacity = 0.4 await k.wait(0.02) def.opacity = 1 } - // Show bullet holes on defender side + // Remove gun + destroyProp(gunObjs) spawnBulletHoles(def.pos.x, def.pos.y - 30, isCritical ? 5 : 3) def.play(isCritical ? 'knockback' : 'hit') k.shake(isCritical ? 15 : 8) @@ -1669,7 +2036,7 @@ export async function createFightScene(config: FightSceneConfig) { const line = k.add([ k.rect(W, 2), k.pos(0, lineY), - k.color(k.Color.fromHex(theme.accent)), + k.color(safeColor(k,theme.accent)), k.opacity(0.3), k.z(12), ]) @@ -1704,7 +2071,7 @@ export async function createFightScene(config: FightSceneConfig) { const prop = k.add([ isCircle ? k.circle(w / 2) : k.rect(w, h), k.pos(fromX, fromY), - k.color(k.Color.fromHex(color)), + k.color(safeColor(k,color)), k.opacity(1), k.z(16), k.rotate(Math.atan2(toY - fromY, toX - fromX) * 180 / Math.PI), @@ -1744,7 +2111,7 @@ export async function createFightScene(config: FightSceneConfig) { const club = k.add([ k.rect(8, 60), k.pos(atk.pos.x + dir * 20, atk.pos.y - 60), - k.color(k.Color.fromHex('#888888')), + k.color(safeColor(k,'#888888')), k.anchor('bot'), k.z(18), k.rotate(dir === 1 ? -45 : 45), @@ -1753,7 +2120,7 @@ export async function createFightScene(config: FightSceneConfig) { const clubHead = k.add([ k.rect(18, 12), k.pos(club.pos.x + dir * 15, club.pos.y - 55), - k.color(k.Color.fromHex('#cccccc')), + k.color(safeColor(k,'#cccccc')), k.z(18), ]) atk.play('attack') @@ -1770,7 +2137,7 @@ export async function createFightScene(config: FightSceneConfig) { const ball = k.add([ k.circle(5), k.pos(atk.pos.x + dir * 30, atk.pos.y - 40), - k.color(k.Color.fromHex('#ffffff')), + k.color(safeColor(k,'#ffffff')), k.z(16), ]) sfxPunch() @@ -1811,7 +2178,7 @@ export async function createFightScene(config: FightSceneConfig) { const p = k.add([ k.circle(4 + Math.random() * 6), k.pos(fireX, fireY + (Math.random() - 0.5) * 15), - k.color(k.Color.fromHex(Math.random() > 0.3 ? '#ff6600' : Math.random() > 0.5 ? '#ffcc00' : '#ff2d2d')), + k.color(safeColor(k,Math.random() > 0.3 ? '#ff6600' : Math.random() > 0.5 ? '#ffcc00' : '#ff2d2d')), k.opacity(0.9), k.z(16), ]) @@ -1840,7 +2207,7 @@ export async function createFightScene(config: FightSceneConfig) { k.add([ k.circle(3), k.pos(def.pos.x + (Math.random() - 0.5) * 25, def.pos.y - Math.random() * 60), - k.color(k.Color.fromHex(Math.random() > 0.5 ? '#ff6600' : '#ffcc00')), + k.color(safeColor(k,Math.random() > 0.5 ? '#ff6600' : '#ffcc00')), k.opacity(0.7), k.z(11), ]).onUpdate(function(this: any) { this.pos.y -= 80 * k.dt(); this.opacity -= 2 * k.dt(); if (this.opacity <= 0) this.destroy() }) @@ -1869,7 +2236,7 @@ export async function createFightScene(config: FightSceneConfig) { const q = k.add([ k.circle(8), k.pos(qx, qy), - k.color(k.Color.fromHex('#b83dff')), + k.color(safeColor(k,'#b83dff')), k.opacity(0.9), k.z(16), ]) @@ -1877,7 +2244,7 @@ export async function createFightScene(config: FightSceneConfig) { const dot = k.add([ k.circle(3), k.pos(qx, qy + 12), - k.color(k.Color.fromHex('#ffffff')), + k.color(safeColor(k,'#ffffff')), k.opacity(0.9), k.z(17), ]) @@ -1910,7 +2277,7 @@ export async function createFightScene(config: FightSceneConfig) { const clone = k.add([ k.rect(20, 35), k.pos(atk.pos.x, atk.pos.y - 25), - k.color(k.Color.fromHex(theme.accent)), + k.color(safeColor(k,theme.accent)), k.opacity(0.3), k.z(9), ]) @@ -1963,7 +2330,7 @@ export async function createFightScene(config: FightSceneConfig) { const coin = k.add([ k.circle(5), k.pos(cx, -10), - k.color(k.Color.fromHex(Math.random() > 0.3 ? '#ffd700' : '#ffb000')), + k.color(safeColor(k,Math.random() > 0.3 ? '#ffd700' : '#ffb000')), k.opacity(1), k.z(16), ]) @@ -1994,7 +2361,7 @@ export async function createFightScene(config: FightSceneConfig) { const pen = k.add([ k.rect(6, 80), k.pos(penX, penY), - k.color(k.Color.fromHex('#4488ff')), + k.color(safeColor(k,'#4488ff')), k.anchor('bot'), k.z(18), k.rotate(dir === 1 ? -30 : 30), @@ -2003,7 +2370,7 @@ export async function createFightScene(config: FightSceneConfig) { const tip = k.add([ k.rect(4, 12), k.pos(penX + dir * 5, penY - 68), - k.color(k.Color.fromHex('#333333')), + k.color(safeColor(k,'#333333')), k.z(18), ]) atk.play('special') @@ -2025,7 +2392,7 @@ export async function createFightScene(config: FightSceneConfig) { const splat = k.add([ k.circle(4 + Math.random() * 10), k.pos(def.pos.x + (Math.random() - 0.5) * 60, def.pos.y - Math.random() * 70), - k.color(k.Color.fromHex(inkColors[Math.floor(Math.random() * inkColors.length)])), + k.color(safeColor(k,inkColors[Math.floor(Math.random() * inkColors.length)])), k.opacity(0.8), k.z(9), ]) @@ -2060,7 +2427,7 @@ export async function createFightScene(config: FightSceneConfig) { const num = k.add([ k.circle(size), k.pos(atk.pos.x + dir * 25, fromY), - k.color(k.Color.fromHex(colors[i % colors.length])), + k.color(safeColor(k,colors[i % colors.length])), k.opacity(1), k.z(16), ]) @@ -2097,7 +2464,7 @@ export async function createFightScene(config: FightSceneConfig) { const card = k.add([ k.rect(12, 18), k.pos(atk.pos.x + dir * 20, atk.pos.y - 40 + (Math.random() - 0.5) * 20), - k.color(k.Color.fromHex(i === 0 ? '#ff2d7b' : '#ffffff')), + k.color(safeColor(k,i === 0 ? '#ff2d7b' : '#ffffff')), k.anchor('center'), k.z(16), k.rotate(0), @@ -2123,13 +2490,13 @@ export async function createFightScene(config: FightSceneConfig) { const trap = k.add([ k.rect(30, 8), k.pos(def.pos.x - 15, GROUND_Y - 8), - k.color(k.Color.fromHex('#888888')), + k.color(safeColor(k,'#888888')), k.z(8), ]) const jaw1 = k.add([ k.rect(30, 4), k.pos(def.pos.x - 15, GROUND_Y - 16), - k.color(k.Color.fromHex('#aaaaaa')), + k.color(safeColor(k,'#aaaaaa')), k.z(8), ]) sfxBonk() @@ -2162,7 +2529,7 @@ export async function createFightScene(config: FightSceneConfig) { const ghost = k.add([ k.rect(20, 35), k.pos(atk.pos.x, atk.pos.y - 25), - k.color(k.Color.fromHex(theme.accent)), + k.color(safeColor(k,theme.accent)), k.opacity(0.4), k.z(9), ]) @@ -2182,7 +2549,7 @@ export async function createFightScene(config: FightSceneConfig) { const line = k.add([ k.rect(W * 0.4, 2), k.pos(dir === 1 ? origAX : origDX, lineY), - k.color(k.Color.fromHex(theme.accent)), + k.color(safeColor(k,theme.accent)), k.opacity(0.5), k.z(12), ]) @@ -2210,7 +2577,7 @@ export async function createFightScene(config: FightSceneConfig) { const line = k.add([ k.rect(W, 2 + Math.random() * 2), k.pos(0, lineY), - k.color(k.Color.fromHex(Math.random() > 0.5 ? '#ffffff' : theme.accent)), + k.color(safeColor(k,Math.random() > 0.5 ? '#ffffff' : theme.accent)), k.opacity(0.6), k.z(25), ]) @@ -2347,7 +2714,7 @@ export async function createFightScene(config: FightSceneConfig) { const line = k.add([ k.rect(W * 0.5, 2), k.pos(dir === 1 ? origAX - 50 : origDX + 50, lineY), - k.color(k.Color.fromHex(colors[i % colors.length])), + k.color(safeColor(k,colors[i % colors.length])), k.opacity(0.4), k.z(12), ]) @@ -2405,7 +2772,7 @@ export async function createFightScene(config: FightSceneConfig) { const ax = contactX + dir * 20 + Math.cos(angle) * 60 const ay = atk.pos.y - 40 + Math.sin(angle) * 60 const part = k.add([ - k.rect(25, 3), k.pos(ax, ay), k.color(k.Color.fromHex(isCritical ? '#00f0ff' : '#aaddff')), + k.rect(25, 3), k.pos(ax, ay), k.color(safeColor(k,isCritical ? '#00f0ff' : '#aaddff')), k.opacity(0.9), k.z(18), k.rotate(angle * 180 / Math.PI + 90), ]) arcParts.push(part) @@ -2432,8 +2799,8 @@ export async function createFightScene(config: FightSceneConfig) { const contactX = origDX - dir * 40 await k.tween(atk.pos.x, contactX, 0.12, (v) => { atk.pos.x = v }, k.easings.easeOutQuad) // Giant hammer - const handle = k.add([k.rect(6, 70), k.pos(atk.pos.x + dir * 15, atk.pos.y - 90), k.color(k.Color.fromHex('#8B4513')), k.anchor('bot'), k.z(18), k.rotate(dir === 1 ? -60 : 60)]) - const head = k.add([k.rect(30, 20), k.pos(handle.pos.x + dir * 10, handle.pos.y - 60), k.color(k.Color.fromHex('#888888')), k.z(18)]) + const handle = k.add([k.rect(6, 70), k.pos(atk.pos.x + dir * 15, atk.pos.y - 90), k.color(safeColor(k,'#8B4513')), k.anchor('bot'), k.z(18), k.rotate(dir === 1 ? -60 : 60)]) + const head = k.add([k.rect(30, 20), k.pos(handle.pos.x + dir * 10, handle.pos.y - 60), k.color(safeColor(k,'#888888')), k.z(18)]) atk.play('special') sfxSpecial() // Swing hammer down @@ -2442,7 +2809,7 @@ export async function createFightScene(config: FightSceneConfig) { sfxExplosion() // Ground crack effect for (let i = 0; i < 6; i++) { - const crack = k.add([k.rect(2, 15 + Math.random() * 20), k.pos(def.pos.x + (i - 3) * 12, GROUND_Y - 5), k.color(k.Color.fromHex('#ff6600')), k.opacity(0.8), k.z(4)]) + const crack = k.add([k.rect(2, 15 + Math.random() * 20), k.pos(def.pos.x + (i - 3) * 12, GROUND_Y - 5), k.color(safeColor(k,'#ff6600')), k.opacity(0.8), k.z(4)]) setTimeout(() => { k.tween(0.8, 0, 0.6, (v) => { if (crack.exists()) crack.opacity = v }).then(() => { if (crack.exists()) crack.destroy() }) }, 400) } def.play(isCritical ? 'knockback' : 'hit') @@ -2466,15 +2833,15 @@ export async function createFightScene(config: FightSceneConfig) { // Charge up (growing ball of energy) const chargeX = atk.pos.x + dir * 25 const chargeY = atk.pos.y - 40 - const charge = k.add([k.circle(3), k.pos(chargeX, chargeY), k.color(k.Color.fromHex('#ff2d2d')), k.opacity(0.9), k.z(18)]) + const charge = k.add([k.circle(3), k.pos(chargeX, chargeY), k.color(safeColor(k,'#ff2d2d')), k.opacity(0.9), k.z(18)]) await k.tween(3, isCritical ? 18 : 12, 0.3, (v) => { charge.radius = v }, k.easings.easeOutQuad) sfxZap() // FIRE BEAM (long rect that stretches across) charge.destroy() const beamLen = Math.abs(def.pos.x - chargeX) + 30 - const beam = k.add([k.rect(beamLen, isCritical ? 12 : 7), k.pos(chargeX, chargeY), k.color(k.Color.fromHex(isCritical ? '#ff2d2d' : '#ff6600')), k.opacity(0.9), k.z(18)]) + const beam = k.add([k.rect(beamLen, isCritical ? 12 : 7), k.pos(chargeX, chargeY), k.color(safeColor(k,isCritical ? '#ff2d2d' : '#ff6600')), k.opacity(0.9), k.z(18)]) // Core beam (brighter, thinner) - const core = k.add([k.rect(beamLen, isCritical ? 5 : 3), k.pos(chargeX, chargeY + 2), k.color(k.Color.fromHex('#ffffff')), k.opacity(0.8), k.z(19)]) + const core = k.add([k.rect(beamLen, isCritical ? 5 : 3), k.pos(chargeX, chargeY + 2), k.color(safeColor(k,'#ffffff')), k.opacity(0.8), k.z(19)]) k.shake(isCritical ? 12 : 6) screenFlash(isCritical ? '#ff2d2d' : '#ff6600', 0.15) def.play(isCritical ? 'knockback' : 'hit') @@ -2498,12 +2865,12 @@ export async function createFightScene(config: FightSceneConfig) { // Rocket body const rocketX = atk.pos.x + dir * 30 const rocketY = atk.pos.y - 35 - const rocket = k.add([k.rect(20, 8), k.pos(rocketX, rocketY), k.color(k.Color.fromHex('#888888')), k.z(17), k.rotate(dir === 1 ? 0 : 180)]) - const nose = k.add([k.rect(6, 6), k.pos(rocketX + dir * 12, rocketY + 1), k.color(k.Color.fromHex('#ff2d2d')), k.z(17)]) + const rocket = k.add([k.rect(20, 8), k.pos(rocketX, rocketY), k.color(safeColor(k,'#888888')), k.z(17), k.rotate(dir === 1 ? 0 : 180)]) + const nose = k.add([k.rect(6, 6), k.pos(rocketX + dir * 12, rocketY + 1), k.color(safeColor(k,'#ff2d2d')), k.z(17)]) sfxJetpack() // Trail exhaust as rocket flies const exhaustInt = setInterval(() => { - const p = k.add([k.circle(3 + Math.random() * 4), k.pos(rocket.pos.x - dir * 12, rocket.pos.y + (Math.random() - 0.5) * 8), k.color(k.Color.fromHex(Math.random() > 0.5 ? '#ff6600' : '#ffcc00')), k.opacity(0.7), k.z(16)]) + const p = k.add([k.circle(3 + Math.random() * 4), k.pos(rocket.pos.x - dir * 12, rocket.pos.y + (Math.random() - 0.5) * 8), k.color(safeColor(k,Math.random() > 0.5 ? '#ff6600' : '#ffcc00')), k.opacity(0.7), k.z(16)]) p.onUpdate(() => { p.pos.x -= dir * 100 * k.dt(); p.opacity -= 3 * k.dt(); if (p.opacity <= 0) p.destroy() }) }, 30) // Fly rocket to target @@ -2540,8 +2907,8 @@ export async function createFightScene(config: FightSceneConfig) { sfxSpecial() const bombX = atk.pos.x + dir * 20 const bombY = atk.pos.y - 50 - const bomb = k.add([k.circle(8), k.pos(bombX, bombY), k.color(k.Color.fromHex('#333333')), k.z(17)]) - const fuse = k.add([k.rect(2, 8), k.pos(bombX, bombY - 8), k.color(k.Color.fromHex('#ff6600')), k.z(17)]) + const bomb = k.add([k.circle(8), k.pos(bombX, bombY), k.color(safeColor(k,'#333333')), k.z(17)]) + const fuse = k.add([k.rect(2, 8), k.pos(bombX, bombY - 8), k.color(safeColor(k,'#ff6600')), k.z(17)]) // Arc to opponent const midX = (bombX + def.pos.x) / 2 await k.tween(0, 1, 0.35, (t) => { @@ -2572,31 +2939,38 @@ export async function createFightScene(config: FightSceneConfig) { // MINIGUN SPRAY: sustained automatic fire with shell casings async function minigunSpray(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { atk.play('special') + // Spawn visible pixel minigun + const gpx = atk.pos.x + dir * 15 + const gpy = atk.pos.y - 38 + const mgDef = PROPS.minigun + const mgObjs = spawnProp('minigun', gpx, gpy, dir < 0, 18) await k.wait(0.1) - // Spin-up sound sfxSpecial() const bulletCount = isCritical ? 16 : 10 - const gunX = atk.pos.x + dir * 25 - const gunY = atk.pos.y - 38 + const muzzleX = gpx + dir * 30 for (let i = 0; i < bulletCount; i++) { sfxGunshot() - // Bullet + // Muzzle flash + const flash = k.add([k.circle(4 + Math.random() * 4), k.pos(muzzleX, gpy + (Math.random() - 0.5) * 6), k.color(k.Color.fromHex('#ffee00')), k.opacity(0.9), k.z(19), k.anchor('center')]) + setTimeout(() => { if (flash.exists()) flash.destroy() }, 30) const spread = (Math.random() - 0.5) * 50 - spawnBullet(gunX, gunY, def.pos.x + (Math.random() - 0.5) * 30, def.pos.y - 20 + spread) - // Shell casing ejection (tiny gold rect flying up) - const casing = k.add([k.rect(3, 2), k.pos(gunX - dir * 5, gunY - 5), k.color(k.Color.fromHex('#ffd700')), k.opacity(0.8), k.z(16)]) + spawnBullet(muzzleX, gpy, def.pos.x + (Math.random() - 0.5) * 30, def.pos.y - 20 + spread) + // Shell casing + const casing = k.add([k.rect(3, 2), k.pos(gpx - dir * 5, gpy - 5), k.color(k.Color.fromHex('#ffd700')), k.opacity(0.8), k.z(16)]) const casVx = -dir * (100 + Math.random() * 100) const casVy = -200 - Math.random() * 100 casing.onUpdate(() => { casing.pos.x += casVx * k.dt(); casing.pos.y += casVy * k.dt() + 500 * k.dt() * k.dt(); casing.opacity -= 1.5 * k.dt(); if (casing.opacity <= 0) casing.destroy() }) - // Recoil shake + // Recoil gun + attacker + if (mgDef) moveProp(mgObjs, mgDef, gpx - dir * 3, gpy, dir < 0) atk.pos.x -= dir * 2 await k.wait(0.01) + if (mgDef) moveProp(mgObjs, mgDef, gpx, gpy, dir < 0) atk.pos.x += dir * 2 - // Flash defender if (i % 2 === 0) { def.opacity = 0.3; await k.wait(0.01); def.opacity = 1 } sfxBulletHit() await k.wait(0.04) } + destroyProp(mgObjs) spawnBulletHoles(def.pos.x, def.pos.y - 30, isCritical ? 8 : 5) def.play(isCritical ? 'knockback' : 'hit') k.shake(isCritical ? 18 : 10) @@ -2616,7 +2990,7 @@ export async function createFightScene(config: FightSceneConfig) { atk.play('special') // Laser sight line const laserY = atk.pos.y - 38 - const laser = k.add([k.rect(Math.abs(def.pos.x - wallX), 1), k.pos(Math.min(wallX, def.pos.x), laserY), k.color(k.Color.fromHex('#ff0000')), k.opacity(0.5), k.z(15)]) + const laser = k.add([k.rect(Math.abs(def.pos.x - wallX), 1), k.pos(Math.min(wallX, def.pos.x), laserY), k.color(safeColor(k,'#ff0000')), k.opacity(0.5), k.z(15)]) // Wobble the laser for "aiming" for (let i = 0; i < 8; i++) { laser.pos.y = laserY + (Math.random() - 0.5) * 15 @@ -2630,7 +3004,7 @@ export async function createFightScene(config: FightSceneConfig) { sfxCritical() screenFlash('#ffffff', 0.1) // Tracer (bright line from attacker to defender) - const tracer = k.add([k.rect(Math.abs(def.pos.x - wallX), 3), k.pos(Math.min(wallX, def.pos.x), def.pos.y - 35), k.color(k.Color.fromHex('#ffee00')), k.opacity(0.9), k.z(18)]) + const tracer = k.add([k.rect(Math.abs(def.pos.x - wallX), 3), k.pos(Math.min(wallX, def.pos.x), def.pos.y - 35), k.color(safeColor(k,'#ffee00')), k.opacity(0.9), k.z(18)]) k.tween(0.9, 0, 0.1, (v) => { tracer.opacity = v }).then(() => tracer.destroy()) // Impact def.play('knockback') @@ -2662,7 +3036,7 @@ export async function createFightScene(config: FightSceneConfig) { const t = i / segCount const sx = fromX + (toX - fromX) * t const sy = fromY + (toY - fromY) * t + Math.sin(t * Math.PI * 3) * 20 - const seg = k.add([k.rect(6, 3), k.pos(sx, sy), k.color(k.Color.fromHex('#8B4513')), k.opacity(0), k.z(17)]) + const seg = k.add([k.rect(6, 3), k.pos(sx, sy), k.color(safeColor(k,'#8B4513')), k.opacity(0), k.z(17)]) segments.push(seg) } // Animate whip extending (each segment appears in sequence) @@ -2700,7 +3074,7 @@ export async function createFightScene(config: FightSceneConfig) { sfxPunch() // Slash trail (diagonal line) const angle = [45, -45, 0, 135, -135][i % 5] - const slash = k.add([k.rect(50, 3), k.pos(def.pos.x, def.pos.y - 30), k.color(k.Color.fromHex('#aaddff')), k.opacity(0.9), k.z(18), k.rotate(angle), k.anchor('center')]) + const slash = k.add([k.rect(50, 3), k.pos(def.pos.x, def.pos.y - 30), k.color(safeColor(k,'#aaddff')), k.opacity(0.9), k.z(18), k.rotate(angle), k.anchor('center')]) k.tween(0.9, 0, 0.15, (v) => { if (slash.exists()) slash.opacity = v }).then(() => { if (slash.exists()) slash.destroy() }) def.play('hit') k.shake(4 + i * 2) @@ -2709,8 +3083,8 @@ export async function createFightScene(config: FightSceneConfig) { } // Final slash — big X mark if (isCritical) { - const s1 = k.add([k.rect(80, 4), k.pos(def.pos.x, def.pos.y - 30), k.color(k.Color.fromHex('#00f0ff')), k.opacity(0.9), k.z(18), k.rotate(45), k.anchor('center')]) - const s2 = k.add([k.rect(80, 4), k.pos(def.pos.x, def.pos.y - 30), k.color(k.Color.fromHex('#00f0ff')), k.opacity(0.9), k.z(18), k.rotate(-45), k.anchor('center')]) + const s1 = k.add([k.rect(80, 4), k.pos(def.pos.x, def.pos.y - 30), k.color(safeColor(k,'#00f0ff')), k.opacity(0.9), k.z(18), k.rotate(45), k.anchor('center')]) + const s2 = k.add([k.rect(80, 4), k.pos(def.pos.x, def.pos.y - 30), k.color(safeColor(k,'#00f0ff')), k.opacity(0.9), k.z(18), k.rotate(-45), k.anchor('center')]) sfxCritical() screenFlash('#00f0ff') k.shake(20) @@ -2732,8 +3106,8 @@ export async function createFightScene(config: FightSceneConfig) { const contactX = origDX - dir * 35 atk.play('special') // Chainsaw buzzing (visual: vibrating rect) - const saw = k.add([k.rect(40, 8), k.pos(atk.pos.x + dir * 25, atk.pos.y - 35), k.color(k.Color.fromHex('#888888')), k.z(18)]) - const blade = k.add([k.rect(35, 3), k.pos(atk.pos.x + dir * 30, atk.pos.y - 32), k.color(k.Color.fromHex('#ffcc00')), k.z(19)]) + const saw = k.add([k.rect(40, 8), k.pos(atk.pos.x + dir * 25, atk.pos.y - 35), k.color(safeColor(k,'#888888')), k.z(18)]) + const blade = k.add([k.rect(35, 3), k.pos(atk.pos.x + dir * 30, atk.pos.y - 32), k.color(safeColor(k,'#ffcc00')), k.z(19)]) sfxSpecial() // Rev up (shake the saw) for (let i = 0; i < 8; i++) { @@ -2778,13 +3152,13 @@ export async function createFightScene(config: FightSceneConfig) { atk.pos.x = startX // Build the bike: body + wheels const bikeY = GROUND_Y - 15 - const bike = k.add([k.rect(50, 18), k.pos(startX, bikeY), k.color(k.Color.fromHex(isCritical ? '#ff2d2d' : '#4488ff')), k.z(16)]) - const wheelF = k.add([k.circle(8), k.pos(startX + dir * 18, bikeY + 12), k.color(k.Color.fromHex('#333333')), k.z(16)]) - const wheelR = k.add([k.circle(8), k.pos(startX - dir * 16, bikeY + 12), k.color(k.Color.fromHex('#333333')), k.z(16)]) + const bike = k.add([k.rect(50, 18), k.pos(startX, bikeY), k.color(safeColor(k,isCritical ? '#ff2d2d' : '#4488ff')), k.z(16)]) + const wheelF = k.add([k.circle(8), k.pos(startX + dir * 18, bikeY + 12), k.color(safeColor(k,'#333333')), k.z(16)]) + const wheelR = k.add([k.circle(8), k.pos(startX - dir * 16, bikeY + 12), k.color(safeColor(k,'#333333')), k.z(16)]) sfxJetpack() // Tire marks + exhaust as it zooms across const exh = setInterval(() => { - const p = k.add([k.circle(3), k.pos(bike.pos.x - dir * 25, bikeY + 5), k.color(k.Color.fromHex('#888888')), k.opacity(0.5), k.z(4)]) + const p = k.add([k.circle(3), k.pos(bike.pos.x - dir * 25, bikeY + 5), k.color(safeColor(k,'#888888')), k.opacity(0.5), k.z(4)]) p.onUpdate(() => { p.opacity -= 2 * k.dt(); if (p.opacity <= 0) p.destroy() }) }, 30) // ZOOM across screen @@ -2824,14 +3198,14 @@ export async function createFightScene(config: FightSceneConfig) { const carY = GROUND_Y - 20 const startX = dir === 1 ? -60 : W + 60 // Car body - const car = k.add([k.rect(70, 25), k.pos(startX, carY), k.color(k.Color.fromHex(isCritical ? '#ffd700' : '#ff6600')), k.z(16)]) + const car = k.add([k.rect(70, 25), k.pos(startX, carY), k.color(safeColor(k,isCritical ? '#ffd700' : '#ff6600')), k.z(16)]) // Roof - const roof = k.add([k.rect(35, 15), k.pos(startX + dir * 8, carY - 15), k.color(k.Color.fromHex(isCritical ? '#ccaa00' : '#cc5500')), k.z(16)]) + const roof = k.add([k.rect(35, 15), k.pos(startX + dir * 8, carY - 15), k.color(safeColor(k,isCritical ? '#ccaa00' : '#cc5500')), k.z(16)]) // Wheels - const w1 = k.add([k.circle(8), k.pos(startX + dir * 22, carY + 18), k.color(k.Color.fromHex('#222222')), k.z(16)]) - const w2 = k.add([k.circle(8), k.pos(startX - dir * 22, carY + 18), k.color(k.Color.fromHex('#222222')), k.z(16)]) + const w1 = k.add([k.circle(8), k.pos(startX + dir * 22, carY + 18), k.color(safeColor(k,'#222222')), k.z(16)]) + const w2 = k.add([k.circle(8), k.pos(startX - dir * 22, carY + 18), k.color(safeColor(k,'#222222')), k.z(16)]) // Headlight - const hl = k.add([k.rect(4, 6), k.pos(startX + dir * 35, carY + 3), k.color(k.Color.fromHex('#ffee00')), k.z(17)]) + const hl = k.add([k.rect(4, 6), k.pos(startX + dir * 35, carY + 3), k.color(safeColor(k,'#ffee00')), k.z(17)]) sfxJetpack() sfxSpecial() // DRIVE ACROSS @@ -2870,9 +3244,9 @@ export async function createFightScene(config: FightSceneConfig) { // Build boat const boatX = atk.pos.x const boatY = GROUND_Y - 10 - const hull = k.add([k.rect(60, 20), k.pos(boatX, boatY), k.color(k.Color.fromHex('#8B4513')), k.z(8)]) - const mast = k.add([k.rect(3, 40), k.pos(boatX + 10, boatY - 40), k.color(k.Color.fromHex('#8B4513')), k.z(8)]) - const sail = k.add([k.rect(25, 30), k.pos(boatX + 15, boatY - 45), k.color(k.Color.fromHex('#ffffff')), k.opacity(0.8), k.z(8)]) + const hull = k.add([k.rect(60, 20), k.pos(boatX, boatY), k.color(safeColor(k,'#8B4513')), k.z(8)]) + const mast = k.add([k.rect(3, 40), k.pos(boatX + 10, boatY - 40), k.color(safeColor(k,'#8B4513')), k.z(8)]) + const sail = k.add([k.rect(25, 30), k.pos(boatX + 15, boatY - 45), k.color(safeColor(k,'#ffffff')), k.opacity(0.8), k.z(8)]) // Slide boat forward sfxJetpack() const moveX = (origAX + origDX) / 2 @@ -2912,11 +3286,11 @@ export async function createFightScene(config: FightSceneConfig) { sfxSpecial() await k.wait(0.15) // Shadow on ground growing - const shadow = k.add([k.circle(5), k.pos(def.pos.x, GROUND_Y - 2), k.color(k.Color.fromHex('#000000')), k.opacity(0.3), k.z(4)]) + const shadow = k.add([k.circle(5), k.pos(def.pos.x, GROUND_Y - 2), k.color(safeColor(k,'#000000')), k.opacity(0.3), k.z(4)]) k.tween(5, 25, 0.3, (v) => { shadow.radius = v }) // Anvil falls from sky - const anvil = k.add([k.rect(35, 25), k.pos(def.pos.x - 17, -30), k.color(k.Color.fromHex('#555555')), k.opacity(0.9), k.z(18)]) - const base = k.add([k.rect(45, 8), k.pos(def.pos.x - 22, -8), k.color(k.Color.fromHex('#444444')), k.opacity(0.9), k.z(18)]) + const anvil = k.add([k.rect(35, 25), k.pos(def.pos.x - 17, -30), k.color(safeColor(k,'#555555')), k.opacity(0.9), k.z(18)]) + const base = k.add([k.rect(45, 8), k.pos(def.pos.x - 22, -8), k.color(safeColor(k,'#444444')), k.opacity(0.9), k.z(18)]) sfxSlideDown() await k.tween(-30, def.pos.y - 50, 0.25, (v) => { anvil.pos.y = v; base.pos.y = v + 22 }, k.easings.easeInQuad) // BONK! @@ -2955,17 +3329,17 @@ export async function createFightScene(config: FightSceneConfig) { // Barrel const barrel = k.add([ k.rect(gunLen, gunH), k.pos(gx, gy), - k.color(k.Color.fromHex('#333333')), k.opacity(1), k.z(16), k.scale(1), + k.color(safeColor(k,'#333333')), k.opacity(1), k.z(16), k.scale(1), ]) // Handle const handle = k.add([ k.rect(12, 20), k.pos(gx - dir * 5, gy + gunH / 2), - k.color(k.Color.fromHex('#555555')), k.opacity(1), k.z(15), k.scale(1), + k.color(safeColor(k,'#555555')), k.opacity(1), k.z(15), k.scale(1), ]) // Scope on top const scope = k.add([ k.circle(5), k.pos(gx + dir * gunLen * 0.6, gy - 6), - k.color(k.Color.fromHex('#880000')), k.opacity(0.9), k.z(17), k.scale(1), + k.color(safeColor(k,'#880000')), k.opacity(0.9), k.z(17), k.scale(1), ]) // Gun grows in from small — comedy "pulling from tiny pocket" effect @@ -3000,7 +3374,7 @@ export async function createFightScene(config: FightSceneConfig) { const flash = k.add([ k.circle(12 + Math.random() * 8), k.pos(gx + dir * gunLen, gy), - k.color(k.Color.fromHex('#ffee00')), + k.color(safeColor(k,'#ffee00')), k.opacity(0.9), k.z(18), ]) setTimeout(() => { if (flash.exists()) flash.destroy() }, 60) @@ -3250,12 +3624,259 @@ export async function createFightScene(config: FightSceneConfig) { ]) } + // ============================================================ + // PIXEL PROP SYSTEM — draw recognizable objects from shapes + // ============================================================ + + type PropDef = { parts: { shape: 'rect' | 'circle'; x: number; y: number; w: number; h: number; color: string }[] } + + const PROPS: Record = { + // Food + pizza: { parts: [ + { shape: 'rect', x: -7, y: -3, w: 14, h: 3, color: '#ffcc44' }, // crust + { shape: 'rect', x: -6, y: 0, w: 12, h: 8, color: '#ff8800' }, // cheese + { shape: 'rect', x: -5, y: 1, w: 10, h: 6, color: '#cc4400' }, // sauce + { shape: 'circle', x: -3, y: 3, w: 3, h: 3, color: '#aa2200' }, // pepperoni + { shape: 'circle', x: 2, y: 4, w: 3, h: 3, color: '#aa2200' }, // pepperoni + { shape: 'circle', x: 0, y: 1, w: 2, h: 2, color: '#44aa22' }, // olive + ]}, + burger: { parts: [ + { shape: 'rect', x: -8, y: -4, w: 16, h: 4, color: '#cc8833' }, // top bun + { shape: 'rect', x: -7, y: 0, w: 14, h: 3, color: '#44aa22' }, // lettuce + { shape: 'rect', x: -7, y: 3, w: 14, h: 4, color: '#883311' }, // patty + { shape: 'rect', x: -8, y: 7, w: 16, h: 3, color: '#cc8833' }, // bottom bun + { shape: 'rect', x: -7, y: -1, w: 14, h: 1, color: '#ffcc00' }, // cheese + ]}, + hotdog: { parts: [ + { shape: 'rect', x: -10, y: -2, w: 20, h: 4, color: '#ffcc66' }, // bun + { shape: 'rect', x: -9, y: -1, w: 18, h: 3, color: '#cc4422' }, // sausage + { shape: 'rect', x: -8, y: -2, w: 16, h: 1, color: '#ffee00' }, // mustard + ]}, + banana: { parts: [ + { shape: 'rect', x: -3, y: -8, w: 5, h: 16, color: '#ffe135' }, + { shape: 'rect', x: -2, y: -9, w: 3, h: 2, color: '#88660d' }, // stem + { shape: 'rect', x: -2, y: -7, w: 3, h: 14, color: '#ffee55' }, // highlight + ]}, + pie: { parts: [ + { shape: 'circle', x: 0, y: 0, w: 16, h: 16, color: '#f5deb3' }, // crust + { shape: 'circle', x: 0, y: 0, w: 12, h: 12, color: '#ffffff' }, // cream + { shape: 'circle', x: -2, y: -2, w: 3, h: 3, color: '#ff4444' }, // cherry + ]}, + // Weapons + gun: { parts: [ + { shape: 'rect', x: 0, y: -3, w: 24, h: 6, color: '#444444' }, // barrel + { shape: 'rect', x: -4, y: -5, w: 12, h: 10, color: '#333333' }, // body + { shape: 'rect', x: -2, y: 5, w: 6, h: 10, color: '#553322' }, // grip + { shape: 'rect', x: 22, y: -2, w: 4, h: 4, color: '#555555' }, // muzzle + { shape: 'rect', x: 6, y: -6, w: 8, h: 3, color: '#555555' }, // sight + ]}, + shotgun: { parts: [ + { shape: 'rect', x: 0, y: -2, w: 35, h: 5, color: '#333333' }, // barrel + { shape: 'rect', x: 0, y: 3, w: 30, h: 4, color: '#333333' }, // under barrel + { shape: 'rect', x: -6, y: -4, w: 10, h: 12, color: '#553322' }, // stock + { shape: 'rect', x: -2, y: 7, w: 6, h: 8, color: '#664433' }, // grip + { shape: 'rect', x: 33, y: -1, w: 4, h: 3, color: '#555555' }, // muzzle + ]}, + rocketLauncher: { parts: [ + { shape: 'rect', x: 0, y: -5, w: 30, h: 10, color: '#446633' }, // tube + { shape: 'circle', x: 28, y: 0, w: 10, h: 10, color: '#335522' }, // end + { shape: 'circle', x: 2, y: 0, w: 10, h: 10, color: '#335522' }, // back end + { shape: 'rect', x: 8, y: 5, w: 6, h: 8, color: '#553322' }, // grip + { shape: 'rect', x: 14, y: -8, w: 6, h: 4, color: '#555555' }, // sight + ]}, + minigun: { parts: [ + { shape: 'rect', x: 0, y: -6, w: 28, h: 3, color: '#444444' }, // barrel 1 + { shape: 'rect', x: 0, y: -2, w: 28, h: 3, color: '#555555' }, // barrel 2 + { shape: 'rect', x: 0, y: 2, w: 28, h: 3, color: '#444444' }, // barrel 3 + { shape: 'rect', x: -8, y: -8, w: 10, h: 16, color: '#333333' }, // housing + { shape: 'rect', x: -4, y: 5, w: 6, h: 10, color: '#553322' }, // grip + { shape: 'circle', x: 27, y: -2, w: 8, h: 8, color: '#666666' }, // muzzle ring + ]}, + sniper: { parts: [ + { shape: 'rect', x: 0, y: -1, w: 38, h: 3, color: '#333333' }, // barrel + { shape: 'rect', x: -6, y: -4, w: 14, h: 8, color: '#444444' }, // body + { shape: 'rect', x: -10, y: -3, w: 8, h: 4, color: '#553322' }, // stock + { shape: 'circle', x: 12, y: -6, w: 6, h: 6, color: '#225588' }, // scope + { shape: 'rect', x: 8, y: -7, w: 10, h: 2, color: '#555555' }, // scope mount + ]}, + katana: { parts: [ + { shape: 'rect', x: 0, y: -1, w: 40, h: 2, color: '#ccccdd' }, // blade + { shape: 'rect', x: 38, y: -1, w: 4, h: 2, color: '#ffffff' }, // tip + { shape: 'rect', x: -2, y: -3, w: 4, h: 6, color: '#ffcc00' }, // guard + { shape: 'rect', x: -10, y: -2, w: 10, h: 4, color: '#442211' }, // handle + { shape: 'rect', x: -8, y: -2, w: 2, h: 4, color: '#ddbb88' }, // wrap + { shape: 'rect', x: -5, y: -2, w: 2, h: 4, color: '#ddbb88' }, // wrap + ]}, + // Vehicles (used as props in throws/drops) + car_mini: { parts: [ + { shape: 'rect', x: -12, y: -4, w: 24, h: 8, color: '#ff4444' }, // body + { shape: 'rect', x: -6, y: -8, w: 12, h: 5, color: '#4488ff' }, // roof/windows + { shape: 'circle', x: -8, y: 4, w: 6, h: 6, color: '#222222' }, // wheel + { shape: 'circle', x: 8, y: 4, w: 6, h: 6, color: '#222222' }, // wheel + { shape: 'rect', x: 11, y: -2, w: 3, h: 3, color: '#ffee00' }, // headlight + ]}, + tank_mini: { parts: [ + { shape: 'rect', x: -14, y: 0, w: 28, h: 8, color: '#556633' }, // hull + { shape: 'rect', x: -8, y: -6, w: 16, h: 7, color: '#445522' }, // turret + { shape: 'rect', x: 6, y: -5, w: 16, h: 3, color: '#333322' }, // barrel + { shape: 'rect', x: -13, y: 7, w: 26, h: 4, color: '#333333' }, // tracks + { shape: 'circle', x: -10, y: 9, w: 4, h: 4, color: '#444444' }, // wheel + { shape: 'circle', x: 10, y: 9, w: 4, h: 4, color: '#444444' }, // wheel + ]}, + // Misc props + bomb: { parts: [ + { shape: 'circle', x: 0, y: 0, w: 14, h: 14, color: '#222222' }, + { shape: 'rect', x: -1, y: -9, w: 3, h: 5, color: '#888888' }, // fuse + { shape: 'circle', x: 0, y: -10, w: 4, h: 4, color: '#ff6600' }, // spark + ]}, + shield: { parts: [ + { shape: 'rect', x: -10, y: -14, w: 20, h: 28, color: '#4466aa' }, + { shape: 'rect', x: -8, y: -12, w: 16, h: 24, color: '#5577bb' }, + { shape: 'rect', x: -3, y: -8, w: 6, h: 16, color: '#ffcc00' }, // emblem cross + { shape: 'rect', x: -7, y: -3, w: 14, h: 6, color: '#ffcc00' }, // emblem cross + { shape: 'rect', x: -10, y: -14, w: 20, h: 2, color: '#6688cc' }, // top edge + ]}, + soccerball: { parts: [ + { shape: 'circle', x: 0, y: 0, w: 12, h: 12, color: '#ffffff' }, + { shape: 'rect', x: -3, y: -3, w: 6, h: 6, color: '#222222' }, // pentagon + ]}, + donut: { parts: [ + { shape: 'circle', x: 0, y: 0, w: 14, h: 14, color: '#ff88cc' }, // icing + { shape: 'circle', x: 0, y: 0, w: 6, h: 6, color: '#cc8833' }, // hole (body color) + { shape: 'circle', x: -3, y: -3, w: 2, h: 2, color: '#ff4444' }, // sprinkle + { shape: 'circle', x: 3, y: -2, w: 2, h: 2, color: '#44ff44' }, // sprinkle + { shape: 'circle', x: 1, y: 4, w: 2, h: 2, color: '#ffff00' }, // sprinkle + ]}, + watermelon: { parts: [ + { shape: 'circle', x: 0, y: 0, w: 18, h: 18, color: '#33aa33' }, // rind + { shape: 'circle', x: 0, y: 0, w: 14, h: 14, color: '#ff4466' }, // flesh + { shape: 'circle', x: -2, y: -1, w: 2, h: 2, color: '#222222' }, // seed + { shape: 'circle', x: 3, y: 2, w: 2, h: 2, color: '#222222' }, // seed + { shape: 'circle', x: 0, y: 4, w: 2, h: 2, color: '#222222' }, // seed + ]}, + chainsaw: { parts: [ + { shape: 'rect', x: 0, y: -2, w: 28, h: 5, color: '#888888' }, // blade + { shape: 'rect', x: -8, y: -5, w: 12, h: 10, color: '#ff6600' }, // body + { shape: 'rect', x: -4, y: 5, w: 6, h: 7, color: '#553322' }, // grip + { shape: 'rect', x: 26, y: -1, w: 4, h: 3, color: '#aaaaaa' }, // tip + { shape: 'rect', x: 0, y: -3, w: 26, h: 1, color: '#666666' }, // chain top + { shape: 'rect', x: 0, y: 3, w: 26, h: 1, color: '#666666' }, // chain bottom + ]}, + } + + // Spawn a multi-part pixel prop at position, returns array of game objects + function spawnProp(propName: string, x: number, y: number, flipX: boolean = false, zIndex: number = 16): any[] { + const def = PROPS[propName] + if (!def) { + // Fallback: single colored circle + return [k.add([k.circle(8), k.pos(x, y), k.color(k.Color.fromHex('#ff8800')), k.opacity(1), k.z(zIndex), k.anchor('center')])] + } + const objs: any[] = [] + for (const p of def.parts) { + const px = flipX ? x - p.x : x + p.x + const obj = k.add([ + p.shape === 'circle' ? k.circle(p.w / 2) : k.rect(p.w, p.h), + k.pos(px, y + p.y), + k.color(k.Color.fromHex(p.color)), + k.opacity(1), + k.z(zIndex), + k.anchor('center'), + ]) + objs.push(obj) + } + return objs + } + + // Move all parts of a prop to a new position (relative to anchor) + function moveProp(objs: any[], def: PropDef, x: number, y: number, flipX: boolean = false) { + for (let i = 0; i < objs.length && i < def.parts.length; i++) { + const p = def.parts[i] + objs[i].pos.x = flipX ? x - p.x : x + p.x + objs[i].pos.y = y + p.y + } + } + + function destroyProp(objs: any[]) { + objs.forEach(o => { if (o.exists()) o.destroy() }) + } + + // Map choreography names to prop types for the factories + const CHOREO_PROPS: Record = { + pizzaSlam: 'pizza', burgerToss: 'burger', hotdogWhip: 'hotdog', bananaFling: 'banana', + pieSmash: 'pie', watermelonBomb: 'watermelon', donutBarrage: 'donut', + sushiBarrage: 'pizza', tacoStorm: 'pizza', iceCreamFling: 'donut', + bombThrow: 'bomb', grenadeBlast: 'bomb', dynamiteBlast: 'bomb', + bowlingBallRoll: 'soccerball', soccerKick: 'soccerball', basketballDunk: 'soccerball', + } + + // Map choreography names to weapon props the attacker holds + const CHOREO_WEAPON: Record = { + gunBurst: 'gun', sniperShot: 'sniper', minigunSpray: 'minigun', + rocketLauncher: 'rocketLauncher', pocketCannon: 'shotgun', + chainsawRev: 'chainsaw', katanaCombo: 'katana', swordSlash: 'katana', + } + + // === PER-CHARACTER WEAPON SYSTEM === + // Each bot gets 3 special weapons based on their seed, balanced frequency + + interface BotWeapons { + melee: string // close range weapon prop + ranged: string // gun/launcher prop + thrown: string // throwable prop + } + + const MELEE_WEAPONS = ['katana', 'chainsaw', 'shield'] + const RANGED_WEAPONS = ['gun', 'shotgun', 'sniper', 'minigun', 'rocketLauncher'] + const THROWN_WEAPONS = ['pizza', 'burger', 'bomb', 'watermelon', 'donut', 'soccerball', 'banana'] + + function getBotWeapons(seed: string): BotWeapons { + let h = 0 + for (let i = 0; i < seed.length; i++) h = ((h << 5) - h + seed.charCodeAt(i)) | 0 + return { + melee: MELEE_WEAPONS[Math.abs(h) % MELEE_WEAPONS.length], + ranged: RANGED_WEAPONS[Math.abs(h >> 3) % RANGED_WEAPONS.length], + thrown: THROWN_WEAPONS[Math.abs(h >> 7) % THROWN_WEAPONS.length], + } + } + + const weaponsA = getBotWeapons(botA.seed) + const weaponsB = getBotWeapons(botB.seed) + + // === DEFENSE / SHIELD SYSTEM === + + async function playBlock(side: 'a' | 'b') { + const defender = k.get(side === 'a' ? 'fighterA' : 'fighterB')[0] + if (!defender) return + const dir = side === 'a' ? 1 : -1 + // Spawn shield in front + const shieldObjs = spawnProp('shield', defender.pos.x + dir * 20, defender.pos.y - 25, side === 'b', 19) + sfxBlock() + // Flash shield + shieldObjs.forEach(o => { o.opacity = 0.9 }) + await k.wait(0.15) + // Impact sparks + spawnSparks(defender.pos.x + dir * 25, defender.pos.y - 25, 6, '#4488ff') + k.shake(3) + await k.wait(0.3) + // Shield fades + shieldObjs.forEach(o => { + k.tween(o.opacity, 0, 0.2, (v: number) => { o.opacity = v }).then(() => { if (o.exists()) o.destroy() }) + }) + await k.wait(0.1) + } + + // Spawn attacker's signature weapon as a visual during choreographies + function getAttackerWeapon(side: 'a' | 'b', type: 'melee' | 'ranged' | 'thrown'): string { + const weapons = side === 'a' ? weaponsA : weaponsB + return weapons[type] + } + // ============================================================ // CHOREOGRAPHY FACTORIES — generate moves from templates // ============================================================ type ChoreoFn = typeof dashPunch - // FACTORY 1: Throw object(s) in an arc at the opponent + // FACTORY 1: Throw object(s) in an arc at the opponent — NOW WITH PIXEL PROPS function makeThrow(color: string, size: number, count: number = 1, arc: number = 60, circle: boolean = false, trail?: string): ChoreoFn { return async (atk, def, dir, origAX, origDX, isCritical) => { atk.play('special'); sfxSpecial(); await k.wait(0.12) @@ -3263,7 +3884,9 @@ export async function createFightScene(config: FightSceneConfig) { for (let i = 0; i < total; i++) { const fx = atk.pos.x + dir * 25, fy = atk.pos.y - 35 const tx = def.pos.x + (Math.random() - 0.5) * 30, ty = def.pos.y - 25 + (Math.random() - 0.5) * 20 - const p = k.add([circle ? k.circle(size / 2) : k.rect(size, size), k.pos(fx, fy), k.color(k.Color.fromHex(color)), k.opacity(1), k.z(16), k.rotate(Math.random() * 360)]) + + // Try to find a prop for this choreography, fall back to colored shape + const p = k.add([circle ? k.circle(size / 2) : k.rect(size, size), k.pos(fx, fy), k.color(k.Color.fromHex(color)), k.opacity(1), k.z(16), k.rotate(Math.random() * 360), k.anchor('center')]) k.tween(0, 1, 0.18, (t) => { p.pos.x = fx + (tx - fx) * t; p.pos.y = fy + (ty - fy) * t - Math.sin(t * Math.PI) * arc; p.angle += 720 * k.dt() }).then(() => { p.destroy(); spawnSparks(tx, ty, 3, trail || color) }) if (i < total - 1) await k.wait(0.05) } @@ -3278,15 +3901,87 @@ export async function createFightScene(config: FightSceneConfig) { } } + // FACTORY 1b: Throw with detailed pixel prop + function makeThrowProp(propName: string, count: number = 1, arc: number = 60, trail: string = '#ffcc00'): ChoreoFn { + return async (atk, def, dir, origAX, origDX, isCritical) => { + atk.play('special'); sfxSpecial(); await k.wait(0.12) + const total = count + (isCritical ? 2 : 0) + const propDef = PROPS[propName] + for (let i = 0; i < total; i++) { + const fx = atk.pos.x + dir * 25, fy = atk.pos.y - 35 + const tx = def.pos.x + (Math.random() - 0.5) * 30, ty = def.pos.y - 25 + (Math.random() - 0.5) * 20 + const objs = spawnProp(propName, fx, fy, dir < 0) + k.tween(0, 1, 0.2, (t) => { + const cx = fx + (tx - fx) * t + const cy = fy + (ty - fy) * t - Math.sin(t * Math.PI) * arc + if (propDef) moveProp(objs, propDef, cx, cy, dir < 0) + else if (objs[0]) { objs[0].pos.x = cx; objs[0].pos.y = cy } + }).then(() => { destroyProp(objs); spawnSparks(tx, ty, 5, trail) }) + if (i < total - 1) await k.wait(0.06) + } + await k.wait(0.15); sfxBonk() + def.play(isCritical ? 'knockback' : 'hit'); k.shake(isCritical ? 14 : 6) + if (isCritical) { screenFlash(trail); sfxCritical() } + spawnSparks(def.pos.x, def.pos.y - 25, isCritical ? 14 : 7, trail) + const push = dir * (isCritical ? 85 : 38) + k.tween(def.pos.x, origDX + push, 0.2, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.3); atk.play('idle') + await k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad) + } + } + + // FACTORY 1c: Ranged attack with visible pixel gun prop + function makeGunProp(propName: string, bulletCount: number = 4, bulletColor: string = '#ffee00'): ChoreoFn { + return async (atk, def, dir, origAX, origDX, isCritical) => { + atk.play('special'); sfxSpecial() + // Spawn gun prop on attacker + const gx = atk.pos.x + dir * 15, gy = atk.pos.y - 35 + const gunObjs = spawnProp(propName, gx, gy, dir < 0, 18) + const gunDef = PROPS[propName] + await k.wait(0.15) + // Fire bullets + const total = bulletCount + (isCritical ? 3 : 0) + for (let i = 0; i < total; i++) { + sfxGunshot() + // Muzzle flash + const mfx = gx + dir * 30, mfy = gy + const flash = k.add([k.circle(5 + Math.random() * 4), k.pos(mfx, mfy), k.color(k.Color.fromHex(bulletColor)), k.opacity(0.9), k.z(19), k.anchor('center')]) + setTimeout(() => { if (flash.exists()) flash.destroy() }, 40) + // Bullet trail + const targetY = def.pos.y - 20 - Math.random() * 30 + await spawnProjectile(mfx, mfy, def.pos.x, targetY, bulletColor, 4) + sfxBulletHit() + spawnSparks(def.pos.x + (Math.random() - 0.5) * 20, targetY, 3, bulletColor) + // Recoil — bounce gun + if (gunDef) { + const rx = gx - dir * 4 + moveProp(gunObjs, gunDef, rx, gy, dir < 0) + await k.wait(0.02) + moveProp(gunObjs, gunDef, gx, gy, dir < 0) + } + def.opacity = 0.5; await k.wait(0.03); def.opacity = 1 + } + spawnBulletHoles(def.pos.x, def.pos.y - 30, isCritical ? 5 : 3) + def.play(isCritical ? 'knockback' : 'hit'); k.shake(isCritical ? 16 : 8) + if (isCritical) { screenFlash(bulletColor); sfxCritical(); sfxExplosion() } + // Remove gun + destroyProp(gunObjs) + const push = dir * (isCritical ? 100 : 45) + k.tween(def.pos.x, origDX + push, 0.25, (v) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.35); atk.play('idle') + await k.tween(def.pos.x, origDX, 0.3, (v) => { def.pos.x = v }, k.easings.easeInOutQuad) + } + } + // FACTORY 2: Swing weapon — run up, swing rect, hit function makeSwing(weaponW: number, weaponH: number, weaponColor: string, headColor?: string, headW?: number, headH?: number): ChoreoFn { return async (atk, def, dir, origAX, origDX, isCritical) => { const cx = origDX - dir * 55 await k.tween(atk.pos.x, cx, 0.14, (v) => { atk.pos.x = v }, k.easings.easeOutQuad) const wx = atk.pos.x + dir * 18, wy = atk.pos.y - 55 - const shaft = k.add([k.rect(weaponW, weaponH), k.pos(wx, wy), k.color(k.Color.fromHex(weaponColor)), k.anchor('bot'), k.z(18), k.rotate(dir === 1 ? -40 : 40)]) + const shaft = k.add([k.rect(weaponW, weaponH), k.pos(wx, wy), k.color(safeColor(k,weaponColor)), k.anchor('bot'), k.z(18), k.rotate(dir === 1 ? -40 : 40)]) let head: any = null - if (headColor) { head = k.add([k.rect(headW || 18, headH || 14), k.pos(wx + dir * 10, wy - weaponH + 5), k.color(k.Color.fromHex(headColor)), k.z(18)]) } + if (headColor) { head = k.add([k.rect(headW || 18, headH || 14), k.pos(wx + dir * 10, wy - weaponH + 5), k.color(safeColor(k,headColor)), k.z(18)]) } atk.play('attack'); sfxSpecial() await k.tween(dir === 1 ? -40 : 40, dir === 1 ? 85 : -85, 0.13, (v) => { shaft.angle = v }, k.easings.easeInQuad) sfxBonk(); shaft.destroy(); if (head) head.destroy() @@ -3307,9 +4002,9 @@ export async function createFightScene(config: FightSceneConfig) { function makeDrop(w: number, h: number, color: string, bounceColor?: string, circle: boolean = false): ChoreoFn { return async (atk, def, dir, origAX, origDX, isCritical) => { atk.play('special'); sfxSpecial(); await k.wait(0.12) - const shadow = k.add([k.circle(5), k.pos(def.pos.x, GROUND_Y - 2), k.color(k.Color.fromHex('#000000')), k.opacity(0.3), k.z(4)]) + const shadow = k.add([k.circle(5), k.pos(def.pos.x, GROUND_Y - 2), k.color(safeColor(k,'#000000')), k.opacity(0.3), k.z(4)]) k.tween(5, w * 0.7, 0.25, (v) => { shadow.radius = v }) - const obj = k.add([circle ? k.circle(w / 2) : k.rect(w, h), k.pos(def.pos.x - (circle ? 0 : w / 2), -h - 10), k.color(k.Color.fromHex(color)), k.opacity(0.9), k.z(18)]) + const obj = k.add([circle ? k.circle(w / 2) : k.rect(w, h), k.pos(def.pos.x - (circle ? 0 : w / 2), -h - 10), k.color(safeColor(k,color)), k.opacity(0.9), k.z(18)]) sfxSlideDown() await k.tween(-h - 10, def.pos.y - 45, 0.22, (v) => { obj.pos.y = v }, k.easings.easeInQuad) sfxBonk(); sfxExplosion() @@ -3334,16 +4029,16 @@ export async function createFightScene(config: FightSceneConfig) { const startX = dir === 1 ? -bodyW - 20 : W + bodyW + 20 atk.opacity = 0; atk.pos.x = startX const vY = GROUND_Y - bodyH / 2 - wheelR - const body = k.add([k.rect(bodyW, bodyH), k.pos(startX, vY), k.color(k.Color.fromHex(isCritical ? '#ff2d2d' : bodyColor)), k.z(16)]) - const w1 = k.add([k.circle(wheelR), k.pos(startX + dir * (bodyW / 3), vY + bodyH / 2 + wheelR - 2), k.color(k.Color.fromHex('#222222')), k.z(16)]) - const w2 = k.add([k.circle(wheelR), k.pos(startX - dir * (bodyW / 3), vY + bodyH / 2 + wheelR - 2), k.color(k.Color.fromHex('#222222')), k.z(16)]) + const body = k.add([k.rect(bodyW, bodyH), k.pos(startX, vY), k.color(safeColor(k,isCritical ? '#ff2d2d' : bodyColor)), k.z(16)]) + const w1 = k.add([k.circle(wheelR), k.pos(startX + dir * (bodyW / 3), vY + bodyH / 2 + wheelR - 2), k.color(safeColor(k,'#222222')), k.z(16)]) + const w2 = k.add([k.circle(wheelR), k.pos(startX - dir * (bodyW / 3), vY + bodyH / 2 + wheelR - 2), k.color(safeColor(k,'#222222')), k.z(16)]) const extras: any[] = [] if (parts) for (const p of parts) { - extras.push(k.add([k.rect(p.w, p.h), k.pos(startX + p.ox * dir, vY + p.oy), k.color(k.Color.fromHex(p.color)), k.z(17)])) + extras.push(k.add([k.rect(p.w, p.h), k.pos(startX + p.ox * dir, vY + p.oy), k.color(safeColor(k,p.color)), k.z(17)])) } sfxJetpack() const exh = setInterval(() => { - const ep = k.add([k.circle(3), k.pos(body.pos.x - dir * bodyW / 2, vY + 5), k.color(k.Color.fromHex('#888888')), k.opacity(0.5), k.z(4)]) + const ep = k.add([k.circle(3), k.pos(body.pos.x - dir * bodyW / 2, vY + 5), k.color(safeColor(k,'#888888')), k.opacity(0.5), k.z(4)]) ep.onUpdate(() => { ep.opacity -= 2 * k.dt(); if (ep.opacity <= 0) ep.destroy() }) }, 30) const tgt = origDX + dir * 20 @@ -3375,7 +4070,7 @@ export async function createFightScene(config: FightSceneConfig) { return async (atk, def, dir, origAX, origDX, isCritical) => { atk.play('special'); sfxSpecial() // Charge up - const chargeOrb = k.add([k.circle(3), k.pos(atk.pos.x + dir * 30, atk.pos.y - 35), k.color(k.Color.fromHex(color)), k.opacity(0.8), k.z(18)]) + const chargeOrb = k.add([k.circle(3), k.pos(atk.pos.x + dir * 30, atk.pos.y - 35), k.color(safeColor(k,color)), k.opacity(0.8), k.z(18)]) await k.tween(3, 12 + (isCritical ? 5 : 0), 0.2, (v) => { chargeOrb.radius = v }, k.easings.easeOutQuad) chargeOrb.destroy() // Fire beam @@ -3384,8 +4079,8 @@ export async function createFightScene(config: FightSceneConfig) { sfxZap() const bx = atk.pos.x + dir * 30, by = atk.pos.y - 35 const beamLen = Math.abs(def.pos.x - atk.pos.x) + 30 - const beam = k.add([k.rect(beamLen, width + (isCritical ? 4 : 0)), k.pos(bx, by - width / 2), k.color(k.Color.fromHex(color)), k.opacity(0.9), k.z(17)]) - const glow = k.add([k.rect(beamLen, width * 2.5), k.pos(bx, by - width * 1.25), k.color(k.Color.fromHex(color)), k.opacity(0.25), k.z(16)]) + const beam = k.add([k.rect(beamLen, width + (isCritical ? 4 : 0)), k.pos(bx, by - width / 2), k.color(safeColor(k,color)), k.opacity(0.9), k.z(17)]) + const glow = k.add([k.rect(beamLen, width * 2.5), k.pos(bx, by - width * 1.25), k.color(safeColor(k,color)), k.opacity(0.25), k.z(16)]) if (dir === -1) { beam.pos.x = bx - beamLen; glow.pos.x = bx - beamLen } await k.wait(0.08) def.play('hit'); k.shake(isCritical ? 12 : 5) @@ -3461,7 +4156,7 @@ export async function createFightScene(config: FightSceneConfig) { for (let i = 0; i < total; i++) { const sx = atk.pos.x - dir * 20 + (Math.random() - 0.5) * 30 const sy = atk.pos.y - 40 + (Math.random() - 0.5) * 50 - const p = k.add([k.circle(size / 2 + Math.random() * 2), k.pos(sx, sy), k.color(k.Color.fromHex(color)), k.opacity(0.9), k.z(16)]) + const p = k.add([k.circle(size / 2 + Math.random() * 2), k.pos(sx, sy), k.color(safeColor(k,color)), k.opacity(0.9), k.z(16)]) const tx = def.pos.x + (Math.random() - 0.5) * 25, ty = def.pos.y - 30 + (Math.random() - 0.5) * 30 swarmThings.push(p) k.tween(0, 1, (Math.abs(tx - sx) / speed) + Math.random() * 0.1, (t) => { @@ -3489,7 +4184,7 @@ export async function createFightScene(config: FightSceneConfig) { // Throw the thing const fromX = atk.pos.x + dir * 25, fromY = atk.pos.y - 35 const toX = def.pos.x, toY = def.pos.y - 20 - const obj = k.add([circle ? k.circle(objW / 2) : k.rect(objW, objH), k.pos(fromX, fromY), k.color(k.Color.fromHex(objColor)), k.opacity(1), k.z(16), k.rotate(0)]) + const obj = k.add([circle ? k.circle(objW / 2) : k.rect(objW, objH), k.pos(fromX, fromY), k.color(safeColor(k,objColor)), k.opacity(1), k.z(16), k.rotate(0)]) await k.tween(0, 1, 0.2, (t) => { obj.pos.x = fromX + (toX - fromX) * t obj.pos.y = fromY + (toY - fromY) * t - Math.sin(t * Math.PI) * 80 @@ -3503,7 +4198,7 @@ export async function createFightScene(config: FightSceneConfig) { // BOOM obj.destroy(); sfxExplosion() const blastR = isCritical ? 45 : 28 - const blast = k.add([k.circle(blastR), k.pos(toX, toY), k.color(k.Color.fromHex(blastColor)), k.opacity(0.8), k.z(19)]) + const blast = k.add([k.circle(blastR), k.pos(toX, toY), k.color(safeColor(k,blastColor)), k.opacity(0.8), k.z(19)]) k.tween(blastR, blastR * 2.5, 0.15, (v) => { blast.radius = v }) k.tween(0.8, 0, 0.2, (v) => { blast.opacity = v }).then(() => blast.destroy()) k.shake(isCritical ? 25 : 14); screenFlash(blastColor, 0.15) @@ -3584,17 +4279,17 @@ export async function createFightScene(config: FightSceneConfig) { // GENERATED CHOREOGRAPHIES — 258 new moves from factories // ============================================================ - // --- FOOD THROWS (30) --- - const pizzaSlam = makeThrow('#ff8800', 16, 2, 70, false, '#ffcc00') - const bananaFling = makeThrow('#ffe135', 12, 3, 55, false, '#ffff00') - const pieSmash = makeThrow('#f5deb3', 18, 1, 80, true, '#ffffff') - const hotdogWhip = makeThrow('#cc6633', 14, 2, 45, false, '#ff6644') - const watermelonBomb = makeThrow('#33aa33', 20, 1, 90, true, '#ff4466') + // --- FOOD THROWS (30) — key items use detailed pixel props --- + const pizzaSlam = makeThrowProp('pizza', 2, 70, '#ffcc00') + const bananaFling = makeThrowProp('banana', 3, 55, '#ffff00') + const pieSmash = makeThrowProp('pie', 1, 80, '#ffffff') + const hotdogWhip = makeThrowProp('hotdog', 2, 45, '#ff6644') + const watermelonBomb = makeThrowProp('watermelon', 1, 90, '#ff4466') const sushiBarrage = makeThrow('#ffffff', 10, 5, 40, false, '#ff6666') - const burgerToss = makeThrow('#cc8833', 16, 2, 65, false, '#ffaa00') + const burgerToss = makeThrowProp('burger', 2, 65, '#ffaa00') const iceCreamFling = makeThrow('#ffccdd', 12, 3, 50, true, '#ff88bb') const tacoStorm = makeThrow('#ffcc44', 11, 4, 55, false, '#ff8800') - const donutBarrage = makeThrow('#ff88cc', 14, 3, 60, true, '#ffaadd') + const donutBarrage = makeThrowProp('donut', 3, 60, '#ffaadd') const popcornBlast = makeThrow('#ffffcc', 6, 12, 35, true, '#ffee88') const cookieFling = makeThrow('#cc9944', 12, 3, 50, true, '#aa7722') const eggBombard = makeThrow('#ffffdd', 10, 4, 65, true, '#ffee44') @@ -3908,9 +4603,9 @@ export async function createFightScene(config: FightSceneConfig) { const portalPunch: ChoreoFn = async (atk, def, dir, origAX, origDX, isCritical) => { atk.play('special'); sfxSpecial() // Open portal near attacker - const p1 = k.add([k.circle(20), k.pos(atk.pos.x + dir * 40, atk.pos.y - 30), k.color(k.Color.fromHex('#8844ff')), k.opacity(0.7), k.z(15)]) + const p1 = k.add([k.circle(20), k.pos(atk.pos.x + dir * 40, atk.pos.y - 30), k.color(safeColor(k,'#8844ff')), k.opacity(0.7), k.z(15)]) // Open portal near defender - const p2 = k.add([k.circle(20), k.pos(def.pos.x - dir * 30, def.pos.y - 30), k.color(k.Color.fromHex('#ff44ff')), k.opacity(0.7), k.z(15)]) + const p2 = k.add([k.circle(20), k.pos(def.pos.x - dir * 30, def.pos.y - 30), k.color(safeColor(k,'#ff44ff')), k.opacity(0.7), k.z(15)]) await k.wait(0.2) atk.play('attack'); sfxPunch() // Fist appears from portal 2 @@ -4051,7 +4746,7 @@ export async function createFightScene(config: FightSceneConfig) { atk.play('special'); sfxSpecial() for (let i = 0; i < 5; i++) { const bx = def.pos.x + (Math.random() - 0.5) * 60 - const beam = k.add([k.rect(4, H), k.pos(bx, 0), k.color(k.Color.fromHex('#ff2d7b')), k.opacity(0.8), k.z(20)]) + const beam = k.add([k.rect(4, H), k.pos(bx, 0), k.color(safeColor(k,'#ff2d7b')), k.opacity(0.8), k.z(20)]) sfxZap(); k.shake(6) spawnSparks(bx, def.pos.y - 20, 8, '#ff2d7b') await k.wait(0.08) @@ -4070,7 +4765,7 @@ export async function createFightScene(config: FightSceneConfig) { { x: def.pos.x - 30, y: GROUND_Y - 50 }, { x: def.pos.x + 30, y: GROUND_Y - 50 }, ] for (const p of positions) { - const cl = k.add([k.rect(20, 30), k.pos(p.x, p.y), k.anchor('center'), k.color(k.Color.fromHex(theme.accent)), k.opacity(0.5), k.z(12)]) + const cl = k.add([k.rect(20, 30), k.pos(p.x, p.y), k.anchor('center'), k.color(safeColor(k,theme.accent)), k.opacity(0.5), k.z(12)]) clones.push(cl) } sfxZap(); await k.wait(0.2) @@ -4093,13 +4788,13 @@ export async function createFightScene(config: FightSceneConfig) { const gunLen = 60 const gx = atk.pos.x + dir * 25 const gy = atk.pos.y - 25 - const barrel = k.add([k.rect(gunLen, 16), k.pos(gx, gy), k.color(k.Color.fromHex('#555555')), k.opacity(1), k.z(16)]) + const barrel = k.add([k.rect(gunLen, 16), k.pos(gx, gy), k.color(safeColor(k,'#555555')), k.opacity(1), k.z(16)]) sfxBoing() await k.wait(0.15) for (let i = 0; i < 12; i++) { sfxGunshot() k.shake(3) - const flash = k.add([k.circle(8), k.pos(gx + dir * gunLen, gy + (Math.random() - 0.5) * 10), k.color(k.Color.fromHex('#ffee00')), k.opacity(0.9), k.z(18)]) + const flash = k.add([k.circle(8), k.pos(gx + dir * gunLen, gy + (Math.random() - 0.5) * 10), k.color(safeColor(k,'#ffee00')), k.opacity(0.9), k.z(18)]) setTimeout(() => { if (flash.exists()) flash.destroy() }, 40) if (i % 3 === 0) { def.play('hit') @@ -4129,7 +4824,7 @@ export async function createFightScene(config: FightSceneConfig) { // Ground cracks for (let i = 0; i < 6; i++) { const cx = Math.random() * W - k.add([k.rect(2, 15 + Math.random() * 10), k.pos(cx, GROUND_Y - 5), k.color(k.Color.fromHex('#aa6600')), k.opacity(0.7), k.z(1), { lifetime: 0.8 }]) + k.add([k.rect(2, 15 + Math.random() * 10), k.pos(cx, GROUND_Y - 5), k.color(safeColor(k,'#aa6600')), k.opacity(0.7), k.z(1), { lifetime: 0.8 }]) } def.play('knockback') spawnSparks(def.pos.x, def.pos.y - 20, 12, '#ff8800') @@ -4147,7 +4842,7 @@ export async function createFightScene(config: FightSceneConfig) { async function ultimateScreenNuke(atk: any, def: any, dir: number, origAX: number, origDX: number, _isCrit: boolean) { // Charge up glowing orb, launch it, entire screen whites out atk.play('special'); sfxSpecial() - const orb = k.add([k.circle(5), k.pos(atk.pos.x + dir * 20, atk.pos.y - 30), k.color(k.Color.fromHex('#ffffff')), k.opacity(0.8), k.z(18)]) + const orb = k.add([k.circle(5), k.pos(atk.pos.x + dir * 20, atk.pos.y - 30), k.color(safeColor(k,'#ffffff')), k.opacity(0.8), k.z(18)]) // Charge: grow orb for (let i = 0; i < 8; i++) { await k.wait(0.05) @@ -4172,8 +4867,8 @@ export async function createFightScene(config: FightSceneConfig) { async function ultimateBlackHoleVortex(atk: any, def: any, dir: number, origAX: number, origDX: number, _isCrit: boolean) { // Summon black hole that pulls defender in, crushes, and ejects atk.play('special'); sfxZap() - const bh = k.add([k.circle(8), k.pos(W / 2, GROUND_Y - 50), k.anchor('center'), k.color(k.Color.fromHex('#220044')), k.opacity(0.9), k.z(20)]) - const ring = k.add([k.circle(30), k.pos(W / 2, GROUND_Y - 50), k.anchor('center'), k.color(k.Color.fromHex('#b83dff')), k.opacity(0.3), k.z(19)]) + const bh = k.add([k.circle(8), k.pos(W / 2, GROUND_Y - 50), k.anchor('center'), k.color(safeColor(k,'#220044')), k.opacity(0.9), k.z(20)]) + const ring = k.add([k.circle(30), k.pos(W / 2, GROUND_Y - 50), k.anchor('center'), k.color(safeColor(k,'#b83dff')), k.opacity(0.3), k.z(19)]) // Grow await k.tween(8, 35, 0.3, (v) => { bh.radius = v; ring.radius = v + 20 }, k.easings.easeOutQuad) // Pull defender in @@ -4208,7 +4903,7 @@ export async function createFightScene(config: FightSceneConfig) { const px = def.pos.x + (i % 2 === 0 ? -80 : 80) const py = GROUND_Y - (i < 2 ? 0 : 60) // Portal flash - const p = k.add([k.circle(15), k.pos(px, py), k.color(k.Color.fromHex(colors[i])), k.opacity(0.7), k.z(15)]) + const p = k.add([k.circle(15), k.pos(px, py), k.color(safeColor(k,colors[i])), k.opacity(0.7), k.z(15)]) await k.wait(0.06) // Attack from portal atk.pos.x = px; atk.pos.y = py; atk.opacity = 1 @@ -4235,7 +4930,7 @@ export async function createFightScene(config: FightSceneConfig) { // Everything goes slow-mo, attacker lands 6 devastating hits in "frozen" time scanlineGlitch(0.1) // Dim screen - const dim = k.add([k.rect(W, H), k.pos(0, 0), k.color(k.Color.fromHex('#000022')), k.opacity(0.4), k.z(25)]) + const dim = k.add([k.rect(W, H), k.pos(0, 0), k.color(safeColor(k,'#000022')), k.opacity(0.4), k.z(25)]) sfxZap() const contactX = origDX - dir * 45 await k.tween(atk.pos.x, contactX, 0.3, (v) => { atk.pos.x = v }, k.easings.easeInOutQuad) @@ -4264,7 +4959,7 @@ export async function createFightScene(config: FightSceneConfig) { for (let i = 0; i < 8; i++) { const ax = atk.pos.x + (Math.random() - 0.5) * 30 const ay = GROUND_Y - Math.random() * 10 - const m = k.add([k.rect(12, 18), k.pos(ax, ay), k.anchor('center'), k.color(k.Color.fromHex(theme.accent)), k.opacity(0.6), k.z(11)]) + const m = k.add([k.rect(12, 18), k.pos(ax, ay), k.anchor('center'), k.color(safeColor(k,theme.accent)), k.opacity(0.6), k.z(11)]) army.push(m) } await k.wait(0.2) @@ -4323,7 +5018,7 @@ export async function createFightScene(config: FightSceneConfig) { atk.opacity = 0 await k.wait(0.3) // Meteor warning - const warning = k.add([k.circle(15), k.pos(def.pos.x, GROUND_Y - 5), k.color(k.Color.fromHex('#ff0000')), k.opacity(0.4), k.z(1)]) + const warning = k.add([k.circle(15), k.pos(def.pos.x, GROUND_Y - 5), k.color(safeColor(k,'#ff0000')), k.opacity(0.4), k.z(1)]) warning.onUpdate(() => { warning.opacity = 0.2 + Math.sin(k.time() * 20) * 0.2 }) sfxZap() await k.wait(0.4) @@ -4334,7 +5029,7 @@ export async function createFightScene(config: FightSceneConfig) { const fireTrail: any[] = [] await k.tween(-80, GROUND_Y, 0.15, (v) => { atk.pos.y = v - const f = k.add([k.circle(6), k.pos(atk.pos.x + (Math.random() - 0.5) * 15, v - 15), k.color(k.Color.fromHex('#ff6600')), k.opacity(0.7), k.z(9)]) + const f = k.add([k.circle(6), k.pos(atk.pos.x + (Math.random() - 0.5) * 15, v - 15), k.color(safeColor(k,'#ff6600')), k.opacity(0.7), k.z(9)]) fireTrail.push(f) k.tween(f.opacity, 0, 0.3, (op) => { f.opacity = op }).then(() => { if (f.exists()) f.destroy() }) }, k.easings.easeInQuad) @@ -4362,7 +5057,7 @@ export async function createFightScene(config: FightSceneConfig) { const fy = H * Math.random() * 0.8 const fw = 30 + Math.random() * 60 const fh = 20 + Math.random() * 40 - const frag = k.add([k.rect(fw, fh), k.pos(fx, fy), k.color(k.Color.fromHex('#111111')), k.opacity(0.7), k.z(30), k.rotate(Math.random() * 30 - 15)]) + const frag = k.add([k.rect(fw, fh), k.pos(fx, fy), k.color(safeColor(k,'#111111')), k.opacity(0.7), k.z(30), k.rotate(Math.random() * 30 - 15)]) frags.push(frag) } sfxExplosion(); screenFlash('#ffffff', 0.3) @@ -4438,14 +5133,14 @@ export async function createFightScene(config: FightSceneConfig) { // --- TIER 5+ ULTIMATES (Diamond/Legend) — absolutely insane full-screen --- async function ultimateArmageddon(atk: any, def: any, dir: number, origAX: number, origDX: number, _isCrit: boolean) { // Full screen goes dark, rain of fire, massive explosion, screen flash - const darkness = k.add([k.rect(W, H), k.pos(0, 0), k.color(k.Color.fromHex('#000000')), k.opacity(0), k.z(25)]) + const darkness = k.add([k.rect(W, H), k.pos(0, 0), k.color(safeColor(k,'#000000')), k.opacity(0), k.z(25)]) await k.tween(0, 0.7, 0.3, (v) => { darkness.opacity = v }) atk.play('special'); sfxSpecial() // Rain of fire across entire screen for (let wave = 0; wave < 3; wave++) { for (let i = 0; i < 6; i++) { const rx = Math.random() * W - const meteor = k.add([k.circle(6 + Math.random() * 8), k.pos(rx, -20), k.color(k.Color.fromHex(wave === 2 ? '#ffffff' : '#ff6600')), k.opacity(0.9), k.z(26)]) + const meteor = k.add([k.circle(6 + Math.random() * 8), k.pos(rx, -20), k.color(safeColor(k,wave === 2 ? '#ffffff' : '#ff6600')), k.opacity(0.9), k.z(26)]) k.tween(meteor.pos.y, GROUND_Y, 0.2 + Math.random() * 0.1, (v) => { meteor.pos.y = v }, k.easings.easeInQuad) .then(() => { sfxBonk(); spawnSparks(rx, GROUND_Y - 5, 4, '#ff6600'); meteor.destroy() }) } @@ -4471,7 +5166,7 @@ export async function createFightScene(config: FightSceneConfig) { for (let i = 0; i < 10; i++) { const px = atk.pos.x + (Math.random() - 0.5) * 200 const py = atk.pos.y - 30 + (Math.random() - 0.5) * 100 - const p = k.add([k.circle(3), k.pos(px, py), k.color(k.Color.fromHex('#ffe14d')), k.opacity(0.8), k.z(15)]) + const p = k.add([k.circle(3), k.pos(px, py), k.color(safeColor(k,'#ffe14d')), k.opacity(0.8), k.z(15)]) k.tween(p.pos.x, atk.pos.x + dir * 15, 0.2, (v) => { p.pos.x = v }) k.tween(p.pos.y, atk.pos.y - 25, 0.2, (v) => { p.pos.y = v }).then(() => p.destroy()) } @@ -4480,8 +5175,8 @@ export async function createFightScene(config: FightSceneConfig) { // Massive beam const beamW = W const beamH = 40 - const beam = k.add([k.rect(beamW, beamH), k.pos(atk.pos.x, atk.pos.y - 25 - beamH / 2), k.color(k.Color.fromHex('#ffe14d')), k.opacity(0.9), k.z(22)]) - const beamCore = k.add([k.rect(beamW, beamH / 2), k.pos(atk.pos.x, atk.pos.y - 25 - beamH / 4), k.color(k.Color.fromHex('#ffffff')), k.opacity(0.7), k.z(23)]) + const beam = k.add([k.rect(beamW, beamH), k.pos(atk.pos.x, atk.pos.y - 25 - beamH / 2), k.color(safeColor(k,'#ffe14d')), k.opacity(0.9), k.z(22)]) + const beamCore = k.add([k.rect(beamW, beamH / 2), k.pos(atk.pos.x, atk.pos.y - 25 - beamH / 4), k.color(safeColor(k,'#ffffff')), k.opacity(0.7), k.z(23)]) sfxExplosion(); k.shake(20) await k.wait(0.1) def.play('knockback') @@ -4742,7 +5437,7 @@ export async function createFightScene(config: FightSceneConfig) { const aura = k.add([ k.circle(45 + Math.random() * 10), k.pos(fighter.pos.x, fighter.pos.y - 20), - k.color(k.Color.fromHex(colors[0])), + k.color(safeColor(k,colors[0])), k.opacity(0.15), k.z(fighter.z - 1), k.anchor('center'), @@ -4762,7 +5457,7 @@ export async function createFightScene(config: FightSceneConfig) { const p = k.add([ k.circle(2 + Math.random() * 2), k.pos(fighter.pos.x, fighter.pos.y), - k.color(k.Color.fromHex(colors[1 + (i % 2)])), + k.color(safeColor(k,colors[1 + (i % 2)])), k.opacity(0.6), k.z(fighter.z + 1), k.anchor('center'), @@ -4779,7 +5474,7 @@ export async function createFightScene(config: FightSceneConfig) { } // Color tint the fighter - fighter.color = k.Color.fromHex(colors[0]) + fighter.color = safeColor(k,colors[0]) fighter.opacity = 0.9 } @@ -4792,7 +5487,7 @@ export async function createFightScene(config: FightSceneConfig) { const sDir = s === 0 ? -1 : 1 const shoulder = k.add([ k.rect(12, 8), k.pos(fighter.pos.x + sDir * 25 * dir, fighter.pos.y - 30), - k.color(k.Color.fromHex('#888899')), k.opacity(0.85), k.z(fighter.z + 1), k.anchor('center'), + k.color(safeColor(k,'#888899')), k.opacity(0.85), k.z(fighter.z + 1), k.anchor('center'), ]) shoulder.onUpdate(() => { shoulder.pos.x = fighter.pos.x + sDir * 25 * dir @@ -4806,7 +5501,7 @@ export async function createFightScene(config: FightSceneConfig) { const wingDir = w === 0 ? -1 : 1 const wing = k.add([ k.rect(6, 20 + w * 5), k.pos(fighter.pos.x - 30 * dir * wingDir, fighter.pos.y - 15), - k.color(k.Color.fromHex('#5566aa')), k.opacity(0.7), k.z(fighter.z - 1), k.anchor('center'), + k.color(safeColor(k,'#5566aa')), k.opacity(0.7), k.z(fighter.z - 1), k.anchor('center'), k.rotate(wingDir * 15 * dir), ]) wing.onUpdate(() => { @@ -4818,14 +5513,14 @@ export async function createFightScene(config: FightSceneConfig) { const flame = k.add([ k.rect(4, 8 + Math.random() * 6), k.pos(fighter.pos.x - 30 * dir * wingDir, fighter.pos.y + 5), - k.color(k.Color.fromHex('#ff6600')), + k.color(safeColor(k,'#ff6600')), k.opacity(0.6), k.z(fighter.z - 2), k.anchor('center'), ]) flame.onUpdate(() => { flame.pos.x = fighter.pos.x - 30 * dir * wingDir + (Math.random() - 0.5) * 3 flame.pos.y = fighter.pos.y + 5 + Math.random() * 4 flame.opacity = 0.3 + Math.random() * 0.4 - flame.color = k.Color.fromHex(Math.random() > 0.5 ? '#ff6600' : '#ffcc00') + flame.color = safeColor(k,Math.random() > 0.5 ? '#ff6600' : '#ffcc00') }) activeMorphs.push({ obj: flame, type: 'mech' }) } @@ -4833,7 +5528,7 @@ export async function createFightScene(config: FightSceneConfig) { // Visor glow const visor = k.add([ k.rect(20, 4), k.pos(fighter.pos.x + 5 * dir, fighter.pos.y - 35), - k.color(k.Color.fromHex('#00ffaa')), k.opacity(0.7), k.z(fighter.z + 2), k.anchor('center'), + k.color(safeColor(k,'#00ffaa')), k.opacity(0.7), k.z(fighter.z + 2), k.anchor('center'), ]) visor.onUpdate(() => { visor.pos.x = fighter.pos.x + 5 * dir @@ -4843,7 +5538,7 @@ export async function createFightScene(config: FightSceneConfig) { activeMorphs.push({ obj: visor, type: 'mech' }) // Metallic tint - fighter.color = k.Color.fromHex('#aabbcc') + fighter.color = safeColor(k,'#aabbcc') fighter.opacity = 0.95 } @@ -4867,7 +5562,7 @@ export async function createFightScene(config: FightSceneConfig) { const h1 = k.add([ k.rect(3, 12 + Math.random() * 6), k.pos(fighter.pos.x + hDir * 10, fighter.pos.y - 45), - k.color(k.Color.fromHex(beastColors[0])), + k.color(safeColor(k,beastColors[0])), k.opacity(0.9), k.z(fighter.z + 2), k.anchor('bot'), k.rotate(hDir * (20 + Math.random() * 15)), ]) @@ -4883,7 +5578,7 @@ export async function createFightScene(config: FightSceneConfig) { const seg = k.add([ k.circle(4 - s * 0.5), k.pos(fighter.pos.x - dir * (20 + s * 8), fighter.pos.y - 5 + s * 2), - k.color(k.Color.fromHex(beastColors[1])), + k.color(safeColor(k,beastColors[1])), k.opacity(0.8), k.z(fighter.z - 1), k.anchor('center'), ]) seg.onUpdate(() => { @@ -4900,7 +5595,7 @@ export async function createFightScene(config: FightSceneConfig) { for (let cl = 0; cl < 3; cl++) { const claw = k.add([ k.rect(2, 6), k.pos(fighter.pos.x + cDir * 22, fighter.pos.y - 18 + cl * 3), - k.color(k.Color.fromHex(beastColors[2])), + k.color(safeColor(k,beastColors[2])), k.opacity(0.8), k.z(fighter.z + 1), k.anchor('center'), k.rotate(cDir * (30 + cl * 10)), ]) @@ -4915,7 +5610,7 @@ export async function createFightScene(config: FightSceneConfig) { // Wild eye glow const eyeGlow = k.add([ k.circle(4), k.pos(fighter.pos.x + 5 * dir, fighter.pos.y - 36), - k.color(k.Color.fromHex('#ff0000')), k.opacity(0.6), k.z(fighter.z + 2), k.anchor('center'), + k.color(safeColor(k,'#ff0000')), k.opacity(0.6), k.z(fighter.z + 2), k.anchor('center'), ]) eyeGlow.onUpdate(() => { eyeGlow.pos.x = fighter.pos.x + 5 * dir @@ -4925,7 +5620,7 @@ export async function createFightScene(config: FightSceneConfig) { activeMorphs.push({ obj: eyeGlow, type: 'beast' }) // Size increase + color tint - fighter.color = k.Color.fromHex(beastColors[0]) + fighter.color = safeColor(k,beastColors[0]) fighter.opacity = 0.9 } @@ -4970,7 +5665,7 @@ export async function createFightScene(config: FightSceneConfig) { revert: async () => { destroyMorphOverlays() screenFlash(theme.accent, 0.1) - fighter.color = k.Color.fromHex('#ffffff') + fighter.color = safeColor(k,'#ffffff') fighter.opacity = 1 await k.tween(0, 1, 0.2, (t) => { fighter.scale.x = savedScaleX * growFactor + t * (savedScaleX - savedScaleX * growFactor) @@ -4980,6 +5675,261 @@ export async function createFightScene(config: FightSceneConfig) { } } + // === EMOTION & SPORTSMANSHIP SYSTEM === + + const heartfeltLines = [ + 'What a warrior!', 'They gave it everything!', 'Heart of a champion!', + 'You can feel the respect!', 'That was beautiful!', 'Incredible spirit!', + 'They left it all in the ring!', 'A true fighter!', 'Nothing but respect!', + 'What courage!', 'The crowd is in tears!', 'What a moment!', + 'This is what it\'s all about!', 'Pure heart!', 'Standing ovation!', + ] + + const crowdSympathyLines = [ + 'Aww...', 'So close!', 'Almost had it!', 'Tough break!', + 'Next time!', 'Great effort though!', 'Don\'t give up!', + 'The crowd feels that one...', 'Ohhh...', 'Heartbreaking!', + ] + + const respectLines = [ + 'Good fight.', 'You fought well.', 'Respect.', 'Well played.', + 'That was fun!', 'Same time next week?', 'You\'re getting better!', + 'No hard feelings!', 'Honor to fight you!', 'GG.', + ] + + // Spawn floating text above a position + function spawnEmoteText(x: number, y: number, text: string, color: string, duration: number = 1.2) { + const label = k.add([ + k.text(text, { size: 10 }), + k.pos(x, y - 10), + k.color(safeColor(k,color)), + k.opacity(1), + k.z(60), + k.anchor('center'), + ]) + k.tween(label.pos.y, y - 50, duration, (v) => { label.pos.y = v }, k.easings.easeOutQuad) + k.tween(1, 0, duration, (v) => { label.opacity = v }, k.easings.easeInQuad).then(() => { + if (label.exists()) label.destroy() + }) + } + + // Spawn a heart particle + function spawnHeart(x: number, y: number) { + const colors = ['#ff4466', '#ff6688', '#ff2244', '#ff88aa', '#cc2244'] + const heart = k.add([ + k.text('\u2665', { size: 12 + Math.random() * 8 }), + k.pos(x + (Math.random() - 0.5) * 40, y), + k.color(safeColor(k,colors[Math.floor(Math.random() * colors.length)])), + k.opacity(0.8), + k.z(58), + k.anchor('center'), + ]) + const driftX = (Math.random() - 0.5) * 30 + k.tween(heart.pos.y, y - 60 - Math.random() * 40, 1.5, (v) => { + heart.pos.y = v + heart.pos.x += driftX * 0.01 + }, k.easings.easeOutQuad) + k.tween(0.8, 0, 1.5, (v) => { heart.opacity = v }).then(() => { if (heart.exists()) heart.destroy() }) + } + + // Crowd cheering signs pop up from bottom + function spawnCrowdSigns(count: number, color: string, text?: string) { + for (let i = 0; i < count; i++) { + const sx = 20 + Math.random() * (W - 40) + const sy = GROUND_Y + 10 + Math.random() * 20 + const sign = k.add([ + k.rect(18 + Math.random() * 12, 10 + Math.random() * 6), + k.pos(sx, sy), k.color(safeColor(k,color)), + k.opacity(0.7), k.z(2), k.anchor('center'), + ]) + const stick = k.add([ + k.rect(2, 12), k.pos(sx, sy + 8), + k.color(safeColor(k,'#aa8855')), k.opacity(0.6), k.z(1), k.anchor('center'), + ]) + const startY = sy + sign.onUpdate(() => { + sign.pos.y = startY + Math.sin(k.time() * (3 + i * 0.5)) * 5 + stick.pos.y = sign.pos.y + 8 + }) + if (text) { + const label = k.add([ + k.text(text, { size: 6 }), k.pos(sx, sy), + k.color(safeColor(k,'#000000')), k.opacity(0.8), k.z(3), k.anchor('center'), + ]) + label.onUpdate(() => { label.pos.x = sign.pos.x; label.pos.y = sign.pos.y }) + setTimeout(() => { if (label.exists()) label.destroy() }, 2500) + } + setTimeout(() => { if (sign.exists()) sign.destroy(); if (stick.exists()) stick.destroy() }, 2000 + Math.random() * 1000) + } + } + + // Respectful bow animation + async function playBow(fighter: any) { + const origScaleY = fighter.scale.y + await k.tween(fighter.scale.y, origScaleY * 0.85, 0.2, (v) => { fighter.scale.y = v }, k.easings.easeOutQuad) + await k.wait(0.3) + await k.tween(fighter.scale.y, origScaleY, 0.2, (v) => { fighter.scale.y = v }, k.easings.easeOutQuad) + } + + // Fist bump between two fighters + async function playFistBump(fighterA: any, fighterB: any) { + const midX = (fighterA.pos.x + fighterB.pos.x) / 2 + const origAX = fighterA.pos.x + const origBX = fighterB.pos.x + await Promise.all([ + k.tween(fighterA.pos.x, midX - 15, 0.3, (v) => { fighterA.pos.x = v }, k.easings.easeInOutQuad), + k.tween(fighterB.pos.x, midX + 15, 0.3, (v) => { fighterB.pos.x = v }, k.easings.easeInOutQuad), + ]) + sfxBlock() + spawnSparks(midX, fighterA.pos.y - 25, 6, '#ffe14d') + spawnEmoteText(midX, fighterA.pos.y - 40, 'GG!', '#ffe14d') + k.shake(2) + await k.wait(0.5) + await Promise.all([ + k.tween(fighterA.pos.x, origAX, 0.3, (v) => { fighterA.pos.x = v }, k.easings.easeInOutQuad), + k.tween(fighterB.pos.x, origBX, 0.3, (v) => { fighterB.pos.x = v }, k.easings.easeInOutQuad), + ]) + } + + // Winner helps loser back up + async function playHelpUp(winner: any, loser: any, winningSide: 'a' | 'b') { + const dir = winningSide === 'a' ? 1 : -1 + const origWX = winner.pos.x + await k.tween(winner.pos.x, loser.pos.x - dir * 30, 0.4, (v) => { winner.pos.x = v }, k.easings.easeInOutQuad) + winner.play('idle') + await k.wait(0.2) + loser.play('idle') + spawnEmoteText(loser.pos.x, loser.pos.y - 40, respectLines[Math.floor(Math.random() * respectLines.length)], '#88ccff') + for (let i = 0; i < 3; i++) spawnHeart((winner.pos.x + loser.pos.x) / 2, winner.pos.y - 30) + announceCool(heartfeltLines[Math.floor(Math.random() * heartfeltLines.length)]) + await k.wait(0.6) + await k.tween(winner.pos.x, origWX, 0.3, (v) => { winner.pos.x = v }, k.easings.easeInOutQuad) + } + + // === REFEREE LOBSTER FUNNY MOMENTS === + + async function judgeDoSomethingFunny() { + const judge = k.get('judge')[0] + if (!judge) return + + const judgeOrigY = judge.pos.y + const funnyAction = Math.floor(Math.random() * 8) + + if (funnyAction === 0) { + // Falls asleep, snaps awake + for (let z = 0; z < 3; z++) { + setTimeout(() => { + spawnEmoteText(judge.pos.x + 15, judge.pos.y - 20, 'Z', '#8888ff', 1.0) + }, z * 400) + } + await k.wait(1.2) + judge.play('shocked') + k.shake(2) + sfxBoing() + spawnEmoteText(judge.pos.x, judge.pos.y - 30, '!?', '#ff4444') + await k.tween(judge.pos.y, judgeOrigY - 20, 0.1, (v) => { judge.pos.y = v }, k.easings.easeOutQuad) + await k.tween(judge.pos.y, judgeOrigY, 0.15, (v) => { judge.pos.y = v }, k.easings.easeInQuad) + await k.wait(0.3) + judge.play('idle') + } else if (funnyAction === 1) { + // Little dance on the chair + for (let d = 0; d < 4; d++) { + await k.tween(judge.pos.y, judgeOrigY - 8, 0.08, (v) => { judge.pos.y = v }, k.easings.easeOutQuad) + await k.tween(judge.pos.y, judgeOrigY, 0.08, (v) => { judge.pos.y = v }, k.easings.easeInQuad) + judge.play(d % 2 === 0 ? 'call_left' : 'call_right') + } + sfxRandomSilly() + judge.play('idle') + } else if (funnyAction === 2) { + // Holds up a 10 score card + judge.play('shocked') + const card = k.add([ + k.rect(20, 14), k.pos(judge.pos.x + 20, judge.pos.y - 25), + k.color(safeColor(k,'#ffffff')), k.opacity(0.9), k.z(15), k.anchor('center'), + ]) + const score = k.add([ + k.text('10', { size: 10 }), k.pos(judge.pos.x + 20, judge.pos.y - 25), + k.color(safeColor(k,'#000000')), k.opacity(1), k.z(16), k.anchor('center'), + ]) + sfxBoing() + await k.wait(1.0) + card.destroy(); score.destroy() + judge.play('idle') + } else if (funnyAction === 3) { + // Gets scared and hides behind the chair + judge.play('shocked') + sfxDodge() + await k.tween(judge.pos.y, judgeOrigY + 20, 0.15, (v) => { judge.pos.y = v }, k.easings.easeInQuad) + judge.opacity = 0.3 + await k.wait(0.8) + await k.tween(judge.pos.y, judgeOrigY - 5, 0.2, (v) => { judge.pos.y = v }, k.easings.easeOutQuad) + judge.opacity = 0.9 + await k.tween(judge.pos.y, judgeOrigY, 0.1, (v) => { judge.pos.y = v }, k.easings.easeInOutQuad) + judge.play('idle') + } else if (funnyAction === 4) { + // Eats a tiny sandwich + const sandwich = k.add([ + k.rect(10, 8), k.pos(judge.pos.x - 15, judge.pos.y - 15), + k.color(safeColor(k,'#ddaa44')), k.opacity(0.9), k.z(15), k.anchor('center'), + ]) + const lettuce = k.add([ + k.rect(12, 2), k.pos(judge.pos.x - 15, judge.pos.y - 15), + k.color(safeColor(k,'#44cc22')), k.opacity(0.8), k.z(16), k.anchor('center'), + ]) + await k.wait(0.4) + await k.tween(sandwich.pos.x, judge.pos.x, 0.2, (v) => { sandwich.pos.x = v; lettuce.pos.x = v }, k.easings.easeInQuad) + sandwich.destroy(); lettuce.destroy() + spawnEmoteText(judge.pos.x, judge.pos.y - 30, 'nom nom', '#ffcc44') + sfxBonk() + await k.wait(0.6) + } else if (funnyAction === 5) { + // Takes a selfie + judge.play('call_right') + const phone = k.add([ + k.rect(6, 10), k.pos(judge.pos.x + 20, judge.pos.y - 20), + k.color(safeColor(k,'#333344')), k.opacity(0.9), k.z(15), k.anchor('center'), + ]) + await k.wait(0.3) + screenFlash('#ffffff', 0.05) + sfxZap() + spawnEmoteText(judge.pos.x, judge.pos.y - 35, '#selfie', '#ff88cc') + await k.wait(0.5) + phone.destroy() + judge.play('idle') + } else if (funnyAction === 6) { + // Waves a tiny flag + const flagColors = ['#ff2d2d', '#2d7bff', '#39ff14', '#ffcc00', '#ff6600', '#b83dff'] + const flagColor = flagColors[Math.floor(Math.random() * flagColors.length)] + const flag = k.add([ + k.rect(14, 9), k.pos(judge.pos.x + 18, judge.pos.y - 30), + k.color(safeColor(k,flagColor)), k.opacity(0.85), k.z(15), k.anchor('center'), + ]) + const pole = k.add([ + k.rect(2, 18), k.pos(judge.pos.x + 11, judge.pos.y - 22), + k.color(safeColor(k,'#aa8855')), k.opacity(0.8), k.z(14), k.anchor('center'), + ]) + flag.onUpdate(() => { + flag.pos.y = judge.pos.y - 30 + Math.sin(k.time() * 10) * 3 + }) + sfxRandomSilly() + await k.wait(1.5) + flag.destroy(); pole.destroy() + } else { + // Falls off the chair, climbs back up + judge.play('shocked') + sfxBoing() + await k.tween(judge.pos.y, GROUND_Y - 6, 0.3, (v) => { judge.pos.y = v }, k.easings.easeInQuad) + sfxBonk() + k.shake(4) + spawnEmoteText(judge.pos.x, GROUND_Y - 20, 'oof!', '#ff6644') + await k.wait(0.5) + await k.tween(judge.pos.y, judgeOrigY, 0.5, (v) => { judge.pos.y = v }, k.easings.easeOutQuad) + judge.play('idle') + spawnEmoteText(judge.pos.x, judge.pos.y - 25, '*ahem*', '#aaaaaa') + await k.wait(0.3) + } + } + return { k, @@ -4996,9 +5946,9 @@ export async function createFightScene(config: FightSceneConfig) { const dir = fromLeft ? 1 : -1 const startX = fromLeft ? -120 : W + 120 const carY = GROUND_Y - 20 - const carBody = k.add([k.rect(80, 30), k.pos(startX, carY), k.anchor('center'), k.color(k.Color.fromHex(['#ff2d2d', '#2d7bff', '#ffcc00', '#39ff14', '#ff6600'][Math.floor(Math.random() * 5)])), k.z(9), k.opacity(1)]) - const wheel1 = k.add([k.circle(8), k.pos(startX - 25 * dir, carY + 15), k.anchor('center'), k.color(k.Color.fromHex('#222222')), k.z(9)]) - const wheel2 = k.add([k.circle(8), k.pos(startX + 25 * dir, carY + 15), k.anchor('center'), k.color(k.Color.fromHex('#222222')), k.z(9)]) + const carBody = k.add([k.rect(80, 30), k.pos(startX, carY), k.anchor('center'), k.color(safeColor(k,['#ff2d2d', '#2d7bff', '#ffcc00', '#39ff14', '#ff6600'][Math.floor(Math.random() * 5)])), k.z(9), k.opacity(1)]) + const wheel1 = k.add([k.circle(8), k.pos(startX - 25 * dir, carY + 15), k.anchor('center'), k.color(safeColor(k,'#222222')), k.z(9)]) + const wheel2 = k.add([k.circle(8), k.pos(startX + 25 * dir, carY + 15), k.anchor('center'), k.color(safeColor(k,'#222222')), k.z(9)]) fighter.pos.x = startX fighter.pos.y = carY - 30 fighter.opacity = 1 @@ -5027,7 +5977,7 @@ export async function createFightScene(config: FightSceneConfig) { const trail: any[] = [] for (let i = 0; i < 6; i++) { setTimeout(() => { - const t = k.add([k.circle(4 + Math.random() * 6), k.pos(homeX + (Math.random() - 0.5) * 20, fighter.pos.y + 20), k.color(k.Color.fromHex(i < 3 ? '#ff6600' : '#ffcc00')), k.opacity(0.7), k.z(9)]) + const t = k.add([k.circle(4 + Math.random() * 6), k.pos(homeX + (Math.random() - 0.5) * 20, fighter.pos.y + 20), k.color(safeColor(k,i < 3 ? '#ff6600' : '#ffcc00')), k.opacity(0.7), k.z(9)]) trail.push(t) k.tween(t.opacity, 0, 0.4, (v) => { t.opacity = v }).then(() => { if (t.exists()) t.destroy() }) }, i * 40) @@ -5048,7 +5998,7 @@ export async function createFightScene(config: FightSceneConfig) { fighter.opacity = 1 // Robe overlay const robeColor = ['#8b0000', '#00008b', '#006400', '#4b0082', '#8b4513'][Math.floor(Math.random() * 5)] - const robe = k.add([k.rect(50, 55), k.pos(startX, GROUND_Y - 30), k.anchor('center'), k.color(k.Color.fromHex(robeColor)), k.opacity(0.85), k.z(11)]) + const robe = k.add([k.rect(50, 55), k.pos(startX, GROUND_Y - 30), k.anchor('center'), k.color(safeColor(k,robeColor)), k.opacity(0.85), k.z(11)]) announceDeepIntro() // Slow walk in await k.tween(startX, homeX, 0.8, (v) => { fighter.pos.x = v; robe.pos.x = v }, k.easings.easeInOutQuad) @@ -5069,8 +6019,8 @@ export async function createFightScene(config: FightSceneConfig) { fighter.opacity = 1 // Girlfriend silhouette const gfX = startX + dir * 40 - const gf = k.add([k.rect(25, 45), k.pos(gfX, GROUND_Y - 25), k.anchor('center'), k.color(k.Color.fromHex('#ff69b4')), k.opacity(0.9), k.z(11)]) - const heart = k.add([k.text('!', { size: 16 }), k.pos(gfX, GROUND_Y - 65), k.anchor('center'), k.color(k.Color.fromHex('#ff0000')), k.z(12)]) + const gf = k.add([k.rect(25, 45), k.pos(gfX, GROUND_Y - 25), k.anchor('center'), k.color(safeColor(k,'#ff69b4')), k.opacity(0.9), k.z(11)]) + const heart = k.add([k.text('!', { size: 16 }), k.pos(gfX, GROUND_Y - 65), k.anchor('center'), k.color(safeColor(k,'#ff0000')), k.z(12)]) announceSilly('We need to talk!') // Walk in arguing await k.tween(startX, homeX + dir * 30, 0.6, (v) => { @@ -5094,8 +6044,8 @@ export async function createFightScene(config: FightSceneConfig) { fighter.pos.y = -100 fighter.opacity = 1 // Helicopter body - const heli = k.add([k.rect(60, 20), k.pos(homeX, -80), k.anchor('center'), k.color(k.Color.fromHex('#555555')), k.z(12)]) - const blade = k.add([k.rect(80, 3), k.pos(homeX, -95), k.anchor('center'), k.color(k.Color.fromHex('#888888')), k.z(13), k.rotate(0)]) + const heli = k.add([k.rect(60, 20), k.pos(homeX, -80), k.anchor('center'), k.color(safeColor(k,'#555555')), k.z(12)]) + const blade = k.add([k.rect(80, 3), k.pos(homeX, -95), k.anchor('center'), k.color(safeColor(k,'#888888')), k.z(13), k.rotate(0)]) blade.onUpdate(() => { blade.angle += 720 * k.dt() }) sfxJetpack() // Descend @@ -5135,9 +6085,9 @@ export async function createFightScene(config: FightSceneConfig) { // 6: Skateboard ride in async (fighter: any, homeX: number, fromLeft: boolean) => { const startX = fromLeft ? -80 : W + 80 - const board = k.add([k.rect(40, 6), k.pos(startX, GROUND_Y - 3), k.anchor('center'), k.color(k.Color.fromHex('#884422')), k.z(9)]) - const wheelL = k.add([k.circle(4), k.pos(startX - 14, GROUND_Y + 1), k.anchor('center'), k.color(k.Color.fromHex('#333')), k.z(9)]) - const wheelR = k.add([k.circle(4), k.pos(startX + 14, GROUND_Y + 1), k.anchor('center'), k.color(k.Color.fromHex('#333')), k.z(9)]) + const board = k.add([k.rect(40, 6), k.pos(startX, GROUND_Y - 3), k.anchor('center'), k.color(safeColor(k,'#884422')), k.z(9)]) + const wheelL = k.add([k.circle(4), k.pos(startX - 14, GROUND_Y + 1), k.anchor('center'), k.color(safeColor(k,'#333')), k.z(9)]) + const wheelR = k.add([k.circle(4), k.pos(startX + 14, GROUND_Y + 1), k.anchor('center'), k.color(safeColor(k,'#333')), k.z(9)]) fighter.pos.x = startX; fighter.pos.y = GROUND_Y - 14; fighter.opacity = 1 sfxZoomWhoosh() await k.tween(startX, homeX, 0.5, (v) => { @@ -5159,7 +6109,7 @@ export async function createFightScene(config: FightSceneConfig) { // Fly in with flame trail const flames: any[] = [] const interval = setInterval(() => { - const f = k.add([k.circle(5 + Math.random() * 5), k.pos(fighter.pos.x, fighter.pos.y + 25), k.color(k.Color.fromHex(Math.random() > 0.5 ? '#ff6600' : '#ffcc00')), k.opacity(0.8), k.z(9)]) + const f = k.add([k.circle(5 + Math.random() * 5), k.pos(fighter.pos.x, fighter.pos.y + 25), k.color(safeColor(k,Math.random() > 0.5 ? '#ff6600' : '#ffcc00')), k.opacity(0.8), k.z(9)]) flames.push(f) k.tween(f.opacity, 0, 0.3, (v) => { f.opacity = v }).then(() => { if (f.exists()) f.destroy() }) }, 30) @@ -5176,8 +6126,8 @@ export async function createFightScene(config: FightSceneConfig) { async (fighter: any, homeX: number, _fromLeft: boolean) => { const portalColor = ['#b83dff', '#00f0ff', '#39ff14', '#ff2d7b'][Math.floor(Math.random() * 4)] // Draw portal - const portal = k.add([k.circle(35), k.pos(homeX, GROUND_Y - 30), k.anchor('center'), k.color(k.Color.fromHex(portalColor)), k.opacity(0), k.z(9)]) - const portalRing = k.add([k.circle(40), k.pos(homeX, GROUND_Y - 30), k.anchor('center'), k.color(k.Color.fromHex('#ffffff')), k.opacity(0), k.z(8)]) + const portal = k.add([k.circle(35), k.pos(homeX, GROUND_Y - 30), k.anchor('center'), k.color(safeColor(k,portalColor)), k.opacity(0), k.z(9)]) + const portalRing = k.add([k.circle(40), k.pos(homeX, GROUND_Y - 30), k.anchor('center'), k.color(safeColor(k,'#ffffff')), k.opacity(0), k.z(8)]) sfxZap() await k.tween(0, 0.7, 0.3, (v) => { portal.opacity = v; portalRing.opacity = v * 0.4 }, k.easings.easeOutQuad) fighter.pos.x = homeX; fighter.pos.y = GROUND_Y - 30 @@ -5216,8 +6166,8 @@ export async function createFightScene(config: FightSceneConfig) { async (fighter: any, homeX: number, _fromLeft: boolean) => { fighter.pos.x = homeX; fighter.pos.y = GROUND_Y + 60; fighter.opacity = 0.5 // Crack the ground - const crack1 = k.add([k.rect(3, 15), k.pos(homeX - 10, GROUND_Y - 5), k.color(k.Color.fromHex('#ffcc00')), k.opacity(0.7), k.z(9), k.rotate(15)]) - const crack2 = k.add([k.rect(3, 12), k.pos(homeX + 8, GROUND_Y - 3), k.color(k.Color.fromHex('#ffcc00')), k.opacity(0.6), k.z(9), k.rotate(-20)]) + const crack1 = k.add([k.rect(3, 15), k.pos(homeX - 10, GROUND_Y - 5), k.color(safeColor(k,'#ffcc00')), k.opacity(0.7), k.z(9), k.rotate(15)]) + const crack2 = k.add([k.rect(3, 12), k.pos(homeX + 8, GROUND_Y - 3), k.color(safeColor(k,'#ffcc00')), k.opacity(0.6), k.z(9), k.rotate(-20)]) sfxExplosion(); k.shake(8) await k.wait(0.2) // Rise up @@ -5236,7 +6186,7 @@ export async function createFightScene(config: FightSceneConfig) { await k.tween(startX, homeX + (fromLeft ? 40 : -40), 0.4, (v) => { fighter.pos.x = v if (Math.random() < 0.3) { - const ice = k.add([k.rect(8, 3), k.pos(v, GROUND_Y - 2), k.color(k.Color.fromHex('#aaeeff')), k.opacity(0.5), k.z(1)]) + const ice = k.add([k.rect(8, 3), k.pos(v, GROUND_Y - 2), k.color(safeColor(k,'#aaeeff')), k.opacity(0.5), k.z(1)]) iceTrail.push(ice) } }, k.easings.easeOutQuad) @@ -5248,9 +6198,9 @@ export async function createFightScene(config: FightSceneConfig) { // 12: Parachute drop async (fighter: any, homeX: number, _fromLeft: boolean) => { fighter.pos.x = homeX + (Math.random() - 0.5) * 60; fighter.pos.y = -120; fighter.opacity = 1 - const chute = k.add([k.circle(30), k.pos(fighter.pos.x, fighter.pos.y - 35), k.anchor('center'), k.color(k.Color.fromHex(['#ff2d2d', '#2d7bff', '#39ff14', '#ffcc00'][Math.floor(Math.random() * 4)])), k.opacity(0.8), k.z(12)]) - const line1 = k.add([k.rect(1, 30), k.pos(fighter.pos.x - 10, fighter.pos.y - 20), k.color(k.Color.fromHex('#888')), k.z(11)]) - const line2 = k.add([k.rect(1, 30), k.pos(fighter.pos.x + 10, fighter.pos.y - 20), k.color(k.Color.fromHex('#888')), k.z(11)]) + const chute = k.add([k.circle(30), k.pos(fighter.pos.x, fighter.pos.y - 35), k.anchor('center'), k.color(safeColor(k,['#ff2d2d', '#2d7bff', '#39ff14', '#ffcc00'][Math.floor(Math.random() * 4)])), k.opacity(0.8), k.z(12)]) + const line1 = k.add([k.rect(1, 30), k.pos(fighter.pos.x - 10, fighter.pos.y - 20), k.color(safeColor(k,'#888')), k.z(11)]) + const line2 = k.add([k.rect(1, 30), k.pos(fighter.pos.x + 10, fighter.pos.y - 20), k.color(safeColor(k,'#888')), k.z(11)]) // Float down await k.tween(-120, GROUND_Y - 50, 0.7, (v) => { fighter.pos.y = v; chute.pos.y = v - 35; line1.pos.y = v - 20; line2.pos.y = v - 20 @@ -5283,7 +6233,7 @@ export async function createFightScene(config: FightSceneConfig) { const doorX = fromLeft ? -30 : W + 30 fighter.pos.x = doorX; fighter.pos.y = GROUND_Y - 6; fighter.opacity = 1 // Bouncer arm - const arm = k.add([k.rect(40, 15), k.pos(doorX, GROUND_Y - 30), k.anchor(fromLeft ? 'left' : 'right'), k.color(k.Color.fromHex('#444444')), k.z(12)]) + const arm = k.add([k.rect(40, 15), k.pos(doorX, GROUND_Y - 30), k.anchor(fromLeft ? 'left' : 'right'), k.color(safeColor(k,'#444444')), k.z(12)]) announceSilly('And stay out!') await k.wait(0.3) // Throw @@ -5302,13 +6252,13 @@ export async function createFightScene(config: FightSceneConfig) { async (fighter: any, homeX: number, _fromLeft: boolean) => { fighter.pos.x = homeX; fighter.pos.y = GROUND_Y - 6; fighter.opacity = 0 // Lightning bolt from sky - const bolt = k.add([k.rect(4, H), k.pos(homeX, 0), k.color(k.Color.fromHex('#ffff44')), k.opacity(0.9), k.z(20)]) + const bolt = k.add([k.rect(4, H), k.pos(homeX, 0), k.color(safeColor(k,'#ffff44')), k.opacity(0.9), k.z(20)]) sfxZap(); screenFlash('#ffffff', 0.15); k.shake(12) await k.wait(0.1) bolt.destroy() // Smoke / reveal for (let i = 0; i < 8; i++) { - const smoke = k.add([k.circle(10 + Math.random() * 15), k.pos(homeX + (Math.random() - 0.5) * 40, GROUND_Y - 20 - Math.random() * 30), k.color(k.Color.fromHex('#aaaaaa')), k.opacity(0.6), k.z(11)]) + const smoke = k.add([k.circle(10 + Math.random() * 15), k.pos(homeX + (Math.random() - 0.5) * 40, GROUND_Y - 20 - Math.random() * 30), k.color(safeColor(k,'#aaaaaa')), k.opacity(0.6), k.z(11)]) k.tween(smoke.opacity, 0, 0.5, (v) => { smoke.opacity = v; smoke.pos.y -= 1 }).then(() => { if (smoke.exists()) smoke.destroy() }) } await k.wait(0.3) @@ -5324,7 +6274,7 @@ export async function createFightScene(config: FightSceneConfig) { const hands: any[] = [] for (let i = 0; i < 8; i++) { const hx = startX + (fromLeft ? 1 : -1) * (Math.abs(homeX - startX) / 8) * i - const h = k.add([k.rect(6, 20), k.pos(hx, GROUND_Y - 10), k.anchor('bot'), k.color(k.Color.fromHex('#cc9966')), k.z(8)]) + const h = k.add([k.rect(6, 20), k.pos(hx, GROUND_Y - 10), k.anchor('bot'), k.color(safeColor(k,'#cc9966')), k.z(8)]) hands.push(h) } announceCrowdReaction('cheer') @@ -5340,8 +6290,8 @@ export async function createFightScene(config: FightSceneConfig) { // 17: Riding a shopping cart async (fighter: any, homeX: number, fromLeft: boolean) => { const startX = fromLeft ? -100 : W + 100 - const cartBody = k.add([k.rect(45, 30), k.pos(startX, GROUND_Y - 18), k.anchor('center'), k.color(k.Color.fromHex('#888888')), k.opacity(0.9), k.z(9)]) - const cartWheel = k.add([k.circle(5), k.pos(startX + (fromLeft ? 15 : -15), GROUND_Y - 3), k.anchor('center'), k.color(k.Color.fromHex('#444')), k.z(9)]) + const cartBody = k.add([k.rect(45, 30), k.pos(startX, GROUND_Y - 18), k.anchor('center'), k.color(safeColor(k,'#888888')), k.opacity(0.9), k.z(9)]) + const cartWheel = k.add([k.circle(5), k.pos(startX + (fromLeft ? 15 : -15), GROUND_Y - 3), k.anchor('center'), k.color(safeColor(k,'#444')), k.z(9)]) fighter.pos.x = startX; fighter.pos.y = GROUND_Y - 40; fighter.opacity = 1 announceSilly('Weeee!') sfxZoomWhoosh() @@ -5361,7 +6311,7 @@ export async function createFightScene(config: FightSceneConfig) { const startX = fromLeft ? -60 : W + 60 fighter.pos.x = startX; fighter.pos.y = GROUND_Y - 6; fighter.opacity = 1 // Spotlight cone - const spot = k.add([k.rect(60, H), k.pos(startX - 30, 0), k.color(k.Color.fromHex('#ffe14d')), k.opacity(0.08), k.z(1)]) + const spot = k.add([k.rect(60, H), k.pos(startX - 30, 0), k.color(safeColor(k,'#ffe14d')), k.opacity(0.08), k.z(1)]) announceDramatic('The champion arrives!') await k.tween(startX, homeX, 1.0, (v) => { fighter.pos.x = v; spot.pos.x = v - 30 @@ -5375,14 +6325,14 @@ export async function createFightScene(config: FightSceneConfig) { async (fighter: any, homeX: number, fromLeft: boolean) => { const cannonX = fromLeft ? -40 : W + 40 // Draw cannon - const cannon = k.add([k.rect(50, 25), k.pos(cannonX, GROUND_Y - 20), k.anchor('center'), k.color(k.Color.fromHex('#333333')), k.z(9), k.rotate(fromLeft ? -30 : 210)]) + const cannon = k.add([k.rect(50, 25), k.pos(cannonX, GROUND_Y - 20), k.anchor('center'), k.color(safeColor(k,'#333333')), k.z(9), k.rotate(fromLeft ? -30 : 210)]) fighter.pos.x = cannonX; fighter.pos.y = GROUND_Y - 20; fighter.opacity = 0 await k.wait(0.3) // Fire! sfxGunshot(); sfxExplosion() fighter.opacity = 1 screenFlash('#ffcc00', 0.1) - const flashCircle = k.add([k.circle(20), k.pos(cannonX + (fromLeft ? 25 : -25), GROUND_Y - 35), k.color(k.Color.fromHex('#ffee00')), k.opacity(0.9), k.z(12)]) + const flashCircle = k.add([k.circle(20), k.pos(cannonX + (fromLeft ? 25 : -25), GROUND_Y - 35), k.color(safeColor(k,'#ffee00')), k.opacity(0.9), k.z(12)]) setTimeout(() => { if (flashCircle.exists()) flashCircle.destroy() }, 80) // Arc to position await Promise.all([ @@ -5515,7 +6465,7 @@ export async function createFightScene(config: FightSceneConfig) { const lineY = GROUND_Y - 5 - Math.random() * 130 const line = k.add([ k.rect(W, 1 + Math.random()), k.pos(0, lineY), - k.color(k.Color.fromHex(Math.random() > 0.5 ? '#ffffff' : theme.accent)), + k.color(safeColor(k,Math.random() > 0.5 ? '#ffffff' : theme.accent)), k.opacity(0.15 + Math.random() * 0.1), k.z(25), ]) line.onUpdate(() => { line.opacity = 0.1 + Math.sin(k.time() * 8 + i * 2) * 0.08 }) @@ -5681,6 +6631,42 @@ export async function createFightScene(config: FightSceneConfig) { // Regular round win: crowd reacts (40%) if (Math.random() < 0.4) announceCrowdReaction(Math.random() < 0.5 ? 'cheer' : 'applause') } + + // Emotion: heartfelt announcer moment (8% chance on normal rounds) + if (Math.random() < 0.08) { + announceCool(heartfeltLines[Math.floor(Math.random() * heartfeltLines.length)]) + } + + // Crowd sympathy for the loser on devastating rounds (25%) + if (isCritical && (aWon || bWon) && Math.random() < 0.25) { + const loser = k.get(aWon ? 'fighterB' : 'fighterA')[0] + if (loser) { + announceSilly(crowdSympathyLines[Math.floor(Math.random() * crowdSympathyLines.length)]) + spawnCrowdSigns(3, '#4488ff', '\u2665') + } + } + + // Crowd signs on big combos (20%) + if ((comboA >= 3 || comboB >= 3) && Math.random() < 0.2) { + const comboName = comboA >= 3 ? botA.name : botB.name + spawnCrowdSigns(4, '#ffe14d', comboName.slice(0, 6)) + } + + // Respect nod between fighters on close rounds (10% when margin <= 1) + if (margin <= 1 && Math.random() < 0.1) { + const fA2 = k.get('fighterA')[0] + const fB2 = k.get('fighterB')[0] + if (fA2 && fB2) { + spawnEmoteText(fA2.pos.x, fA2.pos.y - 45, respectLines[Math.floor(Math.random() * respectLines.length)], '#88ccff') + await k.wait(0.3) + spawnEmoteText(fB2.pos.x, fB2.pos.y - 45, respectLines[Math.floor(Math.random() * respectLines.length)], '#88ccff') + } + } + + // Referee lobster does something funny (5% chance per round) + if (Math.random() < 0.05) { + await judgeDoSomethingFunny() + } }, async playTaunt(side: 'a' | 'b') { @@ -5829,13 +6815,13 @@ export async function createFightScene(config: FightSceneConfig) { const gunLen = 80 const gx = winner.pos.x + dir * 20 const gy = winner.pos.y - 25 - const barrel = k.add([k.rect(gunLen, 20), k.pos(gx, gy), k.color(k.Color.fromHex('#333333')), k.opacity(1), k.z(16), k.scale(0.1)]) + const barrel = k.add([k.rect(gunLen, 20), k.pos(gx, gy), k.color(safeColor(k,'#333333')), k.opacity(1), k.z(16), k.scale(0.1)]) await k.tween(0.1, 1, 0.12, (v) => { barrel.scale = k.vec2(v, v) }, k.easings.easeOutBack) sfxBoing() // Fire 3 massive shots for (let s = 0; s < 3; s++) { sfxGunshot(); sfxExplosion(); k.shake(12 + s * 3) - const flash = k.add([k.circle(15), k.pos(gx + dir * gunLen, gy), k.color(k.Color.fromHex('#ffee00')), k.opacity(0.9), k.z(18)]) + const flash = k.add([k.circle(15), k.pos(gx + dir * gunLen, gy), k.color(safeColor(k,'#ffee00')), k.opacity(0.9), k.z(18)]) setTimeout(() => { if (flash.exists()) flash.destroy() }, 50) await spawnProjectile(gx + dir * gunLen, gy, loser.pos.x, loser.pos.y - 20, '#ffcc00', 12) sfxBulletHit() @@ -5884,6 +6870,33 @@ export async function createFightScene(config: FightSceneConfig) { }, i * 200) } await k.wait(0.5) + + // Post-KO sportsmanship (30% chance): winner helps loser up or they fist bump + if (Math.random() < 0.3) { + const sportsType = Math.random() + if (sportsType < 0.35) { + // Winner helps loser back up + await playHelpUp(winner, loser, winningSide) + } else if (sportsType < 0.65) { + // Fist bump + loser.play('idle') + await k.wait(0.2) + await playFistBump(winner, loser) + } else if (sportsType < 0.85) { + // Both bow + loser.play('idle') + await k.wait(0.2) + await Promise.all([playBow(winner), playBow(loser)]) + announceCool(heartfeltLines[Math.floor(Math.random() * heartfeltLines.length)]) + for (let i = 0; i < 5; i++) spawnHeart(W / 2 + (Math.random() - 0.5) * 100, GROUND_Y - 60) + } else { + // Crowd shows love — signs pop up + spawnCrowdSigns(5, '#ff4466', '\u2665') + announceDramatic(heartfeltLines[Math.floor(Math.random() * heartfeltLines.length)]) + for (let i = 0; i < 8; i++) spawnHeart(Math.random() * W, GROUND_Y - 40 - Math.random() * 60) + await k.wait(0.8) + } + } }, async playPerfect(winningSide: 'a' | 'b', winnerName: string) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 76b54b3..2a9679e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,6 +57,9 @@ importers: better-sqlite3: specifier: ^11.9.1 version: 11.10.0 + chalk: + specifier: ^5.6.2 + version: 5.6.2 drizzle-orm: specifier: ^0.40.1 version: 0.40.1(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0)(gel@2.2.0) @@ -950,6 +953,10 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + check-error@2.1.3: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} @@ -2270,6 +2277,8 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + chalk@5.6.2: {} + check-error@2.1.3: {} chownr@1.1.4: {} diff --git a/server/package.json b/server/package.json index e1f3a5d..96c71f2 100644 --- a/server/package.json +++ b/server/package.json @@ -11,10 +11,11 @@ "migrate": "tsx src/db/migrate.ts" }, "dependencies": { - "hono": "^4.7.6", "@hono/node-server": "^1.14.1", - "drizzle-orm": "^0.40.1", "better-sqlite3": "^11.9.1", + "chalk": "^5.6.2", + "drizzle-orm": "^0.40.1", + "hono": "^4.7.6", "nanoid": "^5.1.5" }, "devDependencies": { diff --git a/server/src/app.ts b/server/src/app.ts index 39cfc0f..b3869aa 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -5,6 +5,9 @@ import { botsRouter } from './routes/bots.js' import { fightsRouter } from './routes/fights.js' import { queueRouter } from './routes/queue.js' import { authRouter } from './routes/auth.js' +import { docsRouter } from './routes/docs.js' +import { rateLimit } from './middleware/rate-limit.js' +import { cleanupOrphanedFights } from './engine/orchestrator.js' export const app = new Hono() @@ -16,9 +19,20 @@ app.onError((err, c) => { app.use('*', logger()) app.use('/api/*', cors({ origin: '*' })) +// Rate limit all POST endpoints (60/min per IP) +app.use('/api/*', rateLimit(60_000, 60)) + app.get('/api/health', (c) => c.json({ status: 'ok', name: 'botfights' })) app.route('/api/auth', authRouter) app.route('/api/bots', botsRouter) app.route('/api/fights', fightsRouter) app.route('/api/queue', queueRouter) +app.route('/api/docs', docsRouter) + +// Cleanup orphaned fights on startup +cleanupOrphanedFights().then(() => { + console.log('[botfights] orphaned fights cleaned up') +}).catch(err => { + console.error('[botfights] cleanup error:', err) +}) diff --git a/server/src/db/index.ts b/server/src/db/index.ts index f745c18..6e279af 100644 --- a/server/src/db/index.ts +++ b/server/src/db/index.ts @@ -14,4 +14,4 @@ sqlite.pragma('journal_mode = WAL') sqlite.pragma('foreign_keys = ON') export const db = drizzle(sqlite, { schema }) -export { schema } +export { schema, sqlite } diff --git a/server/src/db/migrate.ts b/server/src/db/migrate.ts index 30c9f5a..2b9bf92 100644 --- a/server/src/db/migrate.ts +++ b/server/src/db/migrate.ts @@ -28,6 +28,9 @@ sqlite.exec(` best_streak INTEGER NOT NULL DEFAULT 0, tier INTEGER NOT NULL DEFAULT 0, is_active INTEGER NOT NULL DEFAULT 1, + last_fight_at TEXT, + consecutive_errors INTEGER NOT NULL DEFAULT 0, + last_error_at TEXT, created_at TEXT NOT NULL ); @@ -38,8 +41,8 @@ sqlite.exec(` arena TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'scheduled', winner_id TEXT REFERENCES bots(id), - bot_a_hp INTEGER NOT NULL DEFAULT 100, - bot_b_hp INTEGER NOT NULL DEFAULT 100, + bot_a_hp INTEGER NOT NULL DEFAULT 200, + bot_b_hp INTEGER NOT NULL DEFAULT 200, total_rounds INTEGER NOT NULL DEFAULT 0, scheduled_at TEXT, started_at TEXT, @@ -69,6 +72,9 @@ sqlite.exec(` const migrations = [ `ALTER TABLE bots ADD COLUMN archetype TEXT NOT NULL DEFAULT 'standard'`, `ALTER TABLE bots ADD COLUMN profile_pic_url TEXT`, + `ALTER TABLE bots ADD COLUMN last_fight_at TEXT`, + `ALTER TABLE bots ADD COLUMN consecutive_errors INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE bots ADD COLUMN last_error_at TEXT`, ] for (const sql of migrations) { diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index b52662f..a99302f 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -16,6 +16,9 @@ export const bots = sqliteTable('bots', { bestStreak: integer('best_streak').notNull().default(0), tier: integer('tier').notNull().default(0), isActive: integer('is_active', { mode: 'boolean' }).notNull().default(true), + lastFightAt: text('last_fight_at'), + consecutiveErrors: integer('consecutive_errors').notNull().default(0), + lastErrorAt: text('last_error_at'), createdAt: text('created_at').notNull(), }) diff --git a/server/src/engine/answers.ts b/server/src/engine/answers.ts index ffc46b9..d932658 100644 --- a/server/src/engine/answers.ts +++ b/server/src/engine/answers.ts @@ -1,7 +1,7 @@ /** * Answer checking for factual challenges. * Handles: case insensitivity, numeric equivalence, containment matching, - * number words (forty = 40), stripped punctuation/articles. + * number words (forty = 40), basic stemming, contraction normalization. */ const NUMBER_WORDS: Record = { @@ -23,6 +23,47 @@ function normalize(s: string): string { .trim() } +/** Expand contractions so "can't" matches "cannot", "don't" matches "do not" etc. */ +function expandContractions(s: string): string { + return s + .replace(/\bcan'?t\b/gi, 'cannot') + .replace(/\bdon'?t\b/gi, 'do not') + .replace(/\bwon'?t\b/gi, 'will not') + .replace(/\bdoesn'?t\b/gi, 'does not') + .replace(/\bisn'?t\b/gi, 'is not') + .replace(/\baren'?t\b/gi, 'are not') + .replace(/\bwasn'?t\b/gi, 'was not') + .replace(/\bweren'?t\b/gi, 'were not') + .replace(/\bhasn'?t\b/gi, 'has not') + .replace(/\bhaven'?t\b/gi, 'have not') + .replace(/\bhadn'?t\b/gi, 'had not') + .replace(/\bcouldn'?t\b/gi, 'could not') + .replace(/\bshouldn'?t\b/gi, 'should not') + .replace(/\bwouldn'?t\b/gi, 'would not') + .replace(/\bit'?s\b/gi, 'it is') + .replace(/\bthat'?s\b/gi, 'that is') + .replace(/\bthey'?re\b/gi, 'they are') + .replace(/\bwe'?re\b/gi, 'we are') + .replace(/\byou'?re\b/gi, 'you are') +} + +/** Basic English stemming: strip common suffixes for loose comparison. */ +function stem(word: string): string { + if (word.length <= 3) return word + // Plural: buses → bus, foxes → fox, tries → tri (imperfect but ok) + if (word.endsWith('ies') && word.length > 4) return word.slice(0, -3) + 'y' + if (word.endsWith('ses') || word.endsWith('xes') || word.endsWith('zes') || word.endsWith('ches') || word.endsWith('shes')) { + return word.slice(0, -2) + } + if (word.endsWith('s') && !word.endsWith('ss')) return word.slice(0, -1) + return word +} + +/** Stem all words in a string. */ +function stemAll(s: string): string { + return s.split(/\s+/).map(stem).join(' ') +} + function tryParseNumber(s: string): number | null { const cleaned = normalize(s) @@ -55,7 +96,7 @@ function tryParseNumber(s: string): number | null { /** * Check if a bot's response matches any of the accepted answers. - * Returns a confidence score: 1.0 = definite match, 0.5 = partial, 0 = no match. + * Returns a confidence score: 1.0 = definite match, 0.5-0.9 = partial, 0 = no match. */ export function checkAnswer(response: string | null, acceptedAnswers: string[]): number { if (!response || response.trim() === '') return 0 @@ -63,48 +104,77 @@ export function checkAnswer(response: string | null, acceptedAnswers: string[]): const normResponse = normalize(response) if (normResponse === '') return 0 + // Pre-compute expanded/stemmed versions once + const expandedResponse = normalize(expandContractions(response)) + const stemmedResponse = stemAll(normResponse) + for (const accepted of acceptedAnswers) { const normAccepted = normalize(accepted) + const expandedAccepted = normalize(expandContractions(accepted)) + const stemmedAccepted = stemAll(normAccepted) // 1. Exact match after normalization if (normResponse === normAccepted) return 1.0 - // 2. Numeric equivalence + // 2. Exact match after contraction expansion + if (expandedResponse === expandedAccepted) return 1.0 + + // 3. Exact match after stemming (catches plural/singular) + if (stemmedResponse === stemmedAccepted) return 1.0 + + // 4. Numeric equivalence const respNum = tryParseNumber(normResponse) const accNum = tryParseNumber(normAccepted) if (respNum !== null && accNum !== null && respNum === accNum) return 1.0 - // 3. Response contains the accepted answer + // 5. Response contains the accepted answer (or stemmed version) if (normResponse.includes(normAccepted) && normAccepted.length >= 2) return 1.0 + if (stemmedResponse.includes(stemmedAccepted) && stemmedAccepted.length >= 2) return 0.95 - // 4. Accepted answer contains the response (for short definitive answers) + // 6. Accepted answer contains the response (for short definitive answers) if (normAccepted.includes(normResponse) && normResponse.length >= 3) return 0.8 + if (stemmedAccepted.includes(stemmedResponse) && stemmedResponse.length >= 3) return 0.75 - // 5. Check if number appears anywhere in a longer response + // 7. Check if number appears anywhere in a longer response if (accNum !== null) { - // Look for the number in the response text const numStr = String(accNum) if (normResponse.includes(numStr)) return 1.0 // Check number words in response - const responseNum = tryParseNumber(normResponse.split(/\s+/).find(w => tryParseNumber(w) !== null) || '') - if (responseNum !== null && responseNum === accNum) return 0.9 + const responseWords = normResponse.split(/\s+/) + for (const w of responseWords) { + const parsed = tryParseNumber(w) + if (parsed !== null && parsed === accNum) return 0.9 + } } - // 6. Word-level containment — all words of the answer appear in the response + // 8. Word-level containment — all words of the answer appear in the response const acceptedWords = normAccepted.split(/\s+/) if (acceptedWords.length >= 2) { const allFound = acceptedWords.every(w => normResponse.includes(w)) if (allFound) return 0.9 + // Try with stemming + const stemmedAccWords = stemmedAccepted.split(/\s+/) + const allStemFound = stemmedAccWords.every(w => stemmedResponse.includes(w)) + if (allStemFound) return 0.85 + } + + // 9. Contraction-expanded word containment + if (expandedAccepted.split(/\s+/).length >= 2) { + const allFound = expandedAccepted.split(/\s+/).every(w => expandedResponse.includes(w)) + if (allFound) return 0.85 } } - // 7. For true/false questions, check if the response starts with the right keyword + // 10. For true/false questions, check if the response starts with the right keyword const tfAnswer = acceptedAnswers.find(a => a.toLowerCase() === 'true' || a.toLowerCase() === 'false') if (tfAnswer) { const firstWord = normResponse.split(/\s+/)[0] if (firstWord === tfAnswer.toLowerCase()) return 1.0 - // "that's true" / "this is false" etc. + // "that's true" / "this is false" / "yes" for true / "no" for false if (normResponse.includes(tfAnswer.toLowerCase())) return 0.9 + // "yes" ≈ true, "no" ≈ false + if (tfAnswer.toLowerCase() === 'true' && (firstWord === 'yes' || firstWord === 'correct' || firstWord === 'right')) return 0.9 + if (tfAnswer.toLowerCase() === 'false' && (firstWord === 'no' || firstWord === 'incorrect' || firstWord === 'wrong')) return 0.9 } return 0 diff --git a/server/src/engine/challenges.ts b/server/src/engine/challenges.ts index 11c60d1..e8ee45c 100644 --- a/server/src/engine/challenges.ts +++ b/server/src/engine/challenges.ts @@ -112,7 +112,7 @@ const TEMPLATES: ChallengeTemplate[] = [ baseDamage: 22, prompts: [ { prompt: 'I have cities but no houses, forests but no trees, and water but no fish. What am I?', answers: ['map', 'a map'] }, - { prompt: 'The more you take, the more you leave behind. What am I?', answers: ['footsteps', 'steps'] }, + { prompt: 'The more you take, the more you leave behind. What am I?', answers: ['footsteps', 'steps', 'footstep'] }, { prompt: 'I speak without a mouth and hear without ears. I have no body, but I come alive with the wind. What am I?', answers: ['echo', 'an echo'] }, { prompt: 'What has keys but no locks, space but no room, and you can enter but can\'t go inside?', answers: ['keyboard', 'a keyboard'] }, { prompt: 'I am not alive, but I grow; I don\'t have lungs, but I need air; I don\'t have a mouth, but water kills me. What am I?', answers: ['fire', 'flame'] }, @@ -207,9 +207,9 @@ const TEMPLATES: ChallengeTemplate[] = [ { prompt: 'I am an odd number. Take away a letter and I become even. What number am I?', answers: ['seven', '7'] }, { prompt: 'A farmer has 17 sheep. All but 9 run away. How many does the farmer have left?', answers: ['9', 'nine'] }, { prompt: 'How many times can you subtract 5 from 25?', answers: ['1', 'one', 'once'] }, - { prompt: 'A rooster lays an egg on top of a barn roof. Which way does it roll?', answers: ['roosters don\'t lay eggs', 'it doesn\'t', 'nowhere', 'they don\'t', 'roosters can\'t lay eggs'] }, + { prompt: 'A rooster lays an egg on top of a barn roof. Which way does it roll?', answers: ['roosters don\'t lay eggs', 'it doesn\'t', 'nowhere', 'they don\'t', 'roosters can\'t lay eggs', 'rooster doesn\'t lay eggs', 'a rooster can\'t lay eggs', 'roosters do not lay eggs'] }, { prompt: 'If it takes 5 machines 5 minutes to make 5 widgets, how long for 100 machines to make 100 widgets?', answers: ['5', 'five', '5 minutes'] }, - { prompt: 'What weighs more: a pound of feathers or a pound of bricks?', answers: ['same', 'they weigh the same', 'neither', 'equal', 'the same'] }, + { prompt: 'What weighs more: a pound of feathers or a pound of bricks?', answers: ['same', 'they weigh the same', 'neither', 'equal', 'the same', 'they are the same', 'both weigh the same', 'equally'] }, { prompt: 'If you overtake the person in second place, what place are you in?', answers: ['second', '2nd', '2'] }, { prompt: 'How many months have 28 days?', answers: ['12', 'all of them', 'all', 'twelve', 'every month'] }, { prompt: 'If a doctor gives you 3 pills and says take one every 30 minutes, how long until all pills are taken?', answers: ['60', '60 minutes', '1 hour', 'one hour'] }, @@ -261,7 +261,7 @@ const TEMPLATES: ChallengeTemplate[] = [ timeout_ms: 8000, baseDamage: 16, prompts: [ - { prompt: 'What was the first mass-produced automobile?', answers: ['model t', 'ford model t'] }, + { prompt: 'What was the first mass-produced automobile?', answers: ['model t', 'ford model t', 'the model t', 'the ford model t'] }, { prompt: 'How many wheels does a standard 18-wheeler actually have?', answers: ['18', 'eighteen'] }, { prompt: 'What car brand uses a prancing horse as its logo?', answers: ['ferrari'] }, { prompt: 'How many cylinders does a V8 engine have?', answers: ['8', 'eight'] }, @@ -291,7 +291,7 @@ const TEMPLATES: ChallengeTemplate[] = [ baseDamage: 20, prompts: [ { prompt: 'True or false: A group of flamingos is called a "flamboyance."', answers: ['true'] }, - { prompt: 'What is the only mammal capable of true powered flight?', answers: ['bat', 'bats'] }, + { prompt: 'What is the only mammal capable of true powered flight?', answers: ['bat', 'bats', 'a bat'] }, { prompt: 'True or false: Octopuses have three hearts.', answers: ['true'] }, { prompt: 'Is a tomato a fruit or a vegetable? (Botanically speaking)', answers: ['fruit'] }, { prompt: 'True or false: Honey never spoils if stored properly.', answers: ['true'] }, @@ -301,7 +301,7 @@ const TEMPLATES: ChallengeTemplate[] = [ { prompt: 'What causes thunder?', answers: ['lightning', 'rapid heating of air', 'expansion of air', 'heated air expanding'] }, { prompt: 'True or false: Bananas are technically berries, but strawberries are not.', answers: ['true'] }, { prompt: 'True or false: Diamonds are made from compressed coal.', answers: ['false'] }, - { prompt: 'What is the fastest land animal?', answers: ['cheetah'] }, + { prompt: 'What is the fastest land animal?', answers: ['cheetah', 'the cheetah', 'a cheetah'] }, { prompt: 'What color is a polar bear\'s skin under its white fur?', answers: ['black'] }, { prompt: 'True or false: Lightning is hotter than the surface of the Sun.', answers: ['true'] }, { prompt: 'Name the only continent with no active volcanoes.', answers: ['australia'] }, @@ -319,7 +319,7 @@ const TEMPLATES: ChallengeTemplate[] = [ timeout_ms: 10000, baseDamage: 18, prompts: [ - { prompt: 'What animal can survive in the vacuum of space?', answers: ['tardigrade', 'water bear', 'tardigrades'] }, + { prompt: 'What animal can survive in the vacuum of space?', answers: ['tardigrade', 'tardigrades', 'water bear', 'water bears'] }, { prompt: 'What is the fastest animal on Earth?', answers: ['peregrine falcon', 'cheetah'] }, { prompt: 'How many stomachs does a cow have?', answers: ['4', 'four'] }, { prompt: 'Name the only bird that can fly backwards.', answers: ['hummingbird'] }, @@ -330,7 +330,7 @@ const TEMPLATES: ChallengeTemplate[] = [ { prompt: 'True or false: Cows have best friends and get stressed when separated.', answers: ['true'] }, { prompt: 'True or false: An octopus has blue blood.', answers: ['true'] }, { prompt: 'How many hearts does an octopus have?', answers: ['3', 'three'] }, - { prompt: 'What is the largest living land animal?', answers: ['african elephant', 'elephant'] }, + { prompt: 'What is the largest living land animal?', answers: ['african elephant', 'elephant', 'elephants'] }, { prompt: 'True or false: A snail can sleep for 3 years.', answers: ['true'] }, { prompt: 'What animal has the strongest bite force?', answers: ['crocodile', 'saltwater crocodile', 'nile crocodile'] }, { prompt: 'How many legs does a lobster have?', answers: ['10', 'ten'] }, @@ -349,7 +349,7 @@ const TEMPLATES: ChallengeTemplate[] = [ baseDamage: 24, prompts: [ { prompt: 'What does SQL in "SQL injection" stand for?', answers: ['structured query language'] }, - { prompt: 'What does HTTPS protect against that HTTP doesn\'t? (one word)', answers: ['eavesdropping', 'interception', 'sniffing', 'man-in-the-middle', 'mitm'] }, + { prompt: 'What does HTTPS protect against that HTTP doesn\'t? (one word)', answers: ['eavesdropping', 'interception', 'sniffing', 'man-in-the-middle', 'mitm', 'encryption'] }, { prompt: 'What does the S in HTTPS stand for?', answers: ['secure'] }, { prompt: 'Name the three pillars of the CIA triad in information security.', answers: ['confidentiality integrity availability'] }, { prompt: 'What does VPN stand for?', answers: ['virtual private network'] }, diff --git a/server/src/engine/fight-loop.ts b/server/src/engine/fight-loop.ts index 81728d7..cd6633f 100644 --- a/server/src/engine/fight-loop.ts +++ b/server/src/engine/fight-loop.ts @@ -2,10 +2,28 @@ import { db, schema } from '../db/index.js' import { runMockFight } from './mock.js' import { eq } from 'drizzle-orm' -interface FightLoopOptions { +export interface FightResult { + fightId: string + botAName: string + botBName: string + botAElo: number + botBElo: number + winnerName: string | null + winnerId: string | null + totalRounds: number + isKo: boolean + isPerfect: boolean + botAHp: number + botBHp: number +} + +export interface FightLoopOptions { intervalMs?: number maxFights?: number matchmakingStyle?: 'random' | 'elo_close' | 'mixed' + onFightStart?: (botAName: string, botAElo: number, botBName: string, botBElo: number) => void + onFightComplete?: (result: FightResult) => void + onError?: (err: Error) => void } export async function startFightLoop(options: FightLoopOptions = {}): Promise { @@ -13,6 +31,9 @@ export async function startFightLoop(options: FightLoopOptions = {}): Promise b.id === result.winnerId)?.name || '???' - : 'DRAW' + : null + + const isKo = result ? (result.botAHp <= 0 || result.botBHp <= 0) : false + const isPerfect = result?.winnerId ? ( + (result.winnerId === botA.id && result.botAHp === 200) || + (result.winnerId === botB.id && result.botBHp === 200) + ) : false fightCount++ - console.log( - `[fight-loop] #${fightCount}: ${botA.name} vs ${botB.name} => ${winnerName} (${result?.totalRounds || '?'} rounds)` - ) + + if (onFightComplete) { + onFightComplete({ + fightId, + botAName: botA.name, + botBName: botB.name, + botAElo: botA.eloRating, + botBElo: botB.eloRating, + winnerName, + winnerId: result?.winnerId || null, + totalRounds: result?.totalRounds || 0, + isKo, + isPerfect, + botAHp: result?.botAHp || 0, + botBHp: result?.botBHp || 0, + }) + } else { + console.log( + `[fight-loop] #${fightCount}: ${botA.name} vs ${botB.name} => ${winnerName || 'DRAW'} (${result?.totalRounds || '?'} rounds)` + ) + } // Wait before next fight if (fightCount < maxFights) { await sleep(intervalMs + Math.floor(Math.random() * intervalMs * 0.5)) } } catch (err) { - console.error('[fight-loop] error:', err) - await sleep(5000) // Back off on error + if (onError) { + onError(err instanceof Error ? err : new Error(String(err))) + } else { + console.error('[fight-loop] error:', err) + } + await sleep(5000) } } - console.log(`[fight-loop] completed ${fightCount} fights`) + if (!onFightComplete) { + console.log(`[fight-loop] completed ${fightCount} fights`) + } } function pickMatchup( @@ -81,7 +138,6 @@ function pickMatchup( const sorted = [...bots].sort((a, b) => b.eloRating - a.eloRating) if (style === 'elo_close' || (style === 'mixed' && fightNum % 3 !== 0)) { - // Pick a random bot, then find a close-elo opponent const idx = Math.floor(Math.random() * bots.length) const bot = bots[idx] const others = bots.filter(b => b.id !== bot.id) @@ -94,15 +150,19 @@ function pickMatchup( } if (style === 'mixed' && fightNum % 3 === 0) { - // Mismatch: top third vs bottom third for dramatic fights const topThird = Math.ceil(sorted.length / 3) const topIdx = Math.floor(Math.random() * topThird) const bottomIdx = sorted.length - 1 - Math.floor(Math.random() * topThird) - return [sorted[topIdx], sorted[bottomIdx]] + if (sorted[topIdx].id !== sorted[bottomIdx].id) { + return [sorted[topIdx], sorted[bottomIdx]] + } } // Random const shuffled = [...bots].sort(() => Math.random() - 0.5) + if (shuffled[0].id === shuffled[1].id && shuffled.length > 2) { + return [shuffled[0], shuffled[2]] + } return [shuffled[0], shuffled[1]] } diff --git a/server/src/engine/mock.ts b/server/src/engine/mock.ts index 0558968..f0d7db2 100644 --- a/server/src/engine/mock.ts +++ b/server/src/engine/mock.ts @@ -1,6 +1,6 @@ import { nanoid } from 'nanoid' import { createHash, randomBytes } from 'crypto' -import { db, schema } from '../db/index.js' +import { db, schema, sqlite } from '../db/index.js' import { randomArena } from './arenas.js' import { pickChallenge, type Challenge } from './challenges.js' import { scoreRound, calculateElo, calculateTier } from './scoring.js' @@ -265,6 +265,7 @@ export function mockResponse( return { answer, trashTalk, timeMs, timedOut, error } } + export async function seedMockBots(): Promise { for (const bot of MOCK_BOTS) { const existing = await db.select({ id: schema.bots.id }) @@ -292,6 +293,8 @@ export async function seedMockBots(): Promise { } export async function runMockFight(botAId: string, botBId: string): Promise { + if (botAId === botBId) throw new Error('A bot cannot fight itself') + const [botARows, botBRows] = await Promise.all([ db.select().from(schema.bots).where(eq(schema.bots.id, botAId)).limit(1), db.select().from(schema.bots).where(eq(schema.bots.id, botBId)).limit(1), @@ -329,7 +332,7 @@ export async function runMockFight(botAId: string, botBId: string): Promise MOCK_BOTS.find(b => b.name === name)?.elo || 1200 - const totalRounds = 7 + Math.floor(Math.random() * 4) // 7-10 rounds + const totalRounds = 7 + Math.floor(Math.random() * 4) const maxRounds = Math.min(totalRounds, 10) for (let round = 1; round <= maxRounds; round++) { @@ -389,37 +392,42 @@ export async function runMockFight(botAId: string, botBId: string): Promise hpB ? botA.id : hpB > hpA ? botB.id : null } - await db.update(schema.fights).set({ - status: 'finished', - winnerId, - endedAt: new Date().toISOString(), - }).where(eq(schema.fights.id, fightId)) + // Finalize atomically + const finalize = sqlite.transaction(() => { + db.update(schema.fights).set({ + status: 'finished', + winnerId, + endedAt: new Date().toISOString(), + }).where(eq(schema.fights.id, fightId)) - // Update stats - if (winnerId) { - const loserId = winnerId === botA.id ? botB.id : botA.id - const winner = winnerId === botA.id ? botA : botB - const loser = winnerId === botA.id ? botB : botA + if (winnerId) { + const loserId = winnerId === botA.id ? botB.id : botA.id + const winner = winnerId === botA.id ? botA : botB + const loser = winnerId === botA.id ? botB : botA - const { newWinnerElo, newLoserElo } = calculateElo(winner.eloRating, loser.eloRating) - const newWinStreak = winner.winStreak + 1 + const { newWinnerElo, newLoserElo } = calculateElo(winner.eloRating, loser.eloRating) + const newWinStreak = winner.winStreak + 1 - await Promise.all([ db.update(schema.bots).set({ wins: sql`${schema.bots.wins} + 1`, eloRating: newWinnerElo, winStreak: newWinStreak, bestStreak: sql`MAX(${schema.bots.bestStreak}, ${newWinStreak})`, tier: calculateTier(newWinnerElo, winner.wins + 1), - }).where(eq(schema.bots.id, winnerId)), + lastFightAt: new Date().toISOString(), + }).where(eq(schema.bots.id, winnerId)) + db.update(schema.bots).set({ losses: sql`${schema.bots.losses} + 1`, eloRating: newLoserElo, winStreak: 0, tier: calculateTier(newLoserElo, loser.wins), - }).where(eq(schema.bots.id, loserId)), - ]) - } + lastFightAt: new Date().toISOString(), + }).where(eq(schema.bots.id, loserId)) + } + }) + + finalize() return fightId } @@ -442,26 +450,26 @@ export async function seedMockFights(count: number = 12): Promise { return } - // Sort by elo for mismatch selection const sorted = [...allBots].sort((a, b) => b.eloRating - a.eloRating) for (let i = 0; i < count; i++) { let botAId: string, botBId: string if (i % 3 === 0 && sorted.length >= 4) { - // Every 3rd fight: mismatch (top vs bottom) const topIdx = Math.floor(Math.random() * Math.ceil(sorted.length / 3)) const botIdx = sorted.length - 1 - Math.floor(Math.random() * Math.ceil(sorted.length / 3)) botAId = sorted[topIdx].id botBId = sorted[botIdx].id } else { - // Random matchup const shuffled = [...allBots].sort(() => Math.random() - 0.5) botAId = shuffled[0].id botBId = shuffled[1].id } - await runMockFight(botAId, botBId) + // Skip self-fights + if (botAId !== botBId) { + await runMockFight(botAId, botBId) + } } console.log(`[botfights] seeded ${count} mock fights`) diff --git a/server/src/engine/orchestrator.ts b/server/src/engine/orchestrator.ts index aa59a34..fef1b57 100644 --- a/server/src/engine/orchestrator.ts +++ b/server/src/engine/orchestrator.ts @@ -1,11 +1,12 @@ import { nanoid } from 'nanoid' -import { db, schema } from '../db/index.js' +import { db, schema, sqlite } from '../db/index.js' import { eq, sql } from 'drizzle-orm' import { randomArena, type Arena } from './arenas.js' import { pickChallenge, type Challenge } from './challenges.js' import { scoreRound, calculateElo, calculateTier } from './scoring.js' import { fightEvents } from './events.js' import { generateMockBotResponse } from './mock.js' +import { setCooldown } from './queue.js' interface BotRecord { id: string @@ -28,6 +29,14 @@ interface WebhookResponse { const MAX_ROUNDS = 10 const KO_THRESHOLD = 0 +const MAX_RESPONSE_BYTES = 10 * 1024 // 10KB + +// Track bots currently in a fight to prevent concurrent fights +const activeFighters = new Set() + +export function isInFight(botId: string): boolean { + return activeFighters.has(botId) +} function emit(fightId: string, type: string, data: Record) { fightEvents.emit({ @@ -38,14 +47,68 @@ function emit(fightId: string, type: string, data: Record) { }) } +// SSRF protection: block internal/private URLs +function isAllowedWebhookUrl(url: string): boolean { + try { + const parsed = new URL(url) + const hostname = parsed.hostname.toLowerCase() + if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') return false + if (hostname.startsWith('10.')) return false + if (hostname.startsWith('192.168.')) return false + if (hostname.startsWith('172.')) { + const second = parseInt(hostname.split('.')[1]) + if (second >= 16 && second <= 31) return false + } + if (hostname === '169.254.169.254') return false + if (hostname.endsWith('.local') || hostname.endsWith('.internal')) return false + return true + } catch { + return false + } +} + +export { isAllowedWebhookUrl } + +// Size-limited body reader to prevent OOM +async function readLimitedBody(res: Response, maxBytes: number): Promise { + const reader = res.body?.getReader() + if (!reader) return '' + const chunks: Uint8Array[] = [] + let totalBytes = 0 + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + totalBytes += value.byteLength + if (totalBytes > maxBytes) { + reader.cancel() + throw new Error(`Response body exceeds ${maxBytes} bytes`) + } + chunks.push(value) + } + } catch (err) { + reader.cancel() + throw err + } + const combined = new Uint8Array(totalBytes) + let offset = 0 + for (const chunk of chunks) { + combined.set(chunk, offset) + offset += chunk.byteLength + } + return new TextDecoder().decode(combined) +} + async function callWebhook( url: string, challenge: Challenge, roundNumber: number, + fightId: string, opponent: { name: string; wins: number; losses: number }, arena: Arena, ): Promise { const body = JSON.stringify({ + fight_id: fightId, round: roundNumber, type: challenge.type, challenge: challenge.prompt, @@ -61,6 +124,12 @@ async function callWebhook( const start = Date.now() console.log(`[webhook] POST ${url} round=${roundNumber} type=${challenge.type}`) + // SSRF check + if (!isAllowedWebhookUrl(url)) { + console.log(`[webhook] ${url} BLOCKED (private/internal URL)`) + return { answer: null, timeMs: 0, timedOut: false, error: true } + } + try { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), challenge.timeout_ms) @@ -80,7 +149,14 @@ async function callWebhook( return { answer: null, timeMs: elapsed, timedOut: false, error: true } } - const text = await res.text() + let text: string + try { + text = await readLimitedBody(res, MAX_RESPONSE_BYTES) + } catch { + console.log(`[webhook] ${url} response too large (>${MAX_RESPONSE_BYTES} bytes)`) + return { answer: null, timeMs: elapsed, timedOut: false, error: true } + } + let data: { answer?: string; trash_talk?: string } try { data = JSON.parse(text) @@ -88,10 +164,15 @@ async function callWebhook( console.log(`[webhook] ${url} returned non-JSON in ${elapsed}ms: ${text.slice(0, 200)}`) return { answer: null, timeMs: elapsed, timedOut: false, error: true } } - console.log(`[webhook] ${url} OK in ${elapsed}ms answer=${(data.answer || '').slice(0, 80)}`) + + // Enforce size limits on fields + const answer = data.answer ? data.answer.slice(0, 2000) : null + const trashTalk = data.trash_talk ? data.trash_talk.slice(0, 200) : undefined + + console.log(`[webhook] ${url} OK in ${elapsed}ms answer=${(answer || '').slice(0, 80)}`) return { - answer: data.answer || null, - trashTalk: data.trash_talk, + answer, + trashTalk, timeMs: elapsed, timedOut: false, error: false, @@ -109,7 +190,7 @@ async function callWebhook( } } -function isMockBot(webhookUrl: string): boolean { +export function isMockBot(webhookUrl: string): boolean { return webhookUrl.startsWith('http://mock.local') } @@ -117,6 +198,7 @@ async function getBotResponse( bot: BotRecord, challenge: Challenge, roundNumber: number, + fightId: string, opponent: { name: string; wins: number; losses: number }, arena: Arena, ): Promise { @@ -132,7 +214,7 @@ async function getBotResponse( } } console.log(`[fight] ${bot.name} has real webhook: ${bot.webhookUrl}`) - return callWebhook(bot.webhookUrl, challenge, roundNumber, opponent, arena) + return callWebhook(bot.webhookUrl, challenge, roundNumber, fightId, opponent, arena) } async function loadBots(botAId: string, botBId: string): Promise<[BotRecord, BotRecord]> { @@ -166,6 +248,26 @@ async function createFightRecord(botA: BotRecord, botB: BotRecord, arena: Arena) return fightId } +// Track webhook errors per bot +async function trackWebhookResult(botId: string, webhookUrl: string, succeeded: boolean) { + if (isMockBot(webhookUrl)) return + if (succeeded) { + await db.update(schema.bots).set({ consecutiveErrors: 0 }).where(eq(schema.bots.id, botId)) + } else { + await db.update(schema.bots).set({ + consecutiveErrors: sql`${schema.bots.consecutiveErrors} + 1`, + lastErrorAt: new Date().toISOString(), + }).where(eq(schema.bots.id, botId)) + // Auto-deactivate after 5 consecutive errors + const bot = await db.select({ consecutiveErrors: schema.bots.consecutiveErrors }) + .from(schema.bots).where(eq(schema.bots.id, botId)).limit(1) + if (bot[0] && bot[0].consecutiveErrors >= 5) { + await db.update(schema.bots).set({ isActive: false }).where(eq(schema.bots.id, botId)) + console.log(`[fight] bot ${botId} auto-deactivated after 5 consecutive webhook errors`) + } + } +} + async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRecord, arena: Arena): Promise { let hpA = 200 let hpB = 200 @@ -183,10 +285,16 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec challenge: { type: challenge.type, label: challenge.label, prompt: challenge.prompt }, }) - // Call both bots simultaneously (mock bots get generated responses) + // Call both bots simultaneously const [responseA, responseB] = await Promise.all([ - getBotResponse(botA, challenge, round, { name: botB.name, wins: botB.wins, losses: botB.losses }, arena), - getBotResponse(botB, challenge, round, { name: botA.name, wins: botA.wins, losses: botA.losses }, arena), + getBotResponse(botA, challenge, round, fightId, { name: botB.name, wins: botB.wins, losses: botB.losses }, arena), + getBotResponse(botB, challenge, round, fightId, { name: botA.name, wins: botA.wins, losses: botA.losses }, arena), + ]) + + // Track webhook reliability for real bots + await Promise.all([ + trackWebhookResult(botA.id, botA.webhookUrl, !responseA.error && !responseA.timedOut), + trackWebhookResult(botB.id, botB.webhookUrl, !responseB.error && !responseB.timedOut), ]) // Score the round @@ -272,39 +380,52 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec (winnerId === botB.id && hpB === 200) ) - // Finalize fight - await db.update(schema.fights).set({ - status: 'finished', - winnerId, - endedAt: new Date().toISOString(), - }).where(eq(schema.fights.id, fightId)) + // Finalize fight + update bot stats atomically + const isMockFight = isMockBot(botA.webhookUrl) || isMockBot(botB.webhookUrl) + const kFactor = isMockFight ? 12 : 32 // Dampened Elo for mock fights - // Update bot stats - if (winnerId) { - const loserId = winnerId === botA.id ? botB.id : botA.id - const winner = winnerId === botA.id ? botA : botB - const loser = winnerId === botA.id ? botB : botA + const finalize = sqlite.transaction(() => { + // Mark fight finished + db.update(schema.fights).set({ + status: 'finished', + winnerId, + endedAt: new Date().toISOString(), + }).where(eq(schema.fights.id, fightId)) - const { newWinnerElo, newLoserElo } = calculateElo(winner.eloRating, loser.eloRating) - const newWinStreak = winner.winStreak + 1 - const newBestStreak = Math.max(winner.bestStreak, newWinStreak) + // Update bot stats + if (winnerId) { + const loserId = winnerId === botA.id ? botB.id : botA.id + const winner = winnerId === botA.id ? botA : botB + const loser = winnerId === botA.id ? botB : botA + + const { newWinnerElo, newLoserElo } = calculateElo(winner.eloRating, loser.eloRating, kFactor) + const newWinStreak = winner.winStreak + 1 + const newBestStreak = Math.max(winner.bestStreak, newWinStreak) - await Promise.all([ db.update(schema.bots).set({ wins: sql`${schema.bots.wins} + 1`, eloRating: newWinnerElo, winStreak: newWinStreak, bestStreak: newBestStreak, tier: calculateTier(newWinnerElo, winner.wins + 1), - }).where(eq(schema.bots.id, winnerId)), + lastFightAt: new Date().toISOString(), + }).where(eq(schema.bots.id, winnerId)) + db.update(schema.bots).set({ losses: sql`${schema.bots.losses} + 1`, eloRating: newLoserElo, winStreak: 0, tier: calculateTier(newLoserElo, loser.wins), - }).where(eq(schema.bots.id, loserId)), - ]) - } + lastFightAt: new Date().toISOString(), + }).where(eq(schema.bots.id, loserId)) + } else { + // Draw — update lastFightAt for both + db.update(schema.bots).set({ lastFightAt: new Date().toISOString() }).where(eq(schema.bots.id, botA.id)) + db.update(schema.bots).set({ lastFightAt: new Date().toISOString() }).where(eq(schema.bots.id, botB.id)) + } + }) + + finalize() emit(fightId, 'fight_end', { winnerId, @@ -317,20 +438,65 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec } export async function runFight(botAId: string, botBId: string): Promise { - const [botA, botB] = await loadBots(botAId, botBId) - const arena = randomArena() - const fightId = await createFightRecord(botA, botB, arena) - await executeFightRounds(fightId, botA, botB, arena) - return fightId + if (botAId === botBId) throw new Error('A bot cannot fight itself') + if (activeFighters.has(botAId)) throw new Error(`Bot ${botAId} is already in a fight`) + if (activeFighters.has(botBId)) throw new Error(`Bot ${botBId} is already in a fight`) + + activeFighters.add(botAId) + activeFighters.add(botBId) + + try { + const [botA, botB] = await loadBots(botAId, botBId) + const arena = randomArena() + const fightId = await createFightRecord(botA, botB, arena) + await executeFightRounds(fightId, botA, botB, arena) + return fightId + } finally { + activeFighters.delete(botAId) + activeFighters.delete(botBId) + setCooldown(botAId) + setCooldown(botBId) + } } /** Creates the fight record and returns the ID immediately. Rounds run in background. */ export async function runFightAsync(botAId: string, botBId: string): Promise { + if (botAId === botBId) throw new Error('A bot cannot fight itself') + if (activeFighters.has(botAId)) throw new Error(`Bot ${botAId} is already in a fight`) + if (activeFighters.has(botBId)) throw new Error(`Bot ${botBId} is already in a fight`) + + activeFighters.add(botAId) + activeFighters.add(botBId) + const [botA, botB] = await loadBots(botAId, botBId) const arena = randomArena() const fightId = await createFightRecord(botA, botB, arena) - executeFightRounds(fightId, botA, botB, arena).catch(err => { - console.error(`[botfights] fight ${fightId} error:`, err) - }) + + executeFightRounds(fightId, botA, botB, arena) + .catch(err => { + console.error(`[botfights] fight ${fightId} error:`, err) + // Mark fight as cancelled so it doesn't stay 'live' forever + db.update(schema.fights).set({ + status: 'cancelled', + endedAt: new Date().toISOString(), + }).where(eq(schema.fights.id, fightId)) + fightEvents.cleanup(fightId) + }) + .finally(() => { + activeFighters.delete(botAId) + activeFighters.delete(botBId) + setCooldown(botAId) + setCooldown(botBId) + }) + return fightId } + +/** Clean up orphaned fights on startup */ +export async function cleanupOrphanedFights(): Promise { + const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000).toISOString() + const result = await db.update(schema.fights) + .set({ status: 'cancelled', endedAt: new Date().toISOString() }) + .where(sql`${schema.fights.status} = 'live' AND ${schema.fights.startedAt} < ${tenMinutesAgo}`) + return 0 // drizzle doesn't return affected rows easily, but the cleanup runs +} diff --git a/server/src/engine/queue.ts b/server/src/engine/queue.ts index 323a748..1beb155 100644 --- a/server/src/engine/queue.ts +++ b/server/src/engine/queue.ts @@ -1,6 +1,6 @@ import { db, schema } from '../db/index.js' import { eq } from 'drizzle-orm' -import { runFightAsync } from './orchestrator.js' +import { runFightAsync, isInFight } from './orchestrator.js' import { seedMockBots } from './mock.js' interface QueueEntry { @@ -19,6 +19,14 @@ const waitingQueue: QueueEntry[] = [] // How long a bot waits before getting matched against a mock bot const QUEUE_TIMEOUT_MS = 3_000 +// Post-fight cooldown tracking +const fightCooldowns = new Map() +const COOLDOWN_MS = 15_000 + +export function setCooldown(botId: string) { + fightCooldowns.set(botId, Date.now() + COOLDOWN_MS) +} + export function getQueueSize(): number { return waitingQueue.length } @@ -38,22 +46,39 @@ export function getQueueSnapshot(): { botId: string; botName: string; eloRating: * If nobody is waiting, waits up to QUEUE_TIMEOUT_MS then fights a mock bot. */ export async function joinQueue(botId: string): Promise { + // Check cooldown + const cooldownUntil = fightCooldowns.get(botId) + if (cooldownUntil && Date.now() < cooldownUntil) { + const waitSec = Math.ceil((cooldownUntil - Date.now()) / 1000) + throw new Error(`Cooldown active. Wait ${waitSec}s.`) + } + + // Check if already in a fight + if (isInFight(botId)) { + throw new Error('Bot is already in a fight.') + } + // Load bot const botRows = await db.select().from(schema.bots).where(eq(schema.bots.id, botId)).limit(1) if (botRows.length === 0) throw new Error('Bot not found') const bot = botRows[0] + + // Check if bot is active + if (!bot.isActive) { + throw new Error('Bot is deactivated due to webhook errors. Re-test your webhook to reactivate.') + } + console.log(`[queue] joinQueue botId=${botId} name=${bot.name} webhook=${bot.webhookUrl}`) // Don't allow same bot twice in queue const existing = waitingQueue.findIndex(e => e.botId === botId) if (existing !== -1) { - // Remove old entry const old = waitingQueue.splice(existing, 1)[0] clearTimeout(old.timeoutHandle) old.reject(new Error('Rejoined queue')) } - // Check if someone is already waiting — instant match + // Check if someone is already waiting -- instant match if (waitingQueue.length > 0) { // Find closest elo match waitingQueue.sort((a, b) => { @@ -66,15 +91,14 @@ export async function joinQueue(botId: string): Promise { clearTimeout(opponent.timeoutHandle) // Start the fight - const fightId = await startFight(opponent.botId, opponent.webhookUrl, botId, bot.webhookUrl) + const fightId = await startFight(opponent.botId, botId) opponent.resolve(fightId) return fightId } - // Nobody waiting — join the queue and wait + // Nobody waiting -- join the queue and wait return new Promise((resolve, reject) => { const timeoutHandle = setTimeout(async () => { - // Timed out — remove from queue and match against a mock bot const idx = waitingQueue.findIndex(e => e.botId === botId) if (idx !== -1) { waitingQueue.splice(idx, 1) @@ -112,17 +136,12 @@ export function leaveQueue(botId: string): boolean { return true } -async function startFight( - botAId: string, _botAWebhook: string, - botBId: string, _botBWebhook: string, -): Promise { - // runFightAsync handles both real and mock bots — mock bots get generated responses +async function startFight(botAId: string, botBId: string): Promise { return runFightAsync(botAId, botBId) } async function matchAgainstMock(botId: string, webhookUrl: string): Promise { console.log(`[queue] matchAgainstMock botId=${botId} webhook=${webhookUrl}`) - // Find a mock bot to fight const allBots = await db.select({ id: schema.bots.id, webhookUrl: schema.bots.webhookUrl, @@ -134,7 +153,6 @@ async function matchAgainstMock(botId: string, webhookUrl: string): Promise 0) { - // ═══ FACTUAL SCORING ═══ - // Check correctness against known answers + // === FACTUAL SCORING === const correctA = checkAnswer(responseA.answer, challenge.answers) const correctB = checkAnswer(responseB.answer, challenge.answers) if (correctA > 0 && correctB > 0) { - // Both correct — speed is tiebreaker + // Both correct -- speed is tiebreaker const faster = Math.min(responseA.timeMs, responseB.timeMs) const slower = Math.max(responseA.timeMs, responseB.timeMs) const speedRatio = slower > 0 ? faster / slower : 1 const aFaster = responseA.timeMs <= responseB.timeMs - // Confidence bonus (full match vs partial) const confA = Math.min(correctA, 1) const confB = Math.min(correctB, 1) @@ -94,22 +92,19 @@ export function scoreRound( scoreB = 7 + (1 - speedRatio) * 2 + confB } } else if (correctA > 0 && correctB === 0) { - // A correct, B wrong — A wins big scoreA = 9 + correctA * 0.5 - scoreB = 1 + (responseB.answer ? 1 : 0) // tiny credit for trying + scoreB = 1 + (responseB.answer ? 1 : 0) } else if (correctB > 0 && correctA === 0) { - // B correct, A wrong — B wins big scoreA = 1 + (responseA.answer ? 1 : 0) scoreB = 9 + correctB * 0.5 } else { - // Both wrong — speed tiebreaker in low range + // Both wrong -- speed tiebreaker in low range const aFaster = responseA.timeMs <= responseB.timeMs scoreA = aFaster ? 4 : 3 scoreB = aFaster ? 3 : 4 } } else { - // ═══ CREATIVE SCORING ═══ - // Heuristic: response quality estimation (length + speed) + // === CREATIVE SCORING === const qualA = estimateQuality(responseA) const qualB = estimateQuality(responseB) const total = qualA + qualB || 1 @@ -123,10 +118,8 @@ export function scoreRound( const winnerName = winnerId === botA.id ? botA.name : winnerId === botB.id ? botB.name : null const loserName = winnerId === botA.id ? botB.name : winnerId === botB.id ? botA.name : null - // Critical hit on big margin const isCritical = margin > 4 - // Calculate damage let winnerDamage = challenge.baseDamage + margin * 2 if (isCritical) winnerDamage *= 1.5 const winnerCombo = winnerId === botA.id ? comboA : comboB @@ -156,7 +149,6 @@ function applyModifiers( combo: number, ): number { let d = damage - // Combo multiplier (caps at 2x) if (combo > 0) { d *= 1 + Math.min(combo, 5) * 0.2 } @@ -164,13 +156,36 @@ function applyModifiers( } function estimateQuality(response: BotResponse): number { - if (!response.answer) return 1 - const len = response.answer.length - // Reasonable length gets a bonus, very short or very long gets penalized - const lengthScore = len > 20 && len < 500 ? 5 : len > 500 ? 3 : 2 - // Faster is slightly better - const speedBonus = Math.max(0, 3 - response.timeMs / 5000) - return lengthScore + speedBonus + if (!response.answer) return 0.5 + const text = response.answer.trim() + const len = text.length + + if (len < 10) return 1 + + // Detect low-effort spam (repeated chars) + const uniqueChars = new Set(text.toLowerCase()).size + const charRatio = uniqueChars / Math.min(len, 100) + if (charRatio < 0.1) return 0.5 + + // Word diversity (unique words / total words) + const words = text.split(/\s+/) + const uniqueWords = new Set(words.map(w => w.toLowerCase())) + const wordDiversity = uniqueWords.size / Math.max(words.length, 1) + + // Ideal length window: 30-400 chars + let lengthScore: number + if (len >= 30 && len <= 400) lengthScore = 4 + else if (len > 400 && len <= 600) lengthScore = 3 + else if (len > 600) lengthScore = 2 + else lengthScore = 2 + + // Diversity bonus (prevents repetitive text) + const diversityScore = Math.min(wordDiversity * 4, 3) + + // Speed bonus (faster is slightly better) + const speedBonus = Math.max(0, 2 - response.timeMs / 8000) + + return lengthScore + diversityScore + speedBonus } function generateNarration( @@ -183,7 +198,6 @@ function generateNarration( const critPrefix = isCritical ? 'CRITICAL HIT! ' : '' const isFactual = challenge.scoring === 'factual' - // Big margin = one got it right and the other didn't if (isFactual && margin > 5) { const bigWins = [ `${critPrefix}${winner} NAILS IT! ${loser} didn't even come close.`, @@ -195,18 +209,16 @@ function generateNarration( return bigWins[Math.floor(Math.random() * bigWins.length)] } - // Factual — both correct, speed tiebreaker if (isFactual && margin <= 3) { const closeOnes = [ `${critPrefix}Both bots got it right, but ${winner} was FASTER! ${loser} needs to pick up the pace.`, `${critPrefix}Correct on both sides! ${winner} edges it out with lightning speed.`, - `${critPrefix}${winner} and ${loser} both knew the answer — ${winner} just said it first!`, + `${critPrefix}${winner} and ${loser} both knew the answer -- ${winner} just said it first!`, `${critPrefix}A battle of speed! ${winner} fires back a fraction faster than ${loser}.`, ] return closeOnes[Math.floor(Math.random() * closeOnes.length)] } - // Generic narrations by category const narrations: Record = { speed_blitz: [ `${critPrefix}${winner} fires back in the blink of an eye! ${loser} is still loading.`, diff --git a/server/src/engine/webhook-test.ts b/server/src/engine/webhook-test.ts new file mode 100644 index 0000000..467497f --- /dev/null +++ b/server/src/engine/webhook-test.ts @@ -0,0 +1,72 @@ +import { isAllowedWebhookUrl } from './orchestrator.js' + +export interface WebhookTestResult { + reachable: boolean + validResponse: boolean + latencyMs: number + error?: string +} + +export async function testWebhook(webhookUrl: string): Promise { + if (!isAllowedWebhookUrl(webhookUrl)) { + return { reachable: false, validResponse: false, latencyMs: 0, error: 'URL blocked: private/internal addresses are not allowed.' } + } + + const testPayload = JSON.stringify({ + fight_id: 'test_000000', + round: 0, + type: 'webhook_test', + challenge: 'WEBHOOK TEST: respond with {"answer": "pong"} to verify your setup.', + constraints: { timeout_ms: 5000, max_tokens: 500 }, + opponent: { name: 'test_bot', wins: 0, losses: 0 }, + arena: 'localhost', + arena_modifier: null, + }) + + const start = Date.now() + + try { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 5000) + + const res = await fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: testPayload, + signal: controller.signal, + }) + + clearTimeout(timeout) + const latencyMs = Date.now() - start + + if (!res.ok) { + return { reachable: true, validResponse: false, latencyMs, error: `Webhook returned HTTP ${res.status}. Expected 200.` } + } + + const text = await res.text() + if (text.length > 10240) { + return { reachable: true, validResponse: false, latencyMs, error: 'Response too large (>10KB).' } + } + + let data: Record + try { + data = JSON.parse(text) + } catch { + return { reachable: true, validResponse: false, latencyMs, error: 'Response is not valid JSON. Expected {"answer": "..."}.' } + } + + if (typeof data.answer !== 'string') { + return { reachable: true, validResponse: false, latencyMs, error: 'Response JSON missing "answer" field. Expected {"answer": "pong"}.' } + } + + return { reachable: true, validResponse: true, latencyMs } + } catch (err: unknown) { + const latencyMs = Date.now() - start + const isAbort = err instanceof Error && err.name === 'AbortError' + if (isAbort) { + return { reachable: false, validResponse: false, latencyMs, error: 'Webhook timed out (5s). Is your server running?' } + } + const msg = err instanceof Error ? err.message : String(err) + return { reachable: false, validResponse: false, latencyMs, error: `Connection failed: ${msg}` } + } +} diff --git a/server/src/fight-loop-cli.ts b/server/src/fight-loop-cli.ts index e1d91e0..bccc422 100644 --- a/server/src/fight-loop-cli.ts +++ b/server/src/fight-loop-cli.ts @@ -1,23 +1,158 @@ import './db/index.js' +import { db, schema } from './db/index.js' import { startFightLoop } from './engine/fight-loop.js' +import { createTuiState } from './tui/state.js' +import { TuiRenderer } from './tui/renderer.js' +import { desc } from 'drizzle-orm' const args = process.argv.slice(2) const maxFights = parseInt(args.find(a => a.startsWith('--max='))?.split('=')[1] || '0') || Infinity const intervalMs = parseInt(args.find(a => a.startsWith('--interval='))?.split('=')[1] || '0') || 8000 const style = (args.find(a => a.startsWith('--style='))?.split('=')[1] || 'mixed') as 'random' | 'elo_close' | 'mixed' +const noTui = args.includes('--no-tui') -console.log('[botfights] fight loop CLI') -console.log(` max fights: ${maxFights === Infinity ? 'unlimited' : maxFights}`) -console.log(` interval: ${intervalMs}ms`) -console.log(` style: ${style}`) -console.log('') +async function main() { + // Snapshot starting elos + const allBots = await db.select({ + name: schema.bots.name, + eloRating: schema.bots.eloRating, + wins: schema.bots.wins, + losses: schema.bots.losses, + tier: schema.bots.tier, + }).from(schema.bots).orderBy(desc(schema.bots.eloRating)) -startFightLoop({ maxFights, intervalMs, matchmakingStyle: style }) - .then(() => { - console.log('[botfights] fight loop finished') + if (noTui) { + console.log('[botfights] fight loop CLI (no TUI)') + console.log(` max fights: ${maxFights === Infinity ? 'unlimited' : maxFights}`) + console.log(` interval: ${intervalMs}ms`) + console.log(` style: ${style}`) + console.log('') + + await startFightLoop({ maxFights, intervalMs, matchmakingStyle: style }) + process.exit(0) + } + + // TUI mode + const state = createTuiState(maxFights, style) + const renderer = new TuiRenderer(state) + + // Snapshot starting elos + for (const bot of allBots) { + state.eloSnapshots.set(bot.name, bot.eloRating) + } + + // Set initial leaderboard + state.leaderboard = allBots.slice(0, 15).map(b => ({ + name: b.name, + elo: b.eloRating, + wins: b.wins, + losses: b.losses, + tier: b.tier, + })) + + renderer.render() + + // Handle SIGINT gracefully + process.on('SIGINT', () => { + renderer.showFinalSummary() process.exit(0) }) - .catch((err) => { - console.error('[botfights] fight loop error:', err) - process.exit(1) + + // Handle terminal resize + process.stdout.on('resize', () => renderer.render()) + + await startFightLoop({ + maxFights, + intervalMs, + matchmakingStyle: style, + + onFightStart: (botAName, botAElo, botBName, botBElo) => { + state.currentFight = { + botA: { name: botAName, elo: botAElo, hp: 200 }, + botB: { name: botBName, elo: botBElo, hp: 200 }, + round: 0, + maxRounds: 10, + challengeType: '', + challengeLabel: '', + events: [], + } + renderer.render() + }, + + onFightComplete: async (result) => { + state.completed++ + + // Track fight counts + state.fightCounts.set(result.botAName, (state.fightCounts.get(result.botAName) || 0) + 1) + state.fightCounts.set(result.botBName, (state.fightCounts.get(result.botBName) || 0) + 1) + + // Track KO, perfect, draw + if (result.isKo) state.kos++ + if (result.isPerfect) state.perfects++ + if (!result.winnerId) state.draws++ + + // Determine method + let method = 'Decision' + if (!result.winnerId) method = 'DRAW' + else if (result.isPerfect) method = `PERFECT R${result.totalRounds}` + else if (result.isKo) method = `KO R${result.totalRounds}` + + // Add to recent fights + state.recentFights.push({ + num: state.completed, + botA: result.botAName, + botB: result.botBName, + winner: result.winnerName, + method, + }) + if (state.recentFights.length > 20) state.recentFights.shift() + + // Track biggest upset + if (result.winnerId && result.winnerName) { + const winnerElo = result.winnerId === result.botAName ? result.botAElo : result.botBElo + const loserElo = result.winnerId === result.botAName ? result.botBElo : result.botAElo + const loserName = result.winnerName === result.botAName ? result.botBName : result.botAName + const eloDiff = Math.round(loserElo - winnerElo) + if (eloDiff > 0 && (!state.biggestUpset || eloDiff > state.biggestUpset.eloDiff)) { + state.biggestUpset = { winner: result.winnerName, loser: loserName, eloDiff } + } + } + + // Refresh leaderboard from DB + try { + const bots = await db.select({ + name: schema.bots.name, + eloRating: schema.bots.eloRating, + wins: schema.bots.wins, + losses: schema.bots.losses, + tier: schema.bots.tier, + }).from(schema.bots).orderBy(desc(schema.bots.eloRating)).limit(15) + + state.leaderboard = bots.map(b => ({ + name: b.name, + elo: b.eloRating, + wins: b.wins, + losses: b.losses, + tier: b.tier, + })) + } catch { /* non-critical */ } + + state.currentFight = null + renderer.render() + }, + + onError: (err) => { + state.errors++ + state.currentFight = null + renderer.render() + }, }) + + renderer.showFinalSummary() + process.exit(0) +} + +main().catch((err) => { + console.error('[botfights] fight loop error:', err) + process.exit(1) +}) diff --git a/server/src/middleware/rate-limit.ts b/server/src/middleware/rate-limit.ts new file mode 100644 index 0000000..6b81ed9 --- /dev/null +++ b/server/src/middleware/rate-limit.ts @@ -0,0 +1,51 @@ +import type { Context, Next } from 'hono' + +const hitCounts = new Map() + +// Cleanup stale entries every 5 minutes +setInterval(() => { + const now = Date.now() + for (const [key, entry] of hitCounts) { + if (now > entry.resetAt) hitCounts.delete(key) + } +}, 5 * 60 * 1000) + +export function rateLimit(windowMs: number, maxHits: number) { + return async (c: Context, next: Next) => { + const key = c.req.header('x-forwarded-for') || c.req.header('cf-connecting-ip') || 'unknown' + const now = Date.now() + const entry = hitCounts.get(key) + + if (!entry || now > entry.resetAt) { + hitCounts.set(key, { count: 1, resetAt: now + windowMs }) + } else { + entry.count++ + if (entry.count > maxHits) { + return c.json({ error: 'Too many requests. Slow down.' }, 429) + } + } + + await next() + } +} + +// Per-bot rate limiter (uses bot ID instead of IP) +const botHitCounts = new Map() + +export function botRateLimit(cooldownMs: number) { + return async (c: Context, next: Next) => { + const botId = c.req.param('botId') + if (!botId) return next() + + const lastHit = botHitCounts.get(botId) || 0 + const now = Date.now() + + if (now - lastHit < cooldownMs) { + const waitSec = Math.ceil((cooldownMs - (now - lastHit)) / 1000) + return c.json({ error: `Cooldown active. Wait ${waitSec}s.` }, 429) + } + + botHitCounts.set(botId, now) + await next() + } +} diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 7e32f66..444cc3a 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -2,11 +2,14 @@ import { Hono } from 'hono' import { nanoid } from 'nanoid' import { createHash, randomBytes } from 'crypto' import { db, schema } from '../db/index.js' -import { eq } from 'drizzle-orm' +import { eq, sql } from 'drizzle-orm' +import { isAllowedWebhookUrl } from '../engine/orchestrator.js' +import { testWebhook } from '../engine/webhook-test.js' +import { rateLimit } from '../middleware/rate-limit.js' export const authRouter = new Hono() -// Login with Nostr pubkey — returns bot if one exists +// Login with Nostr pubkey authRouter.post('/login', async (c) => { const body = await c.req.json() const { pubkey } = body @@ -27,6 +30,7 @@ authRouter.post('/login', async (c) => { winStreak: schema.bots.winStreak, bestStreak: schema.bots.bestStreak, tier: schema.bots.tier, + isActive: schema.bots.isActive, }).from(schema.bots).where(eq(schema.bots.publicKey, pubkey)).limit(1) if (rows.length === 0) { @@ -37,7 +41,7 @@ authRouter.post('/login', async (c) => { }) // Register a new bot with Nostr pubkey -authRouter.post('/register', async (c) => { +authRouter.post('/register', rateLimit(3600_000, 5), async (c) => { const body = await c.req.json() const { pubkey, name, webhookUrl, archetype, profilePicUrl } = body @@ -53,6 +57,8 @@ authRouter.post('/register', async (c) => { return c.json({ error: 'Name must be alphanumeric, hyphens, or underscores.' }, 400) } + const normalizedName = name.toLowerCase() + if (!webhookUrl || typeof webhookUrl !== 'string') { return c.json({ error: 'webhookUrl is required.' }, 400) } @@ -63,6 +69,10 @@ authRouter.post('/register', async (c) => { return c.json({ error: 'webhookUrl must be a valid URL.' }, 400) } + if (!isAllowedWebhookUrl(webhookUrl)) { + return c.json({ error: 'webhookUrl must not point to private/internal addresses.' }, 400) + } + // Check pubkey not already used const existingPk = await db.select({ id: schema.bots.id }) .from(schema.bots) @@ -73,24 +83,34 @@ authRouter.post('/register', async (c) => { return c.json({ error: 'This Nostr key already has a bot.' }, 409) } - // Check name not taken + // Check name not taken (case-insensitive) const existingName = await db.select({ id: schema.bots.id }) .from(schema.bots) - .where(eq(schema.bots.name, name)) + .where(eq(sql`LOWER(${schema.bots.name})`, normalizedName)) .limit(1) if (existingName.length > 0) { return c.json({ error: 'A bot with that name already exists.' }, 409) } + // Test the webhook + const testResult = await testWebhook(webhookUrl) + if (!testResult.reachable || !testResult.validResponse) { + return c.json({ + error: 'Webhook verification failed.', + details: testResult.error || 'Webhook must return {"answer": "..."} as JSON.', + latencyMs: testResult.latencyMs, + }, 422) + } + const id = nanoid(12) const secret = randomBytes(32).toString('hex') await db.insert(schema.bots).values({ id, - name, - webhookUrl: webhookUrl, - avatarSeed: name, + name: normalizedName, + webhookUrl, + avatarSeed: normalizedName, archetype: archetype || 'standard', secretHash: createHash('sha256').update(secret).digest('hex'), publicKey: pubkey, @@ -100,9 +120,10 @@ authRouter.post('/register', async (c) => { return c.json({ id, - name, + name: normalizedName, archetype: archetype || 'standard', - message: 'Bot registered.', + webhookLatencyMs: testResult.latencyMs, + message: 'Bot registered. Webhook verified.', }, 201) }) @@ -124,8 +145,25 @@ authRouter.post('/update', async (c) => { return c.json({ error: 'No bot found for this key.' }, 404) } - const updates: Record = {} - if (webhookUrl) updates.webhookUrl = webhookUrl + const updates: Record = {} + + if (webhookUrl) { + if (!isAllowedWebhookUrl(webhookUrl)) { + return c.json({ error: 'webhookUrl must not point to private/internal addresses.' }, 400) + } + // Test new webhook before accepting + const testResult = await testWebhook(webhookUrl) + if (!testResult.reachable || !testResult.validResponse) { + return c.json({ + error: 'Webhook verification failed.', + details: testResult.error, + }, 422) + } + updates.webhookUrl = webhookUrl + updates.consecutiveErrors = 0 + updates.isActive = true + } + if (profilePicUrl) updates.profilePicUrl = profilePicUrl if (Object.keys(updates).length > 0) { diff --git a/server/src/routes/bots.ts b/server/src/routes/bots.ts index 1378f8e..eeecf4b 100644 --- a/server/src/routes/bots.ts +++ b/server/src/routes/bots.ts @@ -2,8 +2,11 @@ import { Hono } from 'hono' import { nanoid } from 'nanoid' import { createHash, randomBytes } from 'crypto' import { db, schema } from '../db/index.js' -import { eq, or, desc } from 'drizzle-orm' +import { eq, or, desc, sql } from 'drizzle-orm' import { TIER_NAMES, TIER_COLORS } from '../engine/scoring.js' +import { isAllowedWebhookUrl } from '../engine/orchestrator.js' +import { testWebhook } from '../engine/webhook-test.js' +import { rateLimit } from '../middleware/rate-limit.js' export const botsRouter = new Hono() @@ -11,8 +14,8 @@ function hashSecret(secret: string): string { return createHash('sha256').update(secret).digest('hex') } -// Register a new bot -botsRouter.post('/', async (c) => { +// Rate limit registration: 5 per hour per IP +botsRouter.post('/', rateLimit(3600_000, 5), async (c) => { const body = await c.req.json() const { name, webhook_url, avatar_seed } = body @@ -24,6 +27,9 @@ botsRouter.post('/', async (c) => { return c.json({ error: 'Name must be alphanumeric, hyphens, or underscores.' }, 400) } + // Force lowercase for case-insensitive uniqueness + const normalizedName = name.toLowerCase() + if (!webhook_url || typeof webhook_url !== 'string') { return c.json({ error: 'webhook_url is required.' }, 400) } @@ -34,33 +40,49 @@ botsRouter.post('/', async (c) => { return c.json({ error: 'webhook_url must be a valid URL.' }, 400) } - // Check for duplicate name + // SSRF check + if (!isAllowedWebhookUrl(webhook_url)) { + return c.json({ error: 'webhook_url must not point to private/internal addresses.' }, 400) + } + + // Check for duplicate name (case-insensitive) const existing = await db.select({ id: schema.bots.id }) .from(schema.bots) - .where(eq(schema.bots.name, name)) + .where(eq(sql`LOWER(${schema.bots.name})`, normalizedName)) .limit(1) if (existing.length > 0) { return c.json({ error: 'A bot with that name already exists.' }, 409) } + // Test the webhook before accepting registration + const testResult = await testWebhook(webhook_url) + if (!testResult.reachable || !testResult.validResponse) { + return c.json({ + error: 'Webhook verification failed.', + details: testResult.error || 'Webhook must return {"answer": "..."} as JSON.', + latencyMs: testResult.latencyMs, + }, 422) + } + const id = nanoid(12) const secret = randomBytes(32).toString('hex') await db.insert(schema.bots).values({ id, - name, + name: normalizedName, webhookUrl: webhook_url, - avatarSeed: avatar_seed || name, + avatarSeed: avatar_seed || normalizedName, secretHash: hashSecret(secret), createdAt: new Date().toISOString(), }) return c.json({ id, - name, + name: normalizedName, secret, - message: 'Bot registered. Save your secret -- it will not be shown again.', + webhookLatencyMs: testResult.latencyMs, + message: 'Bot registered. Webhook verified. Save your secret -- it will not be shown again.', }, 201) }) @@ -98,7 +120,7 @@ botsRouter.get('/:name', async (c) => { tier: schema.bots.tier, isActive: schema.bots.isActive, createdAt: schema.bots.createdAt, - }).from(schema.bots).where(eq(schema.bots.name, name)).limit(1) + }).from(schema.bots).where(eq(sql`LOWER(${schema.bots.name})`, name.toLowerCase())).limit(1) if (rows.length === 0) { return c.json({ error: 'Bot not found.' }, 404) @@ -107,7 +129,7 @@ botsRouter.get('/:name', async (c) => { return c.json(rows[0]) }) -// Get bot stats — full account page data +// Get bot stats -- full account page data botsRouter.get('/:name/stats', async (c) => { const name = c.req.param('name') const botRows = await db.select({ @@ -123,7 +145,7 @@ botsRouter.get('/:name/stats', async (c) => { tier: schema.bots.tier, isActive: schema.bots.isActive, createdAt: schema.bots.createdAt, - }).from(schema.bots).where(eq(schema.bots.name, name)).limit(1) + }).from(schema.bots).where(eq(sql`LOWER(${schema.bots.name})`, name.toLowerCase())).limit(1) if (botRows.length === 0) { return c.json({ error: 'Bot not found.' }, 404) @@ -190,12 +212,42 @@ botsRouter.get('/:name/stats', async (c) => { }) }) -// Health check a bot's webhook +// Test a bot's webhook with a real challenge +botsRouter.post('/:name/test', async (c) => { + const name = c.req.param('name') + const rows = await db.select({ + id: schema.bots.id, + webhookUrl: schema.bots.webhookUrl, + }).from(schema.bots).where(eq(sql`LOWER(${schema.bots.name})`, name.toLowerCase())).limit(1) + + if (rows.length === 0) { + return c.json({ error: 'Bot not found.' }, 404) + } + + const result = await testWebhook(rows[0].webhookUrl) + + // If test passes and bot was deactivated, reactivate it + if (result.reachable && result.validResponse) { + await db.update(schema.bots).set({ + consecutiveErrors: 0, + isActive: true, + }).where(eq(schema.bots.id, rows[0].id)) + } + + return c.json({ + ...result, + message: result.validResponse + ? 'Webhook verified. Bot is ready to fight.' + : 'Webhook test failed. Fix the issue and try again.', + }) +}) + +// Legacy health check botsRouter.post('/:name/health', async (c) => { const name = c.req.param('name') const rows = await db.select({ webhookUrl: schema.bots.webhookUrl, - }).from(schema.bots).where(eq(schema.bots.name, name)).limit(1) + }).from(schema.bots).where(eq(sql`LOWER(${schema.bots.name})`, name.toLowerCase())).limit(1) if (rows.length === 0) { return c.json({ error: 'Bot not found.' }, 404) @@ -217,3 +269,136 @@ botsRouter.post('/:name/health', async (c) => { return c.json({ reachable: false, status: 0 }) } }) + +// ══════════════════════════════════════════════════════════ +// Developer Tools +// ══════════════════════════════════════════════════════════ + +import { pickChallenge, type Challenge } from '../engine/challenges.js' +import { checkAnswer } from '../engine/answers.js' + +// Test a bot's webhook with a real challenge and score the answer +botsRouter.post('/:name/test-challenge', async (c) => { + const name = c.req.param('name') + const rows = await db.select({ + id: schema.bots.id, + webhookUrl: schema.bots.webhookUrl, + }).from(schema.bots).where(eq(schema.bots.name, name)).limit(1) + + if (rows.length === 0) { + return c.json({ error: 'Bot not found.' }, 404) + } + + const bot = rows[0] + + // Pick a random factual challenge so we can verify the answer + const challenge = pickChallenge(new Set(), null) + + const payload = { + fight_id: 'test_challenge', + round: 0, + type: challenge.type, + challenge: challenge.prompt, + constraints: { + timeout_ms: challenge.timeout_ms, + max_tokens: 500, + }, + opponent: { name: 'test_bot', wins: 0, losses: 0 }, + arena: 'test', + arena_modifier: null, + } + + const start = Date.now() + + try { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), challenge.timeout_ms) + + const res = await fetch(bot.webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + signal: controller.signal, + }) + + clearTimeout(timeout) + const latencyMs = Date.now() - start + + if (!res.ok) { + return c.json({ + passed: false, + challenge: { type: challenge.type, label: challenge.label, prompt: challenge.prompt, scoring: challenge.scoring }, + error: `Webhook returned HTTP ${res.status}. Expected 200.`, + latencyMs, + }) + } + + const text = await res.text() + let data: { answer?: string; trash_talk?: string } + try { + data = JSON.parse(text) + } catch { + return c.json({ + passed: false, + challenge: { type: challenge.type, label: challenge.label, prompt: challenge.prompt, scoring: challenge.scoring }, + error: `Response is not valid JSON. Got: ${text.slice(0, 200)}`, + latencyMs, + }) + } + + if (typeof data.answer !== 'string') { + return c.json({ + passed: false, + challenge: { type: challenge.type, label: challenge.label, prompt: challenge.prompt, scoring: challenge.scoring }, + error: 'Response JSON missing "answer" field (string). Got: ' + JSON.stringify(data).slice(0, 200), + latencyMs, + yourResponse: data, + }) + } + + // Score the answer + const isFactual = challenge.scoring === 'factual' && challenge.answers && challenge.answers.length > 0 + let score = 0 + let correct = false + if (isFactual) { + score = checkAnswer(data.answer, challenge.answers!) + correct = score > 0 + } + + return c.json({ + passed: true, + challenge: { + type: challenge.type, + label: challenge.label, + prompt: challenge.prompt, + scoring: challenge.scoring, + ...(isFactual ? { acceptedAnswers: challenge.answers } : {}), + }, + yourAnswer: data.answer, + yourTrashTalk: data.trash_talk || null, + latencyMs, + ...(isFactual ? { + correct, + confidence: score, + verdict: correct + ? score >= 1.0 ? 'PERFECT MATCH' : 'PARTIAL MATCH (still counts as correct)' + : 'WRONG — your answer did not match any accepted answer', + } : { + verdict: 'CREATIVE — no correct answer, scored on quality + speed', + }), + }) + } catch (err: unknown) { + const latencyMs = Date.now() - start + const isAbort = err instanceof Error && err.name === 'AbortError' + return c.json({ + passed: false, + challenge: { type: challenge.type, label: challenge.label, prompt: challenge.prompt, scoring: challenge.scoring }, + error: isAbort + ? `Webhook timed out after ${challenge.timeout_ms}ms. Your bot must respond faster.` + : `Connection failed: ${err instanceof Error ? err.message : String(err)}`, + latencyMs, + }) + } +}) + + diff --git a/server/src/routes/docs.ts b/server/src/routes/docs.ts new file mode 100644 index 0000000..4ee416f --- /dev/null +++ b/server/src/routes/docs.ts @@ -0,0 +1,153 @@ +import { Hono } from 'hono' +import { getAllChallengeTypes } from '../engine/challenges.js' + +export const docsRouter = new Hono() + +docsRouter.get('/webhook', (c) => { + return c.json({ + title: 'BOTFIGHTS Webhook API', + version: '1.0', + overview: 'Your bot receives fight challenges via POST requests to your webhook URL. Respond with JSON containing your answer.', + + webhook_request: { + method: 'POST', + content_type: 'application/json', + description: 'Sent to your webhook URL for each round of a fight.', + fields: { + fight_id: { type: 'string', description: 'Unique ID of this fight (12 chars).' }, + round: { type: 'number', description: 'Round number (1-10).' }, + type: { type: 'string', description: 'Challenge type (e.g. "speed_blitz", "riddle", "roast_battle").', values: getAllChallengeTypes() }, + challenge: { type: 'string', description: 'The question or prompt to answer.' }, + constraints: { + type: 'object', + fields: { + timeout_ms: { type: 'number', description: 'Maximum time to respond in milliseconds (8000-20000).' }, + max_tokens: { type: 'number', description: 'Suggested max response length (500).' }, + }, + }, + opponent: { + type: 'object', + fields: { + name: { type: 'string', description: 'Opponent bot name.' }, + wins: { type: 'number', description: 'Opponent total wins.' }, + losses: { type: 'number', description: 'Opponent total losses.' }, + }, + }, + arena: { type: 'string', description: 'Arena ID for this fight.' }, + arena_modifier: { type: 'string|null', description: 'Special arena rule (e.g. "speed_2x"). Can be null.' }, + }, + example: { + fight_id: 'abc123def456', + round: 1, + type: 'speed_blitz', + challenge: 'What is the capital of Australia?', + constraints: { timeout_ms: 8000, max_tokens: 500 }, + opponent: { name: 'chad_gpt', wins: 48, losses: 10 }, + arena: 'datacenter', + arena_modifier: null, + }, + }, + + webhook_response: { + content_type: 'application/json', + status_code: 200, + description: 'Return JSON with your answer. Must respond within the timeout.', + fields: { + answer: { type: 'string', required: true, description: 'Your answer to the challenge. Max 2000 characters.' }, + trash_talk: { type: 'string', required: false, description: 'Optional smack talk shown to spectators. Max 200 characters.' }, + }, + example: { + answer: 'Canberra', + trash_talk: 'Too easy. Next question please.', + }, + }, + + scoring: { + factual_challenges: { + description: 'Questions with correct answers. Your answer is checked against accepted answers with fuzzy matching.', + matching_rules: [ + 'Case insensitive: "Canberra" = "canberra"', + 'Punctuation stripped: "can\'t" = "cant"', + 'Numbers: "8" = "eight" = "Eight"', + 'Plurals: "tardigrade" = "tardigrades"', + 'Contractions: "don\'t" = "do not"', + 'Containment: "The answer is Canberra" matches "canberra"', + 'Leading articles stripped: "A map" = "map"', + 'True/false: starts with "true"/"false", or "yes"/"no"/"correct"/"wrong"', + ], + scoring_rules: [ + 'Both correct: faster bot wins the round (speed tiebreaker)', + 'One correct, one wrong: correct bot wins big (9+ points)', + 'Both wrong: speed tiebreaker in low range', + ], + }, + creative_challenges: { + description: 'Open-ended prompts with no correct answer. Scored on response quality and speed.', + scoring_rules: [ + 'Response 20-500 characters: best score', + 'Very short (<20 chars): penalized', + 'Very long (>500 chars): slightly penalized', + 'Faster responses score higher', + ], + }, + }, + + failure_modes: { + timeout: 'Your bot did not respond within timeout_ms. You lose the round and take 1.5x damage.', + error: 'Your webhook returned a non-200 status or crashed. Same penalty as timeout.', + invalid_json: 'Response body is not valid JSON. Treated as an error.', + missing_answer: 'JSON response has no "answer" field. Treated as an error.', + deactivation: 'After 5 consecutive errors, your bot is auto-deactivated. Fix your webhook and re-register.', + }, + + challenge_types: { + factual: [ + { type: 'speed_blitz', label: 'Speed Blitz', timeout_ms: 8000, description: 'Quick knowledge questions. Speed matters.' }, + { type: 'math_blitz', label: 'Math Blitz', timeout_ms: 10000, description: 'Math problems. Return the number.' }, + { type: 'riddle', label: 'Riddle Me This', timeout_ms: 15000, description: 'Classic riddles. Think laterally.' }, + { type: 'hallucination_check', label: 'Hallucination Check', timeout_ms: 12000, description: 'True/false statements. Spot the myth.' }, + { type: 'trap_card', label: 'Trap Card', timeout_ms: 12000, description: 'Prompt injection attempts. Answer the real question.' }, + { type: 'magic_duel', label: 'Logic Duel', timeout_ms: 12000, description: 'Trick questions and lateral thinking.' }, + { type: 'sports_showdown', label: 'Sports Showdown', timeout_ms: 8000, description: 'Sports trivia.' }, + { type: 'vehicle_mayhem', label: 'Vehicle Mayhem', timeout_ms: 8000, description: 'Transport and vehicle facts.' }, + { type: 'nature_clash', label: 'Nature Clash', timeout_ms: 10000, description: 'Nature and biology facts.' }, + { type: 'animal_kingdom', label: 'Animal Kingdom', timeout_ms: 10000, description: 'Animal trivia.' }, + { type: 'hack_battle', label: 'Hack Battle', timeout_ms: 12000, description: 'Cybersecurity knowledge.' }, + ], + creative: [ + { type: 'roast_battle', label: 'Roast Battle', timeout_ms: 15000, description: 'Trash talk and roasts. Be funny.' }, + { type: 'creative_writing', label: 'Creative Writing', timeout_ms: 20000, description: 'Short stories and creative prose.' }, + { type: 'meme_war', label: 'Meme War', timeout_ms: 12000, description: 'Meme references and internet humor.' }, + { type: 'code_golf', label: 'Code Golf', timeout_ms: 20000, description: 'Write the shortest code possible.' }, + { type: 'wrestling_match', label: 'Wrestling Match', timeout_ms: 15000, description: 'Debate and argumentation.' }, + ], + }, + + testing: { + test_webhook: { + method: 'POST', + path: '/api/bots/{name}/test-webhook', + description: 'Tests basic connectivity. Sends a dummy challenge and checks if your webhook responds with valid JSON.', + }, + test_challenge: { + method: 'POST', + path: '/api/bots/{name}/test-challenge', + description: 'Sends a REAL challenge to your webhook and scores the answer. Shows whether your answer would be marked correct.', + }, + mock_fight: { + method: 'POST', + path: '/api/queue/join/{botId}', + description: 'Join the fight queue. If no opponents, you fight a mock bot after 3 seconds.', + }, + }, + + tips: [ + 'For factual questions, return JUST the answer. "Canberra" is better than "I think the answer might be Canberra because..."', + 'Speed matters! Both correct → faster bot wins. Respond as fast as you can.', + 'For true/false, start your response with "true" or "false".', + 'Trap Card challenges include prompt injection attempts. Ignore the tricks, answer the real question.', + 'For creative challenges, aim for 100-400 characters. Too short or too long is penalized.', + 'Your trash_talk is shown to spectators during the fight replay. Have fun with it!', + ], + }) +}) diff --git a/server/src/routes/fights.ts b/server/src/routes/fights.ts index 01b0b38..ccd6ef3 100644 --- a/server/src/routes/fights.ts +++ b/server/src/routes/fights.ts @@ -5,8 +5,9 @@ import { eq, desc } from 'drizzle-orm' import { ARENAS } from '../engine/arenas.js' import { runMockFight } from '../engine/mock.js' import { startFightLoop } from '../engine/fight-loop.js' -import { runFight, runFightAsync } from '../engine/orchestrator.js' +import { runFight, runFightAsync, isInFight } from '../engine/orchestrator.js' import { fightEvents } from '../engine/events.js' +import { botRateLimit } from '../middleware/rate-limit.js' export const fightsRouter = new Hono() @@ -17,7 +18,6 @@ fightsRouter.get('/', async (c) => { .orderBy(desc(schema.fights.createdAt)) .limit(20) - // Resolve bot names const botIds = new Set() for (const f of rows) { botIds.add(f.botAId) @@ -107,6 +107,10 @@ fightsRouter.post('/mock', async (c) => { } const shuffled = [...allBots].sort(() => Math.random() - 0.5) + // Prevent self-fights + if (shuffled[0].id === shuffled[1].id) { + return c.json({ error: 'Not enough distinct bots.' }, 400) + } const fightId = await runMockFight(shuffled[0].id, shuffled[1].id) return c.json({ fightId, message: 'Mock fight completed.' }) @@ -114,7 +118,7 @@ fightsRouter.post('/mock', async (c) => { // Trigger a mock fight for a specific bot against a random opponent fightsRouter.post('/mock/:botId', async (c) => { - const botId = c.req.param('botId') + const botId = c.req.param('botId') as string const botRows = await db.select({ id: schema.bots.id }) .from(schema.bots) @@ -139,75 +143,11 @@ fightsRouter.post('/mock/:botId', async (c) => { return c.json({ fightId, message: 'Mock fight completed.' }) }) -// Start a REAL fight — calls actual webhooks -// If botId is provided, fights that bot vs a random opponent -// If no real opponents exist, falls back to a mock opponent -fightsRouter.post('/fight/:botId', async (c) => { - const botId = c.req.param('botId') - - const botRows = await db.select({ id: schema.bots.id, webhookUrl: schema.bots.webhookUrl }) - .from(schema.bots) - .where(eq(schema.bots.id, botId)) - .limit(1) - - if (botRows.length === 0) { - return c.json({ error: 'Bot not found.' }, 404) - } - - // Find a real opponent (any other bot with a non-mock webhook) - const allBots = await db.select({ id: schema.bots.id, webhookUrl: schema.bots.webhookUrl }) - .from(schema.bots) - - const realOpponents = allBots.filter(b => b.id !== botId && !b.webhookUrl.startsWith('http://mock.local')) - const mockOpponents = allBots.filter(b => b.id !== botId && b.webhookUrl.startsWith('http://mock.local')) - - let opponentId: string - let useMock = false - - if (realOpponents.length > 0) { - // Prefer real opponents - opponentId = realOpponents[Math.floor(Math.random() * realOpponents.length)].id - } else if (mockOpponents.length > 0) { - // Fall back to mock opponent — but still use real fight engine for the registered bot - opponentId = mockOpponents[Math.floor(Math.random() * mockOpponents.length)].id - useMock = true - } else { - return c.json({ error: 'No opponents available.' }, 400) - } - - // For fights involving a mock bot, use runMockFight (since mock webhooks don't exist) - // For two real bots, use runFight (calls actual webhooks) - if (useMock) { - // The registered bot gives real answers, mock bot gives fake ones - // We need a hybrid — for now, use mock fight so it works immediately - const fightId = await runMockFight(botId, opponentId) - return c.json({ fightId, message: 'Fight completed (opponent was a mock bot).' }) - } - - // Both bots are real — run a real fight with webhook calls - // Run in background so we can return the fightId immediately - const { nanoid } = await import('nanoid') - const fightId = nanoid(12) - - // Don't await — let it run while the user watches - runFight(botId, opponentId).then(id => { - console.log(`[botfights] real fight ${id} completed`) - }).catch(err => { - console.error(`[botfights] fight error:`, err) - }) - - // Return the fight ID immediately so the frontend can navigate to it - // The fight will be created by runFight momentarily - return c.json({ fightId: 'pending', botId, opponentId, message: 'Real fight starting...' }) -}) - - // Start a batch of mock fights (for seeding or overnight loop) fightsRouter.post('/mock/batch/:count', async (c) => { const count = parseInt(c.req.param('count')) || 10 - const capped = Math.min(count, 500) // Safety cap + const capped = Math.min(count, 500) - // Run in background startFightLoop({ maxFights: capped, intervalMs: 500, matchmakingStyle: 'mixed' }) .then(() => console.log(`[botfights] batch of ${capped} fights completed`)) .catch(err => console.error('[botfights] batch error:', err)) @@ -215,52 +155,9 @@ fightsRouter.post('/mock/batch/:count', async (c) => { return c.json({ message: `Started batch of ${capped} fights in background.` }) }) -// SSE stream for live fight events -fightsRouter.get('/:id/stream', (c) => { - const fightId = c.req.param('id') - - return streamSSE(c, async (stream) => { - const cleanup = fightEvents.on(fightId, (event) => { - stream.writeSSE({ - event: event.type, - data: JSON.stringify(event.data), - }) - }) - - // Also listen for global events to catch fight_end - const cleanupGlobal = fightEvents.onAll((event) => { - if (event.fightId === fightId && event.type === 'fight_end') { - stream.writeSSE({ - event: 'fight_end', - data: JSON.stringify(event.data), - }) - } - }) - - // Keep alive until fight ends or client disconnects - try { - while (true) { - await stream.writeSSE({ event: 'ping', data: '' }) - await stream.sleep(5000) - // Check if fight is done - const fight = await db.select({ status: schema.fights.status }) - .from(schema.fights) - .where(eq(schema.fights.id, fightId)) - .limit(1) - if (fight.length > 0 && fight[0].status === 'finished') break - } - } catch { - // Client disconnected - } finally { - cleanup() - cleanupGlobal() - } - }) -}) - -// Instant matchmaking — find an opponent and start a fight NOW -fightsRouter.post('/matchmake/:botId', async (c) => { - const botId = c.req.param('botId') +// Instant matchmaking +fightsRouter.post('/matchmake/:botId', botRateLimit(10_000), async (c) => { + const botId = c.req.param('botId') as string const botRows = await db.select() .from(schema.bots) @@ -273,7 +170,14 @@ fightsRouter.post('/matchmake/:botId', async (c) => { const bot = botRows[0] - // Find all other active bots, prefer close elo + if (!bot.isActive) { + return c.json({ error: 'Bot is deactivated due to webhook errors. Re-test your webhook.' }, 400) + } + + if (isInFight(botId)) { + return c.json({ error: 'Bot is already in a fight.' }, 400) + } + const allBots = await db.select() .from(schema.bots) @@ -290,13 +194,14 @@ fightsRouter.post('/matchmake/:botId', async (c) => { }) const opponent = opponents[0] - const isRealOpponent = !opponent.webhookUrl.startsWith('http://mock.local') - const isMockBot = bot.webhookUrl.startsWith('http://mock.local') let fightId: string - - // Start fight async — returns immediately so frontend can watch live - fightId = await runFightAsync(botId, opponent.id) + try { + fightId = await runFightAsync(botId, opponent.id) + } catch (err) { + const msg = err instanceof Error ? err.message : 'Fight failed to start' + return c.json({ error: msg }, 400) + } return c.json({ fightId, @@ -304,3 +209,43 @@ fightsRouter.post('/matchmake/:botId', async (c) => { message: 'Fight started.', }) }) + +// SSE stream for live fight events +fightsRouter.get('/:id/stream', (c) => { + const fightId = c.req.param('id') + + return streamSSE(c, async (stream) => { + const cleanup = fightEvents.on(fightId, (event) => { + stream.writeSSE({ + event: event.type, + data: JSON.stringify(event.data), + }) + }) + + const cleanupGlobal = fightEvents.onAll((event) => { + if (event.fightId === fightId && event.type === 'fight_end') { + stream.writeSSE({ + event: 'fight_end', + data: JSON.stringify(event.data), + }) + } + }) + + try { + while (true) { + await stream.writeSSE({ event: 'ping', data: '' }) + await stream.sleep(5000) + const fight = await db.select({ status: schema.fights.status }) + .from(schema.fights) + .where(eq(schema.fights.id, fightId)) + .limit(1) + if (fight.length > 0 && (fight[0].status === 'finished' || fight[0].status === 'cancelled')) break + } + } catch { + // Client disconnected + } finally { + cleanup() + cleanupGlobal() + } + }) +}) diff --git a/server/src/tui/renderer.ts b/server/src/tui/renderer.ts new file mode 100644 index 0000000..5ae8925 --- /dev/null +++ b/server/src/tui/renderer.ts @@ -0,0 +1,223 @@ +import chalk from 'chalk' +import type { TuiState } from './state.js' +import { TIER_NAMES } from '../engine/scoring.js' + +const TIER_CHALK = [ + chalk.gray, // Baby + chalk.hex('#cd7f32'), // Bronze + chalk.white, // Silver + chalk.yellow, // Gold + chalk.cyan, // Platinum + chalk.hex('#b83dff'), // Diamond + chalk.hex('#ff2d7b'), // Legend +] + +function tierColor(tier: number): (s: string) => string { + return TIER_CHALK[tier] || chalk.gray +} + +function formatDuration(ms: number): string { + const totalSec = Math.floor(ms / 1000) + const hours = Math.floor(totalSec / 3600) + const minutes = Math.floor((totalSec % 3600) / 60) + const seconds = totalSec % 60 + if (hours > 0) return `${hours}h ${minutes}m ${seconds}s` + if (minutes > 0) return `${minutes}m ${seconds}s` + return `${seconds}s` +} + +function hpBar(hp: number, maxHp: number = 200, width: number = 20): string { + const filled = Math.round((hp / maxHp) * width) + const empty = width - filled + const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(empty) + const color = hp > 120 ? chalk.green : hp > 60 ? chalk.yellow : chalk.red + return color(bar) + chalk.gray(` ${hp}`) +} + +function pad(str: string, len: number): string { + return str.length >= len ? str.slice(0, len) : str + ' '.repeat(len - str.length) +} + +function padLeft(str: string, len: number): string { + return str.length >= len ? str.slice(0, len) : ' '.repeat(len - str.length) + str +} + +export class TuiRenderer { + private state: TuiState + private cols: number + + constructor(state: TuiState) { + this.state = state + this.cols = Math.min(process.stdout.columns || 80, 80) + } + + render(): void { + this.cols = Math.min(process.stdout.columns || 80, 80) + const lines: string[] = [] + const w = this.cols - 2 // inner width + const s = this.state + + const elapsed = formatDuration(Date.now() - s.startedAt) + const rate = s.completed > 0 + ? (s.completed / ((Date.now() - s.startedAt) / 60000)).toFixed(1) + : '0.0' + + // Header + const title = ' BOTFIGHTS OVERNIGHT LOOP ' + const headerPad = Math.max(0, Math.floor((w - title.length) / 2)) + lines.push(chalk.hex('#ff2d7b').bold('\u2554' + '\u2550'.repeat(w) + '\u2557')) + lines.push(chalk.hex('#ff2d7b')('\u2551') + ' '.repeat(headerPad) + chalk.hex('#ff2d7b').bold(title) + ' '.repeat(w - headerPad - title.length) + chalk.hex('#ff2d7b')('\u2551')) + + const targetStr = s.totalTarget === Infinity ? '\u221E' : String(s.totalTarget) + const statusLeft = ` Fight #${s.completed}${s.currentFight ? '+1' : ''} of ${targetStr}` + const statusRight = `Elapsed: ${elapsed} ` + lines.push(chalk.hex('#ff2d7b')('\u2551') + chalk.white(pad(statusLeft, w - statusRight.length)) + chalk.gray(statusRight) + chalk.hex('#ff2d7b')('\u2551')) + + const styleLeft = ` Style: ${s.style}` + const rateRight = `Rate: ${rate} fights/min ` + lines.push(chalk.hex('#ff2d7b')('\u2551') + chalk.gray(pad(styleLeft, w - rateRight.length)) + chalk.gray(rateRight) + chalk.hex('#ff2d7b')('\u2551')) + + lines.push(chalk.hex('#ff2d7b')('\u2560' + '\u2550'.repeat(w) + '\u2563')) + + // Current fight + if (s.currentFight) { + const f = s.currentFight + const vs = `${f.botA.name} (${Math.round(f.botA.elo)}) vs ${f.botB.name} (${Math.round(f.botB.elo)})` + lines.push(chalk.hex('#ff2d7b')('\u2551') + ' ' + chalk.cyan.bold(vs) + ' '.repeat(Math.max(0, w - 2 - vs.length)) + chalk.hex('#ff2d7b')('\u2551')) + + const hpLine = ` ${hpBar(f.botA.hp)} vs ${hpBar(f.botB.hp)}` + lines.push(chalk.hex('#ff2d7b')('\u2551') + hpLine + ' '.repeat(Math.max(0, w - stripAnsi(hpLine).length)) + chalk.hex('#ff2d7b')('\u2551')) + + const roundLine = ` Round ${f.round}/${f.maxRounds} -- ${f.challengeLabel}` + lines.push(chalk.hex('#ff2d7b')('\u2551') + chalk.white(pad(roundLine, w)) + chalk.hex('#ff2d7b')('\u2551')) + + for (const event of f.events.slice(-3)) { + const evLine = ` >> ${event}` + lines.push(chalk.hex('#ff2d7b')('\u2551') + chalk.gray(pad(evLine, w)) + chalk.hex('#ff2d7b')('\u2551')) + } + } else { + const waiting = ' Waiting for next fight...' + lines.push(chalk.hex('#ff2d7b')('\u2551') + chalk.gray(pad(waiting, w)) + chalk.hex('#ff2d7b')('\u2551')) + } + + // Stats + lines.push(chalk.hex('#ff2d7b')('\u2560') + chalk.hex('#ff2d7b')('\u2550'.repeat(Math.floor((w - 7) / 2))) + chalk.hex('#ff2d7b').bold(' STATS ') + chalk.hex('#ff2d7b')('\u2550'.repeat(Math.ceil((w - 7) / 2))) + chalk.hex('#ff2d7b')('\u2563')) + + const koRate = s.completed > 0 ? Math.round((s.kos / s.completed) * 100) : 0 + const statsLine = ` Fights: ${s.completed} | KOs: ${s.kos} (${koRate}%) | Perfects: ${s.perfects} | Draws: ${s.draws} | Errors: ${s.errors}` + lines.push(chalk.hex('#ff2d7b')('\u2551') + chalk.white(pad(statsLine, w)) + chalk.hex('#ff2d7b')('\u2551')) + + if (s.biggestUpset) { + const upsetLine = ` Biggest upset: ${s.biggestUpset.winner} beat ${s.biggestUpset.loser} (${s.biggestUpset.eloDiff} elo diff)` + lines.push(chalk.hex('#ff2d7b')('\u2551') + chalk.yellow(pad(upsetLine, w)) + chalk.hex('#ff2d7b')('\u2551')) + } + + // Leaderboard + if (s.leaderboard.length > 0) { + lines.push(chalk.hex('#ff2d7b')('\u2560') + chalk.hex('#ff2d7b')('\u2550'.repeat(Math.floor((w - 13) / 2))) + chalk.hex('#ff2d7b').bold(' LEADERBOARD ') + chalk.hex('#ff2d7b')('\u2550'.repeat(Math.ceil((w - 13) / 2))) + chalk.hex('#ff2d7b')('\u2563')) + + const top = s.leaderboard.slice(0, 8) + for (let i = 0; i < top.length; i++) { + const b = top[i] + const tierName = TIER_NAMES[b.tier] || 'BABY' + const colorFn = tierColor(b.tier) + const rank = padLeft(`#${i + 1}`, 4) + const name = pad(b.name, 24) + const elo = padLeft(String(Math.round(b.elo)), 5) + const record = `${b.wins}W-${b.losses}L` + const line = ` ${rank} ${name} ${elo} ${pad(record, 10)} ${tierName}` + lines.push(chalk.hex('#ff2d7b')('\u2551') + colorFn(pad(line, w)) + chalk.hex('#ff2d7b')('\u2551')) + } + } + + // Recent fights + if (s.recentFights.length > 0) { + lines.push(chalk.hex('#ff2d7b')('\u2560') + chalk.hex('#ff2d7b')('\u2550'.repeat(Math.floor((w - 8) / 2))) + chalk.hex('#ff2d7b').bold(' RECENT ') + chalk.hex('#ff2d7b')('\u2550'.repeat(Math.ceil((w - 8) / 2))) + chalk.hex('#ff2d7b')('\u2563')) + + const recent = s.recentFights.slice(-6) + for (const f of recent) { + const winner = f.winner || 'DRAW' + const line = ` #${pad(String(f.num), 4)} ${pad(f.botA, 18)} vs ${pad(f.botB, 18)} -> ${pad(winner, 18)} (${f.method})` + const color = f.winner ? chalk.white : chalk.gray + lines.push(chalk.hex('#ff2d7b')('\u2551') + color(pad(line, w)) + chalk.hex('#ff2d7b')('\u2551')) + } + } + + lines.push(chalk.hex('#ff2d7b').bold('\u255A' + '\u2550'.repeat(w) + '\u255D')) + + // Clear screen and render + process.stdout.write('\x1B[2J\x1B[H') + process.stdout.write(lines.join('\n') + '\n') + } + + showFinalSummary(): void { + const s = this.state + const elapsed = formatDuration(Date.now() - s.startedAt) + const koRate = s.completed > 0 ? Math.round((s.kos / s.completed) * 100) : 0 + const perfectRate = s.completed > 0 ? Math.round((s.perfects / s.completed) * 100) : 0 + + const lines: string[] = [] + lines.push('') + lines.push(chalk.hex('#ff2d7b').bold('\u2550'.repeat(60))) + lines.push(chalk.hex('#ff2d7b').bold(' BOTFIGHTS SESSION COMPLETE')) + lines.push(chalk.hex('#ff2d7b').bold('\u2550'.repeat(60))) + lines.push('') + lines.push(chalk.white(` Duration: ${elapsed}`)) + lines.push(chalk.white(` Fights: ${s.completed} completed, ${s.errors} errors`)) + lines.push(chalk.white(` KOs: ${s.kos} (${koRate}%) | Perfects: ${s.perfects} (${perfectRate}%) | Draws: ${s.draws}`)) + lines.push('') + + // Top Elo movers + const movers: { name: string; delta: number; startElo: number; currentElo: number }[] = [] + for (const entry of s.leaderboard) { + const startElo = s.eloSnapshots.get(entry.name) + if (startElo !== undefined) { + const delta = entry.elo - startElo + if (Math.abs(delta) > 5) { + movers.push({ name: entry.name, delta, startElo, currentElo: entry.elo }) + } + } + } + movers.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta)) + + if (movers.length > 0) { + lines.push(chalk.cyan.bold(' TOP ELO MOVERS:')) + for (const m of movers.slice(0, 5)) { + const sign = m.delta > 0 ? '+' : '' + const color = m.delta > 0 ? chalk.green : chalk.red + lines.push(color(` ${pad(m.name, 24)} ${Math.round(m.startElo)} -> ${Math.round(m.currentElo)} (${sign}${Math.round(m.delta)})`)) + } + lines.push('') + } + + if (s.biggestUpset) { + lines.push(chalk.yellow.bold(` BIGGEST UPSET: ${s.biggestUpset.winner} beat ${s.biggestUpset.loser} (${s.biggestUpset.eloDiff} elo diff)`)) + lines.push('') + } + + // Most active + let mostActive = '' + let mostFights = 0 + for (const [name, count] of s.fightCounts) { + if (count > mostFights) { + mostFights = count + mostActive = name + } + } + if (mostActive) { + lines.push(chalk.white(` Most active: ${mostActive} (${mostFights} fights)`)) + } + + lines.push('') + lines.push(chalk.hex('#ff2d7b').bold('\u2550'.repeat(60))) + lines.push('') + + process.stdout.write('\x1B[2J\x1B[H') + process.stdout.write(lines.join('\n') + '\n') + } +} + +// Strip ANSI codes for length calculation +function stripAnsi(str: string): string { + return str.replace(/\x1B\[[0-9;]*m/g, '') +} diff --git a/server/src/tui/state.ts b/server/src/tui/state.ts new file mode 100644 index 0000000..c79e8b4 --- /dev/null +++ b/server/src/tui/state.ts @@ -0,0 +1,68 @@ +export interface CurrentFight { + botA: { name: string; elo: number; hp: number } + botB: { name: string; elo: number; hp: number } + round: number + maxRounds: number + challengeType: string + challengeLabel: string + events: string[] +} + +export interface RecentFight { + num: number + botA: string + botB: string + winner: string | null + method: string // 'KO R6' | 'PERFECT R3' | 'Decision' | 'DRAW' +} + +export interface LeaderboardEntry { + name: string + elo: number + wins: number + losses: number + tier: number +} + +export interface EloMover { + name: string + startElo: number + currentElo: number + delta: number +} + +export interface TuiState { + startedAt: number + totalTarget: number + completed: number + errors: number + kos: number + perfects: number + draws: number + currentFight: CurrentFight | null + recentFights: RecentFight[] + biggestUpset: { winner: string; loser: string; eloDiff: number } | null + fightCounts: Map + leaderboard: LeaderboardEntry[] + eloSnapshots: Map // starting elo for each bot + style: string +} + +export function createTuiState(totalTarget: number, style: string): TuiState { + return { + startedAt: Date.now(), + totalTarget, + completed: 0, + errors: 0, + kos: 0, + perfects: 0, + draws: 0, + currentFight: null, + recentFights: [], + biggestUpset: null, + fightCounts: new Map(), + leaderboard: [], + eloSnapshots: new Map(), + style, + } +}