diff --git a/backend/src/main.rs b/backend/src/main.rs index 41015af..e7fdfd8 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -201,6 +201,12 @@ struct GamesResponse { games: Vec, } +#[derive(Serialize)] +struct GameResponse { + game: GameRow, + participants: Vec, +} + #[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, @@ -302,6 +316,7 @@ async fn main() -> Result<(), Box> { .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>, + Path(session_id): Path, +) -> ApiResult { + 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>, Query(query): Query, @@ -1259,6 +1286,121 @@ fn recent_games_for(conn: &Connection, limit: i64) -> Result, ApiEr Ok(rows) } +fn game_for(conn: &Connection, session_id: &str) -> Result, 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, 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::, _>>() + .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 { 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 { let decoded = urlencoding::decode(value) .map_err(|_| ApiError::bad_request("Invalid team name"))? diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index ad3f1a0..1f91c51 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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

Participants unknown

+ } + + return ( +
+ {participants.map((participant) => ( + + {participant.name} + + ))} +
+ ) +} + 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' ? : null} + {route.page === 'battle-logs' ? ( + + ) : null} {route.page === 'uptime' ? : null} {route.page === 'viewers' ? : null} {route.page === 'privacy' ? : null} {route.page === 'docs' ? : null} {route.page === 'player' ? : null} + {route.page === 'game' ? : null}