From 0db73d669ddaafb0ecf78834e17323f163d07e9b Mon Sep 17 00:00:00 2001 From: Heidi Date: Mon, 15 Jun 2026 07:53:33 +0100 Subject: [PATCH] ai generated solutions to our ai generated problems --- backend/src/main.rs | 294 +++++++++++++++++++++++++++++++++++-------- frontend/src/App.jsx | 143 +++------------------ server.cjs | 9 ++ vite.config.js | 6 + 4 files changed, 271 insertions(+), 181 deletions(-) diff --git a/backend/src/main.rs b/backend/src/main.rs index 3ae0b08..fe2cc66 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -9,7 +9,7 @@ use rusqlite::{params, Connection, OptionalExtension}; use serde::{Deserialize, Serialize}; use serde_json::json; use std::{ - collections::BTreeMap, + collections::{BTreeMap, HashMap}, env, fs, net::{IpAddr, Ipv4Addr, SocketAddr}, path::{Path as FsPath, PathBuf}, @@ -97,6 +97,11 @@ struct LeaderboardResponse { teams: Vec, } +#[derive(Serialize)] +struct RecentGamesResponse { + matches: Vec, +} + #[derive(Serialize)] struct SearchResponse { teams: Vec, @@ -138,7 +143,7 @@ struct TeamDetail { players: Vec, } -#[derive(Serialize)] +#[derive(Default, Serialize)] struct TeamSummary { player_count: i64, total_battles: i64, @@ -192,6 +197,8 @@ struct GamesResponse { #[derive(Serialize)] struct GameRow { + #[serde(skip_serializing_if = "Option::is_none")] + team_name: Option, session_id: String, timestamp: i64, endtime_unix: i64, @@ -286,6 +293,7 @@ async fn main() -> Result<(), Box> { let app = Router::new() .route("/health", get(health)) .route("/api/tss/leaderboard/teams", get(leaderboard)) + .route("/api/tss/games/recent", get(recent_games)) .route("/api/tss/teams/resolve", get(resolve_team)) .route("/api/tss/teams/search", get(search_teams)) .route("/api/tss/teams/{team}", get(team_detail)) @@ -385,10 +393,13 @@ async fn leaderboard( .map_err(db_error)? .collect::, _>>() .map_err(db_error)?; + let mut summaries = team_summaries_for(&battles_conn)?; let mut rows = Vec::with_capacity(teams.len()); for team in teams { - let summary = team_summary_for(&battles_conn, &team.name)?; + let summary = summaries + .remove(&team.name.to_ascii_lowercase()) + .unwrap_or_default(); rows.push(TeamLeaderboardRow { team_id: team.team_id, name: team.name, @@ -404,6 +415,16 @@ async fn leaderboard( Ok(Json(LeaderboardResponse { teams: rows })) } +async fn recent_games( + State(state): State>, + Query(query): Query, +) -> ApiResult { + 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 resolve_team( State(state): State>, Query(query): Query, @@ -634,6 +655,60 @@ fn team_summary_for(conn: &Connection, team_name: &str) -> Result Result, ApiError> { + let mut stmt = conn + .prepare( + "SELECT + team_name, + 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_name IS NOT NULL AND team_name != '' + GROUP BY team_name COLLATE NOCASE", + ) + .map_err(db_error)?; + + let summaries = stmt + .query_map([], |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::, _>>() + .map_err(db_error)?; + + Ok(summaries) +} + fn player_summaries_for( teams_conn: &Connection, battles_conn: &Connection, @@ -652,19 +727,16 @@ fn player_summaries_for( .map_err(db_error)?; let members = stmt .query_map(params![team_id], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - )) + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) }) .map_err(db_error)? .collect::, _>>() .map_err(db_error)?; - let mut out = Vec::with_capacity(members.len()); let mut stats_stmt = battles_conn .prepare( "SELECT + UID, 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), @@ -673,29 +745,32 @@ fn player_summaries_for( COALESCE(SUM(assists), 0), COALESCE(SUM(deaths), 0), (SELECT nick FROM player_games_hist - WHERE UID = ?2 AND nick IS NOT NULL + WHERE UID = p.UID AND nick IS NOT NULL ORDER BY endtime_unix DESC LIMIT 1) - FROM player_games_hist - WHERE team_name = ?1 COLLATE NOCASE AND UID = ?2", + FROM player_games_hist p + WHERE team_name = ?1 COLLATE NOCASE + GROUP BY UID", ) .map_err(db_error)?; - for (uid, role) in members { - let summary = stats_stmt - .query_row(params![team_name, 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 nick: Option = row.get(7)?; - let total_kills = ground + air; - Ok(PlayerSummary { - uid: uid.clone(), + 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 = row.get(8)?; + let total_kills = ground + air; + Ok(( + uid.clone(), + PlayerSummary { + uid, nick, - role: role.clone(), + role: String::new(), total_battles: battles, wins, losses, @@ -706,9 +781,31 @@ fn player_summaries_for( assists, deaths, kdr: ratio(total_kills, deaths), - }) - }) - .map_err(db_error)?; + }, + )) + }) + .map_err(db_error)? + .collect::, _>>() + .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); } @@ -812,31 +909,43 @@ fn player_career_for(conn: &Connection, uid: &str) -> Result Result, ApiErro let map_name: Option = row.get(2)?; let mission_mode: Option = row.get(3)?; Ok(GameRow { + team_name: None, session_id, timestamp, endtime_unix: timestamp, @@ -969,6 +1079,83 @@ fn games_for(conn: &Connection, team_name: &str) -> Result, ApiErro Ok(rows) } +fn recent_games_for(conn: &Connection, limit: i64) -> Result, ApiError> { + let mut stmt = conn + .prepare( + "WITH recent AS ( + SELECT team_name, 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 + ) + SELECT + r.team_name, + r.session_id, + COALESCE(m.endtime_unix, r.timestamp, 0) AS timestamp, + m.mission_name, + 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_slot, + m.losing_slot + FROM recent r + JOIN player_games_hist p + ON p.session_id = r.session_id AND p.team_name = r.team_name COLLATE NOCASE + LEFT JOIN match_summary m ON m.session_id = r.session_id + GROUP BY r.team_name COLLATE NOCASE, 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(2)?; + Ok(GameRow { + team_name: row.get(0)?, + session_id: row.get(1)?, + timestamp, + endtime_unix: timestamp, + map_name: row.get(3)?, + mission_mode: row.get(4)?, + result: row.get(5)?, + player_count: row.get(6)?, + winning_team: row.get(16)?, + losing_team: row.get(17)?, + stats: GameStats { + ground_kills: row.get(7)?, + air_kills: row.get(8)?, + assists: row.get(9)?, + captures: row.get(10)?, + deaths: row.get(11)?, + score: row.get(12)?, + missile_evades: row.get(13)?, + shell_interceptions: row.get(14)?, + team_kills_stat: row.get(15)?, + }, + }) + }) + .map_err(db_error)? + .collect::, _>>() + .map_err(db_error)?; + + Ok(rows) +} + fn validate_team_name(name: &str) -> Result<&str, ApiError> { let trimmed = name.trim(); if trimmed.len() < 2 || trimmed.len() > MAX_TEAM_NAME_LENGTH { @@ -982,17 +1169,16 @@ fn validate_team_name(name: &str) -> Result<&str, ApiError> { 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")); + 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()) - { + 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()) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 1f22c0d..cb16d9b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -19,17 +19,17 @@ const apiEndpoints = { songOfDay: '/api/song-of-day', teams: '/api/tss/leaderboard/teams?limit=100', teamsHealth: '/api/tss/leaderboard/teams?limit=1', + recentGames: '/api/tss/games/recent?limit=50', resolve: (name) => `/api/tss/teams/resolve?name=${encodeURIComponent(name)}`, searchTeams: (name) => `/api/tss/teams/search?q=${encodeURIComponent(name)}&limit=10`, 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 = [ { path: '/', label: 'Home' }, - { path: '/teams', label: 'Team leaderboard' }, + { path: '/teams', label: 'Team Leaderboard' }, { path: '/battle-logs', label: 'Battle Logs' }, { path: '/viewers', label: 'Viewers' }, { path: '/docs', label: 'Setup' }, @@ -355,7 +355,7 @@ function deviceType() { function routeLabel(route) { if (route.page === 'team' && route.teamName) return `Team: ${route.teamName}` - if (route.page === 'teams') return 'Team leaderboard' + if (route.page === 'teams') return 'Team Leaderboard' if (route.page === 'battle-logs') return 'Battle Logs' if (route.page === 'uptime') return 'Uptime' if (route.page === 'viewers') return 'viewers' @@ -539,41 +539,8 @@ function applySeo(route, profileDetail = null) { structuredData.textContent = structuredDataForSeo(seo, canonicalUrl) } -async function fetchRecentTssGames(teams, signal) { - const teamNames = teams.map(bestTeamName).filter(Boolean).slice(0, 12) - - if (!teamNames.length) { - return { matches: [] } - } - - const responses = await Promise.allSettled( - teamNames.map((name) => fetchJson(apiEndpoints.games(name), signal).then((data) => ({ name, data }))), - ) - const bySession = new Map() - - responses.forEach((result) => { - if (result.status !== 'fulfilled') return - - const { name, data } = result.value - ;(data.games || []).forEach((game) => { - if (!game.session_id) return - - const existing = bySession.get(game.session_id) - const currentTimestamp = Number(game.timestamp || 0) - if (existing && Number(existing.timestamp || 0) >= currentTimestamp) return - - bySession.set(game.session_id, { - ...game, - team_name: data.name || name, - }) - }) - }) - - return { - matches: Array.from(bySession.values()) - .sort((a, b) => Number(b.timestamp || 0) - Number(a.timestamp || 0)) - .slice(0, 50), - } +async function fetchRecentTssGames(signal) { + return fetchJson(apiEndpoints.recentGames, signal) } function Stat({ label, value }) { @@ -861,7 +828,6 @@ function AppContent() { const [profile, setProfile] = useState({ teamName: '', detail: { status: 'idle', data: null, error: null }, - history: { status: 'idle', data: null, error: null }, games: { status: 'idle', data: null, error: null }, }) const teams = useMemo( @@ -1138,7 +1104,6 @@ function AppContent() { useEffect(() => { if (!['home', 'battle-logs'].includes(route.page)) return - if (!teams.length) return const currentLive = liveRef.current if (currentLive.status === 'ready' && Date.now() - currentLive.updatedAt < liveRefreshMs) return @@ -1149,7 +1114,7 @@ function AppContent() { : { status: 'loading', data: null, error: null, updatedAt: current.updatedAt || 0 }, ) - fetchRecentTssGames(teams, controller.signal) + fetchRecentTssGames(controller.signal) .then((data) => setLive({ status: 'ready', data, error: null, updatedAt: Date.now() })) .catch((error) => { if (!controller.signal.aborted) { @@ -1158,15 +1123,14 @@ function AppContent() { }) return () => controller.abort() - }, [route.page, teams]) + }, [route.page]) useEffect(() => { if (!['home', 'battle-logs'].includes(route.page)) return - if (!teams.length) return const controller = new AbortController() const timer = window.setInterval(() => { - fetchRecentTssGames(teams, controller.signal) + fetchRecentTssGames(controller.signal) .then((data) => setLive({ status: 'ready', data, error: null, updatedAt: Date.now() })) .catch((error) => { if (!controller.signal.aborted) { @@ -1179,7 +1143,7 @@ function AppContent() { window.clearInterval(timer) controller.abort() } - }, [route.page, teams]) + }, [route.page]) useEffect(() => { if (route.page !== 'home') return @@ -1219,7 +1183,6 @@ function AppContent() { setProfile({ teamName: route.teamName, detail: { status: 'loading', data: null, error: null }, - history: { status: 'loading', data: null, error: null }, games: { status: 'loading', data: null, error: null }, }) @@ -1232,18 +1195,13 @@ function AppContent() { } return Promise.allSettled([ - fetchJson(apiEndpoints.history(route.teamName), controller.signal), fetchJson(apiEndpoints.games(route.teamName), controller.signal), - ]).then(([historyResult, gamesResult]) => { + ]).then(([gamesResult]) => { if (controller.signal.aborted) return setProfile({ teamName: route.teamName, detail: { status: 'ready', data: detail, error: null }, - history: - historyResult.status === 'fulfilled' - ? { status: 'ready', data: historyResult.value, error: null } - : { status: 'error', data: null, error: historyResult.reason.message }, games: gamesResult.status === 'fulfilled' ? { status: 'ready', data: gamesResult.value, error: null } @@ -1402,9 +1360,8 @@ function AppContent() { const timer = window.setInterval(() => { Promise.allSettled([ fetchJson(apiEndpoints.detail(route.teamName), controller.signal), - fetchJson(apiEndpoints.history(route.teamName), controller.signal), fetchJson(apiEndpoints.games(route.teamName), controller.signal), - ]).then(([detailResult, historyResult, gamesResult]) => { + ]).then(([detailResult, gamesResult]) => { if (controller.signal.aborted) return setProfile((current) => ({ @@ -1413,10 +1370,6 @@ function AppContent() { detailResult.status === 'fulfilled' ? { status: 'ready', data: detailResult.value, error: null } : current.detail, - history: - historyResult.status === 'fulfilled' - ? { status: 'ready', data: historyResult.value, error: null } - : current.history, games: gamesResult.status === 'fulfilled' ? { status: 'ready', data: gamesResult.value, error: null } @@ -2111,7 +2064,7 @@ function Landing({ onClick={() => navigate('/teams')} type="button" > - Team leaderboard + Team Leaderboard
-
+

Team profile @@ -2898,32 +2848,17 @@ function TeamProfilePage({ navigate, profile, requestedTeam, teams }) { {profile.detail.error || ''}

-
- - Rating {formatNumber(latestRating)} - - - Clan {detail?.clan_id || leaderboardTeam?.clan_id || 'n/a'} - - - {detail?.data_set || 'tss'} - -
-
+
-
-
- - -
+ @@ -2970,51 +2905,6 @@ function RosterTable({ players, status }) { ) } -function RatingPanel({ history, ratingHourly, status }) { - const recentHistory = history.slice(-8) - const firstRating = ratingHourly[0]?.rating || 0 - const latestRating = ratingHourly.at(-1)?.rating || 0 - const ratingChange = latestRating - firstRating - - return ( -
-
-

History

-

- {ratingHourly.length ? `${formatNumber(ratingHourly.length)} rating snapshots` : status} -

-
-
-
- - = 0 ? '+' : ''}${formatNumber(ratingChange)}`} - /> -
- -
- {recentHistory.map((item) => ( -
- {item.period} - {formatNumber(item.battles)} battles - {Number(item.win_rate || 0).toFixed(1)}% WR -
- ))} - {!recentHistory.length ? ( -

- {status === 'loading' ? 'Loading history' : 'No history rows returned'} -

- ) : null} -
-
-
- ) -} - function BattleResults({ games, status }) { return (
@@ -3085,7 +2975,6 @@ function BattleLogsPage({ live, matches }) {

{match.team_name || 'TSS team'}

-

TSS battle record

key !== 'limit') || !Number.isInteger(limit) || limit < 1 || limit > 100) { + return null + } + return url + } + if (pathname === '/api/tss/teams/resolve') { const keys = [...params.keys()] const name = params.get('name') || '' diff --git a/vite.config.js b/vite.config.js index eff1d30..a63c2ce 100644 --- a/vite.config.js +++ b/vite.config.js @@ -277,6 +277,12 @@ function isAllowedApiUrl(req) { return keys.every((key) => key === 'limit') && Number.isInteger(limit) && limit >= 1 && limit <= 100 } + if (url.pathname === '/api/tss/games/recent') { + const keys = [...params.keys()] + const limit = Number(params.get('limit') || 50) + return keys.every((key) => key === 'limit') && Number.isInteger(limit) && limit >= 1 && limit <= 100 + } + if (url.pathname === '/api/tss/teams/resolve') { const keys = [...params.keys()] const name = params.get('name') || ''