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..05c5b5c2
--- /dev/null
+++ b/Pengu Loader/plugins/ROSE-Favorites/index.js
@@ -0,0 +1,585 @@
+/**
+ * @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: -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;
+ 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,
+ .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 {
+ opacity: 1 !important;
+ transform: scale(1.15) !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);
+ }
+ }
+
+ if (allFavorites && champId) {
+ allFavorites[String(champId)] = currentFavorites;
+ }
+
+ 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.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);
+ // 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();
+ }
+
+ 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 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",
+ ".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(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
+ ? "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(starBtn.dataset.chromaId || 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;
+ // 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();
+ }
+
+ 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 (!isInChampSelect) {
+ currentChampionId = null;
+ currentFavorites = { championId: null, skins: [], chromas: {} };
+ }
+ }
+ if (bridge && currentChampionId) bridge.send({ type: "request-favorites", championId: currentChampionId });
+ updateUI();
+ });
+ bridge.subscribe("champion-locked", (data) => {
+ 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();
+ });
+ }
+
+ 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..92f8bd99 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,140 @@
});
}
- /**
- * 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 currentChampionId = null;
+ let currentChampionFavoritesCount = 0;
+ let allFavoritesCache = {};
+
+ 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,78 +172,74 @@
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 || "");
}
+ 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 });
- // 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;
- }
- // Reset retry counters
- if (updateRandomFlag._retryCount) {
- updateRandomFlag._retryCount = 0;
+ hideDiceTooltip();
+ if (diceContainerElement) {
+ diceContainerElement.remove();
+ diceContainerElement = null;
}
- // Reset champion lock state when leaving ChampSelect
championLocked = false;
- // Update button if it exists and is in enabled state
- if (diceButtonElement && diceButtonState === 'enabled') {
- updateDiceButtonImage();
- }
+ currentChampionId = null;
+ currentChampionFavoritesCount = 0;
}
}
@@ -182,530 +247,337 @@
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;
+ } else if (currentChampionId && allFavoritesCache && allFavoritesCache[String(currentChampionId)]) {
+ const favs = allFavoritesCache[String(currentChampionId)];
+ currentChampionFavoritesCount = Array.isArray(favs.skins) ? favs.skins.length : 0;
}
- // 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("skin-state", (data) => {
+ if (data && data.championId) {
+ if (data.championId !== currentChampionId) {
+ currentChampionId = data.championId;
+ recalculateFavoritesCount();
+ }
+ }
+ });
+ bridge.subscribe("favorites-state", (data) => {
+ 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();
+ }
+ });
}
- });
- 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/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',
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/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/pengu/communication/broadcaster.py b/pengu/communication/broadcaster.py
index de4e3787..ab8443a0 100644
--- a/pengu/communication/broadcaster.py
+++ b/pengu/communication/broadcaster.py
@@ -261,23 +261,35 @@ 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(champion_id)
+ 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 +298,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..8db629f6 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,71 @@ 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:
+ 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")
+ 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:
+ 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")
+ 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..e158783f 100644
--- a/pengu/core/skin_monitor.py
+++ b/pengu/core/skin_monitor.py
@@ -176,14 +176,18 @@ 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)"""
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/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}")
diff --git a/threads/handlers/injection_trigger.py b/threads/handlers/injection_trigger.py
index e3558044..1bef9528 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,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:
- self._inject_unowned_skin(name, cname)
+ 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}")
@@ -922,7 +939,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 +970,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 +984,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/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 cbbb2a3b..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)
@@ -210,15 +223,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"""
@@ -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 4b514ff2..69686e79 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)
@@ -53,10 +62,15 @@ 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]) -> 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 +79,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 +88,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 +99,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
"""
@@ -97,10 +115,12 @@ def force_base_skin_and_randomize(self, lcu) -> 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
@@ -108,26 +128,29 @@ def force_base_skin_and_randomize(self, lcu) -> 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
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")
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):
+ def _start_randomization(self, mode: str = "all"):
"""Start the randomization sequence"""
# Check if randomization was cancelled
if not self._randomization_started:
@@ -135,43 +158,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"""
@@ -180,12 +209,16 @@ 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) -> 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 +234,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 +268,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 +377,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..07b32132
--- /dev/null
+++ b/utils/core/favorites.py
@@ -0,0 +1,215 @@
+#!/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):
+ # 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:
+ 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.
+ If all chromas for the skin are deselected, the skin is automatically unfavorited.
+ 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)
+
+ 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})")
+ # 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})")
+
+ 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)
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
)