record-daemon: end-of-game stats, summoner spells
Some checks failed
record-daemon / Build, check and test (push) Failing after 8s

This commit is contained in:
2026-03-25 10:53:42 +01:00
parent 079d72649f
commit 12fe579aca
7 changed files with 754 additions and 21 deletions

View File

@@ -10,7 +10,7 @@ use tokio_tungstenite::{connect_async_tls_with_config, tungstenite::protocol::Me
use tracing::{debug, error, info, trace, warn};
use super::auth::LockfileCredentials;
use super::events::{GameEvent, GameflowSession, RawEvent};
use super::events::{GameEvent, GameflowSession, ItemBuild, ItemInfo, RawEvent};
use crate::error::{LqpError, Result};
/// Custom certificate verifier that accepts any certificate.
@@ -85,8 +85,12 @@ pub mod endpoints {
pub const GAME_STATS: &str = "/lol-end-of-game/v1/eog-stats-block";
pub const CHAMPION_SUMMARY: &str = "/lol-champ-select/v1/current-champion";
pub const RUNE_PAGES: &str = "/lol-perks/v1/currentpage";
pub const ALL_RUNE_PAGES: &str = "/lol-perks/v1/pages";
pub const MATCH_HISTORY: &str = "/lol-match-history/v1/products/lol/current-summoner/matches";
pub const LIVE_CLIENT_DATA: &str = "/liveclientdata/allgamedata";
pub const LIVE_CLIENT_DATA_ACTIVE_PLAYER: &str = "/liveclientdata/activeplayer";
pub const LIVE_CLIENT_DATA_PLAYER_LIST: &str = "/liveclientdata/playerlist";
pub const CHAMPION_SELECT_LOCAL_PLAYER: &str = "/lol-champ-select/v1/session/my-selection";
}
/// Game flow phase states.
@@ -955,6 +959,11 @@ impl LqpClient {
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
@@ -965,6 +974,21 @@ impl LqpClient {
self.request("GET", endpoints::LIVE_CLIENT_DATA).await
}
/// Get active player data from live client.
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 (contains all players with puuid and summoner names).
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
}
/// Fetch pre-game metadata (champion, skin, runes, queue info).
pub async fn fetch_pregame_metadata(&self) -> Result<PreGameMetadata> {
let mut metadata = PreGameMetadata::default();
@@ -1096,6 +1120,397 @@ impl LqpClient {
Ok(metadata)
}
/// 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> {
use super::{RunePage, SummonerSpells};
let mut metadata = super::PlayerGameMetadata::default();
// Get summoner info - try multiple field names for summoner name
if let Ok(summoner) = self.get_summoner().await {
metadata.puuid = summoner.get("puuid").and_then(|p| p.as_str()).map(|s| s.to_string());
// Try displayName first, then name, then internalName
metadata.summoner_name = summoner.get("displayName")
.or_else(|| summoner.get("name"))
.or_else(|| summoner.get("internalName"))
.and_then(|n| n.as_str())
.map(|s| s.to_string());
}
// Get rune page
if let Ok(rune_page) = self.get_rune_page().await {
let primary_style_id = rune_page.get("primaryStyleId").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
.get("selectedPerkIds")
.and_then(|ids| ids.as_array())
.map(|arr| {
arr.iter()
.filter_map(|id| id.as_u64().map(|v| v as u32))
.collect()
})
.unwrap_or_default();
if primary_style_id > 0 {
metadata.runes = Some(RunePage {
primary_style_id,
secondary_style_id,
selected_perks,
stat_modifiers: Vec::new(), // Stat modifiers are part of selectedPerkIds
name: rune_page.get("name").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),
});
}
}
// Try to get summoner spells from live client data (available during game)
if let Ok(active_player) = self.get_live_client_active_player().await {
debug!("[METADATA] Live client active player data: {:?}", active_player);
// Get summoner spells from active player - try multiple field structures
if let Some(summoner_spells) = active_player.get("summonerSpells") {
// Try nested structure first: summonerSpells.summonerSpellOne.spellId
let spell1_id = summoner_spells.get("summonerSpellOne")
.and_then(|s| s.get("spellId"))
.and_then(|id| id.as_u64())
.unwrap_or_else(|| {
// Fallback: direct spell1Id field
summoner_spells.get("spell1Id").and_then(|id| id.as_u64()).unwrap_or(0)
}) as u32;
let spell2_id = summoner_spells.get("summonerSpellTwo")
.and_then(|s| s.get("spellId"))
.and_then(|id| id.as_u64())
.unwrap_or_else(|| {
// Fallback: direct spell2Id field
summoner_spells.get("spell2Id").and_then(|id| id.as_u64()).unwrap_or(0)
}) as u32;
debug!("[METADATA] Summoner spells from live client: spell1={}, spell2={}", spell1_id, spell2_id);
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),
});
}
}
// Get summoner name from active player if not already set
if metadata.summoner_name.is_none() || metadata.summoner_name.as_ref().map_or(true, |n| n.is_empty()) {
metadata.summoner_name = active_player.get("summonerName")
.or_else(|| active_player.get("displayName"))
.or_else(|| active_player.get("riotId"))
.and_then(|n| n.as_str())
.map(|s| s.to_string());
}
}
// Fallback: Get summoner spells from session gameData
if metadata.summoner_spells.is_none() {
if let Ok(session) = self.get_session().await {
if let Some(local_puuid) = &metadata.puuid {
// Try to get from gameData
if let Some(game_data) = session.get("gameData") {
// Check teamOne and teamTwo for player
for team_key in &["teamOne", "teamTwo"] {
if let Some(team) = game_data.get(team_key).and_then(|t| t.as_array()) {
for player in team {
if player.get("puuid").and_then(|p| p.as_str()) == Some(local_puuid.as_str()) {
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 {
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.get("championId").and_then(|id| id.as_u64()).map(|v| v as u32);
metadata.team = player.get("teamId").and_then(|id| id.as_u64()).map(|v| v as u32);
break;
}
}
}
}
}
}
}
}
// Try to get champion name from champion ID
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 from live client data.
/// This should be called during the game to get all player identities.
pub async fn fetch_all_players_identities(&self) -> Result<Vec<super::PlayerIdentity>> {
let mut players = Vec::new();
// Try live client data first (available during game)
if let Ok(player_list) = self.get_live_client_player_list().await {
if let Some(arr) = player_list.as_array() {
for player in arr {
// Get summoner name - try multiple fields
let summoner_name = player.get("summonerName")
.or_else(|| player.get("riotId"))
.and_then(|n| n.as_str())
.unwrap_or("");
// Get team from teamId
let team = player.get("team").and_then(|t| t.as_u64()).map(|v| v as u32);
// Get champion name
let champion_name = player.get("championName")
.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
if players.is_empty() {
if let Ok(session) = self.get_session().await {
if let Some(game_data) = session.get("gameData") {
for team_key in &["teamOne", "teamTwo"] {
let team_id = if *team_key == "teamOne" { 100u32 } else { 200u32 };
if let Some(team) = game_data.get(team_key).and_then(|t| t.as_array()) {
for player in team {
if let (Some(puuid), Some(summoner_name)) = (
player.get("puuid").and_then(|p| p.as_str()),
player.get("summonerName").and_then(|n| n.as_str()),
) {
let champion_id = player.get("championId").and_then(|id| id.as_u64()).map(|v| v as u32);
let champion_name = champion_id.and_then(|id| champion_id_to_name(id));
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: Some(team_id),
});
}
}
}
}
}
}
}
Ok(players)
}
/// Fetch final items from end-of-game stats or live client data.
pub async fn fetch_final_items(&self) -> Result<Option<super::ItemBuild>> {
info!("[ITEMS] Fetching final items...");
// First try live client data (available during game)
match self.get_live_client_player_list().await {
Ok(player_list) => {
info!("[ITEMS] Live client player list response: {:?}", player_list);
if let Some(players) = player_list.as_array() {
info!("[ITEMS] Found {} players in live client data", players.len());
// Find the local player (first player or one with matching puuid)
for player in players {
// Check if this is the local player
let is_local = player.get("isLocalPlayer").and_then(|l| l.as_bool()).unwrap_or(false);
if is_local {
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());
let item_build = self.parse_items_from_live_client(items);
if item_build.is_some() {
info!("[ITEMS] Successfully parsed items from live client data");
return Ok(item_build);
}
} else {
info!("[ITEMS] No items array found for local player");
}
}
}
}
}
Err(e) => {
info!("[ITEMS] Failed to get live client player list: {:?}", e);
}
}
// Fallback: try end-of-game stats
match self.get_game_stats().await {
Ok(stats) => {
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") {
info!("[ITEMS] Found localPlayer in game stats");
if let Some(items) = local_player.get("items").and_then(|i| i.as_array()) {
info!("[ITEMS] localPlayer.items array has {} items", items.len());
let item_build = self.parse_items_from_game_stats(items);
if item_build.is_some() {
info!("[ITEMS] Successfully parsed items from localPlayer");
return Ok(item_build);
}
}
}
// Try teams[].players[] structure
if let Some(teams) = stats.get("teams").and_then(|t| t.as_array()) {
info!("[ITEMS] Found {} teams in game stats", teams.len());
for team in teams {
if let Some(players) = team.get("players").and_then(|p| p.as_array()) {
for player in players {
let is_local = player.get("isLocalPlayer").and_then(|l| l.as_bool()).unwrap_or(false);
if is_local {
info!("[ITEMS] Found local player in teams[].players[]");
if let Some(items) = player.get("items").and_then(|i| i.as_array()) {
info!("[ITEMS] Player items array has {} items", items.len());
let item_build = self.parse_items_from_game_stats(items);
if item_build.is_some() {
info!("[ITEMS] Successfully parsed items from teams[].players[]");
return Ok(item_build);
}
}
}
}
}
}
}
// Try old players array structure (for backwards compatibility)
if let Some(players) = stats.get("players").and_then(|p| p.as_array()) {
info!("[ITEMS] Found {} players in game stats (legacy)", players.len());
if let Some(player) = players.first() {
if let Some(items) = player.get("items").and_then(|i| i.as_array()) {
let item_build = self.parse_items_from_game_stats(items);
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)
}
/// Parse items from live client data format.
fn parse_items_from_live_client(&self, items: &[serde_json::Value]) -> Option<super::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(&self, items: &[serde_json::Value]) -> Option<super::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
}
}
}
/// Convert summoner spell ID to name.
pub fn spell_id_to_name(id: u32) -> Option<String> {
let name = match id {
1 => "Cleanse",
3 => "Exhaust",
4 => "Flash",
6 => "Ghost",
7 => "Heal",
11 => "Smite",
12 => "Teleport",
13 => "Clarity",
14 => "Ignite",
21 => "Barrier",
32 => "Mark",
39 => "Mark",
54 => "Placeholder",
55 => "Placeholder",
_ => return None,
};
Some(name.to_string())
}
/// Convert champion ID to champion name.

