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

1634 lines
50 KiB
Rust

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;
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>>,
}
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 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 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 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 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>,
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,
}
#[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::from_default_env())
.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),
});
spawn_leaderboard_refresh(state.clone());
let app = Router::new()
.route("/health", get(health))
.route("/api/tss/leaderboard/teams", get(leaderboard))
.route("/api/tss/games/recent", get(recent_games))
.route("/api/tss/games/{session_id}", get(game_detail))
.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 }))
}
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>,
) -> ApiResult<GameResponse> {
let session_id = validate_session_id(&session_id)?;
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)?;
Ok(Json(GameResponse { game, participants }))
}
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_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> {
let mut stmt = conn
.prepare(
"SELECT
p.session_id,
COALESCE(m.endtime_unix, MAX(p.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),
(SELECT pg.team_name
FROM player_games_hist pg
WHERE pg.session_id = p.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 = p.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
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)?;
Ok(GameRow {
team_name: None,
session_id,
timestamp,
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)?,
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)?,
},
})
})
.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> {
let mut stmt = conn
.prepare(
"WITH recent AS (
SELECT team_name, 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
)
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),
(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
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",
)
.map_err(db_error)?;
let rows = stmt
.query_map(params![limit], |row| {
let timestamp: i64 = row.get(2)?;
Ok(GameRow {
team_name: row.get(0)?,
session_id: row.get(1)?,
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)?,
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)?,
},
})
})
.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> {
conn.query_row(
"SELECT
p.session_id,
COALESCE(m.endtime_unix, MAX(p.endtime_unix), 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),
(SELECT pg.team_name
FROM player_games_hist pg
WHERE pg.session_id = p.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 = p.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.session_id = ?1
GROUP BY p.session_id",
params![session_id],
|row| {
let timestamp: i64 = row.get(1)?;
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(4)?,
winning_team: row.get(14)?,
losing_team: row.get(15)?,
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)?,
},
})
},
)
.optional()
.map_err(db_error)
}
fn game_participants_for(
conn: &Connection,
session_id: &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
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",
)
.map_err(db_error)?;
let rows = 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)?,
},
})
})
.map_err(db_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(db_error)?;
Ok(rows)
}
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)
}
}
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);
}
}