From 56a5446319d6e06bce6359c4cb57b90b946329db Mon Sep 17 00:00:00 2001 From: c <993934492@qq.com> Date: Thu, 1 Oct 2026 23:22:44 +0800 Subject: [PATCH 1/7] feat(classic-history): restore separate Classic skin history Store Classic and regular history independently, restore a validated Classic target through the native carousel, and keep the history badge anchored to the requested center card. Release history control on explicit user navigation while preserving it during the client base-skin fallback and restored-target transition. Normalize Classic and regular IDs during deactivation so the protected target is not mistaken for a different skin. This commit also restores the missing Classic helper imports in HistoricModeHandler; without them, the target-preservation branch raised NameError and silently fell through its defensive exception handler. Source commits consolidated: 1efe6d17, 66d43660, 22b98203, and the HistoricMode import correction originally carried by 485f65a8. Verification: separate history tests previously passed in the development environment; the current handler and plugin pass Python and JavaScript syntax checks. --- .../plugins/ROSE-ClassicHistoric/index.js | 382 ++++++++++++++++++ pengu/communication/message_handler.py | 36 +- threads/handlers/injection_trigger.py | 32 +- ui/handlers/historic_mode_handler.py | 40 +- utils/core/historic.py | 76 +++- 5 files changed, 525 insertions(+), 41 deletions(-) create mode 100644 Pengu Loader/plugins/ROSE-ClassicHistoric/index.js diff --git a/Pengu Loader/plugins/ROSE-ClassicHistoric/index.js b/Pengu Loader/plugins/ROSE-ClassicHistoric/index.js new file mode 100644 index 00000000..9088d762 --- /dev/null +++ b/Pengu Loader/plugins/ROSE-ClassicHistoric/index.js @@ -0,0 +1,382 @@ +/** + * @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 historicSkinId = 0; + let projectedHistoricSkinId = 0; + let projectionGeneration = 0; + let projectionPending = false; + let customModActive = false; + let customModName = ""; + let customModTargetSkinIds = new Set(); + + 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 cleanup() { + document.getElementById(MARK_ID)?.remove(); + document.getElementById(TOAST_ID)?.remove(); + document.querySelectorAll(".rose-jade-history-anchor").forEach((anchor) => anchor.remove()); + } + + function currentSkinId() { + return Number(window.__roseClassicWheelApi?.currentSelection?.()?.skinId) || 0; + } + + function projectHistoricSelection(reason = "historic-state") { + const wheel = window.__roseClassicWheelApi; + if (!active || randomModeActive || !isInJadeChampSelect || !jadeActive() || !historicSkinId) { + return; + } + if (currentSkinId() === historicSkinId) { + projectedHistoricSkinId = historicSkinId; + presentationReady = true; + render(); + return; + } + if (projectedHistoricSkinId === historicSkinId || projectionPending) return; + + const generation = ++projectionGeneration; + projectionPending = true; + presentationReady = false; + const accepted = wheel?.projectResourceSelection?.( + historicSkinId, + "historic-restore", + () => { + if (generation !== projectionGeneration || !active || historicSkinId <= 0) return; + projectionPending = false; + projectedHistoricSkinId = historicSkinId; + presentationReady = true; + log("info", "Historic skin projected into Classic carousel", { + skinId: historicSkinId, + reason, + }); + render(); + } + ); + if (accepted !== true) { + projectionPending = false; + log("debug", "Historic projection is waiting for the Classic catalog", { + skinId: historicSkinId, + reason, + }); + } + } + + function customModApplies() { + const skinId = currentSkinId(); + return customModActive && skinId > 0 && customModTargetSkinIds.has(skinId); + } + + function renderMarker() { + const card = document.querySelector( + ".rose-jade-native-card.rose-jade-native-card--selected:not(.skins-pane__skin-card--placeholder)" + ); + if (!card) { + document.getElementById(MARK_ID)?.remove(); + return; + } + document.querySelectorAll(".rose-jade-history-anchor").forEach((candidate) => { + if (candidate.parentElement !== card) candidate.remove(); + }); + let anchor = Array.from(card.children).find( + (child) => child.classList?.contains("rose-jade-history-anchor") + ); + if (!anchor) { + anchor = document.createElement("div"); + anchor.className = "rose-jade-history-anchor"; + card.appendChild(anchor); + } + 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; + } + .rose-jade-native-card.rose-jade-native-card--selected > .rose-jade-history-anchor { + position: absolute; top: -14px; right: -14px; left: auto; + width: 32px; height: 32px; display: block; visibility: visible; + opacity: 1; z-index: 24; pointer-events: none; + } + #${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; + } + 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 || ""); + const nextSkinId = Number(data?.historicSkinId) || 0; + if (!nextActive || nextSkinName !== skinName || nextSkinId !== historicSkinId) { + projectionGeneration += 1; + projectionPending = false; + projectedHistoricSkinId = 0; + presentationReady = false; + } + active = nextActive; + skinName = nextSkinName; + historicSkinId = nextSkinId; + log("info", "Historic state updated", { + active, + skinName, + historicSkinId, + randomModeActive, + }); + render(); + projectHistoricSelection(); + }); + bridge.subscribe("random-mode-state", (data) => { + randomModeActive = data?.active === true; + if (randomModeActive) { + projectionGeneration += 1; + projectionPending = false; + projectedHistoricSkinId = 0; + presentationReady = false; + } else { + projectHistoricSelection("random-disabled"); + } + 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-classic-selection-change", (event) => { + const reason = String(event?.detail?.reason || ""); + const selectedSkinId = Number(event?.detail?.selection?.skinId) || 0; + if ( + active && projectedHistoricSkinId && selectedSkinId !== historicSkinId && + (reason === "native-card-click" || reason === "visual-center-change") + ) { + projectionGeneration += 1; + projectionPending = false; + projectedHistoricSkinId = 0; + presentationReady = false; + } + render(); + if (active && !presentationReady) projectHistoricSelection("selection-change"); + }); + bridge.subscribe("phase-change", handlePhaseChange); + const classicState = window.__roseClassicWheelApi?.state?.(); + if (classicState) handlePhaseChange(classicState); + window.addEventListener("rose-jade-wheel-layout", (event) => { + handleWheelLayout(event); + if (event?.detail?.active !== false) projectHistoricSelection("wheel-layout"); + }); + bridge.onReady(() => bridge.send({ type: "request-local-asset", assetPath: ASSET })); + bridge.send({ type: "request-local-asset", assetPath: ASSET }); + log("info", "Classic historic plugin initialized"); + new MutationObserver(render).observe(document.body, { childList: true, subtree: true }); + } + + start(); +})(); 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..bc5db490 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 @@ -988,7 +995,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 +1276,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 +1662,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 +1683,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..6be842c5 100644 --- a/ui/handlers/historic_mode_handler.py +++ b/ui/handlers/historic_mode_handler.py @@ -6,6 +6,12 @@ """ from typing import Optional + +from injection.classic import ( + default_skin_id_for_state, + is_classic_game_mode, + to_regular_skin_id, +) from state import SharedState from utils.core.logging import get_logger @@ -22,10 +28,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 @@ -116,9 +125,13 @@ def check_and_activate(self, skin_id: int) -> None: 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 ( @@ -153,7 +166,27 @@ def check_and_deactivate(self, skin_id: int, new_base_skin_id: Optional[int]) -> if not self.state.historic_mode_active or self.state.locked_champ_id is None: return - base_skin_id = self.state.locked_champ_id * 1000 + base_skin_id = default_skin_id_for_state( + self.state, self.state.locked_champ_id + ) + historic_skin_id = getattr(self.state, "historic_skin_id", None) + if is_classic_game_mode(getattr(self.state, "current_game_mode", None)): + try: + selected_ids = {int(skin_id), int(new_base_skin_id)} + target_id = int(historic_skin_id) + selected_ids.update( + int(to_regular_skin_id(value)) for value in tuple(selected_ids) + ) + target_ids = {target_id, int(to_regular_skin_id(target_id))} + if selected_ids & target_ids: + log.debug( + "[HISTORIC] Keeping historic mode active on restored target %s", + historic_skin_id, + ) + return + except (TypeError, ValueError): + pass + # 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. @@ -181,4 +214,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..3bc2bfd9 100644 --- a/utils/core/historic.py +++ b/utils/core/historic.py @@ -36,15 +36,39 @@ def _historic_file_path() -> Path: return data_dir / "historic.json" +def _historic_path(scope: str = "regular") -> Path: + filename = "historic_classic.json" if scope == "classic" else "historic.json" + return get_user_data_dir() / filename + + 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: + filename = ( + "historic_targets_classic.json" + if scope == "classic" + else "historic_targets.json" + ) + return get_user_data_dir() / filename + + +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 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 +90,50 @@ 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))) + return load_historic_target_map(scope).get(str(int(champion_id))) -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 = load_historic_target_map(scope) targets[str(int(champion_id))] = target_id - _write_json(_historic_target_file_path(), targets) + _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() + targets = load_historic_target_map(scope) if str(int(champion_id)) not in targets: return targets.pop(str(int(champion_id)), None) - _write_json(_historic_target_file_path(), targets) + _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 +158,24 @@ 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() + m = load_historic_map(scope) key = str(int(champion_id)) return m.get(key) -def write_historic_entry(champion_id: int, skin_or_chroma_id: Union[int, str]) -> 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 +183,26 @@ 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 = load_historic_map(scope) m[str(int(champion_id))] = skin_or_chroma_id 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() + m = load_historic_map(scope) key = str(int(champion_id)) if key in m: m.pop(key, None) - _write_json(_historic_file_path(), m) + _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: From ac2b59c3056c481e72033827475d1e63e3ab7554 Mon Sep 17 00:00:00 2001 From: c <993934492@qq.com> Date: Fri, 2 Oct 2026 00:27:25 +0800 Subject: [PATCH 2/7] fix(classic-history): restore saved skins without carousel snapback Restore Classic history from the validated catalog, normalize legacy 600xx champion keys into the resource per-champion history scope, and compare native carrier IDs with resource IDs at the mode boundary. Project the saved target through ClassicWheel, keep the history marker and toast on the requested center card, and suppress automatic automatic carousel movement after explicit native carousel navigation so the selector does not flicker back to history. Resolve historic chroma names through either raw or resource cache IDs while leaving regular history storage and comparison behavior unchanged. Verification: local history scope, native carrier activation, JavaScript syntax, and Python compilation checks passed. --- .../plugins/ROSE-ClassicHistoric/index.js | 15 +++++-- pengu/communication/broadcaster.py | 33 ++++++++++---- ui/handlers/historic_mode_handler.py | 28 +++++++++--- utils/core/historic.py | 45 ++++++++++++++----- 4 files changed, 93 insertions(+), 28 deletions(-) diff --git a/Pengu Loader/plugins/ROSE-ClassicHistoric/index.js b/Pengu Loader/plugins/ROSE-ClassicHistoric/index.js index 9088d762..90e40703 100644 --- a/Pengu Loader/plugins/ROSE-ClassicHistoric/index.js +++ b/Pengu Loader/plugins/ROSE-ClassicHistoric/index.js @@ -20,6 +20,7 @@ let projectedHistoricSkinId = 0; let projectionGeneration = 0; let projectionPending = false; + let projectionSuppressed = false; let customModActive = false; let customModName = ""; let customModTargetSkinIds = new Set(); @@ -48,7 +49,10 @@ function projectHistoricSelection(reason = "historic-state") { const wheel = window.__roseClassicWheelApi; - if (!active || randomModeActive || !isInJadeChampSelect || !jadeActive() || !historicSkinId) { + if ( + !active || randomModeActive || projectionSuppressed || + !isInJadeChampSelect || !jadeActive() || !historicSkinId + ) { return; } if (currentSkinId() === historicSkinId) { @@ -276,6 +280,7 @@ classicMode && (phase === "ChampSelect" || phase === "FINALIZATION"); if (!isInJadeChampSelect) { presentationReady = false; + projectionSuppressed = false; cleanup(); } else render(); @@ -305,6 +310,7 @@ projectionPending = false; projectedHistoricSkinId = 0; presentationReady = false; + projectionSuppressed = false; } active = nextActive; skinName = nextSkinName; @@ -354,16 +360,19 @@ const reason = String(event?.detail?.reason || ""); const selectedSkinId = Number(event?.detail?.selection?.skinId) || 0; if ( - active && projectedHistoricSkinId && selectedSkinId !== historicSkinId && + active && selectedSkinId !== historicSkinId && (reason === "native-card-click" || reason === "visual-center-change") ) { projectionGeneration += 1; projectionPending = false; projectedHistoricSkinId = 0; presentationReady = false; + projectionSuppressed = true; } render(); - if (active && !presentationReady) projectHistoricSelection("selection-change"); + if (active && !presentationReady && !projectionSuppressed) { + projectHistoricSelection("selection-change"); + } }); bridge.subscribe("phase-change", handlePhaseChange); const classicState = window.__roseClassicWheelApi?.state?.(); diff --git a/pengu/communication/broadcaster.py b/pengu/communication/broadcaster.py index de4e3787..2d7ba3d6 100644 --- a/pengu/communication/broadcaster.py +++ b/pengu/communication/broadcaster.py @@ -120,11 +120,15 @@ 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: + from injection.classic import default_skin_id_for_state, to_regular_skin_id + + historic_base_skin_id = default_skin_id_for_state( + self.shared_state, int(locked_champ_id) + ) + 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 +163,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 +427,3 @@ def _skin_has_chromas(self, skin_id: Optional[int]) -> bool: return False return False - diff --git a/ui/handlers/historic_mode_handler.py b/ui/handlers/historic_mode_handler.py index 6be842c5..2615a3ab 100644 --- a/ui/handlers/historic_mode_handler.py +++ b/ui/handlers/historic_mode_handler.py @@ -18,6 +18,16 @@ log = get_logger() +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: @@ -121,7 +131,11 @@ 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, @@ -135,7 +149,7 @@ def check_and_activate(self, skin_id: int) -> None: 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 @@ -166,9 +180,11 @@ def check_and_deactivate(self, skin_id: int, new_base_skin_id: Optional[int]) -> if not self.state.historic_mode_active or self.state.locked_champ_id is None: return - base_skin_id = default_skin_id_for_state( - self.state, self.state.locked_champ_id + 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) historic_skin_id = getattr(self.state, "historic_skin_id", None) if is_classic_game_mode(getattr(self.state, "current_game_mode", None)): try: @@ -190,7 +206,7 @@ def check_and_deactivate(self, skin_id: int, new_base_skin_id: Optional[int]) -> # 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( @@ -201,7 +217,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 diff --git a/utils/core/historic.py b/utils/core/historic.py index 3bc2bfd9..97e1d587 100644 --- a/utils/core/historic.py +++ b/utils/core/historic.py @@ -65,6 +65,16 @@ def historic_scope_for_state(state) -> str: ) +def _champion_keys(champion_id: int, scope: str) -> tuple[str, ...]: + raw_key = str(int(champion_id)) + if scope != "classic": + return (raw_key,) + from injection.classic import to_regular_champion_id + + canonical_key = str(int(to_regular_champion_id(int(champion_id)) 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: @@ -94,7 +104,11 @@ 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(scope).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( @@ -107,7 +121,10 @@ def write_historic_target( return with _write_lock: targets = load_historic_target_map(scope) - targets[str(int(champion_id))] = target_id + 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}") @@ -118,9 +135,11 @@ def clear_historic_target(champion_id: int, scope: str = "regular") -> None: try: with _write_lock: targets = load_historic_target_map(scope) - if str(int(champion_id)) not in targets: + keys = _champion_keys(champion_id, scope) + if not any(key in targets for key in keys): return - targets.pop(str(int(champion_id)), None) + 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}") @@ -167,8 +186,10 @@ def get_historic_skin_for_champion( Integer skin/chroma ID, or string custom mod path (with "path:" prefix), or None """ m = load_historic_map(scope) - key = str(int(champion_id)) - return m.get(key) + return next( + (m[key] for key in _champion_keys(champion_id, scope) if key in m), + None, + ) def write_historic_entry( @@ -184,7 +205,10 @@ def write_historic_entry( """ with _write_lock: m = load_historic_map(scope) - m[str(int(champion_id))] = skin_or_chroma_id + 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_path(scope), m) except Exception as e: @@ -196,9 +220,10 @@ def clear_historic_entry(champion_id: int, scope: str = "regular") -> None: try: with _write_lock: m = load_historic_map(scope) - key = str(int(champion_id)) - if key in m: - m.pop(key, None) + 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}") From 27b29ebc68a308c96311dc02a68277037fef61bd Mon Sep 17 00:00:00 2001 From: c <993934492@qq.com> Date: Thu, 8 Oct 2026 11:11:42 +0800 Subject: [PATCH 3/7] refactor(classic-history): let the wheel handle carousel movement Reuse the wheel's carrier-compatible carousel movement and presentation events instead of maintaining a second carousel navigator. Keep Classic history active while the wheel projects parent cards and scope unowned-skin saving to the Classic history storage. Regular-mode history deactivation remains unchanged. Validation: covered by final branch syntax and git diff checks. No separate live-client run is claimed for this commit. --- .../plugins/ROSE-ClassicHistoric/index.js | 123 ++---------------- threads/handlers/injection_trigger.py | 3 + ui/handlers/historic_mode_handler.py | 25 +--- 3 files changed, 24 insertions(+), 127 deletions(-) diff --git a/Pengu Loader/plugins/ROSE-ClassicHistoric/index.js b/Pengu Loader/plugins/ROSE-ClassicHistoric/index.js index 90e40703..8b5d080f 100644 --- a/Pengu Loader/plugins/ROSE-ClassicHistoric/index.js +++ b/Pengu Loader/plugins/ROSE-ClassicHistoric/index.js @@ -16,11 +16,6 @@ let isInJadeChampSelect = false; let randomModeActive = false; let presentationReady = false; - let historicSkinId = 0; - let projectedHistoricSkinId = 0; - let projectionGeneration = 0; - let projectionPending = false; - let projectionSuppressed = false; let customModActive = false; let customModName = ""; let customModTargetSkinIds = new Set(); @@ -47,73 +42,17 @@ return Number(window.__roseClassicWheelApi?.currentSelection?.()?.skinId) || 0; } - function projectHistoricSelection(reason = "historic-state") { - const wheel = window.__roseClassicWheelApi; - if ( - !active || randomModeActive || projectionSuppressed || - !isInJadeChampSelect || !jadeActive() || !historicSkinId - ) { - return; - } - if (currentSkinId() === historicSkinId) { - projectedHistoricSkinId = historicSkinId; - presentationReady = true; - render(); - return; - } - if (projectedHistoricSkinId === historicSkinId || projectionPending) return; - - const generation = ++projectionGeneration; - projectionPending = true; - presentationReady = false; - const accepted = wheel?.projectResourceSelection?.( - historicSkinId, - "historic-restore", - () => { - if (generation !== projectionGeneration || !active || historicSkinId <= 0) return; - projectionPending = false; - projectedHistoricSkinId = historicSkinId; - presentationReady = true; - log("info", "Historic skin projected into Classic carousel", { - skinId: historicSkinId, - reason, - }); - render(); - } - ); - if (accepted !== true) { - projectionPending = false; - log("debug", "Historic projection is waiting for the Classic catalog", { - skinId: historicSkinId, - reason, - }); - } - } - function customModApplies() { const skinId = currentSkinId(); return customModActive && skinId > 0 && customModTargetSkinIds.has(skinId); } function renderMarker() { - const card = document.querySelector( - ".rose-jade-native-card.rose-jade-native-card--selected:not(.skins-pane__skin-card--placeholder)" - ); - if (!card) { + const host = window.__roseClassicWheelApi?.controlHost?.() || null; + if (!host) { document.getElementById(MARK_ID)?.remove(); return; } - document.querySelectorAll(".rose-jade-history-anchor").forEach((candidate) => { - if (candidate.parentElement !== card) candidate.remove(); - }); - let anchor = Array.from(card.children).find( - (child) => child.classList?.contains("rose-jade-history-anchor") - ); - if (!anchor) { - anchor = document.createElement("div"); - anchor.className = "rose-jade-history-anchor"; - card.appendChild(anchor); - } let mark = document.getElementById(MARK_ID); if (!mark) { mark = document.createElement("div"); @@ -122,7 +61,7 @@ } mark.title = "Historic skin"; if (imageUrl) mark.style.backgroundImage = `url("${imageUrl}")`; - if (mark.parentElement !== anchor) anchor.appendChild(mark); + if (mark.parentElement !== host) host.appendChild(mark); } function createToast() { @@ -198,13 +137,10 @@ 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; - } - .rose-jade-native-card.rose-jade-native-card--selected > .rose-jade-history-anchor { position: absolute; top: -14px; right: -14px; left: auto; width: 32px; height: 32px; display: block; visibility: visible; opacity: 1; z-index: 24; pointer-events: none; + background: center / contain no-repeat; } #${TOAST_ID} { position: fixed; left: 50%; bottom: calc(10% + 215px); @@ -280,7 +216,6 @@ classicMode && (phase === "ChampSelect" || phase === "FINALIZATION"); if (!isInJadeChampSelect) { presentationReady = false; - projectionSuppressed = false; cleanup(); } else render(); @@ -304,36 +239,18 @@ bridge.subscribe("historic-state", (data) => { const nextActive = data?.active === true; const nextSkinName = String(data?.historicSkinName || ""); - const nextSkinId = Number(data?.historicSkinId) || 0; - if (!nextActive || nextSkinName !== skinName || nextSkinId !== historicSkinId) { - projectionGeneration += 1; - projectionPending = false; - projectedHistoricSkinId = 0; - presentationReady = false; - projectionSuppressed = false; - } + if (!nextActive || nextSkinName !== skinName) presentationReady = false; active = nextActive; skinName = nextSkinName; - historicSkinId = nextSkinId; log("info", "Historic state updated", { active, skinName, - historicSkinId, randomModeActive, }); render(); - projectHistoricSelection(); }); bridge.subscribe("random-mode-state", (data) => { randomModeActive = data?.active === true; - if (randomModeActive) { - projectionGeneration += 1; - projectionPending = false; - projectedHistoricSkinId = 0; - presentationReady = false; - } else { - projectHistoricSelection("random-disabled"); - } log("info", "Random state observed", { active: randomModeActive }); render(); }); @@ -356,35 +273,23 @@ imageUrl = String(data.url || "").replace("localhost", "127.0.0.1"); render(); }); - window.addEventListener("rose-classic-selection-change", (event) => { - const reason = String(event?.detail?.reason || ""); - const selectedSkinId = Number(event?.detail?.selection?.skinId) || 0; - if ( - active && selectedSkinId !== historicSkinId && - (reason === "native-card-click" || reason === "visual-center-change") - ) { - projectionGeneration += 1; - projectionPending = false; - projectedHistoricSkinId = 0; - presentationReady = false; - projectionSuppressed = true; - } + window.addEventListener("rose-jade-historic-presentation", (event) => { + skinName = String(event?.detail?.skinName || skinName); + presentationReady = true; render(); - if (active && !presentationReady && !projectionSuppressed) { - projectHistoricSelection("selection-change"); - } }); + 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", (event) => { - handleWheelLayout(event); - if (event?.detail?.active !== false) projectHistoricSelection("wheel-layout"); - }); + 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 }); log("info", "Classic historic plugin initialized"); - new MutationObserver(render).observe(document.body, { childList: true, subtree: true }); } start(); diff --git a/threads/handlers/injection_trigger.py b/threads/handlers/injection_trigger.py index bc5db490..537a4b4e 100644 --- a/threads/handlers/injection_trigger.py +++ b/threads/handlers/injection_trigger.py @@ -932,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: diff --git a/ui/handlers/historic_mode_handler.py b/ui/handlers/historic_mode_handler.py index 2615a3ab..5b7d7b9f 100644 --- a/ui/handlers/historic_mode_handler.py +++ b/ui/handlers/historic_mode_handler.py @@ -179,30 +179,19 @@ 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 = _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) - historic_skin_id = getattr(self.state, "historic_skin_id", None) - if is_classic_game_mode(getattr(self.state, "current_game_mode", None)): - try: - selected_ids = {int(skin_id), int(new_base_skin_id)} - target_id = int(historic_skin_id) - selected_ids.update( - int(to_regular_skin_id(value)) for value in tuple(selected_ids) - ) - target_ids = {target_id, int(to_regular_skin_id(target_id))} - if selected_ids & target_ids: - log.debug( - "[HISTORIC] Keeping historic mode active on restored target %s", - historic_skin_id, - ) - return - except (TypeError, ValueError): - pass - # 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. From 2a6c113da2e9ac6612324ba64d03b2797d663e27 Mon Sep 17 00:00:00 2001 From: c <993934492@qq.com> Date: Thu, 8 Oct 2026 11:11:42 +0800 Subject: [PATCH 4/7] fix(classic-history): anchor state to the selected native card Attach the history marker and label to the current wheel control host, clear stale presentation when leaving JADE, and prevent duplicated notices during native carousel refreshes. Validation: covered by final branch syntax and git diff checks. No separate live-client run is claimed for this commit. --- .../plugins/ROSE-ClassicHistoric/index.js | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/Pengu Loader/plugins/ROSE-ClassicHistoric/index.js b/Pengu Loader/plugins/ROSE-ClassicHistoric/index.js index 8b5d080f..2a2592bc 100644 --- a/Pengu Loader/plugins/ROSE-ClassicHistoric/index.js +++ b/Pengu Loader/plugins/ROSE-ClassicHistoric/index.js @@ -35,7 +35,6 @@ function cleanup() { document.getElementById(MARK_ID)?.remove(); document.getElementById(TOAST_ID)?.remove(); - document.querySelectorAll(".rose-jade-history-anchor").forEach((anchor) => anchor.remove()); } function currentSkinId() { @@ -48,8 +47,10 @@ } function renderMarker() { - const host = window.__roseClassicWheelApi?.controlHost?.() || null; - if (!host) { + 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; } @@ -61,7 +62,7 @@ } mark.title = "Historic skin"; if (imageUrl) mark.style.backgroundImage = `url("${imageUrl}")`; - if (mark.parentElement !== host) host.appendChild(mark); + if (mark.parentElement !== anchor) anchor.appendChild(mark); } function createToast() { @@ -137,10 +138,8 @@ style.id = STYLE_ID; style.textContent = ` #${MARK_ID} { - position: absolute; top: -14px; right: -14px; left: auto; - width: 32px; height: 32px; display: block; visibility: visible; - opacity: 1; z-index: 24; pointer-events: none; - background: center / contain no-repeat; + 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); @@ -242,11 +241,7 @@ if (!nextActive || nextSkinName !== skinName) presentationReady = false; active = nextActive; skinName = nextSkinName; - log("info", "Historic state updated", { - active, - skinName, - randomModeActive, - }); + log("info", "Historic state updated", { active, skinName, randomModeActive }); render(); }); bridge.subscribe("random-mode-state", (data) => { @@ -290,6 +285,7 @@ bridge.onReady(() => bridge.send({ type: "request-local-asset", assetPath: ASSET })); bridge.send({ type: "request-local-asset", assetPath: ASSET }); log("info", "Classic historic plugin initialized"); + new MutationObserver(render).observe(document.body, { childList: true, subtree: true }); } start(); From cabf7635ee89057771133a2fdbadc0edd5ea5651 Mon Sep 17 00:00:00 2001 From: c <993934492@qq.com> Date: Thu, 8 Oct 2026 15:53:51 +0800 Subject: [PATCH 5/7] fix(classic-history): switch history UI after Classic is ready Let the Classic history plugin claim the history presentation layer only after its bridge subscriptions and assets are initialized. The regular HistoricMode plugin now stops rendering only when the current session is JADE and the matching Classic history owner is ready. It remains available in ordinary modes and as the JADE fallback when the dedicated plugin is absent or fails to initialize. This keeps the UI switch inside the history feature PR and avoids using ClassicWheel readiness as a global proxy for unrelated plugin control. Validation: covered by final branch syntax and git diff checks. No separate live-client run is claimed for this commit. --- .../plugins/ROSE-ClassicHistoric/index.js | 13 ++++++++++ .../plugins/ROSE-HistoricMode/index.js | 25 ++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/Pengu Loader/plugins/ROSE-ClassicHistoric/index.js b/Pengu Loader/plugins/ROSE-ClassicHistoric/index.js index 2a2592bc..48d64790 100644 --- a/Pengu Loader/plugins/ROSE-ClassicHistoric/index.js +++ b/Pengu Loader/plugins/ROSE-ClassicHistoric/index.js @@ -19,6 +19,7 @@ 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"; @@ -32,6 +33,16 @@ 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(); @@ -226,6 +237,7 @@ cleanup(); return; } + claimHistoricUi(); render(); } @@ -284,6 +296,7 @@ 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 }); } 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(() => { From 97f2ed4c8e5f56399d2f5767380d49fa95deb357 Mon Sep 17 00:00:00 2001 From: c <993934492@qq.com> Date: Sun, 18 Oct 2026 19:23:40 +0800 Subject: [PATCH 6/7] fix(classic-history): preserve regular history path hooks Route the regular history and target files through their existing path helpers while keeping the new Classic files separate. This preserves upstream behavior and test substitution points without changing the Classic history scope or file names. Validation: the upstream corrupt-history and missing-history tests pass; Python compilation and git diff checks pass. --- utils/core/historic.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/utils/core/historic.py b/utils/core/historic.py index 97e1d587..6aade3fd 100644 --- a/utils/core/historic.py +++ b/utils/core/historic.py @@ -37,8 +37,9 @@ def _historic_file_path() -> Path: def _historic_path(scope: str = "regular") -> Path: - filename = "historic_classic.json" if scope == "classic" else "historic.json" - return get_user_data_dir() / filename + if scope != "classic": + return _historic_file_path() + return get_user_data_dir() / "historic_classic.json" def _historic_target_file_path() -> Path: @@ -47,12 +48,9 @@ def _historic_target_file_path() -> Path: def _historic_target_path(scope: str = "regular") -> Path: - filename = ( - "historic_targets_classic.json" - if scope == "classic" - else "historic_targets.json" - ) - return get_user_data_dir() / filename + 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 f87b9af26e132a0d71ccc0237deaaa050fa53354 Mon Sep 17 00:00:00 2001 From: c <993934492@qq.com> Date: Sun, 18 Oct 2026 22:40:45 +0800 Subject: [PATCH 7/7] fix(classic-history): preserve the standalone Skin0 fallback Use the carrier-aware ClassicWheel helpers when that base PR is present, while retaining the upstream Skin0 behavior when this feature branch is reviewed or merged on its own. This keeps the history PR functionally isolated: it does not copy the wheel implementation or modify the shared Classic core module. Validation: 39 focused injection, Classic library, history, Pengu bridge, chroma, and countdown tests passed. --- pengu/communication/broadcaster.py | 10 +++++++--- ui/handlers/historic_mode_handler.py | 20 +++++++++++++------- utils/core/historic.py | 12 +++++++++--- 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/pengu/communication/broadcaster.py b/pengu/communication/broadcaster.py index 2d7ba3d6..1b784d60 100644 --- a/pengu/communication/broadcaster.py +++ b/pengu/communication/broadcaster.py @@ -121,10 +121,14 @@ def broadcast_historic_state(self) -> None: locked_champ_id = getattr(self.shared_state, 'locked_champ_id', None) historic_entry_available = bool(historic_mode_active) if historic_entry_available and locked_champ_id is not None: - from injection.classic import default_skin_id_for_state, to_regular_skin_id + import injection.classic as classic + from injection.classic import to_regular_skin_id - historic_base_skin_id = default_skin_id_for_state( - self.shared_state, int(locked_champ_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: diff --git a/ui/handlers/historic_mode_handler.py b/ui/handlers/historic_mode_handler.py index 5b7d7b9f..e1e74dd8 100644 --- a/ui/handlers/historic_mode_handler.py +++ b/ui/handlers/historic_mode_handler.py @@ -7,17 +7,23 @@ from typing import Optional -from injection.classic import ( - default_skin_id_for_state, - is_classic_game_mode, - to_regular_skin_id, -) +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) @@ -134,7 +140,7 @@ def check_and_activate(self, skin_id: int) -> None: 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), + _default_skin_id_for_state(self.state, self.state.locked_champ_id), ) try: from utils.core.historic import ( @@ -189,7 +195,7 @@ def check_and_deactivate(self, skin_id: int, new_base_skin_id: Optional[int]) -> base_skin_id = _skin_id_for_state( self.state, - default_skin_id_for_state(self.state, self.state.locked_champ_id), + _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 diff --git a/utils/core/historic.py b/utils/core/historic.py index 6aade3fd..5955c24b 100644 --- a/utils/core/historic.py +++ b/utils/core/historic.py @@ -67,9 +67,15 @@ def _champion_keys(champion_id: int, scope: str) -> tuple[str, ...]: raw_key = str(int(champion_id)) if scope != "classic": return (raw_key,) - from injection.classic import to_regular_champion_id - - canonical_key = str(int(to_regular_champion_id(int(champion_id)) or 0)) + 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)