ai generated solutions to our ai generated problems

This commit is contained in:
2026-06-17 23:34:22 +01:00
parent f6afcf599b
commit 08b6d01fc8
4 changed files with 356 additions and 41 deletions
+155
View File
@@ -201,6 +201,12 @@ struct GamesResponse {
games: Vec<GameRow>,
}
#[derive(Serialize)]
struct GameResponse {
game: GameRow,
participants: Vec<GameParticipant>,
}
#[derive(Serialize)]
struct GameRow {
#[serde(skip_serializing_if = "Option::is_none")]
@@ -230,6 +236,14 @@ struct GameStats {
team_kills_stat: i64,
}
#[derive(Serialize)]
struct GameParticipant {
team_name: String,
result: String,
player_count: i64,
stats: GameStats,
}
#[derive(Serialize)]
struct PlayerSearchResponse {
players: Vec<PlayerRef>,
@@ -302,6 +316,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.route("/health", get(health))
.route("/api/tss/leaderboard/teams", get(leaderboard))
.route("/api/tss/games/recent", get(recent_games))
.route("/api/tss/games/{session_id}", get(game_detail))
.route("/api/tss/teams/resolve", get(resolve_team))
.route("/api/tss/teams/search", get(search_teams))
.route("/api/tss/teams/{team}", get(team_detail))
@@ -494,6 +509,18 @@ async fn recent_games(
Ok(Json(RecentGamesResponse { matches }))
}
async fn game_detail(
State(state): State<Arc<AppState>>,
Path(session_id): Path<String>,
) -> ApiResult<GameResponse> {
let session_id = validate_session_id(&session_id)?;
let battles_conn = open_db(&state.battles_db)?;
let game = game_for(&battles_conn, session_id)?
.ok_or_else(|| ApiError::not_found("Game not found"))?;
let participants = game_participants_for(&battles_conn, session_id)?;
Ok(Json(GameResponse { game, participants }))
}
async fn resolve_team(
State(state): State<Arc<AppState>>,
Query(query): Query<ResolveQuery>,
@@ -1259,6 +1286,121 @@ fn recent_games_for(conn: &Connection, limit: i64) -> Result<Vec<GameRow>, ApiEr
Ok(rows)
}
fn game_for(conn: &Connection, session_id: &str) -> Result<Option<GameRow>, ApiError> {
conn.query_row(
"SELECT
p.session_id,
COALESCE(m.endtime_unix, MAX(p.endtime_unix), 0) AS timestamp,
m.mission_name,
m.mission_mode,
COUNT(DISTINCT p.UID),
COALESCE(SUM(p.ground_kills), 0),
COALESCE(SUM(p.air_kills), 0),
COALESCE(SUM(p.assists), 0),
COALESCE(SUM(p.captures), 0),
COALESCE(SUM(p.deaths), 0),
COALESCE(SUM(p.score), 0),
COALESCE(SUM(p.missile_evades), 0),
COALESCE(SUM(p.shell_interceptions), 0),
COALESCE(SUM(p.team_kills_stat), 0),
m.winning_slot,
m.losing_slot
FROM player_games_hist p
LEFT JOIN match_summary m ON m.session_id = p.session_id
WHERE p.session_id = ?1
GROUP BY p.session_id",
params![session_id],
|row| {
let timestamp: i64 = row.get(1)?;
Ok(GameRow {
team_name: None,
session_id: row.get(0)?,
timestamp,
endtime_unix: timestamp,
map_name: row.get(2)?,
mission_mode: row.get(3)?,
result: String::new(),
player_count: row.get(4)?,
winning_team: row.get(14)?,
losing_team: row.get(15)?,
stats: GameStats {
ground_kills: row.get(5)?,
air_kills: row.get(6)?,
assists: row.get(7)?,
captures: row.get(8)?,
deaths: row.get(9)?,
score: row.get(10)?,
missile_evades: row.get(11)?,
shell_interceptions: row.get(12)?,
team_kills_stat: row.get(13)?,
},
})
},
)
.optional()
.map_err(db_error)
}
fn game_participants_for(
conn: &Connection,
session_id: &str,
) -> Result<Vec<GameParticipant>, ApiError> {
let mut stmt = conn
.prepare(
"SELECT
p.team_name,
CASE
WHEN MAX(CASE WHEN p.victor_bool = 'Win' THEN 1 ELSE 0 END) = 1 THEN 'Win'
ELSE 'Loss'
END AS result,
COUNT(DISTINCT p.UID),
COALESCE(SUM(p.ground_kills), 0),
COALESCE(SUM(p.air_kills), 0),
COALESCE(SUM(p.assists), 0),
COALESCE(SUM(p.captures), 0),
COALESCE(SUM(p.deaths), 0),
COALESCE(SUM(p.score), 0),
COALESCE(SUM(p.missile_evades), 0),
COALESCE(SUM(p.shell_interceptions), 0),
COALESCE(SUM(p.team_kills_stat), 0)
FROM player_games_hist p
WHERE p.session_id = ?1 AND p.team_name IS NOT NULL AND p.team_name != ''
GROUP BY p.team_name COLLATE NOCASE
ORDER BY
CASE
WHEN MAX(CASE WHEN p.victor_bool = 'Win' THEN 1 ELSE 0 END) = 1 THEN 0
ELSE 1
END,
p.team_name COLLATE NOCASE",
)
.map_err(db_error)?;
let rows = stmt
.query_map(params![session_id], |row| {
Ok(GameParticipant {
team_name: row.get(0)?,
result: row.get(1)?,
player_count: row.get(2)?,
stats: GameStats {
ground_kills: row.get(3)?,
air_kills: row.get(4)?,
assists: row.get(5)?,
captures: row.get(6)?,
deaths: row.get(7)?,
score: row.get(8)?,
missile_evades: row.get(9)?,
shell_interceptions: row.get(10)?,
team_kills_stat: row.get(11)?,
},
})
})
.map_err(db_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(db_error)?;
Ok(rows)
}
fn validate_team_name(name: &str) -> Result<&str, ApiError> {
let trimmed = name.trim();
if trimmed.len() < 2 || trimmed.len() > MAX_TEAM_NAME_LENGTH {
@@ -1287,6 +1429,19 @@ fn validate_uid(value: &str) -> Result<String, ApiError> {
Ok(trimmed.to_string())
}
fn validate_session_id(value: &str) -> Result<&str, ApiError> {
if value.is_empty()
|| value.len() > 96
|| !value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
{
return Err(ApiError::bad_request("Invalid game ID"));
}
Ok(value)
}
fn decode_path_team(value: &str) -> Result<String, ApiError> {
let decoded = urlencoding::decode(value)
.map_err(|_| ApiError::bad_request("Invalid team name"))?
+192 -41
View File
@@ -17,6 +17,7 @@ const apiEndpoints = {
teams: '/api/tss/leaderboard/teams?limit=100',
teamsHealth: '/api/tss/leaderboard/teams?limit=1',
recentGames: '/api/tss/games/recent?limit=50',
game: (gameId) => `/api/tss/games/${encodeURIComponent(gameId)}`,
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)}`,
@@ -79,6 +80,10 @@ function parseRoute(pathname = window.location.pathname) {
const uid = decodeURIComponent(pathname.slice('/players/'.length))
return { page: 'player', teamName: '', uid }
}
if (pathname.startsWith('/games/')) {
const gameId = decodeURIComponent(pathname.slice('/games/'.length))
return { page: 'game', teamName: '', gameId }
}
if (pathname.startsWith('/teams/')) {
const teamName = decodeURIComponent(pathname.slice('/teams/'.length))
return { page: 'team', teamName }
@@ -91,6 +96,10 @@ function teamPath(name) {
return `/teams/${encodeURIComponent(name)}`
}
function gamePath(gameId) {
return `/games/${encodeURIComponent(gameId)}`
}
function formatNumber(value) {
return numberFormat.format(Number(value || 0))
}
@@ -100,6 +109,47 @@ function formatDate(timestamp) {
return dateFormat.format(new Date(Number(timestamp) * 1000))
}
function gameParticipants(game) {
const winner = game?.winning_team || ''
const loser = game?.losing_team || ''
if (winner || loser) {
return [
winner ? { name: winner, result: 'win' } : null,
loser ? { name: loser, result: 'loss' } : null,
].filter(Boolean)
}
const fallbackName = game?.team_name || ''
if (!fallbackName) return []
return [
{
name: fallbackName,
result: String(game?.result || '').toLowerCase() === 'win' ? 'win' : 'loss',
},
]
}
function ParticipantNames({ participants }) {
if (!participants.length) {
return <p className="truncate text-sm font-semibold text-text-soft">Participants unknown</p>
}
return (
<div className="flex min-w-0 flex-wrap gap-x-3 gap-y-1">
{participants.map((participant) => (
<span
className={`truncate text-sm font-semibold ${participant.result === 'win' ? 'text-fury-violet' : 'text-text-soft'}`}
key={`${participant.result}-${participant.name}`}
>
{participant.name}
</span>
))}
</div>
)
}
function bestTeamName(team) {
return team?.name || ''
}
@@ -360,6 +410,7 @@ function routeLabel(route) {
if (route.page === 'team' && route.teamName) return `Team: ${route.teamName}`
if (route.page === 'teams') return 'Team Leaderboard'
if (route.page === 'battle-logs') return 'Battle Logs'
if (route.page === 'game') return route.gameId ? `Game ${route.gameId}` : 'Game'
if (route.page === 'uptime') return 'Uptime'
if (route.page === 'viewers') return 'viewers'
if (route.page === 'privacy') return 'Privacy notice'
@@ -376,6 +427,7 @@ function canonicalPathForRoute(route) {
if (route.page === 'team' && route.teamName) return teamPath(route.teamName)
if (route.page === 'teams') return '/teams'
if (route.page === 'battle-logs') return '/battle-logs'
if (route.page === 'game' && route.gameId) return gamePath(route.gameId)
if (route.page === 'uptime') return '/uptime'
if (route.page === 'viewers') return '/viewers'
if (route.page === 'privacy') return '/privacy'
@@ -417,6 +469,15 @@ function seoForRoute(route, profileDetail = null) {
}
}
if (route.page === 'game' && route.gameId) {
return {
title: `Game ${route.gameId} | Toothless' TSS Bot`,
description: `TSS battle log details for game ${route.gameId}, including participants, map, player counts, and combat stats.`,
robots: 'noindex, follow',
path: canonicalPathForRoute(route),
}
}
const byPage = {
teams: {
title: "TSS Team Leaderboard | Toothless' TSS Bot",
@@ -1463,7 +1524,7 @@ function AppContent() {
const activeNavPath =
route.page === 'team'
? '/teams'
: route.page === 'battle-logs'
: route.page === 'battle-logs' || route.page === 'game'
? '/battle-logs'
: route.page === 'viewers'
? '/viewers'
@@ -1561,12 +1622,15 @@ function AppContent() {
teams={teams}
/>
) : null}
{route.page === 'battle-logs' ? <BattleLogsPage live={live} matches={matches} /> : null}
{route.page === 'battle-logs' ? (
<BattleLogsPage live={live} matches={matches} navigate={navigate} />
) : null}
{route.page === 'uptime' ? <UptimePage uptime={uptime} /> : null}
{route.page === 'viewers' ? <ViewersPage viewers={viewers} /> : null}
{route.page === 'privacy' ? <PrivacyPage /> : null}
{route.page === 'docs' ? <DocsPage /> : null}
{route.page === 'player' ? <PlayerPage uid={route.uid} navigate={navigate} /> : null}
{route.page === 'game' ? <GamePage gameId={route.gameId} navigate={navigate} /> : null}
</section>
<Footer navigate={navigate} />
<ConsentBanner preferences={analyticsPreferences} onChoose={chooseAnalyticsConsent} />
@@ -2304,9 +2368,11 @@ function RecentGamesSection({ live, matches, navigate }) {
<div className="mt-6 grid gap-4 lg:grid-cols-3">
{recentMatches.map((match) => (
<article
className="rounded-lg border border-border bg-bg p-4 shadow-sm"
<button
className="rounded-lg border border-border bg-bg p-4 text-left shadow-sm transition hover:border-ring hover:bg-surface"
key={match.session_id}
onClick={() => navigate(gamePath(match.session_id))}
type="button"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
@@ -2315,15 +2381,10 @@ function RecentGamesSection({ live, matches, navigate }) {
</h3>
<p className="mt-1 text-xs text-text-soft">{formatDate(match.timestamp)}</p>
</div>
<span className="shrink-0 rounded-md bg-surface px-2 py-1 text-xs font-semibold text-text-soft">
{match.result || 'Unknown'}
</span>
</div>
<div className="mt-4 grid grid-cols-[1fr_auto] items-center gap-3 text-sm">
<p className="truncate font-semibold text-fury-cyan">
{match.team_name || 'TSS team'}
</p>
<ParticipantNames participants={gameParticipants(match)} />
<p className="text-text-soft">
{formatNumber(match.player_count)} players
</p>
@@ -2333,7 +2394,7 @@ function RecentGamesSection({ live, matches, navigate }) {
<span>{formatNumber(match.stats?.air_kills)} air</span>
<span>{formatNumber(match.stats?.deaths)} deaths</span>
</div>
</article>
</button>
))}
</div>
@@ -2663,7 +2724,112 @@ function TeamProfilePage({ navigate, profile, requestedTeam, teams }) {
<RosterTable players={players} status={profile.detail.status} />
<BattleResults games={games} status={profile.games.status} />
<BattleResults games={games} navigate={navigate} status={profile.games.status} />
</section>
)
}
function GamePage({ gameId, navigate }) {
const [gameState, setGameState] = useState({ status: 'loading', data: null, error: null })
useEffect(() => {
if (!gameId) return
const controller = new AbortController()
setGameState({ status: 'loading', data: null, error: null })
fetchJson(apiEndpoints.game(gameId), controller.signal)
.then((data) => {
if (!controller.signal.aborted) {
setGameState({ status: 'ready', data, error: null })
}
})
.catch((error) => {
if (!controller.signal.aborted) {
setGameState({ status: 'error', data: null, error: error.message })
}
})
return () => controller.abort()
}, [gameId])
const game = gameState.data?.game
const participants = gameState.data?.participants || []
const participantNames = participants.length
? participants.map((participant) => ({
name: participant.team_name,
result: String(participant.result || '').toLowerCase() === 'win' ? 'win' : 'loss',
}))
: gameParticipants(game)
return (
<section className="space-y-6 pt-24 sm:pt-28">
<button
className="text-sm font-semibold text-fury-cyan transition hover:text-text"
onClick={() => navigate('/battle-logs')}
type="button"
>
Back to battle logs
</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}
</p>
{gameState.status === 'error' ? (
<p className="mt-4 text-sm text-danger">{gameState.error}</p>
) : null}
{game ? (
<div className="mt-6 grid gap-5 sm:grid-cols-2 xl:grid-cols-4">
<Stat label="Players" value={formatNumber(game.player_count)} />
<Stat label="Ground" value={formatNumber(game.stats?.ground_kills)} />
<Stat label="Air" value={formatNumber(game.stats?.air_kills)} />
<Stat label="Deaths" value={formatNumber(game.stats?.deaths)} />
</div>
) : 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>
{participants.map((participant) => {
const won = String(participant.result || '').toLowerCase() === 'win'
return (
<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_repeat(5,auto)] md:items-center"
key={participant.team_name}
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-sm">{formatNumber(participant.player_count)} players</p>
<p className="text-sm">{formatNumber(participant.stats?.ground_kills)} ground</p>
<p className="text-sm">{formatNumber(participant.stats?.air_kills)} air</p>
<p className="text-sm">{formatNumber(participant.stats?.assists)} assists</p>
<p className="text-sm">{formatNumber(participant.stats?.deaths)} deaths</p>
</button>
)
})}
{!participants.length ? (
<p className="px-5 py-10 text-sm text-text-soft">
{gameState.status === 'loading' ? 'Loading game' : 'No participants returned'}
</p>
) : null}
</div>
</div>
</section>
)
}
@@ -2708,7 +2874,7 @@ function RosterTable({ players, status }) {
)
}
function BattleResults({ games, status }) {
function BattleResults({ games, navigate, status }) {
return (
<div className="rounded-lg border border-border bg-fury-white shadow-sm">
<div className="border-b border-surface px-5 py-4">
@@ -2717,9 +2883,11 @@ function BattleResults({ games, status }) {
</div>
<div className="max-h-[560px] overflow-auto">
{games.map((game) => (
<div
className="grid gap-4 border-b border-surface px-5 py-4 md:grid-cols-[1fr_auto_repeat(5,auto)] md:items-center"
<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)_repeat(5,auto)] md:items-center"
key={game.session_id}
onClick={() => navigate(gamePath(game.session_id))}
type="button"
>
<div className="min-w-0">
<p className="truncate font-semibold">{game.map_name || 'Unknown map'}</p>
@@ -2727,20 +2895,13 @@ function BattleResults({ games, status }) {
{formatDate(game.timestamp)} · {game.session_id}
</p>
</div>
<p
className={`rounded-md px-3 py-1 text-sm font-semibold ${String(game.result).toLowerCase() === 'win'
? 'bg-surface text-fury-cyan'
: 'bg-fury-ice text-fury-violet'
}`}
>
{game.result || 'Unknown'}
</p>
<ParticipantNames participants={gameParticipants(game)} />
<p className="text-sm">{formatNumber(game.player_count)} players</p>
<p className="text-sm">{formatNumber(game.stats?.ground_kills)} ground</p>
<p className="text-sm">{formatNumber(game.stats?.air_kills)} air</p>
<p className="text-sm">{formatNumber(game.stats?.assists)} assists</p>
<p className="text-sm">{formatNumber(game.stats?.deaths)} deaths</p>
</div>
</button>
))}
{!games.length ? (
<p className="px-5 py-10 text-sm text-text-soft">
@@ -2752,7 +2913,7 @@ function BattleResults({ games, status }) {
)
}
function BattleLogsPage({ live, matches }) {
function BattleLogsPage({ live, matches, navigate }) {
return (
<section className="space-y-6 pt-24 sm:pt-28">
<div>
@@ -2764,9 +2925,11 @@ function BattleLogsPage({ live, matches }) {
<div className="overflow-hidden rounded-lg border border-border bg-fury-white shadow-sm">
{matches.map((match) => (
<div
className="grid gap-4 border-b border-surface px-5 py-4 md:grid-cols-[1fr_1fr_auto_repeat(4,auto)] md:items-center"
<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)_repeat(4,auto)] md:items-center"
key={match.session_id}
onClick={() => navigate(gamePath(match.session_id))}
type="button"
>
<div className="min-w-0">
<p className="truncate font-semibold">{match.map_name || 'Unknown map'}</p>
@@ -2774,24 +2937,12 @@ function BattleLogsPage({ live, matches }) {
{formatDate(match.timestamp)} · {match.session_id}
</p>
</div>
<div className="min-w-0">
<p className="truncate font-semibold text-fury-cyan">
{match.team_name || 'TSS team'}
</p>
</div>
<p
className={`w-fit rounded-md px-3 py-1 text-sm font-semibold ${String(match.result).toLowerCase() === 'win'
? 'bg-surface text-fury-cyan'
: 'bg-fury-ice text-fury-violet'
}`}
>
{match.result || 'Unknown'}
</p>
<ParticipantNames participants={gameParticipants(match)} />
<p className="text-sm">{formatNumber(match.player_count)} players</p>
<p className="text-sm">{formatNumber(match.stats?.ground_kills)} ground</p>
<p className="text-sm">{formatNumber(match.stats?.air_kills)} air</p>
<p className="text-sm">{formatNumber(match.stats?.deaths)} deaths</p>
</div>
</button>
))}
{!matches.length ? (
+5
View File
@@ -1581,6 +1581,11 @@ function allowedApiTarget(req) {
return url
}
if (/^\/api\/tss\/games\/[A-Za-z0-9_-]{1,96}$/.test(pathname)) {
if ([...params.keys()].length) return null
return url
}
if (pathname === '/api/tss/teams/resolve') {
const keys = [...params.keys()]
const name = params.get('name') || ''
+4
View File
@@ -26,6 +26,10 @@ function isAllowedApiUrl(req) {
return keys.every((key) => key === 'limit') && Number.isInteger(limit) && limit >= 1 && limit <= 100
}
if (/^\/api\/tss\/games\/[A-Za-z0-9_-]{1,96}$/.test(url.pathname)) {
return [...params.keys()].length === 0
}
if (url.pathname === '/api/tss/teams/resolve') {
const keys = [...params.keys()]
const name = params.get('name') || ''