add stuff for tournaments
This commit is contained in:
+319
-2
@@ -9,7 +9,7 @@ use rusqlite::{params, Connection, OptionalExtension};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
collections::{BTreeMap, HashMap, HashSet},
|
||||
env, fs,
|
||||
net::{IpAddr, Ipv4Addr, SocketAddr},
|
||||
path::{Path as FsPath, PathBuf},
|
||||
@@ -28,6 +28,7 @@ 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>,
|
||||
@@ -239,6 +240,72 @@ struct GameResponse {
|
||||
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>,
|
||||
@@ -260,6 +327,8 @@ struct GameRow {
|
||||
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>,
|
||||
@@ -371,6 +440,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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",
|
||||
@@ -393,6 +463,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.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))
|
||||
@@ -597,6 +669,38 @@ async fn recent_games(
|
||||
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>,
|
||||
@@ -1449,6 +1553,7 @@ fn games_for(conn: &Connection, team_name: &str) -> Result<Vec<GameRow>, ApiErro
|
||||
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,
|
||||
@@ -1541,6 +1646,7 @@ fn recent_games_for(conn: &Connection, limit: i64) -> Result<Vec<GameRow>, ApiEr
|
||||
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,
|
||||
@@ -1564,6 +1670,207 @@ fn recent_games_for(conn: &Connection, limit: i64) -> Result<Vec<GameRow>, ApiEr
|
||||
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
|
||||
@@ -1604,7 +1911,8 @@ fn game_for(conn: &Connection, session_id: &str) -> Result<Option<GameRow>, ApiE
|
||||
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)
|
||||
LIMIT 1),
|
||||
m.tournament_id
|
||||
FROM (
|
||||
SELECT
|
||||
COUNT(*) AS player_count,
|
||||
@@ -1652,6 +1960,7 @@ fn game_for(conn: &Connection, session_id: &str) -> Result<Option<GameRow>, ApiE
|
||||
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,
|
||||
@@ -1853,6 +2162,14 @@ fn validate_uid(value: &str) -> Result<String, ApiError> {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user