Files
leaguerecorder/record-daemon/src/lqp/client.rs
Valentin Haudiquet 7aa4bfbf64
All checks were successful
record-daemon / Build, check and test (push) Successful in 2m17s
record-daemon: refactor client.rs, introducing typed structs for api types
2026-03-25 17:57:58 +01:00

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()));
}
}