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 2de0e68..760f811 100644 --- a/backend/README.md +++ b/backend/README.md @@ -4,18 +4,36 @@ 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/leaderboard/players?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/backend/src/main.rs b/backend/src/main.rs index 82cd1cf..0e72b4e 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -7,7 +7,7 @@ use axum::{ }; use rusqlite::{params, Connection, OptionalExtension}; use serde::{Deserialize, Serialize}; -use serde_json::json; +use serde_json::{json, Value}; use std::{ collections::{BTreeMap, HashMap}, env, fs, @@ -29,6 +29,8 @@ struct AppState { battles_db: PathBuf, teams_db: PathBuf, leaderboard_cache: Mutex>, + vehicle_names: HashMap>, + vehicle_icons: HashMap, } struct CachedLeaderboard { @@ -77,6 +79,11 @@ struct LimitQuery { limit: Option, } +#[derive(Deserialize)] +struct LangQuery { + lang: Option, +} + #[derive(Deserialize)] struct ResolveQuery { name: String, @@ -232,6 +239,13 @@ struct GameResponse { participants: Vec, } +#[derive(Serialize)] +struct GameLogsResponse { + chat_log: Vec, + battle_log: Vec, + event_log: Value, +} + #[derive(Serialize)] struct GameRow { #[serde(skip_serializing_if = "Option::is_none")] @@ -245,6 +259,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, } @@ -274,9 +293,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, @@ -331,7 +358,10 @@ struct TeamRecord { async fn main() -> Result<(), Box> { load_root_env(); tracing_subscriber::fmt() - .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .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)); @@ -342,7 +372,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.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() @@ -351,6 +394,7 @@ async fn main() -> Result<(), Box> { .route("/api/tss/leaderboard/players", get(player_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)) @@ -556,15 +600,58 @@ 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 })) } +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, @@ -1279,30 +1366,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' @@ -1311,17 +1416,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", ) @@ -1333,6 +1437,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, @@ -1340,20 +1445,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)?, }, }) }) @@ -1364,36 +1472,35 @@ fn games_for(conn: &Connection, team_name: &str) -> Result, ApiErro } 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 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 + ), + 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.team_name, r.session_id, COALESCE(m.endtime_unix, r.timestamp, 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), + (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 @@ -1413,10 +1520,7 @@ fn recent_games_for(conn: &Connection, limit: i64) -> Result, ApiEr 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 ORDER BY timestamp DESC LIMIT ?1", ) @@ -1424,28 +1528,32 @@ 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 timestamp: i64 = row.get(1)?; + let draw_int: i64 = row.get(6)?; Ok(GameRow { - team_name: row.get(0)?, - session_id: row.get(1)?, + team_name: None, + session_id: row.get(0)?, timestamp, 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)?, + 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_name: row.get(4)?, + duration: row.get(5)?, + 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: 0, + air_kills: 0, + assists: 0, + captures: 0, + deaths: 0, + score: 0, + missile_evades: 0, + shell_interceptions: 0, + team_kills_stat: 0, }, }) }) @@ -1457,25 +1565,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' @@ -1484,20 +1598,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)?, @@ -1506,19 +1649,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)?, }, }) }, @@ -1530,34 +1676,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)?; @@ -1586,7 +1742,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) @@ -1596,7 +1753,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 @@ -1609,27 +1770,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)?, @@ -1751,6 +1926,61 @@ fn allowed_origins() -> AllowOrigin { } } +// 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 raw = env::var(env_key).unwrap_or_else(|_| default_file.to_string()); let expanded = expand_home(&raw); @@ -1832,3 +2062,46 @@ 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); + } +} diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index df920b3..2dbb534 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -88,6 +88,11 @@ module.exports = { BACKEND_ALLOWED_ORIGINS: process.env.BACKEND_ALLOWED_ORIGINS || process.env.PUBLIC_ORIGIN || '', TSS_BATTLES_DB: process.env.TSS_BATTLES_DB || 'tss_battles.db', TSS_TEAMS_DB: process.env.TSS_TEAMS_DB || 'tss_teams.db', + // Vehicle name + icon caches (built by the bots in the shared STORAGE volume). + VEHICLE_TRANSLATIONS_JSON: process.env.VEHICLE_TRANSLATIONS_JSON + || '/mnt/HC_Volume_105581488/STORAGE/CACHE/vehicle_translations.json', + VEHICLE_DATA_CACHE_JSON: process.env.VEHICLE_DATA_CACHE_JSON + || '/mnt/HC_Volume_105581488/STORAGE/CACHE/vehicle_data_cache.json', }, }, ], diff --git a/example.env b/example.env index 5d0ad09..5252507 100644 --- a/example.env +++ b/example.env @@ -11,6 +11,18 @@ 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 (icons-only) is what the bots actually write; the +# *_all variant (every vehicle, incl. iconless) also works if present. +VEHICLE_DATA_CACHE_JSON=/mnt/HC_Volume_105581488/STORAGE/CACHE/vehicle_data_cache.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 diff --git a/frontend/public/fonts/symbols_skyquake.ttf b/frontend/public/fonts/symbols_skyquake.ttf new file mode 100644 index 0000000..76fc5d3 Binary files /dev/null and b/frontend/public/fonts/symbols_skyquake.ttf differ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index cec1c16..1917111 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import Tree, { prewarmTreeCanvas } from '../Tree/Tree' import FallingLeaves from '../Tree/FallingLeaves' +import ReplayCanvasPanel from './ReplayCanvas' const numberFormat = new Intl.NumberFormat('en-GB') const dateFormat = new Intl.DateTimeFormat('en-GB', { @@ -20,6 +21,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)}`, @@ -123,6 +125,15 @@ 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 + if (!m) return `${s}s` + return `${m}m ${s}s` +} + function gameParticipants(game) { const winner = displayTeamName(game?.winning_team) const loser = displayTeamName(game?.losing_team) @@ -149,16 +160,39 @@ function displayTeamName(value) { return String(value || '').trim() } -function ParticipantNames({ participants }) { +function ParticipantNames({ participants, spread = false }) { if (!participants.length) { return

Participants unknown

} + // On wide rows (battle logs), spread the two teams to opposite ends with a + // centered "vs" so each side gets an equal share of the available width. + if (spread && participants.length === 2) { + const [first, second] = participants + return ( +
+ + {first.name} + + + vs + + + {second.name} + +
+ ) + } + return ( -
+
{participants.map((participant) => ( {participant.name} @@ -2899,14 +2933,129 @@ function TeamProfilePage({ navigate, profile, requestedTeam, teams }) { ) } +const SCOREBOARD_GRID = 'grid-cols-[minmax(220px,1fr)_repeat(6,minmax(52px,72px))]' + +// Battle-log lines are prefixed +/-/ for winner/loser/neither (matching +// the Discord diff format), so colour each line by its acting team. +function battleLineColor(line) { + if (line.startsWith('+')) return 'text-win' + if (line.startsWith('-')) return 'text-loss' + return 'text-text-soft' +} + +function formatLogTime(ms) { + const totalSeconds = Math.floor(Number(ms || 0) / 1000) + const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, '0') + const seconds = String(totalSeconds % 60).padStart(2, '0') + return `${minutes}:${seconds}` +} + +function deadVehicleKey(uid, cdk) { + return `${String(uid || '').trim()}:${String(cdk || '').trim()}` +} + +function deadVehicleKeysFromEventLog(eventLog) { + const keys = new Set() + const kills = Array.isArray(eventLog?.kills) ? eventLog.kills : [] + kills.forEach((kill) => { + const uid = kill?.offended_uid + const cdk = kill?.offended_unit + if (uid !== undefined && uid !== null && cdk) { + keys.add(deadVehicleKey(uid, cdk)) + } + }) + return keys +} + +function logLookups(participants) { + const players = new Map() + ;(participants || []).forEach((participant) => { + const result = String(participant.result || '').toLowerCase() === 'win' ? 'win' : 'loss' + ;(participant.players || []).forEach((player) => { + const vehicles = new Map() + ;(player.vehicles || []).forEach((vehicle) => { + vehicles.set(String(vehicle.cdk || ''), vehicle.name || vehicle.cdk || 'Unknown') + }) + players.set(String(player.uid), { + name: player.nick || player.uid, + team: participant.team_name || '', + result, + className: result === 'win' ? 'text-win' : 'text-loss', + vehicles, + }) + }) + }) + return players +} + +function logNameLookups(participants) { + const players = new Map() + ;(participants || []).forEach((participant) => { + const result = String(participant.result || '').toLowerCase() === 'win' ? 'win' : 'loss' + ;(participant.players || []).forEach((player) => { + const name = String(player.nick || '').trim() + if (!name) return + players.set(name.toLowerCase(), { + name, + team: participant.team_name || '', + result, + className: result === 'win' ? 'text-win' : 'text-loss', + }) + }) + }) + return players +} + +function logPlayer(players, uid) { + return players.get(String(uid)) || { + name: uid === undefined || uid === null ? 'Unknown' : `Player#${uid}`, + team: '', + result: '', + className: 'text-text-soft', + vehicles: new Map(), + } +} + +function logVehicle(player, cdk) { + if (!cdk) return 'Unknown' + return player.vehicles.get(String(cdk)) || String(cdk) +} + +function structuredBattleEvents(eventLog) { + const kills = Array.isArray(eventLog?.kills) ? eventLog.kills : [] + const damage = Array.isArray(eventLog?.damage) ? eventLog.damage : [] + return [ + ...kills.map((event) => ({ ...event, kind: 'kill' })), + ...damage.map((event) => ({ ...event, kind: 'damage' })), + ].sort((a, b) => Number(a.time || 0) - Number(b.time || 0)) +} + +function chatTypeClass(type, senderClassName) { + return String(type || 'ALL').toUpperCase() === 'ALL' ? 'text-warning' : senderClassName +} + +function parseFormattedChatLine(line) { + const match = String(line || '').match(/^([+-]?)(\[\d{2}:\d{2}\])\s+\[([^\]]+)\]\s+\[[^\]]*\]\s+`([^`]*)`:\s?(.*)$/) + if (!match) return null + return { + prefix: match[1], + time: match[2], + type: match[3], + name: match[4], + message: match[5], + } +} + function GamePage({ gameId, navigate }) { const [gameState, setGameState] = useState({ status: 'loading', data: null, error: null }) + const [logs, setLogs] = useState({ chat_log: [], battle_log: [], event_log: { kills: [], damage: [], chat: [] } }) useEffect(() => { if (!gameId) return const controller = new AbortController() setGameState({ status: 'loading', data: null, error: null }) + setLogs({ chat_log: [], battle_log: [], event_log: { kills: [], damage: [], chat: [] } }) fetchJson(apiEndpoints.game(gameId), controller.signal) .then((data) => { @@ -2920,11 +3069,30 @@ 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 : [], + event_log: data?.event_log || { kills: [], damage: [], chat: [] }, + }) + } + }) + .catch(() => { + // Logs are non-critical; leave them empty on failure. + }) + return () => controller.abort() }, [gameId]) const game = gameState.data?.game - const participants = gameState.data?.participants || [] + const participants = useMemo(() => gameState.data?.participants || [], [gameState.data?.participants]) + const deadVehicleKeys = useMemo(() => deadVehicleKeysFromEventLog(logs.event_log), [logs.event_log]) + const playersByUid = useMemo(() => logLookups(participants), [participants]) + const playersByName = useMemo(() => logNameLookups(participants), [participants]) + const battleEvents = useMemo(() => structuredBattleEvents(logs.event_log), [logs.event_log]) + const chatEvents = Array.isArray(logs.event_log?.chat) ? logs.event_log.chat : [] const participantNames = participants.length ? participants.map((participant) => ({ name: participant.team_name, @@ -2932,6 +3100,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' + const accent = won ? 'border-l-win' : 'border-l-loss' + const nameColor = won ? 'text-win' : 'text-loss' + return ( +
+ -
- {(participant.players || []).map((player) => ( - - ))} +
+ {(participant.players || []).map((player) => ( + + ))} +
-
- ) - })} + ) + })}
{!participants.length ? ( @@ -3030,10 +3238,109 @@ function GamePage({ gameId, navigate }) { ) : null}
+ + + + {battleEvents.length || logs.battle_log.length ? ( +
+ Battle Log +
+ {battleEvents.length ? ( + battleEvents.map((event, i) => ( + + )) + ) : ( + logs.battle_log.map((line, i) => ( +
{line}
+ )) + )} +
+
+ ) : null} + + {chatEvents.length || logs.chat_log.length ? ( +
+ Chat Log +
+ {chatEvents.length ? ( + chatEvents.map((event, i) => ( + + )) + ) : ( + logs.chat_log.map((line, i) => ( + + )) + )} +
+
+ ) : null} ) } +function FormattedChatLine({ line, players }) { + const parsed = parseFormattedChatLine(line) + if (!parsed) { + return
{line}
+ } + + const player = players.get(parsed.name.toLowerCase()) + const className = player?.className || battleLineColor(parsed.prefix) + const team = player?.team || '??' + const name = player?.name || parsed.name + const typeClassName = chatTypeClass(parsed.type, className) + + return ( +
+ {parsed.time} + [{parsed.type}] + [{team}] `{name}`: {parsed.message} +
+ ) +} + +function BattleEventLine({ event, players }) { + const offender = logPlayer(players, event.offender_uid) + const victim = logPlayer(players, event.offended_uid) + const ts = formatLogTime(event.time) + const victimLabel = `[${victim.team}] ${victim.name} (${logVehicle(victim, event.offended_unit)})` + + if (event.kind === 'kill' && (event.crashed || event.offender_uid === undefined || event.offender_uid === null)) { + return ( +
+ [{ts}] + [{victim.team}] {victimLabel} + crashed +
+ ) + } + + const offenderLabel = `${offender.name} (${logVehicle(offender, event.offender_unit)})` + const action = event.kind === 'kill' ? 'destroyed' : `damaged ${event.afire ? '(FIRE) ' : ''}` + + return ( +
+ [{ts}] + [{offender.team}] {offenderLabel} + {action} + {victimLabel} +
+ ) +} + +function ChatEventLine({ event, players }) { + const player = logPlayer(players, event.uid) + const type = event.type || 'ALL' + const typeClassName = chatTypeClass(type, player.className) + return ( +
+ [{formatLogTime(event.time)}] + [{type}] + [{player.team}] `{player.name}`: {event.message || ''} +
+ ) +} + function RosterTable({ players, status }) { const sortedPlayers = [...players].sort((a, b) => { return (b.total_kills || 0) - (a.total_kills || 0) || String(a.nick || '').localeCompare(b.nick || '') @@ -3124,7 +3431,7 @@ function BattleLogsPage({ live, matches, navigate }) {
{matches.map((match) => (
- +

{formatMatchSize(match.player_count)}

))} diff --git a/frontend/src/ReplayCanvas.jsx b/frontend/src/ReplayCanvas.jsx new file mode 100644 index 0000000..df37e82 --- /dev/null +++ b/frontend/src/ReplayCanvas.jsx @@ -0,0 +1,1142 @@ +import { useEffect, useRef, useState } from 'react' + +const RC = { + TRAIL_MS: 18000, + AIR_TRAIL_MS: 4000, + DRONE_TRAIL_MS: 2000, + KILL_TTL: 8000, + DMG_TTL: 4000, + GHOST_TTL: 3000, + DEFAULT_SPEED: 4, + WIN: '#00c800', + LOSE: '#dc1e1e', + WIN_TRAIL: 'rgba(0,120,0,', + LOSE_TRAIL: 'rgba(132,18,18,', + DOT_R: 5, + AIR_R: 4, + DRONE_R: 3, +} + +const CAP_STROKE_PX = 3 +const CAP_ICON_ALPHA = 0.35 +const CAP_ICON_MIN_SIZE = 10 +const CAP_FILL_ALPHA = 0.5 + +function loadImage(src) { + return new Promise((resolve) => { + const img = new Image() + img.crossOrigin = 'anonymous' + img.onload = () => resolve(img) + img.onerror = () => resolve(null) + img.src = src + }) +} + +function esc(value) { + const div = document.createElement('div') + div.textContent = String(value || '') + return div.innerHTML +} + +class ReplayCanvasEngine { + constructor(containerEl, data) { + this.container = containerEl + this.data = data + this.playing = false + this.speed = RC.DEFAULT_SPEED + this.currentTime = 0 + this.tStart = Infinity + this.tEnd = -Infinity + this.lastFrameTime = 0 + this.highlightedPlayerId = null + this.animFrameId = null + this.canvasSize = 720 + this._groundCoords = data.levelCoords + this._tankMapCoords = data.tankMapCoords || data.levelCoords + this._airCoords = data.mapCoords || null + this._fullMapLevel = data.fullMapLevel || null + this.captureAreas = Array.isArray(data.captureAreas) ? data.captureAreas : [] + this._captureState = data.captureState && Object.keys(data.captureState).length ? data.captureState : null + this._tickets = data.tickets && Object.keys(data.tickets).length ? data.tickets : null + this._winnerSlot = Number(data.winnerSlot) || 0 + this._mode = 'ground' + this._mapSrc = { u: 0, v: 0, w: 1, h: 1 } + this.players = {} + for (const p of data.players || []) this.players[p.id] = p + + const hasAircraft = (data.entities || []).some((e) => e.type === 'aircraft') + this.hasAirMode = Boolean(this._airCoords && this._fullMapLevel && hasAircraft) + if (!this._groundCoords && this._airCoords) this._groundCoords = this._airCoords + if (!this._tankMapCoords && this._groundCoords) this._tankMapCoords = this._groundCoords + this._applyCoords(this.hasAirMode && !this._hasGroundEntities(data.entities) ? 'air' : 'ground') + + this.entities = [] + for (const e of data.entities || []) { + if (!e.path || !e.path.length) continue + const times = new Float64Array(e.path.length) + const positions = new Float32Array(e.path.length * 2) + for (let i = 0; i < e.path.length; i++) { + times[i] = Number(e.path[i].t) + positions[i * 2] = Number(e.path[i].x) + positions[i * 2 + 1] = Number(e.path[i].z) + } + if (times[0] < this.tStart) this.tStart = times[0] + if (times[times.length - 1] > this.tEnd) this.tEnd = times[times.length - 1] + const isWinner = e.playerId > 0 + ? this.players[e.playerId]?.team === data.teamWon + : e.droneTeam === data.teamWon + this.entities.push({ + ...e, + times, + positions, + isWinner, + deathTime: null, + ghostEndTime: null, + deathPos: null, + _lastHeading: null, + }) + } + if (!Number.isFinite(this.tStart) || !Number.isFinite(this.tEnd)) { + this.tStart = 0 + this.tEnd = 1 + } + this._computeDeaths() + this.currentTime = this.tStart + } + + _hasGroundEntities(entities) { + return Array.isArray(entities) && entities.some((e) => e.type === 'ground') + } + + _applyCoords(mode) { + this._mode = mode === 'air' && this._airCoords ? 'air' : 'ground' + const coords = this._mode === 'air' ? this._airCoords : this._groundCoords + this.x0 = Number(coords?.x0 || 0) + this.z0 = Number(coords?.z0 || 0) + this.xRange = Number(coords?.x1 || 1) - this.x0 + this.zRange = Number(coords?.z1 || 1) - this.z0 + if (!this.xRange) this.xRange = 1 + if (!this.zRange) this.zRange = 1 + this._updateMapSourceRect() + } + + async init() { + this._buildDOM() + await Promise.all([this._loadMap(), this._loadEntityIcons()]) + this.playing = true + this.playBtn.textContent = 'Pause' + this.lastFrameTime = performance.now() + this._tick = this._tick.bind(this) + this.animFrameId = requestAnimationFrame(this._tick) + } + + _buildDOM() { + this.container.innerHTML = '' + this._panelRows = [] + const layout = document.createElement('div') + layout.className = 'rc-layout' + + this.leftPanel = document.createElement('div') + this.leftPanel.className = 'rc-panel rc-panel-win' + this._buildTeamPanel(this.leftPanel, true) + + const center = document.createElement('div') + center.className = 'rc-center' + this._buildTicketsBar(center) + + this.canvas = document.createElement('canvas') + this.canvas.width = this.canvasSize + this.canvas.height = this.canvasSize + this.canvas.className = 'rc-canvas' + this.ctx = this.canvas.getContext('2d') + center.appendChild(this.canvas) + + const controls = document.createElement('div') + controls.className = 'rc-controls' + controls.innerHTML = ` + +
+ + + + +
+ + 0:00 / 0:00 + ` + center.appendChild(controls) + + const logWrap = document.createElement('div') + logWrap.className = 'rc-log-wrap' + logWrap.innerHTML = '
' + center.appendChild(logWrap) + this.battleLog = logWrap.querySelector('.rc-log') + this._buildEventList() + + this.rightPanel = document.createElement('div') + this.rightPanel.className = 'rc-panel rc-panel-lose' + this._buildTeamPanel(this.rightPanel, false) + + layout.appendChild(this.leftPanel) + layout.appendChild(center) + layout.appendChild(this.rightPanel) + this.container.appendChild(layout) + + this.playBtn = controls.querySelector('.rc-play') + this.scrubber = controls.querySelector('.rc-scrub') + this.timeDisplay = controls.querySelector('.rc-time') + this.playBtn.addEventListener('click', () => this._togglePlay()) + this.scrubber.addEventListener('input', () => { + this.currentTime = this.tStart + (this.scrubber.value / 1000) * (this.tEnd - this.tStart) + this._updatePanelDeathStates() + this._updateBattleLog() + this._updateTicketsBar(this.currentTime) + this.render() + }) + controls.querySelectorAll('.rc-sp').forEach((btn) => { + btn.addEventListener('click', () => { + controls.querySelectorAll('.rc-sp').forEach((b) => b.classList.remove('active')) + btn.classList.add('active') + this.speed = Number(btn.dataset.speed) || RC.DEFAULT_SPEED + }) + }) + + this._mouseOnCanvas = false + this._mouseX = 0 + this._mouseY = 0 + this.canvas.addEventListener('mousemove', (ev) => { + this._mouseOnCanvas = true + const rect = this.canvas.getBoundingClientRect() + this._mouseX = (ev.clientX - rect.left) * (this.canvasSize / rect.width) + this._mouseY = (ev.clientY - rect.top) * (this.canvasSize / rect.height) + }) + this.canvas.addEventListener('mouseleave', () => { + this._mouseOnCanvas = false + this._setHighlight(null) + }) + + this.mapCanvas = document.createElement('canvas') + this.mapCanvas.width = this.canvasSize + this.mapCanvas.height = this.canvasSize + this.mapCtx = this.mapCanvas.getContext('2d') + } + + _buildTeamPanel(panel, isWinner) { + const teamEntities = this.entities.filter((e) => e.playerId > 0 && e.isWinner === isWinner) + // One row per player; a player may have several entities (one per spawn). + const byPlayer = new Map() + for (const e of teamEntities) { + if (!byPlayer.has(e.playerId)) byPlayer.set(e.playerId, []) + byPlayer.get(e.playerId).push(e) + } + for (const list of byPlayer.values()) list.sort((a, b) => a.times[0] - b.times[0]) + + const color = isWinner ? RC.WIN : RC.LOSE + const firstId = byPlayer.size ? byPlayer.keys().next().value : null + const firstPlayer = firstId ? this.players[firstId] : null + const teamSlot = firstPlayer ? firstPlayer.team : (isWinner ? this.data.teamWon : null) + const teamName = teamSlot != null ? this.data.teamNames?.[String(teamSlot)] : '' + const label = teamName ? esc(teamName) : (isWinner ? 'Winners' : 'Losers') + let html = `
${label}
` + for (const [pid, list] of byPlayer) { + const p = this.players[pid] + const name = p ? esc(p.name) : '?' + const ent = list[0] + const veh = esc(ent.vehicleName) + const panelIcon = ent.miniIcon ? ent.miniIcon.replace('mini:', '') : (ent.iconKey || 'medium') + html += `
+ +
+ ${name} + ${veh} +
+ +
` + } + html += '
' + panel.innerHTML = html + + const rows = panel.querySelectorAll('.rc-row') + let i = 0 + for (const [pid, list] of byPlayer) { + const row = rows[i++] + if (!row) continue + const pr = { + row, + playerId: pid, + entities: list, + vehEl: row.querySelector('.rc-row-veh'), + iconEl: row.querySelector('.rc-type-icon'), + statusEl: row.querySelector('.rc-row-status'), + shownEntityIndex: list[0].entityIndex, + currentEntityIndex: list[0].entityIndex, + } + this._panelRows.push(pr) + row.addEventListener('mouseenter', () => { + const ent = pr.currentEntityIndex != null + ? this.entities.find((e) => e.entityIndex === pr.currentEntityIndex) + : null + if (ent && !this._isEntityGone(ent, this.currentTime)) this._setHighlight(pid) + }) + row.addEventListener('mouseleave', () => this._setHighlight(null)) + } + } + + // Resolve which of a player's spawned vehicles is relevant at time t, and + // whether the player is currently alive, down (awaiting respawn), or gone. + _playerStateAtTime(entities, t) { + for (const e of entities) { + const first = e.times[0] + const last = e.times[e.times.length - 1] + if (t >= first && t <= last && !this._isEntityDead(e, t)) { + return { entity: e, dead: false, gone: false } + } + } + let recent = null + for (const e of entities) { + if (e.times[0] <= t && (!recent || e.times[0] >= recent.times[0])) recent = e + } + if (!recent) return { entity: entities[0] || null, dead: false, gone: false } + const hasFutureSpawn = entities.some((e) => e.times[0] > t) + const gone = !hasFutureSpawn && this._isEntityGone(recent, t) + return { entity: recent, dead: true, gone } + } + + _buildEventList() { + this._events = [] + for (const k of this.data.kills || []) { + const killer = this.players[k.killerId] + let victimName = '?' + let victimTeam = -1 + if (k.victimId && this.players[k.victimId]) { + victimName = this.players[k.victimId].name + victimTeam = this.players[k.victimId].team + } else if (k.victimVehicle) { + victimName = k.victimVehicle + } + let html + if (!killer) { + const victimIsWin = victimTeam === this.data.teamWon + html = `${esc(victimName)} crashed` + } else { + const killerIsWin = killer.team === this.data.teamWon + html = `${esc(killer.name)} destroyed ${esc(victimName)}${k.weapon ? `[${esc(k.weapon)}]` : ''}` + } + this._events.push({ time: k.time, type: 'kill', html }) + } + for (const dm of this.data.damages || []) { + const atk = this.players[dm.offenderId] + const vic = this.players[dm.offendedId] + if (!atk || !vic) continue + const atkIsWin = atk.team === this.data.teamWon + this._events.push({ + time: dm.time, + type: 'damage', + html: `${esc(atk.name)} hit ${esc(vic.name)}`, + }) + } + this._events.sort((a, b) => a.time - b.time) + this._lastLogIndex = -1 + } + + worldToPixel(x, z) { + return [ + ((x - this.x0) / this.xRange) * this.canvasSize, + ((this.z0 + this.zRange - z) / this.zRange) * this.canvasSize, + ] + } + + getPositionAtTime(entity, time) { + const { times, positions } = entity + if (time < times[0] || time > times[times.length - 1]) return null + let lo = 0 + let hi = times.length - 1 + while (lo < hi - 1) { + const mid = (lo + hi) >> 1 + if (times[mid] <= time) lo = mid + else hi = mid + } + const t0 = times[lo] + const t1 = times[hi] + const frac = t1 > t0 ? (time - t0) / (t1 - t0) : 0 + const i0 = lo * 2 + const i1 = hi * 2 + return this.worldToPixel( + positions[i0] + (positions[i1] - positions[i0]) * frac, + positions[i0 + 1] + (positions[i1 + 1] - positions[i0 + 1]) * frac, + ) + } + + getHeadingAtTime(entity, time) { + const windows = this._mode === 'air' ? [1800, 1400, 1000, 650, 350] : [700, 500, 300] + for (const dt of windows) { + const p0 = this.getPositionAtTime(entity, time - dt) || this.getPositionAtTime(entity, time) + const p1 = this.getPositionAtTime(entity, time + dt) || this.getPositionAtTime(entity, time) + if (!p0 || !p1) continue + const dx = p1[0] - p0[0] + const dy = p1[1] - p0[1] + const minDist = this._mode === 'air' ? 2 : 0.4 + if (Math.hypot(dx, dy) < minDist) continue + const raw = Math.atan2(dx, -dy) + if (entity._lastHeading === null || entity._lastHeading === undefined) { + entity._lastHeading = raw + } else { + let delta = raw - entity._lastHeading + while (delta > Math.PI) delta -= Math.PI * 2 + while (delta < -Math.PI) delta += Math.PI * 2 + const maxTurn = this._mode === 'air' ? 0.45 : 0.8 + delta = Math.max(-maxTurn, Math.min(maxTurn, delta)) + entity._lastHeading += delta + } + return entity._lastHeading + } + return entity._lastHeading ?? null + } + + _computeDeaths() { + for (const ent of this.entities) { + ent.deathTime = null + ent.ghostEndTime = null + ent.deathPos = null + } + for (const k of this.data.kills || []) { + for (const ent of this.entities) { + const matched = (k.victimEntityIndex && ent.entityIndex === k.victimEntityIndex) + || (k.victimId && ent.playerId === k.victimId && ent.playerId !== 0) + if (matched && ent.deathTime === null) { + ent.deathTime = k.time + ent.ghostEndTime = k.time + RC.GHOST_TTL + if (k.victimPos) ent.deathPos = this.worldToPixel(k.victimPos.x, k.victimPos.z) + break + } + } + } + } + + _entityScreenPos(entity, time) { + if (entity.deathTime !== null && time >= entity.deathTime) return entity.deathPos + return this.getPositionAtTime(entity, time) + } + + _isEntityDead(entity, time) { + return entity.deathTime !== null && time >= entity.deathTime + } + + _isEntityGone(entity, time) { + return entity.ghostEndTime !== null && time >= entity.ghostEndTime + } + + _setHighlight(playerId) { + if (this.highlightedPlayerId === playerId) return + this.highlightedPlayerId = playerId + this.container.querySelectorAll('.rc-row').forEach((row) => { + row.classList.toggle('rc-hl', Number(row.dataset.playerId) === playerId) + }) + if (!this.playing) this.render() + } + + _updatePanelDeathStates() { + const t = this.currentTime + if (!this._panelRows) return + for (const pr of this._panelRows) { + const st = this._playerStateAtTime(pr.entities, t) + pr.currentEntityIndex = st.entity ? st.entity.entityIndex : null + if (st.entity && st.entity.entityIndex !== pr.shownEntityIndex) { + pr.shownEntityIndex = st.entity.entityIndex + if (pr.vehEl) pr.vehEl.textContent = st.entity.vehicleName || '' + if (pr.iconEl) { + const panelIcon = st.entity.miniIcon + ? st.entity.miniIcon.replace('mini:', '') + : (st.entity.iconKey || 'medium') + pr.iconEl.src = `/api/icons/type/${panelIcon}` + pr.iconEl.style.display = '' + } + } + pr.row.classList.toggle('rc-dead', st.dead) + pr.row.classList.toggle('rc-gone', st.gone) + if (pr.statusEl) pr.statusEl.textContent = st.dead || st.gone ? 'x' : '' + } + } + + _updateBattleLog() { + const t = this.currentTime + let idx = -1 + for (let i = 0; i < this._events.length; i++) { + if (this._events[i].time <= t) idx = i + else break + } + if (idx === this._lastLogIndex) return + this._lastLogIndex = idx + this.battleLog.innerHTML = '' + for (let i = 0; i <= idx; i++) { + const ev = this._events[i] + const el = document.createElement('div') + el.className = `rc-ev rc-ev-${ev.type}` + const elapsed = Math.max(0, (ev.time - this.tStart) / 1000) + const mm = Math.floor(elapsed / 60) + const ss = Math.floor(elapsed % 60) + el.innerHTML = `${mm}:${String(ss).padStart(2, '0')}${ev.html}` + this.battleLog.appendChild(el) + } + this.battleLog.scrollTop = this.battleLog.scrollHeight + } + + async _loadEntityIcons() { + this._iconCache = {} + const keysToLoad = new Set() + for (const ent of this.entities) { + if (ent.miniIcon) { + const miniKey = ent.miniIcon.replace('mini:', '') + keysToLoad.add(miniKey) + ent._canvasIconKey = miniKey + } else if (ent.iconKey) { + keysToLoad.add(ent.iconKey) + ent._canvasIconKey = ent.iconKey + } + } + await Promise.all([...keysToLoad].map(async (key) => { + const img = await loadImage(`/api/icons/type/${key}`) + if (img) this._iconCache[key] = img + })) + } + + async _loadMap() { + const level = this.data.mission?.level + this._groundMapImg = level ? await loadImage(`/api/match/minimap/${level}`) : null + this._airMapImg = this._fullMapLevel ? await loadImage(`/api/match/minimap/${this._fullMapLevel}?type=full`) : null + this._capIconCache = { cap_icon: await loadImage('/api/icons/type/cap_icon') } + const capLetters = new Set() + for (let i = 0; i < this.captureAreas.length && i < 26; i++) capLetters.add(String.fromCharCode(97 + i)) + await Promise.all([...capLetters].map(async (letter) => { + this._capIconCache[`capture_${letter}`] = await loadImage(`/api/icons/type/capture_${letter}`) + })) + this._drawMapToCanvas() + } + + _updateMapSourceRect() { + if (this._mode !== 'ground') { + this._mapSrc = { u: 0, v: 0, w: 1, h: 1 } + return + } + const base = this._tankMapCoords || this._groundCoords + const render = this._groundCoords + const bx0 = Number(base?.x0) + const bz0 = Number(base?.z0) + const bx1 = Number(base?.x1) + const bz1 = Number(base?.z1) + const rx0 = Number(render?.x0) + const rz0 = Number(render?.z0) + const rx1 = Number(render?.x1) + const rz1 = Number(render?.z1) + const dx = bx1 - bx0 + const dz = bz1 - bz0 + if (!Number.isFinite(dx) || !Number.isFinite(dz) || dx === 0 || dz === 0) { + this._mapSrc = { u: 0, v: 0, w: 1, h: 1 } + return + } + const u0 = (Math.min(rx0, rx1) - bx0) / dx + const u1 = (Math.max(rx0, rx1) - bx0) / dx + const v0 = (Math.min(rz0, rz1) - bz0) / dz + const v1 = (Math.max(rz0, rz1) - bz0) / dz + const uMin = Math.max(0, Math.min(1, Math.min(u0, u1))) + const uMax = Math.max(0, Math.min(1, Math.max(u0, u1))) + const vMin = Math.max(0, Math.min(1, Math.min(v0, v1))) + const vMax = Math.max(0, Math.min(1, Math.max(v0, v1))) + const w = uMax - uMin + const h = vMax - vMin + this._mapSrc = w > 0 && h > 0 ? { u: uMin, v: 1 - vMax, w, h } : { u: 0, v: 0, w: 1, h: 1 } + } + + _drawMapToCanvas() { + const img = this._mode === 'air' ? (this._airMapImg || this._groundMapImg) : this._groundMapImg + this.mapCtx.clearRect(0, 0, this.canvasSize, this.canvasSize) + if (!img) { + this.mapCtx.fillStyle = '#111' + this.mapCtx.fillRect(0, 0, this.canvasSize, this.canvasSize) + return + } + const { u, v, w, h } = this._mapSrc + this.mapCtx.drawImage( + img, + u * img.naturalWidth, + v * img.naturalHeight, + w * img.naturalWidth, + h * img.naturalHeight, + 0, + 0, + this.canvasSize, + this.canvasSize, + ) + this._drawCaptureAreasOnMap() + } + + _capOutlinePoints(cap) { + const tm = cap?.tm + if (!tm || !Array.isArray(tm.a0) || !Array.isArray(tm.a2) || !Array.isArray(tm.center)) return [] + const ax0 = Number(tm.a0[0]) + const az0 = Number(tm.a0[2]) + const ax2 = Number(tm.a2[0]) + const az2 = Number(tm.a2[2]) + const cx = Number(tm.center[0]) + const cz = Number(tm.center[2]) + if (![ax0, az0, ax2, az2, cx, cz].every(Number.isFinite)) return [] + const capType = String(cap?.type || '').toLowerCase() + const points = [] + if (capType === 'sphere' || capType === 'cylinder') { + for (let i = 0; i < 64; i++) { + const t = (2 * Math.PI * i) / 64 + points.push(this.worldToPixel(cx + Math.cos(t) * ax0 + Math.sin(t) * ax2, cz + Math.cos(t) * az0 + Math.sin(t) * az2)) + } + } else if (capType === 'box') { + for (const [sx, sz] of [[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5]]) { + points.push(this.worldToPixel(cx + sx * ax0 + sz * ax2, cz + sx * az0 + sz * az2)) + } + } + return points + } + + _capCircleRadiusPx(cap) { + const rr = Math.max(0, Number(cap?.radius || 0)) + return Math.max(8, Math.round(rr * ((this.canvasSize / this.xRange + this.canvasSize / this.zRange) * 0.5))) + } + + _capIconForLabel(label) { + const key = String(label || '').toLowerCase() + return this._capIconCache?.[`capture_${key}`] || this._capIconCache?.cap_icon || null + } + + _drawCaptureAreasOnMap() { + if (!this.captureAreas.length) return + const labels = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + for (let i = 0; i < this.captureAreas.length; i++) { + const cap = this.captureAreas[i] + const label = labels[i] || String(i + 1) + const [px, py] = this.worldToPixel(Number(cap?.x || 0), Number(cap?.z || 0)) + const outline = this._capOutlinePoints(cap) + let iconSize = 18 + this.mapCtx.strokeStyle = '#fff' + this.mapCtx.lineWidth = CAP_STROKE_PX + this.mapCtx.beginPath() + if (outline.length >= 3) { + this.mapCtx.moveTo(outline[0][0], outline[0][1]) + for (let p = 1; p < outline.length; p++) this.mapCtx.lineTo(outline[p][0], outline[p][1]) + this.mapCtx.closePath() + let area2 = 0 + for (let p = 0; p < outline.length; p++) { + const a = outline[p] + const b = outline[(p + 1) % outline.length] + area2 += a[0] * b[1] - b[0] * a[1] + } + iconSize = Math.max(CAP_ICON_MIN_SIZE, Math.min(this.canvasSize, Math.round(Math.sqrt(Math.abs(area2) * 0.25)))) + } else { + const rp = this._capCircleRadiusPx(cap) + if (px < -rp || py < -rp || px > this.canvasSize + rp || py > this.canvasSize + rp) continue + this.mapCtx.arc(px, py, rp, 0, Math.PI * 2) + iconSize = Math.max(CAP_ICON_MIN_SIZE, Math.min(this.canvasSize, Math.round(Math.sqrt(Math.PI * rp * rp / 2)))) + } + this.mapCtx.stroke() + const img = this._capIconForLabel(label) + if (img?.naturalWidth) { + this.mapCtx.save() + this.mapCtx.globalAlpha = CAP_ICON_ALPHA + this.mapCtx.drawImage(img, px - iconSize / 2, py - iconSize / 2, iconSize, iconSize) + this.mapCtx.restore() + } + } + } + + _capSeriesForIndex(i) { + if (!this._captureState) return null + const letter = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'[i] + return letter ? this._captureState[letter] : null + } + + _interpSeries(series, t, step = false) { + if (!series || !series.length) return null + if (t <= series[0][0]) return series[0][1] + const last = series[series.length - 1] + if (t >= last[0]) return last[1] + for (let i = 1; i < series.length; i++) { + if (series[i][0] >= t) { + if (step) return series[i - 1][1] + const t0 = series[i - 1][0] + const v0 = series[i - 1][1] + const t1 = series[i][0] + const v1 = series[i][1] + const f = t1 === t0 ? 0 : (t - t0) / (t1 - t0) + return v0 + (v1 - v0) * f + } + } + return last[1] + } + + _drawCaptureState(ctx, t) { + if (!this._captureState) return + for (let i = 0; i < this.captureAreas.length; i++) { + const series = this._capSeriesForIndex(i) + if (!series) continue + const val = this._interpSeries(series, t, true) + if (val === null) continue + const frac = Math.min(1, Math.abs(val) / 100) + if (frac <= 0.01) continue + const ownerSlot = val > 0 ? 2 : 1 + const color = ownerSlot === this._winnerSlot ? RC.WIN : RC.LOSE + const cap = this.captureAreas[i] + const [cx, cy] = this.worldToPixel(Number(cap?.x || 0), Number(cap?.z || 0)) + const outline = this._capOutlinePoints(cap) + ctx.save() + ctx.beginPath() + if (outline.length >= 3) { + ctx.moveTo(outline[0][0], outline[0][1]) + for (let p = 1; p < outline.length; p++) ctx.lineTo(outline[p][0], outline[p][1]) + ctx.closePath() + } else { + ctx.arc(cx, cy, this._capCircleRadiusPx(cap), 0, Math.PI * 2) + } + ctx.clip() + const start = -Math.PI / 2 + const end = start + 2 * Math.PI * frac + ctx.globalAlpha = CAP_FILL_ALPHA + ctx.fillStyle = color + ctx.beginPath() + ctx.moveTo(cx, cy) + ctx.arc(cx, cy, this.canvasSize, start, end) + ctx.closePath() + ctx.fill() + ctx.restore() + } + } + + _buildTicketsBar(center) { + if (!this._tickets) return + const winSlot = this._winnerSlot || 1 + this._tkWinSlot = winSlot + this._tkLoseSlot = winSlot === 1 ? 2 : 1 + const bar = document.createElement('div') + bar.className = 'rc-tickets' + bar.innerHTML = '0
0' + center.appendChild(bar) + this.ticketsBar = bar + this._tkWinFill = bar.querySelector('.rc-tk-fill-win') + this._tkLoseFill = bar.querySelector('.rc-tk-fill-lose') + this._tkWinVal = bar.querySelector('.rc-tk-val-win') + this._tkLoseVal = bar.querySelector('.rc-tk-val-lose') + } + + _updateTicketsBar(t) { + if (!this._tickets || !this.ticketsBar) return + const w = Math.max(0, Math.round(this._interpSeries(this._tickets[String(this._tkWinSlot)], t) ?? 0)) + const l = Math.max(0, Math.round(this._interpSeries(this._tickets[String(this._tkLoseSlot)], t) ?? 0)) + const total = w + l + const wPct = total > 0 ? (w / total) * 100 : 50 + this._tkWinFill.style.width = `${wPct.toFixed(1)}%` + this._tkLoseFill.style.width = `${(100 - wPct).toFixed(1)}%` + this._tkWinVal.textContent = String(w) + this._tkLoseVal.textContent = String(l) + this.container.classList.toggle('rc-game-over', l <= 0) + } + + setMode(mode) { + if (mode === this._mode) return + if (mode === 'air' && !this.hasAirMode) return + this._applyCoords(mode) + for (const ent of this.entities) ent._lastHeading = null + this._drawMapToCanvas() + this._computeDeaths() + this.render() + } + + _togglePlay() { + this.playing = !this.playing + this.playBtn.textContent = this.playing ? 'Pause' : 'Play' + if (this.playing) { + if (this.currentTime >= this.tEnd) this.currentTime = this.tStart + this.lastFrameTime = performance.now() + } + } + + _tick(now) { + if (this.playing) { + const dt = now - this.lastFrameTime + this.lastFrameTime = now + this.currentTime += dt * this.speed + if (this.currentTime >= this.tEnd) { + this.currentTime = this.tEnd + this.playing = false + this.playBtn.textContent = 'Play' + } + } + this.render() + this._updateTicketsBar(this.currentTime) + this._updateControls() + if (!this._lastPanelUpdate || now - this._lastPanelUpdate > 250) { + this._updatePanelDeathStates() + this._updateBattleLog() + this._lastPanelUpdate = now + } + this.animFrameId = requestAnimationFrame(this._tick) + } + + _updateControls() { + const frac = this.tEnd > this.tStart ? (this.currentTime - this.tStart) / (this.tEnd - this.tStart) : 0 + const clamped = Math.max(0, Math.min(1, frac)) + this.scrubber.value = Math.round(clamped * 1000) + this.scrubber.style.setProperty('--rc-progress', `${(clamped * 100).toFixed(1)}%`) + const cur = Math.max(0, (this.currentTime - this.tStart) / 1000) + const total = Math.max(0, (this.tEnd - this.tStart) / 1000) + const fmt = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}` + this.timeDisplay.textContent = `${fmt(cur)} / ${fmt(total)}` + } + + _updateCanvasHighlight() { + if (!this._mouseOnCanvas) return + let bestId = null + let bestDist = 400 + for (const ent of this.entities) { + if (ent.playerId === 0 || this._isEntityGone(ent, this.currentTime)) continue + const pos = this._entityScreenPos(ent, this.currentTime) + if (!pos) continue + const dx = pos[0] - this._mouseX + const dy = pos[1] - this._mouseY + const dist = dx * dx + dy * dy + if (dist < bestDist) { + bestDist = dist + bestId = ent.playerId + } + } + this._setHighlight(bestId) + } + + render() { + const ctx = this.ctx + if (!ctx || !this.mapCanvas) return + const t = this.currentTime + this._updateCanvasHighlight() + ctx.drawImage(this.mapCanvas, 0, 0) + this._drawCaptureState(ctx, t) + this._drawTrails(ctx, t) + this._drawDamageLines(ctx, t) + this._drawKillLines(ctx, t) + this._drawEntities(ctx, t) + } + + _drawTrails(ctx, time) { + for (const ent of this.entities) { + if (this._isEntityGone(ent, time)) continue + const endT = ent.deathTime !== null ? Math.min(time, ent.deathTime) : time + const trailLen = ent.type === 'ground' ? RC.TRAIL_MS : ent.type === 'aircraft' ? (this._mode === 'air' ? RC.TRAIL_MS : RC.AIR_TRAIL_MS) : RC.DRONE_TRAIL_MS + const tMin = endT - trailLen + const baseColor = ent.isWinner ? RC.WIN_TRAIL : RC.LOSE_TRAIL + ctx.lineWidth = ent.type === 'ground' ? 2 : (this._mode === 'air' ? 2 : 1.5) + ctx.lineCap = 'round' + if (this._mode === 'air' && ent.type === 'aircraft') { + let prev = null + for (let tt = Math.max(tMin, ent.times[0]); tt <= Math.min(endT, ent.times[ent.times.length - 1]); tt += 200) { + const pos = this.getPositionAtTime(ent, tt) + if (!pos) continue + if (prev) { + const age = time - tt + const alpha = Math.max(0.08, 1 - age / trailLen) + ctx.strokeStyle = `${baseColor}${alpha.toFixed(2)})` + ctx.beginPath() + ctx.moveTo(prev[0], prev[1]) + ctx.lineTo(pos[0], pos[1]) + ctx.stroke() + } + prev = pos + } + continue + } + let prev = null + for (let i = 0; i < ent.times.length; i++) { + if (ent.times[i] < tMin) continue + if (ent.times[i] > endT) break + const pos = this.worldToPixel(ent.positions[i * 2], ent.positions[i * 2 + 1]) + if (prev) { + const age = time - ent.times[i] + const alpha = Math.max(0.08, 1 - age / trailLen) + ctx.strokeStyle = `${baseColor}${alpha.toFixed(2)})` + ctx.beginPath() + ctx.moveTo(prev[0], prev[1]) + ctx.lineTo(pos[0], pos[1]) + ctx.stroke() + } + prev = pos + } + } + } + + _drawDamageLines(ctx, time) { + for (const dm of this.data.damages || []) { + const age = time - dm.time + if (age < 0 || age > RC.DMG_TTL) continue + const attacker = this.entities.find((e) => e.playerId === dm.offenderId) + const victim = this.entities.find((e) => e.playerId === dm.offendedId) + if (!attacker || !victim) continue + const aPos = this.getPositionAtTime(attacker, dm.time) + const vPos = this.getPositionAtTime(victim, dm.time) + if (!aPos || !vPos) continue + const alpha = Math.max(0, 1 - age / RC.DMG_TTL) + ctx.globalAlpha = alpha * 0.4 + ctx.strokeStyle = '#ffcc44' + ctx.lineWidth = 1 + ctx.setLineDash([3, 4]) + ctx.beginPath() + ctx.moveTo(aPos[0], aPos[1]) + ctx.lineTo(vPos[0], vPos[1]) + ctx.stroke() + ctx.setLineDash([]) + ctx.globalAlpha = 1 + } + } + + _drawKillLines(ctx, time) { + for (const k of this.data.kills || []) { + const age = time - k.time + if (age < 0 || age > RC.KILL_TTL || !k.killerPos || !k.victimPos) continue + const alpha = Math.max(0, 1 - age / RC.KILL_TTL) + const [kx, ky] = this.worldToPixel(k.killerPos.x, k.killerPos.z) + const [vx, vy] = this.worldToPixel(k.victimPos.x, k.victimPos.z) + ctx.globalAlpha = alpha * 0.6 + ctx.strokeStyle = '#ff3333' + ctx.lineWidth = 1.5 + ctx.setLineDash([4, 3]) + ctx.beginPath() + ctx.moveTo(kx, ky) + ctx.lineTo(vx, vy) + ctx.stroke() + ctx.setLineDash([]) + ctx.globalAlpha = alpha * 0.9 + ctx.strokeStyle = '#ff3333' + ctx.lineWidth = 2 + ctx.beginPath() + ctx.moveTo(vx - 5, vy - 5) + ctx.lineTo(vx + 5, vy + 5) + ctx.moveTo(vx + 5, vy - 5) + ctx.lineTo(vx - 5, vy + 5) + ctx.stroke() + if (k.weapon && alpha > 0.4) { + ctx.font = '600 9px system-ui, sans-serif' + ctx.fillStyle = `rgba(255,230,100,${(alpha * 0.85).toFixed(2)})` + ctx.fillText(k.weapon, (kx + vx) / 2 + 6, (ky + vy) / 2 - 6) + } + ctx.globalAlpha = 1 + } + } + + _getTintedIcon(iconKey, color, size) { + const cacheKey = `${iconKey}_${color}_${size}` + if (!this._tintCache) this._tintCache = {} + if (this._tintCache[cacheKey]) return this._tintCache[cacheKey] + const img = this._iconCache?.[iconKey] + if (!img?.naturalWidth) return null + const c = document.createElement('canvas') + c.width = size + c.height = size + const cx = c.getContext('2d') + const [dx, dy, dw, dh] = this._containedImageRect(img, size) + cx.drawImage(img, dx, dy, dw, dh) + cx.globalCompositeOperation = 'source-atop' + cx.fillStyle = color + cx.fillRect(0, 0, size, size) + cx.globalCompositeOperation = 'source-over' + this._tintCache[cacheKey] = c + return c + } + + _containedImageRect(img, size) { + const ratio = img.naturalWidth && img.naturalHeight ? img.naturalWidth / img.naturalHeight : 1 + let w = size + let h = size + if (ratio > 1) h = size / ratio + else w = size * ratio + return [(size - w) / 2, (size - h) / 2, w, h] + } + + _drawContainedIcon(ctx, img, x, y, size) { + const [dx, dy, dw, dh] = this._containedImageRect(img, size) + ctx.drawImage(img, x - size / 2 + dx, y - size / 2 + dy, dw, dh) + } + + _drawEntities(ctx, time) { + const hl = this.highlightedPlayerId + for (const ent of this.entities) { + if (!this._isEntityDead(ent, time) || this._isEntityGone(ent, time)) continue + const pos = ent.deathPos + if (!pos) continue + const fade = 1 - (time - ent.deathTime) / RC.GHOST_TTL + ctx.globalAlpha = Math.max(0, fade * 0.5) + ctx.fillStyle = '#333' + const r = ent.type === 'ground' ? RC.DOT_R : ent.type === 'aircraft' ? RC.AIR_R : RC.DRONE_R + ctx.beginPath() + ctx.arc(pos[0], pos[1], r, 0, Math.PI * 2) + ctx.fill() + ctx.globalAlpha = 1 + } + for (const ent of this.entities) { + if (this._isEntityDead(ent, time)) continue + const pos = this.getPositionAtTime(ent, time) + if (!pos) continue + const [px, py] = pos + if (px < -20 || py < -20 || px > this.canvasSize + 20 || py > this.canvasSize + 20) continue + let alpha = 1 + if (hl !== null && ent.playerId !== hl && ent.playerId !== 0) alpha = 0.25 + const color = ent.isWinner ? RC.WIN : RC.LOSE + const iconSize = ent.type === 'ground' ? 12 : ent.type === 'aircraft' ? 20 : 14 + const iconImg = this._iconCache?.[ent._canvasIconKey] + ctx.globalAlpha = alpha + if (hl === ent.playerId && ent.playerId !== 0) { + const hr = iconSize / 2 + 5 + ctx.strokeStyle = '#fff' + ctx.lineWidth = 2 + ctx.beginPath() + ctx.arc(px, py, hr, 0, Math.PI * 2) + ctx.stroke() + ctx.strokeStyle = color + ctx.lineWidth = 1 + ctx.beginPath() + ctx.arc(px, py, hr - 2, 0, Math.PI * 2) + ctx.stroke() + } + if (iconImg?.naturalWidth) { + const tinted = this._getTintedIcon(ent._canvasIconKey, color, iconSize) + const drawSrc = tinted || iconImg + if (ent.type === 'aircraft' || ent.type === 'drone') { + const heading = this.getHeadingAtTime(ent, time) + if (heading !== null) { + ctx.save() + ctx.translate(px, py) + ctx.rotate(heading) + if (tinted) ctx.drawImage(drawSrc, -iconSize / 2, -iconSize / 2) + else this._drawContainedIcon(ctx, drawSrc, 0, 0, iconSize) + ctx.restore() + } else if (tinted) { + ctx.drawImage(drawSrc, px - iconSize / 2, py - iconSize / 2) + } else { + this._drawContainedIcon(ctx, drawSrc, px, py, iconSize) + } + } else if (tinted) { + ctx.drawImage(drawSrc, px - iconSize / 2, py - iconSize / 2) + } else { + this._drawContainedIcon(ctx, drawSrc, px, py, iconSize) + } + } else { + const r = ent.type === 'ground' ? RC.DOT_R : ent.type === 'aircraft' ? RC.AIR_R : RC.DRONE_R + ctx.fillStyle = color + ctx.beginPath() + ctx.arc(px, py, r, 0, Math.PI * 2) + ctx.fill() + ctx.strokeStyle = 'rgba(0,0,0,0.5)' + ctx.lineWidth = 1 + ctx.stroke() + } + ctx.globalAlpha = 1 + } + } + + destroy() { + if (this.animFrameId) cancelAnimationFrame(this.animFrameId) + this.animFrameId = null + this.container.innerHTML = '' + } +} + +export default function ReplayCanvasPanel({ gameId }) { + const containerRef = useRef(null) + const engineRef = useRef(null) + const [state, setState] = useState({ status: 'loading', error: '', hasAirMode: false, mode: 'ground' }) + + useEffect(() => { + if (!gameId) return undefined + const controller = new AbortController() + let disposed = false + setState({ status: 'loading', error: '', hasAirMode: false, mode: 'ground' }) + if (engineRef.current) { + engineRef.current.destroy() + engineRef.current = null + } + + async function loadReplay() { + try { + const response = await fetch(`/api/tss/games/${encodeURIComponent(gameId)}/replay-canvas`, { + signal: controller.signal, + headers: { Accept: 'application/json' }, + }) + const body = await response.json().catch(() => null) + if (!response.ok) { + const reason = body?.reason || body?.error || `Replay request failed with ${response.status}` + throw new Error(reason) + } + if (!body?.entities || !body?.players) throw new Error('Invalid replay data') + if (disposed || !containerRef.current) return + const engine = new ReplayCanvasEngine(containerRef.current, body) + engineRef.current = engine + await engine.init() + if (disposed) { + engine.destroy() + return + } + setState({ + status: 'ready', + error: '', + hasAirMode: engine.hasAirMode, + mode: engine._mode, + }) + } catch (error) { + if (controller.signal.aborted || disposed) return + setState({ status: 'error', error: error.message || 'Replay unavailable', hasAirMode: false, mode: 'ground' }) + } + } + + loadReplay() + return () => { + disposed = true + controller.abort() + if (engineRef.current) { + engineRef.current.destroy() + engineRef.current = null + } + } + }, [gameId]) + + function switchMode(mode) { + const engine = engineRef.current + if (!engine) return + engine.setMode(mode) + setState((current) => ({ ...current, mode: engine._mode })) + } + + return ( +
+
+

Replay

+ {state.status === 'ready' && state.hasAirMode ? ( +
+ + +
+ ) : null} +
+ + {state.status === 'loading' ? ( +
Loading replay
+ ) : null} + {state.status === 'error' ? ( +
{state.error}
+ ) : null} +
+
+ ) +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index dd3218a..1a83570 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -80,6 +80,8 @@ --color-success: #00f2ff; --color-warning: #f4ee3e; --color-danger: #e82517; + --color-win: #1a9e4b; + --color-loss: #e82517; } :root[data-theme="dark"] { @@ -102,6 +104,8 @@ --color-success: #58f0f5; --color-warning: #f4ee3e; --color-danger: #ff6a5f; + --color-win: #46d17e; + --color-loss: #ff6a5f; color-scheme: dark; } @@ -115,6 +119,32 @@ box-sizing: border-box; } +@font-face { + font-family: "skyquakesymbols"; + src: url("/fonts/symbols_skyquake.ttf") format("truetype"); + font-display: swap; +} + +/* War Thunder vehicle names carry country/event glyphs (▀ ▄ ◊ + PUA markers) + that only the skyquake symbol font renders; list it first, then fall back. */ +.vehicle-name { + font-family: + "skyquakesymbols", "SF Pro Rounded", "SF Pro Text", -apple-system, BlinkMacSystemFont, + "Segoe UI", Inter, ui-sans-serif, system-ui, sans-serif; +} + +.vehicle-dead { + opacity: 0.45; + color: var(--color-danger); +} + +/* Chat/battle logs: monospace for column alignment, with skyquake before the + generic keyword so country/event glyphs still resolve per-character. */ +.log-mono { + font-family: + ui-monospace, SFMono-Regular, Menlo, Consolas, "skyquakesymbols", monospace; +} + body { margin: 0; min-width: 320px; @@ -436,6 +466,443 @@ h3 { z-index: 1; } +.replay-card { + overflow: hidden; +} + +.replay-status { + display: flex; + min-height: 120px; + align-items: center; + justify-content: center; + border: 1px solid var(--color-border); + border-radius: 8px; + background: var(--color-surface); + color: var(--color-text-soft); + font-size: 0.9rem; + font-weight: 600; +} + +.replay-status-error { + color: var(--color-danger); +} + +.rc-mode-toggle { + display: none; + gap: 2px; + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-surface); + padding: 2px; +} + +.rc-mode-toggle.visible { + display: inline-flex; +} + +.rc-mode-btn { + border: 0; + border-radius: 4px; + background: transparent; + color: var(--color-text-soft); + cursor: pointer; + font: inherit; + font-size: 0.78rem; + font-weight: 700; + padding: 4px 14px; + transition: background-color 120ms ease, color 120ms ease; +} + +.rc-mode-btn:hover { + color: var(--color-text); +} + +.rc-mode-btn.active { + background: var(--color-fury-cyan); + color: var(--color-fury-white); +} + +.rc-layout { + display: grid; + grid-template-columns: minmax(160px, 200px) min(720px, 60vh, 92vw) minmax(160px, 200px); + gap: 0.5rem; + align-items: start; + justify-content: center; + margin: 0 auto; +} + +@media (max-width: 1120px) { + .rc-layout { + grid-template-columns: 1fr; + } + + .rc-panel { + max-height: 200px; + } +} + +.rc-panel { + overflow-y: auto; + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 8px; + background: #15100b; + color: #fff2e6; + font-size: 0.78rem; +} + +.rc-panel-head { + position: sticky; + z-index: 1; + top: 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(21, 16, 11, 0.95); + padding: 0.5rem 0.6rem 0.4rem; + text-align: center; +} + +.rc-panel-label { + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.rc-clan-tag { + font-family: "skyquakesymbols", monospace; + font-size: 0.85rem; + letter-spacing: 0; + text-transform: none; +} + +.rc-panel-list { + padding: 0.25rem 0; +} + +.rc-row { + display: flex; + align-items: center; + gap: 0.45rem; + border-left: 2px solid transparent; + cursor: pointer; + padding: 0.35rem 0.6rem; + transition: background-color 120ms ease, opacity 300ms ease; +} + +.rc-row:hover, +.rc-row.rc-hl { + background: rgba(255, 255, 255, 0.06); +} + +.rc-panel-win .rc-row.rc-hl { + border-left-color: rgba(0, 200, 0, 0.5); +} + +.rc-panel-lose .rc-row.rc-hl { + border-left-color: rgba(220, 30, 30, 0.5); +} + +.rc-row.rc-dead { + opacity: 0.4; +} + +.rc-row.rc-gone { + cursor: default; + opacity: 0.2; +} + +.rc-row.rc-dead:hover, +.rc-row.rc-gone:hover { + background: transparent; +} + +.rc-type-icon { + width: 28px; + height: 22px; + flex-shrink: 0; + object-fit: contain; + opacity: 0.6; +} + +.rc-row-info { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; +} + +.rc-row-name { + overflow: hidden; + color: rgba(255, 255, 255, 0.86); + font-size: 0.76rem; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rc-row-veh { + overflow: hidden; + color: rgba(255, 255, 255, 0.42); + font-size: 0.65rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rc-row-status { + width: 16px; + flex-shrink: 0; + color: rgba(255, 255, 255, 0.4); + font-size: 0.7rem; + font-weight: 800; + text-align: center; +} + +.rc-center { + display: flex; + min-width: 0; + flex-direction: column; + align-items: center; +} + +.rc-canvas { + width: 100%; + height: auto; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + background: #111; + cursor: crosshair; +} + +.rc-tickets { + display: flex; + width: 100%; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; + color: rgba(255, 255, 255, 0.8); + font-size: 0.7rem; +} + +.rc-tk-val { + min-width: 2.6rem; + font-variant-numeric: tabular-nums; + font-weight: 700; +} + +.rc-tk-val-win { + color: #5cdf5c; + text-align: right; +} + +.rc-tk-val-lose { + color: #e85555; + text-align: left; +} + +.rc-tk-track { + display: flex; + height: 10px; + flex: 1 1 auto; + overflow: hidden; + border-radius: 5px; + background: rgba(255, 255, 255, 0.1); +} + +.rc-tk-fill { + height: 100%; + transition: width 100ms linear; +} + +.rc-tk-fill-win { + background: #2a8f2a; +} + +.rc-tk-fill-lose { + background: #b22020; +} + +.rc-game-over .rc-tk-track { + animation: rcTkGlow 1.4s ease-in-out infinite; +} + +.rc-game-over .rc-tk-val-win, +.rc-game-over .rc-panel-win .rc-panel-label { + animation: rcTkTextGlow 1.4s ease-in-out infinite; +} + +@keyframes rcTkGlow { + 0%, + 100% { + box-shadow: 0 0 2px 0 rgba(92, 223, 92, 0.25); + } + + 50% { + box-shadow: 0 0 7px 1px rgba(92, 223, 92, 0.55); + } +} + +@keyframes rcTkTextGlow { + 0%, + 100% { + text-shadow: 0 0 3px rgba(92, 223, 92, 0.35); + } + + 50% { + text-shadow: 0 0 9px rgba(92, 223, 92, 0.85); + } +} + +.rc-controls { + display: flex; + width: 100%; + align-items: center; + gap: 0.4rem; + padding: 0.45rem 0; +} + +.rc-btn { + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 4px; + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.7); + cursor: pointer; + font: inherit; + font-size: 0.72rem; + font-weight: 700; + padding: 0.25rem 0.45rem; + transition: background-color 120ms ease, color 120ms ease; +} + +.rc-btn:hover { + background: rgba(255, 255, 255, 0.14); + color: rgba(255, 255, 255, 0.9); +} + +.rc-play { + min-width: 54px; +} + +.rc-speeds { + display: flex; + gap: 1px; +} + +.rc-sp.active { + border-color: rgba(255, 255, 255, 0.18); + background: rgba(255, 255, 255, 0.16); + color: #fff2e6; +} + +.rc-scrub { + box-sizing: content-box; + height: 6px; + flex: 1; + padding: 6px 0; + border-radius: 3px; + appearance: none; + background: linear-gradient(to right, #2a6e2a var(--rc-progress, 0%), rgba(255, 255, 255, 0.14) var(--rc-progress, 0%)); + background-clip: content-box; + cursor: pointer; + outline: none; +} + +.rc-scrub::-webkit-slider-thumb { + width: 14px; + height: 14px; + border-radius: 50%; + -webkit-appearance: none; + appearance: none; + background: #90ee90; + box-shadow: 0 0 3px rgba(0, 0, 0, 0.5); + cursor: pointer; +} + +.rc-scrub::-moz-range-thumb { + width: 14px; + height: 14px; + border: 0; + border-radius: 50%; + background: #90ee90; + box-shadow: 0 0 3px rgba(0, 0, 0, 0.5); + cursor: pointer; +} + +.rc-time { + min-width: 65px; + color: rgba(255, 255, 255, 0.48); + font-size: 0.65rem; + font-variant-numeric: tabular-nums; + text-align: right; + white-space: nowrap; +} + +.rc-log-wrap { + width: 100%; + margin-top: 0.4rem; +} + +.rc-log { + overflow-y: auto; + max-height: 130px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + background: rgba(255, 255, 255, 0.05); + padding: 0.6rem 0.8rem; + scrollbar-color: rgba(255, 255, 255, 0.14) transparent; + scrollbar-width: thin; +} + +.rc-log:empty::after { + color: rgba(255, 255, 255, 0.22); + content: "Waiting for events..."; + font-size: 0.7rem; + font-style: italic; +} + +.rc-ev { + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + color: rgba(255, 255, 255, 0.74); + font-size: 0.72rem; + padding: 0.2rem 0; +} + +.rc-ev:last-child { + border-bottom: 0; +} + +.rc-ev-damage { + opacity: 0.6; +} + +.rc-ev-time { + display: inline-block; + width: 32px; + margin-right: 0.4rem; + color: rgba(255, 255, 255, 0.34); + font-size: 0.65rem; + font-variant-numeric: tabular-nums; +} + +.rc-ev-win { + color: #5cdf5c; + font-weight: 700; +} + +.rc-ev-lose { + color: #e85555; + font-weight: 700; +} + +.rc-ev-action { + color: rgba(255, 255, 255, 0.48); + font-size: 0.68rem; +} + +.rc-ev-weapon { + margin-left: 0.3rem; + color: rgba(255, 255, 255, 0.34); + font-size: 0.62rem; +} + @keyframes scrollPulse { 0% { transform: translateY(-100%); diff --git a/scripts/verify_game_detail.sh b/scripts/verify_game_detail.sh new file mode 100755 index 0000000..7f324d5 --- /dev/null +++ b/scripts/verify_game_detail.sh @@ -0,0 +1,217 @@ +#!/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