From b64937601adb099e2a888e04359daabc9c9f4b21 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Fri, 27 Mar 2026 22:25:24 +0100 Subject: [PATCH] record-daemon: add record raw events, subscribe lp change events --- record-daemon/src/lqp/client.rs | 36 ++-- record-daemon/src/lqp/endpoints.rs | 8 + record-daemon/src/lqp/events.rs | 77 ++++++++ record-daemon/src/lqp/mod.rs | 2 +- record-daemon/src/lqp/websocket.rs | 261 ++++++++++++++++++++++++++-- record-daemon/src/main.rs | 13 +- record-daemon/src/timeline/mod.rs | 26 +++ record-daemon/src/timeline/store.rs | 8 +- 8 files changed, 401 insertions(+), 30 deletions(-) diff --git a/record-daemon/src/lqp/client.rs b/record-daemon/src/lqp/client.rs index e9eba90..86354e4 100644 --- a/record-daemon/src/lqp/client.rs +++ b/record-daemon/src/lqp/client.rs @@ -14,7 +14,7 @@ use super::endpoints; use super::events::GameEvent; use super::state::{ClientState, GameflowPhase}; use super::tls::create_insecure_tls_config; -use super::websocket::parse_websocket_message; +use super::websocket::{parse_websocket_message, ParsedEvent}; use crate::error::{LqpError, Result}; /// LQP Client for League Client communication. @@ -24,7 +24,7 @@ pub struct LqpClient { /// Current client state. state: Arc>, /// Event broadcaster. - event_sender: broadcast::Sender, + event_sender: broadcast::Sender, /// HTTP client for REST API. http_client: reqwest::Client, /// Shutdown signal. @@ -54,7 +54,7 @@ impl LqpClient { } /// Get a subscriber for game events. - pub fn subscribe(&self) -> broadcast::Receiver { + pub fn subscribe(&self) -> broadcast::Receiver { self.event_sender.subscribe() } @@ -184,12 +184,12 @@ impl LqpClient { if text.is_empty() { continue; } - if let Some(event) = parse_websocket_message(&text) { + if let Some(parsed) = parse_websocket_message(&text) { // Update state based on event - Self::update_state_from_event(&state, &event).await; + Self::update_state_from_event(&state, &parsed.event).await; // Check for duplicate GameStart events - if let GameEvent::GameStart(ref info) = event { + if let GameEvent::GameStart(ref info) = parsed.event { let mut last_game_id = last_emitted_game_id.write().await; if *last_game_id == Some(info.game_id) { info!( @@ -202,12 +202,12 @@ impl LqpClient { } // Reset last_emitted_game_id on GameEnd to allow new game starts - if let GameEvent::GameEnd(_) = &event { + if let GameEvent::GameEnd(_) = &parsed.event { *last_emitted_game_id.write().await = None; } // Broadcast event - if event_sender.send(event.clone()).is_err() { + if event_sender.send(parsed).is_err() { trace!("No event subscribers"); } } @@ -217,12 +217,12 @@ impl LqpClient { // Try to parse as UTF-8 if let Ok(text) = String::from_utf8(data) { if !text.is_empty() { - if let Some(event) = parse_websocket_message(&text) { + if let Some(parsed) = parse_websocket_message(&text) { // Update state based on event - Self::update_state_from_event(&state, &event).await; + Self::update_state_from_event(&state, &parsed.event).await; // Check for duplicate GameStart events - if let GameEvent::GameStart(ref info) = event { + if let GameEvent::GameStart(ref info) = parsed.event { let mut last_game_id = last_emitted_game_id.write().await; if *last_game_id == Some(info.game_id) { info!( @@ -235,12 +235,12 @@ impl LqpClient { } // Reset last_emitted_game_id on GameEnd to allow new game starts - if let GameEvent::GameEnd(_) = &event { + if let GameEvent::GameEnd(_) = &parsed.event { *last_emitted_game_id.write().await = None; } // Broadcast event - if event_sender.send(event.clone()).is_err() { + if event_sender.send(parsed).is_err() { trace!("No event subscribers"); } } @@ -448,6 +448,16 @@ impl LqpClient { pub async fn fetch_raw_end_game_stats(&self) -> Result { self.request("GET", endpoints::GAME_STATS).await } + + /// Fetch ranked stats as JSON (for LP tracking). + pub async fn fetch_ranked_stats(&self) -> Result { + self.request("GET", endpoints::RANKED_STATS).await + } + + /// Fetch current ranked stats as JSON. + pub async fn fetch_current_ranked_stats(&self) -> Result { + self.request("GET", endpoints::CURRENT_RANKED_STATS).await + } } impl Default for LqpClient { diff --git a/record-daemon/src/lqp/endpoints.rs b/record-daemon/src/lqp/endpoints.rs index 076f7ef..8e66df7 100644 --- a/record-daemon/src/lqp/endpoints.rs +++ b/record-daemon/src/lqp/endpoints.rs @@ -22,6 +22,12 @@ pub const ALL_RUNE_PAGES: &str = "/lol-perks/v1/pages"; 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"; +/// Ranked stats endpoint (for LP tracking). +pub const RANKED_STATS: &str = "/lol-ranked/v1/ranked-stats"; +/// Current ranked stats for the local player. +pub const CURRENT_RANKED_STATS: &str = "/lol-ranked/v1/current-ranked-stats"; +/// LP change notification endpoint. +pub const LP_CHANGE_NOTIFICATION: &str = "/lol-ranked/v1/current-lp-change-notification"; /// Live client active player endpoint. pub const LIVE_CLIENT_DATA_ACTIVE_PLAYER: &str = "/liveclientdata/activeplayer"; /// Live client player list endpoint. @@ -38,6 +44,8 @@ pub const SUBSCRIBE_ENDPOINTS: &[&str] = &[ CHAMPION_SELECT, "/lol-lobby/v2/lobby", GAME_STATS, + RANKED_STATS, + LP_CHANGE_NOTIFICATION, ]; #[cfg(test)] diff --git a/record-daemon/src/lqp/events.rs b/record-daemon/src/lqp/events.rs index 4ed10f4..9773497 100644 --- a/record-daemon/src/lqp/events.rs +++ b/record-daemon/src/lqp/events.rs @@ -49,6 +49,10 @@ pub enum GameEvent { #[serde(rename = "lcu-phase-change")] PhaseChange(PhaseChangeInfo), + /// LP changed after a ranked game. + #[serde(rename = "lcu-lp-change")] + LpChange(LpChangeInfo), + /// Unknown event type. #[serde(other)] Unknown, @@ -66,6 +70,66 @@ pub struct PhaseChangeInfo { pub timestamp: DateTime, } +/// LP change event data after a ranked game. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LpChangeInfo { + /// Queue type (RANKED_SOLO_5x5, RANKED_FLEX_SR, etc.). + pub queue_type: String, + + /// LP change amount (positive for gain, negative for loss). + pub lp_change: i32, + + /// LP before the game. + pub lp_before: i32, + + /// LP after the game. + pub lp_after: i32, + + /// Current tier (IRON, BRONZE, SILVER, GOLD, PLATINUM, DIAMOND, MASTER, GRANDMASTER, CHALLENGER). + pub tier: String, + + /// Current division (I, II, III, IV). + #[serde(default)] + pub division: Option, + + /// Current league points. + #[serde(default)] + pub league_points: Option, + + /// Whether the player is in a promotional series. + #[serde(default)] + pub in_promos: bool, + + /// Promotional series progress (e.g., "W:2 L:1"). + #[serde(default)] + pub promo_progress: Option, + + /// Number of wins in promo series. + #[serde(default)] + pub promo_wins: Option, + + /// Number of losses in promo series. + #[serde(default)] + pub promo_losses: Option, + + /// Total games played in this queue. + #[serde(default)] + pub total_games: Option, + + /// Total wins in this queue. + #[serde(default)] + pub total_wins: Option, + + /// Total losses in this queue. + #[serde(default)] + pub total_losses: Option, + + /// Timestamp when LP change occurred. + #[serde(default = "Utc::now")] + pub timestamp: DateTime, +} + /// Match found event data. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -462,6 +526,18 @@ impl GameEvent { GameEvent::PhaseChange(info) => { format!("Phase changed to: {}", info.phase) } + GameEvent::LpChange(lp) => { + let sign = if lp.lp_change >= 0 { "+" } else { "" }; + format!( + "LP Change: {}{} LP ({} {} {} -> {} LP)", + sign, + lp.lp_change, + lp.tier, + lp.division.as_deref().unwrap_or(""), + lp.queue_type, + lp.lp_after + ) + } GameEvent::Unknown => "Unknown event".to_string(), } } @@ -479,6 +555,7 @@ impl GameEvent { GameEvent::StatsUpdate(_) => "stats_update", GameEvent::GameEnd(_) => "game_end", GameEvent::PhaseChange(_) => "phase_change", + GameEvent::LpChange(_) => "lp_change", GameEvent::Unknown => "unknown", } } diff --git a/record-daemon/src/lqp/mod.rs b/record-daemon/src/lqp/mod.rs index 5097cef..fcfe354 100644 --- a/record-daemon/src/lqp/mod.rs +++ b/record-daemon/src/lqp/mod.rs @@ -26,4 +26,4 @@ pub use events::{ QueueInfo, RunePage, RuneSlot, SummonerSpells, TeamMember, }; pub use state::{ClientState, GameflowPhase}; -pub use websocket::{parse_event_from_uri, parse_websocket_message}; +pub use websocket::{parse_event_from_uri, parse_websocket_message, ParsedEvent}; diff --git a/record-daemon/src/lqp/websocket.rs b/record-daemon/src/lqp/websocket.rs index 95defd5..432dde1 100644 --- a/record-daemon/src/lqp/websocket.rs +++ b/record-daemon/src/lqp/websocket.rs @@ -7,8 +7,19 @@ use tracing::{debug, info, warn}; use super::events::{GameEvent, GameflowSession}; -/// Parse a WebSocket message into a game event. -pub fn parse_websocket_message(text: &str) -> Option { +/// Parsed event with raw data preserved. +#[derive(Debug, Clone)] +pub struct ParsedEvent { + /// The parsed game event. + pub event: GameEvent, + /// The raw JSON data from the API. + pub raw_data: serde_json::Value, + /// The URI of the endpoint that triggered this event. + pub uri: String, +} + +/// Parse a WebSocket message into a parsed event with raw data. +pub fn parse_websocket_message(text: &str) -> Option { // Parse the message array format: [type, callback, data] let value: serde_json::Value = match serde_json::from_str(text) { Ok(v) => v, @@ -37,22 +48,33 @@ pub fn parse_websocket_message(text: &str) -> Option { .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(), - ); + let raw_data = + serde_json::to_value(raw_event.data.clone()).unwrap_or_default(); + let uri = raw_event.uri.clone(); + if let Some(event) = parse_event_from_uri(&uri, event_type, &raw_data) { + return Some(ParsedEvent { + event, + raw_data, + uri, + }); + } } // Fallback to manual extraction - let uri = event_data.get("uri")?.as_str()?; - let data = event_data.get("data")?; + let uri = event_data.get("uri")?.as_str()?.to_string(); + let data = event_data.get("data")?.clone(); 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); + if let Some(event) = parse_event_from_uri(&uri, event_type, &data) { + return Some(ParsedEvent { + event, + raw_data: data, + uri, + }); + } } else { debug!("Unknown callback: {}", callback); } @@ -113,6 +135,16 @@ pub fn parse_event_from_uri( return parse_end_of_game_stats(data); } + // Handle LP change notifications + if uri == "/lol-ranked/v1/current-lp-change-notification" { + return parse_lp_change_notification(data); + } + + // Handle ranked stats updates (with UUID suffix) + if uri.starts_with("/lol-ranked/v1/ranked-stats/") { + return parse_ranked_stats_event(data); + } + // Handle lobby if uri.starts_with("/lol-lobby") { debug!("Lobby event: {}", uri); @@ -423,6 +455,215 @@ fn parse_end_of_game_stats(data: &serde_json::Value) -> Option { } } +/// Parse LP change notification event. +/// +/// This is the primary source for LP changes after a ranked game. +/// The notification contains the LP delta and current rank info. +fn parse_lp_change_notification(data: &serde_json::Value) -> Option { + info!("LP change notification received: {:?}", data); + + // Extract queue type + let queue_type = data + .get("queueType") + .and_then(|q| q.as_str()) + .unwrap_or("UNKNOWN") + .to_string(); + + // Extract LP change amount + let lp_change = data.get("lpChange").and_then(|lp| lp.as_i64()).unwrap_or(0) as i32; + + // Extract LP before and after + let lp_before = data.get("lpBefore").and_then(|lp| lp.as_i64()).unwrap_or(0) as i32; + + let lp_after = data.get("lpAfter").and_then(|lp| lp.as_i64()).unwrap_or(0) as i32; + + // Extract tier and division + let tier = data + .get("tier") + .and_then(|t| t.as_str()) + .unwrap_or("UNRANKED") + .to_string(); + + let division = data + .get("division") + .and_then(|d| d.as_str()) + .map(|s| s.to_string()); + + // Check for promotional series + let in_promos = data.get("miniSeries").is_some(); + + let promo_progress = if in_promos { + data.get("miniSeries") + .and_then(|ms| ms.get("progress")) + .and_then(|p| p.as_str()) + .map(|s| s.to_string()) + } else { + None + }; + + let promo_wins = data + .get("miniSeries") + .and_then(|ms| ms.get("wins")) + .and_then(|w| w.as_u64()) + .map(|w| w as u32); + + let promo_losses = data + .get("miniSeries") + .and_then(|ms| ms.get("losses")) + .and_then(|l| l.as_u64()) + .map(|l| l as u32); + + // Extract total games stats if available + let total_wins = data.get("wins").and_then(|w| w.as_u64()).unwrap_or(0) as u32; + + let total_losses = data.get("losses").and_then(|l| l.as_u64()).unwrap_or(0) as u32; + + let total_games = total_wins + total_losses; + + info!( + "LP change notification: {} {} LP change: {} ({} -> {})", + queue_type, tier, lp_change, lp_before, lp_after + ); + + let event_json = serde_json::json!({ + "eventType": "lcu-lp-change", + "queueType": queue_type, + "lpChange": lp_change, + "lpBefore": lp_before, + "lpAfter": lp_after, + "tier": tier, + "division": division, + "leaguePoints": lp_after, + "inPromos": in_promos, + "promoProgress": promo_progress, + "promoWins": promo_wins, + "promoLosses": promo_losses, + "totalGames": total_games, + "totalWins": total_wins, + "totalLosses": total_losses + }); + + GameEvent::from_json(&event_json) +} + +/// Parse ranked stats event for LP changes. +/// +/// The ranked stats endpoint provides updates when LP changes occur. +/// We extract the queue-specific data and emit an LpChange event. +fn parse_ranked_stats_event(data: &serde_json::Value) -> Option { + info!("Ranked stats event received: {:?}", data); + + // The ranked stats data contains queue-specific stats + // We look for RANKED_SOLO_5x5 and RANKED_FLEX_SR queues + let queues = data.get("queues")?.as_array()?; + + for queue_data in queues { + let queue_type = queue_data + .get("queueType") + .and_then(|q| q.as_str()) + .unwrap_or(""); + + // Only process ranked queues + if queue_type != "RANKED_SOLO_5x5" && queue_type != "RANKED_FLEX_SR" { + continue; + } + + // Extract tier and division + let tier = queue_data + .get("tier") + .and_then(|t| t.as_str()) + .unwrap_or("UNRANKED") + .to_string(); + + let division = queue_data + .get("division") + .and_then(|d| d.as_str()) + .map(|s| s.to_string()); + + // Extract LP + let league_points = queue_data + .get("leaguePoints") + .and_then(|lp| lp.as_i64()) + .unwrap_or(0) as i32; + + // Extract previous LP if available (for calculating change) + let previous_lp = queue_data + .get("previousLeaguePoints") + .and_then(|lp| lp.as_i64()) + .unwrap_or(league_points as i64) as i32; + + // Calculate LP change + let lp_change = league_points - previous_lp; + + // Only emit event if there was an actual change + if lp_change == 0 { + continue; + } + + // Check for promotional series + let in_promos = queue_data.get("miniSeries").is_some(); + + let promo_progress = if in_promos { + queue_data + .get("miniSeries") + .and_then(|ms| ms.get("progress")) + .and_then(|p| p.as_str()) + .map(|s| s.to_string()) + } else { + None + }; + + let promo_wins = queue_data + .get("miniSeries") + .and_then(|ms| ms.get("wins")) + .and_then(|w| w.as_u64()) + .map(|w| w as u32); + + let promo_losses = queue_data + .get("miniSeries") + .and_then(|ms| ms.get("losses")) + .and_then(|l| l.as_u64()) + .map(|l| l as u32); + + // Extract total games stats + let total_wins = queue_data.get("wins").and_then(|w| w.as_u64()).unwrap_or(0) as u32; + + let total_losses = queue_data + .get("losses") + .and_then(|l| l.as_u64()) + .unwrap_or(0) as u32; + + let total_games = total_wins + total_losses; + + info!( + "LP change detected: {} {} LP change: {} ({} -> {})", + queue_type, tier, lp_change, previous_lp, league_points + ); + + let event_json = serde_json::json!({ + "eventType": "lcu-lp-change", + "queueType": queue_type, + "lpChange": lp_change, + "lpBefore": previous_lp, + "lpAfter": league_points, + "tier": tier, + "division": division, + "leaguePoints": league_points, + "inPromos": in_promos, + "promoProgress": promo_progress, + "promoWins": promo_wins, + "promoLosses": promo_losses, + "totalGames": total_games, + "totalWins": total_wins, + "totalLosses": total_losses + }); + + return GameEvent::from_json(&event_json); + } + + None +} + #[cfg(test)] mod tests { use super::*; diff --git a/record-daemon/src/main.rs b/record-daemon/src/main.rs index 3ee3b88..10f5649 100644 --- a/record-daemon/src/main.rs +++ b/record-daemon/src/main.rs @@ -223,11 +223,12 @@ impl Daemon { } /// Handle a game event. - async fn handle_game_event(&self, event: GameEvent) -> Result<()> { + async fn handle_game_event(&self, parsed: record_daemon::lqp::ParsedEvent) -> Result<()> { + let event = &parsed.event; info!("[EVENT_HANDLER] Game event received: {:?}", event); // Handle pre-game data collection - match &event { + match event { GameEvent::PhaseChange(info) if info.phase == "ChampSelect" => { info!("[EVENT_HANDLER] Champion select started"); } @@ -249,10 +250,10 @@ impl Daemon { // Record event to timeline if recording (BEFORE state transition for GameEnd) // This ensures GameEnd events are recorded while still in recording state if self.state_machine.is_recording() { - if let Some((video_ts, game_ts)) = self.event_mapper.write().handle_event(&event) { + if let Some((video_ts, game_ts)) = self.event_mapper.write().handle_event(event) { // Get the current recording ID if let Some(recording_id) = *self.current_recording_id.read() { - // Create a timestamped event + // Create a timestamped event with raw data let timestamped_event = TimestampedEvent { video_timestamp: video_ts, game_timestamp: game_ts, @@ -260,6 +261,8 @@ impl Daemon { event_type: event.event_type_name().to_string(), description: event.description(), event: event.clone(), + raw_data: Some(parsed.raw_data.clone()), + uri: Some(parsed.uri.clone()), }; // Add the event to the timeline store @@ -284,7 +287,7 @@ impl Daemon { } // Process state transitions - if let Some(transition) = self.state_machine.process_event(&event) { + if let Some(transition) = self.state_machine.process_event(event) { info!("[EVENT_HANDLER] State transition: {:?}", transition); // Only process the transition if it's valid diff --git a/record-daemon/src/timeline/mod.rs b/record-daemon/src/timeline/mod.rs index a072b69..9c44479 100644 --- a/record-daemon/src/timeline/mod.rs +++ b/record-daemon/src/timeline/mod.rs @@ -89,6 +89,31 @@ impl Timeline { event_type: event_type_name(&event), description: event.description(), event, + raw_data: None, + uri: None, + }; + + self.events.push(timestamped); + } + + /// Add an event to the timeline with raw data. + pub fn add_event_with_raw( + &mut self, + event: GameEvent, + video_timestamp: Duration, + game_timestamp: Option, + raw_data: serde_json::Value, + uri: String, + ) { + let timestamped = TimestampedEvent { + video_timestamp, + game_timestamp, + timestamp: Utc::now(), + event_type: event_type_name(&event), + description: event.description(), + event, + raw_data: Some(raw_data), + uri: Some(uri), }; self.events.push(timestamped); @@ -176,6 +201,7 @@ fn event_type_name(event: &GameEvent) -> String { GameEvent::StatsUpdate(_) => "stats_update", GameEvent::GameEnd(_) => "game_end", GameEvent::PhaseChange(_) => "phase_change", + GameEvent::LpChange(_) => "lp_change", GameEvent::Unknown => "unknown", } .to_string() diff --git a/record-daemon/src/timeline/store.rs b/record-daemon/src/timeline/store.rs index 0217e22..cdcc2ad 100644 --- a/record-daemon/src/timeline/store.rs +++ b/record-daemon/src/timeline/store.rs @@ -23,12 +23,18 @@ pub struct TimestampedEvent { pub game_timestamp: Option, /// Real-world timestamp. pub timestamp: DateTime, - /// Event type name. + /// Event type name (derived from URI). pub event_type: String, /// Human-readable description. pub description: String, /// The actual event data. pub event: GameEvent, + /// Raw JSON data from the API (for flexibility). + #[serde(default)] + pub raw_data: Option, + /// URI of the endpoint that triggered this event. + #[serde(default)] + pub uri: Option, } /// Metadata for a recording.