event log

This commit is contained in:
FURRO404
2026-06-18 02:38:44 -07:00
parent 92eb461e2d
commit d5fc2b5831
4 changed files with 78 additions and 31 deletions
+29 -9
View File
@@ -7,7 +7,7 @@ use axum::{
}; };
use rusqlite::{params, Connection, OptionalExtension}; use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::{json, Value};
use std::{ use std::{
collections::{BTreeMap, HashMap}, collections::{BTreeMap, HashMap},
env, fs, env, fs,
@@ -218,6 +218,7 @@ struct GameResponse {
struct GameLogsResponse { struct GameLogsResponse {
chat_log: Vec<String>, chat_log: Vec<String>,
battle_log: Vec<String>, battle_log: Vec<String>,
event_log: Value,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -581,21 +582,37 @@ async fn game_logs(
let session_id = validate_session_id(&session_id)?; let session_id = validate_session_id(&session_id)?;
let conn = open_db(&state.battles_db)?; let conn = open_db(&state.battles_db)?;
// Logs are non-critical: a missing match_logs table or row yields empty arrays. // Logs are non-critical: a missing match_logs table or row yields empty arrays.
let row: Option<(Option<String>, Option<String>)> = conn let row: Option<(Option<String>, Option<String>, Option<String>)> = match conn
.query_row( .query_row(
"SELECT chat_log_json, battle_log_json FROM match_logs WHERE session_id = ?1", "SELECT chat_log_json, battle_log_json, event_log_json FROM match_logs WHERE session_id = ?1",
params![session_id], params![session_id],
|r| Ok((r.get(0)?, r.get(1)?)), |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
) )
.optional() .optional()
.unwrap_or(None); {
let parse = |s: Option<String>| -> Vec<String> { Ok(row) => row,
s.and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default() Err(_) => conn
.query_row(
"SELECT chat_log_json, battle_log_json FROM match_logs WHERE session_id = ?1",
params![session_id],
|r| Ok((r.get(0)?, r.get(1)?, None)),
)
.optional()
.unwrap_or(None),
}; };
let (chat, battle) = row.unwrap_or((None, None)); let parse = |s: Option<String>| -> Vec<String> {
s.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or_default()
};
let parse_event_log = |s: Option<String>| -> Value {
s.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or_else(|| json!({ "kills": [], "damage": [] }))
};
let (chat, battle, event_log) = row.unwrap_or((None, None, None));
Ok(Json(GameLogsResponse { Ok(Json(GameLogsResponse {
chat_log: parse(chat), chat_log: parse(chat),
battle_log: parse(battle), battle_log: parse(battle),
event_log: parse_event_log(event_log),
})) }))
} }
@@ -1929,7 +1946,10 @@ mod tests {
names.insert("ussr_t_34".to_string(), t); names.insert("ussr_t_34".to_string(), t);
assert_eq!(lookup_vehicle_name(&names, "ussr_t_34", "ru"), "Т-34"); assert_eq!(lookup_vehicle_name(&names, "ussr_t_34", "ru"), "Т-34");
assert_eq!(lookup_vehicle_name(&names, "ussr_t_34", "de"), "T-34"); assert_eq!(lookup_vehicle_name(&names, "ussr_t_34", "de"), "T-34");
assert_eq!(lookup_vehicle_name(&names, "unknown_cdk", "en"), "unknown_cdk"); assert_eq!(
lookup_vehicle_name(&names, "unknown_cdk", "en"),
"unknown_cdk"
);
} }
#[test] #[test]
+39 -17
View File
@@ -2789,16 +2789,33 @@ function battleLineColor(line) {
return 'text-text-soft' return 'text-text-soft'
} }
function deadVehicleKey(uid, cdk) {
return `${String(uid || '').trim()}:${String(cdk || '').trim()}`
}
function deadVehicleKeysFromEventLog(eventLog) {
const keys = new Set()
const kills = Array.isArray(eventLog?.kills) ? eventLog.kills : []
kills.forEach((kill) => {
const uid = kill?.offended_uid
const cdk = kill?.offended_unit
if (uid !== undefined && uid !== null && cdk) {
keys.add(deadVehicleKey(uid, cdk))
}
})
return keys
}
function GamePage({ gameId, navigate }) { function GamePage({ gameId, navigate }) {
const [gameState, setGameState] = useState({ status: 'loading', data: null, error: null }) const [gameState, setGameState] = useState({ status: 'loading', data: null, error: null })
const [logs, setLogs] = useState({ chat_log: [], battle_log: [] }) const [logs, setLogs] = useState({ chat_log: [], battle_log: [], event_log: { kills: [], damage: [] } })
useEffect(() => { useEffect(() => {
if (!gameId) return if (!gameId) return
const controller = new AbortController() const controller = new AbortController()
setGameState({ status: 'loading', data: null, error: null }) setGameState({ status: 'loading', data: null, error: null })
setLogs({ chat_log: [], battle_log: [] }) setLogs({ chat_log: [], battle_log: [], event_log: { kills: [], damage: [] } })
fetchJson(apiEndpoints.game(gameId), controller.signal) fetchJson(apiEndpoints.game(gameId), controller.signal)
.then((data) => { .then((data) => {
@@ -2818,6 +2835,7 @@ function GamePage({ gameId, navigate }) {
setLogs({ setLogs({
chat_log: Array.isArray(data?.chat_log) ? data.chat_log : [], chat_log: Array.isArray(data?.chat_log) ? data.chat_log : [],
battle_log: Array.isArray(data?.battle_log) ? data.battle_log : [], battle_log: Array.isArray(data?.battle_log) ? data.battle_log : [],
event_log: data?.event_log || { kills: [], damage: [] },
}) })
} }
}) })
@@ -2830,6 +2848,7 @@ function GamePage({ gameId, navigate }) {
const game = gameState.data?.game const game = gameState.data?.game
const participants = gameState.data?.participants || [] const participants = gameState.data?.participants || []
const deadVehicleKeys = useMemo(() => deadVehicleKeysFromEventLog(logs.event_log), [logs.event_log])
const participantNames = participants.length const participantNames = participants.length
? participants.map((participant) => ({ ? participants.map((participant) => ({
name: participant.team_name, name: participant.team_name,
@@ -2931,21 +2950,24 @@ function GamePage({ gameId, navigate }) {
<p className="truncate font-semibold text-text">{player.nick || player.uid}</p> <p className="truncate font-semibold text-text">{player.nick || player.uid}</p>
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5"> <div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5">
{(player.vehicles || []).length ? ( {(player.vehicles || []).length ? (
player.vehicles.map((vehicle) => ( player.vehicles.map((vehicle) => {
<span const isDead = deadVehicleKeys.has(deadVehicleKey(player.uid, vehicle.cdk))
className="vehicle-name inline-flex items-center gap-1 text-xs text-text-soft" return (
key={vehicle.cdk} <span
> className={`vehicle-name inline-flex items-center gap-1 text-xs text-text-soft transition-opacity ${isDead ? 'vehicle-dead' : ''}`}
<img key={vehicle.cdk}
alt="" >
className="h-4 w-4 object-contain" <img
loading="lazy" alt=""
onError={(event) => { event.currentTarget.style.display = 'none' }} className="h-4 w-4 object-contain"
src={`/vehicle-icons/${vehicle.icon}`} loading="lazy"
/> onError={(event) => { event.currentTarget.style.display = 'none' }}
{vehicle.name} src={`/vehicle-icons/${vehicle.icon}`}
</span> />
)) {vehicle.name}
</span>
)
})
) : ( ) : (
<span className="text-xs text-text-muted">{player.uid}</span> <span className="text-xs text-text-muted">{player.uid}</span>
)} )}
+4
View File
@@ -133,6 +133,10 @@
"Segoe UI", Inter, ui-sans-serif, system-ui, sans-serif; "Segoe UI", Inter, ui-sans-serif, system-ui, sans-serif;
} }
.vehicle-dead {
opacity: 0.45;
}
/* Chat/battle logs: monospace for column alignment, with skyquake before the /* Chat/battle logs: monospace for column alignment, with skyquake before the
generic keyword so country/event glyphs still resolve per-character. */ generic keyword so country/event glyphs still resolve per-character. */
.log-mono { .log-mono {
+6 -5
View File
@@ -122,16 +122,17 @@ CREATE TABLE player_games_hist (UID TEXT, nick TEXT, team_name TEXT, team_slot T
assists INT, captures INT, deaths INT, score INT, missile_evades INT, shell_interceptions INT, assists INT, captures INT, deaths INT, score INT, missile_evades INT, shell_interceptions INT,
team_kills_stat INT, country_id INT, victor_bool TEXT, endtime_unix INT, team_id INT, team_kills_stat INT, country_id INT, victor_bool TEXT, endtime_unix INT, team_id INT,
tss_role TEXT, pvp_ratio REAL); tss_role TEXT, pvp_ratio REAL);
CREATE TABLE match_logs (session_id TEXT PRIMARY KEY, chat_log_json TEXT, battle_log_json TEXT, built_unix INT); CREATE TABLE match_logs (session_id TEXT PRIMARY KEY, chat_log_json TEXT, battle_log_json TEXT, event_log_json TEXT, built_unix INT);
""") """)
b.execute("INSERT INTO match_summary VALUES ('abc','Dom','Test Map','','','',0,1000,420.0,0,'1','2',0,0,'Cup Finals','','')") b.execute("INSERT INTO match_summary VALUES ('abc','Dom','Test Map','','','',0,1000,420.0,0,'1','2',0,0,'Cup Finals','','')")
# alice used TWO vehicles -> two rows, identical per-player stats (score 100). # alice used TWO vehicles -> two rows, identical per-player stats (score 100).
b.execute("INSERT INTO player_games_hist VALUES ('1','alice','TeamWin','1','abc','t34','ussr_t_34',2,0,1,0,0,100,0,0,0,0,'Win',1000,10,'',1.5)") b.execute("INSERT INTO player_games_hist VALUES ('1','alice','TeamWin','1','abc','t34','ussr_t_34',2,0,1,0,0,100,0,0,0,0,'Win',1000,10,'',1.5)")
b.execute("INSERT INTO player_games_hist VALUES ('1','alice','TeamWin','1','abc','is2','ussr_is_2',2,0,1,0,0,100,0,0,0,0,'Win',1000,10,'',1.5)") b.execute("INSERT INTO player_games_hist VALUES ('1','alice','TeamWin','1','abc','is2','ussr_is_2',2,0,1,0,0,100,0,0,0,0,'Win',1000,10,'',1.5)")
b.execute("INSERT INTO player_games_hist VALUES ('2','bob','TeamLose','2','abc','pz','germ_pz_iv',0,0,0,0,1,10,0,0,0,0,'Loss',1000,11,'',0.5)") b.execute("INSERT INTO player_games_hist VALUES ('2','bob','TeamLose','2','abc','pz','germ_pz_iv',0,0,0,0,1,10,0,0,0,0,'Loss',1000,11,'',0.5)")
b.execute("INSERT INTO match_logs VALUES ('abc', ?, ?, 1000)", b.execute("INSERT INTO match_logs VALUES ('abc', ?, ?, ?, 1000)",
(json.dumps(["[00:01] [ALL] [WIN] `alice`: gg"]), (json.dumps(["[00:01] [ALL] [WIN] `alice`: gg"]),
json.dumps(["+[00:30] [WIN] alice (T-34) destroyed bob (Pz.IV)"]))) json.dumps(["+[00:30] [WIN] alice (T-34) destroyed bob (Pz.IV)"]),
json.dumps({"kills": [{"offender_uid": "1", "offender_unit": "ussr_t_34", "offended_uid": "2", "offended_unit": "germ_pz_iv", "crashed": False, "time": 30000}], "damage": []})))
b.commit() b.commit()
t = sqlite3.connect(f"{wd}/tss_teams.db") t = sqlite3.connect(f"{wd}/tss_teams.db")
t.executescript("CREATE TABLE teams_data (team_id INT PRIMARY KEY, name TEXT, members INT DEFAULT 0, captain_uid TEXT);") t.executescript("CREATE TABLE teams_data (team_id INT PRIMARY KEY, name TEXT, members INT DEFAULT 0, captain_uid TEXT);")
@@ -187,8 +188,8 @@ if [[ $? -eq 0 ]]; then ok "backend game detail (en)"; else bad "backend game de
RU_NAME="$(curl -s "localhost:$BE_PORT/api/tss/games/abc?lang=ru" | "$PYTHON" -c "import sys,json; d=json.load(sys.stdin); print([v['name'] for p in d['participants'] for pl in p['players'] for v in pl['vehicles'] if v['cdk']=='ussr_t_34'][0])")" RU_NAME="$(curl -s "localhost:$BE_PORT/api/tss/games/abc?lang=ru" | "$PYTHON" -c "import sys,json; d=json.load(sys.stdin); print([v['name'] for p in d['participants'] for pl in p['players'] for v in pl['vehicles'] if v['cdk']=='ussr_t_34'][0])")"
assert_eq "ru translation of ussr_t_34" "$RU_NAME" "Т-34" assert_eq "ru translation of ussr_t_34" "$RU_NAME" "Т-34"
LOG_COUNTS="$(curl -s "localhost:$BE_PORT/api/tss/games/abc/logs" | "$PYTHON" -c "import sys,json; d=json.load(sys.stdin); print(len(d['chat_log']), len(d['battle_log']))")" LOG_COUNTS="$(curl -s "localhost:$BE_PORT/api/tss/games/abc/logs" | "$PYTHON" -c "import sys,json; d=json.load(sys.stdin); print(len(d['chat_log']), len(d['battle_log']), len(d.get('event_log', {}).get('kills', [])))")"
assert_eq "logs chat/battle counts" "$LOG_COUNTS" "1 1" assert_eq "logs chat/battle/kill counts" "$LOG_COUNTS" "1 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"