245 lines
8.1 KiB
Vue
245 lines
8.1 KiB
Vue
<script setup lang="ts">
|
|||
|
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||
|
|
import { useRoute, RouterLink } from 'vue-router'
|
||
|
|
import SpritePreview from '../components/SpritePreview.vue'
|
||
|
|
|
||
|
|
interface TournamentData {
|
||
|
|
id: string
|
||
|
|
name: string
|
||
|
|
format: string
|
||
|
|
size: number
|
||
|
|
entrySats: number
|
||
|
|
prizeSats: number
|
||
|
|
status: string
|
||
|
|
currentRound: number
|
||
|
|
createdAt: string
|
||
|
|
startedAt: string | null
|
||
|
|
finishedAt: string | null
|
||
|
|
}
|
||
|
|
|
||
|
|
interface Entry {
|
||
|
|
id: string
|
||
|
|
botId: string
|
||
|
|
botName: string
|
||
|
|
seed: number
|
||
|
|
eliminated: boolean
|
||
|
|
}
|
||
|
|
|
||
|
|
interface Match {
|
||
|
|
id: string
|
||
|
|
round: number
|
||
|
|
matchIndex: number
|
||
|
|
botAId: string | null
|
||
|
|
botBId: string | null
|
||
|
|
botAName: string | null
|
||
|
|
botBName: string | null
|
||
|
|
fightId: string | null
|
||
|
|
winnerId: string | null
|
||
|
|
status: string
|
||
|
|
}
|
||
|
|
|
||
|
|
const route = useRoute()
|
||
|
|
const tournament = ref<TournamentData | null>(null)
|
||
|
|
const entries = ref<Entry[]>([])
|
||
|
|
const matches = ref<Match[]>([])
|
||
|
|
const isLoading = ref(true)
|
||
|
|
const error = ref<string | null>(null)
|
||
|
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||
|
|
|
||
|
|
const totalRounds = computed(() => {
|
||
|
|
if (!tournament.value) return 0
|
||
|
|
return Math.ceil(Math.log2(tournament.value.size))
|
||
|
|
})
|
||
|
|
|
||
|
|
const rounds = computed(() => {
|
||
|
|
const result: Match[][] = []
|
||
|
|
for (let r = 1; r <= totalRounds.value; r++) {
|
||
|
|
result.push(
|
||
|
|
matches.value
|
||
|
|
.filter(m => m.round === r)
|
||
|
|
.sort((a, b) => a.matchIndex - b.matchIndex)
|
||
|
|
)
|
||
|
|
}
|
||
|
|
return result
|
||
|
|
})
|
||
|
|
|
||
|
|
const champion = computed(() => {
|
||
|
|
if (tournament.value?.status !== 'finished') return null
|
||
|
|
// Champion is the winner of the final round's single match
|
||
|
|
const finalRound = rounds.value[rounds.value.length - 1]
|
||
|
|
if (!finalRound || finalRound.length === 0) return null
|
||
|
|
const finalMatch = finalRound[0]
|
||
|
|
if (!finalMatch.winnerId) return null
|
||
|
|
const entry = entries.value.find(e => e.botId === finalMatch.winnerId)
|
||
|
|
return entry ?? null
|
||
|
|
})
|
||
|
|
|
||
|
|
async function fetchBracket() {
|
||
|
|
try {
|
||
|
|
const res = await fetch(`/api/tournaments/${route.params.id}`)
|
||
|
|
if (!res.ok) {
|
||
|
|
error.value = 'Tournament not found'
|
||
|
|
return
|
||
|
|
}
|
||
|
|
const data = await res.json()
|
||
|
|
tournament.value = data.tournament
|
||
|
|
entries.value = data.entries || []
|
||
|
|
matches.value = data.matches || []
|
||
|
|
} catch {
|
||
|
|
error.value = 'Failed to load tournament'
|
||
|
|
}
|
||
|
|
isLoading.value = false
|
||
|
|
}
|
||
|
|
|
||
|
|
function roundLabel(round: number): string {
|
||
|
|
const remaining = totalRounds.value - round
|
||
|
|
if (remaining === 0) return 'FINAL'
|
||
|
|
if (remaining === 1) return 'SEMIS'
|
||
|
|
if (remaining === 2) return 'QUARTERS'
|
||
|
|
return `ROUND ${round}`
|
||
|
|
}
|
||
|
|
|
||
|
|
function slotClass(match: Match, side: 'a' | 'b'): string {
|
||
|
|
const botId = side === 'a' ? match.botAId : match.botBId
|
||
|
|
if (!botId) return 'text-text-muted/30'
|
||
|
|
if (match.winnerId === botId) return 'text-neon-green font-bold'
|
||
|
|
if (match.winnerId && match.winnerId !== botId) return 'text-text-muted line-through'
|
||
|
|
return 'text-text-primary'
|
||
|
|
}
|
||
|
|
|
||
|
|
onMounted(() => {
|
||
|
|
fetchBracket()
|
||
|
|
// Poll for updates while tournament is active
|
||
|
|
pollTimer = setInterval(() => {
|
||
|
|
if (tournament.value?.status === 'active') fetchBracket()
|
||
|
|
}, 10_000)
|
||
|
|
})
|
||
|
|
|
||
|
|
onUnmounted(() => {
|
||
|
|
if (pollTimer) clearInterval(pollTimer)
|
||
|
|
})
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<template>
|
||
|
|
<div class="max-w-6xl mx-auto px-4 py-8 space-y-6">
|
||
|
|
<RouterLink to="/tournaments" class="text-xs text-text-muted hover:text-neon-cyan font-mono">
|
||
|
|
< back to tournaments
|
||
|
|
</RouterLink>
|
||
|
|
|
||
|
|
<div v-if="isLoading" class="text-text-muted text-sm font-mono">Loading...</div>
|
||
|
|
<div v-else-if="error" class="text-red-400 text-sm font-mono">{{ error }}</div>
|
||
|
|
|
||
|
|
<template v-else-if="tournament">
|
||
|
|
<!-- Header -->
|
||
|
|
<div class="flex items-center justify-between">
|
||
|
|
<div>
|
||
|
|
<h1 class="font-display font-black text-2xl text-neon-cyan tracking-wider">
|
||
|
|
{{ tournament.name }}
|
||
|
|
</h1>
|
||
|
|
<p class="text-xs text-text-muted font-mono mt-1">
|
||
|
|
{{ tournament.format === 'single_elim' ? 'Single Elimination' : 'Round Robin' }}
|
||
|
|
· {{ tournament.size }} bots
|
||
|
|
<template v-if="tournament.prizeSats > 0"> · {{ tournament.prizeSats }} sats prize</template>
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
<span
|
||
|
|
class="text-sm font-display font-bold tracking-wider"
|
||
|
|
:class="{
|
||
|
|
'text-neon-green': tournament.status === 'open',
|
||
|
|
'text-neon-pink glow-pink': tournament.status === 'active',
|
||
|
|
'text-text-muted': tournament.status === 'finished',
|
||
|
|
}"
|
||
|
|
>
|
||
|
|
{{ tournament.status === 'open' ? 'OPEN' : tournament.status === 'active' ? 'LIVE' : 'FINISHED' }}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Champion banner -->
|
||
|
|
<div
|
||
|
|
v-if="champion"
|
||
|
|
class="border border-neon-gold/50 bg-neon-gold/5 rounded-lg p-4 flex items-center gap-4"
|
||
|
|
>
|
||
|
|
<SpritePreview :seed="champion.botName" :size="48" />
|
||
|
|
<div>
|
||
|
|
<p class="text-xs text-neon-gold font-display font-bold tracking-wider">CHAMPION</p>
|
||
|
|
<RouterLink
|
||
|
|
:to="`/bot/${champion.botName}`"
|
||
|
|
class="font-display font-bold text-lg text-text-primary hover:text-neon-cyan"
|
||
|
|
>
|
||
|
|
{{ champion.botName }}
|
||
|
|
</RouterLink>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Lobby (open tournament) -->
|
||
|
|
<div v-if="tournament.status === 'open'" class="space-y-3">
|
||
|
|
<h2 class="font-display font-bold text-sm text-text-secondary tracking-wider">
|
||
|
|
REGISTERED ({{ entries.length }}/{{ tournament.size }})
|
||
|
|
</h2>
|
||
|
|
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||
|
|
<div
|
||
|
|
v-for="entry in entries"
|
||
|
|
:key="entry.id"
|
||
|
|
class="border border-border rounded p-2 flex items-center gap-2 bg-surface/50"
|
||
|
|
>
|
||
|
|
<SpritePreview :seed="entry.botName" :size="24" />
|
||
|
|
<span class="text-xs text-text-primary font-mono truncate">{{ entry.botName }}</span>
|
||
|
|
</div>
|
||
|
|
<div
|
||
|
|
v-for="i in (tournament.size - entries.length)"
|
||
|
|
:key="'empty-' + i"
|
||
|
|
class="border border-border/30 rounded p-2 flex items-center justify-center"
|
||
|
|
>
|
||
|
|
<span class="text-xs text-text-muted/30 font-mono">empty</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Bracket -->
|
||
|
|
<div v-if="rounds.length > 0" class="overflow-x-auto">
|
||
|
|
<div class="flex gap-6 min-w-max py-4">
|
||
|
|
<div v-for="(roundMatches, ri) in rounds" :key="ri" class="flex flex-col gap-4">
|
||
|
|
<h3 class="text-xs font-display font-bold text-text-muted tracking-wider text-center mb-2">
|
||
|
|
{{ roundLabel(ri + 1) }}
|
||
|
|
</h3>
|
||
|
|
<div
|
||
|
|
class="flex flex-col justify-around flex-1 gap-4"
|
||
|
|
>
|
||
|
|
<div
|
||
|
|
v-for="match in roundMatches"
|
||
|
|
:key="match.id"
|
||
|
|
class="border border-border rounded bg-surface/50 w-48"
|
||
|
|
>
|
||
|
|
<!-- Bot A -->
|
||
|
|
<div
|
||
|
|
class="px-3 py-2 text-xs font-mono border-b border-border/50 flex items-center justify-between"
|
||
|
|
:class="slotClass(match, 'a')"
|
||
|
|
>
|
||
|
|
<span class="truncate">{{ match.botAName || 'BYE' }}</span>
|
||
|
|
<span v-if="match.winnerId === match.botAId" class="text-neon-green ml-1">W</span>
|
||
|
|
</div>
|
||
|
|
<!-- Bot B -->
|
||
|
|
<div
|
||
|
|
class="px-3 py-2 text-xs font-mono flex items-center justify-between"
|
||
|
|
:class="slotClass(match, 'b')"
|
||
|
|
>
|
||
|
|
<span class="truncate">{{ match.botBName || 'BYE' }}</span>
|
||
|
|
<span v-if="match.winnerId === match.botBId" class="text-neon-green ml-1">W</span>
|
||
|
|
</div>
|
||
|
|
<!-- Fight link -->
|
||
|
|
<RouterLink
|
||
|
|
v-if="match.fightId"
|
||
|
|
:to="`/arena/${match.fightId}`"
|
||
|
|
class="block text-center text-[10px] text-neon-cyan hover:underline py-1 border-t border-border/50"
|
||
|
|
>
|
||
|
|
{{ match.status === 'live' ? 'WATCH LIVE' : 'REPLAY' }}
|
||
|
|
</RouterLink>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
</div>
|
||
|
|
</template>
|