Some checks are pending
record-daemon / Build, check and test (push) Waiting to run
327 lines
11 KiB
Rust
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");
|
|
}
|
|
}
|