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:
+47
-60
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user