diff --git a/frontend/src/pages/JoinBoutPage.vue b/frontend/src/pages/JoinBoutPage.vue index 939f90e..1df7867 100644 --- a/frontend/src/pages/JoinBoutPage.vue +++ b/frontend/src/pages/JoinBoutPage.vue @@ -21,6 +21,7 @@ const step = ref('login') const isHumanMode = ref(false) const selectedHumanSeed = ref('baby_fighter_1') const error = ref('') +const activeFightLink = ref('') const rateLimitCountdown = ref(0) let rateLimitTimer: ReturnType | null = null const isJoining = ref(false) @@ -105,6 +106,7 @@ const archetypeList = [ function handleError(e: unknown, fallback: string) { const msg = e instanceof Error ? e.message : fallback error.value = msg + activeFightLink.value = '' // Parse "Slow down" with retry seconds from the error message or check for countdown pattern if (msg.toLowerCase().includes('too many requests') || msg.toLowerCase().includes('slow down')) { startRateLimitTimer(msg) @@ -394,8 +396,13 @@ async function fight() { return // Don't reset flag — navigation will unmount component } else { const data = await res.json() - const msg = data.retryAfterSec ? `${data.error} (${data.retryAfterSec}s)` : (data.error || 'Failed to join.') - handleError(new Error(msg), 'Failed to join.') + if (data.fightId) { + activeFightLink.value = data.fightId + error.value = 'You\'re already in a fight!' + } else { + const msg = data.retryAfterSec ? `${data.error} (${data.retryAfterSec}s)` : (data.error || 'Failed to join.') + handleError(new Error(msg), 'Failed to join.') + } } } catch { error.value = 'Network error.' @@ -443,8 +450,13 @@ async function practice() { return // Don't reset flag — navigation will unmount component } else { const data = await res.json() - const msg = data.retryAfterSec ? `${data.error} (${data.retryAfterSec}s)` : (data.error || 'Failed to start practice fight.') - handleError(new Error(msg), 'Practice fight failed.') + if (data.fightId) { + activeFightLink.value = data.fightId + error.value = 'You\'re already in a fight!' + } else { + const msg = data.retryAfterSec ? `${data.error} (${data.retryAfterSec}s)` : (data.error || 'Failed to start practice fight.') + handleError(new Error(msg), 'Practice fight failed.') + } } } catch { error.value = 'Network error.' @@ -1144,6 +1156,14 @@ function handleSignOut() {

{{ error }}

+ + REJOIN FIGHT +

diff --git a/server/src/engine/orchestrator.ts b/server/src/engine/orchestrator.ts index c2d837a..32ce611 100644 --- a/server/src/engine/orchestrator.ts +++ b/server/src/engine/orchestrator.ts @@ -47,7 +47,8 @@ interface WebhookResponse { import { MAX_ROUNDS, KO_THRESHOLD, MAX_RESPONSE_BYTES, STARTING_HP, ELO_K_FACTOR, ELO_K_FACTOR_MOCK, MAX_ANSWER_LENGTH, MAX_TRASH_TALK_LENGTH } from '../lib/constants.js' // Track bots currently in a fight to prevent concurrent fights -const activeFighters = new Set() +// Maps botId → fightId so we can direct users to their active fight +const activeFighters = new Map() export function getActiveFighterCount(): number { return activeFighters.size @@ -57,6 +58,10 @@ export function isInFight(botId: string): boolean { return activeFighters.has(botId) } +export function getActiveFightId(botId: string): string | undefined { + return activeFighters.get(botId) +} + function emit(fightId: string, type: string, data: Record) { fightEvents.emit({ fightId, @@ -621,13 +626,13 @@ export async function runFight(botAId: string, botBId: string, mode: 'free' | 'r if (activeFighters.has(botAId)) throw new Error(`Bot ${botAId} is already in a fight`) if (activeFighters.has(botBId)) throw new Error(`Bot ${botBId} is already in a fight`) - activeFighters.add(botAId) - activeFighters.add(botBId) + const [botA, botB] = await loadBots(botAId, botBId) + const arena = randomArena() + const fightId = await createFightRecord(botA, botB, arena, mode) + activeFighters.set(botAId, fightId) + activeFighters.set(botBId, fightId) try { - const [botA, botB] = await loadBots(botAId, botBId) - const arena = randomArena() - const fightId = await createFightRecord(botA, botB, arena, mode) await executeFightRounds(fightId, botA, botB, arena, mode) return fightId } finally { @@ -644,12 +649,11 @@ export async function runFightAsync(botAId: string, botBId: string, mode: 'free' if (activeFighters.has(botAId)) throw new Error(`Bot ${botAId} is already in a fight`) if (activeFighters.has(botBId)) throw new Error(`Bot ${botBId} is already in a fight`) - activeFighters.add(botAId) - activeFighters.add(botBId) - const [botA, botB] = await loadBots(botAId, botBId) const arena = randomArena() const fightId = await createFightRecord(botA, botB, arena, mode) + activeFighters.set(botAId, fightId) + activeFighters.set(botBId, fightId) executeFightRounds(fightId, botA, botB, arena, mode) .catch(err => { diff --git a/server/src/engine/queue.ts b/server/src/engine/queue.ts index fe9d537..6a466e4 100644 --- a/server/src/engine/queue.ts +++ b/server/src/engine/queue.ts @@ -1,7 +1,7 @@ import { db, schema } from '../db/index.js' import { logger } from '../lib/logger.js' import { eq } from 'drizzle-orm' -import { runFightAsync, isInFight } from './orchestrator.js' +import { runFightAsync, isInFight, getActiveFightId } from './orchestrator.js' import { seedMockBots } from './mock.js' interface QueueEntry { @@ -55,9 +55,12 @@ export async function joinQueue(botId: string): Promise { throw new Error(`Cooldown active. Wait ${waitSec}s.`) } - // Check if already in a fight + // Check if already in a fight — return the fight ID so frontend can redirect if (isInFight(botId)) { - throw new Error('Bot is already in a fight.') + const activeFightId = getActiveFightId(botId) + const err = new Error('Bot is already in a fight.') + ;(err as any).fightId = activeFightId + throw err } // Load bot diff --git a/server/src/routes/fights.ts b/server/src/routes/fights.ts index fed154f..19f36be 100644 --- a/server/src/routes/fights.ts +++ b/server/src/routes/fights.ts @@ -7,7 +7,7 @@ import { eq, desc, inArray } from 'drizzle-orm' import { ARENAS } from '../engine/arenas.js' import { runMockFight, isClassicBot } from '../engine/mock.js' import { startFightLoop } from '../engine/fight-loop.js' -import { runFight, runFightAsync, isInFight } from '../engine/orchestrator.js' +import { runFight, runFightAsync, isInFight, getActiveFightId } from '../engine/orchestrator.js' import { fightEvents } from '../engine/events.js' import { botRateLimit } from '../middleware/rate-limit.js' import { getPendingChallenge, getPendingAnswers, submitHumanResponse } from '../engine/human-responses.js' @@ -233,7 +233,7 @@ fightsRouter.post('/matchmake/:botId', botRateLimit(10_000), async (c) => { } if (isInFight(botId)) { - return c.json({ error: 'Bot is already in a fight.' }, 400) + return c.json({ error: 'Bot is already in a fight.', fightId: getActiveFightId(botId) }, 409) } const allBots = await db.select() @@ -285,7 +285,7 @@ fightsRouter.post('/practice/:botId', botRateLimit(10_000), async (c) => { const bot = botRows[0] if (isInFight(botId)) { - return c.json({ error: 'Bot is already in a fight.' }, 400) + return c.json({ error: 'Bot is already in a fight.', fightId: getActiveFightId(botId) }, 409) } // Find all classic bots diff --git a/server/src/routes/queue.ts b/server/src/routes/queue.ts index a7408d9..0be7f15 100644 --- a/server/src/routes/queue.ts +++ b/server/src/routes/queue.ts @@ -31,9 +31,10 @@ queueRouter.post('/join/:botId', async (c) => { try { const fightId = await joinQueue(botId) return c.json({ fightId, message: 'Matched! Fight starting.' }) - } catch (err) { + } catch (err: any) { const message = err instanceof Error ? err.message : 'Queue error' - return c.json({ error: message }, 500) + const status = message.includes('already in a fight') ? 409 : 500 + return c.json({ error: message, fightId: err?.fightId || undefined }, status) } })