record raw events everywhere
Some checks are pending
record-daemon / Build, check and test (push) Waiting to run

This commit is contained in:
2026-05-06 23:53:01 +02:00
parent ff713da1e8
commit fcfa55d0aa
13 changed files with 848 additions and 2043 deletions

693
record-daemon/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,6 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::config::Settings;
use crate::lqp::GameEvent;
use crate::recording::RecordingResult;
use crate::state::DaemonStatus;
use crate::timeline::RecordingMetadata;
@@ -153,7 +152,10 @@ pub enum IpcNotification {
},
/// Game event received.
GameEvent { event: Box<GameEvent> },
GameEvent {
event_type: String,
raw_data: serde_json::Value,
},
/// Daemon status changed.
StatusChanged { status: DaemonStatus },

View File

@@ -11,7 +11,7 @@ use tracing::{debug, error, info, trace, warn};
use super::auth::LockfileCredentials;
use super::endpoints;
use super::events::GameEvent;
use super::events::EVENT_TYPE_GAME_END;
use super::state::{ClientState, GameflowPhase};
use super::tls::create_insecure_tls_config;
use super::websocket::{parse_live_client_event, parse_websocket_message, ParsedEvent};
@@ -186,23 +186,35 @@ impl LqpClient {
}
if let Some(parsed) = parse_websocket_message(&text) {
// Update state based on event
Self::update_state_from_event(&state, &parsed.event).await;
Self::update_state_from_event(&state, &parsed).await;
// Check for duplicate GameStart events
if let GameEvent::GameStart(ref info) = parsed.event {
if parsed.event_type == super::events::EVENT_TYPE_GAME_START {
let game_id = parsed
.raw_data
.get("gameId")
.or_else(|| {
parsed
.raw_data
.get("gameData")
.and_then(|gd| gd.get("gameId"))
})
.and_then(|v| v.as_u64())
.unwrap_or(0);
let mut last_game_id = last_emitted_game_id.write().await;
if *last_game_id == Some(info.game_id) {
if *last_game_id == Some(game_id) && game_id != 0 {
info!(
"Skipping duplicate GameStart event for game_id={}",
info.game_id
game_id
);
continue;
}
*last_game_id = Some(info.game_id);
*last_game_id = Some(game_id);
}
// Reset last_emitted_game_id on GameEnd to allow new game starts
if let GameEvent::GameEnd(_) = &parsed.event {
if parsed.event_type == EVENT_TYPE_GAME_END {
*last_emitted_game_id.write().await = None;
}
@@ -219,23 +231,35 @@ impl LqpClient {
if !text.is_empty() {
if let Some(parsed) = parse_websocket_message(&text) {
// Update state based on event
Self::update_state_from_event(&state, &parsed.event).await;
Self::update_state_from_event(&state, &parsed).await;
// Check for duplicate GameStart events
if let GameEvent::GameStart(ref info) = parsed.event {
if parsed.event_type == super::events::EVENT_TYPE_GAME_START {
let game_id = parsed
.raw_data
.get("gameId")
.or_else(|| {
parsed
.raw_data
.get("gameData")
.and_then(|gd| gd.get("gameId"))
})
.and_then(|v| v.as_u64())
.unwrap_or(0);
let mut last_game_id = last_emitted_game_id.write().await;
if *last_game_id == Some(info.game_id) {
if *last_game_id == Some(game_id) && game_id != 0 {
info!(
"Skipping duplicate GameStart event for game_id={}",
info.game_id
game_id
);
continue;
}
*last_game_id = Some(info.game_id);
*last_game_id = Some(game_id);
}
// Reset last_emitted_game_id on GameEnd to allow new game starts
if let GameEvent::GameEnd(_) = &parsed.event {
if parsed.event_type == EVENT_TYPE_GAME_END {
*last_emitted_game_id.write().await = None;
}
@@ -277,19 +301,36 @@ impl LqpClient {
Ok(())
}
/// Update internal state from a game event.
async fn update_state_from_event(state: &Arc<RwLock<ClientState>>, event: &GameEvent) {
/// Update internal state from a parsed event.
async fn update_state_from_event(state: &Arc<RwLock<ClientState>>, parsed: &ParsedEvent) {
let mut state = state.write().await;
match event {
GameEvent::GameStart(info) => {
match parsed.event_type.as_str() {
super::events::EVENT_TYPE_GAME_START => {
state.phase = GameflowPhase::InProgress;
state.game_id = Some(info.game_id);
state.champion = info.champion.clone();
state.game_id = parsed
.raw_data
.get("gameId")
.or_else(|| {
parsed
.raw_data
.get("gameData")
.and_then(|gd| gd.get("gameId"))
})
.and_then(|v| v.as_u64());
// Champion is not extracted here — raw session data has it
}
GameEvent::GameEnd(_) => {
super::events::EVENT_TYPE_GAME_END => {
state.phase = GameflowPhase::EndOfGame;
}
super::events::EVENT_TYPE_PHASE_CHANGE => {
let phase_str = parsed
.raw_data
.as_str()
.or_else(|| parsed.raw_data.get("phase").and_then(|v| v.as_str()))
.unwrap_or("");
state.phase = GameflowPhase::from(phase_str);
}
_ => {}
}
}
@@ -497,6 +538,7 @@ impl LqpClient {
Ok(events) => {
// The response has an "Events" key containing the array
let events_array = events.get("Events").and_then(|e| e.as_array());
if let Some(events_array) = events_array {
let event_count = events_array.len();
if event_count > 0 {
@@ -522,10 +564,12 @@ impl LqpClient {
// Parse and broadcast the event
if let Some(parsed) = parse_live_client_event(event) {
info!("Parsed live client event: {:?}", parsed.event);
info!(
"Parsed live client event: type={}",
parsed.event_type
);
// Update state based on event
Self::update_state_from_event(&state, &parsed.event)
.await;
Self::update_state_from_event(&state, &parsed).await;
// Broadcast event
if event_sender.send(parsed).is_err() {

View File

@@ -1,596 +1,141 @@
//! Game event types from the League Client API.
//! Event type constants and metadata structures for the League Client API.
//!
//! These events are received via WebSocket subscription to LQP endpoints.
//! The record-daemon stores raw API JSON data only — no typed event parsing.
//! Event types are simple string constants derived from the URI, used for
//! classification and state machine transitions. The tauri-app parses the
//! raw data it needs on the frontend side.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
/// A game event received from the League Client.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "eventType", rename_all = "camelCase")]
pub enum GameEvent {
/// Match found in queue.
#[serde(rename = "lcu-match-found")]
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.
#[serde(rename = "lcu-game-start")]
GameStart(Box<GameStartInfo>),
/// Player killed an enemy.
#[serde(rename = "lcu-kill")]
Kill(KillEvent),
/// Player died.
#[serde(rename = "lcu-death")]
Death(DeathEvent),
/// Objective was taken.
#[serde(rename = "lcu-objective")]
Objective(ObjectiveEvent),
/// In-game stats update.
#[serde(rename = "lcu-stats-update")]
StatsUpdate(InGameStats),
/// Game has ended.
#[serde(rename = "lcu-game-end")]
GameEnd(GameEndInfo),
/// Gameflow phase changed.
#[serde(rename = "lcu-phase-change")]
PhaseChange(PhaseChangeInfo),
/// LP changed after a ranked game.
#[serde(rename = "lcu-lp-change")]
LpChange(LpChangeInfo),
/// Unknown event type.
#[serde(other)]
Unknown,
}
/// Phase change event data.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PhaseChangeInfo {
/// The new phase.
pub phase: String,
/// Timestamp when phase changed.
#[serde(default = "Utc::now")]
pub timestamp: DateTime<Utc>,
}
/// LP change event data after a ranked game.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LpChangeInfo {
/// Queue type (RANKED_SOLO_5x5, RANKED_FLEX_SR, etc.).
pub queue_type: String,
/// LP change amount (positive for gain, negative for loss).
pub lp_change: i32,
/// LP before the game.
pub lp_before: i32,
/// LP after the game.
pub lp_after: i32,
/// Current tier (IRON, BRONZE, SILVER, GOLD, PLATINUM, DIAMOND, MASTER, GRANDMASTER, CHALLENGER).
pub tier: String,
/// Current division (I, II, III, IV).
#[serde(default)]
pub division: Option<String>,
/// Current league points.
#[serde(default)]
pub league_points: Option<i32>,
/// Whether the player is in a promotional series.
#[serde(default)]
pub in_promos: bool,
/// Promotional series progress (e.g., "W:2 L:1").
#[serde(default)]
pub promo_progress: Option<String>,
/// Number of wins in promo series.
#[serde(default)]
pub promo_wins: Option<u32>,
/// Number of losses in promo series.
#[serde(default)]
pub promo_losses: Option<u32>,
/// Total games played in this queue.
#[serde(default)]
pub total_games: Option<u32>,
/// Total wins in this queue.
#[serde(default)]
pub total_wins: Option<u32>,
/// Total losses in this queue.
#[serde(default)]
pub total_losses: Option<u32>,
/// Timestamp when LP change occurred.
#[serde(default = "Utc::now")]
pub timestamp: DateTime<Utc>,
}
/// Match found event data.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MatchInfo {
/// Match ID.
#[serde(default)]
pub match_id: Option<String>,
/// Queue type (ranked, normal, aram, etc.).
pub queue_type: String,
/// Queue ID (numeric identifier).
#[serde(default)]
pub queue_id: Option<u32>,
/// Map name.
pub map: String,
/// Game mode.
pub game_mode: String,
/// Timestamp when match was found.
#[serde(default = "Utc::now")]
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>,
}
// ============================================================================
// Event Type Constants
// ============================================================================
/// Match found in queue.
pub const EVENT_TYPE_MATCH_FOUND: &str = "match_found";
/// Champion select phase started.
pub const EVENT_TYPE_CHAMP_SELECT_START: &str = "champ_select_start";
/// Player picked a champion.
pub const EVENT_TYPE_CHAMPION_PICK: &str = "champion_pick";
/// Game has started.
pub const EVENT_TYPE_GAME_START: &str = "game_start";
/// Player killed an enemy.
pub const EVENT_TYPE_KILL: &str = "kill";
/// Player died.
pub const EVENT_TYPE_DEATH: &str = "death";
/// Objective was taken.
pub const EVENT_TYPE_OBJECTIVE: &str = "objective";
/// In-game stats update.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InGameStats {
/// Current kills.
pub kills: u32,
pub const EVENT_TYPE_STATS_UPDATE: &str = "stats_update";
/// Game has ended.
pub const EVENT_TYPE_GAME_END: &str = "game_end";
/// Gameflow phase changed.
pub const EVENT_TYPE_PHASE_CHANGE: &str = "phase_change";
/// LP changed after a ranked game.
pub const EVENT_TYPE_LP_CHANGE: &str = "lp_change";
/// Unknown / unclassified event.
pub const EVENT_TYPE_UNKNOWN: &str = "unknown";
/// 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.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GameStartInfo {
/// Game ID.
pub game_id: u64,
/// Server address.
#[serde(default)]
pub server: Option<String>,
/// Player's champion name.
#[serde(default)]
pub champion: Option<String>,
/// Player's summoner name.
#[serde(default)]
pub summoner_name: Option<String>,
/// Team (100 = blue, 200 = red).
#[serde(default)]
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.
#[serde(default = "Utc::now")]
pub timestamp: DateTime<Utc>,
}
/// Kill event data.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KillEvent {
/// Killer summoner name.
pub killer: String,
/// Killer champion name.
#[serde(default)]
pub killer_champion: Option<String>,
/// Victim summoner name.
pub victim: String,
/// Victim champion name.
#[serde(default)]
pub victim_champion: Option<String>,
/// Whether this was a solo kill.
#[serde(default)]
pub solo_kill: bool,
/// Number of assists.
#[serde(default)]
pub assists: u32,
/// Kill position on map.
#[serde(default)]
pub position: Option<Position>,
/// Game time when kill occurred.
#[serde(default)]
pub game_time: Option<f64>,
/// Real timestamp.
#[serde(default = "Utc::now")]
pub timestamp: DateTime<Utc>,
}
/// Death event data.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeathEvent {
/// Killer summoner name (if killed by champion).
#[serde(default)]
pub killer: Option<String>,
/// Killer champion name.
#[serde(default)]
pub killer_champion: Option<String>,
/// Death cause (champion, minion, tower, etc.).
pub cause: String,
/// Death position on map.
#[serde(default)]
pub position: Option<Position>,
/// Game time when death occurred.
#[serde(default)]
pub game_time: Option<f64>,
/// Real timestamp.
#[serde(default = "Utc::now")]
pub timestamp: DateTime<Utc>,
}
/// Objective event data.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ObjectiveEvent {
/// Type of objective.
pub objective_type: ObjectiveType,
/// Team that took the objective (100 = blue, 200 = red).
pub team: u32,
/// Whether the player participated.
#[serde(default)]
pub participated: bool,
/// Game time when objective was taken.
#[serde(default)]
pub game_time: Option<f64>,
/// Real timestamp.
#[serde(default = "Utc::now")]
pub timestamp: DateTime<Utc>,
}
/// Type of objective.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ObjectiveType {
Dragon,
Herald,
Baron,
Tower,
Inhibitor,
Nexus,
RiftHerald,
ElderDragon,
}
/// 2D position on the map.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct Position {
pub x: f32,
pub y: f32,
}
/// Game end event data.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GameEndInfo {
/// Game ID.
pub game_id: u64,
/// Whether the player's team won.
pub victory: bool,
/// Game duration in seconds.
pub duration: f64,
/// Player's stats.
#[serde(default)]
pub stats: Option<PlayerStats>,
/// End timestamp.
#[serde(default = "Utc::now")]
pub timestamp: DateTime<Utc>,
}
/// Player statistics at game end.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlayerStats {
/// Kills.
pub kills: u32,
/// Deaths.
pub deaths: u32,
/// Assists.
pub assists: u32,
/// Total gold earned.
pub gold_earned: u32,
/// Total damage dealt.
pub damage_dealt: u64,
/// Total damage taken.
pub damage_taken: u64,
/// Minions killed (CS).
pub minions_killed: u32,
/// Vision score.
#[serde(default)]
pub vision_score: f64,
}
/// Raw event data from LQP WebSocket.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RawEvent {
/// Event type URI.
#[serde(rename = "uri")]
pub uri: String,
/// Event data.
pub data: EventData,
}
/// Event data payload.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum EventData {
/// Game event.
GameEvent(Box<GameEvent>),
/// Raw JSON value.
Raw(serde_json::Value),
}
impl GameEvent {
/// Parse a game event from raw WebSocket data.
pub fn from_json(value: &serde_json::Value) -> Option<Self> {
match serde_json::from_value(value.clone()) {
Ok(event) => Some(event),
Err(e) => {
// Log the parsing error for debugging
tracing::warn!("Failed to parse game event: {}. Data: {:?}", e, value);
None
}
/// Generate a brief human-readable description from raw event data.
///
/// This is used for logging and timeline display. It extracts minimal
/// information from the raw JSON — no full parsing required.
pub fn describe_event(event_type: &str, raw_data: &serde_json::Value) -> String {
match event_type {
EVENT_TYPE_MATCH_FOUND => {
let queue = raw_data
.get("queueType")
.or_else(|| {
raw_data
.get("gameData")
.and_then(|gd| gd.get("queue"))
.and_then(|q| q.get("name"))
})
.and_then(|v| v.as_str())
.unwrap_or("Unknown");
format!("Match found: {}", queue)
}
}
/// Check if this event is relevant for recording.
pub fn is_relevant(&self) -> bool {
!matches!(self, GameEvent::Unknown)
}
/// Get a human-readable description of the event.
pub fn description(&self) -> String {
match self {
GameEvent::MatchFound(info) => {
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) => {
format!("Game started: ID {}", info.game_id)
}
GameEvent::Kill(kill) => {
format!("{} killed {}", kill.killer, kill.victim)
}
GameEvent::Death(death) => {
format!("Player died ({})", death.cause)
}
GameEvent::Objective(obj) => {
let team = if obj.team == 100 { "Blue" } else { "Red" };
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) => {
let result = if end.victory { "Victory" } else { "Defeat" };
format!("Game ended: {} ({:.1}s)", result, end.duration)
}
GameEvent::PhaseChange(info) => {
format!("Phase changed to: {}", info.phase)
}
GameEvent::LpChange(lp) => {
let sign = if lp.lp_change >= 0 { "+" } else { "" };
format!(
"LP Change: {}{} LP ({} {} {} -> {} LP)",
sign,
lp.lp_change,
lp.tier,
lp.division.as_deref().unwrap_or(""),
lp.queue_type,
lp.lp_after
)
}
GameEvent::Unknown => "Unknown event".to_string(),
EVENT_TYPE_CHAMP_SELECT_START => "Champion select started".to_string(),
EVENT_TYPE_CHAMPION_PICK => {
let champ = raw_data
.get("championName")
.or_else(|| raw_data.get("championName"))
.and_then(|v| v.as_str())
.unwrap_or("Unknown");
format!("Champion picked: {}", champ)
}
}
/// 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::LpChange(_) => "lp_change",
GameEvent::Unknown => "unknown",
EVENT_TYPE_GAME_START => {
let game_id = raw_data
.get("gameId")
.or_else(|| raw_data.get("gameData").and_then(|gd| gd.get("gameId")))
.and_then(|v| v.as_u64())
.unwrap_or(0);
format!("Game started: ID {}", game_id)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_kill_event() {
let json = serde_json::json!({
"eventType": "lcu-kill",
"killer": "Player1",
"killerChampion": "Ahri",
"victim": "Player2",
"victimChampion": "Lux",
"soloKill": true,
"assists": 0
});
let event: GameEvent = serde_json::from_value(json).unwrap();
if let GameEvent::Kill(kill) = event {
assert_eq!(kill.killer, "Player1");
assert!(kill.solo_kill);
} else {
panic!("Expected Kill event");
EVENT_TYPE_KILL => {
let killer = raw_data
.get("KillerName")
.or_else(|| raw_data.get("killer"))
.and_then(|v| v.as_str())
.unwrap_or("Unknown");
let victim = raw_data
.get("VictimName")
.or_else(|| raw_data.get("victim"))
.and_then(|v| v.as_str())
.unwrap_or("Unknown");
format!("{} killed {}", killer, victim)
}
}
#[test]
fn test_objective_type_deserialization() {
let json = serde_json::json!("dragon");
let obj: ObjectiveType = serde_json::from_value(json).unwrap();
assert_eq!(obj, ObjectiveType::Dragon);
EVENT_TYPE_DEATH => {
let cause = raw_data
.get("DeathCause")
.or_else(|| raw_data.get("cause"))
.and_then(|v| v.as_str())
.unwrap_or("Unknown");
format!("Player died ({})", cause)
}
EVENT_TYPE_OBJECTIVE => {
let obj_type = raw_data
.get("EventName")
.or_else(|| raw_data.get("objectiveType"))
.and_then(|v| v.as_str())
.unwrap_or("objective");
let team = raw_data
.get("Team")
.or_else(|| raw_data.get("team"))
.and_then(|v| v.as_u64())
.map(|t| if t == 100 { "Blue" } else { "Red" })
.unwrap_or("Unknown");
format!("{} took {}", team, obj_type)
}
EVENT_TYPE_STATS_UPDATE => "Stats update".to_string(),
EVENT_TYPE_GAME_END => {
let victory = raw_data
.get("victory")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let result = if victory { "Victory" } else { "Defeat" };
format!("Game ended: {}", result)
}
EVENT_TYPE_PHASE_CHANGE => {
let phase = raw_data
.as_str()
.or_else(|| raw_data.get("phase").and_then(|v| v.as_str()))
.unwrap_or("Unknown");
format!("Phase changed to: {}", phase)
}
EVENT_TYPE_LP_CHANGE => {
let lp_change = raw_data
.get("lpChange")
.and_then(|v| v.as_i64())
.unwrap_or(0);
let tier = raw_data
.get("tier")
.and_then(|v| v.as_str())
.unwrap_or("UNRANKED");
let sign = if lp_change >= 0 { "+" } else { "" };
format!("LP Change: {}{} LP ({})", sign, lp_change, tier)
}
_ => "Unknown event".to_string(),
}
}
@@ -868,3 +413,46 @@ impl GameflowSession {
self.queue.as_ref().map(|q| q.id)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_event_type_constants() {
assert_eq!(EVENT_TYPE_GAME_START, "game_start");
assert_eq!(EVENT_TYPE_LP_CHANGE, "lp_change");
assert_eq!(EVENT_TYPE_KILL, "kill");
}
#[test]
fn test_describe_event_lp_change() {
let raw = serde_json::json!({
"lpChange": 22,
"lpAfter": 75,
"tier": "GOLD",
"queueType": "RANKED_SOLO_5x5"
});
let desc = describe_event(EVENT_TYPE_LP_CHANGE, &raw);
assert!(desc.contains("+22"));
assert!(desc.contains("GOLD"));
}
#[test]
fn test_describe_event_kill() {
let raw = serde_json::json!({
"KillerName": "Player1",
"VictimName": "Player2"
});
let desc = describe_event(EVENT_TYPE_KILL, &raw);
assert_eq!(desc, "Player1 killed Player2");
}
#[test]
fn test_describe_event_phase_change() {
// Phase change raw_data is just the phase string
let raw = serde_json::json!("InProgress");
let desc = describe_event(EVENT_TYPE_PHASE_CHANGE, &raw);
assert_eq!(desc, "Phase changed to: InProgress");
}
}

View File

@@ -20,12 +20,13 @@ pub use endpoints::{
MATCH_HISTORY, RUNE_PAGES, SESSION, SUBSCRIBE_ENDPOINTS, SUMMONER,
};
pub use events::{
ChampSelectStartInfo, ChampionPickInfo, DeathEvent, EventData, GameEndInfo, GameEvent,
GameStartInfo, GameflowSession, InGameStats, ItemBuild, ItemInfo, KillEvent, MatchInfo,
ObjectiveEvent, ObjectiveType, PlayerChampionSelection, PlayerGameMetadata, PlayerIdentity,
QueueInfo, RunePage, RuneSlot, SummonerSpells, TeamMember,
describe_event, GameflowSession, ItemBuild, ItemInfo, PlayerChampionSelection,
PlayerGameMetadata, PlayerIdentity, QueueInfo, RunePage, RuneSlot, SummonerSpells, TeamMember,
EVENT_TYPE_CHAMPION_PICK, EVENT_TYPE_CHAMP_SELECT_START, EVENT_TYPE_DEATH, EVENT_TYPE_GAME_END,
EVENT_TYPE_GAME_START, EVENT_TYPE_KILL, EVENT_TYPE_LP_CHANGE, EVENT_TYPE_MATCH_FOUND,
EVENT_TYPE_OBJECTIVE, EVENT_TYPE_PHASE_CHANGE, EVENT_TYPE_STATS_UPDATE, EVENT_TYPE_UNKNOWN,
};
pub use state::{ClientState, GameflowPhase};
pub use websocket::{
parse_event_from_uri, parse_live_client_event, parse_websocket_message, ParsedEvent,
classify_event_from_uri, parse_live_client_event, parse_websocket_message, ParsedEvent,
};

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,10 @@ use record_daemon::{
config::{self, Settings},
error::Result,
ipc::{self, IpcHandlers, IpcServer, IpcServerConfig},
lqp::{GameEvent, LockfileWatcher, LqpClient},
lqp::{
describe_event, LockfileWatcher, LqpClient, EVENT_TYPE_CHAMPION_PICK,
EVENT_TYPE_GAME_START, EVENT_TYPE_PHASE_CHANGE,
},
recording::RecordingEngine,
state::{DaemonStateMachine, DaemonStatus, StateTransition},
timeline::{EventMapper, TimelineStore, TimestampedEvent},
@@ -226,25 +229,44 @@ impl Daemon {
/// Handle a game event.
async fn handle_game_event(&self, parsed: record_daemon::lqp::ParsedEvent) -> Result<()> {
let event = &parsed.event;
info!("[EVENT_HANDLER] Game event received: {:?}", event);
let event_type = &parsed.event_type;
let raw_data = &parsed.raw_data;
let description = describe_event(event_type, raw_data);
info!(
"[EVENT_HANDLER] Game event received: type={}, desc={}",
event_type, description
);
// Handle pre-game data collection
match event {
GameEvent::PhaseChange(info) if info.phase == "ChampSelect" => {
info!("[EVENT_HANDLER] Champion select started");
match event_type.as_str() {
EVENT_TYPE_PHASE_CHANGE => {
let phase = raw_data
.as_str()
.or_else(|| raw_data.get("phase").and_then(|v| v.as_str()))
.unwrap_or("");
if phase == "ChampSelect" {
info!("[EVENT_HANDLER] Champion select started");
}
}
GameEvent::ChampionPick(pick) if pick.is_local_player => {
info!(
"[EVENT_HANDLER] Local player picked champion: {}",
pick.champion_name
);
EVENT_TYPE_CHAMPION_PICK => {
let is_local = raw_data
.get("isLocalPlayer")
.or_else(|| raw_data.get("is_local_player"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
if is_local {
let champion = raw_data
.get("championName")
.or_else(|| raw_data.get("champion_name"))
.and_then(|v| v.as_str())
.unwrap_or("unknown");
info!("[EVENT_HANDLER] Local player picked champion: {}", champion);
}
}
GameEvent::GameStart(info) => {
info!(
"[EVENT_HANDLER] Game started with metadata: queue={:?}, mode={:?}, map={:?}",
info.queue_type, info.game_mode, info.map_name
);
EVENT_TYPE_GAME_START => {
let game_id = raw_data.get("gameId").and_then(|v| v.as_u64()).unwrap_or(0);
info!("[EVENT_HANDLER] Game started with game_id: {}", game_id);
}
_ => {}
}
@@ -252,7 +274,7 @@ impl Daemon {
// 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) {
if let Some((video_ts, game_ts)) = self.event_mapper.write().handle_event(event_type) {
// Get the current recording ID
if let Some(recording_id) = *self.current_recording_id.read() {
// Create a timestamped event with raw data
@@ -260,11 +282,10 @@ impl Daemon {
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(),
raw_data: Some(parsed.raw_data.clone()),
uri: Some(parsed.uri.clone()),
event_type: event_type.clone(),
description: description.clone(),
raw_data: parsed.raw_data.clone(),
uri: parsed.uri.clone(),
};
// Add the event to the timeline store
@@ -277,9 +298,7 @@ impl Daemon {
} else {
debug!(
"Event added to timeline: video_ts={:?}, game_ts={:?}, type={}",
video_ts,
game_ts,
event.event_type_name()
video_ts, game_ts, event_type
);
}
} else {
@@ -289,7 +308,7 @@ impl Daemon {
}
// Process state transitions
if let Some(transition) = self.state_machine.process_event(event) {
if let Some(transition) = self.state_machine.process_event(event_type, raw_data) {
info!("[EVENT_HANDLER] State transition: {:?}", transition);
// Only process the transition if it's valid
@@ -297,12 +316,8 @@ impl Daemon {
// Handle recording start/stop
match transition {
StateTransition::GameStarted => {
// Extract game_id from the GameStart event
let game_id = if let GameEvent::GameStart(ref info) = event {
info.game_id
} else {
0
};
// Extract game_id from raw_data
let game_id = raw_data.get("gameId").and_then(|v| v.as_u64()).unwrap_or(0);
info!(
"[EVENT_HANDLER] GameStarted transition - game_id: {}",

View File

@@ -9,7 +9,7 @@ use parking_lot::RwLock;
use tracing::{info, trace, warn};
use super::DaemonStatus;
use crate::lqp::{GameEvent, GameflowPhase};
use crate::lqp::GameflowPhase;
/// Internal daemon state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -203,22 +203,29 @@ impl DaemonStateMachine {
/// Process a game event and potentially trigger a transition.
///
/// Only returns state transitions
/// All data is stored in events in the timeline and processed at game end.
pub fn process_event(&self, event: &GameEvent) -> Option<StateTransition> {
/// Uses event_type string and raw_data instead of typed GameEvent.
/// Only returns state transitions — all data is stored in raw events.
pub fn process_event(
&self,
event_type: &str,
raw_data: &serde_json::Value,
) -> Option<StateTransition> {
trace!(
"Processing event in state {:?}: {:?}",
"Processing event in state {:?}: type={}",
self.current_state(),
event
event_type
);
match event {
GameEvent::GameStart(_) => Some(StateTransition::GameStarted),
GameEvent::GameEnd(_) => Some(StateTransition::GameEnded),
GameEvent::PhaseChange(info) => {
match event_type {
crate::lqp::EVENT_TYPE_GAME_START => Some(StateTransition::GameStarted),
crate::lqp::EVENT_TYPE_GAME_END => Some(StateTransition::GameEnded),
crate::lqp::EVENT_TYPE_PHASE_CHANGE => {
// Only trigger GameEnded on EndOfGame phase (stats are available by then)
// The actual GameEnd event with stats comes from /lol-end-of-game/v1/eog-stats-block
if info.phase == "EndOfGame" && self.is_recording() {
let phase = raw_data
.as_str()
.or_else(|| raw_data.get("phase").and_then(|v| v.as_str()))
.unwrap_or("");
if phase == "EndOfGame" && self.is_recording() {
Some(StateTransition::GameEnded)
} else {
None
@@ -294,4 +301,26 @@ mod tests {
assert_eq!(machine.current_state(), DaemonState::Idle);
assert_eq!(machine.last_error(), None);
}
#[test]
fn test_process_event_game_start() {
let machine = DaemonStateMachine::new();
machine.transition(StateTransition::ClientStarted);
let raw = serde_json::json!({"gameId": 12345});
let transition = machine.process_event(crate::lqp::EVENT_TYPE_GAME_START, &raw);
assert!(matches!(transition, Some(StateTransition::GameStarted)));
}
#[test]
fn test_process_event_phase_change_end_of_game() {
let machine = DaemonStateMachine::new();
machine.transition(StateTransition::ClientStarted);
machine.transition(StateTransition::GameStarted);
// Phase change to EndOfGame while recording should trigger GameEnded
let raw = serde_json::json!("EndOfGame");
let transition = machine.process_event(crate::lqp::EVENT_TYPE_PHASE_CHANGE, &raw);
assert!(matches!(transition, Some(StateTransition::GameEnded)));
}
}

View File

@@ -3,8 +3,6 @@
use chrono::{DateTime, Duration, Utc};
use tracing::debug;
use crate::lqp::GameEvent;
/// Event mapper that tracks recording start time and maps events to video timestamps.
pub struct EventMapper {
/// Recording start time.
@@ -45,8 +43,8 @@ impl EventMapper {
self.start_time.is_some()
}
/// Map a game event to video and game timestamps.
pub fn map_event(&self, _event: &GameEvent) -> Option<(Duration, Option<Duration>)> {
/// Map an event to video and game timestamps.
pub fn map_event(&self) -> Option<(Duration, Option<Duration>)> {
let start_time = self.start_time?;
let now = Utc::now();
@@ -60,25 +58,23 @@ impl EventMapper {
self.synchronizer.adjust_game_timestamp(raw_ts)
});
// Update game start time if this is a game start event
// (handled separately in handle_event)
Some((video_timestamp, game_timestamp))
}
/// Handle a game event and return mapped timestamps.
pub fn handle_event(&mut self, event: &GameEvent) -> Option<(Duration, Option<Duration>)> {
/// event_type is used to detect game start for timestamp tracking.
pub fn handle_event(&mut self, event_type: &str) -> Option<(Duration, Option<Duration>)> {
if !self.is_active() {
return None;
}
// Track game start time
if let GameEvent::GameStart(_) = event {
if event_type == crate::lqp::EVENT_TYPE_GAME_START {
self.game_start_time = Some(Utc::now());
debug!("Game start time recorded: {:?}", self.game_start_time);
}
let result = self.map_event(event);
let result = self.map_event();
// Add sync point if we have both timestamps
if let Some((video_ts, Some(game_ts))) = result {

View File

@@ -10,8 +10,6 @@ use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::lqp::GameEvent;
/// A timeline of events for a recording.
/// Stores raw API responses for maximum flexibility and future-proofing.
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -79,31 +77,11 @@ impl Timeline {
}
}
/// Add an event to the timeline.
/// Add an event to the timeline with raw data.
pub fn add_event(
&mut self,
event: GameEvent,
video_timestamp: Duration,
game_timestamp: Option<Duration>,
) {
let timestamped = TimestampedEvent {
video_timestamp,
game_timestamp,
timestamp: Utc::now(),
event_type: event_type_name(&event),
description: event.description(),
event,
raw_data: None,
uri: None,
};
self.events.push(timestamped);
}
/// Add an event to the timeline with raw data.
pub fn add_event_with_raw(
&mut self,
event: GameEvent,
event_type: &str,
description: &str,
video_timestamp: Duration,
game_timestamp: Option<Duration>,
raw_data: serde_json::Value,
@@ -113,11 +91,10 @@ impl Timeline {
video_timestamp,
game_timestamp,
timestamp: Utc::now(),
event_type: event_type_name(&event),
description: event.description(),
event,
raw_data: Some(raw_data),
uri: Some(uri),
event_type: event_type.to_string(),
description: description.to_string(),
raw_data,
uri,
};
self.events.push(timestamped);
@@ -192,29 +169,9 @@ fn format_timestamp(duration: Duration) -> String {
format!("{:02}:{:02}:{:02}.{:03}", hours, minutes, seconds, millis)
}
/// Get the event type name.
fn event_type_name(event: &GameEvent) -> String {
match event {
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::LpChange(_) => "lp_change",
GameEvent::Unknown => "unknown",
}
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lqp::KillEvent;
#[test]
fn test_timeline_creation() {
@@ -230,21 +187,22 @@ mod tests {
let id = Uuid::new_v4();
let mut timeline = Timeline::new(id);
let event = GameEvent::Kill(KillEvent {
killer: "Player1".to_string(),
killer_champion: Some("Ahri".to_string()),
victim: "Player2".to_string(),
victim_champion: Some("Lux".to_string()),
solo_kill: true,
assists: 0,
position: None,
game_time: Some(120.0),
timestamp: Utc::now(),
let raw_data = serde_json::json!({
"killer": "Player1",
"victim": "Player2"
});
timeline.add_event(event, Duration::seconds(5), Some(Duration::seconds(120)));
timeline.add_event(
"kill",
"Player1 killed Player2",
Duration::seconds(5),
Some(Duration::seconds(120)),
raw_data,
"/lol-game-events/v1/events".to_string(),
);
assert_eq!(timeline.event_count(), 1);
assert_eq!(timeline.events[0].event_type, "kill");
}
#[test]

View File

@@ -9,32 +9,30 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::{Result, TimelineError};
use crate::lqp::GameEvent;
use crate::recording::RecordingResult;
/// A timestamped event in the timeline.
/// Stores raw API data only — no typed event parsing.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimestampedEvent {
/// Video timestamp (offset from recording start).
// #[serde(with = "chrono::serde::seconds")]
pub video_timestamp: Duration,
/// Game timestamp (in-game time).
// #[serde(with = "chrono::serde::seconds_option")]
pub game_timestamp: Option<Duration>,
/// Real-world timestamp.
pub timestamp: DateTime<Utc>,
/// Event type name (derived from URI).
/// Event type string derived from URI (e.g. "game_start", "lp_change", "kill").
pub event_type: String,
/// Human-readable description.
pub description: String,
/// The actual event data.
pub event: GameEvent,
/// Raw JSON data from the API (for flexibility).
/// Human-readable description for logging.
#[serde(default)]
pub raw_data: Option<serde_json::Value>,
pub description: String,
/// The raw JSON data from the API — the single source of truth.
/// The tauri-app parses the values it needs from this data.
#[serde(default)]
pub raw_data: serde_json::Value,
/// URI of the endpoint that triggered this event.
#[serde(default)]
pub uri: Option<String>,
pub uri: String,
}
/// Metadata for a recording.