record-daemon: refactor client.rs

This commit is contained in:
2026-03-25 16:08:39 +01:00
parent ac4020b841
commit b6b145215b
3 changed files with 173 additions and 122 deletions

View File

@@ -11,7 +11,8 @@ use tracing::{debug, error, info, trace, warn};
use super::auth::LockfileCredentials; use super::auth::LockfileCredentials;
use super::endpoints; use super::endpoints;
use super::events::{GameEvent, ItemBuild, ItemInfo}; 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::mappings::{champion_id_to_name, spell_id_to_name};
use super::metadata::{GameEndMetadata, PreGameMetadata}; use super::metadata::{GameEndMetadata, PreGameMetadata};
use super::state::{ClientState, GameflowPhase}; use super::state::{ClientState, GameflowPhase};
@@ -303,6 +304,10 @@ impl LqpClient {
} }
} }
// =========================================================================
// REST API Methods
// =========================================================================
/// Make a REST API request to the League Client. /// Make a REST API request to the League Client.
pub async fn request(&self, method: &str, endpoint: &str) -> Result<serde_json::Value> { pub async fn request(&self, method: &str, endpoint: &str) -> Result<serde_json::Value> {
let creds = self let creds = self
@@ -351,11 +356,9 @@ impl LqpClient {
/// 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?;
let phase_str = json let phase_str = json
.as_str() .as_str()
.ok_or_else(|| LqpError::EventParseError("Invalid phase response".to_string()))?; .ok_or_else(|| LqpError::EventParseError("Invalid phase response".to_string()))?;
Ok(GameflowPhase::from(phase_str)) Ok(GameflowPhase::from(phase_str))
} }
@@ -410,7 +413,7 @@ impl LqpClient {
.await .await
} }
/// Get player list from live client (contains all players with puuid and summoner names). /// Get player list from live client.
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
@@ -422,6 +425,10 @@ impl LqpClient {
.await .await
} }
// =========================================================================
// Metadata Fetching Methods
// =========================================================================
/// Fetch pre-game metadata (champion, skin, runes, queue info). /// Fetch pre-game metadata (champion, skin, runes, queue info).
pub async fn fetch_pregame_metadata(&self) -> Result<PreGameMetadata> { pub async fn fetch_pregame_metadata(&self) -> Result<PreGameMetadata> {
let mut metadata = PreGameMetadata::default(); let mut metadata = PreGameMetadata::default();
@@ -444,17 +451,14 @@ impl LqpClient {
if let Some(name) = summoner.get("displayName").and_then(|n| n.as_str()) { if let Some(name) = summoner.get("displayName").and_then(|n| n.as_str()) {
metadata.summoner_name = Some(name.to_string()); metadata.summoner_name = Some(name.to_string());
} }
// Store the local player's puuid in state
if let Some(puuid) = summoner.get("puuid").and_then(|p| p.as_str()) { if let Some(puuid) = summoner.get("puuid").and_then(|p| p.as_str()) {
metadata.local_puuid = Some(puuid.to_string()); metadata.local_puuid = Some(puuid.to_string());
let mut state = self.state.write().await; self.state.write().await.local_puuid = Some(puuid.to_string());
state.local_puuid = Some(puuid.to_string());
} }
} }
// 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().await {
// Find local player's cell
if let Some(local_player_cell_id) = champ_select if let Some(local_player_cell_id) = champ_select
.get("localPlayerCellId") .get("localPlayerCellId")
.and_then(|id| id.as_i64()) .and_then(|id| id.as_i64())
@@ -496,14 +500,11 @@ impl LqpClient {
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().await {
// Get game result
if let Some(victory) = stats.get("gameResult").and_then(|r| r.as_str()) { if let Some(victory) = stats.get("gameResult").and_then(|r| r.as_str()) {
metadata.victory = Some(victory == "WIN"); metadata.victory = Some(victory == "WIN");
} }
// Get player stats
if let Some(players) = stats.get("players").and_then(|p| p.as_array()) { if let Some(players) = stats.get("players").and_then(|p| p.as_array()) {
// Find the local player (first player in the array usually)
if let Some(player) = players.first() { if let Some(player) = players.first() {
if let Some(stats_obj) = player.get("stats") { if let Some(stats_obj) = player.get("stats") {
metadata.kills = metadata.kills =
@@ -540,12 +541,10 @@ impl LqpClient {
} }
} }
// Get game duration
if let Some(duration) = stats.get("gameLength").and_then(|d| d.as_f64()) { if let Some(duration) = stats.get("gameLength").and_then(|d| d.as_f64()) {
metadata.game_duration = duration; metadata.game_duration = duration;
} }
// Get match ID
if let Some(match_id) = stats.get("matchId").and_then(|id| id.as_u64()) { if let Some(match_id) = stats.get("matchId").and_then(|id| id.as_u64()) {
metadata.match_id = Some(match_id.to_string()); metadata.match_id = Some(match_id.to_string());
} }
@@ -555,19 +554,17 @@ impl LqpClient {
} }
/// Fetch complete player game metadata including runes, summoner spells, and items. /// Fetch complete player game metadata including runes, summoner spells, and items.
/// This should be called when the game starts.
pub async fn fetch_player_game_metadata(&self) -> Result<super::PlayerGameMetadata> { pub async fn fetch_player_game_metadata(&self) -> Result<super::PlayerGameMetadata> {
use super::{RunePage, SummonerSpells}; use super::{RunePage, SummonerSpells};
let mut metadata = super::PlayerGameMetadata::default(); let mut metadata = super::PlayerGameMetadata::default();
// Get summoner info - try multiple field names for summoner name // Get summoner info
if let Ok(summoner) = self.get_summoner().await { if let Ok(summoner) = self.get_summoner().await {
metadata.puuid = summoner metadata.puuid = summoner
.get("puuid") .get("puuid")
.and_then(|p| p.as_str()) .and_then(|p| p.as_str())
.map(|s| s.to_string()); .map(|s| s.to_string());
// Try displayName first, then name, then internalName
metadata.summoner_name = summoner metadata.summoner_name = summoner
.get("displayName") .get("displayName")
.or_else(|| summoner.get("name")) .or_else(|| summoner.get("name"))
@@ -601,7 +598,7 @@ impl LqpClient {
primary_style_id, primary_style_id,
secondary_style_id, secondary_style_id,
selected_perks, selected_perks,
stat_modifiers: Vec::new(), // Stat modifiers are part of selectedPerkIds stat_modifiers: Vec::new(),
name: rune_page name: rune_page
.get("name") .get("name")
.and_then(|n| n.as_str()) .and_then(|n| n.as_str())
@@ -614,22 +611,19 @@ impl LqpClient {
} }
} }
// Try to get summoner spells from live client data (available during game) // Get summoner spells from live client data
if let Ok(active_player) = self.get_live_client_active_player().await { if let Ok(active_player) = self.get_live_client_active_player().await {
debug!( debug!(
"[METADATA] Live client active player data: {:?}", "[METADATA] Live client active player data: {:?}",
active_player active_player
); );
// Get summoner spells from active player - try multiple field structures
if let Some(summoner_spells) = active_player.get("summonerSpells") { if let Some(summoner_spells) = active_player.get("summonerSpells") {
// Try nested structure first: summonerSpells.summonerSpellOne.spellId
let spell1_id = summoner_spells let spell1_id = summoner_spells
.get("summonerSpellOne") .get("summonerSpellOne")
.and_then(|s| s.get("spellId")) .and_then(|s| s.get("spellId"))
.and_then(|id| id.as_u64()) .and_then(|id| id.as_u64())
.unwrap_or_else(|| { .unwrap_or_else(|| {
// Fallback: direct spell1Id field
summoner_spells summoner_spells
.get("spell1Id") .get("spell1Id")
.and_then(|id| id.as_u64()) .and_then(|id| id.as_u64())
@@ -641,18 +635,12 @@ impl LqpClient {
.and_then(|s| s.get("spellId")) .and_then(|s| s.get("spellId"))
.and_then(|id| id.as_u64()) .and_then(|id| id.as_u64())
.unwrap_or_else(|| { .unwrap_or_else(|| {
// Fallback: direct spell2Id field
summoner_spells summoner_spells
.get("spell2Id") .get("spell2Id")
.and_then(|id| id.as_u64()) .and_then(|id| id.as_u64())
.unwrap_or(0) .unwrap_or(0)
}) as u32; }) as u32;
debug!(
"[METADATA] Summoner spells from live client: spell1={}, spell2={}",
spell1_id, spell2_id
);
if spell1_id > 0 || spell2_id > 0 { if spell1_id > 0 || spell2_id > 0 {
metadata.summoner_spells = Some(SummonerSpells { metadata.summoner_spells = Some(SummonerSpells {
spell1_id, spell1_id,
@@ -663,7 +651,6 @@ impl LqpClient {
} }
} }
// Get summoner name from active player if not already set
if metadata.summoner_name.is_none() if metadata.summoner_name.is_none()
|| metadata.summoner_name.as_ref().is_none_or(|n| n.is_empty()) || metadata.summoner_name.as_ref().is_none_or(|n| n.is_empty())
{ {
@@ -680,9 +667,7 @@ impl LqpClient {
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().await {
if let Some(local_puuid) = &metadata.puuid { if let Some(local_puuid) = &metadata.puuid {
// Try to get from gameData
if let Some(game_data) = session.get("gameData") { if let Some(game_data) = session.get("gameData") {
// Check teamOne and teamTwo for player
for team_key in &["teamOne", "teamTwo"] { for team_key in &["teamOne", "teamTwo"] {
if let Some(team) = game_data.get(team_key).and_then(|t| t.as_array()) { if let Some(team) = game_data.get(team_key).and_then(|t| t.as_array()) {
for player in team { for player in team {
@@ -727,7 +712,6 @@ impl LqpClient {
} }
} }
// Try to get champion name from champion ID
if let Some(champ_id) = metadata.champion_id { if let Some(champ_id) = metadata.champion_id {
metadata.champion_name = champion_id_to_name(champ_id); metadata.champion_name = champion_id_to_name(champ_id);
} }
@@ -735,29 +719,25 @@ impl LqpClient {
Ok(metadata) Ok(metadata)
} }
/// Fetch all players' puuid to summoner name mapping from live client data. /// Fetch all players' puuid to summoner name mapping.
/// This should be called during the game to get all player identities.
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 (available during game) // Try live client data first
if let Ok(player_list) = self.get_live_client_player_list().await { if let Ok(player_list) = self.get_live_client_player_list().await {
if let Some(arr) = player_list.as_array() { if let Some(arr) = player_list.as_array() {
for player in arr { for player in arr {
// Get summoner name - try multiple fields
let summoner_name = player let summoner_name = player
.get("summonerName") .get("summonerName")
.or_else(|| player.get("riotId")) .or_else(|| player.get("riotId"))
.and_then(|n| n.as_str()) .and_then(|n| n.as_str())
.unwrap_or(""); .unwrap_or("");
// Get team from teamId
let team = player let team = player
.get("team") .get("team")
.and_then(|t| t.as_u64()) .and_then(|t| t.as_u64())
.map(|v| v as u32); .map(|v| v as u32);
// Get champion name
let champion_name = player let champion_name = player
.get("championName") .get("championName")
.and_then(|n| n.as_str()) .and_then(|n| n.as_str())
@@ -818,10 +798,10 @@ impl LqpClient {
} }
/// Fetch final items from end-of-game stats or live client data. /// Fetch final items from end-of-game stats or live client data.
pub async fn fetch_final_items(&self) -> Result<Option<super::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 (available during game) // First try live client data
match self.get_live_client_player_list().await { match self.get_live_client_player_list().await {
Ok(player_list) => { Ok(player_list) => {
info!( info!(
@@ -833,9 +813,7 @@ impl LqpClient {
"[ITEMS] Found {} players in live client data", "[ITEMS] Found {} players in live client data",
players.len() players.len()
); );
// Find the local player (first player or one with matching puuid)
for player in players { for player in players {
// Check if this is the local player
let is_local = player let is_local = player
.get("isLocalPlayer") .get("isLocalPlayer")
.and_then(|l| l.as_bool()) .and_then(|l| l.as_bool())
@@ -844,15 +822,13 @@ impl LqpClient {
info!("[ITEMS] Found local player in live client data"); info!("[ITEMS] Found local player in live client data");
if let Some(items) = player.get("items").and_then(|i| i.as_array()) { if let Some(items) = player.get("items").and_then(|i| i.as_array()) {
info!("[ITEMS] Items array has {} items", items.len()); info!("[ITEMS] Items array has {} items", items.len());
let item_build = Self::parse_items_from_live_client(items); let item_build = parse_items_from_live_client(items);
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);
} }
} else {
info!("[ITEMS] No items array found for local player");
} }
} }
} }
@@ -868,12 +844,11 @@ impl LqpClient {
Ok(stats) => { Ok(stats) => {
info!("[ITEMS] Game stats response received"); info!("[ITEMS] Game stats response received");
// First try localPlayer field (items are just numbers in an array)
if let Some(local_player) = stats.get("localPlayer") { if let Some(local_player) = stats.get("localPlayer") {
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(items) = local_player.get("items").and_then(|i| i.as_array()) {
info!("[ITEMS] localPlayer.items array has {} items", items.len()); info!("[ITEMS] localPlayer.items array has {} items", items.len());
let item_build = Self::parse_items_from_game_stats(items); let item_build = parse_items_from_game_stats(items);
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);
@@ -881,7 +856,6 @@ impl LqpClient {
} }
} }
// Try teams[].players[] structure
if let Some(teams) = stats.get("teams").and_then(|t| t.as_array()) { if let Some(teams) = stats.get("teams").and_then(|t| t.as_array()) {
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 {
@@ -900,7 +874,7 @@ impl LqpClient {
"[ITEMS] Player items array has {} items", "[ITEMS] Player items array has {} items",
items.len() items.len()
); );
let item_build = Self::parse_items_from_game_stats(items); let item_build = parse_items_from_game_stats(items);
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);
@@ -912,7 +886,6 @@ impl LqpClient {
} }
} }
// Try old players array structure (for backwards compatibility)
if let Some(players) = stats.get("players").and_then(|p| p.as_array()) { if let Some(players) = stats.get("players").and_then(|p| p.as_array()) {
info!( info!(
"[ITEMS] Found {} players in game stats (legacy)", "[ITEMS] Found {} players in game stats (legacy)",
@@ -920,7 +893,7 @@ impl LqpClient {
); );
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(items) = player.get("items").and_then(|i| i.as_array()) {
let item_build = Self::parse_items_from_game_stats(items); let item_build = parse_items_from_game_stats(items);
if item_build.is_some() { if item_build.is_some() {
return Ok(item_build); return Ok(item_build);
} }
@@ -938,78 +911,6 @@ impl LqpClient {
info!("[ITEMS] Could not fetch final items from any source"); info!("[ITEMS] Could not fetch final items from any source");
Ok(None) Ok(None)
} }
/// Parse items from live client data format.
fn parse_items_from_live_client(items: &[serde_json::Value]) -> Option<ItemBuild> {
let mut item_list = Vec::new();
let mut trinket = None;
for (slot, item) in items.iter().enumerate() {
if let Some(item_id) = item.get("itemID").and_then(|id| id.as_u64()) {
if item_id > 0 {
let item_info = ItemInfo {
item_id: item_id as u32,
name: item
.get("displayName")
.and_then(|n| n.as_str())
.map(|s| s.to_string()),
slot: slot as u32,
};
// Slot 6 is typically the trinket
if slot == 6 {
trinket = Some(item_info);
} else {
item_list.push(item_info);
}
}
}
}
if !item_list.is_empty() || trinket.is_some() {
Some(ItemBuild {
items: item_list,
trinket,
})
} else {
None
}
}
/// Parse items from game stats format (array of item IDs as numbers).
fn parse_items_from_game_stats(items: &[serde_json::Value]) -> Option<ItemBuild> {
let mut item_list = Vec::new();
let mut trinket = None;
for (slot, item) in items.iter().enumerate() {
// Items in game stats are just numbers (item IDs), not objects
if let Some(item_id) = item.as_u64() {
if item_id > 0 {
let item_info = ItemInfo {
item_id: item_id as u32,
name: None, // Item names would need a separate mapping
slot: slot as u32,
};
// Slot 6 is typically the trinket
if slot == 6 {
trinket = Some(item_info);
} else {
item_list.push(item_info);
}
}
}
}
if !item_list.is_empty() || trinket.is_some() {
Some(ItemBuild {
items: item_list,
trinket,
})
} else {
None
}
}
} }
impl Default for LqpClient { impl Default for LqpClient {

View File

@@ -0,0 +1,148 @@
//! Item parsing utilities for LQP client.
//!
//! Provides functions for parsing item builds from different data formats.
use super::events::{ItemBuild, ItemInfo};
/// Parse items from live client data format.
///
/// Live client data items are objects with `itemID` and `displayName` fields.
pub fn parse_items_from_live_client(items: &[serde_json::Value]) -> Option<ItemBuild> {
let mut item_list = Vec::new();
let mut trinket = None;
for (slot, item) in items.iter().enumerate() {
if let Some(item_id) = item.get("itemID").and_then(|id| id.as_u64()) {
if item_id > 0 {
let item_info = ItemInfo {
item_id: item_id as u32,
name: item
.get("displayName")
.and_then(|n| n.as_str())
.map(|s| s.to_string()),
slot: slot as u32,
};
// Slot 6 is typically the trinket
if slot == 6 {
trinket = Some(item_info);
} else {
item_list.push(item_info);
}
}
}
}
if !item_list.is_empty() || trinket.is_some() {
Some(ItemBuild {
items: item_list,
trinket,
})
} else {
None
}
}
/// Parse items from game stats format.
///
/// Game stats items are just numbers (item IDs) in an array.
pub fn parse_items_from_game_stats(items: &[serde_json::Value]) -> Option<ItemBuild> {
let mut item_list = Vec::new();
let mut trinket = None;
for (slot, item) in items.iter().enumerate() {
// Items in game stats are just numbers (item IDs), not objects
if let Some(item_id) = item.as_u64() {
if item_id > 0 {
let item_info = ItemInfo {
item_id: item_id as u32,
name: None, // Item names would need a separate mapping
slot: slot as u32,
};
// Slot 6 is typically the trinket
if slot == 6 {
trinket = Some(item_info);
} else {
item_list.push(item_info);
}
}
}
}
if !item_list.is_empty() || trinket.is_some() {
Some(ItemBuild {
items: item_list,
trinket,
})
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_items_from_live_client_empty() {
let items = vec![];
let result = parse_items_from_live_client(&items);
assert!(result.is_none());
}
#[test]
fn test_parse_items_from_live_client_with_items() {
let items = vec![
serde_json::json!({"itemID": 1001, "displayName": "Boots"}),
serde_json::json!({"itemID": 0, "displayName": ""}),
];
let result = parse_items_from_live_client(&items);
assert!(result.is_some());
let build = result.unwrap();
assert_eq!(build.items.len(), 1);
assert_eq!(build.items[0].item_id, 1001);
}
#[test]
fn test_parse_items_from_game_stats_empty() {
let items = vec![];
let result = parse_items_from_game_stats(&items);
assert!(result.is_none());
}
#[test]
fn test_parse_items_from_game_stats_with_items() {
let items = vec![
serde_json::json!(1001),
serde_json::json!(0),
serde_json::json!(3020),
];
let result = parse_items_from_game_stats(&items);
assert!(result.is_some());
let build = result.unwrap();
assert_eq!(build.items.len(), 2);
assert_eq!(build.items[0].item_id, 1001);
assert_eq!(build.items[1].item_id, 3020);
}
#[test]
fn test_parse_items_with_trinket() {
// Slot 6 is trinket
let items = vec![
serde_json::json!(1001),
serde_json::json!(0),
serde_json::json!(0),
serde_json::json!(0),
serde_json::json!(0),
serde_json::json!(0),
serde_json::json!(3340), // Trinket in slot 6
];
let result = parse_items_from_game_stats(&items);
assert!(result.is_some());
let build = result.unwrap();
assert_eq!(build.items.len(), 1);
assert!(build.trinket.is_some());
assert_eq!(build.trinket.unwrap().item_id, 3340);
}
}

View File

@@ -7,6 +7,7 @@ mod auth;
mod client; mod client;
mod endpoints; mod endpoints;
mod events; mod events;
mod items;
mod mappings; mod mappings;
mod metadata; mod metadata;
mod state; mod state;
@@ -27,6 +28,7 @@ pub use events::{
ObjectiveEvent, ObjectiveType, PlayerChampionSelection, PlayerGameMetadata, PlayerIdentity, ObjectiveEvent, ObjectiveType, PlayerChampionSelection, PlayerGameMetadata, PlayerIdentity,
QueueInfo, RunePage, RuneSlot, SummonerSpells, TeamMember, QueueInfo, RunePage, RuneSlot, SummonerSpells, TeamMember,
}; };
pub use items::{parse_items_from_game_stats, parse_items_from_live_client};
pub use mappings::{champion_id_to_name, map_id_to_name, spell_id_to_name}; pub use mappings::{champion_id_to_name, map_id_to_name, spell_id_to_name};
pub use metadata::{GameEndMetadata, PreGameMetadata}; pub use metadata::{GameEndMetadata, PreGameMetadata};
pub use state::{ClientState, GameflowPhase}; pub use state::{ClientState, GameflowPhase};