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:
Dorian
2026-03-07 15:20:14 +00:00
co-authored by Claude Opus 4.6
parent 56785cfdea
commit 8448f1d823
15 changed files with 1805 additions and 85 deletions
+1 -1
View File
@@ -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}$/
+100
View File
@@ -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,
}
}
+20 -1
View File
@@ -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 {
+70
View File
@@ -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()
+40
View File
@@ -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')