record-daemon: add game start and end metadata
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
+376 -63
View File
@@ -16,7 +16,7 @@ use record_daemon::{
lqp::{GameEvent, LockfileWatcher, LqpClient},
recording::RecordingEngine,
state::{DaemonStateMachine, DaemonStatus, StateTransition},
timeline::{EventMapper, TimelineStore},
timeline::{EventMapper, TimelineStore, TimestampedEvent},
};
/// Record Daemon - League of Legends recording daemon.
@@ -54,6 +54,10 @@ struct Daemon {
timeline_store: Arc<RwLock<TimelineStore>>,
/// Event mapper.
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: Option<IpcServer>,
/// Shutdown signal.
@@ -72,6 +76,8 @@ impl Daemon {
recording_engine: Arc::new(RwLock::new(None)),
timeline_store: Arc::new(RwLock::new(TimelineStore::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,
shutdown_tx,
}
@@ -223,80 +229,300 @@ impl Daemon {
async fn handle_game_event(&self, event: GameEvent) -> Result<()> {
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
if let Some(transition) = self.state_machine.process_event(&event) {
info!("[EVENT_HANDLER] State transition: {:?}", transition);
self.state_machine.transition(transition.clone());
// Handle recording start/stop
match transition {
StateTransition::GameStarted { game_id, champion } => {
info!(
"[EVENT_HANDLER] GameStarted transition - game_id: {}, champion: {:?}",
game_id, champion
// Only process the transition if it's valid
if let Some(_new_state) = self.state_machine.transition(transition.clone()) {
// Handle recording start/stop
match transition {
StateTransition::GameStarted {
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 self.state_machine.is_recording() {
info!(
// If already recording, stop the current recording first
if self.state_machine.is_recording() {
info!(
"[EVENT_HANDLER] Stopping previous recording before starting new one"
);
if let Err(e) = self.stop_recording().await {
warn!("[EVENT_HANDLER] Failed to stop previous recording: {}", e);
if let Err(e) = self.stop_recording().await {
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");
}
}
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
StateTransition::GameEnded { game_end_info } => {
info!(
"[EVENT_HANDLER] GameEnded transition with info: {:?}",
game_end_info
);
eprintln!(
"[EVENT_HANDLER] PANIC before start_recording: {:?}",
panic_info
// Convert GameEndInfo to GameEndMetadata if available
let game_end_metadata =
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 {
error!("[EVENT_HANDLER] Failed to start recording: {}", e);
if let Err(e) = self.stop_recording_with_metadata(game_end_metadata).await {
error!("[EVENT_HANDLER] Failed to stop recording: {}", e);
// Don't propagate error - keep daemon running
} else {
info!("[EVENT_HANDLER] start_recording completed successfully");
// Don't propagate error - keep daemon running
}
// Clear pre-game metadata
*self.pregame_metadata.write() = None;
}
_ => {}
}
StateTransition::GameEnded => {
info!("[EVENT_HANDLER] GameEnded transition");
if let Err(e) = self.stop_recording().await {
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
} else {
warn!(
"[EVENT_HANDLER] State transition rejected: {:?}",
transition
);
}
}
@@ -306,13 +532,37 @@ impl Daemon {
Ok(())
}
/// Start recording.
async fn start_recording(&self, game_id: u64, champion: Option<&str>) -> Result<()> {
/// Start recording with game metadata.
async fn start_recording_with_metadata(
&self,
game_id: u64,
champion: Option<&str>,
metadata_update: record_daemon::timeline::MetadataUpdate,
) -> Result<()> {
info!(
"Daemon::start_recording called - game {} ({:?})",
"Daemon::start_recording_with_metadata called - game {} ({:?})",
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
let recording_engine = self.recording_engine.clone();
let event_mapper = self.event_mapper.clone();
@@ -334,7 +584,7 @@ impl Daemon {
warn!("Recording engine is None!");
}
info!("Daemon::start_recording completed successfully");
info!("Daemon::start_recording_with_metadata completed successfully");
Ok(())
})
.await
@@ -348,12 +598,24 @@ impl Daemon {
/// Stop recording.
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");
// 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
let recording_engine = self.recording_engine.clone();
let event_mapper = self.event_mapper.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
tokio::task::spawn_blocking(move || {
@@ -362,8 +624,59 @@ impl Daemon {
let result = engine.stop_recording()?;
event_mapper.write().stop();
// Save to timeline
timeline_store.write().add_recording(result)?;
// Use the existing recording ID if available, otherwise create new
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(())