record-daemon: initial commit
This commit is contained in:
340
record-daemon/src/lqp/auth.rs
Normal file
340
record-daemon/src/lqp/auth.rs
Normal 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 "));
|
||||
}
|
||||
}
|
||||
423
record-daemon/src/lqp/client.rs
Normal file
423
record-daemon/src/lqp/client.rs
Normal 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()));
|
||||
}
|
||||
}
|
||||
346
record-daemon/src/lqp/events.rs
Normal file
346
record-daemon/src/lqp/events.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
15
record-daemon/src/lqp/mod.rs
Normal file
15
record-daemon/src/lqp/mod.rs
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user