fuck it we ball

This commit is contained in:
FURRO404
2026-05-30 08:44:28 -07:00
parent 64c72d2ecb
commit 3436c91fdc
3 changed files with 408 additions and 0 deletions
+261
View File
@@ -252,6 +252,50 @@ struct GameStats {
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 {
team_id: i64,
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}/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<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> {
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<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> {
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<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> {
let decoded = urlencoding::decode(value)
.map_err(|_| ApiError::bad_request("Invalid team name"))?