record-daemon: refactor client.rs
This commit is contained in:
482
record-daemon/src/lqp/websocket.rs
Normal file
482
record-daemon/src/lqp/websocket.rs
Normal file
@@ -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<GameEvent> {
|
||||
// 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::<super::events::RawEvent>(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<GameEvent> {
|
||||
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<GameEvent> {
|
||||
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<GameEvent> {
|
||||
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<GameEvent> {
|
||||
info!("Game is now in progress!");
|
||||
|
||||
// Try to parse the gameData into a GameflowSession struct
|
||||
let session: Option<GameflowSession> = 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<GameEvent> {
|
||||
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<GameEvent> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user