add stuff for tournaments

This commit is contained in:
FURRO404
2026-06-20 21:12:39 -07:00
parent eca52ca078
commit 1eb0f1ffc8
3 changed files with 838 additions and 6 deletions
+319 -2
View File
@@ -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