8 Commits

Author SHA1 Message Date
vhaudiquet 384ccda515 tryfix: reconnect to league client
record-daemon / Build, check and test (push) Failing after 10m47s
2026-06-05 21:17:15 +02:00
vhaudiquet 1c1b9c4d1a tryfix: reconnect to client on connection error
record-daemon / Build, check and test (push) Failing after 10s
2026-06-04 13:47:48 +02:00
vhaudiquet 642ad3e13a fix: enable assists by default on highlights
record-daemon / Build, check and test (push) Failing after 13m50s
2026-06-02 21:54:11 +02:00
vhaudiquet 3592b86a94 fmt
record-daemon / Build, check and test (push) Failing after 13m47s
2026-06-02 20:14:17 +02:00
vhaudiquet b49ed22665 fix: tryfix record-daemon disconnecting from league client 2026-06-02 20:14:01 +02:00
vhaudiquet abb35b8fac fix: tryfix multikill grouping
record-daemon / Build, check and test (push) Failing after 15m23s
2026-05-30 19:27:31 +02:00
vhaudiquet 598c5a2391 feat: record-daemon logs to a file 2026-05-30 10:41:40 +02:00
vhaudiquet 7f54e959b8 bump version (0.2.0 start)
record-daemon / Build, check and test (push) Successful in 2m3s
2026-05-18 17:19:17 +02:00
9 changed files with 340 additions and 53 deletions
+21
View File
@@ -2418,6 +2418,7 @@ dependencies = [
"tokio-util", "tokio-util",
"toml", "toml",
"tracing", "tracing",
"tracing-appender",
"tracing-subscriber", "tracing-subscriber",
"uuid", "uuid",
"winapi 0.3.9", "winapi 0.3.9",
@@ -3074,6 +3075,12 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "symlink"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.117" version = "2.0.117"
@@ -3255,6 +3262,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [ dependencies = [
"deranged", "deranged",
"itoa",
"num-conv", "num-conv",
"powerfmt", "powerfmt",
"serde_core", "serde_core",
@@ -3520,6 +3528,19 @@ dependencies = [
"tracing-core", "tracing-core",
] ]
[[package]]
name = "tracing-appender"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
dependencies = [
"crossbeam-channel",
"symlink",
"thiserror 2.0.18",
"time",
"tracing-subscriber",
]
[[package]] [[package]]
name = "tracing-attributes" name = "tracing-attributes"
version = "0.1.31" version = "0.1.31"
+1
View File
@@ -34,6 +34,7 @@ anyhow = "1"
# Logging # Logging
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-appender = "0.2"
# UUID for recording IDs # UUID for recording IDs
uuid = { version = "1", features = ["v4", "serde"] } uuid = { version = "1", features = ["v4", "serde"] }
+33 -8
View File
@@ -31,6 +31,8 @@ pub struct LqpClient {
shutdown: Arc<RwLock<bool>>, shutdown: Arc<RwLock<bool>>,
/// Last emitted game ID for deduplication of GameStart events. /// Last emitted game ID for deduplication of GameStart events.
last_emitted_game_id: Arc<RwLock<Option<u64>>>, last_emitted_game_id: Arc<RwLock<Option<u64>>>,
/// WebSocket connection state (true if WebSocket is connected).
ws_connected: Arc<RwLock<bool>>,
} }
impl LqpClient { impl LqpClient {
@@ -50,6 +52,7 @@ impl LqpClient {
http_client, http_client,
shutdown: Arc::new(RwLock::new(false)), shutdown: Arc::new(RwLock::new(false)),
last_emitted_game_id: Arc::new(RwLock::new(None)), last_emitted_game_id: Arc::new(RwLock::new(None)),
ws_connected: Arc::new(RwLock::new(false)),
} }
} }
@@ -63,15 +66,26 @@ impl LqpClient {
self.state.read().await.clone() self.state.read().await.clone()
} }
/// Check if connected to League Client. /// Check if connected to League Client (has valid credentials).
pub async fn is_connected(&self) -> bool { pub async fn is_connected(&self) -> bool {
self.credentials.read().await.is_some() self.credentials.read().await.is_some()
} }
/// Check if WebSocket is connected.
pub async fn is_ws_connected(&self) -> bool {
*self.ws_connected.read().await
}
/// Connect to the League Client with the given credentials. /// Connect to the League Client with the given credentials.
///
/// This only stores credentials and verifies basic connectivity.
/// The actual WebSocket connection is established in start_event_listener().
pub async fn connect(&self, creds: LockfileCredentials) -> Result<()> { pub async fn connect(&self, creds: LockfileCredentials) -> Result<()> {
info!("Connecting to League Client at port {}", creds.port); info!("Connecting to League Client at port {}", creds.port);
// Reset shutdown flag for new connection
*self.shutdown.write().await = false;
// Store credentials // Store credentials
*self.credentials.write().await = Some(creds.clone()); *self.credentials.write().await = Some(creds.clone());
@@ -82,12 +96,13 @@ impl LqpClient {
info!("Connected to League Client, current phase: {:?}", phase); info!("Connected to League Client, current phase: {:?}", phase);
} }
Err(e) => { Err(e) => {
warn!("Failed to verify connection: {}", e); warn!("Failed to verify connection via REST API: {}", e);
// Still consider connected, WebSocket might work // REST API might not be ready yet, but WebSocket could work
// Don't fail here - let start_event_listener() try the WebSocket
} }
} }
// Fetch local player's puuid for champion extraction // Fetch local player's puuid for champion extraction (best effort)
if let Ok(summoner) = self.get_summoner().await { if let Ok(summoner) = self.get_summoner().await {
if let Some(puuid) = summoner.get("puuid").and_then(|p| p.as_str()) { if let Some(puuid) = summoner.get("puuid").and_then(|p| p.as_str()) {
self.state.write().await.local_puuid = Some(puuid.to_string()); self.state.write().await.local_puuid = Some(puuid.to_string());
@@ -101,6 +116,7 @@ impl LqpClient {
/// Disconnect from the League Client. /// Disconnect from the League Client.
pub async fn disconnect(&self) { pub async fn disconnect(&self) {
*self.shutdown.write().await = true; *self.shutdown.write().await = true;
*self.ws_connected.write().await = false;
*self.credentials.write().await = None; *self.credentials.write().await = None;
*self.state.write().await = ClientState::default(); *self.state.write().await = ClientState::default();
*self.last_emitted_game_id.write().await = None; *self.last_emitted_game_id.write().await = None;
@@ -164,12 +180,16 @@ impl LqpClient {
} }
info!("All subscriptions sent"); info!("All subscriptions sent");
// Mark WebSocket as connected
*self.ws_connected.write().await = true;
// Clone references for the async block // Clone references for the async block
let event_sender = self.event_sender.clone(); let event_sender = self.event_sender.clone();
let state = self.state.clone(); let state = self.state.clone();
let shutdown = self.shutdown.clone(); let shutdown = self.shutdown.clone();
let credentials = self.credentials.clone(); let credentials = self.credentials.clone();
let last_emitted_game_id = self.last_emitted_game_id.clone(); let last_emitted_game_id = self.last_emitted_game_id.clone();
let ws_connected = self.ws_connected.clone();
// Spawn the message handler // Spawn the message handler
tokio::spawn(async move { tokio::spawn(async move {
@@ -293,9 +313,10 @@ impl LqpClient {
} }
} }
// Clear credentials on disconnect // Clear connection state on disconnect
*ws_connected.write().await = false;
*credentials.write().await = None; *credentials.write().await = None;
debug!("WebSocket listener ended"); info!("WebSocket listener ended");
}); });
Ok(()) Ok(())
@@ -500,12 +521,16 @@ impl LqpClient {
info!("Starting live client event poller"); info!("Starting live client event poller");
tokio::spawn(async move { tokio::spawn(async move {
// Small delay to ensure connection is stable
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
let mut last_event_id: Option<u64> = None; let mut last_event_id: Option<u64> = None;
let mut poll_count = 0u32; let mut poll_count = 0u32;
loop { loop {
if *shutdown.read().await { let is_shutdown = *shutdown.read().await;
info!("Live client event poller shutting down"); if is_shutdown {
info!("Live client event poller shutting down (shutdown flag is true)");
break; break;
} }
+95 -15
View File
@@ -42,6 +42,10 @@ struct Args {
/// Socket path for IPC. /// Socket path for IPC.
#[arg(short, long)] #[arg(short, long)]
socket: Option<std::path::PathBuf>, socket: Option<std::path::PathBuf>,
/// Path to log file. If not specified, logs to stdout/stderr.
#[arg(short, long, value_name = "PATH")]
log_file: Option<std::path::PathBuf>,
} }
/// Main daemon structure. /// Main daemon structure.
@@ -202,16 +206,31 @@ impl Daemon {
match watcher.check()? { match watcher.check()? {
Some(true) => { Some(true) => {
// Client started // Client started - try to connect
info!("League Client detected"); info!("League Client detected");
self.state_machine
.transition(StateTransition::ClientStarted);
if let Some(creds) = watcher.credentials() { if let Some(creds) = watcher.credentials() {
self.lqp_client.connect(creds.clone()).await?; match self.lqp_client.connect(creds.clone()).await {
self.lqp_client.start_event_listener().await?; Ok(()) => {
// Start polling for live client events (kills, deaths, objectives) // Only transition to Monitoring after successful connection
self.lqp_client.start_live_client_event_poller().await; self.state_machine
.transition(StateTransition::ClientStarted);
if let Err(e) = self.lqp_client.start_event_listener().await {
warn!("Failed to start event listener: {}", e);
// Still stay in Monitoring, will retry on next check
} else {
// Start polling for live client events (kills, deaths, objectives)
self.lqp_client.start_live_client_event_poller().await;
}
}
Err(e) => {
warn!("Failed to connect to League Client: {}", e);
// Don't transition to Monitoring - will retry on next check
// The lockfile watcher will return None on next check since
// credentials are already set, so we need to handle reconnection
}
}
} }
} }
Some(false) => { Some(false) => {
@@ -221,7 +240,34 @@ impl Daemon {
.transition(StateTransition::ClientStopped); .transition(StateTransition::ClientStopped);
self.lqp_client.disconnect().await; self.lqp_client.disconnect().await;
} }
None => {} None => {
// No change in lockfile status
// Check if we need to reconnect (lockfile exists but WebSocket not connected)
if watcher.credentials().is_some() && !self.lqp_client.is_ws_connected().await {
info!("Lockfile exists but WebSocket not connected, attempting reconnect...");
if let Some(creds) = watcher.credentials() {
match self.lqp_client.connect(creds.clone()).await {
Ok(()) => {
// Transition to Monitoring if not already
if !self.state_machine.is_monitoring() {
self.state_machine
.transition(StateTransition::ClientStarted);
}
if let Err(e) = self.lqp_client.start_event_listener().await {
warn!("Failed to start event listener on reconnect: {}", e);
} else {
self.lqp_client.start_live_client_event_poller().await;
}
}
Err(e) => {
info!("Reconnect attempt failed: {}", e);
// Will retry on next check
}
}
}
}
}
} }
tokio::time::sleep(poll_interval).await; tokio::time::sleep(poll_interval).await;
@@ -579,15 +625,49 @@ impl Daemon {
} }
} }
/// Initialize logging. /// Initialize logging with optional file output.
fn init_logging(level: &str) { fn init_logging(level: &str, log_file: Option<&std::path::Path>) {
let filter = tracing_subscriber::EnvFilter::try_from_default_env() let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(level)); .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(level));
tracing_subscriber::registry() if let Some(log_path) = log_file {
.with(filter) // Ensure parent directory exists
.with(tracing_subscriber::fmt::layer()) if let Some(parent) = log_path.parent() {
.init(); let _ = std::fs::create_dir_all(parent);
}
// Use file appender for logging
let prefix = log_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("daemon");
let file_appender = tracing_appender::rolling::RollingFileAppender::builder()
.rotation(tracing_appender::rolling::Rotation::DAILY)
.filename_prefix(prefix)
.filename_suffix("log")
.build(log_path.parent().unwrap_or(std::path::Path::new(".")))
.expect("Failed to create log file appender");
let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
// Store the guard to prevent it from being dropped
// We leak it intentionally to keep logging working for the daemon's lifetime
std::mem::forget(_guard);
tracing_subscriber::registry()
.with(filter)
.with(tracing_subscriber::fmt::layer().with_writer(non_blocking))
.init();
eprintln!("Logging to file: {}", log_path.display());
} else {
// Log to stdout/stderr
tracing_subscriber::registry()
.with(filter)
.with(tracing_subscriber::fmt::layer())
.init();
}
// Set up panic hook to log panics // Set up panic hook to log panics
std::panic::set_hook(Box::new(|panic_info| { std::panic::set_hook(Box::new(|panic_info| {
@@ -614,7 +694,7 @@ async fn main() -> Result<()> {
let args = Args::parse(); let args = Args::parse();
// Initialize logging // Initialize logging
init_logging(&args.log_level); init_logging(&args.log_level, args.log_file.as_deref());
info!("Record Daemon v{} starting", record_daemon::VERSION); info!("Record Daemon v{} starting", record_daemon::VERSION);
+39
View File
@@ -314,6 +314,20 @@ fn find_daemon_binary() -> Option<PathBuf> {
None None
} }
/// Get the log file path for the daemon.
///
/// Returns a path in the app's data directory.
fn get_log_file_path() -> Result<PathBuf, String> {
let data_dir = directories::ProjectDirs::from("com", "leaguerecorder", "record-daemon")
.ok_or_else(|| "Failed to get app data directory".to_string())?;
let log_dir = data_dir.data_dir().join("logs");
std::fs::create_dir_all(&log_dir)
.map_err(|e| format!("Failed to create log directory: {}", e))?;
Ok(log_dir.join("daemon.log"))
}
/// Spawn the record-daemon as a detached background process. /// Spawn the record-daemon as a detached background process.
/// ///
/// Returns `Ok(())` if the process was successfully spawned. /// Returns `Ok(())` if the process was successfully spawned.
@@ -321,10 +335,13 @@ fn spawn_daemon() -> Result<(), String> {
let binary = find_daemon_binary() let binary = find_daemon_binary()
.ok_or_else(|| "record-daemon binary not found".to_string())?; .ok_or_else(|| "record-daemon binary not found".to_string())?;
let log_file = get_log_file_path()?;
eprintln!("[daemon] Spawning record-daemon: {}", binary.display()); eprintln!("[daemon] Spawning record-daemon: {}", binary.display());
eprintln!("[daemon] Log file: {}", log_file.display());
let mut cmd = std::process::Command::new(&binary); let mut cmd = std::process::Command::new(&binary);
cmd.arg("--foreground"); cmd.arg("--foreground");
cmd.arg("--log-file").arg(&log_file);
// Set the working directory to the daemon binary's directory so it can // Set the working directory to the daemon binary's directory so it can
// find its bundled resources (OBS plugins, etc.) relative to itself. // find its bundled resources (OBS plugins, etc.) relative to itself.
@@ -536,3 +553,25 @@ pub async fn daemon_shutdown() -> Result<serde_json::Value, String> {
Err(response.error.unwrap_or_else(|| "Unknown error".to_string())) Err(response.error.unwrap_or_else(|| "Unknown error".to_string()))
} }
} }
/// Get the daemon log file path.
#[tauri::command]
pub fn daemon_get_log_path() -> Result<String, String> {
get_log_file_path()
.map(|p| p.to_string_lossy().to_string())
}
/// Read the daemon log file.
///
/// Returns the last `lines` lines of the log file (default 100, max 1000).
#[tauri::command]
pub fn daemon_read_logs(lines: Option<usize>) -> Result<String, String> {
let log_path = get_log_file_path()?;
let max_lines = lines.unwrap_or(100).min(1000);
let content = std::fs::read_to_string(&log_path)
.map_err(|e| format!("Failed to read log file: {}", e))?;
let lines: Vec<&str> = content.lines().rev().take(max_lines).collect();
Ok(lines.into_iter().rev().collect::<Vec<_>>().join("\n"))
}
+2
View File
@@ -439,6 +439,8 @@ pub fn run() {
daemon_ipc::daemon_start_recording, daemon_ipc::daemon_start_recording,
daemon_ipc::daemon_stop_recording, daemon_ipc::daemon_stop_recording,
daemon_ipc::daemon_shutdown, daemon_ipc::daemon_shutdown,
daemon_ipc::daemon_get_log_path,
daemon_ipc::daemon_read_logs,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "leaguerecorder", "productName": "leaguerecorder",
"version": "0.1.0", "version": "0.2.0",
"identifier": "v.leaguerecorder", "identifier": "v.leaguerecorder",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
@@ -42,6 +42,7 @@ const typeToggles = ref<Record<HighlightType, boolean>>({
death: DEFAULT_HIGHLIGHT_SETTINGS.included_types.includes("death"), death: DEFAULT_HIGHLIGHT_SETTINGS.included_types.includes("death"),
assist: DEFAULT_HIGHLIGHT_SETTINGS.included_types.includes("assist"), assist: DEFAULT_HIGHLIGHT_SETTINGS.included_types.includes("assist"),
multi_kill: DEFAULT_HIGHLIGHT_SETTINGS.included_types.includes("multi_kill"), multi_kill: DEFAULT_HIGHLIGHT_SETTINGS.included_types.includes("multi_kill"),
trade_kill: DEFAULT_HIGHLIGHT_SETTINGS.included_types.includes("trade_kill"),
}); });
// Watch type toggles and update settings // Watch type toggles and update settings
@@ -82,6 +83,7 @@ const activeClipId = computed(() => {
const killCount = computed(() => props.highlights.filter(h => h.highlight_type === "kill").length); const killCount = computed(() => props.highlights.filter(h => h.highlight_type === "kill").length);
const deathCount = computed(() => props.highlights.filter(h => h.highlight_type === "death").length); const deathCount = computed(() => props.highlights.filter(h => h.highlight_type === "death").length);
const multiKillCount = computed(() => props.highlights.filter(h => h.highlight_type === "multi_kill").length); const multiKillCount = computed(() => props.highlights.filter(h => h.highlight_type === "multi_kill").length);
const tradeKillCount = computed(() => props.highlights.filter(h => h.highlight_type === "trade_kill").length);
const assistCount = computed(() => props.highlights.filter(h => h.highlight_type === "assist").length); const assistCount = computed(() => props.highlights.filter(h => h.highlight_type === "assist").length);
const totalDuration = computed(() => { const totalDuration = computed(() => {
const total = props.highlights.reduce((sum, h) => sum + (h.end_time - h.start_time), 0); const total = props.highlights.reduce((sum, h) => sum + (h.end_time - h.start_time), 0);
@@ -173,6 +175,7 @@ function formatClipDuration(clip: HighlightClip): string {
{ type: 'death' as HighlightType, label: 'Deaths', icon: '💀' }, { type: 'death' as HighlightType, label: 'Deaths', icon: '💀' },
{ type: 'assist' as HighlightType, label: 'Assists', icon: '🤝' }, { type: 'assist' as HighlightType, label: 'Assists', icon: '🤝' },
{ type: 'multi_kill' as HighlightType, label: 'Multi Kills', icon: '🔥' }, { type: 'multi_kill' as HighlightType, label: 'Multi Kills', icon: '🔥' },
{ type: 'trade_kill' as HighlightType, label: 'Trade Kills', icon: '🔄' },
]" ]"
:key="typeInfo.type" :key="typeInfo.type"
class="type-toggle" class="type-toggle"
@@ -230,6 +233,9 @@ function formatClipDuration(clip: HighlightClip): string {
<div class="stat-pill" v-if="multiKillCount > 0"> <div class="stat-pill" v-if="multiKillCount > 0">
<span>🔥</span> {{ multiKillCount }} <span>🔥</span> {{ multiKillCount }}
</div> </div>
<div class="stat-pill" v-if="tradeKillCount > 0">
<span>🔄</span> {{ tradeKillCount }}
</div>
<div class="stat-pill total"> <div class="stat-pill total">
<span>⏱️</span> {{ formatDuration(totalDuration) }} <span>⏱️</span> {{ formatDuration(totalDuration) }}
</div> </div>
+141 -28
View File
@@ -728,7 +728,7 @@ export type EventCategory =
/** /**
* The type of highlight clip. * The type of highlight clip.
*/ */
export type HighlightType = "kill" | "death" | "assist" | "multi_kill"; export type HighlightType = "kill" | "death" | "assist" | "multi_kill" | "trade_kill";
/** /**
* Configuration for highlight generation. * Configuration for highlight generation.
@@ -755,7 +755,7 @@ export const DEFAULT_HIGHLIGHT_SETTINGS: HighlightSettings = {
buffer_before: 10, buffer_before: 10,
buffer_after: 3, buffer_after: 3,
min_duration: 5, min_duration: 5,
included_types: ["kill", "death", "multi_kill"], included_types: ["kill", "death", "multi_kill", "trade_kill", "assist"],
merge_overlapping: true, merge_overlapping: true,
merge_gap_secs: 5, merge_gap_secs: 5,
}; };
@@ -808,23 +808,46 @@ export function getKillHighlightType(
return null; // Not involved — skip return null; // Not involved — skip
} }
/**
* Result of multi-kill detection, including whether it's a trade kill.
*/
export interface MultiKillGroup {
/** Kill events in the group. */
kills: TimestampedEvent[];
/** Death event if the player died during the multi-kill window. */
death: TimestampedEvent | null;
/** Whether this is a trade kill (player died during the sequence). */
isTradeKill: boolean;
}
/** /**
* Detect multi-kills from a sequence of kill events. * Detect multi-kills from a sequence of kill events.
* Returns groups of kills that happened within `windowSecs` seconds. * Returns groups of kills that happened within `windowSecs` seconds.
* Also checks if the player died during the multi-kill window (trade kill).
*/ */
export function detectMultiKills( export function detectMultiKills(
killEvents: TimestampedEvent[], killEvents: TimestampedEvent[],
deathEvents: TimestampedEvent[] = [],
windowSecs: number = 12, windowSecs: number = 12,
): TimestampedEvent[][] { localPlayerName?: string,
if (killEvents.length === 0) return []; ): MultiKillGroup[] {
// Filter to only include kills where the player is the killer
const playerKills = localPlayerName
? killEvents.filter((event) => {
const rawData = event.raw_data as { KillerName?: string } | null;
return rawData?.KillerName === localPlayerName;
})
: killEvents;
const sorted = [...killEvents].sort((a, b) => { if (playerKills.length === 0) return [];
const sorted = [...playerKills].sort((a, b) => {
const aTime = a.video_timestamp[0] + a.video_timestamp[1] / 1e9; const aTime = a.video_timestamp[0] + a.video_timestamp[1] / 1e9;
const bTime = b.video_timestamp[0] + b.video_timestamp[1] / 1e9; const bTime = b.video_timestamp[0] + b.video_timestamp[1] / 1e9;
return aTime - bTime; return aTime - bTime;
}); });
const groups: TimestampedEvent[][] = []; const groups: MultiKillGroup[] = [];
let currentGroup: TimestampedEvent[] = [sorted[0]]; let currentGroup: TimestampedEvent[] = [sorted[0]];
for (let i = 1; i < sorted.length; i++) { for (let i = 1; i < sorted.length; i++) {
@@ -837,19 +860,57 @@ export function detectMultiKills(
currentGroup.push(sorted[i]); currentGroup.push(sorted[i]);
} else { } else {
if (currentGroup.length >= 2) { if (currentGroup.length >= 2) {
groups.push(currentGroup); // Check for death in this window
const firstTime = currentGroup[0].video_timestamp[0] + currentGroup[0].video_timestamp[1] / 1e9;
const lastTime = currentGroup[currentGroup.length - 1].video_timestamp[0] + currentGroup[currentGroup.length - 1].video_timestamp[1] / 1e9;
const death = findDeathInWindow(deathEvents, firstTime, lastTime, windowSecs);
groups.push({
kills: currentGroup,
death,
isTradeKill: death !== null,
});
} }
currentGroup = [sorted[i]]; currentGroup = [sorted[i]];
} }
} }
// Handle last group
if (currentGroup.length >= 2) { if (currentGroup.length >= 2) {
groups.push(currentGroup); const firstTime = currentGroup[0].video_timestamp[0] + currentGroup[0].video_timestamp[1] / 1e9;
const lastTime = currentGroup[currentGroup.length - 1].video_timestamp[0] + currentGroup[currentGroup.length - 1].video_timestamp[1] / 1e9;
const death = findDeathInWindow(deathEvents, firstTime, lastTime, windowSecs);
groups.push({
kills: currentGroup,
death,
isTradeKill: death !== null,
});
} }
return groups; return groups;
} }
/**
* Find a death event within the multi-kill window.
* Checks from the first kill to the last kill, plus the window buffer.
*/
function findDeathInWindow(
deathEvents: TimestampedEvent[],
firstKillTime: number,
lastKillTime: number,
windowSecs: number,
): TimestampedEvent | null {
for (const death of deathEvents) {
const deathTime = death.video_timestamp[0] + death.video_timestamp[1] / 1e9;
// Death must occur within the window starting from first kill
if (deathTime >= firstKillTime && deathTime <= lastKillTime + windowSecs) {
return death;
}
}
return null;
}
/** /**
* Get the multi-kill label based on number of kills. * Get the multi-kill label based on number of kills.
*/ */
@@ -872,6 +933,7 @@ export function getHighlightTypeColor(type: HighlightType): string {
case "death": return "#f87171"; // red case "death": return "#f87171"; // red
case "assist": return "#a78bfa"; // purple case "assist": return "#a78bfa"; // purple
case "multi_kill": return "#f97316"; // orange case "multi_kill": return "#f97316"; // orange
case "trade_kill": return "#fbbf24"; // amber/yellow
} }
} }
@@ -884,6 +946,7 @@ export function getHighlightTypeIcon(type: HighlightType): string {
case "death": return "💀"; case "death": return "💀";
case "assist": return "🤝"; case "assist": return "🤝";
case "multi_kill": return "🔥"; case "multi_kill": return "🔥";
case "trade_kill": return "🔄";
} }
} }
@@ -975,24 +1038,49 @@ export function computeHighlights(
}); });
} }
// Detect multi-kills and create clips for them (only if enabled) // Detect multi-kills and trade kills (only if enabled)
if (settings.included_types.includes("multi_kill")) { if (settings.included_types.includes("multi_kill") || settings.included_types.includes("trade_kill")) {
const multiKillGroups = detectMultiKills(playerKills); const multiKillGroups = detectMultiKills(playerKills, playerDeaths);
for (const group of multiKillGroups) { for (const group of multiKillGroups) {
const firstTime = group[0].video_timestamp[0] + group[0].video_timestamp[1] / 1e9; // Skip if neither multi_kill nor trade_kill is enabled
if (!settings.included_types.includes("multi_kill") && !settings.included_types.includes("trade_kill")) {
continue;
}
// If it's a trade kill but trade_kill is not enabled, skip
if (group.isTradeKill && !settings.included_types.includes("trade_kill")) {
continue;
}
// If it's a multi kill but multi_kill is not enabled, skip
if (!group.isTradeKill && !settings.included_types.includes("multi_kill")) {
continue;
}
const firstTime = group.kills[0].video_timestamp[0] + group.kills[0].video_timestamp[1] / 1e9;
const lastTime = const lastTime =
group[group.length - 1].video_timestamp[0] + group.kills[group.kills.length - 1].video_timestamp[0] +
group[group.length - 1].video_timestamp[1] / 1e9; group.kills[group.kills.length - 1].video_timestamp[1] / 1e9;
const label = getMultiKillLabel(group.length); const label = getMultiKillLabel(group.kills.length);
// Include death event if present
const allEvents = group.death ? [...group.kills, group.death] : group.kills;
// Extend end time if death occurred after last kill
const endTime = group.death
? Math.max(lastTime, group.death.video_timestamp[0] + group.death.video_timestamp[1] / 1e9)
: lastTime;
clips.push({ clips.push({
id: clipId++, id: clipId++,
start_time: Math.max(0, firstTime - settings.buffer_before), start_time: Math.max(0, firstTime - settings.buffer_before),
end_time: Math.min(duration, lastTime + settings.buffer_after), end_time: Math.min(duration, endTime + settings.buffer_after),
highlight_type: "multi_kill", highlight_type: group.isTradeKill ? "trade_kill" : "multi_kill",
events: group, events: allEvents,
title: label || `${group.length}x Kill`, title: group.isTradeKill ? `Trade Kill` : (label || `${group.kills.length}x Kill`),
subtitle: `${group.length} kills in quick succession`, subtitle: group.isTradeKill
? `${group.kills.length} kills but died in the trade`
: `${group.kills.length} kills in quick succession`,
}); });
} }
} }
@@ -1013,7 +1101,7 @@ export function computeHighlights(
current.end_time = Math.max(current.end_time, next.end_time); current.end_time = Math.max(current.end_time, next.end_time);
current.events = [...current.events, ...next.events]; current.events = [...current.events, ...next.events];
// Keep the "more important" highlight type // Keep the "more important" highlight type
const typePriority: HighlightType[] = ["multi_kill", "kill", "death", "assist"]; const typePriority: HighlightType[] = ["multi_kill", "trade_kill", "kill", "death", "assist"];
const currentIdx = typePriority.indexOf(current.highlight_type); const currentIdx = typePriority.indexOf(current.highlight_type);
const nextIdx = typePriority.indexOf(next.highlight_type); const nextIdx = typePriority.indexOf(next.highlight_type);
if (nextIdx < currentIdx) { if (nextIdx < currentIdx) {
@@ -1021,14 +1109,39 @@ export function computeHighlights(
current.title = next.title; current.title = next.title;
current.subtitle = next.subtitle; current.subtitle = next.subtitle;
} }
// If both are kills, check if combined they form a multi-kill // If both are kills, check if combined they form a multi-kill or trade kill
const killEvents = current.events.filter(e => e.event_type === "kill"); // Only count kills where the player is the killer, not all kill events
if (killEvents.length >= 2) { // Deduplicate by video_timestamp since same event can appear in multiple merged clips
const label = getMultiKillLabel(killEvents.length); const seenTimestamps = new Set<string>();
const playerKillEvents = current.events.filter(e => {
if (e.event_type !== "kill") return false;
const rawData = e.raw_data as { KillerName?: string } | null;
if (rawData?.KillerName !== localPlayerName) return false;
// Deduplicate by timestamp
const key = `${e.video_timestamp[0]}-${e.video_timestamp[1]}`;
if (seenTimestamps.has(key)) return false;
seenTimestamps.add(key);
return true;
});
const playerDeathEvents = current.events.filter(e => {
if (e.event_type !== "kill") return false;
const rawData = e.raw_data as { VictimName?: string } | null;
if (rawData?.VictimName !== localPlayerName) return false;
// Deduplicate by timestamp
const key = `${e.video_timestamp[0]}-${e.video_timestamp[1]}`;
if (seenTimestamps.has(key)) return false;
seenTimestamps.add(key);
return true;
});
if (playerKillEvents.length >= 2) {
const label = getMultiKillLabel(playerKillEvents.length);
if (label) { if (label) {
current.highlight_type = "multi_kill"; const isTradeKill = playerDeathEvents.length > 0;
current.title = label; current.highlight_type = isTradeKill ? "trade_kill" : "multi_kill";
current.subtitle = `${killEvents.length} kills`; current.title = isTradeKill ? `Trade Kill` : label;
current.subtitle = isTradeKill
? `${playerKillEvents.length} kills but died in the trade`
: `${playerKillEvents.length} kills`;
} }
} }
} else { } else {