View File

@@ -517,6 +517,136 @@ mod tests {
}
}
// ============================================================================
// Player Metadata Structures (Runes, Summoner Spells, Items)
// ============================================================================
/// Rune slot information
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RuneSlot {
/// Rune ID
pub rune_id: u32,
/// Slot index
pub slot: u32,
}
/// Rune page configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RunePage {
/// Primary rune style (keystone tree)
pub primary_style_id: u32,
/// Secondary rune style
pub secondary_style_id: u32,
/// Selected runes
pub selected_perks: Vec<u32>,
/// Stat modifiers (flex, offense, defense)
#[serde(default)]
pub stat_modifiers: Vec<u32>,
/// Rune page name
#[serde(default)]
pub name: Option<String>,
/// Whether this is the current page
#[serde(default)]
pub current: bool,
}
/// Summoner spell information
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SummonerSpells {
/// First summoner spell ID
pub spell1_id: u32,
/// Second summoner spell ID
pub spell2_id: u32,
/// First summoner spell name (resolved)
#[serde(default)]
pub spell1_name: Option<String>,
/// Second summoner spell name (resolved)
#[serde(default)]
pub spell2_name: Option<String>,
}
/// Item information at game end
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ItemBuild {
/// Final items (6 item slots)
pub items: Vec<ItemInfo>,
/// Trinket item
#[serde(default)]
pub trinket: Option<ItemInfo>,
}
/// Individual item information
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ItemInfo {
/// Item ID
pub item_id: u32,
/// Item name (resolved)
#[serde(default)]
pub name: Option<String>,
/// Slot index (0-5 for main items, 6 for trinket)
pub slot: u32,
}
/// Complete player metadata captured at game start
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct PlayerGameMetadata {
/// Player's PUUID
#[serde(default)]
pub puuid: Option<String>,
/// Player's summoner name
#[serde(default)]
pub summoner_name: Option<String>,
/// Champion ID
#[serde(default)]
pub champion_id: Option<u32>,
/// Champion name
#[serde(default)]
pub champion_name: Option<String>,
/// Skin ID
#[serde(default)]
pub skin_id: Option<u32>,
/// Skin name
#[serde(default)]
pub skin_name: Option<String>,
/// Team (100 = blue, 200 = red)
#[serde(default)]
pub team: Option<u32>,
/// Selected rune page
#[serde(default)]
pub runes: Option<RunePage>,
/// Summoner spells
#[serde(default)]
pub summoner_spells: Option<SummonerSpells>,
/// Final item build (captured at game end)
#[serde(default)]
pub final_items: Option<ItemBuild>,
}
/// Player information for mapping puuid to summoner name
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlayerIdentity {
/// Player's PUUID
pub puuid: String,
/// Player's summoner name
pub summoner_name: String,
/// Player's summoner ID
#[serde(default)]
pub summoner_id: Option<u64>,
/// Player's champion name
#[serde(default)]
pub champion_name: Option<String>,
/// Player's team (100 or 200)
#[serde(default)]
pub team: Option<u32>,
}
// ============================================================================
// GameFlow Session Data Structures
// ============================================================================

View File

@@ -8,9 +8,10 @@ mod client;
mod events;
pub use auth::{LockfileCredentials, LockfileWatcher};
pub use client::{champion_id_to_name, GameEndMetadata, GameflowPhase, LqpClient, PreGameMetadata};
pub use client::{champion_id_to_name, spell_id_to_name, GameEndMetadata, GameflowPhase, LqpClient, PreGameMetadata};
pub use events::{
ChampSelectStartInfo, ChampionPickInfo, DeathEvent, EventData, GameEndInfo, GameEvent,
GameStartInfo, GameflowSession, InGameStats, KillEvent, MatchInfo, ObjectiveEvent,
ObjectiveType, PlayerChampionSelection, QueueInfo, TeamMember,
GameStartInfo, GameflowSession, InGameStats, ItemBuild, ItemInfo, KillEvent, MatchInfo,
ObjectiveEvent, ObjectiveType, PlayerChampionSelection, PlayerGameMetadata, PlayerIdentity,
QueueInfo, RunePage, RuneSlot, SummonerSpells, TeamMember,
};