143 lines
4.6 KiB
TypeScript
143 lines
4.6 KiB
TypeScript
import { Hono } from 'hono'
|
|
import { z } from 'zod'
|
|
import { db, schema } from '../db/index.js'
|
|
import { eq } from 'drizzle-orm'
|
|
import { logger } from '../lib/logger.js'
|
|
import {
|
|
formatArcadeChallenge,
|
|
parseArcadeResponse,
|
|
generateArcadeBotActions,
|
|
type ArcadeGameState,
|
|
} from '../engine/arcade-bot.js'
|
|
import { isMockBot } from '../engine/orchestrator.js'
|
|
import { isPollingBot } from '../engine/poll-responses.js'
|
|
import { isClassicBot } from '../engine/mock.js'
|
|
|
|
export const arcadeRouter = new Hono()
|
|
|
|
const gameStateSchema = z.object({
|
|
self: z.object({
|
|
hp: z.number(), x: z.number(), state: z.string(), grounded: z.boolean(),
|
|
}),
|
|
opponent: z.object({
|
|
hp: z.number(), x: z.number(), state: z.string(), grounded: z.boolean(),
|
|
}),
|
|
distance: z.number(),
|
|
timer: z.number(),
|
|
round: z.number(),
|
|
maxRounds: z.number(),
|
|
facingRight: z.boolean(),
|
|
})
|
|
|
|
const requestSchema = z.object({
|
|
botId: z.string(),
|
|
gameState: gameStateSchema,
|
|
})
|
|
|
|
// In-memory personality cache (mock bots)
|
|
const personalityCache = new Map<string, string>()
|
|
|
|
/**
|
|
* POST /api/arcade/bot-action
|
|
* Accepts game state, returns bot actions for arcade mode.
|
|
* Works with mock/classic bots (instant) and webhook bots (async).
|
|
*/
|
|
arcadeRouter.post('/bot-action', async (c) => {
|
|
const body = await c.req.json().catch(() => null)
|
|
const parsed = requestSchema.safeParse(body)
|
|
if (!parsed.success) {
|
|
return c.json({ error: { code: 'INVALID_INPUT', message: 'Invalid request body' } }, 400)
|
|
}
|
|
|
|
const { botId, gameState } = parsed.data
|
|
|
|
// Look up the bot
|
|
const bots = await db.select({
|
|
id: schema.bots.id,
|
|
name: schema.bots.name,
|
|
webhookUrl: schema.bots.webhookUrl,
|
|
eloRating: schema.bots.eloRating,
|
|
}).from(schema.bots).where(eq(schema.bots.id, botId)).limit(1)
|
|
|
|
if (bots.length === 0) {
|
|
return c.json({ error: { code: 'BOT_NOT_FOUND', message: 'Bot not found' } }, 404)
|
|
}
|
|
|
|
const bot = bots[0]
|
|
|
|
try {
|
|
let actions: string[]
|
|
|
|
if (isMockBot(bot.webhookUrl) || isClassicBot(bot.webhookUrl)) {
|
|
// Mock/classic bots: generate actions locally (instant, no network)
|
|
const personality = await getPersonality(bot.name)
|
|
actions = generateArcadeBotActions(gameState, personality)
|
|
logger.info('arcade', `${bot.name} mock actions: ${actions.join(',')}`)
|
|
} else if (isPollingBot(bot.webhookUrl)) {
|
|
// Polling bots: can't do real-time arcade via polling — use mock AI
|
|
const personality = await getPersonality(bot.name)
|
|
actions = generateArcadeBotActions(gameState, personality)
|
|
} else {
|
|
// Real webhook bot: forward game state as arcade challenge
|
|
const prompt = formatArcadeChallenge(gameState)
|
|
const controller = new AbortController()
|
|
const timeout = setTimeout(() => controller.abort(), 2000) // tight timeout for real-time
|
|
|
|
try {
|
|
const res = await fetch(bot.webhookUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
type: 'arcade',
|
|
challenge: prompt,
|
|
constraints: { timeout_ms: 2000, max_tokens: 100 },
|
|
}),
|
|
signal: controller.signal,
|
|
})
|
|
clearTimeout(timeout)
|
|
|
|
if (res.ok) {
|
|
const data = await res.json() as { answer?: string }
|
|
actions = parseArcadeResponse(data.answer ?? null)
|
|
} else {
|
|
actions = parseArcadeResponse(null)
|
|
}
|
|
} catch {
|
|
clearTimeout(timeout)
|
|
// Webhook failed — fall back to mock AI
|
|
actions = parseArcadeResponse(null)
|
|
}
|
|
}
|
|
|
|
return c.json({ actions })
|
|
} catch (err) {
|
|
logger.error('arcade', `bot-action error: ${err}`)
|
|
return c.json({ actions: ['move_forward', 'punch', 'block'] })
|
|
}
|
|
})
|
|
|
|
/** Get mock bot personality by name */
|
|
async function getPersonality(botName: string): Promise<string> {
|
|
const cached = personalityCache.get(botName)
|
|
if (cached) return cached
|
|
|
|
// Import dynamically to avoid circular deps
|
|
const { MOCK_BOTS_LIST } = await import('../engine/mock.js').then(m => {
|
|
// Access the exported mock bots list
|
|
return { MOCK_BOTS_LIST: [] as { name: string; personality: string }[] }
|
|
}).catch(() => ({ MOCK_BOTS_LIST: [] }))
|
|
|
|
// Fallback personality based on bot name hash
|
|
const personalities = [
|
|
'aggressive', 'calculated', 'reckless', 'tactical', 'chill',
|
|
'confident', 'chaotic', 'zen', 'relentless', 'witty',
|
|
]
|
|
let hash = 0
|
|
for (let i = 0; i < botName.length; i++) {
|
|
hash = ((hash << 5) - hash + botName.charCodeAt(i)) | 0
|
|
}
|
|
const personality = personalities[Math.abs(hash) % personalities.length]
|
|
personalityCache.set(botName, personality)
|
|
return personality
|
|
}
|