ai generated solutions to our ai generated problems

This commit is contained in:
2026-06-15 07:53:33 +01:00
parent 5cd95cf78f
commit 0db73d669d
4 changed files with 271 additions and 181 deletions
+240 -54
View File
@@ -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<TeamLeaderboardRow>,
}
#[derive(Serialize)]
struct RecentGamesResponse {
matches: Vec<GameRow>,
}
#[derive(Serialize)]
struct SearchResponse {
teams: Vec<TeamSearchRow>,
@@ -138,7 +143,7 @@ struct TeamDetail {
players: Vec<PlayerSummary>,
}
#[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<String>,
session_id: String,
timestamp: i64,
endtime_unix: i64,
@@ -286,6 +293,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
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::<Result<Vec<_>, _>>()
.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<Arc<AppState>>,
Query(query): Query<LimitQuery>,
) -> ApiResult<RecentGamesResponse> {
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<Arc<AppState>>,
Query(query): Query<ResolveQuery>,
@@ -634,6 +655,60 @@ fn team_summary_for(conn: &Connection, team_name: &str) -> Result<TeamSummary, A
.map_err(db_error)
}
fn team_summaries_for(conn: &Connection) -> Result<HashMap<String, TeamSummary>, 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::<Result<HashMap<_, _>, _>>()
.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::<Result<Vec<_>, _>>()
.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<String> = 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<String> = 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::<Result<HashMap<_, _>, _>>()
.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<Option<PlayerCareer
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))
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),
})
}
})
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),
})
}
},
)
})
}
@@ -941,6 +1050,7 @@ fn games_for(conn: &Connection, team_name: &str) -> Result<Vec<GameRow>, ApiErro
let map_name: Option<String> = row.get(2)?;
let mission_mode: Option<String> = 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<Vec<GameRow>, ApiErro
Ok(rows)
}
fn recent_games_for(conn: &Connection, limit: i64) -> Result<Vec<GameRow>, 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::<Result<Vec<_>, _>>()
.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<String, ApiError> {
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())