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

View File

@@ -11,7 +11,7 @@ use tracing::{debug, error, info, trace, warn};
use super::auth::LockfileCredentials; use super::auth::LockfileCredentials;
use super::endpoints; use super::endpoints;
use super::events::GameEvent; use super::events::EVENT_TYPE_GAME_END;
use super::state::{ClientState, GameflowPhase}; use super::state::{ClientState, GameflowPhase};
use super::tls::create_insecure_tls_config; use super::tls::create_insecure_tls_config;
use super::websocket::{parse_live_client_event, parse_websocket_message, ParsedEvent}; 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) { if let Some(parsed) = parse_websocket_message(&text) {
// Update state based on event // 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 // 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; 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!( info!(
"Skipping duplicate GameStart event for game_id={}", "Skipping duplicate GameStart event for game_id={}",
info.game_id game_id
); );
continue; 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 // 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; *last_emitted_game_id.write().await = None;
} }
@@ -219,23 +231,35 @@ impl LqpClient {
if !text.is_empty() { if !text.is_empty() {
if let Some(parsed) = parse_websocket_message(&text) { if let Some(parsed) = parse_websocket_message(&text) {
// Update state based on event // 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 // 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; 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!( info!(
"Skipping duplicate GameStart event for game_id={}", "Skipping duplicate GameStart event for game_id={}",
info.game_id game_id
); );
continue; 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 // 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; *last_emitted_game_id.write().await = None;
} }
@@ -277,19 +301,36 @@ impl LqpClient {
Ok(()) Ok(())
} }
/// Update internal state from a game event. /// Update internal state from a parsed event.
async fn update_state_from_event(state: &Arc<RwLock<ClientState>>, event: &GameEvent) { async fn update_state_from_event(state: &Arc<RwLock<ClientState>>, parsed: &ParsedEvent) {
let mut state = state.write().await; let mut state = state.write().await;
match event { match parsed.event_type.as_str() {
GameEvent::GameStart(info) => { super::events::EVENT_TYPE_GAME_START => {
state.phase = GameflowPhase::InProgress; state.phase = GameflowPhase::InProgress;
state.game_id = Some(info.game_id); state.game_id = parsed
state.champion = info.champion.clone(); .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; 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) => { Ok(events) => {
// The response has an "Events" key containing the array // The response has an "Events" key containing the array
let events_array = events.get("Events").and_then(|e| e.as_array()); let events_array = events.get("Events").and_then(|e| e.as_array());
if let Some(events_array) = events_array { if let Some(events_array) = events_array {
let event_count = events_array.len(); let event_count = events_array.len();
if event_count > 0 { if event_count > 0 {
@@ -522,10 +564,12 @@ impl LqpClient {
// Parse and broadcast the event // Parse and broadcast the event
if let Some(parsed) = parse_live_client_event(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 // Update state based on event
Self::update_state_from_event(&state, &parsed.event) Self::update_state_from_event(&state, &parsed).await;
.await;
// Broadcast event // Broadcast event
if event_sender.send(parsed).is_err() { 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}; use serde::{Deserialize, Serialize};
/// A game event received from the League Client. // ============================================================================
#[derive(Debug, Clone, Serialize, Deserialize)] // Event Type Constants
#[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>,
}
/// 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. /// In-game stats update.
#[derive(Debug, Clone, Serialize, Deserialize)] pub const EVENT_TYPE_STATS_UPDATE: &str = "stats_update";
#[serde(rename_all = "camelCase")] /// Game has ended.
pub struct InGameStats { pub const EVENT_TYPE_GAME_END: &str = "game_end";
/// Current kills. /// Gameflow phase changed.
pub kills: u32, 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. /// Generate a brief human-readable description from raw event data.
pub deaths: u32, ///
/// This is used for logging and timeline display. It extracts minimal
/// Current assists. /// information from the raw JSON — no full parsing required.
pub assists: u32, pub fn describe_event(event_type: &str, raw_data: &serde_json::Value) -> String {
match event_type {
/// Current creep score. EVENT_TYPE_MATCH_FOUND => {
#[serde(default)] let queue = raw_data
pub creep_score: u32, .get("queueType")
.or_else(|| {
/// Current gold. raw_data
#[serde(default)] .get("gameData")
pub gold: u32, .and_then(|gd| gd.get("queue"))
.and_then(|q| q.get("name"))
/// Current level. })
#[serde(default)] .and_then(|v| v.as_str())
pub level: u32, .unwrap_or("Unknown");
format!("Match found: {}", queue)
/// 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
}
} }
} EVENT_TYPE_CHAMP_SELECT_START => "Champion select started".to_string(),
EVENT_TYPE_CHAMPION_PICK => {
/// Check if this event is relevant for recording. let champ = raw_data
pub fn is_relevant(&self) -> bool { .get("championName")
!matches!(self, GameEvent::Unknown) .or_else(|| raw_data.get("championName"))
} .and_then(|v| v.as_str())
.unwrap_or("Unknown");
/// Get a human-readable description of the event. format!("Champion picked: {}", champ)
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_GAME_START => {
let game_id = raw_data
/// Get the event type name for categorization. .get("gameId")
pub fn event_type_name(&self) -> &'static str { .or_else(|| raw_data.get("gameData").and_then(|gd| gd.get("gameId")))
match self { .and_then(|v| v.as_u64())
GameEvent::MatchFound(_) => "match_found", .unwrap_or(0);
GameEvent::ChampSelectStart(_) => "champ_select_start", format!("Game started: ID {}", game_id)
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_KILL => {
} let killer = raw_data
.get("KillerName")
#[cfg(test)] .or_else(|| raw_data.get("killer"))
mod tests { .and_then(|v| v.as_str())
use super::*; .unwrap_or("Unknown");
let victim = raw_data
#[test] .get("VictimName")
fn test_parse_kill_event() { .or_else(|| raw_data.get("victim"))
let json = serde_json::json!({ .and_then(|v| v.as_str())
"eventType": "lcu-kill", .unwrap_or("Unknown");
"killer": "Player1", format!("{} killed {}", killer, victim)
"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_DEATH => {
let cause = raw_data
#[test] .get("DeathCause")
fn test_objective_type_deserialization() { .or_else(|| raw_data.get("cause"))
let json = serde_json::json!("dragon"); .and_then(|v| v.as_str())
let obj: ObjectiveType = serde_json::from_value(json).unwrap(); .unwrap_or("Unknown");
assert_eq!(obj, ObjectiveType::Dragon); 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) 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, MATCH_HISTORY, RUNE_PAGES, SESSION, SUBSCRIBE_ENDPOINTS, SUMMONER,
}; };
pub use events::{ pub use events::{
ChampSelectStartInfo, ChampionPickInfo, DeathEvent, EventData, GameEndInfo, GameEvent, describe_event, GameflowSession, ItemBuild, ItemInfo, PlayerChampionSelection,
GameStartInfo, GameflowSession, InGameStats, ItemBuild, ItemInfo, KillEvent, MatchInfo, PlayerGameMetadata, PlayerIdentity, QueueInfo, RunePage, RuneSlot, SummonerSpells, TeamMember,
ObjectiveEvent, ObjectiveType, PlayerChampionSelection, PlayerGameMetadata, PlayerIdentity, EVENT_TYPE_CHAMPION_PICK, EVENT_TYPE_CHAMP_SELECT_START, EVENT_TYPE_DEATH, EVENT_TYPE_GAME_END,
QueueInfo, RunePage, RuneSlot, SummonerSpells, TeamMember, 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 state::{ClientState, GameflowPhase};
pub use websocket::{ 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}, config::{self, Settings},
error::Result, error::Result,
ipc::{self, IpcHandlers, IpcServer, IpcServerConfig}, 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, recording::RecordingEngine,
state::{DaemonStateMachine, DaemonStatus, StateTransition}, state::{DaemonStateMachine, DaemonStatus, StateTransition},
timeline::{EventMapper, TimelineStore, TimestampedEvent}, timeline::{EventMapper, TimelineStore, TimestampedEvent},
@@ -226,25 +229,44 @@ impl Daemon {
/// Handle a game event. /// Handle a game event.
async fn handle_game_event(&self, parsed: record_daemon::lqp::ParsedEvent) -> Result<()> { async fn handle_game_event(&self, parsed: record_daemon::lqp::ParsedEvent) -> Result<()> {
let event = &parsed.event; let event_type = &parsed.event_type;
info!("[EVENT_HANDLER] Game event received: {:?}", event); 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 // Handle pre-game data collection
match event { match event_type.as_str() {
GameEvent::PhaseChange(info) if info.phase == "ChampSelect" => { EVENT_TYPE_PHASE_CHANGE => {
info!("[EVENT_HANDLER] Champion select started"); 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 => { EVENT_TYPE_CHAMPION_PICK => {
info!( let is_local = raw_data
"[EVENT_HANDLER] Local player picked champion: {}", .get("isLocalPlayer")
pick.champion_name .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) => { EVENT_TYPE_GAME_START => {
info!( let game_id = raw_data.get("gameId").and_then(|v| v.as_u64()).unwrap_or(0);
"[EVENT_HANDLER] Game started with metadata: queue={:?}, mode={:?}, map={:?}", info!("[EVENT_HANDLER] Game started with game_id: {}", game_id);
info.queue_type, info.game_mode, info.map_name
);
} }
_ => {} _ => {}
} }
@@ -252,7 +274,7 @@ impl Daemon {
// Record event to timeline if recording (BEFORE state transition for GameEnd) // Record event to timeline if recording (BEFORE state transition for GameEnd)
// This ensures GameEnd events are recorded while still in recording state // This ensures GameEnd events are recorded while still in recording state
if self.state_machine.is_recording() { 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 // Get the current recording ID
if let Some(recording_id) = *self.current_recording_id.read() { if let Some(recording_id) = *self.current_recording_id.read() {
// Create a timestamped event with raw data // Create a timestamped event with raw data
@@ -260,11 +282,10 @@ impl Daemon {
video_timestamp: video_ts, video_timestamp: video_ts,
game_timestamp: game_ts, game_timestamp: game_ts,
timestamp: chrono::Utc::now(), timestamp: chrono::Utc::now(),
event_type: event.event_type_name().to_string(), event_type: event_type.clone(),
description: event.description(), description: description.clone(),
event: event.clone(), raw_data: parsed.raw_data.clone(),
raw_data: Some(parsed.raw_data.clone()), uri: parsed.uri.clone(),
uri: Some(parsed.uri.clone()),
}; };
// Add the event to the timeline store // Add the event to the timeline store
@@ -277,9 +298,7 @@ impl Daemon {
} else { } else {
debug!( debug!(
"Event added to timeline: video_ts={:?}, game_ts={:?}, type={}", "Event added to timeline: video_ts={:?}, game_ts={:?}, type={}",
video_ts, video_ts, game_ts, event_type
game_ts,
event.event_type_name()
); );
} }
} else { } else {
@@ -289,7 +308,7 @@ impl Daemon {
} }
// 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_type, raw_data) {
info!("[EVENT_HANDLER] State transition: {:?}", transition); info!("[EVENT_HANDLER] State transition: {:?}", transition);
// Only process the transition if it's valid // Only process the transition if it's valid
@@ -297,12 +316,8 @@ impl Daemon {
// Handle recording start/stop // Handle recording start/stop
match transition { match transition {
StateTransition::GameStarted => { StateTransition::GameStarted => {
// Extract game_id from the GameStart event // Extract game_id from raw_data
let game_id = if let GameEvent::GameStart(ref info) = event { let game_id = raw_data.get("gameId").and_then(|v| v.as_u64()).unwrap_or(0);
info.game_id
} else {
0
};
info!( info!(
"[EVENT_HANDLER] GameStarted transition - game_id: {}", "[EVENT_HANDLER] GameStarted transition - game_id: {}",

View File

@@ -9,7 +9,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::GameflowPhase;
/// Internal daemon state. /// Internal daemon state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -203,22 +203,29 @@ impl DaemonStateMachine {
/// Process a game event and potentially trigger a transition. /// Process a game event and potentially trigger a transition.
/// ///
/// Only returns state transitions /// Uses event_type string and raw_data instead of typed GameEvent.
/// All data is stored in events in the timeline and processed at game end. /// Only returns state transitions — all data is stored in raw events.
pub fn process_event(&self, event: &GameEvent) -> Option<StateTransition> { pub fn process_event(
&self,
event_type: &str,
raw_data: &serde_json::Value,
) -> Option<StateTransition> {
trace!( trace!(
"Processing event in state {:?}: {:?}", "Processing event in state {:?}: type={}",
self.current_state(), self.current_state(),
event event_type
); );
match event { match event_type {
GameEvent::GameStart(_) => Some(StateTransition::GameStarted), crate::lqp::EVENT_TYPE_GAME_START => Some(StateTransition::GameStarted),
GameEvent::GameEnd(_) => Some(StateTransition::GameEnded), crate::lqp::EVENT_TYPE_GAME_END => Some(StateTransition::GameEnded),
GameEvent::PhaseChange(info) => { crate::lqp::EVENT_TYPE_PHASE_CHANGE => {
// Only trigger GameEnded on EndOfGame phase (stats are available by then) // 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 let phase = raw_data
if info.phase == "EndOfGame" && self.is_recording() { .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) Some(StateTransition::GameEnded)
} else { } else {
None None
@@ -294,4 +301,26 @@ mod tests {
assert_eq!(machine.current_state(), DaemonState::Idle); assert_eq!(machine.current_state(), DaemonState::Idle);
assert_eq!(machine.last_error(), None); 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 chrono::{DateTime, Duration, Utc};
use tracing::debug; use tracing::debug;
use crate::lqp::GameEvent;
/// Event mapper that tracks recording start time and maps events to video timestamps. /// Event mapper that tracks recording start time and maps events to video timestamps.
pub struct EventMapper { pub struct EventMapper {
/// Recording start time. /// Recording start time.
@@ -45,8 +43,8 @@ impl EventMapper {
self.start_time.is_some() self.start_time.is_some()
} }
/// Map a game event to video and game timestamps. /// Map an event to video and game timestamps.
pub fn map_event(&self, _event: &GameEvent) -> Option<(Duration, Option<Duration>)> { pub fn map_event(&self) -> Option<(Duration, Option<Duration>)> {
let start_time = self.start_time?; let start_time = self.start_time?;
let now = Utc::now(); let now = Utc::now();
@@ -60,25 +58,23 @@ impl EventMapper {
self.synchronizer.adjust_game_timestamp(raw_ts) 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)) Some((video_timestamp, game_timestamp))
} }
/// Handle a game event and return mapped timestamps. /// 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() { if !self.is_active() {
return None; return None;
} }
// Track game start time // 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()); self.game_start_time = Some(Utc::now());
debug!("Game start time recorded: {:?}", self.game_start_time); 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 // Add sync point if we have both timestamps
if let Some((video_ts, Some(game_ts))) = result { 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 serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
use crate::lqp::GameEvent;
/// A timeline of events for a recording. /// A timeline of events for a recording.
/// Stores raw API responses for maximum flexibility and future-proofing. /// Stores raw API responses for maximum flexibility and future-proofing.
#[derive(Debug, Clone, Serialize, Deserialize)] #[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( pub fn add_event(
&mut self, &mut self,
event: GameEvent, event_type: &str,
video_timestamp: Duration, description: &str,
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,
video_timestamp: Duration, video_timestamp: Duration,
game_timestamp: Option<Duration>, game_timestamp: Option<Duration>,
raw_data: serde_json::Value, raw_data: serde_json::Value,
@@ -113,11 +91,10 @@ impl Timeline {
video_timestamp, video_timestamp,
game_timestamp, game_timestamp,
timestamp: Utc::now(), timestamp: Utc::now(),
event_type: event_type_name(&event), event_type: event_type.to_string(),
description: event.description(), description: description.to_string(),
event, raw_data,
raw_data: Some(raw_data), uri,
uri: Some(uri),
}; };
self.events.push(timestamped); self.events.push(timestamped);
@@ -192,29 +169,9 @@ fn format_timestamp(duration: Duration) -> String {
format!("{:02}:{:02}:{:02}.{:03}", hours, minutes, seconds, millis) 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::lqp::KillEvent;
#[test] #[test]
fn test_timeline_creation() { fn test_timeline_creation() {
@@ -230,21 +187,22 @@ mod tests {
let id = Uuid::new_v4(); let id = Uuid::new_v4();
let mut timeline = Timeline::new(id); let mut timeline = Timeline::new(id);
let event = GameEvent::Kill(KillEvent { let raw_data = serde_json::json!({
killer: "Player1".to_string(), "killer": "Player1",
killer_champion: Some("Ahri".to_string()), "victim": "Player2"
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(),
}); });
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.event_count(), 1);
assert_eq!(timeline.events[0].event_type, "kill");
} }
#[test] #[test]

View File

@@ -9,32 +9,30 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
use crate::error::{Result, TimelineError}; use crate::error::{Result, TimelineError};
use crate::lqp::GameEvent;
use crate::recording::RecordingResult; use crate::recording::RecordingResult;
/// A timestamped event in the timeline. /// A timestamped event in the timeline.
/// Stores raw API data only — no typed event parsing.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimestampedEvent { pub struct TimestampedEvent {
/// Video timestamp (offset from recording start). /// Video timestamp (offset from recording start).
// #[serde(with = "chrono::serde::seconds")]
pub video_timestamp: Duration, pub video_timestamp: Duration,
/// Game timestamp (in-game time). /// Game timestamp (in-game time).
// #[serde(with = "chrono::serde::seconds_option")]
pub game_timestamp: Option<Duration>, pub game_timestamp: Option<Duration>,
/// Real-world timestamp. /// Real-world timestamp.
pub timestamp: DateTime<Utc>, 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, pub event_type: String,
/// Human-readable description. /// Human-readable description for logging.
pub description: String,
/// The actual event data.
pub event: GameEvent,
/// Raw JSON data from the API (for flexibility).
#[serde(default)] #[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. /// URI of the endpoint that triggered this event.
#[serde(default)] #[serde(default)]
pub uri: Option<String>, pub uri: String,
} }
/// Metadata for a recording. /// Metadata for a recording.

View File

@@ -94,10 +94,11 @@ function getEventColor(event: TimestampedEvent): string {
case "phase_change": case "phase_change":
return "#60a5fa"; // blue return "#60a5fa"; // blue
case "kill": case "kill":
case "champions_killed":
return getKillEventColor(event); return getKillEventColor(event);
case "objective": case "objective":
return "#fbbf24"; // yellow return "#fbbf24"; // yellow
case "lp_change":
return "#a78bfa"; // purple
default: default:
return "#9ca3af"; // gray return "#9ca3af"; // gray
} }
@@ -105,14 +106,13 @@ function getEventColor(event: TimestampedEvent): string {
// Get kill event color based on player involvement // Get kill event color based on player involvement
function getKillEventColor(event: TimestampedEvent): string { function getKillEventColor(event: TimestampedEvent): string {
const rawEvent = event.event as { killer?: string; victim?: string } | null; const rawData = event.raw_data as { KillerName?: string; VictimName?: string; Assisters?: string[] } | null;
const rawData = event.raw_data as { Assisters?: string[] } | null;
if (!rawEvent) return "#9ca3af"; // gray for unknown if (!rawData) return "#9ca3af"; // gray for unknown
const killer = rawEvent.killer; const killer = rawData.KillerName;
const victim = rawEvent.victim; const victim = rawData.VictimName;
const assisters = rawData?.Assisters || []; const assisters = rawData.Assisters || [];
// Get local player's summoner name // Get local player's summoner name
const localPlayerName = localPlayer.value?.riotIdGameName; const localPlayerName = localPlayer.value?.riotIdGameName;

View File

@@ -13,10 +13,14 @@ export interface TimestampedEvent {
game_timestamp: [number, number] | null; game_timestamp: [number, number] | null;
/** Real-world timestamp (ISO 8601). */ /** Real-world timestamp (ISO 8601). */
timestamp: string; timestamp: string;
/** Event type name. */ /** Event type name (e.g. "game_start", "lp_change", "kill"). */
event_type: string; event_type: string;
/** Human-readable description. */ /** Human-readable description. */
description: string; description: string;
/** Raw JSON data from the League Client API — the single source of truth. */
raw_data: Record<string, unknown>;
/** URI of the endpoint that triggered this event. */
uri: string;
} }
/** /**
@@ -601,12 +605,19 @@ export function getEventCategory(eventType: string): EventCategory {
return "death"; return "death";
case "objective": case "objective":
return "objective"; return "objective";
case "gameend": case "game_end":
case "gamestart": case "game_start":
case "matchfound": case "match_found":
return "game"; return "game";
case "phasechange": case "phase_change":
return "phase"; return "phase";
case "lp_change":
return "game";
case "champ_select_start":
case "champion_pick":
return "phase";
case "stats_update":
return "unknown";
default: default:
return "unknown"; return "unknown";
} }
@@ -623,14 +634,22 @@ export function getEventLabel(eventType: string): string {
return "Death"; return "Death";
case "objective": case "objective":
return "Objective"; return "Objective";
case "gameend": case "game_end":
return "Game End"; return "Game End";
case "gamestart": case "game_start":
return "Game Start"; return "Game Start";
case "matchfound": case "match_found":
return "Match Found"; return "Match Found";
case "phasechange": case "phase_change":
return "Phase Change"; return "Phase Change";
case "lp_change":
return "LP Change";
case "champ_select_start":
return "Champ Select";
case "champion_pick":
return "Champion Pick";
case "stats_update":
return "Stats Update";
default: default:
return eventType; return eventType;
} }