fix: dedup battle-logs list (one row/session), case-insensitive vehicle lookup, win/loss colors, clearer dividers, centered stat columns

This commit is contained in:
FURRO404
2026-06-18 01:29:30 -07:00
parent 616139d6ce
commit 51c0dd264a
4 changed files with 81 additions and 84 deletions
+47 -60
View File
@@ -1322,6 +1322,9 @@ fn games_for(conn: &Connection, team_name: &str) -> Result<Vec<GameRow>, ApiErro
}
fn recent_games_for(conn: &Connection, limit: i64) -> Result<Vec<GameRow>, ApiError> {
// One row per SESSION (not per team) so the battle-logs list shows each game
// once. Both team names come from the winner/loser subqueries; player_count is
// the larger team's size so the "NvN" label is per-side.
let mut stmt = conn
.prepare(
"WITH recent AS (
@@ -1332,47 +1335,25 @@ fn recent_games_for(conn: &Connection, limit: i64) -> Result<Vec<GameRow>, ApiEr
ORDER BY timestamp DESC
LIMIT ?1
),
per_player AS (
SELECT session_id, team_name, UID,
MAX(endtime_unix) AS endtime_unix,
MAX(CASE WHEN victor_bool = 'Win' THEN 1 ELSE 0 END) AS won,
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(missile_evades) AS missile_evades,
MAX(shell_interceptions) AS shell_interceptions,
MAX(team_kills_stat) AS team_kills_stat
team_size AS (
SELECT session_id, team_name, COUNT(DISTINCT UID) AS players
FROM player_games_hist
WHERE session_id IN (SELECT session_id FROM recent)
AND team_name IS NOT NULL AND team_name != ''
GROUP BY session_id, team_name COLLATE NOCASE, UID
GROUP BY session_id, team_name COLLATE NOCASE
)
SELECT
pp.team_name,
pp.session_id,
COALESCE(m.endtime_unix, MAX(pp.endtime_unix), 0) AS timestamp,
r.session_id,
COALESCE(m.endtime_unix, r.timestamp, 0) AS timestamp,
m.mission_name,
m.mission_mode,
m.tournament_name,
m.duration,
COALESCE(m.draw, 0),
CASE WHEN MAX(pp.won) = 1 THEN 'Win' ELSE 'Loss' END AS result,
COUNT(*),
COALESCE(SUM(pp.ground_kills), 0),
COALESCE(SUM(pp.air_kills), 0),
COALESCE(SUM(pp.assists), 0),
COALESCE(SUM(pp.captures), 0),
COALESCE(SUM(pp.deaths), 0),
COALESCE(SUM(pp.score), 0),
COALESCE(SUM(pp.missile_evades), 0),
COALESCE(SUM(pp.shell_interceptions), 0),
COALESCE(SUM(pp.team_kills_stat), 0),
(SELECT MAX(players) FROM team_size ts WHERE ts.session_id = r.session_id) AS player_count,
(SELECT pg.team_name
FROM player_games_hist pg
WHERE pg.session_id = pp.session_id
WHERE pg.session_id = r.session_id
AND pg.team_name IS NOT NULL
AND pg.team_name != ''
AND pg.victor_bool = 'Win'
@@ -1381,16 +1362,15 @@ fn recent_games_for(conn: &Connection, limit: i64) -> Result<Vec<GameRow>, ApiEr
LIMIT 1),
(SELECT pg.team_name
FROM player_games_hist pg
WHERE pg.session_id = pp.session_id
WHERE pg.session_id = r.session_id
AND pg.team_name IS NOT NULL
AND pg.team_name != ''
AND pg.victor_bool = 'Loss'
GROUP BY pg.team_name COLLATE NOCASE
ORDER BY COUNT(DISTINCT pg.UID) DESC, pg.team_name COLLATE NOCASE
LIMIT 1)
FROM per_player pp
LEFT JOIN match_summary m ON m.session_id = pp.session_id
GROUP BY pp.team_name COLLATE NOCASE, pp.session_id
FROM recent r
LEFT JOIN match_summary m ON m.session_id = r.session_id
ORDER BY timestamp DESC
LIMIT ?1",
)
@@ -1398,32 +1378,32 @@ fn recent_games_for(conn: &Connection, limit: i64) -> Result<Vec<GameRow>, ApiEr
let rows = stmt
.query_map(params![limit], |row| {
let timestamp: i64 = row.get(2)?;
let draw_int: i64 = row.get(7)?;
let timestamp: i64 = row.get(1)?;
let draw_int: i64 = row.get(6)?;
Ok(GameRow {
team_name: row.get(0)?,
session_id: row.get(1)?,
team_name: None,
session_id: row.get(0)?,
timestamp,
endtime_unix: timestamp,
map_name: row.get(3)?,
mission_mode: row.get(4)?,
result: row.get(8)?,
player_count: row.get(9)?,
winning_team: row.get(19)?,
losing_team: row.get(20)?,
tournament_name: row.get(5)?,
duration: row.get(6)?,
map_name: row.get(2)?,
mission_mode: row.get(3)?,
result: String::new(),
player_count: row.get(7)?,
winning_team: row.get(8)?,
losing_team: row.get(9)?,
tournament_name: row.get(4)?,
duration: row.get(5)?,
draw: draw_int != 0,
stats: GameStats {
ground_kills: row.get(10)?,
air_kills: row.get(11)?,
assists: row.get(12)?,
captures: row.get(13)?,
deaths: row.get(14)?,
score: row.get(15)?,
missile_evades: row.get(16)?,
shell_interceptions: row.get(17)?,
team_kills_stat: row.get(18)?,
ground_kills: 0,
air_kills: 0,
assists: 0,
captures: 0,
deaths: 0,
score: 0,
missile_evades: 0,
shell_interceptions: 0,
team_kills_stat: 0,
},
})
})
@@ -1796,11 +1776,18 @@ fn allowed_origins() -> AllowOrigin {
}
}
// Vehicle cdk keys are lowercased on load and at lookup time so DB casing
// (e.g. "us_M4A2_76W_sherman" vs "us_m4a2_76w_sherman") never misses, mirroring
// the Python LangTableReader's case-insensitive behaviour.
fn load_vehicle_names(path: &FsPath) -> HashMap<String, HashMap<String, String>> {
match fs::read_to_string(path) {
let parsed: HashMap<String, HashMap<String, String>> = match fs::read_to_string(path) {
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
Err(_) => HashMap::new(),
}
};
parsed
.into_iter()
.map(|(k, v)| (k.to_lowercase(), v))
.collect()
}
fn load_vehicle_icons(path: &FsPath) -> HashMap<String, String> {
@@ -1815,7 +1802,7 @@ fn load_vehicle_icons(path: &FsPath) -> HashMap<String, String> {
entry.get(0).and_then(|v| v.as_str()),
entry.get(2).and_then(|v| v.as_str()),
) {
out.insert(cdk.to_string(), icon.to_string());
out.insert(cdk.to_lowercase(), icon.to_string());
}
}
out
@@ -1826,7 +1813,7 @@ fn lookup_vehicle_name(
cdk: &str,
lang: &str,
) -> String {
if let Some(by_lang) = names.get(cdk) {
if let Some(by_lang) = names.get(&cdk.to_lowercase()) {
if let Some(n) = by_lang.get(lang) {
return n.clone();
}
@@ -1839,9 +1826,9 @@ fn lookup_vehicle_name(
fn lookup_vehicle_icon(icons: &HashMap<String, String>, cdk: &str) -> String {
icons
.get(cdk)
.get(&cdk.to_lowercase())
.cloned()
.unwrap_or_else(|| format!("{cdk}.png"))
.unwrap_or_else(|| format!("{}.png", cdk.to_lowercase()))
}
fn resolve_db_path(env_key: &str, default_file: &str) -> PathBuf {
+26 -24
View File
@@ -2870,43 +2870,45 @@ function GamePage({ gameId, navigate }) {
<div className="overflow-x-auto">
<div className="min-w-[720px]">
{participants.length ? (
<div className={`grid ${SCOREBOARD_GRID} gap-3 border-b border-surface px-5 py-3 text-xs font-semibold uppercase tracking-wide text-text-soft`}>
<div className={`grid ${SCOREBOARD_GRID} gap-3 border-b border-border px-5 py-3 text-xs font-semibold uppercase tracking-wide text-text-soft`}>
<p>Team / player</p>
<p className="text-right">Air</p>
<p className="text-right">Ground</p>
<p className="text-right">Assists</p>
<p className="text-right">Deaths</p>
<p className="text-right">Caps</p>
<p className="text-right">Score</p>
<p className="text-center">Air</p>
<p className="text-center">Ground</p>
<p className="text-center">Assists</p>
<p className="text-center">Deaths</p>
<p className="text-center">Caps</p>
<p className="text-center">Score</p>
</div>
) : null}
{participants.map((participant) => {
const won = String(participant.result || '').toLowerCase() === 'win'
const accent = won ? 'border-l-win' : 'border-l-loss'
const nameColor = won ? 'text-win' : 'text-loss'
return (
<div className="border-b border-surface" key={participant.team_name}>
<div className={`border-b-4 border-border border-l-4 ${accent}`} key={participant.team_name}>
<button
className={`grid w-full ${SCOREBOARD_GRID} items-center gap-3 px-5 py-3 text-left transition hover:bg-surface`}
className={`grid w-full ${SCOREBOARD_GRID} items-center gap-3 border-b border-border bg-surface/60 px-5 py-3 text-left transition hover:bg-surface`}
onClick={() => navigate(teamPath(participant.team_name))}
type="button"
>
<div className="min-w-0">
<p className={`truncate font-semibold ${won ? 'text-fury-violet' : 'text-text-soft'}`}>
<p className={`truncate font-bold ${nameColor}`}>
{participant.team_name}
</p>
<p className="text-xs text-text-muted">
<p className={`text-xs font-semibold uppercase tracking-wide ${nameColor}`}>
{won ? 'Win' : 'Loss'} · {formatNumber(participant.player_count)} players
</p>
</div>
<p className="text-right text-sm">{formatNumber(participant.stats?.air_kills)}</p>
<p className="text-right text-sm">{formatNumber(participant.stats?.ground_kills)}</p>
<p className="text-right text-sm">{formatNumber(participant.stats?.assists)}</p>
<p className="text-right text-sm">{formatNumber(participant.stats?.deaths)}</p>
<p className="text-right text-sm">{formatNumber(participant.stats?.captures)}</p>
<p className="text-right text-sm font-semibold">{formatNumber(participant.stats?.score)}</p>
<p className="text-center text-sm">{formatNumber(participant.stats?.air_kills)}</p>
<p className="text-center text-sm">{formatNumber(participant.stats?.ground_kills)}</p>
<p className="text-center text-sm">{formatNumber(participant.stats?.assists)}</p>
<p className="text-center text-sm">{formatNumber(participant.stats?.deaths)}</p>
<p className="text-center text-sm">{formatNumber(participant.stats?.captures)}</p>
<p className="text-center text-sm font-semibold">{formatNumber(participant.stats?.score)}</p>
</button>
<div className="pb-3">
<div className="divide-y divide-surface py-1">
{(participant.players || []).map((player) => (
<button
className={`grid w-full ${SCOREBOARD_GRID} items-center gap-3 px-5 py-2 text-left text-sm transition hover:bg-surface`}
@@ -2938,12 +2940,12 @@ function GamePage({ gameId, navigate }) {
)}
</div>
</div>
<p className="text-right">{formatNumber(player.stats?.air_kills)}</p>
<p className="text-right">{formatNumber(player.stats?.ground_kills)}</p>
<p className="text-right">{formatNumber(player.stats?.assists)}</p>
<p className="text-right">{formatNumber(player.stats?.deaths)}</p>
<p className="text-right">{formatNumber(player.stats?.captures)}</p>
<p className="text-right font-semibold">{formatNumber(player.stats?.score)}</p>
<p className="text-center">{formatNumber(player.stats?.air_kills)}</p>
<p className="text-center">{formatNumber(player.stats?.ground_kills)}</p>
<p className="text-center">{formatNumber(player.stats?.assists)}</p>
<p className="text-center">{formatNumber(player.stats?.deaths)}</p>
<p className="text-center">{formatNumber(player.stats?.captures)}</p>
<p className="text-center font-semibold">{formatNumber(player.stats?.score)}</p>
</button>
))}
</div>
+4
View File
@@ -80,6 +80,8 @@
--color-success: #00f2ff;
--color-warning: #f4ee3e;
--color-danger: #e82517;
--color-win: #1a9e4b;
--color-loss: #e82517;
}
:root[data-theme="dark"] {
@@ -102,6 +104,8 @@
--color-success: #58f0f5;
--color-warning: #f4ee3e;
--color-danger: #ff6a5f;
--color-win: #46d17e;
--color-loss: #ff6a5f;
color-scheme: dark;
}
+4
View File
@@ -193,6 +193,10 @@ assert_eq "logs chat/battle counts" "$LOG_COUNTS" "1 1"
MISS="$(curl -s "localhost:$BE_PORT/api/tss/games/deadbeef/logs" | "$PYTHON" -c "import sys,json; d=json.load(sys.stdin); print(len(d['chat_log'])+len(d['battle_log']))")"
assert_eq "missing-session logs empty" "$MISS" "0"
# recent games must list each session ONCE (not once per team)
REC="$(curl -s "localhost:$BE_PORT/api/tss/games/recent?limit=50" | "$PYTHON" -c "import sys,json; d=json.load(sys.stdin); ids=[m['session_id'] for m in d['matches']]; print(ids.count('abc'), ([m['player_count'] for m in d['matches'] if m['session_id']=='abc'] or [0])[0])")"
assert_eq "recent lists session once + per-side count" "$REC" "1 1"
# ---------------------------------------------------------------------------
echo "== 7. Node prod server: icons + proxy allowlist =="
H=(-H "Origin: http://localhost:$WEB_PORT" -H "Referer: http://localhost:$WEB_PORT/games/abc" -H "Sec-Fetch-Site: same-origin")