From 31ad72745645b1b3a294b3cf31aa848a27a78e36 Mon Sep 17 00:00:00 2001 From: Spok0jny Date: Sun, 30 Aug 2026 18:38:09 +0200 Subject: [PATCH 1/9] feat: implement favorite skins system with dual dice roll and quickplay favorites management --- .gitignore | 2 + .../plugins/ROSE-ChromaWheel/index.js | 2 + Pengu Loader/plugins/ROSE-Favorites/index.js | 513 +++++++++++ Pengu Loader/plugins/ROSE-RandomSkin/index.js | 812 +++++++----------- injection/mods/zip_resolver.py | 6 +- pengu/communication/broadcaster.py | 50 +- pengu/communication/message_handler.py | 55 +- pengu/core/skin_monitor.py | 4 + threads/handlers/injection_trigger.py | 35 +- ui/core/user_interface.py | 6 +- ui/handlers/randomization_handler.py | 199 +++-- utils/core/favorites.py | 187 ++++ 12 files changed, 1293 insertions(+), 578 deletions(-) create mode 100644 Pengu Loader/plugins/ROSE-Favorites/index.js create mode 100644 utils/core/favorites.py diff --git a/.gitignore b/.gitignore index 981d7c99..19a4202b 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,7 @@ ENV/ # IDE / Editor .vscode/ .idea/ +.agent/ .claude/settings.local.json # Local / temp @@ -138,3 +139,4 @@ relay-worker/.wrangler/ /test/ injection/tools/cslol-dll.dll injection/tools/cslol-dll.stub +run_rose.bat diff --git a/Pengu Loader/plugins/ROSE-ChromaWheel/index.js b/Pengu Loader/plugins/ROSE-ChromaWheel/index.js index b425959c..56aeab1d 100644 --- a/Pengu Loader/plugins/ROSE-ChromaWheel/index.js +++ b/Pengu Loader/plugins/ROSE-ChromaWheel/index.js @@ -2881,10 +2881,12 @@ listItem.style.alignItems = "center"; listItem.style.justifyContent = "center"; + listItem.dataset.chromaId = String(chroma.id); const emberView = document.createElement("div"); emberView.className = "ember-view"; const chromaButton = document.createElement("div"); + chromaButton.dataset.chromaId = String(chroma.id); chromaButton.className = `chroma-skin-button ${chroma.locked ? "locked" : "" } ${chroma.selected ? "selected" : ""} ${chroma.purchaseDisabled ? "purchase-disabled" : "" }`; diff --git a/Pengu Loader/plugins/ROSE-Favorites/index.js b/Pengu Loader/plugins/ROSE-Favorites/index.js new file mode 100644 index 00000000..11f57aa4 --- /dev/null +++ b/Pengu Loader/plugins/ROSE-Favorites/index.js @@ -0,0 +1,513 @@ +/** + * @name Rose-Favorites + * @author Rose Team & Spok0jny + * @description Non-invasive favorite skins & chromas manager for Pengu Loader + * @link https://github.com/Alban1911/Rose + */ +(function initFavorites() { + const LOG_PREFIX = "[Rose-Favorites]"; + + let bridge = null; + let currentChampionId = null; + let currentSkinId = null; + let currentFavorites = { skins: [], chromas: {} }; + let allFavorites = {}; + + function log(level, message, data = null) { + const payload = { + type: "chroma-log", + source: "LU-Favorites", + level: level, + message: message, + timestamp: Date.now(), + }; + if (data) payload.data = data; + if (bridge) bridge.send(payload); + + const consoleMethod = + level === "error" ? console.error : level === "warn" ? console.warn : console.log; + consoleMethod(`${LOG_PREFIX} ${message}`, data || ""); + } + + function waitForBridge() { + return new Promise((resolve, reject) => { + const timeout = 10000; + const interval = 50; + let elapsed = 0; + const check = () => { + if (window.__roseBridge) return resolve(window.__roseBridge); + elapsed += interval; + if (elapsed >= timeout) return reject(new Error("Bridge not available")); + setTimeout(check, interval); + }; + check(); + }); + } + + const GOLD_STAR_SVG = ` + + + + `; + + const INACTIVE_STAR_SVG = ` + + + + `; + + const CYAN_STAR_SVG = ` + + + + `; + + const INACTIVE_CYAN_STAR_SVG = ` + + + + `; + + const CSS_RULES = ` + /* Active Skin Title Star Button */ + .rose-title-star-btn { + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + width: 26px !important; + height: 26px !important; + padding: 4px !important; + box-sizing: content-box !important; + margin-left: 8px !important; + vertical-align: middle !important; + cursor: pointer !important; + pointer-events: auto !important; + transition: transform 0.15s ease, filter 0.15s ease !important; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.8)) !important; + z-index: 999 !important; + } + + .rose-title-star-btn:hover { + transform: scale(1.25) !important; + } + + .rose-title-star-btn.active { + filter: drop-shadow(0 0 8px #ffd700) !important; + } + + /* Individual Chroma Swatch Star Button */ + .chroma-selection li, + .chroma-modal .chroma-list li, + .chroma-information ~ .chroma-selection li { + position: relative !important; + overflow: visible !important; + } + + .rose-chroma-fav-btn { + position: absolute !important; + top: -10px !important; + right: -10px !important; + width: 22px !important; + height: 22px !important; + padding: 4px !important; + box-sizing: content-box !important; + cursor: pointer !important; + pointer-events: auto !important; + z-index: 1000 !important; + opacity: 0.75; + transition: transform 0.15s ease, opacity 0.15s ease, filter 0.15s ease !important; + filter: drop-shadow(0 0 3px rgba(0, 0, 0, 0.9)) !important; + } + + .chroma-selection li:hover .rose-chroma-fav-btn, + .rose-chroma-fav-btn:hover, + .rose-chroma-fav-btn.active { + opacity: 1 !important; + } + + .rose-chroma-fav-btn:hover { + transform: scale(1.3) !important; + } + + .rose-chroma-fav-btn.active { + filter: drop-shadow(0 0 6px #0acbe6) !important; + } + `; + + function injectCSS() { + if (document.getElementById("rose-favorites-css")) return; + const style = document.createElement("style"); + style.id = "rose-favorites-css"; + style.textContent = CSS_RULES; + document.head.appendChild(style); + } + + function getActiveSkinId() { + const skinState = window.__roseSkinState || {}; + return skinState.skinId || currentSkinId || null; + } + + function extractSkinId(el) { + if (!el) return null; + const dataId = + el.getAttribute("data-skin-id") || + el.getAttribute("data-id") || + el.querySelector("[data-skin-id]")?.getAttribute("data-skin-id") || + el.querySelector("[data-id]")?.getAttribute("data-id"); + + if (dataId && !isNaN(parseInt(dataId, 10)) && parseInt(dataId, 10) > 0) { + return parseInt(dataId, 10); + } + + try { + if (window.Ember && window.Ember.View && window.Ember.View.views && el.id && window.Ember.View.views[el.id]) { + const view = window.Ember.View.views[el.id]; + const ctx = view.context || view._context || (typeof view.get === "function" ? view.get("context") : null); + if (ctx) { + const skin = ctx.skin || ctx.item?.skin || ctx; + if (skin && (skin.id || skin.skinId)) { + return parseInt(skin.id || skin.skinId, 10); + } + } + } + } catch (e) {} + + const img = el.querySelector("img") || (el.tagName === "IMG" ? el : null); + const bgImage = (img && (img.src || img.getAttribute("src"))) || el.style?.backgroundImage || ""; + const match = + bgImage.match(/champion-(?:splashes|tiles|chroma-images)\/\d+\/(\d+)\.(?:jpg|png)/i) || + bgImage.match(/\/(\d{4,8})\.(?:jpg|png)/i); + + if (match && match[1] && parseInt(match[1], 10) > 0) { + return parseInt(match[1], 10); + } + + return null; + } + + function toggleSkinFavorite(skinId) { + let targetSkin = skinId || getActiveSkinId(); + if (!targetSkin) return; + + let champId = currentChampionId; + if (!champId && targetSkin) { + champId = Math.floor(targetSkin / 1000); + } + if (!champId) return; + + log("info", "Toggling skin favorite", { champId, skinId: targetSkin }); + + if (bridge) { + bridge.send({ + type: "favorite-toggle-skin", + championId: champId, + skinId: targetSkin, + timestamp: Date.now(), + }); + } + + if (!currentFavorites.skins) currentFavorites.skins = []; + const idx = currentFavorites.skins.indexOf(targetSkin); + if (idx >= 0) { + currentFavorites.skins.splice(idx, 1); + if (currentFavorites.chromas && currentFavorites.chromas[String(targetSkin)]) { + delete currentFavorites.chromas[String(targetSkin)]; + } + } else { + currentFavorites.skins.push(targetSkin); + if (!currentFavorites.chromas) currentFavorites.chromas = {}; + const skinKey = String(targetSkin); + if (!currentFavorites.chromas[skinKey]) currentFavorites.chromas[skinKey] = []; + if (!currentFavorites.chromas[skinKey].includes(targetSkin)) { + currentFavorites.chromas[skinKey].push(targetSkin); + } + } + + updateUI(); + } + + function toggleChromaFavorite(chromaId, skinId) { + let targetSkin = skinId || getActiveSkinId(); + let targetChroma = chromaId; + + let champId = currentChampionId; + if (!champId && targetSkin) { + champId = Math.floor(targetSkin / 1000); + } + if (!champId || !targetSkin || !targetChroma) return; + + log("info", "Toggling chroma favorite", { champId, skinId: targetSkin, chromaId: targetChroma }); + + if (bridge) { + bridge.send({ + type: "favorite-toggle-chroma", + championId: champId, + skinId: targetSkin, + chromaId: targetChroma, + timestamp: Date.now(), + }); + } + + if (!currentFavorites.skins) currentFavorites.skins = []; + if (!currentFavorites.skins.includes(targetSkin)) { + currentFavorites.skins.push(targetSkin); + } + if (!currentFavorites.chromas) currentFavorites.chromas = {}; + const skinKey = String(targetSkin); + if (!currentFavorites.chromas[skinKey]) currentFavorites.chromas[skinKey] = []; + + const cIdx = currentFavorites.chromas[skinKey].indexOf(targetChroma); + if (cIdx >= 0) { + currentFavorites.chromas[skinKey].splice(cIdx, 1); + } else { + currentFavorites.chromas[skinKey].push(targetChroma); + } + + updateUI(); + } + + let isInChampSelect = false; + + function updateUI() { + // Check if we are in Champ Select + const champSelectEl = document.querySelector( + ".skin-selection-carousel, #champ-select, .champ-select-bg, .champion-select-main-container, .skin-selection-carousel-container" + ); + if (isInChampSelect || champSelectEl) { + // Remove all star buttons from DOM when in Champ Select + document.querySelectorAll(".rose-title-star-btn, .rose-chroma-fav-btn").forEach((btn) => btn.remove()); + + // Still dispatch update to ROSE-RandomSkin so it knows favorite counts + window.dispatchEvent( + new CustomEvent("rose-favorites-updated", { + detail: { + championId: currentChampionId, + favorites: currentFavorites, + allFavorites: allFavorites, + }, + }) + ); + return; + } + + const activeSkin = getActiveSkinId(); + + // 1. Single Golden Star next to Active Skin Title in Quickplay / Lobby + if (activeSkin) { + const isFav = currentFavorites.skins && currentFavorites.skins.includes(activeSkin); + + const titleSelectors = [ + ".champion-name-title", + ".child-skin-name", + ".skin-name", + ".skin-title", + "[class*='skin-name']", + "[class*='skin-title']", + ]; + + let targetTitleEl = null; + for (const selector of titleSelectors) { + const nodes = document.querySelectorAll(selector); + for (const node of nodes) { + if (node.offsetParent !== null && node.textContent.trim().length > 0) { + targetTitleEl = node; + break; + } + } + if (targetTitleEl) break; + } + + // Remove any duplicate star buttons in document + document.querySelectorAll(".rose-title-star-btn").forEach((btn) => { + if (btn.parentElement !== targetTitleEl) { + btn.remove(); + } + }); + + if (targetTitleEl) { + let starBtn = targetTitleEl.querySelector(".rose-title-star-btn"); + if (!starBtn) { + starBtn = document.createElement("span"); + starBtn.className = "rose-title-star-btn"; + starBtn.addEventListener("click", (e) => { + e.stopPropagation(); + e.preventDefault(); + // Dynamically get the currently active skin at click time! + const currentActive = getActiveSkinId(); + toggleSkinFavorite(currentActive); + }); + targetTitleEl.appendChild(starBtn); + } + + starBtn.className = `rose-title-star-btn ${isFav ? "active" : ""}`; + starBtn.innerHTML = isFav ? GOLD_STAR_SVG : INACTIVE_STAR_SVG; + starBtn.title = isFav + ? "Favorited Skin ⭐ (Click to remove from favorites)" + : "Click to add to Favorite Skins ⭐"; + } + } + + // 2. Attach Click & Right-Click Favorite Toggle to Quickplay Thumbnails only + const cards = document.querySelectorAll(".thumbnail-wrapper"); + cards.forEach((card) => { + if (!card._roseCtxAttached) { + card._roseCtxAttached = true; + // On click thumbnail: update currentSkinId dynamically + card.addEventListener("click", () => { + const sid = extractSkinId(card); + if (sid) { + currentSkinId = sid; + setTimeout(updateUI, 50); + } + }); + // On right-click thumbnail: toggle favorite for that skin + card.addEventListener("contextmenu", (e) => { + e.stopPropagation(); + e.preventDefault(); + const sid = extractSkinId(card) || getActiveSkinId(); + if (sid) toggleSkinFavorite(sid); + }); + } + }); + + // 3. Decorate each individual Chroma button in Chroma Panel (.chroma-selection li) + if (activeSkin) { + const chromaItems = document.querySelectorAll( + ".chroma-selection li, .chroma-modal .chroma-list li, .chroma-list li" + ); + chromaItems.forEach((li, index) => { + let chromaId = + parseInt( + li.getAttribute("data-chroma-id") || + li.getAttribute("data-id") || + li.querySelector(".chroma-skin-button")?.getAttribute("data-chroma-id") || + li.querySelector(".chroma-skin-button")?.getAttribute("data-id"), + 10 + ); + + if (!chromaId || isNaN(chromaId)) { + chromaId = index === 0 ? activeSkin : activeSkin + index; + } + + const skinKey = String(activeSkin); + const isChromaFav = + currentFavorites.chromas && + currentFavorites.chromas[skinKey] && + currentFavorites.chromas[skinKey].includes(chromaId); + + let starBtn = li.querySelector(".rose-chroma-fav-btn"); + if (!starBtn) { + starBtn = document.createElement("div"); + starBtn.className = "rose-chroma-fav-btn"; + starBtn.addEventListener("click", (e) => { + e.stopPropagation(); + e.preventDefault(); + const currentActive = getActiveSkinId(); + const targetChrId = parseInt(li.dataset.chromaId, 10) || chromaId; + toggleChromaFavorite(targetChrId, currentActive); + }); + li.appendChild(starBtn); + } + + starBtn.className = `rose-chroma-fav-btn ${isChromaFav ? "active" : ""}`; + starBtn.innerHTML = isChromaFav ? CYAN_STAR_SVG : INACTIVE_CYAN_STAR_SVG; + starBtn.title = isChromaFav + ? "Favorited Chroma ⭐ (Click to remove)" + : "Click to add this Chroma to favorites ⭐"; + + if (!li._roseChrCtx) { + li._roseChrCtx = true; + li.addEventListener("contextmenu", (e) => { + e.stopPropagation(); + e.preventDefault(); + const currentActive = getActiveSkinId(); + const targetChrId = parseInt(li.dataset.chromaId, 10) || chromaId; + toggleChromaFavorite(targetChrId, currentActive); + }); + } + }); + } + + // Dispatch update to ROSE-RandomSkin + window.dispatchEvent( + new CustomEvent("rose-favorites-updated", { + detail: { + championId: currentChampionId, + favorites: currentFavorites, + allFavorites: allFavorites, + }, + }) + ); + } + + function handleFavoritesState(data) { + log("info", "Received favorites-state", data); + if (data.championFavorites) currentFavorites = data.championFavorites; + if (data.allFavorites) allFavorites = data.allFavorites; + if (data.championId) currentChampionId = data.championId; + updateUI(); + } + + function handleSkinState(data) { + if (!data) return; + if (data.skinId) { + currentSkinId = data.skinId; + window.__roseSkinState = Object.assign(window.__roseSkinState || {}, data); + } + if (data.championId) { + if (data.championId !== currentChampionId) { + currentChampionId = data.championId; + if (bridge) { + bridge.send({ type: "request-favorites", championId: currentChampionId }); + } + } + } + setTimeout(updateUI, 50); + } + + async function start() { + injectCSS(); + + setInterval(updateUI, 400); + + try { + bridge = await waitForBridge(); + log("info", "Bridge ready in ROSE-Favorites"); + + if (bridge.subscribe) { + bridge.subscribe("favorites-state", handleFavoritesState); + bridge.subscribe("skin-state", handleSkinState); + bridge.subscribe("phase-change", (data) => { + if (data && data.phase) { + isInChampSelect = data.phase === "ChampSelect" || data.phase === "FINALIZATION"; + } + if (bridge) bridge.send({ type: "request-favorites", championId: currentChampionId }); + updateUI(); + }); + bridge.subscribe("champion-locked", (data) => { + if (data.locked && bridge) { + bridge.send({ type: "request-favorites", championId: currentChampionId }); + } + }); + } + + window.addEventListener("lu-skin-monitor-state", (e) => { + if (e.detail) handleSkinState(e.detail); + }); + + bridge.send({ type: "request-favorites" }); + } catch (e) { + log("error", "Failed to init favorites bridge:", e); + } + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", start); + } else { + start(); + } +})(); diff --git a/Pengu Loader/plugins/ROSE-RandomSkin/index.js b/Pengu Loader/plugins/ROSE-RandomSkin/index.js index 5c0f0c3d..6481ef28 100644 --- a/Pengu Loader/plugins/ROSE-RandomSkin/index.js +++ b/Pengu Loader/plugins/ROSE-RandomSkin/index.js @@ -1,17 +1,14 @@ /** * @name Rose-RandomSkin - * @author Rose Team - * @description Random skin for Pengu Loader - * @link https://github.com/FlorentTariolle/Rose-RandomSkin + * @author Rose Team & Spok0jny + * @description Dual Random Skin & Favorites Roll for Pengu Loader + * @link https://github.com/Alban1911/Rose */ (function initRandomSkin() { const LOG_PREFIX = "[Rose-RandomSkin]"; - const REWARDS_SELECTOR = ".skin-selection-item-information.loyalty-reward-icon--rewards"; - const RANDOM_FLAG_ASSET_PATH = "random_flag.png"; const DICE_DISABLED_ASSET_PATH = "dice-disabled.png"; const DICE_ENABLED_ASSET_PATH = "dice-enabled.png"; - // Shared bridge (provided by ROSE-SkinMonitor) let bridge = null; function waitForBridge() { @@ -29,68 +26,138 @@ }); } - /** - * Escape HTML special characters to prevent XSS (CWE-79) - * @param {string} str - String to escape - * @returns {string} Escaped string safe for innerHTML - */ - function escapeHtml(str) { - if (typeof str !== 'string') return String(str); - return str - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); + let activeDiceTooltipWrapper = null; + + function hideDiceTooltip() { + if (activeDiceTooltipWrapper) { + try { activeDiceTooltipWrapper.remove(); } catch (e) {} + activeDiceTooltipWrapper = null; + } + document.querySelectorAll(".rose-dice-tooltip-wrapper").forEach((el) => { + try { el.remove(); } catch (e) {} + }); + } + + function attachTooltip(el, textGetter) { + el.addEventListener("mouseenter", () => { + hideDiceTooltip(); + const text = typeof textGetter === "function" ? textGetter() : textGetter; + if (!text) return; + + const wrapper = document.createElement("div"); + wrapper.className = "rose-dice-tooltip-wrapper"; + wrapper.style.cssText = "position:fixed;pointer-events:none;z-index:10000;visibility:hidden"; + const tip = document.createElement("lol-uikit-tooltip"); + tip.setAttribute("data-tooltip-position", "bottom"); + const content = document.createElement("lol-uikit-content-block"); + content.setAttribute("type", "tooltip-system"); + const p = document.createElement("p"); + p.textContent = text; + content.appendChild(p); + tip.appendChild(content); + wrapper.appendChild(tip); + document.body.appendChild(wrapper); + activeDiceTooltipWrapper = wrapper; + + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (!wrapper || !wrapper.isConnected) return; + const btnRect = el.getBoundingClientRect(); + const wrapRect = wrapper.getBoundingClientRect(); + const centerX = btnRect.left + btnRect.width / 2; + wrapper.style.left = `${centerX - wrapRect.width / 2}px`; + wrapper.style.top = `${btnRect.bottom + 6}px`; + wrapper.style.visibility = "visible"; + }); + }); + }); + + el.addEventListener("mouseleave", hideDiceTooltip); + el.addEventListener("click", hideDiceTooltip); } let randomModeActive = false; - let currentRewardsElement = null; - let randomFlagImageUrl = null; // HTTP URL from Python - const pendingRandomFlagRequest = new Map(); // Track pending requests - let isInChampSelect = false; // Track if we're in ChampSelect phase - let championLocked = false; // Track if a champion is locked - - // Dice button state - let diceButtonElement = null; - let diceButtonState = 'disabled'; // 'disabled' or 'enabled' - let diceDisabledImageUrl = null; // HTTP URL from Python - let diceEnabledImageUrl = null; // HTTP URL from Python - const pendingDiceImageRequests = new Map(); // Track pending requests + let randomModeType = "all"; + let randomSkinId = null; + let isInChampSelect = false; + let championLocked = false; + let currentChampionFavoritesCount = 0; + + let diceContainerElement = null; + let regularDiceBtn = null; + let favoriteDiceBtn = null; + let diceButtonState = "disabled"; + let diceDisabledImageUrl = null; + let diceEnabledImageUrl = null; + const pendingDiceImageRequests = new Map(); const CSS_RULES = ` - .skin-selection-item-information.loyalty-reward-icon--rewards.lu-random-flag-active { - background-repeat: no-repeat !important; - background-size: contain !important; - height: 32px !important; - width: 32px !important; + .lu-random-dice-group { position: absolute !important; - right: -14px !important; - top: -14px !important; - pointer-events: none !important; - cursor: default !important; - -webkit-user-select: none !important; - list-style-type: none !important; - content: " " !important; + display: flex !important; + flex-direction: row !important; + align-items: center !important; + justify-content: center !important; + gap: 10px !important; + z-index: 10 !important; + pointer-events: auto !important; } - + .lu-random-dice-button { - position: absolute !important; width: 38px !important; height: 23px !important; cursor: pointer !important; - z-index: 0 !important; pointer-events: auto !important; background-size: contain !important; background-repeat: no-repeat !important; background-position: center !important; opacity: 1 !important; - display: block !important; - visibility: visible !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + transition: transform 0.15s ease, opacity 0.15s ease, filter 0.15s ease !important; + position: relative !important; } - .lu-random-dice-button:hover { - opacity: 0.8 !important; + .lu-random-dice-button:hover:not(.disabled-fav) { + transform: scale(1.12) !important; + opacity: 0.95 !important; + } + + .lu-random-dice-button.favorite-dice { + filter: drop-shadow(0 0 4px rgba(245, 211, 101, 0.7)); + } + + .lu-random-dice-button.favorite-dice:hover:not(.disabled-fav) { + filter: drop-shadow(0 0 8px rgba(245, 211, 101, 1)); + } + + .lu-random-dice-button.favorite-dice .dice-star-badge { + position: absolute; + top: -6px; + right: -6px; + width: 14px; + height: 14px; + pointer-events: none; + filter: drop-shadow(0 0 3px #ffd700); + } + + .lu-random-dice-button.disabled-fav { + opacity: 0.45 !important; + filter: grayscale(0.8) !important; + cursor: default !important; + } + + @keyframes lu-dice-roll { + 0% { transform: rotate(0deg) scale(1); } + 35% { transform: rotate(-30deg) scale(1.25); } + 75% { transform: rotate(20deg) scale(1.1); } + 100% { transform: rotate(0deg) scale(1); } + } + + .lu-random-dice-button.rolling { + animation: lu-dice-roll 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275) !important; + pointer-events: none !important; } `; @@ -103,10 +170,8 @@ timestamp: Date.now(), }; if (data) payload.data = data; - if (bridge) bridge.send(payload); - // Also log to console for debugging const consoleMethod = level === "error" ? console.error : level === "warn" ? console.warn : console.log; consoleMethod(`${LOG_PREFIX} ${message}`, data || ""); } @@ -117,64 +182,42 @@ log("debug", "Received champion lock state update", { locked: championLocked, wasLocked: wasLocked }); - // Only create dice button if entering ChampSelect and champion is now locked if (isInChampSelect && championLocked && !wasLocked) { - log("debug", "Champion locked - creating dice button"); + log("debug", "Champion locked - creating dual dice buttons"); setTimeout(() => { - createDiceButton(); - if (randomModeActive) { - updateRandomFlag(); - } + createDiceButtons(); }, 100); } else if (!championLocked && wasLocked) { - // Champion unlocked - remove dice button - log("debug", "Champion unlocked - removing dice button"); - if (diceButtonElement) { - diceButtonElement.remove(); - diceButtonElement = null; + log("debug", "Champion unlocked - removing dice buttons"); + hideDiceTooltip(); + if (diceContainerElement) { + diceContainerElement.remove(); + diceContainerElement = null; } } } function handlePhaseChange(data) { const wasInChampSelect = isInChampSelect; - // Check if we're entering ChampSelect phase - isInChampSelect = data.phase === "ChampSelect" || data.phase === "FINALIZATION"; + isInChampSelect = data.phase === "ChampSelect" || data.phase === "FINALIZATION" || data.phase === "Lobby"; + + hideDiceTooltip(); if (isInChampSelect && !wasInChampSelect) { log("debug", "Entered ChampSelect phase - enabling plugin"); - // Only create dice button if champion is already locked if (championLocked) { setTimeout(() => { - createDiceButton(); - if (randomModeActive) { - updateRandomFlag(); - } + createDiceButtons(); }, 100); - } else { - log("debug", "Waiting for champion lock before creating dice button"); } } else if (!isInChampSelect && wasInChampSelect) { log("debug", "Left ChampSelect phase - disabling plugin"); - // Hide flag and remove dice button when leaving ChampSelect - if (currentRewardsElement) { - hideFlagOnElement(currentRewardsElement); - currentRewardsElement = null; - } - if (diceButtonElement) { - diceButtonElement.remove(); - diceButtonElement = null; + hideDiceTooltip(); + if (diceContainerElement) { + diceContainerElement.remove(); + diceContainerElement = null; } - // Reset retry counters - if (updateRandomFlag._retryCount) { - updateRandomFlag._retryCount = 0; - } - // Reset champion lock state when leaving ChampSelect championLocked = false; - // Update button if it exists and is in enabled state - if (diceButtonElement && diceButtonState === 'enabled') { - updateDiceButtonImage(); - } } } @@ -182,530 +225,313 @@ const assetPath = data.assetPath; let url = data.url; - // Fix: Ensure we use 127.0.0.1 for asset URLs to match the bridge connection - if (url && typeof url === 'string') { - url = url.replace('localhost', '127.0.0.1'); + if (url && typeof url === "string") { + url = url.replace("localhost", "127.0.0.1"); } - if (assetPath === RANDOM_FLAG_ASSET_PATH && url) { - randomFlagImageUrl = url; - pendingRandomFlagRequest.delete(RANDOM_FLAG_ASSET_PATH); - log("info", "Received random flag image URL from Python", { url: url }); - - // Update the flag if it's currently active and we're in ChampSelect - if (isInChampSelect && randomModeActive) { - updateRandomFlag(); - } - } else if (assetPath === DICE_DISABLED_ASSET_PATH && url) { + if (assetPath === DICE_DISABLED_ASSET_PATH && url) { diceDisabledImageUrl = url; pendingDiceImageRequests.delete(DICE_DISABLED_ASSET_PATH); - log("info", "Received dice disabled image URL from Python", { url: url }); - - // Update button if it exists and is in disabled state - if (diceButtonElement && diceButtonState === 'disabled') { - updateDiceButtonImage(); - } + updateDiceButtonsImages(); } else if (assetPath === DICE_ENABLED_ASSET_PATH && url) { diceEnabledImageUrl = url; pendingDiceImageRequests.delete(DICE_ENABLED_ASSET_PATH); - log("info", "Received dice enabled image URL from Python", { url: url }); - - // Update button if it exists and is in enabled state - if (diceButtonElement && diceButtonState === 'enabled') { - updateDiceButtonImage(); - } + updateDiceButtonsImages(); } } function handleRandomModeStateUpdate(data) { const wasActive = randomModeActive; - const previousState = diceButtonState; randomModeActive = data.active === true; - diceButtonState = data.diceState || 'disabled'; + randomModeType = data.randomModeType || "all"; + randomSkinId = data.randomSkinId; + diceButtonState = data.diceState || "disabled"; log("info", "Received random mode state update", { active: randomModeActive, wasActive: wasActive, diceState: diceButtonState, - randomSkinId: data.randomSkinId + randomSkinId: data.randomSkinId, + randomChromaId: data.randomChromaId, + randomModeType: randomModeType, }); - // Only update if we're in ChampSelect - if (!isInChampSelect) { - return; - } - - // Update dice button state - updateDiceButton(); - - // Always update the flag when we receive a state update (even if state didn't change) - // This ensures the flag is shown even if the element wasn't found initially - updateRandomFlag(); - } - - function findRewardsElement() { - // Only try to find elements when in ChampSelect - if (!isInChampSelect) { - return null; - } - - // Only consider the central skin item (offset-2) in the carousel - const allItems = document.querySelectorAll(".skin-selection-item"); - for (const item of allItems) { - // Check if this is the central item (offset-2) - if (item.classList.contains("skin-carousel-offset-2")) { - const info = item.querySelector(".skin-selection-item-information.loyalty-reward-icon--rewards"); - if (info) { - return info; - } - } - } - - // Only log if we're actually in ChampSelect (to avoid spam before entering) - return null; + updateDiceButtons(); } function findDiceButtonContainer() { - // Only try to find container when in ChampSelect - if (!isInChampSelect) { - return null; - } - - // Find the carousel container to match its stacking context const carouselContainer = document.querySelector(".skin-selection-carousel-container"); - if (carouselContainer) { - return carouselContainer; - } + if (carouselContainer) return carouselContainer; - // Fallback: find the carousel itself const carousel = document.querySelector(".skin-selection-carousel"); - if (carousel) { - return carousel; - } + if (carousel) return carousel; - // Last fallback: find the main champ select container and then div.visible const mainContainer = document.querySelector(".champion-select-main-container"); if (mainContainer) { const visibleDiv = mainContainer.querySelector("div.visible"); - if (visibleDiv) { - return visibleDiv; - } + if (visibleDiv) return visibleDiv; } return null; } function findDiceButtonLocation() { - // Only try to find location when in ChampSelect - if (!isInChampSelect) { - return null; - } - - // Always find the central skin item (offset-2) in the carousel const allItems = document.querySelectorAll(".skin-selection-item"); for (const item of allItems) { - // Check if this is the central item (offset-2) if (item.classList.contains("skin-carousel-offset-2")) { const rect = item.getBoundingClientRect(); - // Position at same x (centered) but 78px lower in y than central skin return { - x: rect.left + rect.width / 2 - 19, // Half of button width (38px) - centered - y: rect.top + 78, // 78px lower than the top of the central skin - width: 38, + x: rect.left + rect.width / 2 - 43, + y: rect.top + 78, + width: 86, height: 23, - relativeTo: item + relativeTo: item, }; } } - // Fallback: try selected item if central item not found const selectedItem = document.querySelector(".skin-selection-item.skin-selection-item-selected"); if (selectedItem) { const rect = selectedItem.getBoundingClientRect(); return { - x: rect.left + rect.width / 2 - 19, // Half of button width (38px) - centered - y: rect.top + 78, // 78px lower than the top of the selected skin - width: 38, + x: rect.left + rect.width / 2 - 43, + y: rect.top + 78, + width: 86, height: 23, - relativeTo: selectedItem + relativeTo: selectedItem, }; } return null; } - function createDiceButton() { - // Don't create button if champion is not locked - if (!championLocked) { - log("debug", "Cannot create dice button - champion not locked"); - return; - } + function createDiceButtons() { + if (!championLocked) return; - // Remove existing button if it exists - if (diceButtonElement) { - diceButtonElement.remove(); - diceButtonElement = null; + if (diceContainerElement) { + diceContainerElement.remove(); + diceContainerElement = null; } - // Find the target container (div.visible in main champ select container) const targetContainer = findDiceButtonContainer(); - if (!targetContainer) { - // Don't log error on every attempt - only log occasionally - if (!createDiceButton._lastLogTime || Date.now() - createDiceButton._lastLogTime > 5000) { - log("debug", "Could not find dice button container (will retry)"); - createDiceButton._lastLogTime = Date.now(); - } - return; - } + if (!targetContainer) return; const location = findDiceButtonLocation(); - if (!location) { - // Don't log error on every attempt - only log occasionally - if (!createDiceButton._lastLogTime || Date.now() - createDiceButton._lastLogTime > 5000) { - log("debug", "Could not find dice button location (will retry)"); - createDiceButton._lastLogTime = Date.now(); - } - return; - } + if (!location) return; - // Request images if not already loaded requestDiceButtonImages(); - // Get container's position relative to viewport for absolute positioning const containerRect = targetContainer.getBoundingClientRect(); - - // Ensure container has positioning context for absolute children const containerComputedStyle = window.getComputedStyle(targetContainer); - if (containerComputedStyle.position === 'static') { - targetContainer.style.position = 'relative'; - } - - const button = document.createElement("div"); - button.className = `lu-random-dice-button ${diceButtonState}`; - button.style.position = "absolute"; // Use absolute positioning relative to container - // Calculate position relative to container - // getBoundingClientRect() already accounts for scroll, transforms, etc. - button.style.left = `${location.x - containerRect.left}px`; - button.style.top = `${location.y - containerRect.top}px`; - button.style.width = `${location.width}px`; - button.style.height = `${location.height}px`; - button.style.zIndex = "0"; - button.style.display = "block"; // Ensure button is visible - button.style.visibility = "visible"; // Ensure button is visible - button.style.opacity = "1"; // Ensure button is visible - - // Append to the target container instead of document.body - targetContainer.appendChild(button); - diceButtonElement = button; - - // Set initial background image based on state (after appending to DOM) - updateDiceButtonImage(); - - // Add click handler - button.addEventListener("click", (e) => { + if (containerComputedStyle.position === "static") { + targetContainer.style.position = "relative"; + } + + const group = document.createElement("div"); + group.className = "lu-random-dice-group"; + group.style.position = "absolute"; + group.style.left = `${location.x - containerRect.left}px`; + group.style.top = `${location.y - containerRect.top}px`; + group.style.width = `${location.width}px`; + group.style.height = `${location.height}px`; + + // 1. Regular Dice (Roll All) + const regBtn = document.createElement("div"); + regBtn.className = `lu-random-dice-button regular-dice ${diceButtonState}`; + regBtn.addEventListener("click", (e) => { + e.stopPropagation(); + e.preventDefault(); + handleDiceClick("all"); + }); + attachTooltip(regBtn, "Roll Random Skin (All Skins)"); + + // 2. Favorite Dice (Roll Favorites) + const favBtn = document.createElement("div"); + favBtn.className = `lu-random-dice-button favorite-dice ${diceButtonState}`; + favBtn.innerHTML = ` + + + + `; + favBtn.addEventListener("click", (e) => { e.stopPropagation(); e.preventDefault(); - handleDiceButtonClick(); + if (currentChampionFavoritesCount <= 0) return; + handleDiceClick("favorites"); }); + attachTooltip(favBtn, () => { + if (currentChampionFavoritesCount > 0) { + return `Roll Random Favorite Skin (${currentChampionFavoritesCount} favorited)`; + } + return "No favorite skins for this champion. Star skins in Quickplay to add favorites!"; + }); + + group.appendChild(regBtn); + group.appendChild(favBtn); + targetContainer.appendChild(group); - // Store the relative element for repositioning - diceButtonElement._relativeTo = location.relativeTo; - diceButtonElement._container = targetContainer; + diceContainerElement = group; + regularDiceBtn = regBtn; + favoriteDiceBtn = favBtn; - // Force browser to render the button immediately - void button.offsetHeight; // Trigger reflow - button.style.display = "block"; // Ensure it's set again after reflow + updateDiceButtonsImages(); + updateFavoritesState(); - log("info", "Created dice button", { x: location.x, y: location.y, state: diceButtonState }); + log("info", "Created dual dice buttons", { x: location.x, y: location.y }); } - function updateDiceButtonImage() { - if (!diceButtonElement) { - return; - } + function updateDiceButtonsImages() { + if (!regularDiceBtn || !favoriteDiceBtn) return; - // Use local images if available, otherwise wait for them to load - if (diceButtonState === 'disabled' && diceDisabledImageUrl) { - diceButtonElement.style.backgroundImage = `url("${diceDisabledImageUrl}")`; - } else if (diceButtonState === 'enabled' && diceEnabledImageUrl) { - diceButtonElement.style.backgroundImage = `url("${diceEnabledImageUrl}")`; + if (diceButtonState === "disabled" && diceDisabledImageUrl) { + regularDiceBtn.style.backgroundImage = `url("${diceDisabledImageUrl}")`; + favoriteDiceBtn.style.backgroundImage = `url("${diceDisabledImageUrl}")`; + } else if (diceButtonState === "enabled" && diceEnabledImageUrl) { + if (randomModeType === "favorites") { + favoriteDiceBtn.style.backgroundImage = `url("${diceEnabledImageUrl}")`; + regularDiceBtn.style.backgroundImage = diceDisabledImageUrl ? `url("${diceDisabledImageUrl}")` : ""; + } else { + regularDiceBtn.style.backgroundImage = `url("${diceEnabledImageUrl}")`; + favoriteDiceBtn.style.backgroundImage = diceDisabledImageUrl ? `url("${diceDisabledImageUrl}")` : ""; + } } else { - // Images not loaded yet, request them requestDiceButtonImages(); } } - function requestDiceButtonImages() { - // Request disabled image - if (!diceDisabledImageUrl && !pendingDiceImageRequests.has(DICE_DISABLED_ASSET_PATH)) { - pendingDiceImageRequests.set(DICE_DISABLED_ASSET_PATH, true); - - const payload = { - type: "request-local-asset", - assetPath: DICE_DISABLED_ASSET_PATH, - timestamp: Date.now(), - }; - - log("debug", "Requesting dice disabled image from Python", { assetPath: DICE_DISABLED_ASSET_PATH }); - - if (bridge) bridge.send(payload); + function updateFavoritesState(favDetail) { + if (favDetail && favDetail.favorites && Array.isArray(favDetail.favorites.skins)) { + currentChampionFavoritesCount = favDetail.favorites.skins.length; } - // Request enabled image - if (!diceEnabledImageUrl && !pendingDiceImageRequests.has(DICE_ENABLED_ASSET_PATH)) { - pendingDiceImageRequests.set(DICE_ENABLED_ASSET_PATH, true); - - const payload = { - type: "request-local-asset", - assetPath: DICE_ENABLED_ASSET_PATH, - timestamp: Date.now(), - }; - - log("debug", "Requesting dice enabled image from Python", { assetPath: DICE_ENABLED_ASSET_PATH }); - - if (bridge) bridge.send(payload); + if (favoriteDiceBtn) { + if (currentChampionFavoritesCount > 0) { + favoriteDiceBtn.classList.remove("disabled-fav"); + } else { + favoriteDiceBtn.classList.add("disabled-fav"); + } } } - function updateDiceButton() { - if (!diceButtonElement) { - createDiceButton(); + function updateDiceButtons() { + if (!diceContainerElement) { + createDiceButtons(); return; } + updateDiceButtonsImages(); + updateFavoritesState(); + } - // Don't update position dynamically - it's set once on creation - // Only update button state and image + let lastDiceClickTime = 0; - // Update button state - diceButtonElement.className = `lu-random-dice-button ${diceButtonState}`; + function handleDiceClick(mode) { + const now = Date.now(); + if (now - lastDiceClickTime < 450) { + log("debug", "Debouncing rapid dice click"); + return; + } + lastDiceClickTime = now; - // Update button image based on state - updateDiceButtonImage(); + // Trigger visual roll animation on clicked button + const targetBtn = mode === "favorites" ? favoriteDiceBtn : regularDiceBtn; + if (targetBtn) { + targetBtn.classList.remove("rolling"); + void targetBtn.offsetWidth; // trigger reflow + targetBtn.classList.add("rolling"); + setTimeout(() => { + if (targetBtn) targetBtn.classList.remove("rolling"); + }, 400); + } - log("debug", "Updated dice button state", { state: diceButtonState }); - } + // Determine state for Python: + // If random mode is active in the SAME mode, clicking it toggles off (sends 'enabled') + // If random mode is NOT active OR active in a DIFFERENT mode, clicking it activates/switches (sends 'disabled') + let sendState = "disabled"; + if (randomModeActive && randomModeType === mode) { + sendState = "enabled"; + } else { + sendState = "disabled"; + } - function handleDiceButtonClick() { - log("info", "Dice button clicked", { currentState: diceButtonState }); + log("info", "Dice button clicked", { + mode: mode, + sendState: sendState, + currentActive: randomModeActive, + currentType: randomModeType, + }); - // Send click event to Python const payload = { type: "dice-button-click", - state: diceButtonState, + state: sendState, + mode: mode, timestamp: Date.now(), }; if (bridge) { bridge.send(payload); - } else { - log("warn", "Bridge not ready, dice button click lost"); } } - function requestRandomFlagImage() { - // Request random flag image from Python (same way as HistoricMode) - if (!randomFlagImageUrl && !pendingRandomFlagRequest.has(RANDOM_FLAG_ASSET_PATH)) { - pendingRandomFlagRequest.set(RANDOM_FLAG_ASSET_PATH, true); - - const payload = { - type: "request-local-asset", - assetPath: RANDOM_FLAG_ASSET_PATH, - timestamp: Date.now(), - }; - - log("debug", "Requesting random flag image from Python", { assetPath: RANDOM_FLAG_ASSET_PATH }); - - if (bridge) bridge.send(payload); - } - } - - function updateRandomFlag() { - // Only try to update if we're in ChampSelect - if (!isInChampSelect) { - return; - } - - // Always find the element in the currently selected skin (don't use cached element) - const element = findRewardsElement(); - - if (!element) { - // Only retry if we're still in ChampSelect - if (!isInChampSelect) { - return; - } - log("debug", "Rewards element not found, will retry"); - // Retry after a short delay (max 5 retries to avoid infinite loop) - if (!updateRandomFlag._retryCount) { - updateRandomFlag._retryCount = 0; - } - if (updateRandomFlag._retryCount < 5) { - updateRandomFlag._retryCount++; - setTimeout(() => { - if (isInChampSelect) { // Check again before retrying - updateRandomFlag(); - } else { - updateRandomFlag._retryCount = 0; // Reset if we left ChampSelect - } - }, 500); - } else { - log("warn", "Rewards element not found after 5 retries, giving up"); - updateRandomFlag._retryCount = 0; // Reset for next attempt - } - return; - } - - // Reset retry count on success - updateRandomFlag._retryCount = 0; - - // If we have a previously cached element that's different from the current one, hide it first - if (currentRewardsElement && currentRewardsElement !== element) { - log("debug", "Selected skin changed - hiding flag on previous element"); - hideFlagOnElement(currentRewardsElement); - } - - currentRewardsElement = element; - - // Check element visibility (no logging to reduce spam) - const computedStyle = window.getComputedStyle(element); - const isVisible = computedStyle.display !== "none" && computedStyle.visibility !== "hidden" && computedStyle.opacity !== "0"; - - if (randomModeActive) { - // Request image if we don't have it yet - if (!randomFlagImageUrl) { - requestRandomFlagImage(); - // Wait for image URL before applying - return; - } - - // Force element to be visible (rewards icon is usually hidden) - element.style.setProperty("display", "block", "important"); - element.style.setProperty("visibility", "visible", "important"); - element.style.setProperty("opacity", "1", "important"); - - // Apply the image URL from Python - element.classList.add("lu-random-flag-active"); - element.style.setProperty("background-image", `url("${randomFlagImageUrl}")`, "important"); - element.style.setProperty("background-repeat", "no-repeat", "important"); - element.style.setProperty("background-size", "contain", "important"); - element.style.setProperty("height", "32px", "important"); - element.style.setProperty("width", "32px", "important"); - element.style.setProperty("position", "absolute", "important"); - element.style.setProperty("right", "-14px", "important"); - element.style.setProperty("top", "-14px", "important"); - element.style.setProperty("pointer-events", "none", "important"); - element.style.setProperty("cursor", "default", "important"); - element.style.setProperty("-webkit-user-select", "none", "important"); - element.style.setProperty("list-style-type", "none", "important"); - element.style.setProperty("content", " ", "important"); - - log("info", "Random flag shown on rewards element", { - url: randomFlagImageUrl, - display: element.style.display, - visibility: element.style.visibility - }); - } else { - // Random mode is inactive - hide the flag - hideFlagOnElement(element); - log("info", "Random flag hidden on rewards element"); + function requestDiceButtonImages() { + if (!diceDisabledImageUrl && !pendingDiceImageRequests.has(DICE_DISABLED_ASSET_PATH)) { + pendingDiceImageRequests.set(DICE_DISABLED_ASSET_PATH, true); + if (bridge) bridge.send({ type: "request-local-asset", assetPath: DICE_DISABLED_ASSET_PATH, timestamp: Date.now() }); } - } - function hideFlagOnElement(element) { - if (!element) return; - - // Only remove our flag class - element.classList.remove("lu-random-flag-active"); - - // Check if historic flag is active - if so, don't remove shared styles - const hasHistoricFlag = element.classList.contains("lu-historic-flag-active"); - - if (!hasHistoricFlag) { - // No other flag is active - safe to remove all styles - element.style.removeProperty("background-image"); - element.style.removeProperty("background-repeat"); - element.style.removeProperty("background-size"); - element.style.removeProperty("height"); - element.style.removeProperty("width"); - element.style.removeProperty("position"); - element.style.removeProperty("right"); - element.style.removeProperty("top"); - element.style.removeProperty("pointer-events"); - element.style.removeProperty("cursor"); - element.style.removeProperty("-webkit-user-select"); - element.style.removeProperty("list-style-type"); - element.style.removeProperty("content"); - // Explicitly hide the element (rewards icon is usually hidden by default) - element.style.setProperty("display", "none", "important"); - element.style.setProperty("visibility", "hidden", "important"); - element.style.setProperty("opacity", "0", "important"); - } else { - // Historic flag is active - only remove our background image, keep shared styles - // Check if the background-image is ours (contains random_flag.png) - const bgImage = element.style.getPropertyValue("background-image"); - if (bgImage && bgImage.includes("random_flag.png")) { - element.style.removeProperty("background-image"); - } - // Don't remove other styles as historic flag needs them + if (!diceEnabledImageUrl && !pendingDiceImageRequests.has(DICE_ENABLED_ASSET_PATH)) { + pendingDiceImageRequests.set(DICE_ENABLED_ASSET_PATH, true); + if (bridge) bridge.send({ type: "request-local-asset", assetPath: DICE_ENABLED_ASSET_PATH, timestamp: Date.now() }); } } - async function init() { - log("info", "Initializing LU-RandomSkin plugin"); - - // Wait for shared bridge - bridge = await waitForBridge(); - - // Ensure random mode starts as inactive - randomModeActive = false; - diceButtonState = 'disabled'; - - // Inject CSS + function injectCSS() { + if (document.getElementById("rose-random-skin-css")) return; const style = document.createElement("style"); + style.id = "rose-random-skin-css"; style.textContent = CSS_RULES; document.head.appendChild(style); + } - // Subscribe to bridge message types - bridge.subscribe("random-mode-state", handleRandomModeStateUpdate); - bridge.subscribe("local-asset-url", handleLocalAssetUrl); - bridge.subscribe("phase-change", handlePhaseChange); - bridge.subscribe("champion-locked", handleChampionLocked); - - // On bridge (re)connect, re-request assets - bridge.onReady(() => { - requestRandomFlagImage(); - requestDiceButtonImages(); - }); - - // Watch for DOM changes to find rewards element and dice button location (only when in ChampSelect) - const observer = new MutationObserver(() => { - if (!isInChampSelect) { - return; // Don't do anything if not in ChampSelect - } - - if (randomModeActive && !currentRewardsElement) { - updateRandomFlag(); - } - // Only create dice button if it doesn't exist and champion is locked - don't update position dynamically - if (!diceButtonElement && championLocked) { - createDiceButton(); + async function start() { + injectCSS(); + + try { + bridge = await waitForBridge(); + log("info", "Bridge ready in ROSE-RandomSkin"); + + if (bridge.subscribe) { + bridge.subscribe("phase-change", handlePhaseChange); + bridge.subscribe("champion-locked", handleChampionLocked); + bridge.subscribe("local-asset-url", handleLocalAssetUrl); + bridge.subscribe("random-mode-state", handleRandomModeStateUpdate); + bridge.subscribe("favorites-state", (data) => { + if (data && data.championFavorites) { + updateFavoritesState({ favorites: data.championFavorites }); + } + }); } - }); - observer.observe(document.body, { - childList: true, - subtree: true, - }); - - // Don't try to create elements on init - wait for phase-change message to know if we're in ChampSelect + window.addEventListener("rose-favorites-updated", (e) => { + if (e.detail) { + updateFavoritesState(e.detail); + } + }); - log("info", "LU-RandomSkin plugin initialized"); + window.addEventListener("blur", hideDiceTooltip); + window.addEventListener("pointerdown", (e) => { + if (activeDiceTooltipWrapper && !e.target.closest(".lu-random-dice-button")) { + hideDiceTooltip(); + } + }); + } catch (e) { + log("error", "Failed to initialize RandomSkin bridge:", e); + } } - // Start when DOM is ready if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", init); + document.addEventListener("DOMContentLoaded", start); } else { - init(); + start(); } })(); - diff --git a/injection/mods/zip_resolver.py b/injection/mods/zip_resolver.py index 3626ff47..34f57a76 100644 --- a/injection/mods/zip_resolver.py +++ b/injection/mods/zip_resolver.py @@ -55,9 +55,9 @@ def resolve_zip(self, zip_arg: str, chroma_id: int = None, skin_name: str = None return cand # Handle ID-based naming convention from random selection - if zip_arg.startswith('skin_'): - # Format: skin_{skin_id} - check if this is actually a chroma - skin_id = int(zip_arg.split('_')[1]) + if zip_arg.startswith('skin_') or zip_arg.isdigit(): + # Format: skin_{skin_id} or numeric skin_id + skin_id = int(zip_arg.split('_')[1]) if zip_arg.startswith('skin_') else int(zip_arg) if not champion_id: log.warning(f"[INJECT] No champion_id provided for skin ID: {skin_id}") return None diff --git a/pengu/communication/broadcaster.py b/pengu/communication/broadcaster.py index de4e3787..f52029ed 100644 --- a/pengu/communication/broadcaster.py +++ b/pengu/communication/broadcaster.py @@ -278,6 +278,11 @@ def broadcast_champion_locked(self, locked: bool) -> None: ) self._send_message(json.dumps(payload)) + if locked: + try: + self.broadcast_favorites_state() + except Exception as e: + log.debug("[SkinMonitor] Failed to broadcast favorites on champion lock: %s", e) def broadcast_random_mode_state(self) -> None: """Broadcast random mode state to JavaScript plugins""" @@ -286,21 +291,64 @@ def broadcast_random_mode_state(self) -> None: random_mode_active = getattr(self.shared_state, 'random_mode_active', False) random_skin_id = getattr(self.shared_state, 'random_skin_id', None) + random_skin_name = getattr(self.shared_state, 'random_skin_name', None) + random_chroma_id = getattr(self.shared_state, 'random_chroma_id', None) + random_mode_type = getattr(self.shared_state, 'random_mode_type', 'all') dice_state = 'enabled' if random_mode_active else 'disabled' payload = { "type": "random-mode-state", "active": random_mode_active, "randomSkinId": random_skin_id, + "randomSkinName": random_skin_name, + "randomChromaId": random_chroma_id, + "randomModeType": random_mode_type, "diceState": dice_state, "timestamp": int(time.time() * 1000), } log.debug( - "[SkinMonitor] Broadcasting random mode state → active=%s diceState=%s randomSkinId=%s", + "[SkinMonitor] Broadcasting random mode state → active=%s diceState=%s randomSkinId=%s randomChromaId=%s mode=%s", random_mode_active, dice_state, random_skin_id, + random_chroma_id, + random_mode_type, + ) + + self._send_message(json.dumps(payload)) + + def broadcast_favorites_state(self, champion_id: Optional[int] = None) -> None: + """Broadcast favorites state for current or specified champion to JavaScript plugins""" + if not self.websocket_server.loop or not self.websocket_server.connections: + return + + if champion_id is None: + champion_id = getattr(self.shared_state, 'locked_champ_id', None) + if champion_id is None and self.skin_scraper and self.skin_scraper.cache: + champion_id = getattr(self.skin_scraper.cache, 'champion_id', None) + + from utils.core.favorites import get_champion_favorites, load_favorites_data + + if champion_id is not None: + champ_favs = get_champion_favorites(champion_id) + else: + champ_favs = {"championId": None, "skins": [], "chromas": {}} + + all_data = load_favorites_data() + + payload = { + "type": "favorites-state", + "championId": champion_id, + "championFavorites": champ_favs, + "allFavorites": all_data.get("favorites", {}), + "timestamp": int(time.time() * 1000), + } + + log.debug( + "[SkinMonitor] Broadcasting favorites state for champion %s (skins: %s)", + champion_id, + champ_favs.get("skins", []), ) self._send_message(json.dumps(payload)) diff --git a/pengu/communication/message_handler.py b/pengu/communication/message_handler.py index 552ac22d..c094b701 100644 --- a/pengu/communication/message_handler.py +++ b/pengu/communication/message_handler.py @@ -246,6 +246,12 @@ def _route(self, payload_type: Optional[str], payload: dict) -> None: self._handle_chroma_selection(payload) elif payload_type == "dice-button-click": self._handle_dice_button_click(payload) + elif payload_type == "favorite-toggle-skin": + self._handle_favorite_toggle_skin(payload) + elif payload_type == "favorite-toggle-chroma": + self._handle_favorite_toggle_chroma(payload) + elif payload_type == "request-favorites": + self._handle_request_favorites(payload) elif payload_type == "settings-request": self._handle_settings_request(payload) elif payload_type == "path-validate": @@ -483,20 +489,65 @@ def _handle_find_match_hover(self, payload: dict) -> None: def _handle_dice_button_click(self, payload: dict) -> None: """Handle dice button click""" button_state = payload.get("state", "disabled") - log.info(f"[SkinMonitor] Dice button clicked from JavaScript: state={button_state}") + mode = payload.get("mode", "all") + log.info(f"[SkinMonitor] Dice button clicked from JavaScript: state={button_state}, mode={mode}") try: from ui.core.user_interface import get_user_interface ui = get_user_interface(self.shared_state, self.skin_scraper) if button_state == "disabled": - ui._handle_dice_click_disabled() + ui._handle_dice_click_disabled(mode=mode) elif button_state == "enabled": ui._handle_dice_click_enabled() else: log.warning(f"[SkinMonitor] Unknown dice button state: {button_state}") except Exception as e: log.error(f"[SkinMonitor] Failed to handle dice button click: {e}") + + def _handle_favorite_toggle_skin(self, payload: dict) -> None: + """Handle skin favorite toggle event from JavaScript""" + try: + from utils.core.favorites import toggle_skin_favorite + champion_id = payload.get("championId") + skin_id = payload.get("skinId") + if not champion_id or not skin_id: + log.warning("[SkinMonitor] Invalid favorite-toggle-skin payload: %s", payload) + return + + is_fav, champ_favs = toggle_skin_favorite(int(champion_id), int(skin_id)) + log.info(f"[SkinMonitor] Toggled favorite skin {skin_id} for champion {champion_id} -> {is_fav}") + self.broadcaster.broadcast_favorites_state(int(champion_id)) + except Exception as e: + log.error(f"[SkinMonitor] Failed to handle favorite-toggle-skin: {e}") + + def _handle_favorite_toggle_chroma(self, payload: dict) -> None: + """Handle chroma favorite toggle event from JavaScript""" + try: + from utils.core.favorites import toggle_chroma_favorite + champion_id = payload.get("championId") + skin_id = payload.get("skinId") + chroma_id = payload.get("chromaId") + if not champion_id or not skin_id or not chroma_id: + log.warning("[SkinMonitor] Invalid favorite-toggle-chroma payload: %s", payload) + return + + is_fav, champ_favs = toggle_chroma_favorite(int(champion_id), int(skin_id), int(chroma_id)) + log.info(f"[SkinMonitor] Toggled favorite chroma {chroma_id} for skin {skin_id} -> {is_fav}") + self.broadcaster.broadcast_favorites_state(int(champion_id)) + except Exception as e: + log.error(f"[SkinMonitor] Failed to handle favorite-toggle-chroma: {e}") + + def _handle_request_favorites(self, payload: dict) -> None: + """Handle favorites request from JavaScript""" + try: + champion_id = payload.get("championId") + if champion_id: + self.broadcaster.broadcast_favorites_state(int(champion_id)) + else: + self.broadcaster.broadcast_favorites_state() + except Exception as e: + log.error(f"[SkinMonitor] Failed to handle request-favorites: {e}") def _handle_settings_request(self, payload: dict) -> None: """Handle settings request""" diff --git a/pengu/core/skin_monitor.py b/pengu/core/skin_monitor.py index 4930d138..f5c2212d 100644 --- a/pengu/core/skin_monitor.py +++ b/pengu/core/skin_monitor.py @@ -184,6 +184,10 @@ def _broadcast_random_mode_state(self) -> None: """Broadcast random mode state (delegates to broadcaster)""" self.broadcaster.broadcast_random_mode_state() + def _broadcast_favorites_state(self, champion_id: Optional[int] = None) -> None: + """Broadcast favorites state (delegates to broadcaster)""" + self.broadcaster.broadcast_favorites_state(champion_id) + def _broadcast_skip_base_skin(self) -> None: self.broadcaster.broadcast_skip_base_skin() diff --git a/threads/handlers/injection_trigger.py b/threads/handlers/injection_trigger.py index e3558044..e94edb80 100644 --- a/threads/handlers/injection_trigger.py +++ b/threads/handlers/injection_trigger.py @@ -182,7 +182,19 @@ def trigger_injection(self, name: str, ticker_id: int, cname: str = ""): return # Check if custom mod is selected for this skin (before logging) - ui_skin_id = self.state.last_hovered_skin_id + random_mode_active = getattr(self.state, 'random_mode_active', False) + random_skin_id = getattr(self.state, 'random_skin_id', None) + random_skin_name = getattr(self.state, 'random_skin_name', None) + random_chroma_id = getattr(self.state, 'random_chroma_id', None) + display_skin_name = name + if random_mode_active and random_skin_id: + ui_skin_id = random_skin_id + name = f"skin_{random_skin_id}" + if random_skin_name: + display_skin_name = random_skin_name + log.info(f"[INJECT] Random mode active - using random skin ID: {ui_skin_id}, name: '{name}', display: '{display_skin_name}', chroma: {random_chroma_id}") + else: + ui_skin_id = self.state.last_hovered_skin_id locked_champ_id = self.state.locked_champ_id or self.state.hovered_champ_id # Determine effective skin/chroma ID based on active modes @@ -196,6 +208,8 @@ def trigger_injection(self, name: str, ticker_id: int, cname: str = ""): random_active = getattr(self.state, 'random_mode_active', False) random_skin_id = getattr(self.state, 'random_skin_id', None) selected_chroma_id = getattr(self.state, 'selected_chroma_id', None) + if random_active and random_chroma_id: + selected_chroma_id = random_chroma_id effective_skin_id = None if historic_active and historic_skin_id is not None: @@ -206,9 +220,9 @@ def trigger_injection(self, name: str, ticker_id: int, cname: str = ""): except (ValueError, TypeError): pass - if effective_skin_id is None and random_active and random_skin_id is not None: + if effective_skin_id is None and random_active and (random_chroma_id or random_skin_id is not None): try: - effective_skin_id = int(random_skin_id) + effective_skin_id = int(random_chroma_id if random_chroma_id else random_skin_id) except (ValueError, TypeError): pass @@ -246,7 +260,7 @@ def trigger_injection(self, name: str, ticker_id: int, cname: str = ""): mod_target_skin = selected_custom_mod.get("skin_id", ui_skin_id) if selected_custom_mod else ui_skin_id mod_labels.append(f"{mod_name} (SKIN_{mod_target_skin})") else: - mod_labels.append(name.upper()) + mod_labels.append(display_skin_name.upper()) # Add map/font/announcer/other mods if selected selected_map_mod = getattr(self.state, 'selected_map_mod', None) @@ -787,8 +801,7 @@ def auto_select_historic_mod(mod_type: str, category_attr: str): return # Skip injection for base/default skins (only if no mods are selected and - # historic mode is not active — if historic is active, the skin resolver - # already overrides to the saved skin and injection should proceed normally) + # neither historic nor random mode is active) historic_active = getattr(self.state, 'historic_mode_active', False) random_active = getattr(self.state, 'random_mode_active', False) is_default = effective_skin_id is not None and is_default_skin(effective_skin_id) @@ -842,7 +855,8 @@ def auto_select_historic_mod(mod_type: str, category_attr: str): # Inject if user doesn't own the hovered skin elif self.injection_manager: - self._inject_unowned_skin(name, cname) + chroma_id_to_inject = effective_skin_id if (effective_skin_id and effective_skin_id != ui_skin_id) else None + self._inject_unowned_skin(name, cname, chroma_id=chroma_id_to_inject) except Exception as e: log.warning(f"[loadout #{ticker_id}] injection setup failed: {e}") @@ -922,7 +936,7 @@ def _force_owned_skin(self, skin_id: int): except Exception as e: log.warning(f"[INJECT] Failed to resume game after forcing owned skin: {e}") - def _inject_unowned_skin(self, name: str, cname: str): + def _inject_unowned_skin(self, name: str, cname: str, chroma_id: int = None): """Inject unowned skin/chroma""" try: # Force base skin selection via LCU before injecting @@ -953,7 +967,7 @@ def _inject_unowned_skin(self, name: str, cname: str): game_ended_callback = make_game_ended_callback(self.state) # Inject skin in a separate thread - log.info(f"[INJECT] Starting injection: {name}") + log.info(f"[INJECT] Starting injection: {name} (chroma_id={chroma_id})") champ_id_for_history = self.state.locked_champ_id @@ -967,7 +981,8 @@ def run_injection(): name, stop_callback=game_ended_callback, champion_name=cname, - champion_id=self.state.locked_champ_id + champion_id=self.state.locked_champ_id, + chroma_id=chroma_id, ) # Clear random state after injection diff --git a/ui/core/user_interface.py b/ui/core/user_interface.py index cbbb2a3b..ae823cc9 100644 --- a/ui/core/user_interface.py +++ b/ui/core/user_interface.py @@ -210,15 +210,15 @@ def destroy_ui(self): # Clear skin display handler reference self.skin_display_handler = None - def _handle_dice_click_disabled(self): + def _handle_dice_click_disabled(self, mode: str = "all"): """Handle dice button click in disabled state - start randomization""" - if self.randomization_handler.handle_dice_click_disabled(self.current_skin_id): + if self.randomization_handler.handle_dice_click_disabled(self.current_skin_id, mode=mode): return # Need to force base skin first lcu = self.skin_scraper.lcu if self.skin_scraper and hasattr(self.skin_scraper, 'lcu') else None if lcu: - self.randomization_handler.force_base_skin_and_randomize(lcu) + self.randomization_handler.force_base_skin_and_randomize(lcu, mode=mode) def _handle_dice_click_enabled(self): """Handle dice button click in enabled state - cancel randomization""" diff --git a/ui/handlers/randomization_handler.py b/ui/handlers/randomization_handler.py index 4b514ff2..bfe862ae 100644 --- a/ui/handlers/randomization_handler.py +++ b/ui/handlers/randomization_handler.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- """ Randomization Handler -Handles random skin selection logic +Handles random skin selection logic (supports all-skins and favorites-only modes) """ import random @@ -10,6 +10,10 @@ from state import SharedState from utils.core.logging import get_logger from utils.core.utilities import is_base_skin +from utils.core.favorites import ( + get_favorite_skins_for_champion, + get_favorite_chromas_for_skin, +) log = get_logger() @@ -18,7 +22,9 @@ def _clear_random_mode(state: SharedState) -> None: """Reset random mode state and notify the JavaScript dice button.""" state.random_skin_name = None state.random_skin_id = None + state.random_chroma_id = None state.random_mode_active = False + state.random_mode_type = "all" try: if state and getattr(state, 'ui_skin_thread', None): @@ -31,7 +37,10 @@ def cancel_random_mode_for_selection(state: SharedState, selected_skin_id: Optio """Disable random mode when the user explicitly picks a different skin/chroma.""" if not getattr(state, 'random_mode_active', False): return False - if selected_skin_id and selected_skin_id == getattr(state, 'random_skin_id', None): + if selected_skin_id and ( + selected_skin_id == getattr(state, 'random_skin_id', None) + or selected_skin_id == getattr(state, 'random_chroma_id', None) + ): return False _clear_random_mode(state) @@ -54,9 +63,13 @@ def __init__(self, state: SharedState, skin_scraper=None): self._randomization_in_progress = False self._randomization_started = False - def handle_dice_click_disabled(self, current_skin_id: Optional[int]) -> bool: + def handle_dice_click_disabled(self, current_skin_id: Optional[int], mode: str = "all") -> bool: """Handle dice button click in disabled state - start randomization + Args: + current_skin_id: Current skin ID from UI + mode: "all" for all skins, "favorites" for favorites only + Returns: True if randomization was started, False otherwise """ @@ -65,7 +78,7 @@ def handle_dice_click_disabled(self, current_skin_id: Optional[int]) -> bool: log.debug("[UI] Randomization already in progress, ignoring click") return False - log.info("[UI] Starting random skin selection") + log.info(f"[UI] Starting random skin selection (mode={mode})") self._randomization_started = True # Force champion's base skin first @@ -74,7 +87,7 @@ def handle_dice_click_disabled(self, current_skin_id: Optional[int]) -> bool: if current_skin_id == base_champion_skin_id: # Already champion's base skin, proceed with randomization - self._start_randomization() + self._start_randomization(mode=mode) return True else: # Need to force champion's base skin first @@ -85,9 +98,13 @@ def handle_dice_click_enabled(self): log.info("[UI] Cancelling random skin selection") self.cancel() - def force_base_skin_and_randomize(self, lcu) -> bool: + def force_base_skin_and_randomize(self, lcu, mode: str = "all") -> bool: """Force champion's base skin via LCU API then start randomization + Args: + lcu: LCU instance + mode: "all" or "favorites" + Returns: True if base skin was forced and randomization started, False otherwise """ @@ -114,7 +131,7 @@ def force_base_skin_and_randomize(self, lcu) -> bool: if lcu.set_my_selection_skin(base_skin_id): log.info(f"[UI] Forced champion base skin: {base_skin_id}") # Start randomization immediately - self._start_randomization() + self._start_randomization(mode=mode) return True else: log.warning("[UI] Failed to force champion base skin") @@ -127,7 +144,7 @@ def force_base_skin_and_randomize(self, lcu) -> bool: self._randomization_started = False return False - def _start_randomization(self): + def _start_randomization(self, mode: str = "all"): """Start the randomization sequence""" # Check if randomization was cancelled if not self._randomization_started: @@ -135,43 +152,49 @@ def _start_randomization(self): self._randomization_in_progress = False return - # Disable HistoricMode if active try: - if getattr(self.state, 'historic_mode_active', False): - self.state.historic_mode_active = False - self.state.historic_skin_id = None - log.info("[HISTORIC] Historic mode DISABLED due to RandomMode activation") - # Broadcast state to JavaScript + # Disable HistoricMode if active + try: + if getattr(self.state, 'historic_mode_active', False): + self.state.historic_mode_active = False + self.state.historic_skin_id = None + log.info("[HISTORIC] Historic mode DISABLED due to RandomMode activation") + # Broadcast state to JavaScript + try: + if self.state and hasattr(self.state, 'ui_skin_thread') and self.state.ui_skin_thread: + self.state.ui_skin_thread._broadcast_historic_state() + except Exception as e: + log.debug(f"[UI] Failed to broadcast historic state on RandomMode activation: {e}") + except Exception: + pass + + # Select random skin + random_selection = self.select_random_skin(mode=mode) + if random_selection: + random_skin_name, random_skin_id, selected_chroma_id = random_selection + self.state.random_skin_name = random_skin_name + self.state.random_skin_id = random_skin_id + self.state.random_chroma_id = selected_chroma_id + self.state.random_mode_active = True + self.state.random_mode_type = mode + log.info(f"[UI] Random skin selected: {random_skin_name} (ID: {random_skin_id}, Chroma: {selected_chroma_id}, Mode: {mode})") + + # Broadcast random mode state to JavaScript try: if self.state and hasattr(self.state, 'ui_skin_thread') and self.state.ui_skin_thread: - self.state.ui_skin_thread._broadcast_historic_state() + self.state.ui_skin_thread._broadcast_random_mode_state() except Exception as e: - log.debug(f"[UI] Failed to broadcast historic state on RandomMode activation: {e}") - except Exception: - pass - - # Select random skin - random_selection = self.select_random_skin() - if random_selection: - random_skin_name, random_skin_id = random_selection - self.state.random_skin_name = random_skin_name - self.state.random_skin_id = random_skin_id - self.state.random_mode_active = True - log.info(f"[UI] Random skin selected: {random_skin_name} (ID: {random_skin_id})") - - # Broadcast random mode state to JavaScript - try: - if self.state and hasattr(self.state, 'ui_skin_thread') and self.state.ui_skin_thread: - self.state.ui_skin_thread._broadcast_random_mode_state() - except Exception as e: - log.debug(f"[UI] Failed to broadcast random mode state: {e}") - else: - log.warning("[UI] No random skin available") + log.debug(f"[UI] Failed to broadcast random mode state: {e}") + else: + log.warning(f"[UI] No random skin available for mode={mode}") + self.cancel() + except Exception as e: + log.error(f"[UI] Unexpected error in _start_randomization: {e}") self.cancel() - - # Clear the randomization flags AFTER everything is set up - self._randomization_in_progress = False - self._randomization_started = False + finally: + # Clear the randomization flags AFTER everything is set up + self._randomization_in_progress = False + self._randomization_started = False def cancel(self): """Cancel randomization and reset state""" @@ -181,11 +204,14 @@ def cancel(self): self._randomization_in_progress = False self._randomization_started = False - def select_random_skin(self) -> Optional[Tuple[str, int]]: - """Select a random skin from available skins (excluding base skin) + def select_random_skin(self, mode: str = "all") -> Optional[Tuple[str, int, Optional[int]]]: + """Select a random skin from available skins (excluding base champion skin) + Args: + mode: "all" to roll from all skins, "favorites" to roll strictly from favorites + Returns: - Tuple of (skin_name, skin_id) or None if no skin available + Tuple of (skin_name, skin_id, chroma_id) or None if no skin available """ if not self.skin_scraper or not self.skin_scraper.cache.skins: log.warning("[UI] No skins available for random selection") @@ -201,6 +227,20 @@ def select_random_skin(self) -> Optional[Tuple[str, int]]: if skin.get('skinId') != base_champion_skin_id and is_base_skin(skin.get('skinId'), chroma_id_map) ] + # If favorites mode, filter down to favorited skins only + if mode == "favorites" and champion_id: + fav_skin_ids = get_favorite_skins_for_champion(champion_id) + if not fav_skin_ids: + log.warning(f"[UI] No favorite skins defined for champion {champion_id}") + return None + + fav_available = [skin for skin in available_skins if skin.get('skinId') in fav_skin_ids] + if not fav_available: + log.warning(f"[UI] None of the favorited skins {fav_skin_ids} matched available skins") + return None + available_skins = fav_available + log.info(f"[UI] Filtered to {len(available_skins)} favorite skins for champion {champion_id}") + # Debug logging log.debug(f"[UI] Champion ID: {champion_id}, Base skin ID: {base_champion_skin_id}") log.debug(f"[UI] Total skins in cache: {len(self.skin_scraper.cache.skins)}") @@ -221,46 +261,72 @@ def select_random_skin(self) -> Optional[Tuple[str, int]]: log.warning("[UI] Selected skin has no name or ID") return None - # Use localized skin name directly from LCU english_skin_name = localized_skin_name # Check if this skin has chromas chromas = self.skin_scraper.get_chromas_for_skin(skin_id) if chromas: - log.info(f"[UI] Skin '{english_skin_name}' has {len(chromas)} chromas, selecting random chroma") + log.info(f"[UI] Skin '{english_skin_name}' has {len(chromas)} chromas") + + # If favorites mode, check if specific chromas are favorited for this skin + fav_chromas = get_favorite_chromas_for_skin(champion_id, skin_id) if (mode == "favorites" and champion_id) else [] - # Create list of all options: base skin + all chromas all_options = [] - # Add base skin - all_options.append({ - 'id': skin_id, - 'name': english_skin_name, - 'type': 'base' - }) + # If specific chromas were favorited: + if fav_chromas: + # If base style (skin_id itself) is favorited, include base style + if skin_id in fav_chromas: + all_options.append({ + 'id': skin_id, + 'name': english_skin_name, + 'type': 'base', + 'chromaId': None + }) + # Include favorited chromas + for chroma in chromas: + c_id = chroma.get('id') + if c_id in fav_chromas: + localized_chroma_name = chroma.get('name', f'{english_skin_name} Chroma') + all_options.append({ + 'id': c_id, + 'name': localized_chroma_name, + 'type': 'chroma', + 'chromaId': c_id + }) - # Add all chromas - for chroma in chromas: - localized_chroma_name = chroma.get('name', f'{english_skin_name} Chroma') - english_chroma_name = localized_chroma_name + # If no chroma restrictions or no favorited chromas matched, allow all + if not all_options: + # Add base skin all_options.append({ - 'id': chroma.get('id'), - 'name': english_chroma_name, - 'type': 'chroma' + 'id': skin_id, + 'name': english_skin_name, + 'type': 'base', + 'chromaId': None }) + # Add all chromas + for chroma in chromas: + localized_chroma_name = chroma.get('name', f'{english_skin_name} Chroma') + c_id = chroma.get('id') + all_options.append({ + 'id': c_id, + 'name': localized_chroma_name, + 'type': 'chroma', + 'chromaId': c_id + }) - # Select random option from base + chromas + # Select random option from pool selected_option = random.choice(all_options) - selected_name = selected_option['name'] - selected_id = selected_option['id'] + selected_chroma_name = selected_option['name'] + selected_chroma_id = selected_option.get('chromaId') selected_type = selected_option['type'] - log.info(f"[UI] Random selection: {selected_type} '{selected_name}' (ID: {selected_id})") - return (selected_name, selected_id) + log.info(f"[UI] Random selection: {selected_type} '{selected_chroma_name}' (Base Skin: {english_skin_name} [{skin_id}], Chroma ID: {selected_chroma_id})") + return (english_skin_name, skin_id, selected_chroma_id) else: # No chromas, return the base skin name and ID log.info(f"[UI] Skin '{english_skin_name}' has no chromas, using base skin") - return (english_skin_name, skin_id) + return (english_skin_name, skin_id, None) def update_dice_button(self, current_skin_id: Optional[int]): """Broadcast dice button state to JavaScript""" @@ -304,10 +370,11 @@ def reset_on_skin_change(self): if self.state.random_mode_active: self.state.random_skin_name = None self.state.random_skin_id = None + self.state.random_chroma_id = None self.state.random_mode_active = False + self.state.random_mode_type = "all" try: if self.state and hasattr(self.state, 'ui_skin_thread') and self.state.ui_skin_thread: self.state.ui_skin_thread._broadcast_random_mode_state() except Exception as e: log.debug(f"[UI] Failed to broadcast random mode state on skin change: {e}") - diff --git a/utils/core/favorites.py b/utils/core/favorites.py new file mode 100644 index 00000000..0379c9cb --- /dev/null +++ b/utils/core/favorites.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +Favorites Manager +Persists and manages favorite skins and chromas per champion. +Saved in %LOCALAPPDATA%\\Rose\\favorites.json. +""" + +from __future__ import annotations + +import json +import logging +import threading +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from utils.core.paths import get_user_data_dir + +log = logging.getLogger(__name__) + +_favorites_lock = threading.Lock() + + +def _get_favorites_file_path() -> Path: + """Return the Path to favorites.json in the user data directory.""" + data_dir = get_user_data_dir() + return data_dir / "favorites.json" + + +def load_favorites_data() -> Dict[str, Any]: + """Load the full favorites data dictionary. Returns default structure on error/missing.""" + with _favorites_lock: + path = _get_favorites_file_path() + if not path.exists(): + return {"version": 1, "favorites": {}} + + try: + with path.open("r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, dict) and "favorites" in data and isinstance(data["favorites"], dict): + return data + return {"version": 1, "favorites": {}} + except Exception as e: + log.warning(f"[Favorites] Failed to load favorites.json: {e}") + return {"version": 1, "favorites": {}} + + +def save_favorites_data(data: Dict[str, Any]) -> bool: + """Save the full favorites data dictionary to favorites.json.""" + with _favorites_lock: + path = _get_favorites_file_path() + try: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + return True + except Exception as e: + log.error(f"[Favorites] Failed to save favorites.json: {e}") + return False + + +def get_champion_favorites(champion_id: int) -> Dict[str, Any]: + """ + Get the favorites structure for a specific champion. + Returns: + { + "skins": [skin_id, ...], + "chromas": { + "": [chroma_id, ...] + } + } + """ + data = load_favorites_data() + favorites_map = data.get("favorites", {}) + champ_key = str(int(champion_id)) + champ_favs = favorites_map.get(champ_key, {}) + + skins = champ_favs.get("skins", []) + if not isinstance(skins, list): + skins = [] + + chromas = champ_favs.get("chromas", {}) + if not isinstance(chromas, dict): + chromas = {} + + return { + "championId": int(champion_id), + "skins": [int(s) for s in skins if isinstance(s, (int, str)) and str(s).isdigit()], + "chromas": { + str(k): [int(c) for c in v if isinstance(c, (int, str)) and str(c).isdigit()] + for k, v in chromas.items() + if isinstance(v, list) + } + } + + +def get_favorite_skins_for_champion(champion_id: int) -> List[int]: + """Get the list of favorited skin IDs for a champion.""" + favs = get_champion_favorites(champion_id) + return favs.get("skins", []) + + +def get_favorite_chromas_for_skin(champion_id: int, skin_id: int) -> List[int]: + """Get the list of favorited chroma IDs (including base style if favorited) for a skin.""" + favs = get_champion_favorites(champion_id) + chromas_map = favs.get("chromas", {}) + return chromas_map.get(str(int(skin_id)), []) + + +def toggle_skin_favorite(champion_id: int, skin_id: int) -> Tuple[bool, Dict[str, Any]]: + """ + Toggle favorite status for a skin. + Returns (is_now_favorite, updated_champion_favorites). + """ + data = load_favorites_data() + favorites_map = data.setdefault("favorites", {}) + champ_key = str(int(champion_id)) + champ_favs = favorites_map.setdefault(champ_key, {"skins": [], "chromas": {}}) + + skins_list = champ_favs.setdefault("skins", []) + skin_num = int(skin_id) + + if skin_num in skins_list: + skins_list.remove(skin_num) + # Also clean up chromas for this skin if removed + if "chromas" in champ_favs and str(skin_num) in champ_favs["chromas"]: + del champ_favs["chromas"][str(skin_num)] + is_fav = False + log.info(f"[Favorites] Removed skin {skin_num} from favorites for champion {champion_id}") + else: + skins_list.append(skin_num) + # Also auto-favorite the base/default chroma style (skin_num itself) + chromas_dict = champ_favs.setdefault("chromas", {}) + skin_chromas = chromas_dict.setdefault(str(skin_num), []) + if skin_num not in skin_chromas: + skin_chromas.append(skin_num) + is_fav = True + log.info(f"[Favorites] Added skin {skin_num} (with default base style) to favorites for champion {champion_id}") + + save_favorites_data(data) + return is_fav, get_champion_favorites(champion_id) + + +def toggle_chroma_favorite(champion_id: int, skin_id: int, chroma_id: int) -> Tuple[bool, Dict[str, Any]]: + """ + Toggle favorite status for a chroma (or base skin style). + If the parent skin is not favorited yet, it is automatically favorited too. + Returns (is_now_favorite, updated_champion_favorites). + """ + data = load_favorites_data() + favorites_map = data.setdefault("favorites", {}) + champ_key = str(int(champion_id)) + champ_favs = favorites_map.setdefault(champ_key, {"skins": [], "chromas": {}}) + + skins_list = champ_favs.setdefault("skins", []) + skin_num = int(skin_id) + chroma_num = int(chroma_id) + + # Auto-add parent skin to favorites if not present + if skin_num not in skins_list: + skins_list.append(skin_num) + + chromas_dict = champ_favs.setdefault("chromas", {}) + skin_chromas = chromas_dict.setdefault(str(skin_num), []) + + if chroma_num in skin_chromas: + skin_chromas.remove(chroma_num) + is_fav = False + log.info(f"[Favorites] Removed chroma {chroma_num} from skin {skin_num} (champion {champion_id})") + else: + skin_chromas.append(chroma_num) + is_fav = True + log.info(f"[Favorites] Added chroma {chroma_num} to skin {skin_num} (champion {champion_id})") + + save_favorites_data(data) + return is_fav, get_champion_favorites(champion_id) + + +def is_skin_favorite(champion_id: int, skin_id: int) -> bool: + """Check if a skin is favorited for the champion.""" + return int(skin_id) in get_favorite_skins_for_champion(champion_id) + + +def is_chroma_favorite(champion_id: int, skin_id: int, chroma_id: int) -> bool: + """Check if a chroma is favorited for the given skin.""" + return int(chroma_id) in get_favorite_chromas_for_skin(champion_id, skin_id) From 936b13f73a865536f2d00c28590ab97bbe3852cf Mon Sep 17 00:00:00 2001 From: Spok0jny Date: Tue, 8 Sep 2026 23:51:07 +0200 Subject: [PATCH 2/9] fix(favorites): prevent stale championId desync on lock and recalculate dice favorites count --- Pengu Loader/plugins/ROSE-Favorites/index.js | 19 +++++-- Pengu Loader/plugins/ROSE-RandomSkin/index.js | 50 ++++++++++++++++++- pengu/communication/broadcaster.py | 13 +++-- pengu/core/skin_monitor.py | 4 +- threads/core/lcu_monitor_thread.py | 3 +- threads/handlers/champion_lock_handler.py | 2 +- 6 files changed, 79 insertions(+), 12 deletions(-) diff --git a/Pengu Loader/plugins/ROSE-Favorites/index.js b/Pengu Loader/plugins/ROSE-Favorites/index.js index 11f57aa4..a6084c4b 100644 --- a/Pengu Loader/plugins/ROSE-Favorites/index.js +++ b/Pengu Loader/plugins/ROSE-Favorites/index.js @@ -484,14 +484,27 @@ bridge.subscribe("phase-change", (data) => { if (data && data.phase) { isInChampSelect = data.phase === "ChampSelect" || data.phase === "FINALIZATION"; + if (!isInChampSelect) { + currentChampionId = null; + currentFavorites = { championId: null, skins: [], chromas: {} }; + } } - if (bridge) bridge.send({ type: "request-favorites", championId: currentChampionId }); + if (bridge && currentChampionId) bridge.send({ type: "request-favorites", championId: currentChampionId }); updateUI(); }); bridge.subscribe("champion-locked", (data) => { - if (data.locked && bridge) { - bridge.send({ type: "request-favorites", championId: currentChampionId }); + if (data.locked) { + if (data.championId) { + currentChampionId = data.championId; + } + if (bridge && currentChampionId) { + bridge.send({ type: "request-favorites", championId: currentChampionId }); + } + } else { + currentChampionId = null; + currentFavorites = { championId: null, skins: [], chromas: {} }; } + updateUI(); }); } diff --git a/Pengu Loader/plugins/ROSE-RandomSkin/index.js b/Pengu Loader/plugins/ROSE-RandomSkin/index.js index 6481ef28..92f8bd99 100644 --- a/Pengu Loader/plugins/ROSE-RandomSkin/index.js +++ b/Pengu Loader/plugins/ROSE-RandomSkin/index.js @@ -81,7 +81,9 @@ let randomSkinId = null; let isInChampSelect = false; let championLocked = false; + let currentChampionId = null; let currentChampionFavoritesCount = 0; + let allFavoritesCache = {}; let diceContainerElement = null; let regularDiceBtn = null; @@ -176,11 +178,29 @@ consoleMethod(`${LOG_PREFIX} ${message}`, data || ""); } + function recalculateFavoritesCount() { + if (currentChampionId && allFavoritesCache && allFavoritesCache[String(currentChampionId)]) { + const favs = allFavoritesCache[String(currentChampionId)]; + currentChampionFavoritesCount = Array.isArray(favs.skins) ? favs.skins.length : 0; + } else { + currentChampionFavoritesCount = 0; + } + updateFavoritesState(); + } + function handleChampionLocked(data) { const wasLocked = championLocked; championLocked = data.locked === true; - log("debug", "Received champion lock state update", { locked: championLocked, wasLocked: wasLocked }); + if (championLocked && data.championId) { + currentChampionId = data.championId; + recalculateFavoritesCount(); + } else if (!championLocked) { + currentChampionId = null; + currentChampionFavoritesCount = 0; + } + + log("debug", "Received champion lock state update", { locked: championLocked, wasLocked: wasLocked, championId: currentChampionId }); if (isInChampSelect && championLocked && !wasLocked) { log("debug", "Champion locked - creating dual dice buttons"); @@ -218,6 +238,8 @@ diceContainerElement = null; } championLocked = false; + currentChampionId = null; + currentChampionFavoritesCount = 0; } } @@ -403,6 +425,9 @@ function updateFavoritesState(favDetail) { if (favDetail && favDetail.favorites && Array.isArray(favDetail.favorites.skins)) { currentChampionFavoritesCount = favDetail.favorites.skins.length; + } else if (currentChampionId && allFavoritesCache && allFavoritesCache[String(currentChampionId)]) { + const favs = allFavoritesCache[String(currentChampionId)]; + currentChampionFavoritesCount = Array.isArray(favs.skins) ? favs.skins.length : 0; } if (favoriteDiceBtn) { @@ -505,9 +530,30 @@ bridge.subscribe("champion-locked", handleChampionLocked); bridge.subscribe("local-asset-url", handleLocalAssetUrl); bridge.subscribe("random-mode-state", handleRandomModeStateUpdate); + bridge.subscribe("skin-state", (data) => { + if (data && data.championId) { + if (data.championId !== currentChampionId) { + currentChampionId = data.championId; + recalculateFavoritesCount(); + } + } + }); bridge.subscribe("favorites-state", (data) => { - if (data && data.championFavorites) { + if (!data) return; + if (data.allFavorites) { + allFavoritesCache = data.allFavorites; + } + if (data.championId && !currentChampionId) { + currentChampionId = data.championId; + } + if (currentChampionId && allFavoritesCache && allFavoritesCache[String(currentChampionId)]) { + const favs = allFavoritesCache[String(currentChampionId)]; + currentChampionFavoritesCount = Array.isArray(favs.skins) ? favs.skins.length : 0; + updateFavoritesState({ favorites: favs }); + } else if (data.championId === currentChampionId && data.championFavorites) { updateFavoritesState({ favorites: data.championFavorites }); + } else { + recalculateFavoritesCount(); } }); } diff --git a/pengu/communication/broadcaster.py b/pengu/communication/broadcaster.py index f52029ed..ab8443a0 100644 --- a/pengu/communication/broadcaster.py +++ b/pengu/communication/broadcaster.py @@ -261,26 +261,33 @@ def broadcast_phase_change(self, phase: str) -> None: self._send_message(json.dumps(payload)) - def broadcast_champion_locked(self, locked: bool) -> None: + def broadcast_champion_locked(self, locked: bool, champion_id: Optional[int] = None) -> None: """Broadcast champion lock state to JavaScript plugins""" if not self.websocket_server.loop or not self.websocket_server.connections: return + if champion_id is None: + champion_id = getattr(self.shared_state, 'locked_champ_id', None) + if champion_id is None and self.skin_scraper and self.skin_scraper.cache: + champion_id = getattr(self.skin_scraper.cache, 'champion_id', None) + payload = { "type": "champion-locked", "locked": locked, + "championId": champion_id, "timestamp": int(time.time() * 1000), } log.debug( - "[SkinMonitor] Broadcasting champion lock state → locked=%s", + "[SkinMonitor] Broadcasting champion lock state → locked=%s championId=%s", locked, + champion_id, ) self._send_message(json.dumps(payload)) if locked: try: - self.broadcast_favorites_state() + self.broadcast_favorites_state(champion_id) except Exception as e: log.debug("[SkinMonitor] Failed to broadcast favorites on champion lock: %s", e) diff --git a/pengu/core/skin_monitor.py b/pengu/core/skin_monitor.py index f5c2212d..e158783f 100644 --- a/pengu/core/skin_monitor.py +++ b/pengu/core/skin_monitor.py @@ -176,9 +176,9 @@ def _broadcast_language_changed(self) -> None: """Broadcast a menu language change (delegates to broadcaster)""" self.broadcaster.broadcast_language_changed() - def _broadcast_champion_locked(self, locked: bool) -> None: + def _broadcast_champion_locked(self, locked: bool, champion_id: Optional[int] = None) -> None: """Broadcast champion lock state (delegates to broadcaster)""" - self.broadcaster.broadcast_champion_locked(locked) + self.broadcaster.broadcast_champion_locked(locked, champion_id) def _broadcast_random_mode_state(self) -> None: """Broadcast random mode state (delegates to broadcaster)""" diff --git a/threads/core/lcu_monitor_thread.py b/threads/core/lcu_monitor_thread.py index bf95f8d9..70a19df2 100644 --- a/threads/core/lcu_monitor_thread.py +++ b/threads/core/lcu_monitor_thread.py @@ -342,7 +342,8 @@ def _sync_ui_for_late_lock(self) -> None: log.debug(f"[init-state] Failed to broadcast ChampSelect phase: {e}") try: - ui_thread._broadcast_champion_locked(True) + locked_champ_id = getattr(self.state, "locked_champ_id", None) + ui_thread._broadcast_champion_locked(True, locked_champ_id) except Exception as e: log.debug(f"[init-state] Failed to broadcast champion lock state: {e}") diff --git a/threads/handlers/champion_lock_handler.py b/threads/handlers/champion_lock_handler.py index 9900dbb2..b2f69bce 100644 --- a/threads/handlers/champion_lock_handler.py +++ b/threads/handlers/champion_lock_handler.py @@ -265,7 +265,7 @@ def on_own_champion_locked(self, champion_id: int, champion_label: str, old_cham # Broadcast champion lock state try: if self.state and hasattr(self.state, 'ui_skin_thread') and self.state.ui_skin_thread: - self.state.ui_skin_thread._broadcast_champion_locked(True) + self.state.ui_skin_thread._broadcast_champion_locked(True, champion_id) except Exception as e: log.debug(f"[lock:champ] Failed to broadcast champion lock state: {e}") From 9a51e36efb09fe89f5051d0ed646f8fc271af42d Mon Sep 17 00:00:00 2001 From: Spok0jny Date: Wed, 9 Sep 2026 00:28:45 +0200 Subject: [PATCH 3/9] fix(favorites): refine chroma favorite star size and hover hit area in Quickplay --- Pengu Loader/plugins/ROSE-Favorites/index.js | 27 +++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/Pengu Loader/plugins/ROSE-Favorites/index.js b/Pengu Loader/plugins/ROSE-Favorites/index.js index a6084c4b..04345a08 100644 --- a/Pengu Loader/plugins/ROSE-Favorites/index.js +++ b/Pengu Loader/plugins/ROSE-Favorites/index.js @@ -109,32 +109,35 @@ .rose-chroma-fav-btn { position: absolute !important; - top: -10px !important; - right: -10px !important; - width: 22px !important; - height: 22px !important; - padding: 4px !important; + top: -4px !important; + right: -4px !important; + width: 13px !important; + height: 13px !important; + padding: 2px !important; box-sizing: content-box !important; cursor: pointer !important; pointer-events: auto !important; z-index: 1000 !important; - opacity: 0.75; + opacity: 0; transition: transform 0.15s ease, opacity 0.15s ease, filter 0.15s ease !important; filter: drop-shadow(0 0 3px rgba(0, 0, 0, 0.9)) !important; } .chroma-selection li:hover .rose-chroma-fav-btn, - .rose-chroma-fav-btn:hover, + .chroma-modal .chroma-list li:hover .rose-chroma-fav-btn, + .chroma-list li:hover .rose-chroma-fav-btn, + .rose-chroma-fav-btn:hover { + opacity: 0.85 !important; + } + .rose-chroma-fav-btn.active { opacity: 1 !important; + filter: drop-shadow(0 0 5px #0acbe6) !important; } .rose-chroma-fav-btn:hover { - transform: scale(1.3) !important; - } - - .rose-chroma-fav-btn.active { - filter: drop-shadow(0 0 6px #0acbe6) !important; + opacity: 1 !important; + transform: scale(1.15) !important; } `; From 6972d82e63947e92521b85471b89423b19962d1b Mon Sep 17 00:00:00 2001 From: Spok0jny Date: Wed, 9 Sep 2026 00:35:27 +0200 Subject: [PATCH 4/9] fix(favorites): auto-remove skin from favorites when all chromas are deselected --- Pengu Loader/plugins/ROSE-Favorites/index.js | 22 ++++++++++-- utils/core/favorites.py | 36 +++++++++++++++++--- 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/Pengu Loader/plugins/ROSE-Favorites/index.js b/Pengu Loader/plugins/ROSE-Favorites/index.js index 04345a08..3269a136 100644 --- a/Pengu Loader/plugins/ROSE-Favorites/index.js +++ b/Pengu Loader/plugins/ROSE-Favorites/index.js @@ -230,6 +230,10 @@ } } + if (allFavorites && champId) { + allFavorites[String(champId)] = currentFavorites; + } + updateUI(); } @@ -256,9 +260,6 @@ } if (!currentFavorites.skins) currentFavorites.skins = []; - if (!currentFavorites.skins.includes(targetSkin)) { - currentFavorites.skins.push(targetSkin); - } if (!currentFavorites.chromas) currentFavorites.chromas = {}; const skinKey = String(targetSkin); if (!currentFavorites.chromas[skinKey]) currentFavorites.chromas[skinKey] = []; @@ -266,10 +267,25 @@ const cIdx = currentFavorites.chromas[skinKey].indexOf(targetChroma); if (cIdx >= 0) { currentFavorites.chromas[skinKey].splice(cIdx, 1); + // If no chromas remain for this skin, auto-remove the skin from favorites + if (currentFavorites.chromas[skinKey].length === 0) { + delete currentFavorites.chromas[skinKey]; + const sIdx = currentFavorites.skins.indexOf(targetSkin); + if (sIdx >= 0) { + currentFavorites.skins.splice(sIdx, 1); + } + } } else { + if (!currentFavorites.skins.includes(targetSkin)) { + currentFavorites.skins.push(targetSkin); + } currentFavorites.chromas[skinKey].push(targetChroma); } + if (allFavorites && champId) { + allFavorites[String(champId)] = currentFavorites; + } + updateUI(); } diff --git a/utils/core/favorites.py b/utils/core/favorites.py index 0379c9cb..07b32132 100644 --- a/utils/core/favorites.py +++ b/utils/core/favorites.py @@ -39,6 +39,27 @@ def load_favorites_data() -> Dict[str, Any]: with path.open("r", encoding="utf-8") as f: data = json.load(f) if isinstance(data, dict) and "favorites" in data and isinstance(data["favorites"], dict): + # Clean up any orphaned empty chroma entries where all chromas were deselected + modified = False + for champ_key, champ_val in data["favorites"].items(): + if isinstance(champ_val, dict): + chromas = champ_val.get("chromas", {}) + skins = champ_val.get("skins", []) + if isinstance(chromas, dict) and isinstance(skins, list): + empty_keys = [k for k, v in chromas.items() if isinstance(v, list) and len(v) == 0] + for k in empty_keys: + del chromas[k] + k_int = int(k) if k.isdigit() else None + if k_int and k_int in skins: + skins.remove(k_int) + modified = True + if modified: + try: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + except Exception: + pass return data return {"version": 1, "favorites": {}} except Exception as e: @@ -146,6 +167,7 @@ def toggle_chroma_favorite(champion_id: int, skin_id: int, chroma_id: int) -> Tu """ Toggle favorite status for a chroma (or base skin style). If the parent skin is not favorited yet, it is automatically favorited too. + If all chromas for the skin are deselected, the skin is automatically unfavorited. Returns (is_now_favorite, updated_champion_favorites). """ data = load_favorites_data() @@ -157,10 +179,6 @@ def toggle_chroma_favorite(champion_id: int, skin_id: int, chroma_id: int) -> Tu skin_num = int(skin_id) chroma_num = int(chroma_id) - # Auto-add parent skin to favorites if not present - if skin_num not in skins_list: - skins_list.append(skin_num) - chromas_dict = champ_favs.setdefault("chromas", {}) skin_chromas = chromas_dict.setdefault(str(skin_num), []) @@ -168,7 +186,17 @@ def toggle_chroma_favorite(champion_id: int, skin_id: int, chroma_id: int) -> Tu skin_chromas.remove(chroma_num) is_fav = False log.info(f"[Favorites] Removed chroma {chroma_num} from skin {skin_num} (champion {champion_id})") + # If no chromas remain for this skin, auto-remove the skin from favorites + if len(skin_chromas) == 0: + if skin_num in skins_list: + skins_list.remove(skin_num) + log.info(f"[Favorites] Auto-removed skin {skin_num} from favorites as all chromas were deselected") + if str(skin_num) in chromas_dict: + del chromas_dict[str(skin_num)] else: + # Auto-add parent skin to favorites if not present + if skin_num not in skins_list: + skins_list.append(skin_num) skin_chromas.append(chroma_num) is_fav = True log.info(f"[Favorites] Added chroma {chroma_num} to skin {skin_num} (champion {champion_id})") From 5b1f17e62aad6db714d973c5de552441d83e5375 Mon Sep 17 00:00:00 2001 From: Spok0jny Date: Wed, 9 Sep 2026 00:41:32 +0200 Subject: [PATCH 5/9] fix(favorites): ensure UI immediately prunes skins with empty chroma lists and dynamic reload in backend --- Pengu Loader/plugins/ROSE-Favorites/index.js | 50 ++++++++++++++++++-- pengu/communication/message_handler.py | 6 +++ 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/Pengu Loader/plugins/ROSE-Favorites/index.js b/Pengu Loader/plugins/ROSE-Favorites/index.js index 3269a136..05c5b5c2 100644 --- a/Pengu Loader/plugins/ROSE-Favorites/index.js +++ b/Pengu Loader/plugins/ROSE-Favorites/index.js @@ -317,7 +317,17 @@ // 1. Single Golden Star next to Active Skin Title in Quickplay / Lobby if (activeSkin) { - const isFav = currentFavorites.skins && currentFavorites.skins.includes(activeSkin); + const skinKey = String(activeSkin); + const hasEmptyChromas = + currentFavorites.chromas && + currentFavorites.chromas[skinKey] && + Array.isArray(currentFavorites.chromas[skinKey]) && + currentFavorites.chromas[skinKey].length === 0; + + const isFav = + !hasEmptyChromas && + currentFavorites.skins && + currentFavorites.skins.includes(activeSkin); const titleSelectors = [ ".champion-name-title", @@ -426,12 +436,15 @@ e.stopPropagation(); e.preventDefault(); const currentActive = getActiveSkinId(); - const targetChrId = parseInt(li.dataset.chromaId, 10) || chromaId; + const targetChrId = parseInt(starBtn.dataset.chromaId || li.dataset.chromaId, 10) || chromaId; toggleChromaFavorite(targetChrId, currentActive); }); li.appendChild(starBtn); } + starBtn.dataset.chromaId = String(chromaId); + li.dataset.chromaId = String(chromaId); + starBtn.className = `rose-chroma-fav-btn ${isChromaFav ? "active" : ""}`; starBtn.innerHTML = isChromaFav ? CYAN_STAR_SVG : INACTIVE_CYAN_STAR_SVG; starBtn.title = isChromaFav @@ -444,7 +457,7 @@ e.stopPropagation(); e.preventDefault(); const currentActive = getActiveSkinId(); - const targetChrId = parseInt(li.dataset.chromaId, 10) || chromaId; + const targetChrId = parseInt(starBtn.dataset.chromaId || li.dataset.chromaId, 10) || chromaId; toggleChromaFavorite(targetChrId, currentActive); }); } @@ -465,8 +478,35 @@ function handleFavoritesState(data) { log("info", "Received favorites-state", data); - if (data.championFavorites) currentFavorites = data.championFavorites; - if (data.allFavorites) allFavorites = data.allFavorites; + if (data.championFavorites) { + currentFavorites = data.championFavorites; + // Sanitize: If a skin has an empty chromas list, it means all chromas were deselected + if (currentFavorites.chromas && Array.isArray(currentFavorites.skins)) { + Object.keys(currentFavorites.chromas).forEach((skinKey) => { + if (Array.isArray(currentFavorites.chromas[skinKey]) && currentFavorites.chromas[skinKey].length === 0) { + delete currentFavorites.chromas[skinKey]; + const sId = parseInt(skinKey, 10); + const idx = currentFavorites.skins.indexOf(sId); + if (idx >= 0) currentFavorites.skins.splice(idx, 1); + } + }); + } + } + if (data.allFavorites) { + allFavorites = data.allFavorites; + Object.values(allFavorites).forEach((champFavs) => { + if (champFavs && champFavs.chromas && Array.isArray(champFavs.skins)) { + Object.keys(champFavs.chromas).forEach((skinKey) => { + if (Array.isArray(champFavs.chromas[skinKey]) && champFavs.chromas[skinKey].length === 0) { + delete champFavs.chromas[skinKey]; + const sId = parseInt(skinKey, 10); + const idx = champFavs.skins.indexOf(sId); + if (idx >= 0) champFavs.skins.splice(idx, 1); + } + }); + } + }); + } if (data.championId) currentChampionId = data.championId; updateUI(); } diff --git a/pengu/communication/message_handler.py b/pengu/communication/message_handler.py index c094b701..8db629f6 100644 --- a/pengu/communication/message_handler.py +++ b/pengu/communication/message_handler.py @@ -508,6 +508,9 @@ def _handle_dice_button_click(self, payload: dict) -> None: def _handle_favorite_toggle_skin(self, payload: dict) -> None: """Handle skin favorite toggle event from JavaScript""" try: + import importlib + import utils.core.favorites + importlib.reload(utils.core.favorites) from utils.core.favorites import toggle_skin_favorite champion_id = payload.get("championId") skin_id = payload.get("skinId") @@ -524,6 +527,9 @@ def _handle_favorite_toggle_skin(self, payload: dict) -> None: def _handle_favorite_toggle_chroma(self, payload: dict) -> None: """Handle chroma favorite toggle event from JavaScript""" try: + import importlib + import utils.core.favorites + importlib.reload(utils.core.favorites) from utils.core.favorites import toggle_chroma_favorite champion_id = payload.get("championId") skin_id = payload.get("skinId") From 46367ab7582ea444558e2dce7f027dcc395b0908 Mon Sep 17 00:00:00 2001 From: Spok0jny Date: Sun, 18 Oct 2026 14:22:11 +0200 Subject: [PATCH 6/9] fix(build): add utils.core.favorites to PyInstaller hiddenimports in Rose.spec --- Rose.spec | 1 + 1 file changed, 1 insertion(+) diff --git a/Rose.spec b/Rose.spec index 5fd74bdd..088e4c77 100644 --- a/Rose.spec +++ b/Rose.spec @@ -227,6 +227,7 @@ hiddenimports = [ 'utils.core.validation', 'utils.core.normalization', 'utils.core.historic', + 'utils.core.favorites', 'utils.core.modpkg', 'utils.system', 'utils.system.admin_utils', From c4e1d441b84ff8360d087d95f8704986efd881d1 Mon Sep 17 00:00:00 2001 From: Spok0jny Date: Sun, 18 Oct 2026 15:40:38 +0200 Subject: [PATCH 7/9] fix(injection): preserve _inject_unowned_skin call signature for standalone chroma injections --- threads/handlers/injection_trigger.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/threads/handlers/injection_trigger.py b/threads/handlers/injection_trigger.py index e94edb80..1bef9528 100644 --- a/threads/handlers/injection_trigger.py +++ b/threads/handlers/injection_trigger.py @@ -855,8 +855,11 @@ def auto_select_historic_mod(mod_type: str, category_attr: str): # Inject if user doesn't own the hovered skin elif self.injection_manager: - chroma_id_to_inject = effective_skin_id if (effective_skin_id and effective_skin_id != ui_skin_id) else None - self._inject_unowned_skin(name, cname, chroma_id=chroma_id_to_inject) + chroma_id_to_inject = effective_skin_id if (effective_skin_id and effective_skin_id != ui_skin_id and not name.startswith("chroma_")) else None + if chroma_id_to_inject: + self._inject_unowned_skin(name, cname, chroma_id=chroma_id_to_inject) + else: + self._inject_unowned_skin(name, cname) except Exception as e: log.warning(f"[loadout #{ticker_id}] injection setup failed: {e}") From fa720c7fe16635db1bb52b16eea9d6f5484aa470 Mon Sep 17 00:00:00 2001 From: Spok0jny Date: Sun, 18 Oct 2026 16:29:16 +0200 Subject: [PATCH 8/9] fix(random): prevent forced base skin LCU event from canceling active dice state --- threads/tests/test_random_mode.py | 105 +++++++++++++++++++++++++++ ui/core/user_interface.py | 26 +++++-- ui/handlers/randomization_handler.py | 7 ++ 3 files changed, 132 insertions(+), 6 deletions(-) create mode 100644 threads/tests/test_random_mode.py diff --git a/threads/tests/test_random_mode.py b/threads/tests/test_random_mode.py new file mode 100644 index 00000000..8006ca87 --- /dev/null +++ b/threads/tests/test_random_mode.py @@ -0,0 +1,105 @@ +import unittest +from unittest.mock import MagicMock, patch + +from state import SharedState +from ui.core.user_interface import UserInterface +from ui.handlers.randomization_handler import RandomizationHandler + +DARIUS = 122 +BASE_DARIUS = 122000 +GOD_KING_DARIUS = 122015 +DIVINE_GOD_KING_DARIUS = 122064 + + +class RandomModeTests(unittest.TestCase): + """Verify that forced base skin transition does not cancel active random mode, + while manual carousel selection of a different skin does.""" + + def setUp(self): + self.state = SharedState() + self.state.locked_champ_id = DARIUS + self.skin_scraper = MagicMock() + self.skin_scraper.cache.champion_id = DARIUS + self.skin_scraper.cache.skins = [ + {'skinId': BASE_DARIUS, 'skinName': 'Darius'}, + {'skinId': GOD_KING_DARIUS, 'skinName': 'God-King Darius'}, + {'skinId': DIVINE_GOD_KING_DARIUS, 'skinName': 'Divine God-King Darius'}, + ] + self.skin_scraper.cache.chroma_id_map = {} + self.skin_scraper.get_chromas_for_skin.return_value = [] + + self.ui = UserInterface(self.state, self.skin_scraper) + self.ui.is_ui_initialized = MagicMock(return_value=True) + self.ui.skin_display_handler = MagicMock() + self.ui.skin_display_handler.show_skin.return_value = (None, None) + self.ui.historic_handler = MagicMock() + + def test_forced_base_skin_does_not_cancel_random_mode(self): + # User entered ChampSelect with God-King Darius selected + self.ui.current_skin_id = GOD_KING_DARIUS + + lcu_mock = MagicMock() + lcu_mock.set_my_selection_skin.return_value = True + + # User clicks random favorites dice + success = self.ui.randomization_handler.force_base_skin_and_randomize(lcu_mock, mode="all") + self.assertTrue(success) + self.assertTrue(self.state.random_mode_active) + self.assertEqual(self.ui.randomization_handler._forced_base_skin_id, BASE_DARIUS) + + # Asynchronous LCU skin change event arrives for the forced base skin + self.ui.show_skin(BASE_DARIUS, "Darius", "Darius", DARIUS) + + # Random mode MUST remain active and not cancelled + self.assertTrue(self.state.random_mode_active) + self.assertIsNone(self.ui.randomization_handler._forced_base_skin_id) + + def test_manual_carousel_selection_cancels_random_mode(self): + # Random mode is active on base skin + self.state.random_mode_active = True + self.state.random_skin_id = DIVINE_GOD_KING_DARIUS + self.ui.current_skin_id = BASE_DARIUS + + # User manually clicks God-King Darius on the carousel + self.ui.show_skin(GOD_KING_DARIUS, "God-King Darius", "Darius", DARIUS) + + # Random mode MUST be cancelled + self.assertFalse(self.state.random_mode_active) + self.assertIsNone(self.state.random_skin_id) + + def test_handle_dice_click_disabled_when_on_non_base_skin(self): + self.ui.current_skin_id = GOD_KING_DARIUS + lcu_mock = MagicMock() + lcu_mock.set_my_selection_skin.return_value = True + self.skin_scraper.lcu = lcu_mock + + # User clicks dice + self.ui._handle_dice_click_disabled(mode="all") + + # LCU was asked to set base skin and random mode is active + lcu_mock.set_my_selection_skin.assert_called_once_with(BASE_DARIUS) + self.assertTrue(self.state.random_mode_active) + + # LCU notifies of skin change to base skin + self.ui.show_skin(BASE_DARIUS, "Darius", "Darius", DARIUS) + self.assertTrue(self.state.random_mode_active) + + def test_handle_dice_click_enabled_cancels_random_mode(self): + self.state.random_mode_active = True + self.state.random_skin_id = DIVINE_GOD_KING_DARIUS + self.ui.current_skin_id = BASE_DARIUS + + self.ui._handle_dice_click_enabled() + + self.assertFalse(self.state.random_mode_active) + self.assertIsNone(self.state.random_skin_id) + + def test_reset_skin_state_clears_forced_base_skin_id(self): + self.ui.randomization_handler._forced_base_skin_id = BASE_DARIUS + self.ui.reset_skin_state() + self.assertIsNone(self.ui.randomization_handler._forced_base_skin_id) + + +if __name__ == "__main__": + unittest.main() + diff --git a/ui/core/user_interface.py b/ui/core/user_interface.py index ae823cc9..a5126bd2 100644 --- a/ui/core/user_interface.py +++ b/ui/core/user_interface.py @@ -111,13 +111,26 @@ def show_skin(self, skin_id: int, skin_name: str, champion_name: str = None, cha new_base_skin_id = None prev_base_skin_id = None - # Cancel randomization if skin changed and random mode is active + # Calculate base skin ID for champion (champion_id * 1000) + champ_id = champion_id or self.current_champion_id or self.state.locked_champ_id + base_skin_id = (champ_id * 1000) if champ_id else None + + forced_base_id = getattr(self.randomization_handler, '_forced_base_skin_id', None) + is_forced_base_skin = (forced_base_id is not None and skin_id == forced_base_id) + if is_forced_base_skin: + self.randomization_handler._forced_base_skin_id = None + log.debug(f"[UI] Detected expected forced base skin: {skin_id}, maintaining random mode") + + # Cancel randomization only if skin changed to a different non-base skin while random mode is active if self.state.random_mode_active and not self.randomization_handler.randomization_in_progress: - self.randomization_handler.cancel() - - # Always reset randomization flags if skin changed - self.randomization_handler.reset_on_skin_change() - + if not is_forced_base_skin and base_skin_id is not None and skin_id != base_skin_id: + log.info(f"[UI] User selected different skin {skin_id} (base: {base_skin_id}), cancelling random mode") + self.randomization_handler.cancel() + + # Always reset randomization flags if skin changed to a non-base skin + if not is_forced_base_skin and (base_skin_id is None or skin_id != base_skin_id): + self.randomization_handler.reset_on_skin_change() + # Broadcast dice button state to JavaScript self.randomization_handler.update_dice_button(self.current_skin_id) @@ -239,6 +252,7 @@ def reset_skin_state(self): # Reset randomization state self.randomization_handler.randomization_started = False + self.randomization_handler._forced_base_skin_id = None # Force UI recreation for new ChampSelect self.lifecycle_manager.reset_skin_state() diff --git a/ui/handlers/randomization_handler.py b/ui/handlers/randomization_handler.py index bfe862ae..69686e79 100644 --- a/ui/handlers/randomization_handler.py +++ b/ui/handlers/randomization_handler.py @@ -62,6 +62,7 @@ def __init__(self, state: SharedState, skin_scraper=None): self.skin_scraper = skin_scraper self._randomization_in_progress = False self._randomization_started = False + self._forced_base_skin_id: Optional[int] = None def handle_dice_click_disabled(self, current_skin_id: Optional[int], mode: str = "all") -> bool: """Handle dice button click in disabled state - start randomization @@ -114,10 +115,12 @@ def force_base_skin_and_randomize(self, lcu, mode: str = "all") -> bool: # Set flag to prevent cancellation during randomization self._randomization_in_progress = True + self._randomization_started = True # Get champion's base skin ID (champion_id * 1000) champion_id = self.state.locked_champ_id base_skin_id = champion_id * 1000 + self._forced_base_skin_id = base_skin_id log.info(f"[UI] Forcing champion base skin: {base_skin_id} (champion {champion_id})") # Force base skin via LCU @@ -125,6 +128,7 @@ def force_base_skin_and_randomize(self, lcu, mode: str = "all") -> bool: if not lcu: log.warning("[UI] No LCU instance available") self._randomization_in_progress = False + self._forced_base_skin_id = None return False # Try to set base skin @@ -137,11 +141,13 @@ def force_base_skin_and_randomize(self, lcu, mode: str = "all") -> bool: log.warning("[UI] Failed to force champion base skin") self._randomization_in_progress = False self._randomization_started = False + self._forced_base_skin_id = None return False except Exception as e: log.error(f"[UI] Error forcing champion base skin: {e}") self._randomization_in_progress = False self._randomization_started = False + self._forced_base_skin_id = None return False def _start_randomization(self, mode: str = "all"): @@ -203,6 +209,7 @@ def cancel(self): # Clear randomization flags self._randomization_in_progress = False self._randomization_started = False + self._forced_base_skin_id = None def select_random_skin(self, mode: str = "all") -> Optional[Tuple[str, int, Optional[int]]]: """Select a random skin from available skins (excluding base champion skin) From 4e85bb99ef00008a96c1e9e1eff730e81d53bcce Mon Sep 17 00:00:00 2001 From: Spok0jny Date: Sun, 18 Oct 2026 16:41:23 +0200 Subject: [PATCH 9/9] fix(system): enforce utf-8 encoding with error replacement in schtasks and mkoverlay subprocess calls --- injection/overlay/overlay_manager.py | 5 ++++- utils/system/admin_utils.py | 6 ++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/injection/overlay/overlay_manager.py b/injection/overlay/overlay_manager.py index 42243f07..12f17553 100644 --- a/injection/overlay/overlay_manager.py +++ b/injection/overlay/overlay_manager.py @@ -298,7 +298,10 @@ def mk_run_overlay(self, mod_names: List[str], timeout: int = 120, stop_callback creationflags = subprocess.CREATE_NO_WINDOW # Capture both stdout and stderr - CSLOL uses logi() which may write to stdout - proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, creationflags=creationflags, text=True, bufsize=1) + proc = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + creationflags=creationflags, text=True, encoding="utf-8", errors="replace", bufsize=1 + ) # Boost process priority to maximize CPU contention if enabled if ENABLE_MKOVERLAY_PRIORITY_BOOST and PSUTIL_AVAILABLE: diff --git a/utils/system/admin_utils.py b/utils/system/admin_utils.py index 37aea972..666192ee 100644 --- a/utils/system/admin_utils.py +++ b/utils/system/admin_utils.py @@ -96,6 +96,8 @@ def is_registered_for_autostart(): ['schtasks', '/Query', '/TN', 'Rose'], capture_output=True, text=True, + encoding="utf-8", + errors="replace", creationflags=subprocess.CREATE_NO_WINDOW ) return result.returncode == 0 @@ -143,6 +145,8 @@ def register_autostart(): cmd, capture_output=True, text=True, + encoding="utf-8", + errors="replace", creationflags=subprocess.CREATE_NO_WINDOW ) @@ -182,6 +186,8 @@ def unregister_autostart(): cmd, capture_output=True, text=True, + encoding="utf-8", + errors="replace", creationflags=subprocess.CREATE_NO_WINDOW )