record-daemon: add record raw events, subscribe lp change events
All checks were successful
record-daemon / Build, check and test (push) Successful in 2m0s

This commit is contained in:
2026-03-27 22:25:24 +01:00
parent aa53a84a46
commit b64937601a
8 changed files with 401 additions and 30 deletions

View File

@@ -14,7 +14,7 @@ use super::endpoints;
use super::events::GameEvent; use super::events::GameEvent;
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_websocket_message; use super::websocket::{parse_websocket_message, ParsedEvent};
use crate::error::{LqpError, Result}; use crate::error::{LqpError, Result};
/// LQP Client for League Client communication. /// LQP Client for League Client communication.
@@ -24,7 +24,7 @@ pub struct LqpClient {
/// Current client state. /// Current client state.
state: Arc<RwLock<ClientState>>, state: Arc<RwLock<ClientState>>,
/// Event broadcaster. /// Event broadcaster.
event_sender: broadcast::Sender<GameEvent>, event_sender: broadcast::Sender<ParsedEvent>,
/// HTTP client for REST API. /// HTTP client for REST API.
http_client: reqwest::Client, http_client: reqwest::Client,
/// Shutdown signal. /// Shutdown signal.
@@ -54,7 +54,7 @@ impl LqpClient {
} }
/// Get a subscriber for game events. /// Get a subscriber for game events.
pub fn subscribe(&self) -> broadcast::Receiver<GameEvent> { pub fn subscribe(&self) -> broadcast::Receiver<ParsedEvent> {
self.event_sender.subscribe() self.event_sender.subscribe()
} }
@@ -184,12 +184,12 @@ impl LqpClient {
if text.is_empty() { if text.is_empty() {
continue; continue;
} }
if let Some(event) = 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, &event).await; Self::update_state_from_event(&state, &parsed.event).await;
// Check for duplicate GameStart events // Check for duplicate GameStart events
if let GameEvent::GameStart(ref info) = event { if let GameEvent::GameStart(ref info) = parsed.event {
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(info.game_id) {
info!( info!(
@@ -202,12 +202,12 @@ impl LqpClient {
} }
// 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(_) = &event { if let GameEvent::GameEnd(_) = &parsed.event {
*last_emitted_game_id.write().await = None; *last_emitted_game_id.write().await = None;
} }
// Broadcast event // Broadcast event
if event_sender.send(event.clone()).is_err() { if event_sender.send(parsed).is_err() {
trace!("No event subscribers"); trace!("No event subscribers");
} }
} }
@@ -217,12 +217,12 @@ impl LqpClient {
// Try to parse as UTF-8 // Try to parse as UTF-8
if let Ok(text) = String::from_utf8(data) { if let Ok(text) = String::from_utf8(data) {
if !text.is_empty() { if !text.is_empty() {
if let Some(event) = 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, &event).await; Self::update_state_from_event(&state, &parsed.event).await;
// Check for duplicate GameStart events // Check for duplicate GameStart events
if let GameEvent::GameStart(ref info) = event { if let GameEvent::GameStart(ref info) = parsed.event {
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(info.game_id) {
info!( info!(
@@ -235,12 +235,12 @@ impl LqpClient {
} }
// 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(_) = &event { if let GameEvent::GameEnd(_) = &parsed.event {
*last_emitted_game_id.write().await = None; *last_emitted_game_id.write().await = None;
} }
// Broadcast event // Broadcast event
if event_sender.send(event.clone()).is_err() { if event_sender.send(parsed).is_err() {
trace!("No event subscribers"); trace!("No event subscribers");
} }
} }
@@ -448,6 +448,16 @@ impl LqpClient {
pub async fn fetch_raw_end_game_stats(&self) -> Result<serde_json::Value> { pub async fn fetch_raw_end_game_stats(&self) -> Result<serde_json::Value> {
self.request("GET", endpoints::GAME_STATS).await self.request("GET", endpoints::GAME_STATS).await
} }
/// Fetch ranked stats as JSON (for LP tracking).
pub async fn fetch_ranked_stats(&self) -> Result<serde_json::Value> {
self.request("GET", endpoints::RANKED_STATS).await
}
/// Fetch current ranked stats as JSON.
pub async fn fetch_current_ranked_stats(&self) -> Result<serde_json::Value> {
self.request("GET", endpoints::CURRENT_RANKED_STATS).await
}
} }
impl Default for LqpClient { impl Default for LqpClient {

View File

@@ -22,6 +22,12 @@ pub const ALL_RUNE_PAGES: &str = "/lol-perks/v1/pages";
pub const MATCH_HISTORY: &str = "/lol-match-history/v1/products/lol/current-summoner/matches"; pub const MATCH_HISTORY: &str = "/lol-match-history/v1/products/lol/current-summoner/matches";
/// Live client data endpoint (all game data). /// Live client data endpoint (all game data).
pub const LIVE_CLIENT_DATA: &str = "/liveclientdata/allgamedata"; pub const LIVE_CLIENT_DATA: &str = "/liveclientdata/allgamedata";
/// Ranked stats endpoint (for LP tracking).
pub const RANKED_STATS: &str = "/lol-ranked/v1/ranked-stats";
/// Current ranked stats for the local player.
pub const CURRENT_RANKED_STATS: &str = "/lol-ranked/v1/current-ranked-stats";
/// LP change notification endpoint.
pub const LP_CHANGE_NOTIFICATION: &str = "/lol-ranked/v1/current-lp-change-notification";
/// Live client active player endpoint. /// Live client active player endpoint.
pub const LIVE_CLIENT_DATA_ACTIVE_PLAYER: &str = "/liveclientdata/activeplayer"; pub const LIVE_CLIENT_DATA_ACTIVE_PLAYER: &str = "/liveclientdata/activeplayer";
/// Live client player list endpoint. /// Live client player list endpoint.
@@ -38,6 +44,8 @@ pub const SUBSCRIBE_ENDPOINTS: &[&str] = &[
CHAMPION_SELECT, CHAMPION_SELECT,
"/lol-lobby/v2/lobby", "/lol-lobby/v2/lobby",
GAME_STATS, GAME_STATS,
RANKED_STATS,
LP_CHANGE_NOTIFICATION,
]; ];
#[cfg(test)] #[cfg(test)]

View File

@@ -49,6 +49,10 @@ pub enum GameEvent {
#[serde(rename = "lcu-phase-change")] #[serde(rename = "lcu-phase-change")]
PhaseChange(PhaseChangeInfo), PhaseChange(PhaseChangeInfo),
/// LP changed after a ranked game.
#[serde(rename = "lcu-lp-change")]
LpChange(LpChangeInfo),
/// Unknown event type. /// Unknown event type.
#[serde(other)] #[serde(other)]
Unknown, Unknown,
@@ -66,6 +70,66 @@ pub struct PhaseChangeInfo {
pub timestamp: DateTime<Utc>, 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. /// Match found event data.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -462,6 +526,18 @@ impl GameEvent {
GameEvent::PhaseChange(info) => { GameEvent::PhaseChange(info) => {
format!("Phase changed to: {}", info.phase) 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(), GameEvent::Unknown => "Unknown event".to_string(),
} }
} }
@@ -479,6 +555,7 @@ impl GameEvent {
GameEvent::StatsUpdate(_) => "stats_update", GameEvent::StatsUpdate(_) => "stats_update",
GameEvent::GameEnd(_) => "game_end", GameEvent::GameEnd(_) => "game_end",
GameEvent::PhaseChange(_) => "phase_change", GameEvent::PhaseChange(_) => "phase_change",
GameEvent::LpChange(_) => "lp_change",
GameEvent::Unknown => "unknown", GameEvent::Unknown => "unknown",
} }
} }

View File

@@ -26,4 +26,4 @@ pub use events::{
QueueInfo, RunePage, RuneSlot, SummonerSpells, TeamMember, QueueInfo, RunePage, RuneSlot, SummonerSpells, TeamMember,
}; };
pub use state::{ClientState, GameflowPhase}; pub use state::{ClientState, GameflowPhase};
pub use websocket::{parse_event_from_uri, parse_websocket_message}; pub use websocket::{parse_event_from_uri, parse_websocket_message, ParsedEvent};

