From 8965c3c19b8a63d8a75a9954be267e9138f291ed Mon Sep 17 00:00:00 2001 From: FURRO404 Date: Thu, 18 Jun 2026 00:24:38 -0700 Subject: [PATCH 01/24] feat(backend): load vehicle translation + icon caches --- backend/src/main.rs | 103 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/backend/src/main.rs b/backend/src/main.rs index 80435f7..a8418d3 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -29,6 +29,8 @@ struct AppState { battles_db: PathBuf, teams_db: PathBuf, leaderboard_cache: Mutex>, + vehicle_names: HashMap>, + vehicle_icons: HashMap, } struct CachedLeaderboard { @@ -317,7 +319,20 @@ async fn main() -> Result<(), Box> { battles_db: resolve_db_path("TSS_BATTLES_DB", "tss_battles.db"), teams_db: resolve_db_path("TSS_TEAMS_DB", "tss_teams.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_all.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() @@ -1621,6 +1636,54 @@ fn allowed_origins() -> AllowOrigin { } } +fn load_vehicle_names(path: &FsPath) -> HashMap> { + match fs::read_to_string(path) { + Ok(s) => serde_json::from_str(&s).unwrap_or_default(), + Err(_) => HashMap::new(), + } +} + +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_string(), icon.to_string()); + } + } + out +} + +fn lookup_vehicle_name( + names: &HashMap>, + cdk: &str, + lang: &str, +) -> String { + if let Some(by_lang) = names.get(cdk) { + 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) + .cloned() + .unwrap_or_else(|| format!("{cdk}.png")) +} + fn resolve_db_path(env_key: &str, default_file: &str) -> PathBuf { let raw = env::var(env_key).unwrap_or_else(|_| default_file.to_string()); let expanded = expand_home(&raw); @@ -1702,3 +1765,43 @@ fn load_env_file(path: &FsPath) { 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); + } +} From dc3a0b33f4cb0d16cf4b1e8e36791cc5f0291ccd Mon Sep 17 00:00:00 2001 From: FURRO404 Date: Thu, 18 Jun 2026 00:31:42 -0700 Subject: [PATCH 02/24] fix(backend): dedup per-vehicle rows; add vehicle lineup, tournament, duration, draw --- backend/src/main.rs | 406 +++++++++++++++++++++++++++++--------------- 1 file changed, 267 insertions(+), 139 deletions(-) diff --git a/backend/src/main.rs b/backend/src/main.rs index a8418d3..f35f57b 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -79,6 +79,11 @@ struct LimitQuery { limit: Option, } +#[derive(Deserialize)] +struct LangQuery { + lang: Option, +} + #[derive(Deserialize)] struct ResolveQuery { name: String, @@ -222,6 +227,11 @@ struct GameRow { player_count: i64, winning_team: Option, losing_team: Option, + #[serde(skip_serializing_if = "Option::is_none")] + tournament_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + duration: Option, + draw: bool, stats: GameStats, } @@ -251,9 +261,17 @@ struct GameParticipant { 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, @@ -535,12 +553,14 @@ async fn recent_games( 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)?; + let participants = game_participants_for(&battles_conn, session_id, &state, lang)?; Ok(Json(GameResponse { game, participants })) } @@ -1164,30 +1184,48 @@ fn period_history_for(conn: &Connection, team_name: &str) -> Result Result, ApiError> { + // Reduce per-vehicle duplicate rows to one row per UID (MAX) before summing. let mut stmt = conn .prepare( - "SELECT - p.session_id, - COALESCE(m.endtime_unix, MAX(p.endtime_unix), 0) AS timestamp, + "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, - 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), + 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 = p.session_id + WHERE pg.session_id = pp.session_id AND pg.team_name IS NOT NULL AND pg.team_name != '' AND pg.victor_bool = 'Win' @@ -1196,17 +1234,16 @@ fn games_for(conn: &Connection, team_name: &str) -> Result, ApiErro LIMIT 1), (SELECT pg.team_name FROM player_games_hist pg - WHERE pg.session_id = p.session_id + 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 player_games_hist p - LEFT JOIN match_summary m ON m.session_id = p.session_id - WHERE p.team_name = ?1 COLLATE NOCASE - GROUP BY p.session_id + 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", ) @@ -1218,6 +1255,7 @@ fn games_for(conn: &Connection, team_name: &str) -> Result, ApiErro 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, @@ -1225,20 +1263,23 @@ fn games_for(conn: &Connection, team_name: &str) -> Result, ApiErro endtime_unix: timestamp, map_name, mission_mode, - result: row.get(4)?, - player_count: row.get(5)?, - winning_team: row.get(15)?, - losing_team: row.get(16)?, + result: row.get(7)?, + player_count: row.get(8)?, + winning_team: row.get(18)?, + losing_team: row.get(19)?, + tournament_name: row.get(4)?, + duration: row.get(5)?, + draw: draw_int != 0, stats: GameStats { - ground_kills: row.get(6)?, - air_kills: row.get(7)?, - assists: row.get(8)?, - captures: row.get(9)?, - deaths: row.get(10)?, - score: row.get(11)?, - missile_evades: row.get(12)?, - shell_interceptions: row.get(13)?, - team_kills_stat: row.get(14)?, + 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)?, }, }) }) @@ -1252,36 +1293,54 @@ fn recent_games_for(conn: &Connection, limit: i64) -> Result, ApiEr let mut stmt = conn .prepare( "WITH recent AS ( - SELECT team_name, session_id, MAX(endtime_unix) AS timestamp + 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 + ), + per_player AS ( + SELECT session_id, team_name, 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 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, UID ) SELECT - r.team_name, - r.session_id, - COALESCE(m.endtime_unix, r.timestamp, 0) AS timestamp, + pp.team_name, + pp.session_id, + COALESCE(m.endtime_unix, MAX(pp.endtime_unix), 0) AS timestamp, m.mission_name, m.mission_mode, - 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), + 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 = r.session_id + WHERE pg.session_id = pp.session_id AND pg.team_name IS NOT NULL AND pg.team_name != '' AND pg.victor_bool = 'Win' @@ -1290,18 +1349,16 @@ fn recent_games_for(conn: &Connection, limit: i64) -> Result, ApiEr LIMIT 1), (SELECT pg.team_name FROM player_games_hist pg - WHERE pg.session_id = r.session_id + 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 recent r - JOIN player_games_hist p - ON p.session_id = r.session_id AND p.team_name = r.team_name COLLATE NOCASE - LEFT JOIN match_summary m ON m.session_id = r.session_id - GROUP BY r.team_name COLLATE NOCASE, r.session_id + FROM per_player pp + LEFT JOIN match_summary m ON m.session_id = pp.session_id + GROUP BY pp.team_name COLLATE NOCASE, pp.session_id ORDER BY timestamp DESC LIMIT ?1", ) @@ -1310,6 +1367,7 @@ fn recent_games_for(conn: &Connection, limit: i64) -> Result, ApiEr let rows = stmt .query_map(params![limit], |row| { let timestamp: i64 = row.get(2)?; + let draw_int: i64 = row.get(7)?; Ok(GameRow { team_name: row.get(0)?, session_id: row.get(1)?, @@ -1317,20 +1375,23 @@ fn recent_games_for(conn: &Connection, limit: i64) -> Result, ApiEr endtime_unix: timestamp, map_name: row.get(3)?, mission_mode: row.get(4)?, - result: row.get(5)?, - player_count: row.get(6)?, - winning_team: row.get(16)?, - losing_team: row.get(17)?, + result: row.get(8)?, + player_count: row.get(9)?, + winning_team: row.get(19)?, + losing_team: row.get(20)?, + tournament_name: row.get(5)?, + duration: row.get(6)?, + draw: draw_int != 0, stats: GameStats { - ground_kills: row.get(7)?, - air_kills: row.get(8)?, - assists: row.get(9)?, - captures: row.get(10)?, - deaths: row.get(11)?, - score: row.get(12)?, - missile_evades: row.get(13)?, - shell_interceptions: row.get(14)?, - team_kills_stat: row.get(15)?, + ground_kills: row.get(10)?, + air_kills: row.get(11)?, + assists: row.get(12)?, + captures: row.get(13)?, + deaths: row.get(14)?, + score: row.get(15)?, + missile_evades: row.get(16)?, + shell_interceptions: row.get(17)?, + team_kills_stat: row.get(18)?, }, }) }) @@ -1342,25 +1403,31 @@ fn recent_games_for(conn: &Connection, limit: i64) -> Result, ApiEr } 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 - p.session_id, - COALESCE(m.endtime_unix, MAX(p.endtime_unix), 0) AS timestamp, + ?1 AS session_id, + COALESCE(m.endtime_unix, agg.timestamp, 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.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 = p.session_id + WHERE pg.session_id = ?1 AND pg.team_name IS NOT NULL AND pg.team_name != '' AND pg.victor_bool = 'Win' @@ -1369,20 +1436,49 @@ fn game_for(conn: &Connection, session_id: &str) -> Result, ApiE LIMIT 1), (SELECT pg.team_name FROM player_games_hist pg - WHERE pg.session_id = p.session_id + 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) - 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", + 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)?, @@ -1391,19 +1487,22 @@ fn game_for(conn: &Connection, session_id: &str) -> Result, ApiE 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)?, + player_count: row.get(7)?, + winning_team: row.get(17)?, + losing_team: row.get(18)?, + tournament_name: row.get(4)?, + duration: row.get(5)?, + draw: draw_int != 0, 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)?, + 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)?, }, }) }, @@ -1415,34 +1514,44 @@ fn game_for(conn: &Connection, session_id: &str) -> Result, ApiE fn game_participants_for( conn: &Connection, session_id: &str, + state: &AppState, + lang: &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 + 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(CASE WHEN p.victor_bool = 'Win' THEN 1 ELSE 0 END) = 1 THEN 0 - ELSE 1 - END, - p.team_name COLLATE NOCASE", + CASE WHEN MAX(pp.won) = 1 THEN 0 ELSE 1 END, + pp.team_name COLLATE NOCASE", ) .map_err(db_error)?; @@ -1471,7 +1580,8 @@ fn game_participants_for( .map_err(db_error)?; for participant in &mut participants { - participant.players = game_players_for(conn, session_id, &participant.team_name)?; + participant.players = + game_players_for(conn, session_id, &participant.team_name, state, lang)?; } Ok(participants) @@ -1481,7 +1591,11 @@ 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 @@ -1494,27 +1608,41 @@ fn game_players_for( AND pg.nick != '' ORDER BY pg.endtime_unix DESC LIMIT 1), - 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) + 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 COALESCE(SUM(p.score), 0) DESC, 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)?, From 18a45f664b1f514571f985b5d4a9aa87071420e7 Mon Sep 17 00:00:00 2001 From: FURRO404 Date: Thu, 18 Jun 2026 00:32:51 -0700 Subject: [PATCH 03/24] feat(backend): /api/tss/games/:id/logs endpoint --- backend/src/main.rs | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/backend/src/main.rs b/backend/src/main.rs index f35f57b..ab54b06 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -214,6 +214,12 @@ struct GameResponse { participants: Vec, } +#[derive(Serialize)] +struct GameLogsResponse { + chat_log: Vec, + battle_log: Vec, +} + #[derive(Serialize)] struct GameRow { #[serde(skip_serializing_if = "Option::is_none")] @@ -358,6 +364,7 @@ async fn main() -> Result<(), Box> { .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/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)) @@ -564,6 +571,31 @@ async fn game_detail( 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)> = 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)?)), + ) + .optional() + .unwrap_or(None); + let parse = |s: Option| -> Vec { + s.and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default() + }; + let (chat, battle) = row.unwrap_or((None, None)); + Ok(Json(GameLogsResponse { + chat_log: parse(chat), + battle_log: parse(battle), + })) +} + async fn resolve_team( State(state): State>, Query(query): Query, From 49e75cc8f06e844c6c7de1d43a65a1fa4add3e15 Mon Sep 17 00:00:00 2001 From: FURRO404 Date: Thu, 18 Jun 2026 00:33:48 -0700 Subject: [PATCH 04/24] docs: vehicle cache env vars and logs endpoint --- README.md | 6 ++++++ backend/README.md | 20 +++++++++++++++++++- example.env | 10 ++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2dc6388..8e63d72 100644 --- a/README.md +++ b/README.md @@ -73,11 +73,17 @@ The server serves `/health` locally and only proxies the API routes used by the app: - `GET /api/tss/leaderboard/teams?limit=1..100` +- `GET /api/tss/games/recent?limit=1..100` +- `GET /api/tss/games/:session_id?lang=...` +- `GET /api/tss/games/:session_id/logs` - `GET /api/tss/teams/resolve?name=...` - `GET /api/tss/teams/:team` - `GET /api/tss/teams/:team/history` - `GET /api/tss/teams/:team/games` +Vehicle icon PNGs are served statically at `/vehicle-icons` from `VEHICLE_ICONS_DIR` +(populated at deploy from `SHARED/ICONS/VEHICLES`). + The proxy blocks cross-origin/API-navigation requests, strips CORS headers from the upstream response, rate limits callers, and caches successful GET responses briefly so public page traffic does not hammer the upstream API. All responses diff --git a/backend/README.md b/backend/README.md index b4e7295..3788286 100644 --- a/backend/README.md +++ b/backend/README.md @@ -4,17 +4,35 @@ Rust backend API service for Toothless' TSS Bot. It reads two SQLite databases: -- `TSS_BATTLES_DB` for `tss_battles.db` +- `TSS_BATTLES_DB` for `tss_battles.db` (matches, players, and the `match_logs` table) - `TSS_TEAMS_DB` for `tss_teams.db` - `BACKEND_HOST` bind host, default `127.0.0.1` - `BACKEND_ALLOWED_ORIGINS` comma-separated browser origins allowed by CORS Both paths can be absolute or relative to the repo root when run through the root scripts/PM2. +## Vehicle translation + icons + +At startup the backend loads two cache files (built by the bots, shared under +`STORAGE/CACHE`) into memory to translate `player_games_hist.vehicle_internal` +(the WT cdk) into localized vehicle names and icon filenames for the game scoreboard: + +- `VEHICLE_TRANSLATIONS_JSON` → `vehicle_translations.json` (`{ cdk: { en, ru, ... } }`). + The `/api/tss/games/:id` endpoint honors `?lang=` (default `en`), falling back + `lang → en → raw cdk`. +- `VEHICLE_DATA_CACHE_JSON` → `vehicle_data_cache_all.json` (`[cdk, name, icon, tags]`), + used for icon filenames (fallback `.png`). + +The icon PNGs themselves are served statically by the frontend at `/vehicle-icons` +(deploy-time copy/symlink of `SHARED/ICONS/VEHICLES`). + It currently exposes: - `GET /health` - `GET /api/tss/leaderboard/teams?limit=100` +- `GET /api/tss/games/recent?limit=50` +- `GET /api/tss/games/:session_id?lang=en` — scoreboard (teams, players, vehicle lineup) +- `GET /api/tss/games/:session_id/logs` — chat + battle logs from `match_logs` - `GET /api/tss/teams/resolve?name=...` - `GET /api/tss/teams/search?q=...&limit=10` - `GET /api/tss/teams/:team` diff --git a/example.env b/example.env index 5d0ad09..6fdbad8 100644 --- a/example.env +++ b/example.env @@ -11,6 +11,16 @@ BACKEND_ALLOWED_ORIGINS=https://example.com TSS_BATTLES_DB=./tss_battles.db TSS_TEAMS_DB=./tss_teams.db +# Vehicle name translation + icon caches (shared STORAGE/CACHE, built by the bots). +# The backend loads these at startup to translate vehicle_internal (cdk) -> name +# and resolve icon filenames for the game scoreboard. +VEHICLE_TRANSLATIONS_JSON=/mnt/HC_Volume_105581488/STORAGE/CACHE/vehicle_translations.json +VEHICLE_DATA_CACHE_JSON=/mnt/HC_Volume_105581488/STORAGE/CACHE/vehicle_data_cache_all.json +# Directory of vehicle icon PNGs served at /vehicle-icons (deploy-time copy/symlink +# of SHARED/ICONS/VEHICLES). VEHICLE_ICONS_SRC is the deploy source. +VEHICLE_ICONS_DIR=./dist/vehicle-icons +VEHICLE_ICONS_SRC=/home/deploy/BOTS/SHARED/ICONS/VEHICLES + UPTIME_STORAGE_DIR=~/tsswebstorage UPTIME_DATABASE_FILE=uptime.sqlite UPTIME_SAMPLE_INTERVAL_MS=1800000 From 177ddd408d22d120cc57b691cdf7cb630d94d71c Mon Sep 17 00:00:00 2001 From: FURRO404 Date: Thu, 18 Jun 2026 00:38:00 -0700 Subject: [PATCH 05/24] feat(web): serve vehicle icons statically + deploy symlink + dev guard for logs/lang --- server.cjs | 33 +++++++++++++++++++++++++++++++++ vite.config.js | 6 ++++++ webhook.cjs | 24 ++++++++++++++++++++++++ 3 files changed, 63 insertions(+) diff --git a/server.cjs b/server.cjs index b841b4b..7c974b7 100644 --- a/server.cjs +++ b/server.cjs @@ -56,6 +56,10 @@ const TURNSTILE_VERIFY_TIMEOUT_MS = Number(process.env.TURNSTILE_VERIFY_TIMEOUT_ const TURNSTILE_MAX_TOKEN_LENGTH = 2048 const TURNSTILE_TOKEN_MAX_AGE_MS = 5 * 60 * 1000 const DIST_DIR = path.join(__dirname, 'dist') +const VEHICLE_ICONS_DIR = path.resolve( + __dirname, + process.env.VEHICLE_ICONS_DIR || path.join('dist', 'vehicle-icons'), +) const MAX_TEAM_NAME_LENGTH = 80 const MAX_CACHE_ENTRIES = 200 const MAX_RATE_LIMIT_KEYS = 1000 @@ -2027,6 +2031,30 @@ function serveStatic(req, res) { }) } +function serveVehicleIcon(req, res) { + let requestPath = '/' + try { + requestPath = decodeURIComponent(new URL(req.url, `http://localhost:${PORT}`).pathname) + } catch { + return send(res, 400, 'Bad request', { 'content-type': 'text/plain; charset=utf-8' }) + } + const name = requestPath.slice('/vehicle-icons/'.length) + const filePath = path.resolve(VEHICLE_ICONS_DIR, `./${name}`) + const relative = path.relative(VEHICLE_ICONS_DIR, filePath) + if (relative.startsWith('..') || path.isAbsolute(relative) || path.extname(filePath) !== '.png') { + return send(res, 403, 'Forbidden', { 'content-type': 'text/plain; charset=utf-8' }) + } + fs.readFile(filePath, (error, data) => { + if (error) { + return send(res, 404, 'Not found', { 'content-type': 'text/plain; charset=utf-8' }) + } + send(res, 200, data, { + 'content-type': 'image/png', + 'cache-control': 'public, max-age=604800', + }) + }) +} + const server = http.createServer((req, res) => { if (req.method === 'GET' && req.url === '/robots.txt') { sendRobotsTxt(req, res) @@ -2152,6 +2180,11 @@ const server = http.createServer((req, res) => { return } + if (req.method === 'GET' && req.url.startsWith('/vehicle-icons/')) { + serveVehicleIcon(req, res) + return + } + if (req.url.startsWith('/api/')) { proxyRequest(req, res) return diff --git a/vite.config.js b/vite.config.js index 7c8ca23..0c773f0 100644 --- a/vite.config.js +++ b/vite.config.js @@ -27,6 +27,12 @@ function isAllowedApiUrl(req) { } if (/^\/api\/tss\/games\/[A-Za-z0-9_-]{1,96}$/.test(url.pathname)) { + const keys = [...params.keys()] + const lang = params.get('lang') || 'en' + return keys.every((key) => key === 'lang') && /^[A-Za-z-]{2,8}$/.test(lang) + } + + if (/^\/api\/tss\/games\/[A-Za-z0-9_-]{1,96}\/logs$/.test(url.pathname)) { return [...params.keys()].length === 0 } diff --git a/webhook.cjs b/webhook.cjs index 3ea5e95..500f1df 100644 --- a/webhook.cjs +++ b/webhook.cjs @@ -348,6 +348,29 @@ function promoteBuiltDist() { } } +function syncVehicleIcons() { + // Point the served icon dir at the bot-managed SHARED/ICONS/VEHICLES so the + // scoreboard can render vehicle icons without bloating this repo. Symlink when + // possible; fall back to skipping (icons hide gracefully) if the source is absent. + const src = process.env.VEHICLE_ICONS_SRC || '/home/deploy/BOTS/SHARED/ICONS/VEHICLES' + const dst = path.resolve( + __dirname, + process.env.VEHICLE_ICONS_DIR || path.join('dist', 'vehicle-icons'), + ) + if (!fs.existsSync(src)) { + console.warn(`vehicle icons source missing, skipping: ${src}`) + return + } + try { + fs.rmSync(dst, { recursive: true, force: true }) + fs.mkdirSync(path.dirname(dst), { recursive: true }) + fs.symlinkSync(src, dst) + console.log(`linked vehicle icons ${dst} -> ${src}`) + } catch (error) { + console.error(`vehicle icon sync failed: ${error.message}`) + } +} + function validateBuiltDist() { const indexPath = path.join(NEXT_DIST_DIR, 'index.html') if (!fs.existsSync(indexPath)) { @@ -535,6 +558,7 @@ async function deploy(push) { validateBuiltDist() await run('cargo', ['build', '--manifest-path', 'backend/Cargo.toml', '--release']) promoteBuiltDist() + syncVehicleIcons() for (const target of RESTART_TARGETS) { await run('pm2', ['reload', target, '--update-env']) From 8158a878dbada3503396f66f6c6d190f4a517fbc Mon Sep 17 00:00:00 2001 From: FURRO404 Date: Thu, 18 Jun 2026 00:41:05 -0700 Subject: [PATCH 06/24] =?UTF-8?q?feat(web):=20rebuild=20game=20detail=20?= =?UTF-8?q?=E2=80=94=20vehicles,=20icons,=20full=20scoreboard,=20logs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.jsx | 191 ++++++++++++++++++++++++++++++------------- 1 file changed, 134 insertions(+), 57 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d92cb44..b1cf7d8 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -19,6 +19,7 @@ const apiEndpoints = { teamsHealth: '/api/tss/leaderboard/teams?limit=1', recentGames: '/api/tss/games/recent?limit=50', game: (gameId) => `/api/tss/games/${encodeURIComponent(gameId)}`, + gameLogs: (gameId) => `/api/tss/games/${encodeURIComponent(gameId)}/logs`, resolve: (name) => `/api/tss/teams/resolve?name=${encodeURIComponent(name)}`, searchTeams: (name) => `/api/tss/teams/search?q=${encodeURIComponent(name)}&limit=10`, detail: (name) => `/api/tss/teams/${encodeURIComponent(name)}`, @@ -116,6 +117,14 @@ function formatDate(timestamp) { return dateFormat.format(new Date(Number(timestamp) * 1000)) } +function formatDuration(seconds) { + const total = Math.round(Number(seconds || 0)) + if (!total) return '' + const m = Math.floor(total / 60) + const s = total % 60 + return `${m}:${String(s).padStart(2, '0')}` +} + function gameParticipants(game) { const winner = displayTeamName(game?.winning_team) const loser = displayTeamName(game?.losing_team) @@ -2769,14 +2778,18 @@ function TeamProfilePage({ navigate, profile, requestedTeam, teams }) { ) } +const SCOREBOARD_GRID = 'grid-cols-[minmax(220px,1fr)_repeat(6,minmax(52px,72px))]' + function GamePage({ gameId, navigate }) { const [gameState, setGameState] = useState({ status: 'loading', data: null, error: null }) + const [logs, setLogs] = useState({ chat_log: [], battle_log: [] }) useEffect(() => { if (!gameId) return const controller = new AbortController() setGameState({ status: 'loading', data: null, error: null }) + setLogs({ chat_log: [], battle_log: [] }) fetchJson(apiEndpoints.game(gameId), controller.signal) .then((data) => { @@ -2790,6 +2803,19 @@ function GamePage({ gameId, navigate }) { } }) + fetchJson(apiEndpoints.gameLogs(gameId), controller.signal) + .then((data) => { + if (!controller.signal.aborted) { + setLogs({ + chat_log: Array.isArray(data?.chat_log) ? data.chat_log : [], + battle_log: Array.isArray(data?.battle_log) ? data.battle_log : [], + }) + } + }) + .catch(() => { + // Logs are non-critical; leave them empty on failure. + }) + return () => controller.abort() }, [gameId]) @@ -2802,6 +2828,9 @@ function GamePage({ gameId, navigate }) { })) : gameParticipants(game) + const subtitle = [game?.mission_mode, game?.tournament_name].filter(Boolean).join(' · ') + const duration = formatDuration(game?.duration) + return (
+ {participants.map((participant) => { + const won = String(participant.result || '').toLowerCase() === 'win' + return ( +
+ -
- {(participant.players || []).map((player) => ( - - ))} +
+ {(participant.players || []).map((player) => ( + + ))} +
-
- ) - })} + ) + })} {!participants.length ? ( @@ -2900,6 +2959,24 @@ function GamePage({ gameId, navigate }) { ) : null} + + {logs.battle_log.length ? ( +
+ Battle Log +
+            {logs.battle_log.join('\n')}
+          
+
+ ) : null} + + {logs.chat_log.length ? ( +
+ Chat Log +
+            {logs.chat_log.join('\n')}
+          
+
+ ) : null}
) } From ae7adcce183b755b7be4cb9c51306781bedb36ca Mon Sep 17 00:00:00 2001 From: FURRO404 Date: Thu, 18 Jun 2026 00:43:39 -0700 Subject: [PATCH 07/24] fix(web): allow ?lang and /logs through prod API proxy allowlist --- server.cjs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/server.cjs b/server.cjs index 7c974b7..c628418 100644 --- a/server.cjs +++ b/server.cjs @@ -1586,6 +1586,15 @@ function allowedApiTarget(req) { } if (/^\/api\/tss\/games\/[A-Za-z0-9_-]{1,96}$/.test(pathname)) { + const keys = [...params.keys()] + const lang = params.get('lang') || 'en' + if (keys.some((key) => key !== 'lang') || !/^[A-Za-z-]{2,8}$/.test(lang)) { + return null + } + return url + } + + if (/^\/api\/tss\/games\/[A-Za-z0-9_-]{1,96}\/logs$/.test(pathname)) { if ([...params.keys()].length) return null return url } From 5c911abc1020ba9e47c27944d954b1574a3613d7 Mon Sep 17 00:00:00 2001 From: FURRO404 Date: Thu, 18 Jun 2026 00:58:35 -0700 Subject: [PATCH 08/24] test: end-to-end verification script for game-detail parity --- scripts/verify_game_detail.sh | 195 ++++++++++++++++++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100755 scripts/verify_game_detail.sh diff --git a/scripts/verify_game_detail.sh b/scripts/verify_game_detail.sh new file mode 100755 index 0000000..81c7bb3 --- /dev/null +++ b/scripts/verify_game_detail.sh @@ -0,0 +1,195 @@ +#!/usr/bin/env bash +# +# verify_game_detail.sh — end-to-end check for the TSSBOT game-detail parity work. +# +# Spins up a SELF-CONTAINED fixture (temp SQLite DBs, vehicle caches, icons) and +# exercises the whole stack: the Python log builder, the Rust backend (dedup fix, +# vehicle translation, logs endpoint), and the Node prod server (icon serving + +# API proxy allowlist). Touches NO production data. Exits non-zero on any failure. +# +# Usage: +# bash scripts/verify_game_detail.sh +# +# Env overrides: +# BOTS_REPO path to the BOTS repo (default: ~/GitHub/BOTS, fallback ~/BOTS, /home/deploy/BOTS) +# PYTHON python interpreter (default: BOTS venv if present, else python3) +# BE_PORT backend port (default 6071) +# WEB_PORT node server port (default 3071) + +set -uo pipefail + +WEB_REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BE_PORT="${BE_PORT:-6071}" +WEB_PORT="${WEB_PORT:-3071}" + +# ---- locate BOTS repo + python -------------------------------------------- +if [[ -z "${BOTS_REPO:-}" ]]; then + for c in "$HOME/GitHub/BOTS" "$HOME/BOTS" "/home/deploy/BOTS"; do + [[ -d "$c" ]] && BOTS_REPO="$c" && break + done +fi +if [[ -z "${BOTS_REPO:-}" || ! -d "$BOTS_REPO" ]]; then + echo "FAIL: could not find BOTS repo; set BOTS_REPO=..." >&2 + exit 1 +fi +if [[ -z "${PYTHON:-}" ]]; then + if [[ -x "$BOTS_REPO/SHARED/.venv/bin/python" ]]; then + PYTHON="$BOTS_REPO/SHARED/.venv/bin/python" + else + PYTHON="python3" + fi +fi + +PASS=0 +FAIL=0 +ok() { echo " PASS: $1"; PASS=$((PASS+1)); } +bad() { echo " FAIL: $1" >&2; FAIL=$((FAIL+1)); } +# assert_eq