fuck it we ball
This commit is contained in:
@@ -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"))?
|
||||
|
||||
Reference in New Issue
Block a user