diff --git a/record-daemon/src/lqp/client.rs b/record-daemon/src/lqp/client.rs index 39e391c..619d72d 100644 --- a/record-daemon/src/lqp/client.rs +++ b/record-daemon/src/lqp/client.rs @@ -11,7 +11,8 @@ use tracing::{debug, error, info, trace, warn}; use super::auth::LockfileCredentials; use super::endpoints; -use super::events::{GameEvent, ItemBuild, ItemInfo}; +use super::events::{GameEvent, ItemBuild}; +use super::items::{parse_items_from_game_stats, parse_items_from_live_client}; use super::mappings::{champion_id_to_name, spell_id_to_name}; use super::metadata::{GameEndMetadata, PreGameMetadata}; use super::state::{ClientState, GameflowPhase}; @@ -303,6 +304,10 @@ impl LqpClient { } } + // ========================================================================= + // REST API Methods + // ========================================================================= + /// Make a REST API request to the League Client. pub async fn request(&self, method: &str, endpoint: &str) -> Result { let creds = self @@ -351,11 +356,9 @@ impl LqpClient { /// Get the current gameflow phase. pub async fn get_gameflow_phase(&self) -> Result { let json = self.request("GET", endpoints::GAMEFLOW_PHASE).await?; - let phase_str = json .as_str() .ok_or_else(|| LqpError::EventParseError("Invalid phase response".to_string()))?; - Ok(GameflowPhase::from(phase_str)) } @@ -410,7 +413,7 @@ impl LqpClient { .await } - /// Get player list from live client (contains all players with puuid and summoner names). + /// Get player list from live client. pub async fn get_live_client_player_list(&self) -> Result { self.request("GET", endpoints::LIVE_CLIENT_DATA_PLAYER_LIST) .await @@ -422,6 +425,10 @@ impl LqpClient { .await } + // ========================================================================= + // Metadata Fetching Methods + // ========================================================================= + /// Fetch pre-game metadata (champion, skin, runes, queue info). pub async fn fetch_pregame_metadata(&self) -> Result { let mut metadata = PreGameMetadata::default(); @@ -444,17 +451,14 @@ impl LqpClient { 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()); + self.state.write().await.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()) @@ -496,14 +500,11 @@ impl LqpClient { 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 = @@ -540,12 +541,10 @@ impl LqpClient { } } - // 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()); } @@ -555,19 +554,17 @@ impl LqpClient { } /// 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 + // Get summoner info 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")) @@ -601,7 +598,7 @@ impl LqpClient { primary_style_id, secondary_style_id, selected_perks, - stat_modifiers: Vec::new(), // Stat modifiers are part of selectedPerkIds + stat_modifiers: Vec::new(), name: rune_page .get("name") .and_then(|n| n.as_str()) @@ -614,22 +611,19 @@ impl LqpClient { } } - // Try to get summoner spells from live client data (available during game) + // Get summoner spells from live client data 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()) @@ -641,18 +635,12 @@ impl LqpClient { .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, @@ -663,7 +651,6 @@ impl LqpClient { } } - // Get summoner name from active player if not already set if metadata.summoner_name.is_none() || metadata.summoner_name.as_ref().is_none_or(|n| n.is_empty()) { @@ -680,9 +667,7 @@ impl LqpClient { 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 { @@ -727,7 +712,6 @@ impl LqpClient { } } - // 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); } @@ -735,29 +719,25 @@ impl LqpClient { 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. + /// Fetch all players' puuid to summoner name mapping. pub async fn fetch_all_players_identities(&self) -> Result> { let mut players = Vec::new(); - // Try live client data first (available during game) + // Try live client data first 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()) @@ -818,10 +798,10 @@ impl LqpClient { } /// Fetch final items from end-of-game stats or live client data. - pub async fn fetch_final_items(&self) -> Result> { + pub async fn fetch_final_items(&self) -> Result> { info!("[ITEMS] Fetching final items..."); - // First try live client data (available during game) + // First try live client data match self.get_live_client_player_list().await { Ok(player_list) => { info!( @@ -833,9 +813,7 @@ impl LqpClient { "[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()) @@ -844,15 +822,13 @@ impl LqpClient { 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); + let item_build = 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"); } } } @@ -868,12 +844,11 @@ impl LqpClient { 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); + let item_build = parse_items_from_game_stats(items); if item_build.is_some() { info!("[ITEMS] Successfully parsed items from localPlayer"); return Ok(item_build); @@ -881,7 +856,6 @@ impl LqpClient { } } - // 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 { @@ -900,7 +874,7 @@ impl LqpClient { "[ITEMS] Player items array has {} items", items.len() ); - let item_build = Self::parse_items_from_game_stats(items); + let item_build = parse_items_from_game_stats(items); if item_build.is_some() { info!("[ITEMS] Successfully parsed items from teams[].players[]"); return Ok(item_build); @@ -912,7 +886,6 @@ impl LqpClient { } } - // 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)", @@ -920,7 +893,7 @@ impl LqpClient { ); 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); + let item_build = parse_items_from_game_stats(items); if item_build.is_some() { return Ok(item_build); } @@ -938,78 +911,6 @@ impl LqpClient { 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(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(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 - } - } } impl Default for LqpClient { diff --git a/record-daemon/src/lqp/items.rs b/record-daemon/src/lqp/items.rs new file mode 100644 index 0000000..93ae925 --- /dev/null +++ b/record-daemon/src/lqp/items.rs @@ -0,0 +1,148 @@ +//! Item parsing utilities for LQP client. +//! +//! Provides functions for parsing item builds from different data formats. + +use super::events::{ItemBuild, ItemInfo}; + +/// Parse items from live client data format. +/// +/// Live client data items are objects with `itemID` and `displayName` fields. +pub fn parse_items_from_live_client(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. +/// +/// Game stats items are just numbers (item IDs) in an array. +pub fn parse_items_from_game_stats(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 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_items_from_live_client_empty() { + let items = vec![]; + let result = parse_items_from_live_client(&items); + assert!(result.is_none()); + } + + #[test] + fn test_parse_items_from_live_client_with_items() { + let items = vec![ + serde_json::json!({"itemID": 1001, "displayName": "Boots"}), + serde_json::json!({"itemID": 0, "displayName": ""}), + ]; + let result = parse_items_from_live_client(&items); + assert!(result.is_some()); + let build = result.unwrap(); + assert_eq!(build.items.len(), 1); + assert_eq!(build.items[0].item_id, 1001); + } + + #[test] + fn test_parse_items_from_game_stats_empty() { + let items = vec![]; + let result = parse_items_from_game_stats(&items); + assert!(result.is_none()); + } + + #[test] + fn test_parse_items_from_game_stats_with_items() { + let items = vec![ + serde_json::json!(1001), + serde_json::json!(0), + serde_json::json!(3020), + ]; + let result = parse_items_from_game_stats(&items); + assert!(result.is_some()); + let build = result.unwrap(); + assert_eq!(build.items.len(), 2); + assert_eq!(build.items[0].item_id, 1001); + assert_eq!(build.items[1].item_id, 3020); + } + + #[test] + fn test_parse_items_with_trinket() { + // Slot 6 is trinket + let items = vec![ + serde_json::json!(1001), + serde_json::json!(0), + serde_json::json!(0), + serde_json::json!(0), + serde_json::json!(0), + serde_json::json!(0), + serde_json::json!(3340), // Trinket in slot 6 + ]; + let result = parse_items_from_game_stats(&items); + assert!(result.is_some()); + let build = result.unwrap(); + assert_eq!(build.items.len(), 1); + assert!(build.trinket.is_some()); + assert_eq!(build.trinket.unwrap().item_id, 3340); + } +} diff --git a/record-daemon/src/lqp/mod.rs b/record-daemon/src/lqp/mod.rs index 33446a7..fce40a3 100644 --- a/record-daemon/src/lqp/mod.rs +++ b/record-daemon/src/lqp/mod.rs @@ -7,6 +7,7 @@ mod auth; mod client; mod endpoints; mod events; +mod items; mod mappings; mod metadata; mod state; @@ -27,6 +28,7 @@ pub use events::{ ObjectiveEvent, ObjectiveType, PlayerChampionSelection, PlayerGameMetadata, PlayerIdentity, QueueInfo, RunePage, RuneSlot, SummonerSpells, TeamMember, }; +pub use items::{parse_items_from_game_stats, parse_items_from_live_client}; pub use mappings::{champion_id_to_name, map_id_to_name, spell_id_to_name}; pub use metadata::{GameEndMetadata, PreGameMetadata}; pub use state::{ClientState, GameflowPhase};