From 3436c91fdcf2a18423cde6032ca7497bf70785d6 Mon Sep 17 00:00:00 2001 From: FURRO404 Date: Sat, 30 May 2026 08:44:28 -0700 Subject: [PATCH] fuck it we ball --- backend/src/main.rs | 261 +++++++++++++++++++++++++++++++++++++++++++ frontend/src/App.jsx | 104 +++++++++++++++++ server.cjs | 43 +++++++ 3 files changed, 408 insertions(+) diff --git a/backend/src/main.rs b/backend/src/main.rs index ecaf589..83155b2 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -252,6 +252,50 @@ struct GameStats { team_kills_stat: i64, } +#[derive(Serialize)] +struct PlayerSearchResponse { + players: Vec, +} + +#[derive(Serialize)] +struct PlayerRef { + uid: String, + nick: Option, +} + +#[derive(Serialize)] +struct PlayerProfile { + uid: String, + nick: Option, + data_set: &'static str, + career: PlayerCareer, + teams: Vec, +} + +#[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, + team_tag: Option, + team_name: Option, + games: i64, + last_seen: i64, +} + struct TeamRecord { team_id: i64, long_name: String, @@ -290,6 +334,9 @@ async fn main() -> Result<(), Box> { .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]) @@ -535,6 +582,50 @@ async fn team_games( })) } +async fn resolve_player( + State(state): State>, + Query(query): Query, +) -> ApiResult { + 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>, + Query(query): Query, +) -> ApiResult { + 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>, + Path(uid): Path, +) -> ApiResult { + 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::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY).map_err(|error| { ApiError::internal(format!("Could not open {}: {}", path.display(), error)) @@ -713,6 +804,157 @@ fn player_summaries_for( Ok(out) } +fn player_search(conn: &Connection, query: &str, limit: i64) -> Result, 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::, _>>() + .map_err(db_error)?; + Ok(players) +} + +fn player_resolve(conn: &Connection, name: &str) -> Result, 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::, _>>() + .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, 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>(0), + ) + .optional() + .map_err(db_error) + .map(|opt| opt.flatten()) +} + +fn player_career_for(conn: &Connection, uid: &str) -> Result, 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, ApiError> { + let mut stmt = conn + .prepare( + "SELECT team_tag, MAX(team_name) AS team_name, team_id, + COUNT(DISTINCT session_id) AS games, MAX(endtime_unix) AS last_seen + FROM player_games_hist + WHERE UID = ?1 + GROUP BY team_tag + ORDER BY last_seen DESC", + ) + .map_err(db_error)?; + let teams = stmt + .query_map(params![uid], |row| { + Ok(PlayerTeamRef { + team_tag: row.get(0)?, + team_name: row.get(1)?, + team_id: row.get(2)?, + games: row.get(3)?, + last_seen: row.get(4)?, + }) + }) + .map_err(db_error)? + .collect::, _>>() + .map_err(db_error)?; + Ok(teams) +} + fn period_history_for(conn: &Connection, team_id: i64) -> Result, ApiError> { let mut stmt = conn .prepare( @@ -847,6 +1089,25 @@ fn validate_team_name(name: &str) -> Result<&str, ApiError> { 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 { + 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 decode_path_team(value: &str) -> Result { let decoded = urlencoding::decode(value) .map_err(|_| ApiError::bad_request("Invalid team name"))? diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 5aaf8d2..189c631 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -24,6 +24,7 @@ const apiEndpoints = { detail: (name) => `/api/tss/teams/${encodeURIComponent(name)}`, history: (name) => `/api/tss/teams/${encodeURIComponent(name)}/history`, games: (name) => `/api/tss/teams/${encodeURIComponent(name)}/games`, + player: (uid) => `/api/tss/player/${encodeURIComponent(uid)}`, } const navItems = [ @@ -77,6 +78,10 @@ function parseRoute(pathname = window.location.pathname) { if (pathname === '/viewers') return { page: 'viewers', teamName: '' } if (pathname === '/privacy') return { page: 'privacy', teamName: '' } if (pathname === '/docs') return { page: 'docs', teamName: '' } + if (pathname.startsWith('/players/')) { + const uid = decodeURIComponent(pathname.slice('/players/'.length)) + return { page: 'player', teamName: '', uid } + } if (pathname.startsWith('/teams/')) { const teamName = decodeURIComponent(pathname.slice('/teams/'.length)) return { page: 'team', teamName } @@ -356,6 +361,7 @@ function routeLabel(route) { if (route.page === 'viewers') return 'viewers' if (route.page === 'privacy') return 'Privacy notice' if (route.page === 'docs') return 'Docs' + if (route.page === 'player') return route.uid ? `Player ${route.uid}` : 'Player' return 'Home' } @@ -371,6 +377,7 @@ function canonicalPathForRoute(route) { if (route.page === 'viewers') return '/viewers' if (route.page === 'privacy') return '/privacy' if (route.page === 'docs') return '/docs' + if (route.page === 'player' && route.uid) return `/players/${encodeURIComponent(route.uid)}` return '/' } @@ -398,6 +405,15 @@ function seoForRoute(route, profileDetail = null) { } } + if (route.page === 'player' && route.uid) { + return { + title: `Player ${route.uid} | Toothless' TSS Bot`, + description: `TSS career stats for player ${route.uid} — battles, win rate, kills, and teams seen with.`, + robots: 'noindex, follow', + path: canonicalPathForRoute(route), + } + } + const byPage = { teams: { title: "TSS Team Leaderboard | Toothless' TSS Bot", @@ -1602,6 +1618,7 @@ function AppContent() { {route.page === 'viewers' ? : null} {route.page === 'privacy' ? : null} {route.page === 'docs' ? : null} + {route.page === 'player' ? : null}