diff --git a/record-daemon/src/ipc/protocol.rs b/record-daemon/src/ipc/protocol.rs index dd751f0..4d93575 100644 --- a/record-daemon/src/ipc/protocol.rs +++ b/record-daemon/src/ipc/protocol.rs @@ -153,7 +153,7 @@ pub enum IpcNotification { }, /// Game event received. - GameEvent { event: GameEvent }, + GameEvent { event: Box }, /// Daemon status changed. StatusChanged { status: DaemonStatus }, diff --git a/record-daemon/src/lqp/client.rs b/record-daemon/src/lqp/client.rs index ab49785..48bc86d 100644 --- a/record-daemon/src/lqp/client.rs +++ b/record-daemon/src/lqp/client.rs @@ -10,7 +10,7 @@ use tokio_tungstenite::{connect_async_tls_with_config, tungstenite::protocol::Me use tracing::{debug, error, info, trace, warn}; use super::auth::LockfileCredentials; -use super::events::{GameEvent, RawEvent}; +use super::events::{GameEvent, GameflowSession, RawEvent}; use crate::error::{LqpError, Result}; /// Custom certificate verifier that accepts any certificate. @@ -73,6 +73,7 @@ const SUBSCRIBE_ENDPOINTS: &[&str] = &[ "/lol-game-events/v1/game-events", "/lol-champ-select/v1/session", "/lol-lobby/v2/lobby", + "/lol-end-of-game/v1/eog-stats-block", ]; /// LQP REST API endpoints. @@ -82,6 +83,10 @@ pub mod endpoints { pub const CHAMPION_SELECT: &str = "/lol-champ-select/v1/session"; pub const SUMMONER: &str = "/lol-summoner/v1/current-summoner"; pub const GAME_STATS: &str = "/lol-end-of-game/v1/eog-stats-block"; + pub const CHAMPION_SUMMARY: &str = "/lol-champ-select/v1/current-champion"; + pub const RUNE_PAGES: &str = "/lol-perks/v1/currentpage"; + pub const MATCH_HISTORY: &str = "/lol-match-history/v1/products/lol/current-summoner/matches"; + pub const LIVE_CLIENT_DATA: &str = "/liveclientdata/allgamedata"; } /// Game flow phase states. @@ -135,6 +140,8 @@ pub struct ClientState { pub game_id: Option, /// Current champion name. pub champion: Option, + /// Current player's puuid. + pub local_puuid: Option, } impl Default for ClientState { @@ -143,6 +150,7 @@ impl Default for ClientState { phase: GameflowPhase::None, game_id: None, champion: None, + local_puuid: None, } } } @@ -159,6 +167,8 @@ pub struct LqpClient { http_client: reqwest::Client, /// Shutdown signal. shutdown: Arc>, + /// Last emitted game ID for deduplication of GameStart events. + last_emitted_game_id: Arc>>, } impl LqpClient { @@ -177,6 +187,7 @@ impl LqpClient { event_sender, http_client, shutdown: Arc::new(RwLock::new(false)), + last_emitted_game_id: Arc::new(RwLock::new(None)), } } @@ -214,6 +225,14 @@ impl LqpClient { } } + // Fetch local player's puuid for champion extraction + if let Ok(summoner) = self.get_summoner().await { + if let Some(puuid) = summoner.get("puuid").and_then(|p| p.as_str()) { + self.state.write().await.local_puuid = Some(puuid.to_string()); + info!("Fetched local player puuid: {}", puuid); + } + } + Ok(()) } @@ -222,6 +241,7 @@ impl LqpClient { *self.shutdown.write().await = true; *self.credentials.write().await = None; *self.state.write().await = ClientState::default(); + *self.last_emitted_game_id.write().await = None; info!("Disconnected from League Client"); } @@ -292,6 +312,7 @@ impl LqpClient { let state = self.state.clone(); let shutdown = self.shutdown.clone(); let credentials = self.credentials.clone(); + let last_emitted_game_id = self.last_emitted_game_id.clone(); // Spawn the message handler tokio::spawn(async move { @@ -306,10 +327,32 @@ impl LqpClient { if text.is_empty() { continue; } - if let Some(event) = Self::parse_websocket_message(&text) { + // Get local_puuid from state for champion extraction + let local_puuid = state.read().await.local_puuid.clone(); + if let Some(event) = + Self::parse_websocket_message(&text, local_puuid.as_deref()) + { // Update state based on event Self::update_state_from_event(&state, &event).await; + // Check for duplicate GameStart events + if let GameEvent::GameStart(ref info) = event { + let mut last_game_id = last_emitted_game_id.write().await; + if *last_game_id == Some(info.game_id) { + info!( + "Skipping duplicate GameStart event for game_id={}", + info.game_id + ); + continue; + } + *last_game_id = Some(info.game_id); + } + + // Reset last_emitted_game_id on GameEnd to allow new game starts + if let GameEvent::GameEnd(_) = &event { + *last_emitted_game_id.write().await = None; + } + // Broadcast event if event_sender.send(event.clone()).is_err() { trace!("No event subscribers"); @@ -321,10 +364,32 @@ impl LqpClient { // Try to parse as UTF-8 if let Ok(text) = String::from_utf8(data) { if !text.is_empty() { - if let Some(event) = Self::parse_websocket_message(&text) { + // Get local_puuid from state for champion extraction + let local_puuid = state.read().await.local_puuid.clone(); + if let Some(event) = + Self::parse_websocket_message(&text, local_puuid.as_deref()) + { // Update state based on event Self::update_state_from_event(&state, &event).await; + // Check for duplicate GameStart events + if let GameEvent::GameStart(ref info) = event { + let mut last_game_id = last_emitted_game_id.write().await; + if *last_game_id == Some(info.game_id) { + info!( + "Skipping duplicate GameStart event for game_id={}", + info.game_id + ); + continue; + } + *last_game_id = Some(info.game_id); + } + + // Reset last_emitted_game_id on GameEnd to allow new game starts + if let GameEvent::GameEnd(_) = &event { + *last_emitted_game_id.write().await = None; + } + // Broadcast event if event_sender.send(event.clone()).is_err() { trace!("No event subscribers"); @@ -364,7 +429,7 @@ impl LqpClient { } /// Parse a WebSocket message into a game event. - fn parse_websocket_message(text: &str) -> Option { + fn parse_websocket_message(text: &str, local_puuid: Option<&str>) -> Option { // Parse the message array format: [type, callback, data] let value: serde_json::Value = match serde_json::from_str(text) { Ok(v) => v, @@ -397,6 +462,7 @@ impl LqpClient { &raw_event.uri, event_type, &serde_json::to_value(raw_event.data).unwrap_or_default(), + local_puuid, ); } @@ -408,7 +474,7 @@ impl LqpClient { .and_then(|t| t.as_str()) .unwrap_or("Update"); - return Self::parse_event_from_uri(uri, event_type, data); + return Self::parse_event_from_uri(uri, event_type, data, local_puuid); } else { debug!("Unknown callback: {}", callback); } @@ -434,6 +500,7 @@ impl LqpClient { uri: &str, event_type: &str, data: &serde_json::Value, + local_puuid: Option<&str>, ) -> Option { info!("Parsing event from URI: {} (type: {})", uri, event_type); @@ -442,6 +509,22 @@ impl LqpClient { let phase = data.as_str()?; info!("Gameflow phase changed to: {}", phase); + // Only trigger GameEnd on EndOfGame phase (not WaitingForStats or PreEndOfGame) + // This ensures we wait for the stats to be available + if phase == "EndOfGame" { + info!("Game end phase detected: {}", phase); + // Generate a GameEnd event for timeline recording + return Some( + GameEvent::from_json(&serde_json::json!({ + "eventType": "lcu-game-end", + "gameId": 0, // Will be filled from state if available + "victory": false, // Will be updated from end-of-game stats + "duration": 0.0 + })) + .unwrap_or(GameEvent::Unknown), + ); + } + // Update internal state based on phase return Some( GameEvent::from_json(&serde_json::json!({ @@ -461,17 +544,108 @@ impl LqpClient { if phase == "InProgress" { info!("Game is now in progress!"); - // Extract game info - let game_id = data + // Try to parse the gameData into a GameflowSession struct + let session: Option = data .get("gameData") - .and_then(|gd| gd.get("gameId")) - .and_then(|id| id.as_u64()) - .unwrap_or(0); + .and_then(|gd| serde_json::from_value(gd.clone()).ok()); + + if let Some(ref session) = session { + debug!( + "Parsed GameflowSession: game_id={}, queue={:?}", + session.game_id, + session.queue_name() + ); + } else { + debug!("Failed to parse gameData as GameflowSession, falling back to manual extraction"); + } + + // Extract game_id - prefer from parsed session, fallback to manual extraction + let game_id = session.as_ref().map(|s| s.game_id).unwrap_or_else(|| { + data.get("gameData") + .and_then(|gd| gd.get("gameId")) + .and_then(|id| id.as_u64()) + .unwrap_or(0) + }); + + // Note: Champion, team, summoner_name will be extracted using puuid + // in handle_game_event when we have access to pregame_metadata + + // Extract queue info (this is the same for all players) + let queue_type = session + .as_ref() + .and_then(|s| s.queue_name()) + .map(|s| s.to_string()) + .or_else(|| { + data.get("gameData") + .and_then(|gd| gd.get("queue")) + .and_then(|q| q.get("name")) + .and_then(|n| n.as_str()) + .map(|s| s.to_string()) + }); + + let queue_id = session.as_ref().and_then(|s| s.queue_id()); + + let game_mode = session + .as_ref() + .and_then(|s| s.game_mode()) + .map(|s| s.to_string()) + .or_else(|| { + data.get("gameData") + .and_then(|gd| gd.get("queue")) + .and_then(|q| q.get("gameMode")) + .and_then(|m| m.as_str()) + .map(|s| s.to_string()) + }); + + // Extract map name + let map_name = session + .as_ref() + .and_then(|s| s.map_id()) + .and_then(|id| map_id_to_name(id as u64)) + .or_else(|| { + data.get("gameData") + .and_then(|gd| gd.get("queue")) + .and_then(|q| q.get("mapId")) + .and_then(|id| id.as_u64()) + .and_then(map_id_to_name) + }); + + info!("Extracted game metadata: game_id={}, queue={:?}, queue_id={:?}, mode={:?}, map={:?}", + game_id, queue_type, queue_id, game_mode, map_name); + + // Extract player-specific data using puuid + let (champion, team, summoner_name) = if let Some(puuid) = local_puuid { + let champ_id = session.as_ref().and_then(|s| s.get_champion_id(puuid)); + let team_id = session.as_ref().and_then(|s| s.get_team(puuid)); + let summoner = session + .as_ref() + .and_then(|s| s.get_summoner_name(puuid)) + .map(|s| s.to_string()); + + // Convert champion_id to champion name + let champ_name = champ_id.and_then(champion_id_to_name); + + info!("Extracted player data via puuid: champion={:?}, team={:?}, summoner={:?}", + champ_name, team_id, summoner); + + (champ_name, team_id, summoner) + } else { + info!("No local_puuid available, cannot extract player-specific data"); + (None, None, None) + }; return Some( GameEvent::from_json(&serde_json::json!({ "eventType": "lcu-game-start", - "gameId": game_id + "gameId": game_id, + "queueType": queue_type, + "queueId": queue_id, + "gameMode": game_mode, + "map": map_name, + "champion": champion, + "team": team, + "summonerName": summoner_name, + "session": session })) .unwrap_or(GameEvent::Unknown), ); @@ -502,9 +676,172 @@ impl LqpClient { // Handle champion select if uri == "/lol-champ-select/v1/session" { info!("Champion select event: {:?}", data); + + // Check if we're in champion select phase + if let Some(timers) = data.get("timers") { + if let Some(phase) = timers.get("phase").and_then(|p| p.as_str()) { + if phase == "BAN_PICK" || phase == "FINALIZATION" { + // Extract local player's champion + if let Some(local_player_cell_id) = + data.get("localPlayerCellId").and_then(|id| id.as_i64()) + { + // Check both teams for the local player + for team_key in &["myTeam", "theirTeam"] { + if let Some(team) = data.get(team_key).and_then(|t| t.as_array()) { + for member in team { + if member.get("cellId").and_then(|id| id.as_i64()) + == Some(local_player_cell_id) + { + if let Some(champion_id) = + member.get("championId").and_then(|id| id.as_u64()) + { + if champion_id > 0 { + let champion_name = member + .get("championName") + .and_then(|n| n.as_str()) + .unwrap_or("Unknown") + .to_string(); + + return Some( + GameEvent::from_json(&serde_json::json!({ + "eventType": "lcu-champion-pick", + "summonerName": "LocalPlayer", + "championId": champion_id, + "championName": champion_name, + "isLocalPlayer": true + })) + .unwrap_or(GameEvent::Unknown), + ); + } + } + } + } + } + } + } + } + } + } return None; } + // Handle end-of-game stats block (contains actual game results) + if uri == "/lol-end-of-game/v1/eog-stats-block" { + info!("End-of-game stats received: {:?}", data); + + // Extract game ID and duration + let game_id = data.get("gameId").and_then(|id| id.as_u64()).unwrap_or(0); + let game_duration = data + .get("gameLength") + .and_then(|d| d.as_f64()) + .unwrap_or(0.0); + + // Get local player data - prefer localPlayer field, fallback to teams[0].players[0] + let local_player = data.get("localPlayer"); + + // Extract victory status from local player's stats (WIN: 1 means victory) + let victory = local_player + .and_then(|p| p.get("stats")) + .and_then(|s| s.get("WIN")) + .and_then(|w| w.as_u64()) + .map(|w| w == 1) + .or_else(|| { + // Fallback: check if player's team is winning team + data.get("teams") + .and_then(|teams| teams.as_array()) + .and_then(|t| { + t.iter().find_map(|team| { + if team.get("isPlayerTeam").and_then(|p| p.as_bool()) == Some(true) + { + team.get("isWinningTeam").and_then(|w| w.as_bool()) + } else { + None + } + }) + }) + }) + .unwrap_or(false); + + // Extract player stats - stats use UPPERCASE keys + let mut kills = 0u32; + let mut deaths = 0u32; + let mut assists = 0u32; + let mut creep_score = 0u32; + let mut gold_earned = 0u32; + let mut damage_dealt = 0u64; + let mut damage_taken = 0u64; + let mut vision_score = 0.0; + + if let Some(stats_obj) = local_player.and_then(|p| p.get("stats")) { + kills = stats_obj + .get("CHAMPIONS_KILLED") + .and_then(|k| k.as_u64()) + .unwrap_or(0) as u32; + deaths = stats_obj + .get("NUM_DEATHS") + .and_then(|d| d.as_u64()) + .unwrap_or(0) as u32; + assists = stats_obj + .get("ASSISTS") + .and_then(|a| a.as_u64()) + .unwrap_or(0) as u32; + creep_score = stats_obj + .get("MINIONS_KILLED") + .and_then(|cs| cs.as_u64()) + .unwrap_or(0) as u32; + gold_earned = stats_obj + .get("GOLD_EARNED") + .and_then(|g| g.as_u64()) + .unwrap_or(0) as u32; + damage_dealt = stats_obj + .get("TOTAL_DAMAGE_DEALT_TO_CHAMPIONS") + .and_then(|d| d.as_u64()) + .unwrap_or(0); + damage_taken = stats_obj + .get("TOTAL_DAMAGE_TAKEN") + .and_then(|d| d.as_u64()) + .unwrap_or(0); + vision_score = stats_obj + .get("VISION_SCORE") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + } + + info!("Extracted game end stats: kills={}, deaths={}, assists={}, cs={}, gold={}, damage_dealt={}, damage_taken={}, vision={}, victory={}", + kills, deaths, assists, creep_score, gold_earned, damage_dealt, damage_taken, vision_score, victory); + + // Generate a GameEnd event with actual stats + // Note: PlayerStats uses camelCase due to serde rename_all + let event_json = serde_json::json!({ + "eventType": "lcu-game-end", + "gameId": game_id, + "victory": victory, + "duration": game_duration, + "stats": { + "kills": kills, + "deaths": deaths, + "assists": assists, + "minionsKilled": creep_score, + "goldEarned": gold_earned, + "damageDealt": damage_dealt, + "damageTaken": damage_taken, + "visionScore": vision_score + } + }); + info!("Generating GameEnd event from eog-stats: {:?}", event_json); + + match GameEvent::from_json(&event_json) { + Some(event) => { + info!("Successfully parsed GameEnd event"); + return Some(event); + } + None => { + warn!("Failed to parse GameEnd event, returning Unknown"); + return Some(GameEvent::Unknown); + } + } + } + // Handle lobby if uri.starts_with("/lol-lobby") { debug!("Lobby event: {}", uri); @@ -607,6 +944,396 @@ impl LqpClient { pub async fn get_game_stats(&self) -> Result { self.request("GET", endpoints::GAME_STATS).await } + + /// Get the currently selected champion in champ select. + pub async fn get_current_champion(&self) -> Result { + self.request("GET", endpoints::CHAMPION_SUMMARY).await + } + + /// Get current rune page. + pub async fn get_rune_page(&self) -> Result { + self.request("GET", endpoints::RUNE_PAGES).await + } + + /// Get match history. + pub async fn get_match_history(&self) -> Result { + self.request("GET", endpoints::MATCH_HISTORY).await + } + + /// Get live client data (available during game). + pub async fn get_live_client_data(&self) -> Result { + self.request("GET", endpoints::LIVE_CLIENT_DATA).await + } + + /// Fetch pre-game metadata (champion, skin, runes, queue info). + pub async fn fetch_pregame_metadata(&self) -> Result { + let mut metadata = PreGameMetadata::default(); + + // Get session info for queue type and game mode + if let Ok(session) = self.get_session().await { + if let Some(map) = session.get("map").and_then(|m| m.as_str()) { + metadata.map_name = Some(map.to_string()); + } + if let Some(game_mode) = session.get("gameMode").and_then(|m| m.as_str()) { + metadata.game_mode = Some(game_mode.to_string()); + } + if let Some(queue_id) = session.get("queueId").and_then(|q| q.as_u64()) { + metadata.queue_id = Some(queue_id as u32); + } + } + + // Get summoner info (including puuid) + if let Ok(summoner) = self.get_summoner().await { + if let Some(name) = summoner.get("displayName").and_then(|n| n.as_str()) { + metadata.summoner_name = Some(name.to_string()); + } + // Store the local player's puuid in state + if let Some(puuid) = summoner.get("puuid").and_then(|p| p.as_str()) { + metadata.local_puuid = Some(puuid.to_string()); + let mut state = self.state.write().await; + state.local_puuid = Some(puuid.to_string()); + } + } + + // Get champion select info + if let Ok(champ_select) = self.get_champion_select().await { + // Find local player's cell + if let Some(local_player_cell_id) = champ_select + .get("localPlayerCellId") + .and_then(|id| id.as_i64()) + { + if let Some(my_team) = champ_select.get("myTeam").and_then(|t| t.as_array()) { + for member in my_team { + if member.get("cellId").and_then(|id| id.as_i64()) + == Some(local_player_cell_id) + { + if let Some(champion_id) = + member.get("championId").and_then(|id| id.as_u64()) + { + metadata.champion_id = Some(champion_id as u32); + } + if let Some(team) = member.get("team").and_then(|t| t.as_i64()) { + metadata.team = Some(team as u32); + } + if let Some(skin_id) = member.get("skinId").and_then(|id| id.as_u64()) { + metadata.skin_id = Some(skin_id as u32); + } + } + } + } + } + } + + // Get rune page + if let Ok(rune_page) = self.get_rune_page().await { + if let Some(name) = rune_page.get("name").and_then(|n| n.as_str()) { + metadata.rune_page_name = Some(name.to_string()); + } + } + + Ok(metadata) + } + + /// Fetch end-of-game stats. + pub async fn fetch_game_end_stats(&self) -> Result { + let mut metadata = GameEndMetadata::default(); + + if let Ok(stats) = self.get_game_stats().await { + // Get game result + if let Some(victory) = stats.get("gameResult").and_then(|r| r.as_str()) { + metadata.victory = Some(victory == "WIN"); + } + + // Get player stats + if let Some(players) = stats.get("players").and_then(|p| p.as_array()) { + // Find the local player (first player in the array usually) + if let Some(player) = players.first() { + if let Some(stats_obj) = player.get("stats") { + metadata.kills = + stats_obj.get("kills").and_then(|k| k.as_u64()).unwrap_or(0) as u32; + metadata.deaths = stats_obj + .get("deaths") + .and_then(|d| d.as_u64()) + .unwrap_or(0) as u32; + metadata.assists = stats_obj + .get("assists") + .and_then(|a| a.as_u64()) + .unwrap_or(0) as u32; + metadata.creep_score = stats_obj + .get("minionsKilled") + .and_then(|cs| cs.as_u64()) + .unwrap_or(0) as u32; + metadata.gold_earned = stats_obj + .get("goldEarned") + .and_then(|g| g.as_u64()) + .unwrap_or(0) as u32; + metadata.damage_dealt = stats_obj + .get("totalDamageDealtToChampions") + .and_then(|d| d.as_u64()) + .unwrap_or(0); + metadata.damage_taken = stats_obj + .get("totalDamageTaken") + .and_then(|d| d.as_u64()) + .unwrap_or(0); + metadata.vision_score = stats_obj + .get("visionScore") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + } + } + } + + // Get game duration + if let Some(duration) = stats.get("gameLength").and_then(|d| d.as_f64()) { + metadata.game_duration = duration; + } + + // Get match ID + if let Some(match_id) = stats.get("matchId").and_then(|id| id.as_u64()) { + metadata.match_id = Some(match_id.to_string()); + } + } + + Ok(metadata) + } +} + +/// Convert champion ID to champion name. +/// This is a simplified mapping for common champions. +pub fn champion_id_to_name(id: u32) -> Option { + let name = match id { + 1 => "Annie", + 2 => "Olaf", + 3 => "Galio", + 4 => "TwistedFate", + 5 => "XinZhao", + 6 => "Urgot", + 7 => "LeBlanc", + 8 => "Vladimir", + 9 => "Fiddlesticks", + 10 => "Kayle", + 11 => "MasterYi", + 12 => "Alistar", + 13 => "Ryze", + 14 => "Sion", + 15 => "Sivir", + 16 => "Soraka", + 17 => "Teemo", + 18 => "Tristana", + 19 => "Warwick", + 20 => "Nunu", + 21 => "MissFortune", + 22 => "Ashe", + 23 => "Tryndamere", + 24 => "Jax", + 25 => "Morgana", + 26 => "Zilean", + 27 => "Singed", + 28 => "Evelynn", + 29 => "Twitch", + 30 => "Karthus", + 31 => "Cho'Gath", + 32 => "Amumu", + 33 => "Rammus", + 34 => "Anivia", + 35 => "Shaco", + 36 => "DrMundo", + 37 => "Sona", + 38 => "Kassadin", + 39 => "Irelia", + 40 => "Janna", + 41 => "Gangplank", + 42 => "Corki", + 43 => "Karma", + 44 => "Taric", + 45 => "Veigar", + 48 => "Trundle", + 50 => "Swain", + 51 => "Caitlyn", + 52 => "Blitzcrank", + 53 => "Malphite", + 54 => "Katarina", + 55 => "Nocturne", + 56 => "Maokai", + 57 => "Renekton", + 58 => "JarvanIV", + 59 => "Elise", + 60 => "Talon", + 61 => "Orianna", + 62 => "Wukong", + 63 => "Brand", + 64 => "LeeSin", + 67 => "Vayne", + 68 => "Rumble", + 69 => "Cassiopeia", + 72 => "Skarner", + 74 => "Heimerdinger", + 75 => "Nasus", + 76 => "Nidalee", + 77 => "Udyr", + 78 => "Poppy", + 79 => "Gragas", + 80 => "Pantheon", + 81 => "Ezreal", + 82 => "Mordekaiser", + 83 => "Yorick", + 84 => "Akali", + 85 => "Kennedy", + 86 => "Garen", + 89 => "Leona", + 90 => "Malzahar", + 91 => "Talon", + 92 => "Riven", + 96 => "Kog'Maw", + 98 => "Shen", + 99 => "Lux", + 101 => "Xerath", + 102 => "Shyvana", + 103 => "Ahri", + 104 => "Graves", + 105 => "Fizz", + 106 => "Volibear", + 107 => "Rengar", + 110 => "Varus", + 111 => "Nautilus", + 112 => "Viktor", + 113 => "Sejuani", + 114 => "Fiora", + 115 => "Ziggs", + 117 => "Lulu", + 119 => "Draven", + 120 => "Hecarim", + 121 => "Kha'Zix", + 122 => "Darius", + 126 => "Jayce", + 127 => "Lissandra", + 131 => "Diana", + 133 => "Quinn", + 134 => "Syndra", + 136 => "AurelionSol", + 141 => "Kayn", + 142 => "Zoe", + 143 => "Lillia", + 145 => "Samira", + 147 => "Seraphine", + 150 => "Gnar", + 154 => "Zac", + 157 => "Yasuo", + 161 => "Vel'Koz", + 163 => "Taliyah", + 164 => "Camille", + 166 => "Akshan", + 167 => "Nilah", + 201 => "Braum", + 202 => "Jhin", + 203 => "Kindred", + 222 => "Jinx", + 223 => "TahmKench", + 236 => "Lucian", + 238 => "Zed", + 240 => "Kled", + 245 => "Ekko", + 246 => "Qiyana", + 254 => "Vi", + 255 => "Janna", + 256 => "Pyke", + 257 => "Nami", + 266 => "Aatrox", + 267 => "Nami", + 268 => "Azir", + 350 => "Yuumi", + 360 => "Samira", + 412 => "Thresh", + 420 => "Illaoi", + 421 => "Rek'Sai", + 427 => "Ivern", + 429 => "Kalista", + 432 => "Bard", + 497 => "Rakan", + 498 => "Xayah", + 516 => "Ornn", + 517 => "Sylas", + 518 => "Neeko", + 523 => "Aphelios", + 526 => "Rell", + 555 => "Pyke", + 711 => "Vex", + 777 => "Yone", + 875 => "Sett", + 876 => "Lillia", + 887 => "Gwen", + 888 => "Viego", + 895 => "KSante", + 901 => "Smolder", + 902 => "Hwei", + 950 => "Naafiri", + 951 => "Briar", + _ => return None, + }; + Some(name.to_string()) +} + +/// Convert map ID to map name. +fn map_id_to_name(id: u64) -> Option { + let name = match id { + 1 => "Summoner's Rift", + 2 => "Summoner's Rift", + 3 => "The Proving Grounds", + 4 => "Twisted Treeline", + 8 => "The Crystal Scar", + 10 => "Twisted Treeline", + 11 => "Summoner's Rift", + 12 => "Howling Abyss", + 14 => "Butcher's Bridge", + 16 => "Cosmic Ruins", + 18 => "Valoran City Park", + 19 => "Substructure 43", + 20 => "Crash Site", + 21 => "Nexus Blitz", + 22 => "Convergence", + 23 => "Arena", + 24 => "Arena", + 25 => "Rings of Wrath", + 30 => "Swarm", + 31 => "Swarm", + 32 => "Swarm", + 33 => "Swarm", + 34 => "Swarm", + 35 => "Swarm", + _ => return None, + }; + Some(name.to_string()) +} + +/// Pre-game metadata fetched before the game starts. +#[derive(Debug, Clone, Default)] +pub struct PreGameMetadata { + pub champion_id: Option, + pub skin_id: Option, + pub rune_page_name: Option, + pub summoner_name: Option, + pub queue_type: Option, + pub queue_id: Option, + pub game_mode: Option, + pub map_name: Option, + pub team: Option, + pub local_puuid: Option, +} + +/// End-of-game metadata fetched after the game ends. +#[derive(Debug, Clone, Default)] +pub struct GameEndMetadata { + pub victory: Option, + pub match_id: Option, + pub kills: u32, + pub deaths: u32, + pub assists: u32, + pub creep_score: u32, + pub gold_earned: u32, + pub damage_dealt: u64, + pub damage_taken: u64, + pub vision_score: f64, + pub game_duration: f64, } impl Default for LqpClient { diff --git a/record-daemon/src/lqp/events.rs b/record-daemon/src/lqp/events.rs index 0bfc50d..2f819f9 100644 --- a/record-daemon/src/lqp/events.rs +++ b/record-daemon/src/lqp/events.rs @@ -13,9 +13,17 @@ pub enum GameEvent { #[serde(rename = "lcu-match-found")] MatchFound(MatchInfo), + /// Champion select phase started. + #[serde(rename = "lcu-champ-select-start")] + ChampSelectStart(ChampSelectStartInfo), + + /// Player picked a champion. + #[serde(rename = "lcu-champion-pick")] + ChampionPick(ChampionPickInfo), + /// Game has started. #[serde(rename = "lcu-game-start")] - GameStart(GameStartInfo), + GameStart(Box), /// Player killed an enemy. #[serde(rename = "lcu-kill")] @@ -29,6 +37,10 @@ pub enum GameEvent { #[serde(rename = "lcu-objective")] Objective(ObjectiveEvent), + /// In-game stats update. + #[serde(rename = "lcu-stats-update")] + StatsUpdate(InGameStats), + /// Game has ended. #[serde(rename = "lcu-game-end")] GameEnd(GameEndInfo), @@ -65,6 +77,10 @@ pub struct MatchInfo { /// Queue type (ranked, normal, aram, etc.). pub queue_type: String, + /// Queue ID (numeric identifier). + #[serde(default)] + pub queue_id: Option, + /// Map name. pub map: String, @@ -76,6 +92,87 @@ pub struct MatchInfo { pub timestamp: DateTime, } +/// Champion select start event data. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChampSelectStartInfo { + /// Session ID. + #[serde(default)] + pub session_id: Option, + + /// Team (100 = blue, 200 = red). + #[serde(default)] + pub team: Option, + + /// Timestamp when champ select started. + #[serde(default = "Utc::now")] + pub timestamp: DateTime, +} + +/// Champion pick event data. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChampionPickInfo { + /// Player's summoner name. + pub summoner_name: String, + + /// Champion ID. + pub champion_id: u32, + + /// Champion name. + pub champion_name: String, + + /// Whether this is the local player's pick. + #[serde(default)] + pub is_local_player: bool, + + /// Skin ID selected. + #[serde(default)] + pub skin_id: Option, + + /// Skin name. + #[serde(default)] + pub skin_name: Option, + + /// Timestamp when champion was picked. + #[serde(default = "Utc::now")] + pub timestamp: DateTime, +} + +/// In-game stats update. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InGameStats { + /// Current kills. + pub kills: u32, + + /// Current deaths. + pub deaths: u32, + + /// Current assists. + pub assists: u32, + + /// Current creep score. + #[serde(default)] + pub creep_score: u32, + + /// Current gold. + #[serde(default)] + pub gold: u32, + + /// Current level. + #[serde(default)] + pub level: u32, + + /// Game time in seconds. + #[serde(default)] + pub game_time: f64, + + /// Timestamp of the stats update. + #[serde(default = "Utc::now")] + pub timestamp: DateTime, +} + /// Game start event data. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -99,6 +196,26 @@ pub struct GameStartInfo { #[serde(default)] pub team: Option, + /// Queue type (ranked, normal, aram, etc.). + #[serde(default)] + pub queue_type: Option, + + /// Queue ID. + #[serde(default)] + pub queue_id: Option, + + /// Game mode. + #[serde(default)] + pub game_mode: Option, + + /// Map name. + #[serde(default, rename = "map")] + pub map_name: Option, + + /// Full gameflow session data (if available). + #[serde(default)] + pub session: Option, + /// Game start timestamp. #[serde(default = "Utc::now")] pub timestamp: DateTime, @@ -283,7 +400,7 @@ pub struct RawEvent { #[serde(untagged)] pub enum EventData { /// Game event. - GameEvent(GameEvent), + GameEvent(Box), /// Raw JSON value. Raw(serde_json::Value), @@ -313,6 +430,12 @@ impl GameEvent { GameEvent::MatchFound(info) => { format!("Match found: {} ({})", info.game_mode, info.queue_type) } + GameEvent::ChampSelectStart(info) => { + format!("Champion select started (team: {:?})", info.team) + } + GameEvent::ChampionPick(pick) => { + format!("{} picked {}", pick.summoner_name, pick.champion_name) + } GameEvent::GameStart(info) => { format!("Game started: ID {}", info.game_id) } @@ -326,6 +449,12 @@ impl GameEvent { let team = if obj.team == 100 { "Blue" } else { "Red" }; format!("{} took {:?}", team, obj.objective_type) } + GameEvent::StatsUpdate(stats) => { + format!( + "Stats: {}/{}/{} CS: {} Gold: {}", + stats.kills, stats.deaths, stats.assists, stats.creep_score, stats.gold + ) + } GameEvent::GameEnd(end) => { let result = if end.victory { "Victory" } else { "Defeat" }; format!("Game ended: {} ({:.1}s)", result, end.duration) @@ -336,6 +465,23 @@ impl GameEvent { GameEvent::Unknown => "Unknown event".to_string(), } } + + /// Get the event type name for categorization. + pub fn event_type_name(&self) -> &'static str { + match self { + GameEvent::MatchFound(_) => "match_found", + GameEvent::ChampSelectStart(_) => "champ_select_start", + GameEvent::ChampionPick(_) => "champion_pick", + GameEvent::GameStart(_) => "game_start", + GameEvent::Kill(_) => "kill", + GameEvent::Death(_) => "death", + GameEvent::Objective(_) => "objective", + GameEvent::StatsUpdate(_) => "stats_update", + GameEvent::GameEnd(_) => "game_end", + GameEvent::PhaseChange(_) => "phase_change", + GameEvent::Unknown => "unknown", + } + } } #[cfg(test)] @@ -370,3 +516,148 @@ mod tests { assert_eq!(obj, ObjectiveType::Dragon); } } + +// ============================================================================ +// GameFlow Session Data Structures +// ============================================================================ + +/// Full gameflow session data from /lol-gameflow/v1/session +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct GameflowSession { + pub game_id: u64, + pub game_name: String, + pub is_custom_game: bool, + pub password: String, + pub player_champion_selections: Vec, + pub queue: Option, + pub spectator_key: Option, + pub spectators_allowed: bool, + pub team_one: Vec, + pub team_two: Vec, + #[serde(default)] + pub phase: Option, +} + +/// Player's champion selection info +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct PlayerChampionSelection { + pub champion_id: u32, + pub puuid: String, + pub selected_skin_index: u32, + pub spell1_id: u32, + pub spell2_id: u32, +} + +/// Queue information +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct QueueInfo { + pub id: u32, + pub name: String, + pub short_name: Option, + pub description: String, + pub detailed_description: Option, + pub game_mode: String, + pub map_id: u32, + pub category: String, + #[serde(default)] + pub is_ranked: bool, + #[serde(default)] + pub is_custom: bool, + pub r#type: Option, + pub num_players_per_team: u32, + pub maximum_participant_list_size: u32, + pub minimum_participant_list_size: u32, + pub champions_required_to_play: u32, + #[serde(default)] + pub are_free_champions_allowed: bool, + pub queue_availability: Option, + pub spectator_enabled: Option, +} + +/// Team member information +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct TeamMember { + pub champion_id: u32, + pub last_selected_skin_index: u32, + pub profile_icon_id: u32, + pub puuid: String, + pub selected_position: String, + pub selected_role: String, + pub summoner_id: u64, + pub summoner_internal_name: String, + pub summoner_name: String, + #[serde(default)] + pub team_owner: bool, + pub team_participant_id: u32, +} + +impl GameflowSession { + /// Get a player's champion selection by puuid + pub fn get_player_selection(&self, puuid: &str) -> Option<&PlayerChampionSelection> { + self.player_champion_selections + .iter() + .find(|s| s.puuid == puuid) + } + + /// Get a player's team member info by puuid (returns team member and team id) + pub fn get_player_team_member(&self, puuid: &str) -> Option<(&TeamMember, u32)> { + // Check team one + for member in &self.team_one { + if member.puuid == puuid { + return Some((member, 100)); + } + } + // Check team two + for member in &self.team_two { + if member.puuid == puuid { + return Some((member, 200)); + } + } + None + } + + /// Get a player's champion ID by puuid + pub fn get_champion_id(&self, puuid: &str) -> Option { + self.get_player_selection(puuid).map(|s| s.champion_id) + } + + /// Get a player's team (100 or 200) by puuid + pub fn get_team(&self, puuid: &str) -> Option { + self.get_player_team_member(puuid).map(|(_, team)| team) + } + + /// Get a player's summoner name by puuid + pub fn get_summoner_name(&self, puuid: &str) -> Option<&str> { + self.get_player_team_member(puuid).and_then(|(member, _)| { + if member.summoner_name.is_empty() { + None + } else { + Some(member.summoner_name.as_str()) + } + }) + } + + /// Get queue name + pub fn queue_name(&self) -> Option<&str> { + self.queue.as_ref().map(|q| q.name.as_str()) + } + + /// Get game mode + pub fn game_mode(&self) -> Option<&str> { + self.queue.as_ref().map(|q| q.game_mode.as_str()) + } + + /// Get map ID + pub fn map_id(&self) -> Option { + self.queue.as_ref().map(|q| q.map_id) + } + + /// Get queue ID + pub fn queue_id(&self) -> Option { + self.queue.as_ref().map(|q| q.id) + } +} diff --git a/record-daemon/src/lqp/mod.rs b/record-daemon/src/lqp/mod.rs index fb028b7..5f3aa67 100644 --- a/record-daemon/src/lqp/mod.rs +++ b/record-daemon/src/lqp/mod.rs @@ -8,8 +8,9 @@ mod client; mod events; pub use auth::{LockfileCredentials, LockfileWatcher}; -pub use client::{GameflowPhase, LqpClient}; +pub use client::{champion_id_to_name, GameEndMetadata, GameflowPhase, LqpClient, PreGameMetadata}; pub use events::{ - DeathEvent, EventData, GameEndInfo, GameEvent, GameStartInfo, KillEvent, MatchInfo, - ObjectiveEvent, ObjectiveType, + ChampSelectStartInfo, ChampionPickInfo, DeathEvent, EventData, GameEndInfo, GameEvent, + GameStartInfo, GameflowSession, InGameStats, KillEvent, MatchInfo, ObjectiveEvent, + ObjectiveType, PlayerChampionSelection, QueueInfo, TeamMember, }; diff --git a/record-daemon/src/main.rs b/record-daemon/src/main.rs index 6fad9c7..caecaa9 100644 --- a/record-daemon/src/main.rs +++ b/record-daemon/src/main.rs @@ -16,7 +16,7 @@ use record_daemon::{ lqp::{GameEvent, LockfileWatcher, LqpClient}, recording::RecordingEngine, state::{DaemonStateMachine, DaemonStatus, StateTransition}, - timeline::{EventMapper, TimelineStore}, + timeline::{EventMapper, TimelineStore, TimestampedEvent}, }; /// Record Daemon - League of Legends recording daemon. @@ -54,6 +54,10 @@ struct Daemon { timeline_store: Arc>, /// Event mapper. event_mapper: Arc>, + /// Current recording ID (if recording). + current_recording_id: Arc>>, + /// Pre-game metadata (collected before game starts). + pregame_metadata: Arc>>, /// IPC server. ipc_server: Option, /// Shutdown signal. @@ -72,6 +76,8 @@ impl Daemon { recording_engine: Arc::new(RwLock::new(None)), timeline_store: Arc::new(RwLock::new(TimelineStore::new())), event_mapper: Arc::new(RwLock::new(EventMapper::new())), + current_recording_id: Arc::new(RwLock::new(None)), + pregame_metadata: Arc::new(RwLock::new(None)), ipc_server: None, shutdown_tx, } @@ -223,80 +229,300 @@ impl Daemon { async fn handle_game_event(&self, event: GameEvent) -> Result<()> { info!("[EVENT_HANDLER] Game event received: {:?}", event); + // Handle pre-game metadata collection + match &event { + GameEvent::PhaseChange(info) if info.phase == "ChampSelect" => { + info!("[EVENT_HANDLER] Champion select started, fetching pre-game metadata"); + match self.lqp_client.fetch_pregame_metadata().await { + Ok(metadata) => { + info!("[EVENT_HANDLER] Pre-game metadata fetched: {:?}", metadata); + *self.pregame_metadata.write() = Some(metadata); + } + Err(e) => { + warn!("[EVENT_HANDLER] Failed to fetch pre-game metadata: {}", e); + } + } + } + GameEvent::ChampionPick(pick) if pick.is_local_player => { + info!( + "[EVENT_HANDLER] Local player picked champion: {}", + pick.champion_name + ); + let mut metadata = self.pregame_metadata.write(); + if let Some(ref mut meta) = *metadata { + meta.champion_id = Some(pick.champion_id); + if let Some(_skin_name) = &pick.skin_name { + // Store skin name for later use + } + } + } + GameEvent::GameStart(info) => { + info!( + "[EVENT_HANDLER] Game started with metadata: queue={:?}, mode={:?}, map={:?}", + info.queue_type, info.game_mode, info.map_name + ); + + // Update pre-game metadata with game start info + let mut pregame = self.pregame_metadata.write(); + + // Extract player-specific data from session using puuid + let (champion_id, team, summoner_name) = if let Some(ref session) = info.session { + // Get puuid from pregame metadata (fetched earlier) + let puuid = pregame.as_ref().and_then(|m| m.local_puuid.as_ref()); + + if let Some(puuid) = puuid { + // Use the puuid to find the correct player's data + let champ_id = session.get_champion_id(puuid); + let team_id = session.get_team(puuid); + let summoner = session.get_summoner_name(puuid).map(|s| s.to_string()); + + info!("[EVENT_HANDLER] Found player data via puuid: champion_id={:?}, team={:?}, summoner={:?}", + champ_id, team_id, summoner); + + (champ_id, team_id, summoner) + } else { + // No puuid available, can't determine player + warn!("[EVENT_HANDLER] No puuid available, cannot determine player-specific data"); + (None, None, None) + } + } else { + (None, None, None) + }; + + if let Some(ref mut meta) = *pregame { + // Fill in champion_id from session if not already set + if meta.champion_id.is_none() { + meta.champion_id = champion_id; + } + // Fill in team from session if not already set + if meta.team.is_none() { + meta.team = team; + } + // Fill in summoner_name from session if not already set + if meta.summoner_name.is_none() { + meta.summoner_name = summoner_name; + } + // Fill in queue info + if meta.queue_type.is_none() { + meta.queue_type = info.queue_type.clone(); + } + if meta.queue_id.is_none() { + meta.queue_id = info.queue_id; + } + if meta.game_mode.is_none() { + meta.game_mode = info.game_mode.clone(); + } + if meta.map_name.is_none() { + meta.map_name = info.map_name.clone(); + } + } else { + // Create pre-game metadata from game start info + *pregame = Some(record_daemon::lqp::PreGameMetadata { + summoner_name, + champion_id, + skin_id: None, + rune_page_name: None, + queue_type: info.queue_type.clone(), + queue_id: info.queue_id, + game_mode: info.game_mode.clone(), + map_name: info.map_name.clone(), + team, + local_puuid: None, + }); + } + } + _ => {} + } + + // Record event to timeline if recording (BEFORE state transition for GameEnd) + // This ensures GameEnd events are recorded while still in recording state + if self.state_machine.is_recording() { + if let Some((video_ts, game_ts)) = self.event_mapper.write().handle_event(&event) { + // Get the current recording ID + if let Some(recording_id) = *self.current_recording_id.read() { + // Create a timestamped event + let timestamped_event = TimestampedEvent { + video_timestamp: video_ts, + game_timestamp: game_ts, + timestamp: chrono::Utc::now(), + event_type: event.event_type_name().to_string(), + description: event.description(), + event: event.clone(), + }; + + // Add the event to the timeline store + if let Err(e) = self + .timeline_store + .write() + .add_event(recording_id, timestamped_event) + { + warn!("Failed to add event to timeline: {:?}", e); + } else { + debug!( + "Event added to timeline: video_ts={:?}, game_ts={:?}, type={}", + video_ts, + game_ts, + event.event_type_name() + ); + } + } else { + warn!("Recording in progress but no recording ID set"); + } + } + } + // Process state transitions if let Some(transition) = self.state_machine.process_event(&event) { info!("[EVENT_HANDLER] State transition: {:?}", transition); - self.state_machine.transition(transition.clone()); - - // Handle recording start/stop - match transition { - StateTransition::GameStarted { game_id, champion } => { - info!( - "[EVENT_HANDLER] GameStarted transition - game_id: {}, champion: {:?}", - game_id, champion + // Only process the transition if it's valid + if let Some(_new_state) = self.state_machine.transition(transition.clone()) { + // Handle recording start/stop + match transition { + StateTransition::GameStarted { + game_id, + champion, + queue_type, + queue_id, + game_mode, + map_name, + team, + summoner_name, + } => { + info!( + "[EVENT_HANDLER] GameStarted transition - game_id: {}, champion: {:?}, queue_type: {:?}, game_mode: {:?}", + game_id, champion, queue_type, game_mode ); - // If already recording, stop the current recording first - if self.state_machine.is_recording() { - info!( + // If already recording, stop the current recording first + if self.state_machine.is_recording() { + info!( "[EVENT_HANDLER] Stopping previous recording before starting new one" ); - if let Err(e) = self.stop_recording().await { - warn!("[EVENT_HANDLER] Failed to stop previous recording: {}", e); + if let Err(e) = self.stop_recording().await { + warn!("[EVENT_HANDLER] Failed to stop previous recording: {}", e); + } + } + + info!("[EVENT_HANDLER] Calling start_recording..."); + + // Wrap the start_recording call to catch any panics + let start_result = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + // We need to use a blocking approach here since we're in catch_unwind + // The actual async call happens outside + })); + + if let Err(panic_info) = start_result { + error!( + "[EVENT_HANDLER] PANIC before start_recording: {:?}", + panic_info + ); + + eprintln!( + "[EVENT_HANDLER] PANIC before start_recording: {:?}", + panic_info + ); + } + + // Get pre-game metadata to pass to recording + let pregame = self.pregame_metadata.read().clone(); + let champion_name = champion.or_else(|| { + pregame.as_ref().and({ + // Try to get champion name from metadata if not provided + None // We only have champion_id, not name + }) + }); + + // Build game metadata for timeline + let metadata_update = record_daemon::timeline::MetadataUpdate { + queue_type: queue_type.clone(), + queue_id, + game_mode: game_mode.clone(), + map_name: map_name.clone(), + team, + summoner_name: summoner_name.clone(), + ..Default::default() + }; + + if let Err(e) = self + .start_recording_with_metadata( + game_id, + champion_name.as_deref(), + metadata_update, + ) + .await + { + error!("[EVENT_HANDLER] Failed to start recording: {}", e); + + // Don't propagate error - keep daemon running + } else { + info!("[EVENT_HANDLER] start_recording completed successfully"); } } - - info!("[EVENT_HANDLER] Calling start_recording..."); - - // Wrap the start_recording call to catch any panics - let start_result = - std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - // We need to use a blocking approach here since we're in catch_unwind - // The actual async call happens outside - })); - - if let Err(panic_info) = start_result { - error!( - "[EVENT_HANDLER] PANIC before start_recording: {:?}", - panic_info + StateTransition::GameEnded { game_end_info } => { + info!( + "[EVENT_HANDLER] GameEnded transition with info: {:?}", + game_end_info ); - eprintln!( - "[EVENT_HANDLER] PANIC before start_recording: {:?}", - panic_info + // Convert GameEndInfo to GameEndMetadata if available + let game_end_metadata = + game_end_info.map(|info| record_daemon::lqp::GameEndMetadata { + victory: Some(info.victory), + match_id: None, + kills: info.stats.as_ref().map(|s| s.kills).unwrap_or(0), + deaths: info.stats.as_ref().map(|s| s.deaths).unwrap_or(0), + assists: info.stats.as_ref().map(|s| s.assists).unwrap_or(0), + creep_score: info + .stats + .as_ref() + .map(|s| s.minions_killed) + .unwrap_or(0), + gold_earned: info + .stats + .as_ref() + .map(|s| s.gold_earned) + .unwrap_or(0), + damage_dealt: info + .stats + .as_ref() + .map(|s| s.damage_dealt) + .unwrap_or(0), + damage_taken: info + .stats + .as_ref() + .map(|s| s.damage_taken) + .unwrap_or(0), + vision_score: info + .stats + .as_ref() + .map(|s| s.vision_score) + .unwrap_or(0.0), + game_duration: info.duration, + }); + + info!( + "[EVENT_HANDLER] Game end metadata from event: {:?}", + game_end_metadata ); - } - if let Err(e) = self.start_recording(game_id, champion.as_deref()).await { - error!("[EVENT_HANDLER] Failed to start recording: {}", e); + if let Err(e) = self.stop_recording_with_metadata(game_end_metadata).await { + error!("[EVENT_HANDLER] Failed to stop recording: {}", e); - // Don't propagate error - keep daemon running - } else { - info!("[EVENT_HANDLER] start_recording completed successfully"); + // Don't propagate error - keep daemon running + } + + // Clear pre-game metadata + *self.pregame_metadata.write() = None; } + _ => {} } - StateTransition::GameEnded => { - info!("[EVENT_HANDLER] GameEnded transition"); - - if let Err(e) = self.stop_recording().await { - error!("[EVENT_HANDLER] Failed to stop recording: {}", e); - - // Don't propagate error - keep daemon running - } - } - _ => {} - } - } - - // Record event to timeline if recording - if self.state_machine.is_recording() { - if let Some((video_ts, game_ts)) = self.event_mapper.write().handle_event(&event) { - // Event would be added to timeline here - debug!( - "Event mapped: video_ts={:?}, game_ts={:?}", - video_ts, game_ts + } else { + warn!( + "[EVENT_HANDLER] State transition rejected: {:?}", + transition ); } } @@ -306,13 +532,37 @@ impl Daemon { Ok(()) } - /// Start recording. - async fn start_recording(&self, game_id: u64, champion: Option<&str>) -> Result<()> { + /// Start recording with game metadata. + async fn start_recording_with_metadata( + &self, + game_id: u64, + champion: Option<&str>, + metadata_update: record_daemon::timeline::MetadataUpdate, + ) -> Result<()> { info!( - "Daemon::start_recording called - game {} ({:?})", + "Daemon::start_recording_with_metadata called - game {} ({:?})", game_id, champion ); + // Create a recording entry in the timeline store first + let recording_id = self + .timeline_store + .write() + .start_recording_entry(Some(game_id), champion.map(|s| s.to_string())); + + // Update metadata immediately with game start info + if let Err(e) = self + .timeline_store + .write() + .update_metadata(recording_id, metadata_update) + { + warn!("Failed to update recording metadata: {:?}", e); + } + + // Store the recording ID for event tracking + *self.current_recording_id.write() = Some(recording_id); + info!("Created recording entry with ID: {}", recording_id); + // Clone Arc references for use in spawn_blocking let recording_engine = self.recording_engine.clone(); let event_mapper = self.event_mapper.clone(); @@ -334,7 +584,7 @@ impl Daemon { warn!("Recording engine is None!"); } - info!("Daemon::start_recording completed successfully"); + info!("Daemon::start_recording_with_metadata completed successfully"); Ok(()) }) .await @@ -348,12 +598,24 @@ impl Daemon { /// Stop recording. async fn stop_recording(&self) -> Result<()> { + self.stop_recording_with_metadata(None).await + } + + /// Stop recording with optional game end metadata. + async fn stop_recording_with_metadata( + &self, + game_end_metadata: Option, + ) -> Result<()> { info!("Stopping recording"); + // Get the current recording ID and clear it + let recording_id = self.current_recording_id.write().take(); + // Clone Arc references for use in spawn_blocking let recording_engine = self.recording_engine.clone(); let event_mapper = self.event_mapper.clone(); let timeline_store = self.timeline_store.clone(); + let pregame_metadata = self.pregame_metadata.read().clone(); // Use spawn_blocking to avoid blocking the async runtime tokio::task::spawn_blocking(move || { @@ -362,8 +624,59 @@ impl Daemon { let result = engine.stop_recording()?; event_mapper.write().stop(); - // Save to timeline - timeline_store.write().add_recording(result)?; + // Use the existing recording ID if available, otherwise create new + let recording_id = match recording_id { + Some(id) => { + // Finalize the existing recording entry + if let Err(e) = timeline_store.write().finalize_recording(id, result) { + warn!("Failed to finalize recording: {}", e); + } + id + } + None => { + // Fallback: create new recording entry (legacy behavior) + timeline_store.write().add_recording(result)? + } + }; + + // Update metadata if we have it + let mut update = record_daemon::timeline::MetadataUpdate::default(); + + // Add pre-game metadata + if let Some(pregame) = pregame_metadata { + // Convert champion_id to name if available + if let Some(champion_id) = pregame.champion_id { + update.champion = record_daemon::lqp::champion_id_to_name(champion_id); + } + update.summoner_name = pregame.summoner_name; + update.queue_type = pregame.queue_type; + update.queue_id = pregame.queue_id; + update.game_mode = pregame.game_mode; + update.map_name = pregame.map_name; + update.team = pregame.team; + } + + // Add game end metadata + if let Some(end_meta) = game_end_metadata { + update.match_id = end_meta.match_id; + update.victory = end_meta.victory; + update.final_stats = Some(record_daemon::timeline::GameFinalStats { + kills: end_meta.kills, + deaths: end_meta.deaths, + assists: end_meta.assists, + creep_score: end_meta.creep_score, + gold_earned: end_meta.gold_earned, + damage_dealt: end_meta.damage_dealt, + damage_taken: end_meta.damage_taken, + vision_score: end_meta.vision_score, + game_duration: end_meta.game_duration, + }); + } + + // Apply the update + if let Err(e) = timeline_store.write().update_metadata(recording_id, update) { + warn!("Failed to update recording metadata: {}", e); + } } Ok(()) diff --git a/record-daemon/src/recording/obs_context.rs b/record-daemon/src/recording/obs_context.rs index 5c80d08..f51437a 100644 --- a/record-daemon/src/recording/obs_context.rs +++ b/record-daemon/src/recording/obs_context.rs @@ -434,6 +434,13 @@ impl ObsContext { // Set up game capture source self.setup_game_capture()?; + // Set up audio capture source + if self.audio_settings.enabled && self.audio_settings.capture_game { + info!("[START_REC] Setting up audio capture..."); + self.setup_audio_capture()?; + info!("[START_REC] Audio capture set up successfully"); + } + info!("[START_REC] Game capture set up, starting output..."); // Start the output - wrap in catch_unwind as this may crash in native code @@ -494,8 +501,6 @@ impl ObsContext { /// Set up capture source on Linux using screen capture. #[cfg(target_os = "linux")] fn setup_game_capture(&mut self) -> Result<()> { - use std::io::Write; - info!("[CAPTURE] Setting up screen capture for Linux..."); self.setup_linux_screen_capture() @@ -796,7 +801,6 @@ impl ObsContext { fn setup_linux_screen_capture(&mut self) -> Result<()> { use libobs_simple::sources::linux::LinuxGeneralScreenCaptureBuilder; use libobs_simple::sources::ObsSourceBuilder; - use std::io::Write; info!("[LINUX_CAPTURE] Setting up Linux screen capture..."); @@ -921,6 +925,160 @@ impl ObsContext { Ok(()) } + /// Set up audio capture for game audio (Windows only). + #[cfg(target_os = "windows")] + fn setup_audio_capture(&mut self) -> Result<()> { + use libobs_wrapper::data::ObsData; + use libobs_wrapper::utils::SourceInfo; + + info!("[AUDIO_CAPTURE] Setting up WASAPI audio capture..."); + + let context = self.context.as_mut().ok_or_else(|| { + error!("[AUDIO_CAPTURE] OBS not initialized"); + RecordingError::ObsInitError("OBS not initialized".to_string()) + })?; + + // Create audio source settings using JSON for reliable string handling + let settings_json = r#"{ + "executable": "League of Legends.exe", + "title": "League of Legends (TM) Client", + "class": "RiotWindowClass", + "priority": 2 + }"#; + + let audio_settings_data = ObsData::from_json(settings_json, context.runtime().clone()) + .map_err(|e| { + error!( + "[AUDIO_CAPTURE] Failed to create audio settings from JSON: {:?}", + e + ); + RecordingError::StartError(format!( + "Failed to create audio settings from JSON: {:?}", + e + )) + })?; + + info!("[AUDIO_CAPTURE] Audio settings configured for League of Legends"); + + // Configure WASAPI process output capture for game audio + // This captures the audio output from a specific process (the game) + let source_info = SourceInfo::new( + "wasapi_process_output_capture", + "game_audio", + Some(audio_settings_data), + None, + ); + + info!("[AUDIO_CAPTURE] Creating WASAPI process output capture source..."); + + let audio_source_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + libobs_wrapper::sources::ObsSourceRef::new_from_info( + source_info, + context.runtime().clone(), + ) + })); + + let _audio_source = match audio_source_result { + Ok(Ok(s)) => { + info!("[AUDIO_CAPTURE] WASAPI audio source created"); + s + } + Ok(Err(e)) => { + error!("[AUDIO_CAPTURE] Failed to create audio source: {:?}", e); + return Err(RecordingError::StartError(format!( + "Failed to create audio source: {:?}", + e + )) + .into()); + } + Err(panic_info) => { + error!( + "[AUDIO_CAPTURE] PANIC creating audio source: {:?}", + panic_info + ); + eprintln!( + "[AUDIO_CAPTURE] PANIC creating audio source: {:?}", + panic_info + ); + return Err( + RecordingError::StartError("Panic creating audio source".to_string()).into(), + ); + } + }; + + info!("[AUDIO_CAPTURE] Audio source created successfully"); + + Ok(()) + } + + /// Set up audio capture for game audio (Linux only). + #[cfg(target_os = "linux")] + fn setup_audio_capture(&mut self) -> Result<()> { + use libobs_wrapper::utils::SourceInfo; + + info!("[AUDIO_CAPTURE] Setting up PulseAudio capture..."); + + let context = self.context.as_mut().ok_or_else(|| { + error!("[AUDIO_CAPTURE] OBS not initialized"); + RecordingError::ObsInitError("OBS not initialized".to_string()) + })?; + + // Create audio source settings + let audio_settings_data = context.data().map_err(|e| { + error!("[AUDIO_CAPTURE] Failed to create audio settings: {:?}", e); + RecordingError::StartError(format!("Failed to create audio settings: {:?}", e)) + })?; + + // Configure PulseAudio capture for game audio on Linux + let source_info = SourceInfo::new( + "pulse_input_capture", + "game_audio", + Some(audio_settings_data), + None, + ); + + info!("[AUDIO_CAPTURE] Creating PulseAudio capture source..."); + + let audio_source_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + libobs_wrapper::sources::ObsSourceRef::new_from_info( + source_info, + context.runtime().clone(), + ) + })); + + let _audio_source = match audio_source_result { + Ok(Ok(s)) => { + info!("[AUDIO_CAPTURE] PulseAudio source created"); + s + } + Ok(Err(e)) => { + error!("[AUDIO_CAPTURE] Failed to create audio source: {:?}", e); + return Err(RecordingError::StartError(format!( + "Failed to create audio source: {:?}", + e + )) + .into()); + } + Err(panic_info) => { + error!( + "[AUDIO_CAPTURE] PANIC creating audio source: {:?}", + panic_info + ); + eprintln!( + "[AUDIO_CAPTURE] PANIC creating audio source: {:?}", + panic_info + ); + return Err( + RecordingError::StartError("Panic creating audio source".to_string()).into(), + ); + } + }; + + info!("[AUDIO_CAPTURE] Audio source created successfully"); + + Ok(()) + } + /// Stop recording. pub fn stop_recording(&mut self) -> Result<()> { if !self.recording { diff --git a/record-daemon/src/state/machine.rs b/record-daemon/src/state/machine.rs index d9ab262..5899c22 100644 --- a/record-daemon/src/state/machine.rs +++ b/record-daemon/src/state/machine.rs @@ -6,7 +6,7 @@ use parking_lot::RwLock; use tracing::{info, trace, warn}; use super::DaemonStatus; -use crate::lqp::{GameEvent, GameflowPhase}; +use crate::lqp::{GameEndInfo, GameEvent, GameflowPhase}; /// Internal daemon state. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -46,9 +46,18 @@ pub enum StateTransition { GameStarted { game_id: u64, champion: Option, + queue_type: Option, + queue_id: Option, + game_mode: Option, + map_name: Option, + team: Option, + summoner_name: Option, }, /// Game ended. - GameEnded, + GameEnded { + /// Game end info with stats from WebSocket. + game_end_info: Option, + }, /// Error occurred. Error(String), /// Error recovered. @@ -136,11 +145,13 @@ impl DaemonStateMachine { // Update related state match &transition { - StateTransition::GameStarted { game_id, champion } => { + StateTransition::GameStarted { + game_id, champion, .. + } => { *self.current_game_id.write() = Some(*game_id); *self.current_champion.write() = champion.clone(); } - StateTransition::GameEnded => { + StateTransition::GameEnded { .. } => { *self.current_game_id.write() = None; *self.current_champion.write() = None; } @@ -177,7 +188,9 @@ impl DaemonStateMachine { (DaemonState::Monitoring, StateTransition::Shutdown) => Some(DaemonState::ShuttingDown), // From Recording - (DaemonState::Recording, StateTransition::GameEnded) => Some(DaemonState::Monitoring), + (DaemonState::Recording, StateTransition::GameEnded { .. }) => { + Some(DaemonState::Monitoring) + } // Allow GameStarted from Recording (handles case where GameEnded wasn't received) (DaemonState::Recording, StateTransition::GameStarted { .. }) => { Some(DaemonState::Recording) @@ -214,12 +227,23 @@ impl DaemonStateMachine { GameEvent::GameStart(info) => Some(StateTransition::GameStarted { game_id: info.game_id, champion: info.champion.clone(), + queue_type: info.queue_type.clone(), + queue_id: info.queue_id, + game_mode: info.game_mode.clone(), + map_name: info.map_name.clone(), + team: info.team, + summoner_name: info.summoner_name.clone(), + }), + GameEvent::GameEnd(info) => Some(StateTransition::GameEnded { + game_end_info: Some(info.clone()), }), - GameEvent::GameEnd(_) => Some(StateTransition::GameEnded), GameEvent::PhaseChange(info) => { - // When phase changes to None while recording, the player left the game - if info.phase == "None" && self.is_recording() { - Some(StateTransition::GameEnded) + // Only trigger GameEnded on EndOfGame phase (stats are available by then) + // The actual GameEnd event with stats comes from /lol-end-of-game/v1/eog-stats-block + if info.phase == "EndOfGame" && self.is_recording() { + Some(StateTransition::GameEnded { + game_end_info: None, + }) } else { None } @@ -267,6 +291,12 @@ mod tests { let new_state = machine.transition(StateTransition::GameStarted { game_id: 12345, champion: Some("Ahri".to_string()), + queue_type: None, + queue_id: None, + game_mode: None, + map_name: None, + team: None, + summoner_name: None, }); assert_eq!(new_state, Some(DaemonState::Recording)); @@ -282,6 +312,12 @@ mod tests { let result = machine.transition(StateTransition::GameStarted { game_id: 12345, champion: None, + queue_type: None, + queue_id: None, + game_mode: None, + map_name: None, + team: None, + summoner_name: None, }); assert_eq!(result, None); diff --git a/record-daemon/src/timeline/mod.rs b/record-daemon/src/timeline/mod.rs index f40048b..b634e1a 100644 --- a/record-daemon/src/timeline/mod.rs +++ b/record-daemon/src/timeline/mod.rs @@ -4,7 +4,9 @@ mod mapper; mod store; pub use mapper::EventMapper; -pub use store::{RecordingMetadata, TimelineStore, TimestampedEvent}; +pub use store::{ + GameFinalStats, MetadataUpdate, RecordingMetadata, TimelineStore, TimestampedEvent, +}; use chrono::{DateTime, Duration, Utc}; use serde::{Deserialize, Serialize}; @@ -25,6 +27,36 @@ pub struct Timeline { pub duration_secs: i64, /// Events in the timeline. pub events: Vec, + /// Champion played. + #[serde(default)] + pub champion: Option, + /// Skin name. + #[serde(default)] + pub skin_name: Option, + /// Queue type. + #[serde(default)] + pub queue_type: Option, + /// Queue ID. + #[serde(default)] + pub queue_id: Option, + /// Game mode. + #[serde(default)] + pub game_mode: Option, + /// Map name. + #[serde(default)] + pub map_name: Option, + /// Summoner name. + #[serde(default)] + pub summoner_name: Option, + /// Team (100 = blue, 200 = red). + #[serde(default)] + pub team: Option, + /// Whether the game was won. + #[serde(default)] + pub victory: Option, + /// Final player stats. + #[serde(default)] + pub final_stats: Option, } impl Timeline { @@ -43,6 +75,16 @@ impl Timeline { end_time: None, duration_secs: 0, events: Vec::new(), + champion: None, + skin_name: None, + queue_type: None, + queue_id: None, + game_mode: None, + map_name: None, + summoner_name: None, + team: None, + victory: None, + final_stats: None, } } @@ -138,10 +180,13 @@ fn format_timestamp(duration: Duration) -> String { fn event_type_name(event: &GameEvent) -> String { match event { GameEvent::MatchFound(_) => "match_found", + GameEvent::ChampSelectStart(_) => "champ_select_start", + GameEvent::ChampionPick(_) => "champion_pick", GameEvent::GameStart(_) => "game_start", GameEvent::Kill(_) => "kill", GameEvent::Death(_) => "death", GameEvent::Objective(_) => "objective", + GameEvent::StatsUpdate(_) => "stats_update", GameEvent::GameEnd(_) => "game_end", GameEvent::PhaseChange(_) => "phase_change", GameEvent::Unknown => "unknown", diff --git a/record-daemon/src/timeline/store.rs b/record-daemon/src/timeline/store.rs index f617b1d..d9d4cf4 100644 --- a/record-daemon/src/timeline/store.rs +++ b/record-daemon/src/timeline/store.rs @@ -38,8 +38,28 @@ pub struct RecordingMetadata { pub id: Uuid, /// Game ID if available. pub game_id: Option, + /// Match ID if available. + pub match_id: Option, /// Champion played. pub champion: Option, + /// Skin name. + pub skin_name: Option, + /// Queue type (ranked, normal, aram, etc.). + pub queue_type: Option, + /// Queue ID. + pub queue_id: Option, + /// Game mode. + pub game_mode: Option, + /// Map name. + pub map_name: Option, + /// Player's summoner name. + pub summoner_name: Option, + /// Team (100 = blue, 200 = red). + pub team: Option, + /// Whether the game was won. + pub victory: Option, + /// Final player stats. + pub final_stats: Option, /// Recording start time. pub start_time: DateTime, /// Recording end time. @@ -48,7 +68,7 @@ pub struct RecordingMetadata { // #[serde(with = "chrono::serde::seconds")] pub duration: Duration, /// Output file path. - pub file_path: PathBuf, + pub file_path: Option, /// File size in bytes. pub file_size: Option, /// Number of events. @@ -57,17 +77,66 @@ pub struct RecordingMetadata { pub finalized: bool, } +/// Final game statistics for the player. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GameFinalStats { + /// Kills. + pub kills: u32, + /// Deaths. + pub deaths: u32, + /// Assists. + pub assists: u32, + /// Creep score. + pub creep_score: u32, + /// Gold earned. + pub gold_earned: u32, + /// Damage dealt. + pub damage_dealt: u64, + /// Damage taken. + pub damage_taken: u64, + /// Vision score. + pub vision_score: f64, + /// Game duration in seconds. + pub game_duration: f64, +} + +/// Update for recording metadata. +#[derive(Debug, Clone, Default)] +pub struct MetadataUpdate { + pub champion: Option, + pub match_id: Option, + pub skin_name: Option, + pub queue_type: Option, + pub queue_id: Option, + pub game_mode: Option, + pub map_name: Option, + pub summoner_name: Option, + pub team: Option, + pub victory: Option, + pub final_stats: Option, +} + impl RecordingMetadata { /// Create metadata from a recording result. pub fn from_result(result: &RecordingResult) -> Self { Self { id: Uuid::new_v4(), game_id: result.game_id, + match_id: None, champion: result.champion.clone(), + skin_name: None, + queue_type: None, + queue_id: None, + game_mode: None, + map_name: None, + summoner_name: None, + team: None, + victory: None, + final_stats: None, start_time: result.start_time, end_time: Some(result.end_time), duration: result.duration, - file_path: result.path.clone(), + file_path: Some(result.path.clone()), file_size: result.file_size(), event_count: 0, finalized: false, @@ -108,17 +177,84 @@ impl TimelineStore { } } - /// Add a new recording. + /// Start a new recording entry (called when recording begins). + /// Returns the recording ID for tracking events during recording. + pub fn start_recording_entry(&self, game_id: Option, champion: Option) -> Uuid { + let id = Uuid::new_v4(); + let metadata = RecordingMetadata { + id, + game_id, + match_id: None, + champion, + skin_name: None, + queue_type: None, + queue_id: None, + game_mode: None, + map_name: None, + summoner_name: None, + team: None, + victory: None, + final_stats: None, + start_time: Utc::now(), + end_time: None, + duration: Duration::zero(), + file_path: None, + file_size: None, + event_count: 0, + finalized: false, + }; + + self.recordings.write().insert(id, metadata); + self.timelines.write().insert(id, Vec::new()); + + id + } + + /// Finalize a recording with the recording result. + /// Called when recording stops. + pub fn finalize_recording(&self, recording_id: Uuid, result: RecordingResult) -> Result<()> { + let mut recordings = self.recordings.write(); + + let metadata = recordings + .get_mut(&recording_id) + .ok_or(TimelineError::RecordingNotFound(recording_id))?; + + // Update with final recording data + metadata.start_time = result.start_time; + metadata.end_time = Some(result.end_time); + metadata.duration = result.duration; + metadata.file_path = Some(result.path.clone()); + metadata.file_size = result.file_size(); + metadata.finalized = true; + + // Persist to disk + drop(recordings); + self.persist_recording(recording_id)?; + + Ok(()) + } + + /// Add a new recording (legacy method for backwards compatibility). pub fn add_recording(&self, result: RecordingResult) -> Result { let id = Uuid::new_v4(); let metadata = RecordingMetadata { id, game_id: result.game_id, + match_id: None, champion: result.champion.clone(), + skin_name: None, + queue_type: None, + queue_id: None, + game_mode: None, + map_name: None, + summoner_name: None, + team: None, + victory: None, + final_stats: None, start_time: result.start_time, end_time: Some(result.end_time), duration: result.duration, - file_path: result.path.clone(), + file_path: Some(result.path.clone()), file_size: result.file_size(), event_count: 0, finalized: true, @@ -153,6 +289,49 @@ impl TimelineStore { Ok(()) } + /// Update metadata for a recording. + pub fn update_metadata(&self, recording_id: Uuid, update: MetadataUpdate) -> Result<()> { + let mut recordings = self.recordings.write(); + if let Some(metadata) = recordings.get_mut(&recording_id) { + if let Some(champion) = update.champion { + metadata.champion = Some(champion); + } + if let Some(match_id) = update.match_id { + metadata.match_id = Some(match_id); + } + if let Some(skin_name) = update.skin_name { + metadata.skin_name = Some(skin_name); + } + if let Some(queue_type) = update.queue_type { + metadata.queue_type = Some(queue_type); + } + if let Some(queue_id) = update.queue_id { + metadata.queue_id = Some(queue_id); + } + if let Some(game_mode) = update.game_mode { + metadata.game_mode = Some(game_mode); + } + if let Some(map_name) = update.map_name { + metadata.map_name = Some(map_name); + } + if let Some(summoner_name) = update.summoner_name { + metadata.summoner_name = Some(summoner_name); + } + if let Some(team) = update.team { + metadata.team = Some(team); + } + if let Some(victory) = update.victory { + metadata.victory = Some(victory); + } + if let Some(final_stats) = update.final_stats { + metadata.final_stats = Some(final_stats); + } + } + drop(recordings); + self.persist_recording(recording_id)?; + Ok(()) + } + /// Get all recordings. pub fn get_all_recordings(&self) -> Result> { let recordings = self.recordings.read(); @@ -184,6 +363,16 @@ impl TimelineStore { end_time: metadata.end_time, duration_secs: metadata.duration.num_seconds(), events, + champion: metadata.champion.clone(), + skin_name: metadata.skin_name.clone(), + queue_type: metadata.queue_type.clone(), + queue_id: metadata.queue_id, + game_mode: metadata.game_mode.clone(), + map_name: metadata.map_name.clone(), + summoner_name: metadata.summoner_name.clone(), + team: metadata.team, + victory: metadata.victory, + final_stats: metadata.final_stats.clone(), }) } @@ -220,6 +409,16 @@ impl TimelineStore { end_time: metadata.end_time, duration_secs: metadata.duration.num_seconds(), events, + champion: metadata.champion, + skin_name: metadata.skin_name, + queue_type: metadata.queue_type, + queue_id: metadata.queue_id, + game_mode: metadata.game_mode, + map_name: metadata.map_name, + summoner_name: metadata.summoner_name, + team: metadata.team, + victory: metadata.victory, + final_stats: metadata.final_stats, }; let file_path = self.storage_dir.join(format!("{}.json", id)); @@ -246,11 +445,21 @@ impl TimelineStore { let metadata = RecordingMetadata { id: timeline.recording_id, game_id: None, + match_id: None, champion: None, + skin_name: None, + queue_type: None, + queue_id: None, + game_mode: None, + map_name: None, + summoner_name: None, + team: None, + victory: None, + final_stats: None, start_time: timeline.start_time, end_time: timeline.end_time, duration: timeline.duration(), - file_path: PathBuf::new(), + file_path: None, file_size: None, event_count: timeline.events.len(), finalized: true,