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
+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')