ai generated solutions to our ai generated problems
This commit is contained in:
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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%);
|
||||
|
||||
Executable
+217
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user