View File

@@ -7,8 +7,19 @@ use tracing::{debug, info, warn};
use super::events::{GameEvent, GameflowSession}; use super::events::{GameEvent, GameflowSession};
/// Parse a WebSocket message into a game event. /// Parsed event with raw data preserved.
pub fn parse_websocket_message(text: &str) -> Option<GameEvent> { #[derive(Debug, Clone)]
pub struct ParsedEvent {
/// The parsed game event.
pub event: GameEvent,
/// The raw JSON data from the API.
pub raw_data: serde_json::Value,
/// The URI of the endpoint that triggered this event.
pub uri: String,
}
/// Parse a WebSocket message into a parsed event with raw data.
pub fn parse_websocket_message(text: &str) -> Option<ParsedEvent> {
// Parse the message array format: [type, callback, data] // Parse the message array format: [type, callback, data]
let value: serde_json::Value = match serde_json::from_str(text) { let value: serde_json::Value = match serde_json::from_str(text) {
Ok(v) => v, Ok(v) => v,
@@ -37,22 +48,33 @@ pub fn parse_websocket_message(text: &str) -> Option<GameEvent> {
.get("eventType") .get("eventType")
.and_then(|t| t.as_str()) .and_then(|t| t.as_str())
.unwrap_or("Update"); .unwrap_or("Update");
return parse_event_from_uri( let raw_data =
&raw_event.uri, serde_json::to_value(raw_event.data.clone()).unwrap_or_default();
event_type, let uri = raw_event.uri.clone();
&serde_json::to_value(raw_event.data).unwrap_or_default(), if let Some(event) = parse_event_from_uri(&uri, event_type, &raw_data) {
); return Some(ParsedEvent {
event,
raw_data,
uri,
});
}
} }
// Fallback to manual extraction // Fallback to manual extraction
let uri = event_data.get("uri")?.as_str()?; let uri = event_data.get("uri")?.as_str()?.to_string();
let data = event_data.get("data")?; let data = event_data.get("data")?.clone();
let event_type = event_data let event_type = event_data
.get("eventType") .get("eventType")
.and_then(|t| t.as_str()) .and_then(|t| t.as_str())
.unwrap_or("Update"); .unwrap_or("Update");
return parse_event_from_uri(uri, event_type, data); if let Some(event) = parse_event_from_uri(&uri, event_type, &data) {
return Some(ParsedEvent {
event,
raw_data: data,
uri,
});
}
} else { } else {
debug!("Unknown callback: {}", callback); debug!("Unknown callback: {}", callback);
} }
@@ -113,6 +135,16 @@ pub fn parse_event_from_uri(
return parse_end_of_game_stats(data); return parse_end_of_game_stats(data);
} }
// Handle LP change notifications
if uri == "/lol-ranked/v1/current-lp-change-notification" {
return parse_lp_change_notification(data);
}
// Handle ranked stats updates (with UUID suffix)
if uri.starts_with("/lol-ranked/v1/ranked-stats/") {
return parse_ranked_stats_event(data);
}
// Handle lobby // Handle lobby
if uri.starts_with("/lol-lobby") { if uri.starts_with("/lol-lobby") {
debug!("Lobby event: {}", uri); debug!("Lobby event: {}", uri);
@@ -423,6 +455,215 @@ fn parse_end_of_game_stats(data: &serde_json::Value) -> Option<GameEvent> {
} }
} }
/// Parse LP change notification event.
///
/// This is the primary source for LP changes after a ranked game.
/// The notification contains the LP delta and current rank info.
fn parse_lp_change_notification(data: &serde_json::Value) -> Option<GameEvent> {
info!("LP change notification received: {:?}", data);
// Extract queue type
let queue_type = data
.get("queueType")
.and_then(|q| q.as_str())
.unwrap_or("UNKNOWN")
.to_string();
// Extract LP change amount
let lp_change = data.get("lpChange").and_then(|lp| lp.as_i64()).unwrap_or(0) as i32;
// Extract LP before and after
let lp_before = data.get("lpBefore").and_then(|lp| lp.as_i64()).unwrap_or(0) as i32;
let lp_after = data.get("lpAfter").and_then(|lp| lp.as_i64()).unwrap_or(0) as i32;
// Extract tier and division
let tier = data
.get("tier")
.and_then(|t| t.as_str())
.unwrap_or("UNRANKED")
.to_string();
let division = data
.get("division")
.and_then(|d| d.as_str())
.map(|s| s.to_string());
// Check for promotional series
let in_promos = data.get("miniSeries").is_some();
let promo_progress = if in_promos {
data.get("miniSeries")
.and_then(|ms| ms.get("progress"))
.and_then(|p| p.as_str())
.map(|s| s.to_string())
} else {
None
};
let promo_wins = data
.get("miniSeries")
.and_then(|ms| ms.get("wins"))
.and_then(|w| w.as_u64())
.map(|w| w as u32);
let promo_losses = data
.get("miniSeries")
.and_then(|ms| ms.get("losses"))
.and_then(|l| l.as_u64())
.map(|l| l as u32);
// Extract total games stats if available
let total_wins = data.get("wins").and_then(|w| w.as_u64()).unwrap_or(0) as u32;
let total_losses = data.get("losses").and_then(|l| l.as_u64()).unwrap_or(0) as u32;
let total_games = total_wins + total_losses;
info!(
"LP change notification: {} {} LP change: {} ({} -> {})",
queue_type, tier, lp_change, lp_before, lp_after
);
let event_json = serde_json::json!({
"eventType": "lcu-lp-change",
"queueType": queue_type,
"lpChange": lp_change,
"lpBefore": lp_before,
"lpAfter": lp_after,
"tier": tier,
"division": division,
"leaguePoints": lp_after,
"inPromos": in_promos,
"promoProgress": promo_progress,
"promoWins": promo_wins,
"promoLosses": promo_losses,
"totalGames": total_games,
"totalWins": total_wins,
"totalLosses": total_losses
});
GameEvent::from_json(&event_json)
}
/// Parse ranked stats event for LP changes.
///
/// The ranked stats endpoint provides updates when LP changes occur.
/// We extract the queue-specific data and emit an LpChange event.
fn parse_ranked_stats_event(data: &serde_json::Value) -> Option<GameEvent> {
info!("Ranked stats event received: {:?}", data);
// The ranked stats data contains queue-specific stats
// We look for RANKED_SOLO_5x5 and RANKED_FLEX_SR queues
let queues = data.get("queues")?.as_array()?;
for queue_data in queues {
let queue_type = queue_data
.get("queueType")
.and_then(|q| q.as_str())
.unwrap_or("");
// Only process ranked queues
if queue_type != "RANKED_SOLO_5x5" && queue_type != "RANKED_FLEX_SR" {
continue;
}
// Extract tier and division
let tier = queue_data
.get("tier")
.and_then(|t| t.as_str())
.unwrap_or("UNRANKED")
.to_string();
let division = queue_data
.get("division")
.and_then(|d| d.as_str())
.map(|s| s.to_string());
// Extract LP
let league_points = queue_data
.get("leaguePoints")
.and_then(|lp| lp.as_i64())
.unwrap_or(0) as i32;
// Extract previous LP if available (for calculating change)
let previous_lp = queue_data
.get("previousLeaguePoints")
.and_then(|lp| lp.as_i64())
.unwrap_or(league_points as i64) as i32;
// Calculate LP change
let lp_change = league_points - previous_lp;
// Only emit event if there was an actual change
if lp_change == 0 {
continue;
}
// Check for promotional series
let in_promos = queue_data.get("miniSeries").is_some();
let promo_progress = if in_promos {
queue_data
.get("miniSeries")
.and_then(|ms| ms.get("progress"))
.and_then(|p| p.as_str())
.map(|s| s.to_string())
} else {
None
};
let promo_wins = queue_data
.get("miniSeries")
.and_then(|ms| ms.get("wins"))
.and_then(|w| w.as_u64())
.map(|w| w as u32);
let promo_losses = queue_data
.get("miniSeries")
.and_then(|ms| ms.get("losses"))
.and_then(|l| l.as_u64())
.map(|l| l as u32);
// Extract total games stats
let total_wins = queue_data.get("wins").and_then(|w| w.as_u64()).unwrap_or(0) as u32;
let total_losses = queue_data
.get("losses")
.and_then(|l| l.as_u64())
.unwrap_or(0) as u32;
let total_games = total_wins + total_losses;
info!(
"LP change detected: {} {} LP change: {} ({} -> {})",
queue_type, tier, lp_change, previous_lp, league_points
);
let event_json = serde_json::json!({
"eventType": "lcu-lp-change",
"queueType": queue_type,
"lpChange": lp_change,
"lpBefore": previous_lp,
"lpAfter": league_points,
"tier": tier,
"division": division,
"leaguePoints": league_points,
"inPromos": in_promos,
"promoProgress": promo_progress,
"promoWins": promo_wins,
"promoLosses": promo_losses,
"totalGames": total_games,
"totalWins": total_wins,
"totalLosses": total_losses
});
return GameEvent::from_json(&event_json);
}
None
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@@ -223,11 +223,12 @@ impl Daemon {
} }
/// Handle a game event. /// Handle a game event.
async fn handle_game_event(&self, event: GameEvent) -> Result<()> { async fn handle_game_event(&self, parsed: record_daemon::lqp::ParsedEvent) -> Result<()> {
let event = &parsed.event;
info!("[EVENT_HANDLER] Game event received: {:?}", event); info!("[EVENT_HANDLER] Game event received: {:?}", event);
// Handle pre-game data collection // Handle pre-game data collection
match &event { match event {
GameEvent::PhaseChange(info) if info.phase == "ChampSelect" => { GameEvent::PhaseChange(info) if info.phase == "ChampSelect" => {
info!("[EVENT_HANDLER] Champion select started"); info!("[EVENT_HANDLER] Champion select started");
} }
@@ -249,10 +250,10 @@ 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) {
// 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 // Create a timestamped event with raw data
let timestamped_event = TimestampedEvent { let timestamped_event = TimestampedEvent {
video_timestamp: video_ts, video_timestamp: video_ts,
game_timestamp: game_ts, game_timestamp: game_ts,
@@ -260,6 +261,8 @@ impl Daemon {
event_type: event.event_type_name().to_string(), event_type: event.event_type_name().to_string(),
description: event.description(), description: event.description(),
event: event.clone(), event: event.clone(),
raw_data: Some(parsed.raw_data.clone()),
uri: Some(parsed.uri.clone()),
}; };
// Add the event to the timeline store // Add the event to the timeline store
@@ -284,7 +287,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) {
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

View File

@@ -89,6 +89,31 @@ impl Timeline {
event_type: event_type_name(&event), event_type: event_type_name(&event),
description: event.description(), description: event.description(),
event, 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,
game_timestamp: Option<Duration>,
raw_data: serde_json::Value,
uri: String,
) {
let timestamped = TimestampedEvent {
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),
}; };
self.events.push(timestamped); self.events.push(timestamped);
@@ -176,6 +201,7 @@ fn event_type_name(event: &GameEvent) -> String {
GameEvent::StatsUpdate(_) => "stats_update", GameEvent::StatsUpdate(_) => "stats_update",
GameEvent::GameEnd(_) => "game_end", GameEvent::GameEnd(_) => "game_end",
GameEvent::PhaseChange(_) => "phase_change", GameEvent::PhaseChange(_) => "phase_change",
GameEvent::LpChange(_) => "lp_change",
GameEvent::Unknown => "unknown", GameEvent::Unknown => "unknown",
} }
.to_string() .to_string()

View File

@@ -23,12 +23,18 @@ pub struct TimestampedEvent {
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. /// Event type name (derived from URI).
pub event_type: String, pub event_type: String,
/// Human-readable description. /// Human-readable description.
pub description: String, pub description: String,
/// The actual event data. /// The actual event data.
pub event: GameEvent, pub event: GameEvent,
/// Raw JSON data from the API (for flexibility).
#[serde(default)]
pub raw_data: Option<serde_json::Value>,
/// URI of the endpoint that triggered this event.
#[serde(default)]
pub uri: Option<String>,
} }
/// Metadata for a recording. /// Metadata for a recording.