record-daemon: end-of-game stats, summoner spells
Some checks failed
record-daemon / Build, check and test (push) Failing after 8s
Some checks failed
record-daemon / Build, check and test (push) Failing after 8s
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -388,11 +388,18 @@ impl Daemon {
|
||||
map_name,
|
||||
team,
|
||||
summoner_name,
|
||||
puuid: transition_puuid,
|
||||
runes: transition_runes,
|
||||
summoner_spells: transition_summoner_spells,
|
||||
} => {
|
||||
info!(
|
||||
"[EVENT_HANDLER] GameStarted transition - game_id: {}, champion: {:?}, queue_type: {:?}, game_mode: {:?}",
|
||||
game_id, champion, queue_type, game_mode
|
||||
);
|
||||
info!(
|
||||
"[EVENT_HANDLER] Transition provided: puuid={:?}, runes={:?}, summoner_spells={:?}",
|
||||
transition_puuid, transition_runes, transition_summoner_spells
|
||||
);
|
||||
|
||||
// If already recording, stop the current recording first
|
||||
if self.state_machine.is_recording() {
|
||||
@@ -435,6 +442,35 @@ impl Daemon {
|
||||
})
|
||||
});
|
||||
|
||||
// Fetch player game metadata (runes, summoner spells, puuid) as fallback
|
||||
let player_metadata = self.lqp_client.fetch_player_game_metadata().await.ok();
|
||||
let (fetched_puuid, fetched_runes, fetched_summoner_spells) =
|
||||
player_metadata.map_or((None, None, None), |m| {
|
||||
(m.puuid, m.runes, m.summoner_spells)
|
||||
});
|
||||
|
||||
// Use transition values first, then fall back to fetched values
|
||||
let final_puuid = transition_puuid.or(fetched_puuid);
|
||||
let final_runes = transition_runes.or(fetched_runes);
|
||||
let final_summoner_spells = transition_summoner_spells.or(fetched_summoner_spells);
|
||||
|
||||
info!(
|
||||
"[EVENT_HANDLER] Final values: puuid={:?}, runes={:?}, summoner_spells={:?}",
|
||||
final_puuid, final_runes, final_summoner_spells
|
||||
);
|
||||
|
||||
// Fetch all players identities for puuid mapping
|
||||
let all_players_identities = self.lqp_client.fetch_all_players_identities().await.ok();
|
||||
let all_players_info: Vec<record_daemon::timeline::PlayerIdentityInfo> =
|
||||
all_players_identities.unwrap_or_default().into_iter().map(|p| {
|
||||
record_daemon::timeline::PlayerIdentityInfo {
|
||||
puuid: p.puuid,
|
||||
summoner_name: p.summoner_name,
|
||||
champion_name: p.champion_name,
|
||||
team: p.team,
|
||||
}
|
||||
}).collect();
|
||||
|
||||
// Build game metadata for timeline
|
||||
let metadata_update = record_daemon::timeline::MetadataUpdate {
|
||||
queue_type: queue_type.clone(),
|
||||
@@ -443,6 +479,10 @@ impl Daemon {
|
||||
map_name: map_name.clone(),
|
||||
team,
|
||||
summoner_name: summoner_name.clone(),
|
||||
puuid: final_puuid,
|
||||
runes: final_runes,
|
||||
summoner_spells: final_summoner_spells,
|
||||
all_players: all_players_info,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -461,12 +501,15 @@ impl Daemon {
|
||||
info!("[EVENT_HANDLER] start_recording completed successfully");
|
||||
}
|
||||
}
|
||||
StateTransition::GameEnded { game_end_info } => {
|
||||
StateTransition::GameEnded { game_end_info, final_items: _ } => {
|
||||
info!(
|
||||
"[EVENT_HANDLER] GameEnded transition with info: {:?}",
|
||||
game_end_info
|
||||
);
|
||||
|
||||
// Fetch final items before stopping
|
||||
let fetched_final_items = self.lqp_client.fetch_final_items().await.ok().flatten();
|
||||
|
||||
// Convert GameEndInfo to GameEndMetadata if available
|
||||
let game_end_metadata =
|
||||
game_end_info.map(|info| record_daemon::lqp::GameEndMetadata {
|
||||
@@ -508,7 +551,7 @@ impl Daemon {
|
||||
game_end_metadata
|
||||
);
|
||||
|
||||
if let Err(e) = self.stop_recording_with_metadata(game_end_metadata).await {
|
||||
if let Err(e) = self.stop_recording_with_metadata(game_end_metadata, fetched_final_items).await {
|
||||
error!("[EVENT_HANDLER] Failed to stop recording: {}", e);
|
||||
|
||||
// Don't propagate error - keep daemon running
|
||||
@@ -598,13 +641,14 @@ impl Daemon {
|
||||
|
||||
/// Stop recording.
|
||||
async fn stop_recording(&self) -> Result<()> {
|
||||
self.stop_recording_with_metadata(None).await
|
||||
self.stop_recording_with_metadata(None, None).await
|
||||
}
|
||||
|
||||
/// Stop recording with optional game end metadata.
|
||||
async fn stop_recording_with_metadata(
|
||||
&self,
|
||||
game_end_metadata: Option<record_daemon::lqp::GameEndMetadata>,
|
||||
final_items: Option<record_daemon::lqp::ItemBuild>,
|
||||
) -> Result<()> {
|
||||
info!("Stopping recording");
|
||||
|
||||
@@ -673,6 +717,9 @@ impl Daemon {
|
||||
});
|
||||
}
|
||||
|
||||
// Add final items
|
||||
update.final_items = final_items;
|
||||
|
||||
// Apply the update
|
||||
if let Err(e) = timeline_store.write().update_metadata(recording_id, update) {
|
||||
warn!("Failed to update recording metadata: {}", e);
|
||||
|
||||
@@ -6,7 +6,7 @@ use parking_lot::RwLock;
|
||||
use tracing::{info, trace, warn};
|
||||
|
||||
use super::DaemonStatus;
|
||||
use crate::lqp::{GameEndInfo, GameEvent, GameflowPhase};
|
||||
use crate::lqp::{GameEndInfo, GameEvent, GameflowPhase, ItemBuild, RunePage, SummonerSpells};
|
||||
|
||||
/// Internal daemon state.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -52,11 +52,19 @@ pub enum StateTransition {
|
||||
map_name: Option<String>,
|
||||
team: Option<u32>,
|
||||
summoner_name: Option<String>,
|
||||
/// Player's PUUID
|
||||
puuid: Option<String>,
|
||||
/// Rune page at game start
|
||||
runes: Option<RunePage>,
|
||||
/// Summoner spells
|
||||
summoner_spells: Option<SummonerSpells>,
|
||||
},
|
||||
/// Game ended.
|
||||
GameEnded {
|
||||
/// Game end info with stats from WebSocket.
|
||||
game_end_info: Option<GameEndInfo>,
|
||||
/// Final items
|
||||
final_items: Option<ItemBuild>,
|
||||
},
|
||||
/// Error occurred.
|
||||
Error(String),
|
||||
@@ -224,18 +232,41 @@ impl DaemonStateMachine {
|
||||
);
|
||||
|
||||
match event {
|
||||
GameEvent::GameStart(info) => Some(StateTransition::GameStarted {
|
||||
game_id: info.game_id,
|
||||
champion: info.champion.clone(),
|
||||
queue_type: info.queue_type.clone(),
|
||||
queue_id: info.queue_id,
|
||||
game_mode: info.game_mode.clone(),
|
||||
map_name: info.map_name.clone(),
|
||||
team: info.team,
|
||||
summoner_name: info.summoner_name.clone(),
|
||||
}),
|
||||
GameEvent::GameStart(info) => {
|
||||
// Extract summoner spells from session data if available
|
||||
let summoner_spells = info.session.as_ref().and_then(|session| {
|
||||
session.player_champion_selections.first().map(|selection| {
|
||||
SummonerSpells {
|
||||
spell1_id: selection.spell1_id,
|
||||
spell2_id: selection.spell2_id,
|
||||
spell1_name: crate::lqp::spell_id_to_name(selection.spell1_id),
|
||||
spell2_name: crate::lqp::spell_id_to_name(selection.spell2_id),
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// Extract puuid from session data if available
|
||||
let puuid = info.session.as_ref().and_then(|session| {
|
||||
session.player_champion_selections.first().map(|selection| selection.puuid.clone())
|
||||
});
|
||||
|
||||
Some(StateTransition::GameStarted {
|
||||
game_id: info.game_id,
|
||||
champion: info.champion.clone(),
|
||||
queue_type: info.queue_type.clone(),
|
||||
queue_id: info.queue_id,
|
||||
game_mode: info.game_mode.clone(),
|
||||
map_name: info.map_name.clone(),
|
||||
team: info.team,
|
||||
summoner_name: info.summoner_name.clone(),
|
||||
puuid,
|
||||
runes: None, // Will be populated from client.fetch_player_game_metadata()
|
||||
summoner_spells,
|
||||
})
|
||||
}
|
||||
GameEvent::GameEnd(info) => Some(StateTransition::GameEnded {
|
||||
game_end_info: Some(info.clone()),
|
||||
final_items: None, // Will be populated from client.fetch_final_items()
|
||||
}),
|
||||
GameEvent::PhaseChange(info) => {
|
||||
// Only trigger GameEnded on EndOfGame phase (stats are available by then)
|
||||
@@ -243,6 +274,7 @@ impl DaemonStateMachine {
|
||||
if info.phase == "EndOfGame" && self.is_recording() {
|
||||
Some(StateTransition::GameEnded {
|
||||
game_end_info: None,
|
||||
final_items: None,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
@@ -297,6 +329,9 @@ mod tests {
|
||||
map_name: None,
|
||||
team: None,
|
||||
summoner_name: None,
|
||||
puuid: None,
|
||||
runes: None,
|
||||
summoner_spells: None,
|
||||
});
|
||||
|
||||
assert_eq!(new_state, Some(DaemonState::Recording));
|
||||
@@ -318,6 +353,9 @@ mod tests {
|
||||
map_name: None,
|
||||
team: None,
|
||||
summoner_name: None,
|
||||
puuid: None,
|
||||
runes: None,
|
||||
summoner_spells: None,
|
||||
});
|
||||
|
||||
assert_eq!(result, None);
|
||||
|
||||
@@ -5,14 +5,15 @@ mod store;
|
||||
|
||||
pub use mapper::EventMapper;
|
||||
pub use store::{
|
||||
GameFinalStats, MetadataUpdate, RecordingMetadata, TimelineStore, TimestampedEvent,
|
||||
GameFinalStats, MetadataUpdate, PlayerIdentityInfo, RecordingMetadata, TimelineStore,
|
||||
TimestampedEvent,
|
||||
};
|
||||
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::lqp::GameEvent;
|
||||
use crate::lqp::{GameEvent, ItemBuild, RunePage, SummonerSpells};
|
||||
|
||||
/// A timeline of events for a recording.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -48,6 +49,9 @@ pub struct Timeline {
|
||||
/// Summoner name.
|
||||
#[serde(default)]
|
||||
pub summoner_name: Option<String>,
|
||||
/// Player's PUUID.
|
||||
#[serde(default)]
|
||||
pub puuid: Option<String>,
|
||||
/// Team (100 = blue, 200 = red).
|
||||
#[serde(default)]
|
||||
pub team: Option<u32>,
|
||||
@@ -57,6 +61,18 @@ pub struct Timeline {
|
||||
/// Final player stats.
|
||||
#[serde(default)]
|
||||
pub final_stats: Option<GameFinalStats>,
|
||||
/// Rune page at game start.
|
||||
#[serde(default)]
|
||||
pub runes: Option<RunePage>,
|
||||
/// Summoner spells.
|
||||
#[serde(default)]
|
||||
pub summoner_spells: Option<SummonerSpells>,
|
||||
/// Final item build at game end.
|
||||
#[serde(default)]
|
||||
pub final_items: Option<ItemBuild>,
|
||||
/// All players in the game (puuid to summoner name mapping).
|
||||
#[serde(default)]
|
||||
pub all_players: Vec<PlayerIdentityInfo>,
|
||||
}
|
||||
|
||||
impl Timeline {
|
||||
@@ -82,9 +98,14 @@ impl Timeline {
|
||||
game_mode: None,
|
||||
map_name: None,
|
||||
summoner_name: None,
|
||||
puuid: None,
|
||||
team: None,
|
||||
victory: None,
|
||||
final_stats: None,
|
||||
runes: None,
|
||||
summoner_spells: None,
|
||||
final_items: None,
|
||||
all_players: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{Result, TimelineError};
|
||||
use crate::lqp::GameEvent;
|
||||
use crate::lqp::{GameEvent, ItemBuild, RunePage, SummonerSpells};
|
||||
use crate::recording::RecordingResult;
|
||||
|
||||
/// A timestamped event in the timeline.
|
||||
@@ -54,12 +54,27 @@ pub struct RecordingMetadata {
|
||||
pub map_name: Option<String>,
|
||||
/// Player's summoner name.
|
||||
pub summoner_name: Option<String>,
|
||||
/// Player's PUUID.
|
||||
#[serde(default)]
|
||||
pub puuid: Option<String>,
|
||||
/// Team (100 = blue, 200 = red).
|
||||
pub team: Option<u32>,
|
||||
/// Whether the game was won.
|
||||
pub victory: Option<bool>,
|
||||
/// Final player stats.
|
||||
pub final_stats: Option<GameFinalStats>,
|
||||
/// Player's rune page at game start.
|
||||
#[serde(default)]
|
||||
pub runes: Option<RunePage>,
|
||||
/// Player's summoner spells.
|
||||
#[serde(default)]
|
||||
pub summoner_spells: Option<SummonerSpells>,
|
||||
/// Final item build at game end.
|
||||
#[serde(default)]
|
||||
pub final_items: Option<ItemBuild>,
|
||||
/// All players in the game (puuid -> summoner name mapping).
|
||||
#[serde(default)]
|
||||
pub all_players: Vec<PlayerIdentityInfo>,
|
||||
/// Recording start time.
|
||||
pub start_time: DateTime<Utc>,
|
||||
/// Recording end time.
|
||||
@@ -77,6 +92,22 @@ pub struct RecordingMetadata {
|
||||
pub finalized: bool,
|
||||
}
|
||||
|
||||
/// Player identity information for puuid to summoner name mapping.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlayerIdentityInfo {
|
||||
/// Player's PUUID.
|
||||
pub puuid: String,
|
||||
/// Player's summoner name.
|
||||
pub summoner_name: String,
|
||||
/// Player's champion name.
|
||||
#[serde(default)]
|
||||
pub champion_name: Option<String>,
|
||||
/// Player's team (100 or 200).
|
||||
#[serde(default)]
|
||||
pub team: Option<u32>,
|
||||
}
|
||||
|
||||
/// Final game statistics for the player.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GameFinalStats {
|
||||
@@ -111,9 +142,14 @@ pub struct MetadataUpdate {
|
||||
pub game_mode: Option<String>,
|
||||
pub map_name: Option<String>,
|
||||
pub summoner_name: Option<String>,
|
||||
pub puuid: Option<String>,
|
||||
pub team: Option<u32>,
|
||||
pub victory: Option<bool>,
|
||||
pub final_stats: Option<GameFinalStats>,
|
||||
pub runes: Option<RunePage>,
|
||||
pub summoner_spells: Option<SummonerSpells>,
|
||||
pub final_items: Option<ItemBuild>,
|
||||
pub all_players: Vec<PlayerIdentityInfo>,
|
||||
}
|
||||
|
||||
impl RecordingMetadata {
|
||||
@@ -130,9 +166,14 @@ impl RecordingMetadata {
|
||||
game_mode: None,
|
||||
map_name: None,
|
||||
summoner_name: None,
|
||||
puuid: None,
|
||||
team: None,
|
||||
victory: None,
|
||||
final_stats: None,
|
||||
runes: None,
|
||||
summoner_spells: None,
|
||||
final_items: None,
|
||||
all_players: Vec::new(),
|
||||
start_time: result.start_time,
|
||||
end_time: Some(result.end_time),
|
||||
duration: result.duration,
|
||||
@@ -192,9 +233,14 @@ impl TimelineStore {
|
||||
game_mode: None,
|
||||
map_name: None,
|
||||
summoner_name: None,
|
||||
puuid: None,
|
||||
team: None,
|
||||
victory: None,
|
||||
final_stats: None,
|
||||
runes: None,
|
||||
summoner_spells: None,
|
||||
final_items: None,
|
||||
all_players: Vec::new(),
|
||||
start_time: Utc::now(),
|
||||
end_time: None,
|
||||
duration: Duration::zero(),
|
||||
@@ -248,9 +294,14 @@ impl TimelineStore {
|
||||
game_mode: None,
|
||||
map_name: None,
|
||||
summoner_name: None,
|
||||
puuid: None,
|
||||
team: None,
|
||||
victory: None,
|
||||
final_stats: None,
|
||||
runes: None,
|
||||
summoner_spells: None,
|
||||
final_items: None,
|
||||
all_players: Vec::new(),
|
||||
start_time: result.start_time,
|
||||
end_time: Some(result.end_time),
|
||||
duration: result.duration,
|
||||
@@ -317,6 +368,9 @@ impl TimelineStore {
|
||||
if let Some(summoner_name) = update.summoner_name {
|
||||
metadata.summoner_name = Some(summoner_name);
|
||||
}
|
||||
if let Some(puuid) = update.puuid {
|
||||
metadata.puuid = Some(puuid);
|
||||
}
|
||||
if let Some(team) = update.team {
|
||||
metadata.team = Some(team);
|
||||
}
|
||||
@@ -326,6 +380,18 @@ impl TimelineStore {
|
||||
if let Some(final_stats) = update.final_stats {
|
||||
metadata.final_stats = Some(final_stats);
|
||||
}
|
||||
if let Some(runes) = update.runes {
|
||||
metadata.runes = Some(runes);
|
||||
}
|
||||
if let Some(summoner_spells) = update.summoner_spells {
|
||||
metadata.summoner_spells = Some(summoner_spells);
|
||||
}
|
||||
if let Some(final_items) = update.final_items {
|
||||
metadata.final_items = Some(final_items);
|
||||
}
|
||||
if !update.all_players.is_empty() {
|
||||
metadata.all_players = update.all_players;
|
||||
}
|
||||
}
|
||||
drop(recordings);
|
||||
self.persist_recording(recording_id)?;
|
||||
@@ -370,9 +436,14 @@ impl TimelineStore {
|
||||
game_mode: metadata.game_mode.clone(),
|
||||
map_name: metadata.map_name.clone(),
|
||||
summoner_name: metadata.summoner_name.clone(),
|
||||
puuid: metadata.puuid.clone(),
|
||||
team: metadata.team,
|
||||
victory: metadata.victory,
|
||||
final_stats: metadata.final_stats.clone(),
|
||||
runes: metadata.runes.clone(),
|
||||
summoner_spells: metadata.summoner_spells.clone(),
|
||||
final_items: metadata.final_items.clone(),
|
||||
all_players: metadata.all_players.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -416,9 +487,14 @@ impl TimelineStore {
|
||||
game_mode: metadata.game_mode,
|
||||
map_name: metadata.map_name,
|
||||
summoner_name: metadata.summoner_name,
|
||||
puuid: metadata.puuid,
|
||||
team: metadata.team,
|
||||
victory: metadata.victory,
|
||||
final_stats: metadata.final_stats,
|
||||
runes: metadata.runes,
|
||||
summoner_spells: metadata.summoner_spells,
|
||||
final_items: metadata.final_items,
|
||||
all_players: metadata.all_players,
|
||||
};
|
||||
|
||||
let file_path = self.storage_dir.join(format!("{}.json", id));
|
||||
@@ -453,9 +529,14 @@ impl TimelineStore {
|
||||
game_mode: None,
|
||||
map_name: None,
|
||||
summoner_name: None,
|
||||
puuid: None,
|
||||
team: None,
|
||||
victory: None,
|
||||
final_stats: None,
|
||||
runes: None,
|
||||
summoner_spells: None,
|
||||
final_items: None,
|
||||
all_players: Vec::new(),
|
||||
start_time: timeline.start_time,
|
||||
end_time: timeline.end_time,
|
||||
duration: timeline.duration(),
|
||||
|
||||
Reference in New Issue
Block a user