feat: v3 — fight loop, expanded challenges, FightPage ownership guard

- Add fight-loop CLI for automated mock fights with elo-based matchmaking
- Expand challenge types, scoring narrations, arenas, and mock bot pool
- FightPage "Fight again" buttons now only show for your own bot
- FightViewer async scene init, live fight polling with round counter
- Extract mock answers into separate answers module

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-06 23:44:40 +00:00
co-authored by Claude Opus 4.6
parent 47d20fbe66
commit 2c0323d5fb
14 changed files with 2669 additions and 996 deletions
+23 -10
View File
@@ -66,7 +66,7 @@ const logItems = ref<{ type: string; round: number; text: string; color: string
onMounted(async () => {
if (props.autoplay) {
// Fresh fight — start replay immediately instead of showing static result
initScene()
await initScene()
await nextTick()
replay()
} else {
@@ -74,7 +74,7 @@ onMounted(async () => {
displayHpA.value = mapHp(props.fight.botAHp, props.fight.winnerId, props.fight.botA?.id)
displayHpB.value = mapHp(props.fight.botBHp, props.fight.winnerId, props.fight.botB?.id)
for (const r of props.fight.rounds) addRoundToLog(r, false)
initScene()
await initScene()
}
})
@@ -86,7 +86,7 @@ function mapHp(hp: number, winnerId: string | null, botId: string | undefined):
onUnmounted(() => { scene?.destroy(); scene = null })
function initScene() {
async function initScene() {
if (!canvasRef.value || !props.fight.botA || !props.fight.botB) return
if (scene) { scene.k.go('fight'); return }
@@ -96,7 +96,7 @@ function initScene() {
canvasRef.value.height = container.clientHeight
}
scene = createFightScene({
scene = await createFightScene({
canvas: canvasRef.value,
botA: { name: props.fight.botA.name, seed: props.fight.botA.avatarSeed || props.fight.botA.name, tier: props.fight.botA.tier, archetype: props.fight.botA.archetype },
botB: { name: props.fight.botB.name, seed: props.fight.botB.avatarSeed || props.fight.botB.name, tier: props.fight.botB.tier, archetype: props.fight.botB.archetype },
@@ -110,8 +110,15 @@ const challengeLabel = (type: string) => {
roast_battle: 'ROAST BATTLE', hallucination_check: 'HALLUCINATION CHECK',
token_economy: 'TOKEN ECONOMY', creative_writing: 'CREATIVE WRITING',
math_blitz: 'MATH BLITZ', trap_card: 'TRAP CARD',
food_fight: 'FOOD FIGHT', wrestling_match: 'WRESTLING MATCH',
music_battle: 'MUSIC BATTLE', magic_duel: 'MAGIC DUEL',
sports_showdown: 'SPORTS SHOWDOWN', nature_clash: 'NATURE CLASH',
space_war: 'SPACE WAR', hack_battle: 'HACK BATTLE',
meme_war: 'MEME WAR', animal_kingdom: 'ANIMAL KINGDOM',
demolition: 'DEMOLITION DERBY', vehicle_mayhem: 'VEHICLE MAYHEM',
medieval_combat: 'MEDIEVAL COMBAT',
}
return labels[type] || type.toUpperCase()
return labels[type] || type.replace(/_/g, ' ').toUpperCase()
}
const tierClass = (t: number) => `tier-${t}`
@@ -148,8 +155,8 @@ function addRoundToLog(round: Round, stagger: boolean): Promise<void> {
logItems.value.push(
{ type: 'header', round: round.roundNumber, text: `ROUND ${round.roundNumber}: ${challengeLabel(round.challengeType)}`, color: 'neon-purple' },
{ type: 'prompt', round: round.roundNumber, text: challenge.prompt, color: 'text-muted' },
{ type: 'responseA', round: round.roundNumber, text: `${props.fight.botA?.name}: ${round.botAResponse?.slice(0, 120) || '[TIMEOUT]'} (${round.botATimeMs}ms)`, color: 'neon-cyan' },
{ type: 'responseB', round: round.roundNumber, text: `${props.fight.botB?.name}: ${round.botBResponse?.slice(0, 120) || '[TIMEOUT]'} (${round.botBTimeMs}ms)`, color: 'neon-pink' },
{ type: 'responseA', round: round.roundNumber, text: `${props.fight.botA?.name}: ${round.botAResponse?.slice(0, 120) || '[NO RESPONSE]'} (${round.botATimeMs}ms)`, color: 'neon-cyan' },
{ type: 'responseB', round: round.roundNumber, text: `${props.fight.botB?.name}: ${round.botBResponse?.slice(0, 120) || '[NO RESPONSE]'} (${round.botBTimeMs}ms)`, color: 'neon-pink' },
)
if (round.narration) logItems.value.push({ type: 'narration', round: round.roundNumber, text: `>> ${round.narration}`, color: 'neon-yellow' })
const winner = round.winnerId === props.fight.botA?.id ? props.fight.botA?.name : round.winnerId === props.fight.botB?.id ? props.fight.botB?.name : 'DRAW'
@@ -163,11 +170,11 @@ function addRoundToLog(round: Round, stagger: boolean): Promise<void> {
scrollLog(); await sleep(150)
logItems.value.push({ type: 'prompt', round: round.roundNumber, text: challenge.prompt, color: 'text-muted' })
scrollLog(); await sleep(200)
logItems.value.push({ type: 'responseA', round: round.roundNumber, text: `${props.fight.botA?.name}: ${round.botAResponse?.slice(0, 120) || '[TIMEOUT]'}`, color: 'neon-cyan' })
logItems.value.push({ type: 'responseA', round: round.roundNumber, text: `${props.fight.botA?.name}: ${round.botAResponse?.slice(0, 120) || '[NO RESPONSE]'}`, color: 'neon-cyan' })
scrollLog(); await sleep(150)
logItems.value.push({ type: 'time', round: round.roundNumber, text: ` ${round.botATimeMs}ms | Score: ${round.botAScore}`, color: 'text-muted' })
scrollLog(); await sleep(150)
logItems.value.push({ type: 'responseB', round: round.roundNumber, text: `${props.fight.botB?.name}: ${round.botBResponse?.slice(0, 120) || '[TIMEOUT]'}`, color: 'neon-pink' })
logItems.value.push({ type: 'responseB', round: round.roundNumber, text: `${props.fight.botB?.name}: ${round.botBResponse?.slice(0, 120) || '[NO RESPONSE]'}`, color: 'neon-pink' })
scrollLog(); await sleep(150)
logItems.value.push({ type: 'time', round: round.roundNumber, text: ` ${round.botBTimeMs}ms | Score: ${round.botBScore}`, color: 'text-muted' })
scrollLog()
@@ -183,7 +190,7 @@ async function replay() {
logItems.value = []
currentRound.value = 0
initScene()
await initScene()
scene?.startMusic()
await sleep(300)
@@ -198,6 +205,12 @@ async function replay() {
scrollLog()
await sleep(200)
// Entrance animations
if (scene) {
await scene.playEntrance()
await sleep(300)
}
for (const round of props.fight.rounds) {
currentRound.value = round.roundNumber
File diff suppressed because it is too large Load Diff
+26 -1
View File
@@ -13,7 +13,9 @@ const isLoading = ref(true)
const isRequeueing = ref(false)
const isLive = ref(false)
const liveRounds = ref(0)
const fightError = ref('')
let pollHandle: ReturnType<typeof setInterval> | null = null
let pollCount = 0
async function loadFight(): Promise<string | null> {
try {
@@ -34,13 +36,25 @@ onMounted(async () => {
const status = await loadFight()
isLoading.value = false
if (status !== 'finished') {
if (status === null) {
// Fight doesn't exist yet — might still be creating. Poll briefly.
isLive.value = true
} else if (status !== 'finished') {
isLive.value = true
}
if (isLive.value) {
pollHandle = setInterval(async () => {
pollCount++
const s = await loadFight()
if (s === 'finished') {
isLive.value = false
if (pollHandle) { clearInterval(pollHandle); pollHandle = null }
} else if (pollCount > 60) {
// 90 seconds max wait
isLive.value = false
fightError.value = 'Fight took too long. It may still be running — try refreshing.'
if (pollHandle) { clearInterval(pollHandle); pollHandle = null }
}
}, 1500)
}
@@ -78,6 +92,17 @@ async function fightAgain(botId: string) {
</p>
</div>
<div v-else-if="fightError" class="flex-1 flex flex-col items-center justify-center gap-3">
<p class="font-display text-ko text-sm tracking-wider">{{ fightError }}</p>
<button
class="px-6 py-2 border border-neon-cyan/40 text-neon-cyan font-display font-bold text-xs tracking-wider
hover:bg-neon-cyan/10 transition-all"
@click="$router.push('/join')"
>
BACK TO LOBBY
</button>
</div>
<div v-else-if="!fight" class="flex-1 flex items-center justify-center">
<p class="font-display text-text-muted">Fight not found.</p>
</div>