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