record-daemon: refactor client.rs, introducing typed structs for api types
All checks were successful
record-daemon / Build, check and test (push) Successful in 2m17s

This commit is contained in:
2026-03-25 17:57:58 +01:00
parent b6b145215b
commit 7aa4bfbf64
3 changed files with 844 additions and 295 deletions

View File

@@ -0,0 +1,576 @@
//! API response types for League Client API.
//!
//! These structs map directly to the JSON responses from the LQP REST API,
//! allowing automatic deserialization via serde.
use serde::{Deserialize, Serialize};
// =============================================================================
// Summoner API Responses
// =============================================================================
/// Response from `/lol-summoner/v1/current-summoner`
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SummonerResponse {
/// Summoner ID.
#[serde(default)]
pub summoner_id: Option<u64>,
/// Account ID.
#[serde(default)]
pub account_id: Option<u64>,
/// PUUID (globally unique identifier).
#[serde(default)]
pub puuid: Option<String>,
/// Display name.
#[serde(default)]
pub display_name: Option<String>,
/// Internal name.
#[serde(default)]
pub internal_name: Option<String>,
/// Name (legacy field).
#[serde(default)]
pub name: Option<String>,
/// Profile icon ID.
#[serde(default)]
pub profile_icon_id: Option<u32>,
/// Summoner level.
#[serde(default)]
pub summoner_level: Option<u32>,
}
// =============================================================================
// Gameflow Session API Responses
// =============================================================================
/// Response from `/lol-gameflow/v1/session`
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GameflowSessionResponse {
/// Current gameflow phase.
#[serde(default)]
pub phase: Option<String>,
/// Game data (present when in game).
#[serde(default)]
pub game_data: Option<GameData>,
/// Map name.
#[serde(default)]
pub map: Option<String>,
/// Game mode.
#[serde(default)]
pub game_mode: Option<String>,
/// Queue ID.
#[serde(default)]
pub queue_id: Option<u64>,
}
/// Game data within a gameflow session.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GameData {
/// Game ID.
#[serde(default)]
pub game_id: Option<u64>,
/// Queue information.
#[serde(default)]
pub queue: Option<QueueData>,
/// Team one players.
#[serde(default)]
pub team_one: Option<Vec<TeamPlayer>>,
/// Team two players.
#[serde(default)]
pub team_two: Option<Vec<TeamPlayer>>,
}
/// Queue data within game data.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct QueueData {
/// Queue ID.
#[serde(default)]
pub id: Option<u64>,
/// Queue name.
#[serde(default)]
pub name: Option<String>,
/// Game mode.
#[serde(default)]
pub game_mode: Option<String>,
/// Map ID.
#[serde(default)]
pub map_id: Option<u64>,
}
/// Player in a team.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TeamPlayer {
/// Player PUUID.
#[serde(default)]
pub puuid: Option<String>,
/// Summoner name.
#[serde(default)]
pub summoner_name: Option<String>,
/// Summoner ID.
#[serde(default)]
pub summoner_id: Option<u64>,
/// Champion ID.
#[serde(default)]
pub champion_id: Option<u64>,
/// Team ID.
#[serde(default)]
pub team_id: Option<u64>,
/// First summoner spell ID.
#[serde(default)]
pub spell1_id: Option<u64>,
/// Second summoner spell ID.
#[serde(default)]
pub spell2_id: Option<u64>,
}
// =============================================================================
// Champion Select API Responses
// =============================================================================
/// Response from `/lol-champ-select/v1/session`
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChampionSelectResponse {
/// Local player cell ID.
#[serde(default)]
pub local_player_cell_id: Option<i64>,
/// Timer information.
#[serde(default)]
pub timers: Option<Timers>,
/// My team.
#[serde(default)]
pub my_team: Option<Vec<ChampionSelectPlayer>>,
/// Their team.
#[serde(default)]
pub their_team: Option<Vec<ChampionSelectPlayer>>,
}
/// Timer information in champion select.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Timers {
/// Current phase.
#[serde(default)]
pub phase: Option<String>,
}
/// Player in champion select.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChampionSelectPlayer {
/// Cell ID.
#[serde(default)]
pub cell_id: Option<i64>,
/// Champion ID.
#[serde(default)]
pub champion_id: Option<u64>,
/// Champion name.
#[serde(default)]
pub champion_name: Option<String>,
/// Team.
#[serde(default)]
pub team: Option<i64>,
/// Skin ID.
#[serde(default)]
pub skin_id: Option<u64>,
}
// =============================================================================
// Rune Pages API Responses
// =============================================================================
/// Response from `/lol-perks/v1/currentpage`
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RunePageResponse {
/// Rune page ID.
#[serde(default)]
pub id: Option<u64>,
/// Rune page name.
#[serde(default)]
pub name: Option<String>,
/// Whether this is the current page.
#[serde(default)]
pub current: Option<bool>,
/// Primary style ID.
#[serde(default)]
pub primary_style_id: Option<u64>,
/// Secondary style ID.
#[serde(default)]
pub sub_style_id: Option<u64>,
/// Selected perk IDs.
#[serde(default)]
pub selected_perk_ids: Option<Vec<u64>>,
}
// =============================================================================
// End of Game Stats API Responses
// =============================================================================
/// Response from `/lol-end-of-game/v1/eog-stats-block`
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EndOfGameStatsResponse {
/// Game ID.
#[serde(default)]
pub game_id: Option<u64>,
/// Game length in seconds.
#[serde(default)]
pub game_length: Option<f64>,
/// Match ID.
#[serde(default)]
pub match_id: Option<u64>,
/// Game result.
#[serde(default)]
pub game_result: Option<String>,
/// Local player data.
#[serde(default)]
pub local_player: Option<EndOfGamePlayer>,
/// Teams data.
#[serde(default)]
pub teams: Option<Vec<EndOfGameTeam>>,
/// Players (legacy format).
#[serde(default)]
pub players: Option<Vec<EndOfGamePlayer>>,
}
/// Player in end-of-game stats.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EndOfGamePlayer {
/// Whether this is the local player.
#[serde(default)]
pub is_local_player: Option<bool>,
/// Player stats.
#[serde(default)]
pub stats: Option<PlayerStats>,
/// Items.
#[serde(default)]
pub items: Option<Vec<u64>>,
}
/// Team in end-of-game stats.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EndOfGameTeam {
/// Whether this is the player's team.
#[serde(default)]
pub is_player_team: Option<bool>,
/// Whether this is the winning team.
#[serde(default)]
pub is_winning_team: Option<bool>,
/// Players on the team.
#[serde(default)]
pub players: Option<Vec<EndOfGamePlayer>>,
}
/// Player stats in end-of-game.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub struct PlayerStats {
/// Kills.
#[serde(default)]
pub champions_killed: Option<u64>,
/// Deaths.
#[serde(default)]
pub num_deaths: Option<u64>,
/// Assists.
#[serde(default)]
pub assists: Option<u64>,
/// Minions killed (CS).
#[serde(default)]
pub minions_killed: Option<u64>,
/// Gold earned.
#[serde(default)]
pub gold_earned: Option<u64>,
/// Total damage dealt to champions.
#[serde(default)]
pub total_damage_dealt_to_champions: Option<u64>,
/// Total damage taken.
#[serde(default)]
pub total_damage_taken: Option<u64>,
/// Vision score.
#[serde(default)]
pub vision_score: Option<f64>,
/// Win status (1 = win).
#[serde(default)]
pub win: Option<u64>,
}
// =============================================================================
// Live Client Data API Responses
// =============================================================================
/// Response from `/liveclientdata/activeplayer`
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ActivePlayerResponse {
/// Active player's summoner name.
#[serde(default)]
pub summoner_name: Option<String>,
/// Display name.
#[serde(default)]
pub display_name: Option<String>,
/// Riot ID.
#[serde(default)]
pub riot_id: Option<String>,
/// Summoner spells.
#[serde(default)]
pub summoner_spells: Option<SummonerSpellsData>,
}
/// Summoner spells data from live client.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SummonerSpellsData {
/// First summoner spell.
#[serde(default)]
pub summoner_spell_one: Option<SpellData>,
/// Second summoner spell.
#[serde(default)]
pub summoner_spell_two: Option<SpellData>,
/// First spell ID (alternative field).
#[serde(default)]
pub spell1_id: Option<u64>,
/// Second spell ID (alternative field).
#[serde(default)]
pub spell2_id: Option<u64>,
}
/// Individual spell data.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SpellData {
/// Spell ID.
#[serde(default)]
pub spell_id: Option<u64>,
/// Display name.
#[serde(default)]
pub display_name: Option<String>,
}
/// Response from `/liveclientdata/playerlist`
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlayerListResponse(pub Vec<LiveClientPlayer>);
/// Player in live client data.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LiveClientPlayer {
/// Whether this is the local player.
#[serde(default)]
pub is_local_player: Option<bool>,
/// Summoner name.
#[serde(default)]
pub summoner_name: Option<String>,
/// Riot ID.
#[serde(default)]
pub riot_id: Option<String>,
/// PUUID.
#[serde(default)]
pub puuid: Option<String>,
/// Summoner ID.
#[serde(default)]
pub summoner_id: Option<u64>,
/// Champion name.
#[serde(default)]
pub champion_name: Option<String>,
/// Team.
#[serde(default)]
pub team: Option<u64>,
/// Items.
#[serde(default)]
pub items: Option<Vec<LiveClientItem>>,
}
/// Item in live client data.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LiveClientItem {
/// Item ID.
#[serde(default)]
pub item_id: Option<u64>,
/// Display name.
#[serde(default)]
pub display_name: Option<String>,
}
// =============================================================================
// Helper implementations
// =============================================================================
impl EndOfGameStatsResponse {
/// Get the local player from the response.
pub fn get_local_player(&self) -> Option<&EndOfGamePlayer> {
// First check local_player field
if let Some(ref player) = self.local_player {
return Some(player);
}
// Then check teams
if let Some(ref teams) = self.teams {
for team in teams {
if let Some(ref players) = team.players {
for player in players {
if player.is_local_player == Some(true) {
return Some(player);
}
}
}
}
}
// Finally check legacy players array
if let Some(ref players) = self.players {
return players.first();
}
None
}
/// Check if the local player won.
pub fn is_victory(&self) -> bool {
// Check local player stats
if let Some(player) = self.get_local_player() {
if let Some(ref stats) = player.stats {
if stats.win == Some(1) {
return true;
}
}
}
// Check teams
if let Some(ref teams) = self.teams {
for team in teams {
if team.is_player_team == Some(true) && team.is_winning_team == Some(true) {
return true;
}
}
}
false
}
}
impl ChampionSelectResponse {
/// Get the local player's champion selection.
pub fn get_local_player_selection(&self) -> Option<&ChampionSelectPlayer> {
let cell_id = self.local_player_cell_id?;
// Check my team first
if let Some(ref team) = self.my_team {
for player in team {
if player.cell_id == Some(cell_id) {
return Some(player);
}
}
}
// Check their team
if let Some(ref team) = self.their_team {
for player in team {
if player.cell_id == Some(cell_id) {
return Some(player);
}
}
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_summoner_deserialization() {
let json = r#"{"summonerId": 12345, "puuid": "abc-123", "displayName": "TestPlayer"}"#;
let summoner: SummonerResponse = serde_json::from_str(json).unwrap();
assert_eq!(summoner.summoner_id, Some(12345));
assert_eq!(summoner.puuid, Some("abc-123".to_string()));
assert_eq!(summoner.display_name, Some("TestPlayer".to_string()));
}
#[test]
fn test_player_stats_deserialization() {
let json = r#"{"CHAMPIONS_KILLED": 10, "NUM_DEATHS": 3, "ASSISTS": 15}"#;
let stats: PlayerStats = serde_json::from_str(json).unwrap();
assert_eq!(stats.champions_killed, Some(10));
assert_eq!(stats.num_deaths, Some(3));
assert_eq!(stats.assists, Some(15));
}
}

View File

@@ -5,10 +5,15 @@
use std::sync::Arc; use std::sync::Arc;
use futures::{SinkExt, StreamExt}; use futures::{SinkExt, StreamExt};
use serde::de::DeserializeOwned;
use tokio::sync::{broadcast, RwLock}; use tokio::sync::{broadcast, RwLock};
use tokio_tungstenite::{connect_async_tls_with_config, tungstenite::protocol::Message}; use tokio_tungstenite::{connect_async_tls_with_config, tungstenite::protocol::Message};
use tracing::{debug, error, info, trace, warn}; use tracing::{debug, error, info, trace, warn};
use super::api_types::{
ActivePlayerResponse, ChampionSelectResponse, EndOfGameStatsResponse, GameflowSessionResponse,
PlayerListResponse, RunePageResponse, SummonerResponse,
};
use super::auth::LockfileCredentials; use super::auth::LockfileCredentials;
use super::endpoints; use super::endpoints;
use super::events::{GameEvent, ItemBuild}; use super::events::{GameEvent, ItemBuild};
@@ -353,6 +358,13 @@ impl LqpClient {
Ok(json) 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. /// Get the current gameflow phase.
pub async fn get_gameflow_phase(&self) -> Result<GameflowPhase> { pub async fn get_gameflow_phase(&self) -> Result<GameflowPhase> {
let json = self.request("GET", endpoints::GAMEFLOW_PHASE).await?; let json = self.request("GET", endpoints::GAMEFLOW_PHASE).await?;
@@ -362,22 +374,42 @@ impl LqpClient {
Ok(GameflowPhase::from(phase_str)) Ok(GameflowPhase::from(phase_str))
} }
/// Get the current game session info. /// 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> { pub async fn get_session(&self) -> Result<serde_json::Value> {
self.request("GET", endpoints::SESSION).await self.request("GET", endpoints::SESSION).await
} }
/// Get current summoner info. /// 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> { pub async fn get_summoner(&self) -> Result<serde_json::Value> {
self.request("GET", endpoints::SUMMONER).await self.request("GET", endpoints::SUMMONER).await
} }
/// Get champion select session info. /// 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> { pub async fn get_champion_select(&self) -> Result<serde_json::Value> {
self.request("GET", endpoints::CHAMPION_SELECT).await self.request("GET", endpoints::CHAMPION_SELECT).await
} }
/// Get end-of-game stats. /// 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> { pub async fn get_game_stats(&self) -> Result<serde_json::Value> {
self.request("GET", endpoints::GAME_STATS).await self.request("GET", endpoints::GAME_STATS).await
} }
@@ -387,7 +419,12 @@ impl LqpClient {
self.request("GET", endpoints::CHAMPION_SUMMARY).await self.request("GET", endpoints::CHAMPION_SUMMARY).await
} }
/// Get current rune page. /// 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> { pub async fn get_rune_page(&self) -> Result<serde_json::Value> {
self.request("GET", endpoints::RUNE_PAGES).await self.request("GET", endpoints::RUNE_PAGES).await
} }
@@ -407,13 +444,25 @@ impl LqpClient {
self.request("GET", endpoints::LIVE_CLIENT_DATA).await self.request("GET", endpoints::LIVE_CLIENT_DATA).await
} }
/// Get active player data from live client. /// 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> { pub async fn get_live_client_active_player(&self) -> Result<serde_json::Value> {
self.request("GET", endpoints::LIVE_CLIENT_DATA_ACTIVE_PLAYER) self.request("GET", endpoints::LIVE_CLIENT_DATA_ACTIVE_PLAYER)
.await .await
} }
/// Get player list from live client. /// 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> { pub async fn get_live_client_player_list(&self) -> Result<serde_json::Value> {
self.request("GET", endpoints::LIVE_CLIENT_DATA_PLAYER_LIST) self.request("GET", endpoints::LIVE_CLIENT_DATA_PLAYER_LIST)
.await .await
@@ -434,62 +483,33 @@ impl LqpClient {
let mut metadata = PreGameMetadata::default(); let mut metadata = PreGameMetadata::default();
// Get session info for queue type and game mode // Get session info for queue type and game mode
if let Ok(session) = self.get_session().await { if let Ok(session) = self.get_session_typed().await {
if let Some(map) = session.get("map").and_then(|m| m.as_str()) { metadata.map_name = session.map;
metadata.map_name = Some(map.to_string()); metadata.game_mode = session.game_mode;
} metadata.queue_id = session.queue_id.map(|id| id as u32);
if let Some(game_mode) = session.get("gameMode").and_then(|m| m.as_str()) {
metadata.game_mode = Some(game_mode.to_string());
}
if let Some(queue_id) = session.get("queueId").and_then(|q| q.as_u64()) {
metadata.queue_id = Some(queue_id as u32);
}
} }
// Get summoner info (including puuid) // Get summoner info (including puuid)
if let Ok(summoner) = self.get_summoner().await { if let Ok(summoner) = self.get_summoner_typed().await {
if let Some(name) = summoner.get("displayName").and_then(|n| n.as_str()) { metadata.summoner_name = summoner.display_name;
metadata.summoner_name = Some(name.to_string()); if let Some(puuid) = &summoner.puuid {
} self.state.write().await.local_puuid = Some(puuid.clone());
if let Some(puuid) = summoner.get("puuid").and_then(|p| p.as_str()) {
metadata.local_puuid = Some(puuid.to_string());
self.state.write().await.local_puuid = Some(puuid.to_string());
} }
metadata.local_puuid = summoner.puuid;
} }
// Get champion select info // Get champion select info
if let Ok(champ_select) = self.get_champion_select().await { if let Ok(champ_select) = self.get_champion_select_typed().await {
if let Some(local_player_cell_id) = champ_select if let Some(player) = champ_select.get_local_player_selection() {
.get("localPlayerCellId") metadata.champion_id = player.champion_id.map(|id| id as u32);
.and_then(|id| id.as_i64()) metadata.team = player.team.map(|id| id as u32);
{ metadata.skin_id = player.skin_id.map(|id| id as u32);
if let Some(my_team) = champ_select.get("myTeam").and_then(|t| t.as_array()) {
for member in my_team {
if member.get("cellId").and_then(|id| id.as_i64())
== Some(local_player_cell_id)
{
if let Some(champion_id) =
member.get("championId").and_then(|id| id.as_u64())
{
metadata.champion_id = Some(champion_id as u32);
}
if let Some(team) = member.get("team").and_then(|t| t.as_i64()) {
metadata.team = Some(team as u32);
}
if let Some(skin_id) = member.get("skinId").and_then(|id| id.as_u64()) {
metadata.skin_id = Some(skin_id as u32);
}
}
}
}
} }
} }
// Get rune page // Get rune page
if let Ok(rune_page) = self.get_rune_page().await { if let Ok(rune_page) = self.get_rune_page_typed().await {
if let Some(name) = rune_page.get("name").and_then(|n| n.as_str()) { metadata.rune_page_name = rune_page.name;
metadata.rune_page_name = Some(name.to_string());
}
} }
Ok(metadata) Ok(metadata)
@@ -499,55 +519,25 @@ impl LqpClient {
pub async fn fetch_game_end_stats(&self) -> Result<GameEndMetadata> { pub async fn fetch_game_end_stats(&self) -> Result<GameEndMetadata> {
let mut metadata = GameEndMetadata::default(); let mut metadata = GameEndMetadata::default();
if let Ok(stats) = self.get_game_stats().await { if let Ok(stats) = self.get_game_stats_typed().await {
if let Some(victory) = stats.get("gameResult").and_then(|r| r.as_str()) { metadata.victory = Some(stats.is_victory());
metadata.victory = Some(victory == "WIN"); metadata.game_duration = stats.game_length.unwrap_or(0.0);
} metadata.match_id = stats.match_id.map(|id| id.to_string());
if let Some(players) = stats.get("players").and_then(|p| p.as_array()) { // Extract player stats
if let Some(player) = players.first() { if let Some(player) = stats.get_local_player() {
if let Some(stats_obj) = player.get("stats") { if let Some(player_stats) = &player.stats {
metadata.kills = metadata.kills = player_stats.champions_killed.unwrap_or(0) as u32;
stats_obj.get("kills").and_then(|k| k.as_u64()).unwrap_or(0) as u32; metadata.deaths = player_stats.num_deaths.unwrap_or(0) as u32;
metadata.deaths = stats_obj metadata.assists = player_stats.assists.unwrap_or(0) as u32;
.get("deaths") metadata.creep_score = player_stats.minions_killed.unwrap_or(0) as u32;
.and_then(|d| d.as_u64()) metadata.gold_earned = player_stats.gold_earned.unwrap_or(0) as u32;
.unwrap_or(0) as u32; metadata.damage_dealt =
metadata.assists = stats_obj player_stats.total_damage_dealt_to_champions.unwrap_or(0);
.get("assists") metadata.damage_taken = player_stats.total_damage_taken.unwrap_or(0);
.and_then(|a| a.as_u64()) metadata.vision_score = player_stats.vision_score.unwrap_or(0.0);
.unwrap_or(0) as u32;
metadata.creep_score = stats_obj
.get("minionsKilled")
.and_then(|cs| cs.as_u64())
.unwrap_or(0) as u32;
metadata.gold_earned = stats_obj
.get("goldEarned")
.and_then(|g| g.as_u64())
.unwrap_or(0) as u32;
metadata.damage_dealt = stats_obj
.get("totalDamageDealtToChampions")
.and_then(|d| d.as_u64())
.unwrap_or(0);
metadata.damage_taken = stats_obj
.get("totalDamageTaken")
.and_then(|d| d.as_u64())
.unwrap_or(0);
metadata.vision_score = stats_obj
.get("visionScore")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
}
} }
} }
if let Some(duration) = stats.get("gameLength").and_then(|d| d.as_f64()) {
metadata.game_duration = duration;
}
if let Some(match_id) = stats.get("matchId").and_then(|id| id.as_u64()) {
metadata.match_id = Some(match_id.to_string());
}
} }
Ok(metadata) Ok(metadata)
@@ -559,39 +549,25 @@ impl LqpClient {
let mut metadata = super::PlayerGameMetadata::default(); let mut metadata = super::PlayerGameMetadata::default();
// Get summoner info // Get summoner info (typed)
if let Ok(summoner) = self.get_summoner().await { if let Ok(summoner) = self.get_summoner_typed().await {
metadata.puuid = summoner metadata.puuid = summoner.puuid;
.get("puuid")
.and_then(|p| p.as_str())
.map(|s| s.to_string());
metadata.summoner_name = summoner metadata.summoner_name = summoner
.get("displayName") .display_name
.or_else(|| summoner.get("name")) .or(summoner.name)
.or_else(|| summoner.get("internalName")) .or(summoner.internal_name);
.and_then(|n| n.as_str())
.map(|s| s.to_string());
} }
// Get rune page // Get rune page (typed)
if let Ok(rune_page) = self.get_rune_page().await { if let Ok(rune_page) = self.get_rune_page_typed().await {
let primary_style_id = rune_page let primary_style_id = rune_page.primary_style_id.unwrap_or(0) as u32;
.get("primaryStyleId") let secondary_style_id = rune_page.sub_style_id.unwrap_or(0) as u32;
.and_then(|id| id.as_u64())
.unwrap_or(0) as u32;
let secondary_style_id = rune_page
.get("subStyleId")
.and_then(|id| id.as_u64())
.unwrap_or(0) as u32;
let selected_perks = rune_page let selected_perks = rune_page
.get("selectedPerkIds") .selected_perk_ids
.and_then(|ids| ids.as_array()) .unwrap_or_default()
.map(|arr| { .iter()
arr.iter() .map(|id| *id as u32)
.filter_map(|id| id.as_u64().map(|v| v as u32)) .collect();
.collect()
})
.unwrap_or_default();
if primary_style_id > 0 { if primary_style_id > 0 {
metadata.runes = Some(RunePage { metadata.runes = Some(RunePage {
@@ -599,47 +575,30 @@ impl LqpClient {
secondary_style_id, secondary_style_id,
selected_perks, selected_perks,
stat_modifiers: Vec::new(), stat_modifiers: Vec::new(),
name: rune_page name: rune_page.name,
.get("name") current: rune_page.current.unwrap_or(true),
.and_then(|n| n.as_str())
.map(|s| s.to_string()),
current: rune_page
.get("current")
.and_then(|c| c.as_bool())
.unwrap_or(true),
}); });
} }
} }
// Get summoner spells from live client data // Get summoner spells from live client data (typed)
if let Ok(active_player) = self.get_live_client_active_player().await { if let Ok(active_player) = self.get_live_client_active_player_typed().await {
debug!( debug!("[METADATA] Live client active player data received");
"[METADATA] Live client active player data: {:?}",
active_player
);
if let Some(summoner_spells) = active_player.get("summonerSpells") { if let Some(ref spells) = active_player.summoner_spells {
let spell1_id = summoner_spells let spell1_id = spells
.get("summonerSpellOne") .summoner_spell_one
.and_then(|s| s.get("spellId")) .as_ref()
.and_then(|id| id.as_u64()) .and_then(|s| s.spell_id)
.unwrap_or_else(|| { .or(spells.spell1_id)
summoner_spells .unwrap_or(0) as u32;
.get("spell1Id")
.and_then(|id| id.as_u64())
.unwrap_or(0)
}) as u32;
let spell2_id = summoner_spells let spell2_id = spells
.get("summonerSpellTwo") .summoner_spell_two
.and_then(|s| s.get("spellId")) .as_ref()
.and_then(|id| id.as_u64()) .and_then(|s| s.spell_id)
.unwrap_or_else(|| { .or(spells.spell2_id)
summoner_spells .unwrap_or(0) as u32;
.get("spell2Id")
.and_then(|id| id.as_u64())
.unwrap_or(0)
}) as u32;
if spell1_id > 0 || spell2_id > 0 { if spell1_id > 0 || spell2_id > 0 {
metadata.summoner_spells = Some(SummonerSpells { metadata.summoner_spells = Some(SummonerSpells {
@@ -655,35 +614,46 @@ impl LqpClient {
|| metadata.summoner_name.as_ref().is_none_or(|n| n.is_empty()) || metadata.summoner_name.as_ref().is_none_or(|n| n.is_empty())
{ {
metadata.summoner_name = active_player metadata.summoner_name = active_player
.get("summonerName") .summoner_name
.or_else(|| active_player.get("displayName")) .or(active_player.display_name)
.or_else(|| active_player.get("riotId")) .or(active_player.riot_id);
.and_then(|n| n.as_str())
.map(|s| s.to_string());
} }
} }
// Fallback: Get summoner spells from session gameData // Fallback: Get summoner spells from session gameData (typed)
if metadata.summoner_spells.is_none() { if metadata.summoner_spells.is_none() {
if let Ok(session) = self.get_session().await { if let Ok(session) = self.get_session_typed().await {
if let Some(local_puuid) = &metadata.puuid { if let Some(local_puuid) = &metadata.puuid {
if let Some(game_data) = session.get("gameData") { if let Some(ref game_data) = session.game_data {
for team_key in &["teamOne", "teamTwo"] { // Check team one
if let Some(team) = game_data.get(team_key).and_then(|t| t.as_array()) { 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 { for player in team {
if player.get("puuid").and_then(|p| p.as_str()) if player.puuid.as_deref() == Some(local_puuid.as_str()) {
== 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;
let spell1_id = player
.get("spell1Id")
.and_then(|id| id.as_u64())
.unwrap_or(0)
as u32;
let spell2_id = player
.get("spell2Id")
.and_then(|id| id.as_u64())
.unwrap_or(0)
as u32;
if spell1_id > 0 || spell2_id > 0 { if spell1_id > 0 || spell2_id > 0 {
metadata.summoner_spells = Some(SummonerSpells { metadata.summoner_spells = Some(SummonerSpells {
@@ -694,14 +664,9 @@ impl LqpClient {
}); });
} }
metadata.champion_id = player metadata.champion_id =
.get("championId") player.champion_id.map(|id| id as u32);
.and_then(|id| id.as_u64()) metadata.team = player.team_id.map(|id| id as u32);
.map(|v| v as u32);
metadata.team = player
.get("teamId")
.and_then(|id| id.as_u64())
.map(|v| v as u32);
break; break;
} }
} }
@@ -723,70 +688,64 @@ impl LqpClient {
pub async fn fetch_all_players_identities(&self) -> Result<Vec<super::PlayerIdentity>> { pub async fn fetch_all_players_identities(&self) -> Result<Vec<super::PlayerIdentity>> {
let mut players = Vec::new(); let mut players = Vec::new();
// Try live client data first // Try live client data first (typed)
if let Ok(player_list) = self.get_live_client_player_list().await { if let Ok(player_list) = self.get_live_client_player_list_typed().await {
if let Some(arr) = player_list.as_array() { for player in &player_list.0 {
for player in arr { let summoner_name = player
let summoner_name = player .summoner_name
.get("summonerName") .as_deref()
.or_else(|| player.get("riotId")) .or(player.riot_id.as_deref())
.and_then(|n| n.as_str()) .unwrap_or("");
.unwrap_or("");
let team = player if let Some(ref puuid) = player.puuid {
.get("team") players.push(super::PlayerIdentity {
.and_then(|t| t.as_u64()) puuid: puuid.clone(),
.map(|v| v as u32); summoner_name: summoner_name.to_string(),
summoner_id: player.summoner_id,
let champion_name = player champion_name: player.champion_name.clone(),
.get("championName") team: player.team.map(|id| id as u32),
.and_then(|n| n.as_str()) });
.map(|s| s.to_string());
if let Some(puuid) = player.get("puuid").and_then(|p| p.as_str()) {
players.push(super::PlayerIdentity {
puuid: puuid.to_string(),
summoner_name: summoner_name.to_string(),
summoner_id: player.get("summonerId").and_then(|id| id.as_u64()),
champion_name,
team,
});
}
} }
} }
} }
// Fallback: try from gameflow session // Fallback: try from gameflow session (typed)
if players.is_empty() { if players.is_empty() {
if let Ok(session) = self.get_session().await { if let Ok(session) = self.get_session_typed().await {
if let Some(game_data) = session.get("gameData") { if let Some(ref game_data) = session.game_data {
for team_key in &["teamOne", "teamTwo"] { // Team one (team ID 100)
let team_id = if *team_key == "teamOne" { if let Some(ref team) = game_data.team_one {
100u32 for player in team {
} else { if let (Some(ref puuid), Some(ref summoner_name)) =
200u32 (&player.puuid, &player.summoner_name)
}; {
if let Some(team) = game_data.get(team_key).and_then(|t| t.as_array()) { let champion_id = player.champion_id.map(|id| id as u32);
for player in team { let champion_name = champion_id.and_then(champion_id_to_name);
if let (Some(puuid), Some(summoner_name)) = ( players.push(super::PlayerIdentity {
player.get("puuid").and_then(|p| p.as_str()), puuid: puuid.clone(),
player.get("summonerName").and_then(|n| n.as_str()), summoner_name: summoner_name.clone(),
) { summoner_id: player.summoner_id,
let champion_id = player champion_name,
.get("championId") team: Some(100),
.and_then(|id| id.as_u64()) });
.map(|v| v as u32); }
let champion_name = champion_id.and_then(champion_id_to_name); }
players.push(super::PlayerIdentity { }
puuid: puuid.to_string(), // Team two (team ID 200)
summoner_name: summoner_name.to_string(), if let Some(ref team) = game_data.team_two {
summoner_id: player for player in team {
.get("summonerId") if let (Some(ref puuid), Some(ref summoner_name)) =
.and_then(|id| id.as_u64()), (&player.puuid, &player.summoner_name)
champion_name, {
team: Some(team_id), 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),
});
} }
} }
} }
@@ -801,34 +760,31 @@ impl LqpClient {
pub async fn fetch_final_items(&self) -> Result<Option<ItemBuild>> { pub async fn fetch_final_items(&self) -> Result<Option<ItemBuild>> {
info!("[ITEMS] Fetching final items..."); info!("[ITEMS] Fetching final items...");
// First try live client data // First try live client data (typed)
match self.get_live_client_player_list().await { match self.get_live_client_player_list_typed().await {
Ok(player_list) => { Ok(player_list) => {
info!( info!(
"[ITEMS] Live client player list response: {:?}", "[ITEMS] Live client player list response received with {} players",
player_list player_list.0.len()
); );
if let Some(players) = player_list.as_array() { for player in &player_list.0 {
info!( if player.is_local_player == Some(true) {
"[ITEMS] Found {} players in live client data", info!("[ITEMS] Found local player in live client data");
players.len() if let Some(ref items) = player.items {
); info!("[ITEMS] Items array has {} items", items.len());
for player in players { // Convert LiveClientItem to serde_json::Value for parsing
let is_local = player let items_json: Vec<serde_json::Value> = items
.get("isLocalPlayer") .iter()
.and_then(|l| l.as_bool()) .filter_map(|item| {
.unwrap_or(false); item.item_id.map(|id| {
if is_local { serde_json::json!({"itemId": id, "displayName": item.display_name})
info!("[ITEMS] Found local player in live client data"); })
if let Some(items) = player.get("items").and_then(|i| i.as_array()) { })
info!("[ITEMS] Items array has {} items", items.len()); .collect();
let item_build = parse_items_from_live_client(items); let item_build = parse_items_from_live_client(&items_json);
if item_build.is_some() { if item_build.is_some() {
info!( info!("[ITEMS] Successfully parsed items from live client data");
"[ITEMS] Successfully parsed items from live client data" return Ok(item_build);
);
return Ok(item_build);
}
} }
} }
} }
@@ -839,16 +795,22 @@ impl LqpClient {
} }
} }
// Fallback: try end-of-game stats // Fallback: try end-of-game stats (typed)
match self.get_game_stats().await { match self.get_game_stats_typed().await {
Ok(stats) => { Ok(stats) => {
info!("[ITEMS] Game stats response received"); info!("[ITEMS] Game stats response received");
if let Some(local_player) = stats.get("localPlayer") { // Try local player first
if let Some(local_player) = stats.get_local_player() {
info!("[ITEMS] Found localPlayer in game stats"); info!("[ITEMS] Found localPlayer in game stats");
if let Some(items) = local_player.get("items").and_then(|i| i.as_array()) { if let Some(ref items) = local_player.items {
info!("[ITEMS] localPlayer.items array has {} items", items.len()); info!("[ITEMS] localPlayer.items array has {} items", items.len());
let item_build = parse_items_from_game_stats(items); // 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() { if item_build.is_some() {
info!("[ITEMS] Successfully parsed items from localPlayer"); info!("[ITEMS] Successfully parsed items from localPlayer");
return Ok(item_build); return Ok(item_build);
@@ -856,25 +818,24 @@ impl LqpClient {
} }
} }
if let Some(teams) = stats.get("teams").and_then(|t| t.as_array()) { // Try teams
if let Some(ref teams) = stats.teams {
info!("[ITEMS] Found {} teams in game stats", teams.len()); info!("[ITEMS] Found {} teams in game stats", teams.len());
for team in teams { for team in teams {
if let Some(players) = team.get("players").and_then(|p| p.as_array()) { if let Some(ref players) = team.players {
for player in players { for player in players {
let is_local = player if player.is_local_player == Some(true) {
.get("isLocalPlayer")
.and_then(|l| l.as_bool())
.unwrap_or(false);
if is_local {
info!("[ITEMS] Found local player in teams[].players[]"); info!("[ITEMS] Found local player in teams[].players[]");
if let Some(items) = if let Some(ref items) = player.items {
player.get("items").and_then(|i| i.as_array())
{
info!( info!(
"[ITEMS] Player items array has {} items", "[ITEMS] Player items array has {} items",
items.len() items.len()
); );
let item_build = parse_items_from_game_stats(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() { if item_build.is_some() {
info!("[ITEMS] Successfully parsed items from teams[].players[]"); info!("[ITEMS] Successfully parsed items from teams[].players[]");
return Ok(item_build); return Ok(item_build);
@@ -886,14 +847,19 @@ impl LqpClient {
} }
} }
if let Some(players) = stats.get("players").and_then(|p| p.as_array()) { // Try legacy players array
if let Some(ref players) = stats.players {
info!( info!(
"[ITEMS] Found {} players in game stats (legacy)", "[ITEMS] Found {} players in game stats (legacy)",
players.len() players.len()
); );
if let Some(player) = players.first() { if let Some(player) = players.first() {
if let Some(items) = player.get("items").and_then(|i| i.as_array()) { if let Some(ref items) = player.items {
let item_build = parse_items_from_game_stats(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() { if item_build.is_some() {
return Ok(item_build); return Ok(item_build);
} }

View File

@@ -3,6 +3,7 @@
//! This module handles communication with the League of Legends client //! This module handles communication with the League of Legends client
//! via WebSocket and REST API for game event detection and capture. //! via WebSocket and REST API for game event detection and capture.
mod api_types;
mod auth; mod auth;
mod client; mod client;
mod endpoints; mod endpoints;
@@ -14,6 +15,12 @@ mod state;
mod tls; mod tls;
mod websocket; mod websocket;
pub use api_types::{
ActivePlayerResponse, ChampionSelectPlayer, ChampionSelectResponse, EndOfGamePlayer,
EndOfGameStatsResponse, EndOfGameTeam, GameData, GameflowSessionResponse, LiveClientItem,
LiveClientPlayer, PlayerListResponse, PlayerStats, QueueData, RunePageResponse,
SummonerResponse, SummonerSpellsData, TeamPlayer, Timers,
};
pub use auth::{LockfileCredentials, LockfileWatcher}; pub use auth::{LockfileCredentials, LockfileWatcher};
pub use client::LqpClient; pub use client::LqpClient;
pub use endpoints::{ pub use endpoints::{