use axum::{ extract::{Path, Query, State}, http::{header, HeaderValue, Method, StatusCode}, response::{IntoResponse, Response}, routing::get, Json, Router, }; use rusqlite::{params, Connection, OptionalExtension}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::{ collections::{BTreeMap, HashMap, HashSet}, env, fs, net::{IpAddr, Ipv4Addr, SocketAddr}, path::{Path as FsPath, PathBuf}, sync::{Arc, Mutex}, time::Duration, }; use tokio::net::TcpListener; use tower_http::{ cors::{AllowOrigin, CorsLayer}, trace::TraceLayer, }; const MAX_TEAM_NAME_LENGTH: usize = 80; 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, } struct CachedLeaderboard { teams: Vec, } #[derive(Debug)] struct ApiError { status: StatusCode, message: String, } impl ApiError { fn bad_request(message: impl Into) -> Self { Self { status: StatusCode::BAD_REQUEST, message: message.into(), } } fn not_found(message: impl Into) -> Self { Self { status: StatusCode::NOT_FOUND, message: message.into(), } } fn internal(message: impl Into) -> Self { Self { status: StatusCode::INTERNAL_SERVER_ERROR, message: message.into(), } } } impl IntoResponse for ApiError { fn into_response(self) -> Response { (self.status, Json(json!({ "error": self.message }))).into_response() } } type ApiResult = Result, ApiError>; #[derive(Deserialize)] struct LimitQuery { limit: Option, } #[derive(Deserialize)] struct LangQuery { lang: Option, } #[derive(Deserialize)] struct ResolveQuery { name: String, } #[derive(Deserialize)] struct SearchQuery { q: Option, name: Option, limit: Option, } #[derive(Serialize)] struct HealthResponse { ok: bool, service: &'static str, battles_db: String, teams_db: String, databases: BTreeMap<&'static str, bool>, } #[derive(Clone, Serialize)] struct LeaderboardResponse { teams: Vec, } #[derive(Serialize)] struct PlayerLeaderboardResponse { players: Vec, } #[derive(Serialize)] struct RecentGamesResponse { matches: Vec, } #[derive(Serialize)] struct SearchResponse { teams: Vec, } #[derive(Serialize)] struct ResolveResponse { team_id: i64, name: String, } #[derive(Serialize)] struct TeamSearchRow { team_id: i64, name: String, members: i64, } #[derive(Clone, Serialize)] struct TeamLeaderboardRow { team_id: i64, name: String, player_count: i64, total_battles: i64, wins: i64, losses: i64, win_rate: f64, total_kills: i64, } #[derive(Serialize)] struct PlayerLeaderboardRow { uid: String, nick: Option, total_battles: i64, wins: i64, losses: i64, win_rate: f64, ground_kills: i64, air_kills: i64, total_kills: i64, assists: i64, captures: i64, deaths: i64, score: i64, kdr: f64, teams_seen: i64, last_seen: i64, } #[derive(Serialize)] struct TeamDetail { team_id: i64, name: String, members: i64, captain_uid: Option, data_set: &'static str, team_summary: TeamSummary, players: Vec, } #[derive(Default, Serialize)] struct TeamSummary { player_count: i64, total_battles: i64, wins: i64, losses: i64, win_rate: f64, kdr: f64, total_kills: i64, total_points: i64, } #[derive(Serialize)] struct PlayerSummary { uid: String, nick: Option, role: String, total_battles: i64, wins: i64, losses: i64, win_rate: f64, total_kills: i64, ground_kills: i64, air_kills: i64, assists: i64, deaths: i64, kdr: f64, } #[derive(Serialize)] struct HistoryResponse { team_id: i64, name: String, history: Vec, } #[derive(Serialize)] struct PeriodHistory { period: String, battles: i64, wins: i64, losses: i64, win_rate: f64, } #[derive(Serialize)] struct GamesResponse { team_id: i64, name: String, games: Vec, } #[derive(Serialize)] struct GameResponse { game: GameRow, 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, battle_log: Vec, event_log: Value, } #[derive(Serialize)] struct GameRow { #[serde(skip_serializing_if = "Option::is_none")] team_name: Option, session_id: String, timestamp: i64, endtime_unix: i64, map_name: Option, mission_mode: Option, result: String, player_count: i64, 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, draw: bool, stats: GameStats, } #[derive(Serialize)] struct GameStats { ground_kills: i64, air_kills: i64, assists: i64, captures: i64, deaths: i64, score: i64, missile_evades: i64, shell_interceptions: i64, team_kills_stat: i64, } #[derive(Serialize)] struct GameParticipant { team_name: String, result: String, player_count: i64, stats: GameStats, players: Vec, } #[derive(Serialize)] struct GamePlayer { uid: String, nick: Option, vehicles: Vec, stats: GameStats, } #[derive(Serialize)] struct Vehicle { cdk: String, name: String, icon: String, } #[derive(Serialize)] struct PlayerSearchResponse { players: Vec, } #[derive(Serialize)] struct PlayerRef { uid: String, nick: Option, } #[derive(Serialize)] struct PlayerProfile { uid: String, nick: Option, data_set: &'static str, career: PlayerCareer, teams: Vec, } #[derive(Serialize)] struct PlayerCareer { total_battles: i64, wins: i64, losses: i64, win_rate: f64, ground_kills: i64, air_kills: i64, total_kills: i64, assists: i64, captures: i64, deaths: i64, kdr: f64, } #[derive(Serialize)] struct PlayerTeamRef { team_id: Option, team_name: Option, games: i64, last_seen: i64, } struct TeamRecord { team_id: i64, name: String, members: i64, captain_uid: Option, } #[tokio::main] async fn main() -> Result<(), Box> { load_root_env(); tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), ) .init(); let host = env_ip("BACKEND_HOST").unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST)); let port = env_u16("BACKEND_PORT") .or_else(|| env_u16("PORT")) .unwrap_or(6000); 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", "vehicle_translations.json", )), vehicle_icons: load_vehicle_icons(&resolve_db_path( "VEHICLE_DATA_CACHE_JSON", "vehicle_data_cache.json", )), }); tracing::info!( "loaded {} vehicle name maps, {} vehicle icons", state.vehicle_names.len(), state.vehicle_icons.len() ); spawn_leaderboard_refresh(state.clone()); let app = Router::new() .route("/health", get(health)) .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)) .route("/api/tss/teams/search", get(search_teams)) .route("/api/tss/teams/{team}", get(team_detail)) .route("/api/tss/teams/{team}/history", get(team_history)) .route("/api/tss/teams/{team}/games", get(team_games)) .route("/api/tss/players/resolve", get(resolve_player)) .route("/api/tss/players/search", get(search_players)) .route("/api/tss/player/{uid}", get(player_detail)) .layer( CorsLayer::new() .allow_methods([Method::GET]) .allow_origin(allowed_origins()) .allow_headers([header::ACCEPT, header::CONTENT_TYPE]), ) .layer(TraceLayer::new_for_http()) .with_state(state); let addr = SocketAddr::from((host, port)); let listener = TcpListener::bind(addr).await?; tracing::info!("tssbot backend listening on http://{}", addr); axum::serve(listener, app) .with_graceful_shutdown(shutdown_signal()) .await?; Ok(()) } async fn shutdown_signal() { let ctrl_c = async { tokio::signal::ctrl_c() .await .expect("failed to install Ctrl+C handler"); }; #[cfg(unix)] let terminate = async { tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) .expect("failed to install signal handler") .recv() .await; }; #[cfg(not(unix))] let terminate = std::future::pending::<()>(); tokio::select! { _ = ctrl_c => {}, _ = terminate => {}, } } async fn health(State(state): State>) -> Json { let mut databases = BTreeMap::new(); databases.insert( "battles", Connection::open_with_flags( &state.battles_db, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, ) .is_ok(), ); databases.insert( "teams", Connection::open_with_flags(&state.teams_db, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) .is_ok(), ); Json(HealthResponse { ok: databases.values().all(|ok| *ok), service: "tssbot-backend", battles_db: state.battles_db.display().to_string(), teams_db: state.teams_db.display().to_string(), databases, }) } async fn leaderboard( State(state): State>, Query(query): Query, ) -> ApiResult { let limit = usize::try_from(query.limit.unwrap_or(100).clamp(1, 100)).unwrap_or(100); if let Some(teams) = cached_leaderboard(&state, limit)? { return Ok(Json(LeaderboardResponse { teams })); } let teams = leaderboard_roster_rows(&state, limit)?; Ok(Json(LeaderboardResponse { teams })) } async fn player_leaderboard( State(state): State>, Query(query): Query, ) -> ApiResult { let limit = i64::from(query.limit.unwrap_or(100).clamp(1, 100)); let battles_conn = open_db(&state.battles_db)?; let players = player_leaderboard_rows(&battles_conn, limit)?; Ok(Json(PlayerLeaderboardResponse { players })) } fn leaderboard_teams(state: &AppState, limit: usize) -> Result, ApiError> { let teams_conn = open_db(&state.teams_db)?; // Deduplicate teams by name across tournaments — pick the highest team_id // (most recent) per name for the roster count, but stats come from team_name. let mut stmt = teams_conn .prepare( "SELECT MAX(team_id), name, MAX(members), MAX(captain_uid) FROM teams_data GROUP BY name COLLATE NOCASE ORDER BY MAX(members) DESC, name COLLATE NOCASE ASC LIMIT ?1", ) .map_err(db_error)?; let teams = stmt .query_map(params![limit as i64], |row| read_team_record(row)) .map_err(db_error)? .collect::, _>>() .map_err(db_error)?; Ok(teams) } fn leaderboard_roster_rows( state: &AppState, limit: usize, ) -> Result, ApiError> { leaderboard_teams(state, limit).map(|teams| { teams .into_iter() .map(|team| TeamLeaderboardRow { team_id: team.team_id, name: team.name, player_count: team.members, total_battles: 0, wins: 0, losses: 0, win_rate: 0.0, total_kills: 0, }) .collect() }) } fn leaderboard_rows(state: &AppState, limit: usize) -> Result, ApiError> { let battles_conn = open_db(&state.battles_db)?; let teams = leaderboard_teams(state, limit)?; let team_names = teams .iter() .map(|team| team.name.as_str()) .collect::>(); let mut summaries = team_summaries_for(&battles_conn, &team_names)?; let mut rows = Vec::with_capacity(teams.len()); for team in teams { let summary = summaries .remove(&team.name.to_ascii_lowercase()) .unwrap_or_default(); rows.push(TeamLeaderboardRow { team_id: team.team_id, name: team.name, player_count: team.members, total_battles: summary.total_battles, wins: summary.wins, losses: summary.losses, win_rate: summary.win_rate, total_kills: summary.total_kills, }); } Ok(rows) } fn spawn_leaderboard_refresh(state: Arc) { tokio::spawn(async move { loop { let refresh_state = state.clone(); match tokio::task::spawn_blocking(move || leaderboard_rows(&refresh_state, 100)).await { Ok(Ok(rows)) => { if let Err(error) = cache_leaderboard(&state, &rows) { tracing::warn!("could not cache leaderboard refresh: {}", error.message); } } Ok(Err(error)) => { tracing::warn!("could not refresh leaderboard cache: {}", error.message); } Err(error) => { tracing::warn!("leaderboard refresh task failed: {}", error); } } tokio::time::sleep(LEADERBOARD_CACHE_TTL).await; } }); } async fn recent_games( State(state): State>, Query(query): Query, ) -> ApiResult { let limit = i64::from(query.limit.unwrap_or(50).clamp(1, 100)); let battles_conn = open_db(&state.battles_db)?; let matches = recent_games_for(&battles_conn, limit)?; 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, Query(query): Query, ) -> ApiResult { let session_id = validate_session_id(&session_id)?; let lang = query.lang.as_deref().unwrap_or("en"); 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, &state, lang)?; Ok(Json(GameResponse { game, participants })) } async fn game_logs( State(state): State>, Path(session_id): Path, ) -> ApiResult { let session_id = validate_session_id(&session_id)?; let conn = open_db(&state.battles_db)?; // Logs are non-critical: a missing match_logs table or row yields empty arrays. let row: Option<(Option, Option, Option)> = match conn .query_row( "SELECT chat_log_json, battle_log_json, event_log_json FROM match_logs WHERE session_id = ?1", params![session_id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), ) .optional() { Ok(row) => row, Err(_) => conn .query_row( "SELECT chat_log_json, battle_log_json FROM match_logs WHERE session_id = ?1", params![session_id], |r| Ok((r.get(0)?, r.get(1)?, None)), ) .optional() .unwrap_or(None), }; let parse = |s: Option| -> Vec { s.and_then(|t| serde_json::from_str(&t).ok()) .unwrap_or_default() }; let parse_event_log = |s: Option| -> Value { s.and_then(|t| serde_json::from_str(&t).ok()) .unwrap_or_else(|| json!({ "kills": [], "damage": [] })) }; let (chat, battle, event_log) = row.unwrap_or((None, None, None)); Ok(Json(GameLogsResponse { chat_log: parse(chat), battle_log: parse(battle), event_log: parse_event_log(event_log), })) } async fn resolve_team( State(state): State>, Query(query): Query, ) -> ApiResult { let name = validate_team_name(&query.name)?; let conn = open_db(&state.teams_db)?; let team = find_team(&conn, name)?.ok_or_else(|| ApiError::not_found("Team not found"))?; Ok(Json(ResolveResponse { team_id: team.team_id, name: team.name, })) } async fn search_teams( State(state): State>, Query(query): Query, ) -> ApiResult { let raw = query.q.as_deref().or(query.name.as_deref()).unwrap_or(""); let name = validate_team_name(raw)?; let limit = i64::from(query.limit.unwrap_or(10).clamp(1, 20)); let like = format!("%{}%", escape_like(name)); let conn = open_db(&state.teams_db)?; let mut stmt = conn .prepare( "SELECT team_id, name, members FROM teams_data WHERE name LIKE ?1 ESCAPE '\\' ORDER BY CASE WHEN name = ?2 COLLATE NOCASE THEN 0 ELSE 1 END, members DESC, name COLLATE NOCASE ASC LIMIT ?3", ) .map_err(db_error)?; let teams = stmt .query_map(params![like, name, limit], |row| { Ok(TeamSearchRow { team_id: row.get(0)?, name: row.get(1)?, members: row.get(2)?, }) }) .map_err(db_error)? .collect::, _>>() .map_err(db_error)?; Ok(Json(SearchResponse { teams })) } async fn team_detail( State(state): State>, Path(team_name): Path, ) -> ApiResult { let decoded = decode_path_team(&team_name)?; let teams_conn = open_db(&state.teams_db)?; let battles_conn = open_db(&state.battles_db)?; let team = find_team(&teams_conn, &decoded)?.ok_or_else(|| ApiError::not_found("Team not found"))?; let summary = team_summary_for(&battles_conn, &team.name)?; // team_id is the most recent tournament entry — used only for the roster lookup. let players = player_summaries_for(&teams_conn, &battles_conn, team.team_id, &team.name)?; Ok(Json(TeamDetail { team_id: team.team_id, name: team.name, members: team.members, captain_uid: team.captain_uid, data_set: "tss", team_summary: summary, players, })) } async fn team_history( State(state): State>, Path(team_name): Path, ) -> ApiResult { let decoded = decode_path_team(&team_name)?; let teams_conn = open_db(&state.teams_db)?; let battles_conn = open_db(&state.battles_db)?; let team = find_team(&teams_conn, &decoded)?.ok_or_else(|| ApiError::not_found("Team not found"))?; let history = period_history_for(&battles_conn, &team.name)?; Ok(Json(HistoryResponse { team_id: team.team_id, name: team.name, history, })) } async fn team_games( State(state): State>, Path(team_name): Path, ) -> ApiResult { let decoded = decode_path_team(&team_name)?; let teams_conn = open_db(&state.teams_db)?; let battles_conn = open_db(&state.battles_db)?; let team = find_team(&teams_conn, &decoded)?.ok_or_else(|| ApiError::not_found("Team not found"))?; let games = games_for(&battles_conn, &team.name)?; Ok(Json(GamesResponse { team_id: team.team_id, name: team.name, games, })) } async fn resolve_player( State(state): State>, Query(query): Query, ) -> ApiResult { let name = validate_player_name(&query.name)?; let conn = open_db(&state.battles_db)?; let players = player_resolve(&conn, name)?; if players.is_empty() { return Err(ApiError::not_found("Player not found")); } Ok(Json(PlayerSearchResponse { players })) } async fn search_players( State(state): State>, Query(query): Query, ) -> ApiResult { let raw = query.q.as_deref().or(query.name.as_deref()).unwrap_or(""); let name = validate_player_name(raw)?; let limit = i64::from(query.limit.unwrap_or(25).clamp(1, 25)); let conn = open_db(&state.battles_db)?; let players = player_search(&conn, name, limit)?; Ok(Json(PlayerSearchResponse { players })) } async fn player_detail( State(state): State>, Path(uid): Path, ) -> ApiResult { let uid = validate_uid(&uid)?; let conn = open_db(&state.battles_db)?; let career = player_career_for(&conn, &uid)?.ok_or_else(|| ApiError::not_found("Player not found"))?; let nick = latest_nick_for(&conn, &uid)?; let teams = player_teams_for(&conn, &uid)?; Ok(Json(PlayerProfile { uid, nick, data_set: "tss", career, teams, })) } fn open_db(path: &FsPath) -> Result { Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY).map_err(|error| { ApiError::internal(format!("Could not open {}: {}", path.display(), error)) }) } fn db_error(error: rusqlite::Error) -> ApiError { ApiError::internal(format!("Database query failed: {}", error)) } fn read_team_record(row: &rusqlite::Row<'_>) -> rusqlite::Result { Ok(TeamRecord { team_id: row.get(0)?, name: row.get(1)?, members: row.get(2)?, captain_uid: row.get(3)?, }) } fn find_team(conn: &Connection, name: &str) -> Result, ApiError> { // Return the most recent tournament entry for this team (highest team_id). conn.query_row( "SELECT team_id, name, members, captain_uid FROM teams_data WHERE name = ?2 COLLATE NOCASE OR team_id = ?1 ORDER BY team_id DESC LIMIT 1", params![name.parse::().ok(), name], read_team_record, ) .optional() .map_err(db_error) } fn team_summary_for(conn: &Connection, team_name: &str) -> Result { conn.query_row( "SELECT COUNT(DISTINCT session_id), COUNT(DISTINCT CASE WHEN victor_bool = 'Win' THEN session_id END), COUNT(DISTINCT CASE WHEN victor_bool != 'Win' THEN session_id END), COALESCE(SUM(ground_kills), 0), COALESCE(SUM(air_kills), 0), COALESCE(SUM(assists), 0), COALESCE(SUM(deaths), 0), COALESCE(SUM(score), 0), COUNT(DISTINCT UID) FROM player_games_hist WHERE team_name = ?1 COLLATE NOCASE", params![team_name], |row| { let battles: i64 = row.get(0)?; let wins: i64 = row.get(1)?; let losses: i64 = row.get(2)?; let ground: i64 = row.get(3)?; let air: i64 = row.get(4)?; let assists: i64 = row.get(5)?; let deaths: i64 = row.get(6)?; let score: i64 = row.get(7)?; let player_count: i64 = row.get(8)?; let total_kills = ground + air; Ok(TeamSummary { player_count, total_battles: battles, wins, losses, win_rate: percent(wins, battles), kdr: ratio(total_kills, deaths), total_kills, total_points: score + assists + total_kills, }) }, ) .map_err(db_error) } fn team_summaries_for( conn: &Connection, team_names: &[&str], ) -> Result, ApiError> { if team_names.is_empty() { return Ok(HashMap::new()); } let placeholders = std::iter::repeat("?") .take(team_names.len()) .collect::>() .join(", "); let sql = format!( "SELECT team_name, COUNT(DISTINCT session_id), COUNT(DISTINCT CASE WHEN victor_bool = 'Win' THEN session_id END), COUNT(DISTINCT CASE WHEN victor_bool != 'Win' THEN session_id END), COALESCE(SUM(ground_kills), 0), COALESCE(SUM(air_kills), 0), COALESCE(SUM(assists), 0), COALESCE(SUM(deaths), 0), COALESCE(SUM(score), 0), COUNT(DISTINCT UID) FROM player_games_hist WHERE team_name COLLATE NOCASE IN ({placeholders}) GROUP BY team_name COLLATE NOCASE" ); let mut stmt = conn.prepare(&sql).map_err(db_error)?; let summaries = stmt .query_map(rusqlite::params_from_iter(team_names.iter()), |row| { let name: String = row.get(0)?; let battles: i64 = row.get(1)?; let wins: i64 = row.get(2)?; let losses: i64 = row.get(3)?; let ground: i64 = row.get(4)?; let air: i64 = row.get(5)?; let assists: i64 = row.get(6)?; let deaths: i64 = row.get(7)?; let score: i64 = row.get(8)?; let player_count: i64 = row.get(9)?; let total_kills = ground + air; Ok(( name.to_ascii_lowercase(), TeamSummary { player_count, total_battles: battles, wins, losses, win_rate: percent(wins, battles), kdr: ratio(total_kills, deaths), total_kills, total_points: score + assists + total_kills, }, )) }) .map_err(db_error)? .collect::, _>>() .map_err(db_error)?; Ok(summaries) } fn cached_leaderboard( state: &AppState, limit: usize, ) -> Result>, ApiError> { let cache = state .leaderboard_cache .lock() .map_err(|_| ApiError::internal("Leaderboard cache lock failed"))?; Ok(cache.as_ref().and_then(|cached| { (cached.teams.len() >= limit).then(|| cached.teams.iter().take(limit).cloned().collect()) })) } fn cache_leaderboard(state: &AppState, teams: &[TeamLeaderboardRow]) -> Result<(), ApiError> { let mut cache = state .leaderboard_cache .lock() .map_err(|_| ApiError::internal("Leaderboard cache lock failed"))?; *cache = Some(CachedLeaderboard { teams: teams.to_vec(), }); Ok(()) } fn player_summaries_for( teams_conn: &Connection, battles_conn: &Connection, // team_id: roster for this specific tournament entry only team_id: i64, // team_name: cross-tournament stats key team_name: &str, ) -> Result, ApiError> { let mut stmt = teams_conn .prepare( "SELECT uid, role FROM team_members WHERE team_id = ?1 ORDER BY uid", ) .map_err(db_error)?; let members = stmt .query_map(params![team_id], |row| { Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) }) .map_err(db_error)? .collect::, _>>() .map_err(db_error)?; let mut stats_stmt = battles_conn .prepare( "SELECT UID, COUNT(DISTINCT session_id), COUNT(DISTINCT CASE WHEN victor_bool = 'Win' THEN session_id END), COUNT(DISTINCT CASE WHEN victor_bool != 'Win' THEN session_id END), COALESCE(SUM(ground_kills), 0), COALESCE(SUM(air_kills), 0), COALESCE(SUM(assists), 0), COALESCE(SUM(deaths), 0), (SELECT nick FROM player_games_hist WHERE UID = p.UID AND nick IS NOT NULL ORDER BY endtime_unix DESC LIMIT 1) FROM player_games_hist p WHERE team_name = ?1 COLLATE NOCASE GROUP BY UID", ) .map_err(db_error)?; let mut summaries = stats_stmt .query_map(params![team_name], |row| { let uid: String = row.get(0)?; let battles: i64 = row.get(1)?; let wins: i64 = row.get(2)?; let losses: i64 = row.get(3)?; let ground: i64 = row.get(4)?; let air: i64 = row.get(5)?; let assists: i64 = row.get(6)?; let deaths: i64 = row.get(7)?; let nick: Option = row.get(8)?; let total_kills = ground + air; Ok(( uid.clone(), PlayerSummary { uid, nick, role: String::new(), total_battles: battles, wins, losses, win_rate: percent(wins, battles), total_kills, ground_kills: ground, air_kills: air, assists, deaths, kdr: ratio(total_kills, deaths), }, )) }) .map_err(db_error)? .collect::, _>>() .map_err(db_error)?; let mut out = Vec::with_capacity(members.len()); for (uid, role) in members { let mut summary = summaries.remove(&uid).unwrap_or(PlayerSummary { uid, nick: None, role: String::new(), total_battles: 0, wins: 0, losses: 0, win_rate: 0.0, total_kills: 0, ground_kills: 0, air_kills: 0, assists: 0, deaths: 0, kdr: 0.0, }); summary.role = role; out.push(summary); } out.sort_by(|a, b| b.total_battles.cmp(&a.total_battles)); Ok(out) } fn player_leaderboard_rows( conn: &Connection, limit: i64, ) -> Result, ApiError> { let mut stmt = conn .prepare( "WITH per_session AS ( SELECT UID, session_id, MAX(victor_bool) AS victor_bool, MAX(ground_kills) AS ground_kills, MAX(air_kills) AS air_kills, MAX(assists) AS assists, MAX(captures) AS captures, MAX(deaths) AS deaths, MAX(score) AS score, MAX(endtime_unix) AS endtime_unix, MAX(team_name) AS team_name FROM player_games_hist WHERE UID IS NOT NULL AND UID != '' GROUP BY UID, session_id ), latest_names AS ( SELECT UID, nick FROM ( SELECT UID, nick, ROW_NUMBER() OVER ( PARTITION BY UID ORDER BY endtime_unix DESC, nick COLLATE NOCASE ASC ) AS rn FROM player_games_hist WHERE nick IS NOT NULL AND nick != '' ) WHERE rn = 1 ) SELECT p.UID, n.nick, COUNT(*) AS battles, COALESCE(SUM(CASE WHEN UPPER(p.victor_bool) = 'WIN' THEN 1 ELSE 0 END), 0) AS wins, COALESCE(SUM(CASE WHEN UPPER(p.victor_bool) = 'LOSS' THEN 1 ELSE 0 END), 0) AS losses, COALESCE(SUM(p.ground_kills), 0) AS ground_kills, COALESCE(SUM(p.air_kills), 0) AS air_kills, COALESCE(SUM(p.assists), 0) AS assists, COALESCE(SUM(p.captures), 0) AS captures, COALESCE(SUM(p.deaths), 0) AS deaths, COALESCE(SUM(p.score), 0) AS score, COUNT(DISTINCT p.team_name) AS teams_seen, MAX(p.endtime_unix) AS last_seen FROM per_session p LEFT JOIN latest_names n ON n.UID = p.UID GROUP BY p.UID ORDER BY score DESC, (ground_kills + air_kills) DESC, battles DESC, p.UID ASC LIMIT ?1", ) .map_err(db_error)?; let players = stmt .query_map(params![limit], |row| { let ground: i64 = row.get(5)?; let air: i64 = row.get(6)?; let battles: i64 = row.get(2)?; let wins: i64 = row.get(3)?; let deaths: i64 = row.get(9)?; let total_kills = ground + air; Ok(PlayerLeaderboardRow { uid: row.get(0)?, nick: row.get(1)?, total_battles: battles, wins, losses: row.get(4)?, win_rate: percent(wins, battles), ground_kills: ground, air_kills: air, total_kills, assists: row.get(7)?, captures: row.get(8)?, deaths, score: row.get(10)?, kdr: ratio(total_kills, deaths), teams_seen: row.get(11)?, last_seen: row.get(12)?, }) }) .map_err(db_error)? .collect::, _>>() .map_err(db_error)?; Ok(players) } fn player_search(conn: &Connection, query: &str, limit: i64) -> Result, ApiError> { let like = format!("%{}%", escape_like(query)); let mut stmt = conn .prepare( "SELECT UID, MIN(nick) AS nick, MAX(endtime_unix) AS last_seen FROM player_games_hist WHERE nick LIKE ?1 ESCAPE '\\' COLLATE NOCASE GROUP BY UID ORDER BY CASE WHEN MIN(nick) = ?2 COLLATE NOCASE THEN 0 ELSE 1 END, last_seen DESC LIMIT ?3", ) .map_err(db_error)?; let players = stmt .query_map(params![like, query, limit], |row| { Ok(PlayerRef { uid: row.get(0)?, nick: row.get(1)?, }) }) .map_err(db_error)? .collect::, _>>() .map_err(db_error)?; Ok(players) } fn player_resolve(conn: &Connection, name: &str) -> Result, ApiError> { // Exact nick match first; fall back to substring search. let mut stmt = conn .prepare( "SELECT UID, MIN(nick) AS nick FROM player_games_hist WHERE nick = ?1 COLLATE NOCASE GROUP BY UID ORDER BY nick LIMIT 25", ) .map_err(db_error)?; let exact = stmt .query_map(params![name], |row| { Ok(PlayerRef { uid: row.get(0)?, nick: row.get(1)?, }) }) .map_err(db_error)? .collect::, _>>() .map_err(db_error)?; if !exact.is_empty() { return Ok(exact); } player_search(conn, name, 25) } fn latest_nick_for(conn: &Connection, uid: &str) -> Result, ApiError> { conn.query_row( "SELECT nick FROM player_games_hist WHERE UID = ?1 ORDER BY endtime_unix DESC LIMIT 1", params![uid], |row| row.get::<_, Option>(0), ) .optional() .map_err(db_error) .map(|opt| opt.flatten()) } fn player_career_for(conn: &Connection, uid: &str) -> Result, ApiError> { // Player totals repeat across a player's per-vehicle rows for a session, so // collapse to one value per session (MAX) before summing. conn.query_row( "SELECT COUNT(*) AS battles, COALESCE(SUM(CASE WHEN UPPER(victor_bool) = 'WIN' THEN 1 ELSE 0 END), 0) AS wins, COALESCE(SUM(CASE WHEN UPPER(victor_bool) = 'LOSS' THEN 1 ELSE 0 END), 0) AS losses, COALESCE(SUM(gk), 0), COALESCE(SUM(ak), 0), COALESCE(SUM(asi), 0), COALESCE(SUM(cap), 0), COALESCE(SUM(de), 0) FROM ( SELECT session_id, MAX(victor_bool) AS victor_bool, MAX(ground_kills) AS gk, MAX(air_kills) AS ak, MAX(assists) AS asi, MAX(captures) AS cap, MAX(deaths) AS de FROM player_games_hist WHERE UID = ?1 GROUP BY session_id )", params![uid], |row| { let battles: i64 = row.get(0)?; let wins: i64 = row.get(1)?; let losses: i64 = row.get(2)?; let ground: i64 = row.get(3)?; let air: i64 = row.get(4)?; let assists: i64 = row.get(5)?; let captures: i64 = row.get(6)?; let deaths: i64 = row.get(7)?; let total_kills = ground + air; Ok(( battles, wins, losses, ground, air, assists, captures, deaths, total_kills, )) }, ) .optional() .map_err(db_error) .map(|opt| { opt.and_then( |(battles, wins, losses, ground, air, assists, captures, deaths, total_kills)| { if battles == 0 { None } else { Some(PlayerCareer { total_battles: battles, wins, losses, win_rate: percent(wins, battles), ground_kills: ground, air_kills: air, total_kills, assists, captures, deaths, kdr: ratio(total_kills, deaths), }) } }, ) }) } fn player_teams_for(conn: &Connection, uid: &str) -> Result, ApiError> { let mut stmt = conn .prepare( "SELECT team_id, MAX(team_name) AS team_name, COUNT(DISTINCT session_id) AS games, MAX(endtime_unix) AS last_seen FROM player_games_hist WHERE UID = ?1 AND team_id IS NOT NULL GROUP BY team_id ORDER BY last_seen DESC", ) .map_err(db_error)?; let teams = stmt .query_map(params![uid], |row| { Ok(PlayerTeamRef { team_id: row.get(0)?, team_name: row.get(1)?, games: row.get(2)?, last_seen: row.get(3)?, }) }) .map_err(db_error)? .collect::, _>>() .map_err(db_error)?; Ok(teams) } fn period_history_for(conn: &Connection, team_name: &str) -> Result, ApiError> { let mut stmt = conn .prepare( "SELECT strftime('%Y-%m', endtime_unix, 'unixepoch') AS period, COUNT(DISTINCT session_id), COUNT(DISTINCT CASE WHEN victor_bool = 'Win' THEN session_id END), COUNT(DISTINCT CASE WHEN victor_bool != 'Win' THEN session_id END) FROM player_games_hist WHERE team_name = ?1 COLLATE NOCASE AND endtime_unix > 0 GROUP BY period ORDER BY period ASC", ) .map_err(db_error)?; let rows = stmt .query_map(params![team_name], |row| { let period: String = row.get(0)?; let battles: i64 = row.get(1)?; let wins: i64 = row.get(2)?; let losses: i64 = row.get(3)?; Ok(PeriodHistory { period, battles, wins, losses, win_rate: percent(wins, battles), }) }) .map_err(db_error)? .collect::, _>>() .map_err(db_error)?; Ok(rows) } fn games_for(conn: &Connection, team_name: &str) -> Result, ApiError> { // Reduce per-vehicle duplicate rows to one row per UID (MAX) before summing. let mut stmt = conn .prepare( "WITH per_player AS ( SELECT session_id, UID, MAX(endtime_unix) AS endtime_unix, MAX(CASE WHEN victor_bool = 'Win' THEN 1 ELSE 0 END) AS won, MAX(ground_kills) AS ground_kills, MAX(air_kills) AS air_kills, MAX(assists) AS assists, MAX(captures) AS captures, MAX(deaths) AS deaths, MAX(score) AS score, MAX(missile_evades) AS missile_evades, MAX(shell_interceptions) AS shell_interceptions, MAX(team_kills_stat) AS team_kills_stat FROM player_games_hist WHERE team_name = ?1 COLLATE NOCASE GROUP BY session_id, UID ) SELECT pp.session_id, COALESCE(m.endtime_unix, MAX(pp.endtime_unix), 0) AS timestamp, m.mission_name, m.mission_mode, m.tournament_name, m.duration, COALESCE(m.draw, 0), CASE WHEN MAX(pp.won) = 1 THEN 'Win' ELSE 'Loss' END AS result, COUNT(*), COALESCE(SUM(pp.ground_kills), 0), COALESCE(SUM(pp.air_kills), 0), COALESCE(SUM(pp.assists), 0), COALESCE(SUM(pp.captures), 0), COALESCE(SUM(pp.deaths), 0), COALESCE(SUM(pp.score), 0), COALESCE(SUM(pp.missile_evades), 0), COALESCE(SUM(pp.shell_interceptions), 0), COALESCE(SUM(pp.team_kills_stat), 0), (SELECT pg.team_name FROM player_games_hist pg WHERE pg.session_id = pp.session_id AND pg.team_name IS NOT NULL AND pg.team_name != '' AND pg.victor_bool = 'Win' GROUP BY pg.team_name COLLATE NOCASE ORDER BY COUNT(DISTINCT pg.UID) DESC, pg.team_name COLLATE NOCASE LIMIT 1), (SELECT pg.team_name FROM player_games_hist pg WHERE pg.session_id = pp.session_id AND pg.team_name IS NOT NULL AND pg.team_name != '' 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) FROM per_player pp LEFT JOIN match_summary m ON m.session_id = pp.session_id GROUP BY pp.session_id ORDER BY timestamp DESC LIMIT 100", ) .map_err(db_error)?; let rows = stmt .query_map(params![team_name], |row| { let session_id: String = row.get(0)?; let timestamp: i64 = row.get(1)?; let map_name: Option = row.get(2)?; let mission_mode: Option = row.get(3)?; let draw_int: i64 = row.get(6)?; Ok(GameRow { team_name: None, session_id, timestamp, endtime_unix: timestamp, map_name, mission_mode, result: row.get(7)?, 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, stats: GameStats { ground_kills: row.get(9)?, air_kills: row.get(10)?, assists: row.get(11)?, captures: row.get(12)?, deaths: row.get(13)?, score: row.get(14)?, missile_evades: row.get(15)?, shell_interceptions: row.get(16)?, team_kills_stat: row.get(17)?, }, }) }) .map_err(db_error)? .collect::, _>>() .map_err(db_error)?; Ok(rows) } fn recent_games_for(conn: &Connection, limit: i64) -> Result, ApiError> { // One row per SESSION (not per team) so the battle-logs list shows each game // once. Both team names come from the winner/loser subqueries; player_count is // the larger team's size so the "NvN" label is per-side. let mut stmt = conn .prepare( "WITH recent AS ( SELECT session_id, MAX(endtime_unix) AS timestamp FROM player_games_hist WHERE team_name IS NOT NULL AND team_name != '' GROUP BY session_id ORDER BY timestamp DESC LIMIT ?1 ), team_size AS ( SELECT session_id, team_name, COUNT(DISTINCT UID) AS players FROM player_games_hist WHERE session_id IN (SELECT session_id FROM recent) AND team_name IS NOT NULL AND team_name != '' GROUP BY session_id, team_name COLLATE NOCASE ) SELECT r.session_id, COALESCE(m.endtime_unix, r.timestamp, 0) AS timestamp, m.mission_name, m.mission_mode, m.tournament_name, m.duration, COALESCE(m.draw, 0), (SELECT MAX(players) FROM team_size ts WHERE ts.session_id = r.session_id) AS player_count, (SELECT pg.team_name FROM player_games_hist pg WHERE pg.session_id = r.session_id AND pg.team_name IS NOT NULL AND pg.team_name != '' AND pg.victor_bool = 'Win' GROUP BY pg.team_name COLLATE NOCASE ORDER BY COUNT(DISTINCT pg.UID) DESC, pg.team_name COLLATE NOCASE LIMIT 1), (SELECT pg.team_name FROM player_games_hist pg WHERE pg.session_id = r.session_id AND pg.team_name IS NOT NULL AND pg.team_name != '' 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) FROM recent r LEFT JOIN match_summary m ON m.session_id = r.session_id ORDER BY timestamp DESC LIMIT ?1", ) .map_err(db_error)?; let rows = stmt .query_map(params![limit], |row| { let timestamp: i64 = row.get(1)?; let draw_int: i64 = row.get(6)?; 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(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, stats: GameStats { ground_kills: 0, air_kills: 0, assists: 0, captures: 0, deaths: 0, score: 0, missile_evades: 0, shell_interceptions: 0, team_kills_stat: 0, }, }) }) .map_err(db_error)? .collect::, _>>() .map_err(db_error)?; 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 // (MAX, since the values are identical) BEFORE summing team/game totals. conn.query_row( "SELECT ?1 AS session_id, COALESCE(m.endtime_unix, agg.timestamp, 0) AS timestamp, m.mission_name, m.mission_mode, m.tournament_name, m.duration, COALESCE(m.draw, 0), agg.player_count, agg.ground_kills, agg.air_kills, agg.assists, agg.captures, agg.deaths, agg.score, agg.missile_evades, agg.shell_interceptions, agg.team_kills_stat, (SELECT pg.team_name FROM player_games_hist pg WHERE pg.session_id = ?1 AND pg.team_name IS NOT NULL AND pg.team_name != '' AND pg.victor_bool = 'Win' GROUP BY pg.team_name COLLATE NOCASE ORDER BY COUNT(DISTINCT pg.UID) DESC, pg.team_name COLLATE NOCASE LIMIT 1), (SELECT pg.team_name FROM player_games_hist pg WHERE pg.session_id = ?1 AND pg.team_name IS NOT NULL AND pg.team_name != '' 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), m.tournament_id FROM ( SELECT COUNT(*) AS player_count, MAX(endtime_unix) AS timestamp, COALESCE(SUM(ground_kills), 0) AS ground_kills, COALESCE(SUM(air_kills), 0) AS air_kills, COALESCE(SUM(assists), 0) AS assists, COALESCE(SUM(captures), 0) AS captures, COALESCE(SUM(deaths), 0) AS deaths, COALESCE(SUM(score), 0) AS score, COALESCE(SUM(missile_evades), 0) AS missile_evades, COALESCE(SUM(shell_interceptions), 0) AS shell_interceptions, COALESCE(SUM(team_kills_stat), 0) AS team_kills_stat FROM ( SELECT UID, MAX(endtime_unix) AS endtime_unix, MAX(ground_kills) AS ground_kills, MAX(air_kills) AS air_kills, MAX(assists) AS assists, MAX(captures) AS captures, MAX(deaths) AS deaths, MAX(score) AS score, MAX(missile_evades) AS missile_evades, MAX(shell_interceptions) AS shell_interceptions, MAX(team_kills_stat) AS team_kills_stat FROM player_games_hist WHERE session_id = ?1 GROUP BY UID ) ) agg LEFT JOIN match_summary m ON m.session_id = ?1 WHERE agg.player_count > 0", params![session_id], |row| { let timestamp: i64 = row.get(1)?; let draw_int: i64 = row.get(6)?; 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(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, stats: GameStats { ground_kills: row.get(8)?, air_kills: row.get(9)?, assists: row.get(10)?, captures: row.get(11)?, deaths: row.get(12)?, score: row.get(13)?, missile_evades: row.get(14)?, shell_interceptions: row.get(15)?, team_kills_stat: row.get(16)?, }, }) }, ) .optional() .map_err(db_error) } fn game_participants_for( conn: &Connection, session_id: &str, state: &AppState, lang: &str, ) -> Result, ApiError> { let mut stmt = conn .prepare( "SELECT pp.team_name, CASE WHEN MAX(pp.won) = 1 THEN 'Win' ELSE 'Loss' END AS result, COUNT(*), COALESCE(SUM(pp.ground_kills), 0), COALESCE(SUM(pp.air_kills), 0), COALESCE(SUM(pp.assists), 0), COALESCE(SUM(pp.captures), 0), COALESCE(SUM(pp.deaths), 0), COALESCE(SUM(pp.score), 0), COALESCE(SUM(pp.missile_evades), 0), COALESCE(SUM(pp.shell_interceptions), 0), COALESCE(SUM(pp.team_kills_stat), 0) FROM ( SELECT UID, team_name, MAX(CASE WHEN victor_bool = 'Win' THEN 1 ELSE 0 END) AS won, MAX(ground_kills) AS ground_kills, MAX(air_kills) AS air_kills, MAX(assists) AS assists, MAX(captures) AS captures, MAX(deaths) AS deaths, MAX(score) AS score, MAX(missile_evades) AS missile_evades, MAX(shell_interceptions) AS shell_interceptions, MAX(team_kills_stat) AS team_kills_stat FROM player_games_hist WHERE session_id = ?1 AND team_name IS NOT NULL AND team_name != '' GROUP BY UID, team_name COLLATE NOCASE ) pp GROUP BY pp.team_name COLLATE NOCASE ORDER BY CASE WHEN MAX(pp.won) = 1 THEN 0 ELSE 1 END, pp.team_name COLLATE NOCASE", ) .map_err(db_error)?; let mut participants = 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)?, }, players: Vec::new(), }) }) .map_err(db_error)? .collect::, _>>() .map_err(db_error)?; for participant in &mut participants { participant.players = game_players_for(conn, session_id, &participant.team_name, state, lang)?; } Ok(participants) } fn game_players_for( conn: &Connection, session_id: &str, team_name: &str, state: &AppState, lang: &str, ) -> Result, ApiError> { // Stats are duplicated across a player's per-vehicle rows, so take MAX (not // SUM) per UID. vehicle_internal collected (DISTINCT, comma-joined) for the lineup. let mut stmt = conn .prepare( "SELECT p.UID, (SELECT pg.nick FROM player_games_hist pg WHERE pg.session_id = p.session_id AND pg.UID = p.UID AND pg.nick IS NOT NULL AND pg.nick != '' ORDER BY pg.endtime_unix DESC LIMIT 1), MAX(p.ground_kills), MAX(p.air_kills), MAX(p.assists), MAX(p.captures), MAX(p.deaths), MAX(p.score), MAX(p.missile_evades), MAX(p.shell_interceptions), MAX(p.team_kills_stat), GROUP_CONCAT(DISTINCT p.vehicle_internal) FROM player_games_hist p WHERE p.session_id = ?1 AND p.team_name = ?2 COLLATE NOCASE GROUP BY p.UID ORDER BY MAX(p.score) DESC, p.UID", ) .map_err(db_error)?; let players = stmt .query_map(params![session_id, team_name], |row| { let cdks: Option = row.get(11)?; let vehicles = cdks .unwrap_or_default() .split(',') .map(|c| c.trim()) .filter(|c| !c.is_empty()) .map(|cdk| Vehicle { name: lookup_vehicle_name(&state.vehicle_names, cdk, lang), icon: lookup_vehicle_icon(&state.vehicle_icons, cdk), cdk: cdk.to_string(), }) .collect(); Ok(GamePlayer { uid: row.get(0)?, nick: row.get(1)?, vehicles, stats: GameStats { ground_kills: row.get(2)?, air_kills: row.get(3)?, assists: row.get(4)?, captures: row.get(5)?, deaths: row.get(6)?, score: row.get(7)?, missile_evades: row.get(8)?, shell_interceptions: row.get(9)?, team_kills_stat: row.get(10)?, }, }) }) .map_err(db_error)? .collect::, _>>() .map_err(db_error)?; Ok(players) } fn validate_team_name(name: &str) -> Result<&str, ApiError> { let trimmed = name.trim(); if trimmed.len() < 2 || trimmed.len() > MAX_TEAM_NAME_LENGTH { return Err(ApiError::bad_request( "Team name must be 2 to 80 characters", )); } Ok(trimmed) } fn validate_player_name(name: &str) -> Result<&str, ApiError> { let trimmed = name.trim(); if trimmed.len() < 2 || trimmed.len() > MAX_TEAM_NAME_LENGTH { return Err(ApiError::bad_request( "Player name must be 2 to 80 characters", )); } Ok(trimmed) } fn validate_uid(value: &str) -> Result { let trimmed = value.trim(); if trimmed.is_empty() || trimmed.len() > 32 || !trimmed.chars().all(|c| c.is_ascii_digit()) { return Err(ApiError::bad_request("Invalid player UID")); } 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 || !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"))? .into_owned(); validate_team_name(&decoded)?; Ok(decoded) } fn escape_like(value: &str) -> String { value .replace('\\', "\\\\") .replace('%', "\\%") .replace('_', "\\_") } fn percent(part: i64, total: i64) -> f64 { if total <= 0 { 0.0 } else { (part as f64 / total as f64) * 100.0 } } fn ratio(top: i64, bottom: i64) -> f64 { if bottom <= 0 { top as f64 } else { top as f64 / bottom as f64 } } fn env_u16(key: &str) -> Option { env::var(key).ok()?.parse().ok() } fn env_ip(key: &str) -> Option { env::var(key).ok()?.parse().ok() } fn allowed_origins() -> AllowOrigin { let origins = env::var("BACKEND_ALLOWED_ORIGINS") .or_else(|_| env::var("PUBLIC_ORIGIN")) .unwrap_or_default() .split(',') .filter_map(|origin| { let origin = origin.trim(); if origin.is_empty() { return None; } HeaderValue::from_str(origin).ok() }) .collect::>(); if origins.is_empty() { AllowOrigin::list(Vec::::new()) } else { AllowOrigin::list(origins) } } // Vehicle cdk keys are lowercased on load and at lookup time so DB casing // (e.g. "us_M4A2_76W_sherman" vs "us_m4a2_76w_sherman") never misses, mirroring // the Python LangTableReader's case-insensitive behaviour. fn load_vehicle_names(path: &FsPath) -> HashMap> { let parsed: HashMap> = match fs::read_to_string(path) { Ok(s) => serde_json::from_str(&s).unwrap_or_default(), Err(_) => HashMap::new(), }; parsed .into_iter() .map(|(k, v)| (k.to_lowercase(), v)) .collect() } fn load_vehicle_icons(path: &FsPath) -> HashMap { // vehicle_data_cache_all.json is [[cdk, name, icon, tags], ...] let raw: Vec = match fs::read_to_string(path) { Ok(s) => serde_json::from_str(&s).unwrap_or_default(), Err(_) => Vec::new(), }; let mut out = HashMap::new(); for entry in raw { if let (Some(cdk), Some(icon)) = ( entry.get(0).and_then(|v| v.as_str()), entry.get(2).and_then(|v| v.as_str()), ) { out.insert(cdk.to_lowercase(), icon.to_string()); } } out } fn lookup_vehicle_name( names: &HashMap>, cdk: &str, lang: &str, ) -> String { if let Some(by_lang) = names.get(&cdk.to_lowercase()) { if let Some(n) = by_lang.get(lang) { return n.clone(); } if let Some(n) = by_lang.get("en") { return n.clone(); } } cdk.to_string() } fn lookup_vehicle_icon(icons: &HashMap, cdk: &str) -> String { icons .get(&cdk.to_lowercase()) .cloned() .unwrap_or_else(|| format!("{}.png", cdk.to_lowercase())) } fn resolve_db_path(env_key: &str, default_file: &str) -> PathBuf { let path = if let Ok(raw) = env::var(env_key) { PathBuf::from(expand_home(&raw)) } else if let Ok(storage) = env::var("STORAGE_VOL_PATH") { PathBuf::from(expand_home(&storage)).join(default_file) } else { PathBuf::from(default_file) }; if path.is_absolute() { path } else { env::current_dir() .unwrap_or_else(|_| PathBuf::from(".")) .join(path) } } fn expand_home(value: &str) -> String { if value == "~" { return home_dir() .map(|p| p.display().to_string()) .unwrap_or_else(|| value.to_string()); } if let Some(rest) = value .strip_prefix("~/") .or_else(|| value.strip_prefix("~\\")) { if let Some(home) = home_dir() { return home.join(rest).display().to_string(); } } value.to_string() } fn home_dir() -> Option { env::var_os("HOME") .or_else(|| env::var_os("USERPROFILE")) .map(PathBuf::from) } fn load_root_env() { let candidates = [ env::current_dir().ok().map(|path| path.join(".env")), env::current_dir() .ok() .and_then(|path| path.parent().map(|parent| parent.join(".env"))), Some( PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("..") .join(".env"), ), ]; for candidate in candidates.into_iter().flatten() { if candidate.exists() { load_env_file(&candidate); return; } } } fn load_env_file(path: &FsPath) { let Ok(contents) = fs::read_to_string(path) else { return; }; for line in contents.lines() { let trimmed = line.trim(); if trimmed.is_empty() || trimmed.starts_with('#') { continue; } let Some((key, value)) = trimmed.split_once('=') else { continue; }; let key = key.trim(); if key.is_empty() || env::var_os(key).is_some() { continue; } let value = value .trim() .trim_matches('"') .trim_matches('\'') .to_string(); env::set_var(key, value); } } #[cfg(test)] mod tests { use super::*; #[test] fn vehicle_name_prefers_lang_then_en_then_cdk() { let mut names = HashMap::new(); let mut t = HashMap::new(); t.insert("en".to_string(), "T-34".to_string()); t.insert("ru".to_string(), "Т-34".to_string()); names.insert("ussr_t_34".to_string(), t); assert_eq!(lookup_vehicle_name(&names, "ussr_t_34", "ru"), "Т-34"); assert_eq!(lookup_vehicle_name(&names, "ussr_t_34", "de"), "T-34"); assert_eq!( lookup_vehicle_name(&names, "unknown_cdk", "en"), "unknown_cdk" ); } #[test] fn vehicle_icon_falls_back_to_cdk_png() { let mut icons = HashMap::new(); icons.insert("ussr_t_34".to_string(), "ussr_t_34.png".to_string()); assert_eq!(lookup_vehicle_icon(&icons, "ussr_t_34"), "ussr_t_34.png"); assert_eq!(lookup_vehicle_icon(&icons, "germ_pz"), "germ_pz.png"); } #[test] fn load_vehicle_icons_parses_cache_array() { let dir = std::env::temp_dir(); let p = dir.join("test_vehicle_cache_all.json"); fs::write( &p, r#"[["ussr_t_34","T-34","ussr_t_34.png",{}],["germ_pz","Pz","germ_pz.png",{}]]"#, ) .unwrap(); let icons = load_vehicle_icons(&p); assert_eq!(icons.get("ussr_t_34").unwrap(), "ussr_t_34.png"); assert_eq!(icons.len(), 2); let _ = fs::remove_file(&p); } }