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:
- `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
+19 -1
View File
@@ -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 `<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:
- `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`
+413 -140
View File
@@ -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<Option<CachedLeaderboard>>,
vehicle_names: HashMap<String, HashMap<String, String>>,
vehicle_icons: HashMap<String, String>,
}
struct CachedLeaderboard {
@@ -77,6 +79,11 @@ struct LimitQuery {
limit: Option<u32>,
}
#[derive(Deserialize)]
struct LangQuery {
lang: Option<String>,
}
#[derive(Deserialize)]
struct ResolveQuery {
name: String,
@@ -232,6 +239,13 @@ struct GameResponse {
participants: Vec<GameParticipant>,
}
#[derive(Serialize)]
struct GameLogsResponse {
chat_log: Vec<String>,
battle_log: Vec<String>,
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<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,
}
@@ -274,9 +293,17 @@ struct GameParticipant {
struct GamePlayer {
uid: String,
nick: Option<String>,
vehicles: Vec<Vehicle>,
stats: GameStats,
}
#[derive(Serialize)]
struct Vehicle {
cdk: String,
name: String,
icon: String,
}
#[derive(Serialize)]
struct PlayerSearchResponse {
players: Vec<PlayerRef>,
@@ -331,7 +358,10 @@ struct TeamRecord {
async fn main() -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
.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<Arc<AppState>>,
Path(session_id): Path<String>,
Query(query): Query<LangQuery>,
) -> ApiResult<GameResponse> {
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<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(
State(state): State<Arc<AppState>>,
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> {
// 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<Vec<GameRow>, 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<Vec<GameRow>, ApiErro
let timestamp: i64 = row.get(1)?;
let map_name: Option<String> = row.get(2)?;
let mission_mode: Option<String> = 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<Vec<GameRow>, 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<Vec<GameRow>, ApiErro
}
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
.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<Vec<GameRow>, 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<Vec<GameRow>, 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<Vec<GameRow>, ApiEr
}
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(
"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<Option<GameRow>, 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<Option<GameRow>, 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<Option<GameRow>, ApiE
fn game_participants_for(
conn: &Connection,
session_id: &str,
state: &AppState,
lang: &str,
) -> Result<Vec<GameParticipant>, 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<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
.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<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 {
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<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 {
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);
}
}
+5
View File
@@ -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',
},
},
],
+12
View File
@@ -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
Binary file not shown.
+351 -44
View File
@@ -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 <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 flex-wrap gap-x-3 gap-y-1">
<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 (
<div className={`flex min-w-0 flex-wrap gap-x-3 gap-y-1${spread ? ' justify-between' : ''}`}>
{participants.map((participant) => (
<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}`}
>
{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 }) {
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 (
<section className="space-y-6 pt-24 sm:pt-28">
<button
@@ -2943,78 +3114,115 @@ function GamePage({ gameId, navigate }) {
</button>
<div className="rounded-lg border border-border bg-fury-white p-6 shadow-sm">
<div className="flex flex-wrap items-baseline gap-x-3">
<p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">Game</p>
<h1 className="mt-1 text-4xl font-bold">{game?.map_name || 'Battle log'}</h1>
<p className="mt-2 break-all text-sm text-text-soft">
{game ? `${formatDate(game.timestamp)} · ${game.session_id}` : gameId}
<span className="break-all text-xs text-text-muted opacity-70">{game?.session_id || gameId}</span>
</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>
<div className="mt-3">
<ParticipantNames participants={participantNames} />
</div>
{gameState.status === 'error' ? (
<p className="mt-4 text-sm text-danger">{gameState.error}</p>
) : null}
</div>
<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="min-w-[760px]">
<div className="min-w-[720px]">
{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 className="text-right">Players</p>
<p className="text-right">Ground</p>
<p className="text-right">Air</p>
<p className="text-right">Assists</p>
<p className="text-right">Score</p>
<p className="text-right">Deaths</p>
<p className="text-center">Air</p>
<p className="text-center">Ground</p>
<p className="text-center">Assists</p>
<p className="text-center">Deaths</p>
<p className="text-center">Caps</p>
<p className="text-center">Score</p>
</div>
) : null}
{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 (
<div className="border-b border-surface" key={participant.team_name}>
<div className={`border-b-4 border-border border-l-4 ${accent}`} key={participant.team_name}>
<button
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"
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))}
type="button"
>
<p className={`truncate font-semibold ${won ? 'text-fury-violet' : 'text-text-soft'}`}>
<div className="min-w-0">
<p className={`truncate font-bold ${nameColor}`}>
{participant.team_name}
</p>
<p className="text-right text-sm">{formatNumber(participant.player_count)}</p>
<p className="text-right text-sm">{formatNumber(participant.stats?.ground_kills)}</p>
<p className="text-right text-sm">{formatNumber(participant.stats?.air_kills)}</p>
<p className="text-right text-sm">{formatNumber(participant.stats?.assists)}</p>
<p className="text-right text-sm text-text-muted">-</p>
<p className="text-right text-sm">{formatNumber(participant.stats?.deaths)}</p>
<p className={`text-xs font-semibold uppercase tracking-wide ${nameColor}`}>
{won ? 'Win' : 'Loss'} · {formatNumber(participant.player_count)} players
</p>
</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) => (
<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}
onClick={() => navigate(playerPath(player.uid))}
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="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">
{(player.vehicles || []).length ? (
player.vehicles.map((vehicle) => {
const isDead = deadVehicleKeys.has(deadVehicleKey(player.uid, vehicle.cdk))
return (
<span
className={`vehicle-name inline-flex items-center gap-1 text-xs text-text-soft transition-opacity ${isDead ? 'vehicle-dead' : ''}`}
key={vehicle.cdk}
>
<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>
<p className="text-right text-text-muted">-</p>
<p className="text-right">{formatNumber(player.stats?.ground_kills)}</p>
<p className="text-right">{formatNumber(player.stats?.air_kills)}</p>
<p className="text-right">{formatNumber(player.stats?.assists)}</p>
<p className="text-right">{formatNumber(player.stats?.score)}</p>
<p className="text-right">{formatNumber(player.stats?.deaths)}</p>
</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>
@@ -3030,10 +3238,109 @@ function GamePage({ gameId, navigate }) {
) : null}
</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>
)
}
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 }) {
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 }) {
<div className="overflow-hidden rounded-lg border border-border bg-fury-white shadow-sm">
{matches.map((match) => (
<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}
onClick={() => navigate(gamePath(match.session_id))}
type="button"
@@ -3135,7 +3442,7 @@ function BattleLogsPage({ live, matches, navigate }) {
{formatDate(match.timestamp)} · {match.session_id}
</p>
</div>
<ParticipantNames participants={gameParticipants(match)} />
<ParticipantNames participants={gameParticipants(match)} spread />
<p className="text-sm">{formatMatchSize(match.player_count)}</p>
</button>
))}
File diff suppressed because it is too large Load Diff
+467
View File
@@ -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%);
+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 { execFile } = require('node:child_process')
const crypto = require('node:crypto')
const http = require('node:http')
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_TOKEN_MAX_AGE_MS = 5 * 60 * 1000
const DIST_DIR = path.join(__dirname, 'dist')
const VEHICLE_ICONS_DIR = path.resolve(
__dirname,
process.env.VEHICLE_ICONS_DIR || path.join('dist', 'vehicle-icons'),
)
const 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_CACHE_ENTRIES = 200
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)) {
const keys = [...params.keys()]
const lang = params.get('lang') || 'en'
if (keys.some((key) => key !== 'lang') || !/^[A-Za-z-]{2,8}$/.test(lang)) {
return null
}
return url
}
if (/^\/api\/tss\/games\/[A-Za-z0-9_-]{1,96}\/logs$/.test(pathname)) {
if ([...params.keys()].length) return null
return url
}
@@ -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) => {
if (req.method === 'GET' && req.url === '/robots.txt') {
sendRobotsTxt(req, res)
@@ -2167,6 +2436,36 @@ const server = http.createServer((req, res) => {
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/')) {
proxyRequest(req, res)
return
+6
View File
@@ -27,6 +27,12 @@ function isAllowedApiUrl(req) {
}
if (/^\/api\/tss\/games\/[A-Za-z0-9_-]{1,96}$/.test(url.pathname)) {
const keys = [...params.keys()]
const lang = params.get('lang') || 'en'
return keys.every((key) => key === 'lang') && /^[A-Za-z-]{2,8}$/.test(lang)
}
if (/^\/api\/tss\/games\/[A-Za-z0-9_-]{1,96}\/logs$/.test(url.pathname)) {
return [...params.keys()].length === 0
}
+42 -2
View File
@@ -45,6 +45,9 @@ const RESTART_TARGETS = (process.env.PM2_RESTART_TARGETS || 'tssbot-web,tssbot-b
.map((target) => target.trim())
.filter((target) => /^[A-Za-z0-9_.:-]{1,80}$/.test(target))
.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 NEXT_DIST_DIR = path.join(__dirname, 'dist-next')
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() {
const indexPath = path.join(NEXT_DIST_DIR, 'index.html')
if (!fs.existsSync(indexPath)) {
@@ -535,9 +561,23 @@ async function deploy(push) {
validateBuiltDist()
await run('cargo', ['build', '--manifest-path', 'backend/Cargo.toml', '--release'])
promoteBuiltDist()
syncVehicleIcons()
for (const target of RESTART_TARGETS) {
await run('pm2', ['reload', target, '--update-env'])
// Reload via the ecosystem file (not by bare name) with --only so each deploy
// 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)