diff --git a/backend/README.md b/backend/README.md index b4e7295..2de0e68 100644 --- a/backend/README.md +++ b/backend/README.md @@ -15,11 +15,13 @@ It currently exposes: - `GET /health` - `GET /api/tss/leaderboard/teams?limit=100` +- `GET /api/tss/leaderboard/players?limit=100` - `GET /api/tss/teams/resolve?name=...` - `GET /api/tss/teams/search?q=...&limit=10` - `GET /api/tss/teams/:team` - `GET /api/tss/teams/:team/history` - `GET /api/tss/teams/:team/games` +- `GET /api/tss/player/:uid` ## Local development diff --git a/backend/src/main.rs b/backend/src/main.rs index 80435f7..82cd1cf 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -103,6 +103,11 @@ struct LeaderboardResponse { teams: Vec, } +#[derive(Serialize)] +struct PlayerLeaderboardResponse { + players: Vec, +} + #[derive(Serialize)] struct RecentGamesResponse { matches: Vec, @@ -138,6 +143,26 @@ struct TeamLeaderboardRow { total_kills: i64, } +#[derive(Serialize)] +struct PlayerLeaderboardRow { + uid: String, + nick: Option, + 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, + score: i64, + kdr: f64, + teams_seen: i64, + last_seen: i64, +} + #[derive(Serialize)] struct TeamDetail { team_id: i64, @@ -323,6 +348,7 @@ async fn main() -> Result<(), Box> { let app = Router::new() .route("/health", get(health)) .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/games/{session_id}", get(game_detail)) .route("/api/tss/teams/resolve", get(resolve_team)) @@ -413,6 +439,16 @@ async fn leaderboard( Ok(Json(LeaderboardResponse { teams })) } +async fn player_leaderboard( + State(state): State>, + Query(query): Query, +) -> ApiResult { + let limit = i64::from(query.limit.unwrap_or(100).clamp(1, 100)); + let battles_conn = open_db(&state.battles_db)?; + let players = player_leaderboard_rows(&battles_conn, limit)?; + Ok(Json(PlayerLeaderboardResponse { players })) +} + fn leaderboard_teams(state: &AppState, limit: usize) -> Result, ApiError> { let teams_conn = open_db(&state.teams_db)?; // Deduplicate teams by name across tournaments — pick the highest team_id @@ -951,6 +987,100 @@ fn player_summaries_for( Ok(out) } +fn player_leaderboard_rows( + conn: &Connection, + limit: i64, +) -> Result, ApiError> { + let mut stmt = conn + .prepare( + "WITH per_session AS ( + SELECT + UID, + session_id, + MAX(victor_bool) AS victor_bool, + MAX(ground_kills) AS ground_kills, + MAX(air_kills) AS air_kills, + MAX(assists) AS assists, + MAX(captures) AS captures, + MAX(deaths) AS deaths, + MAX(score) AS score, + MAX(endtime_unix) AS endtime_unix, + MAX(team_name) AS team_name + FROM player_games_hist + WHERE UID IS NOT NULL AND UID != '' + GROUP BY UID, session_id + ), + latest_names AS ( + SELECT UID, nick + FROM ( + SELECT + UID, + nick, + ROW_NUMBER() OVER ( + PARTITION BY UID + ORDER BY endtime_unix DESC, nick COLLATE NOCASE ASC + ) AS rn + FROM player_games_hist + WHERE nick IS NOT NULL AND nick != '' + ) + WHERE rn = 1 + ) + SELECT + p.UID, + n.nick, + COUNT(*) AS battles, + COALESCE(SUM(CASE WHEN UPPER(p.victor_bool) = 'WIN' THEN 1 ELSE 0 END), 0) AS wins, + COALESCE(SUM(CASE WHEN UPPER(p.victor_bool) = 'LOSS' THEN 1 ELSE 0 END), 0) AS losses, + COALESCE(SUM(p.ground_kills), 0) AS ground_kills, + COALESCE(SUM(p.air_kills), 0) AS air_kills, + COALESCE(SUM(p.assists), 0) AS assists, + COALESCE(SUM(p.captures), 0) AS captures, + COALESCE(SUM(p.deaths), 0) AS deaths, + COALESCE(SUM(p.score), 0) AS score, + COUNT(DISTINCT p.team_name) AS teams_seen, + MAX(p.endtime_unix) AS last_seen + FROM per_session p + LEFT JOIN latest_names n ON n.UID = p.UID + GROUP BY p.UID + ORDER BY score DESC, (ground_kills + air_kills) DESC, battles DESC, p.UID ASC + LIMIT ?1", + ) + .map_err(db_error)?; + + let players = stmt + .query_map(params![limit], |row| { + let ground: i64 = row.get(5)?; + let air: i64 = row.get(6)?; + let battles: i64 = row.get(2)?; + let wins: i64 = row.get(3)?; + let deaths: i64 = row.get(9)?; + let total_kills = ground + air; + Ok(PlayerLeaderboardRow { + uid: row.get(0)?, + nick: row.get(1)?, + total_battles: battles, + wins, + losses: row.get(4)?, + win_rate: percent(wins, battles), + ground_kills: ground, + air_kills: air, + total_kills, + assists: row.get(7)?, + captures: row.get(8)?, + deaths, + score: row.get(10)?, + kdr: ratio(total_kills, deaths), + teams_seen: row.get(11)?, + last_seen: row.get(12)?, + }) + }) + .map_err(db_error)? + .collect::, _>>() + .map_err(db_error)?; + + Ok(players) +} + fn player_search(conn: &Connection, query: &str, limit: i64) -> Result, ApiError> { let like = format!("%{}%", escape_like(query)); let mut stmt = conn diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d92cb44..cec1c16 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -15,6 +15,7 @@ const apiEndpoints = { viewerEvent: '/api/viewers/event', viewerDelete: '/api/viewers/delete', teams: '/api/tss/leaderboard/teams?limit=100', + players: '/api/tss/leaderboard/players?limit=100', homeTeams: '/api/tss/leaderboard/teams?limit=4', teamsHealth: '/api/tss/leaderboard/teams?limit=1', recentGames: '/api/tss/games/recent?limit=50', @@ -29,6 +30,7 @@ const apiEndpoints = { const navItems = [ { path: '/', label: 'Home' }, { path: '/teams', label: 'Team Leaderboard' }, + { path: '/players', label: 'Player Leaderboard' }, { path: '/battle-logs', label: 'Battle Logs' }, { path: '/viewers', label: 'Viewers' }, { path: '/docs', label: 'Setup' }, @@ -74,6 +76,7 @@ async function fetchJson(path, signal) { function parseRoute(pathname = window.location.pathname) { if (pathname === '/') return { page: 'home', teamName: '' } if (pathname === '/teams') return { page: 'teams', teamName: '' } + if (pathname === '/players') return { page: 'players', teamName: '' } if (pathname === '/uptime') return { page: 'uptime', teamName: '' } if (pathname === '/viewers') return { page: 'viewers', teamName: '' } if (pathname === '/privacy') return { page: 'privacy', teamName: '' } @@ -102,6 +105,10 @@ function gamePath(gameId) { return `/games/${encodeURIComponent(gameId)}` } +function playerPath(uid) { + return `/players/${encodeURIComponent(uid)}` +} + function formatNumber(value) { return numberFormat.format(Number(value || 0)) } @@ -420,6 +427,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 === 'players') return 'Player Leaderboard' if (route.page === 'battle-logs') return 'Battle Logs' if (route.page === 'game') return route.gameId ? `Game ${route.gameId}` : 'Game' if (route.page === 'uptime') return 'Uptime' @@ -437,6 +445,7 @@ function currentPublicOrigin() { function canonicalPathForRoute(route) { if (route.page === 'team' && route.teamName) return teamPath(route.teamName) if (route.page === 'teams') return '/teams' + if (route.page === 'players') return '/players' if (route.page === 'battle-logs') return '/battle-logs' if (route.page === 'game' && route.gameId) return gamePath(route.gameId) if (route.page === 'uptime') return '/uptime' @@ -496,6 +505,12 @@ function seoForRoute(route, profileDetail = null) { robots: 'index, follow', path: '/teams', }, + players: { + title: "TSS Player Leaderboard | Toothless' TSS Bot", + description: 'Browse the TSS player leaderboard, compare War Thunder player score, kills, win rate, KDR, and battle activity.', + robots: 'index, follow', + path: '/players', + }, 'battle-logs': { title: "TSS Battle Logs | Toothless' TSS Bot", description: 'Read recent TSS battle logs with team names, map history, player counts, battle times, and War Thunder match context.', @@ -912,6 +927,7 @@ function App() { function AppContent() { const [route, setRoute] = useState(() => parseRoute()) const [leaderboard, setLeaderboard] = useState({ status: 'idle', data: null, error: null }) + const [playerLeaderboard, setPlayerLeaderboard] = useState({ status: 'idle', data: null, error: null }) const [homeTeams, setHomeTeams] = useState({ status: 'idle', data: null, error: null }) const [live, setLive] = useState({ status: 'idle', data: null, error: null, updatedAt: 0 }) const [uptime, setUptime] = useState({ status: 'idle', checks: [], history: [], updatedAt: null }) @@ -935,6 +951,10 @@ function AppContent() { () => leaderboard.data?.teams || leaderboard.data?.squadrons || [], [leaderboard.data], ) + const players = useMemo( + () => playerLeaderboard.data?.players || [], + [playerLeaderboard.data], + ) const teamsToWatch = useMemo( () => homeTeams.data?.teams || homeTeams.data?.squadrons || teams.slice(0, 4), [homeTeams.data, teams], @@ -1187,6 +1207,24 @@ function AppContent() { return () => controller.abort() }, [leaderboard.status, route.page]) + useEffect(() => { + if (route.page !== 'players') return + if (playerLeaderboard.status === 'ready' || playerLeaderboard.status === 'loading') return + + const controller = new AbortController() + setPlayerLeaderboard({ status: 'loading', data: null, error: null }) + + fetchJson(apiEndpoints.players, controller.signal) + .then((data) => setPlayerLeaderboard({ status: 'ready', data, error: null })) + .catch((error) => { + if (!controller.signal.aborted) { + setPlayerLeaderboard({ status: 'error', data: null, error: error.message }) + } + }) + + return () => controller.abort() + }, [playerLeaderboard.status, route.page]) + useEffect(() => { if (route.page !== 'home') return if (homeTeams.status === 'ready' || homeTeams.status === 'loading') return @@ -1558,6 +1596,8 @@ function AppContent() { const activeNavPath = route.page === 'team' ? '/teams' + : route.page === 'player' || route.page === 'players' + ? '/players' : route.page === 'battle-logs' || route.page === 'game' ? '/battle-logs' : route.page === 'viewers' @@ -1586,6 +1626,26 @@ function AppContent() { } }, [route.page]) + useEffect(() => { + if (route.page !== 'players') return + + const controller = new AbortController() + const timer = window.setInterval(() => { + fetchJson(apiEndpoints.players, controller.signal) + .then((data) => setPlayerLeaderboard({ status: 'ready', data, error: null })) + .catch((error) => { + if (!controller.signal.aborted) { + setPlayerLeaderboard((current) => ({ ...current, error: error.message })) + } + }) + }, 60000) + + return () => { + window.clearInterval(timer) + controller.abort() + } + }, [route.page]) + return (
) : null} + {route.page === 'players' ? ( + + ) : null} {route.page === 'team' ? ( +
+

Player Leaderboard

+

+ {leaderboard.status === 'loading' + ? 'Loading player leaderboard' + : leaderboard.error || `${players.length} players returned`} +

+
+ +
+
+
+ {players.length ? ( +
+

Rank

+

Player

+

Score

+

Battles

+

Kills

+

Assists

+

WR

+

KDR

+

Teams

+
+ ) : null} + + {players.map((player, index) => ( + + ))} +
+
+ + {!players.length ? ( +

+ {leaderboard.status === 'loading' + ? 'Loading player leaderboard' + : leaderboard.error || 'No players returned'} +

+ ) : null} +
+ + ) +} + function TeamProfilePage({ navigate, profile, requestedTeam, teams }) { const detail = profile.detail.data const summary = detail?.team_summary || detail?.squadron_summary @@ -2872,7 +3002,7 @@ function GamePage({ gameId, navigate }) {