record-daemon: refactor client.rs
This commit is contained in:
@@ -10,155 +10,15 @@ use tokio_tungstenite::{connect_async_tls_with_config, tungstenite::protocol::Me
|
|||||||
use tracing::{debug, error, info, trace, warn};
|
use tracing::{debug, error, info, trace, warn};
|
||||||
|
|
||||||
use super::auth::LockfileCredentials;
|
use super::auth::LockfileCredentials;
|
||||||
use super::events::{GameEvent, GameflowSession, ItemBuild, ItemInfo, RawEvent};
|
use super::endpoints;
|
||||||
|
use super::events::{GameEvent, ItemBuild, ItemInfo};
|
||||||
|
use super::mappings::{champion_id_to_name, spell_id_to_name};
|
||||||
|
use super::metadata::{GameEndMetadata, PreGameMetadata};
|
||||||
|
use super::state::{ClientState, GameflowPhase};
|
||||||
|
use super::tls::create_insecure_tls_config;
|
||||||
|
use super::websocket::parse_websocket_message;
|
||||||
use crate::error::{LqpError, Result};
|
use crate::error::{LqpError, Result};
|
||||||
|
|
||||||
/// Custom certificate verifier that accepts any certificate.
|
|
||||||
/// This is needed because the League Client uses a self-signed certificate.
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct InsecureVerifier;
|
|
||||||
|
|
||||||
impl rustls::client::danger::ServerCertVerifier for InsecureVerifier {
|
|
||||||
fn verify_server_cert(
|
|
||||||
&self,
|
|
||||||
_end_entity: &rustls::pki_types::CertificateDer<'_>,
|
|
||||||
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
|
|
||||||
_server_name: &rustls::pki_types::ServerName<'_>,
|
|
||||||
_ocsp_response: &[u8],
|
|
||||||
_now: rustls::pki_types::UnixTime,
|
|
||||||
) -> std::result::Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
|
||||||
// Accept any certificate - League Client uses self-signed certificates
|
|
||||||
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn verify_tls12_signature(
|
|
||||||
&self,
|
|
||||||
_message: &[u8],
|
|
||||||
_cert: &rustls::pki_types::CertificateDer<'_>,
|
|
||||||
_dss: &rustls::DigitallySignedStruct,
|
|
||||||
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
|
||||||
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn verify_tls13_signature(
|
|
||||||
&self,
|
|
||||||
_message: &[u8],
|
|
||||||
_cert: &rustls::pki_types::CertificateDer<'_>,
|
|
||||||
_dss: &rustls::DigitallySignedStruct,
|
|
||||||
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
|
||||||
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
|
||||||
vec![
|
|
||||||
rustls::SignatureScheme::RSA_PKCS1_SHA256,
|
|
||||||
rustls::SignatureScheme::RSA_PKCS1_SHA384,
|
|
||||||
rustls::SignatureScheme::RSA_PKCS1_SHA512,
|
|
||||||
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
|
|
||||||
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
|
|
||||||
rustls::SignatureScheme::ECDSA_NISTP521_SHA512,
|
|
||||||
rustls::SignatureScheme::RSA_PSS_SHA256,
|
|
||||||
rustls::SignatureScheme::RSA_PSS_SHA384,
|
|
||||||
rustls::SignatureScheme::RSA_PSS_SHA512,
|
|
||||||
rustls::SignatureScheme::ED25519,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// LQP WebSocket endpoints to subscribe to.
|
|
||||||
const SUBSCRIBE_ENDPOINTS: &[&str] = &[
|
|
||||||
"/lol-gameflow/v1/gameflow-phase",
|
|
||||||
"/lol-gameflow/v1/session",
|
|
||||||
"/lol-matchmaking/v1/ready-check",
|
|
||||||
"/lol-game-events/v1/game-events",
|
|
||||||
"/lol-champ-select/v1/session",
|
|
||||||
"/lol-lobby/v2/lobby",
|
|
||||||
"/lol-end-of-game/v1/eog-stats-block",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// LQP REST API endpoints.
|
|
||||||
pub mod endpoints {
|
|
||||||
pub const GAMEFLOW_PHASE: &str = "/lol-gameflow/v1/gameflow-phase";
|
|
||||||
pub const SESSION: &str = "/lol-gameflow/v1/session";
|
|
||||||
pub const CHAMPION_SELECT: &str = "/lol-champ-select/v1/session";
|
|
||||||
pub const SUMMONER: &str = "/lol-summoner/v1/current-summoner";
|
|
||||||
pub const GAME_STATS: &str = "/lol-end-of-game/v1/eog-stats-block";
|
|
||||||
pub const CHAMPION_SUMMARY: &str = "/lol-champ-select/v1/current-champion";
|
|
||||||
pub const RUNE_PAGES: &str = "/lol-perks/v1/currentpage";
|
|
||||||
pub const ALL_RUNE_PAGES: &str = "/lol-perks/v1/pages";
|
|
||||||
pub const MATCH_HISTORY: &str = "/lol-match-history/v1/products/lol/current-summoner/matches";
|
|
||||||
pub const LIVE_CLIENT_DATA: &str = "/liveclientdata/allgamedata";
|
|
||||||
pub const LIVE_CLIENT_DATA_ACTIVE_PLAYER: &str = "/liveclientdata/activeplayer";
|
|
||||||
pub const LIVE_CLIENT_DATA_PLAYER_LIST: &str = "/liveclientdata/playerlist";
|
|
||||||
pub const CHAMPION_SELECT_LOCAL_PLAYER: &str = "/lol-champ-select/v1/session/my-selection";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Game flow phase states.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum GameflowPhase {
|
|
||||||
/// Client is in main menu or lobby.
|
|
||||||
None,
|
|
||||||
/// In lobby.
|
|
||||||
Lobby,
|
|
||||||
/// In queue.
|
|
||||||
Queue,
|
|
||||||
/// Match found, ready check.
|
|
||||||
ReadyCheck,
|
|
||||||
/// In champion select.
|
|
||||||
ChampSelect,
|
|
||||||
/// Game is starting.
|
|
||||||
GameStart,
|
|
||||||
/// In game.
|
|
||||||
InProgress,
|
|
||||||
/// Game ended, waiting for stats.
|
|
||||||
WaitingForStats,
|
|
||||||
/// End of game stats screen.
|
|
||||||
EndOfGame,
|
|
||||||
/// Unknown phase.
|
|
||||||
Unknown,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&str> for GameflowPhase {
|
|
||||||
fn from(s: &str) -> Self {
|
|
||||||
match s {
|
|
||||||
"None" => GameflowPhase::None,
|
|
||||||
"Lobby" => GameflowPhase::Lobby,
|
|
||||||
"Queue" => GameflowPhase::Queue,
|
|
||||||
"ReadyCheck" => GameflowPhase::ReadyCheck,
|
|
||||||
"ChampSelect" => GameflowPhase::ChampSelect,
|
|
||||||
"GameStart" => GameflowPhase::GameStart,
|
|
||||||
"InProgress" => GameflowPhase::InProgress,
|
|
||||||
"WaitingForStats" => GameflowPhase::WaitingForStats,
|
|
||||||
"EndOfGame" => GameflowPhase::EndOfGame,
|
|
||||||
_ => GameflowPhase::Unknown,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// LQP Client state.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct ClientState {
|
|
||||||
/// Current gameflow phase.
|
|
||||||
pub phase: GameflowPhase,
|
|
||||||
/// Current game ID if in game.
|
|
||||||
pub game_id: Option<u64>,
|
|
||||||
/// Current champion name.
|
|
||||||
pub champion: Option<String>,
|
|
||||||
/// Current player's puuid.
|
|
||||||
pub local_puuid: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for ClientState {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
phase: GameflowPhase::None,
|
|
||||||
game_id: None,
|
|
||||||
champion: None,
|
|
||||||
local_puuid: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// LQP Client for League Client communication.
|
/// LQP Client for League Client communication.
|
||||||
pub struct LqpClient {
|
pub struct LqpClient {
|
||||||
/// Connection credentials.
|
/// Connection credentials.
|
||||||
@@ -268,12 +128,7 @@ impl LqpClient {
|
|||||||
// Create a TLS connector that accepts the self-signed certificate from League Client
|
// Create a TLS connector that accepts the self-signed certificate from League Client
|
||||||
use tokio_tungstenite::Connector;
|
use tokio_tungstenite::Connector;
|
||||||
|
|
||||||
let config = rustls::ClientConfig::builder()
|
let connector = Connector::Rustls(create_insecure_tls_config());
|
||||||
.dangerous()
|
|
||||||
.with_custom_certificate_verifier(Arc::new(InsecureVerifier))
|
|
||||||
.with_no_client_auth();
|
|
||||||
|
|
||||||
let connector = Connector::Rustls(Arc::new(config));
|
|
||||||
|
|
||||||
// Build WebSocket request with auth header
|
// Build WebSocket request with auth header
|
||||||
let request = tokio_tungstenite::tungstenite::http::Request::builder()
|
let request = tokio_tungstenite::tungstenite::http::Request::builder()
|
||||||
@@ -300,7 +155,7 @@ impl LqpClient {
|
|||||||
|
|
||||||
// Subscribe to endpoints using OnJsonApiEvent format
|
// Subscribe to endpoints using OnJsonApiEvent format
|
||||||
// Format: [5, "OnJsonApiEvent", endpoint]
|
// Format: [5, "OnJsonApiEvent", endpoint]
|
||||||
for endpoint in SUBSCRIBE_ENDPOINTS {
|
for endpoint in super::endpoints::SUBSCRIBE_ENDPOINTS {
|
||||||
let subscribe_msg = serde_json::json!([5, "OnJsonApiEvent", endpoint]);
|
let subscribe_msg = serde_json::json!([5, "OnJsonApiEvent", endpoint]);
|
||||||
let msg = Message::Text(subscribe_msg.to_string());
|
let msg = Message::Text(subscribe_msg.to_string());
|
||||||
info!("Subscribing to: {} with OnJsonApiEvent", endpoint);
|
info!("Subscribing to: {} with OnJsonApiEvent", endpoint);
|
||||||
@@ -333,8 +188,7 @@ impl LqpClient {
|
|||||||
}
|
}
|
||||||
// Get local_puuid from state for champion extraction
|
// Get local_puuid from state for champion extraction
|
||||||
let local_puuid = state.read().await.local_puuid.clone();
|
let local_puuid = state.read().await.local_puuid.clone();
|
||||||
if let Some(event) =
|
if let Some(event) = parse_websocket_message(&text, local_puuid.as_deref())
|
||||||
Self::parse_websocket_message(&text, local_puuid.as_deref())
|
|
||||||
{
|
{
|
||||||
// Update state based on event
|
// Update state based on event
|
||||||
Self::update_state_from_event(&state, &event).await;
|
Self::update_state_from_event(&state, &event).await;
|
||||||
@@ -371,7 +225,7 @@ impl LqpClient {
|
|||||||
// Get local_puuid from state for champion extraction
|
// Get local_puuid from state for champion extraction
|
||||||
let local_puuid = state.read().await.local_puuid.clone();
|
let local_puuid = state.read().await.local_puuid.clone();
|
||||||
if let Some(event) =
|
if let Some(event) =
|
||||||
Self::parse_websocket_message(&text, local_puuid.as_deref())
|
parse_websocket_message(&text, local_puuid.as_deref())
|
||||||
{
|
{
|
||||||
// Update state based on event
|
// Update state based on event
|
||||||
Self::update_state_from_event(&state, &event).await;
|
Self::update_state_from_event(&state, &event).await;
|
||||||
@@ -432,430 +286,6 @@ impl LqpClient {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a WebSocket message into a game event.
|
|
||||||
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::<RawEvent>(event_data.clone())
|
|
||||||
{
|
|
||||||
let event_type = event_data
|
|
||||||
.get("eventType")
|
|
||||||
.and_then(|t| t.as_str())
|
|
||||||
.unwrap_or("Update");
|
|
||||||
return Self::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 Self::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.
|
|
||||||
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" {
|
|
||||||
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
|
|
||||||
return Some(
|
|
||||||
GameEvent::from_json(&serde_json::json!({
|
|
||||||
"eventType": "lcu-phase-change",
|
|
||||||
"phase": phase
|
|
||||||
}))
|
|
||||||
.unwrap_or(GameEvent::Unknown),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle gameflow session updates
|
|
||||||
if uri == "/lol-gameflow/v1/session" {
|
|
||||||
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" {
|
|
||||||
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)
|
|
||||||
});
|
|
||||||
|
|
||||||
// Note: Champion, team, summoner_name will be extracted using puuid
|
|
||||||
// in handle_game_event when we have access to pregame_metadata
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
};
|
|
||||||
|
|
||||||
return 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),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Some(
|
|
||||||
GameEvent::from_json(&serde_json::json!({
|
|
||||||
"eventType": "lcu-phase-change",
|
|
||||||
"phase": phase
|
|
||||||
}))
|
|
||||||
.unwrap_or(GameEvent::Unknown),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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" {
|
|
||||||
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),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle end-of-game stats block (contains actual game results)
|
|
||||||
if uri == "/lol-end-of-game/v1/eog-stats-block" {
|
|
||||||
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");
|
|
||||||
return Some(event);
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
warn!("Failed to parse GameEnd event, returning Unknown");
|
|
||||||
return Some(GameEvent::Unknown);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle lobby
|
|
||||||
if uri.starts_with("/lol-lobby") {
|
|
||||||
debug!("Lobby event: {}", uri);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
debug!("Unhandled URI: {}", uri);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update internal state from a game event.
|
/// Update internal state from a game event.
|
||||||
async fn update_state_from_event(state: &Arc<RwLock<ClientState>>, event: &GameEvent) {
|
async fn update_state_from_event(state: &Arc<RwLock<ClientState>>, event: &GameEvent) {
|
||||||
let mut state = state.write().await;
|
let mut state = state.write().await;
|
||||||
@@ -1414,7 +844,7 @@ impl LqpClient {
|
|||||||
info!("[ITEMS] Found local player in live client data");
|
info!("[ITEMS] Found local player in live client data");
|
||||||
if let Some(items) = player.get("items").and_then(|i| i.as_array()) {
|
if let Some(items) = player.get("items").and_then(|i| i.as_array()) {
|
||||||
info!("[ITEMS] Items array has {} items", items.len());
|
info!("[ITEMS] Items array has {} items", items.len());
|
||||||
let item_build = self.parse_items_from_live_client(items);
|
let item_build = Self::parse_items_from_live_client(items);
|
||||||
if item_build.is_some() {
|
if item_build.is_some() {
|
||||||
info!(
|
info!(
|
||||||
"[ITEMS] Successfully parsed items from live client data"
|
"[ITEMS] Successfully parsed items from live client data"
|
||||||
@@ -1443,7 +873,7 @@ impl LqpClient {
|
|||||||
info!("[ITEMS] Found localPlayer in game stats");
|
info!("[ITEMS] Found localPlayer in game stats");
|
||||||
if let Some(items) = local_player.get("items").and_then(|i| i.as_array()) {
|
if let Some(items) = local_player.get("items").and_then(|i| i.as_array()) {
|
||||||
info!("[ITEMS] localPlayer.items array has {} items", items.len());
|
info!("[ITEMS] localPlayer.items array has {} items", items.len());
|
||||||
let item_build = self.parse_items_from_game_stats(items);
|
let item_build = Self::parse_items_from_game_stats(items);
|
||||||
if item_build.is_some() {
|
if item_build.is_some() {
|
||||||
info!("[ITEMS] Successfully parsed items from localPlayer");
|
info!("[ITEMS] Successfully parsed items from localPlayer");
|
||||||
return Ok(item_build);
|
return Ok(item_build);
|
||||||
@@ -1470,7 +900,7 @@ impl LqpClient {
|
|||||||
"[ITEMS] Player items array has {} items",
|
"[ITEMS] Player items array has {} items",
|
||||||
items.len()
|
items.len()
|
||||||
);
|
);
|
||||||
let item_build = self.parse_items_from_game_stats(items);
|
let item_build = Self::parse_items_from_game_stats(items);
|
||||||
if item_build.is_some() {
|
if item_build.is_some() {
|
||||||
info!("[ITEMS] Successfully parsed items from teams[].players[]");
|
info!("[ITEMS] Successfully parsed items from teams[].players[]");
|
||||||
return Ok(item_build);
|
return Ok(item_build);
|
||||||
@@ -1490,7 +920,7 @@ impl LqpClient {
|
|||||||
);
|
);
|
||||||
if let Some(player) = players.first() {
|
if let Some(player) = players.first() {
|
||||||
if let Some(items) = player.get("items").and_then(|i| i.as_array()) {
|
if let Some(items) = player.get("items").and_then(|i| i.as_array()) {
|
||||||
let item_build = self.parse_items_from_game_stats(items);
|
let item_build = Self::parse_items_from_game_stats(items);
|
||||||
if item_build.is_some() {
|
if item_build.is_some() {
|
||||||
return Ok(item_build);
|
return Ok(item_build);
|
||||||
}
|
}
|
||||||
@@ -1510,10 +940,7 @@ impl LqpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Parse items from live client data format.
|
/// Parse items from live client data format.
|
||||||
fn parse_items_from_live_client(
|
fn parse_items_from_live_client(items: &[serde_json::Value]) -> Option<ItemBuild> {
|
||||||
&self,
|
|
||||||
items: &[serde_json::Value],
|
|
||||||
) -> Option<super::ItemBuild> {
|
|
||||||
let mut item_list = Vec::new();
|
let mut item_list = Vec::new();
|
||||||
let mut trinket = None;
|
let mut trinket = None;
|
||||||
|
|
||||||
@@ -1550,7 +977,7 @@ impl LqpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Parse items from game stats format (array of item IDs as numbers).
|
/// Parse items from game stats format (array of item IDs as numbers).
|
||||||
fn parse_items_from_game_stats(&self, items: &[serde_json::Value]) -> Option<super::ItemBuild> {
|
fn parse_items_from_game_stats(items: &[serde_json::Value]) -> Option<ItemBuild> {
|
||||||
let mut item_list = Vec::new();
|
let mut item_list = Vec::new();
|
||||||
let mut trinket = None;
|
let mut trinket = None;
|
||||||
|
|
||||||
@@ -1585,266 +1012,6 @@ impl LqpClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert summoner spell ID to name.
|
|
||||||
pub fn spell_id_to_name(id: u32) -> Option<String> {
|
|
||||||
let name = match id {
|
|
||||||
1 => "Cleanse",
|
|
||||||
3 => "Exhaust",
|
|
||||||
4 => "Flash",
|
|
||||||
6 => "Ghost",
|
|
||||||
7 => "Heal",
|
|
||||||
11 => "Smite",
|
|
||||||
12 => "Teleport",
|
|
||||||
13 => "Clarity",
|
|
||||||
14 => "Ignite",
|
|
||||||
21 => "Barrier",
|
|
||||||
32 => "Mark",
|
|
||||||
39 => "Mark",
|
|
||||||
54 => "Placeholder",
|
|
||||||
55 => "Placeholder",
|
|
||||||
_ => return None,
|
|
||||||
};
|
|
||||||
Some(name.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert champion ID to champion name.
|
|
||||||
/// This is a simplified mapping for common champions.
|
|
||||||
pub fn champion_id_to_name(id: u32) -> Option<String> {
|
|
||||||
let name = match id {
|
|
||||||
1 => "Annie",
|
|
||||||
2 => "Olaf",
|
|
||||||
3 => "Galio",
|
|
||||||
4 => "TwistedFate",
|
|
||||||
5 => "XinZhao",
|
|
||||||
6 => "Urgot",
|
|
||||||
7 => "LeBlanc",
|
|
||||||
8 => "Vladimir",
|
|
||||||
9 => "Fiddlesticks",
|
|
||||||
10 => "Kayle",
|
|
||||||
11 => "MasterYi",
|
|
||||||
12 => "Alistar",
|
|
||||||
13 => "Ryze",
|
|
||||||
14 => "Sion",
|
|
||||||
15 => "Sivir",
|
|
||||||
16 => "Soraka",
|
|
||||||
17 => "Teemo",
|
|
||||||
18 => "Tristana",
|
|
||||||
19 => "Warwick",
|
|
||||||
20 => "Nunu",
|
|
||||||
21 => "MissFortune",
|
|
||||||
22 => "Ashe",
|
|
||||||
23 => "Tryndamere",
|
|
||||||
24 => "Jax",
|
|
||||||
25 => "Morgana",
|
|
||||||
26 => "Zilean",
|
|
||||||
27 => "Singed",
|
|
||||||
28 => "Evelynn",
|
|
||||||
29 => "Twitch",
|
|
||||||
30 => "Karthus",
|
|
||||||
31 => "Cho'Gath",
|
|
||||||
32 => "Amumu",
|
|
||||||
33 => "Rammus",
|
|
||||||
34 => "Anivia",
|
|
||||||
35 => "Shaco",
|
|
||||||
36 => "DrMundo",
|
|
||||||
37 => "Sona",
|
|
||||||
38 => "Kassadin",
|
|
||||||
39 => "Irelia",
|
|
||||||
40 => "Janna",
|
|
||||||
41 => "Gangplank",
|
|
||||||
42 => "Corki",
|
|
||||||
43 => "Karma",
|
|
||||||
44 => "Taric",
|
|
||||||
45 => "Veigar",
|
|
||||||
48 => "Trundle",
|
|
||||||
50 => "Swain",
|
|
||||||
51 => "Caitlyn",
|
|
||||||
52 => "Blitzcrank",
|
|
||||||
53 => "Malphite",
|
|
||||||
54 => "Katarina",
|
|
||||||
55 => "Nocturne",
|
|
||||||
56 => "Maokai",
|
|
||||||
57 => "Renekton",
|
|
||||||
58 => "JarvanIV",
|
|
||||||
59 => "Elise",
|
|
||||||
60 => "Talon",
|
|
||||||
61 => "Orianna",
|
|
||||||
62 => "Wukong",
|
|
||||||
63 => "Brand",
|
|
||||||
64 => "LeeSin",
|
|
||||||
67 => "Vayne",
|
|
||||||
68 => "Rumble",
|
|
||||||
69 => "Cassiopeia",
|
|
||||||
72 => "Skarner",
|
|
||||||
74 => "Heimerdinger",
|
|
||||||
75 => "Nasus",
|
|
||||||
76 => "Nidalee",
|
|
||||||
77 => "Udyr",
|
|
||||||
78 => "Poppy",
|
|
||||||
79 => "Gragas",
|
|
||||||
80 => "Pantheon",
|
|
||||||
81 => "Ezreal",
|
|
||||||
82 => "Mordekaiser",
|
|
||||||
83 => "Yorick",
|
|
||||||
84 => "Akali",
|
|
||||||
85 => "Kennedy",
|
|
||||||
86 => "Garen",
|
|
||||||
89 => "Leona",
|
|
||||||
90 => "Malzahar",
|
|
||||||
91 => "Talon",
|
|
||||||
92 => "Riven",
|
|
||||||
96 => "Kog'Maw",
|
|
||||||
98 => "Shen",
|
|
||||||
99 => "Lux",
|
|
||||||
101 => "Xerath",
|
|
||||||
102 => "Shyvana",
|
|
||||||
103 => "Ahri",
|
|
||||||
104 => "Graves",
|
|
||||||
105 => "Fizz",
|
|
||||||
106 => "Volibear",
|
|
||||||
107 => "Rengar",
|
|
||||||
110 => "Varus",
|
|
||||||
111 => "Nautilus",
|
|
||||||
112 => "Viktor",
|
|
||||||
113 => "Sejuani",
|
|
||||||
114 => "Fiora",
|
|
||||||
115 => "Ziggs",
|
|
||||||
117 => "Lulu",
|
|
||||||
119 => "Draven",
|
|
||||||
120 => "Hecarim",
|
|
||||||
121 => "Kha'Zix",
|
|
||||||
122 => "Darius",
|
|
||||||
126 => "Jayce",
|
|
||||||
127 => "Lissandra",
|
|
||||||
131 => "Diana",
|
|
||||||
133 => "Quinn",
|
|
||||||
134 => "Syndra",
|
|
||||||
136 => "AurelionSol",
|
|
||||||
141 => "Kayn",
|
|
||||||
142 => "Zoe",
|
|
||||||
143 => "Lillia",
|
|
||||||
145 => "Samira",
|
|
||||||
147 => "Seraphine",
|
|
||||||
150 => "Gnar",
|
|
||||||
154 => "Zac",
|
|
||||||
157 => "Yasuo",
|
|
||||||
161 => "Vel'Koz",
|
|
||||||
163 => "Taliyah",
|
|
||||||
164 => "Camille",
|
|
||||||
166 => "Akshan",
|
|
||||||
167 => "Nilah",
|
|
||||||
201 => "Braum",
|
|
||||||
202 => "Jhin",
|
|
||||||
203 => "Kindred",
|
|
||||||
222 => "Jinx",
|
|
||||||
223 => "TahmKench",
|
|
||||||
236 => "Lucian",
|
|
||||||
238 => "Zed",
|
|
||||||
240 => "Kled",
|
|
||||||
245 => "Ekko",
|
|
||||||
246 => "Qiyana",
|
|
||||||
254 => "Vi",
|
|
||||||
255 => "Janna",
|
|
||||||
256 => "Pyke",
|
|
||||||
257 => "Nami",
|
|
||||||
266 => "Aatrox",
|
|
||||||
267 => "Nami",
|
|
||||||
268 => "Azir",
|
|
||||||
350 => "Yuumi",
|
|
||||||
360 => "Samira",
|
|
||||||
412 => "Thresh",
|
|
||||||
420 => "Illaoi",
|
|
||||||
421 => "Rek'Sai",
|
|
||||||
427 => "Ivern",
|
|
||||||
429 => "Kalista",
|
|
||||||
432 => "Bard",
|
|
||||||
497 => "Rakan",
|
|
||||||
498 => "Xayah",
|
|
||||||
516 => "Ornn",
|
|
||||||
517 => "Sylas",
|
|
||||||
518 => "Neeko",
|
|
||||||
523 => "Aphelios",
|
|
||||||
526 => "Rell",
|
|
||||||
555 => "Pyke",
|
|
||||||
711 => "Vex",
|
|
||||||
777 => "Yone",
|
|
||||||
875 => "Sett",
|
|
||||||
876 => "Lillia",
|
|
||||||
887 => "Gwen",
|
|
||||||
888 => "Viego",
|
|
||||||
895 => "KSante",
|
|
||||||
901 => "Smolder",
|
|
||||||
902 => "Hwei",
|
|
||||||
950 => "Naafiri",
|
|
||||||
951 => "Briar",
|
|
||||||
_ => return None,
|
|
||||||
};
|
|
||||||
Some(name.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert map ID to map name.
|
|
||||||
fn map_id_to_name(id: u64) -> Option<String> {
|
|
||||||
let name = match id {
|
|
||||||
1 => "Summoner's Rift",
|
|
||||||
2 => "Summoner's Rift",
|
|
||||||
3 => "The Proving Grounds",
|
|
||||||
4 => "Twisted Treeline",
|
|
||||||
8 => "The Crystal Scar",
|
|
||||||
10 => "Twisted Treeline",
|
|
||||||
11 => "Summoner's Rift",
|
|
||||||
12 => "Howling Abyss",
|
|
||||||
14 => "Butcher's Bridge",
|
|
||||||
16 => "Cosmic Ruins",
|
|
||||||
18 => "Valoran City Park",
|
|
||||||
19 => "Substructure 43",
|
|
||||||
20 => "Crash Site",
|
|
||||||
21 => "Nexus Blitz",
|
|
||||||
22 => "Convergence",
|
|
||||||
23 => "Arena",
|
|
||||||
24 => "Arena",
|
|
||||||
25 => "Rings of Wrath",
|
|
||||||
30 => "Swarm",
|
|
||||||
31 => "Swarm",
|
|
||||||
32 => "Swarm",
|
|
||||||
33 => "Swarm",
|
|
||||||
34 => "Swarm",
|
|
||||||
35 => "Swarm",
|
|
||||||
_ => return None,
|
|
||||||
};
|
|
||||||
Some(name.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pre-game metadata fetched before the game starts.
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct PreGameMetadata {
|
|
||||||
pub champion_id: Option<u32>,
|
|
||||||
pub skin_id: Option<u32>,
|
|
||||||
pub rune_page_name: Option<String>,
|
|
||||||
pub summoner_name: Option<String>,
|
|
||||||
pub queue_type: Option<String>,
|
|
||||||
pub queue_id: Option<u32>,
|
|
||||||
pub game_mode: Option<String>,
|
|
||||||
pub map_name: Option<String>,
|
|
||||||
pub team: Option<u32>,
|
|
||||||
pub local_puuid: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// End-of-game metadata fetched after the game ends.
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct GameEndMetadata {
|
|
||||||
pub victory: Option<bool>,
|
|
||||||
pub match_id: Option<String>,
|
|
||||||
pub kills: u32,
|
|
||||||
pub deaths: u32,
|
|
||||||
pub assists: u32,
|
|
||||||
pub creep_score: u32,
|
|
||||||
pub gold_earned: u32,
|
|
||||||
pub damage_dealt: u64,
|
|
||||||
pub damage_taken: u64,
|
|
||||||
pub vision_score: f64,
|
|
||||||
pub game_duration: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for LqpClient {
|
impl Default for LqpClient {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::new()
|
Self::new()
|
||||||
@@ -1855,16 +1022,6 @@ impl Default for LqpClient {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_gameflow_phase_from_str() {
|
|
||||||
assert_eq!(GameflowPhase::from("InProgress"), GameflowPhase::InProgress);
|
|
||||||
assert_eq!(
|
|
||||||
GameflowPhase::from("ChampSelect"),
|
|
||||||
GameflowPhase::ChampSelect
|
|
||||||
);
|
|
||||||
assert_eq!(GameflowPhase::from("UnknownPhase"), GameflowPhase::Unknown);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_client_creation() {
|
fn test_client_creation() {
|
||||||
let client = LqpClient::new();
|
let client = LqpClient::new();
|
||||||
|
|||||||
59
record-daemon/src/lqp/endpoints.rs
Normal file
59
record-daemon/src/lqp/endpoints.rs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
//! LQP REST API endpoints.
|
||||||
|
//!
|
||||||
|
//! Defines the endpoint paths for the League Client API.
|
||||||
|
|
||||||
|
/// Gameflow phase endpoint.
|
||||||
|
pub const GAMEFLOW_PHASE: &str = "/lol-gameflow/v1/gameflow-phase";
|
||||||
|
/// Game session endpoint.
|
||||||
|
pub const SESSION: &str = "/lol-gameflow/v1/session";
|
||||||
|
/// Champion select session endpoint.
|
||||||
|
pub const CHAMPION_SELECT: &str = "/lol-champ-select/v1/session";
|
||||||
|
/// Current summoner endpoint.
|
||||||
|
pub const SUMMONER: &str = "/lol-summoner/v1/current-summoner";
|
||||||
|
/// End-of-game stats endpoint.
|
||||||
|
pub const GAME_STATS: &str = "/lol-end-of-game/v1/eog-stats-block";
|
||||||
|
/// Current champion in champ select.
|
||||||
|
pub const CHAMPION_SUMMARY: &str = "/lol-champ-select/v1/current-champion";
|
||||||
|
/// Current rune page endpoint.
|
||||||
|
pub const RUNE_PAGES: &str = "/lol-perks/v1/currentpage";
|
||||||
|
/// All rune pages endpoint.
|
||||||
|
pub const ALL_RUNE_PAGES: &str = "/lol-perks/v1/pages";
|
||||||
|
/// Match history endpoint.
|
||||||
|
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";
|
||||||
|
/// Live client active player endpoint.
|
||||||
|
pub const LIVE_CLIENT_DATA_ACTIVE_PLAYER: &str = "/liveclientdata/activeplayer";
|
||||||
|
/// Live client player list endpoint.
|
||||||
|
pub const LIVE_CLIENT_DATA_PLAYER_LIST: &str = "/liveclientdata/playerlist";
|
||||||
|
/// Local player selection in champion select.
|
||||||
|
pub const CHAMPION_SELECT_LOCAL_PLAYER: &str = "/lol-champ-select/v1/session/my-selection";
|
||||||
|
|
||||||
|
/// LQP WebSocket endpoints to subscribe to.
|
||||||
|
pub const SUBSCRIBE_ENDPOINTS: &[&str] = &[
|
||||||
|
GAMEFLOW_PHASE,
|
||||||
|
SESSION,
|
||||||
|
"/lol-matchmaking/v1/ready-check",
|
||||||
|
"/lol-game-events/v1/game-events",
|
||||||
|
CHAMPION_SELECT,
|
||||||
|
"/lol-lobby/v2/lobby",
|
||||||
|
GAME_STATS,
|
||||||
|
];
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_endpoints_are_valid() {
|
||||||
|
// All endpoints should start with /
|
||||||
|
assert!(GAMEFLOW_PHASE.starts_with('/'));
|
||||||
|
assert!(SESSION.starts_with('/'));
|
||||||
|
assert!(CHAMPION_SELECT.starts_with('/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_subscribe_endpoints_not_empty() {
|
||||||
|
assert!(!SUBSCRIBE_ENDPOINTS.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
261
record-daemon/src/lqp/mappings.rs
Normal file
261
record-daemon/src/lqp/mappings.rs
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
//! ID to name mappings for League of Legends data.
|
||||||
|
//!
|
||||||
|
//! Provides conversion functions for champion IDs, summoner spell IDs,
|
||||||
|
//! and map IDs to their human-readable names.
|
||||||
|
|
||||||
|
/// Convert summoner spell ID to name.
|
||||||
|
pub fn spell_id_to_name(id: u32) -> Option<String> {
|
||||||
|
let name = match id {
|
||||||
|
1 => "Cleanse",
|
||||||
|
3 => "Exhaust",
|
||||||
|
4 => "Flash",
|
||||||
|
6 => "Ghost",
|
||||||
|
7 => "Heal",
|
||||||
|
11 => "Smite",
|
||||||
|
12 => "Teleport",
|
||||||
|
13 => "Clarity",
|
||||||
|
14 => "Ignite",
|
||||||
|
21 => "Barrier",
|
||||||
|
32 => "Mark",
|
||||||
|
39 => "Mark",
|
||||||
|
54 => "Placeholder",
|
||||||
|
55 => "Placeholder",
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some(name.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert champion ID to champion name.
|
||||||
|
/// This is a simplified mapping for common champions.
|
||||||
|
pub fn champion_id_to_name(id: u32) -> Option<String> {
|
||||||
|
let name = match id {
|
||||||
|
1 => "Annie",
|
||||||
|
2 => "Olaf",
|
||||||
|
3 => "Galio",
|
||||||
|
4 => "TwistedFate",
|
||||||
|
5 => "XinZhao",
|
||||||
|
6 => "Urgot",
|
||||||
|
7 => "LeBlanc",
|
||||||
|
8 => "Vladimir",
|
||||||
|
9 => "Fiddlesticks",
|
||||||
|
10 => "Kayle",
|
||||||
|
11 => "MasterYi",
|
||||||
|
12 => "Alistar",
|
||||||
|
13 => "Ryze",
|
||||||
|
14 => "Sion",
|
||||||
|
15 => "Sivir",
|
||||||
|
16 => "Soraka",
|
||||||
|
17 => "Teemo",
|
||||||
|
18 => "Tristana",
|
||||||
|
19 => "Warwick",
|
||||||
|
20 => "Nunu",
|
||||||
|
21 => "MissFortune",
|
||||||
|
22 => "Ashe",
|
||||||
|
23 => "Tryndamere",
|
||||||
|
24 => "Jax",
|
||||||
|
25 => "Morgana",
|
||||||
|
26 => "Zilean",
|
||||||
|
27 => "Singed",
|
||||||
|
28 => "Evelynn",
|
||||||
|
29 => "Twitch",
|
||||||
|
30 => "Karthus",
|
||||||
|
31 => "Cho'Gath",
|
||||||
|
32 => "Amumu",
|
||||||
|
33 => "Rammus",
|
||||||
|
34 => "Anivia",
|
||||||
|
35 => "Shaco",
|
||||||
|
36 => "DrMundo",
|
||||||
|
37 => "Sona",
|
||||||
|
38 => "Kassadin",
|
||||||
|
39 => "Irelia",
|
||||||
|
40 => "Janna",
|
||||||
|
41 => "Gangplank",
|
||||||
|
42 => "Corki",
|
||||||
|
43 => "Karma",
|
||||||
|
44 => "Taric",
|
||||||
|
45 => "Veigar",
|
||||||
|
48 => "Trundle",
|
||||||
|
50 => "Swain",
|
||||||
|
51 => "Caitlyn",
|
||||||
|
52 => "Blitzcrank",
|
||||||
|
53 => "Malphite",
|
||||||
|
54 => "Katarina",
|
||||||
|
55 => "Nocturne",
|
||||||
|
56 => "Maokai",
|
||||||
|
57 => "Renekton",
|
||||||
|
58 => "JarvanIV",
|
||||||
|
59 => "Elise",
|
||||||
|
60 => "Talon",
|
||||||
|
61 => "Orianna",
|
||||||
|
62 => "Wukong",
|
||||||
|
63 => "Brand",
|
||||||
|
64 => "LeeSin",
|
||||||
|
67 => "Vayne",
|
||||||
|
68 => "Rumble",
|
||||||
|
69 => "Cassiopeia",
|
||||||
|
72 => "Skarner",
|
||||||
|
74 => "Heimerdinger",
|
||||||
|
75 => "Nasus",
|
||||||
|
76 => "Nidalee",
|
||||||
|
77 => "Udyr",
|
||||||
|
78 => "Poppy",
|
||||||
|
79 => "Gragas",
|
||||||
|
80 => "Pantheon",
|
||||||
|
81 => "Ezreal",
|
||||||
|
82 => "Mordekaiser",
|
||||||
|
83 => "Yorick",
|
||||||
|
84 => "Akali",
|
||||||
|
85 => "Kennedy",
|
||||||
|
86 => "Garen",
|
||||||
|
89 => "Leona",
|
||||||
|
90 => "Malzahar",
|
||||||
|
91 => "Talon",
|
||||||
|
92 => "Riven",
|
||||||
|
96 => "Kog'Maw",
|
||||||
|
98 => "Shen",
|
||||||
|
99 => "Lux",
|
||||||
|
101 => "Xerath",
|
||||||
|
102 => "Shyvana",
|
||||||
|
103 => "Ahri",
|
||||||
|
104 => "Graves",
|
||||||
|
105 => "Fizz",
|
||||||
|
106 => "Volibear",
|
||||||
|
107 => "Rengar",
|
||||||
|
110 => "Varus",
|
||||||
|
111 => "Nautilus",
|
||||||
|
112 => "Viktor",
|
||||||
|
113 => "Sejuani",
|
||||||
|
114 => "Fiora",
|
||||||
|
115 => "Ziggs",
|
||||||
|
117 => "Lulu",
|
||||||
|
119 => "Draven",
|
||||||
|
120 => "Hecarim",
|
||||||
|
121 => "Kha'Zix",
|
||||||
|
122 => "Darius",
|
||||||
|
126 => "Jayce",
|
||||||
|
127 => "Lissandra",
|
||||||
|
131 => "Diana",
|
||||||
|
133 => "Quinn",
|
||||||
|
134 => "Syndra",
|
||||||
|
136 => "AurelionSol",
|
||||||
|
141 => "Kayn",
|
||||||
|
142 => "Zoe",
|
||||||
|
143 => "Lillia",
|
||||||
|
145 => "Samira",
|
||||||
|
147 => "Seraphine",
|
||||||
|
150 => "Gnar",
|
||||||
|
154 => "Zac",
|
||||||
|
157 => "Yasuo",
|
||||||
|
161 => "Vel'Koz",
|
||||||
|
163 => "Taliyah",
|
||||||
|
164 => "Camille",
|
||||||
|
166 => "Akshan",
|
||||||
|
167 => "Nilah",
|
||||||
|
201 => "Braum",
|
||||||
|
202 => "Jhin",
|
||||||
|
203 => "Kindred",
|
||||||
|
222 => "Jinx",
|
||||||
|
223 => "TahmKench",
|
||||||
|
236 => "Lucian",
|
||||||
|
238 => "Zed",
|
||||||
|
240 => "Kled",
|
||||||
|
245 => "Ekko",
|
||||||
|
246 => "Qiyana",
|
||||||
|
254 => "Vi",
|
||||||
|
255 => "Janna",
|
||||||
|
256 => "Pyke",
|
||||||
|
257 => "Nami",
|
||||||
|
266 => "Aatrox",
|
||||||
|
267 => "Nami",
|
||||||
|
268 => "Azir",
|
||||||
|
350 => "Yuumi",
|
||||||
|
360 => "Samira",
|
||||||
|
412 => "Thresh",
|
||||||
|
420 => "Illaoi",
|
||||||
|
421 => "Rek'Sai",
|
||||||
|
427 => "Ivern",
|
||||||
|
429 => "Kalista",
|
||||||
|
432 => "Bard",
|
||||||
|
497 => "Rakan",
|
||||||
|
498 => "Xayah",
|
||||||
|
516 => "Ornn",
|
||||||
|
517 => "Sylas",
|
||||||
|
518 => "Neeko",
|
||||||
|
523 => "Aphelios",
|
||||||
|
526 => "Rell",
|
||||||
|
555 => "Pyke",
|
||||||
|
711 => "Vex",
|
||||||
|
777 => "Yone",
|
||||||
|
875 => "Sett",
|
||||||
|
876 => "Lillia",
|
||||||
|
887 => "Gwen",
|
||||||
|
888 => "Viego",
|
||||||
|
895 => "KSante",
|
||||||
|
901 => "Smolder",
|
||||||
|
902 => "Hwei",
|
||||||
|
950 => "Naafiri",
|
||||||
|
951 => "Briar",
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some(name.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert map ID to map name.
|
||||||
|
pub fn map_id_to_name(id: u64) -> Option<String> {
|
||||||
|
let name = match id {
|
||||||
|
1 => "Summoner's Rift",
|
||||||
|
2 => "Summoner's Rift",
|
||||||
|
3 => "The Proving Grounds",
|
||||||
|
4 => "Twisted Treeline",
|
||||||
|
8 => "The Crystal Scar",
|
||||||
|
10 => "Twisted Treeline",
|
||||||
|
11 => "Summoner's Rift",
|
||||||
|
12 => "Howling Abyss",
|
||||||
|
14 => "Butcher's Bridge",
|
||||||
|
16 => "Cosmic Ruins",
|
||||||
|
18 => "Valoran City Park",
|
||||||
|
19 => "Substructure 43",
|
||||||
|
20 => "Crash Site",
|
||||||
|
21 => "Nexus Blitz",
|
||||||
|
22 => "Convergence",
|
||||||
|
23 => "Arena",
|
||||||
|
24 => "Arena",
|
||||||
|
25 => "Rings of Wrath",
|
||||||
|
30 => "Swarm",
|
||||||
|
31 => "Swarm",
|
||||||
|
32 => "Swarm",
|
||||||
|
33 => "Swarm",
|
||||||
|
34 => "Swarm",
|
||||||
|
35 => "Swarm",
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some(name.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_spell_id_to_name() {
|
||||||
|
assert_eq!(spell_id_to_name(4), Some("Flash".to_string()));
|
||||||
|
assert_eq!(spell_id_to_name(7), Some("Heal".to_string()));
|
||||||
|
assert_eq!(spell_id_to_name(11), Some("Smite".to_string()));
|
||||||
|
assert_eq!(spell_id_to_name(999), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_champion_id_to_name() {
|
||||||
|
assert_eq!(champion_id_to_name(1), Some("Annie".to_string()));
|
||||||
|
assert_eq!(champion_id_to_name(22), Some("Ashe".to_string()));
|
||||||
|
assert_eq!(champion_id_to_name(157), Some("Yasuo".to_string()));
|
||||||
|
assert_eq!(champion_id_to_name(9999), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_map_id_to_name() {
|
||||||
|
assert_eq!(map_id_to_name(11), Some("Summoner's Rift".to_string()));
|
||||||
|
assert_eq!(map_id_to_name(12), Some("Howling Abyss".to_string()));
|
||||||
|
assert_eq!(map_id_to_name(999), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
77
record-daemon/src/lqp/metadata.rs
Normal file
77
record-daemon/src/lqp/metadata.rs
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
//! Metadata structures for pre-game and end-of-game data.
|
||||||
|
//!
|
||||||
|
//! Contains structures for capturing game information before and after matches.
|
||||||
|
|
||||||
|
/// Pre-game metadata fetched before the game starts.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct PreGameMetadata {
|
||||||
|
/// Champion ID selected.
|
||||||
|
pub champion_id: Option<u32>,
|
||||||
|
/// Skin ID selected.
|
||||||
|
pub skin_id: Option<u32>,
|
||||||
|
/// Name of the rune page.
|
||||||
|
pub rune_page_name: Option<String>,
|
||||||
|
/// Summoner name.
|
||||||
|
pub summoner_name: Option<String>,
|
||||||
|
/// Queue type (e.g., "RANKED_SOLO_5x5").
|
||||||
|
pub queue_type: Option<String>,
|
||||||
|
/// Queue ID.
|
||||||
|
pub queue_id: Option<u32>,
|
||||||
|
/// Game mode (e.g., "CLASSIC", "ARAM").
|
||||||
|
pub game_mode: Option<String>,
|
||||||
|
/// Map name.
|
||||||
|
pub map_name: Option<String>,
|
||||||
|
/// Team ID (100 or 200).
|
||||||
|
pub team: Option<u32>,
|
||||||
|
/// Local player's PUUID.
|
||||||
|
pub local_puuid: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End-of-game metadata fetched after the game ends.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct GameEndMetadata {
|
||||||
|
/// Whether the player won.
|
||||||
|
pub victory: Option<bool>,
|
||||||
|
/// Match ID.
|
||||||
|
pub match_id: Option<String>,
|
||||||
|
/// Number of kills.
|
||||||
|
pub kills: u32,
|
||||||
|
/// Number of deaths.
|
||||||
|
pub deaths: u32,
|
||||||
|
/// Number of assists.
|
||||||
|
pub assists: u32,
|
||||||
|
/// Creep score (minions killed).
|
||||||
|
pub creep_score: u32,
|
||||||
|
/// Gold earned.
|
||||||
|
pub gold_earned: u32,
|
||||||
|
/// Damage dealt to champions.
|
||||||
|
pub damage_dealt: u64,
|
||||||
|
/// Damage taken.
|
||||||
|
pub damage_taken: u64,
|
||||||
|
/// Vision score.
|
||||||
|
pub vision_score: f64,
|
||||||
|
/// Game duration in seconds.
|
||||||
|
pub game_duration: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_pre_game_metadata_default() {
|
||||||
|
let metadata = PreGameMetadata::default();
|
||||||
|
assert!(metadata.champion_id.is_none());
|
||||||
|
assert!(metadata.skin_id.is_none());
|
||||||
|
assert!(metadata.summoner_name.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_game_end_metadata_default() {
|
||||||
|
let metadata = GameEndMetadata::default();
|
||||||
|
assert!(metadata.victory.is_none());
|
||||||
|
assert_eq!(metadata.kills, 0);
|
||||||
|
assert_eq!(metadata.deaths, 0);
|
||||||
|
assert_eq!(metadata.assists, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,12 +5,21 @@
|
|||||||
|
|
||||||
mod auth;
|
mod auth;
|
||||||
mod client;
|
mod client;
|
||||||
|
mod endpoints;
|
||||||
mod events;
|
mod events;
|
||||||
|
mod mappings;
|
||||||
|
mod metadata;
|
||||||
|
mod state;
|
||||||
|
mod tls;
|
||||||
|
mod websocket;
|
||||||
|
|
||||||
pub use auth::{LockfileCredentials, LockfileWatcher};
|
pub use auth::{LockfileCredentials, LockfileWatcher};
|
||||||
pub use client::{
|
pub use client::LqpClient;
|
||||||
champion_id_to_name, spell_id_to_name, GameEndMetadata, GameflowPhase, LqpClient,
|
pub use endpoints::{
|
||||||
PreGameMetadata,
|
ALL_RUNE_PAGES, CHAMPION_SELECT, CHAMPION_SELECT_LOCAL_PLAYER, CHAMPION_SUMMARY,
|
||||||
|
GAMEFLOW_PHASE, GAME_STATS, LIVE_CLIENT_DATA, LIVE_CLIENT_DATA_ACTIVE_PLAYER,
|
||||||
|
LIVE_CLIENT_DATA_PLAYER_LIST, MATCH_HISTORY, RUNE_PAGES, SESSION, SUBSCRIBE_ENDPOINTS,
|
||||||
|
SUMMONER,
|
||||||
};
|
};
|
||||||
pub use events::{
|
pub use events::{
|
||||||
ChampSelectStartInfo, ChampionPickInfo, DeathEvent, EventData, GameEndInfo, GameEvent,
|
ChampSelectStartInfo, ChampionPickInfo, DeathEvent, EventData, GameEndInfo, GameEvent,
|
||||||
@@ -18,3 +27,7 @@ pub use events::{
|
|||||||
ObjectiveEvent, ObjectiveType, PlayerChampionSelection, PlayerGameMetadata, PlayerIdentity,
|
ObjectiveEvent, ObjectiveType, PlayerChampionSelection, PlayerGameMetadata, PlayerIdentity,
|
||||||
QueueInfo, RunePage, RuneSlot, SummonerSpells, TeamMember,
|
QueueInfo, RunePage, RuneSlot, SummonerSpells, TeamMember,
|
||||||
};
|
};
|
||||||
|
pub use mappings::{champion_id_to_name, map_id_to_name, spell_id_to_name};
|
||||||
|
pub use metadata::{GameEndMetadata, PreGameMetadata};
|
||||||
|
pub use state::{ClientState, GameflowPhase};
|
||||||
|
pub use websocket::{parse_event_from_uri, parse_websocket_message};
|
||||||
|
|||||||
94
record-daemon/src/lqp/state.rs
Normal file
94
record-daemon/src/lqp/state.rs
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
//! LQP Client state management.
|
||||||
|
//!
|
||||||
|
//! Defines the game flow phases and client state tracking.
|
||||||
|
|
||||||
|
/// Game flow phase states.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum GameflowPhase {
|
||||||
|
/// Client is in main menu or lobby.
|
||||||
|
#[default]
|
||||||
|
None,
|
||||||
|
/// In lobby.
|
||||||
|
Lobby,
|
||||||
|
/// In queue.
|
||||||
|
Queue,
|
||||||
|
/// Match found, ready check.
|
||||||
|
ReadyCheck,
|
||||||
|
/// In champion select.
|
||||||
|
ChampSelect,
|
||||||
|
/// Game is starting.
|
||||||
|
GameStart,
|
||||||
|
/// In game.
|
||||||
|
InProgress,
|
||||||
|
/// Game ended, waiting for stats.
|
||||||
|
WaitingForStats,
|
||||||
|
/// End of game stats screen.
|
||||||
|
EndOfGame,
|
||||||
|
/// Unknown phase.
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&str> for GameflowPhase {
|
||||||
|
fn from(s: &str) -> Self {
|
||||||
|
match s {
|
||||||
|
"None" => GameflowPhase::None,
|
||||||
|
"Lobby" => GameflowPhase::Lobby,
|
||||||
|
"Queue" => GameflowPhase::Queue,
|
||||||
|
"ReadyCheck" => GameflowPhase::ReadyCheck,
|
||||||
|
"ChampSelect" => GameflowPhase::ChampSelect,
|
||||||
|
"GameStart" => GameflowPhase::GameStart,
|
||||||
|
"InProgress" => GameflowPhase::InProgress,
|
||||||
|
"WaitingForStats" => GameflowPhase::WaitingForStats,
|
||||||
|
"EndOfGame" => GameflowPhase::EndOfGame,
|
||||||
|
_ => GameflowPhase::Unknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// LQP Client state.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ClientState {
|
||||||
|
/// Current gameflow phase.
|
||||||
|
pub phase: GameflowPhase,
|
||||||
|
/// Current game ID if in game.
|
||||||
|
pub game_id: Option<u64>,
|
||||||
|
/// Current champion name.
|
||||||
|
pub champion: Option<String>,
|
||||||
|
/// Current player's puuid.
|
||||||
|
pub local_puuid: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ClientState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
phase: GameflowPhase::None,
|
||||||
|
game_id: None,
|
||||||
|
champion: None,
|
||||||
|
local_puuid: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_gameflow_phase_from_str() {
|
||||||
|
assert_eq!(GameflowPhase::from("InProgress"), GameflowPhase::InProgress);
|
||||||
|
assert_eq!(
|
||||||
|
GameflowPhase::from("ChampSelect"),
|
||||||
|
GameflowPhase::ChampSelect
|
||||||
|
);
|
||||||
|
assert_eq!(GameflowPhase::from("UnknownPhase"), GameflowPhase::Unknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_client_state_default() {
|
||||||
|
let state = ClientState::default();
|
||||||
|
assert_eq!(state.phase, GameflowPhase::None);
|
||||||
|
assert!(state.game_id.is_none());
|
||||||
|
assert!(state.champion.is_none());
|
||||||
|
assert!(state.local_puuid.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
85
record-daemon/src/lqp/tls.rs
Normal file
85
record-daemon/src/lqp/tls.rs
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
//! TLS configuration for LQP client.
|
||||||
|
//!
|
||||||
|
//! Provides custom certificate verification for the League Client's
|
||||||
|
//! self-signed certificates.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// Custom certificate verifier that accepts any certificate.
|
||||||
|
/// This is needed because the League Client uses a self-signed certificate.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct InsecureVerifier;
|
||||||
|
|
||||||
|
impl rustls::client::danger::ServerCertVerifier for InsecureVerifier {
|
||||||
|
fn verify_server_cert(
|
||||||
|
&self,
|
||||||
|
_end_entity: &rustls::pki_types::CertificateDer<'_>,
|
||||||
|
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
|
||||||
|
_server_name: &rustls::pki_types::ServerName<'_>,
|
||||||
|
_ocsp_response: &[u8],
|
||||||
|
_now: rustls::pki_types::UnixTime,
|
||||||
|
) -> std::result::Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||||
|
// Accept any certificate - League Client uses self-signed certificates
|
||||||
|
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_tls12_signature(
|
||||||
|
&self,
|
||||||
|
_message: &[u8],
|
||||||
|
_cert: &rustls::pki_types::CertificateDer<'_>,
|
||||||
|
_dss: &rustls::DigitallySignedStruct,
|
||||||
|
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||||
|
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_tls13_signature(
|
||||||
|
&self,
|
||||||
|
_message: &[u8],
|
||||||
|
_cert: &rustls::pki_types::CertificateDer<'_>,
|
||||||
|
_dss: &rustls::DigitallySignedStruct,
|
||||||
|
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||||
|
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||||
|
vec![
|
||||||
|
rustls::SignatureScheme::RSA_PKCS1_SHA256,
|
||||||
|
rustls::SignatureScheme::RSA_PKCS1_SHA384,
|
||||||
|
rustls::SignatureScheme::RSA_PKCS1_SHA512,
|
||||||
|
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
|
||||||
|
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
|
||||||
|
rustls::SignatureScheme::ECDSA_NISTP521_SHA512,
|
||||||
|
rustls::SignatureScheme::RSA_PSS_SHA256,
|
||||||
|
rustls::SignatureScheme::RSA_PSS_SHA384,
|
||||||
|
rustls::SignatureScheme::RSA_PSS_SHA512,
|
||||||
|
rustls::SignatureScheme::ED25519,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a TLS client config that accepts self-signed certificates.
|
||||||
|
pub fn create_insecure_tls_config() -> Arc<rustls::ClientConfig> {
|
||||||
|
let config = rustls::ClientConfig::builder()
|
||||||
|
.dangerous()
|
||||||
|
.with_custom_certificate_verifier(Arc::new(InsecureVerifier))
|
||||||
|
.with_no_client_auth();
|
||||||
|
|
||||||
|
Arc::new(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_insecure_verifier_creation() {
|
||||||
|
let _verifier = InsecureVerifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tls_config_creation() {
|
||||||
|
let config = create_insecure_tls_config();
|
||||||
|
// Verify the config was created successfully
|
||||||
|
assert!(Arc::strong_count(&config) >= 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
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