Files
TSSBOT-web/backend/src/main.rs
T

2108 lines
66 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use axum::{
extract::{Path, Query, State},
http::{header, HeaderValue, Method, StatusCode},
response::{IntoResponse, Response},
routing::get,
Json, Router,
};
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::{
collections::{BTreeMap, HashMap},
env, fs,
net::{IpAddr, Ipv4Addr, SocketAddr},
path::{Path as FsPath, PathBuf},
sync::{Arc, Mutex},
time::Duration,
};
use tokio::net::TcpListener;
use tower_http::{
cors::{AllowOrigin, CorsLayer},
trace::TraceLayer,
};
const MAX_TEAM_NAME_LENGTH: usize = 80;
const LEADERBOARD_CACHE_TTL: Duration = Duration::from_secs(300);
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 {
teams: Vec<TeamLeaderboardRow>,
}
#[derive(Debug)]
struct ApiError {
status: StatusCode,
message: String,
}
impl ApiError {
fn bad_request(message: impl Into<String>) -> Self {
Self {
status: StatusCode::BAD_REQUEST,
message: message.into(),
}
}
fn not_found(message: impl Into<String>) -> Self {
Self {
status: StatusCode::NOT_FOUND,
message: message.into(),
}
}
fn internal(message: impl Into<String>) -> Self {
Self {
status: StatusCode::INTERNAL_SERVER_ERROR,
message: message.into(),
}
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
(self.status, Json(json!({ "error": self.message }))).into_response()
}
}
type ApiResult<T> = Result<Json<T>, ApiError>;
#[derive(Deserialize)]
struct LimitQuery {
limit: Option<u32>,
}
#[derive(Deserialize)]
struct LangQuery {
lang: Option<String>,
}
#[derive(Deserialize)]
struct ResolveQuery {
name: String,
}
#[derive(Deserialize)]
struct SearchQuery {
q: Option<String>,
name: Option<String>,
limit: Option<u32>,
}
#[derive(Serialize)]
struct HealthResponse {
ok: bool,
service: &'static str,
battles_db: String,
teams_db: String,
databases: BTreeMap<&'static str, bool>,
}
#[derive(Clone, Serialize)]
struct LeaderboardResponse {
teams: Vec<TeamLeaderboardRow>,
}
#[derive(Serialize)]
struct PlayerLeaderboardResponse {
players: Vec<PlayerLeaderboardRow>,
}
#[derive(Serialize)]
struct RecentGamesResponse {
matches: Vec<GameRow>,
}
#[derive(Serialize)]
struct SearchResponse {
teams: Vec<TeamSearchRow>,
}
#[derive(Serialize)]
struct ResolveResponse {
team_id: i64,
name: String,
}
#[derive(Serialize)]
struct TeamSearchRow {
team_id: i64,
name: String,
members: i64,
}
#[derive(Clone, Serialize)]
struct TeamLeaderboardRow {
team_id: i64,
name: String,
player_count: i64,
total_battles: i64,
wins: i64,
losses: i64,
win_rate: f64,
total_kills: i64,
}
#[derive(Serialize)]
struct PlayerLeaderboardRow {
uid: String,
nick: Option<String>,
total_battles: i64,
wins: i64,
losses: i64,
win_rate: f64,
ground_kills: i64,
air_kills: i64,
total_kills: i64,
assists: i64,
captures: i64,
deaths: i64,
score: i64,
kdr: f64,
teams_seen: i64,
last_seen: i64,
}
#[derive(Serialize)]
struct TeamDetail {
team_id: i64,
name: String,
members: i64,
captain_uid: Option<String>,
data_set: &'static str,
team_summary: TeamSummary,
players: Vec<PlayerSummary>,
}
#[derive(Default, Serialize)]
struct TeamSummary {
player_count: i64,
total_battles: i64,
wins: i64,
losses: i64,
win_rate: f64,
kdr: f64,
total_kills: i64,
total_points: i64,
}
#[derive(Serialize)]
struct PlayerSummary {
uid: String,
nick: Option<String>,
role: String,
total_battles: i64,
wins: i64,
losses: i64,
win_rate: f64,
total_kills: i64,
ground_kills: i64,
air_kills: i64,
assists: i64,
deaths: i64,
kdr: f64,
}
#[derive(Serialize)]
struct HistoryResponse {
team_id: i64,
name: String,
history: Vec<PeriodHistory>,
}
#[derive(Serialize)]
struct PeriodHistory {
period: String,
battles: i64,
wins: i64,
losses: i64,
win_rate: f64,
}
#[derive(Serialize)]
struct GamesResponse {
team_id: i64,
name: String,
games: Vec<GameRow>,
}
#[derive(Serialize)]
struct GameResponse {
game: GameRow,
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")]
team_name: Option<String>,
session_id: String,
timestamp: i64,
endtime_unix: i64,
map_name: Option<String>,
mission_mode: Option<String>,
result: String,
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,
}
#[derive(Serialize)]
struct GameStats {
ground_kills: i64,
air_kills: i64,
assists: i64,
captures: i64,
deaths: i64,
score: i64,
missile_evades: i64,
shell_interceptions: i64,
team_kills_stat: i64,
}
#[derive(Serialize)]
struct GameParticipant {
team_name: String,
result: String,
player_count: i64,
stats: GameStats,
players: Vec<GamePlayer>,
}
#[derive(Serialize)]
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>,
}
#[derive(Serialize)]
struct PlayerRef {
uid: String,
nick: Option<String>,
}
#[derive(Serialize)]
struct PlayerProfile {
uid: String,
nick: Option<String>,
data_set: &'static str,
career: PlayerCareer,
teams: Vec<PlayerTeamRef>,
}
#[derive(Serialize)]
struct PlayerCareer {
total_battles: i64,
wins: i64,
losses: i64,
win_rate: f64,
ground_kills: i64,
air_kills: i64,
total_kills: i64,
assists: i64,
captures: i64,
deaths: i64,
kdr: f64,
}
#[derive(Serialize)]
struct PlayerTeamRef {
team_id: Option<i64>,
team_name: Option<String>,
games: i64,
last_seen: i64,
}
struct TeamRecord {
team_id: i64,
name: String,
members: i64,
captain_uid: Option<String>,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
load_root_env();
tracing_subscriber::fmt()
.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));
let port = env_u16("BACKEND_PORT")
.or_else(|| env_u16("PORT"))
.unwrap_or(6000);
let state = Arc::new(AppState {
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()
.route("/health", get(health))
.route("/api/tss/leaderboard/teams", get(leaderboard))
.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))
.route("/api/tss/teams/{team}/history", get(team_history))
.route("/api/tss/teams/{team}/games", get(team_games))
.route("/api/tss/players/resolve", get(resolve_player))
.route("/api/tss/players/search", get(search_players))
.route("/api/tss/player/{uid}", get(player_detail))
.layer(
CorsLayer::new()
.allow_methods([Method::GET])
.allow_origin(allowed_origins())
.allow_headers([header::ACCEPT, header::CONTENT_TYPE]),
)
.layer(TraceLayer::new_for_http())
.with_state(state);
let addr = SocketAddr::from((host, port));
let listener = TcpListener::bind(addr).await?;
tracing::info!("tssbot backend listening on http://{}", addr);
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
Ok(())
}
async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install signal handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
}
async fn health(State(state): State<Arc<AppState>>) -> Json<HealthResponse> {
let mut databases = BTreeMap::new();
databases.insert(
"battles",
Connection::open_with_flags(
&state.battles_db,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
)
.is_ok(),
);
databases.insert(
"teams",
Connection::open_with_flags(&state.teams_db, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)
.is_ok(),
);
Json(HealthResponse {
ok: databases.values().all(|ok| *ok),
service: "tssbot-backend",
battles_db: state.battles_db.display().to_string(),
teams_db: state.teams_db.display().to_string(),
databases,
})
}
async fn leaderboard(
State(state): State<Arc<AppState>>,
Query(query): Query<LimitQuery>,
) -> ApiResult<LeaderboardResponse> {
let limit = usize::try_from(query.limit.unwrap_or(100).clamp(1, 100)).unwrap_or(100);
if let Some(teams) = cached_leaderboard(&state, limit)? {
return Ok(Json(LeaderboardResponse { teams }));
}
let teams = leaderboard_roster_rows(&state, limit)?;
Ok(Json(LeaderboardResponse { teams }))
}
async fn player_leaderboard(
State(state): State<Arc<AppState>>,
Query(query): Query<LimitQuery>,
) -> ApiResult<PlayerLeaderboardResponse> {
let limit = i64::from(query.limit.unwrap_or(100).clamp(1, 100));
let battles_conn = open_db(&state.battles_db)?;
let players = player_leaderboard_rows(&battles_conn, limit)?;
Ok(Json(PlayerLeaderboardResponse { players }))
}
fn leaderboard_teams(state: &AppState, limit: usize) -> Result<Vec<TeamRecord>, ApiError> {
let teams_conn = open_db(&state.teams_db)?;
// Deduplicate teams by name across tournaments — pick the highest team_id
// (most recent) per name for the roster count, but stats come from team_name.
let mut stmt = teams_conn
.prepare(
"SELECT MAX(team_id), name, MAX(members), MAX(captain_uid)
FROM teams_data
GROUP BY name COLLATE NOCASE
ORDER BY MAX(members) DESC, name COLLATE NOCASE ASC
LIMIT ?1",
)
.map_err(db_error)?;
let teams = stmt
.query_map(params![limit as i64], |row| read_team_record(row))
.map_err(db_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(db_error)?;
Ok(teams)
}
fn leaderboard_roster_rows(
state: &AppState,
limit: usize,
) -> Result<Vec<TeamLeaderboardRow>, ApiError> {
leaderboard_teams(state, limit).map(|teams| {
teams
.into_iter()
.map(|team| TeamLeaderboardRow {
team_id: team.team_id,
name: team.name,
player_count: team.members,
total_battles: 0,
wins: 0,
losses: 0,
win_rate: 0.0,
total_kills: 0,
})
.collect()
})
}
fn leaderboard_rows(state: &AppState, limit: usize) -> Result<Vec<TeamLeaderboardRow>, ApiError> {
let battles_conn = open_db(&state.battles_db)?;
let teams = leaderboard_teams(state, limit)?;
let team_names = teams
.iter()
.map(|team| team.name.as_str())
.collect::<Vec<_>>();
let mut summaries = team_summaries_for(&battles_conn, &team_names)?;
let mut rows = Vec::with_capacity(teams.len());
for team in teams {
let summary = summaries
.remove(&team.name.to_ascii_lowercase())
.unwrap_or_default();
rows.push(TeamLeaderboardRow {
team_id: team.team_id,
name: team.name,
player_count: team.members,
total_battles: summary.total_battles,
wins: summary.wins,
losses: summary.losses,
win_rate: summary.win_rate,
total_kills: summary.total_kills,
});
}
Ok(rows)
}
fn spawn_leaderboard_refresh(state: Arc<AppState>) {
tokio::spawn(async move {
loop {
let refresh_state = state.clone();
match tokio::task::spawn_blocking(move || leaderboard_rows(&refresh_state, 100)).await {
Ok(Ok(rows)) => {
if let Err(error) = cache_leaderboard(&state, &rows) {
tracing::warn!("could not cache leaderboard refresh: {}", error.message);
}
}
Ok(Err(error)) => {
tracing::warn!("could not refresh leaderboard cache: {}", error.message);
}
Err(error) => {
tracing::warn!("leaderboard refresh task failed: {}", error);
}
}
tokio::time::sleep(LEADERBOARD_CACHE_TTL).await;
}
});
}
async fn recent_games(
State(state): State<Arc<AppState>>,
Query(query): Query<LimitQuery>,
) -> ApiResult<RecentGamesResponse> {
let limit = i64::from(query.limit.unwrap_or(50).clamp(1, 100));
let battles_conn = open_db(&state.battles_db)?;
let matches = recent_games_for(&battles_conn, limit)?;
Ok(Json(RecentGamesResponse { matches }))
}
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, &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>,
) -> ApiResult<ResolveResponse> {
let name = validate_team_name(&query.name)?;
let conn = open_db(&state.teams_db)?;
let team = find_team(&conn, name)?.ok_or_else(|| ApiError::not_found("Team not found"))?;
Ok(Json(ResolveResponse {
team_id: team.team_id,
name: team.name,
}))
}
async fn search_teams(
State(state): State<Arc<AppState>>,
Query(query): Query<SearchQuery>,
) -> ApiResult<SearchResponse> {
let raw = query.q.as_deref().or(query.name.as_deref()).unwrap_or("");
let name = validate_team_name(raw)?;
let limit = i64::from(query.limit.unwrap_or(10).clamp(1, 20));
let like = format!("%{}%", escape_like(name));
let conn = open_db(&state.teams_db)?;
let mut stmt = conn
.prepare(
"SELECT team_id, name, members
FROM teams_data
WHERE name LIKE ?1 ESCAPE '\\'
ORDER BY
CASE WHEN name = ?2 COLLATE NOCASE THEN 0 ELSE 1 END,
members DESC,
name COLLATE NOCASE ASC
LIMIT ?3",
)
.map_err(db_error)?;
let teams = stmt
.query_map(params![like, name, limit], |row| {
Ok(TeamSearchRow {
team_id: row.get(0)?,
name: row.get(1)?,
members: row.get(2)?,
})
})
.map_err(db_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(db_error)?;
Ok(Json(SearchResponse { teams }))
}
async fn team_detail(
State(state): State<Arc<AppState>>,
Path(team_name): Path<String>,
) -> ApiResult<TeamDetail> {
let decoded = decode_path_team(&team_name)?;
let teams_conn = open_db(&state.teams_db)?;
let battles_conn = open_db(&state.battles_db)?;
let team =
find_team(&teams_conn, &decoded)?.ok_or_else(|| ApiError::not_found("Team not found"))?;
let summary = team_summary_for(&battles_conn, &team.name)?;
// team_id is the most recent tournament entry — used only for the roster lookup.
let players = player_summaries_for(&teams_conn, &battles_conn, team.team_id, &team.name)?;
Ok(Json(TeamDetail {
team_id: team.team_id,
name: team.name,
members: team.members,
captain_uid: team.captain_uid,
data_set: "tss",
team_summary: summary,
players,
}))
}
async fn team_history(
State(state): State<Arc<AppState>>,
Path(team_name): Path<String>,
) -> ApiResult<HistoryResponse> {
let decoded = decode_path_team(&team_name)?;
let teams_conn = open_db(&state.teams_db)?;
let battles_conn = open_db(&state.battles_db)?;
let team =
find_team(&teams_conn, &decoded)?.ok_or_else(|| ApiError::not_found("Team not found"))?;
let history = period_history_for(&battles_conn, &team.name)?;
Ok(Json(HistoryResponse {
team_id: team.team_id,
name: team.name,
history,
}))
}
async fn team_games(
State(state): State<Arc<AppState>>,
Path(team_name): Path<String>,
) -> ApiResult<GamesResponse> {
let decoded = decode_path_team(&team_name)?;
let teams_conn = open_db(&state.teams_db)?;
let battles_conn = open_db(&state.battles_db)?;
let team =
find_team(&teams_conn, &decoded)?.ok_or_else(|| ApiError::not_found("Team not found"))?;
let games = games_for(&battles_conn, &team.name)?;
Ok(Json(GamesResponse {
team_id: team.team_id,
name: team.name,
games,
}))
}
async fn resolve_player(
State(state): State<Arc<AppState>>,
Query(query): Query<ResolveQuery>,
) -> ApiResult<PlayerSearchResponse> {
let name = validate_player_name(&query.name)?;
let conn = open_db(&state.battles_db)?;
let players = player_resolve(&conn, name)?;
if players.is_empty() {
return Err(ApiError::not_found("Player not found"));
}
Ok(Json(PlayerSearchResponse { players }))
}
async fn search_players(
State(state): State<Arc<AppState>>,
Query(query): Query<SearchQuery>,
) -> ApiResult<PlayerSearchResponse> {
let raw = query.q.as_deref().or(query.name.as_deref()).unwrap_or("");
let name = validate_player_name(raw)?;
let limit = i64::from(query.limit.unwrap_or(25).clamp(1, 25));
let conn = open_db(&state.battles_db)?;
let players = player_search(&conn, name, limit)?;
Ok(Json(PlayerSearchResponse { players }))
}
async fn player_detail(
State(state): State<Arc<AppState>>,
Path(uid): Path<String>,
) -> ApiResult<PlayerProfile> {
let uid = validate_uid(&uid)?;
let conn = open_db(&state.battles_db)?;
let career =
player_career_for(&conn, &uid)?.ok_or_else(|| ApiError::not_found("Player not found"))?;
let nick = latest_nick_for(&conn, &uid)?;
let teams = player_teams_for(&conn, &uid)?;
Ok(Json(PlayerProfile {
uid,
nick,
data_set: "tss",
career,
teams,
}))
}
fn open_db(path: &FsPath) -> Result<Connection, ApiError> {
Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY).map_err(|error| {
ApiError::internal(format!("Could not open {}: {}", path.display(), error))
})
}
fn db_error(error: rusqlite::Error) -> ApiError {
ApiError::internal(format!("Database query failed: {}", error))
}
fn read_team_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<TeamRecord> {
Ok(TeamRecord {
team_id: row.get(0)?,
name: row.get(1)?,
members: row.get(2)?,
captain_uid: row.get(3)?,
})
}
fn find_team(conn: &Connection, name: &str) -> Result<Option<TeamRecord>, ApiError> {
// Return the most recent tournament entry for this team (highest team_id).
conn.query_row(
"SELECT team_id, name, members, captain_uid
FROM teams_data
WHERE name = ?2 COLLATE NOCASE OR team_id = ?1
ORDER BY team_id DESC
LIMIT 1",
params![name.parse::<i64>().ok(), name],
read_team_record,
)
.optional()
.map_err(db_error)
}
fn team_summary_for(conn: &Connection, team_name: &str) -> Result<TeamSummary, ApiError> {
conn.query_row(
"SELECT
COUNT(DISTINCT session_id),
SUM(CASE WHEN victor_bool = 'Win' THEN 1 ELSE 0 END),
SUM(CASE WHEN victor_bool != 'Win' THEN 1 ELSE 0 END),
COALESCE(SUM(ground_kills), 0),
COALESCE(SUM(air_kills), 0),
COALESCE(SUM(assists), 0),
COALESCE(SUM(deaths), 0),
COALESCE(SUM(score), 0),
COUNT(DISTINCT UID)
FROM player_games_hist
WHERE team_name = ?1 COLLATE NOCASE",
params![team_name],
|row| {
let battles: i64 = row.get(0)?;
let wins: i64 = row.get(1)?;
let losses: i64 = row.get(2)?;
let ground: i64 = row.get(3)?;
let air: i64 = row.get(4)?;
let assists: i64 = row.get(5)?;
let deaths: i64 = row.get(6)?;
let score: i64 = row.get(7)?;
let player_count: i64 = row.get(8)?;
let total_kills = ground + air;
Ok(TeamSummary {
player_count,
total_battles: battles,
wins,
losses,
win_rate: percent(wins, battles),
kdr: ratio(total_kills, deaths),
total_kills,
total_points: score + assists + total_kills,
})
},
)
.map_err(db_error)
}
fn team_summaries_for(
conn: &Connection,
team_names: &[&str],
) -> Result<HashMap<String, TeamSummary>, ApiError> {
if team_names.is_empty() {
return Ok(HashMap::new());
}
let placeholders = std::iter::repeat("?")
.take(team_names.len())
.collect::<Vec<_>>()
.join(", ");
let sql = format!(
"SELECT
team_name,
COUNT(DISTINCT session_id),
SUM(CASE WHEN victor_bool = 'Win' THEN 1 ELSE 0 END),
SUM(CASE WHEN victor_bool != 'Win' THEN 1 ELSE 0 END),
COALESCE(SUM(ground_kills), 0),
COALESCE(SUM(air_kills), 0),
COALESCE(SUM(assists), 0),
COALESCE(SUM(deaths), 0),
COALESCE(SUM(score), 0),
COUNT(DISTINCT UID)
FROM player_games_hist
WHERE team_name COLLATE NOCASE IN ({placeholders})
GROUP BY team_name COLLATE NOCASE"
);
let mut stmt = conn.prepare(&sql).map_err(db_error)?;
let summaries = stmt
.query_map(rusqlite::params_from_iter(team_names.iter()), |row| {
let name: String = row.get(0)?;
let battles: i64 = row.get(1)?;
let wins: i64 = row.get(2)?;
let losses: i64 = row.get(3)?;
let ground: i64 = row.get(4)?;
let air: i64 = row.get(5)?;
let assists: i64 = row.get(6)?;
let deaths: i64 = row.get(7)?;
let score: i64 = row.get(8)?;
let player_count: i64 = row.get(9)?;
let total_kills = ground + air;
Ok((
name.to_ascii_lowercase(),
TeamSummary {
player_count,
total_battles: battles,
wins,
losses,
win_rate: percent(wins, battles),
kdr: ratio(total_kills, deaths),
total_kills,
total_points: score + assists + total_kills,
},
))
})
.map_err(db_error)?
.collect::<Result<HashMap<_, _>, _>>()
.map_err(db_error)?;
Ok(summaries)
}
fn cached_leaderboard(
state: &AppState,
limit: usize,
) -> Result<Option<Vec<TeamLeaderboardRow>>, ApiError> {
let cache = state
.leaderboard_cache
.lock()
.map_err(|_| ApiError::internal("Leaderboard cache lock failed"))?;
Ok(cache.as_ref().and_then(|cached| {
(cached.teams.len() >= limit).then(|| cached.teams.iter().take(limit).cloned().collect())
}))
}
fn cache_leaderboard(state: &AppState, teams: &[TeamLeaderboardRow]) -> Result<(), ApiError> {
let mut cache = state
.leaderboard_cache
.lock()
.map_err(|_| ApiError::internal("Leaderboard cache lock failed"))?;
*cache = Some(CachedLeaderboard {
teams: teams.to_vec(),
});
Ok(())
}
fn player_summaries_for(
teams_conn: &Connection,
battles_conn: &Connection,
// team_id: roster for this specific tournament entry only
team_id: i64,
// team_name: cross-tournament stats key
team_name: &str,
) -> Result<Vec<PlayerSummary>, ApiError> {
let mut stmt = teams_conn
.prepare(
"SELECT uid, role
FROM team_members
WHERE team_id = ?1
ORDER BY uid",
)
.map_err(db_error)?;
let members = stmt
.query_map(params![team_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.map_err(db_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(db_error)?;
let mut stats_stmt = battles_conn
.prepare(
"SELECT
UID,
COUNT(DISTINCT session_id),
SUM(CASE WHEN victor_bool = 'Win' THEN 1 ELSE 0 END),
SUM(CASE WHEN victor_bool != 'Win' THEN 1 ELSE 0 END),
COALESCE(SUM(ground_kills), 0),
COALESCE(SUM(air_kills), 0),
COALESCE(SUM(assists), 0),
COALESCE(SUM(deaths), 0),
(SELECT nick FROM player_games_hist
WHERE UID = p.UID AND nick IS NOT NULL
ORDER BY endtime_unix DESC LIMIT 1)
FROM player_games_hist p
WHERE team_name = ?1 COLLATE NOCASE
GROUP BY UID",
)
.map_err(db_error)?;
let mut summaries = stats_stmt
.query_map(params![team_name], |row| {
let uid: String = row.get(0)?;
let battles: i64 = row.get(1)?;
let wins: i64 = row.get(2)?;
let losses: i64 = row.get(3)?;
let ground: i64 = row.get(4)?;
let air: i64 = row.get(5)?;
let assists: i64 = row.get(6)?;
let deaths: i64 = row.get(7)?;
let nick: Option<String> = row.get(8)?;
let total_kills = ground + air;
Ok((
uid.clone(),
PlayerSummary {
uid,
nick,
role: String::new(),
total_battles: battles,
wins,
losses,
win_rate: percent(wins, battles),
total_kills,
ground_kills: ground,
air_kills: air,
assists,
deaths,
kdr: ratio(total_kills, deaths),
},
))
})
.map_err(db_error)?
.collect::<Result<HashMap<_, _>, _>>()
.map_err(db_error)?;
let mut out = Vec::with_capacity(members.len());
for (uid, role) in members {
let mut summary = summaries.remove(&uid).unwrap_or(PlayerSummary {
uid,
nick: None,
role: String::new(),
total_battles: 0,
wins: 0,
losses: 0,
win_rate: 0.0,
total_kills: 0,
ground_kills: 0,
air_kills: 0,
assists: 0,
deaths: 0,
kdr: 0.0,
});
summary.role = role;
out.push(summary);
}
out.sort_by(|a, b| b.total_battles.cmp(&a.total_battles));
Ok(out)
}
fn player_leaderboard_rows(
conn: &Connection,
limit: i64,
) -> Result<Vec<PlayerLeaderboardRow>, ApiError> {
let mut stmt = conn
.prepare(
"WITH per_session AS (
SELECT
UID,
session_id,
MAX(victor_bool) AS victor_bool,
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(endtime_unix) AS endtime_unix,
MAX(team_name) AS team_name
FROM player_games_hist
WHERE UID IS NOT NULL AND UID != ''
GROUP BY UID, session_id
),
latest_names AS (
SELECT UID, nick
FROM (
SELECT
UID,
nick,
ROW_NUMBER() OVER (
PARTITION BY UID
ORDER BY endtime_unix DESC, nick COLLATE NOCASE ASC
) AS rn
FROM player_games_hist
WHERE nick IS NOT NULL AND nick != ''
)
WHERE rn = 1
)
SELECT
p.UID,
n.nick,
COUNT(*) AS battles,
COALESCE(SUM(CASE WHEN UPPER(p.victor_bool) = 'WIN' THEN 1 ELSE 0 END), 0) AS wins,
COALESCE(SUM(CASE WHEN UPPER(p.victor_bool) = 'LOSS' THEN 1 ELSE 0 END), 0) AS losses,
COALESCE(SUM(p.ground_kills), 0) AS ground_kills,
COALESCE(SUM(p.air_kills), 0) AS air_kills,
COALESCE(SUM(p.assists), 0) AS assists,
COALESCE(SUM(p.captures), 0) AS captures,
COALESCE(SUM(p.deaths), 0) AS deaths,
COALESCE(SUM(p.score), 0) AS score,
COUNT(DISTINCT p.team_name) AS teams_seen,
MAX(p.endtime_unix) AS last_seen
FROM per_session p
LEFT JOIN latest_names n ON n.UID = p.UID
GROUP BY p.UID
ORDER BY score DESC, (ground_kills + air_kills) DESC, battles DESC, p.UID ASC
LIMIT ?1",
)
.map_err(db_error)?;
let players = stmt
.query_map(params![limit], |row| {
let ground: i64 = row.get(5)?;
let air: i64 = row.get(6)?;
let battles: i64 = row.get(2)?;
let wins: i64 = row.get(3)?;
let deaths: i64 = row.get(9)?;
let total_kills = ground + air;
Ok(PlayerLeaderboardRow {
uid: row.get(0)?,
nick: row.get(1)?,
total_battles: battles,
wins,
losses: row.get(4)?,
win_rate: percent(wins, battles),
ground_kills: ground,
air_kills: air,
total_kills,
assists: row.get(7)?,
captures: row.get(8)?,
deaths,
score: row.get(10)?,
kdr: ratio(total_kills, deaths),
teams_seen: row.get(11)?,
last_seen: row.get(12)?,
})
})
.map_err(db_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(db_error)?;
Ok(players)
}
fn player_search(conn: &Connection, query: &str, limit: i64) -> Result<Vec<PlayerRef>, ApiError> {
let like = format!("%{}%", escape_like(query));
let mut stmt = conn
.prepare(
"SELECT UID, MIN(nick) AS nick, MAX(endtime_unix) AS last_seen
FROM player_games_hist
WHERE nick LIKE ?1 ESCAPE '\\' COLLATE NOCASE
GROUP BY UID
ORDER BY
CASE WHEN MIN(nick) = ?2 COLLATE NOCASE THEN 0 ELSE 1 END,
last_seen DESC
LIMIT ?3",
)
.map_err(db_error)?;
let players = stmt
.query_map(params![like, query, limit], |row| {
Ok(PlayerRef {
uid: row.get(0)?,
nick: row.get(1)?,
})
})
.map_err(db_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(db_error)?;
Ok(players)
}
fn player_resolve(conn: &Connection, name: &str) -> Result<Vec<PlayerRef>, ApiError> {
// Exact nick match first; fall back to substring search.
let mut stmt = conn
.prepare(
"SELECT UID, MIN(nick) AS nick
FROM player_games_hist
WHERE nick = ?1 COLLATE NOCASE
GROUP BY UID
ORDER BY nick
LIMIT 25",
)
.map_err(db_error)?;
let exact = stmt
.query_map(params![name], |row| {
Ok(PlayerRef {
uid: row.get(0)?,
nick: row.get(1)?,
})
})
.map_err(db_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(db_error)?;
if !exact.is_empty() {
return Ok(exact);
}
player_search(conn, name, 25)
}
fn latest_nick_for(conn: &Connection, uid: &str) -> Result<Option<String>, ApiError> {
conn.query_row(
"SELECT nick FROM player_games_hist WHERE UID = ?1 ORDER BY endtime_unix DESC LIMIT 1",
params![uid],
|row| row.get::<_, Option<String>>(0),
)
.optional()
.map_err(db_error)
.map(|opt| opt.flatten())
}
fn player_career_for(conn: &Connection, uid: &str) -> Result<Option<PlayerCareer>, ApiError> {
// Player totals repeat across a player's per-vehicle rows for a session, so
// collapse to one value per session (MAX) before summing.
conn.query_row(
"SELECT
COUNT(*) AS battles,
COALESCE(SUM(CASE WHEN UPPER(victor_bool) = 'WIN' THEN 1 ELSE 0 END), 0) AS wins,
COALESCE(SUM(CASE WHEN UPPER(victor_bool) = 'LOSS' THEN 1 ELSE 0 END), 0) AS losses,
COALESCE(SUM(gk), 0), COALESCE(SUM(ak), 0),
COALESCE(SUM(asi), 0), COALESCE(SUM(cap), 0), COALESCE(SUM(de), 0)
FROM (
SELECT session_id,
MAX(victor_bool) AS victor_bool,
MAX(ground_kills) AS gk, MAX(air_kills) AS ak,
MAX(assists) AS asi, MAX(captures) AS cap, MAX(deaths) AS de
FROM player_games_hist
WHERE UID = ?1
GROUP BY session_id
)",
params![uid],
|row| {
let battles: i64 = row.get(0)?;
let wins: i64 = row.get(1)?;
let losses: i64 = row.get(2)?;
let ground: i64 = row.get(3)?;
let air: i64 = row.get(4)?;
let assists: i64 = row.get(5)?;
let captures: i64 = row.get(6)?;
let deaths: i64 = row.get(7)?;
let total_kills = ground + air;
Ok((
battles,
wins,
losses,
ground,
air,
assists,
captures,
deaths,
total_kills,
))
},
)
.optional()
.map_err(db_error)
.map(|opt| {
opt.and_then(
|(battles, wins, losses, ground, air, assists, captures, deaths, total_kills)| {
if battles == 0 {
None
} else {
Some(PlayerCareer {
total_battles: battles,
wins,
losses,
win_rate: percent(wins, battles),
ground_kills: ground,
air_kills: air,
total_kills,
assists,
captures,
deaths,
kdr: ratio(total_kills, deaths),
})
}
},
)
})
}
fn player_teams_for(conn: &Connection, uid: &str) -> Result<Vec<PlayerTeamRef>, ApiError> {
let mut stmt = conn
.prepare(
"SELECT team_id, MAX(team_name) AS team_name,
COUNT(DISTINCT session_id) AS games, MAX(endtime_unix) AS last_seen
FROM player_games_hist
WHERE UID = ?1 AND team_id IS NOT NULL
GROUP BY team_id
ORDER BY last_seen DESC",
)
.map_err(db_error)?;
let teams = stmt
.query_map(params![uid], |row| {
Ok(PlayerTeamRef {
team_id: row.get(0)?,
team_name: row.get(1)?,
games: row.get(2)?,
last_seen: row.get(3)?,
})
})
.map_err(db_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(db_error)?;
Ok(teams)
}
fn period_history_for(conn: &Connection, team_name: &str) -> Result<Vec<PeriodHistory>, ApiError> {
let mut stmt = conn
.prepare(
"SELECT
strftime('%Y-%m', endtime_unix, 'unixepoch') AS period,
COUNT(DISTINCT session_id),
SUM(CASE WHEN victor_bool = 'Win' THEN 1 ELSE 0 END),
SUM(CASE WHEN victor_bool != 'Win' THEN 1 ELSE 0 END)
FROM player_games_hist
WHERE team_name = ?1 COLLATE NOCASE AND endtime_unix > 0
GROUP BY period
ORDER BY period ASC",
)
.map_err(db_error)?;
let rows = stmt
.query_map(params![team_name], |row| {
let period: String = row.get(0)?;
let battles: i64 = row.get(1)?;
let wins: i64 = row.get(2)?;
let losses: i64 = row.get(3)?;
Ok(PeriodHistory {
period,
battles,
wins,
losses,
win_rate: percent(wins, battles),
})
})
.map_err(db_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(db_error)?;
Ok(rows)
}
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(
"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,
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 = pp.session_id
AND pg.team_name IS NOT NULL
AND pg.team_name != ''
AND pg.victor_bool = 'Win'
GROUP BY pg.team_name COLLATE NOCASE
ORDER BY COUNT(DISTINCT pg.UID) DESC, pg.team_name COLLATE NOCASE
LIMIT 1),
(SELECT pg.team_name
FROM player_games_hist pg
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 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",
)
.map_err(db_error)?;
let rows = stmt
.query_map(params![team_name], |row| {
let session_id: String = row.get(0)?;
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,
timestamp,
endtime_unix: timestamp,
map_name,
mission_mode,
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(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)?,
},
})
})
.map_err(db_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(db_error)?;
Ok(rows)
}
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 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.session_id,
COALESCE(m.endtime_unix, r.timestamp, 0) AS timestamp,
m.mission_name,
m.mission_mode,
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
AND pg.team_name IS NOT NULL
AND pg.team_name != ''
AND pg.victor_bool = 'Win'
GROUP BY pg.team_name COLLATE NOCASE
ORDER BY COUNT(DISTINCT pg.UID) DESC, pg.team_name COLLATE NOCASE
LIMIT 1),
(SELECT pg.team_name
FROM player_games_hist pg
WHERE pg.session_id = r.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 recent r
LEFT JOIN match_summary m ON m.session_id = r.session_id
ORDER BY timestamp DESC
LIMIT ?1",
)
.map_err(db_error)?;
let rows = stmt
.query_map(params![limit], |row| {
let timestamp: i64 = row.get(1)?;
let draw_int: i64 = row.get(6)?;
Ok(GameRow {
team_name: None,
session_id: row.get(0)?,
timestamp,
endtime_unix: timestamp,
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: 0,
air_kills: 0,
assists: 0,
captures: 0,
deaths: 0,
score: 0,
missile_evades: 0,
shell_interceptions: 0,
team_kills_stat: 0,
},
})
})
.map_err(db_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(db_error)?;
Ok(rows)
}
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
?1 AS session_id,
COALESCE(m.endtime_unix, agg.timestamp, 0) AS timestamp,
m.mission_name,
m.mission_mode,
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 = ?1
AND pg.team_name IS NOT NULL
AND pg.team_name != ''
AND pg.victor_bool = 'Win'
GROUP BY pg.team_name COLLATE NOCASE
ORDER BY COUNT(DISTINCT pg.UID) DESC, pg.team_name COLLATE NOCASE
LIMIT 1),
(SELECT pg.team_name
FROM player_games_hist pg
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 (
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)?,
timestamp,
endtime_unix: timestamp,
map_name: row.get(2)?,
mission_mode: row.get(3)?,
result: String::new(),
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(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)?,
},
})
},
)
.optional()
.map_err(db_error)
}
fn game_participants_for(
conn: &Connection,
session_id: &str,
state: &AppState,
lang: &str,
) -> Result<Vec<GameParticipant>, ApiError> {
let mut stmt = conn
.prepare(
"SELECT
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(pp.won) = 1 THEN 0 ELSE 1 END,
pp.team_name COLLATE NOCASE",
)
.map_err(db_error)?;
let mut participants = stmt
.query_map(params![session_id], |row| {
Ok(GameParticipant {
team_name: row.get(0)?,
result: row.get(1)?,
player_count: row.get(2)?,
stats: GameStats {
ground_kills: row.get(3)?,
air_kills: row.get(4)?,
assists: row.get(5)?,
captures: row.get(6)?,
deaths: row.get(7)?,
score: row.get(8)?,
missile_evades: row.get(9)?,
shell_interceptions: row.get(10)?,
team_kills_stat: row.get(11)?,
},
players: Vec::new(),
})
})
.map_err(db_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(db_error)?;
for participant in &mut participants {
participant.players =
game_players_for(conn, session_id, &participant.team_name, state, lang)?;
}
Ok(participants)
}
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
p.UID,
(SELECT pg.nick
FROM player_games_hist pg
WHERE pg.session_id = p.session_id
AND pg.UID = p.UID
AND pg.nick IS NOT NULL
AND pg.nick != ''
ORDER BY pg.endtime_unix DESC
LIMIT 1),
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 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)?,
assists: row.get(4)?,
captures: row.get(5)?,
deaths: row.get(6)?,
score: row.get(7)?,
missile_evades: row.get(8)?,
shell_interceptions: row.get(9)?,
team_kills_stat: row.get(10)?,
},
})
})
.map_err(db_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(db_error)?;
Ok(players)
}
fn validate_team_name(name: &str) -> Result<&str, ApiError> {
let trimmed = name.trim();
if trimmed.len() < 2 || trimmed.len() > MAX_TEAM_NAME_LENGTH {
return Err(ApiError::bad_request(
"Team name must be 2 to 80 characters",
));
}
Ok(trimmed)
}
fn validate_player_name(name: &str) -> Result<&str, ApiError> {
let trimmed = name.trim();
if trimmed.len() < 2 || trimmed.len() > MAX_TEAM_NAME_LENGTH {
return Err(ApiError::bad_request(
"Player name must be 2 to 80 characters",
));
}
Ok(trimmed)
}
fn validate_uid(value: &str) -> Result<String, ApiError> {
let trimmed = value.trim();
if trimmed.is_empty() || trimmed.len() > 32 || !trimmed.chars().all(|c| c.is_ascii_digit()) {
return Err(ApiError::bad_request("Invalid player UID"));
}
Ok(trimmed.to_string())
}
fn validate_session_id(value: &str) -> Result<&str, ApiError> {
if value.is_empty()
|| value.len() > 96
|| !value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
{
return Err(ApiError::bad_request("Invalid game ID"));
}
Ok(value)
}
fn decode_path_team(value: &str) -> Result<String, ApiError> {
let decoded = urlencoding::decode(value)
.map_err(|_| ApiError::bad_request("Invalid team name"))?
.into_owned();
validate_team_name(&decoded)?;
Ok(decoded)
}
fn escape_like(value: &str) -> String {
value
.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_")
}
fn percent(part: i64, total: i64) -> f64 {
if total <= 0 {
0.0
} else {
(part as f64 / total as f64) * 100.0
}
}
fn ratio(top: i64, bottom: i64) -> f64 {
if bottom <= 0 {
top as f64
} else {
top as f64 / bottom as f64
}
}
fn env_u16(key: &str) -> Option<u16> {
env::var(key).ok()?.parse().ok()
}
fn env_ip(key: &str) -> Option<IpAddr> {
env::var(key).ok()?.parse().ok()
}
fn allowed_origins() -> AllowOrigin {
let origins = env::var("BACKEND_ALLOWED_ORIGINS")
.or_else(|_| env::var("PUBLIC_ORIGIN"))
.unwrap_or_default()
.split(',')
.filter_map(|origin| {
let origin = origin.trim();
if origin.is_empty() {
return None;
}
HeaderValue::from_str(origin).ok()
})
.collect::<Vec<_>>();
if origins.is_empty() {
AllowOrigin::list(Vec::<HeaderValue>::new())
} else {
AllowOrigin::list(origins)
}
}
// 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);
let path = PathBuf::from(expanded);
if path.is_absolute() {
path
} else {
env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(path)
}
}
fn expand_home(value: &str) -> String {
if value == "~" {
return home_dir()
.map(|p| p.display().to_string())
.unwrap_or_else(|| value.to_string());
}
if let Some(rest) = value
.strip_prefix("~/")
.or_else(|| value.strip_prefix("~\\"))
{
if let Some(home) = home_dir() {
return home.join(rest).display().to_string();
}
}
value.to_string()
}
fn home_dir() -> Option<PathBuf> {
env::var_os("HOME")
.or_else(|| env::var_os("USERPROFILE"))
.map(PathBuf::from)
}
fn load_root_env() {
let candidates = [
env::current_dir().ok().map(|path| path.join(".env")),
env::current_dir()
.ok()
.and_then(|path| path.parent().map(|parent| parent.join(".env"))),
Some(
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join(".env"),
),
];
for candidate in candidates.into_iter().flatten() {
if candidate.exists() {
load_env_file(&candidate);
return;
}
}
}
fn load_env_file(path: &FsPath) {
let Ok(contents) = fs::read_to_string(path) else {
return;
};
for line in contents.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let Some((key, value)) = trimmed.split_once('=') else {
continue;
};
let key = key.trim();
if key.is_empty() || env::var_os(key).is_some() {
continue;
}
let value = value
.trim()
.trim_matches('"')
.trim_matches('\'')
.to_string();
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);
}
}