record-daemon: initial commit

This commit is contained in:
2026-03-19 17:48:07 +01:00
commit d6c0334369
30 changed files with 9486 additions and 0 deletions

View File

@@ -0,0 +1,340 @@
//! LQP authentication via League Client lockfile.
//!
//! The League Client writes a lockfile on startup containing connection credentials.
use std::fs;
use std::path::PathBuf;
use std::time::Duration;
use tracing::{debug, trace, warn};
use crate::error::{LqpError, Result};
/// League Client lockfile location (relative to League Client install).
pub const LOCKFILE_NAME: &str = "lockfile";
/// Default League Client paths on Linux (via Lutris/Wine).
pub const LINUX_CLIENT_PATHS: &[&str] = &[
// Lutris default path
"$HOME/Games/League of Legends/LeagueClient",
// Wine prefix
"$HOME/.wine/drive_c/Riot Games/League of Legends/LeagueClient",
];
/// Default League Client paths on Windows.
pub const WINDOWS_CLIENT_PATHS: &[&str] = &[
"C:\\Riot Games\\League of Legends\\lockfile",
"D:\\Riot Games\\League of Legends\\lockfile",
];
/// Credentials extracted from the League Client lockfile.
#[derive(Debug, Clone)]
pub struct LockfileCredentials {
/// Process name (usually "LeagueClient").
pub process_name: String,
/// Process ID.
pub pid: u32,
/// Port for the LQP API.
pub port: u16,
/// Password for authentication.
pub password: String,
/// Protocol (usually "https").
pub protocol: String,
}
impl LockfileCredentials {
/// Parse lockfile contents into credentials.
///
/// Lockfile format: `LeagueClient:PID:PORT:PASSWORD:PROTOCOL`
pub fn parse(contents: &str) -> Result<Self> {
let parts: Vec<&str> = contents.trim().split(':').collect();
if parts.len() != 5 {
return Err(LqpError::LockfileParseError(format!(
"Expected 5 fields, got {}",
parts.len()
))
.into());
}
let process_name = parts[0].to_string();
let pid = parts[1]
.parse::<u32>()
.map_err(|e| LqpError::LockfileParseError(format!("Invalid PID: {}", e)))?;
let port = parts[2]
.parse::<u16>()
.map_err(|e| LqpError::LockfileParseError(format!("Invalid port: {}", e)))?;
let password = parts[3].to_string();
let protocol = parts[4].to_string();
Ok(Self {
process_name,
pid,
port,
password,
protocol,
})
}
/// Get the base URL for the LQP REST API.
pub fn base_url(&self) -> String {
format!("{}://127.0.0.1:{}", self.protocol, self.port)
}
/// Get the WebSocket URL for the LQP WebSocket API.
pub fn ws_url(&self) -> String {
format!("wss://127.0.0.1:{}", self.port)
}
/// Get the Basic Auth header value.
pub fn auth_header(&self) -> String {
let credentials = format!("riot:{}", self.password);
let encoded =
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, credentials);
format!("Basic {}", encoded)
}
}
/// Watcher for League Client lockfile.
///
/// Monitors for the League Client starting and stopping by watching
/// for the lockfile to appear/disappear.
pub struct LockfileWatcher {
/// Possible lockfile paths to check.
paths: Vec<PathBuf>,
/// Current credentials if client is running.
current: Option<LockfileCredentials>,
}
impl LockfileWatcher {
/// Create a new lockfile watcher with default paths.
pub fn new() -> Self {
let paths = Self::discover_lockfile_paths();
Self {
paths,
current: None,
}
}
/// Create a watcher with a specific lockfile path.
pub fn with_path(path: PathBuf) -> Self {
Self {
paths: vec![path],
current: None,
}
}
/// Discover possible lockfile paths based on OS.
fn discover_lockfile_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
#[cfg(target_os = "linux")]
{
// Check for Wine/Lutris installations
if let Some(home) = std::env::var_os("HOME") {
let home_path = PathBuf::from(&home);
// Lutris default
let lutris_path = home_path
.join("Games")
.join("League of Legends")
.join("lockfile");
paths.push(lutris_path);
// Wine prefix
let wine_path = home_path
.join(".wine")
.join("drive_c")
.join("Riot Games")
.join("League of Legends")
.join("lockfile");
paths.push(wine_path);
// Proton (Steam)
let proton_path = home_path
.join(".local")
.join("share")
.join("Steam")
.join("steamapps")
.join("compatdata")
.join("League of Legends")
.join("pfx")
.join("drive_c")
.join("Riot Games")
.join("League of Legends")
.join("lockfile");
paths.push(proton_path);
}
}
#[cfg(target_os = "windows")]
{
// Default Windows paths
paths.push(PathBuf::from("C:\\Riot Games\\League of Legends\\lockfile"));
paths.push(PathBuf::from("D:\\Riot Games\\League of Legends\\lockfile"));
// Check Program Files
paths.push(PathBuf::from(
"C:\\Program Files\\Riot Games\\League of Legends\\lockfile",
));
paths.push(PathBuf::from(
"C:\\Program Files (x86)\\Riot Games\\League of Legends\\lockfile",
));
}
paths
}
/// Check if the League Client is currently running.
pub fn is_client_running(&self) -> bool {
self.current.is_some()
}
/// Get current credentials if available.
pub fn credentials(&self) -> Option<&LockfileCredentials> {
self.current.as_ref()
}
/// Check for lockfile and update credentials.
///
/// Returns:
/// - `Some(true)` if client just started
/// - `Some(false)` if client just stopped
/// - `None` if no change
pub fn check(&mut self) -> Result<Option<bool>> {
// Try to find and read lockfile
let found = self.find_lockfile()?;
match (found, &self.current) {
(Some(creds), None) => {
// Client started
debug!(
"League Client detected (PID: {}, Port: {})",
creds.pid, creds.port
);
self.current = Some(creds);
Ok(Some(true))
}
(Some(new_creds), Some(old_creds)) => {
// Client still running, check if restarted
if new_creds.pid != old_creds.pid {
debug!(
"League Client restarted (PID: {} -> {})",
old_creds.pid, new_creds.pid
);
self.current = Some(new_creds);
// Don't report as change, just update
Ok(None)
} else {
// No change
Ok(None)
}
}
(None, Some(_)) => {
// Client stopped
debug!("League Client stopped");
self.current = None;
Ok(Some(false))
}
(None, None) => {
// Still not running
Ok(None)
}
}
}
/// Find and read the lockfile.
fn find_lockfile(&self) -> Result<Option<LockfileCredentials>> {
for path in &self.paths {
trace!("Checking lockfile path: {:?}", path);
if path.exists() {
match fs::read_to_string(path) {
Ok(contents) => match LockfileCredentials::parse(&contents) {
Ok(creds) => return Ok(Some(creds)),
Err(e) => {
warn!("Failed to parse lockfile at {:?}: {}", path, e);
continue;
}
},
Err(e) => {
trace!("Failed to read lockfile at {:?}: {}", path, e);
continue;
}
}
}
}
Ok(None)
}
/// Wait for the League Client to start.
///
/// Blocks until the client is detected or timeout is reached.
pub async fn wait_for_client(&mut self, timeout: Duration) -> Result<LockfileCredentials> {
let start = std::time::Instant::now();
loop {
if let Some(creds) = self.find_lockfile()? {
self.current = Some(creds.clone());
return Ok(creds);
}
if start.elapsed() >= timeout {
return Err(LqpError::Timeout.into());
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
}
}
impl Default for LockfileWatcher {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_lockfile() {
let contents = "LeagueClient:12345:52436:abc123:https";
let creds = LockfileCredentials::parse(contents).unwrap();
assert_eq!(creds.process_name, "LeagueClient");
assert_eq!(creds.pid, 12345);
assert_eq!(creds.port, 52436);
assert_eq!(creds.password, "abc123");
assert_eq!(creds.protocol, "https");
}
#[test]
fn test_base_url() {
let creds = LockfileCredentials {
process_name: "LeagueClient".to_string(),
pid: 12345,
port: 52436,
password: "abc123".to_string(),
protocol: "https".to_string(),
};
assert_eq!(creds.base_url(), "https://127.0.0.1:52436");
}
#[test]
fn test_auth_header() {
let creds = LockfileCredentials {
process_name: "LeagueClient".to_string(),
pid: 12345,
port: 52436,
password: "abc123".to_string(),
protocol: "https".to_string(),
};
// base64("riot:abc123") = "cmlvdDphYmMxMjM="
assert!(creds.auth_header().contains("Basic "));
}
}

View File

@@ -0,0 +1,423 @@
//! LQP Client for communicating with the League Client API.
//!
//! Provides both WebSocket (for events) and REST (for queries) interfaces.
use std::sync::Arc;
use futures::{SinkExt, StreamExt};
use tokio::sync::{broadcast, RwLock};
use tokio_tungstenite::{connect_async_with_config, tungstenite::protocol::Message};
use tracing::{debug, error, info, trace, warn};
use super::auth::LockfileCredentials;
use super::events::GameEvent;
use crate::error::{LqpError, Result};
/// LQP WebSocket endpoints to subscribe to.
const SUBSCRIBE_ENDPOINTS: &[&str] = &[
"/lol-gameflow/v1/gameflow-phase",
"/lol-matchmaking/v1/ready-check",
"/lol-game-events/v1/game-events",
];
/// 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";
}
/// 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>,
}
impl Default for ClientState {
fn default() -> Self {
Self {
phase: GameflowPhase::None,
game_id: None,
champion: None,
}
}
}
/// LQP Client for League Client communication.
pub struct LqpClient {
/// Connection credentials.
credentials: Arc<RwLock<Option<LockfileCredentials>>>,
/// Current client state.
state: Arc<RwLock<ClientState>>,
/// Event broadcaster.
event_sender: broadcast::Sender<GameEvent>,
/// HTTP client for REST API.
http_client: reqwest::Client,
/// Shutdown signal.
shutdown: Arc<RwLock<bool>>,
}
impl LqpClient {
/// Create a new LQP client.
pub fn new() -> Self {
let (event_sender, _) = broadcast::channel(256);
let http_client = reqwest::Client::builder()
.danger_accept_invalid_certs(true) // LQP uses self-signed certs
.build()
.expect("Failed to create HTTP client");
Self {
credentials: Arc::new(RwLock::new(None)),
state: Arc::new(RwLock::new(ClientState::default())),
event_sender,
http_client,
shutdown: Arc::new(RwLock::new(false)),
}
}
/// Get a subscriber for game events.
pub fn subscribe(&self) -> broadcast::Receiver<GameEvent> {
self.event_sender.subscribe()
}
/// Get current client state.
pub async fn state(&self) -> ClientState {
self.state.read().await.clone()
}
/// Check if connected to League Client.
pub async fn is_connected(&self) -> bool {
self.credentials.read().await.is_some()
}
/// Connect to the League Client with the given credentials.
pub async fn connect(&self, creds: LockfileCredentials) -> Result<()> {
info!("Connecting to League Client at port {}", creds.port);
// Store credentials
*self.credentials.write().await = Some(creds.clone());
// Verify connection by fetching current phase
match self.get_gameflow_phase().await {
Ok(phase) => {
self.state.write().await.phase = phase;
info!("Connected to League Client, current phase: {:?}", phase);
}
Err(e) => {
warn!("Failed to verify connection: {}", e);
// Still consider connected, WebSocket might work
}
}
Ok(())
}
/// Disconnect from the League Client.
pub async fn disconnect(&self) {
*self.shutdown.write().await = true;
*self.credentials.write().await = None;
*self.state.write().await = ClientState::default();
info!("Disconnected from League Client");
}
/// Start the WebSocket event listener.
///
/// This runs in a background task and broadcasts events to subscribers.
pub async fn start_event_listener(&self) -> Result<()> {
let creds = self
.credentials
.read()
.await
.clone()
.ok_or(LqpError::ClientNotRunning)?;
let ws_url = format!("{}/", creds.ws_url());
let auth_header = creds.auth_header();
info!("Connecting to LQP WebSocket at {}", ws_url);
// Build WebSocket request with auth header
let request = tokio_tungstenite::tungstenite::http::Request::builder()
.uri(&ws_url)
.header("Authorization", auth_header)
.body(())
.map_err(|e| LqpError::ConnectionFailed(e.to_string()))?;
let (ws_stream, _) = connect_async_with_config(
request, None, false,
// None,
)
.await
.map_err(|e| LqpError::WebSocketError(e.to_string()))?;
info!("WebSocket connected, subscribing to events");
let (mut write, mut read) = ws_stream.split();
// Subscribe to endpoints
for endpoint in SUBSCRIBE_ENDPOINTS {
let subscribe_msg = serde_json::json!([5, endpoint]);
let msg = Message::Text(subscribe_msg.to_string());
write
.send(msg)
.await
.map_err(|e| LqpError::WebSocketError(e.to_string()))?;
trace!("Subscribed to {}", endpoint);
}
// Clone references for the async block
let event_sender = self.event_sender.clone();
let state = self.state.clone();
let shutdown = self.shutdown.clone();
let credentials = self.credentials.clone();
// Spawn the message handler
tokio::spawn(async move {
while let Some(msg) = read.next().await {
if *shutdown.read().await {
debug!("WebSocket listener shutting down");
break;
}
match msg {
Ok(Message::Text(text)) => {
if let Some(event) = Self::parse_websocket_message(&text) {
// Update state based on event
Self::update_state_from_event(&state, &event).await;
// Broadcast event
if event_sender.send(event.clone()).is_err() {
trace!("No event subscribers");
}
}
}
Ok(Message::Close(_)) => {
info!("WebSocket closed by server");
break;
}
Ok(Message::Ping(data)) => {
// Respond with pong
let _ = write.send(Message::Pong(data)).await;
}
Err(e) => {
error!("WebSocket error: {}", e);
break;
}
_ => {}
}
}
// Clear credentials on disconnect
*credentials.write().await = None;
debug!("WebSocket listener ended");
});
Ok(())
}
/// Parse a WebSocket message into a game event.
fn parse_websocket_message(text: &str) -> Option<GameEvent> {
trace!("WebSocket message: {}", text);
// Parse the message array format: [type, endpoint, data]
let value: serde_json::Value = serde_json::from_str(text).ok()?;
// 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
let endpoint = arr.get(1)?.as_str()?;
let data = arr.get(2)?;
return Self::parse_event_from_endpoint(endpoint, data);
}
}
}
None
}
/// Parse an event based on the endpoint.
fn parse_event_from_endpoint(endpoint: &str, data: &serde_json::Value) -> Option<GameEvent> {
match endpoint {
"/lol-gameflow/v1/gameflow-phase" => {
let phase = data.as_str()?;
Some(
GameEvent::from_json(&serde_json::json!({
"eventType": "lcu-phase-change",
"phase": phase
}))
.unwrap_or(GameEvent::Unknown),
)
}
"/lol-game-events/v1/game-events" => GameEvent::from_json(data),
_ => {
trace!("Unhandled endpoint: {}", endpoint);
None
}
}
}
/// Update internal state from a game event.
async fn update_state_from_event(state: &Arc<RwLock<ClientState>>, event: &GameEvent) {
let mut state = state.write().await;
match event {
GameEvent::GameStart(info) => {
state.phase = GameflowPhase::InProgress;
state.game_id = Some(info.game_id);
state.champion = info.champion.clone();
}
GameEvent::GameEnd(_) => {
state.phase = GameflowPhase::EndOfGame;
}
_ => {}
}
}
/// Make a REST API request to the League Client.
pub async fn request(&self, method: &str, endpoint: &str) -> Result<serde_json::Value> {
let creds = self
.credentials
.read()
.await
.clone()
.ok_or(LqpError::ClientNotRunning)?;
let url = format!("{}{}", creds.base_url(), endpoint);
let request = match method {
"GET" => self.http_client.get(&url),
"POST" => self.http_client.post(&url),
"PUT" => self.http_client.put(&url),
"DELETE" => self.http_client.delete(&url),
_ => {
return Err(
LqpError::ConnectionFailed(format!("Invalid method: {}", method)).into(),
)
}
}
.header("Authorization", creds.auth_header());
let response = request
.send()
.await
.map_err(|e| LqpError::ConnectionFailed(e.to_string()))?;
if !response.status().is_success() {
return Err(LqpError::ConnectionFailed(format!(
"API request failed: {}",
response.status()
))
.into());
}
let json = response
.json()
.await
.map_err(|e| LqpError::EventParseError(e.to_string()))?;
Ok(json)
}
/// Get the current gameflow phase.
pub async fn get_gameflow_phase(&self) -> Result<GameflowPhase> {
let json = self.request("GET", endpoints::GAMEFLOW_PHASE).await?;
let phase_str = json
.as_str()
.ok_or_else(|| LqpError::EventParseError("Invalid phase response".to_string()))?;
Ok(GameflowPhase::from(phase_str))
}
/// Get the current game session info.
pub async fn get_session(&self) -> Result<serde_json::Value> {
self.request("GET", endpoints::SESSION).await
}
/// Get current summoner info.
pub async fn get_summoner(&self) -> Result<serde_json::Value> {
self.request("GET", endpoints::SUMMONER).await
}
}
impl Default for LqpClient {
fn default() -> Self {
Self::new()
}
}
#[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_creation() {
let client = LqpClient::new();
assert!(!tokio_test::block_on(client.is_connected()));
}
}

