From 7aa4bfbf646a82ac1c4f7c6734fd932b496ed646 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Wed, 25 Mar 2026 17:57:58 +0100 Subject: [PATCH] record-daemon: refactor client.rs, introducing typed structs for api types --- record-daemon/src/lqp/api_types.rs | 576 +++++++++++++++++++++++++++++ record-daemon/src/lqp/client.rs | 556 +++++++++++++--------------- record-daemon/src/lqp/mod.rs | 7 + 3 files changed, 844 insertions(+), 295 deletions(-) create mode 100644 record-daemon/src/lqp/api_types.rs diff --git a/record-daemon/src/lqp/api_types.rs b/record-daemon/src/lqp/api_types.rs new file mode 100644 index 0000000..286598d --- /dev/null +++ b/record-daemon/src/lqp/api_types.rs @@ -0,0 +1,576 @@ +//! API response types for League Client API. +//! +//! These structs map directly to the JSON responses from the LQP REST API, +//! allowing automatic deserialization via serde. + +use serde::{Deserialize, Serialize}; + +// ============================================================================= +// Summoner API Responses +// ============================================================================= + +/// Response from `/lol-summoner/v1/current-summoner` +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SummonerResponse { + /// Summoner ID. + #[serde(default)] + pub summoner_id: Option, + + /// Account ID. + #[serde(default)] + pub account_id: Option, + + /// PUUID (globally unique identifier). + #[serde(default)] + pub puuid: Option, + + /// Display name. + #[serde(default)] + pub display_name: Option, + + /// Internal name. + #[serde(default)] + pub internal_name: Option, + + /// Name (legacy field). + #[serde(default)] + pub name: Option, + + /// Profile icon ID. + #[serde(default)] + pub profile_icon_id: Option, + + /// Summoner level. + #[serde(default)] + pub summoner_level: Option, +} + +// ============================================================================= +// Gameflow Session API Responses +// ============================================================================= + +/// Response from `/lol-gameflow/v1/session` +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GameflowSessionResponse { + /// Current gameflow phase. + #[serde(default)] + pub phase: Option, + + /// Game data (present when in game). + #[serde(default)] + pub game_data: Option, + + /// Map name. + #[serde(default)] + pub map: Option, + + /// Game mode. + #[serde(default)] + pub game_mode: Option, + + /// Queue ID. + #[serde(default)] + pub queue_id: Option, +} + +/// Game data within a gameflow session. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GameData { + /// Game ID. + #[serde(default)] + pub game_id: Option, + + /// Queue information. + #[serde(default)] + pub queue: Option, + + /// Team one players. + #[serde(default)] + pub team_one: Option>, + + /// Team two players. + #[serde(default)] + pub team_two: Option>, +} + +/// Queue data within game data. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QueueData { + /// Queue ID. + #[serde(default)] + pub id: Option, + + /// Queue name. + #[serde(default)] + pub name: Option, + + /// Game mode. + #[serde(default)] + pub game_mode: Option, + + /// Map ID. + #[serde(default)] + pub map_id: Option, +} + +/// Player in a team. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TeamPlayer { + /// Player PUUID. + #[serde(default)] + pub puuid: Option, + + /// Summoner name. + #[serde(default)] + pub summoner_name: Option, + + /// Summoner ID. + #[serde(default)] + pub summoner_id: Option, + + /// Champion ID. + #[serde(default)] + pub champion_id: Option, + + /// Team ID. + #[serde(default)] + pub team_id: Option, + + /// First summoner spell ID. + #[serde(default)] + pub spell1_id: Option, + + /// Second summoner spell ID. + #[serde(default)] + pub spell2_id: Option, +} + +// ============================================================================= +// Champion Select API Responses +// ============================================================================= + +/// Response from `/lol-champ-select/v1/session` +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChampionSelectResponse { + /// Local player cell ID. + #[serde(default)] + pub local_player_cell_id: Option, + + /// Timer information. + #[serde(default)] + pub timers: Option, + + /// My team. + #[serde(default)] + pub my_team: Option>, + + /// Their team. + #[serde(default)] + pub their_team: Option>, +} + +/// Timer information in champion select. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Timers { + /// Current phase. + #[serde(default)] + pub phase: Option, +} + +/// Player in champion select. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChampionSelectPlayer { + /// Cell ID. + #[serde(default)] + pub cell_id: Option, + + /// Champion ID. + #[serde(default)] + pub champion_id: Option, + + /// Champion name. + #[serde(default)] + pub champion_name: Option, + + /// Team. + #[serde(default)] + pub team: Option, + + /// Skin ID. + #[serde(default)] + pub skin_id: Option, +} + +// ============================================================================= +// Rune Pages API Responses +// ============================================================================= + +/// Response from `/lol-perks/v1/currentpage` +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RunePageResponse { + /// Rune page ID. + #[serde(default)] + pub id: Option, + + /// Rune page name. + #[serde(default)] + pub name: Option, + + /// Whether this is the current page. + #[serde(default)] + pub current: Option, + + /// Primary style ID. + #[serde(default)] + pub primary_style_id: Option, + + /// Secondary style ID. + #[serde(default)] + pub sub_style_id: Option, + + /// Selected perk IDs. + #[serde(default)] + pub selected_perk_ids: Option>, +} + +// ============================================================================= +// End of Game Stats API Responses +// ============================================================================= + +/// Response from `/lol-end-of-game/v1/eog-stats-block` +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EndOfGameStatsResponse { + /// Game ID. + #[serde(default)] + pub game_id: Option, + + /// Game length in seconds. + #[serde(default)] + pub game_length: Option, + + /// Match ID. + #[serde(default)] + pub match_id: Option, + + /// Game result. + #[serde(default)] + pub game_result: Option, + + /// Local player data. + #[serde(default)] + pub local_player: Option, + + /// Teams data. + #[serde(default)] + pub teams: Option>, + + /// Players (legacy format). + #[serde(default)] + pub players: Option>, +} + +/// Player in end-of-game stats. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EndOfGamePlayer { + /// Whether this is the local player. + #[serde(default)] + pub is_local_player: Option, + + /// Player stats. + #[serde(default)] + pub stats: Option, + + /// Items. + #[serde(default)] + pub items: Option>, +} + +/// Team in end-of-game stats. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EndOfGameTeam { + /// Whether this is the player's team. + #[serde(default)] + pub is_player_team: Option, + + /// Whether this is the winning team. + #[serde(default)] + pub is_winning_team: Option, + + /// Players on the team. + #[serde(default)] + pub players: Option>, +} + +/// Player stats in end-of-game. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "UPPERCASE")] +pub struct PlayerStats { + /// Kills. + #[serde(default)] + pub champions_killed: Option, + + /// Deaths. + #[serde(default)] + pub num_deaths: Option, + + /// Assists. + #[serde(default)] + pub assists: Option, + + /// Minions killed (CS). + #[serde(default)] + pub minions_killed: Option, + + /// Gold earned. + #[serde(default)] + pub gold_earned: Option, + + /// Total damage dealt to champions. + #[serde(default)] + pub total_damage_dealt_to_champions: Option, + + /// Total damage taken. + #[serde(default)] + pub total_damage_taken: Option, + + /// Vision score. + #[serde(default)] + pub vision_score: Option, + + /// Win status (1 = win). + #[serde(default)] + pub win: Option, +} + +// ============================================================================= +// Live Client Data API Responses +// ============================================================================= + +/// Response from `/liveclientdata/activeplayer` +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ActivePlayerResponse { + /// Active player's summoner name. + #[serde(default)] + pub summoner_name: Option, + + /// Display name. + #[serde(default)] + pub display_name: Option, + + /// Riot ID. + #[serde(default)] + pub riot_id: Option, + + /// Summoner spells. + #[serde(default)] + pub summoner_spells: Option, +} + +/// Summoner spells data from live client. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SummonerSpellsData { + /// First summoner spell. + #[serde(default)] + pub summoner_spell_one: Option, + + /// Second summoner spell. + #[serde(default)] + pub summoner_spell_two: Option, + + /// First spell ID (alternative field). + #[serde(default)] + pub spell1_id: Option, + + /// Second spell ID (alternative field). + #[serde(default)] + pub spell2_id: Option, +} + +/// Individual spell data. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpellData { + /// Spell ID. + #[serde(default)] + pub spell_id: Option, + + /// Display name. + #[serde(default)] + pub display_name: Option, +} + +/// Response from `/liveclientdata/playerlist` +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PlayerListResponse(pub Vec); + +/// Player in live client data. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LiveClientPlayer { + /// Whether this is the local player. + #[serde(default)] + pub is_local_player: Option, + + /// Summoner name. + #[serde(default)] + pub summoner_name: Option, + + /// Riot ID. + #[serde(default)] + pub riot_id: Option, + + /// PUUID. + #[serde(default)] + pub puuid: Option, + + /// Summoner ID. + #[serde(default)] + pub summoner_id: Option, + + /// Champion name. + #[serde(default)] + pub champion_name: Option, + + /// Team. + #[serde(default)] + pub team: Option, + + /// Items. + #[serde(default)] + pub items: Option>, +} + +/// Item in live client data. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LiveClientItem { + /// Item ID. + #[serde(default)] + pub item_id: Option, + + /// Display name. + #[serde(default)] + pub display_name: Option, +} + +// ============================================================================= +// Helper implementations +// ============================================================================= + +impl EndOfGameStatsResponse { + /// Get the local player from the response. + pub fn get_local_player(&self) -> Option<&EndOfGamePlayer> { + // First check local_player field + if let Some(ref player) = self.local_player { + return Some(player); + } + + // Then check teams + if let Some(ref teams) = self.teams { + for team in teams { + if let Some(ref players) = team.players { + for player in players { + if player.is_local_player == Some(true) { + return Some(player); + } + } + } + } + } + + // Finally check legacy players array + if let Some(ref players) = self.players { + return players.first(); + } + + None + } + + /// Check if the local player won. + pub fn is_victory(&self) -> bool { + // Check local player stats + if let Some(player) = self.get_local_player() { + if let Some(ref stats) = player.stats { + if stats.win == Some(1) { + return true; + } + } + } + + // Check teams + if let Some(ref teams) = self.teams { + for team in teams { + if team.is_player_team == Some(true) && team.is_winning_team == Some(true) { + return true; + } + } + } + + false + } +} + +impl ChampionSelectResponse { + /// Get the local player's champion selection. + pub fn get_local_player_selection(&self) -> Option<&ChampionSelectPlayer> { + let cell_id = self.local_player_cell_id?; + + // Check my team first + if let Some(ref team) = self.my_team { + for player in team { + if player.cell_id == Some(cell_id) { + return Some(player); + } + } + } + + // Check their team + if let Some(ref team) = self.their_team { + for player in team { + if player.cell_id == Some(cell_id) { + return Some(player); + } + } + } + + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_summoner_deserialization() { + let json = r#"{"summonerId": 12345, "puuid": "abc-123", "displayName": "TestPlayer"}"#; + let summoner: SummonerResponse = serde_json::from_str(json).unwrap(); + assert_eq!(summoner.summoner_id, Some(12345)); + assert_eq!(summoner.puuid, Some("abc-123".to_string())); + assert_eq!(summoner.display_name, Some("TestPlayer".to_string())); + } + + #[test] + fn test_player_stats_deserialization() { + let json = r#"{"CHAMPIONS_KILLED": 10, "NUM_DEATHS": 3, "ASSISTS": 15}"#; + let stats: PlayerStats = serde_json::from_str(json).unwrap(); + assert_eq!(stats.champions_killed, Some(10)); + assert_eq!(stats.num_deaths, Some(3)); + assert_eq!(stats.assists, Some(15)); + } +} diff --git a/record-daemon/src/lqp/client.rs b/record-daemon/src/lqp/client.rs index 619d72d..edc71f1 100644 --- a/record-daemon/src/lqp/client.rs +++ b/record-daemon/src/lqp/client.rs @@ -5,10 +5,15 @@ use std::sync::Arc; use futures::{SinkExt, StreamExt}; +use serde::de::DeserializeOwned; use tokio::sync::{broadcast, RwLock}; use tokio_tungstenite::{connect_async_tls_with_config, tungstenite::protocol::Message}; use tracing::{debug, error, info, trace, warn}; +use super::api_types::{ + ActivePlayerResponse, ChampionSelectResponse, EndOfGameStatsResponse, GameflowSessionResponse, + PlayerListResponse, RunePageResponse, SummonerResponse, +}; use super::auth::LockfileCredentials; use super::endpoints; use super::events::{GameEvent, ItemBuild}; @@ -353,6 +358,13 @@ impl LqpClient { Ok(json) } + /// Make a typed REST API request to the League Client. + async fn request_typed(&self, method: &str, endpoint: &str) -> Result { + let json = self.request(method, endpoint).await?; + serde_json::from_value(json) + .map_err(|e| LqpError::EventParseError(format!("Deserialization failed: {}", e)).into()) + } + /// Get the current gameflow phase. pub async fn get_gameflow_phase(&self) -> Result { let json = self.request("GET", endpoints::GAMEFLOW_PHASE).await?; @@ -362,22 +374,42 @@ impl LqpClient { Ok(GameflowPhase::from(phase_str)) } - /// Get the current game session info. + /// Get the current game session info (typed). + pub async fn get_session_typed(&self) -> Result { + self.request_typed("GET", endpoints::SESSION).await + } + + /// Get the current game session info (raw JSON for backward compatibility). pub async fn get_session(&self) -> Result { self.request("GET", endpoints::SESSION).await } - /// Get current summoner info. + /// Get current summoner info (typed). + pub async fn get_summoner_typed(&self) -> Result { + self.request_typed("GET", endpoints::SUMMONER).await + } + + /// Get current summoner info (raw JSON for backward compatibility). pub async fn get_summoner(&self) -> Result { self.request("GET", endpoints::SUMMONER).await } - /// Get champion select session info. + /// Get champion select session info (typed). + pub async fn get_champion_select_typed(&self) -> Result { + self.request_typed("GET", endpoints::CHAMPION_SELECT).await + } + + /// Get champion select session info (raw JSON for backward compatibility). pub async fn get_champion_select(&self) -> Result { self.request("GET", endpoints::CHAMPION_SELECT).await } - /// Get end-of-game stats. + /// Get end-of-game stats (typed). + pub async fn get_game_stats_typed(&self) -> Result { + self.request_typed("GET", endpoints::GAME_STATS).await + } + + /// Get end-of-game stats (raw JSON for backward compatibility). pub async fn get_game_stats(&self) -> Result { self.request("GET", endpoints::GAME_STATS).await } @@ -387,7 +419,12 @@ impl LqpClient { self.request("GET", endpoints::CHAMPION_SUMMARY).await } - /// Get current rune page. + /// Get current rune page (typed). + pub async fn get_rune_page_typed(&self) -> Result { + self.request_typed("GET", endpoints::RUNE_PAGES).await + } + + /// Get current rune page (raw JSON for backward compatibility). pub async fn get_rune_page(&self) -> Result { self.request("GET", endpoints::RUNE_PAGES).await } @@ -407,13 +444,25 @@ impl LqpClient { self.request("GET", endpoints::LIVE_CLIENT_DATA).await } - /// Get active player data from live client. + /// Get active player data from live client (typed). + pub async fn get_live_client_active_player_typed(&self) -> Result { + self.request_typed("GET", endpoints::LIVE_CLIENT_DATA_ACTIVE_PLAYER) + .await + } + + /// Get active player data from live client (raw JSON for backward compatibility). 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. + /// Get player list from live client (typed). + pub async fn get_live_client_player_list_typed(&self) -> Result { + self.request_typed("GET", endpoints::LIVE_CLIENT_DATA_PLAYER_LIST) + .await + } + + /// Get player list from live client (raw JSON for backward compatibility). pub async fn get_live_client_player_list(&self) -> Result { self.request("GET", endpoints::LIVE_CLIENT_DATA_PLAYER_LIST) .await @@ -434,62 +483,33 @@ impl LqpClient { 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); - } + if let Ok(session) = self.get_session_typed().await { + metadata.map_name = session.map; + metadata.game_mode = session.game_mode; + metadata.queue_id = session.queue_id.map(|id| 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()); - } - if let Some(puuid) = summoner.get("puuid").and_then(|p| p.as_str()) { - metadata.local_puuid = Some(puuid.to_string()); - self.state.write().await.local_puuid = Some(puuid.to_string()); + if let Ok(summoner) = self.get_summoner_typed().await { + metadata.summoner_name = summoner.display_name; + if let Some(puuid) = &summoner.puuid { + self.state.write().await.local_puuid = Some(puuid.clone()); } + metadata.local_puuid = summoner.puuid; } // Get champion select info - if let Ok(champ_select) = self.get_champion_select().await { - 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); - } - } - } - } + if let Ok(champ_select) = self.get_champion_select_typed().await { + if let Some(player) = champ_select.get_local_player_selection() { + metadata.champion_id = player.champion_id.map(|id| id as u32); + metadata.team = player.team.map(|id| id as u32); + metadata.skin_id = player.skin_id.map(|id| 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()); - } + if let Ok(rune_page) = self.get_rune_page_typed().await { + metadata.rune_page_name = rune_page.name; } Ok(metadata) @@ -499,55 +519,25 @@ impl LqpClient { pub async fn fetch_game_end_stats(&self) -> Result { let mut metadata = GameEndMetadata::default(); - if let Ok(stats) = self.get_game_stats().await { - if let Some(victory) = stats.get("gameResult").and_then(|r| r.as_str()) { - metadata.victory = Some(victory == "WIN"); - } + if let Ok(stats) = self.get_game_stats_typed().await { + metadata.victory = Some(stats.is_victory()); + metadata.game_duration = stats.game_length.unwrap_or(0.0); + metadata.match_id = stats.match_id.map(|id| id.to_string()); - if let Some(players) = stats.get("players").and_then(|p| p.as_array()) { - 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); - } + // Extract player stats + if let Some(player) = stats.get_local_player() { + if let Some(player_stats) = &player.stats { + metadata.kills = player_stats.champions_killed.unwrap_or(0) as u32; + metadata.deaths = player_stats.num_deaths.unwrap_or(0) as u32; + metadata.assists = player_stats.assists.unwrap_or(0) as u32; + metadata.creep_score = player_stats.minions_killed.unwrap_or(0) as u32; + metadata.gold_earned = player_stats.gold_earned.unwrap_or(0) as u32; + metadata.damage_dealt = + player_stats.total_damage_dealt_to_champions.unwrap_or(0); + metadata.damage_taken = player_stats.total_damage_taken.unwrap_or(0); + metadata.vision_score = player_stats.vision_score.unwrap_or(0.0); } } - - if let Some(duration) = stats.get("gameLength").and_then(|d| d.as_f64()) { - metadata.game_duration = duration; - } - - if let Some(match_id) = stats.get("matchId").and_then(|id| id.as_u64()) { - metadata.match_id = Some(match_id.to_string()); - } } Ok(metadata) @@ -559,39 +549,25 @@ impl LqpClient { let mut metadata = super::PlayerGameMetadata::default(); - // 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()); + // Get summoner info (typed) + if let Ok(summoner) = self.get_summoner_typed().await { + metadata.puuid = summoner.puuid; 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()); + .display_name + .or(summoner.name) + .or(summoner.internal_name); } - // 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; + // Get rune page (typed) + if let Ok(rune_page) = self.get_rune_page_typed().await { + let primary_style_id = rune_page.primary_style_id.unwrap_or(0) as u32; + let secondary_style_id = rune_page.sub_style_id.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(); + .selected_perk_ids + .unwrap_or_default() + .iter() + .map(|id| *id as u32) + .collect(); if primary_style_id > 0 { metadata.runes = Some(RunePage { @@ -599,47 +575,30 @@ impl LqpClient { secondary_style_id, selected_perks, stat_modifiers: Vec::new(), - 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), + name: rune_page.name, + current: rune_page.current.unwrap_or(true), }); } } - // 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 live client data (typed) + if let Ok(active_player) = self.get_live_client_active_player_typed().await { + debug!("[METADATA] Live client active player data received"); - if let Some(summoner_spells) = active_player.get("summonerSpells") { - let spell1_id = summoner_spells - .get("summonerSpellOne") - .and_then(|s| s.get("spellId")) - .and_then(|id| id.as_u64()) - .unwrap_or_else(|| { - summoner_spells - .get("spell1Id") - .and_then(|id| id.as_u64()) - .unwrap_or(0) - }) as u32; + if let Some(ref spells) = active_player.summoner_spells { + let spell1_id = spells + .summoner_spell_one + .as_ref() + .and_then(|s| s.spell_id) + .or(spells.spell1_id) + .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(|| { - summoner_spells - .get("spell2Id") - .and_then(|id| id.as_u64()) - .unwrap_or(0) - }) as u32; + let spell2_id = spells + .summoner_spell_two + .as_ref() + .and_then(|s| s.spell_id) + .or(spells.spell2_id) + .unwrap_or(0) as u32; if spell1_id > 0 || spell2_id > 0 { metadata.summoner_spells = Some(SummonerSpells { @@ -655,35 +614,46 @@ impl LqpClient { || metadata.summoner_name.as_ref().is_none_or(|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()); + .summoner_name + .or(active_player.display_name) + .or(active_player.riot_id); } } - // Fallback: Get summoner spells from session gameData + // Fallback: Get summoner spells from session gameData (typed) if metadata.summoner_spells.is_none() { - if let Ok(session) = self.get_session().await { + if let Ok(session) = self.get_session_typed().await { if let Some(local_puuid) = &metadata.puuid { - if let Some(game_data) = session.get("gameData") { - for team_key in &["teamOne", "teamTwo"] { - if let Some(team) = game_data.get(team_key).and_then(|t| t.as_array()) { + if let Some(ref game_data) = session.game_data { + // Check team one + if let Some(ref team) = game_data.team_one { + for player in team { + if player.puuid.as_deref() == Some(local_puuid.as_str()) { + let spell1_id = player.spell1_id.unwrap_or(0) as u32; + let spell2_id = player.spell2_id.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.champion_id.map(|id| id as u32); + metadata.team = player.team_id.map(|id| id as u32); + break; + } + } + } + // Check team two if not found + if metadata.summoner_spells.is_none() { + if let Some(ref team) = game_data.team_two { 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 player.puuid.as_deref() == Some(local_puuid.as_str()) { + let spell1_id = player.spell1_id.unwrap_or(0) as u32; + let spell2_id = player.spell2_id.unwrap_or(0) as u32; if spell1_id > 0 || spell2_id > 0 { metadata.summoner_spells = Some(SummonerSpells { @@ -694,14 +664,9 @@ impl LqpClient { }); } - 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); + metadata.champion_id = + player.champion_id.map(|id| id as u32); + metadata.team = player.team_id.map(|id| id as u32); break; } } @@ -723,70 +688,64 @@ impl LqpClient { pub async fn fetch_all_players_identities(&self) -> Result> { let mut players = Vec::new(); - // 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 { - let summoner_name = player - .get("summonerName") - .or_else(|| player.get("riotId")) - .and_then(|n| n.as_str()) - .unwrap_or(""); + // Try live client data first (typed) + if let Ok(player_list) = self.get_live_client_player_list_typed().await { + for player in &player_list.0 { + let summoner_name = player + .summoner_name + .as_deref() + .or(player.riot_id.as_deref()) + .unwrap_or(""); - let team = player - .get("team") - .and_then(|t| t.as_u64()) - .map(|v| v as u32); - - 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, - }); - } + if let Some(ref puuid) = player.puuid { + players.push(super::PlayerIdentity { + puuid: puuid.clone(), + summoner_name: summoner_name.to_string(), + summoner_id: player.summoner_id, + champion_name: player.champion_name.clone(), + team: player.team.map(|id| id as u32), + }); } } } - // Fallback: try from gameflow session + // Fallback: try from gameflow session (typed) 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(champion_id_to_name); - 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), - }); - } + if let Ok(session) = self.get_session_typed().await { + if let Some(ref game_data) = session.game_data { + // Team one (team ID 100) + if let Some(ref team) = game_data.team_one { + for player in team { + if let (Some(ref puuid), Some(ref summoner_name)) = + (&player.puuid, &player.summoner_name) + { + let champion_id = player.champion_id.map(|id| id as u32); + let champion_name = champion_id.and_then(champion_id_to_name); + players.push(super::PlayerIdentity { + puuid: puuid.clone(), + summoner_name: summoner_name.clone(), + summoner_id: player.summoner_id, + champion_name, + team: Some(100), + }); + } + } + } + // Team two (team ID 200) + if let Some(ref team) = game_data.team_two { + for player in team { + if let (Some(ref puuid), Some(ref summoner_name)) = + (&player.puuid, &player.summoner_name) + { + let champion_id = player.champion_id.map(|id| id as u32); + let champion_name = champion_id.and_then(champion_id_to_name); + players.push(super::PlayerIdentity { + puuid: puuid.clone(), + summoner_name: summoner_name.clone(), + summoner_id: player.summoner_id, + champion_name, + team: Some(200), + }); } } } @@ -801,34 +760,31 @@ impl LqpClient { pub async fn fetch_final_items(&self) -> Result> { info!("[ITEMS] Fetching final items..."); - // First try live client data - match self.get_live_client_player_list().await { + // First try live client data (typed) + match self.get_live_client_player_list_typed().await { Ok(player_list) => { info!( - "[ITEMS] Live client player list response: {:?}", - player_list + "[ITEMS] Live client player list response received with {} players", + player_list.0.len() ); - if let Some(players) = player_list.as_array() { - info!( - "[ITEMS] Found {} players in live client data", - players.len() - ); - 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 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 = parse_items_from_live_client(items); - if item_build.is_some() { - info!( - "[ITEMS] Successfully parsed items from live client data" - ); - return Ok(item_build); - } + for player in &player_list.0 { + if player.is_local_player == Some(true) { + info!("[ITEMS] Found local player in live client data"); + if let Some(ref items) = player.items { + info!("[ITEMS] Items array has {} items", items.len()); + // Convert LiveClientItem to serde_json::Value for parsing + let items_json: Vec = items + .iter() + .filter_map(|item| { + item.item_id.map(|id| { + serde_json::json!({"itemId": id, "displayName": item.display_name}) + }) + }) + .collect(); + let item_build = parse_items_from_live_client(&items_json); + if item_build.is_some() { + info!("[ITEMS] Successfully parsed items from live client data"); + return Ok(item_build); } } } @@ -839,16 +795,22 @@ impl LqpClient { } } - // Fallback: try end-of-game stats - match self.get_game_stats().await { + // Fallback: try end-of-game stats (typed) + match self.get_game_stats_typed().await { Ok(stats) => { info!("[ITEMS] Game stats response received"); - if let Some(local_player) = stats.get("localPlayer") { + // Try local player first + if let Some(local_player) = stats.get_local_player() { info!("[ITEMS] Found localPlayer in game stats"); - if let Some(items) = local_player.get("items").and_then(|i| i.as_array()) { + if let Some(ref items) = local_player.items { info!("[ITEMS] localPlayer.items array has {} items", items.len()); - let item_build = parse_items_from_game_stats(items); + // Convert item IDs to serde_json::Value for parsing + let items_json: Vec = items + .iter() + .map(|id| serde_json::json!({"itemID": id})) + .collect(); + let item_build = parse_items_from_game_stats(&items_json); if item_build.is_some() { info!("[ITEMS] Successfully parsed items from localPlayer"); return Ok(item_build); @@ -856,25 +818,24 @@ impl LqpClient { } } - if let Some(teams) = stats.get("teams").and_then(|t| t.as_array()) { + // Try teams + if let Some(ref teams) = stats.teams { 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()) { + if let Some(ref players) = team.players { for player in players { - let is_local = player - .get("isLocalPlayer") - .and_then(|l| l.as_bool()) - .unwrap_or(false); - if is_local { + if player.is_local_player == Some(true) { info!("[ITEMS] Found local player in teams[].players[]"); - if let Some(items) = - player.get("items").and_then(|i| i.as_array()) - { + if let Some(ref items) = player.items { info!( "[ITEMS] Player items array has {} items", items.len() ); - let item_build = parse_items_from_game_stats(items); + let items_json: Vec = items + .iter() + .map(|id| serde_json::json!({"itemID": id})) + .collect(); + let item_build = parse_items_from_game_stats(&items_json); if item_build.is_some() { info!("[ITEMS] Successfully parsed items from teams[].players[]"); return Ok(item_build); @@ -886,14 +847,19 @@ impl LqpClient { } } - if let Some(players) = stats.get("players").and_then(|p| p.as_array()) { + // Try legacy players array + if let Some(ref players) = stats.players { 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 = parse_items_from_game_stats(items); + if let Some(ref items) = player.items { + let items_json: Vec = items + .iter() + .map(|id| serde_json::json!({"itemID": id})) + .collect(); + let item_build = parse_items_from_game_stats(&items_json); if item_build.is_some() { return Ok(item_build); } diff --git a/record-daemon/src/lqp/mod.rs b/record-daemon/src/lqp/mod.rs index fce40a3..19ceacf 100644 --- a/record-daemon/src/lqp/mod.rs +++ b/record-daemon/src/lqp/mod.rs @@ -3,6 +3,7 @@ //! This module handles communication with the League of Legends client //! via WebSocket and REST API for game event detection and capture. +mod api_types; mod auth; mod client; mod endpoints; @@ -14,6 +15,12 @@ mod state; mod tls; mod websocket; +pub use api_types::{ + ActivePlayerResponse, ChampionSelectPlayer, ChampionSelectResponse, EndOfGamePlayer, + EndOfGameStatsResponse, EndOfGameTeam, GameData, GameflowSessionResponse, LiveClientItem, + LiveClientPlayer, PlayerListResponse, PlayerStats, QueueData, RunePageResponse, + SummonerResponse, SummonerSpellsData, TeamPlayer, Timers, +}; pub use auth::{LockfileCredentials, LockfileWatcher}; pub use client::LqpClient; pub use endpoints::{