diff --git a/src-tauri/src/commands/league/mod.rs b/src-tauri/src/commands/league/mod.rs index 64a3bb82..31992b3b 100644 --- a/src-tauri/src/commands/league/mod.rs +++ b/src-tauri/src/commands/league/mod.rs @@ -1501,6 +1501,218 @@ pub async fn league_install_dir() -> Result { Ok(json!({ "path": dir.map(|p| p.to_string_lossy().to_string()) })) } +/// Full detail of one match: every participant with runes, items, spells and the +/// long tail of stats the client keeps but its own end-of-game screen hides, +/// plus the puuid needed to look a player up afterwards. +#[tauri::command] +pub async fn league_match_detail(game_id: i64) -> Result { + ensure_enabled()?; + if game_id <= 0 { + return Err("invalid game".to_string()); + } + let client = get_client().await?; + let detail = lcu_get_raw(&client, &format!("/lol-match-history/v1/games/{}", game_id)).await?; + + let identities: Vec = detail + .get("participantIdentities") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let identity_of = |participant_id: i64| -> Option { + identities + .iter() + .find(|i| i.get("participantId").and_then(Value::as_i64) == Some(participant_id)) + .and_then(|i| i.get("player").cloned()) + }; + + let participants: Vec = detail + .get("participants") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() + .iter() + .map(|p| { + let stats = p.get("stats").cloned().unwrap_or(Value::Null); + let timeline = p.get("timeline").cloned().unwrap_or(Value::Null); + let participant_id = p.get("participantId").and_then(Value::as_i64).unwrap_or(0); + let player = identity_of(participant_id).unwrap_or(Value::Null); + let num = |key: &str| -> i64 { stats.get(key).and_then(Value::as_i64).unwrap_or(0) }; + + let mut row = serde_json::Map::new(); + row.insert("participantId".into(), json!(participant_id)); + row.insert( + "teamId".into(), + json!(p.get("teamId").and_then(Value::as_i64).unwrap_or(0)), + ); + row.insert( + "championId".into(), + json!(p.get("championId").and_then(Value::as_i64).unwrap_or(0)), + ); + row.insert( + "puuid".into(), + json!(player.get("puuid").and_then(Value::as_str).unwrap_or("")), + ); + row.insert( + "gameName".into(), + json!(player + .get("gameName") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .or_else(|| player.get("summonerName").and_then(Value::as_str)) + .unwrap_or("")), + ); + row.insert( + "tagLine".into(), + json!(player.get("tagLine").and_then(Value::as_str).unwrap_or("")), + ); + row.insert( + "win".into(), + json!(stats.get("win").and_then(Value::as_bool).unwrap_or(false)), + ); + row.insert( + "spells".into(), + json!([ + p.get("spell1Id").and_then(Value::as_i64).unwrap_or(0), + p.get("spell2Id").and_then(Value::as_i64).unwrap_or(0), + ]), + ); + row.insert( + "items".into(), + json!((0..7) + .map(|i| num(&format!("item{}", i))) + .collect::>()), + ); + row.insert( + "runes".into(), + json!({ + "primaryStyle": num("perkPrimaryStyle"), + "subStyle": num("perkSubStyle"), + "perks": (0..6).map(|i| num(&format!("perk{}", i))).collect::>(), + "statMods": [num("statPerk0"), num("statPerk1"), num("statPerk2")], + }), + ); + for (key, value) in [ + ("kills", num("kills")), + ("deaths", num("deaths")), + ("assists", num("assists")), + ("level", num("champLevel")), + ( + "cs", + num("totalMinionsKilled") + num("neutralMinionsKilled"), + ), + ("gold", num("goldEarned")), + ("damageToChampions", num("totalDamageDealtToChampions")), + ("physicalDamage", num("physicalDamageDealtToChampions")), + ("magicDamage", num("magicDamageDealtToChampions")), + ("trueDamage", num("trueDamageDealtToChampions")), + ("damageTaken", num("totalDamageTaken")), + ("damageMitigated", num("damageSelfMitigated")), + ("damageToObjectives", num("damageDealtToObjectives")), + ("damageToTurrets", num("damageDealtToTurrets")), + ("healing", num("totalHeal")), + ("shielding", num("totalDamageShieldedOnTeammates")), + ("visionScore", num("visionScore")), + ("wardsPlaced", num("wardsPlaced")), + ("wardsKilled", num("wardsKilled")), + ("controlWards", num("visionWardsBoughtInGame")), + ("ccTime", num("timeCCingOthers")), + ("largestSpree", num("largestKillingSpree")), + ("largestMultiKill", num("largestMultiKill")), + ("doubleKills", num("doubleKills")), + ("tripleKills", num("tripleKills")), + ("quadraKills", num("quadraKills")), + ("pentaKills", num("pentaKills")), + ("turretKills", num("turretKills")), + ("inhibitorKills", num("inhibitorKills")), + ] { + row.insert(key.into(), json!(value)); + } + row.insert( + "firstBlood".into(), + json!(stats + .get("firstBloodKill") + .and_then(Value::as_bool) + .unwrap_or(false)), + ); + row.insert( + "firstTower".into(), + json!(stats + .get("firstTowerKill") + .and_then(Value::as_bool) + .unwrap_or(false)), + ); + row.insert( + "lane".into(), + json!(timeline.get("lane").and_then(Value::as_str).unwrap_or("")), + ); + row.insert( + "role".into(), + json!(timeline.get("role").and_then(Value::as_str).unwrap_or("")), + ); + Value::Object(row) + }) + .collect(); + + Ok(json!({ + "gameId": game_id, + "gameMode": detail.get("gameMode").and_then(Value::as_str).unwrap_or(""), + "queueId": detail.get("queueId").and_then(Value::as_i64).unwrap_or(0), + "gameCreation": detail.get("gameCreation").and_then(Value::as_i64).unwrap_or(0), + "gameDuration": detail.get("gameDuration").and_then(Value::as_i64).unwrap_or(0), + "teams": detail.get("teams").cloned().unwrap_or(Value::Null), + "participants": participants, + })) +} + +/// Recent games of any player by puuid, for looking someone up straight from a +/// scoreboard instead of retyping their name. +#[tauri::command] +pub async fn league_player_history( + puuid: String, + beg_index: Option, + end_index: Option, +) -> Result { + ensure_enabled()?; + if puuid.is_empty() || !puuid.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') { + return Err("invalid player".to_string()); + } + let beg = beg_index.unwrap_or(0).max(0); + let end = end_index.unwrap_or(beg + 9).max(beg); + let client = get_client().await?; + lcu_get_raw( + &client, + &format!( + "/lol-match-history/v1/products/lol/{}/matches?begIndex={}&endIndex={}", + puuid, beg, end + ), + ) + .await +} + +/// Perk metadata (name and icon) so runes can be rendered without hardcoding +/// paths. Cached by the patch-static rule. +#[tauri::command] +pub async fn league_perks() -> Result { + ensure_enabled()?; + let client = get_client().await?; + let perks = lcu_get_raw(&client, "/lol-perks/v1/perks").await?; + let list: Vec = perks + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|p| { + Some(json!({ + "id": p.get("id").and_then(Value::as_i64)?, + "name": p.get("name").and_then(Value::as_str).unwrap_or(""), + "iconPath": p.get("iconPath").and_then(Value::as_str).unwrap_or(""), + })) + }) + .collect() + }) + .unwrap_or_default(); + Ok(json!({ "perks": list })) +} + /// Objective respawn estimates and a readable feed of what just happened, /// derived from the in-game event log. #[tauri::command] diff --git a/src-tauri/src/commands/league/ws.rs b/src-tauri/src/commands/league/ws.rs index 2cdcb3ef..59945320 100644 --- a/src-tauri/src/commands/league/ws.rs +++ b/src-tauri/src/commands/league/ws.rs @@ -16,6 +16,10 @@ static WS_CONNECTED: AtomicBool = AtomicBool::new(false); static MESSAGE_SENT: AtomicBool = AtomicBool::new(false); static ACCEPT_PENDING: AtomicBool = AtomicBool::new(false); static NOTIFIED_READY_CHECK: AtomicBool = AtomicBool::new(false); +#[allow(clippy::type_complexity)] +static LAST_CHAMP_SELECT: once_cell::sync::Lazy< + tokio::sync::Mutex>, +> = once_cell::sync::Lazy::new(|| tokio::sync::Mutex::new(None)); static TRADES_HANDLED: once_cell::sync::Lazy>> = once_cell::sync::Lazy::new(|| tokio::sync::Mutex::new(std::collections::HashSet::new())); #[allow(clippy::type_complexity)] @@ -197,6 +201,103 @@ async fn seed_state(client: &LcuClient) { } } +/// The client republishes the champion select session several times per second — +/// the phase timer alone changes on every tick. Re-emitting the whole object that +/// often floods the webview: every event replaces a large state object and forces +/// each mounted panel to re-render, which is enough to lock the UI thread. +/// +/// The fingerprint covers what the UI actually reacts to, so a session that only +/// advanced its clock is dropped. A slower heartbeat still gets through, so a +/// missed change can never leave the panel stale for long. +fn champ_select_fingerprint(session: &Value) -> String { + let mut parts: Vec = Vec::new(); + parts.push( + session + .get("localPlayerCellId") + .and_then(Value::as_i64) + .unwrap_or(-1) + .to_string(), + ); + if let Some(groups) = session.get("actions").and_then(Value::as_array) { + for group in groups { + for action in group.as_array().map(|a| a.as_slice()).unwrap_or(&[]) { + parts.push(format!( + "a{}:{}:{}:{}", + action.get("id").and_then(Value::as_i64).unwrap_or(-1), + action + .get("championId") + .and_then(Value::as_i64) + .unwrap_or(0), + action + .get("completed") + .and_then(Value::as_bool) + .unwrap_or(false), + action + .get("isInProgress") + .and_then(Value::as_bool) + .unwrap_or(false), + )); + } + } + } + for key in ["myTeam", "theirTeam"] { + for member in session + .get(key) + .and_then(Value::as_array) + .unwrap_or(&vec![]) + { + parts.push(format!( + "m{}:{}:{}", + member.get("cellId").and_then(Value::as_i64).unwrap_or(-1), + member + .get("championId") + .and_then(Value::as_i64) + .unwrap_or(0), + member + .get("championPickIntent") + .and_then(Value::as_i64) + .unwrap_or(0), + )); + } + } + for key in [ + "benchChampions", + "trades", + "positionSwaps", + "pickOrderSwaps", + ] { + for entry in session + .get(key) + .and_then(Value::as_array) + .unwrap_or(&vec![]) + { + parts.push(format!( + "{}{}:{}:{}", + key, + entry.get("id").and_then(Value::as_i64).unwrap_or(-1), + entry.get("championId").and_then(Value::as_i64).unwrap_or(0), + entry.get("state").and_then(Value::as_str).unwrap_or(""), + )); + } + } + parts.join("|") +} + +/// True when this session is worth pushing to the UI: something the panels read +/// changed, or the heartbeat window elapsed. +async fn should_emit_champ_select(session: &Value) -> bool { + const HEARTBEAT: std::time::Duration = std::time::Duration::from_millis(1500); + let fingerprint = champ_select_fingerprint(session); + let mut last = LAST_CHAMP_SELECT.lock().await; + match last.as_ref() { + Some((previous, at)) if previous == &fingerprint && at.elapsed() < HEARTBEAT => false, + _ => { + *last = Some((fingerprint, std::time::Instant::now())); + true + } + } +} + async fn handle_event(client: &LcuClient, value: &Value) { let payload = match value.as_array().and_then(|a| a.get(2)) { Some(p) => p, @@ -275,10 +376,13 @@ async fn handle_event(client: &LcuClient, value: &Value) { TRADES_HANDLED.lock().await.clear(); SWAPS_HANDLED.lock().await.clear(); MESSAGE_SENT.store(false, Ordering::SeqCst); + *LAST_CHAMP_SELECT.lock().await = None; emit("league-champ-select", Value::Null); return; } - emit("league-champ-select", data.clone()); + if should_emit_champ_select(&data).await { + emit("league-champ-select", data.clone()); + } let settings = league_settings(); if settings.auto_pick || settings.auto_ban { if let Err(e) = super::handle_champ_select(client, &settings, &data).await { @@ -595,4 +699,55 @@ mod tests { assert_eq!(accept_delay_seconds(3), 3); assert_eq!(accept_delay_seconds(0), 0); } + + #[test] + fn a_session_that_only_advanced_its_clock_has_the_same_fingerprint() { + let base = json!({ + "localPlayerCellId": 2, + "timer": { "adjustedTimeLeftInPhase": 27000, "phase": "BAN_PICK" }, + "actions": [[{ "id": 5, "championId": 64, "completed": false, "isInProgress": true }]], + "myTeam": [{ "cellId": 2, "championId": 64, "championPickIntent": 0 }] + }); + let mut ticked = base.clone(); + ticked["timer"]["adjustedTimeLeftInPhase"] = json!(100); + assert_eq!( + champ_select_fingerprint(&base), + champ_select_fingerprint(&ticked), + "the phase clock must not count as a change" + ); + } + + #[test] + fn anything_the_panels_read_changes_the_fingerprint() { + let base = json!({ + "localPlayerCellId": 2, + "actions": [[{ "id": 5, "championId": 64, "completed": false, "isInProgress": true }]], + "myTeam": [{ "cellId": 2, "championId": 64, "championPickIntent": 0 }], + "benchChampions": [{ "championId": 12 }], + "trades": [{ "id": 1, "state": "AVAILABLE" }] + }); + let fingerprint = champ_select_fingerprint(&base); + + let mut locked = base.clone(); + locked["actions"][0][0]["completed"] = json!(true); + assert_ne!(fingerprint, champ_select_fingerprint(&locked)); + + let mut hovered = base.clone(); + hovered["myTeam"][0]["championPickIntent"] = json!(99); + assert_ne!(fingerprint, champ_select_fingerprint(&hovered)); + + let mut bench = base.clone(); + bench["benchChampions"][0]["championId"] = json!(34); + assert_ne!(fingerprint, champ_select_fingerprint(&bench)); + + let mut trade = base.clone(); + trade["trades"][0]["state"] = json!("RECEIVED"); + assert_ne!(fingerprint, champ_select_fingerprint(&trade)); + } + + #[test] + fn an_empty_session_is_fingerprinted_without_panicking() { + assert!(!champ_select_fingerprint(&json!({})).is_empty()); + assert!(!champ_select_fingerprint(&Value::Null).is_empty()); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 384d77af..5afb5fac 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -819,6 +819,9 @@ pub fn run() { commands::league::league_summoner, commands::league::league_ranked, commands::league::league_gameflow, + commands::league::league_match_detail, + commands::league::league_player_history, + commands::league::league_perks, commands::league::league_match_history, commands::league::league_accept_ready_check, commands::league::league_auto_accept_set, diff --git a/src/components/league/HistoryTab.svelte b/src/components/league/HistoryTab.svelte index 61cccb9f..27d1dc25 100644 --- a/src/components/league/HistoryTab.svelte +++ b/src/components/league/HistoryTab.svelte @@ -2,7 +2,7 @@ import { invoke } from "@tauri-apps/api/core"; import { t, locale } from "$lib/i18n"; import timeAgo from "$lib/time-ago"; - import { CDRAGON, type Champion } from "./shared"; + import { CDRAGON, assetUrl, type Champion } from "./shared"; import { filterByQueue, queuesInGames, summarise } from "$lib/league-history"; import { findTeam, teamBans, teamObjectives } from "$lib/league-match-detail"; @@ -70,10 +70,10 @@ if (gameDetails[gameId]) return; gameDetailLoading = gameId; try { - const detail = await invoke("league_get", { - path: `/lol-match-history/v1/games/${gameId}`, - }); + const detail = await invoke("league_match_detail", { gameId }); gameDetails = { ...gameDetails, [gameId]: detail }; + expandedDuration = detail?.gameDuration ?? 0; + loadPerkIcons(); } catch { gameDetails = { ...gameDetails, [gameId]: null }; } finally { @@ -81,30 +81,86 @@ } } + let perkIcons = $state>({}); + let lookupPuuid = $state(""); + let lookupName = $state(""); + let lookupGames = $state([]); + let lookupLoading = $state(false); + let lookupError = $state(""); + + async function loadPerkIcons() { + if (Object.keys(perkIcons).length > 0) return; + try { + const res = await invoke("league_perks"); + const map: Record = {}; + for (const perk of res?.perks ?? []) { + if (perk?.id && perk?.iconPath) map[perk.id] = assetUrl(perk.iconPath); + } + perkIcons = map; + } catch { + perkIcons = {}; + } + } + + async function openPlayer(player: any) { + if (!player?.puuid) return; + lookupPuuid = player.puuid; + lookupName = player.tagLine ? `${player.gameName}#${player.tagLine}` : player.gameName; + lookupGames = []; + lookupError = ""; + lookupLoading = true; + try { + const res = await invoke("league_player_history", { + puuid: player.puuid, + begIndex: 0, + endIndex: 9, + }); + lookupGames = res?.games?.games ?? []; + } catch (e: any) { + lookupError = typeof e === "string" ? e : (e?.message ?? String(e)); + } finally { + lookupLoading = false; + } + } + + function closeLookup() { + lookupPuuid = ""; + lookupGames = []; + lookupError = ""; + } + + function statLine(p: any): { key: string; label: string; value: string }[] { + const minutes = Math.max((expandedDuration || 1) / 60, 0.1); + return [ + { key: "dmg", label: "league.stat_damage", value: `${(p.damageToChampions / 1000).toFixed(1)}k` }, + { key: "taken", label: "league.stat_taken", value: `${(p.damageTaken / 1000).toFixed(1)}k` }, + { key: "mitigated", label: "league.stat_mitigated", value: `${(p.damageMitigated / 1000).toFixed(1)}k` }, + { key: "heal", label: "league.stat_healing", value: `${(p.healing / 1000).toFixed(1)}k` }, + { key: "obj", label: "league.stat_objectives", value: `${(p.damageToObjectives / 1000).toFixed(1)}k` }, + { key: "gpm", label: "league.stat_gold_min", value: Math.round(p.gold / minutes).toString() }, + { key: "cspm", label: "league.stat_cs_min", value: (p.cs / minutes).toFixed(1) }, + { key: "vision", label: "league.stat_vision", value: String(p.visionScore) }, + { key: "wards", label: "league.stat_wards", value: `${p.wardsPlaced}/${p.wardsKilled}/${p.controlWards}` }, + { key: "cc", label: "league.stat_cc", value: `${p.ccTime}s` }, + { key: "spree", label: "league.stat_spree", value: String(p.largestSpree) }, + ]; + } + + let expandedDuration = $state(0); + function scoreboardTeams(detail: any): { teamId: number; players: any[] }[] { - const participants: any[] = detail?.participants ?? []; - const identities: any[] = detail?.participantIdentities ?? []; - const nameOf = (pid: number): string => { - const player = identities.find((i) => i.participantId === pid)?.player; - if (!player) return ""; - return player.gameName || player.summonerName || ""; - }; - const rows = participants.map((p) => ({ - participantId: p.participantId, - teamId: p.teamId, - championId: p.championId, - name: nameOf(p.participantId), - kills: p.stats?.kills ?? 0, - deaths: p.stats?.deaths ?? 0, - assists: p.stats?.assists ?? 0, - cs: (p.stats?.totalMinionsKilled ?? 0) + (p.stats?.neutralMinionsKilled ?? 0), - gold: p.stats?.goldEarned ?? 0, - damage: p.stats?.totalDamageDealtToChampions ?? 0, - win: p.stats?.win ?? false, - })); + const rows: any[] = detail?.participants ?? []; const teamIds = [...new Set(rows.map((r) => r.teamId))]; return teamIds.map((teamId) => ({ teamId, players: rows.filter((r) => r.teamId === teamId) })); } + + function itemIcon(id: number): string { + return `${CDRAGON}/../../game/assets/items/icons2d/${id}.png`; + } + + function spellIcon(id: number): string { + return `${CDRAGON}/summoner-spells/${id}.png`; + }
@@ -194,13 +250,55 @@ {/if} {#each team.players as sp (sp.participantId)} -
- - {sp.name || championById.get(sp.championId)?.name || "—"} - {sp.kills}/{sp.deaths}/{sp.assists} - {sp.cs} CS - {(sp.gold / 1000).toFixed(1)}k - {(sp.damage / 1000).toFixed(1)}k {$t("league.match_damage")} +
+
+ +
+ {#each sp.spells ?? [] as spell (spell)} + { (e.currentTarget as HTMLImageElement).style.visibility = "hidden"; }} /> + {/each} +
+ {#if sp.runes} +
+ {#if perkIcons[sp.runes.perks?.[0]]} + + {/if} + {#if sp.runes.subStyle} + + {/if} +
+ {/if} + {#if sp.puuid} + + {:else} + {sp.gameName || championById.get(sp.championId)?.name || "—"} + {/if} + {sp.kills}/{sp.deaths}/{sp.assists} + {$t("league.stat_level")} {sp.level} + {sp.cs} CS + {(sp.gold / 1000).toFixed(1)}k +
+
+ {#each sp.items ?? [] as item, idx (`${idx}-${item}`)} + {#if item > 0} + { (e.currentTarget as HTMLImageElement).style.visibility = "hidden"; }} /> + {:else} + + {/if} + {/each} +
+
+ {#each statLine(sp) as stat (stat.key)} + {$t(stat.label)} {stat.value} + {/each} + {#if sp.pentaKills > 0}{$t("league.stat_penta")}{/if} + {#if sp.quadraKills > 0}{$t("league.stat_quadra")}{/if} + {#if sp.tripleKills > 0}{$t("league.stat_triple")}{/if} + {#if sp.firstBlood}{$t("league.stat_first_blood")}{/if} + {#if sp.firstTower}{$t("league.stat_first_tower")}{/if} +
{/each}
@@ -213,4 +311,34 @@ {/each} {/if} + {#if lookupPuuid} +
+
+

{lookupName}

+ +
+ {#if lookupLoading} +

+ {:else if lookupError} + + {:else if lookupGames.length === 0} +

{$t("league.history_empty")}

+ {:else} +
+ {#each lookupGames as g (g.gameId)} + {@const lp = playerStats(g)} +
+ +
+ {lp.win ? $t("league.victory") : $t("league.defeat")} + {queueName(g.queueId, g.gameMode)} +
+ {lp.kills} / {lp.deaths} / {lp.assists} + {timeAgo(g.gameCreation, $locale)} +
+ {/each} +
+ {/if} +
+ {/if}
diff --git a/src/lib/i18n/el.json b/src/lib/i18n/el.json index 401edb07..7913f5a3 100644 --- a/src/lib/i18n/el.json +++ b/src/lib/i18n/el.json @@ -273,6 +273,25 @@ "obj_inhibitors": "Αναστολείς", "obj_heralds": "Heralds", "obj_bans": "Bans", + "open_player": "Δες τα πρόσφατα παιχνίδια του παίκτη", + "close": "Κλείσιμο", + "stat_level": "Επ", + "stat_damage": "Ζημιά", + "stat_taken": "Δέχτηκε", + "stat_mitigated": "Μειώθηκε", + "stat_healing": "Θεραπεία", + "stat_objectives": "Στόχοι", + "stat_gold_min": "Χρυσός/λεπτό", + "stat_cs_min": "CS/λεπτό", + "stat_vision": "Όραση", + "stat_wards": "Wards τ/κ/ctrl", + "stat_cc": "CC", + "stat_spree": "Καλύτερο σερί", + "stat_penta": "Pentakill", + "stat_quadra": "Quadrakill", + "stat_triple": "Triple kill", + "stat_first_blood": "Πρώτο αίμα", + "stat_first_tower": "Πρώτος πύργος", "match_damage": "ζημιά", "match_detail_unavailable": "Οι λεπτομέρειες του αγώνα δεν είναι διαθέσιμες.", "pick_list": "Προτεραιότητα pick", diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json index 8d492892..72880bd2 100644 --- a/src/lib/i18n/en.json +++ b/src/lib/i18n/en.json @@ -291,6 +291,25 @@ "obj_inhibitors": "Inhibitors", "obj_heralds": "Heralds", "obj_bans": "Bans", + "open_player": "See this player's recent matches", + "close": "Close", + "stat_level": "Lvl", + "stat_damage": "Dealt", + "stat_taken": "Taken", + "stat_mitigated": "Mitigated", + "stat_healing": "Healing", + "stat_objectives": "Objectives", + "stat_gold_min": "Gold/min", + "stat_cs_min": "CS/min", + "stat_vision": "Vision", + "stat_wards": "Wards p/k/ctrl", + "stat_cc": "CC", + "stat_spree": "Best spree", + "stat_penta": "Pentakill", + "stat_quadra": "Quadrakill", + "stat_triple": "Triple kill", + "stat_first_blood": "First blood", + "stat_first_tower": "First tower", "match_damage": "dmg", "match_detail_unavailable": "Match details unavailable.", "pick_list": "Pick priority", diff --git a/src/lib/i18n/es.json b/src/lib/i18n/es.json index c93a6e83..0f725cf2 100644 --- a/src/lib/i18n/es.json +++ b/src/lib/i18n/es.json @@ -273,6 +273,25 @@ "obj_inhibitors": "Inhibidores", "obj_heralds": "Heraldos", "obj_bans": "Baneos", + "open_player": "Ver las partidas recientes de este jugador", + "close": "Cerrar", + "stat_level": "Nv", + "stat_damage": "Infligido", + "stat_taken": "Recibido", + "stat_mitigated": "Mitigado", + "stat_healing": "Curación", + "stat_objectives": "Objetivos", + "stat_gold_min": "Oro/min", + "stat_cs_min": "CS/min", + "stat_vision": "Visión", + "stat_wards": "Guardianes p/d/ctrl", + "stat_cc": "CC", + "stat_spree": "Mejor racha", + "stat_penta": "Pentakill", + "stat_quadra": "Quadrakill", + "stat_triple": "Triple kill", + "stat_first_blood": "Primera sangre", + "stat_first_tower": "Primera torre", "match_damage": "daño", "match_detail_unavailable": "Detalles de la partida no disponibles.", "pick_list": "Prioridad de picks", diff --git a/src/lib/i18n/fr.json b/src/lib/i18n/fr.json index 2afc5829..004340e3 100644 --- a/src/lib/i18n/fr.json +++ b/src/lib/i18n/fr.json @@ -273,6 +273,25 @@ "obj_inhibitors": "Inhibiteurs", "obj_heralds": "Hérauts", "obj_bans": "Bannissements", + "open_player": "Voir les parties récentes de ce joueur", + "close": "Fermer", + "stat_level": "Nv", + "stat_damage": "Infligés", + "stat_taken": "Subis", + "stat_mitigated": "Atténués", + "stat_healing": "Soins", + "stat_objectives": "Objectifs", + "stat_gold_min": "Or/min", + "stat_cs_min": "CS/min", + "stat_vision": "Vision", + "stat_wards": "Balises p/d/ctrl", + "stat_cc": "CC", + "stat_spree": "Meilleure série", + "stat_penta": "Pentakill", + "stat_quadra": "Quadrakill", + "stat_triple": "Triple kill", + "stat_first_blood": "Premier sang", + "stat_first_tower": "Première tourelle", "match_damage": "dégâts", "match_detail_unavailable": "Détails de la partie indisponibles.", "pick_list": "Priorité de picks", diff --git a/src/lib/i18n/it.json b/src/lib/i18n/it.json index 91ca9309..1bdd0602 100644 --- a/src/lib/i18n/it.json +++ b/src/lib/i18n/it.json @@ -273,6 +273,25 @@ "obj_inhibitors": "Inibitori", "obj_heralds": "Araldi", "obj_bans": "Ban", + "open_player": "Vedi le partite recenti di questo giocatore", + "close": "Chiudi", + "stat_level": "Lv", + "stat_damage": "Inflitti", + "stat_taken": "Subiti", + "stat_mitigated": "Mitigati", + "stat_healing": "Cure", + "stat_objectives": "Obiettivi", + "stat_gold_min": "Oro/min", + "stat_cs_min": "CS/min", + "stat_vision": "Visione", + "stat_wards": "Ward p/d/ctrl", + "stat_cc": "CC", + "stat_spree": "Serie migliore", + "stat_penta": "Pentakill", + "stat_quadra": "Quadrakill", + "stat_triple": "Triple kill", + "stat_first_blood": "Primo sangue", + "stat_first_tower": "Prima torre", "match_damage": "danni", "match_detail_unavailable": "Dettagli della partita non disponibili.", "pick_list": "Priorità pick", diff --git a/src/lib/i18n/ja.json b/src/lib/i18n/ja.json index 5d8f5bf2..803bdc63 100644 --- a/src/lib/i18n/ja.json +++ b/src/lib/i18n/ja.json @@ -273,6 +273,25 @@ "obj_inhibitors": "インヒビター", "obj_heralds": "ヘラルド", "obj_bans": "BAN", + "open_player": "このプレイヤーの直近の試合を見る", + "close": "閉じる", + "stat_level": "Lv", + "stat_damage": "与ダメ", + "stat_taken": "被ダメ", + "stat_mitigated": "軽減", + "stat_healing": "回復", + "stat_objectives": "オブジェクト", + "stat_gold_min": "ゴールド/分", + "stat_cs_min": "CS/分", + "stat_vision": "視界", + "stat_wards": "ワード 設置/破壊/コントロール", + "stat_cc": "CC", + "stat_spree": "最大連続キル", + "stat_penta": "ペンタキル", + "stat_quadra": "クアドラキル", + "stat_triple": "トリプルキル", + "stat_first_blood": "ファーストブラッド", + "stat_first_tower": "ファーストタワー", "match_damage": "ダメージ", "match_detail_unavailable": "試合の詳細を取得できません。", "pick_list": "ピック優先度", diff --git a/src/lib/i18n/keys.ts b/src/lib/i18n/keys.ts index 6bf543d3..40f23dc5 100644 --- a/src/lib/i18n/keys.ts +++ b/src/lib/i18n/keys.ts @@ -442,6 +442,7 @@ export type TranslationKeys = | 'league.chat_build' | 'league.chat_placeholder' | 'league.chat_send' + | 'league.close' | 'league.col_diff' | 'league.col_gold' | 'league.col_gold_hint' @@ -524,6 +525,7 @@ export type TranslationKeys = | 'league.objective_inhibitor' | 'league.objectives_estimate' | 'league.objectives_title' + | 'league.open_player' | 'league.open_settings' | 'league.phase_champ_select' | 'league.phase_end_of_game' @@ -626,6 +628,23 @@ export type TranslationKeys = | 'league.squad_ally' | 'league.squad_enemy' | 'league.start_queue' + | 'league.stat_cc' + | 'league.stat_cs_min' + | 'league.stat_damage' + | 'league.stat_first_blood' + | 'league.stat_first_tower' + | 'league.stat_gold_min' + | 'league.stat_healing' + | 'league.stat_level' + | 'league.stat_mitigated' + | 'league.stat_objectives' + | 'league.stat_penta' + | 'league.stat_quadra' + | 'league.stat_spree' + | 'league.stat_taken' + | 'league.stat_triple' + | 'league.stat_vision' + | 'league.stat_wards' | 'league.stat_winrate' | 'league.status_away' | 'league.status_chat' diff --git a/src/lib/i18n/pt.json b/src/lib/i18n/pt.json index cf4729c5..a66bb712 100644 --- a/src/lib/i18n/pt.json +++ b/src/lib/i18n/pt.json @@ -273,6 +273,25 @@ "obj_inhibitors": "Inibidores", "obj_heralds": "Arautos", "obj_bans": "Bans", + "open_player": "Ver as partidas recentes deste jogador", + "close": "Fechar", + "stat_level": "Nv", + "stat_damage": "Causado", + "stat_taken": "Recebido", + "stat_mitigated": "Mitigado", + "stat_healing": "Cura", + "stat_objectives": "Objetivos", + "stat_gold_min": "Ouro/min", + "stat_cs_min": "CS/min", + "stat_vision": "Visão", + "stat_wards": "Wards p/d/ctrl", + "stat_cc": "CC", + "stat_spree": "Melhor sequência", + "stat_penta": "Pentakill", + "stat_quadra": "Quadrakill", + "stat_triple": "Triple kill", + "stat_first_blood": "First blood", + "stat_first_tower": "Primeira torre", "match_damage": "dano", "match_detail_unavailable": "Detalhes da partida indisponíveis.", "pick_list": "Prioridade de pick", diff --git a/src/lib/i18n/ru.json b/src/lib/i18n/ru.json index 234ff9ee..e037be71 100644 --- a/src/lib/i18n/ru.json +++ b/src/lib/i18n/ru.json @@ -273,6 +273,25 @@ "obj_inhibitors": "Ингибиторы", "obj_heralds": "Вестники", "obj_bans": "Баны", + "open_player": "Посмотреть последние матчи игрока", + "close": "Закрыть", + "stat_level": "Ур", + "stat_damage": "Нанесено", + "stat_taken": "Получено", + "stat_mitigated": "Смягчено", + "stat_healing": "Лечение", + "stat_objectives": "Объекты", + "stat_gold_min": "Золото/мин", + "stat_cs_min": "CS/мин", + "stat_vision": "Обзор", + "stat_wards": "Варды п/у/контр", + "stat_cc": "Контроль", + "stat_spree": "Лучшая серия", + "stat_penta": "Пентакилл", + "stat_quadra": "Квадракилл", + "stat_triple": "Тройное убийство", + "stat_first_blood": "Первая кровь", + "stat_first_tower": "Первая башня", "match_damage": "урон", "match_detail_unavailable": "Детали матча недоступны.", "pick_list": "Приоритет пиков", diff --git a/src/lib/i18n/zh-TW.json b/src/lib/i18n/zh-TW.json index 6bbc44fd..6f8b7604 100644 --- a/src/lib/i18n/zh-TW.json +++ b/src/lib/i18n/zh-TW.json @@ -273,6 +273,25 @@ "obj_inhibitors": "水晶兵營", "obj_heralds": "峽谷先鋒", "obj_bans": "禁用", + "open_player": "查看該玩家的近期對局", + "close": "關閉", + "stat_level": "等級", + "stat_damage": "造成", + "stat_taken": "承受", + "stat_mitigated": "減免", + "stat_healing": "治療", + "stat_objectives": "目標", + "stat_gold_min": "金幣/分", + "stat_cs_min": "補刀/分", + "stat_vision": "視野", + "stat_wards": "眼位 放/排/控", + "stat_cc": "控制", + "stat_spree": "最高連殺", + "stat_penta": "五殺", + "stat_quadra": "四殺", + "stat_triple": "三殺", + "stat_first_blood": "一血", + "stat_first_tower": "首塔", "match_damage": "傷害", "match_detail_unavailable": "無法取得對局詳情。", "pick_list": "選角優先序", diff --git a/src/lib/i18n/zh.json b/src/lib/i18n/zh.json index 3971ffa4..beafcf57 100644 --- a/src/lib/i18n/zh.json +++ b/src/lib/i18n/zh.json @@ -273,6 +273,25 @@ "obj_inhibitors": "水晶兵营", "obj_heralds": "峡谷先锋", "obj_bans": "禁用", + "open_player": "查看该玩家的近期对局", + "close": "关闭", + "stat_level": "等级", + "stat_damage": "造成", + "stat_taken": "承受", + "stat_mitigated": "减免", + "stat_healing": "治疗", + "stat_objectives": "目标", + "stat_gold_min": "金币/分", + "stat_cs_min": "补刀/分", + "stat_vision": "视野", + "stat_wards": "眼位 放/排/控", + "stat_cc": "控制", + "stat_spree": "最高连杀", + "stat_penta": "五杀", + "stat_quadra": "四杀", + "stat_triple": "三杀", + "stat_first_blood": "一血", + "stat_first_tower": "首塔", "match_damage": "伤害", "match_detail_unavailable": "无法获取对局详情。", "pick_list": "选用优先级", diff --git a/src/routes/league/+page.svelte b/src/routes/league/+page.svelte index f1807c23..c4e61902 100644 --- a/src/routes/league/+page.svelte +++ b/src/routes/league/+page.svelte @@ -197,27 +197,46 @@ } } + // Without these guards a slow client lets every 4s tick queue another round of + // requests; the replies pile up on the UI thread and freeze the whole window. + let liveMetricsInFlight = false; + let liveEventsInFlight = false; + let cooldownsInFlight = false; + let refreshInFlight = false; + async function loadLiveMetrics() { + if (liveMetricsInFlight) return; + liveMetricsInFlight = true; try { liveMetrics = await invoke("league_live_metrics"); } catch { liveMetrics = null; + } finally { + liveMetricsInFlight = false; } } async function loadLiveEvents() { + if (liveEventsInFlight) return; + liveEventsInFlight = true; try { liveEvents = await invoke("league_live_events"); } catch { liveEvents = null; + } finally { + liveEventsInFlight = false; } } async function loadCooldowns() { + if (cooldownsInFlight) return; + cooldownsInFlight = true; try { cooldowns = await invoke("league_ability_cooldowns"); } catch { cooldowns = null; + } finally { + cooldownsInFlight = false; } } @@ -267,6 +286,19 @@ } async function refreshPhaseData() { + // A tick that arrives while the previous one is still running is dropped + // rather than queued: the client is the slow part, and stacking rounds of + // requests is what makes the window stop responding. + if (refreshInFlight) return; + refreshInFlight = true; + try { + await refreshPhaseDataInner(); + } finally { + refreshInFlight = false; + } + } + + async function refreshPhaseDataInner() { if (phase === "ChampSelect") { try { champSelect = await invoke("league_champ_select_session"); @@ -1933,6 +1965,101 @@ background: var(--surface-hover); } + .league-page :global(.scoreboard-row.full) { + display: flex; + flex-direction: column; + gap: 4px; + padding: 6px 0; + border-bottom: 1px solid var(--border); + } + + .league-page :global(.sb-identity) { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + } + + .league-page :global(.sb-spells), + .league-page :global(.sb-runes) { + display: flex; + gap: 2px; + } + + .league-page :global(.sb-spell), + .league-page :global(.sb-rune) { + width: 14px; + height: 14px; + border-radius: 3px; + } + + .league-page :global(.sb-rune.keystone) { + width: 16px; + height: 16px; + } + + .league-page :global(.sb-name) { + font-size: 12.5px; + min-width: 110px; + } + + .league-page :global(.sb-name.link) { + background: none; + border: none; + padding: 0; + color: var(--text); + cursor: pointer; + text-align: left; + } + + .league-page :global(.sb-name.link:hover) { + color: var(--accent); + text-decoration: underline; + } + + .league-page :global(.sb-items) { + display: flex; + gap: 2px; + } + + .league-page :global(.item-icon.tiny), + .league-page :global(.item-empty) { + width: 16px; + height: 16px; + border-radius: 3px; + } + + .league-page :global(.item-empty) { + border: 1px solid var(--border); + display: inline-block; + } + + .league-page :global(.sb-stats) { + display: flex; + gap: 10px; + flex-wrap: wrap; + font-size: 11px; + font-variant-numeric: tabular-nums; + } + + .league-page :global(.sb-flag) { + font-size: 10.5px; + color: var(--accent); + } + + .league-page :global(.lookup-drawer) { + margin-top: 12px; + padding-top: 10px; + border-top: 1px solid var(--border); + } + + .league-page :global(.game-row.static) { + display: flex; + align-items: center; + gap: 8px; + cursor: default; + } + .league-page :global(.feature-badge) { font-size: 10px; font-weight: 400;