2429 lines
76 KiB
Rust
2429 lines
76 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, Value};
|
||
use std::{
|
||
collections::{BTreeMap, HashMap, HashSet},
|
||
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,
|
||
tournaments_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 TournamentsResponse {
|
||
tournaments: Vec<TournamentSummary>,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
struct TournamentSummary {
|
||
tournament_id: i64,
|
||
name: Option<String>,
|
||
format: Option<String>,
|
||
status: Option<String>,
|
||
match_count: i64,
|
||
team_count: i64,
|
||
date_start: Option<i64>,
|
||
date_end: Option<i64>,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
struct TournamentDetailResponse {
|
||
tournament_id: i64,
|
||
name: Option<String>,
|
||
format: Option<String>,
|
||
status: Option<String>,
|
||
match_count: i64,
|
||
team_count: i64,
|
||
date_start: Option<i64>,
|
||
date_end: Option<i64>,
|
||
matches: Vec<TournamentMatchRow>,
|
||
standings: Vec<TournamentStandingRow>,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
struct TournamentMatchRow {
|
||
match_id: String,
|
||
type_bracket: String,
|
||
side: Option<String>,
|
||
round: Option<i64>,
|
||
position: Option<i64>,
|
||
team_a_name: Option<String>,
|
||
team_b_name: Option<String>,
|
||
winner_name: Option<String>,
|
||
score_a: i64,
|
||
score_b: i64,
|
||
status: String,
|
||
battles: Vec<TournamentBattleRow>,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
struct TournamentBattleRow {
|
||
session_hex: String,
|
||
position: Option<i64>,
|
||
have_replay: bool,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
struct TournamentStandingRow {
|
||
group_index: i64,
|
||
team_name: Option<String>,
|
||
points: i64,
|
||
wins: i64,
|
||
draws: i64,
|
||
losses: i64,
|
||
buchholz: f64,
|
||
rank: Option<i64>,
|
||
}
|
||
|
||
#[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_id: Option<i64>,
|
||
#[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"),
|
||
tournaments_db: resolve_db_path("TSS_TOURNAMENTS_DB", "tss_tournaments.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/tournaments", get(tournaments))
|
||
.route("/api/tss/tournaments/{tournament_id}", get(tournament_detail))
|
||
.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 tournaments(State(state): State<Arc<AppState>>) -> ApiResult<TournamentsResponse> {
|
||
let conn = open_db(&state.tournaments_db)?;
|
||
let tournaments = tournaments_list(&conn)?;
|
||
Ok(Json(TournamentsResponse { tournaments }))
|
||
}
|
||
|
||
async fn tournament_detail(
|
||
State(state): State<Arc<AppState>>,
|
||
Path(tournament_id): Path<String>,
|
||
) -> ApiResult<TournamentDetailResponse> {
|
||
let tid = validate_tournament_id(&tournament_id)?;
|
||
let conn = open_db(&state.tournaments_db)?;
|
||
let summary = tournament_summary_for(&conn, tid)?
|
||
.ok_or_else(|| ApiError::not_found("Tournament not found"))?;
|
||
let standings = tournament_standings_for(&conn, tid)?;
|
||
let mut matches = tournament_match_rows_for(&conn, tid)?;
|
||
attach_battles(&conn, &state.battles_db, tid, &mut matches)?;
|
||
|
||
Ok(Json(TournamentDetailResponse {
|
||
tournament_id: summary.tournament_id,
|
||
name: summary.name,
|
||
format: summary.format,
|
||
status: summary.status,
|
||
match_count: summary.match_count,
|
||
team_count: summary.team_count,
|
||
date_start: summary.date_start,
|
||
date_end: summary.date_end,
|
||
matches,
|
||
standings,
|
||
}))
|
||
}
|
||
|
||
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),
|
||
COUNT(DISTINCT CASE WHEN victor_bool = 'Win' THEN session_id END),
|
||
COUNT(DISTINCT CASE WHEN victor_bool != 'Win' THEN session_id 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),
|
||
COUNT(DISTINCT CASE WHEN victor_bool = 'Win' THEN session_id END),
|
||
COUNT(DISTINCT CASE WHEN victor_bool != 'Win' THEN session_id 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),
|
||
COUNT(DISTINCT CASE WHEN victor_bool = 'Win' THEN session_id END),
|
||
COUNT(DISTINCT CASE WHEN victor_bool != 'Win' THEN session_id 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),
|
||
COUNT(DISTINCT CASE WHEN victor_bool = 'Win' THEN session_id END),
|
||
COUNT(DISTINCT CASE WHEN victor_bool != 'Win' THEN session_id 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_id: None,
|
||
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_id: None,
|
||
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 tournaments_list(conn: &Connection) -> Result<Vec<TournamentSummary>, ApiError> {
|
||
let mut stmt = conn
|
||
.prepare(
|
||
"SELECT tournament_id, name, format, status, match_count, team_count,
|
||
date_start, date_end
|
||
FROM tournaments
|
||
ORDER BY COALESCE(date_end, scanned_unix, 0) DESC, tournament_id DESC
|
||
LIMIT 500",
|
||
)
|
||
.map_err(db_error)?;
|
||
let rows = stmt
|
||
.query_map([], read_tournament_summary)
|
||
.map_err(db_error)?
|
||
.collect::<Result<Vec<_>, _>>()
|
||
.map_err(db_error)?;
|
||
Ok(rows)
|
||
}
|
||
|
||
fn tournament_summary_for(
|
||
conn: &Connection,
|
||
tid: i64,
|
||
) -> Result<Option<TournamentSummary>, ApiError> {
|
||
conn.query_row(
|
||
"SELECT tournament_id, name, format, status, match_count, team_count,
|
||
date_start, date_end
|
||
FROM tournaments WHERE tournament_id = ?1",
|
||
params![tid],
|
||
read_tournament_summary,
|
||
)
|
||
.optional()
|
||
.map_err(db_error)
|
||
}
|
||
|
||
fn read_tournament_summary(row: &rusqlite::Row<'_>) -> rusqlite::Result<TournamentSummary> {
|
||
Ok(TournamentSummary {
|
||
tournament_id: row.get(0)?,
|
||
name: row.get(1)?,
|
||
format: row.get(2)?,
|
||
status: row.get(3)?,
|
||
match_count: row.get(4)?,
|
||
team_count: row.get(5)?,
|
||
date_start: row.get(6)?,
|
||
date_end: row.get(7)?,
|
||
})
|
||
}
|
||
|
||
fn tournament_match_rows_for(
|
||
conn: &Connection,
|
||
tid: i64,
|
||
) -> Result<Vec<TournamentMatchRow>, ApiError> {
|
||
// Order: winner side, then final, then loser; group/swiss after. Within a
|
||
// side, by round then position (the schedule's round/matchNumber). NULLs last.
|
||
let mut stmt = conn
|
||
.prepare(
|
||
"SELECT match_id, type_bracket, side, round, position,
|
||
team_a_name, team_b_name, winner_name, score_a, score_b, status
|
||
FROM tournament_matches
|
||
WHERE tournament_id = ?1
|
||
ORDER BY
|
||
CASE side WHEN 'winner' THEN 0 WHEN 'final' THEN 1 WHEN 'loser' THEN 2
|
||
WHEN 'group' THEN 3 WHEN 'swiss' THEN 4 ELSE 5 END,
|
||
round IS NULL, round,
|
||
position IS NULL, position,
|
||
time_start, match_id",
|
||
)
|
||
.map_err(db_error)?;
|
||
let rows = stmt
|
||
.query_map(params![tid], |row| {
|
||
Ok(TournamentMatchRow {
|
||
match_id: row.get(0)?,
|
||
type_bracket: row.get(1)?,
|
||
side: row.get(2)?,
|
||
round: row.get(3)?,
|
||
position: row.get(4)?,
|
||
team_a_name: row.get(5)?,
|
||
team_b_name: row.get(6)?,
|
||
winner_name: row.get(7)?,
|
||
score_a: row.get(8)?,
|
||
score_b: row.get(9)?,
|
||
status: row.get(10)?,
|
||
battles: Vec::new(),
|
||
})
|
||
})
|
||
.map_err(db_error)?
|
||
.collect::<Result<Vec<_>, _>>()
|
||
.map_err(db_error)?;
|
||
Ok(rows)
|
||
}
|
||
|
||
fn tournament_standings_for(
|
||
conn: &Connection,
|
||
tid: i64,
|
||
) -> Result<Vec<TournamentStandingRow>, ApiError> {
|
||
let mut stmt = conn
|
||
.prepare(
|
||
"SELECT group_index, team_name, points, wins, draws, losses, buchholz, rank
|
||
FROM tournament_standings
|
||
WHERE tournament_id = ?1
|
||
ORDER BY group_index, rank IS NULL, rank, points DESC",
|
||
)
|
||
.map_err(db_error)?;
|
||
let rows = stmt
|
||
.query_map(params![tid], |row| {
|
||
Ok(TournamentStandingRow {
|
||
group_index: row.get(0)?,
|
||
team_name: row.get(1)?,
|
||
points: row.get(2)?,
|
||
wins: row.get(3)?,
|
||
draws: row.get(4)?,
|
||
losses: row.get(5)?,
|
||
buchholz: row.get(6)?,
|
||
rank: row.get(7)?,
|
||
})
|
||
})
|
||
.map_err(db_error)?
|
||
.collect::<Result<Vec<_>, _>>()
|
||
.map_err(db_error)?;
|
||
Ok(rows)
|
||
}
|
||
|
||
// Attach each match's battles and mark which ones we actually hold a replay for
|
||
// (session_hex present in match_summary over in the battles DB).
|
||
fn attach_battles(
|
||
conn: &Connection,
|
||
battles_db: &FsPath,
|
||
tid: i64,
|
||
matches: &mut [TournamentMatchRow],
|
||
) -> Result<(), ApiError> {
|
||
let mut index: HashMap<(String, String), usize> = HashMap::new();
|
||
for (i, m) in matches.iter().enumerate() {
|
||
index.insert((m.match_id.clone(), m.type_bracket.clone()), i);
|
||
}
|
||
|
||
let mut stmt = conn
|
||
.prepare(
|
||
"SELECT match_id, type_bracket, session_hex, position
|
||
FROM tournament_battles
|
||
WHERE tournament_id = ?1
|
||
ORDER BY match_id, type_bracket, position IS NULL, position",
|
||
)
|
||
.map_err(db_error)?;
|
||
let battles = stmt
|
||
.query_map(params![tid], |row| {
|
||
Ok((
|
||
row.get::<_, String>(0)?,
|
||
row.get::<_, String>(1)?,
|
||
row.get::<_, String>(2)?,
|
||
row.get::<_, Option<i64>>(3)?,
|
||
))
|
||
})
|
||
.map_err(db_error)?
|
||
.collect::<Result<Vec<_>, _>>()
|
||
.map_err(db_error)?;
|
||
|
||
let hexes: Vec<String> = battles.iter().map(|(_, _, h, _)| h.clone()).collect();
|
||
let held = held_session_ids(battles_db, &hexes)?;
|
||
|
||
for (match_id, type_bracket, session_hex, position) in battles {
|
||
if let Some(&idx) = index.get(&(match_id, type_bracket)) {
|
||
let have_replay = held.contains(&session_hex);
|
||
matches[idx].battles.push(TournamentBattleRow {
|
||
session_hex,
|
||
position,
|
||
have_replay,
|
||
});
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn held_session_ids(
|
||
battles_db: &FsPath,
|
||
hexes: &[String],
|
||
) -> Result<HashSet<String>, ApiError> {
|
||
let mut held = HashSet::new();
|
||
if hexes.is_empty() {
|
||
return Ok(held);
|
||
}
|
||
let conn = open_db(battles_db)?;
|
||
for chunk in hexes.chunks(400) {
|
||
let placeholders = std::iter::repeat("?")
|
||
.take(chunk.len())
|
||
.collect::<Vec<_>>()
|
||
.join(",");
|
||
let sql = format!(
|
||
"SELECT session_id FROM match_summary WHERE session_id IN ({placeholders})"
|
||
);
|
||
let mut stmt = conn.prepare(&sql).map_err(db_error)?;
|
||
let rows = stmt
|
||
.query_map(rusqlite::params_from_iter(chunk.iter()), |row| {
|
||
row.get::<_, String>(0)
|
||
})
|
||
.map_err(db_error)?;
|
||
for r in rows {
|
||
held.insert(r.map_err(db_error)?);
|
||
}
|
||
}
|
||
Ok(held)
|
||
}
|
||
|
||
|
||
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),
|
||
m.tournament_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)?,
|
||
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_id: row.get(19)?,
|
||
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_tournament_id(value: &str) -> Result<i64, ApiError> {
|
||
let trimmed = value.trim();
|
||
match trimmed.parse::<i64>() {
|
||
Ok(id) if id > 0 => Ok(id),
|
||
_ => Err(ApiError::bad_request("Invalid tournament ID")),
|
||
}
|
||
}
|
||
|
||
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 path = if let Ok(raw) = env::var(env_key) {
|
||
PathBuf::from(expand_home(&raw))
|
||
} else if let Ok(storage) = env::var("STORAGE_VOL_PATH") {
|
||
PathBuf::from(expand_home(&storage)).join(default_file)
|
||
} else {
|
||
PathBuf::from(default_file)
|
||
};
|
||
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);
|
||
}
|
||
}
|