diff --git a/backend/src/main.rs b/backend/src/main.rs index 174739c..a18704a 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -9,7 +9,7 @@ use rusqlite::{params, Connection, OptionalExtension}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::{ - collections::{BTreeMap, HashMap}, + collections::{BTreeMap, HashMap, HashSet}, env, fs, net::{IpAddr, Ipv4Addr, SocketAddr}, path::{Path as FsPath, PathBuf}, @@ -28,6 +28,7 @@ const LEADERBOARD_CACHE_TTL: Duration = Duration::from_secs(300); struct AppState { battles_db: PathBuf, teams_db: PathBuf, + tournaments_db: PathBuf, leaderboard_cache: Mutex>, vehicle_names: HashMap>, vehicle_icons: HashMap, @@ -239,6 +240,72 @@ struct GameResponse { participants: Vec, } +#[derive(Serialize)] +struct TournamentsResponse { + tournaments: Vec, +} + +#[derive(Serialize)] +struct TournamentSummary { + tournament_id: i64, + name: Option, + format: Option, + status: Option, + match_count: i64, + team_count: i64, + date_start: Option, + date_end: Option, +} + +#[derive(Serialize)] +struct TournamentDetailResponse { + tournament_id: i64, + name: Option, + format: Option, + status: Option, + match_count: i64, + team_count: i64, + date_start: Option, + date_end: Option, + matches: Vec, + standings: Vec, +} + +#[derive(Serialize)] +struct TournamentMatchRow { + match_id: String, + type_bracket: String, + side: Option, + round: Option, + position: Option, + team_a_name: Option, + team_b_name: Option, + winner_name: Option, + score_a: i64, + score_b: i64, + status: String, + battles: Vec, +} + +#[derive(Serialize)] +struct TournamentBattleRow { + session_hex: String, + position: Option, + have_replay: bool, +} + +#[derive(Serialize)] +struct TournamentStandingRow { + group_index: i64, + team_name: Option, + points: i64, + wins: i64, + draws: i64, + losses: i64, + buchholz: f64, + rank: Option, +} + #[derive(Serialize)] struct GameLogsResponse { chat_log: Vec, @@ -260,6 +327,8 @@ struct GameRow { winning_team: Option, losing_team: Option, #[serde(skip_serializing_if = "Option::is_none")] + tournament_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] tournament_name: Option, #[serde(skip_serializing_if = "Option::is_none")] duration: Option, @@ -371,6 +440,7 @@ async fn main() -> Result<(), Box> { let state = Arc::new(AppState { battles_db: resolve_db_path("TSS_BATTLES_DB", "tss_battles.db"), teams_db: resolve_db_path("TSS_TEAMS_DB", "tss_teams.db"), + tournaments_db: resolve_db_path("TSS_TOURNAMENTS_DB", "tss_tournaments.db"), leaderboard_cache: Mutex::new(None), vehicle_names: load_vehicle_names(&resolve_db_path( "VEHICLE_TRANSLATIONS_JSON", @@ -393,6 +463,8 @@ async fn main() -> Result<(), Box> { .route("/api/tss/leaderboard/teams", get(leaderboard)) .route("/api/tss/leaderboard/players", get(player_leaderboard)) .route("/api/tss/games/recent", get(recent_games)) + .route("/api/tss/tournaments", get(tournaments)) + .route("/api/tss/tournaments/{tournament_id}", get(tournament_detail)) .route("/api/tss/games/{session_id}", get(game_detail)) .route("/api/tss/games/{session_id}/logs", get(game_logs)) .route("/api/tss/teams/resolve", get(resolve_team)) @@ -597,6 +669,38 @@ async fn recent_games( Ok(Json(RecentGamesResponse { matches })) } +async fn tournaments(State(state): State>) -> ApiResult { + let conn = open_db(&state.tournaments_db)?; + let tournaments = tournaments_list(&conn)?; + Ok(Json(TournamentsResponse { tournaments })) +} + +async fn tournament_detail( + State(state): State>, + Path(tournament_id): Path, +) -> ApiResult { + let tid = validate_tournament_id(&tournament_id)?; + let conn = open_db(&state.tournaments_db)?; + let summary = tournament_summary_for(&conn, tid)? + .ok_or_else(|| ApiError::not_found("Tournament not found"))?; + let standings = tournament_standings_for(&conn, tid)?; + let mut matches = tournament_match_rows_for(&conn, tid)?; + attach_battles(&conn, &state.battles_db, tid, &mut matches)?; + + Ok(Json(TournamentDetailResponse { + tournament_id: summary.tournament_id, + name: summary.name, + format: summary.format, + status: summary.status, + match_count: summary.match_count, + team_count: summary.team_count, + date_start: summary.date_start, + date_end: summary.date_end, + matches, + standings, + })) +} + async fn game_detail( State(state): State>, Path(session_id): Path, @@ -1449,6 +1553,7 @@ fn games_for(conn: &Connection, team_name: &str) -> Result, ApiErro player_count: row.get(8)?, winning_team: row.get(18)?, losing_team: row.get(19)?, + tournament_id: None, tournament_name: row.get(4)?, duration: row.get(5)?, draw: draw_int != 0, @@ -1541,6 +1646,7 @@ fn recent_games_for(conn: &Connection, limit: i64) -> Result, ApiEr player_count: row.get(7)?, winning_team: row.get(8)?, losing_team: row.get(9)?, + tournament_id: None, tournament_name: row.get(4)?, duration: row.get(5)?, draw: draw_int != 0, @@ -1564,6 +1670,207 @@ fn recent_games_for(conn: &Connection, limit: i64) -> Result, ApiEr Ok(rows) } +fn tournaments_list(conn: &Connection) -> Result, ApiError> { + let mut stmt = conn + .prepare( + "SELECT tournament_id, name, format, status, match_count, team_count, + date_start, date_end + FROM tournaments + ORDER BY COALESCE(date_end, scanned_unix, 0) DESC, tournament_id DESC + LIMIT 500", + ) + .map_err(db_error)?; + let rows = stmt + .query_map([], read_tournament_summary) + .map_err(db_error)? + .collect::, _>>() + .map_err(db_error)?; + Ok(rows) +} + +fn tournament_summary_for( + conn: &Connection, + tid: i64, +) -> Result, ApiError> { + conn.query_row( + "SELECT tournament_id, name, format, status, match_count, team_count, + date_start, date_end + FROM tournaments WHERE tournament_id = ?1", + params![tid], + read_tournament_summary, + ) + .optional() + .map_err(db_error) +} + +fn read_tournament_summary(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(TournamentSummary { + tournament_id: row.get(0)?, + name: row.get(1)?, + format: row.get(2)?, + status: row.get(3)?, + match_count: row.get(4)?, + team_count: row.get(5)?, + date_start: row.get(6)?, + date_end: row.get(7)?, + }) +} + +fn tournament_match_rows_for( + conn: &Connection, + tid: i64, +) -> Result, ApiError> { + // Order: winner side, then final, then loser; group/swiss after. Within a + // side, by round then position (the schedule's round/matchNumber). NULLs last. + let mut stmt = conn + .prepare( + "SELECT match_id, type_bracket, side, round, position, + team_a_name, team_b_name, winner_name, score_a, score_b, status + FROM tournament_matches + WHERE tournament_id = ?1 + ORDER BY + CASE side WHEN 'winner' THEN 0 WHEN 'final' THEN 1 WHEN 'loser' THEN 2 + WHEN 'group' THEN 3 WHEN 'swiss' THEN 4 ELSE 5 END, + round IS NULL, round, + position IS NULL, position, + time_start, match_id", + ) + .map_err(db_error)?; + let rows = stmt + .query_map(params![tid], |row| { + Ok(TournamentMatchRow { + match_id: row.get(0)?, + type_bracket: row.get(1)?, + side: row.get(2)?, + round: row.get(3)?, + position: row.get(4)?, + team_a_name: row.get(5)?, + team_b_name: row.get(6)?, + winner_name: row.get(7)?, + score_a: row.get(8)?, + score_b: row.get(9)?, + status: row.get(10)?, + battles: Vec::new(), + }) + }) + .map_err(db_error)? + .collect::, _>>() + .map_err(db_error)?; + Ok(rows) +} + +fn tournament_standings_for( + conn: &Connection, + tid: i64, +) -> Result, ApiError> { + let mut stmt = conn + .prepare( + "SELECT group_index, team_name, points, wins, draws, losses, buchholz, rank + FROM tournament_standings + WHERE tournament_id = ?1 + ORDER BY group_index, rank IS NULL, rank, points DESC", + ) + .map_err(db_error)?; + let rows = stmt + .query_map(params![tid], |row| { + Ok(TournamentStandingRow { + group_index: row.get(0)?, + team_name: row.get(1)?, + points: row.get(2)?, + wins: row.get(3)?, + draws: row.get(4)?, + losses: row.get(5)?, + buchholz: row.get(6)?, + rank: row.get(7)?, + }) + }) + .map_err(db_error)? + .collect::, _>>() + .map_err(db_error)?; + Ok(rows) +} + +// Attach each match's battles and mark which ones we actually hold a replay for +// (session_hex present in match_summary over in the battles DB). +fn attach_battles( + conn: &Connection, + battles_db: &FsPath, + tid: i64, + matches: &mut [TournamentMatchRow], +) -> Result<(), ApiError> { + let mut index: HashMap<(String, String), usize> = HashMap::new(); + for (i, m) in matches.iter().enumerate() { + index.insert((m.match_id.clone(), m.type_bracket.clone()), i); + } + + let mut stmt = conn + .prepare( + "SELECT match_id, type_bracket, session_hex, position + FROM tournament_battles + WHERE tournament_id = ?1 + ORDER BY match_id, type_bracket, position IS NULL, position", + ) + .map_err(db_error)?; + let battles = stmt + .query_map(params![tid], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, Option>(3)?, + )) + }) + .map_err(db_error)? + .collect::, _>>() + .map_err(db_error)?; + + let hexes: Vec = battles.iter().map(|(_, _, h, _)| h.clone()).collect(); + let held = held_session_ids(battles_db, &hexes)?; + + for (match_id, type_bracket, session_hex, position) in battles { + if let Some(&idx) = index.get(&(match_id, type_bracket)) { + let have_replay = held.contains(&session_hex); + matches[idx].battles.push(TournamentBattleRow { + session_hex, + position, + have_replay, + }); + } + } + Ok(()) +} + +fn held_session_ids( + battles_db: &FsPath, + hexes: &[String], +) -> Result, ApiError> { + let mut held = HashSet::new(); + if hexes.is_empty() { + return Ok(held); + } + let conn = open_db(battles_db)?; + for chunk in hexes.chunks(400) { + let placeholders = std::iter::repeat("?") + .take(chunk.len()) + .collect::>() + .join(","); + let sql = format!( + "SELECT session_id FROM match_summary WHERE session_id IN ({placeholders})" + ); + let mut stmt = conn.prepare(&sql).map_err(db_error)?; + let rows = stmt + .query_map(rusqlite::params_from_iter(chunk.iter()), |row| { + row.get::<_, String>(0) + }) + .map_err(db_error)?; + for r in rows { + held.insert(r.map_err(db_error)?); + } + } + Ok(held) +} + + fn game_for(conn: &Connection, session_id: &str) -> Result, ApiError> { // player_games_hist stores one row per used vehicle per player, with the // per-player stats duplicated across those rows. Reduce to one row per UID @@ -1604,7 +1911,8 @@ fn game_for(conn: &Connection, session_id: &str) -> Result, ApiE AND pg.victor_bool = 'Loss' GROUP BY pg.team_name COLLATE NOCASE ORDER BY COUNT(DISTINCT pg.UID) DESC, pg.team_name COLLATE NOCASE - LIMIT 1) + LIMIT 1), + m.tournament_id FROM ( SELECT COUNT(*) AS player_count, @@ -1652,6 +1960,7 @@ fn game_for(conn: &Connection, session_id: &str) -> Result, ApiE player_count: row.get(7)?, winning_team: row.get(17)?, losing_team: row.get(18)?, + tournament_id: row.get(19)?, tournament_name: row.get(4)?, duration: row.get(5)?, draw: draw_int != 0, @@ -1853,6 +2162,14 @@ fn validate_uid(value: &str) -> Result { Ok(trimmed.to_string()) } +fn validate_tournament_id(value: &str) -> Result { + let trimmed = value.trim(); + match trimmed.parse::() { + Ok(id) if id > 0 => Ok(id), + _ => Err(ApiError::bad_request("Invalid tournament ID")), + } +} + fn validate_session_id(value: &str) -> Result<&str, ApiError> { if value.is_empty() || value.len() > 96 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f24bff6..da4be24 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -17,6 +17,8 @@ const apiEndpoints = { viewerDelete: '/api/viewers/delete', teams: '/api/tss/leaderboard/teams?limit=100', players: '/api/tss/leaderboard/players?limit=100', + tournaments: '/api/tss/tournaments', + tournament: (id) => `/api/tss/tournaments/${encodeURIComponent(id)}`, homeTeams: '/api/tss/leaderboard/teams?limit=4', teamsHealth: '/api/tss/leaderboard/teams?limit=1', recentGames: '/api/tss/games/recent?limit=50', @@ -35,6 +37,7 @@ const navItems = [ { path: '/teams', label: 'Team Leaderboard' }, { path: '/players', label: 'Player Leaderboard' }, { path: '/battle-logs', label: 'Battle Logs' }, + { path: '/tournaments', label: 'Tournaments' }, { path: '/viewers', label: 'Viewers' }, { path: '/docs', label: 'Setup' }, ] @@ -180,6 +183,11 @@ function parseRoute(pathname = window.location.pathname) { if (pathname === '/viewers') return { page: 'viewers', teamName: '' } if (pathname === '/privacy') return { page: 'privacy', teamName: '' } if (pathname === '/docs') return { page: 'docs', teamName: '' } + if (pathname === '/tournaments') return { page: 'tournaments-list', teamName: '' } + if (pathname.startsWith('/tournaments/')) { + const tournamentId = decodeURIComponent(pathname.slice('/tournaments/'.length)) + return { page: 'tournament', teamName: '', tournamentId } + } if (pathname.startsWith('/players/')) { const uid = decodeURIComponent(pathname.slice('/players/'.length)) return { page: 'player', teamName: '', uid } @@ -208,6 +216,10 @@ function playerPath(uid) { return `/players/${encodeURIComponent(uid)}` } +function tournamentPath(tournamentId) { + return `/tournaments/${encodeURIComponent(tournamentId)}` +} + function formatNumber(value) { return numberFormat.format(Number(value || 0)) } @@ -601,6 +613,8 @@ function routeLabel(route) { if (route.page === 'players') return 'Player Leaderboard' if (route.page === 'battle-logs') return 'Battle Logs' if (route.page === 'game') return route.gameId ? `Game ${route.gameId}` : 'Game' + if (route.page === 'tournaments-list') return 'Tournaments' + if (route.page === 'tournament') return route.tournamentId ? `Tournament ${route.tournamentId}` : 'Tournament' if (route.page === 'uptime') return 'Uptime' if (route.page === 'viewers') return 'viewers' if (route.page === 'privacy') return 'Privacy notice' @@ -619,6 +633,8 @@ function canonicalPathForRoute(route) { if (route.page === 'players') return '/players' if (route.page === 'battle-logs') return '/battle-logs' if (route.page === 'game' && route.gameId) return gamePath(route.gameId) + if (route.page === 'tournaments-list') return '/tournaments' + if (route.page === 'tournament' && route.tournamentId) return tournamentPath(route.tournamentId) if (route.page === 'uptime') return '/uptime' if (route.page === 'viewers') return '/viewers' if (route.page === 'privacy') return '/privacy' @@ -669,6 +685,15 @@ function seoForRoute(route, profileDetail = null) { } } + if (route.page === 'tournament' && route.tournamentId) { + return { + title: `Tournament ${route.tournamentId} | Toothless' TSS Bot`, + description: `TSS tournament ${route.tournamentId} bracket, matches, standings, and games tracked by Toothless' TSS Bot.`, + robots: 'index, follow', + path: canonicalPathForRoute(route), + } + } + const byPage = { teams: { title: "TSS Team Leaderboard | Toothless' TSS Bot", @@ -688,6 +713,12 @@ function seoForRoute(route, profileDetail = null) { robots: 'index, follow', path: '/battle-logs', }, + 'tournaments-list': { + title: "TSS Tournaments | Toothless' TSS Bot", + description: 'Browse tracked TSS tournaments with authoritative brackets, standings, and linked replay availability.', + robots: 'index, follow', + path: '/tournaments', + }, uptime: { title: "TSS Bot Uptime Status | Toothless' TSS Bot", description: 'Check Toothless TSS Bot uptime, API health, TSS data proxy status, and recent availability history.', @@ -1789,6 +1820,8 @@ function AppContent() { ? '/players' : route.page === 'battle-logs' || route.page === 'game' ? '/battle-logs' + : route.page === 'tournaments-list' || route.page === 'tournament' + ? '/tournaments' : route.page === 'viewers' ? '/viewers' : window.location.pathname @@ -1923,6 +1956,8 @@ function AppContent() { {route.page === 'docs' ? : null} {route.page === 'player' ? : null} {route.page === 'game' ? : null} + {route.page === 'tournaments-list' ? : null} + {route.page === 'tournament' ? : null}