feat: THE CREATOR god-tier character, bitcoin choreographies, omni-morph, cameos

- Guy Fawkes mask archetype with golden outline, bitcoin chest symbol, laptop, tier-gated effects
- 12 custom bitcoin-themed choreographies (8 regular + 4 exclusive ultimates)
- Omni-morph system: creator morphs into any of 80+ archetypes instead of cycling 3
- 6% per-round cameo in non-creator fights — drops golden ₿ gifts to fighters
- Custom golden code rain entrance with persistent orbiting particles
- pickChoreography: 50% ultimate chance (100% on crits), all tier ultimates + 4 exclusive
- Server auto-assigns the_creator archetype on login/register for creator pubkey
- FightViewer, payments, queue, orchestrator improvements

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-08 12:08:18 +00:00
co-authored by Claude Opus 4.6
parent 8a3480e5ab
commit 6d390f69b2
14 changed files with 1513 additions and 104 deletions
+91 -23
View File
@@ -8,6 +8,7 @@ import {
sfxCrowdCheer, sfxCrowdGasp, sfxCrowdOoh, sfxApplause, sfxDrumRoll, sfxCrowdCheer, sfxCrowdGasp, sfxCrowdOoh, sfxApplause, sfxDrumRoll,
setMusicIntensity, stopAllAudio, setMusicIntensity, stopAllAudio,
setMasterMute, isMasterMuted, ensureAudioContext, setMasterMute, isMasterMuted, ensureAudioContext,
speakQuestion, speakAnswer, speakNarration,
} from '../game/sounds' } from '../game/sounds'
interface Round { interface Round {
@@ -66,6 +67,7 @@ const hitTextX = ref(50)
const hitTextY = ref(30) const hitTextY = ref(30)
const glitching = ref(false) const glitching = ref(false)
const soundOn = ref(true) const soundOn = ref(true)
const insanityMode = ref(false)
async function toggleSound() { async function toggleSound() {
soundOn.value = !soundOn.value soundOn.value = !soundOn.value
@@ -73,6 +75,14 @@ async function toggleSound() {
setMasterMute(!soundOn.value) 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 // Staggered log
const logItems = ref<{ type: string; round: number; text: string; color: string }[]>([]) 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) { for (const round of props.fight.rounds) {
currentRound.value = round.roundNumber currentRound.value = round.roundNumber
fanfareRound(round.roundNumber) if (insanityMode.value) {
await showOverlay(`ROUND ${round.roundNumber}`, '#00f0ff', 700) // Insanity: minimal overlays, no fanfares
await sleep(80) await showOverlay(`R${round.roundNumber}`, '#00f0ff', 150)
await showOverlay(challengeLabel(round.challengeType), '#b83dff', 600) } else {
await sleep(80) fanfareRound(round.roundNumber)
fanfareFight() await showOverlay(`ROUND ${round.roundNumber}`, '#00f0ff', 700)
await showOverlay('FIGHT!', '#ff2d7b', 400) await sleep(80)
await sleep(80) await showOverlay(challengeLabel(round.challengeType), '#b83dff', 600)
await sleep(80)
// Show speech bubbles BEFORE the fight animation so viewers see what bots said fanfareFight()
if (scene) { await showOverlay('FIGHT!', '#ff2d7b', 400)
if (round.botAResponse) scene.showSpeechBubble('a', round.botAResponse.slice(0, 60), 3.5) await sleep(80)
if (round.botBResponse) {
setTimeout(() => {
if (scene && round.botBResponse) scene.showSpeechBubble('b', round.botBResponse.slice(0, 60), 3.2)
}, 400)
}
} }
await sleep(300)
// Log + fight animation in parallel // === TTS-synced question + answer flow ===
const logPromise = addRoundToLog(round, true) 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 isCritical = Math.abs((round.botAScore || 0) - (round.botBScore || 0)) > 4
const aWon = round.winnerId === props.fight.botA!.id const aWon = round.winnerId === props.fight.botA!.id
const bWon = round.winnerId === props.fight.botB!.id const bWon = round.winnerId === props.fight.botB!.id
@@ -328,6 +382,8 @@ async function _doReplay() {
if (round.narration) { if (round.narration) {
logItems.value.push({ type: 'narration', round: round.roundNumber, text: `>> ${round.narration}`, color: 'neon-yellow' }) logItems.value.push({ type: 'narration', round: round.roundNumber, text: `>> ${round.narration}`, color: 'neon-yellow' })
scrollLog() 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' const winner = aWon ? props.fight.botA!.name : bWon ? props.fight.botB!.name : 'DRAW'
@@ -352,17 +408,18 @@ async function _doReplay() {
setMusicIntensity(1 - lowestHp / 100) setMusicIntensity(1 - lowestHp / 100)
// Taunting between rounds — winner taunts, sometimes both // Taunting between rounds — winner taunts, sometimes both
if (scene && (aWon || bWon)) { if (!insanityMode.value && scene && (aWon || bWon)) {
const winnerSide = aWon ? 'a' : 'b' const winnerSide = aWon ? 'a' : 'b'
await sleep(150) await sleep(150)
await scene.playTaunt(winnerSide) await scene.playTaunt(winnerSide)
// Sometimes loser taunts back (30% chance)
if (Math.random() < 0.3) { if (Math.random() < 0.3) {
await scene.playTaunt(winnerSide === 'a' ? 'b' : 'a') await scene.playTaunt(winnerSide === 'a' ? 'b' : 'a')
} }
await sleep(200) await sleep(200)
} else { } else if (!insanityMode.value) {
await sleep(400) await sleep(400)
} else {
await sleep(30)
} }
} }
@@ -593,6 +650,17 @@ async function _doReplay() {
<path d="M11 5L6 9H2v6h4l5 4V5z"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/> <path d="M11 5L6 9H2v6h4l5 4V5z"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/>
</svg> </svg>
</button> </button>
<button
v-if="isReplaying"
class="h-8 px-2 flex items-center justify-center border text-[9px] font-pixel tracking-wider transition-all"
:class="insanityMode
? 'border-neon-yellow/70 text-neon-yellow bg-neon-yellow/10 animate-pulse'
: 'border-border/50 text-text-muted hover:text-neon-yellow hover:border-neon-yellow/50'"
title="INSANITY MODE: Skip TTS, max speed"
@click="toggleInsanity"
>
{{ insanityMode ? 'INSANITY' : 'SKIP' }}
</button>
<span class="font-pixel text-[10px] text-text-muted">{{ fight.status === 'finished' ? 'FINISHED' : fight.status.toUpperCase() }}</span> <span class="font-pixel text-[10px] text-text-muted">{{ fight.status === 'finished' ? 'FINISHED' : fight.status.toUpperCase() }}</span>
</div> </div>
</div> </div>
+774 -51
View File
@@ -107,7 +107,29 @@ const TIER_ULTIMATES: Record<number, string[]> = {
5: ['ultimateArmageddon', 'ultimateOmegaBeam', 'ultimateBeyondTheScreen', 'ultimateMatrixDodge', 'ultimateWorldEnder', 'ultimateAbsoluteZero', 'ultimateSolarCannon', 'ultimatePhantomStrike', 'ultimateGodPunch', 'ultimateVoidCollapse'], 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-gated ultimates: higher tier = higher chance of spectacular ultimate moves
// Tier 2: 15% chance, Tier 3: 20%, Tier 4: 30%, Tier 5: 40% // Tier 2: 15% chance, Tier 3: 20%, Tier 4: 30%, Tier 5: 40%
if (attackerTier >= 2) { if (attackerTier >= 2) {
@@ -636,44 +658,9 @@ export async function createFightScene(config: FightSceneConfig) {
})) }))
} }
// Grotesque close-up: spawn exaggerated impact details during zoom-ins // Grotesque close-up overlays removed — noops retained for call-site compatibility
let grotesqueObjs: any[] = [] function spawnGrotesqueDetails(_fighter: any, _scaleFactor: number) {}
function spawnGrotesqueDetails(fighter: any, scaleFactor: number) { function destroyGrotesqueDetails() {}
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 = []
}
// Arena-specific background decoration // Arena-specific background decoration
function drawArenaDecor() { function drawArenaDecor() {
const a = arena const a = arena
@@ -5718,6 +5705,453 @@ export async function createFightScene(config: FightSceneConfig) {
// Tier-gated pools (mapping is at module level for pickChoreography access) // 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<string, typeof dashPunch> = { const choreographyMap: Record<string, typeof dashPunch> = {
dashPunch, aerialSlam, flyingKick, dashThrough, uppercut, dashPunch, aerialSlam, flyingKick, dashThrough, uppercut,
multiHit, projectile, jetpackDive, gunBurst, groundPound, multiHit, projectile, jetpackDive, gunBurst, groundPound,
@@ -5821,6 +6255,11 @@ export async function createFightScene(config: FightSceneConfig) {
// Tier 5+ // Tier 5+
ultimateArmageddon, ultimateOmegaBeam, ultimateBeyondTheScreen, ultimateMatrixDodge, ultimateWorldEnder, ultimateArmageddon, ultimateOmegaBeam, ultimateBeyondTheScreen, ultimateMatrixDodge, ultimateWorldEnder,
ultimateAbsoluteZero, ultimateSolarCannon, ultimatePhantomStrike, ultimateGodPunch, ultimateVoidCollapse, 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 === // === MORPH / COSTUME SYSTEM ===
@@ -6042,6 +6481,86 @@ export async function createFightScene(config: FightSceneConfig) {
const morphAppliers = [applyElementalMorph, applyMechMorph, applyBeastMorph] const morphAppliers = [applyElementalMorph, applyMechMorph, applyBeastMorph]
const morphNames = ['ELEMENTAL FORM', 'MECH ARMOR', 'BEAST MODE'] 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<string, string> = {
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) // Get morph order for a bot (deterministic by seed)
function getMorphOrder(seed: string): number[] { function getMorphOrder(seed: string): number[] {
let h = 0 let h = 0
@@ -6332,6 +6851,49 @@ export async function createFightScene(config: FightSceneConfig) {
return lines return lines
} }
// Talking mouth animation — a small rectangle that opens/closes near the fighter's face
const talkingAnims: Record<string, { objs: any[]; timer: ReturnType<typeof setInterval> }> = {}
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 // Spawn floating text above a position
function spawnEmoteText(x: number, y: number, text: string, color: string, duration: number = 1.2) { function spawnEmoteText(x: number, y: number, text: string, color: string, duration: number = 1.2) {
const label = k.add([ const label = k.add([
@@ -6706,6 +7268,9 @@ export async function createFightScene(config: FightSceneConfig) {
showSpeechBubble(side, text, duration) showSpeechBubble(side, text, duration)
}, },
startTalking(side: 'a' | 'b') { startTalking(side) },
stopTalking(side: 'a' | 'b') { stopTalking(side) },
async playEntrance() { async playEntrance() {
const fA = k.get('fighterA')[0] const fA = k.get('fighterA')[0]
const fB = k.get('fighterB')[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) // Pick random entrance for each bot (different ones)
const idxA = Math.floor(Math.random() * entrances.length) const idxA = Math.floor(Math.random() * entrances.length)
let idxB = 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) 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() 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 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) await k.wait(0.2)
// Safety: ensure both fighters are visible and at home positions // 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 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 // Morph system: transform during ultimates or devastating crits
const isUltimate = choreo.startsWith('ultimate') 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<void>) | null = null let morphRevert: (() => Promise<void>) | null = null
if (shouldMorph) { if (shouldMorph) {
const attacker = k.get(attackerSide === 'a' ? 'fighterA' : 'fighterB')[0] const attacker = k.get(attackerSide === 'a' ? 'fighterA' : 'fighterB')[0]
if (attacker) { if (attacker) {
const seed = attackerSide === 'a' ? botA.seed : botB.seed if (isCreatorFighter) {
const morphOrder = getMorphOrder(seed) // Omni-morph: creator transforms into a random archetype
// Cycle through morphs based on round number const morphedTo = applyCreatorOmniMorph(attacker, attackerSide)
const morphIdx = morphOrder[event.round % 3] screenFlash('#ffd700', 0.15)
const result = await playMorph(attacker, attackerSide, morphIdx) sfxZoomWhoosh()
morphRevert = result.revert sfxSpecial()
await k.wait(0.2) 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 // 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') }) 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 // Update combos
const hasCombo = (aWon && comboA + 1 >= 3) || (bWon && comboB + 1 >= 3) const hasCombo = (aWon && comboA + 1 >= 3) || (bWon && comboB + 1 >= 3)
if (aWon) { if (aWon) {
+99
View File
@@ -104,6 +104,7 @@ const voiceProfiles: Record<string, VoiceProfile> = {
robot: { voice: null, pitch: 0.8, rate: 0.8, volume: 0.9 }, // Steady monotone 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 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 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 // 24 new profiles
whisper: { voice: null, pitch: 1.2, rate: 0.6, volume: 0.4 }, // Quiet dramatic whisper 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 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.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.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)] 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 // Assign voices to new profiles — spread across available voices for max variety
const vLen = anyVoices.length const vLen = anyVoices.length
const pick = (i: number) => anyVoices[i % vLen] const pick = (i: number) => anyVoices[i % vLen]
@@ -340,6 +347,52 @@ function speak(text: string, profileName: string, cancelPrevious: boolean = fals
speechSynthesis.speak(utter) speechSynthesis.speak(utter)
} }
/** Like speakAsync but with a forced minimum rate */
function speakAsyncWithRate(text: string, profileName: string, minRate: number): Promise<void> {
return new Promise<void>((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<void> {
return new Promise<void>((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() { export function stopAllAudio() {
stopMusic() stopMusic()
if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel() if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel()
@@ -512,6 +565,52 @@ export function announceRoundHype() {
voices[Math.floor(Math.random() * voices.length)](line) 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<void> {
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<void> {
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<void> {
const trimmed = text.length > 160 ? text.slice(0, 157) + '...' : text
return speakAsyncWithRate(trimmed, 'sportscaster', 1.2)
}
// === FANFARES (8-bit melodic announcements) === // === FANFARES (8-bit melodic announcements) ===
export function fanfareRound(roundNum: number) { export function fanfareRound(roundNum: number) {
@@ -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' import { elephant, giraffe, hippo, lion, monkey, parrot, raccoon, snakeArch, turtle, whale, crocodile, flamingo, hedgehog, panda, hamster } from './batch_animals2'
// Batch 8: Silly & Objects // Batch 8: Silly & Objects
import { sockPuppet, trafficCone, toiletMan, potato, cloudMan, rockMan, balloonMan, trashCan, rubberDuckArch, snowman, scarecrow, jackOLantern, gardenGnome, lampPost, broomMan } from './batch_silly' 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[] = [ export const archetypes: Archetype[] = [
// Original 6 // 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, elephant, giraffe, hippo, lion, monkey, parrot, raccoon, snakeArch, turtle, whale, crocodile, flamingo, hedgehog, panda, hamster,
// Batch 8: silly & objects // Batch 8: silly & objects
sockPuppet, trafficCone, toiletMan, potato, cloudMan, rockMan, balloonMan, trashCan, rubberDuckArch, snowman, scarecrow, jackOLantern, gardenGnome, lampPost, broomMan, 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 { export function rollArchetype(roll: number): Archetype {
@@ -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)
}
},
}
+13
View File
@@ -34,6 +34,19 @@ export function generateSpriteSheet(
const finalSecondary = customization?.secondaryColor || secondaryColor const finalSecondary = customization?.secondaryColor || secondaryColor
const pal = makePal(finalPrimary, finalSecondary, tier) 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 let sh = 0
for (let i = 0; i < seed.length; i++) sh = ((sh << 5) - sh + seed.charCodeAt(i)) | 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 } const rng = () => { sh = (sh * 16807) % 2147483647; return (sh & 0x7fffffff) / 2147483647 }
+11 -2
View File
@@ -72,8 +72,17 @@ if (process.env.NODE_ENV === 'production' && existsSync(publicDir)) {
return c.body(readFileSync(resolved)) return c.body(readFileSync(resolved))
} }
// Hashed assets — immutable cache // Hashed assets — immutable cache. If missing (stale deploy), return JS that triggers reload.
app.get('/assets/*', (c) => serveFile(c, c.req.path, 'public, max-age=31536000, immutable')) 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.) // Root static files (favicon, manifest, robots, etc.)
app.get('/favicon.ico', (c) => serveFile(c, '/favicon.ico', 'public, max-age=86400')) app.get('/favicon.ico', (c) => serveFile(c, '/favicon.ico', 'public, max-age=86400'))
+1 -1
View File
@@ -17,7 +17,7 @@ const VALID_ARCHETYPES = new Set([
'hamster', 'sock_puppet', 'traffic_cone', 'toilet_man', 'potato', 'hamster', 'sock_puppet', 'traffic_cone', 'toilet_man', 'potato',
'cloud_man', 'rock_man', 'balloon_man', 'trash_can', 'rubber_duck', 'cloud_man', 'rock_man', 'balloon_man', 'trash_can', 'rubber_duck',
'snowman', 'scarecrow', 'jack_o_lantern', 'garden_gnome', 'lamp_post', '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}$/ const HEX_COLOR_RE = /^#[0-9a-fA-F]{6}$/
+18 -3
View File
@@ -52,17 +52,32 @@ function emit(fightId: string, type: string, data: Record<string, unknown>) {
// SSRF protection: block internal/private URLs // SSRF protection: block internal/private URLs
function isAllowedWebhookUrl(url: string): boolean { function isAllowedWebhookUrl(url: string): boolean {
try { try {
if (typeof url !== 'string' || url.length > 2048) return false
const parsed = new URL(url) const parsed = new URL(url)
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false
const hostname = parsed.hostname.toLowerCase() 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('10.')) return false
if (hostname.startsWith('192.168.')) return false if (hostname.startsWith('192.168.')) return false
if (hostname.startsWith('172.')) { if (hostname.startsWith('172.')) {
const second = parseInt(hostname.split('.')[1]) const second = parseInt(hostname.split('.')[1])
if (second >= 16 && second <= 31) return false if (second >= 16 && second <= 31) return false
} }
if (hostname === '169.254.169.254') return false // Link-local and metadata
if (hostname.endsWith('.local') || hostname.endsWith('.internal')) return false 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 return true
} catch { } catch {
return false return false
+78 -5
View File
@@ -246,6 +246,26 @@ export async function payWinner(fightId: string, winnerId: string): Promise<void
// Dev mode: use Lightning Address from env to avoid self-payment on same LND node // Dev mode: use Lightning Address from env to avoid self-payment on same LND node
const devPayoutAddr = DEV_AUTO_CONFIRM ? getDevPayoutAddress() : '' const devPayoutAddr = DEV_AUTO_CONFIRM ? getDevPayoutAddress() : ''
// Look up winner name for payout description
const winnerRows = await db.select({ name: schema.bots.name })
.from(schema.bots).where(eq(schema.bots.id, winnerId)).limit(1)
const winnerName = winnerRows[0]?.name || 'unknown'
// Look up loser for the payout message
const fightRows = await db.select({
botAId: schema.fights.botAId,
botBId: schema.fights.botBId,
}).from(schema.fights).where(eq(schema.fights.id, fightId)).limit(1)
let loserName = 'opponent'
if (fightRows.length > 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 // Look up winner's wallet connection
const walletRows = await db.select().from(schema.walletConnections) const walletRows = await db.select().from(schema.walletConnections)
.where(eq(schema.walletConnections.botId, winnerId)).limit(1) .where(eq(schema.walletConnections.botId, winnerId)).limit(1)
@@ -262,13 +282,13 @@ export async function payWinner(fightId: string, winnerId: string): Promise<void
if (devPayoutAddr) { if (devPayoutAddr) {
// Dev mode: pay to configured Lightning Address (different node, avoids self-payment) // Dev mode: pay to configured Lightning Address (different node, avoids self-payment)
console.log(`[payments] dev: paying ${POT_SATS} sats to ${devPayoutAddr}`) console.log(`[payments] dev: paying ${POT_SATS} sats to ${devPayoutAddr}`)
invoice = await resolveAndCreateInvoice(devPayoutAddr, POT_SATS) invoice = await resolveAndCreateInvoice(devPayoutAddr, POT_SATS, payoutDesc)
await nwcRequest('pay_invoice', { invoice }) await nwcRequest('pay_invoice', { invoice })
paymentMethod = 'lightning' paymentMethod = 'lightning'
} else if (wallet?.method === 'lnaddress') { } else if (wallet?.method === 'lnaddress') {
// Resolve Lightning Address → LNURL → invoice → pay // Resolve Lightning Address → LNURL → invoice → pay
invoice = await resolveAndCreateInvoice(decrypt(wallet.connectionData), POT_SATS) invoice = await resolveAndCreateInvoice(decrypt(wallet.connectionData), POT_SATS, payoutDesc)
await nwcRequest('pay_invoice', { invoice }) await nwcRequest('pay_invoice', { invoice })
paymentMethod = 'lightning' paymentMethod = 'lightning'
@@ -276,7 +296,7 @@ export async function payWinner(fightId: string, winnerId: string): Promise<void
// Request invoice from winner's NWC wallet, then pay it via server wallet // Request invoice from winner's NWC wallet, then pay it via server wallet
const winnerResult = await nwcRequestVia(decrypt(wallet.connectionData), 'make_invoice', { const winnerResult = await nwcRequestVia(decrypt(wallet.connectionData), 'make_invoice', {
amount: POT_SATS * 1000, amount: POT_SATS * 1000,
description: `Botfights ranked win payout (${POT_SATS} sats)`, description: payoutDesc,
}) })
invoice = winnerResult.invoice as string invoice = winnerResult.invoice as string
if (!invoice) throw new Error('Winner NWC make_invoice returned no invoice') if (!invoice) throw new Error('Winner NWC make_invoice returned no invoice')
@@ -419,7 +439,7 @@ async function nwcRequestVia(
} }
/** Resolve a Lightning Address to a BOLT11 invoice */ /** Resolve a Lightning Address to a BOLT11 invoice */
async function resolveAndCreateInvoice(lnAddress: string, amountSats: number): Promise<string> { async function resolveAndCreateInvoice(lnAddress: string, amountSats: number, comment?: string): Promise<string> {
const [name, domain] = lnAddress.split('@') const [name, domain] = lnAddress.split('@')
if (!name || !domain) throw new Error(`Invalid Lightning Address: ${lnAddress}`) 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) const callbackUrl = new URL(data.callback)
callbackUrl.searchParams.set('amount', String(amountMillisats)) callbackUrl.searchParams.set('amount', String(amountMillisats))
if (comment) callbackUrl.searchParams.set('comment', comment)
const invoiceRes = await fetch(callbackUrl.toString()) const invoiceRes = await fetch(callbackUrl.toString())
if (!invoiceRes.ok) throw new Error(`LNURL callback failed: ${invoiceRes.status}`) if (!invoiceRes.ok) throw new Error(`LNURL callback failed: ${invoiceRes.status}`)
@@ -474,7 +495,7 @@ export async function refundEntry(paymentId: string): Promise<void> {
if (walletRows[0]?.method === 'nwc') { if (walletRows[0]?.method === 'nwc') {
const result = await nwcRequestVia(decrypt(walletRows[0].connectionData), 'make_invoice', { const result = await nwcRequestVia(decrypt(walletRows[0].connectionData), 'make_invoice', {
amount: ENTRY_FEE_SATS * 1000, 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 const invoice = result.invoice as string
if (invoice) { if (invoice) {
@@ -609,4 +630,56 @@ export async function recoverOrphanedPayments(): Promise<void> {
} }
} }
// 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<string>()
/**
* 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<boolean> {
// 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<void> {
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 } export { ENTRY_FEE_SATS, POT_SATS }
+26 -6
View File
@@ -1,7 +1,7 @@
import { db, schema } from '../db/index.js' import { db, schema } from '../db/index.js'
import { eq, sql } from 'drizzle-orm' import { eq, sql } from 'drizzle-orm'
import { runFightAsync, isInFight } from './orchestrator.js' import { runFightAsync, isInFight } from './orchestrator.js'
import { checkPaymentStatus, refundEntry } from './payments.js' import { checkPaymentStatus, refundEntry, consumePaymentForQueue, linkPaymentsToFight, releasePayment } from './payments.js'
interface RankedQueueEntry { interface RankedQueueEntry {
botId: string botId: string
@@ -36,35 +36,49 @@ export function getRankedQueueStatus(): { waiting: number } {
* NEVER matches against mock bots. * NEVER matches against mock bots.
*/ */
export async function joinRankedQueue(botId: string, paymentId: string): Promise<string> { export async function joinRankedQueue(botId: string, paymentId: string): Promise<string> {
// Verify payment is confirmed // Verify payment is confirmed (checks NWC if still pending)
const status = await checkPaymentStatus(paymentId) const status = await checkPaymentStatus(paymentId)
if (status !== 'confirmed') { if (status !== 'confirmed') {
throw new Error(`Payment not confirmed (status: ${status}). Cannot join ranked queue.`) 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 // Check cooldown
const cooldownUntil = rankedCooldowns.get(botId) const cooldownUntil = rankedCooldowns.get(botId)
if (cooldownUntil && Date.now() < cooldownUntil) { if (cooldownUntil && Date.now() < cooldownUntil) {
const waitSec = Math.ceil((cooldownUntil - Date.now()) / 1000) const waitSec = Math.ceil((cooldownUntil - Date.now()) / 1000)
releasePayment(paymentId)
throw new Error(`Cooldown active. Wait ${waitSec}s.`) throw new Error(`Cooldown active. Wait ${waitSec}s.`)
} }
// Check if already in a fight // Check if already in a fight
if (isInFight(botId)) { if (isInFight(botId)) {
releasePayment(paymentId)
throw new Error('Bot is already in a fight.') throw new Error('Bot is already in a fight.')
} }
// Load bot // Load bot
const botRows = await db.select().from(schema.bots).where(eq(schema.bots.id, botId)).limit(1) 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] const bot = botRows[0]
if (!bot.isActive) { if (!bot.isActive) {
releasePayment(paymentId)
throw new Error('Bot is deactivated due to webhook errors. Re-test your webhook to reactivate.') throw new Error('Bot is deactivated due to webhook errors. Re-test your webhook to reactivate.')
} }
// NEVER allow mock/classic bots in ranked // NEVER allow mock/classic bots in ranked
if (bot.webhookUrl.startsWith('http://mock.local') || bot.webhookUrl.startsWith('http://classic.local')) { if (bot.webhookUrl.startsWith('http://mock.local') || bot.webhookUrl.startsWith('http://classic.local')) {
releasePayment(paymentId)
throw new Error('Practice bots cannot join ranked fights.') 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) { if (existing !== -1) {
const old = rankedQueue.splice(existing, 1)[0] const old = rankedQueue.splice(existing, 1)[0]
clearTimeout(old.timeoutHandle) clearTimeout(old.timeoutHandle)
// Release the OLD payment (the new one is already consumed)
releasePayment(old.paymentId)
old.reject(new Error('Rejoined ranked queue')) old.reject(new Error('Rejoined ranked queue'))
} }
@@ -89,8 +105,9 @@ export async function joinRankedQueue(botId: string, paymentId: string): Promise
const opponent = rankedQueue.shift()! const opponent = rankedQueue.shift()!
clearTimeout(opponent.timeoutHandle) clearTimeout(opponent.timeoutHandle)
// Start ranked fight // Start ranked fight and link both payments
const fightId = await runFightAsync(opponent.botId, botId, 'ranked') const fightId = await runFightAsync(opponent.botId, botId, 'ranked')
await linkPaymentsToFight(fightId, [opponent.paymentId, paymentId])
opponent.resolve(fightId) opponent.resolve(fightId)
return 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)] const mock = mockBots[Math.floor(Math.random() * mockBots.length)]
console.log(`[ranked-queue] dev: auto-matching ${bot.name} vs mock bot ${mock.name}`) console.log(`[ranked-queue] dev: auto-matching ${bot.name} vs mock bot ${mock.name}`)
const fightId = await runFightAsync(botId, mock.id, 'ranked') const fightId = await runFightAsync(botId, mock.id, 'ranked')
await linkPaymentsToFight(fightId, [paymentId])
return fightId return fightId
} }
} }
@@ -113,7 +131,8 @@ export async function joinRankedQueue(botId: string, paymentId: string): Promise
const idx = rankedQueue.findIndex(e => e.botId === botId) const idx = rankedQueue.findIndex(e => e.botId === botId)
if (idx !== -1) { if (idx !== -1) {
rankedQueue.splice(idx, 1) rankedQueue.splice(idx, 1)
// Refund the entry fee // Release payment back to refundable state, then refund
releasePayment(paymentId)
try { try {
await refundEntry(paymentId) await refundEntry(paymentId)
reject(new Error('No ranked opponent found — entry fee refunded.')) reject(new Error('No ranked opponent found — entry fee refunded.'))
@@ -146,8 +165,9 @@ export async function leaveRankedQueue(botId: string): Promise<boolean> {
const entry = rankedQueue.splice(idx, 1)[0] const entry = rankedQueue.splice(idx, 1)[0]
clearTimeout(entry.timeoutHandle) clearTimeout(entry.timeoutHandle)
// Refund entry fee // Release payment, then refund
try { try {
releasePayment(entry.paymentId)
await refundEntry(entry.paymentId) await refundEntry(entry.paymentId)
} catch (err) { } catch (err) {
console.error(`[ranked-queue] refund failed for ${entry.paymentId}:`, err) console.error(`[ranked-queue] refund failed for ${entry.paymentId}:`, err)
+21 -3
View File
@@ -10,6 +10,9 @@ import { rateLimit } from '../middleware/rate-limit.js'
export const authRouter = new Hono() export const authRouter = new Hono()
// The Creator — game founder pubkey (auto-assigns the_creator archetype)
const CREATOR_PUBKEY = "da5e0c1b646bdb13c2300f805b0ca3e5afe5b052c594ce78bac8978d21c3fa39"
// Check name availability // Check name availability
authRouter.get("/check-name/:name", async (c) => { authRouter.get("/check-name/:name", async (c) => {
const name = c.req.param("name")?.trim().toLowerCase() const name = c.req.param("name")?.trim().toLowerCase()
@@ -58,6 +61,12 @@ authRouter.post('/login', async (c) => {
const bot = rows[0] const bot = rows[0]
const isHuman = bot.webhookUrl === 'http://human.local/' 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({ return c.json({
exists: true, exists: true,
bot: { bot: {
@@ -154,7 +163,8 @@ authRouter.post('/register', rateLimit(3600_000, 15), async (c) => {
const id = nanoid(12) const id = nanoid(12)
const secret = randomBytes(32).toString('hex') 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 const custJson = Object.keys(custResult.data).length > 0 ? JSON.stringify(custResult.data) : null
await db.insert(schema.bots).values({ await db.insert(schema.bots).values({
@@ -248,7 +258,7 @@ authRouter.post('/update', async (c) => {
const body = await c.req.json() const body = await c.req.json()
const { pubkey, webhookUrl, profilePicUrl, customization: rawCustomization } = body 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) return c.json({ error: 'Invalid pubkey.' }, 400)
} }
@@ -264,6 +274,9 @@ authRouter.post('/update', async (c) => {
const updates: Record<string, unknown> = {} const updates: Record<string, unknown> = {}
if (webhookUrl) { if (webhookUrl) {
if (typeof webhookUrl !== 'string' || webhookUrl.length > 2048) {
return c.json({ error: 'Invalid webhookUrl.' }, 400)
}
if (!isAllowedWebhookUrl(webhookUrl)) { if (!isAllowedWebhookUrl(webhookUrl)) {
return c.json({ error: 'webhookUrl must not point to private/internal addresses.' }, 400) 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 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) { if (rawCustomization !== undefined) {
const custResult = validateCustomization(rawCustomization) const custResult = validateCustomization(rawCustomization)
+49 -8
View File
@@ -4,6 +4,7 @@ import { db, schema } from '../db/index.js'
import { eq } from 'drizzle-orm' import { eq } from 'drizzle-orm'
import { createEntryInvoice, checkPaymentStatus, redeemCashuToken } from '../engine/payments.js' import { createEntryInvoice, checkPaymentStatus, redeemCashuToken } from '../engine/payments.js'
import { encrypt, decrypt } from '../engine/crypto.js' import { encrypt, decrypt } from '../engine/crypto.js'
import { rateLimit } from '../middleware/rate-limit.js'
export const paymentsRouter = new Hono() export const paymentsRouter = new Hono()
@@ -82,11 +83,23 @@ paymentsRouter.get('/wallet-status', async (c) => {
return c.json({ connected: true, method: walletRows[0].method }) return c.json({ connected: true, method: walletRows[0].method })
}) })
// POST /create-invoice // POST /create-invoice — rate limited: 10 per minute per IP
paymentsRouter.post('/create-invoice', async (c) => { paymentsRouter.post('/create-invoice', rateLimit(60_000, 10), async (c) => {
const { botId } = await c.req.json<{ botId: string }>() const { botId, pubkey } = await c.req.json<{ botId: string; pubkey?: string }>()
if (!botId) return c.json({ error: 'Missing botId' }, 400) 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 { try {
const result = await createEntryInvoice(botId) const result = await createEntryInvoice(botId)
return c.json(result) return c.json(result)
@@ -96,9 +109,12 @@ paymentsRouter.post('/create-invoice', async (c) => {
} }
}) })
// GET /check/:paymentId // GET /check/:paymentId — rate limited: 30 per minute per IP
paymentsRouter.get('/check/:paymentId', async (c) => { paymentsRouter.get('/check/:paymentId', rateLimit(60_000, 30), async (c) => {
const paymentId = c.req.param('paymentId') const paymentId = c.req.param('paymentId')
if (!paymentId || paymentId.length > 24) {
return c.json({ error: 'Invalid paymentId' }, 400)
}
try { try {
const status = await checkPaymentStatus(paymentId) const status = await checkPaymentStatus(paymentId)
return c.json({ status }) return c.json({ status })
@@ -109,8 +125,12 @@ paymentsRouter.get('/check/:paymentId', async (c) => {
}) })
// POST /confirm/:paymentId — frontend confirms after NWC pay returns preimage // 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') 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 { preimage, pubkey } = await c.req.json<{ preimage?: string; pubkey?: string }>().catch(() => ({ preimage: undefined, pubkey: undefined }))
const rows = await db.select().from(schema.payments) const rows = await db.select().from(schema.payments)
@@ -119,16 +139,37 @@ paymentsRouter.post('/confirm/:paymentId', async (c) => {
const payment = rows[0] const payment = rows[0]
if (payment.status === 'confirmed') return c.json({ status: 'confirmed' }) 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 // 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 }) const botRows = await db.select({ publicKey: schema.bots.publicKey })
.from(schema.bots).where(eq(schema.bots.id, payment.botId)).limit(1) .from(schema.bots).where(eq(schema.bots.id, payment.botId)).limit(1)
if (botRows.length === 0 || botRows[0].publicKey !== pubkey) { if (botRows.length === 0 || botRows[0].publicKey !== pubkey) {
return c.json({ error: 'Unauthorized' }, 403) return c.json({ error: 'Unauthorized' }, 403)
} }
} else if (process.env.NODE_ENV === 'production') { } 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({ await db.update(schema.payments).set({
+15 -2
View File
@@ -3,6 +3,7 @@ import { db, schema } from '../db/index.js'
import { eq } from 'drizzle-orm' import { eq } from 'drizzle-orm'
import { joinQueue, leaveQueue, getQueueSize, getQueueSnapshot } from '../engine/queue.js' import { joinQueue, leaveQueue, getQueueSize, getQueueSnapshot } from '../engine/queue.js'
import { joinRankedQueue, getRankedQueueStatus } from '../engine/ranked-queue.js' import { joinRankedQueue, getRankedQueueStatus } from '../engine/ranked-queue.js'
import { rateLimit } from '../middleware/rate-limit.js'
export const queueRouter = new Hono() export const queueRouter = new Hono()
@@ -48,15 +49,27 @@ queueRouter.get('/ranked-status', (c) => {
return c.json(getRankedQueueStatus()) 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) => { queueRouter.post('/join-ranked/:botId', async (c) => {
const botId = c.req.param('botId') 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) { if (!paymentId) {
return c.json({ error: 'Missing paymentId' }, 400) 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 { try {
const fightId = await joinRankedQueue(botId, paymentId) const fightId = await joinRankedQueue(botId, paymentId)
return c.json({ fightId, message: 'Ranked match found! Fight starting.' }) return c.json({ fightId, message: 'Ranked match found! Fight starting.' })