record-daemon: add game start and end metadata
All checks were successful
record-daemon / Build, check and test (push) Successful in 2m9s

This commit is contained in:
2026-03-24 18:16:53 +01:00
parent fc7ba40b30
commit f90e549b1e
9 changed files with 1878 additions and 98 deletions

View File

@@ -153,7 +153,7 @@ pub enum IpcNotification {
}, },
/// Game event received. /// Game event received.
GameEvent { event: GameEvent }, GameEvent { event: Box<GameEvent> },
/// Daemon status changed. /// Daemon status changed.
StatusChanged { status: DaemonStatus }, StatusChanged { status: DaemonStatus },

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 tracing::{debug, error, info, trace, warn};
use super::auth::LockfileCredentials; use super::auth::LockfileCredentials;
use super::events::{GameEvent, RawEvent}; use super::events::{GameEvent, GameflowSession, RawEvent};
use crate::error::{LqpError, Result}; use crate::error::{LqpError, Result};
/// Custom certificate verifier that accepts any certificate. /// Custom certificate verifier that accepts any certificate.
@@ -73,6 +73,7 @@ const SUBSCRIBE_ENDPOINTS: &[&str] = &[
"/lol-game-events/v1/game-events", "/lol-game-events/v1/game-events",
"/lol-champ-select/v1/session", "/lol-champ-select/v1/session",
"/lol-lobby/v2/lobby", "/lol-lobby/v2/lobby",
"/lol-end-of-game/v1/eog-stats-block",
]; ];
/// LQP REST API endpoints. /// LQP REST API endpoints.
@@ -82,6 +83,10 @@ pub mod endpoints {
pub const CHAMPION_SELECT: &str = "/lol-champ-select/v1/session"; pub const CHAMPION_SELECT: &str = "/lol-champ-select/v1/session";
pub const SUMMONER: &str = "/lol-summoner/v1/current-summoner"; pub const SUMMONER: &str = "/lol-summoner/v1/current-summoner";
pub const GAME_STATS: &str = "/lol-end-of-game/v1/eog-stats-block"; 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 MATCH_HISTORY: &str = "/lol-match-history/v1/products/lol/current-summoner/matches";
pub const LIVE_CLIENT_DATA: &str = "/liveclientdata/allgamedata";
} }
/// Game flow phase states. /// Game flow phase states.
@@ -135,6 +140,8 @@ pub struct ClientState {
pub game_id: Option<u64>, pub game_id: Option<u64>,
/// Current champion name. /// Current champion name.
pub champion: Option<String>, pub champion: Option<String>,
/// Current player's puuid.
pub local_puuid: Option<String>,
} }
impl Default for ClientState { impl Default for ClientState {
@@ -143,6 +150,7 @@ impl Default for ClientState {
phase: GameflowPhase::None, phase: GameflowPhase::None,
game_id: None, game_id: None,
champion: None, champion: None,
local_puuid: None,
} }
} }
} }
@@ -159,6 +167,8 @@ pub struct LqpClient {
http_client: reqwest::Client, http_client: reqwest::Client,
/// Shutdown signal. /// Shutdown signal.
shutdown: Arc<RwLock<bool>>, shutdown: Arc<RwLock<bool>>,
/// Last emitted game ID for deduplication of GameStart events.
last_emitted_game_id: Arc<RwLock<Option<u64>>>,
} }
impl LqpClient { impl LqpClient {
@@ -177,6 +187,7 @@ impl LqpClient {
event_sender, event_sender,
http_client, http_client,
shutdown: Arc::new(RwLock::new(false)), shutdown: Arc::new(RwLock::new(false)),
last_emitted_game_id: Arc::new(RwLock::new(None)),
} }
} }
@@ -214,6 +225,14 @@ impl LqpClient {
} }
} }
// Fetch local player's puuid for champion extraction
if let Ok(summoner) = self.get_summoner().await {
if let Some(puuid) = summoner.get("puuid").and_then(|p| p.as_str()) {
self.state.write().await.local_puuid = Some(puuid.to_string());
info!("Fetched local player puuid: {}", puuid);
}
}
Ok(()) Ok(())
} }
@@ -222,6 +241,7 @@ impl LqpClient {
*self.shutdown.write().await = true; *self.shutdown.write().await = true;
*self.credentials.write().await = None; *self.credentials.write().await = None;
*self.state.write().await = ClientState::default(); *self.state.write().await = ClientState::default();
*self.last_emitted_game_id.write().await = None;
info!("Disconnected from League Client"); info!("Disconnected from League Client");
} }
@@ -292,6 +312,7 @@ impl LqpClient {
let state = self.state.clone(); let state = self.state.clone();
let shutdown = self.shutdown.clone(); let shutdown = self.shutdown.clone();
let credentials = self.credentials.clone(); let credentials = self.credentials.clone();
let last_emitted_game_id = self.last_emitted_game_id.clone();
// Spawn the message handler // Spawn the message handler
tokio::spawn(async move { tokio::spawn(async move {
@@ -306,10 +327,32 @@ impl LqpClient {
if text.is_empty() { if text.is_empty() {
continue; continue;
} }
if let Some(event) = Self::parse_websocket_message(&text) { // Get local_puuid from state for champion extraction
let local_puuid = state.read().await.local_puuid.clone();
if let Some(event) =
Self::parse_websocket_message(&text, local_puuid.as_deref())
{
// Update state based on event // Update state based on event
Self::update_state_from_event(&state, &event).await; Self::update_state_from_event(&state, &event).await;
// Check for duplicate GameStart events
if let GameEvent::GameStart(ref info) = event {
let mut last_game_id = last_emitted_game_id.write().await;
if *last_game_id == Some(info.game_id) {
info!(
"Skipping duplicate GameStart event for game_id={}",
info.game_id
);
continue;
}
*last_game_id = Some(info.game_id);
}
// Reset last_emitted_game_id on GameEnd to allow new game starts
if let GameEvent::GameEnd(_) = &event {
*last_emitted_game_id.write().await = None;
}
// Broadcast event // Broadcast event
if event_sender.send(event.clone()).is_err() { if event_sender.send(event.clone()).is_err() {
trace!("No event subscribers"); trace!("No event subscribers");
@@ -321,10 +364,32 @@ impl LqpClient {
// Try to parse as UTF-8 // Try to parse as UTF-8
if let Ok(text) = String::from_utf8(data) { if let Ok(text) = String::from_utf8(data) {
if !text.is_empty() { if !text.is_empty() {
if let Some(event) = Self::parse_websocket_message(&text) { // Get local_puuid from state for champion extraction
let local_puuid = state.read().await.local_puuid.clone();
if let Some(event) =
Self::parse_websocket_message(&text, local_puuid.as_deref())
{
// Update state based on event // Update state based on event
Self::update_state_from_event(&state, &event).await; Self::update_state_from_event(&state, &event).await;
// Check for duplicate GameStart events
if let GameEvent::GameStart(ref info) = event {
let mut last_game_id = last_emitted_game_id.write().await;
if *last_game_id == Some(info.game_id) {
info!(
"Skipping duplicate GameStart event for game_id={}",
info.game_id
);
continue;
}
*last_game_id = Some(info.game_id);
}
// Reset last_emitted_game_id on GameEnd to allow new game starts
if let GameEvent::GameEnd(_) = &event {
*last_emitted_game_id.write().await = None;
}
// Broadcast event // Broadcast event
if event_sender.send(event.clone()).is_err() { if event_sender.send(event.clone()).is_err() {
trace!("No event subscribers"); trace!("No event subscribers");
@@ -364,7 +429,7 @@ impl LqpClient {
} }
/// Parse a WebSocket message into a game event. /// Parse a WebSocket message into a game event.
fn parse_websocket_message(text: &str) -> Option<GameEvent> { fn parse_websocket_message(text: &str, local_puuid: Option<&str>) -> Option<GameEvent> {
// Parse the message array format: [type, callback, data] // Parse the message array format: [type, callback, data]
let value: serde_json::Value = match serde_json::from_str(text) { let value: serde_json::Value = match serde_json::from_str(text) {
Ok(v) => v, Ok(v) => v,
@@ -397,6 +462,7 @@ impl LqpClient {
&raw_event.uri, &raw_event.uri,
event_type, event_type,
&serde_json::to_value(raw_event.data).unwrap_or_default(), &serde_json::to_value(raw_event.data).unwrap_or_default(),
local_puuid,
); );
} }
@@ -408,7 +474,7 @@ impl LqpClient {
.and_then(|t| t.as_str()) .and_then(|t| t.as_str())
.unwrap_or("Update"); .unwrap_or("Update");
return Self::parse_event_from_uri(uri, event_type, data); return Self::parse_event_from_uri(uri, event_type, data, local_puuid);
} else { } else {
debug!("Unknown callback: {}", callback); debug!("Unknown callback: {}", callback);
} }
@@ -434,6 +500,7 @@ impl LqpClient {
uri: &str, uri: &str,
event_type: &str, event_type: &str,
data: &serde_json::Value, data: &serde_json::Value,
local_puuid: Option<&str>,
) -> Option<GameEvent> { ) -> Option<GameEvent> {
info!("Parsing event from URI: {} (type: {})", uri, event_type); info!("Parsing event from URI: {} (type: {})", uri, event_type);
@@ -442,6 +509,22 @@ impl LqpClient {
let phase = data.as_str()?; let phase = data.as_str()?;
info!("Gameflow phase changed to: {}", phase); info!("Gameflow phase changed to: {}", phase);
// Only trigger GameEnd on EndOfGame phase (not WaitingForStats or PreEndOfGame)
// This ensures we wait for the stats to be available
if phase == "EndOfGame" {
info!("Game end phase detected: {}", phase);
// Generate a GameEnd event for timeline recording
return Some(
GameEvent::from_json(&serde_json::json!({
"eventType": "lcu-game-end",
"gameId": 0, // Will be filled from state if available
"victory": false, // Will be updated from end-of-game stats
"duration": 0.0
}))
.unwrap_or(GameEvent::Unknown),
);
}
// Update internal state based on phase // Update internal state based on phase
return Some( return Some(
GameEvent::from_json(&serde_json::json!({ GameEvent::from_json(&serde_json::json!({
@@ -461,17 +544,108 @@ impl LqpClient {
if phase == "InProgress" { if phase == "InProgress" {
info!("Game is now in progress!"); info!("Game is now in progress!");
// Extract game info // Try to parse the gameData into a GameflowSession struct
let game_id = data let session: Option<GameflowSession> = data
.get("gameData") .get("gameData")
.and_then(|gd| gd.get("gameId")) .and_then(|gd| serde_json::from_value(gd.clone()).ok());
.and_then(|id| id.as_u64())
.unwrap_or(0); if let Some(ref session) = session {
debug!(
"Parsed GameflowSession: game_id={}, queue={:?}",
session.game_id,
session.queue_name()
);
} else {
debug!("Failed to parse gameData as GameflowSession, falling back to manual extraction");
}
// Extract game_id - prefer from parsed session, fallback to manual extraction
let game_id = session.as_ref().map(|s| s.game_id).unwrap_or_else(|| {
data.get("gameData")
.and_then(|gd| gd.get("gameId"))
.and_then(|id| id.as_u64())
.unwrap_or(0)
});
// Note: Champion, team, summoner_name will be extracted using puuid
// in handle_game_event when we have access to pregame_metadata
// Extract queue info (this is the same for all players)
let queue_type = session
.as_ref()
.and_then(|s| s.queue_name())
.map(|s| s.to_string())
.or_else(|| {
data.get("gameData")
.and_then(|gd| gd.get("queue"))
.and_then(|q| q.get("name"))
.and_then(|n| n.as_str())
.map(|s| s.to_string())
});
let queue_id = session.as_ref().and_then(|s| s.queue_id());
let game_mode = session
.as_ref()
.and_then(|s| s.game_mode())
.map(|s| s.to_string())
.or_else(|| {
data.get("gameData")
.and_then(|gd| gd.get("queue"))
.and_then(|q| q.get("gameMode"))
.and_then(|m| m.as_str())
.map(|s| s.to_string())
});
// Extract map name
let map_name = session
.as_ref()
.and_then(|s| s.map_id())
.and_then(|id| map_id_to_name(id as u64))
.or_else(|| {
data.get("gameData")
.and_then(|gd| gd.get("queue"))
.and_then(|q| q.get("mapId"))
.and_then(|id| id.as_u64())
.and_then(map_id_to_name)
});
info!("Extracted game metadata: game_id={}, queue={:?}, queue_id={:?}, mode={:?}, map={:?}",
game_id, queue_type, queue_id, game_mode, map_name);
// Extract player-specific data using puuid
let (champion, team, summoner_name) = if let Some(puuid) = local_puuid {
let champ_id = session.as_ref().and_then(|s| s.get_champion_id(puuid));
let team_id = session.as_ref().and_then(|s| s.get_team(puuid));
let summoner = session
.as_ref()
.and_then(|s| s.get_summoner_name(puuid))
.map(|s| s.to_string());
// Convert champion_id to champion name
let champ_name = champ_id.and_then(champion_id_to_name);
info!("Extracted player data via puuid: champion={:?}, team={:?}, summoner={:?}",
champ_name, team_id, summoner);
(champ_name, team_id, summoner)
} else {
info!("No local_puuid available, cannot extract player-specific data");
(None, None, None)
};
return Some( return Some(
GameEvent::from_json(&serde_json::json!({ GameEvent::from_json(&serde_json::json!({
"eventType": "lcu-game-start", "eventType": "lcu-game-start",
"gameId": game_id "gameId": game_id,
"queueType": queue_type,
"queueId": queue_id,
"gameMode": game_mode,
"map": map_name,
"champion": champion,
"team": team,
"summonerName": summoner_name,
"session": session
})) }))
.unwrap_or(GameEvent::Unknown), .unwrap_or(GameEvent::Unknown),
); );
@@ -502,9 +676,172 @@ impl LqpClient {
// Handle champion select // Handle champion select
if uri == "/lol-champ-select/v1/session" { if uri == "/lol-champ-select/v1/session" {
info!("Champion select event: {:?}", data); info!("Champion select event: {:?}", data);
// Check if we're in champion select phase
if let Some(timers) = data.get("timers") {
if let Some(phase) = timers.get("phase").and_then(|p| p.as_str()) {
if phase == "BAN_PICK" || phase == "FINALIZATION" {
// Extract local player's champion
if let Some(local_player_cell_id) =
data.get("localPlayerCellId").and_then(|id| id.as_i64())
{
// Check both teams for the local player
for team_key in &["myTeam", "theirTeam"] {
if let Some(team) = data.get(team_key).and_then(|t| t.as_array()) {
for member in 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())
{
if champion_id > 0 {
let champion_name = member
.get("championName")
.and_then(|n| n.as_str())
.unwrap_or("Unknown")
.to_string();
return Some(
GameEvent::from_json(&serde_json::json!({
"eventType": "lcu-champion-pick",
"summonerName": "LocalPlayer",
"championId": champion_id,
"championName": champion_name,
"isLocalPlayer": true
}))
.unwrap_or(GameEvent::Unknown),
);
}
}
}
}
}
}
}
}
}
}
return None; return None;
} }
// Handle end-of-game stats block (contains actual game results)
if uri == "/lol-end-of-game/v1/eog-stats-block" {
info!("End-of-game stats received: {:?}", data);
// Extract game ID and duration
let game_id = data.get("gameId").and_then(|id| id.as_u64()).unwrap_or(0);
let game_duration = data
.get("gameLength")
.and_then(|d| d.as_f64())
.unwrap_or(0.0);
// Get local player data - prefer localPlayer field, fallback to teams[0].players[0]
let local_player = data.get("localPlayer");
// Extract victory status from local player's stats (WIN: 1 means victory)
let victory = local_player
.and_then(|p| p.get("stats"))
.and_then(|s| s.get("WIN"))
.and_then(|w| w.as_u64())
.map(|w| w == 1)
.or_else(|| {
// Fallback: check if player's team is winning team
data.get("teams")
.and_then(|teams| teams.as_array())
.and_then(|t| {
t.iter().find_map(|team| {
if team.get("isPlayerTeam").and_then(|p| p.as_bool()) == Some(true)
{
team.get("isWinningTeam").and_then(|w| w.as_bool())
} else {
None
}
})
})
})
.unwrap_or(false);
// Extract player stats - stats use UPPERCASE keys
let mut kills = 0u32;
let mut deaths = 0u32;
let mut assists = 0u32;
let mut creep_score = 0u32;
let mut gold_earned = 0u32;
let mut damage_dealt = 0u64;
let mut damage_taken = 0u64;
let mut vision_score = 0.0;
if let Some(stats_obj) = local_player.and_then(|p| p.get("stats")) {
kills = stats_obj
.get("CHAMPIONS_KILLED")
.and_then(|k| k.as_u64())
.unwrap_or(0) as u32;
deaths = stats_obj
.get("NUM_DEATHS")
.and_then(|d| d.as_u64())
.unwrap_or(0) as u32;
assists = stats_obj
.get("ASSISTS")
.and_then(|a| a.as_u64())
.unwrap_or(0) as u32;
creep_score = stats_obj
.get("MINIONS_KILLED")
.and_then(|cs| cs.as_u64())
.unwrap_or(0) as u32;
gold_earned = stats_obj
.get("GOLD_EARNED")
.and_then(|g| g.as_u64())
.unwrap_or(0) as u32;
damage_dealt = stats_obj
.get("TOTAL_DAMAGE_DEALT_TO_CHAMPIONS")
.and_then(|d| d.as_u64())
.unwrap_or(0);
damage_taken = stats_obj
.get("TOTAL_DAMAGE_TAKEN")
.and_then(|d| d.as_u64())
.unwrap_or(0);
vision_score = stats_obj
.get("VISION_SCORE")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
}
info!("Extracted game end stats: kills={}, deaths={}, assists={}, cs={}, gold={}, damage_dealt={}, damage_taken={}, vision={}, victory={}",
kills, deaths, assists, creep_score, gold_earned, damage_dealt, damage_taken, vision_score, victory);
// Generate a GameEnd event with actual stats
// Note: PlayerStats uses camelCase due to serde rename_all
let event_json = serde_json::json!({
"eventType": "lcu-game-end",
"gameId": game_id,
"victory": victory,
"duration": game_duration,
"stats": {
"kills": kills,
"deaths": deaths,
"assists": assists,
"minionsKilled": creep_score,
"goldEarned": gold_earned,
"damageDealt": damage_dealt,
"damageTaken": damage_taken,
"visionScore": vision_score
}
});
info!("Generating GameEnd event from eog-stats: {:?}", event_json);
match GameEvent::from_json(&event_json) {
Some(event) => {
info!("Successfully parsed GameEnd event");
return Some(event);
}
None => {
warn!("Failed to parse GameEnd event, returning Unknown");
return Some(GameEvent::Unknown);
}
}
}
// Handle lobby // Handle lobby
if uri.starts_with("/lol-lobby") { if uri.starts_with("/lol-lobby") {
debug!("Lobby event: {}", uri); debug!("Lobby event: {}", uri);
@@ -607,6 +944,396 @@ impl LqpClient {
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
} }
/// Get the currently selected champion in champ select.
pub async fn get_current_champion(&self) -> Result<serde_json::Value> {
self.request("GET", endpoints::CHAMPION_SUMMARY).await
}
/// Get current rune page.
pub async fn get_rune_page(&self) -> Result<serde_json::Value> {
self.request("GET", endpoints::RUNE_PAGES).await
}
/// Get match history.
pub async fn get_match_history(&self) -> Result<serde_json::Value> {
self.request("GET", endpoints::MATCH_HISTORY).await
}
/// Get live client data (available during game).
pub async fn get_live_client_data(&self) -> Result<serde_json::Value> {
self.request("GET", endpoints::LIVE_CLIENT_DATA).await
}
/// Fetch pre-game metadata (champion, skin, runes, queue info).
pub async fn fetch_pregame_metadata(&self) -> Result<PreGameMetadata> {
let mut metadata = PreGameMetadata::default();
// Get session info for queue type and game mode
if let Ok(session) = self.get_session().await {
if let Some(map) = session.get("map").and_then(|m| m.as_str()) {
metadata.map_name = Some(map.to_string());
}
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)
if let Ok(summoner) = self.get_summoner().await {
if let Some(name) = summoner.get("displayName").and_then(|n| n.as_str()) {
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()) {
metadata.local_puuid = Some(puuid.to_string());
let mut state = self.state.write().await;
state.local_puuid = Some(puuid.to_string());
}
}
// Get champion select info
if let Ok(champ_select) = self.get_champion_select().await {
// Find local player's cell
if let Some(local_player_cell_id) = champ_select
.get("localPlayerCellId")
.and_then(|id| id.as_i64())
{
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
if let Ok(rune_page) = self.get_rune_page().await {
if let Some(name) = rune_page.get("name").and_then(|n| n.as_str()) {
metadata.rune_page_name = Some(name.to_string());
}
}
Ok(metadata)
}
/// Fetch end-of-game stats.
pub async fn fetch_game_end_stats(&self) -> Result<GameEndMetadata> {
let mut metadata = GameEndMetadata::default();
if let Ok(stats) = self.get_game_stats().await {
// Get game result
if let Some(victory) = stats.get("gameResult").and_then(|r| r.as_str()) {
metadata.victory = Some(victory == "WIN");
}
// Get player stats
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(stats_obj) = player.get("stats") {
metadata.kills =
stats_obj.get("kills").and_then(|k| k.as_u64()).unwrap_or(0) as u32;
metadata.deaths = stats_obj
.get("deaths")
.and_then(|d| d.as_u64())
.unwrap_or(0) as u32;
metadata.assists = stats_obj
.get("assists")
.and_then(|a| a.as_u64())
.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);
}
}
}
// Get game duration
if let Some(duration) = stats.get("gameLength").and_then(|d| d.as_f64()) {
metadata.game_duration = duration;
}
// Get match ID
if let Some(match_id) = stats.get("matchId").and_then(|id| id.as_u64()) {
metadata.match_id = Some(match_id.to_string());
}
}
Ok(metadata)
}
}
/// Convert champion ID to champion name.
/// This is a simplified mapping for common champions.
pub fn champion_id_to_name(id: u32) -> Option<String> {
let name = match id {
1 => "Annie",
2 => "Olaf",
3 => "Galio",
4 => "TwistedFate",
5 => "XinZhao",
6 => "Urgot",
7 => "LeBlanc",
8 => "Vladimir",
9 => "Fiddlesticks",
10 => "Kayle",
11 => "MasterYi",
12 => "Alistar",
13 => "Ryze",
14 => "Sion",
15 => "Sivir",
16 => "Soraka",
17 => "Teemo",
18 => "Tristana",
19 => "Warwick",
20 => "Nunu",
21 => "MissFortune",
22 => "Ashe",
23 => "Tryndamere",
24 => "Jax",
25 => "Morgana",
26 => "Zilean",
27 => "Singed",
28 => "Evelynn",
29 => "Twitch",
30 => "Karthus",
31 => "Cho'Gath",
32 => "Amumu",
33 => "Rammus",
34 => "Anivia",
35 => "Shaco",
36 => "DrMundo",
37 => "Sona",
38 => "Kassadin",
39 => "Irelia",
40 => "Janna",
41 => "Gangplank",
42 => "Corki",
43 => "Karma",
44 => "Taric",
45 => "Veigar",
48 => "Trundle",
50 => "Swain",
51 => "Caitlyn",
52 => "Blitzcrank",
53 => "Malphite",
54 => "Katarina",
55 => "Nocturne",
56 => "Maokai",
57 => "Renekton",
58 => "JarvanIV",
59 => "Elise",
60 => "Talon",
61 => "Orianna",
62 => "Wukong",
63 => "Brand",
64 => "LeeSin",
67 => "Vayne",
68 => "Rumble",
69 => "Cassiopeia",
72 => "Skarner",
74 => "Heimerdinger",
75 => "Nasus",
76 => "Nidalee",
77 => "Udyr",
78 => "Poppy",
79 => "Gragas",
80 => "Pantheon",
81 => "Ezreal",
82 => "Mordekaiser",
83 => "Yorick",
84 => "Akali",
85 => "Kennedy",
86 => "Garen",
89 => "Leona",
90 => "Malzahar",
91 => "Talon",
92 => "Riven",
96 => "Kog'Maw",
98 => "Shen",
99 => "Lux",
101 => "Xerath",
102 => "Shyvana",
103 => "Ahri",
104 => "Graves",
105 => "Fizz",
106 => "Volibear",
107 => "Rengar",
110 => "Varus",
111 => "Nautilus",
112 => "Viktor",
113 => "Sejuani",
114 => "Fiora",
115 => "Ziggs",
117 => "Lulu",
119 => "Draven",
120 => "Hecarim",
121 => "Kha'Zix",
122 => "Darius",
126 => "Jayce",
127 => "Lissandra",
131 => "Diana",
133 => "Quinn",
134 => "Syndra",
136 => "AurelionSol",
141 => "Kayn",
142 => "Zoe",
143 => "Lillia",
145 => "Samira",
147 => "Seraphine",
150 => "Gnar",
154 => "Zac",
157 => "Yasuo",
161 => "Vel'Koz",
163 => "Taliyah",
164 => "Camille",
166 => "Akshan",
167 => "Nilah",
201 => "Braum",
202 => "Jhin",
203 => "Kindred",
222 => "Jinx",
223 => "TahmKench",
236 => "Lucian",
238 => "Zed",
240 => "Kled",
245 => "Ekko",
246 => "Qiyana",
254 => "Vi",
255 => "Janna",
256 => "Pyke",
257 => "Nami",
266 => "Aatrox",
267 => "Nami",
268 => "Azir",
350 => "Yuumi",
360 => "Samira",
412 => "Thresh",
420 => "Illaoi",
421 => "Rek'Sai",
427 => "Ivern",
429 => "Kalista",
432 => "Bard",
497 => "Rakan",
498 => "Xayah",
516 => "Ornn",
517 => "Sylas",
518 => "Neeko",
523 => "Aphelios",
526 => "Rell",
555 => "Pyke",
711 => "Vex",
777 => "Yone",
875 => "Sett",
876 => "Lillia",
887 => "Gwen",
888 => "Viego",
895 => "KSante",
901 => "Smolder",
902 => "Hwei",
950 => "Naafiri",
951 => "Briar",
_ => return None,
};
Some(name.to_string())
}
/// Convert map ID to map name.
fn map_id_to_name(id: u64) -> Option<String> {
let name = match id {
1 => "Summoner's Rift",
2 => "Summoner's Rift",
3 => "The Proving Grounds",
4 => "Twisted Treeline",
8 => "The Crystal Scar",
10 => "Twisted Treeline",
11 => "Summoner's Rift",
12 => "Howling Abyss",
14 => "Butcher's Bridge",
16 => "Cosmic Ruins",
18 => "Valoran City Park",
19 => "Substructure 43",
20 => "Crash Site",
21 => "Nexus Blitz",
22 => "Convergence",
23 => "Arena",
24 => "Arena",
25 => "Rings of Wrath",
30 => "Swarm",
31 => "Swarm",
32 => "Swarm",
33 => "Swarm",
34 => "Swarm",
35 => "Swarm",
_ => return None,
};
Some(name.to_string())
}
/// Pre-game metadata fetched before the game starts.
#[derive(Debug, Clone, Default)]
pub struct PreGameMetadata {
pub champion_id: Option<u32>,
pub skin_id: Option<u32>,
pub rune_page_name: Option<String>,
pub summoner_name: Option<String>,
pub queue_type: Option<String>,
pub queue_id: Option<u32>,
pub game_mode: Option<String>,
pub map_name: Option<String>,
pub team: Option<u32>,
pub local_puuid: Option<String>,
}
/// End-of-game metadata fetched after the game ends.
#[derive(Debug, Clone, Default)]
pub struct GameEndMetadata {
pub victory: Option<bool>,
pub match_id: Option<String>,
pub kills: u32,
pub deaths: u32,
pub assists: u32,
pub creep_score: u32,
pub gold_earned: u32,
pub damage_dealt: u64,
pub damage_taken: u64,
pub vision_score: f64,
pub game_duration: f64,
} }
impl Default for LqpClient { impl Default for LqpClient {

View File

@@ -13,9 +13,17 @@ pub enum GameEvent {
#[serde(rename = "lcu-match-found")] #[serde(rename = "lcu-match-found")]
MatchFound(MatchInfo), MatchFound(MatchInfo),
/// Champion select phase started.
#[serde(rename = "lcu-champ-select-start")]
ChampSelectStart(ChampSelectStartInfo),
/// Player picked a champion.
#[serde(rename = "lcu-champion-pick")]
ChampionPick(ChampionPickInfo),
/// Game has started. /// Game has started.
#[serde(rename = "lcu-game-start")] #[serde(rename = "lcu-game-start")]
GameStart(GameStartInfo), GameStart(Box<GameStartInfo>),
/// Player killed an enemy. /// Player killed an enemy.
#[serde(rename = "lcu-kill")] #[serde(rename = "lcu-kill")]
@@ -29,6 +37,10 @@ pub enum GameEvent {
#[serde(rename = "lcu-objective")] #[serde(rename = "lcu-objective")]
Objective(ObjectiveEvent), Objective(ObjectiveEvent),
/// In-game stats update.
#[serde(rename = "lcu-stats-update")]
StatsUpdate(InGameStats),
/// Game has ended. /// Game has ended.
#[serde(rename = "lcu-game-end")] #[serde(rename = "lcu-game-end")]
GameEnd(GameEndInfo), GameEnd(GameEndInfo),
@@ -65,6 +77,10 @@ pub struct MatchInfo {
/// Queue type (ranked, normal, aram, etc.). /// Queue type (ranked, normal, aram, etc.).
pub queue_type: String, pub queue_type: String,
/// Queue ID (numeric identifier).
#[serde(default)]
pub queue_id: Option<u32>,
/// Map name. /// Map name.
pub map: String, pub map: String,
@@ -76,6 +92,87 @@ pub struct MatchInfo {
pub timestamp: DateTime<Utc>, pub timestamp: DateTime<Utc>,
} }
/// Champion select start event data.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChampSelectStartInfo {
/// Session ID.
#[serde(default)]
pub session_id: Option<String>,
/// Team (100 = blue, 200 = red).
#[serde(default)]
pub team: Option<u32>,
/// Timestamp when champ select started.
#[serde(default = "Utc::now")]
pub timestamp: DateTime<Utc>,
}
/// Champion pick event data.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChampionPickInfo {
/// Player's summoner name.
pub summoner_name: String,
/// Champion ID.
pub champion_id: u32,
/// Champion name.
pub champion_name: String,
/// Whether this is the local player's pick.
#[serde(default)]
pub is_local_player: bool,
/// Skin ID selected.
#[serde(default)]
pub skin_id: Option<u32>,
/// Skin name.
#[serde(default)]
pub skin_name: Option<String>,
/// Timestamp when champion was picked.
#[serde(default = "Utc::now")]
pub timestamp: DateTime<Utc>,
}
/// In-game stats update.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InGameStats {
/// Current kills.
pub kills: u32,
/// Current deaths.
pub deaths: u32,
/// Current assists.
pub assists: u32,
/// Current creep score.
#[serde(default)]
pub creep_score: u32,
/// Current gold.
#[serde(default)]
pub gold: u32,
/// Current level.
#[serde(default)]
pub level: u32,
/// Game time in seconds.
#[serde(default)]
pub game_time: f64,
/// Timestamp of the stats update.
#[serde(default = "Utc::now")]
pub timestamp: DateTime<Utc>,
}
/// Game start event data. /// Game start event data.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -99,6 +196,26 @@ pub struct GameStartInfo {
#[serde(default)] #[serde(default)]
pub team: Option<u32>, pub team: Option<u32>,
/// Queue type (ranked, normal, aram, etc.).
#[serde(default)]
pub queue_type: Option<String>,
/// Queue ID.
#[serde(default)]
pub queue_id: Option<u32>,
/// Game mode.
#[serde(default)]
pub game_mode: Option<String>,
/// Map name.
#[serde(default, rename = "map")]
pub map_name: Option<String>,
/// Full gameflow session data (if available).
#[serde(default)]
pub session: Option<GameflowSession>,
/// Game start timestamp. /// Game start timestamp.
#[serde(default = "Utc::now")] #[serde(default = "Utc::now")]
pub timestamp: DateTime<Utc>, pub timestamp: DateTime<Utc>,
@@ -283,7 +400,7 @@ pub struct RawEvent {
#[serde(untagged)] #[serde(untagged)]
pub enum EventData { pub enum EventData {
/// Game event. /// Game event.
GameEvent(GameEvent), GameEvent(Box<GameEvent>),
/// Raw JSON value. /// Raw JSON value.
Raw(serde_json::Value), Raw(serde_json::Value),
@@ -313,6 +430,12 @@ impl GameEvent {
GameEvent::MatchFound(info) => { GameEvent::MatchFound(info) => {
format!("Match found: {} ({})", info.game_mode, info.queue_type) format!("Match found: {} ({})", info.game_mode, info.queue_type)
} }
GameEvent::ChampSelectStart(info) => {
format!("Champion select started (team: {:?})", info.team)
}
GameEvent::ChampionPick(pick) => {
format!("{} picked {}", pick.summoner_name, pick.champion_name)
}
GameEvent::GameStart(info) => { GameEvent::GameStart(info) => {
format!("Game started: ID {}", info.game_id) format!("Game started: ID {}", info.game_id)
} }
@@ -326,6 +449,12 @@ impl GameEvent {
let team = if obj.team == 100 { "Blue" } else { "Red" }; let team = if obj.team == 100 { "Blue" } else { "Red" };
format!("{} took {:?}", team, obj.objective_type) format!("{} took {:?}", team, obj.objective_type)
} }
GameEvent::StatsUpdate(stats) => {
format!(
"Stats: {}/{}/{} CS: {} Gold: {}",
stats.kills, stats.deaths, stats.assists, stats.creep_score, stats.gold
)
}
GameEvent::GameEnd(end) => { GameEvent::GameEnd(end) => {
let result = if end.victory { "Victory" } else { "Defeat" }; let result = if end.victory { "Victory" } else { "Defeat" };
format!("Game ended: {} ({:.1}s)", result, end.duration) format!("Game ended: {} ({:.1}s)", result, end.duration)
@@ -336,6 +465,23 @@ impl GameEvent {
GameEvent::Unknown => "Unknown event".to_string(), GameEvent::Unknown => "Unknown event".to_string(),
} }
} }
/// Get the event type name for categorization.
pub fn event_type_name(&self) -> &'static str {
match self {
GameEvent::MatchFound(_) => "match_found",
GameEvent::ChampSelectStart(_) => "champ_select_start",
GameEvent::ChampionPick(_) => "champion_pick",
GameEvent::GameStart(_) => "game_start",
GameEvent::Kill(_) => "kill",
GameEvent::Death(_) => "death",
GameEvent::Objective(_) => "objective",
GameEvent::StatsUpdate(_) => "stats_update",
GameEvent::GameEnd(_) => "game_end",
GameEvent::PhaseChange(_) => "phase_change",
GameEvent::Unknown => "unknown",
}
}
} }
#[cfg(test)] #[cfg(test)]
@@ -370,3 +516,148 @@ mod tests {
assert_eq!(obj, ObjectiveType::Dragon); assert_eq!(obj, ObjectiveType::Dragon);
} }
} }
// ============================================================================
// GameFlow Session Data Structures
// ============================================================================
/// Full gameflow session data from /lol-gameflow/v1/session
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct GameflowSession {
pub game_id: u64,
pub game_name: String,
pub is_custom_game: bool,
pub password: String,
pub player_champion_selections: Vec<PlayerChampionSelection>,
pub queue: Option<QueueInfo>,
pub spectator_key: Option<String>,
pub spectators_allowed: bool,
pub team_one: Vec<TeamMember>,
pub team_two: Vec<TeamMember>,
#[serde(default)]
pub phase: Option<String>,
}
/// Player's champion selection info
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct PlayerChampionSelection {
pub champion_id: u32,
pub puuid: String,
pub selected_skin_index: u32,
pub spell1_id: u32,
pub spell2_id: u32,
}
/// Queue information
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct QueueInfo {
pub id: u32,
pub name: String,
pub short_name: Option<String>,
pub description: String,
pub detailed_description: Option<String>,
pub game_mode: String,
pub map_id: u32,
pub category: String,
#[serde(default)]
pub is_ranked: bool,
#[serde(default)]
pub is_custom: bool,
pub r#type: Option<String>,
pub num_players_per_team: u32,
pub maximum_participant_list_size: u32,
pub minimum_participant_list_size: u32,
pub champions_required_to_play: u32,
#[serde(default)]
pub are_free_champions_allowed: bool,
pub queue_availability: Option<String>,
pub spectator_enabled: Option<bool>,
}
/// Team member information
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct TeamMember {
pub champion_id: u32,
pub last_selected_skin_index: u32,
pub profile_icon_id: u32,
pub puuid: String,
pub selected_position: String,
pub selected_role: String,
pub summoner_id: u64,
pub summoner_internal_name: String,
pub summoner_name: String,
#[serde(default)]
pub team_owner: bool,
pub team_participant_id: u32,
}
impl GameflowSession {
/// Get a player's champion selection by puuid
pub fn get_player_selection(&self, puuid: &str) -> Option<&PlayerChampionSelection> {
self.player_champion_selections
.iter()
.find(|s| s.puuid == puuid)
}
/// Get a player's team member info by puuid (returns team member and team id)
pub fn get_player_team_member(&self, puuid: &str) -> Option<(&TeamMember, u32)> {
// Check team one
for member in &self.team_one {
if member.puuid == puuid {
return Some((member, 100));
}
}
// Check team two
for member in &self.team_two {
if member.puuid == puuid {
return Some((member, 200));
}
}
None
}
/// Get a player's champion ID by puuid
pub fn get_champion_id(&self, puuid: &str) -> Option<u32> {
self.get_player_selection(puuid).map(|s| s.champion_id)
}
/// Get a player's team (100 or 200) by puuid
pub fn get_team(&self, puuid: &str) -> Option<u32> {
self.get_player_team_member(puuid).map(|(_, team)| team)
}
/// Get a player's summoner name by puuid
pub fn get_summoner_name(&self, puuid: &str) -> Option<&str> {
self.get_player_team_member(puuid).and_then(|(member, _)| {
if member.summoner_name.is_empty() {
None
} else {
Some(member.summoner_name.as_str())
}
})
}
/// Get queue name
pub fn queue_name(&self) -> Option<&str> {
self.queue.as_ref().map(|q| q.name.as_str())
}
/// Get game mode
pub fn game_mode(&self) -> Option<&str> {
self.queue.as_ref().map(|q| q.game_mode.as_str())
}
/// Get map ID
pub fn map_id(&self) -> Option<u32> {
self.queue.as_ref().map(|q| q.map_id)
}
/// Get queue ID
pub fn queue_id(&self) -> Option<u32> {
self.queue.as_ref().map(|q| q.id)
}
}

View File

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

View File

@@ -16,7 +16,7 @@ use record_daemon::{
lqp::{GameEvent, LockfileWatcher, LqpClient}, lqp::{GameEvent, LockfileWatcher, LqpClient},
recording::RecordingEngine, recording::RecordingEngine,
state::{DaemonStateMachine, DaemonStatus, StateTransition}, state::{DaemonStateMachine, DaemonStatus, StateTransition},
timeline::{EventMapper, TimelineStore}, timeline::{EventMapper, TimelineStore, TimestampedEvent},
}; };
/// Record Daemon - League of Legends recording daemon. /// Record Daemon - League of Legends recording daemon.
@@ -54,6 +54,10 @@ struct Daemon {
timeline_store: Arc<RwLock<TimelineStore>>, timeline_store: Arc<RwLock<TimelineStore>>,
/// Event mapper. /// Event mapper.
event_mapper: Arc<RwLock<EventMapper>>, event_mapper: Arc<RwLock<EventMapper>>,
/// Current recording ID (if recording).
current_recording_id: Arc<RwLock<Option<uuid::Uuid>>>,
/// Pre-game metadata (collected before game starts).
pregame_metadata: Arc<RwLock<Option<record_daemon::lqp::PreGameMetadata>>>,
/// IPC server. /// IPC server.
ipc_server: Option<IpcServer>, ipc_server: Option<IpcServer>,
/// Shutdown signal. /// Shutdown signal.
@@ -72,6 +76,8 @@ impl Daemon {
recording_engine: Arc::new(RwLock::new(None)), recording_engine: Arc::new(RwLock::new(None)),
timeline_store: Arc::new(RwLock::new(TimelineStore::new())), timeline_store: Arc::new(RwLock::new(TimelineStore::new())),
event_mapper: Arc::new(RwLock::new(EventMapper::new())), event_mapper: Arc::new(RwLock::new(EventMapper::new())),
current_recording_id: Arc::new(RwLock::new(None)),
pregame_metadata: Arc::new(RwLock::new(None)),
ipc_server: None, ipc_server: None,
shutdown_tx, shutdown_tx,
} }
@@ -223,80 +229,300 @@ impl Daemon {
async fn handle_game_event(&self, event: GameEvent) -> Result<()> { async fn handle_game_event(&self, event: GameEvent) -> Result<()> {
info!("[EVENT_HANDLER] Game event received: {:?}", event); info!("[EVENT_HANDLER] Game event received: {:?}", event);
// Handle pre-game metadata collection
match &event {
GameEvent::PhaseChange(info) if info.phase == "ChampSelect" => {
info!("[EVENT_HANDLER] Champion select started, fetching pre-game metadata");
match self.lqp_client.fetch_pregame_metadata().await {
Ok(metadata) => {
info!("[EVENT_HANDLER] Pre-game metadata fetched: {:?}", metadata);
*self.pregame_metadata.write() = Some(metadata);
}
Err(e) => {
warn!("[EVENT_HANDLER] Failed to fetch pre-game metadata: {}", e);
}
}
}
GameEvent::ChampionPick(pick) if pick.is_local_player => {
info!(
"[EVENT_HANDLER] Local player picked champion: {}",
pick.champion_name
);
let mut metadata = self.pregame_metadata.write();
if let Some(ref mut meta) = *metadata {
meta.champion_id = Some(pick.champion_id);
if let Some(_skin_name) = &pick.skin_name {
// Store skin name for later use
}
}
}
GameEvent::GameStart(info) => {
info!(
"[EVENT_HANDLER] Game started with metadata: queue={:?}, mode={:?}, map={:?}",
info.queue_type, info.game_mode, info.map_name
);
// Update pre-game metadata with game start info
let mut pregame = self.pregame_metadata.write();
// Extract player-specific data from session using puuid
let (champion_id, team, summoner_name) = if let Some(ref session) = info.session {
// Get puuid from pregame metadata (fetched earlier)
let puuid = pregame.as_ref().and_then(|m| m.local_puuid.as_ref());
if let Some(puuid) = puuid {
// Use the puuid to find the correct player's data
let champ_id = session.get_champion_id(puuid);
let team_id = session.get_team(puuid);
let summoner = session.get_summoner_name(puuid).map(|s| s.to_string());
info!("[EVENT_HANDLER] Found player data via puuid: champion_id={:?}, team={:?}, summoner={:?}",
champ_id, team_id, summoner);
(champ_id, team_id, summoner)
} else {
// No puuid available, can't determine player
warn!("[EVENT_HANDLER] No puuid available, cannot determine player-specific data");
(None, None, None)
}
} else {
(None, None, None)
};
if let Some(ref mut meta) = *pregame {
// Fill in champion_id from session if not already set
if meta.champion_id.is_none() {
meta.champion_id = champion_id;
}
// Fill in team from session if not already set
if meta.team.is_none() {
meta.team = team;
}
// Fill in summoner_name from session if not already set
if meta.summoner_name.is_none() {
meta.summoner_name = summoner_name;
}
// Fill in queue info
if meta.queue_type.is_none() {
meta.queue_type = info.queue_type.clone();
}
if meta.queue_id.is_none() {
meta.queue_id = info.queue_id;
}
if meta.game_mode.is_none() {
meta.game_mode = info.game_mode.clone();
}
if meta.map_name.is_none() {
meta.map_name = info.map_name.clone();
}
} else {
// Create pre-game metadata from game start info
*pregame = Some(record_daemon::lqp::PreGameMetadata {
summoner_name,
champion_id,
skin_id: None,
rune_page_name: None,
queue_type: info.queue_type.clone(),
queue_id: info.queue_id,
game_mode: info.game_mode.clone(),
map_name: info.map_name.clone(),
team,
local_puuid: None,
});
}
}
_ => {}
}
// Record event to timeline if recording (BEFORE state transition for GameEnd)
// This ensures GameEnd events are recorded while still in recording state
if self.state_machine.is_recording() {
if let Some((video_ts, game_ts)) = self.event_mapper.write().handle_event(&event) {
// Get the current recording ID
if let Some(recording_id) = *self.current_recording_id.read() {
// Create a timestamped event
let timestamped_event = TimestampedEvent {
video_timestamp: video_ts,
game_timestamp: game_ts,
timestamp: chrono::Utc::now(),
event_type: event.event_type_name().to_string(),
description: event.description(),
event: event.clone(),
};
// Add the event to the timeline store
if let Err(e) = self
.timeline_store
.write()
.add_event(recording_id, timestamped_event)
{
warn!("Failed to add event to timeline: {:?}", e);
} else {
debug!(
"Event added to timeline: video_ts={:?}, game_ts={:?}, type={}",
video_ts,
game_ts,
event.event_type_name()
);
}
} else {
warn!("Recording in progress but no recording ID set");
}
}
}
// Process state transitions // Process state transitions
if let Some(transition) = self.state_machine.process_event(&event) { if let Some(transition) = self.state_machine.process_event(&event) {
info!("[EVENT_HANDLER] State transition: {:?}", transition); info!("[EVENT_HANDLER] State transition: {:?}", transition);
self.state_machine.transition(transition.clone()); // Only process the transition if it's valid
if let Some(_new_state) = self.state_machine.transition(transition.clone()) {
// Handle recording start/stop // Handle recording start/stop
match transition { match transition {
StateTransition::GameStarted { game_id, champion } => { StateTransition::GameStarted {
info!( game_id,
"[EVENT_HANDLER] GameStarted transition - game_id: {}, champion: {:?}", champion,
game_id, champion queue_type,
queue_id,
game_mode,
map_name,
team,
summoner_name,
} => {
info!(
"[EVENT_HANDLER] GameStarted transition - game_id: {}, champion: {:?}, queue_type: {:?}, game_mode: {:?}",
game_id, champion, queue_type, game_mode
); );
// If already recording, stop the current recording first // If already recording, stop the current recording first
if self.state_machine.is_recording() { if self.state_machine.is_recording() {
info!( info!(
"[EVENT_HANDLER] Stopping previous recording before starting new one" "[EVENT_HANDLER] Stopping previous recording before starting new one"
); );
if let Err(e) = self.stop_recording().await { if let Err(e) = self.stop_recording().await {
warn!("[EVENT_HANDLER] Failed to stop previous recording: {}", e); warn!("[EVENT_HANDLER] Failed to stop previous recording: {}", e);
}
}
info!("[EVENT_HANDLER] Calling start_recording...");
// Wrap the start_recording call to catch any panics
let start_result =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
// We need to use a blocking approach here since we're in catch_unwind
// The actual async call happens outside
}));
if let Err(panic_info) = start_result {
error!(
"[EVENT_HANDLER] PANIC before start_recording: {:?}",
panic_info
);
eprintln!(
"[EVENT_HANDLER] PANIC before start_recording: {:?}",
panic_info
);
}
// Get pre-game metadata to pass to recording
let pregame = self.pregame_metadata.read().clone();
let champion_name = champion.or_else(|| {
pregame.as_ref().and({
// Try to get champion name from metadata if not provided
None // We only have champion_id, not name
})
});
// Build game metadata for timeline
let metadata_update = record_daemon::timeline::MetadataUpdate {
queue_type: queue_type.clone(),
queue_id,
game_mode: game_mode.clone(),
map_name: map_name.clone(),
team,
summoner_name: summoner_name.clone(),
..Default::default()
};
if let Err(e) = self
.start_recording_with_metadata(
game_id,
champion_name.as_deref(),
metadata_update,
)
.await
{
error!("[EVENT_HANDLER] Failed to start recording: {}", e);
// Don't propagate error - keep daemon running
} else {
info!("[EVENT_HANDLER] start_recording completed successfully");
} }
} }
StateTransition::GameEnded { game_end_info } => {
info!("[EVENT_HANDLER] Calling start_recording..."); info!(
"[EVENT_HANDLER] GameEnded transition with info: {:?}",
// Wrap the start_recording call to catch any panics game_end_info
let start_result =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
// We need to use a blocking approach here since we're in catch_unwind
// The actual async call happens outside
}));
if let Err(panic_info) = start_result {
error!(
"[EVENT_HANDLER] PANIC before start_recording: {:?}",
panic_info
); );
eprintln!( // Convert GameEndInfo to GameEndMetadata if available
"[EVENT_HANDLER] PANIC before start_recording: {:?}", let game_end_metadata =
panic_info game_end_info.map(|info| record_daemon::lqp::GameEndMetadata {
victory: Some(info.victory),
match_id: None,
kills: info.stats.as_ref().map(|s| s.kills).unwrap_or(0),
deaths: info.stats.as_ref().map(|s| s.deaths).unwrap_or(0),
assists: info.stats.as_ref().map(|s| s.assists).unwrap_or(0),
creep_score: info
.stats
.as_ref()
.map(|s| s.minions_killed)
.unwrap_or(0),
gold_earned: info
.stats
.as_ref()
.map(|s| s.gold_earned)
.unwrap_or(0),
damage_dealt: info
.stats
.as_ref()
.map(|s| s.damage_dealt)
.unwrap_or(0),
damage_taken: info
.stats
.as_ref()
.map(|s| s.damage_taken)
.unwrap_or(0),
vision_score: info
.stats
.as_ref()
.map(|s| s.vision_score)
.unwrap_or(0.0),
game_duration: info.duration,
});
info!(
"[EVENT_HANDLER] Game end metadata from event: {:?}",
game_end_metadata
); );
}
if let Err(e) = self.start_recording(game_id, champion.as_deref()).await { if let Err(e) = self.stop_recording_with_metadata(game_end_metadata).await {
error!("[EVENT_HANDLER] Failed to start recording: {}", e); error!("[EVENT_HANDLER] Failed to stop recording: {}", e);
// Don't propagate error - keep daemon running // Don't propagate error - keep daemon running
} else { }
info!("[EVENT_HANDLER] start_recording completed successfully");
// Clear pre-game metadata
*self.pregame_metadata.write() = None;
} }
_ => {}
} }
StateTransition::GameEnded => { } else {
info!("[EVENT_HANDLER] GameEnded transition"); warn!(
"[EVENT_HANDLER] State transition rejected: {:?}",
if let Err(e) = self.stop_recording().await { transition
error!("[EVENT_HANDLER] Failed to stop recording: {}", e);
// Don't propagate error - keep daemon running
}
}
_ => {}
}
}
// Record event to timeline if recording
if self.state_machine.is_recording() {
if let Some((video_ts, game_ts)) = self.event_mapper.write().handle_event(&event) {
// Event would be added to timeline here
debug!(
"Event mapped: video_ts={:?}, game_ts={:?}",
video_ts, game_ts
); );
} }
} }
@@ -306,13 +532,37 @@ impl Daemon {
Ok(()) Ok(())
} }
/// Start recording. /// Start recording with game metadata.
async fn start_recording(&self, game_id: u64, champion: Option<&str>) -> Result<()> { async fn start_recording_with_metadata(
&self,
game_id: u64,
champion: Option<&str>,
metadata_update: record_daemon::timeline::MetadataUpdate,
) -> Result<()> {
info!( info!(
"Daemon::start_recording called - game {} ({:?})", "Daemon::start_recording_with_metadata called - game {} ({:?})",
game_id, champion game_id, champion
); );
// Create a recording entry in the timeline store first
let recording_id = self
.timeline_store
.write()
.start_recording_entry(Some(game_id), champion.map(|s| s.to_string()));
// Update metadata immediately with game start info
if let Err(e) = self
.timeline_store
.write()
.update_metadata(recording_id, metadata_update)
{
warn!("Failed to update recording metadata: {:?}", e);
}
// Store the recording ID for event tracking
*self.current_recording_id.write() = Some(recording_id);
info!("Created recording entry with ID: {}", recording_id);
// Clone Arc references for use in spawn_blocking // Clone Arc references for use in spawn_blocking
let recording_engine = self.recording_engine.clone(); let recording_engine = self.recording_engine.clone();
let event_mapper = self.event_mapper.clone(); let event_mapper = self.event_mapper.clone();
@@ -334,7 +584,7 @@ impl Daemon {
warn!("Recording engine is None!"); warn!("Recording engine is None!");
} }
info!("Daemon::start_recording completed successfully"); info!("Daemon::start_recording_with_metadata completed successfully");
Ok(()) Ok(())
}) })
.await .await
@@ -348,12 +598,24 @@ impl Daemon {
/// Stop recording. /// Stop recording.
async fn stop_recording(&self) -> Result<()> { async fn stop_recording(&self) -> Result<()> {
self.stop_recording_with_metadata(None).await
}
/// Stop recording with optional game end metadata.
async fn stop_recording_with_metadata(
&self,
game_end_metadata: Option<record_daemon::lqp::GameEndMetadata>,
) -> Result<()> {
info!("Stopping recording"); info!("Stopping recording");
// Get the current recording ID and clear it
let recording_id = self.current_recording_id.write().take();
// Clone Arc references for use in spawn_blocking // Clone Arc references for use in spawn_blocking
let recording_engine = self.recording_engine.clone(); let recording_engine = self.recording_engine.clone();
let event_mapper = self.event_mapper.clone(); let event_mapper = self.event_mapper.clone();
let timeline_store = self.timeline_store.clone(); let timeline_store = self.timeline_store.clone();
let pregame_metadata = self.pregame_metadata.read().clone();
// Use spawn_blocking to avoid blocking the async runtime // Use spawn_blocking to avoid blocking the async runtime
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
@@ -362,8 +624,59 @@ impl Daemon {
let result = engine.stop_recording()?; let result = engine.stop_recording()?;
event_mapper.write().stop(); event_mapper.write().stop();
// Save to timeline // Use the existing recording ID if available, otherwise create new
timeline_store.write().add_recording(result)?; let recording_id = match recording_id {
Some(id) => {
// Finalize the existing recording entry
if let Err(e) = timeline_store.write().finalize_recording(id, result) {
warn!("Failed to finalize recording: {}", e);
}
id
}
None => {
// Fallback: create new recording entry (legacy behavior)
timeline_store.write().add_recording(result)?
}
};
// Update metadata if we have it
let mut update = record_daemon::timeline::MetadataUpdate::default();
// Add pre-game metadata
if let Some(pregame) = pregame_metadata {
// Convert champion_id to name if available
if let Some(champion_id) = pregame.champion_id {
update.champion = record_daemon::lqp::champion_id_to_name(champion_id);
}
update.summoner_name = pregame.summoner_name;
update.queue_type = pregame.queue_type;
update.queue_id = pregame.queue_id;
update.game_mode = pregame.game_mode;
update.map_name = pregame.map_name;
update.team = pregame.team;
}
// Add game end metadata
if let Some(end_meta) = game_end_metadata {
update.match_id = end_meta.match_id;
update.victory = end_meta.victory;
update.final_stats = Some(record_daemon::timeline::GameFinalStats {
kills: end_meta.kills,
deaths: end_meta.deaths,
assists: end_meta.assists,
creep_score: end_meta.creep_score,
gold_earned: end_meta.gold_earned,
damage_dealt: end_meta.damage_dealt,
damage_taken: end_meta.damage_taken,
vision_score: end_meta.vision_score,
game_duration: end_meta.game_duration,
});
}
// Apply the update
if let Err(e) = timeline_store.write().update_metadata(recording_id, update) {
warn!("Failed to update recording metadata: {}", e);
}
} }
Ok(()) Ok(())

View File

@@ -434,6 +434,13 @@ impl ObsContext {
// Set up game capture source // Set up game capture source
self.setup_game_capture()?; self.setup_game_capture()?;
// Set up audio capture source
if self.audio_settings.enabled && self.audio_settings.capture_game {
info!("[START_REC] Setting up audio capture...");
self.setup_audio_capture()?;
info!("[START_REC] Audio capture set up successfully");
}
info!("[START_REC] Game capture set up, starting output..."); info!("[START_REC] Game capture set up, starting output...");
// Start the output - wrap in catch_unwind as this may crash in native code // Start the output - wrap in catch_unwind as this may crash in native code
@@ -494,8 +501,6 @@ impl ObsContext {
/// Set up capture source on Linux using screen capture. /// Set up capture source on Linux using screen capture.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn setup_game_capture(&mut self) -> Result<()> { fn setup_game_capture(&mut self) -> Result<()> {
use std::io::Write;
info!("[CAPTURE] Setting up screen capture for Linux..."); info!("[CAPTURE] Setting up screen capture for Linux...");
self.setup_linux_screen_capture() self.setup_linux_screen_capture()
@@ -796,7 +801,6 @@ impl ObsContext {
fn setup_linux_screen_capture(&mut self) -> Result<()> { fn setup_linux_screen_capture(&mut self) -> Result<()> {
use libobs_simple::sources::linux::LinuxGeneralScreenCaptureBuilder; use libobs_simple::sources::linux::LinuxGeneralScreenCaptureBuilder;
use libobs_simple::sources::ObsSourceBuilder; use libobs_simple::sources::ObsSourceBuilder;
use std::io::Write;
info!("[LINUX_CAPTURE] Setting up Linux screen capture..."); info!("[LINUX_CAPTURE] Setting up Linux screen capture...");
@@ -921,6 +925,160 @@ impl ObsContext {
Ok(()) Ok(())
} }
/// Set up audio capture for game audio (Windows only).
#[cfg(target_os = "windows")]
fn setup_audio_capture(&mut self) -> Result<()> {
use libobs_wrapper::data::ObsData;
use libobs_wrapper::utils::SourceInfo;
info!("[AUDIO_CAPTURE] Setting up WASAPI audio capture...");
let context = self.context.as_mut().ok_or_else(|| {
error!("[AUDIO_CAPTURE] OBS not initialized");
RecordingError::ObsInitError("OBS not initialized".to_string())
})?;
// Create audio source settings using JSON for reliable string handling
let settings_json = r#"{
"executable": "League of Legends.exe",
"title": "League of Legends (TM) Client",
"class": "RiotWindowClass",
"priority": 2
}"#;
let audio_settings_data = ObsData::from_json(settings_json, context.runtime().clone())
.map_err(|e| {
error!(
"[AUDIO_CAPTURE] Failed to create audio settings from JSON: {:?}",
e
);
RecordingError::StartError(format!(
"Failed to create audio settings from JSON: {:?}",
e
))
})?;
info!("[AUDIO_CAPTURE] Audio settings configured for League of Legends");
// Configure WASAPI process output capture for game audio
// This captures the audio output from a specific process (the game)
let source_info = SourceInfo::new(
"wasapi_process_output_capture",
"game_audio",
Some(audio_settings_data),
None,
);
info!("[AUDIO_CAPTURE] Creating WASAPI process output capture source...");
let audio_source_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
libobs_wrapper::sources::ObsSourceRef::new_from_info(
source_info,
context.runtime().clone(),
)
}));
let _audio_source = match audio_source_result {
Ok(Ok(s)) => {
info!("[AUDIO_CAPTURE] WASAPI audio source created");
s
}
Ok(Err(e)) => {
error!("[AUDIO_CAPTURE] Failed to create audio source: {:?}", e);
return Err(RecordingError::StartError(format!(
"Failed to create audio source: {:?}",
e
))
.into());
}
Err(panic_info) => {
error!(
"[AUDIO_CAPTURE] PANIC creating audio source: {:?}",
panic_info
);
eprintln!(
"[AUDIO_CAPTURE] PANIC creating audio source: {:?}",
panic_info
);
return Err(
RecordingError::StartError("Panic creating audio source".to_string()).into(),
);
}
};
info!("[AUDIO_CAPTURE] Audio source created successfully");
Ok(())
}
/// Set up audio capture for game audio (Linux only).
#[cfg(target_os = "linux")]
fn setup_audio_capture(&mut self) -> Result<()> {
use libobs_wrapper::utils::SourceInfo;
info!("[AUDIO_CAPTURE] Setting up PulseAudio capture...");
let context = self.context.as_mut().ok_or_else(|| {
error!("[AUDIO_CAPTURE] OBS not initialized");
RecordingError::ObsInitError("OBS not initialized".to_string())
})?;
// Create audio source settings
let audio_settings_data = context.data().map_err(|e| {
error!("[AUDIO_CAPTURE] Failed to create audio settings: {:?}", e);
RecordingError::StartError(format!("Failed to create audio settings: {:?}", e))
})?;
// Configure PulseAudio capture for game audio on Linux
let source_info = SourceInfo::new(
"pulse_input_capture",
"game_audio",
Some(audio_settings_data),
None,
);
info!("[AUDIO_CAPTURE] Creating PulseAudio capture source...");
let audio_source_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
libobs_wrapper::sources::ObsSourceRef::new_from_info(
source_info,
context.runtime().clone(),
)
}));
let _audio_source = match audio_source_result {
Ok(Ok(s)) => {
info!("[AUDIO_CAPTURE] PulseAudio source created");
s
}
Ok(Err(e)) => {
error!("[AUDIO_CAPTURE] Failed to create audio source: {:?}", e);
return Err(RecordingError::StartError(format!(
"Failed to create audio source: {:?}",
e
))
.into());
}
Err(panic_info) => {
error!(
"[AUDIO_CAPTURE] PANIC creating audio source: {:?}",
panic_info
);
eprintln!(
"[AUDIO_CAPTURE] PANIC creating audio source: {:?}",
panic_info
);
return Err(
RecordingError::StartError("Panic creating audio source".to_string()).into(),
);
}
};
info!("[AUDIO_CAPTURE] Audio source created successfully");
Ok(())
}
/// Stop recording. /// Stop recording.
pub fn stop_recording(&mut self) -> Result<()> { pub fn stop_recording(&mut self) -> Result<()> {
if !self.recording { if !self.recording {

View File

@@ -6,7 +6,7 @@ use parking_lot::RwLock;
use tracing::{info, trace, warn}; use tracing::{info, trace, warn};
use super::DaemonStatus; use super::DaemonStatus;
use crate::lqp::{GameEvent, GameflowPhase}; use crate::lqp::{GameEndInfo, GameEvent, GameflowPhase};
/// Internal daemon state. /// Internal daemon state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -46,9 +46,18 @@ pub enum StateTransition {
GameStarted { GameStarted {
game_id: u64, game_id: u64,
champion: Option<String>, champion: Option<String>,
queue_type: Option<String>,
queue_id: Option<u32>,
game_mode: Option<String>,
map_name: Option<String>,
team: Option<u32>,
summoner_name: Option<String>,
}, },
/// Game ended. /// Game ended.
GameEnded, GameEnded {
/// Game end info with stats from WebSocket.
game_end_info: Option<GameEndInfo>,
},
/// Error occurred. /// Error occurred.
Error(String), Error(String),
/// Error recovered. /// Error recovered.
@@ -136,11 +145,13 @@ impl DaemonStateMachine {
// Update related state // Update related state
match &transition { match &transition {
StateTransition::GameStarted { game_id, champion } => { StateTransition::GameStarted {
game_id, champion, ..
} => {
*self.current_game_id.write() = Some(*game_id); *self.current_game_id.write() = Some(*game_id);
*self.current_champion.write() = champion.clone(); *self.current_champion.write() = champion.clone();
} }
StateTransition::GameEnded => { StateTransition::GameEnded { .. } => {
*self.current_game_id.write() = None; *self.current_game_id.write() = None;
*self.current_champion.write() = None; *self.current_champion.write() = None;
} }
@@ -177,7 +188,9 @@ impl DaemonStateMachine {
(DaemonState::Monitoring, StateTransition::Shutdown) => Some(DaemonState::ShuttingDown), (DaemonState::Monitoring, StateTransition::Shutdown) => Some(DaemonState::ShuttingDown),
// From Recording // From Recording
(DaemonState::Recording, StateTransition::GameEnded) => Some(DaemonState::Monitoring), (DaemonState::Recording, StateTransition::GameEnded { .. }) => {
Some(DaemonState::Monitoring)
}
// Allow GameStarted from Recording (handles case where GameEnded wasn't received) // Allow GameStarted from Recording (handles case where GameEnded wasn't received)
(DaemonState::Recording, StateTransition::GameStarted { .. }) => { (DaemonState::Recording, StateTransition::GameStarted { .. }) => {
Some(DaemonState::Recording) Some(DaemonState::Recording)
@@ -214,12 +227,23 @@ impl DaemonStateMachine {
GameEvent::GameStart(info) => Some(StateTransition::GameStarted { GameEvent::GameStart(info) => Some(StateTransition::GameStarted {
game_id: info.game_id, game_id: info.game_id,
champion: info.champion.clone(), 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::GameEnd(info) => Some(StateTransition::GameEnded {
game_end_info: Some(info.clone()),
}), }),
GameEvent::GameEnd(_) => Some(StateTransition::GameEnded),
GameEvent::PhaseChange(info) => { GameEvent::PhaseChange(info) => {
// When phase changes to None while recording, the player left the game // Only trigger GameEnded on EndOfGame phase (stats are available by then)
if info.phase == "None" && self.is_recording() { // The actual GameEnd event with stats comes from /lol-end-of-game/v1/eog-stats-block
Some(StateTransition::GameEnded) if info.phase == "EndOfGame" && self.is_recording() {
Some(StateTransition::GameEnded {
game_end_info: None,
})
} else { } else {
None None
} }
@@ -267,6 +291,12 @@ mod tests {
let new_state = machine.transition(StateTransition::GameStarted { let new_state = machine.transition(StateTransition::GameStarted {
game_id: 12345, game_id: 12345,
champion: Some("Ahri".to_string()), champion: Some("Ahri".to_string()),
queue_type: None,
queue_id: None,
game_mode: None,
map_name: None,
team: None,
summoner_name: None,
}); });
assert_eq!(new_state, Some(DaemonState::Recording)); assert_eq!(new_state, Some(DaemonState::Recording));
@@ -282,6 +312,12 @@ mod tests {
let result = machine.transition(StateTransition::GameStarted { let result = machine.transition(StateTransition::GameStarted {
game_id: 12345, game_id: 12345,
champion: None, champion: None,
queue_type: None,
queue_id: None,
game_mode: None,
map_name: None,
team: None,
summoner_name: None,
}); });
assert_eq!(result, None); assert_eq!(result, None);

View File

@@ -4,7 +4,9 @@ mod mapper;
mod store; mod store;
pub use mapper::EventMapper; pub use mapper::EventMapper;
pub use store::{RecordingMetadata, TimelineStore, TimestampedEvent}; pub use store::{
GameFinalStats, MetadataUpdate, RecordingMetadata, TimelineStore, TimestampedEvent,
};
use chrono::{DateTime, Duration, Utc}; use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -25,6 +27,36 @@ pub struct Timeline {
pub duration_secs: i64, pub duration_secs: i64,
/// Events in the timeline. /// Events in the timeline.
pub events: Vec<TimestampedEvent>, pub events: Vec<TimestampedEvent>,
/// Champion played.
#[serde(default)]
pub champion: Option<String>,
/// Skin name.
#[serde(default)]
pub skin_name: Option<String>,
/// Queue type.
#[serde(default)]
pub queue_type: Option<String>,
/// Queue ID.
#[serde(default)]
pub queue_id: Option<u32>,
/// Game mode.
#[serde(default)]
pub game_mode: Option<String>,
/// Map name.
#[serde(default)]
pub map_name: Option<String>,
/// Summoner name.
#[serde(default)]
pub summoner_name: Option<String>,
/// Team (100 = blue, 200 = red).
#[serde(default)]
pub team: Option<u32>,
/// Whether the game was won.
#[serde(default)]
pub victory: Option<bool>,
/// Final player stats.
#[serde(default)]
pub final_stats: Option<GameFinalStats>,
} }
impl Timeline { impl Timeline {
@@ -43,6 +75,16 @@ impl Timeline {
end_time: None, end_time: None,
duration_secs: 0, duration_secs: 0,
events: Vec::new(), events: Vec::new(),
champion: None,
skin_name: None,
queue_type: None,
queue_id: None,
game_mode: None,
map_name: None,
summoner_name: None,
team: None,
victory: None,
final_stats: None,
} }
} }
@@ -138,10 +180,13 @@ fn format_timestamp(duration: Duration) -> String {
fn event_type_name(event: &GameEvent) -> String { fn event_type_name(event: &GameEvent) -> String {
match event { match event {
GameEvent::MatchFound(_) => "match_found", GameEvent::MatchFound(_) => "match_found",
GameEvent::ChampSelectStart(_) => "champ_select_start",
GameEvent::ChampionPick(_) => "champion_pick",
GameEvent::GameStart(_) => "game_start", GameEvent::GameStart(_) => "game_start",
GameEvent::Kill(_) => "kill", GameEvent::Kill(_) => "kill",
GameEvent::Death(_) => "death", GameEvent::Death(_) => "death",
GameEvent::Objective(_) => "objective", GameEvent::Objective(_) => "objective",
GameEvent::StatsUpdate(_) => "stats_update",
GameEvent::GameEnd(_) => "game_end", GameEvent::GameEnd(_) => "game_end",
GameEvent::PhaseChange(_) => "phase_change", GameEvent::PhaseChange(_) => "phase_change",
GameEvent::Unknown => "unknown", GameEvent::Unknown => "unknown",

View File

@@ -38,8 +38,28 @@ pub struct RecordingMetadata {
pub id: Uuid, pub id: Uuid,
/// Game ID if available. /// Game ID if available.
pub game_id: Option<u64>, pub game_id: Option<u64>,
/// Match ID if available.
pub match_id: Option<String>,
/// Champion played. /// Champion played.
pub champion: Option<String>, pub champion: Option<String>,
/// Skin name.
pub skin_name: Option<String>,
/// Queue type (ranked, normal, aram, etc.).
pub queue_type: Option<String>,
/// Queue ID.
pub queue_id: Option<u32>,
/// Game mode.
pub game_mode: Option<String>,
/// Map name.
pub map_name: Option<String>,
/// Player's summoner name.
pub summoner_name: 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>,
/// Recording start time. /// Recording start time.
pub start_time: DateTime<Utc>, pub start_time: DateTime<Utc>,
/// Recording end time. /// Recording end time.
@@ -48,7 +68,7 @@ pub struct RecordingMetadata {
// #[serde(with = "chrono::serde::seconds")] // #[serde(with = "chrono::serde::seconds")]
pub duration: Duration, pub duration: Duration,
/// Output file path. /// Output file path.
pub file_path: PathBuf, pub file_path: Option<PathBuf>,
/// File size in bytes. /// File size in bytes.
pub file_size: Option<u64>, pub file_size: Option<u64>,
/// Number of events. /// Number of events.
@@ -57,17 +77,66 @@ pub struct RecordingMetadata {
pub finalized: bool, pub finalized: bool,
} }
/// Final game statistics for the player.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GameFinalStats {
/// Kills.
pub kills: u32,
/// Deaths.
pub deaths: u32,
/// Assists.
pub assists: u32,
/// Creep score.
pub creep_score: u32,
/// Gold earned.
pub gold_earned: u32,
/// Damage dealt.
pub damage_dealt: u64,
/// Damage taken.
pub damage_taken: u64,
/// Vision score.
pub vision_score: f64,
/// Game duration in seconds.
pub game_duration: f64,
}
/// Update for recording metadata.
#[derive(Debug, Clone, Default)]
pub struct MetadataUpdate {
pub champion: Option<String>,
pub match_id: Option<String>,
pub skin_name: Option<String>,
pub queue_type: Option<String>,
pub queue_id: Option<u32>,
pub game_mode: Option<String>,
pub map_name: Option<String>,
pub summoner_name: Option<String>,
pub team: Option<u32>,
pub victory: Option<bool>,
pub final_stats: Option<GameFinalStats>,
}
impl RecordingMetadata { impl RecordingMetadata {
/// Create metadata from a recording result. /// Create metadata from a recording result.
pub fn from_result(result: &RecordingResult) -> Self { pub fn from_result(result: &RecordingResult) -> Self {
Self { Self {
id: Uuid::new_v4(), id: Uuid::new_v4(),
game_id: result.game_id, game_id: result.game_id,
match_id: None,
champion: result.champion.clone(), champion: result.champion.clone(),
skin_name: None,
queue_type: None,
queue_id: None,
game_mode: None,
map_name: None,
summoner_name: None,
team: None,
victory: None,
final_stats: None,
start_time: result.start_time, start_time: result.start_time,
end_time: Some(result.end_time), end_time: Some(result.end_time),
duration: result.duration, duration: result.duration,
file_path: result.path.clone(), file_path: Some(result.path.clone()),
file_size: result.file_size(), file_size: result.file_size(),
event_count: 0, event_count: 0,
finalized: false, finalized: false,
@@ -108,17 +177,84 @@ impl TimelineStore {
} }
} }
/// Add a new recording. /// Start a new recording entry (called when recording begins).
/// Returns the recording ID for tracking events during recording.
pub fn start_recording_entry(&self, game_id: Option<u64>, champion: Option<String>) -> Uuid {
let id = Uuid::new_v4();
let metadata = RecordingMetadata {
id,
game_id,
match_id: None,
champion,
skin_name: None,
queue_type: None,
queue_id: None,
game_mode: None,
map_name: None,
summoner_name: None,
team: None,
victory: None,
final_stats: None,
start_time: Utc::now(),
end_time: None,
duration: Duration::zero(),
file_path: None,
file_size: None,
event_count: 0,
finalized: false,
};
self.recordings.write().insert(id, metadata);
self.timelines.write().insert(id, Vec::new());
id
}
/// Finalize a recording with the recording result.
/// Called when recording stops.
pub fn finalize_recording(&self, recording_id: Uuid, result: RecordingResult) -> Result<()> {
let mut recordings = self.recordings.write();
let metadata = recordings
.get_mut(&recording_id)
.ok_or(TimelineError::RecordingNotFound(recording_id))?;
// Update with final recording data
metadata.start_time = result.start_time;
metadata.end_time = Some(result.end_time);
metadata.duration = result.duration;
metadata.file_path = Some(result.path.clone());
metadata.file_size = result.file_size();
metadata.finalized = true;
// Persist to disk
drop(recordings);
self.persist_recording(recording_id)?;
Ok(())
}
/// Add a new recording (legacy method for backwards compatibility).
pub fn add_recording(&self, result: RecordingResult) -> Result<Uuid> { pub fn add_recording(&self, result: RecordingResult) -> Result<Uuid> {
let id = Uuid::new_v4(); let id = Uuid::new_v4();
let metadata = RecordingMetadata { let metadata = RecordingMetadata {
id, id,
game_id: result.game_id, game_id: result.game_id,
match_id: None,
champion: result.champion.clone(), champion: result.champion.clone(),
skin_name: None,
queue_type: None,
queue_id: None,
game_mode: None,
map_name: None,
summoner_name: None,
team: None,
victory: None,
final_stats: None,
start_time: result.start_time, start_time: result.start_time,
end_time: Some(result.end_time), end_time: Some(result.end_time),
duration: result.duration, duration: result.duration,
file_path: result.path.clone(), file_path: Some(result.path.clone()),
file_size: result.file_size(), file_size: result.file_size(),
event_count: 0, event_count: 0,
finalized: true, finalized: true,
@@ -153,6 +289,49 @@ impl TimelineStore {
Ok(()) Ok(())
} }
/// Update metadata for a recording.
pub fn update_metadata(&self, recording_id: Uuid, update: MetadataUpdate) -> Result<()> {
let mut recordings = self.recordings.write();
if let Some(metadata) = recordings.get_mut(&recording_id) {
if let Some(champion) = update.champion {
metadata.champion = Some(champion);
}
if let Some(match_id) = update.match_id {
metadata.match_id = Some(match_id);
}
if let Some(skin_name) = update.skin_name {
metadata.skin_name = Some(skin_name);
}
if let Some(queue_type) = update.queue_type {
metadata.queue_type = Some(queue_type);
}
if let Some(queue_id) = update.queue_id {
metadata.queue_id = Some(queue_id);
}
if let Some(game_mode) = update.game_mode {
metadata.game_mode = Some(game_mode);
}
if let Some(map_name) = update.map_name {
metadata.map_name = Some(map_name);
}
if let Some(summoner_name) = update.summoner_name {
metadata.summoner_name = Some(summoner_name);
}
if let Some(team) = update.team {
metadata.team = Some(team);
}
if let Some(victory) = update.victory {
metadata.victory = Some(victory);
}
if let Some(final_stats) = update.final_stats {
metadata.final_stats = Some(final_stats);
}
}
drop(recordings);
self.persist_recording(recording_id)?;
Ok(())
}
/// Get all recordings. /// Get all recordings.
pub fn get_all_recordings(&self) -> Result<Vec<RecordingMetadata>> { pub fn get_all_recordings(&self) -> Result<Vec<RecordingMetadata>> {
let recordings = self.recordings.read(); let recordings = self.recordings.read();
@@ -184,6 +363,16 @@ impl TimelineStore {
end_time: metadata.end_time, end_time: metadata.end_time,
duration_secs: metadata.duration.num_seconds(), duration_secs: metadata.duration.num_seconds(),
events, events,
champion: metadata.champion.clone(),
skin_name: metadata.skin_name.clone(),
queue_type: metadata.queue_type.clone(),
queue_id: metadata.queue_id,
game_mode: metadata.game_mode.clone(),
map_name: metadata.map_name.clone(),
summoner_name: metadata.summoner_name.clone(),
team: metadata.team,
victory: metadata.victory,
final_stats: metadata.final_stats.clone(),
}) })
} }
@@ -220,6 +409,16 @@ impl TimelineStore {
end_time: metadata.end_time, end_time: metadata.end_time,
duration_secs: metadata.duration.num_seconds(), duration_secs: metadata.duration.num_seconds(),
events, events,
champion: metadata.champion,
skin_name: metadata.skin_name,
queue_type: metadata.queue_type,
queue_id: metadata.queue_id,
game_mode: metadata.game_mode,
map_name: metadata.map_name,
summoner_name: metadata.summoner_name,
team: metadata.team,
victory: metadata.victory,
final_stats: metadata.final_stats,
}; };
let file_path = self.storage_dir.join(format!("{}.json", id)); let file_path = self.storage_dir.join(format!("{}.json", id));
@@ -246,11 +445,21 @@ impl TimelineStore {
let metadata = RecordingMetadata { let metadata = RecordingMetadata {
id: timeline.recording_id, id: timeline.recording_id,
game_id: None, game_id: None,
match_id: None,
champion: None, champion: None,
skin_name: None,
queue_type: None,
queue_id: None,
game_mode: None,
map_name: None,
summoner_name: None,
team: None,
victory: None,
final_stats: None,
start_time: timeline.start_time, start_time: timeline.start_time,
end_time: timeline.end_time, end_time: timeline.end_time,
duration: timeline.duration(), duration: timeline.duration(),
file_path: PathBuf::new(), file_path: None,
file_size: None, file_size: None,
event_count: timeline.events.len(), event_count: timeline.events.len(),
finalized: true, finalized: true,