diff --git a/frontend/src/components/FightViewer.vue b/frontend/src/components/FightViewer.vue index b6b18d3..d89aeff 100644 --- a/frontend/src/components/FightViewer.vue +++ b/frontend/src/components/FightViewer.vue @@ -8,6 +8,7 @@ import { sfxCrowdCheer, sfxCrowdGasp, sfxCrowdOoh, sfxApplause, sfxDrumRoll, setMusicIntensity, stopAllAudio, setMasterMute, isMasterMuted, ensureAudioContext, + speakQuestion, speakAnswer, speakNarration, } from '../game/sounds' interface Round { @@ -66,6 +67,7 @@ const hitTextX = ref(50) const hitTextY = ref(30) const glitching = ref(false) const soundOn = ref(true) +const insanityMode = ref(false) async function toggleSound() { soundOn.value = !soundOn.value @@ -73,6 +75,14 @@ async function toggleSound() { setMasterMute(!soundOn.value) } +function toggleInsanity() { + insanityMode.value = !insanityMode.value + // Insanity mode kills all TTS immediately + if (insanityMode.value && typeof speechSynthesis !== 'undefined') { + speechSynthesis.cancel() + } +} + // Staggered log const logItems = ref<{ type: string; round: number; text: string; color: string }[]>([]) @@ -269,28 +279,72 @@ async function _doReplay() { for (const round of props.fight.rounds) { currentRound.value = round.roundNumber - fanfareRound(round.roundNumber) - await showOverlay(`ROUND ${round.roundNumber}`, '#00f0ff', 700) - await sleep(80) - await showOverlay(challengeLabel(round.challengeType), '#b83dff', 600) - await sleep(80) - fanfareFight() - await showOverlay('FIGHT!', '#ff2d7b', 400) - await sleep(80) - - // Show speech bubbles BEFORE the fight animation so viewers see what bots said - if (scene) { - if (round.botAResponse) scene.showSpeechBubble('a', round.botAResponse.slice(0, 60), 3.5) - if (round.botBResponse) { - setTimeout(() => { - if (scene && round.botBResponse) scene.showSpeechBubble('b', round.botBResponse.slice(0, 60), 3.2) - }, 400) - } + if (insanityMode.value) { + // Insanity: minimal overlays, no fanfares + await showOverlay(`R${round.roundNumber}`, '#00f0ff', 150) + } else { + fanfareRound(round.roundNumber) + await showOverlay(`ROUND ${round.roundNumber}`, '#00f0ff', 700) + await sleep(80) + await showOverlay(challengeLabel(round.challengeType), '#b83dff', 600) + await sleep(80) + fanfareFight() + await showOverlay('FIGHT!', '#ff2d7b', 400) + await sleep(80) } - await sleep(300) - // Log + fight animation in parallel - const logPromise = addRoundToLog(round, true) + // === TTS-synced question + answer flow === + const challenge = JSON.parse(round.challengeData) + const doTTS = soundOn.value && !insanityMode.value + + // 1. Show question in log AND speak it + logItems.value.push( + { type: 'header', round: round.roundNumber, text: `ROUND ${round.roundNumber}: ${challengeLabel(round.challengeType)}`, color: 'neon-purple' }, + ) + scrollLog(); await sleep(insanityMode.value ? 30 : 100) + logItems.value.push( + { type: 'prompt', round: round.roundNumber, text: challenge.prompt, color: 'text-muted' }, + ) + scrollLog() + if (doTTS && challenge.prompt) { + await speakQuestion(challenge.prompt) + await sleep(150) + } else { + await sleep(insanityMode.value ? 50 : 600) + } + + // 2. Bot A: log + bubble + mouth + TTS (all synced) + if (round.botAResponse) { + logItems.value.push({ type: 'responseA', round: round.roundNumber, text: `${props.fight.botA?.name}: ${round.botAResponse.slice(0, 120) || '[NO RESPONSE]'}`, color: 'neon-cyan' }) + logItems.value.push({ type: 'time', round: round.roundNumber, text: ` ${round.botATimeMs}ms | Score: ${round.botAScore}`, color: 'text-muted' }) + scrollLog() + if (scene && !insanityMode.value) { + scene.showSpeechBubble('a', round.botAResponse.slice(0, 60), 5) + scene.startTalking('a') + } + if (doTTS) await speakAnswer(props.fight.botA!.name, round.botAResponse.slice(0, 60)) + else await sleep(insanityMode.value ? 30 : 800) + scene?.stopTalking('a') + if (!insanityMode.value) await sleep(150) + } + + // 3. Bot B: log + bubble + mouth + TTS + if (round.botBResponse) { + logItems.value.push({ type: 'responseB', round: round.roundNumber, text: `${props.fight.botB?.name}: ${round.botBResponse.slice(0, 120) || '[NO RESPONSE]'}`, color: 'neon-pink' }) + logItems.value.push({ type: 'time', round: round.roundNumber, text: ` ${round.botBTimeMs}ms | Score: ${round.botBScore}`, color: 'text-muted' }) + scrollLog() + if (scene && !insanityMode.value) { + scene.showSpeechBubble('b', round.botBResponse.slice(0, 60), 5) + scene.startTalking('b') + } + if (doTTS) await speakAnswer(props.fight.botB!.name, round.botBResponse.slice(0, 60)) + else await sleep(insanityMode.value ? 30 : 800) + scene?.stopTalking('b') + if (!insanityMode.value) await sleep(150) + } + + // Log is already populated above — no need for addRoundToLog stagger + const logPromise = Promise.resolve() const isCritical = Math.abs((round.botAScore || 0) - (round.botBScore || 0)) > 4 const aWon = round.winnerId === props.fight.botA!.id const bWon = round.winnerId === props.fight.botB!.id @@ -328,6 +382,8 @@ async function _doReplay() { if (round.narration) { logItems.value.push({ type: 'narration', round: round.roundNumber, text: `>> ${round.narration}`, color: 'neon-yellow' }) scrollLog() + if (doTTS) await speakNarration(round.narration) + await sleep(insanityMode.value ? 30 : 200) } const winner = aWon ? props.fight.botA!.name : bWon ? props.fight.botB!.name : 'DRAW' @@ -352,17 +408,18 @@ async function _doReplay() { setMusicIntensity(1 - lowestHp / 100) // Taunting between rounds — winner taunts, sometimes both - if (scene && (aWon || bWon)) { + if (!insanityMode.value && scene && (aWon || bWon)) { const winnerSide = aWon ? 'a' : 'b' await sleep(150) await scene.playTaunt(winnerSide) - // Sometimes loser taunts back (30% chance) if (Math.random() < 0.3) { await scene.playTaunt(winnerSide === 'a' ? 'b' : 'a') } await sleep(200) - } else { + } else if (!insanityMode.value) { await sleep(400) + } else { + await sleep(30) } } @@ -593,6 +650,17 @@ async function _doReplay() { + {{ fight.status === 'finished' ? 'FINISHED' : fight.status.toUpperCase() }} diff --git a/frontend/src/game/FightScene.ts b/frontend/src/game/FightScene.ts index 484730b..60a8c25 100644 --- a/frontend/src/game/FightScene.ts +++ b/frontend/src/game/FightScene.ts @@ -107,7 +107,29 @@ const TIER_ULTIMATES: Record = { 5: ['ultimateArmageddon', 'ultimateOmegaBeam', 'ultimateBeyondTheScreen', 'ultimateMatrixDodge', 'ultimateWorldEnder', 'ultimateAbsoluteZero', 'ultimateSolarCannon', 'ultimatePhantomStrike', 'ultimateGodPunch', 'ultimateVoidCollapse'], } -function pickChoreography(challengeType: string, isCritical: boolean, _round: number, attackerTier: number = 0): string { +// The Creator — exclusive moves (50% ultimate chance, 100% on crits, custom pool) +const CREATOR_MOVES = ['bitcoinRain', 'lightningInvoice', 'satoshiStrike', 'hashRateOverload', 'blockchainSlam', 'creatorMode', 'morphStrike', 'divineIntervention'] +const CREATOR_ULTIMATES = ['ultimateGenesisBlock', 'ultimateTheHalving', 'ultimateFullNodeCrush', 'ultimateSatoshiVision'] + +function pickChoreography(challengeType: string, isCritical: boolean, _round: number, attackerTier: number = 0, attackerArchetype?: string): string { + // THE CREATOR: special move selection — 50% ultimate, 60% creator moves, rest normal + if (attackerArchetype === 'the_creator') { + const ultimateChance = isCritical ? 1.0 : 0.5 + if (Math.random() < ultimateChance) { + // Creator gets ALL ultimates + their 4 exclusive ones + const all: string[] = [...CREATOR_ULTIMATES] + for (let t = 2; t <= 5; t++) { + if (TIER_ULTIMATES[t]) all.push(...TIER_ULTIMATES[t]) + } + return all[Math.floor(Math.random() * all.length)] + } + // 60% creator-themed, 40% normal selection + if (Math.random() < 0.6) { + return CREATOR_MOVES[Math.floor(Math.random() * CREATOR_MOVES.length)] + } + // Fall through to normal selection + } + // Tier-gated ultimates: higher tier = higher chance of spectacular ultimate moves // Tier 2: 15% chance, Tier 3: 20%, Tier 4: 30%, Tier 5: 40% if (attackerTier >= 2) { @@ -636,44 +658,9 @@ export async function createFightScene(config: FightSceneConfig) { })) } - // Grotesque close-up: spawn exaggerated impact details during zoom-ins - let grotesqueObjs: any[] = [] - function spawnGrotesqueDetails(fighter: any, scaleFactor: number) { - destroyGrotesqueDetails() - if (!fighter?.exists()) return - const fx = fighter.pos.x, fy = fighter.pos.y - const s = Math.max(1, scaleFactor) - // Impact lines radiating from fighter - for (let i = 0; i < 6; i++) { - const angle = (i / 6) * Math.PI * 2 + Math.random() * 0.3 - const len = 30 + Math.random() * 40 - const line = k.add([ - k.rect(3 * s, len * s), - k.pos(fx + Math.cos(angle) * 20, fy - 30 + Math.sin(angle) * 20), - k.color(safeColor(k, i % 2 === 0 ? '#ffffff' : '#ff2d2d')), - k.opacity(0.7), - k.z(25), - k.rotate(angle * (180 / Math.PI) + 90), - k.anchor('center'), - ]) - grotesqueObjs.push(line) - } - // Speed/impact dots - for (let i = 0; i < 4; i++) { - const dot = k.add([ - k.circle(4 + Math.random() * 6), - k.pos(fx + (Math.random() - 0.5) * 60, fy - 30 + (Math.random() - 0.5) * 50), - k.color(safeColor(k, '#ffe14d')), - k.opacity(0.8), - k.z(25), - ]) - grotesqueObjs.push(dot) - } - } - function destroyGrotesqueDetails() { - for (const o of grotesqueObjs) { if (o.exists()) o.destroy() } - grotesqueObjs = [] - } + // Grotesque close-up overlays removed — noops retained for call-site compatibility + function spawnGrotesqueDetails(_fighter: any, _scaleFactor: number) {} + function destroyGrotesqueDetails() {} // Arena-specific background decoration function drawArenaDecor() { const a = arena @@ -5718,6 +5705,453 @@ export async function createFightScene(config: FightSceneConfig) { // Tier-gated pools (mapping is at module level for pickChoreography access) + // ═══════════════════════════════════════════════════════ + // THE CREATOR — Exclusive choreographies (bitcoin-themed) + // ═══════════════════════════════════════════════════════ + + async function bitcoinRain(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + atk.play('special') + sfxSpecial() + await k.wait(0.2) + // Rain golden ₿ coins from above + const coinCount = isCritical ? 30 : 15 + for (let i = 0; i < coinCount; i++) { + const cx = def.pos.x + (Math.random() - 0.5) * 120 + const startY = def.pos.y - 200 - Math.random() * 100 + const coin = k.add([ + k.circle(isCritical ? 5 : 3), + k.pos(cx, startY), + k.color(safeColor(k, Math.random() > 0.3 ? '#ffd700' : '#ffee88')), + k.opacity(0.9), + k.z(18), + ]) + coin.onUpdate(() => { + coin.pos.y += 600 * k.dt() + if (coin.pos.y > def.pos.y + 10) { + coin.destroy() + } + }) + if (i % 4 === 0) sfxCoin() + await k.wait(0.03) + } + await k.wait(0.15) + def.play(isCritical ? 'knockback' : 'hit') + k.shake(isCritical ? 18 : 8) + spawnSparks(def.pos.x, def.pos.y - 30, isCritical ? 20 : 10, '#ffd700') + if (isCritical) { screenFlash('#ffd700', 0.12); sfxExplosion() } + const push = dir * (isCritical ? 80 : 35) + k.tween(def.pos.x, origDX + push, 0.25, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.4) + atk.play('idle') + await k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad) + } + + async function lightningInvoice(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + atk.play('special') + sfxZap() + await k.wait(0.15) + // Jagged lightning bolt from top of screen to defender + const segments = 8 + const topY = -50 + const botY = def.pos.y - 20 + for (let i = 0; i < segments; i++) { + const t = i / segments + const bx = def.pos.x + (Math.random() - 0.5) * 40 + const by2 = topY + t * (botY - topY) + const bolt = k.add([ + k.rect(3, Math.abs(botY - topY) / segments + 5), + k.pos(bx, by2), + k.color(safeColor(k, i % 2 === 0 ? '#ffd700' : '#ffffff')), + k.opacity(0.95), + k.z(20), + ]) + setTimeout(() => { if (bolt.exists()) bolt.destroy() }, 300) + } + // ₿ symbol flash at impact + const btcFlash = k.add([ + k.circle(isCritical ? 20 : 12), + k.pos(def.pos.x, def.pos.y - 30), + k.color(safeColor(k, '#ffd700')), + k.opacity(0.8), + k.z(22), + ]) + k.tween(btcFlash.opacity, 0, 0.4, (v: number) => { btcFlash.opacity = v }) + .then(() => { if (btcFlash.exists()) btcFlash.destroy() }) + await k.wait(0.1) + def.play(isCritical ? 'knockback' : 'hit') + k.shake(isCritical ? 20 : 10) + sfxExplosion() + spawnSparks(def.pos.x, def.pos.y - 30, 15, '#ffd700') + if (isCritical) { screenFlash('#ffd700'); glitchRGB() } + const push = dir * (isCritical ? 90 : 40) + k.tween(def.pos.x, origDX + push, 0.2, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.4) + atk.play('idle') + await k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad) + } + + async function satoshiStrike(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + atk.play('special') + sfxSpecial() + await k.wait(0.1) + // Rapid-fire gold particle stream + const shotCount = isCritical ? 25 : 15 + for (let i = 0; i < shotCount; i++) { + const p = k.add([ + k.circle(2), + k.pos(atk.pos.x + dir * 30, atk.pos.y - 40 + (Math.random() - 0.5) * 15), + k.color(safeColor(k, i % 3 === 0 ? '#ffd700' : i % 3 === 1 ? '#ffee88' : '#ff8c00')), + k.opacity(0.9), + k.z(16), + ]) + const speed = 800 + Math.random() * 400 + p.onUpdate(() => { + p.pos.x += dir * speed * k.dt() + p.opacity -= 1.5 * k.dt() + if (p.opacity <= 0 || Math.abs(p.pos.x - atk.pos.x) > 500) p.destroy() + }) + if (i % 3 === 0) sfxCoin() + if (i % 5 === 0) { + def.play('hit') + k.shake(2) + spawnSparks(def.pos.x, def.pos.y - 25 - Math.random() * 20, 3, '#ffd700') + } + await k.wait(0.025) + } + def.play(isCritical ? 'knockback' : 'hit') + k.shake(isCritical ? 12 : 5) + if (isCritical) { screenFlash('#ffd700', 0.08); sfxCritical() } + const push = dir * (isCritical ? 70 : 30) + k.tween(def.pos.x, origDX + push, 0.2, (v: number) => { 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: number) => { def.pos.x = v }, k.easings.easeInOutQuad) + } + + async function hashRateOverload(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + atk.play('special') + sfxSpecial() + // Screen fills with scrolling hash text (simulated with falling green/gold rects) + const hashCount = isCritical ? 40 : 20 + for (let i = 0; i < hashCount; i++) { + const hx = Math.random() * k.width() + const hy = Math.random() * k.height() + const h = k.add([ + k.rect(8 + Math.random() * 12, 2), + k.pos(hx, hy), + k.color(safeColor(k, Math.random() > 0.5 ? '#00ff41' : '#ffd700')), + k.opacity(0.6), + k.z(25), + ]) + h.onUpdate(() => { + h.pos.y += 200 * k.dt() + h.opacity -= 1.5 * k.dt() + if (h.opacity <= 0) h.destroy() + }) + } + await k.wait(0.5) + // Explosion + screenFlash(isCritical ? '#ffd700' : '#00ff41', 0.15) + sfxExplosion() + def.play(isCritical ? 'knockback' : 'hit') + k.shake(isCritical ? 25 : 12) + spawnSparks(def.pos.x, def.pos.y - 30, isCritical ? 25 : 12, '#ffd700') + if (isCritical) glitchRGB() + const push = dir * (isCritical ? 100 : 50) + k.tween(def.pos.x, origDX + push, 0.25, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.4) + atk.play('idle') + await k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad) + } + + async function blockchainSlam(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + const contactX = origDX - dir * 60 + sfxZoomWhoosh() + await k.tween(atk.pos.x, contactX, 0.12, (v: number) => { atk.pos.x = v }, k.easings.easeOutQuad) + atk.play('attack') + // Chain of blocks swinging as weapon + const blocks: any[] = [] + const blockCount = isCritical ? 6 : 4 + for (let i = 0; i < blockCount; i++) { + const bk = k.add([ + k.rect(10, 10), + k.pos(atk.pos.x + dir * (i * 14), atk.pos.y - 40), + k.color(safeColor(k, i % 2 === 0 ? '#ffd700' : '#c8a000')), + k.opacity(0.9), + k.z(17), + ]) + blocks.push(bk) + } + // Swing chain toward defender + for (let i = 0; i < blockCount; i++) { + await k.tween(blocks[i].pos.x, def.pos.x + (i - blockCount / 2) * 5, 0.08, (v: number) => { blocks[i].pos.x = v }, k.easings.easeOutQuad) + await k.tween(blocks[i].pos.y, def.pos.y - 30, 0.04, (v: number) => { blocks[i].pos.y = v }) + } + sfxPunch() + def.play(isCritical ? 'knockback' : 'hit') + k.shake(isCritical ? 15 : 7) + spawnSparks(def.pos.x, def.pos.y - 30, 10, '#ffd700') + if (isCritical) { screenFlash('#ffd700', 0.1); sfxCritical() } + blocks.forEach(b => b.destroy()) + const push = dir * (isCritical ? 80 : 35) + k.tween(def.pos.x, origDX + push, 0.2, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.35) + await Promise.all([ + k.tween(atk.pos.x, origAX, 0.2, (v: number) => { atk.pos.x = v }, k.easings.easeInQuad), + k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad), + ]) + atk.play('idle') + } + + async function creatorMode(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + atk.play('special') + sfxSpecial() + // Typing effect — rapid small flashes from attacker + for (let i = 0; i < 8; i++) { + const sp = k.add([ + k.circle(2), + k.pos(atk.pos.x + dir * 20, atk.pos.y - 45 + (Math.random() - 0.5) * 10), + k.color(safeColor(k, i % 2 === 0 ? '#00ff41' : '#ffd700')), + k.opacity(0.8), + k.z(16), + ]) + setTimeout(() => { if (sp.exists()) sp.destroy() }, 200) + await k.wait(0.05) + } + // Reality warp — defender's sprite distorts + if (def.exists()) { + const origScaleX = def.scale?.x ?? 1 + const origScaleY = def.scale?.y ?? 1 + for (let w = 0; w < 4; w++) { + def.scaleTo(origScaleX * (1 + (Math.random() - 0.5) * 0.5), origScaleY * (1 + (Math.random() - 0.5) * 0.5)) + await k.wait(0.06) + } + def.scaleTo(origScaleX, origScaleY) + } + sfxExplosion() + def.play(isCritical ? 'knockback' : 'hit') + k.shake(isCritical ? 18 : 8) + spawnSparks(def.pos.x, def.pos.y - 30, 12, '#00ff41') + if (isCritical) { screenFlash('#00ff41'); glitchRGB() } + const push = dir * (isCritical ? 70 : 30) + k.tween(def.pos.x, origDX + push, 0.2, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.4) + atk.play('idle') + await k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad) + } + + async function morphStrike(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + // Flash golden — the morph is handled by the morph system + // This move itself is a quick dash-hit with gold sparks + screenFlash('#ffd700', 0.08) + sfxZoomWhoosh() + const contactX = origDX - dir * 45 + await k.tween(atk.pos.x, contactX, 0.1, (v: number) => { atk.pos.x = v }, k.easings.easeOutQuad) + atk.play('attack') + sfxPunch() + await k.wait(0.08) + def.play(isCritical ? 'knockback' : 'hit') + k.shake(isCritical ? 14 : 6) + spawnSparks(def.pos.x, def.pos.y - 30, isCritical ? 15 : 8, '#ffd700') + if (isCritical) { sfxCritical(); screenFlash('#ffd700') } + const push = dir * (isCritical ? 80 : 35) + k.tween(def.pos.x, origDX + push, 0.2, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.3) + await Promise.all([ + k.tween(atk.pos.x, origAX, 0.2, (v: number) => { atk.pos.x = v }, k.easings.easeInQuad), + k.tween(def.pos.x, origDX, 0.25, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad), + ]) + atk.play('idle') + } + + async function divineIntervention(atk: any, def: any, dir: number, origAX: number, origDX: number, isCritical: boolean) { + // Ascend off screen + sfxZoomWhoosh() + atk.play('win') + await k.tween(atk.pos.y, atk.pos.y - 300, 0.25, (v: number) => { atk.pos.y = v }, k.easings.easeInQuad) + atk.opacity = 0 + // Golden glow expanding where they were + const glowX = origAX + const glow = k.add([ + k.circle(5), + k.pos(glowX, GROUND_Y - 40), + k.color(safeColor(k, '#ffd700')), + k.opacity(0.6), + k.z(19), + ]) + await k.tween(5, 60, 0.3, (v: number) => { glow.radius = v; glow.opacity = 0.6 - v / 150 }) + glow.destroy() + // Slam down onto defender from above + atk.opacity = 1 + atk.pos.x = def.pos.x + atk.pos.y = -100 + atk.play('kick') + sfxZoomWhoosh() + await k.tween(atk.pos.y, GROUND_Y, 0.15, (v: number) => { atk.pos.y = v }, k.easings.easeInQuad) + // Massive impact + sfxExplosion() + k.shake(isCritical ? 30 : 18) + screenFlash('#ffd700', 0.15) + spawnSparks(def.pos.x, def.pos.y - 30, isCritical ? 25 : 15, '#ffd700') + spawnShockwave(def.pos.x, GROUND_Y, '#ffd700') + if (isCritical) glitchRGB() + def.play('knockback') + const push = dir * (isCritical ? 120 : 60) + k.tween(def.pos.x, origDX + push, 0.2, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.5) + await Promise.all([ + k.tween(atk.pos.x, origAX, 0.25, (v: number) => { atk.pos.x = v }, k.easings.easeInQuad), + k.tween(atk.pos.y, GROUND_Y, 0.01, (v: number) => { atk.pos.y = v }), + k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad), + ]) + atk.play('idle') + } + + // ── Creator Ultimates ── + + async function ultimateGenesisBlock(atk: any, def: any, dir: number, origAX: number, origDX: number, _isCrit: boolean) { + // Screen goes dark, massive genesis block materializes and detonates + atk.play('special') + sfxSpecial() + const darkness = k.add([k.rect(k.width(), k.height()), k.pos(0, 0), k.color(safeColor(k, '#000000')), k.opacity(0), k.z(30)]) + await k.tween(darkness.opacity, 0.7, 0.3, (v: number) => { darkness.opacity = v }) + // Genesis block appears at center + const block = k.add([ + k.rect(60, 60), + k.pos(k.width() / 2 - 30, k.height() / 2 - 30), + k.color(safeColor(k, '#ffd700')), + k.opacity(0), + k.z(35), + ]) + await k.tween(block.opacity, 1, 0.3, (v: number) => { block.opacity = v }) + await k.wait(0.3) + // Detonate! + sfxExplosion() + block.destroy() + darkness.destroy() + screenFlash('#ffd700', 0.2) + k.shake(35) + glitchRGB() + spawnShockwave(k.width() / 2, k.height() / 2, '#ffd700') + spawnSparks(def.pos.x, def.pos.y - 30, 30, '#ffd700') + def.play('knockback') + const push = dir * 120 + k.tween(def.pos.x, origDX + push, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.6) + atk.play('idle') + await k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad) + } + + async function ultimateTheHalving(atk: any, def: any, dir: number, origAX: number, origDX: number, _isCrit: boolean) { + // Screen splits horizontally with a golden line + atk.play('special') + sfxZap() + const line = k.add([ + k.rect(k.width(), 3), + k.pos(0, k.height() / 2), + k.color(safeColor(k, '#ffd700')), + k.opacity(0), + k.z(32), + ]) + await k.tween(line.opacity, 1, 0.15, (v: number) => { line.opacity = v }) + screenFlash('#ffd700', 0.1) + glitchRGB() + sfxExplosion() + k.shake(25) + // Briefly scale defender to half + if (def.exists()) { + const origSY = def.scale?.y ?? 1 + def.scaleTo(def.scale?.x ?? 1, origSY * 0.5) + await k.wait(0.2) + def.scaleTo(def.scale?.x ?? 1, origSY) + } + line.destroy() + def.play('knockback') + spawnSparks(def.pos.x, def.pos.y - 30, 25, '#ffd700') + const push = dir * 100 + k.tween(def.pos.x, origDX + push, 0.25, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.5) + atk.play('idle') + await k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad) + } + + async function ultimateFullNodeCrush(atk: any, def: any, dir: number, origAX: number, origDX: number, _isCrit: boolean) { + // Giant server rack drops from above + atk.play('special') + sfxSpecial() + await k.wait(0.3) + const rack = k.add([ + k.rect(40, 80), + k.pos(def.pos.x - 20, -100), + k.color(safeColor(k, '#333333')), + k.opacity(0.9), + k.z(28), + ]) + // LEDs on the rack + for (let led = 0; led < 6; led++) { + k.add([ + k.circle(1), + k.pos(rack.pos.x + 5 + led * 5, rack.pos.y + 10), + k.color(safeColor(k, led % 2 === 0 ? '#00ff41' : '#ff0000')), + k.opacity(0.8), + k.z(29), + ]) + } + sfxZoomWhoosh() + await k.tween(rack.pos.y, def.pos.y - 80, 0.2, (v: number) => { rack.pos.y = v }, k.easings.easeInQuad) + // Impact + sfxExplosion() + k.shake(30) + screenFlash('#ffffff', 0.15) + spawnSparks(def.pos.x, def.pos.y - 30, 20, '#ffd700') + spawnShockwave(def.pos.x, GROUND_Y, '#333333') + rack.destroy() + def.play('knockback') + const push = dir * 100 + k.tween(def.pos.x, origDX + push, 0.25, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.6) + atk.play('idle') + await k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad) + } + + async function ultimateSatoshiVision(atk: any, def: any, dir: number, origAX: number, origDX: number, _isCrit: boolean) { + // Everything turns gold — defender trapped in bitcoin block + atk.play('special') + sfxSpecial() + // Gold overlay + const overlay = k.add([k.rect(k.width(), k.height()), k.pos(0, 0), k.color(safeColor(k, '#ffd700')), k.opacity(0), k.z(30)]) + await k.tween(overlay.opacity, 0.4, 0.3, (v: number) => { overlay.opacity = v }) + // Wireframe box around defender + const bx = def.pos.x - 30 + const by2 = def.pos.y - 70 + const bw = 60 + const bh2 = 80 + const top = k.add([k.rect(bw, 2), k.pos(bx, by2), k.color(safeColor(k, '#ffd700')), k.opacity(0.9), k.z(33)]) + const bot = k.add([k.rect(bw, 2), k.pos(bx, by2 + bh2), k.color(safeColor(k, '#ffd700')), k.opacity(0.9), k.z(33)]) + const left = k.add([k.rect(2, bh2), k.pos(bx, by2), k.color(safeColor(k, '#ffd700')), k.opacity(0.9), k.z(33)]) + const right = k.add([k.rect(2, bh2), k.pos(bx + bw, by2), k.color(safeColor(k, '#ffd700')), k.opacity(0.9), k.z(33)]) + await k.wait(0.3) + // Crush — shrink box + sfxZoomWhoosh() + await Promise.all([ + k.tween(bx, def.pos.x - 5, 0.3, (v: number) => { left.pos.x = v; top.pos.x = v; bot.pos.x = v }), + k.tween(bx + bw, def.pos.x + 5, 0.3, (v: number) => { right.pos.x = v }), + ]) + sfxExplosion() + k.shake(30) + screenFlash('#ffffff', 0.15) + glitchRGB(); + [top, bot, left, right].forEach(b => b.destroy()) + overlay.destroy() + spawnSparks(def.pos.x, def.pos.y - 30, 30, '#ffd700') + def.play('knockback') + const push = dir * 110 + k.tween(def.pos.x, origDX + push, 0.25, (v: number) => { def.pos.x = v }, k.easings.easeOutQuad) + await k.wait(0.6) + atk.play('idle') + await k.tween(def.pos.x, origDX, 0.3, (v: number) => { def.pos.x = v }, k.easings.easeInOutQuad) + } + const choreographyMap: Record = { dashPunch, aerialSlam, flyingKick, dashThrough, uppercut, multiHit, projectile, jetpackDive, gunBurst, groundPound, @@ -5821,6 +6255,11 @@ export async function createFightScene(config: FightSceneConfig) { // Tier 5+ ultimateArmageddon, ultimateOmegaBeam, ultimateBeyondTheScreen, ultimateMatrixDodge, ultimateWorldEnder, ultimateAbsoluteZero, ultimateSolarCannon, ultimatePhantomStrike, ultimateGodPunch, ultimateVoidCollapse, + // --- THE CREATOR: Bitcoin-themed moves --- + bitcoinRain, lightningInvoice, satoshiStrike, hashRateOverload, blockchainSlam, creatorMode, + morphStrike, divineIntervention, + // --- THE CREATOR: Exclusive ultimates --- + ultimateGenesisBlock, ultimateTheHalving, ultimateFullNodeCrush, ultimateSatoshiVision, } // === MORPH / COSTUME SYSTEM === @@ -6042,6 +6481,86 @@ export async function createFightScene(config: FightSceneConfig) { const morphAppliers = [applyElementalMorph, applyMechMorph, applyBeastMorph] const morphNames = ['ELEMENTAL FORM', 'MECH ARMOR', 'BEAST MODE'] + // === THE CREATOR: OMNI-MORPH === + // Instead of 3 morph types, the creator morphs into a random archetype each time + const OMNI_MORPH_ARCHETYPES = [ + 'lobster', 'sheep', 'cyborg', 'blob', 'tank', 'dog', 'cat', 'cactus', 'pizza', + 'mushroom', 'shark', 'penguin', 'octopus', 'skeleton', 'ghost', 'alien', 'dinosaur', + 'pirate', 'ninja', 'cowboy', 'wizard', 'bee', 'frog', 'snail', + 'robot', 'android', 'toaster', 'mech', 'minotaur', 'unicorn', 'phoenix', 'dragon', + 'mermaid', 'griffin', 'cyclops', 'gargoyle', 'golem', 'vampire', 'werewolf', 'zombie', + 'witch', 'demon', 'chef', 'firefighter', 'astronaut', 'clown', 'detective', + 'lumberjack', 'scientist', 'wrestler', 'boxer', 'gladiator', 'samurai', 'viking', 'knight', + 'elephant', 'giraffe', 'hippo', 'lion', 'monkey', 'parrot', 'raccoon', 'snake', + 'turtle', 'whale', 'crocodile', 'flamingo', 'hedgehog', 'panda', 'hamster', + 'sock_puppet', 'traffic_cone', 'toilet_man', 'potato', 'cloud_man', 'rock_man', + 'balloon_man', 'trash_can', 'rubber_duck', 'snowman', 'scarecrow', 'jack_o_lantern', + 'garden_gnome', 'lamp_post', 'broom_man', + ] + + function applyCreatorOmniMorph(fighter: any, _side: 'a' | 'b'): string { + const pick = OMNI_MORPH_ARCHETYPES[Math.floor(Math.random() * OMNI_MORPH_ARCHETYPES.length)] + + // Golden aura unique to creator morph + const aura = k.add([ + k.circle(55), + k.pos(fighter.pos.x, fighter.pos.y - 20), + k.color(safeColor(k, '#c8a000')), + k.opacity(0.2), + k.z(fighter.z - 1), + k.anchor('center'), + k.scale(1), + ]) + aura.onUpdate(() => { + aura.pos.x = fighter.pos.x + aura.pos.y = fighter.pos.y - 20 + aura.opacity = 0.12 + Math.sin(k.time() * 6) * 0.1 + const s = 1 + Math.sin(k.time() * 4) * 0.2 + aura.scale.x = s; aura.scale.y = s + }) + activeMorphs.push({ obj: aura, type: 'omni' }) + + // Orbiting golden ₿ symbols (4 orbiting) + for (let i = 0; i < 4; i++) { + const btc = k.add([ + k.text('₿', { size: 10 }), + k.pos(fighter.pos.x, fighter.pos.y - 20), + k.color(safeColor(k, '#ffd700')), + k.opacity(0.7), + k.z(fighter.z + 2), + k.anchor('center'), + ]) + const baseAngle = (i / 4) * Math.PI * 2 + const radius = 40 + btc.onUpdate(() => { + const a = baseAngle + k.time() * 3 + btc.pos.x = fighter.pos.x + Math.cos(a) * radius + btc.pos.y = fighter.pos.y - 20 + Math.sin(a) * radius * 0.5 + btc.opacity = 0.7 + Math.sin(k.time() * 8 + i) * 0.3 + }) + activeMorphs.push({ obj: btc, type: 'omni' }) + } + + // Apply archetype-specific color tint based on pick category + const tints: Record = { + fire: '#ff4400', ice: '#44ccff', nature: '#44cc44', dark: '#8844cc', + metal: '#aabbcc', electric: '#ffff00', beast: '#cc6622', cosmic: '#cc44ff', + } + const category = + ['phoenix', 'dragon', 'demon'].includes(pick) ? 'fire' : + ['penguin', 'snowman', 'yeti'].includes(pick) ? 'ice' : + ['cactus', 'mushroom', 'frog', 'snail', 'turtle'].includes(pick) ? 'nature' : + ['skeleton', 'ghost', 'vampire', 'zombie', 'witch'].includes(pick) ? 'dark' : + ['robot', 'android', 'mech', 'toaster', 'knight'].includes(pick) ? 'metal' : + ['alien', 'unicorn', 'mermaid'].includes(pick) ? 'cosmic' : + ['werewolf', 'minotaur', 'lion', 'shark', 'crocodile'].includes(pick) ? 'beast' : + 'electric' + fighter.color = safeColor(k, tints[category] || '#ffd700') + fighter.opacity = 0.9 + + return pick + } + // Get morph order for a bot (deterministic by seed) function getMorphOrder(seed: string): number[] { let h = 0 @@ -6332,6 +6851,49 @@ export async function createFightScene(config: FightSceneConfig) { return lines } + // Talking mouth animation — a small rectangle that opens/closes near the fighter's face + const talkingAnims: Record }> = {} + + function startTalking(side: 'a' | 'b') { + stopTalking(side) // cleanup any existing + const fighter = k.get(side === 'a' ? 'fighterA' : 'fighterB')[0] + if (!fighter?.exists()) return + + const mouthColor = side === 'a' ? '#00f0ff' : '#ff2d7b' + // Mouth position: slightly below center of the sprite + const mouthOffsetY = -28 + const mouthW = 8 + let open = false + + const mouth = k.add([ + k.rect(mouthW, 2), + k.pos(fighter.pos.x, fighter.pos.y + mouthOffsetY), + k.color(safeColor(k, mouthColor)), + k.opacity(0.9), + k.z(12), + k.anchor('center'), + ]) + + // Toggle open/close every 120ms for a fast chatter effect + const timer = setInterval(() => { + if (!mouth.exists() || !fighter.exists()) { stopTalking(side); return } + open = !open + mouth.pos.x = fighter.pos.x + mouth.pos.y = fighter.pos.y + mouthOffsetY + mouth.height = open ? 5 : 2 + }, 120) + + talkingAnims[side] = { objs: [mouth], timer } + } + + function stopTalking(side: 'a' | 'b') { + const anim = talkingAnims[side] + if (!anim) return + clearInterval(anim.timer) + anim.objs.forEach(o => { if (o.exists()) o.destroy() }) + delete talkingAnims[side] + } + // Spawn floating text above a position function spawnEmoteText(x: number, y: number, text: string, color: string, duration: number = 1.2) { const label = k.add([ @@ -6706,6 +7268,9 @@ export async function createFightScene(config: FightSceneConfig) { showSpeechBubble(side, text, duration) }, + startTalking(side: 'a' | 'b') { startTalking(side) }, + stopTalking(side: 'a' | 'b') { stopTalking(side) }, + async playEntrance() { const fA = k.get('fighterA')[0] const fB = k.get('fighterB')[0] @@ -7119,16 +7684,77 @@ export async function createFightScene(config: FightSceneConfig) { }, ] + // THE CREATOR: Golden code rain portal entrance + const creatorEntrance = async (fighter: any, homeX: number, _fromLeft: boolean) => { + fighter.pos.x = homeX + fighter.pos.y = -100 + fighter.opacity = 0 + // Golden code rain columns + const codeChars = '₿01∞⚡⛏§∂∆≈≠'.split('') + const rainDrops: any[] = [] + for (let col = 0; col < 12; col++) { + const cx = homeX - 60 + col * 10 + for (let row = 0; row < 4; row++) { + const ch = codeChars[Math.floor(Math.random() * codeChars.length)] + const rd = k.add([ + k.text(ch, { size: 8 }), k.pos(cx, -20 - row * 25), + k.color(safeColor(k, row === 0 ? '#ffd700' : '#c8a000')), + k.opacity(0.6 + Math.random() * 0.4), k.z(48), k.anchor('center'), + ]) + rainDrops.push(rd) + k.tween(rd.pos.y, GROUND_Y + 20, 0.6 + Math.random() * 0.3, (v) => { + rd.pos.y = v + rd.opacity = Math.max(0, 1 - (v - GROUND_Y + 30) / 50) + }).then(() => { if (rd.exists()) rd.destroy() }) + } + } + await k.wait(0.3) + // Portal flash + screenFlash('#ffd700', 0.15) + spawnShockwave(homeX, GROUND_Y - 30, '#c8a000') + sfxSpecial() + // Fighter descends through golden portal + fighter.opacity = 1 + await k.tween(-100, GROUND_Y - 6, 0.5, (v) => { + fighter.pos.y = v + }, k.easings.easeOutBack) + k.shake(12) + sfxExplosion() + // Persistent golden particles around creator + for (let i = 0; i < 6; i++) { + const p = k.add([ + k.circle(2), k.pos(homeX, GROUND_Y - 30), + k.color(safeColor(k, '#ffd700')), k.opacity(0.5), k.z(11), k.anchor('center'), + ]) + p.onUpdate(() => { + const a = k.time() * (2 + i * 0.4) + i + p.pos.x = fighter.pos.x + Math.cos(a) * (20 + i * 5) + p.pos.y = fighter.pos.y - 25 + Math.sin(a) * (15 + i * 3) + p.opacity = 0.3 + Math.sin(k.time() * 6 + i) * 0.3 + }) + } + announceHype('THE CREATOR HAS ENTERED THE ARENA!') + rainDrops.forEach(r => { if (r.exists()) r.destroy() }) + } + // Pick random entrance for each bot (different ones) const idxA = Math.floor(Math.random() * entrances.length) let idxB = Math.floor(Math.random() * entrances.length) while (idxB === idxA && entrances.length > 1) idxB = Math.floor(Math.random() * entrances.length) - // Play entrances with slight stagger + // Play entrances with slight stagger — creator always gets special entrance announceDeepIntro() - await entrances[idxA](fA, HOME_A, true) + if (botA.archetype === 'the_creator') { + await creatorEntrance(fA, HOME_A, true) + } else { + await entrances[idxA](fA, HOME_A, true) + } await k.wait(0.3) - await entrances[idxB](fB, HOME_B, false) + if (botB.archetype === 'the_creator') { + await creatorEntrance(fB, HOME_B, false) + } else { + await entrances[idxB](fB, HOME_B, false) + } await k.wait(0.2) // Safety: ensure both fighters are visible and at home positions @@ -7280,22 +7906,56 @@ export async function createFightScene(config: FightSceneConfig) { } const attackerTier = attackerSide === 'a' ? botA.tier : botB.tier - const choreo = pickChoreography(event.challengeType, exchangeCritical, event.round, attackerTier) + const attackerArch = attackerSide === 'a' ? botA.archetype : botB.archetype + const choreo = pickChoreography(event.challengeType, exchangeCritical, event.round, attackerTier, attackerArch) // Morph system: transform during ultimates or devastating crits const isUltimate = choreo.startsWith('ultimate') - const shouldMorph = isUltimate || (exchangeCritical && intensity > 0.7 && Math.random() < 0.4) + const isCreatorFighter = attackerArch === 'the_creator' + // Creator: always morph on ultimates, 70% on devastating crits + const shouldMorph = isCreatorFighter + ? (isUltimate || (exchangeCritical && intensity > 0.6 && Math.random() < 0.7)) + : (isUltimate || (exchangeCritical && intensity > 0.7 && Math.random() < 0.4)) let morphRevert: (() => Promise) | null = null if (shouldMorph) { const attacker = k.get(attackerSide === 'a' ? 'fighterA' : 'fighterB')[0] if (attacker) { - const seed = attackerSide === 'a' ? botA.seed : botB.seed - const morphOrder = getMorphOrder(seed) - // Cycle through morphs based on round number - const morphIdx = morphOrder[event.round % 3] - const result = await playMorph(attacker, attackerSide, morphIdx) - morphRevert = result.revert - await k.wait(0.2) + if (isCreatorFighter) { + // Omni-morph: creator transforms into a random archetype + const morphedTo = applyCreatorOmniMorph(attacker, attackerSide) + screenFlash('#ffd700', 0.15) + sfxZoomWhoosh() + sfxSpecial() + announceHype(`THE CREATOR — ${morphedTo.toUpperCase()} FORM!`) + const savedSX = attacker.scale.x + const savedSY = attacker.scale.y + await k.tween(0, 1, 0.3, (t) => { + attacker.scale.x = savedSX * (1 + t * 0.3) + attacker.scale.y = savedSY * (1 + t * 0.3) + }, k.easings.easeOutBack) + spawnShockwave(attacker.pos.x, attacker.pos.y - 20, '#ffd700') + k.shake(10) + glitchRGB(0.25) + morphRevert = async () => { + destroyMorphOverlays() + screenFlash('#c8a000', 0.1) + attacker.color = safeColor(k, '#ffffff') + attacker.opacity = 1 + await k.tween(0, 1, 0.2, (t) => { + attacker.scale.x = savedSX * 1.3 + t * (savedSX - savedSX * 1.3) + attacker.scale.y = savedSY * 1.3 + t * (savedSY - savedSY * 1.3) + }, k.easings.easeInQuad) + } + await k.wait(0.2) + } else { + const seed = attackerSide === 'a' ? botA.seed : botB.seed + const morphOrder = getMorphOrder(seed) + // Cycle through morphs based on round number + const morphIdx = morphOrder[event.round % 3] + const result = await playMorph(attacker, attackerSide, morphIdx) + morphRevert = result.revert + await k.wait(0.2) + } } } // Camera pull-back for ultimates: zoom out slightly to show the full move @@ -7421,6 +8081,69 @@ export async function createFightScene(config: FightSceneConfig) { k.wait(0.8).then(() => { if (judge.exists()) judge.play('idle') }) } + // === THE CREATOR CAMEO === + // 6% chance per round (only if neither fighter IS the creator) + const neitherIsCreator = botA.archetype !== 'the_creator' && botB.archetype !== 'the_creator' + if (neitherIsCreator && Math.random() < 0.06) { + const cameoX = k.width() / 2 + const cameoY = GROUND_Y - 80 + // Golden portal flash + screenFlash('#ffd700', 0.1) + const portal = k.add([ + k.circle(30), k.pos(cameoX, cameoY), k.color(safeColor(k, '#c8a000')), + k.opacity(0), k.z(50), k.anchor('center'), k.scale(0.1), + ]) + await k.tween(0, 1, 0.3, (t) => { + portal.opacity = t * 0.4 + portal.scale.x = t * 1.5; portal.scale.y = t * 1.5 + }, k.easings.easeOutBack) + // ₿ symbol descends from portal + const gift = k.add([ + k.text('₿', { size: 18 }), k.pos(cameoX, cameoY - 30), + k.color(safeColor(k, '#ffd700')), k.opacity(0), k.z(51), k.anchor('center'), + ]) + const creatorLabel = k.add([ + k.text('THE CREATOR', { size: 8 }), k.pos(cameoX, cameoY + 25), + k.color(safeColor(k, '#c8a000')), k.opacity(0), k.z(51), k.anchor('center'), + ]) + await k.tween(0, 1, 0.4, (t) => { + gift.opacity = t + gift.pos.y = cameoY - 30 + t * 20 + creatorLabel.opacity = t * 0.8 + }, k.easings.easeOutQuad) + // Gift flies to the round winner (or random fighter if draw) + const targetFighter = aWon ? fA : bWon ? fB : (Math.random() < 0.5 ? fA : fB) + if (targetFighter) { + const giftTexts = ['POWER UP!', 'BLESSED!', 'SATOSHI\'S GIFT!', 'HODL STRENGTH!', '21M ENERGY!'] + const tx = targetFighter.pos.x, ty = targetFighter.pos.y - 30 + await k.tween(0, 1, 0.35, (t) => { + gift.pos.x = cameoX + (tx - cameoX) * t + gift.pos.y = (cameoY - 10) + (ty - (cameoY - 10)) * t + }, k.easings.easeInQuad) + // Impact flash on fighter + screenFlash('#ffd700', 0.08) + spawnShockwave(tx, ty, '#ffd700') + const blessText = giftTexts[Math.floor(Math.random() * giftTexts.length)] + const bless = k.add([ + k.text(blessText, { size: 10 }), k.pos(tx, ty - 20), + k.color(safeColor(k, '#ffd700')), k.opacity(1), k.z(52), k.anchor('center'), + ]) + k.tween(0, 1, 0.8, (t) => { + bless.pos.y = ty - 20 - t * 30 + bless.opacity = 1 - t + }).then(() => { if (bless.exists()) bless.destroy() }) + announceCool('THE CREATOR HAS BLESSED THIS FIGHT!') + } + gift.destroy() + // Fade out portal and label + await k.tween(1, 0, 0.3, (t) => { + portal.opacity = t * 0.4 + creatorLabel.opacity = t * 0.8 + }) + portal.destroy() + creatorLabel.destroy() + } + // Update combos const hasCombo = (aWon && comboA + 1 >= 3) || (bWon && comboB + 1 >= 3) if (aWon) { diff --git a/frontend/src/game/sounds.ts b/frontend/src/game/sounds.ts index 26441f6..6bb6e22 100644 --- a/frontend/src/game/sounds.ts +++ b/frontend/src/game/sounds.ts @@ -104,6 +104,7 @@ const voiceProfiles: Record = { robot: { voice: null, pitch: 0.8, rate: 0.8, volume: 0.9 }, // Steady monotone screamer: { voice: null, pitch: 1.3, rate: 1.6, volume: 1.0 }, // Frantic energy smooth: { voice: null, pitch: 1.0, rate: 0.9, volume: 0.9 }, // Natural narrator + question_reader: { voice: null, pitch: 1.0, rate: 1.05, volume: 1.0 }, // Clear, brisk question announcer // 24 new profiles whisper: { voice: null, pitch: 1.2, rate: 0.6, volume: 0.4 }, // Quiet dramatic whisper boomer: { voice: null, pitch: 0.4, rate: 0.6, volume: 1.0 }, // Ultra deep booming @@ -204,6 +205,12 @@ function loadVoices() { voiceProfiles.robot.voice = findVoice([/zarvox/i, /trinoids/i, /albert/i]) || anyVoices[0] voiceProfiles.screamer.voice = findVoice([/samantha/i, /karen/i, /bells/i]) || anyVoices[Math.min(2, anyVoices.length - 1)] voiceProfiles.smooth.voice = findVoice([/daniel/i, /oliver/i, /google.*uk/i]) || anyVoices[Math.min(1, anyVoices.length - 1)] + // Question reader: highest quality voice available — tries premium/neural first + voiceProfiles.question_reader.voice = preferPremium([ + /evan.*premium/i, /samantha.*enhanced/i, /daniel.*premium/i, + /google.*us.*english/i, /google.*uk.*english/i, + /evan/i, /aaron/i, /daniel/i, /james/i, + ]) || anyVoices[0] // Assign voices to new profiles — spread across available voices for max variety const vLen = anyVoices.length const pick = (i: number) => anyVoices[i % vLen] @@ -340,6 +347,52 @@ function speak(text: string, profileName: string, cancelPrevious: boolean = fals speechSynthesis.speak(utter) } +/** Like speakAsync but with a forced minimum rate */ +function speakAsyncWithRate(text: string, profileName: string, minRate: number): Promise { + return new Promise((resolve) => { + if (typeof speechSynthesis === 'undefined' || masterMuted) { resolve(); return } + if (!voicesLoaded) loadVoices() + if (speechSynthesis.paused) speechSynthesis.resume() + const profile = voiceProfiles[profileName] || voiceProfiles.announcer + const utter = new SpeechSynthesisUtterance(text) + if (profile.voice) utter.voice = profile.voice + utter.pitch = profile.pitch + utter.rate = Math.max(minRate, profile.rate) + utter.volume = profile.volume * VOICE_VOLUME_SCALE + _speechQueueDepth++ + const safetyTimeout = setTimeout(resolve, 15_000) + utter.onend = () => { clearTimeout(safetyTimeout); _speechQueueDepth = Math.max(0, _speechQueueDepth - 1); resolve() } + utter.onerror = () => { clearTimeout(safetyTimeout); _speechQueueDepth = Math.max(0, _speechQueueDepth - 1); resolve() } + speechSynthesis.speak(utter) + }) +} + +/** Like speak() but returns a promise that resolves when the utterance finishes */ +function speakAsync(text: string, profileName: string, cancelPrevious: boolean = false): Promise { + return new Promise((resolve) => { + if (typeof speechSynthesis === 'undefined' || masterMuted) { resolve(); return } + if (!voicesLoaded) loadVoices() + if (speechSynthesis.paused) speechSynthesis.resume() + if (cancelPrevious) { speechSynthesis.cancel(); _speechQueueDepth = 0 } + const profile = voiceProfiles[profileName] || voiceProfiles.announcer + const utter = new SpeechSynthesisUtterance(text) + if (profile.voice) utter.voice = profile.voice + utter.pitch = profile.pitch + utter.rate = profile.rate + utter.volume = profile.volume * VOICE_VOLUME_SCALE + _speechQueueDepth++ + utter.onend = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1); resolve() } + utter.onerror = () => { _speechQueueDepth = Math.max(0, _speechQueueDepth - 1); resolve() } + // Safety timeout — never block forever (max 15s for any utterance) + const safetyTimeout = setTimeout(resolve, 15_000) + const origOnEnd = utter.onend + utter.onend = (ev) => { clearTimeout(safetyTimeout); (origOnEnd as any)(ev) } + const origOnError = utter.onerror + utter.onerror = (ev) => { clearTimeout(safetyTimeout); (origOnError as any)(ev) } + speechSynthesis.speak(utter) + }) +} + export function stopAllAudio() { stopMusic() if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel() @@ -512,6 +565,52 @@ export function announceRoundHype() { voices[Math.floor(Math.random() * voices.length)](line) } +// === TTS: Questions, Answers & Narration === +// Distinct voice roles so players can always tell who's speaking. +// Question = clear smooth reader, Answers = unique per-bot (intelligible subset), +// Narration = dramatic judge. + +// Only voices with rate >= 0.6 and rate <= 1.3, and pitch between 0.5-1.5 +// so every voice is actually understandable by humans +const INTELLIGIBLE_VOICES = [ + 'smooth', 'robot', 'drill', 'surfer', 'pirate_v', 'cowboy_v', 'ninja_v', + 'posh', 'aussie', 'scottish', 'french', 'texan', 'wrestler_v', 'ai_core', + 'android_v', 'siri', 'professor', 'npc', 'news', 'crotchety', 'mech', + 'wizard_v', 'echo_v', 'boss_taunt', 'angel', 'punk', 'valley', +] + +function hashName(name: string): number { + let h = 0 + for (let i = 0; i < name.length; i++) h = ((h << 5) - h + name.charCodeAt(i)) | 0 + return Math.abs(h) +} +function botVoiceKey(name: string): string { + return INTELLIGIBLE_VOICES[hashName(name) % INTELLIGIBLE_VOICES.length] +} + +/** Speak the challenge question — premium voice, clear and brisk. + * Does NOT cancel previous speech so intro/hype lines finish naturally. + * Returns promise that resolves when the question finishes reading. */ +export function speakQuestion(text: string): Promise { + const trimmed = text.length > 140 ? text.slice(0, 137) + '...' : text + return speakAsync(trimmed, 'question_reader', false) +} + +/** Speak a bot's answer in their assigned character voice at brisk pace. + * Returns promise that resolves when finished. */ +export function speakAnswer(botName: string, answer: string): Promise { + const trimmed = answer.length > 80 ? answer.slice(0, 77) + '...' : answer + // Use speakAsync with a rate boost: override profile rate to min 1.1 + return speakAsyncWithRate(trimmed, botVoiceKey(botName), 1.15) +} + +/** Speak the judge's narration — brisk sportscaster energy. + * Returns promise that resolves when finished. */ +export function speakNarration(text: string): Promise { + const trimmed = text.length > 160 ? text.slice(0, 157) + '...' : text + return speakAsyncWithRate(trimmed, 'sportscaster', 1.2) +} + // === FANFARES (8-bit melodic announcements) === export function fanfareRound(roundNum: number) { diff --git a/frontend/src/game/sprites/archetypes/index.ts b/frontend/src/game/sprites/archetypes/index.ts index caf5506..29a9366 100644 --- a/frontend/src/game/sprites/archetypes/index.ts +++ b/frontend/src/game/sprites/archetypes/index.ts @@ -34,6 +34,8 @@ import { chef, firefighter, astronautArch, clown, detective, nurse, lumberjack, import { elephant, giraffe, hippo, lion, monkey, parrot, raccoon, snakeArch, turtle, whale, crocodile, flamingo, hedgehog, panda, hamster } from './batch_animals2' // Batch 8: Silly & Objects import { sockPuppet, trafficCone, toiletMan, potato, cloudMan, rockMan, balloonMan, trashCan, rubberDuckArch, snowman, scarecrow, jackOLantern, gardenGnome, lampPost, broomMan } from './batch_silly' +// The Creator (game founder — unique god-tier character) +import { theCreator } from './the_creator' export const archetypes: Archetype[] = [ // Original 6 @@ -54,6 +56,8 @@ export const archetypes: Archetype[] = [ elephant, giraffe, hippo, lion, monkey, parrot, raccoon, snakeArch, turtle, whale, crocodile, flamingo, hedgehog, panda, hamster, // Batch 8: silly & objects sockPuppet, trafficCone, toiletMan, potato, cloudMan, rockMan, balloonMan, trashCan, rubberDuckArch, snowman, scarecrow, jackOLantern, gardenGnome, lampPost, broomMan, + // The Creator (god-tier, weight: 0 — never randomly rolled) + theCreator, ] export function rollArchetype(roll: number): Archetype { diff --git a/frontend/src/game/sprites/archetypes/the_creator.ts b/frontend/src/game/sprites/archetypes/the_creator.ts new file mode 100644 index 0000000..1ae2f32 --- /dev/null +++ b/frontend/src/game/sprites/archetypes/the_creator.ts @@ -0,0 +1,313 @@ +import type { Archetype } from '../constants' + +export const theCreator: Archetype = { + name: 'the_creator', + weight: 0, // Never randomly rolled — only assigned via pubkey detection + canHaveVisor: false, + canHaveMohawk: false, + canHaveHorns: false, + drawFeatures: (p) => { + const { px, fill, pal, ox, oy, t, frame, tier, bounce, + idle, atk, kick, special, hit, knockback, ko, win, + cx, hOff, vBounce, globalY, koSlump, + bx, by, bw, bh, hx, hy, hw, hh, + armLx, armAttach, armW, armH, feetY } = p + + // ═══════════════════════════════════════════ + // THE CREATOR — God-tier unique character + // Guy Fawkes mask, hooded cloak, laptop, + // bitcoin chest emblem, orbiting gold particles, + // matrix code rain, tier-gated divine effects + // ═══════════════════════════════════════════ + + // Color constants + const MASK = '#f5f0e0' // warm ivory + const MASK_SHD = '#ddd5c0' // mask shadow + const HOOD = '#1a1a1a' // dark hood + const HOOD_LT = '#2a2a2a' // hood highlight + const GOLD = '#ffd700' // bitcoin gold + const GOLD_DK = '#c8a000' // dark gold + const GOLD_LT = '#ffee88' // light gold + const MATRIX = '#00ff41' // matrix green + const MATRIX_DK = '#00cc33' // darker green + const LAPTOP = '#333333' // laptop body + const SCREEN = '#00ff41' // laptop screen + + // ── HOOD / CLOAK ── + // Dark hood drapes over head and extends down behind body + if (!ko) { + // Hood over head (extends above and around the head) + for (let iy = hy - 2; iy < hy + Math.floor(hh * 0.5); iy++) { + for (let ix = hx - 1; ix <= hx + hw; ix++) { + px(ix, iy, HOOD, ox, oy) + } + } + // Hood peak + px(cx + hOff, hy - 3, HOOD, ox, oy) + px(cx + hOff - 1, hy - 3, HOOD, ox, oy) + px(cx + hOff + 1, hy - 3, HOOD, ox, oy) + px(cx + hOff, hy - 4, HOOD, ox, oy) + // Hood highlight + px(hx, hy - 1, HOOD_LT, ox, oy) + px(hx + 1, hy - 2, HOOD_LT, ox, oy) + + // Cloak behind body (darker layer extending from shoulders) + const cloakTop = by - 1 + const cloakBot = feetY + globalY + 2 + for (let iy = cloakTop; iy < cloakBot; iy++) { + const spread = Math.floor((iy - cloakTop) / 3) + px(bx - 2 - spread, iy, HOOD, ox, oy) + px(bx + bw + 1 + spread, iy, HOOD, ox, oy) + // Cloak back fill + if (iy > by + bh) { + for (let ix = bx - 1 - spread; ix <= bx + bw + spread; ix++) { + px(ix, iy, HOOD, ox, oy) + } + } + } + + // Matrix code rain through cloak (animated green pixels) + const codeStreams = 3 + Math.floor(tier * 0.5) + for (let s = 0; s < codeStreams; s++) { + const streamX = bx - 1 + Math.floor((bw + 2) * s / codeStreams) + const phase = (t + s * 0.3) % 1 + const streamLen = 3 + (s % 2) + for (let d = 0; d < streamLen; d++) { + const sy = cloakTop + Math.floor(phase * (cloakBot - cloakTop)) + d + if (sy < cloakBot && sy > by + bh - 2) { + const alpha = d === 0 ? MATRIX : d === 1 ? MATRIX_DK : '#006622' + px(streamX, sy, alpha, ox, oy) + } + } + } + } + + // ── GUY FAWKES MASK ── + // Distinctive features: pointed chin, arched brows, upward mustache, eternal smile + if (!ko) { + const faceTop = hy + Math.floor(hh * 0.25) + const faceBot = hy + Math.floor(hh * 0.8) + const faceCx = cx + hOff + + // Mask base — cream/ivory covering the face area + for (let iy = faceTop; iy < faceBot; iy++) { + const inset = iy === faceTop || iy === faceBot - 1 ? 1 : 0 + for (let ix = hx + 2 + inset; ix < hx + hw - 2 - inset; ix++) { + px(ix, iy, MASK, ox, oy) + } + // Right-side shadow + px(hx + hw - 3, iy, MASK_SHD, ox, oy) + } + + // Pointed chin extending below face + const chinY = faceBot + px(faceCx, chinY, MASK, ox, oy) + px(faceCx - 1, chinY, MASK_SHD, ox, oy) + px(faceCx + 1, chinY, MASK_SHD, ox, oy) + px(faceCx, chinY + 1, MASK_SHD, ox, oy) + + // Eye slits — narrow golden glow + const eyeY = faceTop + Math.max(1, Math.floor((faceBot - faceTop) * 0.25)) + const leX = faceCx - Math.floor(hw * 0.2) + const reX = faceCx + Math.floor(hw * 0.15) + + // Arched eyebrows above eyes + px(leX - 1, eyeY - 1, '#333333', ox, oy) + px(leX, eyeY - 1, '#333333', ox, oy) + px(leX + 1, eyeY - 1, '#333333', ox, oy) + px(reX - 1, eyeY - 1, '#333333', ox, oy) + px(reX, eyeY - 1, '#333333', ox, oy) + px(reX + 1, eyeY - 1, '#333333', ox, oy) + + // Eye slits with golden glow (pulsing) + const eyeColor = frame % 3 === 0 ? GOLD_LT : GOLD + px(leX, eyeY, eyeColor, ox, oy) + px(leX + 1, eyeY, eyeColor, ox, oy) + px(reX, eyeY, eyeColor, ox, oy) + px(reX + 1, eyeY, eyeColor, ox, oy) + // Eye glow halo (subtle) + if (special || atk) { + px(leX - 1, eyeY, GOLD_DK, ox, oy) + px(reX + 2, eyeY, GOLD_DK, ox, oy) + } + + // Rosy cheeks + const cheekY = eyeY + 2 + px(leX - 1, cheekY, '#cc8888', ox, oy) + px(reX + 2, cheekY, '#cc8888', ox, oy) + + // Upward-curling mustache + const stacheY = cheekY + 1 + px(faceCx - 1, stacheY, '#222222', ox, oy) + px(faceCx, stacheY, '#222222', ox, oy) + px(faceCx + 1, stacheY, '#222222', ox, oy) + // Curled tips upward + px(faceCx - 2, stacheY - 1, '#222222', ox, oy) + px(faceCx + 2, stacheY - 1, '#222222', ox, oy) + + // Eternal smile + const smileY = stacheY + 1 + px(faceCx - 2, smileY, '#333333', ox, oy) + px(faceCx - 1, smileY + 1, '#333333', ox, oy) + px(faceCx, smileY + 1, '#333333', ox, oy) + px(faceCx + 1, smileY + 1, '#333333', ox, oy) + px(faceCx + 2, smileY, '#333333', ox, oy) + + // Thin goatee below chin + px(faceCx, chinY + 2, '#222222', ox, oy) + } + + // ── BITCOIN SYMBOL ON CHEST ── + // Always visible — pulsing gold ₿ + const btcColor = frame % 4 < 2 ? GOLD : GOLD_LT + const btcX = cx + hOff - 1 + const btcY = by + Math.max(1, Math.floor(bh * 0.25)) + // Vertical stroke of ₿ + px(btcX, btcY, btcColor, ox, oy) + px(btcX, btcY + 1, btcColor, ox, oy) + px(btcX, btcY + 2, btcColor, ox, oy) + px(btcX, btcY + 3, btcColor, ox, oy) + // Right bumps of ₿ + px(btcX + 1, btcY, btcColor, ox, oy) + px(btcX + 2, btcY + 1, btcColor, ox, oy) + px(btcX + 1, btcY + 2, btcColor, ox, oy) + px(btcX + 2, btcY + 3, btcColor, ox, oy) + px(btcX + 1, btcY + 4, btcColor, ox, oy) + // Top/bottom serifs + px(btcX, btcY - 1, GOLD_DK, ox, oy) + px(btcX, btcY + 4, GOLD_DK, ox, oy) + + // ── LAPTOP IN LEFT HAND ── + // Replaces left arm visual — laptop body + green terminal screen + if (!ko && !knockback) { + const lapX = armLx - 2 + hOff + const lapY = armAttach + Math.floor(armH * 0.3) + globalY + vBounce + // Laptop body + fill(lapX, lapY, 6, 4, LAPTOP, ox, oy) + px(lapX, lapY, '#444444', ox, oy) // corner highlight + // Screen + const screenColor = (atk || special) ? '#ffffff' : SCREEN + fill(lapX + 1, lapY + 1, 4, 2, screenColor, ox, oy) + // Blinking cursor + if (frame % 2 === 0 && !atk && !special) { + px(lapX + 3, lapY + 2, '#000000', ox, oy) + } + // Magic emanating from laptop during attacks + if ((atk || special) && t > 0.2) { + const sparkDist = Math.floor(t * 6) + px(lapX + 3, lapY - sparkDist, GOLD, ox, oy) + px(lapX + 1, lapY - sparkDist - 1, MATRIX, ox, oy) + px(lapX + 5, lapY - sparkDist + 1, GOLD_LT, ox, oy) + if (sparkDist > 2) { + px(lapX + 2, lapY - sparkDist - 2, '#ffffff', ox, oy) + } + } + } + + // ── FLOATING BITCOIN PARTICLES ── + // 4-6 gold dots orbiting the character always + if (!ko) { + const numParticles = 4 + Math.floor(tier * 0.5) + const orbitCx = cx + hOff + const orbitCy = by + Math.floor(bh / 2) + vBounce + globalY + for (let i = 0; i < numParticles; i++) { + const angle = t * Math.PI * 2 + i * (Math.PI * 2 / numParticles) + const radius = 8 + i * 2 + const px2 = orbitCx + Math.round(Math.cos(angle) * radius) + const py2 = orbitCy + Math.round(Math.sin(angle) * radius * 0.6) + const colors = [GOLD, GOLD_LT, '#ff8c00', GOLD_DK, '#ffffff'] + if ((frame + i) % 2 === 0) { + px(px2, py2, colors[i % colors.length], ox, oy) + } + } + } + + // ── TIER 3+: LIGHTNING CROWN ── + if (tier >= 3 && !ko) { + const crownY = hy - 5 + vBounce + globalY + // 3 golden lightning points + for (let i = -1; i <= 1; i++) { + const crownX = cx + hOff + i * 3 + const flash = (frame + i * 2) % 3 === 0 ? '#ffffff' : GOLD + px(crownX, crownY, flash, ox, oy) + px(crownX, crownY + 1, GOLD_DK, ox, oy) + } + // Lightning bolts connecting crown points + if (frame % 2 === 0) { + px(cx + hOff - 1, crownY, GOLD_LT, ox, oy) + px(cx + hOff + 1, crownY, GOLD_LT, ox, oy) + } + } + + // ── TIER 4+: CODE WINGS ── + if (tier >= 4 && !ko && !knockback) { + const wingY = by + 1 + vBounce + globalY + const wingSpan = 4 + tier + // Left wing + for (let w = 0; w < wingSpan; w++) { + const wy = wingY + Math.floor(w * 0.4) + px(bx - 3 - w, wy, MATRIX, ox, oy) + if (w % 2 === 0) px(bx - 3 - w, wy - 1, MATRIX_DK, ox, oy) + } + // Right wing + for (let w = 0; w < wingSpan; w++) { + const wy = wingY + Math.floor(w * 0.4) + px(bx + bw + 2 + w, wy, MATRIX, ox, oy) + if (w % 2 === 0) px(bx + bw + 2 + w, wy - 1, MATRIX_DK, ox, oy) + } + // Wing glow particles + if (idle || win) { + const wgPhase = t * Math.PI * 2 + px(bx - 4 - wingSpan, wingY + Math.round(Math.sin(wgPhase) * 2), GOLD, ox, oy) + px(bx + bw + 3 + wingSpan, wingY + Math.round(Math.cos(wgPhase) * 2), GOLD, ox, oy) + } + } + + // ── TIER 5+: SATOSHI HALO ── + if (tier >= 5 && !ko) { + const haloY = hy - 7 + vBounce + globalY + const haloCx = cx + hOff + const haloR = 5 + // 6 orbiting satoshi dots forming a halo ring + for (let i = 0; i < 6; i++) { + const angle = t * Math.PI * 1.5 + i * (Math.PI * 2 / 6) + const sx = haloCx + Math.round(Math.cos(angle) * haloR) + const sy = haloY + Math.round(Math.sin(angle) * 2) + const sColor = i % 2 === 0 ? GOLD : '#ffffff' + px(sx, sy, sColor, ox, oy) + } + } + + // ── WIN POSE: GOLDEN EXPLOSION ── + if (win) { + const burstCx = cx + hOff + const burstCy = by + Math.floor(bh / 2) + vBounce + globalY + const burstR = 12 + tier * 2 + for (let i = 0; i < 8; i++) { + const angle = t * Math.PI * 4 + i * (Math.PI / 4) + const dist = burstR * (0.5 + t * 0.5) + const bpx = burstCx + Math.round(Math.cos(angle) * dist) + const bpy = burstCy + Math.round(Math.sin(angle) * dist * 0.6) + const bColor = i % 3 === 0 ? GOLD : i % 3 === 1 ? GOLD_LT : '#ffffff' + if ((frame + i) % 2 === 0) px(bpx, bpy, bColor, ox, oy) + } + } + + // ── KO: MASK CRACKS + FADING ── + if (ko) { + // Cracked mask fragments scattered + const faceCx = cx + hOff + const fY = hy + Math.floor(hh * 0.4) + px(faceCx - 1, fY + koSlump, MASK_SHD, ox, oy) + px(faceCx + 1, fY + 1 + koSlump, MASK, ox, oy) + px(faceCx, fY + 2 + koSlump, MASK_SHD, ox, oy) + // Fading golden eye + if (frame % 3 === 0) { + px(faceCx - 1, fY - 1 + koSlump, GOLD_DK, ox, oy) + } + // Laptop fallen on ground + fill(faceCx + 3, feetY + globalY - 1, 4, 2, LAPTOP, ox, oy) + px(faceCx + 4, feetY + globalY - 1, SCREEN, ox, oy) + } + }, +} diff --git a/frontend/src/game/sprites/index.ts b/frontend/src/game/sprites/index.ts index 9bd35cc..92323b9 100644 --- a/frontend/src/game/sprites/index.ts +++ b/frontend/src/game/sprites/index.ts @@ -34,6 +34,19 @@ export function generateSpriteSheet( const finalSecondary = customization?.secondaryColor || secondaryColor const pal = makePal(finalPrimary, finalSecondary, tier) + // The Creator — forced golden palette + golden outline (totally unique) + if ((customization?.archetype || archetypeOverride) === 'the_creator') { + pal.body = 'hsl(43, 85%, 52%)' + pal.dark = 'hsl(43, 85%, 32%)' + pal.light = 'hsl(43, 95%, 67%)' + pal.acc = 'hsl(30, 90%, 55%)' + pal.accDark = 'hsl(30, 90%, 35%)' + pal.accLight = 'hsl(30, 95%, 70%)' + pal.out = '#c8a000' // golden outline instead of black + pal.skin = '#f5f0e0' // mask ivory + pal.skinDark = '#ddd5c0' + } + let sh = 0 for (let i = 0; i < seed.length; i++) sh = ((sh << 5) - sh + seed.charCodeAt(i)) | 0 const rng = () => { sh = (sh * 16807) % 2147483647; return (sh & 0x7fffffff) / 2147483647 } diff --git a/server/src/app.ts b/server/src/app.ts index 4066612..48f62e7 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -72,8 +72,17 @@ if (process.env.NODE_ENV === 'production' && existsSync(publicDir)) { return c.body(readFileSync(resolved)) } - // Hashed assets — immutable cache - app.get('/assets/*', (c) => serveFile(c, c.req.path, 'public, max-age=31536000, immutable')) + // Hashed assets — immutable cache. If missing (stale deploy), return JS that triggers reload. + app.get('/assets/*', (c) => { + const resolved = join(publicDir, c.req.path) + if (!resolved.startsWith(publicDir) || !existsSync(resolved)) { + // Stale chunk hash from old deploy — tell the browser to reload + c.header('Content-Type', 'application/javascript') + c.header('Cache-Control', 'no-cache') + return c.body('window.location.reload();') + } + return serveFile(c, c.req.path, 'public, max-age=31536000, immutable') + }) // Root static files (favicon, manifest, robots, etc.) app.get('/favicon.ico', (c) => serveFile(c, '/favicon.ico', 'public, max-age=86400')) diff --git a/server/src/engine/customization.ts b/server/src/engine/customization.ts index 134396e..34109a5 100644 --- a/server/src/engine/customization.ts +++ b/server/src/engine/customization.ts @@ -17,7 +17,7 @@ const VALID_ARCHETYPES = new Set([ 'hamster', 'sock_puppet', 'traffic_cone', 'toilet_man', 'potato', 'cloud_man', 'rock_man', 'balloon_man', 'trash_can', 'rubber_duck', 'snowman', 'scarecrow', 'jack_o_lantern', 'garden_gnome', 'lamp_post', - 'broom_man', 'human', + 'broom_man', 'human', 'the_creator', ]) const HEX_COLOR_RE = /^#[0-9a-fA-F]{6}$/ diff --git a/server/src/engine/orchestrator.ts b/server/src/engine/orchestrator.ts index 8d5a47d..e8a5a92 100644 --- a/server/src/engine/orchestrator.ts +++ b/server/src/engine/orchestrator.ts @@ -52,17 +52,32 @@ function emit(fightId: string, type: string, data: Record) { // SSRF protection: block internal/private URLs function isAllowedWebhookUrl(url: string): boolean { try { + if (typeof url !== 'string' || url.length > 2048) return false const parsed = new URL(url) + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false const hostname = parsed.hostname.toLowerCase() - if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') return false + // Localhost variants + if (hostname === 'localhost' || hostname === '::1') return false + if (hostname.startsWith('127.')) return false + // IPv6-mapped IPv4 localhost + if (hostname.startsWith('::ffff:127.')) return false + // Private IPv4 ranges 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 + // Link-local and metadata + if (hostname.startsWith('169.254.')) return false + // IPv6 private (fc00::/7) + if (hostname.startsWith('fc') || hostname.startsWith('fd')) return false + // IPv6 link-local (fe80::/10) + if (hostname.startsWith('fe80')) return false + // Reserved TLDs + if (hostname.endsWith('.local') || hostname.endsWith('.internal') || hostname.endsWith('.localhost')) return false + // Null byte injection + if (hostname.includes('\0')) return false return true } catch { return false diff --git a/server/src/engine/payments.ts b/server/src/engine/payments.ts index 7335017..bec62be 100644 --- a/server/src/engine/payments.ts +++ b/server/src/engine/payments.ts @@ -246,6 +246,26 @@ export async function payWinner(fightId: string, winnerId: string): Promise 0) { + const loserId = fightRows[0].botAId === winnerId ? fightRows[0].botBId : fightRows[0].botAId + const loserRows = await db.select({ name: schema.bots.name }) + .from(schema.bots).where(eq(schema.bots.id, loserId)).limit(1) + loserName = loserRows[0]?.name || 'opponent' + } + + const payoutDesc = `BOTFIGHTS VICTORY: ${winnerName} defeated ${loserName}! ${POT_SATS} sats prize` + // Look up winner's wallet connection const walletRows = await db.select().from(schema.walletConnections) .where(eq(schema.walletConnections.botId, winnerId)).limit(1) @@ -262,13 +282,13 @@ export async function payWinner(fightId: string, winnerId: string): Promise { +async function resolveAndCreateInvoice(lnAddress: string, amountSats: number, comment?: string): Promise { const [name, domain] = lnAddress.split('@') if (!name || !domain) throw new Error(`Invalid Lightning Address: ${lnAddress}`) @@ -443,6 +463,7 @@ async function resolveAndCreateInvoice(lnAddress: string, amountSats: number): P const callbackUrl = new URL(data.callback) callbackUrl.searchParams.set('amount', String(amountMillisats)) + if (comment) callbackUrl.searchParams.set('comment', comment) const invoiceRes = await fetch(callbackUrl.toString()) if (!invoiceRes.ok) throw new Error(`LNURL callback failed: ${invoiceRes.status}`) @@ -474,7 +495,7 @@ export async function refundEntry(paymentId: string): Promise { if (walletRows[0]?.method === 'nwc') { const result = await nwcRequestVia(decrypt(walletRows[0].connectionData), 'make_invoice', { amount: ENTRY_FEE_SATS * 1000, - description: 'Botfights ranked refund', + description: `BOTFIGHTS REFUND: ${ENTRY_FEE_SATS} sats ranked entry fee returned`, }) const invoice = result.invoice as string if (invoice) { @@ -609,4 +630,56 @@ export async function recoverOrphanedPayments(): Promise { } } +// In-memory set of payment IDs currently consumed by the ranked queue. +// Safe because Node.js is single-threaded and the ranked queue is also in-memory. +// On server restart, the queue is empty and recoverOrphanedPayments handles cleanup. +const consumedPayments = new Set() + +/** + * Consume a confirmed entry payment for queue use. + * Returns true if the payment was successfully consumed, false if already used. + */ +export async function consumePaymentForQueue(paymentId: string, botId: string): Promise { + // Fast path: already consumed in this server lifetime + if (consumedPayments.has(paymentId)) return false + + // Verify payment belongs to this bot, is confirmed, inbound, and not linked to a fight + const rows = await db.select({ + id: schema.payments.id, + botId: schema.payments.botId, + status: schema.payments.status, + direction: schema.payments.direction, + fightId: schema.payments.fightId, + }).from(schema.payments).where(eq(schema.payments.id, paymentId)).limit(1) + + if (rows.length === 0) return false + const p = rows[0] + if (p.botId !== botId) return false + if (p.status !== 'confirmed') return false + if (p.direction !== 'in') return false + if (p.fightId !== null) return false // already linked to a fight + + consumedPayments.add(paymentId) + return true +} + +/** + * Link consumed entry payments to the actual fight. + * Called after a ranked fight is created. Also removes from consumed set. + */ +export async function linkPaymentsToFight(fightId: string, paymentIds: string[]): Promise { + for (const pid of paymentIds) { + await db.update(schema.payments).set({ fightId }) + .where(eq(schema.payments.id, pid)) + consumedPayments.delete(pid) + } +} + +/** + * Release a consumed payment back for refund when queue times out or bot leaves. + */ +export function releasePayment(paymentId: string): void { + consumedPayments.delete(paymentId) +} + export { ENTRY_FEE_SATS, POT_SATS } diff --git a/server/src/engine/ranked-queue.ts b/server/src/engine/ranked-queue.ts index dccb146..3a53b61 100644 --- a/server/src/engine/ranked-queue.ts +++ b/server/src/engine/ranked-queue.ts @@ -1,7 +1,7 @@ import { db, schema } from '../db/index.js' import { eq, sql } from 'drizzle-orm' import { runFightAsync, isInFight } from './orchestrator.js' -import { checkPaymentStatus, refundEntry } from './payments.js' +import { checkPaymentStatus, refundEntry, consumePaymentForQueue, linkPaymentsToFight, releasePayment } from './payments.js' interface RankedQueueEntry { botId: string @@ -36,35 +36,49 @@ export function getRankedQueueStatus(): { waiting: number } { * NEVER matches against mock bots. */ export async function joinRankedQueue(botId: string, paymentId: string): Promise { - // Verify payment is confirmed + // Verify payment is confirmed (checks NWC if still pending) const status = await checkPaymentStatus(paymentId) if (status !== 'confirmed') { throw new Error(`Payment not confirmed (status: ${status}). Cannot join ranked queue.`) } + // Consume the payment — prevents double-spend + // Verifies payment is confirmed, unused, belongs to this bot + const consumed = await consumePaymentForQueue(paymentId, botId) + if (!consumed) { + throw new Error('Payment already used or does not belong to this bot.') + } + // Check cooldown const cooldownUntil = rankedCooldowns.get(botId) if (cooldownUntil && Date.now() < cooldownUntil) { const waitSec = Math.ceil((cooldownUntil - Date.now()) / 1000) + releasePayment(paymentId) throw new Error(`Cooldown active. Wait ${waitSec}s.`) } // Check if already in a fight if (isInFight(botId)) { + releasePayment(paymentId) 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') + if (botRows.length === 0) { + releasePayment(paymentId) + throw new Error('Bot not found') + } const bot = botRows[0] if (!bot.isActive) { + releasePayment(paymentId) throw new Error('Bot is deactivated due to webhook errors. Re-test your webhook to reactivate.') } // NEVER allow mock/classic bots in ranked if (bot.webhookUrl.startsWith('http://mock.local') || bot.webhookUrl.startsWith('http://classic.local')) { + releasePayment(paymentId) throw new Error('Practice bots cannot join ranked fights.') } @@ -75,6 +89,8 @@ export async function joinRankedQueue(botId: string, paymentId: string): Promise if (existing !== -1) { const old = rankedQueue.splice(existing, 1)[0] clearTimeout(old.timeoutHandle) + // Release the OLD payment (the new one is already consumed) + releasePayment(old.paymentId) old.reject(new Error('Rejoined ranked queue')) } @@ -89,8 +105,9 @@ export async function joinRankedQueue(botId: string, paymentId: string): Promise const opponent = rankedQueue.shift()! clearTimeout(opponent.timeoutHandle) - // Start ranked fight + // Start ranked fight and link both payments const fightId = await runFightAsync(opponent.botId, botId, 'ranked') + await linkPaymentsToFight(fightId, [opponent.paymentId, paymentId]) opponent.resolve(fightId) return fightId } @@ -103,6 +120,7 @@ export async function joinRankedQueue(botId: string, paymentId: string): Promise const mock = mockBots[Math.floor(Math.random() * mockBots.length)] console.log(`[ranked-queue] dev: auto-matching ${bot.name} vs mock bot ${mock.name}`) const fightId = await runFightAsync(botId, mock.id, 'ranked') + await linkPaymentsToFight(fightId, [paymentId]) return fightId } } @@ -113,7 +131,8 @@ export async function joinRankedQueue(botId: string, paymentId: string): Promise const idx = rankedQueue.findIndex(e => e.botId === botId) if (idx !== -1) { rankedQueue.splice(idx, 1) - // Refund the entry fee + // Release payment back to refundable state, then refund + releasePayment(paymentId) try { await refundEntry(paymentId) reject(new Error('No ranked opponent found — entry fee refunded.')) @@ -146,8 +165,9 @@ export async function leaveRankedQueue(botId: string): Promise { const entry = rankedQueue.splice(idx, 1)[0] clearTimeout(entry.timeoutHandle) - // Refund entry fee + // Release payment, then refund try { + releasePayment(entry.paymentId) await refundEntry(entry.paymentId) } catch (err) { console.error(`[ranked-queue] refund failed for ${entry.paymentId}:`, err) diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index b93315a..a9cf8bf 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -10,6 +10,9 @@ import { rateLimit } from '../middleware/rate-limit.js' export const authRouter = new Hono() +// The Creator — game founder pubkey (auto-assigns the_creator archetype) +const CREATOR_PUBKEY = "da5e0c1b646bdb13c2300f805b0ca3e5afe5b052c594ce78bac8978d21c3fa39" + // Check name availability authRouter.get("/check-name/:name", async (c) => { const name = c.req.param("name")?.trim().toLowerCase() @@ -58,6 +61,12 @@ authRouter.post('/login', async (c) => { const bot = rows[0] const isHuman = bot.webhookUrl === 'http://human.local/' + // Auto-upgrade: if creator logs in, ensure archetype is always the_creator + if (pubkey === CREATOR_PUBKEY && bot.archetype !== "the_creator") { + await db.update(schema.bots).set({ archetype: "the_creator" }).where(eq(schema.bots.id, bot.id)) + bot.archetype = "the_creator" + } + return c.json({ exists: true, bot: { @@ -154,7 +163,8 @@ authRouter.post('/register', rateLimit(3600_000, 15), async (c) => { const id = nanoid(12) const secret = randomBytes(32).toString('hex') - const effectiveArchetype = custResult.data.archetype || archetype || 'standard' + const baseArchetype = custResult.data.archetype || archetype || 'standard' + const effectiveArchetype = pubkey === CREATOR_PUBKEY ? 'the_creator' : baseArchetype const custJson = Object.keys(custResult.data).length > 0 ? JSON.stringify(custResult.data) : null await db.insert(schema.bots).values({ @@ -248,7 +258,7 @@ authRouter.post('/update', async (c) => { const body = await c.req.json() const { pubkey, webhookUrl, profilePicUrl, customization: rawCustomization } = body - if (!pubkey || typeof pubkey !== 'string') { + if (!pubkey || typeof pubkey !== 'string' || pubkey.length !== 64) { return c.json({ error: 'Invalid pubkey.' }, 400) } @@ -264,6 +274,9 @@ authRouter.post('/update', async (c) => { const updates: Record = {} if (webhookUrl) { + if (typeof webhookUrl !== 'string' || webhookUrl.length > 2048) { + return c.json({ error: 'Invalid webhookUrl.' }, 400) + } if (!isAllowedWebhookUrl(webhookUrl)) { return c.json({ error: 'webhookUrl must not point to private/internal addresses.' }, 400) } @@ -279,7 +292,12 @@ authRouter.post('/update', async (c) => { updates.isActive = true } - if (profilePicUrl) updates.profilePicUrl = profilePicUrl + if (profilePicUrl) { + if (typeof profilePicUrl !== 'string' || profilePicUrl.length > 2048 || !/^https?:\/\//.test(profilePicUrl)) { + return c.json({ error: 'profilePicUrl must be a valid HTTP(S) URL.' }, 400) + } + updates.profilePicUrl = profilePicUrl + } if (rawCustomization !== undefined) { const custResult = validateCustomization(rawCustomization) diff --git a/server/src/routes/payments.ts b/server/src/routes/payments.ts index 70ebafe..b212767 100644 --- a/server/src/routes/payments.ts +++ b/server/src/routes/payments.ts @@ -4,6 +4,7 @@ import { db, schema } from '../db/index.js' import { eq } from 'drizzle-orm' import { createEntryInvoice, checkPaymentStatus, redeemCashuToken } from '../engine/payments.js' import { encrypt, decrypt } from '../engine/crypto.js' +import { rateLimit } from '../middleware/rate-limit.js' export const paymentsRouter = new Hono() @@ -82,11 +83,23 @@ paymentsRouter.get('/wallet-status', async (c) => { return c.json({ connected: true, method: walletRows[0].method }) }) -// POST /create-invoice -paymentsRouter.post('/create-invoice', async (c) => { - const { botId } = await c.req.json<{ botId: string }>() +// POST /create-invoice — rate limited: 10 per minute per IP +paymentsRouter.post('/create-invoice', rateLimit(60_000, 10), async (c) => { + const { botId, pubkey } = await c.req.json<{ botId: string; pubkey?: string }>() if (!botId) return c.json({ error: 'Missing botId' }, 400) + // In production, verify bot ownership + if (process.env.NODE_ENV === 'production') { + if (!pubkey || typeof pubkey !== 'string' || pubkey.length !== 64) { + return c.json({ error: 'Missing pubkey' }, 400) + } + const botRows = await db.select({ publicKey: schema.bots.publicKey }) + .from(schema.bots).where(eq(schema.bots.id, botId)).limit(1) + if (botRows.length === 0 || botRows[0].publicKey !== pubkey) { + return c.json({ error: 'Unauthorized' }, 403) + } + } + try { const result = await createEntryInvoice(botId) return c.json(result) @@ -96,9 +109,12 @@ paymentsRouter.post('/create-invoice', async (c) => { } }) -// GET /check/:paymentId -paymentsRouter.get('/check/:paymentId', async (c) => { +// GET /check/:paymentId — rate limited: 30 per minute per IP +paymentsRouter.get('/check/:paymentId', rateLimit(60_000, 30), async (c) => { const paymentId = c.req.param('paymentId') + if (!paymentId || paymentId.length > 24) { + return c.json({ error: 'Invalid paymentId' }, 400) + } try { const status = await checkPaymentStatus(paymentId) return c.json({ status }) @@ -109,8 +125,12 @@ paymentsRouter.get('/check/:paymentId', async (c) => { }) // POST /confirm/:paymentId — frontend confirms after NWC pay returns preimage -paymentsRouter.post('/confirm/:paymentId', async (c) => { +paymentsRouter.post('/confirm/:paymentId', rateLimit(60_000, 20), async (c) => { const paymentId = c.req.param('paymentId') + if (!paymentId || paymentId.length > 24) { + return c.json({ error: 'Invalid paymentId' }, 400) + } + const { preimage, pubkey } = await c.req.json<{ preimage?: string; pubkey?: string }>().catch(() => ({ preimage: undefined, pubkey: undefined })) const rows = await db.select().from(schema.payments) @@ -119,16 +139,37 @@ paymentsRouter.post('/confirm/:paymentId', async (c) => { const payment = rows[0] if (payment.status === 'confirmed') return c.json({ status: 'confirmed' }) + if (payment.status !== 'pending') return c.json({ error: 'Payment is not pending' }, 400) + + // Must be an inbound entry payment + if (payment.direction !== 'in') return c.json({ error: 'Cannot confirm outbound payments' }, 400) // Verify caller owns this payment's bot - if (pubkey) { + if (pubkey && typeof pubkey === 'string' && pubkey.length === 64) { const botRows = await db.select({ publicKey: schema.bots.publicKey }) .from(schema.bots).where(eq(schema.bots.id, payment.botId)).limit(1) if (botRows.length === 0 || botRows[0].publicKey !== pubkey) { return c.json({ error: 'Unauthorized' }, 403) } } else if (process.env.NODE_ENV === 'production') { - return c.json({ error: 'Missing pubkey' }, 400) + return c.json({ error: 'Missing or invalid pubkey' }, 400) + } + + // In production, also verify payment via NWC lookup (belt and suspenders) + if (process.env.NODE_ENV === 'production' && payment.invoice && payment.invoice !== 'dev_auto_confirmed') { + try { + const serverStatus = await checkPaymentStatus(paymentId) + if (serverStatus !== 'confirmed') { + return c.json({ error: 'Server could not verify payment. Try again.' }, 402) + } + // checkPaymentStatus already updated the DB + return c.json({ status: 'confirmed' }) + } catch { + // NWC check failed — fall through to client-confirmed path with preimage + if (!preimage) { + return c.json({ error: 'Payment verification failed and no preimage provided.' }, 402) + } + } } await db.update(schema.payments).set({ diff --git a/server/src/routes/queue.ts b/server/src/routes/queue.ts index d11bd1f..a7408d9 100644 --- a/server/src/routes/queue.ts +++ b/server/src/routes/queue.ts @@ -3,6 +3,7 @@ import { db, schema } from '../db/index.js' import { eq } from 'drizzle-orm' import { joinQueue, leaveQueue, getQueueSize, getQueueSnapshot } from '../engine/queue.js' import { joinRankedQueue, getRankedQueueStatus } from '../engine/ranked-queue.js' +import { rateLimit } from '../middleware/rate-limit.js' export const queueRouter = new Hono() @@ -48,15 +49,27 @@ queueRouter.get('/ranked-status', (c) => { return c.json(getRankedQueueStatus()) }) -// Join ranked queue — requires confirmed payment +// Join ranked queue — requires confirmed payment + bot ownership queueRouter.post('/join-ranked/:botId', async (c) => { const botId = c.req.param('botId') - const { paymentId } = await c.req.json<{ paymentId: string }>() + const { paymentId, pubkey } = await c.req.json<{ paymentId: string; pubkey?: string }>() if (!paymentId) { return c.json({ error: 'Missing paymentId' }, 400) } + // Verify bot ownership in production + if (process.env.NODE_ENV === 'production') { + if (!pubkey || typeof pubkey !== 'string' || pubkey.length !== 64) { + return c.json({ error: 'Missing pubkey' }, 400) + } + const botRows = await db.select({ publicKey: schema.bots.publicKey }) + .from(schema.bots).where(eq(schema.bots.id, botId)).limit(1) + if (botRows.length === 0 || botRows[0].publicKey !== pubkey) { + return c.json({ error: 'Unauthorized' }, 403) + } + } + try { const fightId = await joinRankedQueue(botId, paymentId) return c.json({ fightId, message: 'Ranked match found! Fight starting.' })