//! WebSocket event parsing for LQP client. //! //! Handles parsing of WebSocket messages from the League Client //! and classifying them by event type based on URI. //! All raw JSON data is preserved as-is — no field extraction or remapping. use tracing::{debug, info, warn}; use super::events::{ describe_event, EVENT_TYPE_CHAMPION_PICK, EVENT_TYPE_CHAMP_SELECT_START, EVENT_TYPE_DEATH, EVENT_TYPE_GAME_END, EVENT_TYPE_GAME_START, EVENT_TYPE_KILL, EVENT_TYPE_LP_CHANGE, EVENT_TYPE_OBJECTIVE, EVENT_TYPE_PHASE_CHANGE, EVENT_TYPE_UNKNOWN, }; /// Parsed event with raw data preserved. #[derive(Debug, Clone)] pub struct ParsedEvent { /// Event type string derived from URI (e.g. "game_start", "lp_change"). pub event_type: String, /// The raw JSON data from the API — stored as-is, no remapping. 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, 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" { let uri = event_data.get("uri")?.as_str()?.to_string(); let data = event_data.get("data")?.clone(); let event_action = event_data .get("eventType") .and_then(|t| t.as_str()) .unwrap_or("Update"); let event_type = classify_event_from_uri(&uri, event_action, &data); let description = describe_event(&event_type, &data); info!( "Event classified: type={}, uri={}, desc={}", event_type, uri, description ); return Some(ParsedEvent { event_type, raw_data: data, uri, }); } 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 } /// Classify an event based on the URI. /// /// Returns an event type string. The raw data is NOT modified — /// all original API fields are preserved as-is. pub fn classify_event_from_uri(uri: &str, _event_action: &str, data: &serde_json::Value) -> String { // Handle gameflow phase changes if uri == "/lol-gameflow/v1/gameflow-phase" { return EVENT_TYPE_PHASE_CHANGE.to_string(); } // Handle gameflow session updates if uri == "/lol-gameflow/v1/session" { if let Some(phase) = data.get("phase").and_then(|p| p.as_str()) { if phase == "InProgress" { return EVENT_TYPE_GAME_START.to_string(); } return EVENT_TYPE_PHASE_CHANGE.to_string(); } return EVENT_TYPE_UNKNOWN.to_string(); } // Handle game events (kills, deaths, objectives) if uri == "/lol-game-events/v1/game-events" { return classify_game_event(data); } // Handle ready check if uri == "/lol-matchmaking/v1/ready-check" { debug!("Ready check event — not recorded"); return EVENT_TYPE_UNKNOWN.to_string(); } // Handle champion select if uri == "/lol-champ-select/v1/session" { return classify_champion_select_event(data); } // Handle end-of-game stats block if uri == "/lol-end-of-game/v1/eog-stats-block" { return EVENT_TYPE_GAME_END.to_string(); } // Handle LP change notifications if uri == "/lol-ranked/v1/current-lp-change-notification" { return EVENT_TYPE_LP_CHANGE.to_string(); } // Handle ranked stats updates (with UUID suffix) if uri.starts_with("/lol-ranked/v1/ranked-stats/") { return EVENT_TYPE_LP_CHANGE.to_string(); } // Handle lobby if uri.starts_with("/lol-lobby") { debug!("Lobby event: {}", uri); return EVENT_TYPE_UNKNOWN.to_string(); } debug!("Unhandled URI: {}", uri); EVENT_TYPE_UNKNOWN.to_string() } /// Classify a champion select event based on the data. fn classify_champion_select_event(data: &serde_json::Value) -> String { // Check if we're in a pick phase and a champion has been selected 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" { // Check if local player has picked a champion if let Some(local_player_cell_id) = data.get("localPlayerCellId").and_then(|id| id.as_i64()) { 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 { return EVENT_TYPE_CHAMPION_PICK.to_string(); } } } } } } } } } } EVENT_TYPE_CHAMP_SELECT_START.to_string() } /// Classify an in-game event based on EventName field. fn classify_game_event(data: &serde_json::Value) -> String { let event_name = data .get("EventName") .or_else(|| data.get("eventName")) .and_then(|n| n.as_str()) .unwrap_or(""); match event_name { "ChampionKill" => EVENT_TYPE_KILL.to_string(), "ChampionDeath" => EVENT_TYPE_DEATH.to_string(), "DragonKill" | "BaronKill" | "HeraldKill" | "RiftHeraldKill" | "ElderDragonKill" => { EVENT_TYPE_OBJECTIVE.to_string() } "TurretKill" | "InhibitorKill" | "NexusKill" => EVENT_TYPE_OBJECTIVE.to_string(), _ => { debug!("Unknown game event type: {}", event_name); EVENT_TYPE_UNKNOWN.to_string() } } } /// Parse events from the Live Client Data API (port 2999). /// /// The Live Client Data API provides real-time game events including: /// - Champion kills and deaths /// - Objective kills (Dragon, Baron, Herald, etc.) /// - Building destruction (Turrets, Inhibitors) /// /// Event format from /liveclientdata/eventdata: /// ```json /// { /// "EventID": 1, /// "EventName": "ChampionKill", /// "EventTime": 123.456, /// "KillerName": "Player1", /// "VictimName": "Player2", /// "Assisters": ["Player3"], /// ... /// } /// ``` pub fn parse_live_client_event(data: &serde_json::Value) -> Option { let event_name = data.get("EventName").and_then(|n| n.as_str()).unwrap_or(""); let event_type = match event_name { "ChampionKill" => EVENT_TYPE_KILL.to_string(), "ChampionDeath" => EVENT_TYPE_DEATH.to_string(), "DragonKill" | "BaronKill" | "HeraldKill" | "RiftHeraldKill" | "ElderDragonKill" => { EVENT_TYPE_OBJECTIVE.to_string() } "TurretKill" | "InhibitorKill" | "NexusKill" => EVENT_TYPE_OBJECTIVE.to_string(), "Multikill" | "FirstBlood" | "GameStart" | "GameEnd" => { // These are derived/special events — the kill/death events cover them return None; } _ => { debug!("Unknown live client event type: {}", event_name); return None; } }; info!( "Live client event classified: type={}, name={}", event_type, event_name ); Some(ParsedEvent { event_type, raw_data: data.clone(), uri: "/liveclientdata/eventdata".to_string(), }) } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_websocket_message_invalid_json() { let result = parse_websocket_message("not json"); assert!(result.is_none()); } #[test] fn test_parse_websocket_message_empty_array() { let result = parse_websocket_message("[]"); assert!(result.is_none()); } #[test] fn test_classify_game_event_kill() { let data = serde_json::json!({ "EventName": "ChampionKill", "KillerName": "Player1", "VictimName": "Player2" }); assert_eq!(classify_game_event(&data), EVENT_TYPE_KILL); } #[test] fn test_classify_game_event_objective() { let data = serde_json::json!({ "EventName": "DragonKill" }); assert_eq!(classify_game_event(&data), EVENT_TYPE_OBJECTIVE); } #[test] fn test_classify_event_from_uri_lp_change() { let data = serde_json::json!({"lpChange": 22, "tier": "GOLD"}); assert_eq!( classify_event_from_uri( "/lol-ranked/v1/current-lp-change-notification", "Update", &data ), EVENT_TYPE_LP_CHANGE ); } #[test] fn test_classify_event_from_uri_game_end() { let data = serde_json::json!({"gameId": 123}); assert_eq!( classify_event_from_uri("/lol-end-of-game/v1/eog-stats-block", "Update", &data), EVENT_TYPE_GAME_END ); } #[test] fn test_parse_live_client_event_kill() { let data = serde_json::json!({ "EventName": "ChampionKill", "KillerName": "Player1", "VictimName": "Player2", "Assisters": [] }); let result = parse_live_client_event(&data).unwrap(); assert_eq!(result.event_type, EVENT_TYPE_KILL); // Raw data is preserved as-is assert_eq!(result.raw_data["KillerName"], "Player1"); } }