ai generated solutions to our ai generated problems

This commit is contained in:
2026-06-19 23:36:45 +01:00
13 changed files with 3003 additions and 211 deletions
+6
View File
@@ -73,11 +73,17 @@ The server serves `/health`
locally and only proxies the API routes used by the app: locally and only proxies the API routes used by the app:
- `GET /api/tss/leaderboard/teams?limit=1..100` - `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/resolve?name=...`
- `GET /api/tss/teams/:team` - `GET /api/tss/teams/:team`
- `GET /api/tss/teams/:team/history` - `GET /api/tss/teams/:team/history`
- `GET /api/tss/teams/:team/games` - `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 proxy blocks cross-origin/API-navigation requests, strips CORS headers from
the upstream response, rate limits callers, and caches successful GET responses the upstream response, rate limits callers, and caches successful GET responses
briefly so public page traffic does not hammer the upstream API. All responses briefly so public page traffic does not hammer the upstream API. All responses
+19 -1
View File
@@ -4,18 +4,36 @@ Rust backend API service for Toothless' TSS Bot.
It reads two SQLite databases: 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` - `TSS_TEAMS_DB` for `tss_teams.db`
- `BACKEND_HOST` bind host, default `127.0.0.1` - `BACKEND_HOST` bind host, default `127.0.0.1`
- `BACKEND_ALLOWED_ORIGINS` comma-separated browser origins allowed by CORS - `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. 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 `<cdk>.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: It currently exposes:
- `GET /health` - `GET /health`
- `GET /api/tss/leaderboard/teams?limit=100` - `GET /api/tss/leaderboard/teams?limit=100`
- `GET /api/tss/leaderboard/players?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/resolve?name=...`
- `GET /api/tss/teams/search?q=...&limit=10` - `GET /api/tss/teams/search?q=...&limit=10`
- `GET /api/tss/teams/:team` - `GET /api/tss/teams/:team`
+413 -140
View File
@@ -7,7 +7,7 @@ use axum::{
}; };
use rusqlite::{params, Connection, OptionalExtension}; use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::{json, Value};
use std::{ use std::{
collections::{BTreeMap, HashMap}, collections::{BTreeMap, HashMap},
env, fs, env, fs,
@@ -29,6 +29,8 @@ struct AppState {
battles_db: PathBuf, battles_db: PathBuf,
teams_db: PathBuf, teams_db: PathBuf,
leaderboard_cache: Mutex<Option<CachedLeaderboard>>, leaderboard_cache: Mutex<Option<CachedLeaderboard>>,
vehicle_names: HashMap<String, HashMap<String, String>>,
vehicle_icons: HashMap<String, String>,
} }
struct CachedLeaderboard { struct CachedLeaderboard {
@@ -77,6 +79,11 @@ struct LimitQuery {
limit: Option<u32>, limit: Option<u32>,
} }
#[derive(Deserialize)]
struct LangQuery {
lang: Option<String>,
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct ResolveQuery { struct ResolveQuery {
name: String, name: String,
@@ -232,6 +239,13 @@ struct GameResponse {
participants: Vec<GameParticipant>, participants: Vec<GameParticipant>,
} }
#[derive(Serialize)]
struct GameLogsResponse {
chat_log: Vec<String>,
battle_log: Vec<String>,
event_log: Value,
}
#[derive(Serialize)] #[derive(Serialize)]
struct GameRow { struct GameRow {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@@ -245,6 +259,11 @@ struct GameRow {
player_count: i64, player_count: i64,
winning_team: Option<String>, winning_team: Option<String>,
losing_team: Option<String>, losing_team: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
tournament_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
duration: Option<f64>,
draw: bool,
stats: GameStats, stats: GameStats,
} }
@@ -274,9 +293,17 @@ struct GameParticipant {
struct GamePlayer { struct GamePlayer {
uid: String, uid: String,
nick: Option<String>, nick: Option<String>,
vehicles: Vec<Vehicle>,
stats: GameStats, stats: GameStats,
} }
#[derive(Serialize)]
struct Vehicle {
cdk: String,
name: String,
icon: String,
}
#[derive(Serialize)] #[derive(Serialize)]
struct PlayerSearchResponse { struct PlayerSearchResponse {
players: Vec<PlayerRef>, players: Vec<PlayerRef>,
@@ -331,7 +358,10 @@ struct TeamRecord {
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {
load_root_env(); load_root_env();
tracing_subscriber::fmt() 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(); .init();
let host = env_ip("BACKEND_HOST").unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST)); let host = env_ip("BACKEND_HOST").unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST));
@@ -342,7 +372,20 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
battles_db: resolve_db_path("TSS_BATTLES_DB", "tss_battles.db"), battles_db: resolve_db_path("TSS_BATTLES_DB", "tss_battles.db"),
teams_db: resolve_db_path("TSS_TEAMS_DB", "tss_teams.db"), teams_db: resolve_db_path("TSS_TEAMS_DB", "tss_teams.db"),
leaderboard_cache: Mutex::new(None), 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()); spawn_leaderboard_refresh(state.clone());
let app = Router::new() let app = Router::new()
@@ -351,6 +394,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.route("/api/tss/leaderboard/players", get(player_leaderboard)) .route("/api/tss/leaderboard/players", get(player_leaderboard))
.route("/api/tss/games/recent", get(recent_games)) .route("/api/tss/games/recent", get(recent_games))
.route("/api/tss/games/{session_id}", get(game_detail)) .route("/api/tss/games/{session_id}", get(game_detail))
.route("/api/tss/games/{session_id}/logs", get(game_logs))
.route("/api/tss/teams/resolve", get(resolve_team)) .route("/api/tss/teams/resolve", get(resolve_team))
.route("/api/tss/teams/search", get(search_teams)) .route("/api/tss/teams/search", get(search_teams))
.route("/api/tss/teams/{team}", get(team_detail)) .route("/api/tss/teams/{team}", get(team_detail))
@@ -556,15 +600,58 @@ async fn recent_games(
async fn game_detail( async fn game_detail(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(session_id): Path<String>, Path(session_id): Path<String>,
Query(query): Query<LangQuery>,
) -> ApiResult<GameResponse> { ) -> ApiResult<GameResponse> {
let session_id = validate_session_id(&session_id)?; 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 battles_conn = open_db(&state.battles_db)?;
let game = game_for(&battles_conn, session_id)? let game = game_for(&battles_conn, session_id)?
.ok_or_else(|| ApiError::not_found("Game not found"))?; .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 })) Ok(Json(GameResponse { game, participants }))
} }
async fn game_logs(
State(state): State<Arc<AppState>>,
Path(session_id): Path<String>,
) -> ApiResult<GameLogsResponse> {
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<String>, Option<String>, Option<String>)> = 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<String>| -> Vec<String> {
s.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or_default()
};
let parse_event_log = |s: Option<String>| -> 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( async fn resolve_team(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Query(query): Query<ResolveQuery>, Query(query): Query<ResolveQuery>,
@@ -1279,30 +1366,48 @@ fn period_history_for(conn: &Connection, team_name: &str) -> Result<Vec<PeriodHi
} }
fn games_for(conn: &Connection, team_name: &str) -> Result<Vec<GameRow>, ApiError> { fn games_for(conn: &Connection, team_name: &str) -> Result<Vec<GameRow>, ApiError> {
// Reduce per-vehicle duplicate rows to one row per UID (MAX) before summing.
let mut stmt = conn let mut stmt = conn
.prepare( .prepare(
"SELECT "WITH per_player AS (
p.session_id, SELECT session_id, UID,
COALESCE(m.endtime_unix, MAX(p.endtime_unix), 0) AS timestamp, 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_name,
m.mission_mode, m.mission_mode,
CASE m.tournament_name,
WHEN MAX(CASE WHEN p.victor_bool = 'Win' THEN 1 ELSE 0 END) = 1 THEN 'Win' m.duration,
ELSE 'Loss' COALESCE(m.draw, 0),
END AS result, CASE WHEN MAX(pp.won) = 1 THEN 'Win' ELSE 'Loss' END AS result,
COUNT(DISTINCT p.UID), COUNT(*),
COALESCE(SUM(p.ground_kills), 0), COALESCE(SUM(pp.ground_kills), 0),
COALESCE(SUM(p.air_kills), 0), COALESCE(SUM(pp.air_kills), 0),
COALESCE(SUM(p.assists), 0), COALESCE(SUM(pp.assists), 0),
COALESCE(SUM(p.captures), 0), COALESCE(SUM(pp.captures), 0),
COALESCE(SUM(p.deaths), 0), COALESCE(SUM(pp.deaths), 0),
COALESCE(SUM(p.score), 0), COALESCE(SUM(pp.score), 0),
COALESCE(SUM(p.missile_evades), 0), COALESCE(SUM(pp.missile_evades), 0),
COALESCE(SUM(p.shell_interceptions), 0), COALESCE(SUM(pp.shell_interceptions), 0),
COALESCE(SUM(p.team_kills_stat), 0), COALESCE(SUM(pp.team_kills_stat), 0),
(SELECT pg.team_name (SELECT pg.team_name
FROM player_games_hist pg 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 IS NOT NULL
AND pg.team_name != '' AND pg.team_name != ''
AND pg.victor_bool = 'Win' AND pg.victor_bool = 'Win'
@@ -1311,17 +1416,16 @@ fn games_for(conn: &Connection, team_name: &str) -> Result<Vec<GameRow>, ApiErro
LIMIT 1), LIMIT 1),
(SELECT pg.team_name (SELECT pg.team_name
FROM player_games_hist pg 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 IS NOT NULL
AND pg.team_name != '' AND pg.team_name != ''
AND pg.victor_bool = 'Loss' AND pg.victor_bool = 'Loss'
GROUP BY pg.team_name COLLATE NOCASE GROUP BY pg.team_name COLLATE NOCASE
ORDER BY COUNT(DISTINCT pg.UID) DESC, pg.team_name COLLATE NOCASE ORDER BY COUNT(DISTINCT pg.UID) DESC, pg.team_name COLLATE NOCASE
LIMIT 1) LIMIT 1)
FROM player_games_hist p FROM per_player pp
LEFT JOIN match_summary m ON m.session_id = p.session_id LEFT JOIN match_summary m ON m.session_id = pp.session_id
WHERE p.team_name = ?1 COLLATE NOCASE GROUP BY pp.session_id
GROUP BY p.session_id
ORDER BY timestamp DESC ORDER BY timestamp DESC
LIMIT 100", LIMIT 100",
) )
@@ -1333,6 +1437,7 @@ fn games_for(conn: &Connection, team_name: &str) -> Result<Vec<GameRow>, ApiErro
let timestamp: i64 = row.get(1)?; let timestamp: i64 = row.get(1)?;
let map_name: Option<String> = row.get(2)?; let map_name: Option<String> = row.get(2)?;
let mission_mode: Option<String> = row.get(3)?; let mission_mode: Option<String> = row.get(3)?;
let draw_int: i64 = row.get(6)?;
Ok(GameRow { Ok(GameRow {
team_name: None, team_name: None,
session_id, session_id,
@@ -1340,20 +1445,23 @@ fn games_for(conn: &Connection, team_name: &str) -> Result<Vec<GameRow>, ApiErro
endtime_unix: timestamp, endtime_unix: timestamp,
map_name, map_name,
mission_mode, mission_mode,
result: row.get(4)?, result: row.get(7)?,
player_count: row.get(5)?, player_count: row.get(8)?,
winning_team: row.get(15)?, winning_team: row.get(18)?,
losing_team: row.get(16)?, losing_team: row.get(19)?,
tournament_name: row.get(4)?,
duration: row.get(5)?,
draw: draw_int != 0,
stats: GameStats { stats: GameStats {
ground_kills: row.get(6)?, ground_kills: row.get(9)?,
air_kills: row.get(7)?, air_kills: row.get(10)?,
assists: row.get(8)?, assists: row.get(11)?,
captures: row.get(9)?, captures: row.get(12)?,
deaths: row.get(10)?, deaths: row.get(13)?,
score: row.get(11)?, score: row.get(14)?,
missile_evades: row.get(12)?, missile_evades: row.get(15)?,
shell_interceptions: row.get(13)?, shell_interceptions: row.get(16)?,
team_kills_stat: row.get(14)?, team_kills_stat: row.get(17)?,
}, },
}) })
}) })
@@ -1364,36 +1472,35 @@ fn games_for(conn: &Connection, team_name: &str) -> Result<Vec<GameRow>, ApiErro
} }
fn recent_games_for(conn: &Connection, limit: i64) -> Result<Vec<GameRow>, ApiError> { fn recent_games_for(conn: &Connection, limit: i64) -> Result<Vec<GameRow>, 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 let mut stmt = conn
.prepare( .prepare(
"WITH recent AS ( "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 FROM player_games_hist
WHERE team_name IS NOT NULL AND team_name != '' WHERE team_name IS NOT NULL AND team_name != ''
GROUP BY session_id GROUP BY session_id
ORDER BY timestamp DESC ORDER BY timestamp DESC
LIMIT ?1 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 SELECT
r.team_name,
r.session_id, r.session_id,
COALESCE(m.endtime_unix, r.timestamp, 0) AS timestamp, COALESCE(m.endtime_unix, r.timestamp, 0) AS timestamp,
m.mission_name, m.mission_name,
m.mission_mode, m.mission_mode,
CASE m.tournament_name,
WHEN MAX(CASE WHEN p.victor_bool = 'Win' THEN 1 ELSE 0 END) = 1 THEN 'Win' m.duration,
ELSE 'Loss' COALESCE(m.draw, 0),
END AS result, (SELECT MAX(players) FROM team_size ts WHERE ts.session_id = r.session_id) AS player_count,
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),
(SELECT pg.team_name (SELECT pg.team_name
FROM player_games_hist pg FROM player_games_hist pg
WHERE pg.session_id = r.session_id WHERE pg.session_id = r.session_id
@@ -1413,10 +1520,7 @@ fn recent_games_for(conn: &Connection, limit: i64) -> Result<Vec<GameRow>, ApiEr
ORDER BY COUNT(DISTINCT pg.UID) DESC, pg.team_name COLLATE NOCASE ORDER BY COUNT(DISTINCT pg.UID) DESC, pg.team_name COLLATE NOCASE
LIMIT 1) LIMIT 1)
FROM recent r 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 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 ORDER BY timestamp DESC
LIMIT ?1", LIMIT ?1",
) )
@@ -1424,28 +1528,32 @@ fn recent_games_for(conn: &Connection, limit: i64) -> Result<Vec<GameRow>, ApiEr
let rows = stmt let rows = stmt
.query_map(params![limit], |row| { .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 { Ok(GameRow {
team_name: row.get(0)?, team_name: None,
session_id: row.get(1)?, session_id: row.get(0)?,
timestamp, timestamp,
endtime_unix: timestamp, endtime_unix: timestamp,
map_name: row.get(3)?, map_name: row.get(2)?,
mission_mode: row.get(4)?, mission_mode: row.get(3)?,
result: row.get(5)?, result: String::new(),
player_count: row.get(6)?, player_count: row.get(7)?,
winning_team: row.get(16)?, winning_team: row.get(8)?,
losing_team: row.get(17)?, losing_team: row.get(9)?,
tournament_name: row.get(4)?,
duration: row.get(5)?,
draw: draw_int != 0,
stats: GameStats { stats: GameStats {
ground_kills: row.get(7)?, ground_kills: 0,
air_kills: row.get(8)?, air_kills: 0,
assists: row.get(9)?, assists: 0,
captures: row.get(10)?, captures: 0,
deaths: row.get(11)?, deaths: 0,
score: row.get(12)?, score: 0,
missile_evades: row.get(13)?, missile_evades: 0,
shell_interceptions: row.get(14)?, shell_interceptions: 0,
team_kills_stat: row.get(15)?, team_kills_stat: 0,
}, },
}) })
}) })
@@ -1457,25 +1565,31 @@ fn recent_games_for(conn: &Connection, limit: i64) -> Result<Vec<GameRow>, ApiEr
} }
fn game_for(conn: &Connection, session_id: &str) -> Result<Option<GameRow>, ApiError> { fn game_for(conn: &Connection, session_id: &str) -> Result<Option<GameRow>, 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( conn.query_row(
"SELECT "SELECT
p.session_id, ?1 AS session_id,
COALESCE(m.endtime_unix, MAX(p.endtime_unix), 0) AS timestamp, COALESCE(m.endtime_unix, agg.timestamp, 0) AS timestamp,
m.mission_name, m.mission_name,
m.mission_mode, m.mission_mode,
COUNT(DISTINCT p.UID), m.tournament_name,
COALESCE(SUM(p.ground_kills), 0), m.duration,
COALESCE(SUM(p.air_kills), 0), COALESCE(m.draw, 0),
COALESCE(SUM(p.assists), 0), agg.player_count,
COALESCE(SUM(p.captures), 0), agg.ground_kills,
COALESCE(SUM(p.deaths), 0), agg.air_kills,
COALESCE(SUM(p.score), 0), agg.assists,
COALESCE(SUM(p.missile_evades), 0), agg.captures,
COALESCE(SUM(p.shell_interceptions), 0), agg.deaths,
COALESCE(SUM(p.team_kills_stat), 0), agg.score,
agg.missile_evades,
agg.shell_interceptions,
agg.team_kills_stat,
(SELECT pg.team_name (SELECT pg.team_name
FROM player_games_hist pg 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 IS NOT NULL
AND pg.team_name != '' AND pg.team_name != ''
AND pg.victor_bool = 'Win' AND pg.victor_bool = 'Win'
@@ -1484,20 +1598,49 @@ fn game_for(conn: &Connection, session_id: &str) -> Result<Option<GameRow>, ApiE
LIMIT 1), LIMIT 1),
(SELECT pg.team_name (SELECT pg.team_name
FROM player_games_hist pg 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 IS NOT NULL
AND pg.team_name != '' AND pg.team_name != ''
AND pg.victor_bool = 'Loss' AND pg.victor_bool = 'Loss'
GROUP BY pg.team_name COLLATE NOCASE GROUP BY pg.team_name COLLATE NOCASE
ORDER BY COUNT(DISTINCT pg.UID) DESC, pg.team_name COLLATE NOCASE ORDER BY COUNT(DISTINCT pg.UID) DESC, pg.team_name COLLATE NOCASE
LIMIT 1) LIMIT 1)
FROM player_games_hist p FROM (
LEFT JOIN match_summary m ON m.session_id = p.session_id SELECT
WHERE p.session_id = ?1 COUNT(*) AS player_count,
GROUP BY p.session_id", 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], params![session_id],
|row| { |row| {
let timestamp: i64 = row.get(1)?; let timestamp: i64 = row.get(1)?;
let draw_int: i64 = row.get(6)?;
Ok(GameRow { Ok(GameRow {
team_name: None, team_name: None,
session_id: row.get(0)?, session_id: row.get(0)?,
@@ -1506,19 +1649,22 @@ fn game_for(conn: &Connection, session_id: &str) -> Result<Option<GameRow>, ApiE
map_name: row.get(2)?, map_name: row.get(2)?,
mission_mode: row.get(3)?, mission_mode: row.get(3)?,
result: String::new(), result: String::new(),
player_count: row.get(4)?, player_count: row.get(7)?,
winning_team: row.get(14)?, winning_team: row.get(17)?,
losing_team: row.get(15)?, losing_team: row.get(18)?,
tournament_name: row.get(4)?,
duration: row.get(5)?,
draw: draw_int != 0,
stats: GameStats { stats: GameStats {
ground_kills: row.get(5)?, ground_kills: row.get(8)?,
air_kills: row.get(6)?, air_kills: row.get(9)?,
assists: row.get(7)?, assists: row.get(10)?,
captures: row.get(8)?, captures: row.get(11)?,
deaths: row.get(9)?, deaths: row.get(12)?,
score: row.get(10)?, score: row.get(13)?,
missile_evades: row.get(11)?, missile_evades: row.get(14)?,
shell_interceptions: row.get(12)?, shell_interceptions: row.get(15)?,
team_kills_stat: row.get(13)?, team_kills_stat: row.get(16)?,
}, },
}) })
}, },
@@ -1530,34 +1676,44 @@ fn game_for(conn: &Connection, session_id: &str) -> Result<Option<GameRow>, ApiE
fn game_participants_for( fn game_participants_for(
conn: &Connection, conn: &Connection,
session_id: &str, session_id: &str,
state: &AppState,
lang: &str,
) -> Result<Vec<GameParticipant>, ApiError> { ) -> Result<Vec<GameParticipant>, ApiError> {
let mut stmt = conn let mut stmt = conn
.prepare( .prepare(
"SELECT "SELECT
p.team_name, pp.team_name,
CASE CASE WHEN MAX(pp.won) = 1 THEN 'Win' ELSE 'Loss' END AS result,
WHEN MAX(CASE WHEN p.victor_bool = 'Win' THEN 1 ELSE 0 END) = 1 THEN 'Win' COUNT(*),
ELSE 'Loss' COALESCE(SUM(pp.ground_kills), 0),
END AS result, COALESCE(SUM(pp.air_kills), 0),
COUNT(DISTINCT p.UID), COALESCE(SUM(pp.assists), 0),
COALESCE(SUM(p.ground_kills), 0), COALESCE(SUM(pp.captures), 0),
COALESCE(SUM(p.air_kills), 0), COALESCE(SUM(pp.deaths), 0),
COALESCE(SUM(p.assists), 0), COALESCE(SUM(pp.score), 0),
COALESCE(SUM(p.captures), 0), COALESCE(SUM(pp.missile_evades), 0),
COALESCE(SUM(p.deaths), 0), COALESCE(SUM(pp.shell_interceptions), 0),
COALESCE(SUM(p.score), 0), COALESCE(SUM(pp.team_kills_stat), 0)
COALESCE(SUM(p.missile_evades), 0), FROM (
COALESCE(SUM(p.shell_interceptions), 0), SELECT UID, team_name,
COALESCE(SUM(p.team_kills_stat), 0) MAX(CASE WHEN victor_bool = 'Win' THEN 1 ELSE 0 END) AS won,
FROM player_games_hist p MAX(ground_kills) AS ground_kills,
WHERE p.session_id = ?1 AND p.team_name IS NOT NULL AND p.team_name != '' MAX(air_kills) AS air_kills,
GROUP BY p.team_name COLLATE NOCASE 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 ORDER BY
CASE CASE WHEN MAX(pp.won) = 1 THEN 0 ELSE 1 END,
WHEN MAX(CASE WHEN p.victor_bool = 'Win' THEN 1 ELSE 0 END) = 1 THEN 0 pp.team_name COLLATE NOCASE",
ELSE 1
END,
p.team_name COLLATE NOCASE",
) )
.map_err(db_error)?; .map_err(db_error)?;
@@ -1586,7 +1742,8 @@ fn game_participants_for(
.map_err(db_error)?; .map_err(db_error)?;
for participant in &mut participants { 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) Ok(participants)
@@ -1596,7 +1753,11 @@ fn game_players_for(
conn: &Connection, conn: &Connection,
session_id: &str, session_id: &str,
team_name: &str, team_name: &str,
state: &AppState,
lang: &str,
) -> Result<Vec<GamePlayer>, ApiError> { ) -> Result<Vec<GamePlayer>, 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 let mut stmt = conn
.prepare( .prepare(
"SELECT "SELECT
@@ -1609,27 +1770,41 @@ fn game_players_for(
AND pg.nick != '' AND pg.nick != ''
ORDER BY pg.endtime_unix DESC ORDER BY pg.endtime_unix DESC
LIMIT 1), LIMIT 1),
COALESCE(SUM(p.ground_kills), 0), MAX(p.ground_kills),
COALESCE(SUM(p.air_kills), 0), MAX(p.air_kills),
COALESCE(SUM(p.assists), 0), MAX(p.assists),
COALESCE(SUM(p.captures), 0), MAX(p.captures),
COALESCE(SUM(p.deaths), 0), MAX(p.deaths),
COALESCE(SUM(p.score), 0), MAX(p.score),
COALESCE(SUM(p.missile_evades), 0), MAX(p.missile_evades),
COALESCE(SUM(p.shell_interceptions), 0), MAX(p.shell_interceptions),
COALESCE(SUM(p.team_kills_stat), 0) MAX(p.team_kills_stat),
GROUP_CONCAT(DISTINCT p.vehicle_internal)
FROM player_games_hist p FROM player_games_hist p
WHERE p.session_id = ?1 AND p.team_name = ?2 COLLATE NOCASE WHERE p.session_id = ?1 AND p.team_name = ?2 COLLATE NOCASE
GROUP BY p.UID 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)?; .map_err(db_error)?;
let players = stmt let players = stmt
.query_map(params![session_id, team_name], |row| { .query_map(params![session_id, team_name], |row| {
let cdks: Option<String> = 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 { Ok(GamePlayer {
uid: row.get(0)?, uid: row.get(0)?,
nick: row.get(1)?, nick: row.get(1)?,
vehicles,
stats: GameStats { stats: GameStats {
ground_kills: row.get(2)?, ground_kills: row.get(2)?,
air_kills: row.get(3)?, 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<String, HashMap<String, String>> {
let parsed: HashMap<String, HashMap<String, String>> = 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<String, String> {
// vehicle_data_cache_all.json is [[cdk, name, icon, tags], ...]
let raw: Vec<serde_json::Value> = 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<String, HashMap<String, String>>,
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<String, String>, 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 { 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 raw = env::var(env_key).unwrap_or_else(|_| default_file.to_string());
let expanded = expand_home(&raw); let expanded = expand_home(&raw);
@@ -1832,3 +2062,46 @@ fn load_env_file(path: &FsPath) {
env::set_var(key, value); 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);
}
}
+5
View File
@@ -88,6 +88,11 @@ module.exports = {
BACKEND_ALLOWED_ORIGINS: process.env.BACKEND_ALLOWED_ORIGINS || process.env.PUBLIC_ORIGIN || '', BACKEND_ALLOWED_ORIGINS: process.env.BACKEND_ALLOWED_ORIGINS || process.env.PUBLIC_ORIGIN || '',
TSS_BATTLES_DB: process.env.TSS_BATTLES_DB || 'tss_battles.db', TSS_BATTLES_DB: process.env.TSS_BATTLES_DB || 'tss_battles.db',
TSS_TEAMS_DB: process.env.TSS_TEAMS_DB || 'tss_teams.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',
}, },
}, },
], ],
+12
View File
@@ -11,6 +11,18 @@ BACKEND_ALLOWED_ORIGINS=https://example.com
TSS_BATTLES_DB=./tss_battles.db TSS_BATTLES_DB=./tss_battles.db
TSS_TEAMS_DB=./tss_teams.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_STORAGE_DIR=~/tsswebstorage
UPTIME_DATABASE_FILE=uptime.sqlite UPTIME_DATABASE_FILE=uptime.sqlite
UPTIME_SAMPLE_INTERVAL_MS=1800000 UPTIME_SAMPLE_INTERVAL_MS=1800000
Binary file not shown.
+375 -68
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import Tree, { prewarmTreeCanvas } from '../Tree/Tree' import Tree, { prewarmTreeCanvas } from '../Tree/Tree'
import FallingLeaves from '../Tree/FallingLeaves' import FallingLeaves from '../Tree/FallingLeaves'
import ReplayCanvasPanel from './ReplayCanvas'
const numberFormat = new Intl.NumberFormat('en-GB') const numberFormat = new Intl.NumberFormat('en-GB')
const dateFormat = new Intl.DateTimeFormat('en-GB', { const dateFormat = new Intl.DateTimeFormat('en-GB', {
@@ -20,6 +21,7 @@ const apiEndpoints = {
teamsHealth: '/api/tss/leaderboard/teams?limit=1', teamsHealth: '/api/tss/leaderboard/teams?limit=1',
recentGames: '/api/tss/games/recent?limit=50', recentGames: '/api/tss/games/recent?limit=50',
game: (gameId) => `/api/tss/games/${encodeURIComponent(gameId)}`, game: (gameId) => `/api/tss/games/${encodeURIComponent(gameId)}`,
gameLogs: (gameId) => `/api/tss/games/${encodeURIComponent(gameId)}/logs`,
resolve: (name) => `/api/tss/teams/resolve?name=${encodeURIComponent(name)}`, resolve: (name) => `/api/tss/teams/resolve?name=${encodeURIComponent(name)}`,
searchTeams: (name) => `/api/tss/teams/search?q=${encodeURIComponent(name)}&limit=10`, searchTeams: (name) => `/api/tss/teams/search?q=${encodeURIComponent(name)}&limit=10`,
detail: (name) => `/api/tss/teams/${encodeURIComponent(name)}`, detail: (name) => `/api/tss/teams/${encodeURIComponent(name)}`,
@@ -123,6 +125,15 @@ function formatDate(timestamp) {
return dateFormat.format(new Date(Number(timestamp) * 1000)) 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) { function gameParticipants(game) {
const winner = displayTeamName(game?.winning_team) const winner = displayTeamName(game?.winning_team)
const loser = displayTeamName(game?.losing_team) const loser = displayTeamName(game?.losing_team)
@@ -149,16 +160,39 @@ function displayTeamName(value) {
return String(value || '').trim() return String(value || '').trim()
} }
function ParticipantNames({ participants }) { function ParticipantNames({ participants, spread = false }) {
if (!participants.length) { if (!participants.length) {
return <p className="truncate text-sm font-semibold text-text-soft">Participants unknown</p> return <p className="truncate text-sm font-semibold text-text-soft">Participants unknown</p>
} }
// 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 (
<div className="flex min-w-0 items-center gap-x-3">
<span
className={`min-w-0 flex-1 truncate text-left text-sm font-semibold ${first.result === 'win' ? 'text-win' : 'text-loss'}`}
>
{first.name}
</span>
<span className="shrink-0 text-xs font-semibold uppercase tracking-wide text-text-muted">
vs
</span>
<span
className={`min-w-0 flex-1 truncate text-right text-sm font-semibold ${second.result === 'win' ? 'text-win' : 'text-loss'}`}
>
{second.name}
</span>
</div>
)
}
return ( return (
<div className="flex min-w-0 flex-wrap gap-x-3 gap-y-1"> <div className={`flex min-w-0 flex-wrap gap-x-3 gap-y-1${spread ? ' justify-between' : ''}`}>
{participants.map((participant) => ( {participants.map((participant) => (
<span <span
className={`truncate text-sm font-semibold ${participant.result === 'win' ? 'text-fury-violet' : 'text-text-soft'}`} className={`truncate text-sm font-semibold ${participant.result === 'win' ? 'text-win' : 'text-loss'}`}
key={`${participant.result}-${participant.name}`} key={`${participant.result}-${participant.name}`}
> >
{participant.name} {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 +/-/<space> 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 }) { function GamePage({ gameId, navigate }) {
const [gameState, setGameState] = useState({ status: 'loading', data: null, error: null }) const [gameState, setGameState] = useState({ status: 'loading', data: null, error: null })
const [logs, setLogs] = useState({ chat_log: [], battle_log: [], event_log: { kills: [], damage: [], chat: [] } })
useEffect(() => { useEffect(() => {
if (!gameId) return if (!gameId) return
const controller = new AbortController() const controller = new AbortController()
setGameState({ status: 'loading', data: null, error: null }) setGameState({ status: 'loading', data: null, error: null })
setLogs({ chat_log: [], battle_log: [], event_log: { kills: [], damage: [], chat: [] } })
fetchJson(apiEndpoints.game(gameId), controller.signal) fetchJson(apiEndpoints.game(gameId), controller.signal)
.then((data) => { .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() return () => controller.abort()
}, [gameId]) }, [gameId])
const game = gameState.data?.game 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 const participantNames = participants.length
? participants.map((participant) => ({ ? participants.map((participant) => ({
name: participant.team_name, name: participant.team_name,
@@ -2932,6 +3100,9 @@ function GamePage({ gameId, navigate }) {
})) }))
: gameParticipants(game) : gameParticipants(game)
const subtitle = [game?.mission_mode, game?.tournament_name].filter(Boolean).join(' · ')
const duration = formatDuration(game?.duration)
return ( return (
<section className="space-y-6 pt-24 sm:pt-28"> <section className="space-y-6 pt-24 sm:pt-28">
<button <button
@@ -2943,84 +3114,121 @@ function GamePage({ gameId, navigate }) {
</button> </button>
<div className="rounded-lg border border-border bg-fury-white p-6 shadow-sm"> <div className="rounded-lg border border-border bg-fury-white p-6 shadow-sm">
<p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">Game</p> <div className="flex flex-wrap items-baseline gap-x-3">
<h1 className="mt-1 text-4xl font-bold">{game?.map_name || 'Battle log'}</h1> <p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">Game</p>
<p className="mt-2 break-all text-sm text-text-soft"> <span className="break-all text-xs text-text-muted opacity-70">{game?.session_id || gameId}</span>
{game ? `${formatDate(game.timestamp)} · ${game.session_id}` : gameId} </div>
<div className="mt-1 flex flex-wrap items-center gap-3">
<h1 className="text-4xl font-bold">{game?.map_name || 'Battle log'}</h1>
{game?.draw ? (
<span className="rounded bg-surface px-2 py-1 text-xs font-semibold uppercase tracking-wide text-text-soft">
Draw
</span>
) : null}
</div>
{subtitle ? <p className="mt-1 text-sm font-medium text-fury-violet">{subtitle}</p> : null}
<p className="mt-2 text-sm text-text-soft">
{game ? formatDate(game.timestamp) : ''}
{duration ? ` · ${duration}` : ''}
</p> </p>
<div className="mt-3">
<ParticipantNames participants={participantNames} />
</div>
{gameState.status === 'error' ? ( {gameState.status === 'error' ? (
<p className="mt-4 text-sm text-danger">{gameState.error}</p> <p className="mt-4 text-sm text-danger">{gameState.error}</p>
) : null} ) : null}
</div> </div>
<div className="rounded-lg border border-border bg-fury-white shadow-sm"> <div className="rounded-lg border border-border bg-fury-white shadow-sm">
<div className="border-b border-surface px-5 py-4">
<h2 className="text-lg font-semibold">Participants</h2>
<div className="mt-1">
<ParticipantNames participants={participantNames} />
</div>
</div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<div className="min-w-[760px]"> <div className="min-w-[720px]">
{participants.length ? ( {participants.length ? (
<div className="grid grid-cols-[minmax(220px,1fr)_80px_repeat(5,88px)] gap-3 border-b border-surface px-5 py-3 text-xs font-semibold uppercase tracking-wide text-text-soft"> <div className={`grid ${SCOREBOARD_GRID} gap-3 border-b border-border px-5 py-3 text-xs font-semibold uppercase tracking-wide text-text-soft`}>
<p>Team / player</p> <p>Team / player</p>
<p className="text-right">Players</p> <p className="text-center">Air</p>
<p className="text-right">Ground</p> <p className="text-center">Ground</p>
<p className="text-right">Air</p> <p className="text-center">Assists</p>
<p className="text-right">Assists</p> <p className="text-center">Deaths</p>
<p className="text-right">Score</p> <p className="text-center">Caps</p>
<p className="text-right">Deaths</p> <p className="text-center">Score</p>
</div> </div>
) : null} ) : null}
{participants.map((participant) => { {participants.map((participant) => {
const won = String(participant.result || '').toLowerCase() === 'win' const won = String(participant.result || '').toLowerCase() === 'win'
return ( const accent = won ? 'border-l-win' : 'border-l-loss'
<div className="border-b border-surface" key={participant.team_name}> const nameColor = won ? 'text-win' : 'text-loss'
<button return (
className="grid w-full grid-cols-[minmax(220px,1fr)_80px_repeat(5,88px)] gap-3 px-5 py-3 text-left transition hover:bg-surface" <div className={`border-b-4 border-border border-l-4 ${accent}`} key={participant.team_name}>
onClick={() => navigate(teamPath(participant.team_name))} <button
type="button" className={`grid w-full ${SCOREBOARD_GRID} items-center gap-3 border-b border-border bg-surface/60 px-5 py-3 text-left transition hover:bg-surface`}
> onClick={() => navigate(teamPath(participant.team_name))}
<p className={`truncate font-semibold ${won ? 'text-fury-violet' : 'text-text-soft'}`}> type="button"
{participant.team_name} >
</p> <div className="min-w-0">
<p className="text-right text-sm">{formatNumber(participant.player_count)}</p> <p className={`truncate font-bold ${nameColor}`}>
<p className="text-right text-sm">{formatNumber(participant.stats?.ground_kills)}</p> {participant.team_name}
<p className="text-right text-sm">{formatNumber(participant.stats?.air_kills)}</p> </p>
<p className="text-right text-sm">{formatNumber(participant.stats?.assists)}</p> <p className={`text-xs font-semibold uppercase tracking-wide ${nameColor}`}>
<p className="text-right text-sm text-text-muted">-</p> {won ? 'Win' : 'Loss'} · {formatNumber(participant.player_count)} players
<p className="text-right text-sm">{formatNumber(participant.stats?.deaths)}</p> </p>
</button> </div>
<p className="text-center text-sm">{formatNumber(participant.stats?.air_kills)}</p>
<p className="text-center text-sm">{formatNumber(participant.stats?.ground_kills)}</p>
<p className="text-center text-sm">{formatNumber(participant.stats?.assists)}</p>
<p className="text-center text-sm">{formatNumber(participant.stats?.deaths)}</p>
<p className="text-center text-sm">{formatNumber(participant.stats?.captures)}</p>
<p className="text-center text-sm font-semibold">{formatNumber(participant.stats?.score)}</p>
</button>
<div className="pb-3"> <div className="divide-y divide-surface py-1">
{(participant.players || []).map((player) => ( {(participant.players || []).map((player) => (
<button <button
className="grid w-full grid-cols-[minmax(220px,1fr)_80px_repeat(5,88px)] gap-3 px-5 py-2 text-left text-sm transition hover:bg-surface" className={`grid w-full ${SCOREBOARD_GRID} items-center gap-3 px-5 py-2 text-left text-sm transition hover:bg-surface`}
key={player.uid} key={player.uid}
onClick={() => navigate(playerPath(player.uid))} onClick={() => navigate(playerPath(player.uid))}
type="button" type="button"
> >
<div className="min-w-0 pl-8 sm:pl-12"> <div className="min-w-0 pl-4 sm:pl-8">
<p className="truncate font-semibold text-text">{player.nick || player.uid}</p> <p className="truncate font-semibold text-text">{player.nick || player.uid}</p>
<p className="text-xs text-text-soft">{player.uid}</p> <div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5">
</div> {(player.vehicles || []).length ? (
<p className="text-right text-text-muted">-</p> player.vehicles.map((vehicle) => {
<p className="text-right">{formatNumber(player.stats?.ground_kills)}</p> const isDead = deadVehicleKeys.has(deadVehicleKey(player.uid, vehicle.cdk))
<p className="text-right">{formatNumber(player.stats?.air_kills)}</p> return (
<p className="text-right">{formatNumber(player.stats?.assists)}</p> <span
<p className="text-right">{formatNumber(player.stats?.score)}</p> className={`vehicle-name inline-flex items-center gap-1 text-xs text-text-soft transition-opacity ${isDead ? 'vehicle-dead' : ''}`}
<p className="text-right">{formatNumber(player.stats?.deaths)}</p> key={vehicle.cdk}
</button> >
))} <img
alt=""
className="h-4 w-4 object-contain"
loading="lazy"
onError={(event) => { event.currentTarget.style.display = 'none' }}
src={`/vehicle-icons/${vehicle.icon}`}
/>
{vehicle.name}
</span>
)
})
) : (
<span className="text-xs text-text-muted">{player.uid}</span>
)}
</div>
</div>
<p className="text-center">{formatNumber(player.stats?.air_kills)}</p>
<p className="text-center">{formatNumber(player.stats?.ground_kills)}</p>
<p className="text-center">{formatNumber(player.stats?.assists)}</p>
<p className="text-center">{formatNumber(player.stats?.deaths)}</p>
<p className="text-center">{formatNumber(player.stats?.captures)}</p>
<p className="text-center font-semibold">{formatNumber(player.stats?.score)}</p>
</button>
))}
</div>
</div> </div>
</div> )
) })}
})}
</div> </div>
{!participants.length ? ( {!participants.length ? (
@@ -3030,10 +3238,109 @@ function GamePage({ gameId, navigate }) {
) : null} ) : null}
</div> </div>
</div> </div>
<ReplayCanvasPanel gameId={gameId} />
{battleEvents.length || logs.battle_log.length ? (
<details className="rounded-lg border border-border bg-fury-white shadow-sm">
<summary className="cursor-pointer px-5 py-4 font-semibold">Battle Log</summary>
<div className="log-mono overflow-x-auto px-5 py-3 text-xs leading-relaxed">
{battleEvents.length ? (
battleEvents.map((event, i) => (
<BattleEventLine event={event} key={`${event.kind}-${event.time}-${i}`} players={playersByUid} />
))
) : (
logs.battle_log.map((line, i) => (
<div className={`whitespace-pre ${battleLineColor(line)}`} key={i}>{line}</div>
))
)}
</div>
</details>
) : null}
{chatEvents.length || logs.chat_log.length ? (
<details className="rounded-lg border border-border bg-fury-white shadow-sm">
<summary className="cursor-pointer px-5 py-4 font-semibold">Chat Log</summary>
<div className="log-mono overflow-x-auto px-5 py-3 text-xs leading-relaxed">
{chatEvents.length ? (
chatEvents.map((event, i) => (
<ChatEventLine event={event} key={`${event.time}-${event.uid}-${i}`} players={playersByUid} />
))
) : (
logs.chat_log.map((line, i) => (
<FormattedChatLine key={i} line={line} players={playersByName} />
))
)}
</div>
</details>
) : null}
</section> </section>
) )
} }
function FormattedChatLine({ line, players }) {
const parsed = parseFormattedChatLine(line)
if (!parsed) {
return <div className={`whitespace-pre-wrap ${battleLineColor(line)}`}>{line}</div>
}
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 (
<div className="whitespace-pre-wrap">
<span className="text-text-soft">{parsed.time} </span>
<span className={typeClassName}>[{parsed.type}]</span>
<span className={className}> [{team}] `{name}`: {parsed.message}</span>
</div>
)
}
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 (
<div className="whitespace-pre">
<span className="text-text-soft">[{ts}] </span>
<span className={victim.className}>[{victim.team}] {victimLabel}</span>
<span className="text-text-soft"> crashed</span>
</div>
)
}
const offenderLabel = `${offender.name} (${logVehicle(offender, event.offender_unit)})`
const action = event.kind === 'kill' ? 'destroyed' : `damaged ${event.afire ? '(FIRE) ' : ''}`
return (
<div className="whitespace-pre">
<span className="text-text-soft">[{ts}] </span>
<span className={offender.className}>[{offender.team}] {offenderLabel}</span>
<span className="text-text-soft"> {action} </span>
<span className={victim.className}>{victimLabel}</span>
</div>
)
}
function ChatEventLine({ event, players }) {
const player = logPlayer(players, event.uid)
const type = event.type || 'ALL'
const typeClassName = chatTypeClass(type, player.className)
return (
<div className="whitespace-pre-wrap">
<span className="text-text-soft">[{formatLogTime(event.time)}] </span>
<span className={typeClassName}>[{type}]</span>
<span className={player.className}> [{player.team}] `{player.name}`: {event.message || ''}</span>
</div>
)
}
function RosterTable({ players, status }) { function RosterTable({ players, status }) {
const sortedPlayers = [...players].sort((a, b) => { const sortedPlayers = [...players].sort((a, b) => {
return (b.total_kills || 0) - (a.total_kills || 0) || String(a.nick || '').localeCompare(b.nick || '') return (b.total_kills || 0) - (a.total_kills || 0) || String(a.nick || '').localeCompare(b.nick || '')
@@ -3124,7 +3431,7 @@ function BattleLogsPage({ live, matches, navigate }) {
<div className="overflow-hidden rounded-lg border border-border bg-fury-white shadow-sm"> <div className="overflow-hidden rounded-lg border border-border bg-fury-white shadow-sm">
{matches.map((match) => ( {matches.map((match) => (
<button <button
className="grid w-full gap-4 border-b border-surface px-5 py-4 text-left transition hover:bg-surface md:grid-cols-[1fr_minmax(10rem,0.8fr)_auto] md:items-center" className="grid w-full gap-x-8 gap-y-2 border-b border-surface px-5 py-4 text-left transition hover:bg-surface md:grid-cols-[minmax(0,0.9fr)_minmax(0,1.7fr)_auto] md:items-center"
key={match.session_id} key={match.session_id}
onClick={() => navigate(gamePath(match.session_id))} onClick={() => navigate(gamePath(match.session_id))}
type="button" type="button"
@@ -3135,7 +3442,7 @@ function BattleLogsPage({ live, matches, navigate }) {
{formatDate(match.timestamp)} · {match.session_id} {formatDate(match.timestamp)} · {match.session_id}
</p> </p>
</div> </div>
<ParticipantNames participants={gameParticipants(match)} /> <ParticipantNames participants={gameParticipants(match)} spread />
<p className="text-sm">{formatMatchSize(match.player_count)}</p> <p className="text-sm">{formatMatchSize(match.player_count)}</p>
</button> </button>
))} ))}
File diff suppressed because it is too large Load Diff
+467
View File
@@ -80,6 +80,8 @@
--color-success: #00f2ff; --color-success: #00f2ff;
--color-warning: #f4ee3e; --color-warning: #f4ee3e;
--color-danger: #e82517; --color-danger: #e82517;
--color-win: #1a9e4b;
--color-loss: #e82517;
} }
:root[data-theme="dark"] { :root[data-theme="dark"] {
@@ -102,6 +104,8 @@
--color-success: #58f0f5; --color-success: #58f0f5;
--color-warning: #f4ee3e; --color-warning: #f4ee3e;
--color-danger: #ff6a5f; --color-danger: #ff6a5f;
--color-win: #46d17e;
--color-loss: #ff6a5f;
color-scheme: dark; color-scheme: dark;
} }
@@ -115,6 +119,32 @@
box-sizing: border-box; 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 { body {
margin: 0; margin: 0;
min-width: 320px; min-width: 320px;
@@ -436,6 +466,443 @@ h3 {
z-index: 1; 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 { @keyframes scrollPulse {
0% { 0% {
transform: translateY(-100%); transform: translateY(-100%);
+217
View File
@@ -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 <label> <actual> <expected>
assert_eq() { if [[ "$2" == "$3" ]]; then ok "$1 ($2)"; else bad "$1: got '$2' expected '$3'"; fi; }
WD="$(mktemp -d)"
ICONS="$(mktemp -d)"
STORE="$(mktemp -d)"
BE_PID=""
WEB_PID=""
cleanup() {
[[ -n "$BE_PID" ]] && kill "$BE_PID" 2>/dev/null
[[ -n "$WEB_PID" ]] && kill "$WEB_PID" 2>/dev/null
rm -rf "$WD" "$ICONS" "$STORE"
}
trap cleanup EXIT
echo "== TSSBOT game-detail verification =="
echo "WEB_REPO=$WEB_REPO BOTS_REPO=$BOTS_REPO PYTHON=$PYTHON"
# ---------------------------------------------------------------------------
echo "== 1. Python log-builder unit tests =="
if "$PYTHON" "$BOTS_REPO/TSSBOT/tests/test_match_logs.py"; then ok "build_match_logs tests"; else bad "build_match_logs tests"; fi
# ---------------------------------------------------------------------------
echo "== 2. Backfill dry-run against a synthetic replay =="
SYN="$(mktemp -d)"
STORAGE_VOL_PATH="$SYN" "$PYTHON" - "$SYN" <<'PY'
import gzip, json, os, pathlib, sys
d = pathlib.Path(sys.argv[1]) / "REPLAYS" / "TSS" / "feed"
d.mkdir(parents=True)
game = {"_id": "feed", "winner": "[WIN]", "loser": "[LOS]",
"players": {"1": {"name": "a", "tag": "[WIN]", "team": "1",
"units": [{"unit": "ussr_t_34", "used": True}]}},
"chat": [{"uid": "1", "type": "ALL", "message": "gg", "time": 1000}],
"events": {"kills": []}}
gzip.open(d / "replay_data.json.gz", "wb").write(json.dumps(game).encode())
PY
if STORAGE_VOL_PATH="$SYN" "$PYTHON" "$BOTS_REPO/TSSBOT/scripts/backfill_match_logs.py" --dry-run | grep -q "Would backfill"; then
ok "backfill --dry-run runs"
else
bad "backfill --dry-run"
fi
rm -rf "$SYN"
# ---------------------------------------------------------------------------
echo "== 3. cargo tests + binary =="
if cargo test --manifest-path "$WEB_REPO/backend/Cargo.toml" >/tmp/vgd_cargo.log 2>&1; then ok "cargo test"; else bad "cargo test (see /tmp/vgd_cargo.log)"; fi
# `cargo test` only builds the test harness, not the standalone binary. Prefer the
# release binary (built at deploy); otherwise build a debug one.
BIN=""
if [[ -x "$WEB_REPO/backend/target/release/tssbot-backend" ]]; then
BIN="$WEB_REPO/backend/target/release/tssbot-backend"
ok "using release binary"
else
if cargo build --manifest-path "$WEB_REPO/backend/Cargo.toml" >/tmp/vgd_build.log 2>&1; then
BIN="$WEB_REPO/backend/target/debug/tssbot-backend"
ok "built debug binary"
else
bad "cargo build (see /tmp/vgd_build.log)"
fi
fi
[[ -n "$BIN" && -x "$BIN" ]] || bad "backend binary unavailable"
# ---------------------------------------------------------------------------
echo "== 4. Build fixture (multi-vehicle player to catch the double-count bug) =="
"$PYTHON" - "$WD" <<'PY'
import sqlite3, json, sys
wd = sys.argv[1]
b = sqlite3.connect(f"{wd}/tss_battles.db")
b.executescript("""
CREATE TABLE match_summary (session_id TEXT PRIMARY KEY, mission_mode TEXT, mission_name TEXT,
level_path TEXT, mission_path TEXT, difficulty TEXT, starttime_unix INT, endtime_unix INT,
duration REAL, draw INT DEFAULT 0, winning_slot TEXT, losing_slot TEXT, received_unix INT,
tournament_id INT, tournament_name TEXT, match_id TEXT, bracket TEXT);
CREATE TABLE player_games_hist (UID TEXT, nick TEXT, team_name TEXT, team_slot TEXT,
session_id TEXT, vehicle TEXT, vehicle_internal TEXT, ground_kills INT, air_kills INT,
assists INT, captures INT, deaths INT, score INT, missile_evades INT, shell_interceptions INT,
team_kills_stat INT, country_id INT, victor_bool TEXT, endtime_unix INT, team_id INT,
tss_role TEXT, pvp_ratio REAL);
CREATE TABLE match_logs (session_id TEXT PRIMARY KEY, chat_log_json TEXT, battle_log_json TEXT, event_log_json TEXT, built_unix INT);
""")
b.execute("INSERT INTO match_summary VALUES ('abc','Dom','Test Map','','','',0,1000,420.0,0,'1','2',0,0,'Cup Finals','','')")
# alice used TWO vehicles -> two rows, identical per-player stats (score 100).
b.execute("INSERT INTO player_games_hist VALUES ('1','alice','TeamWin','1','abc','t34','ussr_t_34',2,0,1,0,0,100,0,0,0,0,'Win',1000,10,'',1.5)")
b.execute("INSERT INTO player_games_hist VALUES ('1','alice','TeamWin','1','abc','is2','ussr_is_2',2,0,1,0,0,100,0,0,0,0,'Win',1000,10,'',1.5)")
b.execute("INSERT INTO player_games_hist VALUES ('2','bob','TeamLose','2','abc','pz','germ_pz_iv',0,0,0,0,1,10,0,0,0,0,'Loss',1000,11,'',0.5)")
b.execute("INSERT INTO match_logs VALUES ('abc', ?, ?, ?, 1000)",
(json.dumps(["[00:01] [ALL] [WIN] `alice`: gg"]),
json.dumps(["+[00:30] [WIN] alice (T-34) destroyed bob (Pz.IV)"]),
json.dumps({"kills": [{"offender_uid": "1", "offender_unit": "ussr_t_34", "offended_uid": "2", "offended_unit": "germ_pz_iv", "crashed": False, "time": 30000}], "damage": [], "chat": [{"uid": "1", "type": "ALL", "message": "gg", "time": 1000}]})))
b.commit()
t = sqlite3.connect(f"{wd}/tss_teams.db")
t.executescript("CREATE TABLE teams_data (team_id INT PRIMARY KEY, name TEXT, members INT DEFAULT 0, captain_uid TEXT);")
t.commit()
json.dump({"ussr_t_34": {"en": "T-34", "ru": "Т-34"}, "ussr_is_2": {"en": "IS-2"}}, open(f"{wd}/vt.json", "w"))
json.dump([["ussr_t_34", "T-34", "ussr_t_34.png", {}], ["ussr_is_2", "IS-2", "ussr_is_2.png", {}]], open(f"{wd}/vc.json", "w"))
print("fixture ready")
PY
printf '\x89PNG' > "$ICONS/ussr_t_34.png"
# ---------------------------------------------------------------------------
echo "== 5. Start backend + node server against the fixture =="
TSS_BATTLES_DB="$WD/tss_battles.db" TSS_TEAMS_DB="$WD/tss_teams.db" \
VEHICLE_TRANSLATIONS_JSON="$WD/vt.json" VEHICLE_DATA_CACHE_JSON="$WD/vc.json" \
BACKEND_PORT="$BE_PORT" "$BIN" >/tmp/vgd_be.log 2>&1 &
BE_PID=$!
# Override PUBLIC_ORIGIN/comingsoon so the server's same-origin guard accepts the
# localhost test origin and serves normally. server.cjs's loadEnvFile only fills
# unset/empty vars, so these non-empty values win over the prod .env.
PORT="$WEB_PORT" API_UPSTREAM="http://127.0.0.1:$BE_PORT" VEHICLE_ICONS_DIR="$ICONS" \
UPTIME_STORAGE_DIR="$STORE" PUBLIC_ORIGIN="http://localhost:$WEB_PORT" comingsoon="FALSE" \
node "$WEB_REPO/server.cjs" >/tmp/vgd_web.log 2>&1 &
WEB_PID=$!
# wait for both to listen
for _ in $(seq 1 20); do
curl -sf "localhost:$BE_PORT/health" >/dev/null 2>&1 && curl -sf "localhost:$WEB_PORT/health" >/dev/null 2>&1 && break
sleep 0.5
done
# ---------------------------------------------------------------------------
echo "== 6. Backend: dedup, vehicle translation, logs =="
GAME_EN="$(curl -s "localhost:$BE_PORT/api/tss/games/abc?lang=en")"
echo "$GAME_EN" | "$PYTHON" - <<PY
import json, sys
d = json.loads('''$GAME_EN''')
def field(name, actual, expected):
print((" PASS" if actual==expected else " FAIL")+f": {name}: {actual!r} (expected {expected!r})")
return actual==expected
alice = [pl for part in d["participants"] for pl in part["players"] if pl["uid"]=="1"][0]
win = [p for p in d["participants"] if p["team_name"]=="TeamWin"][0]
oks = []
oks.append(field("alice score deduped", alice["stats"]["score"], 100))
oks.append(field("alice vehicle count", len(alice["vehicles"]), 2))
oks.append(field("team total score", win["stats"]["score"], 100))
oks.append(field("game total score", d["game"]["stats"]["score"], 110))
oks.append(field("tournament_name", d["game"].get("tournament_name"), "Cup Finals"))
oks.append(field("duration", d["game"].get("duration"), 420.0))
oks.append(field("draw", d["game"]["draw"], False))
sys.exit(0 if all(oks) else 1)
PY
if [[ $? -eq 0 ]]; then ok "backend game detail (en)"; else bad "backend game detail (en)"; fi
RU_NAME="$(curl -s "localhost:$BE_PORT/api/tss/games/abc?lang=ru" | "$PYTHON" -c "import sys,json; d=json.load(sys.stdin); print([v['name'] for p in d['participants'] for pl in p['players'] for v in pl['vehicles'] if v['cdk']=='ussr_t_34'][0])")"
assert_eq "ru translation of ussr_t_34" "$RU_NAME" "Т-34"
LOG_COUNTS="$(curl -s "localhost:$BE_PORT/api/tss/games/abc/logs" | "$PYTHON" -c "import sys,json; d=json.load(sys.stdin); e=d.get('event_log', {}); print(len(d['chat_log']), len(d['battle_log']), len(e.get('kills', [])), len(e.get('chat', [])))")"
assert_eq "logs chat/battle/kill/raw-chat counts" "$LOG_COUNTS" "1 1 1 1"
MISS="$(curl -s "localhost:$BE_PORT/api/tss/games/deadbeef/logs" | "$PYTHON" -c "import sys,json; d=json.load(sys.stdin); print(len(d['chat_log'])+len(d['battle_log']))")"
assert_eq "missing-session logs empty" "$MISS" "0"
# recent games must list each session ONCE (not once per team)
REC="$(curl -s "localhost:$BE_PORT/api/tss/games/recent?limit=50" | "$PYTHON" -c "import sys,json; d=json.load(sys.stdin); ids=[m['session_id'] for m in d['matches']]; print(ids.count('abc'), ([m['player_count'] for m in d['matches'] if m['session_id']=='abc'] or [0])[0])")"
assert_eq "recent lists session once + per-side count" "$REC" "1 1"
# ---------------------------------------------------------------------------
echo "== 7. Node prod server: icons + proxy allowlist =="
H=(-H "Origin: http://localhost:$WEB_PORT" -H "Referer: http://localhost:$WEB_PORT/games/abc" -H "Sec-Fetch-Site: same-origin")
assert_eq "icon served" "$(curl -s -o /dev/null -w '%{http_code}' "localhost:$WEB_PORT/vehicle-icons/ussr_t_34.png")" "200"
assert_eq "icon traversal blocked" "$(curl -s --path-as-is -o /dev/null -w '%{http_code}' "localhost:$WEB_PORT/vehicle-icons/..%2f..%2fserver.cjs")" "403"
assert_eq "proxy game?lang=en" "$(curl -s "${H[@]}" -o /dev/null -w '%{http_code}' "localhost:$WEB_PORT/api/tss/games/abc?lang=en")" "200"
assert_eq "proxy logs" "$(curl -s "${H[@]}" -o /dev/null -w '%{http_code}' "localhost:$WEB_PORT/api/tss/games/abc/logs")" "200"
assert_eq "proxy SPA shell" "$(curl -s -o /dev/null -w '%{http_code}' "localhost:$WEB_PORT/games/abc")" "200"
# bad param must NOT be 200 (allowlist rejects)
BADCODE="$(curl -s "${H[@]}" -o /dev/null -w '%{http_code}' "localhost:$WEB_PORT/api/tss/games/abc?evil=1")"
if [[ "$BADCODE" != "200" ]]; then ok "proxy blocks unknown param ($BADCODE)"; else bad "proxy allowed unknown param"; fi
# ---------------------------------------------------------------------------
echo
echo "== RESULT: $PASS passed, $FAIL failed =="
[[ $FAIL -eq 0 ]] || exit 1
echo "ALL CHECKS PASSED"
+299
View File
@@ -1,4 +1,5 @@
const fs = require('node:fs') const fs = require('node:fs')
const { execFile } = require('node:child_process')
const crypto = require('node:crypto') const crypto = require('node:crypto')
const http = require('node:http') const http = require('node:http')
const https = require('node:https') const https = require('node:https')
@@ -56,6 +57,28 @@ const TURNSTILE_VERIFY_TIMEOUT_MS = Number(process.env.TURNSTILE_VERIFY_TIMEOUT_
const TURNSTILE_MAX_TOKEN_LENGTH = 2048 const TURNSTILE_MAX_TOKEN_LENGTH = 2048
const TURNSTILE_TOKEN_MAX_AGE_MS = 5 * 60 * 1000 const TURNSTILE_TOKEN_MAX_AGE_MS = 5 * 60 * 1000
const DIST_DIR = path.join(__dirname, 'dist') const DIST_DIR = path.join(__dirname, 'dist')
const VEHICLE_ICONS_DIR = path.resolve(
__dirname,
process.env.VEHICLE_ICONS_DIR || path.join('dist', 'vehicle-icons'),
)
const BOTS_REPO_DIR = path.resolve(
expandHome(process.env.BOTS_REPO_DIR || path.join(__dirname, '..', 'BOTS')),
)
const TSSBOT_REPO_DIR = path.resolve(process.env.TSSBOT_REPO_DIR || path.join(BOTS_REPO_DIR, 'TSSBOT'))
const TSS_REPLAY_SAMPLE_DIR = path.join(TSSBOT_REPO_DIR, 'replays_sample')
const TSS_REPLAYS_DIR = path.resolve(
expandHome(
process.env.TSS_REPLAYS_DIR ||
(process.env.STORAGE_VOL_PATH
? path.join(expandHome(process.env.STORAGE_VOL_PATH), 'REPLAYS', 'TSS')
: TSS_REPLAY_SAMPLE_DIR),
),
)
const SHARED_DIR = path.resolve(process.env.SHARED_DIR || path.join(BOTS_REPO_DIR, 'SHARED'))
const TSS_REPLAY_PYTHON = path.resolve(
expandHome(process.env.TSS_REPLAY_PYTHON || path.join(SHARED_DIR, '.venv', 'bin', 'python')),
)
const TSS_REPLAY_RENDER_TIMEOUT_MS = Number(process.env.TSS_REPLAY_RENDER_TIMEOUT_MS || 30000)
const MAX_TEAM_NAME_LENGTH = 80 const MAX_TEAM_NAME_LENGTH = 80
const MAX_CACHE_ENTRIES = 200 const MAX_CACHE_ENTRIES = 200
const MAX_RATE_LIMIT_KEYS = 1000 const MAX_RATE_LIMIT_KEYS = 1000
@@ -1591,6 +1614,15 @@ function allowedApiTarget(req) {
} }
if (/^\/api\/tss\/games\/[A-Za-z0-9_-]{1,96}$/.test(pathname)) { if (/^\/api\/tss\/games\/[A-Za-z0-9_-]{1,96}$/.test(pathname)) {
const keys = [...params.keys()]
const lang = params.get('lang') || 'en'
if (keys.some((key) => key !== 'lang') || !/^[A-Za-z-]{2,8}$/.test(lang)) {
return null
}
return url
}
if (/^\/api\/tss\/games\/[A-Za-z0-9_-]{1,96}\/logs$/.test(pathname)) {
if ([...params.keys()].length) return null if ([...params.keys()].length) return null
return url return url
} }
@@ -2042,6 +2074,243 @@ function serveStatic(req, res) {
}) })
} }
function serveVehicleIcon(req, res) {
let requestPath = '/'
try {
requestPath = decodeURIComponent(new URL(req.url, `http://localhost:${PORT}`).pathname)
} catch {
return send(res, 400, 'Bad request', { 'content-type': 'text/plain; charset=utf-8' })
}
const name = requestPath.slice('/vehicle-icons/'.length)
const filePath = path.resolve(VEHICLE_ICONS_DIR, `./${name}`)
const relative = path.relative(VEHICLE_ICONS_DIR, filePath)
if (relative.startsWith('..') || path.isAbsolute(relative) || path.extname(filePath) !== '.png') {
return send(res, 403, 'Forbidden', { 'content-type': 'text/plain; charset=utf-8' })
}
fs.readFile(filePath, (error, data) => {
if (error) {
return send(res, 404, 'Not found', { 'content-type': 'text/plain; charset=utf-8' })
}
send(res, 200, data, {
'content-type': 'image/png',
'cache-control': 'public, max-age=604800',
})
})
}
function safeUniquePaths(paths) {
return [...new Set(paths.filter(Boolean).map((value) => path.resolve(value)))]
}
function resolveTssReplaySessionDir(sessionId) {
const sid = String(sessionId || '').toLowerCase()
const candidates = safeUniquePaths([
path.join(TSS_REPLAYS_DIR, sid),
path.join(TSS_REPLAYS_DIR, `0${sid}`),
path.join(TSS_REPLAY_SAMPLE_DIR, sid),
path.join(TSS_REPLAY_SAMPLE_DIR, `0${sid}`),
])
for (const dir of candidates) {
try {
if (fs.existsSync(dir) && fs.statSync(dir).isDirectory()) return dir
} catch {
// Keep trying the remaining replay roots.
}
}
return path.join(TSS_REPLAYS_DIR, sid)
}
function findTssReplayDataPath(sessionDir) {
const candidates = [
path.join(sessionDir, 'replay_data.json.gz'),
path.join(sessionDir, 'replay_data.json'),
]
for (const candidate of candidates) {
try {
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate
} catch {
// Ignore unreadable candidates and let the caller return 404.
}
}
return null
}
function readFileResponse(req, res, filePath, headers = {}) {
fs.readFile(filePath, (error, data) => {
if (error) {
sendJson(res, 404, { error: 'File not found' })
return
}
send(res, 200, data, headers)
})
}
function runReplayCanvasRenderer(replayPath, jsonPath) {
const pythonBin = fs.existsSync(TSS_REPLAY_PYTHON) ? TSS_REPLAY_PYTHON : 'python3'
return new Promise((resolve, reject) => {
execFile(
pythonBin,
['-m', 'BOT.render_replay', replayPath, jsonPath],
{
cwd: TSSBOT_REPO_DIR,
timeout: TSS_REPLAY_RENDER_TIMEOUT_MS,
env: process.env,
},
(error, stdout, stderr) => {
if (error) {
reject(new Error(String(stderr || stdout || error.message || 'Replay renderer failed').trim()))
return
}
resolve()
},
)
})
}
let tssCanvasRenderCount = 0
const TSS_CANVAS_RENDER_MAX = 3
async function serveTssReplayCanvas(req, res, sessionId) {
if (!sessionId || !/^[A-Za-z0-9_-]{1,96}$/.test(sessionId)) {
sendJson(res, 400, { error: 'Invalid game ID' })
return
}
if (!isSameOriginRequest(req)) {
sendJson(res, 403, { error: 'API access is restricted to this site' })
return
}
if (isRateLimited(req)) {
sendJson(res, 429, { error: 'Too many requests' }, { 'retry-after': String(Math.ceil(API_RATE_LIMIT_WINDOW_MS / 1000)) })
return
}
const sessionDir = resolveTssReplaySessionDir(sessionId)
const replayPath = findTssReplayDataPath(sessionDir)
const jsonPath = path.join(sessionDir, 'replay_canvas.json')
if (!replayPath) {
sendJson(res, 404, { available: false, reason: 'No replay data available' })
return
}
try {
const jsonStat = fs.existsSync(jsonPath) ? fs.statSync(jsonPath) : null
const replayStat = fs.statSync(replayPath)
if (jsonStat && jsonStat.size > 0 && jsonStat.mtimeMs >= replayStat.mtimeMs) {
readFileResponse(req, res, jsonPath, {
'content-type': 'application/json; charset=utf-8',
'cache-control': 'public, max-age=86400',
})
return
}
} catch {
// Fall through and attempt regeneration.
}
if (tssCanvasRenderCount >= TSS_CANVAS_RENDER_MAX) {
sendJson(res, 503, { available: false, reason: 'Too many replays processing; try again shortly' })
return
}
tssCanvasRenderCount += 1
try {
await runReplayCanvasRenderer(replayPath, jsonPath)
if (!fs.existsSync(jsonPath)) {
sendJson(res, 500, { available: false, reason: 'Replay JSON generation produced no output' })
return
}
readFileResponse(req, res, jsonPath, {
'content-type': 'application/json; charset=utf-8',
'cache-control': 'public, max-age=86400',
})
} catch (error) {
try {
if (fs.existsSync(jsonPath)) fs.unlinkSync(jsonPath)
} catch {
// Ignore cache cleanup errors.
}
sendJson(res, 500, { available: false, reason: 'Replay JSON generation failed', detail: error.message })
} finally {
tssCanvasRenderCount -= 1
}
}
function serveReplayIcon(req, res) {
let iconName = ''
try {
const requestPath = decodeURIComponent(new URL(req.url, `http://localhost:${PORT}`).pathname)
iconName = requestPath.slice('/api/icons/type/'.length)
} catch {
sendJson(res, 400, { error: 'Bad request' })
return
}
if (!iconName || !/^[A-Za-z0-9_-]+$/.test(iconName)) {
sendJson(res, 400, { error: 'Invalid icon name' })
return
}
const iconsBase = path.join(SHARED_DIR, 'ICONS')
const candidates = [
path.join(iconsBase, `${iconName}.png`),
path.join(iconsBase, 'FALLBACKS', `${iconName}.png`),
path.join(iconsBase, 'MINIS', `${iconName}.png`),
]
for (const candidate of candidates) {
const relative = path.relative(iconsBase, candidate)
if (relative.startsWith('..') || path.isAbsolute(relative)) continue
if (fs.existsSync(candidate)) {
readFileResponse(req, res, candidate, {
'content-type': 'image/png',
'cache-control': 'public, max-age=604800',
})
return
}
}
sendJson(res, 404, { error: 'Icon not found' })
}
function serveReplayMinimap(req, res) {
let level = ''
let fullMap = false
try {
const url = new URL(req.url, `http://localhost:${PORT}`)
const requestPath = decodeURIComponent(url.pathname)
level = requestPath.slice('/api/match/minimap/'.length)
fullMap = url.searchParams.get('type') === 'full'
} catch {
sendJson(res, 400, { error: 'Bad request' })
return
}
if (!level || !/^[A-Za-z0-9_]+$/.test(level)) {
sendJson(res, 400, { error: 'Invalid level name' })
return
}
const minimapsDir = path.join(SHARED_DIR, 'MAPS', 'MINIMAPS')
const names = fullMap
? [`${level}.png`, `${level}_map.png`]
: [`${level}_tankmap.png`, `${level}.png`, `${level}_map.png`]
for (const name of [...new Set(names)]) {
const candidate = path.resolve(minimapsDir, name)
const relative = path.relative(minimapsDir, candidate)
if (relative.startsWith('..') || path.isAbsolute(relative)) continue
if (fs.existsSync(candidate)) {
readFileResponse(req, res, candidate, {
'content-type': 'image/png',
'cache-control': 'public, max-age=604800',
})
return
}
}
sendJson(res, 404, { error: 'Minimap not found' })
}
const server = http.createServer((req, res) => { const server = http.createServer((req, res) => {
if (req.method === 'GET' && req.url === '/robots.txt') { if (req.method === 'GET' && req.url === '/robots.txt') {
sendRobotsTxt(req, res) sendRobotsTxt(req, res)
@@ -2167,6 +2436,36 @@ const server = http.createServer((req, res) => {
return return
} }
if (req.method === 'GET') {
let pathname = ''
try {
pathname = new URL(req.url, `http://${req.headers.host || 'localhost'}`).pathname
} catch {
pathname = ''
}
const replayMatch = pathname.match(/^\/api\/tss\/games\/([A-Za-z0-9_-]{1,96})\/replay-canvas$/)
if (replayMatch) {
serveTssReplayCanvas(req, res, replayMatch[1])
return
}
if (pathname.startsWith('/api/icons/type/')) {
serveReplayIcon(req, res)
return
}
if (pathname.startsWith('/api/match/minimap/')) {
serveReplayMinimap(req, res)
return
}
}
if (req.method === 'GET' && req.url.startsWith('/vehicle-icons/')) {
serveVehicleIcon(req, res)
return
}
if (req.url.startsWith('/api/')) { if (req.url.startsWith('/api/')) {
proxyRequest(req, res) proxyRequest(req, res)
return return
+6
View File
@@ -27,6 +27,12 @@ function isAllowedApiUrl(req) {
} }
if (/^\/api\/tss\/games\/[A-Za-z0-9_-]{1,96}$/.test(url.pathname)) { if (/^\/api\/tss\/games\/[A-Za-z0-9_-]{1,96}$/.test(url.pathname)) {
const keys = [...params.keys()]
const lang = params.get('lang') || 'en'
return keys.every((key) => key === 'lang') && /^[A-Za-z-]{2,8}$/.test(lang)
}
if (/^\/api\/tss\/games\/[A-Za-z0-9_-]{1,96}\/logs$/.test(url.pathname)) {
return [...params.keys()].length === 0 return [...params.keys()].length === 0
} }
+42 -2
View File
@@ -45,6 +45,9 @@ const RESTART_TARGETS = (process.env.PM2_RESTART_TARGETS || 'tssbot-web,tssbot-b
.map((target) => target.trim()) .map((target) => target.trim())
.filter((target) => /^[A-Za-z0-9_.:-]{1,80}$/.test(target)) .filter((target) => /^[A-Za-z0-9_.:-]{1,80}$/.test(target))
.filter(Boolean) .filter(Boolean)
// This webhook's own PM2 process name — never reload it during its own deploy.
const SELF_PM2_NAME = process.env.WEBHOOK_PM2_NAME || 'tssbot-webhook'
const DIST_DIR = path.join(__dirname, 'dist') const DIST_DIR = path.join(__dirname, 'dist')
const NEXT_DIST_DIR = path.join(__dirname, 'dist-next') const NEXT_DIST_DIR = path.join(__dirname, 'dist-next')
const PREVIOUS_DIST_DIR = path.join(__dirname, 'dist-previous') const PREVIOUS_DIST_DIR = path.join(__dirname, 'dist-previous')
@@ -348,6 +351,29 @@ function promoteBuiltDist() {
} }
} }
function syncVehicleIcons() {
// Point the served icon dir at the bot-managed SHARED/ICONS/VEHICLES so the
// scoreboard can render vehicle icons without bloating this repo. Symlink when
// possible; fall back to skipping (icons hide gracefully) if the source is absent.
const src = process.env.VEHICLE_ICONS_SRC || '/home/deploy/BOTS/SHARED/ICONS/VEHICLES'
const dst = path.resolve(
__dirname,
process.env.VEHICLE_ICONS_DIR || path.join('dist', 'vehicle-icons'),
)
if (!fs.existsSync(src)) {
console.warn(`vehicle icons source missing, skipping: ${src}`)
return
}
try {
fs.rmSync(dst, { recursive: true, force: true })
fs.mkdirSync(path.dirname(dst), { recursive: true })
fs.symlinkSync(src, dst)
console.log(`linked vehicle icons ${dst} -> ${src}`)
} catch (error) {
console.error(`vehicle icon sync failed: ${error.message}`)
}
}
function validateBuiltDist() { function validateBuiltDist() {
const indexPath = path.join(NEXT_DIST_DIR, 'index.html') const indexPath = path.join(NEXT_DIST_DIR, 'index.html')
if (!fs.existsSync(indexPath)) { if (!fs.existsSync(indexPath)) {
@@ -535,9 +561,23 @@ async function deploy(push) {
validateBuiltDist() validateBuiltDist()
await run('cargo', ['build', '--manifest-path', 'backend/Cargo.toml', '--release']) await run('cargo', ['build', '--manifest-path', 'backend/Cargo.toml', '--release'])
promoteBuiltDist() promoteBuiltDist()
syncVehicleIcons()
for (const target of RESTART_TARGETS) { // Reload via the ecosystem file (not by bare name) with --only so each deploy
await run('pm2', ['reload', target, '--update-env']) // re-reads the committed env blocks (e.g. VEHICLE_* paths). `pm2 reload <name>
// --update-env` would only merge the CLI's process.env and ignore the file.
// Exclude this webhook process itself: reloading it here kills the process
// running this deploy mid-command, interrupting the remaining reloads. The
// webhook is reloaded separately when its own code changes.
const reloadTargets = RESTART_TARGETS.filter((t) => t !== SELF_PM2_NAME)
if (reloadTargets.length) {
await run('pm2', [
'reload',
'ecosystem.config.cjs',
'--only',
reloadTargets.join(','),
'--update-env',
])
} }
await notifyDeployCompleted(push, diff) await notifyDeployCompleted(push, diff)