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)?,