diff --git a/Pengu Loader/plugins/ROSE-ClassicHistoric/index.js b/Pengu Loader/plugins/ROSE-ClassicHistoric/index.js new file mode 100644 index 00000000..48d64790 --- /dev/null +++ b/Pengu Loader/plugins/ROSE-ClassicHistoric/index.js @@ -0,0 +1,305 @@ +/** + * @name ROSE-ClassicHistoric + * @description JADE-only history marker and standard history toast. + */ +(function initJadeHistoric() { + "use strict"; + + const MARK_ID = "rose-jade-historic-mark"; + const TOAST_ID = "rose-jade-historic-toast"; + const STYLE_ID = "rose-jade-historic-style"; + const ASSET = "historic_flag.png"; + let bridge = null; + let active = false; + let skinName = ""; + let imageUrl = ""; + let isInJadeChampSelect = false; + let randomModeActive = false; + let presentationReady = false; + let customModActive = false; + let customModName = ""; + let customModTargetSkinIds = new Set(); + let uiClaimed = false; + + function log(level, message, data = null) { + const method = level === "error" ? "error" : level === "warn" ? "warn" : "log"; + console[method](`[ClassicHistoric] ${message}`, data || ""); + try { + bridge?.send({ type: "plugin-log", source: "ClassicHistoric", level, message, data, timestamp: Date.now() }); + } catch (_) {} + } + + function jadeActive() { + return window.__roseClassicWheelApi?.state?.().active === true; + } + + function claimHistoricUi() { + if (uiClaimed || !window.__roseClassicWheelApi) return; + uiClaimed = true; + const owners = (window.__roseClassicFeatureOwners ||= {}); + owners.historic = true; + window.dispatchEvent(new CustomEvent("rose-classic-feature-owner-change", { + detail: { feature: "historic", ready: true }, + })); + } + + function cleanup() { + document.getElementById(MARK_ID)?.remove(); + document.getElementById(TOAST_ID)?.remove(); + } + + function currentSkinId() { + return Number(window.__roseClassicWheelApi?.currentSelection?.()?.skinId) || 0; + } + + function customModApplies() { + const skinId = currentSkinId(); + return customModActive && skinId > 0 && customModTargetSkinIds.has(skinId); + } + + function renderMarker() { + const anchor = document.querySelector( + ".rose-jade-native-card.rose-jade-native-card--selected:not(.skins-pane__skin-card--placeholder) > .rose-jade-history-anchor" + ); + if (!anchor) { + document.getElementById(MARK_ID)?.remove(); + return; + } + let mark = document.getElementById(MARK_ID); + if (!mark) { + mark = document.createElement("div"); + mark.id = MARK_ID; + mark.setAttribute("aria-label", "Historic skin"); + } + mark.title = "Historic skin"; + if (imageUrl) mark.style.backgroundImage = `url("${imageUrl}")`; + if (mark.parentElement !== anchor) anchor.appendChild(mark); + } + + function createToast() { + const toast = document.createElement("div"); + toast.id = TOAST_ID; + + const toastBody = document.createElement("div"); + toastBody.className = "toast-body"; + + const toastContent = document.createElement("div"); + toastContent.className = "toast-content"; + const frame = document.createElement("lol-uikit-dialog-frame"); + frame.className = "lol-uikit-dialog-frame top dismissable-icon"; + const content = document.createElement("lol-uikit-content-block"); + content.className = "lol-ready-check-notification-party-dodge"; + content.setAttribute("type", "notification"); + const text = document.createElement("p"); + text.className = "rose-jade-historic-text"; + const subBorder = document.createElement("div"); + subBorder.className = "lol-uikit-dialog-frame-sub-border"; + const close = document.createElement("div"); + close.className = "lol-uikit-dialog-frame-toast-close-button"; + close.setAttribute("role", "button"); + close.tabIndex = 0; + close.title = "Close"; + close.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + const dismissType = customModApplies() ? "dismiss-custom-mod" : "dismiss-historic"; + if (dismissType === "dismiss-custom-mod") customModActive = false; + else active = false; + cleanup(); + bridge?.send({ type: dismissType, timestamp: Date.now() }); + }); + + content.appendChild(text); + frame.append(content, subBorder, close); + toastContent.appendChild(frame); + toastBody.appendChild(toastContent); + toast.appendChild(toastBody); + return toast; + } + + function renderToast(displayName) { + if (!displayName) { + document.getElementById(TOAST_ID)?.remove(); + return; + } + let toast = document.getElementById(TOAST_ID); + if (!toast) { + toast = createToast(); + document.body.appendChild(toast); + } + const text = toast.querySelector(".rose-jade-historic-text"); + if (text && text.textContent !== displayName) text.textContent = displayName; + } + + function render() { + const showHistoric = active && !randomModeActive && presentationReady; + const showCustomMod = customModApplies(); + if (!isInJadeChampSelect || !jadeActive() || (!showHistoric && !showCustomMod)) { + cleanup(); + return; + } + if (showHistoric) renderMarker(); + else document.getElementById(MARK_ID)?.remove(); + renderToast(showCustomMod ? customModName : skinName); + } + + function injectStyles() { + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = ` + #${MARK_ID} { + position: absolute; inset: 0; width: 100%; height: 100%; + z-index: 24; pointer-events: none; background: center / contain no-repeat; + } + #${TOAST_ID} { + position: fixed; left: 50%; bottom: calc(10% + 215px); + transform: translateX(-50%); z-index: 10000; display: flex; + align-items: center; justify-content: center; width: auto; max-width: 300px; + margin: 0; padding: 0; box-sizing: border-box; + color: #b2a580; font-size: 14px; line-height: 1.4; pointer-events: none; + } + #${TOAST_ID} .toast-body { + position: relative; display: flex; flex-direction: column; + justify-content: space-between; box-sizing: border-box; width: auto; margin: 0 auto; + } + #${TOAST_ID} .toast-content { + display: flex; align-items: center; justify-content: center; width: 100%; + } + #${TOAST_ID} .lol-uikit-dialog-frame { + position: relative; display: inline-block; border: none; + background: #010a13; box-shadow: 0 0 0 1px rgba(1,10,19,.48); + } + #${TOAST_ID} .lol-uikit-dialog-frame::before { + content: ""; position: absolute; width: calc(100% + 4px); height: calc(100% + 4px); + top: -2px; left: -2px; box-shadow: 0 0 10px 1px rgba(0,0,0,.5); + pointer-events: none; + } + #${TOAST_ID} lol-uikit-content-block { + display: inline-block; position: relative; box-sizing: border-box; + width: auto; padding-left: 25px; padding-right: 25px; + background: transparent; text-align: center; + font-family: "LoL Body",Arial,"Helvetica Neue",Helvetica,sans-serif; + } + #${TOAST_ID} .rose-jade-historic-text { + margin: 0; color: #b2a580; font-size: 14px; line-height: 1.4; + letter-spacing: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + } + #${TOAST_ID} .lol-uikit-dialog-frame-sub-border::before, + #${TOAST_ID} .lol-uikit-dialog-frame-sub-border::after { + content: ""; position: absolute; display: flex; box-sizing: border-box; + left: 12px; width: calc(100% - 24px); height: 0; + border-width: 4px 4px 0 4px; border-image-width: 4px 4px 0 4px; + border-image-slice: 4 4 0 4; border-image-repeat: stretch; border-style: solid; + } + #${TOAST_ID} .lol-uikit-dialog-frame-sub-border::before { + top: -6px; border-image-source: url("/fe/lol-uikit/images/sub-border-primary-horizontal.png"); + transform: rotate(180deg); + } + #${TOAST_ID} .lol-uikit-dialog-frame-sub-border::after { + bottom: -6px; border-image-source: url("/fe/lol-uikit/images/sub-border-secondary-horizontal.png"); + transform: rotate(180deg); + } + #${TOAST_ID} .lol-uikit-dialog-frame-toast-close-button { + position: absolute; top: 2px; right: 2px; width: 16px; height: 16px; + padding: 0; border: 0; border-radius: 50%; cursor: pointer; pointer-events: auto; + background: url("/fe/lol-uikit/images/close.png"), rgba(0,0,0,.7); + background-size: 70% 70%, 100% 100%; background-position: center; + background-repeat: no-repeat; z-index: 10; + } + #${TOAST_ID} .lol-uikit-dialog-frame-toast-close-button:hover { + background: url("/fe/lol-uikit/images/close.png"), rgba(200,50,50,.8); + background-size: 70% 70%, 100% 100%; background-position: center; + background-repeat: no-repeat; + } + `; + document.head.appendChild(style); + } + + function handlePhaseChange(data) { + const phase = String(data?.phase || ""); + const classicMode = + Number(data?.mapId) === 453 || + Number(data?.queueId) === 3260 || + String(data?.gameMode || "").toUpperCase() === "JADE"; + isInJadeChampSelect = + classicMode && (phase === "ChampSelect" || phase === "FINALIZATION"); + if (!isInJadeChampSelect) { + presentationReady = false; + cleanup(); + } + else render(); + } + + function handleWheelLayout(event) { + if (event?.detail?.active === false) { + presentationReady = false; + cleanup(); + return; + } + claimHistoricUi(); + render(); + } + + async function start() { + while (!window.__roseBridge) { + await new Promise((resolve) => setTimeout(resolve, 50)); + } + bridge = window.__roseBridge; + injectStyles(); + bridge.subscribe("historic-state", (data) => { + const nextActive = data?.active === true; + const nextSkinName = String(data?.historicSkinName || ""); + if (!nextActive || nextSkinName !== skinName) presentationReady = false; + active = nextActive; + skinName = nextSkinName; + log("info", "Historic state updated", { active, skinName, randomModeActive }); + render(); + }); + bridge.subscribe("random-mode-state", (data) => { + randomModeActive = data?.active === true; + log("info", "Random state observed", { active: randomModeActive }); + render(); + }); + bridge.subscribe("custom-mod-state", (data) => { + customModActive = data?.active === true; + customModName = customModActive ? String(data?.modName || "") : ""; + customModTargetSkinIds = new Set( + (Array.isArray(data?.targetSkinIds) ? data.targetSkinIds : []) + .map(Number) + .filter((value) => Number.isFinite(value) && value > 0) + ); + const directSkinId = Number(data?.skinId); + if (Number.isFinite(directSkinId) && directSkinId > 0) { + customModTargetSkinIds.add(directSkinId); + } + render(); + }); + bridge.subscribe("local-asset-url", (data) => { + if (data?.assetPath !== ASSET) return; + imageUrl = String(data.url || "").replace("localhost", "127.0.0.1"); + render(); + }); + window.addEventListener("rose-jade-historic-presentation", (event) => { + skinName = String(event?.detail?.skinName || skinName); + presentationReady = true; + render(); + }); + window.addEventListener("rose-jade-historic-presentation-state", (event) => { + presentationReady = event?.detail?.ready === true; + render(); + }); + window.addEventListener("rose-classic-selection-change", render); + bridge.subscribe("phase-change", handlePhaseChange); + const classicState = window.__roseClassicWheelApi?.state?.(); + if (classicState) handlePhaseChange(classicState); + window.addEventListener("rose-jade-wheel-layout", handleWheelLayout); + bridge.onReady(() => bridge.send({ type: "request-local-asset", assetPath: ASSET })); + bridge.send({ type: "request-local-asset", assetPath: ASSET }); + claimHistoricUi(); + log("info", "Classic historic plugin initialized"); + new MutationObserver(render).observe(document.body, { childList: true, subtree: true }); + } + + start(); +})(); diff --git a/Pengu Loader/plugins/ROSE-HistoricMode/index.js b/Pengu Loader/plugins/ROSE-HistoricMode/index.js index 120c97a7..7ba13f6d 100644 --- a/Pengu Loader/plugins/ROSE-HistoricMode/index.js +++ b/Pengu Loader/plugins/ROSE-HistoricMode/index.js @@ -49,6 +49,8 @@ let historicFlagImageUrl = null; // HTTP URL from Python const pendingHistoricFlagRequest = new Map(); // Track pending requests let isInChampSelect = false; // Track if we're in ChampSelect phase + let isInClassicMode = false; + let lastPhaseData = null; let pythonChromaState = null; let customModTargetSkinId = null; let customModTargetSkinIds = new Set(); @@ -134,10 +136,18 @@ } function handlePhaseChange(data) { + lastPhaseData = data; const wasInChampSelect = isInChampSelect; + isInClassicMode = + Number(data?.mapId) === 453 || + Number(data?.queueId) === 3260 || + String(data?.gameMode || "").toUpperCase() === "JADE"; + const classicHistoricOwned = + isInClassicMode && window.__roseClassicFeatureOwners?.historic === true; // Check if we're entering ChampSelect phase isInChampSelect = - data.phase === "ChampSelect" || data.phase === "FINALIZATION"; + !classicHistoricOwned && + (data?.phase === "ChampSelect" || data?.phase === "FINALIZATION"); if (isInChampSelect && !wasInChampSelect) { customModPopupActive = false; @@ -913,6 +923,14 @@ } const handleHistoricSkinNameUpdate = (payload) => { + if ( + isInClassicMode && + window.__roseClassicFeatureOwners?.historic === true + ) { + removeHistoricSkinName(); + return; + } + // A custom-mod popup owns this same visual layer. Historic-state // broadcasts can arrive slightly after the custom-mod selection, so do // not let an inactive historic update erase the custom-mod name. @@ -1194,6 +1212,11 @@ bridge.subscribe("chroma-state", handleChromaStateUpdate); bridge.subscribe("local-asset-url", handleLocalAssetUrl); bridge.subscribe("phase-change", handlePhaseChange); + window.addEventListener("rose-classic-feature-owner-change", (event) => { + if (event?.detail?.feature === "historic" && lastPhaseData) { + handlePhaseChange(lastPhaseData); + } + }); // On bridge (re)connect, re-request assets bridge.onReady(() => { diff --git a/pengu/communication/broadcaster.py b/pengu/communication/broadcaster.py index de4e3787..1b784d60 100644 --- a/pengu/communication/broadcaster.py +++ b/pengu/communication/broadcaster.py @@ -120,11 +120,19 @@ def broadcast_historic_state(self) -> None: historic_skin_id = getattr(self.shared_state, 'historic_skin_id', None) locked_champ_id = getattr(self.shared_state, 'locked_champ_id', None) historic_entry_available = bool(historic_mode_active) - historic_base_skin_id = ( - int(locked_champ_id) * 1000 - if historic_entry_available and locked_champ_id is not None - else None - ) + if historic_entry_available and locked_champ_id is not None: + import injection.classic as classic + from injection.classic import to_regular_skin_id + + resolver = getattr(classic, "default_skin_id_for_state", None) + historic_base_skin_id = ( + resolver(self.shared_state, int(locked_champ_id)) + if callable(resolver) + else int(locked_champ_id) * 1000 + ) + historic_base_skin_id = to_regular_skin_id(historic_base_skin_id) + else: + historic_base_skin_id = None # Handle chroma IDs - they're not in the skin mapping, need to get from chroma cache skin_name = None if historic_skin_id is not None: @@ -159,10 +167,22 @@ def broadcast_historic_state(self) -> None: if self.skin_scraper and self.skin_scraper.cache: chroma_id_map = getattr(self.skin_scraper.cache, "chroma_id_map", None) - if is_chroma_id(historic_skin_id, chroma_id_map): + cache_historic_id = historic_skin_id + if chroma_id_map and historic_skin_id not in chroma_id_map: + from injection.classic import to_regular_skin_id + + canonical_id = int(to_regular_skin_id(historic_skin_id) or 0) + cache_historic_id = next( + ( + value for value in chroma_id_map + if int(to_regular_skin_id(value) or 0) == canonical_id + ), + historic_skin_id, + ) + if is_chroma_id(cache_historic_id, chroma_id_map): # It's a chroma - get chroma name from cache - if chroma_id_map and historic_skin_id in chroma_id_map: - chroma_info = chroma_id_map[historic_skin_id] + if chroma_id_map and cache_historic_id in chroma_id_map: + chroma_info = chroma_id_map[cache_historic_id] chroma_name = chroma_info.get('name', '') skin_name = chroma_name if chroma_name else None else: @@ -411,4 +431,3 @@ def _skin_has_chromas(self, skin_id: Optional[int]) -> bool: return False return False - diff --git a/pengu/communication/message_handler.py b/pengu/communication/message_handler.py index 552ac22d..485c04c6 100644 --- a/pengu/communication/message_handler.py +++ b/pengu/communication/message_handler.py @@ -162,6 +162,11 @@ def __init__( self.mod_storage = mod_storage or ModStorageService() self.injection_manager = injection_manager + def _historic_scope(self) -> str: + from utils.core.historic import historic_scope_for_state + + return historic_scope_for_state(self.shared_state) + def _is_valid_local_league_path(self, game_path: str) -> bool: """Validate a League install path without touching UNC/network paths.""" if not isinstance(game_path, str): @@ -1040,7 +1045,9 @@ def _handle_request_skin_mods(self, payload: dict) -> None: try: from utils.core.historic import get_historic_skin_for_champion, is_custom_mod_path, get_custom_mod_path if champion_id: - historic_value = get_historic_skin_for_champion(champion_id) + historic_value = get_historic_skin_for_champion( + champion_id, self._historic_scope() + ) if historic_value and is_custom_mod_path(historic_value): historic_mod_path = get_custom_mod_path(historic_value) historic_identifier = self._normalize_mod_identifier(historic_mod_path) @@ -1515,11 +1522,14 @@ def _handle_select_skin_mod(self, payload: dict) -> None: champ_id = self.shared_state.selected_custom_mod.get("champion_id") rel_path = self.shared_state.selected_custom_mod.get("relative_path") if champ_id and rel_path: - historic_value = get_historic_skin_for_champion(int(champ_id)) + scope = self._historic_scope() + historic_value = get_historic_skin_for_champion( + int(champ_id), scope + ) if historic_value is not None and is_custom_mod_path(historic_value): historic_path = get_custom_mod_path(historic_value) if historic_path and historic_path.replace("\\", "/") == str(rel_path).replace("\\", "/"): - clear_historic_entry(int(champ_id)) + clear_historic_entry(int(champ_id), scope) log.info("[HISTORIC] Cleared saved custom mod for champion %s", champ_id) except Exception as exc: log.debug("[HISTORIC] Failed to clear saved custom mod on deselect: %s", exc) @@ -1782,11 +1792,14 @@ def _handle_dismiss_custom_mod(self, payload: dict) -> None: champ_id = self.shared_state.selected_custom_mod.get("champion_id") rel_path = self.shared_state.selected_custom_mod.get("relative_path") if champ_id and rel_path: - historic_value = get_historic_skin_for_champion(int(champ_id)) + scope = self._historic_scope() + historic_value = get_historic_skin_for_champion( + int(champ_id), scope + ) if historic_value is not None and is_custom_mod_path(historic_value): historic_path = get_custom_mod_path(historic_value) if historic_path and historic_path.replace("\\", "/") == str(rel_path).replace("\\", "/"): - clear_historic_entry(int(champ_id)) + clear_historic_entry(int(champ_id), scope) log.info("[Dismiss] Cleared historic entry for champion %s", champ_id) except Exception as exc: log.debug("[Dismiss] Failed to clear historic entry: %s", exc) @@ -1826,11 +1839,14 @@ def _clear_active_selection_if_matches(self, champion_id: object, relative_path: get_custom_mod_path, ) if champion_id: - historic_value = get_historic_skin_for_champion(int(champion_id)) + scope = self._historic_scope() + historic_value = get_historic_skin_for_champion( + int(champion_id), scope + ) if historic_value is not None and is_custom_mod_path(historic_value): historic_path = get_custom_mod_path(historic_value) if historic_path and historic_path.replace("\\", "/") == normalized_target: - clear_historic_entry(int(champion_id)) + clear_historic_entry(int(champion_id), scope) log.info("[DeleteMod] Cleared historic entry for champion %s", champion_id) except Exception as exc: log.debug("[DeleteMod] Failed to clear historic entry: %s", exc) @@ -2118,12 +2134,14 @@ def _handle_dismiss_historic(self, payload: dict) -> None: or self.shared_state.hovered_champ_id ) historic_value = ( - get_historic_skin_for_champion(int(champ_id)) + get_historic_skin_for_champion( + int(champ_id), self._historic_scope() + ) if champ_id is not None else None ) if champ_id is not None and is_custom_mod_path(historic_value): - clear_historic_entry(int(champ_id)) + clear_historic_entry(int(champ_id), self._historic_scope()) log.info("[Dismiss] Cleared custom historic entry for champion %s", champ_id) except Exception as exc: log.debug("[Dismiss] Failed to clear custom historic entry: %s", exc) diff --git a/threads/handlers/injection_trigger.py b/threads/handlers/injection_trigger.py index e3558044..537a4b4e 100644 --- a/threads/handlers/injection_trigger.py +++ b/threads/handlers/injection_trigger.py @@ -182,6 +182,9 @@ def trigger_injection(self, name: str, ticker_id: int, cname: str = ""): return # Check if custom mod is selected for this skin (before logging) + from utils.core.historic import historic_scope_for_state + + history_scope = historic_scope_for_state(self.state) ui_skin_id = self.state.last_hovered_skin_id locked_champ_id = self.state.locked_champ_id or self.state.hovered_champ_id @@ -302,7 +305,11 @@ def trigger_injection(self, name: str, ticker_id: int, cname: str = ""): ) champ_id = self.state.locked_champ_id or self.state.hovered_champ_id - historic_value = get_historic_skin_for_champion(champ_id) if champ_id else None + historic_value = ( + get_historic_skin_for_champion(champ_id, history_scope) + if champ_id + else None + ) if historic_value and is_custom_mod_path(historic_value): historic_custom_mod_path = get_custom_mod_path(historic_value) @@ -389,7 +396,7 @@ def trigger_injection(self, name: str, ticker_id: int, cname: str = ""): current_skin_id = effective_skin_id or ui_skin_id target_skin_id = current_skin_id historic_target_skin_id = get_historic_target_for_champion( - int(champion_id) + int(champion_id), history_scope ) if historic_target_skin_id in target_skin_ids: target_skin_id = int(historic_target_skin_id) @@ -805,7 +812,7 @@ def auto_select_historic_mod(mod_type: str, category_attr: str): champ_id = self.state.locked_champ_id or self.state.hovered_champ_id if champ_id: from utils.core.historic import clear_historic_entry - clear_historic_entry(int(champ_id)) + clear_historic_entry(int(champ_id), history_scope) log.info(f"[HISTORIC] Cleared historic entry for champion {champ_id} (default skin played)") # Force owned skins/chromas via LCU @@ -925,6 +932,9 @@ def _force_owned_skin(self, skin_id: int): def _inject_unowned_skin(self, name: str, cname: str): """Inject unowned skin/chroma""" try: + from utils.core.historic import historic_scope_for_state + + history_scope = historic_scope_for_state(self.state) # Force base skin selection via LCU before injecting champ_id = self.state.locked_champ_id or self.state.hovered_champ_id if champ_id: @@ -988,7 +998,9 @@ def run_injection(): champ_id = champ_id_for_history if champ_id is not None and injected_id is not None: from utils.core.historic import write_historic_entry - write_historic_entry(int(champ_id), int(injected_id)) + write_historic_entry( + int(champ_id), int(injected_id), history_scope + ) log.info(f"[HISTORIC] Stored last injected ID {injected_id} for champion {champ_id}") except Exception as e: log.debug(f"[HISTORIC] Failed to store historic entry: {e}") @@ -1267,6 +1279,9 @@ def _run_custom_mod_injection(self, custom_mod: dict, base_skin_name: Optional[s Note: custom_mod can have mod_folder_name=None if only map/font/announcer mods are selected """ + from utils.core.historic import historic_scope_for_state + + history_scope = historic_scope_for_state(self.state) try: from pathlib import Path @@ -1650,10 +1665,14 @@ def normalize_path(p): if champion_id: # Store custom mod path with "path:" prefix custom_mod_path = f"path:{selected_custom_mod['relative_path']}" - write_historic_entry(int(champion_id), custom_mod_path) + write_historic_entry( + int(champion_id), custom_mod_path, history_scope + ) target_skin_id = selected_custom_mod.get("skin_id") if target_skin_id: - write_historic_target(int(champion_id), int(target_skin_id)) + write_historic_target( + int(champion_id), int(target_skin_id), history_scope + ) log.debug(f"[HISTORIC] Stored custom mod path for champion {champion_id}: {selected_custom_mod['relative_path']}") elif base_skin_name: # Store base skin ID in historic if injecting base skin with mods (no custom mod) @@ -1667,7 +1686,9 @@ def normalize_path(p): champion_id = self.state.locked_champ_id or self.state.hovered_champ_id if champion_id is not None and injected_id is not None: - write_historic_entry(int(champion_id), int(injected_id)) + write_historic_entry( + int(champion_id), int(injected_id), history_scope + ) log.info(f"[HISTORIC] Stored last injected ID {injected_id} for champion {champion_id}") except Exception as e: log.debug(f"[HISTORIC] Failed to store base skin entry: {e}") diff --git a/ui/handlers/historic_mode_handler.py b/ui/handlers/historic_mode_handler.py index d0a6853b..e1e74dd8 100644 --- a/ui/handlers/historic_mode_handler.py +++ b/ui/handlers/historic_mode_handler.py @@ -6,12 +6,34 @@ """ from typing import Optional + +import injection.classic as classic +from injection.classic import is_classic_game_mode, to_regular_skin_id from state import SharedState from utils.core.logging import get_logger log = get_logger() +def _default_skin_id_for_state(state: SharedState, champion_id: int) -> int: + resolver = getattr(classic, "default_skin_id_for_state", None) + return ( + int(resolver(state, champion_id)) + if callable(resolver) + else int(champion_id) * 1000 + ) + + +def _skin_id_for_state(state: SharedState, skin_id: object) -> Optional[int]: + try: + value = int(skin_id) + except (TypeError, ValueError): + return None + if is_classic_game_mode(getattr(state, "current_game_mode", None)): + value = int(to_regular_skin_id(value) or 0) + return value or None + + def historic_custom_mod_affects_skin(state: SharedState, *skin_ids: object) -> bool: """Return whether the saved custom-mod history applies to any skin ID.""" try: @@ -22,10 +44,13 @@ def historic_custom_mod_affects_skin(state: SharedState, *skin_ids: object) -> b from utils.core.historic import ( get_custom_mod_path, get_historic_skin_for_champion, + historic_scope_for_state, is_custom_mod_path, ) - historic_value = get_historic_skin_for_champion(int(champion_id)) + historic_value = get_historic_skin_for_champion( + int(champion_id), historic_scope_for_state(state) + ) if not is_custom_mod_path(historic_value): return False @@ -112,17 +137,25 @@ def check_and_activate(self, skin_id: int) -> None: # History may be restored while the client is already showing the # manually selected target; do not require a default-skin spawn first. - base_skin_id = self.state.locked_champ_id * 1000 + selected_skin_id = _skin_id_for_state(self.state, skin_id) + base_skin_id = _skin_id_for_state( + self.state, + _default_skin_id_for_state(self.state, self.state.locked_champ_id), + ) try: from utils.core.historic import ( get_historic_skin_for_champion, + historic_scope_for_state, is_custom_mod_path, ) - historic_value = get_historic_skin_for_champion(self.state.locked_champ_id) + historic_value = get_historic_skin_for_champion( + self.state.locked_champ_id, + historic_scope_for_state(self.state), + ) custom_mod_applies = historic_custom_mod_affects_skin(self.state, skin_id) if historic_value is not None and ( - skin_id == base_skin_id or custom_mod_applies + selected_skin_id == base_skin_id or custom_mod_applies ): self.state.historic_mode_active = True self.state.historic_skin_id = historic_value @@ -152,12 +185,23 @@ def check_and_deactivate(self, skin_id: int, new_base_skin_id: Optional[int]) -> """Check and deactivate historic mode if skin changed from default""" if not self.state.historic_mode_active or self.state.locked_champ_id is None: return + + # Classic carousel projection can expose the parent card while the + # active history target is one of its chromas. Only the explicit + # Classic selection message can distinguish that projection from a + # user choice, and it already owns history cancellation. + if is_classic_game_mode(getattr(self.state, "current_game_mode", None)): + return - base_skin_id = self.state.locked_champ_id * 1000 + base_skin_id = _skin_id_for_state( + self.state, + _default_skin_id_for_state(self.state, self.state.locked_champ_id), + ) + selected_base_skin_id = _skin_id_for_state(self.state, new_base_skin_id) # Keep custom-mod history active when the selected skin/chroma is one # of the saved mod targets. The client is allowed to remain on that # real skin; history is only tracking the mod, not forcing base skin. - if new_base_skin_id != base_skin_id and historic_custom_mod_affects_skin( + if selected_base_skin_id != base_skin_id and historic_custom_mod_affects_skin( self.state, skin_id, new_base_skin_id ): log.debug( @@ -168,7 +212,7 @@ def check_and_deactivate(self, skin_id: int, new_base_skin_id: Optional[int]) -> ) return - if new_base_skin_id != base_skin_id: + if selected_base_skin_id != base_skin_id: # Skin changed to a different base skin - deactivate historic mode self.state.historic_mode_active = False @@ -181,4 +225,3 @@ def check_and_deactivate(self, skin_id: int, new_base_skin_id: Optional[int]) -> self.state.ui_skin_thread._broadcast_historic_state() except Exception as e: log.debug(f"[UI] Failed to broadcast historic state on deactivation: {e}") - diff --git a/utils/core/historic.py b/utils/core/historic.py index 3b3f32b7..5955c24b 100644 --- a/utils/core/historic.py +++ b/utils/core/historic.py @@ -36,15 +36,53 @@ def _historic_file_path() -> Path: return data_dir / "historic.json" +def _historic_path(scope: str = "regular") -> Path: + if scope != "classic": + return _historic_file_path() + return get_user_data_dir() / "historic_classic.json" + + def _historic_target_file_path() -> Path: data_dir = get_user_data_dir() return data_dir / "historic_targets.json" -def load_historic_target_map() -> Dict[str, int]: +def _historic_target_path(scope: str = "regular") -> Path: + if scope != "classic": + return _historic_target_file_path() + return get_user_data_dir() / "historic_targets_classic.json" + + +def historic_scope_for_state(state) -> str: + from injection.classic import is_classic_game_mode + + return ( + "classic" + if is_classic_game_mode(getattr(state, "current_game_mode", None)) + else "regular" + ) + + +def _champion_keys(champion_id: int, scope: str) -> tuple[str, ...]: + raw_key = str(int(champion_id)) + if scope != "classic": + return (raw_key,) + import injection.classic as classic + + resolver = getattr(classic, "to_regular_champion_id", None) + value = int(champion_id) + if callable(resolver): + value = resolver(value) + elif classic.CLASSIC_CHAMPION_ID_OFFSET < value < classic.CLASSIC_CHAMPION_ID_OFFSET + 1000: + value -= classic.CLASSIC_CHAMPION_ID_OFFSET + canonical_key = str(int(value or 0)) + return (canonical_key,) if canonical_key == raw_key else (canonical_key, raw_key) + + +def load_historic_target_map(scope: str = "regular") -> Dict[str, int]: """Load the exact last skin/chroma target for custom history entries.""" try: - p = _historic_target_file_path() + p = _historic_target_path(scope) if not p.exists(): return {} with p.open("r", encoding="utf-8") as f: @@ -66,46 +104,59 @@ def load_historic_target_map() -> Dict[str, int]: return {} -def get_historic_target_for_champion(champion_id: int) -> Optional[int]: +def get_historic_target_for_champion( + champion_id: int, scope: str = "regular" +) -> Optional[int]: """Return the exact last selected skin/chroma target for a champion.""" - return load_historic_target_map().get(str(int(champion_id))) + targets = load_historic_target_map(scope) + return next( + (targets[key] for key in _champion_keys(champion_id, scope) if key in targets), + None, + ) -def write_historic_target(champion_id: int, target_skin_id: int) -> None: +def write_historic_target( + champion_id: int, target_skin_id: int, scope: str = "regular" +) -> None: """Persist the exact last selected skin/chroma target for a champion.""" try: target_id = int(target_skin_id) if target_id <= 0: return with _write_lock: - targets = load_historic_target_map() - targets[str(int(champion_id))] = target_id - _write_json(_historic_target_file_path(), targets) + targets = load_historic_target_map(scope) + keys = _champion_keys(champion_id, scope) + targets[keys[0]] = target_id + for legacy_key in keys[1:]: + targets.pop(legacy_key, None) + _write_json(_historic_target_path(scope), targets) except Exception as e: log.warning(f"[HISTORIC] Could not save target {target_skin_id} for champion {champion_id}: {e}") -def clear_historic_target(champion_id: int) -> None: +def clear_historic_target(champion_id: int, scope: str = "regular") -> None: """Remove the exact last selected skin/chroma target for a champion.""" try: with _write_lock: - targets = load_historic_target_map() - if str(int(champion_id)) not in targets: + targets = load_historic_target_map(scope) + keys = _champion_keys(champion_id, scope) + if not any(key in targets for key in keys): return - targets.pop(str(int(champion_id)), None) - _write_json(_historic_target_file_path(), targets) + for key in keys: + targets.pop(key, None) + _write_json(_historic_target_path(scope), targets) except Exception as e: log.warning(f"[HISTORIC] Could not clear target for champion {champion_id}: {e}") -def load_historic_map() -> Dict[str, Union[int, str]]: +def load_historic_map(scope: str = "regular") -> Dict[str, Union[int, str]]: """Load the historic mapping. Returns empty dict if missing or invalid. Returns: Dict mapping champion IDs to either skin/chroma IDs (int) or custom mod paths (str with "path:" prefix) """ try: - p = _historic_file_path() + p = _historic_path(scope) if not p.exists(): return {} with p.open("r", encoding="utf-8") as f: @@ -130,18 +181,26 @@ def load_historic_map() -> Dict[str, Union[int, str]]: return {} -def get_historic_skin_for_champion(champion_id: int) -> Optional[Union[int, str]]: +def get_historic_skin_for_champion( + champion_id: int, scope: str = "regular" +) -> Optional[Union[int, str]]: """Get historic entry for a champion. Returns: Integer skin/chroma ID, or string custom mod path (with "path:" prefix), or None """ - m = load_historic_map() - key = str(int(champion_id)) - return m.get(key) - - -def write_historic_entry(champion_id: int, skin_or_chroma_id: Union[int, str]) -> None: + m = load_historic_map(scope) + return next( + (m[key] for key in _champion_keys(champion_id, scope) if key in m), + None, + ) + + +def write_historic_entry( + champion_id: int, + skin_or_chroma_id: Union[int, str], + scope: str = "regular", +) -> None: """Write or overwrite the entry for the champion ID. Args: @@ -149,26 +208,30 @@ def write_historic_entry(champion_id: int, skin_or_chroma_id: Union[int, str]) - skin_or_chroma_id: Either an integer skin/chroma ID, or a string custom mod path (with "path:" prefix) """ with _write_lock: - m = load_historic_map() - m[str(int(champion_id))] = skin_or_chroma_id + m = load_historic_map(scope) + keys = _champion_keys(champion_id, scope) + m[keys[0]] = skin_or_chroma_id + for legacy_key in keys[1:]: + m.pop(legacy_key, None) try: - _write_json(_historic_file_path(), m) + _write_json(_historic_path(scope), m) except Exception as e: log.warning(f"[HISTORIC] Could not save entry for champion {champion_id}: {e}") -def clear_historic_entry(champion_id: int) -> None: +def clear_historic_entry(champion_id: int, scope: str = "regular") -> None: """Remove the historic entry for a champion if it exists.""" try: with _write_lock: - m = load_historic_map() - key = str(int(champion_id)) - if key in m: - m.pop(key, None) - _write_json(_historic_file_path(), m) + m = load_historic_map(scope) + keys = _champion_keys(champion_id, scope) + if any(key in m for key in keys): + for key in keys: + m.pop(key, None) + _write_json(_historic_path(scope), m) except Exception as e: log.warning(f"[HISTORIC] Could not clear entry for champion {champion_id}: {e}") - clear_historic_target(champion_id) + clear_historic_target(champion_id, scope) def is_custom_mod_path(value: Union[int, str]) -> bool: