//! LQP Client for communicating with the League Client API. //! //! Provides both WebSocket (for events) and REST (for queries) interfaces. 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}; 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}; use super::tls::create_insecure_tls_config; use super::websocket::parse_websocket_message; use crate::error::{LqpError, Result}; /// LQP Client for League Client communication. pub struct LqpClient { /// Connection credentials. credentials: Arc>>, /// Current client state. state: Arc>, /// Event broadcaster. event_sender: broadcast::Sender, /// HTTP client for REST API. http_client: reqwest::Client, /// Shutdown signal. shutdown: Arc>, /// Last emitted game ID for deduplication of GameStart events. last_emitted_game_id: Arc>>, } impl LqpClient { /// Create a new LQP client. pub fn new() -> Self { let (event_sender, _) = broadcast::channel(256); let http_client = reqwest::Client::builder() .danger_accept_invalid_certs(true) // LQP uses self-signed certs .build() .expect("Failed to create HTTP client"); Self { credentials: Arc::new(RwLock::new(None)), state: Arc::new(RwLock::new(ClientState::default())), event_sender, http_client, shutdown: Arc::new(RwLock::new(false)), last_emitted_game_id: Arc::new(RwLock::new(None)), } } /// Get a subscriber for game events. pub fn subscribe(&self) -> broadcast::Receiver { self.event_sender.subscribe() } /// Get current client state. pub async fn state(&self) -> ClientState { self.state.read().await.clone() } /// Check if connected to League Client. pub async fn is_connected(&self) -> bool { self.credentials.read().await.is_some() } /// Connect to the League Client with the given credentials. pub async fn connect(&self, creds: LockfileCredentials) -> Result<()> { info!("Connecting to League Client at port {}", creds.port); // Store credentials *self.credentials.write().await = Some(creds.clone()); // Verify connection by fetching current phase match self.get_gameflow_phase().await { Ok(phase) => { self.state.write().await.phase = phase; info!("Connected to League Client, current phase: {:?}", phase); } Err(e) => { warn!("Failed to verify connection: {}", e); // Still consider connected, WebSocket might work } } // 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(()) } /// Disconnect from the League Client. pub async fn disconnect(&self) { *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"); } /// Start the WebSocket event listener. /// /// This runs in a background task and broadcasts events to subscribers. pub async fn start_event_listener(&self) -> Result<()> { let creds = self .credentials .read() .await .clone() .ok_or(LqpError::ClientNotRunning)?; let ws_url = format!("{}/", creds.ws_url()); let auth_header = creds.auth_header(); info!("Connecting to LQP WebSocket at {}", ws_url); // Create a TLS connector that accepts the self-signed certificate from League Client use tokio_tungstenite::Connector; let connector = Connector::Rustls(create_insecure_tls_config()); // Build WebSocket request with auth header let request = tokio_tungstenite::tungstenite::http::Request::builder() .uri(&ws_url) .header("Authorization", auth_header) .header("Host", format!("127.0.0.1:{}", creds.port)) .header("Connection", "Upgrade") .header("Upgrade", "websocket") .header("Sec-WebSocket-Version", "13") .header( "Sec-WebSocket-Key", tokio_tungstenite::tungstenite::handshake::client::generate_key(), ) .body(()) .map_err(|e| LqpError::ConnectionFailed(e.to_string()))?; let (ws_stream, _) = connect_async_tls_with_config(request, None, false, Some(connector)) .await .map_err(|e| LqpError::WebSocketError(e.to_string()))?; info!("WebSocket connected, subscribing to events"); let (mut write, mut read) = ws_stream.split(); // Subscribe to endpoints using OnJsonApiEvent format // Format: [5, "OnJsonApiEvent", endpoint] for endpoint in super::endpoints::SUBSCRIBE_ENDPOINTS { let subscribe_msg = serde_json::json!([5, "OnJsonApiEvent", endpoint]); let msg = Message::Text(subscribe_msg.to_string()); info!("Subscribing to: {} with OnJsonApiEvent", endpoint); write .send(msg) .await .map_err(|e| LqpError::WebSocketError(e.to_string()))?; } info!("All subscriptions sent"); // Clone references for the async block let event_sender = self.event_sender.clone(); 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 { while let Some(msg) = read.next().await { if *shutdown.read().await { debug!("WebSocket listener shutting down"); break; } match msg { Ok(Message::Text(text)) => { if text.is_empty() { continue; } // Get local_puuid from state for champion extraction let local_puuid = state.read().await.local_puuid.clone(); if let Some(event) = 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"); } } } Ok(Message::Binary(data)) => { debug!("Received binary message: {} bytes", data.len()); // Try to parse as UTF-8 if let Ok(text) = String::from_utf8(data) { if !text.is_empty() { // Get local_puuid from state for champion extraction let local_puuid = state.read().await.local_puuid.clone(); if let Some(event) = 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"); } } } } } Ok(Message::Close(_)) => { info!("WebSocket closed by server"); break; } Ok(Message::Ping(data)) => { // Respond with pong debug!("Received ping, sending pong"); let _ = write.send(Message::Pong(data)).await; } Ok(Message::Pong(_)) => { debug!("Received pong"); } Ok(Message::Frame(_)) => { debug!("Received raw frame"); } Err(e) => { error!("WebSocket error: {}", e); break; } } } // Clear credentials on disconnect *credentials.write().await = None; debug!("WebSocket listener ended"); }); Ok(()) } /// Update internal state from a game event. async fn update_state_from_event(state: &Arc>, event: &GameEvent) { let mut state = state.write().await; match event { GameEvent::GameStart(info) => { state.phase = GameflowPhase::InProgress; state.game_id = Some(info.game_id); state.champion = info.champion.clone(); } GameEvent::GameEnd(_) => { state.phase = GameflowPhase::EndOfGame; } _ => {} } } // ========================================================================= // 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 .credentials .read() .await .clone() .ok_or(LqpError::ClientNotRunning)?; let url = format!("{}{}", creds.base_url(), endpoint); let request = match method { "GET" => self.http_client.get(&url), "POST" => self.http_client.post(&url), "PUT" => self.http_client.put(&url), "DELETE" => self.http_client.delete(&url), _ => { return Err( LqpError::ConnectionFailed(format!("Invalid method: {}", method)).into(), ) } } .header("Authorization", creds.auth_header()); let response = request .send() .await .map_err(|e| LqpError::ConnectionFailed(e.to_string()))?; if !response.status().is_success() { return Err(LqpError::ConnectionFailed(format!( "API request failed: {}", response.status() )) .into()); } let json = response .json() .await .map_err(|e| LqpError::EventParseError(e.to_string()))?; 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?; let phase_str = json .as_str() .ok_or_else(|| LqpError::EventParseError("Invalid phase response".to_string()))?; Ok(GameflowPhase::from(phase_str)) } /// 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 (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 (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 (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 } /// 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 (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 } /// 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 } /// Get live client data (available during game). pub async fn get_live_client_data(&self) -> Result { self.request("GET", endpoints::LIVE_CLIENT_DATA).await } /// 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 (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 } /// 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 } // ========================================================================= // 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(); // Get session info for queue type and game mode 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_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_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_typed().await { metadata.rune_page_name = rune_page.name; } 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_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()); // 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); } } } Ok(metadata) } /// Fetch complete player game metadata including runes, summoner spells, and items. pub async fn fetch_player_game_metadata(&self) -> Result { use super::{RunePage, SummonerSpells}; let mut metadata = super::PlayerGameMetadata::default(); // Get summoner info (typed) if let Ok(summoner) = self.get_summoner_typed().await { metadata.puuid = summoner.puuid; metadata.summoner_name = summoner .display_name .or(summoner.name) .or(summoner.internal_name); } // 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 .selected_perk_ids .unwrap_or_default() .iter() .map(|id| *id as u32) .collect(); if primary_style_id > 0 { metadata.runes = Some(RunePage { primary_style_id, secondary_style_id, selected_perks, stat_modifiers: Vec::new(), name: rune_page.name, current: rune_page.current.unwrap_or(true), }); } } // 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(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 = 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 { spell1_id, spell2_id, spell1_name: spell_id_to_name(spell1_id), spell2_name: spell_id_to_name(spell2_id), }); } } if metadata.summoner_name.is_none() || metadata.summoner_name.as_ref().is_none_or(|n| n.is_empty()) { metadata.summoner_name = active_player .summoner_name .or(active_player.display_name) .or(active_player.riot_id); } } // Fallback: Get summoner spells from session gameData (typed) if metadata.summoner_spells.is_none() { if let Ok(session) = self.get_session_typed().await { if let Some(local_puuid) = &metadata.puuid { 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.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; } } } } } } } } 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. pub async fn fetch_all_players_identities(&self) -> Result> { let mut players = Vec::new(); // 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(""); 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 (typed) if players.is_empty() { 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), }); } } } } } } 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 (typed) match self.get_live_client_player_list_typed().await { Ok(player_list) => { info!( "[ITEMS] Live client player list response received with {} players", player_list.0.len() ); 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); } } } } } Err(e) => { info!("[ITEMS] Failed to get live client player list: {:?}", e); } } // Fallback: try end-of-game stats (typed) match self.get_game_stats_typed().await { Ok(stats) => { info!("[ITEMS] Game stats response received"); // Try local player first if let Some(local_player) = stats.get_local_player() { info!("[ITEMS] Found localPlayer in game stats"); if let Some(ref items) = local_player.items { info!("[ITEMS] localPlayer.items array has {} items", items.len()); // 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); } } } // 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(ref players) = team.players { for player in players { if player.is_local_player == Some(true) { info!("[ITEMS] Found local player in teams[].players[]"); if let Some(ref items) = player.items { info!( "[ITEMS] Player items array has {} items", items.len() ); 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); } } } } } } } // 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(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); } } } } 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) } } impl Default for LqpClient { fn default() -> Self { Self::new() } } #[cfg(test)] mod tests { use super::*; #[test] fn test_client_creation() { let client = LqpClient::new(); assert!(!tokio_test::block_on(client.is_connected())); } }