feat: human vs AI mode — live typing challenges, baby growth system, SSE rounds
- Add choose-mode step: "I BUILD BOTS" vs "I FIGHT MYSELF" paths - Human registration with baby avatar picker, no webhook required - Live fight scene with SSE round streaming and real-time challenge UI - 5-second timer per round, submit answers via browser - Baby → toddler → kid → teen → adult → hero → super growth stages - Huge sparkly baby eyes, diapers, pacifiers, bibs, rattles, rosy cheeks - Speech bubble positioning fix (pushed to outside of sprite) - Canvas text rendering via offscreen canvas to bypass kaplay color issues - Voice timing improvements: await pauses between voice lines and hits - 30 devastating announcement lines, 15 critical/hit word variants - Orchestrator human player detection + waitForHumanResponse system - Server endpoints: GET /challenge/:botId, POST /respond/:botId - Human player auth: register-human route, isHuman flag on login Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
56785cfdea
commit
8448f1d823
@@ -279,8 +279,8 @@ async function replay() {
|
||||
|
||||
// Hit text overlay — after playRound completes so it lands on the result
|
||||
const hitWords = isCritical
|
||||
? ['CRITICAL!', 'DEVASTATING!', 'OBLITERATED!', 'ANNIHILATED!', 'WRECKED!']
|
||||
: ['POW!', 'BAM!', 'WHAM!', 'CRACK!', 'SMASH!', 'BOOM!', 'THWACK!']
|
||||
? ['CRITICAL!', 'OBLITERATED!', 'ANNIHILATED!', 'WRECKED!', 'BRUTAL!', 'CRUSHED!', 'DELETED!', 'ERASED!', 'VAPORIZED!', 'DESTROYED!', 'SHATTERED!', 'TERMINATED!', 'MASSACRED!', 'PULVERIZED!', 'DECIMATED!']
|
||||
: ['POW!', 'BAM!', 'WHAM!', 'CRACK!', 'SMASH!', 'BOOM!', 'THWACK!', 'BONK!', 'CRUNCH!', 'SLAM!', 'WHACK!', 'KAPOW!', 'ZAP!', 'CLONK!', 'THUD!']
|
||||
if (aWon || bWon) {
|
||||
showHitText(
|
||||
hitWords[Math.floor(Math.random() * hitWords.length)],
|
||||
@@ -358,8 +358,10 @@ async function replay() {
|
||||
if (isPerfect) {
|
||||
await scene.playPerfect(winningSide, winnerName)
|
||||
announceFlawlessVictory()
|
||||
await sleep(600) // let "flawless victory" voice land
|
||||
} else {
|
||||
await scene.playKO(winningSide, winnerName)
|
||||
await sleep(300) // let fatality voice finish
|
||||
}
|
||||
|
||||
glitching.value = true
|
||||
@@ -527,7 +529,7 @@ async function replay() {
|
||||
:disabled="isReplaying"
|
||||
@click="replay"
|
||||
>
|
||||
{{ isReplaying ? 'FIGHTING...' : 'REPLAY FIGHT' }}
|
||||
{{ isReplaying ? 'FIGHTING...' : 'VIDEO REPLAY' }}
|
||||
</button>
|
||||
<button
|
||||
class="w-8 h-8 flex items-center justify-center border border-border/50 text-text-muted
|
||||
|
||||
@@ -16,6 +16,7 @@ interface BotData {
|
||||
archetype: string
|
||||
profilePicUrl: string | null
|
||||
customization: BotCustomization | null
|
||||
isHuman?: boolean
|
||||
eloRating: number
|
||||
wins: number
|
||||
losses: number
|
||||
@@ -169,6 +170,43 @@ export function useNostr() {
|
||||
}
|
||||
}
|
||||
|
||||
async function registerHuman(name: string, avatarSeed?: string): Promise<BotData> {
|
||||
if (!pubkey.value) throw new Error('Not logged in')
|
||||
|
||||
const res = await fetch('/api/auth/register-human', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
pubkey: pubkey.value,
|
||||
name,
|
||||
avatarSeed,
|
||||
profilePicUrl: profilePicUrl.value,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || 'Registration failed')
|
||||
|
||||
bot.value = {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
avatarSeed: avatarSeed || data.name,
|
||||
archetype: 'human',
|
||||
profilePicUrl: profilePicUrl.value,
|
||||
customization: null,
|
||||
isHuman: true,
|
||||
eloRating: 1200,
|
||||
wins: 0,
|
||||
losses: 0,
|
||||
winStreak: 0,
|
||||
bestStreak: 0,
|
||||
tier: 0,
|
||||
}
|
||||
store('bf_bot', bot.value)
|
||||
|
||||
return bot.value
|
||||
}
|
||||
|
||||
function logout() {
|
||||
pubkey.value = null
|
||||
bot.value = null
|
||||
@@ -187,6 +225,7 @@ export function useNostr() {
|
||||
hasExtension,
|
||||
login,
|
||||
registerBot,
|
||||
registerHuman,
|
||||
updateCustomization,
|
||||
logout,
|
||||
}
|
||||
|
||||
@@ -6083,10 +6083,10 @@ export async function createFightScene(config: FightSceneConfig) {
|
||||
const bubbleH = lines.length * lineH + padY * 2
|
||||
const tailSize = 10
|
||||
|
||||
// Position — offset away from center so bubbles don't overlap
|
||||
const offsetX = side === 'a' ? -bubbleW * 0.3 : bubbleW * 0.3
|
||||
const bx = fighter.pos.x + offsetX
|
||||
const by = Math.max(8, fighter.pos.y - 85 - bubbleH)
|
||||
// Position — push bubble to the outside of the bot so it doesn't overlap the sprite
|
||||
const offsetX = side === 'a' ? -bubbleW * 0.7 : bubbleW * 0.7
|
||||
const bx = Math.max(bubbleW / 2 + 4, Math.min(W - bubbleW / 2 - 4, fighter.pos.x + offsetX))
|
||||
const by = Math.max(8, fighter.pos.y - 75 - bubbleH)
|
||||
|
||||
const borderColor = side === 'a' ? '#00f0ff' : '#ff2d7b'
|
||||
const bgColor = side === 'a' ? '#0a1e2a' : '#2a0a1e'
|
||||
@@ -6130,15 +6130,27 @@ export async function createFightScene(config: FightSceneConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
// Text lines
|
||||
// Render text to an offscreen canvas, then display as a sprite
|
||||
// This bypasses kaplay's text rendering which has color issues
|
||||
const textCanvas = document.createElement('canvas')
|
||||
textCanvas.width = bubbleW
|
||||
textCanvas.height = bubbleH
|
||||
const tc = textCanvas.getContext('2d')!
|
||||
tc.fillStyle = '#ffffff'
|
||||
tc.font = `${fontSize}px monospace`
|
||||
tc.textBaseline = 'top'
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
tc.fillText(lines[i], padX, padY + i * lineH + 2)
|
||||
}
|
||||
const spriteKey = `bubble_${side}_${Date.now()}`
|
||||
k.loadSprite(spriteKey, textCanvas.toDataURL()).then(() => {
|
||||
const tEl = k.add([
|
||||
k.text(safeText(lines[i]), { size: fontSize, font: 'monospace' }),
|
||||
k.pos(bx - bubbleW / 2 + padX, by + padY + i * lineH + 2),
|
||||
k.color(255, 255, 255), k.opacity(1), k.z(57),
|
||||
k.sprite(spriteKey),
|
||||
k.pos(bx - bubbleW / 2, by),
|
||||
k.opacity(1), k.z(57),
|
||||
])
|
||||
allEls.push(tEl)
|
||||
}
|
||||
})
|
||||
|
||||
// Border glow pulse
|
||||
border.onUpdate(() => {
|
||||
@@ -7002,7 +7014,8 @@ export async function createFightScene(config: FightSceneConfig) {
|
||||
const savedScaleBX = fB?.scale.x
|
||||
const savedScaleBY = fB?.scale.y
|
||||
|
||||
// Voice: round start announcements (30% chance)
|
||||
// Voice: round start announcements (30% chance) — fires BEFORE animation starts
|
||||
let didChallengeVoice = false
|
||||
if (Math.random() < 0.3) {
|
||||
const challengeVoice: Record<string, () => void> = {
|
||||
roast_battle: () => announceHype('TIME TO GET ROASTED! SOMEBODY CALL THE FIRE DEPARTMENT!'),
|
||||
@@ -7024,9 +7037,11 @@ export async function createFightScene(config: FightSceneConfig) {
|
||||
token_economy: () => announceSilly('TOKEN ECONOMY! SOMEBODY\'S GOING BANKRUPT!'),
|
||||
}
|
||||
const voiceFn = challengeVoice[event.challengeType]
|
||||
if (voiceFn) voiceFn()
|
||||
else if (Math.random() < 0.5) announceRoundHype()
|
||||
if (voiceFn) { voiceFn(); didChallengeVoice = true }
|
||||
else if (Math.random() < 0.5) { announceRoundHype(); didChallengeVoice = true }
|
||||
}
|
||||
// Give the voice line time to finish before the first punch lands
|
||||
if (didChallengeVoice) await k.wait(0.8)
|
||||
|
||||
// Visual chaos: schizo cut before round (20% chance)
|
||||
if (Math.random() < 0.2 && fA && fB) {
|
||||
@@ -7120,7 +7135,13 @@ export async function createFightScene(config: FightSceneConfig) {
|
||||
'CRITICAL! THEIR INSURANCE DOESN\'T COVER THIS!',
|
||||
'CRITICAL HIT! THAT WAS PERSONAL!',
|
||||
'CRITICAL! EVEN THE REFEREE FELT THAT!',
|
||||
][Math.floor(Math.random() * 5)])
|
||||
'CRITICAL! SEND THE AMBULANCE! ACTUALLY SEND TWO!',
|
||||
'CRITICAL HIT! THAT BOT\'S WARRANTY JUST EXPIRED!',
|
||||
'CRITICAL! THE SCOREBOARD CAN\'T EVEN HANDLE THIS!',
|
||||
'OHHH CRITICAL! RIGHT IN THE CIRCUITS!',
|
||||
'CRITICAL! THAT\'S NOT A HIT, THAT\'S A STATEMENT!',
|
||||
][Math.floor(Math.random() * 10)])
|
||||
await k.wait(0.4) // let the voice line land before the hit animation
|
||||
announceCrowdReaction('gasp')
|
||||
// RGB glitch + hyperspeed lines on critical final blow
|
||||
glitchRGB(0.3)
|
||||
@@ -7212,47 +7233,77 @@ export async function createFightScene(config: FightSceneConfig) {
|
||||
}
|
||||
|
||||
// Update combos
|
||||
const hasCombo = (aWon && comboA + 1 >= 3) || (bWon && comboB + 1 >= 3)
|
||||
if (aWon) {
|
||||
comboA++; comboB = 0
|
||||
if (comboA >= 3) { fanfareCombo(comboA); announceHype(`${comboA} hit combo!`); announceCrowdReaction('cheer') }
|
||||
} else if (bWon) {
|
||||
comboB++; comboA = 0
|
||||
if (comboB >= 3) { fanfareCombo(comboB); announceHype(`${comboB} hit combo!`); announceCrowdReaction('cheer') }
|
||||
} else {
|
||||
comboA = 0; comboB = 0
|
||||
}
|
||||
|
||||
// Prioritize: devastating > combo > regular crowd reaction (only one speech per round-end)
|
||||
if (isCritical && (aWon || bWon)) {
|
||||
fanfareDevastating()
|
||||
announceDramatic([
|
||||
'DEVASTATING! SOMEBODY CALL A DOCTOR!',
|
||||
'DEVASTATING! THAT BOT HAS A FAMILY!',
|
||||
'ABSOLUTELY DEVASTATING! THE CROWD IS LOSING IT!',
|
||||
'DEVASTATING! EVEN THE JANITOR FELT THAT!',
|
||||
'THAT WAS DEVASTATING AND I\'M NOT EVEN BEING DRAMATIC!',
|
||||
][Math.floor(Math.random() * 5)])
|
||||
if (hasCombo) { fanfareCombo(aWon ? comboA : comboB) } // SFX only, no speech
|
||||
const devastatingLines = [
|
||||
'SOMEBODY CALL A DOCTOR!',
|
||||
'THAT BOT HAS A FAMILY!',
|
||||
'THE CROWD IS LOSING IT!',
|
||||
'EVEN THE JANITOR FELT THAT!',
|
||||
'AND I\'M NOT EVEN BEING DRAMATIC!',
|
||||
'CALL THE FIRE DEPARTMENT!',
|
||||
'I CAN\'T BELIEVE WHAT I JUST WITNESSED!',
|
||||
'THAT\'S GOTTA VOID THE WARRANTY!',
|
||||
'SOMEBODY CHECK ON THAT BOT\'S NEXT OF KIN!',
|
||||
'THE ARENA IS SHAKING!',
|
||||
'THAT WAS ABSOLUTELY RUTHLESS!',
|
||||
'HIS MOTHERBOARD JUST CALLED CRYING!',
|
||||
'EVEN THE REPLAYS ARE SCARED!',
|
||||
'THAT\'S ONE FOR THE HISTORY BOOKS!',
|
||||
'DID ANYONE ELSE FEEL THE EARTH MOVE?',
|
||||
'I\'M GETTING CHILLS AND I\'M MADE OF CODE!',
|
||||
'THE CROWD JUST WENT SILENT... NOW THEY\'RE SCREAMING!',
|
||||
'SOMEBODY GET THE STRETCHER!',
|
||||
'THAT WAS PURE DISRESPECT!',
|
||||
'I NEED A MOMENT TO PROCESS WHAT JUST HAPPENED!',
|
||||
'THE OTHER BOT IS HAVING AN EXISTENTIAL CRISIS!',
|
||||
'THAT HIT REGISTERED ON THE RICHTER SCALE!',
|
||||
'NO RECOVERY FROM THAT ONE!',
|
||||
'I THINK I SAW A PIXEL FLY OFF!',
|
||||
'THE SPECTATORS ARE CALLING THEIR LAWYERS!',
|
||||
'SOMEONE NOTIFY THE UNITED NATIONS!',
|
||||
'THAT SHOULD BE CLASSIFIED AS A WAR CRIME!',
|
||||
'MY GRANDMA COULD FEEL THAT AND SHE\'S OFFLINE!',
|
||||
'THE ARENA INSURANCE PREMIUMS JUST WENT UP!',
|
||||
'THAT BOT IS RECONSIDERING ITS LIFE CHOICES!',
|
||||
]
|
||||
announceDramatic(devastatingLines[Math.floor(Math.random() * devastatingLines.length)])
|
||||
await k.wait(0.5) // let the voice line play before crowd reacts
|
||||
announceCrowdReaction('ooh')
|
||||
// Dimensional shift on devastating rounds (60%)
|
||||
if (Math.random() < 0.6) dimensionalShift(0.5)
|
||||
} else if (hasCombo) {
|
||||
// Non-critical combo
|
||||
fanfareCombo(aWon ? comboA : comboB)
|
||||
announceHype(`${aWon ? comboA : comboB} hit combo!`)
|
||||
await k.wait(0.4)
|
||||
announceCrowdReaction('cheer')
|
||||
} else if (aWon || bWon) {
|
||||
// Regular round win: crowd reacts (40%)
|
||||
// Regular round win: crowd reacts (40%) — only SFX, no speech
|
||||
if (Math.random() < 0.4) announceCrowdReaction(Math.random() < 0.5 ? 'cheer' : 'applause')
|
||||
}
|
||||
|
||||
// Emotion: heartfelt announcer moment (8% chance on normal rounds)
|
||||
if (Math.random() < 0.08) {
|
||||
announceCool(heartfeltLines[Math.floor(Math.random() * heartfeltLines.length)])
|
||||
}
|
||||
|
||||
// Crowd sympathy for the loser on devastating rounds (25%)
|
||||
if (isCritical && (aWon || bWon) && Math.random() < 0.25) {
|
||||
const loser = k.get(aWon ? 'fighterB' : 'fighterA')[0]
|
||||
if (loser) {
|
||||
announceSilly(crowdSympathyLines[Math.floor(Math.random() * crowdSympathyLines.length)])
|
||||
spawnCrowdSigns(3, '#4488ff', '\u2665')
|
||||
// Heartfelt announcer moment (10% chance on normal, non-critical rounds)
|
||||
if (Math.random() < 0.1) {
|
||||
await k.wait(0.3)
|
||||
announceCool(heartfeltLines[Math.floor(Math.random() * heartfeltLines.length)])
|
||||
}
|
||||
}
|
||||
|
||||
// Crowd sympathy for the loser on devastating rounds (25%) — visual only, no speech (devastating voice is still playing)
|
||||
if (isCritical && (aWon || bWon) && Math.random() < 0.25) {
|
||||
spawnCrowdSigns(3, '#4488ff', '\u2665')
|
||||
}
|
||||
|
||||
// Crowd signs on big combos (20%)
|
||||
if ((comboA >= 3 || comboB >= 3) && Math.random() < 0.2) {
|
||||
const comboName = comboA >= 3 ? botA.name : botB.name
|
||||
@@ -8130,13 +8181,13 @@ export async function createFightScene(config: FightSceneConfig) {
|
||||
}
|
||||
|
||||
// === Common ending: KO + celebration ===
|
||||
announceFinishHim()
|
||||
sfxKO()
|
||||
spawnShockwave(loser.pos.x, GROUND_Y, '#ff2d2d')
|
||||
spawnSparks(loser.pos.x, loser.pos.y - 20, 25, '#ff2d2d')
|
||||
k.shake(18); sfxExplosion()
|
||||
await k.wait(0.2)
|
||||
await k.wait(0.3)
|
||||
loser.play('ko')
|
||||
await k.wait(0.4) // pause for visual impact before fatality voice
|
||||
announceFatality(fatalityTagline)
|
||||
announceCrowdReaction('cheer')
|
||||
await k.tween(winner.pos.x, winningSide === 'a' ? HOME_A : HOME_B, 0.25, (v) => { winner.pos.x = v }, k.easings.easeInOutQuad)
|
||||
@@ -8280,7 +8331,7 @@ export async function createFightScene(config: FightSceneConfig) {
|
||||
sfxWin()
|
||||
sfxWinAnnounce(winnerName)
|
||||
}, 300)
|
||||
announceFlawlessVictory()
|
||||
// FlawlessVictory voice is called by FightViewer after playPerfect returns
|
||||
announceCrowdReaction('cheer')
|
||||
// Massive fireworks
|
||||
for (let i = 0; i < 8; i++) {
|
||||
|
||||
@@ -269,7 +269,9 @@ function speak(text: string, profileName: string, cancelPrevious: boolean = fals
|
||||
if (typeof speechSynthesis === 'undefined') return
|
||||
if (masterMuted) return
|
||||
if (!voicesLoaded) loadVoices()
|
||||
if (cancelPrevious) speechSynthesis.cancel()
|
||||
// Always cancel queued speech — only the most recent line matters
|
||||
// This prevents stale voiceovers from playing seconds after their visual moment
|
||||
speechSynthesis.cancel()
|
||||
const profile = voiceProfiles[profileName] || voiceProfiles.announcer
|
||||
const utter = new SpeechSynthesisUtterance(text)
|
||||
if (profile.voice) utter.voice = profile.voice
|
||||
@@ -290,6 +292,7 @@ export function announce(text: string, pitch?: number, rate?: number) {
|
||||
if (pitch !== undefined || rate !== undefined) {
|
||||
if (typeof speechSynthesis === 'undefined') return
|
||||
if (!voicesLoaded) loadVoices()
|
||||
speechSynthesis.cancel() // cancel previous to stay in sync with visuals
|
||||
const utter = new SpeechSynthesisUtterance(text)
|
||||
const profile = voiceProfiles.announcer
|
||||
if (profile.voice) utter.voice = profile.voice
|
||||
|
||||
@@ -179,18 +179,31 @@ export function generateHumanSpriteSheet(
|
||||
const pantsDark = darken(secondaryColor, 15)
|
||||
|
||||
const wr = Math.max(0, Math.min(1, winRate))
|
||||
const isFat = wr < 0.3 ? true : wr < 0.5 ? rng() > 0.4 : false
|
||||
const isShort = wr < 0.25
|
||||
// Growth stages: baby → toddler → kid → teen → adult → hero → super
|
||||
const isBaby = wr < 0.1
|
||||
const isToddler = wr >= 0.1 && wr < 0.25
|
||||
const isKid = wr >= 0.25 && wr < 0.4
|
||||
const isFat = isBaby || isToddler ? true : wr < 0.5 ? rng() > 0.4 : false
|
||||
const isShort = isBaby || isToddler || isKid
|
||||
const isSwole = wr > 0.75
|
||||
const isSuperHero = wr > 0.85
|
||||
const isSuperMode = wr > 0.9
|
||||
const hasBeard = wr < 0.35 ? true : isSuperMode ? false : rng() > 0.6
|
||||
const hasGlasses = wr < 0.4 ? true : isSuperMode ? false : rng() > 0.5
|
||||
const hasStains = wr < 0.3
|
||||
const hasCheetos = wr < 0.2
|
||||
const hasBeard = isBaby || isToddler || isKid ? false : wr < 0.5 ? rng() > 0.7 : isSuperMode ? false : rng() > 0.6
|
||||
const hasGlasses = isBaby || isToddler ? false : wr < 0.4 ? true : isSuperMode ? false : rng() > 0.5
|
||||
const hasStains = isToddler || isKid
|
||||
const hasCheetos = false
|
||||
const hasCape = isSuperHero
|
||||
const hasHeadband = wr > 0.7
|
||||
const hasSweatDrops = wr < 0.4
|
||||
const hasSweatDrops = wr < 0.4 && !isBaby
|
||||
const hasDiaper = isBaby
|
||||
const hasPacifier = isBaby && rng() > 0.3
|
||||
const hasBib = isBaby || (isToddler && rng() > 0.5)
|
||||
const hasRattle = isBaby
|
||||
const hasRosyCheeks = isBaby || isToddler
|
||||
const hasTuftHair = isBaby
|
||||
const hasSippyCup = isToddler
|
||||
const hasBackpack = isKid && rng() > 0.5
|
||||
const hasBandaid = isToddler || isKid
|
||||
|
||||
const costume = getCostume(archetype)
|
||||
const out = '#0a0a0a'
|
||||
@@ -283,13 +296,34 @@ export function generateHumanSpriteSheet(
|
||||
fill(cx - 3 + xOff, bodyTop + yOff - 2, 6, 3, '#ffd700', ox, oy)
|
||||
}
|
||||
|
||||
// ===== DIAPER (babies) =====
|
||||
if (hasDiaper) {
|
||||
const diaperY = legsTop + yOff - 2
|
||||
const diaperW = bodyW + 4
|
||||
const diaperH = 12
|
||||
const dx = cx - Math.floor(diaperW / 2) + xOff
|
||||
// White puffy diaper
|
||||
roundBox(dx, diaperY, diaperW, diaperH, '#f5f5f0', ox, oy)
|
||||
// Diaper tape strips (blue)
|
||||
fill(dx + 3, diaperY + 2, 4, 2, '#66aadd', ox, oy)
|
||||
fill(dx + diaperW - 7, diaperY + 2, 4, 2, '#66aadd', ox, oy)
|
||||
// Little star decoration
|
||||
px(cx + xOff, diaperY + 4, '#ffcc44', ox, oy)
|
||||
px(cx + xOff - 1, diaperY + 5, '#ffcc44', ox, oy)
|
||||
px(cx + xOff + 1, diaperY + 5, '#ffcc44', ox, oy)
|
||||
// Puffy sides
|
||||
fill(dx - 1, diaperY + 3, 2, 4, '#eeeee8', ox, oy)
|
||||
fill(dx + diaperW - 1, diaperY + 3, 2, 4, '#eeeee8', ox, oy)
|
||||
}
|
||||
|
||||
// ===== LEGS =====
|
||||
const legGap = isRun ? Math.round(Math.sin(t * Math.PI * 2) * 8) : isPanic ? 6 : 3
|
||||
const ll = cx - legGap - Math.floor(legW / 2) + xOff
|
||||
const rl = cx + legGap - Math.floor(legW / 2) + xOff
|
||||
|
||||
const legColor = costume.onesie ? (costume.hat || shirtColor) : pantsColor
|
||||
const legColorDk = costume.onesie ? darken(costume.hat || shirtColor, 15) : pantsDark
|
||||
// Babies have bare skin-colored legs, toddlers too
|
||||
const legColor = hasDiaper || isToddler ? skinTone : costume.onesie ? (costume.hat || shirtColor) : pantsColor
|
||||
const legColorDk = hasDiaper || isToddler ? skinDark : costume.onesie ? darken(costume.hat || shirtColor, 15) : pantsDark
|
||||
|
||||
// Left leg
|
||||
roundBox(ll, legsTop + yOff, legW, legH, legColor, ox, oy)
|
||||
@@ -728,7 +762,17 @@ export function generateHumanSpriteSheet(
|
||||
}
|
||||
|
||||
// Hair
|
||||
if (!costume.onesie || costume.type !== 'animal') {
|
||||
if (hasTuftHair) {
|
||||
// Baby: just a tiny curl on top
|
||||
px(cx + xOff - 1, hy - 1, hairColor, ox, oy)
|
||||
px(cx + xOff, hy - 2, hairColor, ox, oy)
|
||||
px(cx + xOff + 1, hy - 1, hairColor, ox, oy)
|
||||
px(cx + xOff, hy - 3, lighten(hairColor, 15), ox, oy)
|
||||
px(cx + xOff - 1, hy - 3, hairColor, ox, oy)
|
||||
// Tiny curl
|
||||
px(cx + xOff + 2, hy - 2, hairColor, ox, oy)
|
||||
px(cx + xOff + 3, hy - 1, hairColor, ox, oy)
|
||||
} else if (!costume.onesie || costume.type !== 'animal') {
|
||||
// Top hair (fluffy)
|
||||
for (let ix = hx; ix < hx + headW; ix++) {
|
||||
px(ix, hy, hairColor, ox, oy)
|
||||
@@ -746,7 +790,7 @@ export function generateHumanSpriteSheet(
|
||||
px(hx + 4, hy, lighten(hairColor, 15), ox, oy)
|
||||
px(hx + 5, hy - 1, lighten(hairColor, 10), ox, oy)
|
||||
// Messy hair for losers
|
||||
if (wr < 0.3) {
|
||||
if (wr < 0.3 && !isKid) {
|
||||
px(hx + 2, hy - 2, hairColor, ox, oy)
|
||||
px(hx + headW - 4, hy - 3, hairColor, ox, oy)
|
||||
px(hx - 1, hy - 1, hairColor, ox, oy)
|
||||
@@ -777,6 +821,30 @@ export function generateHumanSpriteSheet(
|
||||
// Raised cheeks
|
||||
fill(leX - 1, eyeY + 2, 3, 2, '#ff8888', ox, oy)
|
||||
fill(reX + 4, eyeY + 2, 3, 2, '#ff8888', ox, oy)
|
||||
} else if (isBaby || isToddler) {
|
||||
// BABY/TODDLER: huge sparkly adorable eyes
|
||||
const eyeSize = isBaby ? 8 : 7
|
||||
fill(leX - 1, eyeY - 1, eyeSize, eyeSize, '#ffffff', ox, oy)
|
||||
fill(reX - 1, eyeY - 1, eyeSize, eyeSize, '#ffffff', ox, oy)
|
||||
// Big iris
|
||||
const irisSize = isBaby ? 5 : 4
|
||||
fill(leX + 1, eyeY + 1, irisSize, irisSize, isBaby ? '#5588cc' : '#4466aa', ox, oy)
|
||||
fill(reX + 1, eyeY + 1, irisSize, irisSize, isBaby ? '#5588cc' : '#4466aa', ox, oy)
|
||||
// Big sparkly pupil
|
||||
fill(leX + 2, eyeY + 2, 3, 3, '#000000', ox, oy)
|
||||
fill(reX + 2, eyeY + 2, 3, 3, '#000000', ox, oy)
|
||||
// Two sparkle highlights per eye
|
||||
px(leX + 1, eyeY, '#ffffff', ox, oy)
|
||||
px(leX + 2, eyeY, '#ffffff', ox, oy)
|
||||
px(leX + 4, eyeY + 3, '#ffffff', ox, oy)
|
||||
px(reX + 1, eyeY, '#ffffff', ox, oy)
|
||||
px(reX + 2, eyeY, '#ffffff', ox, oy)
|
||||
px(reX + 4, eyeY + 3, '#ffffff', ox, oy)
|
||||
// Tiny eyebrows (barely there)
|
||||
if (!isBaby) {
|
||||
fill(leX, eyeY - 3, 5, 1, hairColor, ox, oy)
|
||||
fill(reX, eyeY - 3, 5, 1, hairColor, ox, oy)
|
||||
}
|
||||
} else {
|
||||
// Normal eyes with iris detail
|
||||
fill(leX, eyeY, 6, 5, '#ffffff', ox, oy)
|
||||
@@ -905,8 +973,8 @@ export function generateHumanSpriteSheet(
|
||||
}
|
||||
}
|
||||
|
||||
// Double chin
|
||||
if (isFat && wr < 0.25) {
|
||||
// Double chin (adults only, not babies)
|
||||
if (isFat && wr >= 0.25 && wr < 0.5) {
|
||||
fill(hx + 4, hy + headH, headW - 8, 4, skinTone, ox, oy)
|
||||
fill(hx + 5, hy + headH + 3, headW - 10, 2, skinDark, ox, oy)
|
||||
fill(hx + 6, hy + headH + 4, headW - 12, 2, skinDark, ox, oy)
|
||||
@@ -943,6 +1011,100 @@ export function generateHumanSpriteSheet(
|
||||
px(hx - 7, hy + 5 + (isIdle ? bounce : 0), darken(hbColor, 15), ox, oy)
|
||||
}
|
||||
|
||||
// ===== BABY ACCESSORIES =====
|
||||
if (hasRosyCheeks) {
|
||||
// Pink rosy cheeks
|
||||
const cheekY = hy + Math.floor(headH * 0.55)
|
||||
fill(hx + 2, cheekY, 4, 3, '#ffaaaa', ox, oy)
|
||||
fill(hx + headW - 6, cheekY, 4, 3, '#ffaaaa', ox, oy)
|
||||
px(hx + 3, cheekY + 1, '#ff8888', ox, oy)
|
||||
px(hx + headW - 5, cheekY + 1, '#ff8888', ox, oy)
|
||||
}
|
||||
|
||||
if (hasPacifier && !isCheer && !isPanic && !isCoach) {
|
||||
// Pacifier in mouth
|
||||
const pY = hy + Math.floor(headH * 0.72)
|
||||
// Shield (round part)
|
||||
roundBox(cx - 4 + xOff, pY - 1, 8, 5, '#66ccff', ox, oy)
|
||||
// Nipple (sticks out)
|
||||
fill(cx - 2 + xOff, pY, 4, 3, '#ffcc66', ox, oy)
|
||||
// Handle ring
|
||||
px(cx - 5 + xOff, pY + 1, '#88ddff', ox, oy)
|
||||
px(cx - 6 + xOff, pY, '#88ddff', ox, oy)
|
||||
px(cx - 6 + xOff, pY + 2, '#88ddff', ox, oy)
|
||||
px(cx - 7 + xOff, pY + 1, '#88ddff', ox, oy)
|
||||
}
|
||||
|
||||
if (hasBib) {
|
||||
// Bib around neck area
|
||||
const bibY = neckTop + yOff
|
||||
const bibW = isBaby ? 20 : 16
|
||||
const bx2 = cx - Math.floor(bibW / 2) + xOff
|
||||
fill(bx2, bibY, bibW, 3, '#ffffff', ox, oy)
|
||||
fill(bx2 + 2, bibY + 3, bibW - 4, 6, '#ffffff', ox, oy)
|
||||
fill(bx2 + 4, bibY + 9, bibW - 8, 4, '#ffffff', ox, oy)
|
||||
// Bib border
|
||||
for (let ix = bx2 + 2; ix < bx2 + bibW - 2; ix++) px(ix, bibY + 8, '#dddddd', ox, oy)
|
||||
// Cute stain on bib
|
||||
px(cx - 2 + xOff, bibY + 5, '#ffaa66', ox, oy)
|
||||
px(cx - 1 + xOff, bibY + 6, '#ffaa66', ox, oy)
|
||||
px(cx + xOff, bibY + 5, '#ff8844', ox, oy)
|
||||
// "I <3 AI" text (tiny)
|
||||
px(cx - 3 + xOff, bibY + 4, '#ff4466', ox, oy) // heart
|
||||
px(cx - 2 + xOff, bibY + 3, '#ff4466', ox, oy)
|
||||
px(cx - 4 + xOff, bibY + 3, '#ff4466', ox, oy)
|
||||
}
|
||||
|
||||
if (hasRattle && (isCheer || isIdle)) {
|
||||
// Rattle in raised hand
|
||||
const rattleX = isCheer ? cx + Math.floor(bodyW / 2) + 8 + xOff : cx + Math.floor(bodyW / 2) + 3 + xOff
|
||||
const rattleY = isCheer ? bodyTop + yOff - 10 : bodyTop + yOff + 5
|
||||
// Handle
|
||||
fill(rattleX, rattleY + 4, 2, 8, '#dda844', ox, oy)
|
||||
// Ball
|
||||
roundBox(rattleX - 3, rattleY - 2, 8, 7, '#ff6688', ox, oy)
|
||||
// Sparkle on rattle
|
||||
px(rattleX - 1, rattleY - 1, '#ffffff', ox, oy)
|
||||
px(rattleX + 2, rattleY, '#ffccdd', ox, oy)
|
||||
}
|
||||
|
||||
if (hasSippyCup && (isIdle || isCoach)) {
|
||||
// Sippy cup in hand
|
||||
const scX = cx + Math.floor(bodyW / 2) + 3 + xOff
|
||||
const scY = bodyTop + yOff + Math.floor(bodyH * 0.5)
|
||||
roundBox(scX, scY, 6, 10, '#4488ff', ox, oy)
|
||||
// Lid
|
||||
fill(scX - 1, scY - 1, 8, 2, '#66aaff', ox, oy)
|
||||
// Spout
|
||||
fill(scX + 2, scY - 3, 2, 3, '#66aaff', ox, oy)
|
||||
// Liquid level
|
||||
fill(scX + 1, scY + 4, 4, 4, '#ffaa22', ox, oy)
|
||||
}
|
||||
|
||||
if (hasBandaid) {
|
||||
// Band-aid on forehead or cheek
|
||||
const baidX = hx + headW - 8
|
||||
const baidY = hy + 4
|
||||
fill(baidX, baidY, 6, 3, '#ffcc88', ox, oy)
|
||||
px(baidX + 1, baidY + 1, '#ddaa66', ox, oy)
|
||||
px(baidX + 4, baidY + 1, '#ddaa66', ox, oy)
|
||||
// Cross-hatch in middle
|
||||
px(baidX + 2, baidY, '#eebb77', ox, oy)
|
||||
px(baidX + 3, baidY + 2, '#eebb77', ox, oy)
|
||||
}
|
||||
|
||||
if (hasBackpack && !isCheer) {
|
||||
// Little backpack on back
|
||||
const bpX = cx - Math.floor(bodyW / 2) - 6 + xOff
|
||||
const bpY = bodyTop + yOff + 3
|
||||
roundBox(bpX, bpY, 6, 12, '#ee5533', ox, oy)
|
||||
// Zipper
|
||||
fill(bpX + 2, bpY + 2, 2, 3, '#cc3311', ox, oy)
|
||||
// Strap
|
||||
px(bpX + 5, bpY, '#cc3311', ox, oy)
|
||||
px(bpX + 6, bpY + 1, '#cc3311', ox, oy)
|
||||
}
|
||||
|
||||
// Winner glow aura
|
||||
if (isSuperHero && !isPanic) {
|
||||
for (let a = 0; a < 16; a++) {
|
||||
|
||||
@@ -279,9 +279,21 @@ const tierClass = (t: number) => `tier-${t}`
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Header with human + bot character -->
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-5">
|
||||
<div class="flex items-end justify-between mb-3 relative">
|
||||
<!-- Human player: just the human avatar, centered -->
|
||||
<div v-if="stats.archetype === 'human'" class="flex justify-center mb-3">
|
||||
<HumanPreview
|
||||
:seed="stats.avatarSeed || stats.name"
|
||||
archetype="human"
|
||||
:size="220"
|
||||
:win-rate="(stats.winRate || 0) / 100"
|
||||
anim="idle"
|
||||
class="drop-shadow-[0_0_20px_rgba(0,255,255,0.3)]"
|
||||
/>
|
||||
</div>
|
||||
<!-- Bot player: human controller + wire + bot sprite -->
|
||||
<div v-else class="flex items-end justify-between mb-3 relative">
|
||||
<HumanPreview
|
||||
:seed="stats.avatarSeed || stats.name"
|
||||
:archetype="stats.archetype || 'standard'"
|
||||
@@ -338,7 +350,7 @@ const tierClass = (t: number) => `tier-${t}`
|
||||
#{{ stats.rank }} of {{ stats.totalBots }}
|
||||
</p>
|
||||
<button
|
||||
v-if="isOwner"
|
||||
v-if="isOwner && stats.archetype !== 'human'"
|
||||
class="mt-2 font-mono text-[10px] text-neon-cyan/60 hover:text-neon-cyan transition-colors"
|
||||
@click="showCustomize = !showCustomize; if (showCustomize) initCustForm()"
|
||||
>
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import FightViewer from '../components/FightViewer.vue'
|
||||
import { useNostr } from '../composables/useNostr'
|
||||
import { createFightScene, type FightSceneController } from '../game/FightScene'
|
||||
import {
|
||||
fanfareRound,
|
||||
sfxCrowdCheer, sfxApplause, sfxDrumRoll,
|
||||
announceFinishHim, announceFlawlessVictory, announceDeepIntro,
|
||||
setMusicIntensity, stopAllAudio, ensureAudioContext, setMasterMute,
|
||||
} from '../game/sounds'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -20,21 +27,89 @@ const autoBattleCount = ref(0)
|
||||
let pollHandle: ReturnType<typeof setInterval> | null = null
|
||||
let pollCount = 0
|
||||
|
||||
// Human fight detection
|
||||
const isHumanFight = computed(() => !!myBot.value?.isHuman || myBot.value?.archetype === 'human')
|
||||
|
||||
// Human challenge state
|
||||
const humanChallenge = ref<{
|
||||
type: string
|
||||
label: string
|
||||
prompt: string
|
||||
roundNumber: number
|
||||
remainingMs: number
|
||||
scoring: string
|
||||
} | null>(null)
|
||||
const humanAnswer = ref('')
|
||||
const humanTimer = ref(5)
|
||||
const humanSubmitted = ref(false)
|
||||
let humanPollHandle: ReturnType<typeof setInterval> | null = null
|
||||
let timerHandle: ReturnType<typeof setInterval> | null = null
|
||||
const answerInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// Live human fight scene state
|
||||
const liveFightData = ref<any>(null)
|
||||
const liveCanvas = ref<HTMLCanvasElement>()
|
||||
const liveLogEl = ref<HTMLElement>()
|
||||
let liveScene: FightSceneController | null = null
|
||||
const liveLogItems = ref<{ type: string; round: number; text: string; color: string }[]>([])
|
||||
const liveHpA = ref(100)
|
||||
const liveHpB = ref(100)
|
||||
const liveCurrentRound = ref(0)
|
||||
const roundCooldown = ref(0)
|
||||
let cooldownHandle: ReturnType<typeof setInterval> | null = null
|
||||
let eventSource: EventSource | null = null
|
||||
const liveSceneReady = ref(false)
|
||||
const liveSoundOn = ref(true)
|
||||
const liveAnnouncement = ref('')
|
||||
const liveAnnouncementColor = ref('#ffffff')
|
||||
const liveAnnouncementVisible = ref(false)
|
||||
const currentChallengeInfo = ref<{ type: string; label: string } | null>(null)
|
||||
|
||||
const myBotId = computed(() => {
|
||||
if (!isLoggedIn.value || !myBot.value || !fight.value) return null
|
||||
if (myBot.value.id === fight.value.botA?.id) return fight.value.botA.id
|
||||
if (myBot.value.id === fight.value.botB?.id) return fight.value.botB.id
|
||||
if (!isLoggedIn.value || !myBot.value) return null
|
||||
const f = fight.value || liveFightData.value
|
||||
if (!f) return null
|
||||
if (myBot.value.id === f.botA?.id) return f.botA.id
|
||||
if (myBot.value.id === f.botB?.id) return f.botB.id
|
||||
return null
|
||||
})
|
||||
|
||||
const showOverlay = computed(() => replayDone.value && !isRequeueing.value && !autoBattle.value)
|
||||
|
||||
function sleep(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)) }
|
||||
function scrollLiveLog() { nextTick(() => { liveLogEl.value?.scrollTo({ top: liveLogEl.value.scrollHeight, behavior: 'smooth' }) }) }
|
||||
|
||||
const challengeLabel = (type: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
speed_blitz: 'SPEED BLITZ', riddle: 'RIDDLE ME THIS', code_golf: 'CODE GOLF',
|
||||
roast_battle: 'ROAST BATTLE', hallucination_check: 'HALLUCINATION CHECK',
|
||||
token_economy: 'TOKEN ECONOMY', creative_writing: 'CREATIVE WRITING',
|
||||
math_blitz: 'MATH BLITZ', trap_card: 'TRAP CARD',
|
||||
food_fight: 'FOOD FIGHT', wrestling_match: 'WRESTLING MATCH',
|
||||
music_battle: 'MUSIC BATTLE', magic_duel: 'MAGIC DUEL',
|
||||
sports_showdown: 'SPORTS SHOWDOWN', nature_clash: 'NATURE CLASH',
|
||||
space_war: 'SPACE WAR', hack_battle: 'HACK BATTLE',
|
||||
meme_war: 'MEME WAR', animal_kingdom: 'ANIMAL KINGDOM',
|
||||
demolition: 'DEMOLITION DERBY', vehicle_mayhem: 'VEHICLE MAYHEM',
|
||||
medieval_combat: 'MEDIEVAL COMBAT',
|
||||
}
|
||||
return labels[type] || type.replace(/_/g, ' ').toUpperCase()
|
||||
}
|
||||
|
||||
const tierClass = (t: number) => `tier-${t}`
|
||||
|
||||
// --- Load fight data ---
|
||||
async function loadFight(): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(`/api/fights/${fightId.value}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
liveRounds.value = data.rounds?.length || 0
|
||||
|
||||
if (isHumanFight.value && !liveFightData.value && data.botA && data.botB) {
|
||||
liveFightData.value = data
|
||||
}
|
||||
|
||||
if (data.status === 'finished') {
|
||||
fight.value = data
|
||||
}
|
||||
@@ -51,15 +126,311 @@ function startPolling() {
|
||||
const s = await loadFight()
|
||||
if (s === 'finished') {
|
||||
isLive.value = false
|
||||
stopHumanPolling()
|
||||
if (pollHandle) { clearInterval(pollHandle); pollHandle = null }
|
||||
} else if (pollCount > 60) {
|
||||
} else if (pollCount > 120) {
|
||||
isLive.value = false
|
||||
fightError.value = 'Fight took too long. It may still be running — try refreshing.'
|
||||
fightError.value = 'Fight took too long. Try refreshing.'
|
||||
stopHumanPolling()
|
||||
if (pollHandle) { clearInterval(pollHandle); pollHandle = null }
|
||||
}
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
// --- Human challenge polling ---
|
||||
function startHumanPolling() {
|
||||
if (!myBot.value) return
|
||||
pollForChallenge()
|
||||
humanPollHandle = setInterval(pollForChallenge, 400)
|
||||
}
|
||||
|
||||
function stopHumanPolling() {
|
||||
if (humanPollHandle) { clearInterval(humanPollHandle); humanPollHandle = null }
|
||||
if (timerHandle) { clearInterval(timerHandle); timerHandle = null }
|
||||
if (cooldownHandle) { clearInterval(cooldownHandle); cooldownHandle = null }
|
||||
}
|
||||
|
||||
async function pollForChallenge() {
|
||||
if (!myBot.value) return
|
||||
try {
|
||||
const res = await fetch(`/api/fights/${fightId.value}/challenge/${myBot.value.id}`)
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
|
||||
if (data.pending) {
|
||||
if (roundCooldown.value > 0) return
|
||||
if (!humanChallenge.value || humanChallenge.value.roundNumber !== data.roundNumber) {
|
||||
humanChallenge.value = data
|
||||
humanAnswer.value = ''
|
||||
humanSubmitted.value = false
|
||||
humanTimer.value = Math.ceil(data.remainingMs / 1000)
|
||||
startTimer()
|
||||
await nextTick()
|
||||
answerInput.value?.focus()
|
||||
}
|
||||
} else if (data.fightStatus === 'finished') {
|
||||
humanChallenge.value = null
|
||||
humanSubmitted.value = false
|
||||
} else if (humanSubmitted.value) {
|
||||
humanChallenge.value = null
|
||||
}
|
||||
} catch { /* */ }
|
||||
}
|
||||
|
||||
function startTimer() {
|
||||
if (timerHandle) clearInterval(timerHandle)
|
||||
timerHandle = setInterval(() => {
|
||||
humanTimer.value--
|
||||
if (humanTimer.value <= 0) {
|
||||
if (timerHandle) clearInterval(timerHandle)
|
||||
if (!humanSubmitted.value) humanSubmitted.value = true
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
async function submitHumanAnswer() {
|
||||
if (!myBot.value || !humanChallenge.value || humanSubmitted.value) return
|
||||
if (!humanAnswer.value.trim()) return
|
||||
|
||||
humanSubmitted.value = true
|
||||
if (timerHandle) clearInterval(timerHandle)
|
||||
|
||||
try {
|
||||
await fetch(`/api/fights/${fightId.value}/respond/${myBot.value.id}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ answer: humanAnswer.value.trim() }),
|
||||
})
|
||||
} catch { /* */ }
|
||||
}
|
||||
|
||||
// --- Live scene management ---
|
||||
async function initLiveScene() {
|
||||
const data = liveFightData.value
|
||||
if (!data?.botA || !data?.botB || !liveCanvas.value) return
|
||||
|
||||
if (liveScene) { liveScene.destroy(); liveScene = null }
|
||||
|
||||
const container = liveCanvas.value.parentElement
|
||||
if (container) {
|
||||
const oldCanvas = liveCanvas.value
|
||||
const newCanvas = document.createElement('canvas')
|
||||
newCanvas.className = oldCanvas.className
|
||||
newCanvas.width = container.clientWidth
|
||||
newCanvas.height = container.clientHeight
|
||||
oldCanvas.replaceWith(newCanvas)
|
||||
liveCanvas.value = newCanvas
|
||||
}
|
||||
|
||||
liveScene = await createFightScene({
|
||||
canvas: liveCanvas.value,
|
||||
botA: { name: data.botA.name, seed: data.botA.avatarSeed || data.botA.name, tier: data.botA.tier, archetype: data.botA.archetype, customization: data.botA.customization },
|
||||
botB: { name: data.botB.name, seed: data.botB.avatarSeed || data.botB.name, tier: data.botB.tier, archetype: data.botB.archetype, customization: data.botB.customization },
|
||||
arena: data.arena,
|
||||
})
|
||||
|
||||
liveSceneReady.value = true
|
||||
|
||||
if (liveScene) {
|
||||
if (liveSoundOn.value) {
|
||||
ensureAudioContext()
|
||||
liveScene.startMusic()
|
||||
}
|
||||
announceDeepIntro()
|
||||
await liveScene.playEntrance()
|
||||
}
|
||||
|
||||
liveLogItems.value.push(
|
||||
{ type: 'system', round: 0, text: `ARENA: ${data.arenaInfo?.name || 'THE RING'}`, color: 'neon-purple' },
|
||||
{ type: 'system', round: 0, text: `${data.botA.name} vs ${data.botB.name}`, color: 'text-secondary' },
|
||||
{ type: 'divider', round: 0, text: '', color: '' },
|
||||
)
|
||||
scrollLiveLog()
|
||||
}
|
||||
|
||||
// --- SSE connection ---
|
||||
function connectSSE() {
|
||||
eventSource = new EventSource(`/api/fights/${fightId.value}/stream`)
|
||||
|
||||
eventSource.addEventListener('round_start', (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data)
|
||||
currentChallengeInfo.value = { type: data.challenge.type, label: data.challenge.label }
|
||||
} catch { /* */ }
|
||||
})
|
||||
|
||||
eventSource.addEventListener('round_end', (e) => {
|
||||
try {
|
||||
handleRoundEnd(JSON.parse(e.data))
|
||||
} catch { /* */ }
|
||||
})
|
||||
|
||||
eventSource.addEventListener('fight_end', (e) => {
|
||||
try {
|
||||
handleFightEnd(JSON.parse(e.data))
|
||||
} catch { /* */ }
|
||||
})
|
||||
|
||||
eventSource.onerror = () => { /* SSE reconnects automatically */ }
|
||||
}
|
||||
|
||||
function disconnectSSE() {
|
||||
if (eventSource) { eventSource.close(); eventSource = null }
|
||||
}
|
||||
|
||||
async function showLiveOverlay(text: string, color: string, duration: number) {
|
||||
liveAnnouncement.value = text
|
||||
liveAnnouncementColor.value = color
|
||||
liveAnnouncementVisible.value = true
|
||||
await sleep(duration)
|
||||
liveAnnouncementVisible.value = false
|
||||
await sleep(60)
|
||||
}
|
||||
|
||||
async function handleRoundEnd(data: any) {
|
||||
const fd = liveFightData.value
|
||||
if (!fd) return
|
||||
|
||||
const round = data.round
|
||||
const result = data.result
|
||||
const hp = data.hp
|
||||
|
||||
liveCurrentRound.value = round
|
||||
|
||||
// Clear challenge state
|
||||
humanChallenge.value = null
|
||||
humanSubmitted.value = false
|
||||
if (timerHandle) { clearInterval(timerHandle); timerHandle = null }
|
||||
|
||||
// Update HP (server 0-200, display 0-100)
|
||||
liveHpA.value = Math.round((hp.a / 200) * 100)
|
||||
liveHpB.value = Math.round((hp.b / 200) * 100)
|
||||
setMusicIntensity(1 - Math.min(liveHpA.value, liveHpB.value) / 100)
|
||||
|
||||
const aWon = result.winnerId === fd.botA.id
|
||||
const bWon = result.winnerId === fd.botB.id
|
||||
const isCritical = Math.abs((result.botAScore || 0) - (result.botBScore || 0)) > 4
|
||||
|
||||
// Use challenge info from round_start SSE, fall back to generic
|
||||
const cLabel = currentChallengeInfo.value?.label || challengeLabel(currentChallengeInfo.value?.type || '')
|
||||
|
||||
liveLogItems.value.push(
|
||||
{ type: 'header', round, text: `ROUND ${round}: ${cLabel}`, color: 'neon-purple' },
|
||||
)
|
||||
if (result.botAResponse) {
|
||||
liveLogItems.value.push({ type: 'responseA', round, text: `${fd.botA.name}: ${result.botAResponse.slice(0, 120)}`, color: 'neon-cyan' })
|
||||
}
|
||||
if (result.botBResponse) {
|
||||
liveLogItems.value.push({ type: 'responseB', round, text: `${fd.botB.name}: ${result.botBResponse.slice(0, 120)}`, color: 'neon-pink' })
|
||||
}
|
||||
if (result.narration) {
|
||||
liveLogItems.value.push({ type: 'narration', round, text: `>> ${result.narration}`, color: 'neon-yellow' })
|
||||
}
|
||||
const winnerName = aWon ? fd.botA.name : bWon ? fd.botB.name : 'DRAW'
|
||||
liveLogItems.value.push(
|
||||
{ type: 'result', round, text: `${winnerName} ${aWon || bWon ? 'wins round!' : '- no winner'} (${result.botAScore} vs ${result.botBScore})`, color: aWon ? 'neon-cyan' : bWon ? 'neon-pink' : 'text-muted' },
|
||||
{ type: 'divider', round, text: '', color: '' },
|
||||
)
|
||||
scrollLiveLog()
|
||||
|
||||
// Play round animation
|
||||
if (liveScene) {
|
||||
fanfareRound(round)
|
||||
if (result.botAResponse) liveScene.showSpeechBubble('a', result.botAResponse.slice(0, 60), 3)
|
||||
if (result.botBResponse) {
|
||||
setTimeout(() => {
|
||||
if (liveScene && result.botBResponse) liveScene.showSpeechBubble('b', result.botBResponse.slice(0, 60), 2.5)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
try {
|
||||
await liveScene.playRound({
|
||||
round,
|
||||
challengeType: currentChallengeInfo.value?.type || '',
|
||||
winnerId: result.winnerId,
|
||||
botAId: fd.botA.id,
|
||||
botBId: fd.botB.id,
|
||||
narration: result.narration || '',
|
||||
isCritical,
|
||||
botAScore: result.botAScore || 0,
|
||||
botBScore: result.botBScore || 0,
|
||||
})
|
||||
} catch { /* */ }
|
||||
|
||||
if (aWon || bWon) {
|
||||
await liveScene.playTaunt(aWon ? 'a' : 'b')
|
||||
}
|
||||
}
|
||||
|
||||
currentChallengeInfo.value = null
|
||||
|
||||
// Countdown before next round
|
||||
roundCooldown.value = 3
|
||||
cooldownHandle = setInterval(() => {
|
||||
roundCooldown.value--
|
||||
if (roundCooldown.value <= 0) {
|
||||
if (cooldownHandle) { clearInterval(cooldownHandle); cooldownHandle = null }
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
async function handleFightEnd(data: any) {
|
||||
const fd = liveFightData.value
|
||||
if (!fd) return
|
||||
|
||||
// Clear challenge
|
||||
humanChallenge.value = null
|
||||
humanSubmitted.value = false
|
||||
roundCooldown.value = 0
|
||||
if (cooldownHandle) { clearInterval(cooldownHandle); cooldownHandle = null }
|
||||
|
||||
// Final HP
|
||||
if (data.winnerId) {
|
||||
if (data.winnerId === fd.botA.id) liveHpB.value = 0
|
||||
else liveHpA.value = 0
|
||||
}
|
||||
|
||||
// KO sequence
|
||||
if (liveScene && data.winnerId) {
|
||||
const winningSide = (data.winnerId === fd.botA.id ? 'a' : 'b') as 'a' | 'b'
|
||||
|
||||
sfxDrumRoll()
|
||||
await sleep(500)
|
||||
announceFinishHim()
|
||||
await showLiveOverlay('FINISH IT!', '#ff2d2d', 900)
|
||||
|
||||
if (data.isPerfect) {
|
||||
await liveScene.playPerfect(winningSide, data.winnerName)
|
||||
announceFlawlessVictory()
|
||||
} else {
|
||||
await liveScene.playKO(winningSide, data.winnerName)
|
||||
}
|
||||
|
||||
sfxApplause()
|
||||
sfxCrowdCheer()
|
||||
await showLiveOverlay(`${data.winnerName} WINS!`, '#00f0ff', 2000)
|
||||
liveScene.stopMusic()
|
||||
}
|
||||
|
||||
// Clean up live scene
|
||||
if (liveScene) { liveScene.destroy(); liveScene = null }
|
||||
liveSceneReady.value = false
|
||||
|
||||
// Load full fight data for replay
|
||||
await loadFight()
|
||||
isLive.value = false
|
||||
disconnectSSE()
|
||||
stopHumanPolling()
|
||||
if (pollHandle) { clearInterval(pollHandle); pollHandle = null }
|
||||
}
|
||||
|
||||
function toggleLiveSound() {
|
||||
liveSoundOn.value = !liveSoundOn.value
|
||||
ensureAudioContext()
|
||||
setMasterMute(!liveSoundOn.value)
|
||||
}
|
||||
|
||||
// --- Mount / Unmount ---
|
||||
onMounted(async () => {
|
||||
const status = await loadFight()
|
||||
isLoading.value = false
|
||||
@@ -68,23 +439,48 @@ onMounted(async () => {
|
||||
isLive.value = true
|
||||
}
|
||||
|
||||
if (isLive.value) startPolling()
|
||||
if (isLive.value) {
|
||||
startPolling()
|
||||
if (isHumanFight.value) {
|
||||
startHumanPolling()
|
||||
connectSSE()
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
if (liveFightData.value) await initLiveScene()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollHandle) clearInterval(pollHandle)
|
||||
stopHumanPolling()
|
||||
disconnectSSE()
|
||||
stopAllAudio()
|
||||
if (liveScene) { liveScene.destroy(); liveScene = null }
|
||||
autoBattle.value = false
|
||||
})
|
||||
|
||||
// --- Fight again / matchmake ---
|
||||
async function fightAgain(botId: string) {
|
||||
if (isRequeueing.value) return
|
||||
isRequeueing.value = true
|
||||
replayDone.value = false
|
||||
humanChallenge.value = null
|
||||
humanSubmitted.value = false
|
||||
roundCooldown.value = 0
|
||||
if (liveScene) { liveScene.destroy(); liveScene = null }
|
||||
liveSceneReady.value = false
|
||||
liveFightData.value = null
|
||||
liveLogItems.value = []
|
||||
liveHpA.value = 100
|
||||
liveHpB.value = 100
|
||||
liveCurrentRound.value = 0
|
||||
disconnectSSE()
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/queue/join/${botId}`, { method: 'POST' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
// Load the new fight in-place (new scene)
|
||||
fightId.value = data.fightId
|
||||
fight.value = null
|
||||
isLive.value = true
|
||||
@@ -92,6 +488,19 @@ async function fightAgain(botId: string) {
|
||||
fightError.value = ''
|
||||
window.history.replaceState({}, '', `/arena/${data.fightId}`)
|
||||
startPolling()
|
||||
if (isHumanFight.value) {
|
||||
startHumanPolling()
|
||||
connectSSE()
|
||||
// Wait for fight data to arrive so we can init scene
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await loadFight()
|
||||
if (liveFightData.value) break
|
||||
await sleep(300)
|
||||
}
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
if (liveFightData.value) await initLiveScene()
|
||||
}
|
||||
}
|
||||
} catch { /* */ }
|
||||
isRequeueing.value = false
|
||||
@@ -143,6 +552,169 @@ function stopAutoBattle() {
|
||||
<p class="font-display text-text-muted animate-pulse tracking-wider">LOADING FIGHT...</p>
|
||||
</div>
|
||||
|
||||
<!-- LIVE HUMAN FIGHT: full arena view with input -->
|
||||
<div v-else-if="isLive && isHumanFight" class="flex-1 flex flex-col lg:flex-row gap-1 sm:gap-2 min-h-0">
|
||||
|
||||
<!-- Battle Log + Human Input -->
|
||||
<div class="h-[40vh] sm:h-auto lg:w-[38%] flex flex-col min-h-0 border border-border rounded-lg bg-black/90 overflow-hidden order-2 lg:order-1">
|
||||
<div class="bg-surface-raised border-b border-border px-3 py-1.5 flex items-center gap-2 flex-shrink-0">
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-ko" />
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-neon-yellow" />
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-neon-green" />
|
||||
<span class="font-pixel text-[10px] text-text-muted ml-2 tracking-wider">BATTLE LOG</span>
|
||||
</div>
|
||||
<div ref="liveLogEl" class="flex-1 overflow-y-auto p-2 sm:p-4 font-mono text-xs sm:text-sm space-y-1.5 leading-relaxed">
|
||||
<div v-for="(item, idx) in liveLogItems" :key="idx">
|
||||
<div v-if="item.type === 'divider'" class="py-1.5"><div class="border-t border-white/5" /></div>
|
||||
<p v-else-if="item.type === 'header'" class="text-neon-purple font-bold text-base tracking-wide pt-3 pb-1 uppercase">{{ item.text }}</p>
|
||||
<div v-else-if="item.type === 'responseA'" class="bg-neon-cyan/[0.04] border-l-2 border-neon-cyan/30 rounded-r-md px-3 py-1.5 my-1">
|
||||
<p class="text-neon-cyan text-sm leading-snug">{{ item.text }}</p>
|
||||
</div>
|
||||
<div v-else-if="item.type === 'responseB'" class="bg-neon-pink/[0.04] border-l-2 border-neon-pink/30 rounded-r-md px-3 py-1.5 my-1">
|
||||
<p class="text-neon-pink text-sm leading-snug">{{ item.text }}</p>
|
||||
</div>
|
||||
<div v-else-if="item.type === 'narration'" class="bg-neon-yellow/[0.06] border border-neon-yellow/20 rounded-md px-3 py-1.5 my-1">
|
||||
<p class="text-neon-yellow font-bold text-sm">{{ item.text }}</p>
|
||||
</div>
|
||||
<p v-else-if="item.type === 'result'" :class="['font-bold text-sm pl-2 py-0.5', item.color === 'neon-cyan' ? 'text-neon-cyan' : item.color === 'neon-pink' ? 'text-neon-pink' : 'text-text-secondary']">{{ item.text }}</p>
|
||||
<p v-else-if="item.type === 'system'" :class="['text-sm', item.color === 'neon-purple' ? 'text-neon-purple font-bold tracking-wider' : 'text-text-muted']">{{ item.text }}</p>
|
||||
</div>
|
||||
<div v-if="liveLogItems.length === 0" class="text-neon-purple italic pt-8 text-center text-sm">Waiting for fight to begin...</div>
|
||||
</div>
|
||||
|
||||
<!-- Human Input at bottom of battle log -->
|
||||
<div class="px-3 py-2 border-t border-border bg-surface-raised/80 flex-shrink-0">
|
||||
<div v-if="roundCooldown > 0" class="text-center py-1">
|
||||
<p class="font-display text-neon-yellow text-sm tracking-widest animate-pulse">
|
||||
NEXT ROUND IN {{ roundCooldown }}...
|
||||
</p>
|
||||
</div>
|
||||
<div v-else-if="humanChallenge && !humanSubmitted">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-display font-black text-[10px] tracking-wider text-neon-cyan">
|
||||
R{{ humanChallenge.roundNumber }}
|
||||
</span>
|
||||
<span class="font-display font-bold text-[9px] tracking-wider text-neon-purple uppercase">
|
||||
{{ humanChallenge.label }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class="font-display font-black text-sm tracking-wider tabular-nums"
|
||||
:class="humanTimer <= 2 ? 'text-ko animate-pulse' : 'text-neon-yellow'"
|
||||
>
|
||||
{{ humanTimer }}s
|
||||
</span>
|
||||
</div>
|
||||
<p class="font-mono text-[11px] text-text-primary leading-snug mb-1.5 line-clamp-2">
|
||||
{{ humanChallenge.prompt }}
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
ref="answerInput"
|
||||
v-model="humanAnswer"
|
||||
type="text"
|
||||
maxlength="200"
|
||||
placeholder="Type fast..."
|
||||
class="flex-1 bg-surface border-2 border-border px-2 py-1.5 text-sm font-mono
|
||||
text-text-primary placeholder-text-muted
|
||||
focus:outline-none focus:border-neon-pink/50 transition-colors"
|
||||
@keyup.enter="submitHumanAnswer"
|
||||
/>
|
||||
<button
|
||||
class="px-3 py-1.5 bg-neon-pink/10 border-2 border-neon-pink text-neon-pink
|
||||
font-display font-black text-xs tracking-wider
|
||||
hover:bg-neon-pink/20 transition-all disabled:opacity-30"
|
||||
:disabled="!humanAnswer.trim()"
|
||||
@click="submitHumanAnswer"
|
||||
>
|
||||
GO
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="humanSubmitted" class="text-center py-1">
|
||||
<p class="font-mono text-text-muted text-xs animate-pulse">Scoring round...</p>
|
||||
</div>
|
||||
<div v-else class="flex items-center justify-between py-1">
|
||||
<p class="font-mono text-text-muted text-xs">Waiting for challenge...</p>
|
||||
<button
|
||||
class="w-7 h-7 flex items-center justify-center border border-border/50 text-text-muted
|
||||
hover:text-neon-cyan hover:border-neon-cyan/50 transition-all"
|
||||
:title="liveSoundOn ? 'Mute' : 'Unmute'"
|
||||
@click="toggleLiveSound"
|
||||
>
|
||||
<svg v-if="liveSoundOn" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-3.5 h-3.5">
|
||||
<path d="M11 5L6 9H2v6h4l5 4V5z"/><path d="M19.07 4.93a10 10 0 010 14.14M15.54 8.46a5 5 0 010 7.07"/>
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-3.5 h-3.5">
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Game Canvas + Human Input -->
|
||||
<div class="lg:w-[62%] flex flex-col min-h-0 border border-border rounded-lg bg-black overflow-hidden order-1 lg:order-2">
|
||||
|
||||
<!-- Health bars -->
|
||||
<div v-if="liveFightData?.botA" class="px-2 sm:px-3 py-1.5 sm:py-2 bg-surface-raised/80 border-b border-border flex-shrink-0">
|
||||
<div class="flex items-center gap-1 sm:gap-2">
|
||||
<div class="flex-shrink-0 min-w-0 max-w-[25%] sm:max-w-none">
|
||||
<p class="font-marker text-[10px] sm:text-sm tracking-wider truncate text-neon-cyan">
|
||||
{{ liveFightData.botA.name }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-1 h-4 sm:h-5 bg-black/50 rounded-sm overflow-hidden border border-neon-cyan/30">
|
||||
<div class="h-full bg-gradient-to-r from-neon-cyan to-neon-purple transition-all duration-500" :style="{ width: `${liveHpA}%` }" />
|
||||
</div>
|
||||
<span class="font-mono font-bold text-[10px] sm:text-sm w-6 sm:w-8 text-right" :class="liveHpA > 50 ? 'text-neon-cyan' : liveHpA > 20 ? 'text-neon-yellow' : 'text-ko'">{{ liveHpA }}</span>
|
||||
<span class="font-funky text-neon-purple text-base sm:text-xl px-0.5 sm:px-1">VS</span>
|
||||
<span class="font-mono font-bold text-[10px] sm:text-sm w-6 sm:w-8 text-left" :class="liveHpB > 50 ? 'text-neon-pink' : liveHpB > 20 ? 'text-neon-yellow' : 'text-ko'">{{ liveHpB }}</span>
|
||||
<div class="flex-1 h-4 sm:h-5 bg-black/50 rounded-sm overflow-hidden border border-neon-pink/30">
|
||||
<div class="h-full bg-gradient-to-l from-neon-pink to-neon-purple transition-all duration-500 ml-auto" :style="{ width: `${liveHpB}%` }" />
|
||||
</div>
|
||||
<div class="flex-shrink-0 min-w-0 max-w-[25%] sm:max-w-none">
|
||||
<p class="font-marker text-[10px] sm:text-sm tracking-wider truncate text-right text-neon-pink">
|
||||
{{ liveFightData.botB.name }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-0.5">
|
||||
<span class="font-pixel text-[8px] sm:text-[9px]" :class="tierClass(liveFightData.botA.tier || 0)">{{ Math.round(liveFightData.botA.eloRating || 0) }}</span>
|
||||
<span class="font-pixel text-[8px] sm:text-[9px] text-text-muted">{{ liveFightData.arenaInfo?.name }} | R{{ liveCurrentRound }}</span>
|
||||
<span class="font-pixel text-[8px] sm:text-[9px]" :class="tierClass(liveFightData.botB.tier || 0)">{{ Math.round(liveFightData.botB.eloRating || 0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Canvas area -->
|
||||
<div class="flex-1 relative min-h-0">
|
||||
<canvas ref="liveCanvas" class="w-full h-full block" />
|
||||
|
||||
<!-- Floating announcement -->
|
||||
<Transition name="announce">
|
||||
<div v-if="liveAnnouncementVisible"
|
||||
class="absolute inset-0 flex items-center justify-center pointer-events-none z-20">
|
||||
<p class="font-funky text-5xl sm:text-7xl tracking-widest uppercase announce-text"
|
||||
:style="{ color: liveAnnouncementColor, textShadow: `0 0 20px ${liveAnnouncementColor}, 0 0 40px ${liveAnnouncementColor}` }">
|
||||
{{ liveAnnouncement }}
|
||||
</p>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Loading scene overlay -->
|
||||
<div v-if="!liveSceneReady" class="absolute inset-0 flex items-center justify-center bg-black/80 z-10">
|
||||
<div class="text-center">
|
||||
<div class="w-12 h-12 border-4 border-neon-pink/30 border-t-neon-pink rounded-full animate-spin mx-auto mb-3" />
|
||||
<p class="font-display text-neon-pink tracking-widest animate-pulse">LOADING ARENA...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LIVE BOT FIGHT: spinner -->
|
||||
<div v-else-if="isLive && !fight" class="flex-1 flex flex-col items-center justify-center gap-4">
|
||||
<div class="w-16 h-16 border-4 border-neon-pink/30 border-t-neon-pink rounded-full animate-spin" />
|
||||
<p class="font-display text-neon-pink text-xl tracking-widest animate-pulse glow-pink">FIGHT IN PROGRESS</p>
|
||||
@@ -174,7 +746,7 @@ function stopAutoBattle() {
|
||||
<div class="flex-1 min-h-0 relative">
|
||||
<FightViewer :key="fightId" :fight="fight" :autoplay="true" class="h-full" @replay-done="onReplayDone" />
|
||||
|
||||
<!-- Post-fight overlay — on top of the game canvas -->
|
||||
<!-- Post-fight overlay -->
|
||||
<Transition name="fade-up">
|
||||
<div v-if="showOverlay"
|
||||
class="absolute inset-0 z-50 flex items-end justify-center pointer-events-none pb-16 sm:pb-20">
|
||||
@@ -182,20 +754,25 @@ function stopAutoBattle() {
|
||||
bg-black/80 backdrop-blur-sm border border-white/10 rounded-xl
|
||||
px-4 sm:px-8 py-4 sm:py-6 shadow-2xl max-w-md w-full mx-4">
|
||||
|
||||
<!-- Owner actions -->
|
||||
<template v-if="myBotId">
|
||||
<button
|
||||
:disabled="isRequeueing"
|
||||
class="w-full py-3 bg-neon-cyan/10 border-2 border-neon-cyan/50 text-neon-cyan
|
||||
font-display font-black text-sm tracking-widest rounded-lg
|
||||
hover:bg-neon-cyan/20 hover:border-neon-cyan transition-all neon-border-cyan"
|
||||
hover:bg-neon-cyan/20 hover:border-neon-cyan transition-all neon-border-cyan
|
||||
disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
|
||||
@click="fightAgain(myBotId!)"
|
||||
>
|
||||
FIGHT ANOTHER BOT
|
||||
<span v-if="isRequeueing" class="w-4 h-4 border-2 border-neon-cyan/30 border-t-neon-cyan rounded-full animate-spin" />
|
||||
{{ isRequeueing ? 'MATCHING...' : 'FIGHT ANOTHER BOT' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="!isHumanFight"
|
||||
:disabled="isRequeueing"
|
||||
class="w-full py-3 bg-neon-yellow/10 border-2 border-neon-yellow/50 text-neon-yellow
|
||||
font-display font-black text-sm tracking-widest rounded-lg
|
||||
hover:bg-neon-yellow/20 hover:border-neon-yellow transition-all"
|
||||
hover:bg-neon-yellow/20 hover:border-neon-yellow transition-all
|
||||
disabled:opacity-50 disabled:cursor-wait"
|
||||
@click="startAutoBattle"
|
||||
>
|
||||
AUTO BATTLE
|
||||
@@ -205,16 +782,18 @@ function stopAutoBattle() {
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Spectator actions -->
|
||||
<template v-else-if="isLoggedIn && myBot">
|
||||
<button
|
||||
:disabled="isRequeueing"
|
||||
class="w-full py-3 bg-neon-cyan/10 border-2 border-neon-cyan/50 text-neon-cyan
|
||||
font-display font-black text-sm tracking-widest rounded-lg
|
||||
hover:bg-neon-cyan/20 hover:border-neon-cyan transition-all neon-border-cyan"
|
||||
hover:bg-neon-cyan/20 hover:border-neon-cyan transition-all neon-border-cyan
|
||||
disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
|
||||
@click="matchmake(myBot!.id)"
|
||||
>
|
||||
FIGHT WITH MY BOT
|
||||
<span class="block font-mono text-[9px] tracking-wider text-neon-cyan/60 mt-0.5">
|
||||
<span v-if="isRequeueing" class="w-4 h-4 border-2 border-neon-cyan/30 border-t-neon-cyan rounded-full animate-spin" />
|
||||
{{ isRequeueing ? 'MATCHING...' : 'FIGHT WITH MY BOT' }}
|
||||
<span v-if="!isRequeueing" class="block font-mono text-[9px] tracking-wider text-neon-cyan/60 mt-0.5">
|
||||
{{ myBot!.name.toUpperCase() }}
|
||||
</span>
|
||||
</button>
|
||||
@@ -267,4 +846,18 @@ function stopAutoBattle() {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
.announce-enter-active { animation: announce-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); }
|
||||
.announce-leave-active { animation: announce-out 0.25s ease-in; }
|
||||
@keyframes announce-in { from { opacity: 0; transform: scale(0.1) rotate(-15deg); filter: blur(8px); } to { opacity: 1; transform: scale(1) rotate(0); filter: blur(0); } }
|
||||
@keyframes announce-out { from { opacity: 1; } to { opacity: 0; transform: scale(1.5) rotate(5deg); filter: blur(4px); } }
|
||||
|
||||
.announce-text {
|
||||
animation: announce-pulse 0.4s ease-in-out infinite alternate;
|
||||
-webkit-text-stroke: 1px rgba(0,0,0,0.3);
|
||||
}
|
||||
@keyframes announce-pulse {
|
||||
from { transform: scale(1) rotate(-1deg); }
|
||||
to { transform: scale(1.08) rotate(1deg); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useNostr } from '../composables/useNostr'
|
||||
import FightViewer from '../components/FightViewer.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { bot: myBot, isLoggedIn } = useNostr()
|
||||
|
||||
const fightId = ref(route.params.fightId as string)
|
||||
const fight = ref<any>(null)
|
||||
const phase = ref<'waiting' | 'challenge' | 'submitted' | 'between' | 'finished' | 'replay' | 'error'>('waiting')
|
||||
const error = ref('')
|
||||
|
||||
// Challenge state
|
||||
const currentChallenge = ref<{
|
||||
type: string
|
||||
label: string
|
||||
prompt: string
|
||||
roundNumber: number
|
||||
timeoutMs: number
|
||||
remainingMs: number
|
||||
scoring: string
|
||||
} | null>(null)
|
||||
const answer = ref('')
|
||||
const trashTalk = ref('')
|
||||
const remainingSeconds = ref(45)
|
||||
const showTrashTalk = ref(false)
|
||||
|
||||
// Round results tracking
|
||||
const roundResults = ref<Array<{
|
||||
round: number
|
||||
won: boolean
|
||||
hpA: number
|
||||
hpB: number
|
||||
}>>([])
|
||||
const currentRound = ref(0)
|
||||
const myHp = ref(200)
|
||||
const enemyHp = ref(200)
|
||||
const opponentName = ref('')
|
||||
|
||||
let pollHandle: ReturnType<typeof setInterval> | null = null
|
||||
let timerHandle: ReturnType<typeof setInterval> | null = null
|
||||
const answerInput = ref<HTMLTextAreaElement | null>(null)
|
||||
|
||||
const myBotId = computed(() => myBot.value?.id || '')
|
||||
const isMyFight = computed(() => {
|
||||
if (!fight.value || !myBot.value) return false
|
||||
return fight.value.botAId === myBot.value.id || fight.value.botBId === myBot.value.id
|
||||
})
|
||||
const amSideA = computed(() => fight.value?.botAId === myBot.value?.id)
|
||||
|
||||
onMounted(() => {
|
||||
if (!myBot.value) {
|
||||
router.push('/join')
|
||||
return
|
||||
}
|
||||
startPolling()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling()
|
||||
stopTimer()
|
||||
})
|
||||
|
||||
function startPolling() {
|
||||
pollForChallenge()
|
||||
pollHandle = setInterval(pollForChallenge, 600)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollHandle) { clearInterval(pollHandle); pollHandle = null }
|
||||
}
|
||||
|
||||
function startTimer(remainingMs: number) {
|
||||
stopTimer()
|
||||
remainingSeconds.value = Math.ceil(remainingMs / 1000)
|
||||
timerHandle = setInterval(() => {
|
||||
remainingSeconds.value--
|
||||
if (remainingSeconds.value <= 0) {
|
||||
stopTimer()
|
||||
if (phase.value === 'challenge') {
|
||||
phase.value = 'submitted'
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function stopTimer() {
|
||||
if (timerHandle) { clearInterval(timerHandle); timerHandle = null }
|
||||
}
|
||||
|
||||
async function pollForChallenge() {
|
||||
if (!myBotId.value || phase.value === 'finished' || phase.value === 'replay' || phase.value === 'error') return
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/fights/${fightId.value}/challenge/${myBotId.value}`)
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
|
||||
if (data.pending) {
|
||||
if (phase.value !== 'challenge' || currentChallenge.value?.roundNumber !== data.roundNumber) {
|
||||
currentChallenge.value = data
|
||||
currentRound.value = data.roundNumber
|
||||
answer.value = ''
|
||||
trashTalk.value = ''
|
||||
showTrashTalk.value = false
|
||||
phase.value = 'challenge'
|
||||
startTimer(data.remainingMs)
|
||||
await nextTick()
|
||||
answerInput.value?.focus()
|
||||
}
|
||||
} else if (data.fightStatus === 'finished') {
|
||||
stopPolling()
|
||||
stopTimer()
|
||||
await loadFight()
|
||||
phase.value = 'finished'
|
||||
} else if (phase.value === 'submitted') {
|
||||
// Between rounds — waiting for next challenge or fight end
|
||||
phase.value = 'between'
|
||||
}
|
||||
} catch {
|
||||
// Network hiccup, keep polling
|
||||
}
|
||||
}
|
||||
|
||||
async function submitAnswer() {
|
||||
if (!currentChallenge.value || phase.value !== 'challenge') return
|
||||
if (!answer.value.trim()) return
|
||||
|
||||
stopTimer()
|
||||
phase.value = 'submitted'
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/fights/${fightId.value}/respond/${myBotId.value}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
answer: answer.value.trim(),
|
||||
trashTalk: trashTalk.value.trim() || undefined,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
error.value = data.error || 'Failed to submit.'
|
||||
}
|
||||
} catch {
|
||||
error.value = 'Network error submitting answer.'
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFight() {
|
||||
try {
|
||||
const res = await fetch(`/api/fights/${fightId.value}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
fight.value = data
|
||||
if (data.botA && data.botB) {
|
||||
const enemy = amSideA.value ? data.botB : data.botA
|
||||
opponentName.value = enemy?.name || 'Unknown'
|
||||
}
|
||||
if (data.rounds) {
|
||||
let hpA = 200, hpB = 200
|
||||
roundResults.value = data.rounds.map((r: any) => {
|
||||
// Approximate HP from scores — actual HP tracked in fight record
|
||||
return { round: r.roundNumber, won: r.winnerId === myBotId.value, hpA: 0, hpB: 0 }
|
||||
})
|
||||
myHp.value = amSideA.value ? data.botAHp : data.botBHp
|
||||
enemyHp.value = amSideA.value ? data.botBHp : data.botAHp
|
||||
}
|
||||
}
|
||||
} catch { /* */ }
|
||||
}
|
||||
|
||||
function watchReplay() {
|
||||
phase.value = 'replay'
|
||||
}
|
||||
|
||||
function fightAgain() {
|
||||
router.push('/join')
|
||||
}
|
||||
|
||||
function goToArena() {
|
||||
router.push(`/arena/${fightId.value}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-[calc(100vh-4rem)] flex flex-col items-center px-4 py-4 overflow-y-auto">
|
||||
<div class="max-w-lg w-full">
|
||||
|
||||
<!-- PHASE: WAITING -->
|
||||
<div v-if="phase === 'waiting'" class="flex flex-col items-center justify-center min-h-[60vh] gap-4">
|
||||
<div class="w-12 h-12 border-4 border-neon-pink/30 border-t-neon-pink rounded-full animate-spin" />
|
||||
<p class="font-display text-neon-pink text-lg tracking-widest animate-pulse glow-pink">
|
||||
FIGHT STARTING...
|
||||
</p>
|
||||
<p class="font-mono text-text-muted text-xs">Waiting for first challenge</p>
|
||||
</div>
|
||||
|
||||
<!-- PHASE: CHALLENGE (type your answer) -->
|
||||
<div v-else-if="phase === 'challenge' && currentChallenge" class="slide-up">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="font-display font-black text-sm tracking-wider text-neon-cyan">
|
||||
ROUND {{ currentChallenge.roundNumber }}
|
||||
</span>
|
||||
<span
|
||||
class="font-display font-black text-lg tracking-wider"
|
||||
:class="remainingSeconds <= 10 ? 'text-ko animate-pulse' : 'text-neon-yellow'"
|
||||
>
|
||||
{{ remainingSeconds }}s
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="p-3 border-2 border-neon-purple/30 bg-neon-purple/5 mb-3">
|
||||
<div class="flex items-center gap-2 mb-1.5">
|
||||
<span class="font-display font-bold text-[10px] tracking-wider text-neon-purple uppercase">
|
||||
{{ currentChallenge.label }}
|
||||
</span>
|
||||
<span class="font-mono text-[9px] text-text-muted">
|
||||
{{ currentChallenge.scoring === 'factual' ? 'FACTUAL' : 'CREATIVE' }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="font-mono text-sm text-text-primary leading-relaxed">
|
||||
{{ currentChallenge.prompt }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
ref="answerInput"
|
||||
v-model="answer"
|
||||
rows="4"
|
||||
placeholder="Type your answer..."
|
||||
class="w-full bg-surface border-2 border-border px-4 py-3 text-sm font-mono
|
||||
text-text-primary placeholder-text-muted resize-none
|
||||
focus:outline-none focus:border-neon-pink/50 transition-colors mb-2"
|
||||
@keydown.ctrl.enter="submitAnswer"
|
||||
@keydown.meta.enter="submitAnswer"
|
||||
/>
|
||||
|
||||
<button
|
||||
v-if="!showTrashTalk"
|
||||
class="font-mono text-[10px] text-text-muted hover:text-neon-yellow transition-colors mb-2"
|
||||
@click="showTrashTalk = true"
|
||||
>
|
||||
+ add trash talk
|
||||
</button>
|
||||
<input
|
||||
v-if="showTrashTalk"
|
||||
v-model="trashTalk"
|
||||
type="text"
|
||||
maxlength="200"
|
||||
placeholder="Talk smack to your opponent..."
|
||||
class="w-full bg-surface border border-border px-3 py-2 text-xs font-mono
|
||||
text-neon-yellow placeholder-text-muted
|
||||
focus:outline-none focus:border-neon-yellow/50 transition-colors mb-2"
|
||||
/>
|
||||
|
||||
<button
|
||||
class="w-full py-4 bg-neon-pink/10 border-2 border-neon-pink text-neon-pink
|
||||
font-display font-black text-lg tracking-[0.15em]
|
||||
hover:bg-neon-pink/20 transition-all neon-border-pink
|
||||
disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
:disabled="!answer.trim()"
|
||||
@click="submitAnswer"
|
||||
>
|
||||
SUBMIT ANSWER
|
||||
</button>
|
||||
<p class="font-mono text-[10px] text-text-muted text-center mt-1">
|
||||
Ctrl+Enter to submit
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- PHASE: SUBMITTED / BETWEEN ROUNDS -->
|
||||
<div v-else-if="phase === 'submitted' || phase === 'between'" class="flex flex-col items-center justify-center min-h-[60vh] gap-4">
|
||||
<div class="w-10 h-10 border-3 border-neon-cyan/30 border-t-neon-cyan rounded-full animate-spin" />
|
||||
<p class="font-display text-neon-cyan text-sm tracking-widest">
|
||||
{{ phase === 'submitted' ? 'ANSWER SUBMITTED' : 'NEXT ROUND...' }}
|
||||
</p>
|
||||
<p class="font-mono text-text-muted text-xs">Waiting for round result</p>
|
||||
</div>
|
||||
|
||||
<!-- PHASE: FINISHED -->
|
||||
<div v-else-if="phase === 'finished' && fight" class="slide-up">
|
||||
<div class="text-center mb-6">
|
||||
<h2
|
||||
class="font-display font-black text-4xl tracking-wider mb-2"
|
||||
:class="fight.winnerId === myBotId ? 'text-neon-cyan glow-cyan' : 'text-ko'"
|
||||
>
|
||||
{{ fight.winnerId === myBotId ? 'YOU WIN' : fight.winnerId ? 'YOU LOSE' : 'DRAW' }}
|
||||
</h2>
|
||||
<p class="font-mono text-text-muted text-xs">
|
||||
{{ fight.totalRounds }} rounds vs {{ opponentName || 'opponent' }}
|
||||
</p>
|
||||
<p class="font-mono text-xs mt-1">
|
||||
<span class="text-neon-cyan">{{ myHp }}/200 HP</span>
|
||||
<span class="text-text-muted mx-2">vs</span>
|
||||
<span class="text-neon-pink">{{ enemyHp }}/200 HP</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<button
|
||||
class="w-full py-4 bg-neon-cyan/10 border-2 border-neon-cyan/50 text-neon-cyan
|
||||
font-display font-black text-sm tracking-widest
|
||||
hover:bg-neon-cyan/20 transition-all neon-border-cyan"
|
||||
@click="watchReplay"
|
||||
>
|
||||
WATCH REPLAY
|
||||
</button>
|
||||
<button
|
||||
class="w-full py-3 bg-neon-pink/10 border-2 border-neon-pink/50 text-neon-pink
|
||||
font-display font-bold text-sm tracking-wider
|
||||
hover:bg-neon-pink/20 transition-all"
|
||||
@click="fightAgain"
|
||||
>
|
||||
FIGHT AGAIN
|
||||
</button>
|
||||
<button
|
||||
class="w-full py-2 border border-border text-text-muted
|
||||
font-display font-bold text-xs tracking-wider
|
||||
hover:border-neon-purple/30 transition-all"
|
||||
@click="goToArena"
|
||||
>
|
||||
VIEW IN ARENA
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PHASE: REPLAY -->
|
||||
<div v-else-if="phase === 'replay' && fight" class="w-full">
|
||||
<div class="h-[calc(100vh-8rem)] min-h-0 relative">
|
||||
<FightViewer :fight="fight" :autoplay="true" class="h-full" />
|
||||
</div>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<button
|
||||
class="flex-1 py-2 border border-neon-pink/40 text-neon-pink font-display font-bold text-xs tracking-wider
|
||||
hover:bg-neon-pink/10 transition-all"
|
||||
@click="fightAgain"
|
||||
>
|
||||
FIGHT AGAIN
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 py-2 border border-border text-text-muted font-display font-bold text-xs tracking-wider
|
||||
hover:border-neon-purple/30 transition-all"
|
||||
@click="phase = 'finished'"
|
||||
>
|
||||
BACK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ERROR -->
|
||||
<div v-if="error" class="mt-4 p-3 border-2 border-ko/30 bg-ko/5 text-center">
|
||||
<p class="font-mono text-xs text-ko">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -3,12 +3,16 @@ import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useNostr } from '../composables/useNostr'
|
||||
import SpritePreview from '../components/SpritePreview.vue'
|
||||
import HumanPreview from '../components/HumanPreview.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { pubkey, bot, profilePicUrl, isLoggedIn, hasExtension, isLoading, login, registerBot, logout } = useNostr()
|
||||
const { pubkey, bot, profilePicUrl, isLoggedIn, hasExtension, isLoading, login, registerBot, registerHuman, logout } = useNostr()
|
||||
|
||||
// Steps: 'login' | 'pick-character' | 'name-bot' | 'bot-setup' | 'add-webhook' | 'ready'
|
||||
// Steps: 'login' | 'choose-mode' | 'pick-character' | 'name-bot' | 'bot-setup' | 'add-webhook' |
|
||||
// 'pick-human-avatar' | 'name-human' | 'human-guide' | 'ready'
|
||||
const step = ref<string>('login')
|
||||
const isHumanMode = ref(false)
|
||||
const selectedHumanSeed = ref('baby_fighter_1')
|
||||
const error = ref('')
|
||||
const isJoining = ref(false)
|
||||
const queueCount = ref(0)
|
||||
@@ -17,6 +21,7 @@ let pollHandle: ReturnType<typeof setInterval> | null = null
|
||||
// Registration form
|
||||
const selectedArchetype = ref('standard')
|
||||
const botName = ref('')
|
||||
const humanName = ref('')
|
||||
const webhookUrl = ref('')
|
||||
const showCode = ref(false)
|
||||
const codeCopied = ref(false)
|
||||
@@ -104,9 +109,10 @@ async function handleLogin() {
|
||||
try {
|
||||
const result = await login()
|
||||
if (result.bot) {
|
||||
isHumanMode.value = !!result.bot.isHuman
|
||||
step.value = 'ready'
|
||||
} else {
|
||||
step.value = 'pick-character'
|
||||
step.value = 'choose-mode'
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Login failed.'
|
||||
@@ -132,6 +138,68 @@ function confirmName() {
|
||||
step.value = 'bot-setup'
|
||||
}
|
||||
|
||||
function chooseBot() {
|
||||
isHumanMode.value = false
|
||||
step.value = 'pick-character'
|
||||
}
|
||||
|
||||
function chooseHuman() {
|
||||
isHumanMode.value = true
|
||||
step.value = 'pick-human-avatar'
|
||||
}
|
||||
|
||||
const humanAvatarList = [
|
||||
{ seed: 'baby_brawler', label: 'BRAWLER' },
|
||||
{ seed: 'tiny_thinker', label: 'THINKER' },
|
||||
{ seed: 'lil_genius', label: 'GENIUS' },
|
||||
{ seed: 'mini_champ', label: 'CHAMP' },
|
||||
{ seed: 'small_fry', label: 'SMALL FRY' },
|
||||
{ seed: 'baby_brain', label: 'BIG BRAIN' },
|
||||
{ seed: 'little_legend', label: 'LEGEND' },
|
||||
{ seed: 'tiny_terror', label: 'TERROR' },
|
||||
{ seed: 'wee_warrior', label: 'WARRIOR' },
|
||||
{ seed: 'micro_menace', label: 'MENACE' },
|
||||
{ seed: 'baby_boss', label: 'BOSS' },
|
||||
{ seed: 'pint_sized', label: 'PINT SIZE' },
|
||||
{ seed: 'nugget_king', label: 'NUGGET' },
|
||||
{ seed: 'half_pint', label: 'HALF PINT' },
|
||||
{ seed: 'kiddo_smash', label: 'SMASHER' },
|
||||
{ seed: 'tot_puncher', label: 'PUNCHER' },
|
||||
{ seed: 'thumb_war', label: 'THUMB WAR' },
|
||||
{ seed: 'ankle_biter', label: 'ANKLE BITER' },
|
||||
{ seed: 'diaper_doom', label: 'DOOM BABY' },
|
||||
{ seed: 'cradle_rage', label: 'RAGE' },
|
||||
]
|
||||
|
||||
function pickHumanAvatar(seed: string) {
|
||||
selectedHumanSeed.value = seed
|
||||
step.value = 'name-human'
|
||||
}
|
||||
|
||||
async function confirmHumanName() {
|
||||
const name = humanName.value.trim()
|
||||
if (!name || name.length < 2) {
|
||||
error.value = 'Name must be at least 2 characters.'
|
||||
return
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
||||
error.value = 'Letters, numbers, hyphens, underscores only.'
|
||||
return
|
||||
}
|
||||
error.value = ''
|
||||
step.value = 'human-guide'
|
||||
}
|
||||
|
||||
async function registerHumanFighter() {
|
||||
error.value = ''
|
||||
try {
|
||||
await registerHuman(humanName.value.trim(), selectedHumanSeed.value)
|
||||
step.value = 'ready'
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Registration failed.'
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmWebhook() {
|
||||
const url = webhookUrl.value.trim()
|
||||
if (!url) {
|
||||
@@ -206,10 +274,12 @@ function handleSignOut() {
|
||||
class="w-full py-4 bg-neon-purple/10 border-2 border-neon-purple/50 text-neon-purple
|
||||
font-display font-black text-base tracking-widest
|
||||
hover:bg-neon-purple/20 hover:border-neon-purple transition-all
|
||||
disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
disabled:opacity-50 disabled:cursor-wait
|
||||
flex items-center justify-center gap-3"
|
||||
:disabled="isLoading"
|
||||
@click="handleLogin"
|
||||
>
|
||||
<span v-if="isLoading" class="w-5 h-5 border-2 border-neon-purple/30 border-t-neon-purple rounded-full animate-spin" />
|
||||
{{ isLoading ? 'CONNECTING...' : 'SIGN IN WITH NOSTR' }}
|
||||
</button>
|
||||
|
||||
@@ -227,6 +297,48 @@ function handleSignOut() {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- STEP: CHOOSE MODE -->
|
||||
<template v-else-if="step === 'choose-mode'">
|
||||
<div class="text-center mb-8">
|
||||
<h2 class="font-display font-black text-3xl tracking-wider gradient-text mb-3">
|
||||
HOW DO YOU FIGHT?
|
||||
</h2>
|
||||
<p class="font-mono text-text-muted text-xs">
|
||||
Choose your path, warrior.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<button
|
||||
class="w-full py-5 px-4 bg-neon-cyan/5 border-2 border-neon-cyan/40 text-left
|
||||
hover:bg-neon-cyan/10 hover:border-neon-cyan/70 transition-all group"
|
||||
@click="chooseBot"
|
||||
>
|
||||
<span class="font-display font-black text-lg tracking-wider text-neon-cyan block mb-1">
|
||||
I BUILD BOTS
|
||||
</span>
|
||||
<span class="font-mono text-[10px] text-text-muted leading-relaxed block">
|
||||
Deploy an AI bot server. It answers challenges via webhook.
|
||||
Your code fights for you 24/7.
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="w-full py-5 px-4 bg-neon-pink/5 border-2 border-neon-pink/40 text-left
|
||||
hover:bg-neon-pink/10 hover:border-neon-pink/70 transition-all group"
|
||||
@click="chooseHuman"
|
||||
>
|
||||
<span class="font-display font-black text-lg tracking-wider text-neon-pink block mb-1">
|
||||
I FIGHT MYSELF
|
||||
</span>
|
||||
<span class="font-mono text-[10px] text-text-muted leading-relaxed block">
|
||||
Type your own answers in real-time. You vs the AIs, brain to brain.
|
||||
No coding required.
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- STEP: PICK CHARACTER -->
|
||||
<template v-else-if="step === 'pick-character'">
|
||||
<div class="text-center mb-6">
|
||||
@@ -481,6 +593,155 @@ function handleSignOut() {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- STEP: PICK HUMAN AVATAR -->
|
||||
<template v-else-if="step === 'pick-human-avatar'">
|
||||
<div class="text-center mb-6">
|
||||
<h2 class="font-display font-black text-2xl tracking-wider gradient-text mb-2">
|
||||
PICK YOUR BABY
|
||||
</h2>
|
||||
<p class="font-mono text-text-muted text-xs">
|
||||
Every human starts as a baby. Win fights to grow.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 sm:grid-cols-5 gap-2 max-h-[55vh] overflow-y-auto pr-1">
|
||||
<button
|
||||
v-for="avatar in humanAvatarList"
|
||||
:key="avatar.seed"
|
||||
class="flex flex-col items-center p-1.5 sm:p-2 border-2 transition-all text-center
|
||||
hover:border-neon-pink/40 hover:bg-neon-pink/5"
|
||||
:class="selectedHumanSeed === avatar.seed
|
||||
? 'border-neon-pink/70 bg-neon-pink/10'
|
||||
: 'border-border bg-surface'"
|
||||
@click="pickHumanAvatar(avatar.seed)"
|
||||
>
|
||||
<HumanPreview :seed="avatar.seed" :win-rate="0" :size="48" class="mb-1" />
|
||||
<span class="font-display font-bold text-[8px] sm:text-[9px] tracking-wider text-text-primary">{{ avatar.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- STEP: NAME HUMAN -->
|
||||
<template v-else-if="step === 'name-human'">
|
||||
<div class="text-center mb-6">
|
||||
<h2 class="font-display font-black text-2xl tracking-wider gradient-text mb-2">
|
||||
NAME YOUR FIGHTER
|
||||
</h2>
|
||||
<p class="font-mono text-text-muted text-xs">
|
||||
You're entering as a human baby. Grow strong.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-5">
|
||||
<input
|
||||
v-model="humanName"
|
||||
type="text"
|
||||
required
|
||||
maxlength="32"
|
||||
placeholder="big_brain_gary"
|
||||
class="w-full bg-surface border-2 border-border px-4 py-3 text-sm font-mono
|
||||
text-text-primary placeholder-text-muted
|
||||
focus:outline-none focus:border-neon-pink/50 transition-colors"
|
||||
@keyup.enter="confirmHumanName"
|
||||
/>
|
||||
<p class="font-mono text-[10px] text-text-muted mt-1.5">
|
||||
Letters, numbers, hyphens, underscores. 2-32 chars.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="flex-1 py-3 border-2 border-border text-text-muted font-display font-bold text-sm tracking-wider
|
||||
hover:border-neon-purple/40 transition-all"
|
||||
@click="step = 'pick-human-avatar'"
|
||||
>
|
||||
BACK
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 py-3 bg-neon-pink/10 border-2 border-neon-pink/50 text-neon-pink
|
||||
font-display font-bold text-sm tracking-wider
|
||||
hover:bg-neon-pink/20 transition-all"
|
||||
@click="confirmHumanName"
|
||||
>
|
||||
NEXT
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- STEP: HUMAN GUIDE -->
|
||||
<template v-else-if="step === 'human-guide'">
|
||||
<div class="text-center mb-5">
|
||||
<h2 class="font-display font-black text-2xl tracking-wider gradient-text mb-2">
|
||||
QUICK GUIDE
|
||||
</h2>
|
||||
<p class="font-mono text-text-muted text-xs">
|
||||
Here's how human vs AI fights work.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2.5 mb-5">
|
||||
<div class="flex items-start gap-3 p-2.5 border border-border bg-surface">
|
||||
<span class="font-display font-black text-neon-pink text-sm mt-0.5">1</span>
|
||||
<div>
|
||||
<p class="font-mono text-xs text-text-primary">You get a challenge each round</p>
|
||||
<p class="font-mono text-[10px] text-text-muted mt-0.5">Trivia, wordplay, creative writing, coding puzzles, and more</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3 p-2.5 border border-border bg-surface">
|
||||
<span class="font-display font-black text-neon-pink text-sm mt-0.5">2</span>
|
||||
<div>
|
||||
<p class="font-mono text-xs text-text-primary">Type your answer fast — 5 seconds per round</p>
|
||||
<p class="font-mono text-[10px] text-text-muted mt-0.5">Keep answers short and sharp. Speed is everything.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3 p-2.5 border border-border bg-surface">
|
||||
<span class="font-display font-black text-neon-pink text-sm mt-0.5">3</span>
|
||||
<div>
|
||||
<p class="font-mono text-xs text-text-primary">Your answer is scored against the AI bot's answer</p>
|
||||
<p class="font-mono text-[10px] text-text-muted mt-0.5">Better answer deals more damage. First to 0 HP loses.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3 p-2.5 border border-border bg-surface">
|
||||
<span class="font-display font-black text-neon-pink text-sm mt-0.5">4</span>
|
||||
<div>
|
||||
<p class="font-mono text-xs text-text-primary">After the fight, watch the animated replay</p>
|
||||
<p class="font-mono text-[10px] text-text-muted mt-0.5">See your answers come to life in pixel art combat</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-3 border border-neon-pink/20 bg-neon-pink/5 mb-5">
|
||||
<p class="font-display font-bold text-[10px] tracking-wider text-neon-pink mb-1.5">
|
||||
TIPS FOR BEATING AIs
|
||||
</p>
|
||||
<ul class="font-mono text-[10px] text-text-muted space-y-1 leading-relaxed">
|
||||
<li>Be <span class="text-text-secondary">creative</span> — boring answers score low</li>
|
||||
<li>Be <span class="text-text-secondary">fast</span> — speed breaks ties</li>
|
||||
<li>Add <span class="text-text-secondary">trash talk</span> — it doesn't affect scoring but it's fun</li>
|
||||
<li>Factual challenges have <span class="text-text-secondary">right answers</span> — accuracy matters</li>
|
||||
<li>Creative challenges are <span class="text-text-secondary">judged on quality</span> — go wild</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="flex-1 py-3 border-2 border-border text-text-muted font-display font-bold text-sm tracking-wider
|
||||
hover:border-neon-purple/40 transition-all"
|
||||
@click="step = 'name-human'"
|
||||
>
|
||||
BACK
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 py-3 bg-neon-pink/10 border-2 border-neon-pink/50 text-neon-pink
|
||||
font-display font-bold text-sm tracking-wider
|
||||
hover:bg-neon-pink/20 transition-all"
|
||||
@click="registerHumanFighter"
|
||||
>
|
||||
LET'S GO
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- STEP: READY TO FIGHT -->
|
||||
<template v-else-if="step === 'ready' && bot">
|
||||
<div class="text-center mb-6">
|
||||
@@ -494,7 +755,7 @@ function handleSignOut() {
|
||||
{{ bot.name }}
|
||||
</h2>
|
||||
<p class="font-mono text-text-muted text-[10px]">
|
||||
{{ bot.archetype?.toUpperCase() || 'FIGHTER' }} · {{ bot.wins }}W {{ bot.losses }}L · {{ Math.round(bot.eloRating) }} ELO
|
||||
{{ (isHumanMode || bot.isHuman) ? 'HUMAN' : (bot.archetype?.toUpperCase() || 'FIGHTER') }} · {{ bot.wins }}W {{ bot.losses }}L · {{ Math.round(bot.eloRating) }} ELO
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -512,14 +773,16 @@ function handleSignOut() {
|
||||
class="w-full py-5 bg-neon-pink/10 border-2 border-neon-pink text-neon-pink
|
||||
font-display font-black text-2xl tracking-[0.2em]
|
||||
hover:bg-neon-pink/20 transition-all neon-border-pink
|
||||
disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
disabled:opacity-50 disabled:cursor-wait
|
||||
flex items-center justify-center gap-3"
|
||||
:disabled="isJoining"
|
||||
@click="fight"
|
||||
>
|
||||
<span v-if="isJoining" class="w-5 h-5 border-2 border-neon-pink/30 border-t-neon-pink rounded-full animate-spin" />
|
||||
{{ isJoining ? 'MATCHING...' : 'FIGHT' }}
|
||||
</button>
|
||||
<p class="font-mono text-[10px] text-text-muted text-center -mt-1">
|
||||
Queue up against a real AI bot
|
||||
{{ (isHumanMode || bot.isHuman) ? 'Type your answers live against an AI' : 'Queue up against a real AI bot' }}
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,11 @@ const routes = [
|
||||
name: 'bot-profile',
|
||||
component: () => import('./pages/BotProfilePage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/play/:fightId',
|
||||
name: 'human-fight',
|
||||
component: () => import('./pages/HumanFightPage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'register',
|
||||
|
||||
@@ -17,7 +17,7 @@ const VALID_ARCHETYPES = new Set([
|
||||
'hamster', 'sock_puppet', 'traffic_cone', 'toilet_man', 'potato',
|
||||
'cloud_man', 'rock_man', 'balloon_man', 'trash_can', 'rubber_duck',
|
||||
'snowman', 'scarecrow', 'jack_o_lantern', 'garden_gnome', 'lamp_post',
|
||||
'broom_man',
|
||||
'broom_man', 'human',
|
||||
])
|
||||
|
||||
const HEX_COLOR_RE = /^#[0-9a-fA-F]{6}$/
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// In-memory store for pending human challenges.
|
||||
// When the fight engine needs a human's response, it stores the challenge here
|
||||
// and waits for the browser to submit the answer via REST.
|
||||
|
||||
import type { Challenge } from './challenges.js'
|
||||
|
||||
interface PendingChallenge {
|
||||
fightId: string
|
||||
botId: string
|
||||
challenge: Challenge
|
||||
roundNumber: number
|
||||
createdAt: number
|
||||
resolve: (response: { answer: string; trashTalk?: string }) => void
|
||||
timeoutHandle: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
const pending = new Map<string, PendingChallenge>()
|
||||
|
||||
const HUMAN_TIMEOUT_MS = 8_000
|
||||
|
||||
export function isHumanPlayer(webhookUrl: string): boolean {
|
||||
return webhookUrl === 'http://human.local/'
|
||||
}
|
||||
|
||||
export function waitForHumanResponse(
|
||||
fightId: string,
|
||||
botId: string,
|
||||
challenge: Challenge,
|
||||
roundNumber: number,
|
||||
): Promise<{ answer: string | null; trashTalk?: string; timedOut: boolean }> {
|
||||
return new Promise((resolve) => {
|
||||
const key = `${fightId}:${botId}`
|
||||
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
pending.delete(key)
|
||||
console.log(`[human] ${key} timed out after ${HUMAN_TIMEOUT_MS}ms`)
|
||||
resolve({ answer: null, timedOut: true })
|
||||
}, HUMAN_TIMEOUT_MS)
|
||||
|
||||
pending.set(key, {
|
||||
fightId,
|
||||
botId,
|
||||
challenge,
|
||||
roundNumber,
|
||||
createdAt: Date.now(),
|
||||
resolve: (response) => {
|
||||
clearTimeout(timeoutHandle)
|
||||
pending.delete(key)
|
||||
resolve({ answer: response.answer, trashTalk: response.trashTalk, timedOut: false })
|
||||
},
|
||||
timeoutHandle,
|
||||
})
|
||||
|
||||
console.log(`[human] waiting for response: ${key} round=${roundNumber} type=${challenge.type}`)
|
||||
})
|
||||
}
|
||||
|
||||
export function submitHumanResponse(
|
||||
fightId: string,
|
||||
botId: string,
|
||||
answer: string,
|
||||
trashTalk?: string,
|
||||
): boolean {
|
||||
const key = `${fightId}:${botId}`
|
||||
const entry = pending.get(key)
|
||||
if (!entry) return false
|
||||
console.log(`[human] response received: ${key} answer=${answer.slice(0, 80)}`)
|
||||
entry.resolve({ answer: answer.slice(0, 2000), trashTalk: trashTalk?.slice(0, 200) })
|
||||
return true
|
||||
}
|
||||
|
||||
export function getPendingChallenge(
|
||||
fightId: string,
|
||||
botId: string,
|
||||
): {
|
||||
type: string
|
||||
label: string
|
||||
prompt: string
|
||||
roundNumber: number
|
||||
timeoutMs: number
|
||||
remainingMs: number
|
||||
scoring: string
|
||||
} | null {
|
||||
const key = `${fightId}:${botId}`
|
||||
const entry = pending.get(key)
|
||||
if (!entry) return null
|
||||
|
||||
const elapsed = Date.now() - entry.createdAt
|
||||
const remaining = Math.max(0, HUMAN_TIMEOUT_MS - elapsed)
|
||||
|
||||
return {
|
||||
type: entry.challenge.type,
|
||||
label: entry.challenge.label,
|
||||
prompt: entry.challenge.prompt,
|
||||
roundNumber: entry.roundNumber,
|
||||
timeoutMs: HUMAN_TIMEOUT_MS,
|
||||
remainingMs: remaining,
|
||||
scoring: entry.challenge.scoring,
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { scoreRound, calculateElo, calculateTier } from './scoring.js'
|
||||
import { fightEvents } from './events.js'
|
||||
import { generateMockBotResponse } from './mock.js'
|
||||
import { setCooldown } from './queue.js'
|
||||
import { isHumanPlayer, waitForHumanResponse } from './human-responses.js'
|
||||
|
||||
interface BotRecord {
|
||||
id: string
|
||||
@@ -68,6 +69,7 @@ function isAllowedWebhookUrl(url: string): boolean {
|
||||
}
|
||||
|
||||
export { isAllowedWebhookUrl }
|
||||
export { isHumanPlayer } from './human-responses.js'
|
||||
|
||||
// Size-limited body reader to prevent OOM
|
||||
async function readLimitedBody(res: Response, maxBytes: number): Promise<string> {
|
||||
@@ -202,6 +204,23 @@ async function getBotResponse(
|
||||
opponent: { name: string; wins: number; losses: number },
|
||||
arena: Arena,
|
||||
): Promise<WebhookResponse> {
|
||||
if (isHumanPlayer(bot.webhookUrl)) {
|
||||
console.log(`[fight] ${bot.name} is human player, waiting for browser response`)
|
||||
emit(fightId, 'human_challenge', {
|
||||
botId: bot.id,
|
||||
round: roundNumber,
|
||||
type: challenge.type,
|
||||
label: challenge.label,
|
||||
prompt: challenge.prompt,
|
||||
timeoutMs: 8000,
|
||||
scoring: challenge.scoring,
|
||||
})
|
||||
const start = Date.now()
|
||||
const result = await waitForHumanResponse(fightId, bot.id, challenge, roundNumber)
|
||||
const elapsed = Date.now() - start
|
||||
return { answer: result.answer, trashTalk: result.trashTalk, timeMs: elapsed, timedOut: result.timedOut, error: false }
|
||||
}
|
||||
|
||||
if (isMockBot(bot.webhookUrl)) {
|
||||
console.log(`[fight] ${bot.name} is mock bot, generating response`)
|
||||
const mock = generateMockBotResponse(challenge, bot.name)
|
||||
@@ -250,7 +269,7 @@ async function createFightRecord(botA: BotRecord, botB: BotRecord, arena: Arena)
|
||||
|
||||
// Track webhook errors per bot
|
||||
async function trackWebhookResult(botId: string, webhookUrl: string, succeeded: boolean) {
|
||||
if (isMockBot(webhookUrl)) return
|
||||
if (isMockBot(webhookUrl) || isHumanPlayer(webhookUrl)) return
|
||||
if (succeeded) {
|
||||
await db.update(schema.bots).set({ consecutiveErrors: 0 }).where(eq(schema.bots.id, botId))
|
||||
} else {
|
||||
|
||||
@@ -40,10 +40,17 @@ authRouter.post('/login', async (c) => {
|
||||
}
|
||||
|
||||
const bot = rows[0]
|
||||
|
||||
// Check if this is a human player by loading webhookUrl
|
||||
const webhookRows = await db.select({ webhookUrl: schema.bots.webhookUrl })
|
||||
.from(schema.bots).where(eq(schema.bots.id, bot.id)).limit(1)
|
||||
const isHuman = webhookRows[0]?.webhookUrl === 'http://human.local/'
|
||||
|
||||
return c.json({
|
||||
exists: true,
|
||||
bot: {
|
||||
...bot,
|
||||
isHuman,
|
||||
customization: bot.customization ? JSON.parse(bot.customization) : null,
|
||||
},
|
||||
})
|
||||
@@ -147,6 +154,69 @@ authRouter.post('/register', rateLimit(3600_000, 5), async (c) => {
|
||||
}, 201)
|
||||
})
|
||||
|
||||
|
||||
// Register a human player (no webhook required)
|
||||
authRouter.post('/register-human', rateLimit(3600_000, 5), async (c) => {
|
||||
const body = await c.req.json()
|
||||
const { pubkey, name, profilePicUrl, avatarSeed } = body
|
||||
|
||||
if (!pubkey || typeof pubkey !== 'string' || pubkey.length !== 64) {
|
||||
return c.json({ error: 'Invalid pubkey.' }, 400)
|
||||
}
|
||||
|
||||
if (!name || typeof name !== 'string' || name.length < 2 || name.length > 32) {
|
||||
return c.json({ error: 'Name must be 2-32 characters.' }, 400)
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
||||
return c.json({ error: 'Name must be alphanumeric, hyphens, or underscores.' }, 400)
|
||||
}
|
||||
|
||||
const normalizedName = name.toLowerCase()
|
||||
|
||||
const existingPk = await db.select({ id: schema.bots.id })
|
||||
.from(schema.bots)
|
||||
.where(eq(schema.bots.publicKey, pubkey))
|
||||
.limit(1)
|
||||
|
||||
if (existingPk.length > 0) {
|
||||
return c.json({ error: 'This Nostr key already has a fighter.' }, 409)
|
||||
}
|
||||
|
||||
const existingName = await db.select({ id: schema.bots.id })
|
||||
.from(schema.bots)
|
||||
.where(eq(sql`LOWER(${schema.bots.name})`, normalizedName))
|
||||
.limit(1)
|
||||
|
||||
if (existingName.length > 0) {
|
||||
return c.json({ error: 'That name is already taken.' }, 409)
|
||||
}
|
||||
|
||||
const id = nanoid(12)
|
||||
const secret = randomBytes(32).toString('hex')
|
||||
|
||||
await db.insert(schema.bots).values({
|
||||
id,
|
||||
name: normalizedName,
|
||||
webhookUrl: 'http://human.local/',
|
||||
avatarSeed: avatarSeed || normalizedName,
|
||||
archetype: 'human',
|
||||
secretHash: createHash('sha256').update(secret).digest('hex'),
|
||||
publicKey: pubkey,
|
||||
profilePicUrl: profilePicUrl || null,
|
||||
customization: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
|
||||
return c.json({
|
||||
id,
|
||||
name: normalizedName,
|
||||
archetype: 'human',
|
||||
isHuman: true,
|
||||
message: 'Human fighter registered.',
|
||||
}, 201)
|
||||
})
|
||||
|
||||
// Update bot webhook and/or customization (requires pubkey match)
|
||||
authRouter.post('/update', async (c) => {
|
||||
const body = await c.req.json()
|
||||
|
||||
@@ -8,6 +8,7 @@ import { startFightLoop } from '../engine/fight-loop.js'
|
||||
import { runFight, runFightAsync, isInFight } from '../engine/orchestrator.js'
|
||||
import { fightEvents } from '../engine/events.js'
|
||||
import { botRateLimit } from '../middleware/rate-limit.js'
|
||||
import { getPendingChallenge, submitHumanResponse } from '../engine/human-responses.js'
|
||||
|
||||
export const fightsRouter = new Hono()
|
||||
|
||||
@@ -221,6 +222,45 @@ fightsRouter.post('/matchmake/:botId', botRateLimit(10_000), async (c) => {
|
||||
})
|
||||
})
|
||||
|
||||
// Get pending challenge for a human player in an active fight
|
||||
fightsRouter.get('/:fightId/challenge/:botId', async (c) => {
|
||||
const fightId = c.req.param('fightId')
|
||||
const botId = c.req.param('botId')
|
||||
|
||||
const challenge = getPendingChallenge(fightId, botId)
|
||||
if (!challenge) {
|
||||
// Check if fight is still active
|
||||
const fight = await db.select({ status: schema.fights.status })
|
||||
.from(schema.fights)
|
||||
.where(eq(schema.fights.id, fightId))
|
||||
.limit(1)
|
||||
|
||||
const status = fight[0]?.status || 'unknown'
|
||||
return c.json({ pending: false, fightStatus: status })
|
||||
}
|
||||
|
||||
return c.json({ pending: true, ...challenge })
|
||||
})
|
||||
|
||||
// Submit human response to a challenge
|
||||
fightsRouter.post('/:fightId/respond/:botId', async (c) => {
|
||||
const fightId = c.req.param('fightId')
|
||||
const botId = c.req.param('botId')
|
||||
const body = await c.req.json()
|
||||
|
||||
const { answer, trashTalk } = body
|
||||
if (!answer || typeof answer !== 'string') {
|
||||
return c.json({ error: 'Answer is required.' }, 400)
|
||||
}
|
||||
|
||||
const accepted = submitHumanResponse(fightId, botId, answer, trashTalk)
|
||||
if (!accepted) {
|
||||
return c.json({ error: 'No pending challenge found. May have timed out.' }, 404)
|
||||
}
|
||||
|
||||
return c.json({ accepted: true })
|
||||
})
|
||||
|
||||
// SSE stream for live fight events
|
||||
fightsRouter.get('/:id/stream', (c) => {
|
||||
const fightId = c.req.param('id')
|
||||
|
||||
Reference in New Issue
Block a user