diff --git a/record-daemon/src/lqp/client.rs b/record-daemon/src/lqp/client.rs index 48bc86d..1601cdb 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, GameflowSession, RawEvent}; +use super::events::{GameEvent, GameflowSession, ItemBuild, ItemInfo, RawEvent}; use crate::error::{LqpError, Result}; /// Custom certificate verifier that accepts any certificate. @@ -85,8 +85,12 @@ pub mod endpoints { 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 ALL_RUNE_PAGES: &str = "/lol-perks/v1/pages"; pub const MATCH_HISTORY: &str = "/lol-match-history/v1/products/lol/current-summoner/matches"; pub const LIVE_CLIENT_DATA: &str = "/liveclientdata/allgamedata"; + pub const LIVE_CLIENT_DATA_ACTIVE_PLAYER: &str = "/liveclientdata/activeplayer"; + pub const LIVE_CLIENT_DATA_PLAYER_LIST: &str = "/liveclientdata/playerlist"; + pub const CHAMPION_SELECT_LOCAL_PLAYER: &str = "/lol-champ-select/v1/session/my-selection"; } /// Game flow phase states. @@ -955,6 +959,11 @@ impl LqpClient { self.request("GET", endpoints::RUNE_PAGES).await } + /// Get all rune pages. + pub async fn get_all_rune_pages(&self) -> Result { + self.request("GET", endpoints::ALL_RUNE_PAGES).await + } + /// Get match history. pub async fn get_match_history(&self) -> Result { self.request("GET", endpoints::MATCH_HISTORY).await @@ -965,6 +974,21 @@ impl LqpClient { self.request("GET", endpoints::LIVE_CLIENT_DATA).await } + /// Get active player data from live client. + pub async fn get_live_client_active_player(&self) -> Result { + self.request("GET", endpoints::LIVE_CLIENT_DATA_ACTIVE_PLAYER).await + } + + /// Get player list from live client (contains all players with puuid and summoner names). + pub async fn get_live_client_player_list(&self) -> Result { + self.request("GET", endpoints::LIVE_CLIENT_DATA_PLAYER_LIST).await + } + + /// Get local player selection from champion select. + pub async fn get_local_player_selection(&self) -> Result { + self.request("GET", endpoints::CHAMPION_SELECT_LOCAL_PLAYER).await + } + /// Fetch pre-game metadata (champion, skin, runes, queue info). pub async fn fetch_pregame_metadata(&self) -> Result { let mut metadata = PreGameMetadata::default(); @@ -1096,6 +1120,397 @@ impl LqpClient { Ok(metadata) } + + /// Fetch complete player game metadata including runes, summoner spells, and items. + /// This should be called when the game starts. + pub async fn fetch_player_game_metadata(&self) -> Result { + use super::{RunePage, SummonerSpells}; + + let mut metadata = super::PlayerGameMetadata::default(); + + // Get summoner info - try multiple field names for summoner name + if let Ok(summoner) = self.get_summoner().await { + metadata.puuid = summoner.get("puuid").and_then(|p| p.as_str()).map(|s| s.to_string()); + // Try displayName first, then name, then internalName + metadata.summoner_name = summoner.get("displayName") + .or_else(|| summoner.get("name")) + .or_else(|| summoner.get("internalName")) + .and_then(|n| n.as_str()) + .map(|s| s.to_string()); + } + + // Get rune page + if let Ok(rune_page) = self.get_rune_page().await { + let primary_style_id = rune_page.get("primaryStyleId").and_then(|id| id.as_u64()).unwrap_or(0) as u32; + let secondary_style_id = rune_page.get("subStyleId").and_then(|id| id.as_u64()).unwrap_or(0) as u32; + let selected_perks = rune_page + .get("selectedPerkIds") + .and_then(|ids| ids.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|id| id.as_u64().map(|v| v as u32)) + .collect() + }) + .unwrap_or_default(); + + if primary_style_id > 0 { + metadata.runes = Some(RunePage { + primary_style_id, + secondary_style_id, + selected_perks, + stat_modifiers: Vec::new(), // Stat modifiers are part of selectedPerkIds + name: rune_page.get("name").and_then(|n| n.as_str()).map(|s| s.to_string()), + current: rune_page.get("current").and_then(|c| c.as_bool()).unwrap_or(true), + }); + } + } + + // Try to get summoner spells from live client data (available during game) + if let Ok(active_player) = self.get_live_client_active_player().await { + debug!("[METADATA] Live client active player data: {:?}", active_player); + + // Get summoner spells from active player - try multiple field structures + if let Some(summoner_spells) = active_player.get("summonerSpells") { + // Try nested structure first: summonerSpells.summonerSpellOne.spellId + let spell1_id = summoner_spells.get("summonerSpellOne") + .and_then(|s| s.get("spellId")) + .and_then(|id| id.as_u64()) + .unwrap_or_else(|| { + // Fallback: direct spell1Id field + summoner_spells.get("spell1Id").and_then(|id| id.as_u64()).unwrap_or(0) + }) as u32; + + let spell2_id = summoner_spells.get("summonerSpellTwo") + .and_then(|s| s.get("spellId")) + .and_then(|id| id.as_u64()) + .unwrap_or_else(|| { + // Fallback: direct spell2Id field + summoner_spells.get("spell2Id").and_then(|id| id.as_u64()).unwrap_or(0) + }) as u32; + + debug!("[METADATA] Summoner spells from live client: spell1={}, spell2={}", spell1_id, spell2_id); + + if spell1_id > 0 || spell2_id > 0 { + metadata.summoner_spells = Some(SummonerSpells { + spell1_id, + spell2_id, + spell1_name: spell_id_to_name(spell1_id), + spell2_name: spell_id_to_name(spell2_id), + }); + } + } + + // Get summoner name from active player if not already set + if metadata.summoner_name.is_none() || metadata.summoner_name.as_ref().map_or(true, |n| n.is_empty()) { + metadata.summoner_name = active_player.get("summonerName") + .or_else(|| active_player.get("displayName")) + .or_else(|| active_player.get("riotId")) + .and_then(|n| n.as_str()) + .map(|s| s.to_string()); + } + } + + // Fallback: Get summoner spells from session gameData + if metadata.summoner_spells.is_none() { + if let Ok(session) = self.get_session().await { + if let Some(local_puuid) = &metadata.puuid { + // Try to get from gameData + if let Some(game_data) = session.get("gameData") { + // Check teamOne and teamTwo for player + for team_key in &["teamOne", "teamTwo"] { + if let Some(team) = game_data.get(team_key).and_then(|t| t.as_array()) { + for player in team { + if player.get("puuid").and_then(|p| p.as_str()) == Some(local_puuid.as_str()) { + let spell1_id = player.get("spell1Id").and_then(|id| id.as_u64()).unwrap_or(0) as u32; + let spell2_id = player.get("spell2Id").and_then(|id| id.as_u64()).unwrap_or(0) as u32; + + if spell1_id > 0 || spell2_id > 0 { + metadata.summoner_spells = Some(SummonerSpells { + spell1_id, + spell2_id, + spell1_name: spell_id_to_name(spell1_id), + spell2_name: spell_id_to_name(spell2_id), + }); + } + + metadata.champion_id = player.get("championId").and_then(|id| id.as_u64()).map(|v| v as u32); + metadata.team = player.get("teamId").and_then(|id| id.as_u64()).map(|v| v as u32); + break; + } + } + } + } + } + } + } + } + + // Try to get champion name from champion ID + if let Some(champ_id) = metadata.champion_id { + metadata.champion_name = champion_id_to_name(champ_id); + } + + Ok(metadata) + } + + /// Fetch all players' puuid to summoner name mapping from live client data. + /// This should be called during the game to get all player identities. + pub async fn fetch_all_players_identities(&self) -> Result> { + let mut players = Vec::new(); + + // Try live client data first (available during game) + if let Ok(player_list) = self.get_live_client_player_list().await { + if let Some(arr) = player_list.as_array() { + for player in arr { + // Get summoner name - try multiple fields + let summoner_name = player.get("summonerName") + .or_else(|| player.get("riotId")) + .and_then(|n| n.as_str()) + .unwrap_or(""); + + // Get team from teamId + let team = player.get("team").and_then(|t| t.as_u64()).map(|v| v as u32); + + // Get champion name + let champion_name = player.get("championName") + .and_then(|n| n.as_str()) + .map(|s| s.to_string()); + + if let Some(puuid) = player.get("puuid").and_then(|p| p.as_str()) { + players.push(super::PlayerIdentity { + puuid: puuid.to_string(), + summoner_name: summoner_name.to_string(), + summoner_id: player.get("summonerId").and_then(|id| id.as_u64()), + champion_name, + team, + }); + } + } + } + } + + // Fallback: try from gameflow session + if players.is_empty() { + if let Ok(session) = self.get_session().await { + if let Some(game_data) = session.get("gameData") { + for team_key in &["teamOne", "teamTwo"] { + let team_id = if *team_key == "teamOne" { 100u32 } else { 200u32 }; + if let Some(team) = game_data.get(team_key).and_then(|t| t.as_array()) { + for player in team { + if let (Some(puuid), Some(summoner_name)) = ( + player.get("puuid").and_then(|p| p.as_str()), + player.get("summonerName").and_then(|n| n.as_str()), + ) { + let champion_id = player.get("championId").and_then(|id| id.as_u64()).map(|v| v as u32); + let champion_name = champion_id.and_then(|id| champion_id_to_name(id)); + players.push(super::PlayerIdentity { + puuid: puuid.to_string(), + summoner_name: summoner_name.to_string(), + summoner_id: player.get("summonerId").and_then(|id| id.as_u64()), + champion_name, + team: Some(team_id), + }); + } + } + } + } + } + } + } + + Ok(players) + } + + /// Fetch final items from end-of-game stats or live client data. + pub async fn fetch_final_items(&self) -> Result> { + info!("[ITEMS] Fetching final items..."); + + // First try live client data (available during game) + match self.get_live_client_player_list().await { + Ok(player_list) => { + info!("[ITEMS] Live client player list response: {:?}", player_list); + if let Some(players) = player_list.as_array() { + info!("[ITEMS] Found {} players in live client data", players.len()); + // Find the local player (first player or one with matching puuid) + for player in players { + // Check if this is the local player + let is_local = player.get("isLocalPlayer").and_then(|l| l.as_bool()).unwrap_or(false); + if is_local { + info!("[ITEMS] Found local player in live client data"); + if let Some(items) = player.get("items").and_then(|i| i.as_array()) { + info!("[ITEMS] Items array has {} items", items.len()); + let item_build = self.parse_items_from_live_client(items); + if item_build.is_some() { + info!("[ITEMS] Successfully parsed items from live client data"); + return Ok(item_build); + } + } else { + info!("[ITEMS] No items array found for local player"); + } + } + } + } + } + Err(e) => { + info!("[ITEMS] Failed to get live client player list: {:?}", e); + } + } + + // Fallback: try end-of-game stats + match self.get_game_stats().await { + Ok(stats) => { + info!("[ITEMS] Game stats response received"); + + // First try localPlayer field (items are just numbers in an array) + if let Some(local_player) = stats.get("localPlayer") { + info!("[ITEMS] Found localPlayer in game stats"); + if let Some(items) = local_player.get("items").and_then(|i| i.as_array()) { + info!("[ITEMS] localPlayer.items array has {} items", items.len()); + let item_build = self.parse_items_from_game_stats(items); + if item_build.is_some() { + info!("[ITEMS] Successfully parsed items from localPlayer"); + return Ok(item_build); + } + } + } + + // Try teams[].players[] structure + if let Some(teams) = stats.get("teams").and_then(|t| t.as_array()) { + info!("[ITEMS] Found {} teams in game stats", teams.len()); + for team in teams { + if let Some(players) = team.get("players").and_then(|p| p.as_array()) { + for player in players { + let is_local = player.get("isLocalPlayer").and_then(|l| l.as_bool()).unwrap_or(false); + if is_local { + info!("[ITEMS] Found local player in teams[].players[]"); + if let Some(items) = player.get("items").and_then(|i| i.as_array()) { + info!("[ITEMS] Player items array has {} items", items.len()); + let item_build = self.parse_items_from_game_stats(items); + if item_build.is_some() { + info!("[ITEMS] Successfully parsed items from teams[].players[]"); + return Ok(item_build); + } + } + } + } + } + } + } + + // Try old players array structure (for backwards compatibility) + if let Some(players) = stats.get("players").and_then(|p| p.as_array()) { + info!("[ITEMS] Found {} players in game stats (legacy)", players.len()); + if let Some(player) = players.first() { + if let Some(items) = player.get("items").and_then(|i| i.as_array()) { + let item_build = self.parse_items_from_game_stats(items); + if item_build.is_some() { + return Ok(item_build); + } + } + } + } + + info!("[ITEMS] Could not find items in game stats structure"); + } + Err(e) => { + info!("[ITEMS] Failed to get game stats: {:?}", e); + } + } + + info!("[ITEMS] Could not fetch final items from any source"); + Ok(None) + } + + /// Parse items from live client data format. + fn parse_items_from_live_client(&self, items: &[serde_json::Value]) -> Option { + let mut item_list = Vec::new(); + let mut trinket = None; + + for (slot, item) in items.iter().enumerate() { + if let Some(item_id) = item.get("itemID").and_then(|id| id.as_u64()) { + if item_id > 0 { + let item_info = ItemInfo { + item_id: item_id as u32, + name: item.get("displayName").and_then(|n| n.as_str()).map(|s| s.to_string()), + slot: slot as u32, + }; + + // Slot 6 is typically the trinket + if slot == 6 { + trinket = Some(item_info); + } else { + item_list.push(item_info); + } + } + } + } + + if !item_list.is_empty() || trinket.is_some() { + Some(ItemBuild { + items: item_list, + trinket, + }) + } else { + None + } + } + + /// Parse items from game stats format (array of item IDs as numbers). + fn parse_items_from_game_stats(&self, items: &[serde_json::Value]) -> Option { + let mut item_list = Vec::new(); + let mut trinket = None; + + for (slot, item) in items.iter().enumerate() { + // Items in game stats are just numbers (item IDs), not objects + if let Some(item_id) = item.as_u64() { + if item_id > 0 { + let item_info = ItemInfo { + item_id: item_id as u32, + name: None, // Item names would need a separate mapping + slot: slot as u32, + }; + + // Slot 6 is typically the trinket + if slot == 6 { + trinket = Some(item_info); + } else { + item_list.push(item_info); + } + } + } + } + + if !item_list.is_empty() || trinket.is_some() { + Some(ItemBuild { + items: item_list, + trinket, + }) + } else { + None + } + } +} + +/// Convert summoner spell ID to name. +pub fn spell_id_to_name(id: u32) -> Option { + let name = match id { + 1 => "Cleanse", + 3 => "Exhaust", + 4 => "Flash", + 6 => "Ghost", + 7 => "Heal", + 11 => "Smite", + 12 => "Teleport", + 13 => "Clarity", + 14 => "Ignite", + 21 => "Barrier", + 32 => "Mark", + 39 => "Mark", + 54 => "Placeholder", + 55 => "Placeholder", + _ => return None, + }; + Some(name.to_string()) } /// Convert champion ID to champion name. diff --git a/record-daemon/src/lqp/events.rs b/record-daemon/src/lqp/events.rs index 2f819f9..4ed10f4 100644 --- a/record-daemon/src/lqp/events.rs +++ b/record-daemon/src/lqp/events.rs @@ -517,6 +517,136 @@ mod tests { } } +// ============================================================================ +// Player Metadata Structures (Runes, Summoner Spells, Items) +// ============================================================================ + +/// Rune slot information +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RuneSlot { + /// Rune ID + pub rune_id: u32, + /// Slot index + pub slot: u32, +} + +/// Rune page configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RunePage { + /// Primary rune style (keystone tree) + pub primary_style_id: u32, + /// Secondary rune style + pub secondary_style_id: u32, + /// Selected runes + pub selected_perks: Vec, + /// Stat modifiers (flex, offense, defense) + #[serde(default)] + pub stat_modifiers: Vec, + /// Rune page name + #[serde(default)] + pub name: Option, + /// Whether this is the current page + #[serde(default)] + pub current: bool, +} + +/// Summoner spell information +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SummonerSpells { + /// First summoner spell ID + pub spell1_id: u32, + /// Second summoner spell ID + pub spell2_id: u32, + /// First summoner spell name (resolved) + #[serde(default)] + pub spell1_name: Option, + /// Second summoner spell name (resolved) + #[serde(default)] + pub spell2_name: Option, +} + +/// Item information at game end +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ItemBuild { + /// Final items (6 item slots) + pub items: Vec, + /// Trinket item + #[serde(default)] + pub trinket: Option, +} + +/// Individual item information +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ItemInfo { + /// Item ID + pub item_id: u32, + /// Item name (resolved) + #[serde(default)] + pub name: Option, + /// Slot index (0-5 for main items, 6 for trinket) + pub slot: u32, +} + +/// Complete player metadata captured at game start +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct PlayerGameMetadata { + /// Player's PUUID + #[serde(default)] + pub puuid: Option, + /// Player's summoner name + #[serde(default)] + pub summoner_name: Option, + /// Champion ID + #[serde(default)] + pub champion_id: Option, + /// Champion name + #[serde(default)] + pub champion_name: Option, + /// Skin ID + #[serde(default)] + pub skin_id: Option, + /// Skin name + #[serde(default)] + pub skin_name: Option, + /// Team (100 = blue, 200 = red) + #[serde(default)] + pub team: Option, + /// Selected rune page + #[serde(default)] + pub runes: Option, + /// Summoner spells + #[serde(default)] + pub summoner_spells: Option, + /// Final item build (captured at game end) + #[serde(default)] + pub final_items: Option, +} + +/// Player information for mapping puuid to summoner name +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PlayerIdentity { + /// Player's PUUID + pub puuid: String, + /// Player's summoner name + pub summoner_name: String, + /// Player's summoner ID + #[serde(default)] + pub summoner_id: Option, + /// Player's champion name + #[serde(default)] + pub champion_name: Option, + /// Player's team (100 or 200) + #[serde(default)] + pub team: Option, +} + // ============================================================================ // GameFlow Session Data Structures // ============================================================================ diff --git a/record-daemon/src/lqp/mod.rs b/record-daemon/src/lqp/mod.rs index 5f3aa67..a7df3e7 100644 --- a/record-daemon/src/lqp/mod.rs +++ b/record-daemon/src/lqp/mod.rs @@ -8,9 +8,10 @@ mod client; mod events; pub use auth::{LockfileCredentials, LockfileWatcher}; -pub use client::{champion_id_to_name, GameEndMetadata, GameflowPhase, LqpClient, PreGameMetadata}; +pub use client::{champion_id_to_name, spell_id_to_name, GameEndMetadata, GameflowPhase, LqpClient, PreGameMetadata}; pub use events::{ ChampSelectStartInfo, ChampionPickInfo, DeathEvent, EventData, GameEndInfo, GameEvent, - GameStartInfo, GameflowSession, InGameStats, KillEvent, MatchInfo, ObjectiveEvent, - ObjectiveType, PlayerChampionSelection, QueueInfo, TeamMember, + GameStartInfo, GameflowSession, InGameStats, ItemBuild, ItemInfo, KillEvent, MatchInfo, + ObjectiveEvent, ObjectiveType, PlayerChampionSelection, PlayerGameMetadata, PlayerIdentity, + QueueInfo, RunePage, RuneSlot, SummonerSpells, TeamMember, }; diff --git a/record-daemon/src/main.rs b/record-daemon/src/main.rs index caecaa9..5b7658c 100644 --- a/record-daemon/src/main.rs +++ b/record-daemon/src/main.rs @@ -388,11 +388,18 @@ impl Daemon { map_name, team, summoner_name, + puuid: transition_puuid, + runes: transition_runes, + summoner_spells: transition_summoner_spells, } => { info!( "[EVENT_HANDLER] GameStarted transition - game_id: {}, champion: {:?}, queue_type: {:?}, game_mode: {:?}", game_id, champion, queue_type, game_mode ); + info!( + "[EVENT_HANDLER] Transition provided: puuid={:?}, runes={:?}, summoner_spells={:?}", + transition_puuid, transition_runes, transition_summoner_spells + ); // If already recording, stop the current recording first if self.state_machine.is_recording() { @@ -435,6 +442,35 @@ impl Daemon { }) }); + // Fetch player game metadata (runes, summoner spells, puuid) as fallback + let player_metadata = self.lqp_client.fetch_player_game_metadata().await.ok(); + let (fetched_puuid, fetched_runes, fetched_summoner_spells) = + player_metadata.map_or((None, None, None), |m| { + (m.puuid, m.runes, m.summoner_spells) + }); + + // Use transition values first, then fall back to fetched values + let final_puuid = transition_puuid.or(fetched_puuid); + let final_runes = transition_runes.or(fetched_runes); + let final_summoner_spells = transition_summoner_spells.or(fetched_summoner_spells); + + info!( + "[EVENT_HANDLER] Final values: puuid={:?}, runes={:?}, summoner_spells={:?}", + final_puuid, final_runes, final_summoner_spells + ); + + // Fetch all players identities for puuid mapping + let all_players_identities = self.lqp_client.fetch_all_players_identities().await.ok(); + let all_players_info: Vec = + all_players_identities.unwrap_or_default().into_iter().map(|p| { + record_daemon::timeline::PlayerIdentityInfo { + puuid: p.puuid, + summoner_name: p.summoner_name, + champion_name: p.champion_name, + team: p.team, + } + }).collect(); + // Build game metadata for timeline let metadata_update = record_daemon::timeline::MetadataUpdate { queue_type: queue_type.clone(), @@ -443,6 +479,10 @@ impl Daemon { map_name: map_name.clone(), team, summoner_name: summoner_name.clone(), + puuid: final_puuid, + runes: final_runes, + summoner_spells: final_summoner_spells, + all_players: all_players_info, ..Default::default() }; @@ -461,12 +501,15 @@ impl Daemon { info!("[EVENT_HANDLER] start_recording completed successfully"); } } - StateTransition::GameEnded { game_end_info } => { + StateTransition::GameEnded { game_end_info, final_items: _ } => { info!( "[EVENT_HANDLER] GameEnded transition with info: {:?}", game_end_info ); + // Fetch final items before stopping + let fetched_final_items = self.lqp_client.fetch_final_items().await.ok().flatten(); + // Convert GameEndInfo to GameEndMetadata if available let game_end_metadata = game_end_info.map(|info| record_daemon::lqp::GameEndMetadata { @@ -508,7 +551,7 @@ impl Daemon { game_end_metadata ); - if let Err(e) = self.stop_recording_with_metadata(game_end_metadata).await { + if let Err(e) = self.stop_recording_with_metadata(game_end_metadata, fetched_final_items).await { error!("[EVENT_HANDLER] Failed to stop recording: {}", e); // Don't propagate error - keep daemon running @@ -598,13 +641,14 @@ impl Daemon { /// Stop recording. async fn stop_recording(&self) -> Result<()> { - self.stop_recording_with_metadata(None).await + self.stop_recording_with_metadata(None, None).await } /// Stop recording with optional game end metadata. async fn stop_recording_with_metadata( &self, game_end_metadata: Option, + final_items: Option, ) -> Result<()> { info!("Stopping recording"); @@ -673,6 +717,9 @@ impl Daemon { }); } + // Add final items + update.final_items = final_items; + // Apply the update if let Err(e) = timeline_store.write().update_metadata(recording_id, update) { warn!("Failed to update recording metadata: {}", e); diff --git a/record-daemon/src/state/machine.rs b/record-daemon/src/state/machine.rs index 5899c22..287d68a 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::{GameEndInfo, GameEvent, GameflowPhase}; +use crate::lqp::{GameEndInfo, GameEvent, GameflowPhase, ItemBuild, RunePage, SummonerSpells}; /// Internal daemon state. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -52,11 +52,19 @@ pub enum StateTransition { map_name: Option, team: Option, summoner_name: Option, + /// Player's PUUID + puuid: Option, + /// Rune page at game start + runes: Option, + /// Summoner spells + summoner_spells: Option, }, /// Game ended. GameEnded { /// Game end info with stats from WebSocket. game_end_info: Option, + /// Final items + final_items: Option, }, /// Error occurred. Error(String), @@ -224,18 +232,41 @@ impl DaemonStateMachine { ); match event { - 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::GameStart(info) => { + // Extract summoner spells from session data if available + let summoner_spells = info.session.as_ref().and_then(|session| { + session.player_champion_selections.first().map(|selection| { + SummonerSpells { + spell1_id: selection.spell1_id, + spell2_id: selection.spell2_id, + spell1_name: crate::lqp::spell_id_to_name(selection.spell1_id), + spell2_name: crate::lqp::spell_id_to_name(selection.spell2_id), + } + }) + }); + + // Extract puuid from session data if available + let puuid = info.session.as_ref().and_then(|session| { + session.player_champion_selections.first().map(|selection| selection.puuid.clone()) + }); + + 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(), + puuid, + runes: None, // Will be populated from client.fetch_player_game_metadata() + summoner_spells, + }) + } GameEvent::GameEnd(info) => Some(StateTransition::GameEnded { game_end_info: Some(info.clone()), + final_items: None, // Will be populated from client.fetch_final_items() }), GameEvent::PhaseChange(info) => { // Only trigger GameEnded on EndOfGame phase (stats are available by then) @@ -243,6 +274,7 @@ impl DaemonStateMachine { if info.phase == "EndOfGame" && self.is_recording() { Some(StateTransition::GameEnded { game_end_info: None, + final_items: None, }) } else { None @@ -297,6 +329,9 @@ mod tests { map_name: None, team: None, summoner_name: None, + puuid: None, + runes: None, + summoner_spells: None, }); assert_eq!(new_state, Some(DaemonState::Recording)); @@ -318,6 +353,9 @@ mod tests { map_name: None, team: None, summoner_name: None, + puuid: None, + runes: None, + summoner_spells: None, }); assert_eq!(result, None); diff --git a/record-daemon/src/timeline/mod.rs b/record-daemon/src/timeline/mod.rs index b634e1a..fab6550 100644 --- a/record-daemon/src/timeline/mod.rs +++ b/record-daemon/src/timeline/mod.rs @@ -5,14 +5,15 @@ mod store; pub use mapper::EventMapper; pub use store::{ - GameFinalStats, MetadataUpdate, RecordingMetadata, TimelineStore, TimestampedEvent, + GameFinalStats, MetadataUpdate, PlayerIdentityInfo, RecordingMetadata, TimelineStore, + TimestampedEvent, }; use chrono::{DateTime, Duration, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::lqp::GameEvent; +use crate::lqp::{GameEvent, ItemBuild, RunePage, SummonerSpells}; /// A timeline of events for a recording. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -48,6 +49,9 @@ pub struct Timeline { /// Summoner name. #[serde(default)] pub summoner_name: Option, + /// Player's PUUID. + #[serde(default)] + pub puuid: Option, /// Team (100 = blue, 200 = red). #[serde(default)] pub team: Option, @@ -57,6 +61,18 @@ pub struct Timeline { /// Final player stats. #[serde(default)] pub final_stats: Option, + /// Rune page at game start. + #[serde(default)] + pub runes: Option, + /// Summoner spells. + #[serde(default)] + pub summoner_spells: Option, + /// Final item build at game end. + #[serde(default)] + pub final_items: Option, + /// All players in the game (puuid to summoner name mapping). + #[serde(default)] + pub all_players: Vec, } impl Timeline { @@ -82,9 +98,14 @@ impl Timeline { game_mode: None, map_name: None, summoner_name: None, + puuid: None, team: None, victory: None, final_stats: None, + runes: None, + summoner_spells: None, + final_items: None, + all_players: Vec::new(), } } diff --git a/record-daemon/src/timeline/store.rs b/record-daemon/src/timeline/store.rs index d9d4cf4..775649f 100644 --- a/record-daemon/src/timeline/store.rs +++ b/record-daemon/src/timeline/store.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::error::{Result, TimelineError}; -use crate::lqp::GameEvent; +use crate::lqp::{GameEvent, ItemBuild, RunePage, SummonerSpells}; use crate::recording::RecordingResult; /// A timestamped event in the timeline. @@ -54,12 +54,27 @@ pub struct RecordingMetadata { pub map_name: Option, /// Player's summoner name. pub summoner_name: Option, + /// Player's PUUID. + #[serde(default)] + pub puuid: Option, /// Team (100 = blue, 200 = red). pub team: Option, /// Whether the game was won. pub victory: Option, /// Final player stats. pub final_stats: Option, + /// Player's rune page at game start. + #[serde(default)] + pub runes: Option, + /// Player's summoner spells. + #[serde(default)] + pub summoner_spells: Option, + /// Final item build at game end. + #[serde(default)] + pub final_items: Option, + /// All players in the game (puuid -> summoner name mapping). + #[serde(default)] + pub all_players: Vec, /// Recording start time. pub start_time: DateTime, /// Recording end time. @@ -77,6 +92,22 @@ pub struct RecordingMetadata { pub finalized: bool, } +/// Player identity information for puuid to summoner name mapping. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PlayerIdentityInfo { + /// Player's PUUID. + pub puuid: String, + /// Player's summoner name. + pub summoner_name: String, + /// Player's champion name. + #[serde(default)] + pub champion_name: Option, + /// Player's team (100 or 200). + #[serde(default)] + pub team: Option, +} + /// Final game statistics for the player. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GameFinalStats { @@ -111,9 +142,14 @@ pub struct MetadataUpdate { pub game_mode: Option, pub map_name: Option, pub summoner_name: Option, + pub puuid: Option, pub team: Option, pub victory: Option, pub final_stats: Option, + pub runes: Option, + pub summoner_spells: Option, + pub final_items: Option, + pub all_players: Vec, } impl RecordingMetadata { @@ -130,9 +166,14 @@ impl RecordingMetadata { game_mode: None, map_name: None, summoner_name: None, + puuid: None, team: None, victory: None, final_stats: None, + runes: None, + summoner_spells: None, + final_items: None, + all_players: Vec::new(), start_time: result.start_time, end_time: Some(result.end_time), duration: result.duration, @@ -192,9 +233,14 @@ impl TimelineStore { game_mode: None, map_name: None, summoner_name: None, + puuid: None, team: None, victory: None, final_stats: None, + runes: None, + summoner_spells: None, + final_items: None, + all_players: Vec::new(), start_time: Utc::now(), end_time: None, duration: Duration::zero(), @@ -248,9 +294,14 @@ impl TimelineStore { game_mode: None, map_name: None, summoner_name: None, + puuid: None, team: None, victory: None, final_stats: None, + runes: None, + summoner_spells: None, + final_items: None, + all_players: Vec::new(), start_time: result.start_time, end_time: Some(result.end_time), duration: result.duration, @@ -317,6 +368,9 @@ impl TimelineStore { if let Some(summoner_name) = update.summoner_name { metadata.summoner_name = Some(summoner_name); } + if let Some(puuid) = update.puuid { + metadata.puuid = Some(puuid); + } if let Some(team) = update.team { metadata.team = Some(team); } @@ -326,6 +380,18 @@ impl TimelineStore { if let Some(final_stats) = update.final_stats { metadata.final_stats = Some(final_stats); } + if let Some(runes) = update.runes { + metadata.runes = Some(runes); + } + if let Some(summoner_spells) = update.summoner_spells { + metadata.summoner_spells = Some(summoner_spells); + } + if let Some(final_items) = update.final_items { + metadata.final_items = Some(final_items); + } + if !update.all_players.is_empty() { + metadata.all_players = update.all_players; + } } drop(recordings); self.persist_recording(recording_id)?; @@ -370,9 +436,14 @@ impl TimelineStore { game_mode: metadata.game_mode.clone(), map_name: metadata.map_name.clone(), summoner_name: metadata.summoner_name.clone(), + puuid: metadata.puuid.clone(), team: metadata.team, victory: metadata.victory, final_stats: metadata.final_stats.clone(), + runes: metadata.runes.clone(), + summoner_spells: metadata.summoner_spells.clone(), + final_items: metadata.final_items.clone(), + all_players: metadata.all_players.clone(), }) } @@ -416,9 +487,14 @@ impl TimelineStore { game_mode: metadata.game_mode, map_name: metadata.map_name, summoner_name: metadata.summoner_name, + puuid: metadata.puuid, team: metadata.team, victory: metadata.victory, final_stats: metadata.final_stats, + runes: metadata.runes, + summoner_spells: metadata.summoner_spells, + final_items: metadata.final_items, + all_players: metadata.all_players, }; let file_path = self.storage_dir.join(format!("{}.json", id)); @@ -453,9 +529,14 @@ impl TimelineStore { game_mode: None, map_name: None, summoner_name: None, + puuid: None, team: None, victory: None, final_stats: None, + runes: None, + summoner_spells: None, + final_items: None, + all_players: Vec::new(), start_time: timeline.start_time, end_time: timeline.end_time, duration: timeline.duration(),