record-daemon: add record raw events, subscribe lp change events
All checks were successful
record-daemon / Build, check and test (push) Successful in 2m0s
All checks were successful
record-daemon / Build, check and test (push) Successful in 2m0s
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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::*;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user