feat: v2 — queue matchmaking, procedural audio, sprite archetypes, auth
- Add queue-based matchmaking with Elo-proximity and 10s timeout - Procedural sound engine (SFX, voice announcer, 4-track music) - Sprite system refactored into 6 archetypes (standard, lobster, sheep, cyborg, blob, tank) - 42+ fight choreographies with themed/generic/wild card selection - 4 KO finish styles, super-speed mode, hyperdetail close-ups - Auth routes, JoinBout page, bot profile with stats - 7-tier ranking system (Baby through Legend) - Arena and challenge system expansions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
335c148866
commit
47d20fbe66
@@ -5,6 +5,7 @@ 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'
|
||||
import { generateMockBotResponse } from './mock.js'
|
||||
|
||||
interface BotRecord {
|
||||
id: string
|
||||
@@ -25,7 +26,7 @@ interface WebhookResponse {
|
||||
error: boolean
|
||||
}
|
||||
|
||||
const MAX_ROUNDS = 7
|
||||
const MAX_ROUNDS = 10
|
||||
const KO_THRESHOLD = 0
|
||||
|
||||
function emit(fightId: string, type: string, data: Record<string, unknown>) {
|
||||
@@ -58,6 +59,7 @@ async function callWebhook(
|
||||
})
|
||||
|
||||
const start = Date.now()
|
||||
console.log(`[webhook] POST ${url} round=${roundNumber} type=${challenge.type}`)
|
||||
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
@@ -74,10 +76,12 @@ async function callWebhook(
|
||||
const elapsed = Date.now() - start
|
||||
|
||||
if (!res.ok) {
|
||||
console.log(`[webhook] ${url} returned ${res.status} in ${elapsed}ms`)
|
||||
return { answer: null, timeMs: elapsed, timedOut: false, error: true }
|
||||
}
|
||||
|
||||
const data = await res.json() as { answer?: string; trash_talk?: string }
|
||||
console.log(`[webhook] ${url} OK in ${elapsed}ms answer=${(data.answer || '').slice(0, 80)}`)
|
||||
return {
|
||||
answer: data.answer || null,
|
||||
trashTalk: data.trash_talk,
|
||||
@@ -88,6 +92,7 @@ async function callWebhook(
|
||||
} catch (err: unknown) {
|
||||
const elapsed = Date.now() - start
|
||||
const isAbort = err instanceof Error && err.name === 'AbortError'
|
||||
console.log(`[webhook] ${url} ${isAbort ? "TIMEOUT" : "ERROR"} in ${elapsed}ms: ${err instanceof Error ? err.message : err}`)
|
||||
return {
|
||||
answer: null,
|
||||
timeMs: elapsed,
|
||||
@@ -97,25 +102,46 @@ async function callWebhook(
|
||||
}
|
||||
}
|
||||
|
||||
export async function runFight(botAId: string, botBId: string): Promise<string> {
|
||||
// Load bots
|
||||
function isMockBot(webhookUrl: string): boolean {
|
||||
return webhookUrl.startsWith('http://mock.local')
|
||||
}
|
||||
|
||||
async function getBotResponse(
|
||||
bot: BotRecord,
|
||||
challenge: Challenge,
|
||||
roundNumber: number,
|
||||
opponent: { name: string; wins: number; losses: number },
|
||||
arena: Arena,
|
||||
): Promise<WebhookResponse> {
|
||||
if (isMockBot(bot.webhookUrl)) {
|
||||
console.log(`[fight] ${bot.name} is mock bot, generating response`)
|
||||
const mock = generateMockBotResponse(challenge.type, bot.name)
|
||||
return {
|
||||
answer: mock.answer || null,
|
||||
trashTalk: mock.trashTalk,
|
||||
timeMs: mock.timeMs,
|
||||
timedOut: mock.timedOut,
|
||||
error: mock.error,
|
||||
}
|
||||
}
|
||||
console.log(`[fight] ${bot.name} has real webhook: ${bot.webhookUrl}`)
|
||||
return callWebhook(bot.webhookUrl, challenge, roundNumber, opponent, arena)
|
||||
}
|
||||
|
||||
async function loadBots(botAId: string, botBId: string): Promise<[BotRecord, BotRecord]> {
|
||||
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')
|
||||
}
|
||||
return [botARows[0] as BotRecord, botBRows[0] as BotRecord]
|
||||
}
|
||||
|
||||
const botA = botARows[0] as BotRecord
|
||||
const botB = botBRows[0] as BotRecord
|
||||
|
||||
const arena = randomArena()
|
||||
async function createFightRecord(botA: BotRecord, botB: BotRecord, arena: Arena): Promise<string> {
|
||||
const fightId = nanoid(12)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// Create fight record
|
||||
await db.insert(schema.fights).values({
|
||||
id: fightId,
|
||||
botAId: botA.id,
|
||||
@@ -125,15 +151,17 @@ export async function runFight(botAId: string, botBId: string): Promise<string>
|
||||
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 },
|
||||
})
|
||||
return fightId
|
||||
}
|
||||
|
||||
let hpA = 100
|
||||
let hpB = 100
|
||||
async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRecord, arena: Arena): Promise<void> {
|
||||
let hpA = 200
|
||||
let hpB = 200
|
||||
let comboA = 0
|
||||
let comboB = 0
|
||||
let winnerId: string | null = null
|
||||
@@ -148,10 +176,10 @@ export async function runFight(botAId: string, botBId: string): Promise<string>
|
||||
challenge: { type: challenge.type, label: challenge.label, prompt: challenge.prompt },
|
||||
})
|
||||
|
||||
// Call both bots simultaneously
|
||||
// Call both bots simultaneously (mock bots get generated responses)
|
||||
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),
|
||||
getBotResponse(botA, challenge, round, { name: botB.name, wins: botB.wins, losses: botB.losses }, arena),
|
||||
getBotResponse(botB, challenge, round, { name: botA.name, wins: botA.wins, losses: botA.losses }, arena),
|
||||
])
|
||||
|
||||
// Score the round
|
||||
@@ -233,8 +261,8 @@ export async function runFight(botAId: string, botBId: string): Promise<string>
|
||||
|
||||
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)
|
||||
(winnerId === botA.id && hpA === 200) ||
|
||||
(winnerId === botB.id && hpB === 200)
|
||||
)
|
||||
|
||||
// Finalize fight
|
||||
@@ -279,6 +307,23 @@ export async function runFight(botAId: string, botBId: string): Promise<string>
|
||||
})
|
||||
|
||||
fightEvents.cleanup(fightId)
|
||||
}
|
||||
|
||||
export async function runFight(botAId: string, botBId: string): Promise<string> {
|
||||
const [botA, botB] = await loadBots(botAId, botBId)
|
||||
const arena = randomArena()
|
||||
const fightId = await createFightRecord(botA, botB, arena)
|
||||
await executeFightRounds(fightId, botA, botB, arena)
|
||||
return fightId
|
||||
}
|
||||
|
||||
/** Creates the fight record and returns the ID immediately. Rounds run in background. */
|
||||
export async function runFightAsync(botAId: string, botBId: string): Promise<string> {
|
||||
const [botA, botB] = await loadBots(botAId, botBId)
|
||||
const arena = randomArena()
|
||||
const fightId = await createFightRecord(botA, botB, arena)
|
||||
executeFightRounds(fightId, botA, botB, arena).catch(err => {
|
||||
console.error(`[botfights] fight ${fightId} error:`, err)
|
||||
})
|
||||
return fightId
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user