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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user