All checks were successful
record-daemon / Build, check and test (push) Successful in 2m17s
898 lines
37 KiB
Rust
898 lines
37 KiB
Rust
//! 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 serde::de::DeserializeOwned;
|
|
use tokio::sync::{broadcast, RwLock};
|
|
use tokio_tungstenite::{connect_async_tls_with_config, tungstenite::protocol::Message};
|
|
use tracing::{debug, error, info, trace, warn};
|
|
|
|
use super::api_types::{
|
|
ActivePlayerResponse, ChampionSelectResponse, EndOfGameStatsResponse, GameflowSessionResponse,
|
|
PlayerListResponse, RunePageResponse, SummonerResponse,
|
|
};
|
|
use super::auth::LockfileCredentials;
|
|
use super::endpoints;
|
|
use super::events::{GameEvent, ItemBuild};
|
|
use super::items::{parse_items_from_game_stats, parse_items_from_live_client};
|
|
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};
|
|
|
|
/// 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>>,
|
|
/// Last emitted game ID for deduplication of GameStart events.
|
|
last_emitted_game_id: Arc<RwLock<Option<u64>>>,
|
|
}
|
|
|
|
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)),
|
|
last_emitted_game_id: Arc::new(RwLock::new(None)),
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
}
|
|
|
|
// Fetch local player's puuid for champion extraction
|
|
if let Ok(summoner) = self.get_summoner().await {
|
|
if let Some(puuid) = summoner.get("puuid").and_then(|p| p.as_str()) {
|
|
self.state.write().await.local_puuid = Some(puuid.to_string());
|
|
info!("Fetched local player puuid: {}", puuid);
|
|
}
|
|
}
|
|
|
|
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();
|
|
*self.last_emitted_game_id.write().await = None;
|
|
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);
|
|
|
|
// Create a TLS connector that accepts the self-signed certificate from League Client
|
|
use tokio_tungstenite::Connector;
|
|
|
|
let connector = Connector::Rustls(create_insecure_tls_config());
|
|
|
|
// Build WebSocket request with auth header
|
|
let request = tokio_tungstenite::tungstenite::http::Request::builder()
|
|
.uri(&ws_url)
|
|
.header("Authorization", auth_header)
|
|
.header("Host", format!("127.0.0.1:{}", creds.port))
|
|
.header("Connection", "Upgrade")
|
|
.header("Upgrade", "websocket")
|
|
.header("Sec-WebSocket-Version", "13")
|
|
.header(
|
|
"Sec-WebSocket-Key",
|
|
tokio_tungstenite::tungstenite::handshake::client::generate_key(),
|
|
)
|
|
.body(())
|
|
.map_err(|e| LqpError::ConnectionFailed(e.to_string()))?;
|
|
|
|
let (ws_stream, _) = connect_async_tls_with_config(request, None, false, Some(connector))
|
|
.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 using OnJsonApiEvent format
|
|
// Format: [5, "OnJsonApiEvent", endpoint]
|
|
for endpoint in super::endpoints::SUBSCRIBE_ENDPOINTS {
|
|
let subscribe_msg = serde_json::json!([5, "OnJsonApiEvent", endpoint]);
|
|
let msg = Message::Text(subscribe_msg.to_string());
|
|
info!("Subscribing to: {} with OnJsonApiEvent", endpoint);
|
|
write
|
|
.send(msg)
|
|
.await
|
|
.map_err(|e| LqpError::WebSocketError(e.to_string()))?;
|
|
}
|
|
info!("All subscriptions sent");
|
|
|
|
// 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();
|
|
let last_emitted_game_id = self.last_emitted_game_id.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 text.is_empty() {
|
|
continue;
|
|
}
|
|
// Get local_puuid from state for champion extraction
|
|
let local_puuid = state.read().await.local_puuid.clone();
|
|
if let Some(event) = parse_websocket_message(&text, local_puuid.as_deref())
|
|
{
|
|
// Update state based on event
|
|
Self::update_state_from_event(&state, &event).await;
|
|
|
|
// Check for duplicate GameStart events
|
|
if let GameEvent::GameStart(ref info) = event {
|
|
let mut last_game_id = last_emitted_game_id.write().await;
|
|
if *last_game_id == Some(info.game_id) {
|
|
info!(
|
|
"Skipping duplicate GameStart event for game_id={}",
|
|
info.game_id
|
|
);
|
|
continue;
|
|
}
|
|
*last_game_id = Some(info.game_id);
|
|
}
|
|
|
|
// Reset last_emitted_game_id on GameEnd to allow new game starts
|
|
if let GameEvent::GameEnd(_) = &event {
|
|
*last_emitted_game_id.write().await = None;
|
|
}
|
|
|
|
// Broadcast event
|
|
if event_sender.send(event.clone()).is_err() {
|
|
trace!("No event subscribers");
|
|
}
|
|
}
|
|
}
|
|
Ok(Message::Binary(data)) => {
|
|
debug!("Received binary message: {} bytes", data.len());
|
|
// Try to parse as UTF-8
|
|
if let Ok(text) = String::from_utf8(data) {
|
|
if !text.is_empty() {
|
|
// Get local_puuid from state for champion extraction
|
|
let local_puuid = state.read().await.local_puuid.clone();
|
|
if let Some(event) =
|
|
parse_websocket_message(&text, local_puuid.as_deref())
|
|
{
|
|
// Update state based on event
|
|
Self::update_state_from_event(&state, &event).await;
|
|
|
|
// Check for duplicate GameStart events
|
|
if let GameEvent::GameStart(ref info) = event {
|
|
let mut last_game_id = last_emitted_game_id.write().await;
|
|
if *last_game_id == Some(info.game_id) {
|
|
info!(
|
|
"Skipping duplicate GameStart event for game_id={}",
|
|
info.game_id
|
|
);
|
|
continue;
|
|
}
|
|
*last_game_id = Some(info.game_id);
|
|
}
|
|
|
|
// Reset last_emitted_game_id on GameEnd to allow new game starts
|
|
if let GameEvent::GameEnd(_) = &event {
|
|
*last_emitted_game_id.write().await = None;
|
|
}
|
|
|
|
// 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
|
|
debug!("Received ping, sending pong");
|
|
let _ = write.send(Message::Pong(data)).await;
|
|
}
|
|
Ok(Message::Pong(_)) => {
|
|
debug!("Received pong");
|
|
}
|
|
Ok(Message::Frame(_)) => {
|
|
debug!("Received raw frame");
|
|
}
|
|
Err(e) => {
|
|
error!("WebSocket error: {}", e);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clear credentials on disconnect
|
|
*credentials.write().await = None;
|
|
debug!("WebSocket listener ended");
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// 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;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// REST API Methods
|
|
// =========================================================================
|
|
|
|
/// 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)
|
|
}
|
|
|
|
/// Make a typed REST API request to the League Client.
|
|
async fn request_typed<T: DeserializeOwned>(&self, method: &str, endpoint: &str) -> Result<T> {
|
|
let json = self.request(method, endpoint).await?;
|
|
serde_json::from_value(json)
|
|
.map_err(|e| LqpError::EventParseError(format!("Deserialization failed: {}", e)).into())
|
|
}
|
|
|
|
/// 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 (typed).
|
|
pub async fn get_session_typed(&self) -> Result<GameflowSessionResponse> {
|
|
self.request_typed("GET", endpoints::SESSION).await
|
|
}
|
|
|
|
/// Get the current game session info (raw JSON for backward compatibility).
|
|
pub async fn get_session(&self) -> Result<serde_json::Value> {
|
|
self.request("GET", endpoints::SESSION).await
|
|
}
|
|
|
|
/// Get current summoner info (typed).
|
|
pub async fn get_summoner_typed(&self) -> Result<SummonerResponse> {
|
|
self.request_typed("GET", endpoints::SUMMONER).await
|
|
}
|
|
|
|
/// Get current summoner info (raw JSON for backward compatibility).
|
|
pub async fn get_summoner(&self) -> Result<serde_json::Value> {
|
|
self.request("GET", endpoints::SUMMONER).await
|
|
}
|
|
|
|
/// Get champion select session info (typed).
|
|
pub async fn get_champion_select_typed(&self) -> Result<ChampionSelectResponse> {
|
|
self.request_typed("GET", endpoints::CHAMPION_SELECT).await
|
|
}
|
|
|
|
/// Get champion select session info (raw JSON for backward compatibility).
|
|
pub async fn get_champion_select(&self) -> Result<serde_json::Value> {
|
|
self.request("GET", endpoints::CHAMPION_SELECT).await
|
|
}
|
|
|
|
/// Get end-of-game stats (typed).
|
|
pub async fn get_game_stats_typed(&self) -> Result<EndOfGameStatsResponse> {
|
|
self.request_typed("GET", endpoints::GAME_STATS).await
|
|
}
|
|
|
|
/// Get end-of-game stats (raw JSON for backward compatibility).
|
|
pub async fn get_game_stats(&self) -> Result<serde_json::Value> {
|
|
self.request("GET", endpoints::GAME_STATS).await
|
|
}
|
|
|
|
/// Get the currently selected champion in champ select.
|
|
pub async fn get_current_champion(&self) -> Result<serde_json::Value> {
|
|
self.request("GET", endpoints::CHAMPION_SUMMARY).await
|
|
}
|
|
|
|
/// Get current rune page (typed).
|
|
pub async fn get_rune_page_typed(&self) -> Result<RunePageResponse> {
|
|
self.request_typed("GET", endpoints::RUNE_PAGES).await
|
|
}
|
|
|
|
/// Get current rune page (raw JSON for backward compatibility).
|
|
pub async fn get_rune_page(&self) -> Result<serde_json::Value> {
|
|
self.request("GET", endpoints::RUNE_PAGES).await
|
|
}
|
|
|
|
/// Get all rune pages.
|
|
pub async fn get_all_rune_pages(&self) -> Result<serde_json::Value> {
|
|
self.request("GET", endpoints::ALL_RUNE_PAGES).await
|
|
}
|
|
|
|
/// Get match history.
|
|
pub async fn get_match_history(&self) -> Result<serde_json::Value> {
|
|
self.request("GET", endpoints::MATCH_HISTORY).await
|
|
}
|
|
|
|
/// Get live client data (available during game).
|
|
pub async fn get_live_client_data(&self) -> Result<serde_json::Value> {
|
|
self.request("GET", endpoints::LIVE_CLIENT_DATA).await
|
|
}
|
|
|
|
/// Get active player data from live client (typed).
|
|
pub async fn get_live_client_active_player_typed(&self) -> Result<ActivePlayerResponse> {
|
|
self.request_typed("GET", endpoints::LIVE_CLIENT_DATA_ACTIVE_PLAYER)
|
|
.await
|
|
}
|
|
|
|
/// Get active player data from live client (raw JSON for backward compatibility).
|
|
pub async fn get_live_client_active_player(&self) -> Result<serde_json::Value> {
|
|
self.request("GET", endpoints::LIVE_CLIENT_DATA_ACTIVE_PLAYER)
|
|
.await
|
|
}
|
|
|
|
/// Get player list from live client (typed).
|
|
pub async fn get_live_client_player_list_typed(&self) -> Result<PlayerListResponse> {
|
|
self.request_typed("GET", endpoints::LIVE_CLIENT_DATA_PLAYER_LIST)
|
|
.await
|
|
}
|
|
|
|
/// Get player list from live client (raw JSON for backward compatibility).
|
|
pub async fn get_live_client_player_list(&self) -> Result<serde_json::Value> {
|
|
self.request("GET", endpoints::LIVE_CLIENT_DATA_PLAYER_LIST)
|
|
.await
|
|
}
|
|
|
|
/// Get local player selection from champion select.
|
|
pub async fn get_local_player_selection(&self) -> Result<serde_json::Value> {
|
|
self.request("GET", endpoints::CHAMPION_SELECT_LOCAL_PLAYER)
|
|
.await
|
|
}
|
|
|
|
// =========================================================================
|
|
// Metadata Fetching Methods
|
|
// =========================================================================
|
|
|
|
/// Fetch pre-game metadata (champion, skin, runes, queue info).
|
|
pub async fn fetch_pregame_metadata(&self) -> Result<PreGameMetadata> {
|
|
let mut metadata = PreGameMetadata::default();
|
|
|
|
// Get session info for queue type and game mode
|
|
if let Ok(session) = self.get_session_typed().await {
|
|
metadata.map_name = session.map;
|
|
metadata.game_mode = session.game_mode;
|
|
metadata.queue_id = session.queue_id.map(|id| id as u32);
|
|
}
|
|
|
|
// Get summoner info (including puuid)
|
|
if let Ok(summoner) = self.get_summoner_typed().await {
|
|
metadata.summoner_name = summoner.display_name;
|
|
if let Some(puuid) = &summoner.puuid {
|
|
self.state.write().await.local_puuid = Some(puuid.clone());
|
|
}
|
|
metadata.local_puuid = summoner.puuid;
|
|
}
|
|
|
|
// Get champion select info
|
|
if let Ok(champ_select) = self.get_champion_select_typed().await {
|
|
if let Some(player) = champ_select.get_local_player_selection() {
|
|
metadata.champion_id = player.champion_id.map(|id| id as u32);
|
|
metadata.team = player.team.map(|id| id as u32);
|
|
metadata.skin_id = player.skin_id.map(|id| id as u32);
|
|
}
|
|
}
|
|
|
|
// Get rune page
|
|
if let Ok(rune_page) = self.get_rune_page_typed().await {
|
|
metadata.rune_page_name = rune_page.name;
|
|
}
|
|
|
|
Ok(metadata)
|
|
}
|
|
|
|
/// Fetch end-of-game stats.
|
|
pub async fn fetch_game_end_stats(&self) -> Result<GameEndMetadata> {
|
|
let mut metadata = GameEndMetadata::default();
|
|
|
|
if let Ok(stats) = self.get_game_stats_typed().await {
|
|
metadata.victory = Some(stats.is_victory());
|
|
metadata.game_duration = stats.game_length.unwrap_or(0.0);
|
|
metadata.match_id = stats.match_id.map(|id| id.to_string());
|
|
|
|
// Extract player stats
|
|
if let Some(player) = stats.get_local_player() {
|
|
if let Some(player_stats) = &player.stats {
|
|
metadata.kills = player_stats.champions_killed.unwrap_or(0) as u32;
|
|
metadata.deaths = player_stats.num_deaths.unwrap_or(0) as u32;
|
|
metadata.assists = player_stats.assists.unwrap_or(0) as u32;
|
|
metadata.creep_score = player_stats.minions_killed.unwrap_or(0) as u32;
|
|
metadata.gold_earned = player_stats.gold_earned.unwrap_or(0) as u32;
|
|
metadata.damage_dealt =
|
|
player_stats.total_damage_dealt_to_champions.unwrap_or(0);
|
|
metadata.damage_taken = player_stats.total_damage_taken.unwrap_or(0);
|
|
metadata.vision_score = player_stats.vision_score.unwrap_or(0.0);
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(metadata)
|
|
}
|
|
|
|
/// Fetch complete player game metadata including runes, summoner spells, and items.
|
|
pub async fn fetch_player_game_metadata(&self) -> Result<super::PlayerGameMetadata> {
|
|
use super::{RunePage, SummonerSpells};
|
|
|
|
let mut metadata = super::PlayerGameMetadata::default();
|
|
|
|
// Get summoner info (typed)
|
|
if let Ok(summoner) = self.get_summoner_typed().await {
|
|
metadata.puuid = summoner.puuid;
|
|
metadata.summoner_name = summoner
|
|
.display_name
|
|
.or(summoner.name)
|
|
.or(summoner.internal_name);
|
|
}
|
|
|
|
// Get rune page (typed)
|
|
if let Ok(rune_page) = self.get_rune_page_typed().await {
|
|
let primary_style_id = rune_page.primary_style_id.unwrap_or(0) as u32;
|
|
let secondary_style_id = rune_page.sub_style_id.unwrap_or(0) as u32;
|
|
let selected_perks = rune_page
|
|
.selected_perk_ids
|
|
.unwrap_or_default()
|
|
.iter()
|
|
.map(|id| *id as u32)
|
|
.collect();
|
|
|
|
if primary_style_id > 0 {
|
|
metadata.runes = Some(RunePage {
|
|
primary_style_id,
|
|
secondary_style_id,
|
|
selected_perks,
|
|
stat_modifiers: Vec::new(),
|
|
name: rune_page.name,
|
|
current: rune_page.current.unwrap_or(true),
|
|
});
|
|
}
|
|
}
|
|
|
|
// Get summoner spells from live client data (typed)
|
|
if let Ok(active_player) = self.get_live_client_active_player_typed().await {
|
|
debug!("[METADATA] Live client active player data received");
|
|
|
|
if let Some(ref spells) = active_player.summoner_spells {
|
|
let spell1_id = spells
|
|
.summoner_spell_one
|
|
.as_ref()
|
|
.and_then(|s| s.spell_id)
|
|
.or(spells.spell1_id)
|
|
.unwrap_or(0) as u32;
|
|
|
|
let spell2_id = spells
|
|
.summoner_spell_two
|
|
.as_ref()
|
|
.and_then(|s| s.spell_id)
|
|
.or(spells.spell2_id)
|
|
.unwrap_or(0) as u32;
|
|
|
|
if spell1_id > 0 || spell2_id > 0 {
|
|
metadata.summoner_spells = Some(SummonerSpells {
|
|
spell1_id,
|
|
spell2_id,
|
|
spell1_name: spell_id_to_name(spell1_id),
|
|
spell2_name: spell_id_to_name(spell2_id),
|
|
});
|
|
}
|
|
}
|
|
|
|
if metadata.summoner_name.is_none()
|
|
|| metadata.summoner_name.as_ref().is_none_or(|n| n.is_empty())
|
|
{
|
|
metadata.summoner_name = active_player
|
|
.summoner_name
|
|
.or(active_player.display_name)
|
|
.or(active_player.riot_id);
|
|
}
|
|
}
|
|
|
|
// Fallback: Get summoner spells from session gameData (typed)
|
|
if metadata.summoner_spells.is_none() {
|
|
if let Ok(session) = self.get_session_typed().await {
|
|
if let Some(local_puuid) = &metadata.puuid {
|
|
if let Some(ref game_data) = session.game_data {
|
|
// Check team one
|
|
if let Some(ref team) = game_data.team_one {
|
|
for player in team {
|
|
if player.puuid.as_deref() == Some(local_puuid.as_str()) {
|
|
let spell1_id = player.spell1_id.unwrap_or(0) as u32;
|
|
let spell2_id = player.spell2_id.unwrap_or(0) as u32;
|
|
|
|
if spell1_id > 0 || spell2_id > 0 {
|
|
metadata.summoner_spells = Some(SummonerSpells {
|
|
spell1_id,
|
|
spell2_id,
|
|
spell1_name: spell_id_to_name(spell1_id),
|
|
spell2_name: spell_id_to_name(spell2_id),
|
|
});
|
|
}
|
|
|
|
metadata.champion_id = player.champion_id.map(|id| id as u32);
|
|
metadata.team = player.team_id.map(|id| id as u32);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
// Check team two if not found
|
|
if metadata.summoner_spells.is_none() {
|
|
if let Some(ref team) = game_data.team_two {
|
|
for player in team {
|
|
if player.puuid.as_deref() == Some(local_puuid.as_str()) {
|
|
let spell1_id = player.spell1_id.unwrap_or(0) as u32;
|
|
let spell2_id = player.spell2_id.unwrap_or(0) as u32;
|
|
|
|
if spell1_id > 0 || spell2_id > 0 {
|
|
metadata.summoner_spells = Some(SummonerSpells {
|
|
spell1_id,
|
|
spell2_id,
|
|
spell1_name: spell_id_to_name(spell1_id),
|
|
spell2_name: spell_id_to_name(spell2_id),
|
|
});
|
|
}
|
|
|
|
metadata.champion_id =
|
|
player.champion_id.map(|id| id as u32);
|
|
metadata.team = player.team_id.map(|id| id as u32);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(champ_id) = metadata.champion_id {
|
|
metadata.champion_name = champion_id_to_name(champ_id);
|
|
}
|
|
|
|
Ok(metadata)
|
|
}
|
|
|
|
/// Fetch all players' puuid to summoner name mapping.
|
|
pub async fn fetch_all_players_identities(&self) -> Result<Vec<super::PlayerIdentity>> {
|
|
let mut players = Vec::new();
|
|
|
|
// Try live client data first (typed)
|
|
if let Ok(player_list) = self.get_live_client_player_list_typed().await {
|
|
for player in &player_list.0 {
|
|
let summoner_name = player
|
|
.summoner_name
|
|
.as_deref()
|
|
.or(player.riot_id.as_deref())
|
|
.unwrap_or("");
|
|
|
|
if let Some(ref puuid) = player.puuid {
|
|
players.push(super::PlayerIdentity {
|
|
puuid: puuid.clone(),
|
|
summoner_name: summoner_name.to_string(),
|
|
summoner_id: player.summoner_id,
|
|
champion_name: player.champion_name.clone(),
|
|
team: player.team.map(|id| id as u32),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback: try from gameflow session (typed)
|
|
if players.is_empty() {
|
|
if let Ok(session) = self.get_session_typed().await {
|
|
if let Some(ref game_data) = session.game_data {
|
|
// Team one (team ID 100)
|
|
if let Some(ref team) = game_data.team_one {
|
|
for player in team {
|
|
if let (Some(ref puuid), Some(ref summoner_name)) =
|
|
(&player.puuid, &player.summoner_name)
|
|
{
|
|
let champion_id = player.champion_id.map(|id| id as u32);
|
|
let champion_name = champion_id.and_then(champion_id_to_name);
|
|
players.push(super::PlayerIdentity {
|
|
puuid: puuid.clone(),
|
|
summoner_name: summoner_name.clone(),
|
|
summoner_id: player.summoner_id,
|
|
champion_name,
|
|
team: Some(100),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
// Team two (team ID 200)
|
|
if let Some(ref team) = game_data.team_two {
|
|
for player in team {
|
|
if let (Some(ref puuid), Some(ref summoner_name)) =
|
|
(&player.puuid, &player.summoner_name)
|
|
{
|
|
let champion_id = player.champion_id.map(|id| id as u32);
|
|
let champion_name = champion_id.and_then(champion_id_to_name);
|
|
players.push(super::PlayerIdentity {
|
|
puuid: puuid.clone(),
|
|
summoner_name: summoner_name.clone(),
|
|
summoner_id: player.summoner_id,
|
|
champion_name,
|
|
team: Some(200),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(players)
|
|
}
|
|
|
|
/// Fetch final items from end-of-game stats or live client data.
|
|
pub async fn fetch_final_items(&self) -> Result<Option<ItemBuild>> {
|
|
info!("[ITEMS] Fetching final items...");
|
|
|
|
// First try live client data (typed)
|
|
match self.get_live_client_player_list_typed().await {
|
|
Ok(player_list) => {
|
|
info!(
|
|
"[ITEMS] Live client player list response received with {} players",
|
|
player_list.0.len()
|
|
);
|
|
for player in &player_list.0 {
|
|
if player.is_local_player == Some(true) {
|
|
info!("[ITEMS] Found local player in live client data");
|
|
if let Some(ref items) = player.items {
|
|
info!("[ITEMS] Items array has {} items", items.len());
|
|
// Convert LiveClientItem to serde_json::Value for parsing
|
|
let items_json: Vec<serde_json::Value> = items
|
|
.iter()
|
|
.filter_map(|item| {
|
|
item.item_id.map(|id| {
|
|
serde_json::json!({"itemId": id, "displayName": item.display_name})
|
|
})
|
|
})
|
|
.collect();
|
|
let item_build = parse_items_from_live_client(&items_json);
|
|
if item_build.is_some() {
|
|
info!("[ITEMS] Successfully parsed items from live client data");
|
|
return Ok(item_build);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
info!("[ITEMS] Failed to get live client player list: {:?}", e);
|
|
}
|
|
}
|
|
|
|
// Fallback: try end-of-game stats (typed)
|
|
match self.get_game_stats_typed().await {
|
|
Ok(stats) => {
|
|
info!("[ITEMS] Game stats response received");
|
|
|
|
// Try local player first
|
|
if let Some(local_player) = stats.get_local_player() {
|
|
info!("[ITEMS] Found localPlayer in game stats");
|
|
if let Some(ref items) = local_player.items {
|
|
info!("[ITEMS] localPlayer.items array has {} items", items.len());
|
|
// Convert item IDs to serde_json::Value for parsing
|
|
let items_json: Vec<serde_json::Value> = items
|
|
.iter()
|
|
.map(|id| serde_json::json!({"itemID": id}))
|
|
.collect();
|
|
let item_build = parse_items_from_game_stats(&items_json);
|
|
if item_build.is_some() {
|
|
info!("[ITEMS] Successfully parsed items from localPlayer");
|
|
return Ok(item_build);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Try teams
|
|
if let Some(ref teams) = stats.teams {
|
|
info!("[ITEMS] Found {} teams in game stats", teams.len());
|
|
for team in teams {
|
|
if let Some(ref players) = team.players {
|
|
for player in players {
|
|
if player.is_local_player == Some(true) {
|
|
info!("[ITEMS] Found local player in teams[].players[]");
|
|
if let Some(ref items) = player.items {
|
|
info!(
|
|
"[ITEMS] Player items array has {} items",
|
|
items.len()
|
|
);
|
|
let items_json: Vec<serde_json::Value> = items
|
|
.iter()
|
|
.map(|id| serde_json::json!({"itemID": id}))
|
|
.collect();
|
|
let item_build = parse_items_from_game_stats(&items_json);
|
|
if item_build.is_some() {
|
|
info!("[ITEMS] Successfully parsed items from teams[].players[]");
|
|
return Ok(item_build);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Try legacy players array
|
|
if let Some(ref players) = stats.players {
|
|
info!(
|
|
"[ITEMS] Found {} players in game stats (legacy)",
|
|
players.len()
|
|
);
|
|
if let Some(player) = players.first() {
|
|
if let Some(ref items) = player.items {
|
|
let items_json: Vec<serde_json::Value> = items
|
|
.iter()
|
|
.map(|id| serde_json::json!({"itemID": id}))
|
|
.collect();
|
|
let item_build = parse_items_from_game_stats(&items_json);
|
|
if item_build.is_some() {
|
|
return Ok(item_build);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
info!("[ITEMS] Could not find items in game stats structure");
|
|
}
|
|
Err(e) => {
|
|
info!("[ITEMS] Failed to get game stats: {:?}", e);
|
|
}
|
|
}
|
|
|
|
info!("[ITEMS] Could not fetch final items from any source");
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
impl Default for LqpClient {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_client_creation() {
|
|
let client = LqpClient::new();
|
|
assert!(!tokio_test::block_on(client.is_connected()));
|
|
}
|
|
}
|