diff --git a/record-daemon/src/lqp/client.rs b/record-daemon/src/lqp/client.rs index 7d3f142..39e391c 100644 --- a/record-daemon/src/lqp/client.rs +++ b/record-daemon/src/lqp/client.rs @@ -10,155 +10,15 @@ 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, ItemBuild, ItemInfo, RawEvent}; +use super::endpoints; +use super::events::{GameEvent, ItemBuild, ItemInfo}; +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}; -/// Custom certificate verifier that accepts any certificate. -/// This is needed because the League Client uses a self-signed certificate. -#[derive(Debug)] -struct InsecureVerifier; - -impl rustls::client::danger::ServerCertVerifier for InsecureVerifier { - fn verify_server_cert( - &self, - _end_entity: &rustls::pki_types::CertificateDer<'_>, - _intermediates: &[rustls::pki_types::CertificateDer<'_>], - _server_name: &rustls::pki_types::ServerName<'_>, - _ocsp_response: &[u8], - _now: rustls::pki_types::UnixTime, - ) -> std::result::Result { - // Accept any certificate - League Client uses self-signed certificates - Ok(rustls::client::danger::ServerCertVerified::assertion()) - } - - fn verify_tls12_signature( - &self, - _message: &[u8], - _cert: &rustls::pki_types::CertificateDer<'_>, - _dss: &rustls::DigitallySignedStruct, - ) -> std::result::Result { - Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) - } - - fn verify_tls13_signature( - &self, - _message: &[u8], - _cert: &rustls::pki_types::CertificateDer<'_>, - _dss: &rustls::DigitallySignedStruct, - ) -> std::result::Result { - Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) - } - - fn supported_verify_schemes(&self) -> Vec { - vec![ - rustls::SignatureScheme::RSA_PKCS1_SHA256, - rustls::SignatureScheme::RSA_PKCS1_SHA384, - rustls::SignatureScheme::RSA_PKCS1_SHA512, - rustls::SignatureScheme::ECDSA_NISTP256_SHA256, - rustls::SignatureScheme::ECDSA_NISTP384_SHA384, - rustls::SignatureScheme::ECDSA_NISTP521_SHA512, - rustls::SignatureScheme::RSA_PSS_SHA256, - rustls::SignatureScheme::RSA_PSS_SHA384, - rustls::SignatureScheme::RSA_PSS_SHA512, - rustls::SignatureScheme::ED25519, - ] - } -} - -/// LQP WebSocket endpoints to subscribe to. -const SUBSCRIBE_ENDPOINTS: &[&str] = &[ - "/lol-gameflow/v1/gameflow-phase", - "/lol-gameflow/v1/session", - "/lol-matchmaking/v1/ready-check", - "/lol-game-events/v1/game-events", - "/lol-champ-select/v1/session", - "/lol-lobby/v2/lobby", - "/lol-end-of-game/v1/eog-stats-block", -]; - -/// LQP REST API endpoints. -pub mod endpoints { - pub const GAMEFLOW_PHASE: &str = "/lol-gameflow/v1/gameflow-phase"; - pub const SESSION: &str = "/lol-gameflow/v1/session"; - pub const CHAMPION_SELECT: &str = "/lol-champ-select/v1/session"; - pub const SUMMONER: &str = "/lol-summoner/v1/current-summoner"; - pub const GAME_STATS: &str = "/lol-end-of-game/v1/eog-stats-block"; - pub const CHAMPION_SUMMARY: &str = "/lol-champ-select/v1/current-champion"; - pub const RUNE_PAGES: &str = "/lol-perks/v1/currentpage"; - pub const 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. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum GameflowPhase { - /// Client is in main menu or lobby. - None, - /// In lobby. - Lobby, - /// In queue. - Queue, - /// Match found, ready check. - ReadyCheck, - /// In champion select. - ChampSelect, - /// Game is starting. - GameStart, - /// In game. - InProgress, - /// Game ended, waiting for stats. - WaitingForStats, - /// End of game stats screen. - EndOfGame, - /// Unknown phase. - Unknown, -} - -impl From<&str> for GameflowPhase { - fn from(s: &str) -> Self { - match s { - "None" => GameflowPhase::None, - "Lobby" => GameflowPhase::Lobby, - "Queue" => GameflowPhase::Queue, - "ReadyCheck" => GameflowPhase::ReadyCheck, - "ChampSelect" => GameflowPhase::ChampSelect, - "GameStart" => GameflowPhase::GameStart, - "InProgress" => GameflowPhase::InProgress, - "WaitingForStats" => GameflowPhase::WaitingForStats, - "EndOfGame" => GameflowPhase::EndOfGame, - _ => GameflowPhase::Unknown, - } - } -} - -/// LQP Client state. -#[derive(Debug, Clone)] -pub struct ClientState { - /// Current gameflow phase. - pub phase: GameflowPhase, - /// Current game ID if in game. - pub game_id: Option, - /// Current champion name. - pub champion: Option, - /// Current player's puuid. - pub local_puuid: Option, -} - -impl Default for ClientState { - fn default() -> Self { - Self { - phase: GameflowPhase::None, - game_id: None, - champion: None, - local_puuid: None, - } - } -} - /// LQP Client for League Client communication. pub struct LqpClient { /// Connection credentials. @@ -268,12 +128,7 @@ impl LqpClient { // Create a TLS connector that accepts the self-signed certificate from League Client use tokio_tungstenite::Connector; - let config = rustls::ClientConfig::builder() - .dangerous() - .with_custom_certificate_verifier(Arc::new(InsecureVerifier)) - .with_no_client_auth(); - - let connector = Connector::Rustls(Arc::new(config)); + let connector = Connector::Rustls(create_insecure_tls_config()); // Build WebSocket request with auth header let request = tokio_tungstenite::tungstenite::http::Request::builder() @@ -300,7 +155,7 @@ impl LqpClient { // Subscribe to endpoints using OnJsonApiEvent format // Format: [5, "OnJsonApiEvent", endpoint] - for endpoint in SUBSCRIBE_ENDPOINTS { + 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); @@ -333,8 +188,7 @@ impl LqpClient { } // Get local_puuid from state for champion extraction let local_puuid = state.read().await.local_puuid.clone(); - if let Some(event) = - Self::parse_websocket_message(&text, local_puuid.as_deref()) + 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; @@ -371,7 +225,7 @@ impl LqpClient { // Get local_puuid from state for champion extraction let local_puuid = state.read().await.local_puuid.clone(); if let Some(event) = - Self::parse_websocket_message(&text, local_puuid.as_deref()) + parse_websocket_message(&text, local_puuid.as_deref()) { // Update state based on event Self::update_state_from_event(&state, &event).await; @@ -432,430 +286,6 @@ impl LqpClient { Ok(()) } - /// Parse a WebSocket message into a game event. - fn parse_websocket_message(text: &str, local_puuid: Option<&str>) -> Option { - // Parse the message array format: [type, callback, data] - let value: serde_json::Value = match serde_json::from_str(text) { - Ok(v) => v, - Err(e) => { - warn!("Failed to parse WebSocket message as JSON: {}", e); - return None; - } - }; - - // Check if it's an event message (type 8) - if let Some(arr) = value.as_array() { - if arr.len() >= 3 { - let msg_type = arr.first()?.as_u64()?; - - if msg_type == 8 { - // Event message format: [8, "OnJsonApiEvent", {"data": ..., "eventType": ..., "uri": ...}] - let callback = arr.get(1)?.as_str()?; - let event_data = arr.get(2)?; - - if callback == "OnJsonApiEvent" { - // Try to parse as RawEvent for type-safe access - if let Ok(raw_event) = - serde_json::from_value::(event_data.clone()) - { - let event_type = event_data - .get("eventType") - .and_then(|t| t.as_str()) - .unwrap_or("Update"); - return Self::parse_event_from_uri( - &raw_event.uri, - event_type, - &serde_json::to_value(raw_event.data).unwrap_or_default(), - local_puuid, - ); - } - - // Fallback to manual extraction - let uri = event_data.get("uri")?.as_str()?; - let data = event_data.get("data")?; - let event_type = event_data - .get("eventType") - .and_then(|t| t.as_str()) - .unwrap_or("Update"); - - return Self::parse_event_from_uri(uri, event_type, data, local_puuid); - } else { - debug!("Unknown callback: {}", callback); - } - } else if msg_type == 4 { - // Response to subscription - this is normal - debug!("Subscription response received"); - } else if msg_type == 0 { - // Welcome message - info!("WebSocket welcome message received"); - } else { - debug!("Unknown message type {msg_type} received"); - } - } - } else { - debug!("Message is not an array: {:?}", value); - } - - None - } - - /// Parse an event based on the URI. - fn parse_event_from_uri( - uri: &str, - event_type: &str, - data: &serde_json::Value, - local_puuid: Option<&str>, - ) -> Option { - info!("Parsing event from URI: {} (type: {})", uri, event_type); - - // Handle gameflow phase changes - if uri == "/lol-gameflow/v1/gameflow-phase" { - let phase = data.as_str()?; - info!("Gameflow phase changed to: {}", phase); - - // Only trigger GameEnd on EndOfGame phase (not WaitingForStats or PreEndOfGame) - // This ensures we wait for the stats to be available - if phase == "EndOfGame" { - info!("Game end phase detected: {}", phase); - // Generate a GameEnd event for timeline recording - return Some( - GameEvent::from_json(&serde_json::json!({ - "eventType": "lcu-game-end", - "gameId": 0, // Will be filled from state if available - "victory": false, // Will be updated from end-of-game stats - "duration": 0.0 - })) - .unwrap_or(GameEvent::Unknown), - ); - } - - // Update internal state based on phase - return Some( - GameEvent::from_json(&serde_json::json!({ - "eventType": "lcu-phase-change", - "phase": phase - })) - .unwrap_or(GameEvent::Unknown), - ); - } - - // Handle gameflow session updates - if uri == "/lol-gameflow/v1/session" { - if let Some(phase) = data.get("phase").and_then(|p| p.as_str()) { - info!("Gameflow session phase: {}", phase); - - // Check for game start - if phase == "InProgress" { - info!("Game is now in progress!"); - - // Try to parse the gameData into a GameflowSession struct - let session: Option = data - .get("gameData") - .and_then(|gd| serde_json::from_value(gd.clone()).ok()); - - if let Some(ref session) = session { - debug!( - "Parsed GameflowSession: game_id={}, queue={:?}", - session.game_id, - session.queue_name() - ); - } else { - debug!("Failed to parse gameData as GameflowSession, falling back to manual extraction"); - } - - // Extract game_id - prefer from parsed session, fallback to manual extraction - let game_id = session.as_ref().map(|s| s.game_id).unwrap_or_else(|| { - data.get("gameData") - .and_then(|gd| gd.get("gameId")) - .and_then(|id| id.as_u64()) - .unwrap_or(0) - }); - - // Note: Champion, team, summoner_name will be extracted using puuid - // in handle_game_event when we have access to pregame_metadata - - // Extract queue info (this is the same for all players) - let queue_type = session - .as_ref() - .and_then(|s| s.queue_name()) - .map(|s| s.to_string()) - .or_else(|| { - data.get("gameData") - .and_then(|gd| gd.get("queue")) - .and_then(|q| q.get("name")) - .and_then(|n| n.as_str()) - .map(|s| s.to_string()) - }); - - let queue_id = session.as_ref().and_then(|s| s.queue_id()); - - let game_mode = session - .as_ref() - .and_then(|s| s.game_mode()) - .map(|s| s.to_string()) - .or_else(|| { - data.get("gameData") - .and_then(|gd| gd.get("queue")) - .and_then(|q| q.get("gameMode")) - .and_then(|m| m.as_str()) - .map(|s| s.to_string()) - }); - - // Extract map name - let map_name = session - .as_ref() - .and_then(|s| s.map_id()) - .and_then(|id| map_id_to_name(id as u64)) - .or_else(|| { - data.get("gameData") - .and_then(|gd| gd.get("queue")) - .and_then(|q| q.get("mapId")) - .and_then(|id| id.as_u64()) - .and_then(map_id_to_name) - }); - - info!("Extracted game metadata: game_id={}, queue={:?}, queue_id={:?}, mode={:?}, map={:?}", - game_id, queue_type, queue_id, game_mode, map_name); - - // Extract player-specific data using puuid - let (champion, team, summoner_name) = if let Some(puuid) = local_puuid { - let champ_id = session.as_ref().and_then(|s| s.get_champion_id(puuid)); - let team_id = session.as_ref().and_then(|s| s.get_team(puuid)); - let summoner = session - .as_ref() - .and_then(|s| s.get_summoner_name(puuid)) - .map(|s| s.to_string()); - - // Convert champion_id to champion name - let champ_name = champ_id.and_then(champion_id_to_name); - - info!("Extracted player data via puuid: champion={:?}, team={:?}, summoner={:?}", - champ_name, team_id, summoner); - - (champ_name, team_id, summoner) - } else { - info!("No local_puuid available, cannot extract player-specific data"); - (None, None, None) - }; - - return Some( - GameEvent::from_json(&serde_json::json!({ - "eventType": "lcu-game-start", - "gameId": game_id, - "queueType": queue_type, - "queueId": queue_id, - "gameMode": game_mode, - "map": map_name, - "champion": champion, - "team": team, - "summonerName": summoner_name, - "session": session - })) - .unwrap_or(GameEvent::Unknown), - ); - } - - return Some( - GameEvent::from_json(&serde_json::json!({ - "eventType": "lcu-phase-change", - "phase": phase - })) - .unwrap_or(GameEvent::Unknown), - ); - } - } - - // Handle game events (kills, deaths, objectives) - if uri == "/lol-game-events/v1/game-events" { - info!("Game event received: {:?}", data); - return GameEvent::from_json(data); - } - - // Handle ready check - if uri == "/lol-matchmaking/v1/ready-check" { - info!("Ready check event: {:?}", data); - return None; - } - - // Handle champion select - if uri == "/lol-champ-select/v1/session" { - info!("Champion select event: {:?}", data); - - // Check if we're in champion select phase - if let Some(timers) = data.get("timers") { - if let Some(phase) = timers.get("phase").and_then(|p| p.as_str()) { - if phase == "BAN_PICK" || phase == "FINALIZATION" { - // Extract local player's champion - if let Some(local_player_cell_id) = - data.get("localPlayerCellId").and_then(|id| id.as_i64()) - { - // Check both teams for the local player - for team_key in &["myTeam", "theirTeam"] { - if let Some(team) = data.get(team_key).and_then(|t| t.as_array()) { - for member in team { - if member.get("cellId").and_then(|id| id.as_i64()) - == Some(local_player_cell_id) - { - if let Some(champion_id) = - member.get("championId").and_then(|id| id.as_u64()) - { - if champion_id > 0 { - let champion_name = member - .get("championName") - .and_then(|n| n.as_str()) - .unwrap_or("Unknown") - .to_string(); - - return Some( - GameEvent::from_json(&serde_json::json!({ - "eventType": "lcu-champion-pick", - "summonerName": "LocalPlayer", - "championId": champion_id, - "championName": champion_name, - "isLocalPlayer": true - })) - .unwrap_or(GameEvent::Unknown), - ); - } - } - } - } - } - } - } - } - } - } - return None; - } - - // Handle end-of-game stats block (contains actual game results) - if uri == "/lol-end-of-game/v1/eog-stats-block" { - info!("End-of-game stats received: {:?}", data); - - // Extract game ID and duration - let game_id = data.get("gameId").and_then(|id| id.as_u64()).unwrap_or(0); - let game_duration = data - .get("gameLength") - .and_then(|d| d.as_f64()) - .unwrap_or(0.0); - - // Get local player data - prefer localPlayer field, fallback to teams[0].players[0] - let local_player = data.get("localPlayer"); - - // Extract victory status from local player's stats (WIN: 1 means victory) - let victory = local_player - .and_then(|p| p.get("stats")) - .and_then(|s| s.get("WIN")) - .and_then(|w| w.as_u64()) - .map(|w| w == 1) - .or_else(|| { - // Fallback: check if player's team is winning team - data.get("teams") - .and_then(|teams| teams.as_array()) - .and_then(|t| { - t.iter().find_map(|team| { - if team.get("isPlayerTeam").and_then(|p| p.as_bool()) == Some(true) - { - team.get("isWinningTeam").and_then(|w| w.as_bool()) - } else { - None - } - }) - }) - }) - .unwrap_or(false); - - // Extract player stats - stats use UPPERCASE keys - let mut kills = 0u32; - let mut deaths = 0u32; - let mut assists = 0u32; - let mut creep_score = 0u32; - let mut gold_earned = 0u32; - let mut damage_dealt = 0u64; - let mut damage_taken = 0u64; - let mut vision_score = 0.0; - - if let Some(stats_obj) = local_player.and_then(|p| p.get("stats")) { - kills = stats_obj - .get("CHAMPIONS_KILLED") - .and_then(|k| k.as_u64()) - .unwrap_or(0) as u32; - deaths = stats_obj - .get("NUM_DEATHS") - .and_then(|d| d.as_u64()) - .unwrap_or(0) as u32; - assists = stats_obj - .get("ASSISTS") - .and_then(|a| a.as_u64()) - .unwrap_or(0) as u32; - creep_score = stats_obj - .get("MINIONS_KILLED") - .and_then(|cs| cs.as_u64()) - .unwrap_or(0) as u32; - gold_earned = stats_obj - .get("GOLD_EARNED") - .and_then(|g| g.as_u64()) - .unwrap_or(0) as u32; - damage_dealt = stats_obj - .get("TOTAL_DAMAGE_DEALT_TO_CHAMPIONS") - .and_then(|d| d.as_u64()) - .unwrap_or(0); - damage_taken = stats_obj - .get("TOTAL_DAMAGE_TAKEN") - .and_then(|d| d.as_u64()) - .unwrap_or(0); - vision_score = stats_obj - .get("VISION_SCORE") - .and_then(|v| v.as_f64()) - .unwrap_or(0.0); - } - - info!("Extracted game end stats: kills={}, deaths={}, assists={}, cs={}, gold={}, damage_dealt={}, damage_taken={}, vision={}, victory={}", - kills, deaths, assists, creep_score, gold_earned, damage_dealt, damage_taken, vision_score, victory); - - // Generate a GameEnd event with actual stats - // Note: PlayerStats uses camelCase due to serde rename_all - let event_json = serde_json::json!({ - "eventType": "lcu-game-end", - "gameId": game_id, - "victory": victory, - "duration": game_duration, - "stats": { - "kills": kills, - "deaths": deaths, - "assists": assists, - "minionsKilled": creep_score, - "goldEarned": gold_earned, - "damageDealt": damage_dealt, - "damageTaken": damage_taken, - "visionScore": vision_score - } - }); - info!("Generating GameEnd event from eog-stats: {:?}", event_json); - - match GameEvent::from_json(&event_json) { - Some(event) => { - info!("Successfully parsed GameEnd event"); - return Some(event); - } - None => { - warn!("Failed to parse GameEnd event, returning Unknown"); - return Some(GameEvent::Unknown); - } - } - } - - // Handle lobby - if uri.starts_with("/lol-lobby") { - debug!("Lobby event: {}", uri); - return None; - } - - debug!("Unhandled URI: {}", uri); - None - } - /// Update internal state from a game event. async fn update_state_from_event(state: &Arc>, event: &GameEvent) { let mut state = state.write().await; @@ -1414,7 +844,7 @@ 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 = Self::parse_items_from_live_client(items); if item_build.is_some() { info!( "[ITEMS] Successfully parsed items from live client data" @@ -1443,7 +873,7 @@ impl LqpClient { 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 = Self::parse_items_from_game_stats(items); if item_build.is_some() { info!("[ITEMS] Successfully parsed items from localPlayer"); return Ok(item_build); @@ -1470,7 +900,7 @@ impl LqpClient { "[ITEMS] Player items array has {} items", items.len() ); - let item_build = self.parse_items_from_game_stats(items); + 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); @@ -1490,7 +920,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 = Self::parse_items_from_game_stats(items); if item_build.is_some() { return Ok(item_build); } @@ -1510,10 +940,7 @@ impl LqpClient { } /// Parse items from live client data format. - fn parse_items_from_live_client( - &self, - items: &[serde_json::Value], - ) -> Option { + fn parse_items_from_live_client(items: &[serde_json::Value]) -> Option { let mut item_list = Vec::new(); let mut trinket = None; @@ -1550,7 +977,7 @@ impl LqpClient { } /// Parse items from game stats format (array of item IDs as numbers). - fn parse_items_from_game_stats(&self, items: &[serde_json::Value]) -> Option { + fn parse_items_from_game_stats(items: &[serde_json::Value]) -> Option { let mut item_list = Vec::new(); let mut trinket = None; @@ -1585,266 +1012,6 @@ impl LqpClient { } } -/// 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. -/// This is a simplified mapping for common champions. -pub fn champion_id_to_name(id: u32) -> Option { - let name = match id { - 1 => "Annie", - 2 => "Olaf", - 3 => "Galio", - 4 => "TwistedFate", - 5 => "XinZhao", - 6 => "Urgot", - 7 => "LeBlanc", - 8 => "Vladimir", - 9 => "Fiddlesticks", - 10 => "Kayle", - 11 => "MasterYi", - 12 => "Alistar", - 13 => "Ryze", - 14 => "Sion", - 15 => "Sivir", - 16 => "Soraka", - 17 => "Teemo", - 18 => "Tristana", - 19 => "Warwick", - 20 => "Nunu", - 21 => "MissFortune", - 22 => "Ashe", - 23 => "Tryndamere", - 24 => "Jax", - 25 => "Morgana", - 26 => "Zilean", - 27 => "Singed", - 28 => "Evelynn", - 29 => "Twitch", - 30 => "Karthus", - 31 => "Cho'Gath", - 32 => "Amumu", - 33 => "Rammus", - 34 => "Anivia", - 35 => "Shaco", - 36 => "DrMundo", - 37 => "Sona", - 38 => "Kassadin", - 39 => "Irelia", - 40 => "Janna", - 41 => "Gangplank", - 42 => "Corki", - 43 => "Karma", - 44 => "Taric", - 45 => "Veigar", - 48 => "Trundle", - 50 => "Swain", - 51 => "Caitlyn", - 52 => "Blitzcrank", - 53 => "Malphite", - 54 => "Katarina", - 55 => "Nocturne", - 56 => "Maokai", - 57 => "Renekton", - 58 => "JarvanIV", - 59 => "Elise", - 60 => "Talon", - 61 => "Orianna", - 62 => "Wukong", - 63 => "Brand", - 64 => "LeeSin", - 67 => "Vayne", - 68 => "Rumble", - 69 => "Cassiopeia", - 72 => "Skarner", - 74 => "Heimerdinger", - 75 => "Nasus", - 76 => "Nidalee", - 77 => "Udyr", - 78 => "Poppy", - 79 => "Gragas", - 80 => "Pantheon", - 81 => "Ezreal", - 82 => "Mordekaiser", - 83 => "Yorick", - 84 => "Akali", - 85 => "Kennedy", - 86 => "Garen", - 89 => "Leona", - 90 => "Malzahar", - 91 => "Talon", - 92 => "Riven", - 96 => "Kog'Maw", - 98 => "Shen", - 99 => "Lux", - 101 => "Xerath", - 102 => "Shyvana", - 103 => "Ahri", - 104 => "Graves", - 105 => "Fizz", - 106 => "Volibear", - 107 => "Rengar", - 110 => "Varus", - 111 => "Nautilus", - 112 => "Viktor", - 113 => "Sejuani", - 114 => "Fiora", - 115 => "Ziggs", - 117 => "Lulu", - 119 => "Draven", - 120 => "Hecarim", - 121 => "Kha'Zix", - 122 => "Darius", - 126 => "Jayce", - 127 => "Lissandra", - 131 => "Diana", - 133 => "Quinn", - 134 => "Syndra", - 136 => "AurelionSol", - 141 => "Kayn", - 142 => "Zoe", - 143 => "Lillia", - 145 => "Samira", - 147 => "Seraphine", - 150 => "Gnar", - 154 => "Zac", - 157 => "Yasuo", - 161 => "Vel'Koz", - 163 => "Taliyah", - 164 => "Camille", - 166 => "Akshan", - 167 => "Nilah", - 201 => "Braum", - 202 => "Jhin", - 203 => "Kindred", - 222 => "Jinx", - 223 => "TahmKench", - 236 => "Lucian", - 238 => "Zed", - 240 => "Kled", - 245 => "Ekko", - 246 => "Qiyana", - 254 => "Vi", - 255 => "Janna", - 256 => "Pyke", - 257 => "Nami", - 266 => "Aatrox", - 267 => "Nami", - 268 => "Azir", - 350 => "Yuumi", - 360 => "Samira", - 412 => "Thresh", - 420 => "Illaoi", - 421 => "Rek'Sai", - 427 => "Ivern", - 429 => "Kalista", - 432 => "Bard", - 497 => "Rakan", - 498 => "Xayah", - 516 => "Ornn", - 517 => "Sylas", - 518 => "Neeko", - 523 => "Aphelios", - 526 => "Rell", - 555 => "Pyke", - 711 => "Vex", - 777 => "Yone", - 875 => "Sett", - 876 => "Lillia", - 887 => "Gwen", - 888 => "Viego", - 895 => "KSante", - 901 => "Smolder", - 902 => "Hwei", - 950 => "Naafiri", - 951 => "Briar", - _ => return None, - }; - Some(name.to_string()) -} - -/// Convert map ID to map name. -fn map_id_to_name(id: u64) -> Option { - let name = match id { - 1 => "Summoner's Rift", - 2 => "Summoner's Rift", - 3 => "The Proving Grounds", - 4 => "Twisted Treeline", - 8 => "The Crystal Scar", - 10 => "Twisted Treeline", - 11 => "Summoner's Rift", - 12 => "Howling Abyss", - 14 => "Butcher's Bridge", - 16 => "Cosmic Ruins", - 18 => "Valoran City Park", - 19 => "Substructure 43", - 20 => "Crash Site", - 21 => "Nexus Blitz", - 22 => "Convergence", - 23 => "Arena", - 24 => "Arena", - 25 => "Rings of Wrath", - 30 => "Swarm", - 31 => "Swarm", - 32 => "Swarm", - 33 => "Swarm", - 34 => "Swarm", - 35 => "Swarm", - _ => return None, - }; - Some(name.to_string()) -} - -/// Pre-game metadata fetched before the game starts. -#[derive(Debug, Clone, Default)] -pub struct PreGameMetadata { - pub champion_id: Option, - pub skin_id: Option, - pub rune_page_name: Option, - pub summoner_name: Option, - pub queue_type: Option, - pub queue_id: Option, - pub game_mode: Option, - pub map_name: Option, - pub team: Option, - pub local_puuid: Option, -} - -/// End-of-game metadata fetched after the game ends. -#[derive(Debug, Clone, Default)] -pub struct GameEndMetadata { - pub victory: Option, - pub match_id: Option, - pub kills: u32, - pub deaths: u32, - pub assists: u32, - pub creep_score: u32, - pub gold_earned: u32, - pub damage_dealt: u64, - pub damage_taken: u64, - pub vision_score: f64, - pub game_duration: f64, -} - impl Default for LqpClient { fn default() -> Self { Self::new() @@ -1855,16 +1022,6 @@ impl Default for LqpClient { mod tests { use super::*; - #[test] - fn test_gameflow_phase_from_str() { - assert_eq!(GameflowPhase::from("InProgress"), GameflowPhase::InProgress); - assert_eq!( - GameflowPhase::from("ChampSelect"), - GameflowPhase::ChampSelect - ); - assert_eq!(GameflowPhase::from("UnknownPhase"), GameflowPhase::Unknown); - } - #[test] fn test_client_creation() { let client = LqpClient::new(); diff --git a/record-daemon/src/lqp/endpoints.rs b/record-daemon/src/lqp/endpoints.rs new file mode 100644 index 0000000..076f7ef --- /dev/null +++ b/record-daemon/src/lqp/endpoints.rs @@ -0,0 +1,59 @@ +//! LQP REST API endpoints. +//! +//! Defines the endpoint paths for the League Client API. + +/// Gameflow phase endpoint. +pub const GAMEFLOW_PHASE: &str = "/lol-gameflow/v1/gameflow-phase"; +/// Game session endpoint. +pub const SESSION: &str = "/lol-gameflow/v1/session"; +/// Champion select session endpoint. +pub const CHAMPION_SELECT: &str = "/lol-champ-select/v1/session"; +/// Current summoner endpoint. +pub const SUMMONER: &str = "/lol-summoner/v1/current-summoner"; +/// End-of-game stats endpoint. +pub const GAME_STATS: &str = "/lol-end-of-game/v1/eog-stats-block"; +/// Current champion in champ select. +pub const CHAMPION_SUMMARY: &str = "/lol-champ-select/v1/current-champion"; +/// Current rune page endpoint. +pub const RUNE_PAGES: &str = "/lol-perks/v1/currentpage"; +/// All rune pages endpoint. +pub const ALL_RUNE_PAGES: &str = "/lol-perks/v1/pages"; +/// Match history endpoint. +pub const MATCH_HISTORY: &str = "/lol-match-history/v1/products/lol/current-summoner/matches"; +/// Live client data endpoint (all game data). +pub const LIVE_CLIENT_DATA: &str = "/liveclientdata/allgamedata"; +/// Live client active player endpoint. +pub const LIVE_CLIENT_DATA_ACTIVE_PLAYER: &str = "/liveclientdata/activeplayer"; +/// Live client player list endpoint. +pub const LIVE_CLIENT_DATA_PLAYER_LIST: &str = "/liveclientdata/playerlist"; +/// Local player selection in champion select. +pub const CHAMPION_SELECT_LOCAL_PLAYER: &str = "/lol-champ-select/v1/session/my-selection"; + +/// LQP WebSocket endpoints to subscribe to. +pub const SUBSCRIBE_ENDPOINTS: &[&str] = &[ + GAMEFLOW_PHASE, + SESSION, + "/lol-matchmaking/v1/ready-check", + "/lol-game-events/v1/game-events", + CHAMPION_SELECT, + "/lol-lobby/v2/lobby", + GAME_STATS, +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_endpoints_are_valid() { + // All endpoints should start with / + assert!(GAMEFLOW_PHASE.starts_with('/')); + assert!(SESSION.starts_with('/')); + assert!(CHAMPION_SELECT.starts_with('/')); + } + + #[test] + fn test_subscribe_endpoints_not_empty() { + assert!(!SUBSCRIBE_ENDPOINTS.is_empty()); + } +} diff --git a/record-daemon/src/lqp/mappings.rs b/record-daemon/src/lqp/mappings.rs new file mode 100644 index 0000000..bab8879 --- /dev/null +++ b/record-daemon/src/lqp/mappings.rs @@ -0,0 +1,261 @@ +//! ID to name mappings for League of Legends data. +//! +//! Provides conversion functions for champion IDs, summoner spell IDs, +//! and map IDs to their human-readable names. + +/// 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. +/// This is a simplified mapping for common champions. +pub fn champion_id_to_name(id: u32) -> Option { + let name = match id { + 1 => "Annie", + 2 => "Olaf", + 3 => "Galio", + 4 => "TwistedFate", + 5 => "XinZhao", + 6 => "Urgot", + 7 => "LeBlanc", + 8 => "Vladimir", + 9 => "Fiddlesticks", + 10 => "Kayle", + 11 => "MasterYi", + 12 => "Alistar", + 13 => "Ryze", + 14 => "Sion", + 15 => "Sivir", + 16 => "Soraka", + 17 => "Teemo", + 18 => "Tristana", + 19 => "Warwick", + 20 => "Nunu", + 21 => "MissFortune", + 22 => "Ashe", + 23 => "Tryndamere", + 24 => "Jax", + 25 => "Morgana", + 26 => "Zilean", + 27 => "Singed", + 28 => "Evelynn", + 29 => "Twitch", + 30 => "Karthus", + 31 => "Cho'Gath", + 32 => "Amumu", + 33 => "Rammus", + 34 => "Anivia", + 35 => "Shaco", + 36 => "DrMundo", + 37 => "Sona", + 38 => "Kassadin", + 39 => "Irelia", + 40 => "Janna", + 41 => "Gangplank", + 42 => "Corki", + 43 => "Karma", + 44 => "Taric", + 45 => "Veigar", + 48 => "Trundle", + 50 => "Swain", + 51 => "Caitlyn", + 52 => "Blitzcrank", + 53 => "Malphite", + 54 => "Katarina", + 55 => "Nocturne", + 56 => "Maokai", + 57 => "Renekton", + 58 => "JarvanIV", + 59 => "Elise", + 60 => "Talon", + 61 => "Orianna", + 62 => "Wukong", + 63 => "Brand", + 64 => "LeeSin", + 67 => "Vayne", + 68 => "Rumble", + 69 => "Cassiopeia", + 72 => "Skarner", + 74 => "Heimerdinger", + 75 => "Nasus", + 76 => "Nidalee", + 77 => "Udyr", + 78 => "Poppy", + 79 => "Gragas", + 80 => "Pantheon", + 81 => "Ezreal", + 82 => "Mordekaiser", + 83 => "Yorick", + 84 => "Akali", + 85 => "Kennedy", + 86 => "Garen", + 89 => "Leona", + 90 => "Malzahar", + 91 => "Talon", + 92 => "Riven", + 96 => "Kog'Maw", + 98 => "Shen", + 99 => "Lux", + 101 => "Xerath", + 102 => "Shyvana", + 103 => "Ahri", + 104 => "Graves", + 105 => "Fizz", + 106 => "Volibear", + 107 => "Rengar", + 110 => "Varus", + 111 => "Nautilus", + 112 => "Viktor", + 113 => "Sejuani", + 114 => "Fiora", + 115 => "Ziggs", + 117 => "Lulu", + 119 => "Draven", + 120 => "Hecarim", + 121 => "Kha'Zix", + 122 => "Darius", + 126 => "Jayce", + 127 => "Lissandra", + 131 => "Diana", + 133 => "Quinn", + 134 => "Syndra", + 136 => "AurelionSol", + 141 => "Kayn", + 142 => "Zoe", + 143 => "Lillia", + 145 => "Samira", + 147 => "Seraphine", + 150 => "Gnar", + 154 => "Zac", + 157 => "Yasuo", + 161 => "Vel'Koz", + 163 => "Taliyah", + 164 => "Camille", + 166 => "Akshan", + 167 => "Nilah", + 201 => "Braum", + 202 => "Jhin", + 203 => "Kindred", + 222 => "Jinx", + 223 => "TahmKench", + 236 => "Lucian", + 238 => "Zed", + 240 => "Kled", + 245 => "Ekko", + 246 => "Qiyana", + 254 => "Vi", + 255 => "Janna", + 256 => "Pyke", + 257 => "Nami", + 266 => "Aatrox", + 267 => "Nami", + 268 => "Azir", + 350 => "Yuumi", + 360 => "Samira", + 412 => "Thresh", + 420 => "Illaoi", + 421 => "Rek'Sai", + 427 => "Ivern", + 429 => "Kalista", + 432 => "Bard", + 497 => "Rakan", + 498 => "Xayah", + 516 => "Ornn", + 517 => "Sylas", + 518 => "Neeko", + 523 => "Aphelios", + 526 => "Rell", + 555 => "Pyke", + 711 => "Vex", + 777 => "Yone", + 875 => "Sett", + 876 => "Lillia", + 887 => "Gwen", + 888 => "Viego", + 895 => "KSante", + 901 => "Smolder", + 902 => "Hwei", + 950 => "Naafiri", + 951 => "Briar", + _ => return None, + }; + Some(name.to_string()) +} + +/// Convert map ID to map name. +pub fn map_id_to_name(id: u64) -> Option { + let name = match id { + 1 => "Summoner's Rift", + 2 => "Summoner's Rift", + 3 => "The Proving Grounds", + 4 => "Twisted Treeline", + 8 => "The Crystal Scar", + 10 => "Twisted Treeline", + 11 => "Summoner's Rift", + 12 => "Howling Abyss", + 14 => "Butcher's Bridge", + 16 => "Cosmic Ruins", + 18 => "Valoran City Park", + 19 => "Substructure 43", + 20 => "Crash Site", + 21 => "Nexus Blitz", + 22 => "Convergence", + 23 => "Arena", + 24 => "Arena", + 25 => "Rings of Wrath", + 30 => "Swarm", + 31 => "Swarm", + 32 => "Swarm", + 33 => "Swarm", + 34 => "Swarm", + 35 => "Swarm", + _ => return None, + }; + Some(name.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_spell_id_to_name() { + assert_eq!(spell_id_to_name(4), Some("Flash".to_string())); + assert_eq!(spell_id_to_name(7), Some("Heal".to_string())); + assert_eq!(spell_id_to_name(11), Some("Smite".to_string())); + assert_eq!(spell_id_to_name(999), None); + } + + #[test] + fn test_champion_id_to_name() { + assert_eq!(champion_id_to_name(1), Some("Annie".to_string())); + assert_eq!(champion_id_to_name(22), Some("Ashe".to_string())); + assert_eq!(champion_id_to_name(157), Some("Yasuo".to_string())); + assert_eq!(champion_id_to_name(9999), None); + } + + #[test] + fn test_map_id_to_name() { + assert_eq!(map_id_to_name(11), Some("Summoner's Rift".to_string())); + assert_eq!(map_id_to_name(12), Some("Howling Abyss".to_string())); + assert_eq!(map_id_to_name(999), None); + } +} diff --git a/record-daemon/src/lqp/metadata.rs b/record-daemon/src/lqp/metadata.rs new file mode 100644 index 0000000..99b754f --- /dev/null +++ b/record-daemon/src/lqp/metadata.rs @@ -0,0 +1,77 @@ +//! Metadata structures for pre-game and end-of-game data. +//! +//! Contains structures for capturing game information before and after matches. + +/// Pre-game metadata fetched before the game starts. +#[derive(Debug, Clone, Default)] +pub struct PreGameMetadata { + /// Champion ID selected. + pub champion_id: Option, + /// Skin ID selected. + pub skin_id: Option, + /// Name of the rune page. + pub rune_page_name: Option, + /// Summoner name. + pub summoner_name: Option, + /// Queue type (e.g., "RANKED_SOLO_5x5"). + pub queue_type: Option, + /// Queue ID. + pub queue_id: Option, + /// Game mode (e.g., "CLASSIC", "ARAM"). + pub game_mode: Option, + /// Map name. + pub map_name: Option, + /// Team ID (100 or 200). + pub team: Option, + /// Local player's PUUID. + pub local_puuid: Option, +} + +/// End-of-game metadata fetched after the game ends. +#[derive(Debug, Clone, Default)] +pub struct GameEndMetadata { + /// Whether the player won. + pub victory: Option, + /// Match ID. + pub match_id: Option, + /// Number of kills. + pub kills: u32, + /// Number of deaths. + pub deaths: u32, + /// Number of assists. + pub assists: u32, + /// Creep score (minions killed). + pub creep_score: u32, + /// Gold earned. + pub gold_earned: u32, + /// Damage dealt to champions. + pub damage_dealt: u64, + /// Damage taken. + pub damage_taken: u64, + /// Vision score. + pub vision_score: f64, + /// Game duration in seconds. + pub game_duration: f64, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_pre_game_metadata_default() { + let metadata = PreGameMetadata::default(); + assert!(metadata.champion_id.is_none()); + assert!(metadata.skin_id.is_none()); + assert!(metadata.summoner_name.is_none()); + } + + #[test] + fn test_game_end_metadata_default() { + let metadata = GameEndMetadata::default(); + assert!(metadata.victory.is_none()); + assert_eq!(metadata.kills, 0); + assert_eq!(metadata.deaths, 0); + assert_eq!(metadata.assists, 0); + } +} diff --git a/record-daemon/src/lqp/mod.rs b/record-daemon/src/lqp/mod.rs index 6bb504d..33446a7 100644 --- a/record-daemon/src/lqp/mod.rs +++ b/record-daemon/src/lqp/mod.rs @@ -5,12 +5,21 @@ mod auth; mod client; +mod endpoints; mod events; +mod mappings; +mod metadata; +mod state; +mod tls; +mod websocket; pub use auth::{LockfileCredentials, LockfileWatcher}; -pub use client::{ - champion_id_to_name, spell_id_to_name, GameEndMetadata, GameflowPhase, LqpClient, - PreGameMetadata, +pub use client::LqpClient; +pub use endpoints::{ + ALL_RUNE_PAGES, CHAMPION_SELECT, CHAMPION_SELECT_LOCAL_PLAYER, CHAMPION_SUMMARY, + GAMEFLOW_PHASE, GAME_STATS, LIVE_CLIENT_DATA, LIVE_CLIENT_DATA_ACTIVE_PLAYER, + LIVE_CLIENT_DATA_PLAYER_LIST, MATCH_HISTORY, RUNE_PAGES, SESSION, SUBSCRIBE_ENDPOINTS, + SUMMONER, }; pub use events::{ ChampSelectStartInfo, ChampionPickInfo, DeathEvent, EventData, GameEndInfo, GameEvent, @@ -18,3 +27,7 @@ pub use events::{ ObjectiveEvent, ObjectiveType, PlayerChampionSelection, PlayerGameMetadata, PlayerIdentity, QueueInfo, RunePage, RuneSlot, SummonerSpells, TeamMember, }; +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}; +pub use websocket::{parse_event_from_uri, parse_websocket_message}; diff --git a/record-daemon/src/lqp/state.rs b/record-daemon/src/lqp/state.rs new file mode 100644 index 0000000..df10b29 --- /dev/null +++ b/record-daemon/src/lqp/state.rs @@ -0,0 +1,94 @@ +//! LQP Client state management. +//! +//! Defines the game flow phases and client state tracking. + +/// Game flow phase states. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum GameflowPhase { + /// Client is in main menu or lobby. + #[default] + None, + /// In lobby. + Lobby, + /// In queue. + Queue, + /// Match found, ready check. + ReadyCheck, + /// In champion select. + ChampSelect, + /// Game is starting. + GameStart, + /// In game. + InProgress, + /// Game ended, waiting for stats. + WaitingForStats, + /// End of game stats screen. + EndOfGame, + /// Unknown phase. + Unknown, +} + +impl From<&str> for GameflowPhase { + fn from(s: &str) -> Self { + match s { + "None" => GameflowPhase::None, + "Lobby" => GameflowPhase::Lobby, + "Queue" => GameflowPhase::Queue, + "ReadyCheck" => GameflowPhase::ReadyCheck, + "ChampSelect" => GameflowPhase::ChampSelect, + "GameStart" => GameflowPhase::GameStart, + "InProgress" => GameflowPhase::InProgress, + "WaitingForStats" => GameflowPhase::WaitingForStats, + "EndOfGame" => GameflowPhase::EndOfGame, + _ => GameflowPhase::Unknown, + } + } +} + +/// LQP Client state. +#[derive(Debug, Clone)] +pub struct ClientState { + /// Current gameflow phase. + pub phase: GameflowPhase, + /// Current game ID if in game. + pub game_id: Option, + /// Current champion name. + pub champion: Option, + /// Current player's puuid. + pub local_puuid: Option, +} + +impl Default for ClientState { + fn default() -> Self { + Self { + phase: GameflowPhase::None, + game_id: None, + champion: None, + local_puuid: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_gameflow_phase_from_str() { + assert_eq!(GameflowPhase::from("InProgress"), GameflowPhase::InProgress); + assert_eq!( + GameflowPhase::from("ChampSelect"), + GameflowPhase::ChampSelect + ); + assert_eq!(GameflowPhase::from("UnknownPhase"), GameflowPhase::Unknown); + } + + #[test] + fn test_client_state_default() { + let state = ClientState::default(); + assert_eq!(state.phase, GameflowPhase::None); + assert!(state.game_id.is_none()); + assert!(state.champion.is_none()); + assert!(state.local_puuid.is_none()); + } +} diff --git a/record-daemon/src/lqp/tls.rs b/record-daemon/src/lqp/tls.rs new file mode 100644 index 0000000..b0a2ceb --- /dev/null +++ b/record-daemon/src/lqp/tls.rs @@ -0,0 +1,85 @@ +//! TLS configuration for LQP client. +//! +//! Provides custom certificate verification for the League Client's +//! self-signed certificates. + +use std::sync::Arc; + +/// Custom certificate verifier that accepts any certificate. +/// This is needed because the League Client uses a self-signed certificate. +#[derive(Debug)] +pub struct InsecureVerifier; + +impl rustls::client::danger::ServerCertVerifier for InsecureVerifier { + fn verify_server_cert( + &self, + _end_entity: &rustls::pki_types::CertificateDer<'_>, + _intermediates: &[rustls::pki_types::CertificateDer<'_>], + _server_name: &rustls::pki_types::ServerName<'_>, + _ocsp_response: &[u8], + _now: rustls::pki_types::UnixTime, + ) -> std::result::Result { + // Accept any certificate - League Client uses self-signed certificates + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &rustls::pki_types::CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> std::result::Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &rustls::pki_types::CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> std::result::Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + vec![ + rustls::SignatureScheme::RSA_PKCS1_SHA256, + rustls::SignatureScheme::RSA_PKCS1_SHA384, + rustls::SignatureScheme::RSA_PKCS1_SHA512, + rustls::SignatureScheme::ECDSA_NISTP256_SHA256, + rustls::SignatureScheme::ECDSA_NISTP384_SHA384, + rustls::SignatureScheme::ECDSA_NISTP521_SHA512, + rustls::SignatureScheme::RSA_PSS_SHA256, + rustls::SignatureScheme::RSA_PSS_SHA384, + rustls::SignatureScheme::RSA_PSS_SHA512, + rustls::SignatureScheme::ED25519, + ] + } +} + +/// Create a TLS client config that accepts self-signed certificates. +pub fn create_insecure_tls_config() -> Arc { + let config = rustls::ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(InsecureVerifier)) + .with_no_client_auth(); + + Arc::new(config) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_insecure_verifier_creation() { + let _verifier = InsecureVerifier; + } + + #[test] + fn test_tls_config_creation() { + let config = create_insecure_tls_config(); + // Verify the config was created successfully + assert!(Arc::strong_count(&config) >= 1); + } +} diff --git a/record-daemon/src/lqp/websocket.rs b/record-daemon/src/lqp/websocket.rs new file mode 100644 index 0000000..d9c1ed7 --- /dev/null +++ b/record-daemon/src/lqp/websocket.rs @@ -0,0 +1,482 @@ +//! WebSocket event parsing for LQP client. +//! +//! Handles parsing of WebSocket messages from the League Client +//! and converting them to game events. + +use tracing::{debug, info, warn}; + +use super::events::{GameEvent, GameflowSession}; +use super::mappings::{champion_id_to_name, map_id_to_name}; + +/// Parse a WebSocket message into a game event. +pub fn parse_websocket_message(text: &str, local_puuid: Option<&str>) -> Option { + // Parse the message array format: [type, callback, data] + let value: serde_json::Value = match serde_json::from_str(text) { + Ok(v) => v, + Err(e) => { + warn!("Failed to parse WebSocket message as JSON: {}", e); + return None; + } + }; + + // Check if it's an event message (type 8) + if let Some(arr) = value.as_array() { + if arr.len() >= 3 { + let msg_type = arr.first()?.as_u64()?; + + if msg_type == 8 { + // Event message format: [8, "OnJsonApiEvent", {"data": ..., "eventType": ..., "uri": ...}] + let callback = arr.get(1)?.as_str()?; + let event_data = arr.get(2)?; + + if callback == "OnJsonApiEvent" { + // Try to parse as RawEvent for type-safe access + if let Ok(raw_event) = + serde_json::from_value::(event_data.clone()) + { + let event_type = event_data + .get("eventType") + .and_then(|t| t.as_str()) + .unwrap_or("Update"); + return parse_event_from_uri( + &raw_event.uri, + event_type, + &serde_json::to_value(raw_event.data).unwrap_or_default(), + local_puuid, + ); + } + + // Fallback to manual extraction + let uri = event_data.get("uri")?.as_str()?; + let data = event_data.get("data")?; + let event_type = event_data + .get("eventType") + .and_then(|t| t.as_str()) + .unwrap_or("Update"); + + return parse_event_from_uri(uri, event_type, data, local_puuid); + } else { + debug!("Unknown callback: {}", callback); + } + } else if msg_type == 4 { + // Response to subscription - this is normal + debug!("Subscription response received"); + } else if msg_type == 0 { + // Welcome message + info!("WebSocket welcome message received"); + } else { + debug!("Unknown message type {msg_type} received"); + } + } + } else { + debug!("Message is not an array: {:?}", value); + } + + None +} + +/// Parse an event based on the URI. +pub fn parse_event_from_uri( + uri: &str, + event_type: &str, + data: &serde_json::Value, + local_puuid: Option<&str>, +) -> Option { + info!("Parsing event from URI: {} (type: {})", uri, event_type); + + // Handle gameflow phase changes + if uri == "/lol-gameflow/v1/gameflow-phase" { + return parse_gameflow_phase_event(data); + } + + // Handle gameflow session updates + if uri == "/lol-gameflow/v1/session" { + return parse_gameflow_session_event(data, local_puuid); + } + + // Handle game events (kills, deaths, objectives) + if uri == "/lol-game-events/v1/game-events" { + info!("Game event received: {:?}", data); + return GameEvent::from_json(data); + } + + // Handle ready check + if uri == "/lol-matchmaking/v1/ready-check" { + info!("Ready check event: {:?}", data); + return None; + } + + // Handle champion select + if uri == "/lol-champ-select/v1/session" { + return parse_champion_select_event(data); + } + + // Handle end-of-game stats block (contains actual game results) + if uri == "/lol-end-of-game/v1/eog-stats-block" { + return parse_end_of_game_stats(data); + } + + // Handle lobby + if uri.starts_with("/lol-lobby") { + debug!("Lobby event: {}", uri); + return None; + } + + debug!("Unhandled URI: {}", uri); + None +} + +/// Parse gameflow phase change event. +fn parse_gameflow_phase_event(data: &serde_json::Value) -> Option { + let phase = data.as_str()?; + info!("Gameflow phase changed to: {}", phase); + + // Only trigger GameEnd on EndOfGame phase (not WaitingForStats or PreEndOfGame) + // This ensures we wait for the stats to be available + if phase == "EndOfGame" { + info!("Game end phase detected: {}", phase); + // Generate a GameEnd event for timeline recording + return Some( + GameEvent::from_json(&serde_json::json!({ + "eventType": "lcu-game-end", + "gameId": 0, // Will be filled from state if available + "victory": false, // Will be updated from end-of-game stats + "duration": 0.0 + })) + .unwrap_or(GameEvent::Unknown), + ); + } + + // Update internal state based on phase + Some( + GameEvent::from_json(&serde_json::json!({ + "eventType": "lcu-phase-change", + "phase": phase + })) + .unwrap_or(GameEvent::Unknown), + ) +} + +/// Parse gameflow session event. +fn parse_gameflow_session_event( + data: &serde_json::Value, + local_puuid: Option<&str>, +) -> Option { + if let Some(phase) = data.get("phase").and_then(|p| p.as_str()) { + info!("Gameflow session phase: {}", phase); + + // Check for game start + if phase == "InProgress" { + return parse_game_start_event(data, local_puuid); + } + + return Some( + GameEvent::from_json(&serde_json::json!({ + "eventType": "lcu-phase-change", + "phase": phase + })) + .unwrap_or(GameEvent::Unknown), + ); + } + None +} + +/// Parse game start event from session data. +fn parse_game_start_event( + data: &serde_json::Value, + local_puuid: Option<&str>, +) -> Option { + info!("Game is now in progress!"); + + // Try to parse the gameData into a GameflowSession struct + let session: Option = data + .get("gameData") + .and_then(|gd| serde_json::from_value(gd.clone()).ok()); + + if let Some(ref session) = session { + debug!( + "Parsed GameflowSession: game_id={}, queue={:?}", + session.game_id, + session.queue_name() + ); + } else { + debug!("Failed to parse gameData as GameflowSession, falling back to manual extraction"); + } + + // Extract game_id - prefer from parsed session, fallback to manual extraction + let game_id = session.as_ref().map(|s| s.game_id).unwrap_or_else(|| { + data.get("gameData") + .and_then(|gd| gd.get("gameId")) + .and_then(|id| id.as_u64()) + .unwrap_or(0) + }); + + // Extract queue info (this is the same for all players) + let queue_type = session + .as_ref() + .and_then(|s| s.queue_name()) + .map(|s| s.to_string()) + .or_else(|| { + data.get("gameData") + .and_then(|gd| gd.get("queue")) + .and_then(|q| q.get("name")) + .and_then(|n| n.as_str()) + .map(|s| s.to_string()) + }); + + let queue_id = session.as_ref().and_then(|s| s.queue_id()); + + let game_mode = session + .as_ref() + .and_then(|s| s.game_mode()) + .map(|s| s.to_string()) + .or_else(|| { + data.get("gameData") + .and_then(|gd| gd.get("queue")) + .and_then(|q| q.get("gameMode")) + .and_then(|m| m.as_str()) + .map(|s| s.to_string()) + }); + + // Extract map name + let map_name = session + .as_ref() + .and_then(|s| s.map_id()) + .and_then(|id| map_id_to_name(id as u64)) + .or_else(|| { + data.get("gameData") + .and_then(|gd| gd.get("queue")) + .and_then(|q| q.get("mapId")) + .and_then(|id| id.as_u64()) + .and_then(map_id_to_name) + }); + + info!( + "Extracted game metadata: game_id={}, queue={:?}, queue_id={:?}, mode={:?}, map={:?}", + game_id, queue_type, queue_id, game_mode, map_name + ); + + // Extract player-specific data using puuid + let (champion, team, summoner_name) = if let Some(puuid) = local_puuid { + let champ_id = session.as_ref().and_then(|s| s.get_champion_id(puuid)); + let team_id = session.as_ref().and_then(|s| s.get_team(puuid)); + let summoner = session + .as_ref() + .and_then(|s| s.get_summoner_name(puuid)) + .map(|s| s.to_string()); + + // Convert champion_id to champion name + let champ_name = champ_id.and_then(champion_id_to_name); + + info!( + "Extracted player data via puuid: champion={:?}, team={:?}, summoner={:?}", + champ_name, team_id, summoner + ); + + (champ_name, team_id, summoner) + } else { + info!("No local_puuid available, cannot extract player-specific data"); + (None, None, None) + }; + + Some( + GameEvent::from_json(&serde_json::json!({ + "eventType": "lcu-game-start", + "gameId": game_id, + "queueType": queue_type, + "queueId": queue_id, + "gameMode": game_mode, + "map": map_name, + "champion": champion, + "team": team, + "summonerName": summoner_name, + "session": session + })) + .unwrap_or(GameEvent::Unknown), + ) +} + +/// Parse champion select event. +fn parse_champion_select_event(data: &serde_json::Value) -> Option { + info!("Champion select event: {:?}", data); + + // Check if we're in champion select phase + if let Some(timers) = data.get("timers") { + if let Some(phase) = timers.get("phase").and_then(|p| p.as_str()) { + if phase == "BAN_PICK" || phase == "FINALIZATION" { + // Extract local player's champion + if let Some(local_player_cell_id) = + data.get("localPlayerCellId").and_then(|id| id.as_i64()) + { + // Check both teams for the local player + for team_key in &["myTeam", "theirTeam"] { + if let Some(team) = data.get(team_key).and_then(|t| t.as_array()) { + for member in team { + if member.get("cellId").and_then(|id| id.as_i64()) + == Some(local_player_cell_id) + { + if let Some(champion_id) = + member.get("championId").and_then(|id| id.as_u64()) + { + if champion_id > 0 { + let champion_name = member + .get("championName") + .and_then(|n| n.as_str()) + .unwrap_or("Unknown") + .to_string(); + + return Some( + GameEvent::from_json(&serde_json::json!({ + "eventType": "lcu-champion-pick", + "summonerName": "LocalPlayer", + "championId": champion_id, + "championName": champion_name, + "isLocalPlayer": true + })) + .unwrap_or(GameEvent::Unknown), + ); + } + } + } + } + } + } + } + } + } + } + None +} + +/// Parse end-of-game stats. +fn parse_end_of_game_stats(data: &serde_json::Value) -> Option { + info!("End-of-game stats received: {:?}", data); + + // Extract game ID and duration + let game_id = data.get("gameId").and_then(|id| id.as_u64()).unwrap_or(0); + let game_duration = data + .get("gameLength") + .and_then(|d| d.as_f64()) + .unwrap_or(0.0); + + // Get local player data - prefer localPlayer field, fallback to teams[0].players[0] + let local_player = data.get("localPlayer"); + + // Extract victory status from local player's stats (WIN: 1 means victory) + let victory = local_player + .and_then(|p| p.get("stats")) + .and_then(|s| s.get("WIN")) + .and_then(|w| w.as_u64()) + .map(|w| w == 1) + .or_else(|| { + // Fallback: check if player's team is winning team + data.get("teams") + .and_then(|teams| teams.as_array()) + .and_then(|t| { + t.iter().find_map(|team| { + if team.get("isPlayerTeam").and_then(|p| p.as_bool()) == Some(true) { + team.get("isWinningTeam").and_then(|w| w.as_bool()) + } else { + None + } + }) + }) + }) + .unwrap_or(false); + + // Extract player stats - stats use UPPERCASE keys + let mut kills = 0u32; + let mut deaths = 0u32; + let mut assists = 0u32; + let mut creep_score = 0u32; + let mut gold_earned = 0u32; + let mut damage_dealt = 0u64; + let mut damage_taken = 0u64; + let mut vision_score = 0.0; + + if let Some(stats_obj) = local_player.and_then(|p| p.get("stats")) { + kills = stats_obj + .get("CHAMPIONS_KILLED") + .and_then(|k| k.as_u64()) + .unwrap_or(0) as u32; + deaths = stats_obj + .get("NUM_DEATHS") + .and_then(|d| d.as_u64()) + .unwrap_or(0) as u32; + assists = stats_obj + .get("ASSISTS") + .and_then(|a| a.as_u64()) + .unwrap_or(0) as u32; + creep_score = stats_obj + .get("MINIONS_KILLED") + .and_then(|cs| cs.as_u64()) + .unwrap_or(0) as u32; + gold_earned = stats_obj + .get("GOLD_EARNED") + .and_then(|g| g.as_u64()) + .unwrap_or(0) as u32; + damage_dealt = stats_obj + .get("TOTAL_DAMAGE_DEALT_TO_CHAMPIONS") + .and_then(|d| d.as_u64()) + .unwrap_or(0); + damage_taken = stats_obj + .get("TOTAL_DAMAGE_TAKEN") + .and_then(|d| d.as_u64()) + .unwrap_or(0); + vision_score = stats_obj + .get("VISION_SCORE") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + } + + info!("Extracted game end stats: kills={}, deaths={}, assists={}, cs={}, gold={}, damage_dealt={}, damage_taken={}, vision={}, victory={}", + kills, deaths, assists, creep_score, gold_earned, damage_dealt, damage_taken, vision_score, victory); + + // Generate a GameEnd event with actual stats + // Note: PlayerStats uses camelCase due to serde rename_all + let event_json = serde_json::json!({ + "eventType": "lcu-game-end", + "gameId": game_id, + "victory": victory, + "duration": game_duration, + "stats": { + "kills": kills, + "deaths": deaths, + "assists": assists, + "minionsKilled": creep_score, + "goldEarned": gold_earned, + "damageDealt": damage_dealt, + "damageTaken": damage_taken, + "visionScore": vision_score + } + }); + info!("Generating GameEnd event from eog-stats: {:?}", event_json); + + match GameEvent::from_json(&event_json) { + Some(event) => { + info!("Successfully parsed GameEnd event"); + Some(event) + } + None => { + warn!("Failed to parse GameEnd event, returning Unknown"); + Some(GameEvent::Unknown) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_websocket_message_invalid_json() { + let result = parse_websocket_message("not json", None); + assert!(result.is_none()); + } + + #[test] + fn test_parse_websocket_message_empty_array() { + let result = parse_websocket_message("[]", None); + assert!(result.is_none()); + } +}