View File

@@ -0,0 +1,346 @@
//! Game event types from the League Client API.
//!
//! These events are received via WebSocket subscription to LQP endpoints.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
/// A game event received from the League Client.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "eventType", rename_all = "camelCase")]
pub enum GameEvent {
/// Match found in queue.
#[serde(rename = "lcu-match-found")]
MatchFound(MatchInfo),
/// Game has started.
#[serde(rename = "lcu-game-start")]
GameStart(GameStartInfo),
/// Player killed an enemy.
#[serde(rename = "lcu-kill")]
Kill(KillEvent),
/// Player died.
#[serde(rename = "lcu-death")]
Death(DeathEvent),
/// Objective was taken.
#[serde(rename = "lcu-objective")]
Objective(ObjectiveEvent),
/// Game has ended.
#[serde(rename = "lcu-game-end")]
GameEnd(GameEndInfo),
/// Unknown event type.
#[serde(other)]
Unknown,
}
/// Match found event data.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MatchInfo {
/// Match ID.
#[serde(default)]
pub match_id: Option<String>,
/// Queue type (ranked, normal, aram, etc.).
pub queue_type: String,
/// Map name.
pub map: String,
/// Game mode.
pub game_mode: String,
/// Timestamp when match was found.
#[serde(default = "Utc::now")]
pub timestamp: DateTime<Utc>,
}
/// Game start event data.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GameStartInfo {
/// Game ID.
pub game_id: u64,
/// Server address.
#[serde(default)]
pub server: Option<String>,
/// Player's champion name.
#[serde(default)]
pub champion: Option<String>,
/// Player's summoner name.
#[serde(default)]
pub summoner_name: Option<String>,
/// Team (100 = blue, 200 = red).
#[serde(default)]
pub team: Option<u32>,
/// Game start timestamp.
#[serde(default = "Utc::now")]
pub timestamp: DateTime<Utc>,
}
/// Kill event data.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KillEvent {
/// Killer summoner name.
pub killer: String,
/// Killer champion name.
#[serde(default)]
pub killer_champion: Option<String>,
/// Victim summoner name.
pub victim: String,
/// Victim champion name.
#[serde(default)]
pub victim_champion: Option<String>,
/// Whether this was a solo kill.
#[serde(default)]
pub solo_kill: bool,
/// Number of assists.
#[serde(default)]
pub assists: u32,
/// Kill position on map.
#[serde(default)]
pub position: Option<Position>,
/// Game time when kill occurred.
#[serde(default)]
pub game_time: Option<f64>,
/// Real timestamp.
#[serde(default = "Utc::now")]
pub timestamp: DateTime<Utc>,
}
/// Death event data.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeathEvent {
/// Killer summoner name (if killed by champion).
#[serde(default)]
pub killer: Option<String>,
/// Killer champion name.
#[serde(default)]
pub killer_champion: Option<String>,
/// Death cause (champion, minion, tower, etc.).
pub cause: String,
/// Death position on map.
#[serde(default)]
pub position: Option<Position>,
/// Game time when death occurred.
#[serde(default)]
pub game_time: Option<f64>,
/// Real timestamp.
#[serde(default = "Utc::now")]
pub timestamp: DateTime<Utc>,
}
/// Objective event data.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ObjectiveEvent {
/// Type of objective.
pub objective_type: ObjectiveType,
/// Team that took the objective (100 = blue, 200 = red).
pub team: u32,
/// Whether the player participated.
#[serde(default)]
pub participated: bool,
/// Game time when objective was taken.
#[serde(default)]
pub game_time: Option<f64>,
/// Real timestamp.
#[serde(default = "Utc::now")]
pub timestamp: DateTime<Utc>,
}
/// Type of objective.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ObjectiveType {
Dragon,
Herald,
Baron,
Tower,
Inhibitor,
Nexus,
RiftHerald,
ElderDragon,
}
/// 2D position on the map.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct Position {
pub x: f32,
pub y: f32,
}
/// Game end event data.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GameEndInfo {
/// Game ID.
pub game_id: u64,
/// Whether the player's team won.
pub victory: bool,
/// Game duration in seconds.
pub duration: f64,
/// Player's stats.
#[serde(default)]
pub stats: Option<PlayerStats>,
/// End timestamp.
#[serde(default = "Utc::now")]
pub timestamp: DateTime<Utc>,
}
/// Player statistics at game end.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlayerStats {
/// Kills.
pub kills: u32,
/// Deaths.
pub deaths: u32,
/// Assists.
pub assists: u32,
/// Total gold earned.
pub gold_earned: u32,
/// Total damage dealt.
pub damage_dealt: u64,
/// Total damage taken.
pub damage_taken: u64,
/// Minions killed (CS).
pub minions_killed: u32,
/// Vision score.
#[serde(default)]
pub vision_score: f64,
}
/// Raw event data from LQP WebSocket.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RawEvent {
/// Event type URI.
#[serde(rename = "uri")]
pub uri: String,
/// Event data.
pub data: EventData,
}
/// Event data payload.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum EventData {
/// Game event.
GameEvent(GameEvent),
/// Raw JSON value.
Raw(serde_json::Value),
}
impl GameEvent {
/// Parse a game event from raw WebSocket data.
pub fn from_json(value: &serde_json::Value) -> Option<Self> {
serde_json::from_value(value.clone()).ok()
}
/// Check if this event is relevant for recording.
pub fn is_relevant(&self) -> bool {
!matches!(self, GameEvent::Unknown)
}
/// Get a human-readable description of the event.
pub fn description(&self) -> String {
match self {
GameEvent::MatchFound(info) => {
format!("Match found: {} ({})", info.game_mode, info.queue_type)
}
GameEvent::GameStart(info) => {
format!("Game started: ID {}", info.game_id)
}
GameEvent::Kill(kill) => {
format!("{} killed {}", kill.killer, kill.victim)
}
GameEvent::Death(death) => {
format!("Player died ({})", death.cause)
}
GameEvent::Objective(obj) => {
let team = if obj.team == 100 { "Blue" } else { "Red" };
format!("{} took {:?}", team, obj.objective_type)
}
GameEvent::GameEnd(end) => {
let result = if end.victory { "Victory" } else { "Defeat" };
format!("Game ended: {} ({:.1}s)", result, end.duration)
}
GameEvent::Unknown => "Unknown event".to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_kill_event() {
let json = serde_json::json!({
"eventType": "lcu-kill",
"killer": "Player1",
"killerChampion": "Ahri",
"victim": "Player2",
"victimChampion": "Lux",
"soloKill": true,
"assists": 0
});
let event: GameEvent = serde_json::from_value(json).unwrap();
if let GameEvent::Kill(kill) = event {
assert_eq!(kill.killer, "Player1");
assert!(kill.solo_kill);
} else {
panic!("Expected Kill event");
}
}
#[test]
fn test_objective_type_deserialization() {
let json = serde_json::json!("dragon");
let obj: ObjectiveType = serde_json::from_value(json).unwrap();
assert_eq!(obj, ObjectiveType::Dragon);
}
}

View File

@@ -0,0 +1,15 @@
//! League Client API (LQP) module.
//!
//! This module handles communication with the League of Legends client
//! via WebSocket and REST API for game event detection and capture.
mod auth;
mod client;
mod events;
pub use auth::{LockfileCredentials, LockfileWatcher};
pub use client::{GameflowPhase, LqpClient};
pub use events::{
DeathEvent, EventData, GameEndInfo, GameEvent, GameStartInfo, KillEvent, MatchInfo,
ObjectiveEvent, ObjectiveType,
};