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> {
|
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
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare(
|
||||||
"WITH recent AS (
|
"WITH recent AS (
|
||||||
@@ -1332,47 +1335,25 @@ fn recent_games_for(conn: &Connection, limit: i64) -> Result<Vec<GameRow>, ApiEr
|
|||||||
ORDER BY timestamp DESC
|
ORDER BY timestamp DESC
|
||||||
LIMIT ?1
|
LIMIT ?1
|
||||||
),
|
),
|
||||||
per_player AS (
|
team_size AS (
|
||||||
SELECT session_id, team_name, UID,
|
SELECT session_id, team_name, COUNT(DISTINCT UID) AS players
|
||||||
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
|
|
||||||
FROM player_games_hist
|
FROM player_games_hist
|
||||||
WHERE session_id IN (SELECT session_id FROM recent)
|
WHERE session_id IN (SELECT session_id FROM recent)
|
||||||
AND team_name IS NOT NULL AND team_name != ''
|
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
|
SELECT
|
||||||
pp.team_name,
|
r.session_id,
|
||||||
pp.session_id,
|
COALESCE(m.endtime_unix, r.timestamp, 0) AS timestamp,
|
||||||
COALESCE(m.endtime_unix, MAX(pp.endtime_unix), 0) AS timestamp,
|
|
||||||
m.mission_name,
|
m.mission_name,
|
||||||
m.mission_mode,
|
m.mission_mode,
|
||||||
m.tournament_name,
|
m.tournament_name,
|
||||||
m.duration,
|
m.duration,
|
||||||
COALESCE(m.draw, 0),
|
COALESCE(m.draw, 0),
|
||||||
CASE WHEN MAX(pp.won) = 1 THEN 'Win' ELSE 'Loss' END AS result,
|
(SELECT MAX(players) FROM team_size ts WHERE ts.session_id = r.session_id) AS player_count,
|
||||||
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 pg.team_name
|
(SELECT pg.team_name
|
||||||
FROM player_games_hist pg
|
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 IS NOT NULL
|
||||||
AND pg.team_name != ''
|
AND pg.team_name != ''
|
||||||
AND pg.victor_bool = 'Win'
|
AND pg.victor_bool = 'Win'
|
||||||
@@ -1381,16 +1362,15 @@ fn recent_games_for(conn: &Connection, limit: i64) -> Result<Vec<GameRow>, ApiEr
|
|||||||
LIMIT 1),
|
LIMIT 1),
|
||||||
(SELECT pg.team_name
|
(SELECT pg.team_name
|
||||||
FROM player_games_hist pg
|
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 IS NOT NULL
|
||||||
AND pg.team_name != ''
|
AND pg.team_name != ''
|
||||||
AND pg.victor_bool = 'Loss'
|
AND pg.victor_bool = 'Loss'
|
||||||
GROUP BY pg.team_name COLLATE NOCASE
|
GROUP BY pg.team_name COLLATE NOCASE
|
||||||
ORDER BY COUNT(DISTINCT pg.UID) DESC, pg.team_name COLLATE NOCASE
|
ORDER BY COUNT(DISTINCT pg.UID) DESC, pg.team_name COLLATE NOCASE
|
||||||
LIMIT 1)
|
LIMIT 1)
|
||||||
FROM per_player pp
|
FROM recent r
|
||||||
LEFT JOIN match_summary m ON m.session_id = pp.session_id
|
LEFT JOIN match_summary m ON m.session_id = r.session_id
|
||||||
GROUP BY pp.team_name COLLATE NOCASE, pp.session_id
|
|
||||||
ORDER BY timestamp DESC
|
ORDER BY timestamp DESC
|
||||||
LIMIT ?1",
|
LIMIT ?1",
|
||||||
)
|
)
|
||||||
@@ -1398,32 +1378,32 @@ fn recent_games_for(conn: &Connection, limit: i64) -> Result<Vec<GameRow>, ApiEr
|
|||||||
|
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map(params![limit], |row| {
|
.query_map(params![limit], |row| {
|
||||||
let timestamp: i64 = row.get(2)?;
|
let timestamp: i64 = row.get(1)?;
|
||||||
let draw_int: i64 = row.get(7)?;
|
let draw_int: i64 = row.get(6)?;
|
||||||
Ok(GameRow {
|
Ok(GameRow {
|
||||||
team_name: row.get(0)?,
|
team_name: None,
|
||||||
session_id: row.get(1)?,
|
session_id: row.get(0)?,
|
||||||
timestamp,
|
timestamp,
|
||||||
endtime_unix: timestamp,
|
endtime_unix: timestamp,
|
||||||
map_name: row.get(3)?,
|
map_name: row.get(2)?,
|
||||||
mission_mode: row.get(4)?,
|
mission_mode: row.get(3)?,
|
||||||
result: row.get(8)?,
|
result: String::new(),
|
||||||
player_count: row.get(9)?,
|
player_count: row.get(7)?,
|
||||||
winning_team: row.get(19)?,
|
winning_team: row.get(8)?,
|
||||||
losing_team: row.get(20)?,
|
losing_team: row.get(9)?,
|
||||||
tournament_name: row.get(5)?,
|
tournament_name: row.get(4)?,
|
||||||
duration: row.get(6)?,
|
duration: row.get(5)?,
|
||||||
draw: draw_int != 0,
|
draw: draw_int != 0,
|
||||||
stats: GameStats {
|
stats: GameStats {
|
||||||
ground_kills: row.get(10)?,
|
ground_kills: 0,
|
||||||
air_kills: row.get(11)?,
|
air_kills: 0,
|
||||||
assists: row.get(12)?,
|
assists: 0,
|
||||||
captures: row.get(13)?,
|
captures: 0,
|
||||||
deaths: row.get(14)?,
|
deaths: 0,
|
||||||
score: row.get(15)?,
|
score: 0,
|
||||||
missile_evades: row.get(16)?,
|
missile_evades: 0,
|
||||||
shell_interceptions: row.get(17)?,
|
shell_interceptions: 0,
|
||||||
team_kills_stat: row.get(18)?,
|
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>> {
|
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(),
|
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
|
||||||
Err(_) => HashMap::new(),
|
Err(_) => HashMap::new(),
|
||||||
}
|
};
|
||||||
|
parsed
|
||||||
|
.into_iter()
|
||||||
|
.map(|(k, v)| (k.to_lowercase(), v))
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_vehicle_icons(path: &FsPath) -> HashMap<String, String> {
|
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(0).and_then(|v| v.as_str()),
|
||||||
entry.get(2).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
|
out
|
||||||
@@ -1826,7 +1813,7 @@ fn lookup_vehicle_name(
|
|||||||
cdk: &str,
|
cdk: &str,
|
||||||
lang: &str,
|
lang: &str,
|
||||||
) -> String {
|
) -> 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) {
|
if let Some(n) = by_lang.get(lang) {
|
||||||
return n.clone();
|
return n.clone();
|
||||||
}
|
}
|
||||||
@@ -1839,9 +1826,9 @@ fn lookup_vehicle_name(
|
|||||||
|
|
||||||
fn lookup_vehicle_icon(icons: &HashMap<String, String>, cdk: &str) -> String {
|
fn lookup_vehicle_icon(icons: &HashMap<String, String>, cdk: &str) -> String {
|
||||||
icons
|
icons
|
||||||
.get(cdk)
|
.get(&cdk.to_lowercase())
|
||||||
.cloned()
|
.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 {
|
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="overflow-x-auto">
|
||||||
<div className="min-w-[720px]">
|
<div className="min-w-[720px]">
|
||||||
{participants.length ? (
|
{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>Team / player</p>
|
||||||
<p className="text-right">Air</p>
|
<p className="text-center">Air</p>
|
||||||
<p className="text-right">Ground</p>
|
<p className="text-center">Ground</p>
|
||||||
<p className="text-right">Assists</p>
|
<p className="text-center">Assists</p>
|
||||||
<p className="text-right">Deaths</p>
|
<p className="text-center">Deaths</p>
|
||||||
<p className="text-right">Caps</p>
|
<p className="text-center">Caps</p>
|
||||||
<p className="text-right">Score</p>
|
<p className="text-center">Score</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{participants.map((participant) => {
|
{participants.map((participant) => {
|
||||||
const won = String(participant.result || '').toLowerCase() === 'win'
|
const won = String(participant.result || '').toLowerCase() === 'win'
|
||||||
|
const accent = won ? 'border-l-win' : 'border-l-loss'
|
||||||
|
const nameColor = won ? 'text-win' : 'text-loss'
|
||||||
return (
|
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
|
<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))}
|
onClick={() => navigate(teamPath(participant.team_name))}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<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}
|
{participant.team_name}
|
||||||
</p>
|
</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
|
{won ? 'Win' : 'Loss'} · {formatNumber(participant.player_count)} players
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-right text-sm">{formatNumber(participant.stats?.air_kills)}</p>
|
<p className="text-center text-sm">{formatNumber(participant.stats?.air_kills)}</p>
|
||||||
<p className="text-right text-sm">{formatNumber(participant.stats?.ground_kills)}</p>
|
<p className="text-center text-sm">{formatNumber(participant.stats?.ground_kills)}</p>
|
||||||
<p className="text-right text-sm">{formatNumber(participant.stats?.assists)}</p>
|
<p className="text-center text-sm">{formatNumber(participant.stats?.assists)}</p>
|
||||||
<p className="text-right text-sm">{formatNumber(participant.stats?.deaths)}</p>
|
<p className="text-center text-sm">{formatNumber(participant.stats?.deaths)}</p>
|
||||||
<p className="text-right text-sm">{formatNumber(participant.stats?.captures)}</p>
|
<p className="text-center 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 font-semibold">{formatNumber(participant.stats?.score)}</p>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="pb-3">
|
<div className="divide-y divide-surface py-1">
|
||||||
{(participant.players || []).map((player) => (
|
{(participant.players || []).map((player) => (
|
||||||
<button
|
<button
|
||||||
className={`grid w-full ${SCOREBOARD_GRID} items-center gap-3 px-5 py-2 text-left text-sm transition hover:bg-surface`}
|
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>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-right">{formatNumber(player.stats?.air_kills)}</p>
|
<p className="text-center">{formatNumber(player.stats?.air_kills)}</p>
|
||||||
<p className="text-right">{formatNumber(player.stats?.ground_kills)}</p>
|
<p className="text-center">{formatNumber(player.stats?.ground_kills)}</p>
|
||||||
<p className="text-right">{formatNumber(player.stats?.assists)}</p>
|
<p className="text-center">{formatNumber(player.stats?.assists)}</p>
|
||||||
<p className="text-right">{formatNumber(player.stats?.deaths)}</p>
|
<p className="text-center">{formatNumber(player.stats?.deaths)}</p>
|
||||||
<p className="text-right">{formatNumber(player.stats?.captures)}</p>
|
<p className="text-center">{formatNumber(player.stats?.captures)}</p>
|
||||||
<p className="text-right font-semibold">{formatNumber(player.stats?.score)}</p>
|
<p className="text-center font-semibold">{formatNumber(player.stats?.score)}</p>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -80,6 +80,8 @@
|
|||||||
--color-success: #00f2ff;
|
--color-success: #00f2ff;
|
||||||
--color-warning: #f4ee3e;
|
--color-warning: #f4ee3e;
|
||||||
--color-danger: #e82517;
|
--color-danger: #e82517;
|
||||||
|
--color-win: #1a9e4b;
|
||||||
|
--color-loss: #e82517;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="dark"] {
|
:root[data-theme="dark"] {
|
||||||
@@ -102,6 +104,8 @@
|
|||||||
--color-success: #58f0f5;
|
--color-success: #58f0f5;
|
||||||
--color-warning: #f4ee3e;
|
--color-warning: #f4ee3e;
|
||||||
--color-danger: #ff6a5f;
|
--color-danger: #ff6a5f;
|
||||||
|
--color-win: #46d17e;
|
||||||
|
--color-loss: #ff6a5f;
|
||||||
color-scheme: dark;
|
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']))")"
|
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"
|
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 =="
|
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")
|
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