split the project in 2
This commit is contained in:
@@ -0,0 +1,958 @@
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::{header, 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,
|
||||
env, fs,
|
||||
net::SocketAddr,
|
||||
path::{Path as FsPath, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
use tokio::net::TcpListener;
|
||||
use tower_http::{
|
||||
cors::{Any, CorsLayer},
|
||||
trace::TraceLayer,
|
||||
};
|
||||
|
||||
const MAX_TEAM_NAME_LENGTH: usize = 80;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
battles_db: PathBuf,
|
||||
teams_db: PathBuf,
|
||||
}
|
||||
|
||||
#[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(Serialize)]
|
||||
struct LeaderboardResponse {
|
||||
teams: Vec<TeamLeaderboardRow>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SearchResponse {
|
||||
teams: Vec<TeamSearchRow>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ResolveResponse {
|
||||
team_id: i64,
|
||||
long_name: String,
|
||||
tag_name: Option<String>,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TeamSearchRow {
|
||||
team_id: i64,
|
||||
long_name: String,
|
||||
tag_name: Option<String>,
|
||||
members: i64,
|
||||
clanrating: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TeamLeaderboardRow {
|
||||
team_id: i64,
|
||||
clan_id: i64,
|
||||
long_name: String,
|
||||
tag_name: Option<String>,
|
||||
short_name: Option<String>,
|
||||
player_count: i64,
|
||||
total_battles: i64,
|
||||
wins: i64,
|
||||
losses: i64,
|
||||
win_rate: f64,
|
||||
total_kills: i64,
|
||||
points: Points,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TeamDetail {
|
||||
team_id: i64,
|
||||
clan_id: i64,
|
||||
long_name: String,
|
||||
tag_name: Option<String>,
|
||||
short_name: Option<String>,
|
||||
description: Option<String>,
|
||||
region: Option<String>,
|
||||
members: i64,
|
||||
captain_uid: Option<String>,
|
||||
guild_id: Option<String>,
|
||||
created_unix: Option<i64>,
|
||||
updated_unix: Option<i64>,
|
||||
clanrating: Option<i64>,
|
||||
data_set: &'static str,
|
||||
team_summary: TeamSummary,
|
||||
players: Vec<PlayerSummary>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TeamSummary {
|
||||
player_count: i64,
|
||||
total_battles: i64,
|
||||
wins: i64,
|
||||
losses: i64,
|
||||
win_rate: f64,
|
||||
kdr: f64,
|
||||
total_kills: i64,
|
||||
points: Points,
|
||||
total_points: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Points {
|
||||
total_points: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct PlayerSummary {
|
||||
uid: String,
|
||||
nick: Option<String>,
|
||||
role: String,
|
||||
joined_unix: Option<i64>,
|
||||
points: i64,
|
||||
sqb_points: i64,
|
||||
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,
|
||||
long_name: String,
|
||||
tag_name: Option<String>,
|
||||
history: Vec<PeriodHistory>,
|
||||
rating_hourly: Vec<RatingPoint>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct PeriodHistory {
|
||||
period: String,
|
||||
battles: i64,
|
||||
wins: i64,
|
||||
losses: i64,
|
||||
win_rate: f64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RatingPoint {
|
||||
timestamp: i64,
|
||||
rating: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct GamesResponse {
|
||||
team_id: i64,
|
||||
long_name: String,
|
||||
tag_name: Option<String>,
|
||||
games: Vec<GameRow>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct GameRow {
|
||||
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,
|
||||
}
|
||||
|
||||
struct TeamRecord {
|
||||
team_id: i64,
|
||||
long_name: String,
|
||||
tag_name: Option<String>,
|
||||
description: Option<String>,
|
||||
region: Option<String>,
|
||||
members: i64,
|
||||
captain_uid: Option<String>,
|
||||
guild_id: Option<String>,
|
||||
created_unix: Option<i64>,
|
||||
updated_unix: Option<i64>,
|
||||
clanrating: Option<i64>,
|
||||
}
|
||||
|
||||
#[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 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"),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.route("/health", get(health))
|
||||
.route("/api/tss/leaderboard/teams", get(leaderboard))
|
||||
.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))
|
||||
.layer(
|
||||
CorsLayer::new()
|
||||
.allow_methods([Method::GET])
|
||||
.allow_origin(Any)
|
||||
.allow_headers([header::ACCEPT, header::CONTENT_TYPE]),
|
||||
)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state);
|
||||
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], 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 = i64::from(query.limit.unwrap_or(100).clamp(1, 100));
|
||||
let teams_conn = open_db(&state.teams_db)?;
|
||||
let battles_conn = open_db(&state.battles_db)?;
|
||||
let mut stmt = teams_conn
|
||||
.prepare(
|
||||
"SELECT team_id, long_name, tag_name, description, region, members, captain_uid, guild_id,
|
||||
created_unix, updated_unix, clanrating
|
||||
FROM teams_data
|
||||
ORDER BY COALESCE(clanrating, 0) DESC, members DESC, long_name COLLATE NOCASE ASC
|
||||
LIMIT ?1",
|
||||
)
|
||||
.map_err(db_error)?;
|
||||
|
||||
let teams = stmt
|
||||
.query_map(params![limit], |row| read_team_record(row))
|
||||
.map_err(db_error)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(db_error)?;
|
||||
|
||||
let mut rows = Vec::with_capacity(teams.len());
|
||||
for team in teams {
|
||||
let summary = team_summary_for(&battles_conn, team.team_id)?;
|
||||
rows.push(TeamLeaderboardRow {
|
||||
team_id: team.team_id,
|
||||
clan_id: team.team_id,
|
||||
long_name: team.long_name.clone(),
|
||||
tag_name: team.tag_name.clone(),
|
||||
short_name: team.tag_name.clone(),
|
||||
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,
|
||||
points: Points {
|
||||
total_points: team.clanrating.unwrap_or(summary.total_points),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Json(LeaderboardResponse { teams: rows }))
|
||||
}
|
||||
|
||||
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,
|
||||
long_name: team.long_name.clone(),
|
||||
tag_name: team.tag_name.clone(),
|
||||
name: team.tag_name.clone().unwrap_or(team.long_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, long_name, tag_name, members, clanrating
|
||||
FROM teams_data
|
||||
WHERE long_name LIKE ?1 ESCAPE '\\' OR tag_name LIKE ?1 ESCAPE '\\'
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN tag_name = ?2 COLLATE NOCASE THEN 0
|
||||
WHEN long_name = ?2 COLLATE NOCASE THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
COALESCE(clanrating, 0) DESC,
|
||||
long_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)?,
|
||||
long_name: row.get(1)?,
|
||||
tag_name: row.get(2)?,
|
||||
members: row.get(3)?,
|
||||
clanrating: row.get(4)?,
|
||||
})
|
||||
})
|
||||
.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.team_id)?;
|
||||
let players = player_summaries_for(&teams_conn, &battles_conn, team.team_id)?;
|
||||
|
||||
Ok(Json(TeamDetail {
|
||||
team_id: team.team_id,
|
||||
clan_id: team.team_id,
|
||||
long_name: team.long_name,
|
||||
tag_name: team.tag_name.clone(),
|
||||
short_name: team.tag_name,
|
||||
description: team.description,
|
||||
region: team.region,
|
||||
members: team.members,
|
||||
captain_uid: team.captain_uid,
|
||||
guild_id: team.guild_id,
|
||||
created_unix: team.created_unix,
|
||||
updated_unix: team.updated_unix,
|
||||
clanrating: team.clanrating,
|
||||
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.team_id)?;
|
||||
let rating_hourly = rating_history_for(&teams_conn, team.team_id)?;
|
||||
|
||||
Ok(Json(HistoryResponse {
|
||||
team_id: team.team_id,
|
||||
long_name: team.long_name,
|
||||
tag_name: team.tag_name,
|
||||
history,
|
||||
rating_hourly,
|
||||
}))
|
||||
}
|
||||
|
||||
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.team_id)?;
|
||||
|
||||
Ok(Json(GamesResponse {
|
||||
team_id: team.team_id,
|
||||
long_name: team.long_name,
|
||||
tag_name: team.tag_name,
|
||||
games,
|
||||
}))
|
||||
}
|
||||
|
||||
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)?,
|
||||
long_name: row.get(1)?,
|
||||
tag_name: row.get(2)?,
|
||||
description: row.get(3)?,
|
||||
region: row.get(4)?,
|
||||
members: row.get(5)?,
|
||||
captain_uid: row.get(6)?,
|
||||
guild_id: row.get(7)?,
|
||||
created_unix: row.get(8)?,
|
||||
updated_unix: row.get(9)?,
|
||||
clanrating: row.get(10)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn find_team(conn: &Connection, name: &str) -> Result<Option<TeamRecord>, ApiError> {
|
||||
conn.query_row(
|
||||
"SELECT team_id, long_name, tag_name, description, region, members, captain_uid, guild_id,
|
||||
created_unix, updated_unix, clanrating
|
||||
FROM teams_data
|
||||
WHERE team_id = ?1
|
||||
OR long_name = ?2 COLLATE NOCASE
|
||||
OR tag_name = ?2 COLLATE NOCASE
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN tag_name = ?2 COLLATE NOCASE THEN 0
|
||||
WHEN long_name = ?2 COLLATE NOCASE THEN 1
|
||||
ELSE 2
|
||||
END
|
||||
LIMIT 1",
|
||||
params![name.parse::<i64>().ok(), name],
|
||||
read_team_record,
|
||||
)
|
||||
.optional()
|
||||
.map_err(db_error)
|
||||
}
|
||||
|
||||
fn team_summary_for(conn: &Connection, team_id: i64) -> 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_id = ?1",
|
||||
params![team_id],
|
||||
|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,
|
||||
points: Points {
|
||||
total_points: score + assists + total_kills,
|
||||
},
|
||||
total_points: score + assists + total_kills,
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(db_error)
|
||||
}
|
||||
|
||||
fn player_summaries_for(
|
||||
teams_conn: &Connection,
|
||||
battles_conn: &Connection,
|
||||
team_id: i64,
|
||||
) -> Result<Vec<PlayerSummary>, ApiError> {
|
||||
let mut stmt = teams_conn
|
||||
.prepare(
|
||||
"SELECT uid, nick, role, joined_unix, points
|
||||
FROM team_members
|
||||
WHERE team_id = ?1
|
||||
ORDER BY points DESC, nick COLLATE NOCASE ASC",
|
||||
)
|
||||
.map_err(db_error)?;
|
||||
let members = stmt
|
||||
.query_map(params![team_id], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, Option<String>>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, Option<i64>>(3)?,
|
||||
row.get::<_, i64>(4)?,
|
||||
))
|
||||
})
|
||||
.map_err(db_error)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(db_error)?;
|
||||
|
||||
let mut out = Vec::with_capacity(members.len());
|
||||
let mut stats_stmt = battles_conn
|
||||
.prepare(
|
||||
"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)
|
||||
FROM player_games_hist
|
||||
WHERE team_id = ?1 AND UID = ?2",
|
||||
)
|
||||
.map_err(db_error)?;
|
||||
|
||||
for (uid, nick, role, joined_unix, points) in members {
|
||||
let summary = stats_stmt
|
||||
.query_row(params![team_id, 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 deaths: i64 = row.get(6)?;
|
||||
let score: i64 = row.get(7)?;
|
||||
let total_kills = ground + air;
|
||||
Ok(PlayerSummary {
|
||||
uid: uid.clone(),
|
||||
nick: nick.clone(),
|
||||
role: role.clone(),
|
||||
joined_unix,
|
||||
points,
|
||||
sqb_points: if points == 0 {
|
||||
score + assists + total_kills
|
||||
} else {
|
||||
points
|
||||
},
|
||||
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)?;
|
||||
out.push(summary);
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn period_history_for(conn: &Connection, team_id: i64) -> 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_id = ?1 AND endtime_unix > 0
|
||||
GROUP BY period
|
||||
ORDER BY period ASC",
|
||||
)
|
||||
.map_err(db_error)?;
|
||||
|
||||
stmt.query_map(params![team_id], |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)
|
||||
}
|
||||
|
||||
fn rating_history_for(conn: &Connection, team_id: i64) -> Result<Vec<RatingPoint>, ApiError> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT unix_time, COALESCE(total_score, 0)
|
||||
FROM teams_points
|
||||
WHERE team_id = ?1
|
||||
ORDER BY unix_time ASC",
|
||||
)
|
||||
.map_err(db_error)?;
|
||||
|
||||
stmt.query_map(params![team_id], |row| {
|
||||
Ok(RatingPoint {
|
||||
timestamp: row.get(0)?,
|
||||
rating: row.get(1)?,
|
||||
})
|
||||
})
|
||||
.map_err(db_error)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(db_error)
|
||||
}
|
||||
|
||||
fn games_for(conn: &Connection, team_id: i64) -> 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_mode,
|
||||
CASE
|
||||
WHEN MAX(CASE WHEN p.victor_bool = 'Win' THEN 1 ELSE 0 END) = 1 THEN 'Win'
|
||||
ELSE 'Loss'
|
||||
END AS result,
|
||||
COUNT(DISTINCT p.UID),
|
||||
COALESCE(SUM(p.ground_kills), 0),
|
||||
COALESCE(SUM(p.air_kills), 0),
|
||||
COALESCE(SUM(p.assists), 0),
|
||||
COALESCE(SUM(p.captures), 0),
|
||||
COALESCE(SUM(p.deaths), 0),
|
||||
COALESCE(SUM(p.score), 0),
|
||||
COALESCE(SUM(p.missile_evades), 0),
|
||||
COALESCE(SUM(p.shell_interceptions), 0),
|
||||
COALESCE(SUM(p.team_kills_stat), 0),
|
||||
m.winning_team,
|
||||
m.losing_team
|
||||
FROM player_games_hist p
|
||||
LEFT JOIN match_summary m ON m.session_id = p.session_id
|
||||
WHERE p.team_id = ?1
|
||||
GROUP BY p.session_id
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 100",
|
||||
)
|
||||
.map_err(db_error)?;
|
||||
|
||||
stmt.query_map(params![team_id], |row| {
|
||||
let session_id: String = row.get(0)?;
|
||||
let timestamp: i64 = row.get(1)?;
|
||||
let mission_mode: Option<String> = row.get(2)?;
|
||||
Ok(GameRow {
|
||||
session_id,
|
||||
timestamp,
|
||||
endtime_unix: timestamp,
|
||||
map_name: mission_mode.clone(),
|
||||
mission_mode,
|
||||
result: row.get(3)?,
|
||||
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)?,
|
||||
},
|
||||
})
|
||||
})
|
||||
.map_err(db_error)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(db_error)
|
||||
}
|
||||
|
||||
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 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 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user