ai generated solutions to our ai generated problems
This commit is contained in:
Binary file not shown.
+375
-68
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import Tree, { prewarmTreeCanvas } from '../Tree/Tree'
|
||||
import FallingLeaves from '../Tree/FallingLeaves'
|
||||
import ReplayCanvasPanel from './ReplayCanvas'
|
||||
|
||||
const numberFormat = new Intl.NumberFormat('en-GB')
|
||||
const dateFormat = new Intl.DateTimeFormat('en-GB', {
|
||||
@@ -20,6 +21,7 @@ const apiEndpoints = {
|
||||
teamsHealth: '/api/tss/leaderboard/teams?limit=1',
|
||||
recentGames: '/api/tss/games/recent?limit=50',
|
||||
game: (gameId) => `/api/tss/games/${encodeURIComponent(gameId)}`,
|
||||
gameLogs: (gameId) => `/api/tss/games/${encodeURIComponent(gameId)}/logs`,
|
||||
resolve: (name) => `/api/tss/teams/resolve?name=${encodeURIComponent(name)}`,
|
||||
searchTeams: (name) => `/api/tss/teams/search?q=${encodeURIComponent(name)}&limit=10`,
|
||||
detail: (name) => `/api/tss/teams/${encodeURIComponent(name)}`,
|
||||
@@ -123,6 +125,15 @@ function formatDate(timestamp) {
|
||||
return dateFormat.format(new Date(Number(timestamp) * 1000))
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
const total = Math.round(Number(seconds || 0))
|
||||
if (!total) return ''
|
||||
const m = Math.floor(total / 60)
|
||||
const s = total % 60
|
||||
if (!m) return `${s}s`
|
||||
return `${m}m ${s}s`
|
||||
}
|
||||
|
||||
function gameParticipants(game) {
|
||||
const winner = displayTeamName(game?.winning_team)
|
||||
const loser = displayTeamName(game?.losing_team)
|
||||
@@ -149,16 +160,39 @@ function displayTeamName(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
function ParticipantNames({ participants }) {
|
||||
function ParticipantNames({ participants, spread = false }) {
|
||||
if (!participants.length) {
|
||||
return <p className="truncate text-sm font-semibold text-text-soft">Participants unknown</p>
|
||||
}
|
||||
|
||||
// On wide rows (battle logs), spread the two teams to opposite ends with a
|
||||
// centered "vs" so each side gets an equal share of the available width.
|
||||
if (spread && participants.length === 2) {
|
||||
const [first, second] = participants
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-x-3">
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate text-left text-sm font-semibold ${first.result === 'win' ? 'text-win' : 'text-loss'}`}
|
||||
>
|
||||
{first.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs font-semibold uppercase tracking-wide text-text-muted">
|
||||
vs
|
||||
</span>
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate text-right text-sm font-semibold ${second.result === 'win' ? 'text-win' : 'text-loss'}`}
|
||||
>
|
||||
{second.name}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-wrap gap-x-3 gap-y-1">
|
||||
<div className={`flex min-w-0 flex-wrap gap-x-3 gap-y-1${spread ? ' justify-between' : ''}`}>
|
||||
{participants.map((participant) => (
|
||||
<span
|
||||
className={`truncate text-sm font-semibold ${participant.result === 'win' ? 'text-fury-violet' : 'text-text-soft'}`}
|
||||
className={`truncate text-sm font-semibold ${participant.result === 'win' ? 'text-win' : 'text-loss'}`}
|
||||
key={`${participant.result}-${participant.name}`}
|
||||
>
|
||||
{participant.name}
|
||||
@@ -2899,14 +2933,129 @@ function TeamProfilePage({ navigate, profile, requestedTeam, teams }) {
|
||||
)
|
||||
}
|
||||
|
||||
const SCOREBOARD_GRID = 'grid-cols-[minmax(220px,1fr)_repeat(6,minmax(52px,72px))]'
|
||||
|
||||
// Battle-log lines are prefixed +/-/<space> for winner/loser/neither (matching
|
||||
// the Discord diff format), so colour each line by its acting team.
|
||||
function battleLineColor(line) {
|
||||
if (line.startsWith('+')) return 'text-win'
|
||||
if (line.startsWith('-')) return 'text-loss'
|
||||
return 'text-text-soft'
|
||||
}
|
||||
|
||||
function formatLogTime(ms) {
|
||||
const totalSeconds = Math.floor(Number(ms || 0) / 1000)
|
||||
const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, '0')
|
||||
const seconds = String(totalSeconds % 60).padStart(2, '0')
|
||||
return `${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
function deadVehicleKey(uid, cdk) {
|
||||
return `${String(uid || '').trim()}:${String(cdk || '').trim()}`
|
||||
}
|
||||
|
||||
function deadVehicleKeysFromEventLog(eventLog) {
|
||||
const keys = new Set()
|
||||
const kills = Array.isArray(eventLog?.kills) ? eventLog.kills : []
|
||||
kills.forEach((kill) => {
|
||||
const uid = kill?.offended_uid
|
||||
const cdk = kill?.offended_unit
|
||||
if (uid !== undefined && uid !== null && cdk) {
|
||||
keys.add(deadVehicleKey(uid, cdk))
|
||||
}
|
||||
})
|
||||
return keys
|
||||
}
|
||||
|
||||
function logLookups(participants) {
|
||||
const players = new Map()
|
||||
;(participants || []).forEach((participant) => {
|
||||
const result = String(participant.result || '').toLowerCase() === 'win' ? 'win' : 'loss'
|
||||
;(participant.players || []).forEach((player) => {
|
||||
const vehicles = new Map()
|
||||
;(player.vehicles || []).forEach((vehicle) => {
|
||||
vehicles.set(String(vehicle.cdk || ''), vehicle.name || vehicle.cdk || 'Unknown')
|
||||
})
|
||||
players.set(String(player.uid), {
|
||||
name: player.nick || player.uid,
|
||||
team: participant.team_name || '',
|
||||
result,
|
||||
className: result === 'win' ? 'text-win' : 'text-loss',
|
||||
vehicles,
|
||||
})
|
||||
})
|
||||
})
|
||||
return players
|
||||
}
|
||||
|
||||
function logNameLookups(participants) {
|
||||
const players = new Map()
|
||||
;(participants || []).forEach((participant) => {
|
||||
const result = String(participant.result || '').toLowerCase() === 'win' ? 'win' : 'loss'
|
||||
;(participant.players || []).forEach((player) => {
|
||||
const name = String(player.nick || '').trim()
|
||||
if (!name) return
|
||||
players.set(name.toLowerCase(), {
|
||||
name,
|
||||
team: participant.team_name || '',
|
||||
result,
|
||||
className: result === 'win' ? 'text-win' : 'text-loss',
|
||||
})
|
||||
})
|
||||
})
|
||||
return players
|
||||
}
|
||||
|
||||
function logPlayer(players, uid) {
|
||||
return players.get(String(uid)) || {
|
||||
name: uid === undefined || uid === null ? 'Unknown' : `Player#${uid}`,
|
||||
team: '',
|
||||
result: '',
|
||||
className: 'text-text-soft',
|
||||
vehicles: new Map(),
|
||||
}
|
||||
}
|
||||
|
||||
function logVehicle(player, cdk) {
|
||||
if (!cdk) return 'Unknown'
|
||||
return player.vehicles.get(String(cdk)) || String(cdk)
|
||||
}
|
||||
|
||||
function structuredBattleEvents(eventLog) {
|
||||
const kills = Array.isArray(eventLog?.kills) ? eventLog.kills : []
|
||||
const damage = Array.isArray(eventLog?.damage) ? eventLog.damage : []
|
||||
return [
|
||||
...kills.map((event) => ({ ...event, kind: 'kill' })),
|
||||
...damage.map((event) => ({ ...event, kind: 'damage' })),
|
||||
].sort((a, b) => Number(a.time || 0) - Number(b.time || 0))
|
||||
}
|
||||
|
||||
function chatTypeClass(type, senderClassName) {
|
||||
return String(type || 'ALL').toUpperCase() === 'ALL' ? 'text-warning' : senderClassName
|
||||
}
|
||||
|
||||
function parseFormattedChatLine(line) {
|
||||
const match = String(line || '').match(/^([+-]?)(\[\d{2}:\d{2}\])\s+\[([^\]]+)\]\s+\[[^\]]*\]\s+`([^`]*)`:\s?(.*)$/)
|
||||
if (!match) return null
|
||||
return {
|
||||
prefix: match[1],
|
||||
time: match[2],
|
||||
type: match[3],
|
||||
name: match[4],
|
||||
message: match[5],
|
||||
}
|
||||
}
|
||||
|
||||
function GamePage({ gameId, navigate }) {
|
||||
const [gameState, setGameState] = useState({ status: 'loading', data: null, error: null })
|
||||
const [logs, setLogs] = useState({ chat_log: [], battle_log: [], event_log: { kills: [], damage: [], chat: [] } })
|
||||
|
||||
useEffect(() => {
|
||||
if (!gameId) return
|
||||
|
||||
const controller = new AbortController()
|
||||
setGameState({ status: 'loading', data: null, error: null })
|
||||
setLogs({ chat_log: [], battle_log: [], event_log: { kills: [], damage: [], chat: [] } })
|
||||
|
||||
fetchJson(apiEndpoints.game(gameId), controller.signal)
|
||||
.then((data) => {
|
||||
@@ -2920,11 +3069,30 @@ function GamePage({ gameId, navigate }) {
|
||||
}
|
||||
})
|
||||
|
||||
fetchJson(apiEndpoints.gameLogs(gameId), controller.signal)
|
||||
.then((data) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setLogs({
|
||||
chat_log: Array.isArray(data?.chat_log) ? data.chat_log : [],
|
||||
battle_log: Array.isArray(data?.battle_log) ? data.battle_log : [],
|
||||
event_log: data?.event_log || { kills: [], damage: [], chat: [] },
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Logs are non-critical; leave them empty on failure.
|
||||
})
|
||||
|
||||
return () => controller.abort()
|
||||
}, [gameId])
|
||||
|
||||
const game = gameState.data?.game
|
||||
const participants = gameState.data?.participants || []
|
||||
const participants = useMemo(() => gameState.data?.participants || [], [gameState.data?.participants])
|
||||
const deadVehicleKeys = useMemo(() => deadVehicleKeysFromEventLog(logs.event_log), [logs.event_log])
|
||||
const playersByUid = useMemo(() => logLookups(participants), [participants])
|
||||
const playersByName = useMemo(() => logNameLookups(participants), [participants])
|
||||
const battleEvents = useMemo(() => structuredBattleEvents(logs.event_log), [logs.event_log])
|
||||
const chatEvents = Array.isArray(logs.event_log?.chat) ? logs.event_log.chat : []
|
||||
const participantNames = participants.length
|
||||
? participants.map((participant) => ({
|
||||
name: participant.team_name,
|
||||
@@ -2932,6 +3100,9 @@ function GamePage({ gameId, navigate }) {
|
||||
}))
|
||||
: gameParticipants(game)
|
||||
|
||||
const subtitle = [game?.mission_mode, game?.tournament_name].filter(Boolean).join(' · ')
|
||||
const duration = formatDuration(game?.duration)
|
||||
|
||||
return (
|
||||
<section className="space-y-6 pt-24 sm:pt-28">
|
||||
<button
|
||||
@@ -2943,84 +3114,121 @@ function GamePage({ gameId, navigate }) {
|
||||
</button>
|
||||
|
||||
<div className="rounded-lg border border-border bg-fury-white p-6 shadow-sm">
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">Game</p>
|
||||
<h1 className="mt-1 text-4xl font-bold">{game?.map_name || 'Battle log'}</h1>
|
||||
<p className="mt-2 break-all text-sm text-text-soft">
|
||||
{game ? `${formatDate(game.timestamp)} · ${game.session_id}` : gameId}
|
||||
<div className="flex flex-wrap items-baseline gap-x-3">
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">Game</p>
|
||||
<span className="break-all text-xs text-text-muted opacity-70">{game?.session_id || gameId}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-4xl font-bold">{game?.map_name || 'Battle log'}</h1>
|
||||
{game?.draw ? (
|
||||
<span className="rounded bg-surface px-2 py-1 text-xs font-semibold uppercase tracking-wide text-text-soft">
|
||||
Draw
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{subtitle ? <p className="mt-1 text-sm font-medium text-fury-violet">{subtitle}</p> : null}
|
||||
<p className="mt-2 text-sm text-text-soft">
|
||||
{game ? formatDate(game.timestamp) : ''}
|
||||
{duration ? ` · ${duration}` : ''}
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<ParticipantNames participants={participantNames} />
|
||||
</div>
|
||||
|
||||
{gameState.status === 'error' ? (
|
||||
<p className="mt-4 text-sm text-danger">{gameState.error}</p>
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-fury-white shadow-sm">
|
||||
<div className="border-b border-surface px-5 py-4">
|
||||
<h2 className="text-lg font-semibold">Participants</h2>
|
||||
<div className="mt-1">
|
||||
<ParticipantNames participants={participantNames} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-[760px]">
|
||||
<div className="min-w-[720px]">
|
||||
{participants.length ? (
|
||||
<div className="grid grid-cols-[minmax(220px,1fr)_80px_repeat(5,88px)] gap-3 border-b border-surface px-5 py-3 text-xs font-semibold uppercase tracking-wide text-text-soft">
|
||||
<div className={`grid ${SCOREBOARD_GRID} gap-3 border-b border-border px-5 py-3 text-xs font-semibold uppercase tracking-wide text-text-soft`}>
|
||||
<p>Team / player</p>
|
||||
<p className="text-right">Players</p>
|
||||
<p className="text-right">Ground</p>
|
||||
<p className="text-right">Air</p>
|
||||
<p className="text-right">Assists</p>
|
||||
<p className="text-right">Score</p>
|
||||
<p className="text-right">Deaths</p>
|
||||
<p className="text-center">Air</p>
|
||||
<p className="text-center">Ground</p>
|
||||
<p className="text-center">Assists</p>
|
||||
<p className="text-center">Deaths</p>
|
||||
<p className="text-center">Caps</p>
|
||||
<p className="text-center">Score</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{participants.map((participant) => {
|
||||
const won = String(participant.result || '').toLowerCase() === 'win'
|
||||
return (
|
||||
<div className="border-b border-surface" key={participant.team_name}>
|
||||
<button
|
||||
className="grid w-full grid-cols-[minmax(220px,1fr)_80px_repeat(5,88px)] gap-3 px-5 py-3 text-left transition hover:bg-surface"
|
||||
onClick={() => navigate(teamPath(participant.team_name))}
|
||||
type="button"
|
||||
>
|
||||
<p className={`truncate font-semibold ${won ? 'text-fury-violet' : 'text-text-soft'}`}>
|
||||
{participant.team_name}
|
||||
</p>
|
||||
<p className="text-right text-sm">{formatNumber(participant.player_count)}</p>
|
||||
<p className="text-right text-sm">{formatNumber(participant.stats?.ground_kills)}</p>
|
||||
<p className="text-right text-sm">{formatNumber(participant.stats?.air_kills)}</p>
|
||||
<p className="text-right text-sm">{formatNumber(participant.stats?.assists)}</p>
|
||||
<p className="text-right text-sm text-text-muted">-</p>
|
||||
<p className="text-right text-sm">{formatNumber(participant.stats?.deaths)}</p>
|
||||
</button>
|
||||
{participants.map((participant) => {
|
||||
const won = String(participant.result || '').toLowerCase() === 'win'
|
||||
const accent = won ? 'border-l-win' : 'border-l-loss'
|
||||
const nameColor = won ? 'text-win' : 'text-loss'
|
||||
return (
|
||||
<div className={`border-b-4 border-border border-l-4 ${accent}`} key={participant.team_name}>
|
||||
<button
|
||||
className={`grid w-full ${SCOREBOARD_GRID} items-center gap-3 border-b border-border bg-surface/60 px-5 py-3 text-left transition hover:bg-surface`}
|
||||
onClick={() => navigate(teamPath(participant.team_name))}
|
||||
type="button"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className={`truncate font-bold ${nameColor}`}>
|
||||
{participant.team_name}
|
||||
</p>
|
||||
<p className={`text-xs font-semibold uppercase tracking-wide ${nameColor}`}>
|
||||
{won ? 'Win' : 'Loss'} · {formatNumber(participant.player_count)} players
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-center text-sm">{formatNumber(participant.stats?.air_kills)}</p>
|
||||
<p className="text-center text-sm">{formatNumber(participant.stats?.ground_kills)}</p>
|
||||
<p className="text-center text-sm">{formatNumber(participant.stats?.assists)}</p>
|
||||
<p className="text-center text-sm">{formatNumber(participant.stats?.deaths)}</p>
|
||||
<p className="text-center text-sm">{formatNumber(participant.stats?.captures)}</p>
|
||||
<p className="text-center text-sm font-semibold">{formatNumber(participant.stats?.score)}</p>
|
||||
</button>
|
||||
|
||||
<div className="pb-3">
|
||||
{(participant.players || []).map((player) => (
|
||||
<button
|
||||
className="grid w-full grid-cols-[minmax(220px,1fr)_80px_repeat(5,88px)] gap-3 px-5 py-2 text-left text-sm transition hover:bg-surface"
|
||||
key={player.uid}
|
||||
onClick={() => navigate(playerPath(player.uid))}
|
||||
type="button"
|
||||
>
|
||||
<div className="min-w-0 pl-8 sm:pl-12">
|
||||
<p className="truncate font-semibold text-text">{player.nick || player.uid}</p>
|
||||
<p className="text-xs text-text-soft">{player.uid}</p>
|
||||
</div>
|
||||
<p className="text-right text-text-muted">-</p>
|
||||
<p className="text-right">{formatNumber(player.stats?.ground_kills)}</p>
|
||||
<p className="text-right">{formatNumber(player.stats?.air_kills)}</p>
|
||||
<p className="text-right">{formatNumber(player.stats?.assists)}</p>
|
||||
<p className="text-right">{formatNumber(player.stats?.score)}</p>
|
||||
<p className="text-right">{formatNumber(player.stats?.deaths)}</p>
|
||||
</button>
|
||||
))}
|
||||
<div className="divide-y divide-surface py-1">
|
||||
{(participant.players || []).map((player) => (
|
||||
<button
|
||||
className={`grid w-full ${SCOREBOARD_GRID} items-center gap-3 px-5 py-2 text-left text-sm transition hover:bg-surface`}
|
||||
key={player.uid}
|
||||
onClick={() => navigate(playerPath(player.uid))}
|
||||
type="button"
|
||||
>
|
||||
<div className="min-w-0 pl-4 sm:pl-8">
|
||||
<p className="truncate font-semibold text-text">{player.nick || player.uid}</p>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5">
|
||||
{(player.vehicles || []).length ? (
|
||||
player.vehicles.map((vehicle) => {
|
||||
const isDead = deadVehicleKeys.has(deadVehicleKey(player.uid, vehicle.cdk))
|
||||
return (
|
||||
<span
|
||||
className={`vehicle-name inline-flex items-center gap-1 text-xs text-text-soft transition-opacity ${isDead ? 'vehicle-dead' : ''}`}
|
||||
key={vehicle.cdk}
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
className="h-4 w-4 object-contain"
|
||||
loading="lazy"
|
||||
onError={(event) => { event.currentTarget.style.display = 'none' }}
|
||||
src={`/vehicle-icons/${vehicle.icon}`}
|
||||
/>
|
||||
{vehicle.name}
|
||||
</span>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<span className="text-xs text-text-muted">{player.uid}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-center">{formatNumber(player.stats?.air_kills)}</p>
|
||||
<p className="text-center">{formatNumber(player.stats?.ground_kills)}</p>
|
||||
<p className="text-center">{formatNumber(player.stats?.assists)}</p>
|
||||
<p className="text-center">{formatNumber(player.stats?.deaths)}</p>
|
||||
<p className="text-center">{formatNumber(player.stats?.captures)}</p>
|
||||
<p className="text-center font-semibold">{formatNumber(player.stats?.score)}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{!participants.length ? (
|
||||
@@ -3030,10 +3238,109 @@ function GamePage({ gameId, navigate }) {
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ReplayCanvasPanel gameId={gameId} />
|
||||
|
||||
{battleEvents.length || logs.battle_log.length ? (
|
||||
<details className="rounded-lg border border-border bg-fury-white shadow-sm">
|
||||
<summary className="cursor-pointer px-5 py-4 font-semibold">Battle Log</summary>
|
||||
<div className="log-mono overflow-x-auto px-5 py-3 text-xs leading-relaxed">
|
||||
{battleEvents.length ? (
|
||||
battleEvents.map((event, i) => (
|
||||
<BattleEventLine event={event} key={`${event.kind}-${event.time}-${i}`} players={playersByUid} />
|
||||
))
|
||||
) : (
|
||||
logs.battle_log.map((line, i) => (
|
||||
<div className={`whitespace-pre ${battleLineColor(line)}`} key={i}>{line}</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
|
||||
{chatEvents.length || logs.chat_log.length ? (
|
||||
<details className="rounded-lg border border-border bg-fury-white shadow-sm">
|
||||
<summary className="cursor-pointer px-5 py-4 font-semibold">Chat Log</summary>
|
||||
<div className="log-mono overflow-x-auto px-5 py-3 text-xs leading-relaxed">
|
||||
{chatEvents.length ? (
|
||||
chatEvents.map((event, i) => (
|
||||
<ChatEventLine event={event} key={`${event.time}-${event.uid}-${i}`} players={playersByUid} />
|
||||
))
|
||||
) : (
|
||||
logs.chat_log.map((line, i) => (
|
||||
<FormattedChatLine key={i} line={line} players={playersByName} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function FormattedChatLine({ line, players }) {
|
||||
const parsed = parseFormattedChatLine(line)
|
||||
if (!parsed) {
|
||||
return <div className={`whitespace-pre-wrap ${battleLineColor(line)}`}>{line}</div>
|
||||
}
|
||||
|
||||
const player = players.get(parsed.name.toLowerCase())
|
||||
const className = player?.className || battleLineColor(parsed.prefix)
|
||||
const team = player?.team || '??'
|
||||
const name = player?.name || parsed.name
|
||||
const typeClassName = chatTypeClass(parsed.type, className)
|
||||
|
||||
return (
|
||||
<div className="whitespace-pre-wrap">
|
||||
<span className="text-text-soft">{parsed.time} </span>
|
||||
<span className={typeClassName}>[{parsed.type}]</span>
|
||||
<span className={className}> [{team}] `{name}`: {parsed.message}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BattleEventLine({ event, players }) {
|
||||
const offender = logPlayer(players, event.offender_uid)
|
||||
const victim = logPlayer(players, event.offended_uid)
|
||||
const ts = formatLogTime(event.time)
|
||||
const victimLabel = `[${victim.team}] ${victim.name} (${logVehicle(victim, event.offended_unit)})`
|
||||
|
||||
if (event.kind === 'kill' && (event.crashed || event.offender_uid === undefined || event.offender_uid === null)) {
|
||||
return (
|
||||
<div className="whitespace-pre">
|
||||
<span className="text-text-soft">[{ts}] </span>
|
||||
<span className={victim.className}>[{victim.team}] {victimLabel}</span>
|
||||
<span className="text-text-soft"> crashed</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const offenderLabel = `${offender.name} (${logVehicle(offender, event.offender_unit)})`
|
||||
const action = event.kind === 'kill' ? 'destroyed' : `damaged ${event.afire ? '(FIRE) ' : ''}`
|
||||
|
||||
return (
|
||||
<div className="whitespace-pre">
|
||||
<span className="text-text-soft">[{ts}] </span>
|
||||
<span className={offender.className}>[{offender.team}] {offenderLabel}</span>
|
||||
<span className="text-text-soft"> {action} </span>
|
||||
<span className={victim.className}>{victimLabel}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChatEventLine({ event, players }) {
|
||||
const player = logPlayer(players, event.uid)
|
||||
const type = event.type || 'ALL'
|
||||
const typeClassName = chatTypeClass(type, player.className)
|
||||
return (
|
||||
<div className="whitespace-pre-wrap">
|
||||
<span className="text-text-soft">[{formatLogTime(event.time)}] </span>
|
||||
<span className={typeClassName}>[{type}]</span>
|
||||
<span className={player.className}> [{player.team}] `{player.name}`: {event.message || ''}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RosterTable({ players, status }) {
|
||||
const sortedPlayers = [...players].sort((a, b) => {
|
||||
return (b.total_kills || 0) - (a.total_kills || 0) || String(a.nick || '').localeCompare(b.nick || '')
|
||||
@@ -3124,7 +3431,7 @@ function BattleLogsPage({ live, matches, navigate }) {
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-fury-white shadow-sm">
|
||||
{matches.map((match) => (
|
||||
<button
|
||||
className="grid w-full gap-4 border-b border-surface px-5 py-4 text-left transition hover:bg-surface md:grid-cols-[1fr_minmax(10rem,0.8fr)_auto] md:items-center"
|
||||
className="grid w-full gap-x-8 gap-y-2 border-b border-surface px-5 py-4 text-left transition hover:bg-surface md:grid-cols-[minmax(0,0.9fr)_minmax(0,1.7fr)_auto] md:items-center"
|
||||
key={match.session_id}
|
||||
onClick={() => navigate(gamePath(match.session_id))}
|
||||
type="button"
|
||||
@@ -3135,7 +3442,7 @@ function BattleLogsPage({ live, matches, navigate }) {
|
||||
{formatDate(match.timestamp)} · {match.session_id}
|
||||
</p>
|
||||
</div>
|
||||
<ParticipantNames participants={gameParticipants(match)} />
|
||||
<ParticipantNames participants={gameParticipants(match)} spread />
|
||||
<p className="text-sm">{formatMatchSize(match.player_count)}</p>
|
||||
</button>
|
||||
))}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -80,6 +80,8 @@
|
||||
--color-success: #00f2ff;
|
||||
--color-warning: #f4ee3e;
|
||||
--color-danger: #e82517;
|
||||
--color-win: #1a9e4b;
|
||||
--color-loss: #e82517;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
@@ -102,6 +104,8 @@
|
||||
--color-success: #58f0f5;
|
||||
--color-warning: #f4ee3e;
|
||||
--color-danger: #ff6a5f;
|
||||
--color-win: #46d17e;
|
||||
--color-loss: #ff6a5f;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@@ -115,6 +119,32 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "skyquakesymbols";
|
||||
src: url("/fonts/symbols_skyquake.ttf") format("truetype");
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* War Thunder vehicle names carry country/event glyphs (▀ ▄ ◊ + PUA markers)
|
||||
that only the skyquake symbol font renders; list it first, then fall back. */
|
||||
.vehicle-name {
|
||||
font-family:
|
||||
"skyquakesymbols", "SF Pro Rounded", "SF Pro Text", -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.vehicle-dead {
|
||||
opacity: 0.45;
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* Chat/battle logs: monospace for column alignment, with skyquake before the
|
||||
generic keyword so country/event glyphs still resolve per-character. */
|
||||
.log-mono {
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Menlo, Consolas, "skyquakesymbols", monospace;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
@@ -436,6 +466,443 @@ h3 {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.replay-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.replay-status {
|
||||
display: flex;
|
||||
min-height: 120px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-soft);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.replay-status-error {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.rc-mode-toggle {
|
||||
display: none;
|
||||
gap: 2px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-surface);
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.rc-mode-toggle.visible {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.rc-mode-btn {
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--color-text-soft);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
padding: 4px 14px;
|
||||
transition: background-color 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.rc-mode-btn:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.rc-mode-btn.active {
|
||||
background: var(--color-fury-cyan);
|
||||
color: var(--color-fury-white);
|
||||
}
|
||||
|
||||
.rc-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(160px, 200px) min(720px, 60vh, 92vw) minmax(160px, 200px);
|
||||
gap: 0.5rem;
|
||||
align-items: start;
|
||||
justify-content: center;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.rc-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.rc-panel {
|
||||
max-height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.rc-panel {
|
||||
overflow-y: auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
border-radius: 8px;
|
||||
background: #15100b;
|
||||
color: #fff2e6;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.rc-panel-head {
|
||||
position: sticky;
|
||||
z-index: 1;
|
||||
top: 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(21, 16, 11, 0.95);
|
||||
padding: 0.5rem 0.6rem 0.4rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rc-panel-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.rc-clan-tag {
|
||||
font-family: "skyquakesymbols", monospace;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.rc-panel-list {
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.rc-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
border-left: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
padding: 0.35rem 0.6rem;
|
||||
transition: background-color 120ms ease, opacity 300ms ease;
|
||||
}
|
||||
|
||||
.rc-row:hover,
|
||||
.rc-row.rc-hl {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.rc-panel-win .rc-row.rc-hl {
|
||||
border-left-color: rgba(0, 200, 0, 0.5);
|
||||
}
|
||||
|
||||
.rc-panel-lose .rc-row.rc-hl {
|
||||
border-left-color: rgba(220, 30, 30, 0.5);
|
||||
}
|
||||
|
||||
.rc-row.rc-dead {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.rc-row.rc-gone {
|
||||
cursor: default;
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.rc-row.rc-dead:hover,
|
||||
.rc-row.rc-gone:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.rc-type-icon {
|
||||
width: 28px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
object-fit: contain;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.rc-row-info {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.rc-row-name {
|
||||
overflow: hidden;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rc-row-veh {
|
||||
overflow: hidden;
|
||||
color: rgba(255, 255, 255, 0.42);
|
||||
font-size: 0.65rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rc-row-status {
|
||||
width: 16px;
|
||||
flex-shrink: 0;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rc-center {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rc-canvas {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
background: #111;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.rc-tickets {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.rc-tk-val {
|
||||
min-width: 2.6rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rc-tk-val-win {
|
||||
color: #5cdf5c;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.rc-tk-val-lose {
|
||||
color: #e85555;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.rc-tk-track {
|
||||
display: flex;
|
||||
height: 10px;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
border-radius: 5px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.rc-tk-fill {
|
||||
height: 100%;
|
||||
transition: width 100ms linear;
|
||||
}
|
||||
|
||||
.rc-tk-fill-win {
|
||||
background: #2a8f2a;
|
||||
}
|
||||
|
||||
.rc-tk-fill-lose {
|
||||
background: #b22020;
|
||||
}
|
||||
|
||||
.rc-game-over .rc-tk-track {
|
||||
animation: rcTkGlow 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.rc-game-over .rc-tk-val-win,
|
||||
.rc-game-over .rc-panel-win .rc-panel-label {
|
||||
animation: rcTkTextGlow 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes rcTkGlow {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 2px 0 rgba(92, 223, 92, 0.25);
|
||||
}
|
||||
|
||||
50% {
|
||||
box-shadow: 0 0 7px 1px rgba(92, 223, 92, 0.55);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rcTkTextGlow {
|
||||
0%,
|
||||
100% {
|
||||
text-shadow: 0 0 3px rgba(92, 223, 92, 0.35);
|
||||
}
|
||||
|
||||
50% {
|
||||
text-shadow: 0 0 9px rgba(92, 223, 92, 0.85);
|
||||
}
|
||||
}
|
||||
|
||||
.rc-controls {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.45rem 0;
|
||||
}
|
||||
|
||||
.rc-btn {
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
padding: 0.25rem 0.45rem;
|
||||
transition: background-color 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.rc-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.rc-play {
|
||||
min-width: 54px;
|
||||
}
|
||||
|
||||
.rc-speeds {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.rc-sp.active {
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
color: #fff2e6;
|
||||
}
|
||||
|
||||
.rc-scrub {
|
||||
box-sizing: content-box;
|
||||
height: 6px;
|
||||
flex: 1;
|
||||
padding: 6px 0;
|
||||
border-radius: 3px;
|
||||
appearance: none;
|
||||
background: linear-gradient(to right, #2a6e2a var(--rc-progress, 0%), rgba(255, 255, 255, 0.14) var(--rc-progress, 0%));
|
||||
background-clip: content-box;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.rc-scrub::-webkit-slider-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: #90ee90;
|
||||
box-shadow: 0 0 3px rgba(0, 0, 0, 0.5);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rc-scrub::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: #90ee90;
|
||||
box-shadow: 0 0 3px rgba(0, 0, 0, 0.5);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rc-time {
|
||||
min-width: 65px;
|
||||
color: rgba(255, 255, 255, 0.48);
|
||||
font-size: 0.65rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rc-log-wrap {
|
||||
width: 100%;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.rc-log {
|
||||
overflow-y: auto;
|
||||
max-height: 130px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 0.6rem 0.8rem;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.14) transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.rc-log:empty::after {
|
||||
color: rgba(255, 255, 255, 0.22);
|
||||
content: "Waiting for events...";
|
||||
font-size: 0.7rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.rc-ev {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
color: rgba(255, 255, 255, 0.74);
|
||||
font-size: 0.72rem;
|
||||
padding: 0.2rem 0;
|
||||
}
|
||||
|
||||
.rc-ev:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.rc-ev-damage {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.rc-ev-time {
|
||||
display: inline-block;
|
||||
width: 32px;
|
||||
margin-right: 0.4rem;
|
||||
color: rgba(255, 255, 255, 0.34);
|
||||
font-size: 0.65rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rc-ev-win {
|
||||
color: #5cdf5c;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rc-ev-lose {
|
||||
color: #e85555;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rc-ev-action {
|
||||
color: rgba(255, 255, 255, 0.48);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.rc-ev-weapon {
|
||||
margin-left: 0.3rem;
|
||||
color: rgba(255, 255, 255, 0.34);
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
@keyframes scrollPulse {
|
||||
0% {
|
||||
transform: translateY(-100%);
|
||||
|
||||
Reference in New Issue
Block a user