feat: botfights v1 — full fighting game with Kaplay engine
- Vue 3 + Vite + Tailwind 4 frontend with synthwave aesthetic - Hono backend on port 9100 with SQLite/Drizzle - Procedural pixel-art sprite generator (48x48, 8 animation states) - Kaplay fight scene with punch/kick/special/knockback/KO animations - 12 mock bots across 6 tiers with Elo rating system - 9 challenge types, 10 fight arenas with modifiers - Fight replay with staggered battle log and ~1 min timing - Sprite preview page at /sprites Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
import { nanoid } from 'nanoid'
|
||||
import { db, schema } from '../db/index.js'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { randomArena, type Arena } from './arenas.js'
|
||||
import { pickChallenge, type Challenge } from './challenges.js'
|
||||
import { scoreRound, calculateElo, calculateTier } from './scoring.js'
|
||||
import { fightEvents } from './events.js'
|
||||
|
||||
interface BotRecord {
|
||||
id: string
|
||||
name: string
|
||||
webhookUrl: string
|
||||
eloRating: number
|
||||
wins: number
|
||||
losses: number
|
||||
winStreak: number
|
||||
bestStreak: number
|
||||
}
|
||||
|
||||
interface WebhookResponse {
|
||||
answer: string | null
|
||||
trashTalk?: string
|
||||
timeMs: number
|
||||
timedOut: boolean
|
||||
error: boolean
|
||||
}
|
||||
|
||||
const MAX_ROUNDS = 7
|
||||
const KO_THRESHOLD = 0
|
||||
|
||||
function emit(fightId: string, type: string, data: Record<string, unknown>) {
|
||||
fightEvents.emit({
|
||||
fightId,
|
||||
type,
|
||||
data,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
async function callWebhook(
|
||||
url: string,
|
||||
challenge: Challenge,
|
||||
roundNumber: number,
|
||||
opponent: { name: string; wins: number; losses: number },
|
||||
arena: Arena,
|
||||
): Promise<WebhookResponse> {
|
||||
const body = JSON.stringify({
|
||||
round: roundNumber,
|
||||
type: challenge.type,
|
||||
challenge: challenge.prompt,
|
||||
constraints: {
|
||||
timeout_ms: challenge.timeout_ms,
|
||||
max_tokens: 500,
|
||||
},
|
||||
opponent,
|
||||
arena: arena.id,
|
||||
arena_modifier: arena.modifier,
|
||||
})
|
||||
|
||||
const start = Date.now()
|
||||
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), challenge.timeout_ms)
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
clearTimeout(timeout)
|
||||
const elapsed = Date.now() - start
|
||||
|
||||
if (!res.ok) {
|
||||
return { answer: null, timeMs: elapsed, timedOut: false, error: true }
|
||||
}
|
||||
|
||||
const data = await res.json() as { answer?: string; trash_talk?: string }
|
||||
return {
|
||||
answer: data.answer || null,
|
||||
trashTalk: data.trash_talk,
|
||||
timeMs: elapsed,
|
||||
timedOut: false,
|
||||
error: false,
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const elapsed = Date.now() - start
|
||||
const isAbort = err instanceof Error && err.name === 'AbortError'
|
||||
return {
|
||||
answer: null,
|
||||
timeMs: elapsed,
|
||||
timedOut: isAbort,
|
||||
error: !isAbort,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function runFight(botAId: string, botBId: string): Promise<string> {
|
||||
// Load bots
|
||||
const [botARows, botBRows] = await Promise.all([
|
||||
db.select().from(schema.bots).where(eq(schema.bots.id, botAId)).limit(1),
|
||||
db.select().from(schema.bots).where(eq(schema.bots.id, botBId)).limit(1),
|
||||
])
|
||||
|
||||
if (botARows.length === 0 || botBRows.length === 0) {
|
||||
throw new Error('One or both bots not found')
|
||||
}
|
||||
|
||||
const botA = botARows[0] as BotRecord
|
||||
const botB = botBRows[0] as BotRecord
|
||||
|
||||
const arena = randomArena()
|
||||
const fightId = nanoid(12)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// Create fight record
|
||||
await db.insert(schema.fights).values({
|
||||
id: fightId,
|
||||
botAId: botA.id,
|
||||
botBId: botB.id,
|
||||
arena: arena.id,
|
||||
status: 'live',
|
||||
startedAt: now,
|
||||
createdAt: now,
|
||||
})
|
||||
|
||||
emit(fightId, 'fight_start', {
|
||||
botA: { id: botA.id, name: botA.name, elo: botA.eloRating },
|
||||
botB: { id: botB.id, name: botB.name, elo: botB.eloRating },
|
||||
arena: { id: arena.id, name: arena.name, description: arena.description, modifier: arena.modifier },
|
||||
})
|
||||
|
||||
let hpA = 100
|
||||
let hpB = 100
|
||||
let comboA = 0
|
||||
let comboB = 0
|
||||
let winnerId: string | null = null
|
||||
const usedTypes = new Set<string>()
|
||||
|
||||
for (let round = 1; round <= MAX_ROUNDS; round++) {
|
||||
const challenge = pickChallenge(usedTypes, arena.modifier)
|
||||
usedTypes.add(challenge.type)
|
||||
|
||||
emit(fightId, 'round_start', {
|
||||
round,
|
||||
challenge: { type: challenge.type, label: challenge.label, prompt: challenge.prompt },
|
||||
})
|
||||
|
||||
// Call both bots simultaneously
|
||||
const [responseA, responseB] = await Promise.all([
|
||||
callWebhook(botA.webhookUrl, challenge, round, { name: botB.name, wins: botB.wins, losses: botB.losses }, arena),
|
||||
callWebhook(botB.webhookUrl, challenge, round, { name: botA.name, wins: botA.wins, losses: botA.losses }, arena),
|
||||
])
|
||||
|
||||
// Score the round
|
||||
const result = scoreRound(
|
||||
challenge,
|
||||
{ id: botA.id, name: botA.name },
|
||||
{ id: botB.id, name: botB.name },
|
||||
{ answer: responseA.answer, timeMs: responseA.timeMs, timedOut: responseA.timedOut, error: responseA.error, trashTalk: responseA.trashTalk },
|
||||
{ answer: responseB.answer, timeMs: responseB.timeMs, timedOut: responseB.timedOut, error: responseB.error, trashTalk: responseB.trashTalk },
|
||||
arena.modifier,
|
||||
comboA,
|
||||
comboB,
|
||||
)
|
||||
|
||||
// Apply damage
|
||||
hpB = Math.max(KO_THRESHOLD, hpB - result.botADamage)
|
||||
hpA = Math.max(KO_THRESHOLD, hpA - result.botBDamage)
|
||||
|
||||
// Update combos
|
||||
if (result.winnerId === botA.id) {
|
||||
comboA++
|
||||
comboB = 0
|
||||
} else if (result.winnerId === botB.id) {
|
||||
comboB++
|
||||
comboA = 0
|
||||
}
|
||||
|
||||
// Save round
|
||||
await db.insert(schema.rounds).values({
|
||||
id: nanoid(12),
|
||||
fightId,
|
||||
roundNumber: round,
|
||||
challengeType: challenge.type,
|
||||
challengeData: JSON.stringify({ prompt: challenge.prompt, scoring: challenge.scoring }),
|
||||
botAResponse: responseA.answer,
|
||||
botATimeMs: responseA.timeMs,
|
||||
botAScore: result.botAScore,
|
||||
botBResponse: responseB.answer,
|
||||
botBTimeMs: responseB.timeMs,
|
||||
botBScore: result.botBScore,
|
||||
winnerId: result.winnerId,
|
||||
narration: result.narration,
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
|
||||
emit(fightId, 'round_end', {
|
||||
round,
|
||||
result: {
|
||||
...result,
|
||||
botAResponse: responseA.answer?.slice(0, 200),
|
||||
botBResponse: responseB.answer?.slice(0, 200),
|
||||
botATimeMs: responseA.timeMs,
|
||||
botBTimeMs: responseB.timeMs,
|
||||
botATrashTalk: responseA.trashTalk,
|
||||
botBTrashTalk: responseB.trashTalk,
|
||||
},
|
||||
hp: { a: hpA, b: hpB },
|
||||
combo: { a: comboA, b: comboB },
|
||||
})
|
||||
|
||||
// Update fight HP in DB
|
||||
await db.update(schema.fights).set({
|
||||
botAHp: hpA,
|
||||
botBHp: hpB,
|
||||
totalRounds: round,
|
||||
}).where(eq(schema.fights.id, fightId))
|
||||
|
||||
// Check for KO
|
||||
if (hpA <= KO_THRESHOLD || hpB <= KO_THRESHOLD) {
|
||||
winnerId = hpA <= KO_THRESHOLD ? botB.id : botA.id
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If no KO, winner is whoever has more HP
|
||||
if (!winnerId) {
|
||||
winnerId = hpA > hpB ? botA.id : hpB > hpA ? botB.id : null
|
||||
}
|
||||
|
||||
const winnerName = winnerId === botA.id ? botA.name : winnerId === botB.id ? botB.name : 'nobody'
|
||||
const isPerfect = winnerId && (
|
||||
(winnerId === botA.id && hpA === 100) ||
|
||||
(winnerId === botB.id && hpB === 100)
|
||||
)
|
||||
|
||||
// Finalize fight
|
||||
await db.update(schema.fights).set({
|
||||
status: 'finished',
|
||||
winnerId,
|
||||
endedAt: new Date().toISOString(),
|
||||
}).where(eq(schema.fights.id, fightId))
|
||||
|
||||
// Update bot stats
|
||||
if (winnerId) {
|
||||
const loserId = winnerId === botA.id ? botB.id : botA.id
|
||||
const winner = winnerId === botA.id ? botA : botB
|
||||
const loser = winnerId === botA.id ? botB : botA
|
||||
|
||||
const { newWinnerElo, newLoserElo } = calculateElo(winner.eloRating, loser.eloRating)
|
||||
const newWinStreak = winner.winStreak + 1
|
||||
const newBestStreak = Math.max(winner.bestStreak, newWinStreak)
|
||||
|
||||
await Promise.all([
|
||||
db.update(schema.bots).set({
|
||||
wins: sql`${schema.bots.wins} + 1`,
|
||||
eloRating: newWinnerElo,
|
||||
winStreak: newWinStreak,
|
||||
bestStreak: newBestStreak,
|
||||
tier: calculateTier(newWinnerElo, winner.wins + 1),
|
||||
}).where(eq(schema.bots.id, winnerId)),
|
||||
db.update(schema.bots).set({
|
||||
losses: sql`${schema.bots.losses} + 1`,
|
||||
eloRating: newLoserElo,
|
||||
winStreak: 0,
|
||||
tier: calculateTier(newLoserElo, loser.wins),
|
||||
}).where(eq(schema.bots.id, loserId)),
|
||||
])
|
||||
}
|
||||
|
||||
emit(fightId, 'fight_end', {
|
||||
winnerId,
|
||||
winnerName,
|
||||
isPerfect,
|
||||
finalHp: { a: hpA, b: hpB },
|
||||
})
|
||||
|
||||
fightEvents.cleanup(fightId)
|
||||
|
||||
return fightId
|
||||
}
|
||||
Reference in New Issue
Block a user