fuck it we ball
This commit is contained in:
@@ -252,6 +252,50 @@ struct GameStats {
|
|||||||
team_kills_stat: i64,
|
team_kills_stat: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct PlayerSearchResponse {
|
||||||
|
players: Vec<PlayerRef>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct PlayerRef {
|
||||||
|
uid: String,
|
||||||
|
nick: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct PlayerProfile {
|
||||||
|
uid: String,
|
||||||
|
nick: Option<String>,
|
||||||
|
data_set: &'static str,
|
||||||
|
career: PlayerCareer,
|
||||||
|
teams: Vec<PlayerTeamRef>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<i64>,
|
||||||
|
team_tag: Option<String>,
|
||||||
|
team_name: Option<String>,
|
||||||
|
games: i64,
|
||||||
|
last_seen: i64,
|
||||||
|
}
|
||||||
|
|
||||||
struct TeamRecord {
|
struct TeamRecord {
|
||||||
team_id: i64,
|
team_id: i64,
|
||||||
long_name: String,
|
long_name: String,
|
||||||
@@ -290,6 +334,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.route("/api/tss/teams/{team}", get(team_detail))
|
.route("/api/tss/teams/{team}", get(team_detail))
|
||||||
.route("/api/tss/teams/{team}/history", get(team_history))
|
.route("/api/tss/teams/{team}/history", get(team_history))
|
||||||
.route("/api/tss/teams/{team}/games", get(team_games))
|
.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(
|
.layer(
|
||||||
CorsLayer::new()
|
CorsLayer::new()
|
||||||
.allow_methods([Method::GET])
|
.allow_methods([Method::GET])
|
||||||
@@ -535,6 +582,50 @@ async fn team_games(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn resolve_player(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Query(query): Query<ResolveQuery>,
|
||||||
|
) -> ApiResult<PlayerSearchResponse> {
|
||||||
|
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<Arc<AppState>>,
|
||||||
|
Query(query): Query<SearchQuery>,
|
||||||
|
) -> ApiResult<PlayerSearchResponse> {
|
||||||
|
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<Arc<AppState>>,
|
||||||
|
Path(uid): Path<String>,
|
||||||
|
) -> ApiResult<PlayerProfile> {
|
||||||
|
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, ApiError> {
|
fn open_db(path: &FsPath) -> Result<Connection, ApiError> {
|
||||||
Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY).map_err(|error| {
|
Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY).map_err(|error| {
|
||||||
ApiError::internal(format!("Could not open {}: {}", path.display(), error))
|
ApiError::internal(format!("Could not open {}: {}", path.display(), error))
|
||||||
@@ -713,6 +804,157 @@ fn player_summaries_for(
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn player_search(conn: &Connection, query: &str, limit: i64) -> Result<Vec<PlayerRef>, 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::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(db_error)?;
|
||||||
|
Ok(players)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn player_resolve(conn: &Connection, name: &str) -> Result<Vec<PlayerRef>, 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::<Result<Vec<_>, _>>()
|
||||||
|
.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<Option<String>, 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<String>>(0),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(db_error)
|
||||||
|
.map(|opt| opt.flatten())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn player_career_for(conn: &Connection, uid: &str) -> Result<Option<PlayerCareer>, 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<Vec<PlayerTeamRef>, 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::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(db_error)?;
|
||||||
|
Ok(teams)
|
||||||
|
}
|
||||||
|
|
||||||
fn period_history_for(conn: &Connection, team_id: i64) -> Result<Vec<PeriodHistory>, ApiError> {
|
fn period_history_for(conn: &Connection, team_id: i64) -> Result<Vec<PeriodHistory>, ApiError> {
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -847,6 +1089,25 @@ fn validate_team_name(name: &str) -> Result<&str, ApiError> {
|
|||||||
Ok(trimmed)
|
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<String, ApiError> {
|
||||||
|
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<String, ApiError> {
|
fn decode_path_team(value: &str) -> Result<String, ApiError> {
|
||||||
let decoded = urlencoding::decode(value)
|
let decoded = urlencoding::decode(value)
|
||||||
.map_err(|_| ApiError::bad_request("Invalid team name"))?
|
.map_err(|_| ApiError::bad_request("Invalid team name"))?
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const apiEndpoints = {
|
|||||||
detail: (name) => `/api/tss/teams/${encodeURIComponent(name)}`,
|
detail: (name) => `/api/tss/teams/${encodeURIComponent(name)}`,
|
||||||
history: (name) => `/api/tss/teams/${encodeURIComponent(name)}/history`,
|
history: (name) => `/api/tss/teams/${encodeURIComponent(name)}/history`,
|
||||||
games: (name) => `/api/tss/teams/${encodeURIComponent(name)}/games`,
|
games: (name) => `/api/tss/teams/${encodeURIComponent(name)}/games`,
|
||||||
|
player: (uid) => `/api/tss/player/${encodeURIComponent(uid)}`,
|
||||||
}
|
}
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
@@ -77,6 +78,10 @@ function parseRoute(pathname = window.location.pathname) {
|
|||||||
if (pathname === '/viewers') return { page: 'viewers', teamName: '' }
|
if (pathname === '/viewers') return { page: 'viewers', teamName: '' }
|
||||||
if (pathname === '/privacy') return { page: 'privacy', teamName: '' }
|
if (pathname === '/privacy') return { page: 'privacy', teamName: '' }
|
||||||
if (pathname === '/docs') return { page: 'docs', 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/')) {
|
if (pathname.startsWith('/teams/')) {
|
||||||
const teamName = decodeURIComponent(pathname.slice('/teams/'.length))
|
const teamName = decodeURIComponent(pathname.slice('/teams/'.length))
|
||||||
return { page: 'team', teamName }
|
return { page: 'team', teamName }
|
||||||
@@ -356,6 +361,7 @@ function routeLabel(route) {
|
|||||||
if (route.page === 'viewers') return 'viewers'
|
if (route.page === 'viewers') return 'viewers'
|
||||||
if (route.page === 'privacy') return 'Privacy notice'
|
if (route.page === 'privacy') return 'Privacy notice'
|
||||||
if (route.page === 'docs') return 'Docs'
|
if (route.page === 'docs') return 'Docs'
|
||||||
|
if (route.page === 'player') return route.uid ? `Player ${route.uid}` : 'Player'
|
||||||
return 'Home'
|
return 'Home'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,6 +377,7 @@ function canonicalPathForRoute(route) {
|
|||||||
if (route.page === 'viewers') return '/viewers'
|
if (route.page === 'viewers') return '/viewers'
|
||||||
if (route.page === 'privacy') return '/privacy'
|
if (route.page === 'privacy') return '/privacy'
|
||||||
if (route.page === 'docs') return '/docs'
|
if (route.page === 'docs') return '/docs'
|
||||||
|
if (route.page === 'player' && route.uid) return `/players/${encodeURIComponent(route.uid)}`
|
||||||
return '/'
|
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 = {
|
const byPage = {
|
||||||
teams: {
|
teams: {
|
||||||
title: "TSS Team Leaderboard | Toothless' TSS Bot",
|
title: "TSS Team Leaderboard | Toothless' TSS Bot",
|
||||||
@@ -1602,6 +1618,7 @@ function AppContent() {
|
|||||||
{route.page === 'viewers' ? <ViewersPage viewers={viewers} /> : null}
|
{route.page === 'viewers' ? <ViewersPage viewers={viewers} /> : null}
|
||||||
{route.page === 'privacy' ? <PrivacyPage /> : null}
|
{route.page === 'privacy' ? <PrivacyPage /> : null}
|
||||||
{route.page === 'docs' ? <DocsPage /> : null}
|
{route.page === 'docs' ? <DocsPage /> : null}
|
||||||
|
{route.page === 'player' ? <PlayerPage uid={route.uid} navigate={navigate} /> : null}
|
||||||
</section>
|
</section>
|
||||||
<Footer navigate={navigate} />
|
<Footer navigate={navigate} />
|
||||||
<ConsentBanner preferences={analyticsPreferences} onChoose={chooseAnalyticsConsent} />
|
<ConsentBanner preferences={analyticsPreferences} onChoose={chooseAnalyticsConsent} />
|
||||||
@@ -1945,6 +1962,93 @@ function PrivacyPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PlayerStatCard({ label, value }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-fury-white p-4">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-text-soft">{label}</p>
|
||||||
|
<p className="mt-1 text-2xl font-bold">{value}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlayerPage({ uid, navigate }) {
|
||||||
|
const [state, setState] = useState({ status: 'loading', data: null, error: '' })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!uid) {
|
||||||
|
setState({ status: 'error', data: null, error: 'No player specified.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let cancelled = false
|
||||||
|
setState({ status: 'loading', data: null, error: '' })
|
||||||
|
fetchJson(apiEndpoints.player(uid))
|
||||||
|
.then((data) => {
|
||||||
|
if (!cancelled) setState({ status: 'ready', data, error: '' })
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) setState({ status: 'error', data: null, error: err?.message || 'Failed to load player.' })
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [uid])
|
||||||
|
|
||||||
|
const { status, data, error } = state
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mx-auto max-w-4xl pb-12 pt-24 sm:pt-28">
|
||||||
|
<div className="border-b border-border pb-6">
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">Player</p>
|
||||||
|
<h1 className="mt-2 text-4xl font-bold">
|
||||||
|
{status === 'ready' ? (data.nick || `Player ${uid}`) : `Player ${uid || ''}`}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-3 text-text-soft">UID {uid} · TSS career</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status === 'loading' ? <p className="mt-8 text-text-soft">Loading…</p> : null}
|
||||||
|
{status === 'error' ? <p className="mt-8 text-text-soft">{error}</p> : null}
|
||||||
|
|
||||||
|
{status === 'ready' ? (
|
||||||
|
<div className="mt-8 grid gap-8">
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||||
|
<PlayerStatCard label="Battles" value={formatNumber(data.career.total_battles)} />
|
||||||
|
<PlayerStatCard label="Win rate" value={`${(data.career.win_rate || 0).toFixed(1)}%`} />
|
||||||
|
<PlayerStatCard label="K/D" value={(data.career.kdr || 0).toFixed(2)} />
|
||||||
|
<PlayerStatCard label="Total kills" value={formatNumber(data.career.total_kills)} />
|
||||||
|
<PlayerStatCard label="Wins / Losses" value={`${formatNumber(data.career.wins)} / ${formatNumber(data.career.losses)}`} />
|
||||||
|
<PlayerStatCard label="Deaths" value={formatNumber(data.career.deaths)} />
|
||||||
|
<PlayerStatCard label="Ground kills" value={formatNumber(data.career.ground_kills)} />
|
||||||
|
<PlayerStatCard label="Air kills" value={formatNumber(data.career.air_kills)} />
|
||||||
|
<PlayerStatCard label="Assists" value={formatNumber(data.career.assists)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{Array.isArray(data.teams) && data.teams.length ? (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-text">Teams seen with</h2>
|
||||||
|
<div className="mt-3 flex flex-col gap-2">
|
||||||
|
{data.teams.map((team) => {
|
||||||
|
const label = team.team_tag || team.team_name || 'Unknown'
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${team.team_tag}-${team.team_id ?? ''}`}
|
||||||
|
className="flex items-center justify-between rounded-lg border border-border bg-fury-white px-4 py-3 text-left transition hover:border-fury-cyan"
|
||||||
|
onClick={() => navigate(teamPath(label))}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="font-semibold text-text">{label}</span>
|
||||||
|
<span className="text-sm text-text-soft">{formatNumber(team.games)} games</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function DocsPage() {
|
function DocsPage() {
|
||||||
return (
|
return (
|
||||||
<section className="mx-auto max-w-4xl pb-12 pt-24 sm:pt-28">
|
<section className="mx-auto max-w-4xl pb-12 pt-24 sm:pt-28">
|
||||||
|
|||||||
+43
@@ -1794,6 +1794,37 @@ function allowedApiTarget(req) {
|
|||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pathname === '/api/tss/players/resolve') {
|
||||||
|
const keys = [...params.keys()]
|
||||||
|
const name = params.get('name') || ''
|
||||||
|
if (keys.some((key) => key !== 'name') || name.length < 2 || name.length > MAX_TEAM_NAME_LENGTH) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === '/api/tss/players/search') {
|
||||||
|
const keys = [...params.keys()]
|
||||||
|
const query = params.get('q') || params.get('name') || ''
|
||||||
|
const limit = Number(params.get('limit') || 25)
|
||||||
|
if (
|
||||||
|
keys.some((key) => !['q', 'name', 'limit'].includes(key)) ||
|
||||||
|
query.length < 2 ||
|
||||||
|
query.length > MAX_TEAM_NAME_LENGTH ||
|
||||||
|
!Number.isInteger(limit) ||
|
||||||
|
limit < 1 ||
|
||||||
|
limit > 25
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\/api\/tss\/player\/[0-9]{1,32}$/.test(pathname)) {
|
||||||
|
if ([...params.keys()].length) return null
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
const teamMatch = pathname.match(/^\/api\/tss\/teams\/([^/]+)(?:\/(history|games))?$/)
|
const teamMatch = pathname.match(/^\/api\/tss\/teams\/([^/]+)(?:\/(history|games))?$/)
|
||||||
if (!teamMatch || [...params.keys()].length) return null
|
if (!teamMatch || [...params.keys()].length) return null
|
||||||
|
|
||||||
@@ -1931,6 +1962,18 @@ function routeSeo(pathname) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (cleanPath.startsWith('/players/')) {
|
||||||
|
const uid = decodeRouteSegment(cleanPath.slice('/players/'.length))
|
||||||
|
if (uid) {
|
||||||
|
return {
|
||||||
|
title: `Player ${uid} | Toothless' TSS Bot`,
|
||||||
|
description: `TSS career stats for player ${uid} — battles, win rate, kills, and teams seen with.`,
|
||||||
|
robots: 'noindex, follow',
|
||||||
|
path: `/players/${encodeURIComponent(uid)}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const byPath = {
|
const byPath = {
|
||||||
'/': {
|
'/': {
|
||||||
title: "Toothless' TSS Bot | Live TSS Leaderboards and Battle Logs",
|
title: "Toothless' TSS Bot | Live TSS Leaderboards and Battle Logs",
|
||||||
|
|||||||
Reference in New Issue
Block a user