Files
leaguerecorder/record-daemon/src/lqp/websocket.rs
Valentin Haudiquet fcfa55d0aa
Some checks are pending
record-daemon / Build, check and test (push) Waiting to run
record raw events everywhere
2026-05-06 23:53:01 +02:00

327 lines
11 KiB
Rust

//! 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<ParsedEvent> {
// 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<ParsedEvent> {
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");
}
}