perfectionist-plugins 1.0.3 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -40,6 +40,19 @@ Built for [Pengu Loader](https://pengu.lol)
40
40
  No more scrolling past skins you can't use.
41
41
  </td>
42
42
  </tr>
43
+ <tr>
44
+ <td>&nbsp;</td>
45
+ </tr>
46
+ <tr>
47
+ <th align="left">🔓 Unlock Profile Skins</th>
48
+ </tr>
49
+ <tr>
50
+ <td>
51
+ In the <b>Set profile background</b> window, every skin shows as unlocked, even the event skins (Signature).
52
+ <br><br>
53
+ Browse the full list and pick any skin you like as your profile background.
54
+ </td>
55
+ </tr>
43
56
  </table>
44
57
 
45
58
  ## Install
package/ban-blocker.js CHANGED
@@ -1,4 +1,4 @@
1
- export const version = "3.4.0";
1
+ export const version = "3.4.2";
2
2
 
3
3
  const PLUGIN_ID = "ban-blocker";
4
4
  const STYLE_ID = "perfectionist-ban-blocker-style";
@@ -13,6 +13,7 @@ const FAIL_COOLDOWN_MAX_MS = 30000;
13
13
  const POLL_MS = 40;
14
14
  const ENSURE_MS = 300;
15
15
  const DETECT_RETRY_MS = 2000;
16
+ const GONE_RESET_MS = 1500;
16
17
 
17
18
  const BAN_GLYPH_SVG =
18
19
  "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'>" +
@@ -58,11 +59,14 @@ let ensureTimer = null;
58
59
  let detectTimer = null;
59
60
  let refreshTimer = null;
60
61
  let pumpTimer = null;
62
+ let goneTimer = null;
61
63
  let observer = null;
62
64
  let pollInFlight = false;
63
65
  let uiScheduled = false;
64
66
 
65
67
  const enabled = new Map();
68
+ const alias = new Map();
69
+ const fetchedNames = new Set();
66
70
  const playerNames = new Map();
67
71
  const cellToPlayer = new Map();
68
72
  const buttons = new Map();
@@ -96,13 +100,62 @@ function isPicked(members, champ) {
96
100
  return (members ?? []).some((p) => p.championId === champ && p.completed);
97
101
  }
98
102
 
103
+ function canonOf(id) {
104
+ return (id != null && alias.get(id)) || id;
105
+ }
106
+
99
107
  function isBlocked(id) {
100
- return enabled.get(id) === true;
108
+ return enabled.get(canonOf(id)) === true;
109
+ }
110
+
111
+ function isWeakKey(k) {
112
+ return typeof k === "string" && k.startsWith("cell:");
113
+ }
114
+
115
+ function payloadName(p) {
116
+ return norm(`${p?.gameName ?? ""}${p?.tagLine ?? ""}`) || norm(p?.summonerName) || "";
101
117
  }
102
118
 
103
- function playerId(p) {
119
+ function keyOf(p) {
104
120
  if (typeof p === "number") return p;
105
- return p?.summonerId ?? p?.puuid ?? null;
121
+ const strong = p?.summonerId || p?.puuid;
122
+ if (strong) return strong;
123
+ const n = payloadName(p);
124
+ if (n) return `name:${n}`;
125
+ return p?.cellId != null ? `cell:${p.cellId}` : null;
126
+ }
127
+
128
+ function linkPlayer(p, rowName, cellId) {
129
+ const strong = typeof p === "number" ? p : p?.summonerId || p?.puuid;
130
+ const pn = typeof p === "number" ? "" : payloadName(p);
131
+ const rn = norm(rowName);
132
+ const keys = [];
133
+ if (strong) keys.push(strong);
134
+ if (pn) keys.push(`name:${pn}`);
135
+ if (rn && rn !== pn) keys.push(`name:${rn}`);
136
+ const weak = cellId != null ? `cell:${cellId}` : null;
137
+
138
+ let root = null;
139
+ for (const k of keys) {
140
+ const r = alias.get(k);
141
+ if (r != null) {
142
+ root = r;
143
+ break;
144
+ }
145
+ }
146
+ if (root == null && keys.length) {
147
+ root = keys[0];
148
+ const prev = weak ? alias.get(weak) : null;
149
+ if (prev != null && isWeakKey(prev) && enabled.has(prev)) {
150
+ if (!enabled.has(root)) enabled.set(root, enabled.get(prev));
151
+ enabled.delete(prev);
152
+ }
153
+ }
154
+ if (root == null) root = (weak && alias.get(weak)) || weak;
155
+ if (root == null) return null;
156
+ for (const k of keys) alias.set(k, root);
157
+ if (weak) alias.set(weak, root);
158
+ return root;
106
159
  }
107
160
 
108
161
  function banPending(s) {
@@ -149,7 +202,10 @@ function decide(s) {
149
202
  const me = s?.localPlayerCellId;
150
203
 
151
204
  cellToPlayer.clear();
152
- for (const p of m) cellToPlayer.set(p.cellId, playerId(p));
205
+ for (const p of m) {
206
+ const row = rows.find((r) => r.slotId === p.cellId);
207
+ cellToPlayer.set(p.cellId, linkPlayer(p, row?.name, p.cellId));
208
+ }
153
209
 
154
210
  let myPick = null;
155
211
  for (const a of actions) {
@@ -160,7 +216,7 @@ function decide(s) {
160
216
  }
161
217
  myActionId = myPick ? myPick.id : null;
162
218
 
163
- const blockedCells = new Set(m.filter((p) => isBlocked(playerId(p))).map((p) => p.cellId));
219
+ const blockedCells = new Set(m.filter((p) => isBlocked(cellToPlayer.get(p.cellId))).map((p) => p.cellId));
164
220
 
165
221
  let threat = 0;
166
222
  for (const a of actions) {
@@ -193,6 +249,15 @@ function onSessionGone() {
193
249
  goneAway = true;
194
250
  uiVisible = false;
195
251
  removeButtons();
252
+ if (!goneTimer) {
253
+ goneTimer = setTimeout(() => {
254
+ goneTimer = null;
255
+ enabled.clear();
256
+ alias.clear();
257
+ playerNames.clear();
258
+ fetchedNames.clear();
259
+ }, GONE_RESET_MS);
260
+ }
196
261
  }
197
262
 
198
263
  function onSession(raw) {
@@ -208,6 +273,8 @@ function onSession(raw) {
208
273
  return;
209
274
  }
210
275
  if (!s?.myTeam) return;
276
+ clearTimeout(goneTimer);
277
+ goneTimer = null;
211
278
  goneAway = false;
212
279
  lastSession = s;
213
280
 
@@ -256,29 +323,27 @@ function getRows() {
256
323
  }
257
324
 
258
325
  function ensureName(cell) {
259
- const id = playerId(cell);
260
- if (id == null || playerNames.has(id)) return;
261
- playerNames.set(id, []);
326
+ const strong = cell?.summonerId || cell?.puuid;
327
+ if (!strong || fetchedNames.has(strong)) return;
262
328
  const get = api?.lcu?.get;
263
- if (!get) {
264
- playerNames.delete(id);
265
- return;
266
- }
267
- const byPuuid = typeof id === "string" && id.length > 20;
268
- const path = byPuuid ? `/lol-summoner/v2/summoners/puuid/${id}` : `/lol-summoner/v1/summoners/${id}`;
329
+ if (!get) return;
330
+ fetchedNames.add(strong);
331
+ const byPuuid = typeof strong === "string" && strong.length > 20;
332
+ const path = byPuuid ? `/lol-summoner/v2/summoners/puuid/${strong}` : `/lol-summoner/v1/summoners/${strong}`;
269
333
  get(path)
270
334
  .then((data) => {
271
335
  const names = [norm(data?.displayName), norm(data?.gameName)].filter(Boolean);
272
- if (names.length) playerNames.set(id, names);
273
- else playerNames.delete(id);
336
+ if (!names.length) return;
337
+ const root = canonOf(strong);
338
+ playerNames.set(root, [...new Set([...(playerNames.get(root) ?? []), ...names])]);
274
339
  })
275
- .catch(() => playerNames.delete(id));
340
+ .catch(() => fetchedNames.delete(strong));
276
341
  }
277
342
 
278
343
  function nameMatches(rowName, id) {
279
344
  const rn = norm(rowName);
280
345
  if (!rn) return false;
281
- return (playerNames.get(id) ?? []).some(
346
+ return (playerNames.get(canonOf(id)) ?? []).some(
282
347
  (n) => n === rn || (n.length >= 3 && rn.length >= 3 && (n.startsWith(rn) || rn.startsWith(n))),
283
348
  );
284
349
  }
@@ -292,8 +357,13 @@ function bindRowsToTeam(session) {
292
357
  for (const r of list) {
293
358
  const cell =
294
359
  (r.slotId != null ? cells.find((c) => c.cellId === r.slotId) : null) ??
295
- cells.find((c) => nameMatches(r.name, playerId(c)));
296
- r.player = cell ? playerId(cell) : null;
360
+ cells.find((c) => nameMatches(r.name, keyOf(c)));
361
+ if (cell) {
362
+ r.player = linkPlayer(cell, r.name, cell.cellId);
363
+ cellToPlayer.set(cell.cellId, r.player);
364
+ } else {
365
+ r.player = null;
366
+ }
297
367
  }
298
368
  }
299
369
 
@@ -303,14 +373,18 @@ function stopEvent(e) {
303
373
  e.preventDefault();
304
374
  }
305
375
 
376
+ function playerForBadge(btn) {
377
+ const rowEl = btn.closest(".summoner-object");
378
+ const r = rowEl ? rows.find((x) => x.el === rowEl) : rows[Number(btn.dataset.slot)];
379
+ const byCell = btn.dataset.cell ? cellToPlayer.get(Number(btn.dataset.cell)) ?? null : null;
380
+ return canonOf(byCell ?? r?.player ?? null);
381
+ }
382
+
306
383
  function toggle(btn) {
307
- let id = btn.dataset.cell ? (cellToPlayer.get(Number(btn.dataset.cell)) ?? null) : null;
308
- if (id == null) {
309
- if (lastSession) bindRowsToTeam(lastSession);
310
- const list = getRows();
311
- const rowEl = btn.closest(".summoner-object");
312
- const r = list.find((x) => x.el === rowEl) ?? list[Number(btn.dataset.slot)];
313
- id = r?.player ?? null;
384
+ let id = playerForBadge(btn);
385
+ if (id == null && lastSession) {
386
+ bindRowsToTeam(lastSession);
387
+ id = playerForBadge(btn);
314
388
  }
315
389
  if (id == null) return;
316
390
  const myCell = lastSession?.localPlayerCellId;
@@ -386,7 +460,7 @@ function ensureButtons() {
386
460
  function renderButtons() {
387
461
  const hasSession = !!lastSession?.myTeam;
388
462
  for (const [, btn] of buttons) {
389
- const id = btn.dataset.cell ? (cellToPlayer.get(Number(btn.dataset.cell)) ?? null) : null;
463
+ const id = playerForBadge(btn);
390
464
  const on = id != null && isBlocked(id);
391
465
  btn.classList.toggle("on", on);
392
466
  btn.classList.toggle("unresolved", id == null && hasSession);
@@ -505,7 +579,8 @@ function onDisable() {
505
579
  clearTimeout(refreshTimer);
506
580
  clearTimeout(detectTimer);
507
581
  clearTimeout(pumpTimer);
508
- pollTimer = ensureTimer = refreshTimer = detectTimer = pumpTimer = null;
582
+ clearTimeout(goneTimer);
583
+ pollTimer = ensureTimer = refreshTimer = detectTimer = pumpTimer = goneTimer = null;
509
584
  observer?.disconnect();
510
585
  observer = null;
511
586
 
@@ -518,6 +593,8 @@ function onDisable() {
518
593
  cellToPlayer.clear();
519
594
  enabled.clear();
520
595
  playerNames.clear();
596
+ alias.clear();
597
+ fetchedNames.clear();
521
598
  lastSession = null;
522
599
  rows = [];
523
600
  removeButtons();
@@ -1,4 +1,4 @@
1
- export const version = "2.2.0";
1
+ export const version = "2.3.0";
2
2
 
3
3
  const PLUGIN_ID = "hide-unowned-skins";
4
4
  const STYLE_ID = "perfectionist-hide-unowned-skins-style";
@@ -12,6 +12,7 @@ const CURRENT_SUMMONER_URI = "/lol-summoner/v1/current-summoner";
12
12
  const SUMMONER_TTL = 10 * 60 * 1000;
13
13
  const INVENTORY_TTL = 60 * 1000;
14
14
  const REFRESH_DEBOUNCE_MS = 350;
15
+ const REPACK_SETTLE_MS = 120;
15
16
  const RETRY_MS = 5000;
16
17
  const STATE_KEY = "perfectionist-plugins:state";
17
18
 
@@ -271,18 +272,32 @@ let sessionMisses = 0;
271
272
  let championMisses = 0;
272
273
  let renderObserver = null;
273
274
  const repackOriginals = new Map();
275
+ const skinSuffixCache = new Map();
276
+ let repackTimer = null;
274
277
  let removeStyle = null;
275
278
 
276
279
  function logError(scope, error) {
277
280
  console.error(`[Perfectionist] Hide Unowned Skins: ${scope}`, error);
278
281
  }
279
282
 
283
+ function skinSuffixOf(text) {
284
+ const numbered = text.match(/Skins\/Skin(\d+)\//i) ?? text.match(/Skin(\d+)\//);
285
+ if (numbered) return Number(numbered[1]);
286
+ return /Skins\/Base\//i.test(text) ? 0 : null;
287
+ }
288
+
280
289
  function readSkinId(item) {
281
- const html = item.innerHTML;
282
- const numbered = html.match(/Skins\/Skin(\d+)\//i) ?? html.match(/Skin(\d+)\//);
283
- if (numbered) return currentChampionId * 1000 + Number(numbered[1]);
284
- if (/Skins\/Base\//i.test(html)) return currentChampionId * 1000;
285
- return null;
290
+ for (const img of item.querySelectorAll("img[src]")) {
291
+ const src = img.getAttribute("src");
292
+ let suffix = skinSuffixCache.get(src);
293
+ if (suffix === undefined) {
294
+ suffix = skinSuffixOf(src);
295
+ skinSuffixCache.set(src, suffix);
296
+ }
297
+ if (suffix !== null) return currentChampionId * 1000 + suffix;
298
+ }
299
+ const suffix = skinSuffixOf(item.innerHTML);
300
+ return suffix === null ? null : currentChampionId * 1000 + suffix;
286
301
  }
287
302
 
288
303
  function hideTargetFor(item) {
@@ -297,10 +312,17 @@ function hideTargetFor(item) {
297
312
 
298
313
  function repackVisibleItems(items) {
299
314
  const visible = items.filter((item) => !item.classList.contains(HIDDEN_CLASS));
300
- if (visible.length < 2) return;
315
+ if (visible.length < 2) {
316
+ restoreRepack();
317
+ return;
318
+ }
301
319
 
302
320
  const selectedIndex = items.findIndex((item) => item.classList.contains("skin-selection-item-selected"));
303
- const anchor = selectedIndex >= 0 ? items[selectedIndex] : visible[Math.floor(visible.length / 2)];
321
+ let anchor = selectedIndex >= 0 ? items[selectedIndex] : null;
322
+ if (!anchor && selectedSkinId !== null) {
323
+ anchor = visible.find((item) => readSkinId(item) === selectedSkinId) ?? null;
324
+ }
325
+ if (!anchor) anchor = visible[Math.floor(visible.length / 2)];
304
326
  const anchorDomIndex = items.indexOf(anchor);
305
327
 
306
328
  const widthOf = (item) => item.getBoundingClientRect().width || (item === anchor ? 117 : 73);
@@ -378,10 +400,28 @@ function applyAll() {
378
400
  else if (dotsHidden === 0 && dotList.hasAttribute("data-perf-dots")) dotList.removeAttribute("data-perf-dots");
379
401
  }
380
402
 
381
- if (ownedSkinIds !== null && hidden > 0) repackVisibleItems(items);
403
+ if (ownedSkinIds !== null && hidden > 0) scheduleRepack();
382
404
  else if (hidden === 0) restoreRepack();
383
405
  }
384
406
 
407
+ function scheduleRepack() {
408
+ clearTimeout(repackTimer);
409
+ repackTimer = setTimeout(runRepack, REPACK_SETTLE_MS);
410
+ }
411
+
412
+ function runRepack() {
413
+ repackTimer = null;
414
+ renderObserver?.disconnect();
415
+ try {
416
+ const items = [...document.querySelectorAll(SKIN_ITEM_SELECTOR)];
417
+ const hidden = items.some((item) => item.classList.contains(HIDDEN_CLASS));
418
+ if (ownedSkinIds !== null && hidden) repackVisibleItems(items);
419
+ else restoreRepack();
420
+ } finally {
421
+ renderObserver?.observe(document.documentElement ?? document, OBSERVER_OPTIONS);
422
+ }
423
+ }
424
+
385
425
  function applyAllGuarded() {
386
426
  renderObserver?.disconnect();
387
427
  try {
@@ -565,6 +605,8 @@ function onDisable() {
565
605
  unsubscribe = null;
566
606
  clearTimeout(refreshTimer);
567
607
  refreshTimer = null;
608
+ clearTimeout(repackTimer);
609
+ repackTimer = null;
568
610
  renderObserver?.disconnect();
569
611
  removeStyle?.();
570
612
  renderObserver = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "perfectionist-plugins",
3
- "version": "1.0.3",
3
+ "version": "1.2.0",
4
4
  "description": "Small plugins that fix the annoying parts of League of Legends champ select. Built for Pengu Loader.",
5
5
  "type": "module",
6
6
  "main": "perfectionist-utils.js",
@@ -8,12 +8,14 @@
8
8
  ".": "./perfectionist-utils.js",
9
9
  "./perfectionist-utils.js": "./perfectionist-utils.js",
10
10
  "./ban-blocker.js": "./ban-blocker.js",
11
- "./hide-unowned-skins.js": "./hide-unowned-skins.js"
11
+ "./hide-unowned-skins.js": "./hide-unowned-skins.js",
12
+ "./unlock-profile-skins.js": "./unlock-profile-skins.js"
12
13
  },
13
14
  "files": [
14
15
  "perfectionist-utils.js",
15
16
  "ban-blocker.js",
16
- "hide-unowned-skins.js"
17
+ "hide-unowned-skins.js",
18
+ "unlock-profile-skins.js"
17
19
  ],
18
20
  "repository": {
19
21
  "type": "git",
@@ -6,7 +6,7 @@ const PANEL_ID = "perfectionist-plugins-panel";
6
6
  const STYLE_ID = "perfectionist-plugins-style";
7
7
  const TAB_TITLE = "Perfectionism";
8
8
 
9
- export const version = "1.0.1";
9
+ export const version = "1.1.0";
10
10
 
11
11
  const PKG = "perfectionist-plugins";
12
12
  const CDN = "https://cdn.jsdelivr.net/npm";
@@ -26,7 +26,7 @@ const BUNDLED_MODULES = [
26
26
  id: "ban-blocker",
27
27
  file: "ban-blocker.js",
28
28
  name: "Ban Blocker",
29
- version: "3.4.0",
29
+ version: "3.4.2",
30
30
  description: "During bans, blocking a teammate instantly claims the champion they hover to ban.",
31
31
  },
32
32
  {
@@ -36,6 +36,13 @@ const BUNDLED_MODULES = [
36
36
  version: "2.2.0",
37
37
  description: "In champion select, only shows the skins you own for the picked champion.",
38
38
  },
39
+ {
40
+ id: "unlock-profile-skins",
41
+ file: "unlock-profile-skins.js",
42
+ name: "Unlock Profile Skins",
43
+ version: "1.0.0",
44
+ description: "Unlocks every skin in the Set profile background window.",
45
+ },
39
46
  ];
40
47
 
41
48
  const STYLE_CSS = `
@@ -123,11 +130,15 @@ function saveState() {
123
130
  enabled: state.enabled,
124
131
  };
125
132
  try {
126
- if (typeof DataStore !== "undefined") DataStore.set(STATE_KEY, record);
127
133
  localStorage.setItem(STATE_KEY, JSON.stringify(record));
128
134
  } catch (error) {
129
135
  console.error("[Perfectionism] Could not save plugin state.", error);
130
136
  }
137
+ try {
138
+ if (typeof DataStore !== "undefined") DataStore.set(STATE_KEY, record);
139
+ } catch (error) {
140
+ console.error("[Perfectionism] Could not save plugin state.", error);
141
+ }
131
142
  }
132
143
 
133
144
  function logError(scope, error) {
@@ -0,0 +1,648 @@
1
+ export const version = "1.2.0";
2
+
3
+ const PLUGIN_ID = "unlock-profile-skins";
4
+ const PICKER_BUTTON_SELECTOR = ".style-profile-skin-picker-button";
5
+ const CURRENT_SUMMONER_URI = "/lol-summoner/v1/current-summoner";
6
+ const INVENTORY_PATH = /^\/lol-champions\/v1\/inventories\/(\d+)\/champions$/;
7
+ const AUGMENT_INVENTORY_PATH = /^\/lol-inventory\/v2\/inventory\/SKIN_AUGMENT$/i;
8
+ const SUMMONER_TTL = 10 * 60 * 1000;
9
+ const STATE_KEY = "perfectionist-plugins:state";
10
+ const FAKE_PURCHASE_DATE = 1577836800000;
11
+ const FAKE_RENTAL_END = 4102444800000;
12
+ const TIER_LABEL = "tier";
13
+ const GROUPING_OPTIONS = "lol-uikit-dropdown-option";
14
+ const HARVEST_WAIT_MS = 2000;
15
+
16
+ let filterActive = true;
17
+ let api = null;
18
+ let summonerId = null;
19
+ let summonerPending = false;
20
+ let rawFetch = null;
21
+ let harvestPromise = null;
22
+ const augmentIds = new Set();
23
+ const pendingTargetLoads = new Set();
24
+ const variantIdRemap = new Map();
25
+
26
+ function readDefaultEnabled() {
27
+ try {
28
+ const saved = JSON.parse(localStorage.getItem(STATE_KEY) || "{}");
29
+ return saved.enabled?.[PLUGIN_ID] !== false;
30
+ } catch {
31
+ return true;
32
+ }
33
+ }
34
+
35
+ function logError(scope, error) {
36
+ console.error(`[Perfectionist] Unlock Profile Skins: ${scope}`, error);
37
+ }
38
+
39
+ function isPickerOpen() {
40
+ return document.querySelector(PICKER_BUTTON_SELECTOR) !== null;
41
+ }
42
+
43
+ function cleanPath(url) {
44
+ let path = String(url ?? "").split("?")[0];
45
+ const origin = /^[a-z][a-z0-9+.-]*:\/\/[^/]+/i.exec(path);
46
+ if (origin) path = path.slice(origin[0].length) || "/";
47
+ return path;
48
+ }
49
+
50
+ function pathIdOf(url) {
51
+ const match = INVENTORY_PATH.exec(cleanPath(url));
52
+ return match ? Number(match[1]) : null;
53
+ }
54
+
55
+ function isTargetUrl(url) {
56
+ const id = pathIdOf(url);
57
+ return id !== null && summonerId !== null && id === summonerId;
58
+ }
59
+
60
+ function isAugmentUrl(url) {
61
+ return AUGMENT_INVENTORY_PATH.test(cleanPath(url));
62
+ }
63
+
64
+ function unlockOwnership(ownership) {
65
+ if (!ownership || typeof ownership !== "object") return false;
66
+ let changed = false;
67
+ const needsFakeDate = ownership.owned !== true || ownership.rental?.rented === true;
68
+ if (ownership.owned !== true) {
69
+ ownership.owned = true;
70
+ changed = true;
71
+ }
72
+ if (needsFakeDate) {
73
+ const rental = ownership.rental;
74
+ if (rental && typeof rental === "object") {
75
+ if (rental.purchaseDate !== FAKE_PURCHASE_DATE) {
76
+ rental.purchaseDate = FAKE_PURCHASE_DATE;
77
+ changed = true;
78
+ }
79
+ if (rental.endDate !== undefined && rental.endDate !== FAKE_RENTAL_END) {
80
+ rental.endDate = FAKE_RENTAL_END;
81
+ changed = true;
82
+ }
83
+ }
84
+ if (ownership.purchaseDate !== undefined && ownership.purchaseDate !== FAKE_PURCHASE_DATE) {
85
+ ownership.purchaseDate = FAKE_PURCHASE_DATE;
86
+ changed = true;
87
+ }
88
+ }
89
+ return changed;
90
+ }
91
+
92
+ function synthesizedOwnership() {
93
+ return {
94
+ owned: true,
95
+ loyaltyReward: false,
96
+ xboxGPReward: false,
97
+ rental: { rented: false, purchaseDate: 0, endDate: 0, winCountRemaining: 0 },
98
+ };
99
+ }
100
+
101
+ function harvestFromPayload(node) {
102
+ if (!node || typeof node !== "object") return;
103
+ const list = node.skinAugments?.augments;
104
+ if (Array.isArray(list)) {
105
+ for (const aug of list) {
106
+ if (aug && typeof aug.contentId === "string") augmentIds.add(aug.contentId);
107
+ }
108
+ }
109
+ const values = Array.isArray(node) ? node : Object.values(node);
110
+ for (const value of values) {
111
+ if (value && typeof value === "object") harvestFromPayload(value);
112
+ }
113
+ }
114
+
115
+ function variantLabelFromOverlays(overlays) {
116
+ for (const overlay of overlays || []) {
117
+ const match = /splash_tile_([a-z0-9]+)\.png$/i.exec(String(overlay?.tileLCOverlayPath || ""));
118
+ if (match) return match[1].charAt(0).toUpperCase() + match[1].slice(1);
119
+ }
120
+ return "Variant";
121
+ }
122
+
123
+ function splitVariantTiers(skin) {
124
+ const tiers = skin.questSkinInfo.tiers;
125
+ const parent = typeof skin.id === "number" ? skin.id : null;
126
+ const taken = new Set(tiers.map((tier) => tier?.id));
127
+ const out = [];
128
+ let changed = false;
129
+ let sibling = 0;
130
+ for (const tier of tiers) {
131
+ out.push(tier);
132
+ const augments = tier?.skinAugments?.augments;
133
+ if (!Array.isArray(augments) || augments.length < 2) continue;
134
+ const withOverlays = augments.filter((aug) => Array.isArray(aug?.overlays) && aug.overlays.some((o) => o && o.tileLCOverlayPath));
135
+ const plain = augments.filter((aug) => withOverlays.indexOf(aug) === -1);
136
+ if (withOverlays.length === 0 || plain.length === 0) continue;
137
+ tier.skinAugments = { ...tier.skinAugments, augments: plain };
138
+ for (const aug of withOverlays) {
139
+ let id = parent !== null ? parent + 1000000 * sibling : -(tier.id ?? 0) * 10 - sibling;
140
+ while (taken.has(id)) id += parent !== null ? 1000000 : -1;
141
+ taken.add(id);
142
+ sibling++;
143
+ variantIdRemap.set(id, tier.id);
144
+ out.push({
145
+ ...tier,
146
+ id,
147
+ name: `${tier.name ?? ""} ${variantLabelFromOverlays(aug.overlays)}`.trim(),
148
+ skinAugments: { ...tier.skinAugments, augments: [aug] },
149
+ });
150
+ changed = true;
151
+ }
152
+ }
153
+ if (changed) skin.questSkinInfo.tiers = out;
154
+ return changed;
155
+ }
156
+
157
+ function remapVariantIds(body) {
158
+ if (typeof body !== "string" || variantIdRemap.size === 0) return body;
159
+ let out = body;
160
+ for (const [from, to] of variantIdRemap) {
161
+ out = out.replace(new RegExp(`(["'\\[,:\\s])${from}(?![0-9])`, "g"), `$1${to}`);
162
+ }
163
+ return out;
164
+ }
165
+
166
+ function isShadowEntry(entry) {
167
+ if (!entry || typeof entry !== "object") return false;
168
+ if (Array.isArray(entry.skins)) return typeof entry.id === "number" && entry.id >= 60000;
169
+ return typeof entry.championId === "number" && entry.championId >= 60000 && typeof entry.id === "number";
170
+ }
171
+
172
+ function unlockDeep(node) {
173
+ if (!node || typeof node !== "object") return false;
174
+ let changed = false;
175
+ if (Array.isArray(node)) {
176
+ for (let i = node.length - 1; i >= 0; i--) {
177
+ if (isShadowEntry(node[i])) {
178
+ node.splice(i, 1);
179
+ changed = true;
180
+ }
181
+ }
182
+ }
183
+ if (!Array.isArray(node) && node.questSkinInfo && Array.isArray(node.questSkinInfo.tiers)) {
184
+ changed = splitVariantTiers(node) || changed;
185
+ }
186
+ const augList = node.skinAugments?.augments;
187
+ if (Array.isArray(augList)) {
188
+ for (const aug of augList) {
189
+ if (!aug || typeof aug !== "object") continue;
190
+ if (typeof aug.contentId === "string") augmentIds.add(aug.contentId);
191
+ if (aug.ownership && typeof aug.ownership === "object") {
192
+ changed = unlockOwnership(aug.ownership) || changed;
193
+ } else {
194
+ aug.ownership = synthesizedOwnership();
195
+ changed = true;
196
+ }
197
+ }
198
+ }
199
+ if (node.ownership && typeof node.ownership === "object") {
200
+ changed = unlockOwnership(node.ownership) || changed;
201
+ } else if (typeof node.contentId === "string" && Array.isArray(node.overlays)) {
202
+ node.ownership = synthesizedOwnership();
203
+ changed = true;
204
+ }
205
+ if (node.owned === false) {
206
+ node.owned = true;
207
+ changed = true;
208
+ }
209
+ const values = Array.isArray(node) ? node : Object.values(node);
210
+ for (const value of values) {
211
+ if (value && typeof value === "object") changed = unlockDeep(value) || changed;
212
+ }
213
+ return changed;
214
+ }
215
+
216
+ function unlockAugmentInventory(items) {
217
+ if (!Array.isArray(items)) return false;
218
+ let changed = false;
219
+ const present = new Set();
220
+ for (const item of items) {
221
+ if (!item || typeof item !== "object") continue;
222
+ if (typeof item.uuid === "string") present.add(item.uuid);
223
+ if (item.owned !== true) {
224
+ item.owned = true;
225
+ changed = true;
226
+ }
227
+ if (item.ownershipType !== undefined && item.ownershipType !== "OWNED") {
228
+ item.ownershipType = "OWNED";
229
+ changed = true;
230
+ }
231
+ }
232
+ for (const id of augmentIds) {
233
+ if (present.has(id)) continue;
234
+ items.push({
235
+ uuid: id,
236
+ inventoryType: "SKIN_AUGMENT",
237
+ ownershipType: "OWNED",
238
+ owned: true,
239
+ quantity: 1,
240
+ purchaseDate: FAKE_PURCHASE_DATE,
241
+ });
242
+ changed = true;
243
+ }
244
+ return changed;
245
+ }
246
+
247
+ function ensureAugmentIds() {
248
+ if (augmentIds.size > 0) return Promise.resolve();
249
+ if (harvestPromise) return harvestPromise;
250
+ harvestPromise = (async () => {
251
+ const waits = [...pendingTargetLoads];
252
+ if (waits.length > 0) {
253
+ await Promise.race([
254
+ Promise.allSettled(waits),
255
+ new Promise((resolve) => setTimeout(resolve, HARVEST_WAIT_MS)),
256
+ ]);
257
+ }
258
+ if (augmentIds.size > 0) return;
259
+ try {
260
+ const get = rawFetch || ((...args) => window.fetch(...args));
261
+ let id = summonerId;
262
+ if (id === null) {
263
+ const response = await get(CURRENT_SUMMONER_URI);
264
+ const summoner = await response.json().catch(() => null);
265
+ id = typeof summoner?.summonerId === "number" ? summoner.summonerId : null;
266
+ if (id !== null) summonerId = id;
267
+ }
268
+ if (id === null) return;
269
+ const response = await get(`/lol-champions/v1/inventories/${id}/champions`);
270
+ const json = await response.json().catch(() => null);
271
+ if (json) harvestFromPayload(json);
272
+ } catch {}
273
+ })().finally(() => {
274
+ harvestPromise = null;
275
+ });
276
+ return harvestPromise;
277
+ }
278
+
279
+ let tierObserver = null;
280
+ let tierAppliedForOpen = false;
281
+ let tierDropdown = null;
282
+
283
+ function findGroupingDropdown() {
284
+ for (const dd of document.querySelectorAll("lol-uikit-framed-dropdown")) {
285
+ const labels = [...dd.querySelectorAll(GROUPING_OPTIONS)].map((o) => o.textContent.trim().toLowerCase());
286
+ if (labels.includes("my collection") && labels.includes(TIER_LABEL)) return dd;
287
+ }
288
+ return null;
289
+ }
290
+
291
+ function applyTierGrouping() {
292
+ if (tierDropdown && !tierDropdown.isConnected) {
293
+ tierDropdown = null;
294
+ tierAppliedForOpen = false;
295
+ }
296
+ if (tierAppliedForOpen) return;
297
+ const dd = tierDropdown ?? findGroupingDropdown();
298
+ if (!dd) {
299
+ tierAppliedForOpen = false;
300
+ return;
301
+ }
302
+ tierDropdown = dd;
303
+ const options = [...dd.querySelectorAll(GROUPING_OPTIONS)];
304
+ const tier = options.find((o) => o.textContent.trim().toLowerCase() === TIER_LABEL);
305
+ if (!tier) return;
306
+ const current = options.find((o) => o.hasAttribute("selected"));
307
+ if (current !== tier) tier.click();
308
+ tierAppliedForOpen = true;
309
+ }
310
+
311
+ function startTierGrouping() {
312
+ if (tierObserver) return;
313
+ tierObserver = new MutationObserver(() => {
314
+ try {
315
+ applyTierGrouping();
316
+ } catch (error) {
317
+ logError("tier grouping failed.", error);
318
+ }
319
+ });
320
+ tierObserver.observe(document.documentElement ?? document, { childList: true, subtree: true });
321
+ applyTierGrouping();
322
+ }
323
+
324
+ function stopTierGrouping() {
325
+ tierObserver?.disconnect();
326
+ tierObserver = null;
327
+ tierAppliedForOpen = false;
328
+ tierDropdown = null;
329
+ }
330
+
331
+ function maybeRefreshSummoner() {
332
+ if (summonerId !== null || summonerPending || !isPickerOpen()) return;
333
+ ensureSummonerId();
334
+ }
335
+
336
+ function ensureSummonerId() {
337
+ if (summonerId !== null || summonerPending) return;
338
+ const get = api?.lcu?.get;
339
+ if (!get) return;
340
+ summonerPending = true;
341
+ get(CURRENT_SUMMONER_URI, { ttl: SUMMONER_TTL })
342
+ .then((summoner) => {
343
+ summonerId = typeof summoner?.summonerId === "number" ? summoner.summonerId : null;
344
+ })
345
+ .catch(() => {})
346
+ .finally(() => {
347
+ summonerPending = false;
348
+ });
349
+ }
350
+
351
+ function rewriteWebSocketFrame(text) {
352
+ if (!filterActive || typeof text !== "string") return null;
353
+ const targetFrame = text.indexOf("lol-champions/v1/inventories") !== -1;
354
+ const augmentFrame = text.indexOf("SKIN_AUGMENT") !== -1;
355
+ if (!targetFrame && !augmentFrame) return null;
356
+ if (!isPickerOpen()) return null;
357
+ let frame;
358
+ try {
359
+ frame = JSON.parse(text);
360
+ } catch {
361
+ return null;
362
+ }
363
+ if (!Array.isArray(frame) || frame.length < 3) return null;
364
+ const payload = frame[2];
365
+ if (!payload || typeof payload !== "object" || typeof payload.uri !== "string") return null;
366
+ let changed;
367
+ if (isTargetUrl(payload.uri)) {
368
+ changed = unlockDeep(payload.data);
369
+ } else if (isAugmentUrl(payload.uri)) {
370
+ changed = unlockAugmentInventory(payload.data);
371
+ } else {
372
+ return null;
373
+ }
374
+ if (!changed) return null;
375
+ return JSON.stringify(frame);
376
+ }
377
+
378
+ function wrapMessageListener(listener) {
379
+ if (typeof listener !== "function" || listener.__perfProfileWrap) return listener;
380
+ const wrapped = function (event) {
381
+ if (event && typeof event.data === "string") {
382
+ const rewritten = rewriteWebSocketFrame(event.data);
383
+ if (rewritten !== null) {
384
+ const fresh = new MessageEvent("message", {
385
+ data: rewritten,
386
+ origin: event.origin,
387
+ lastEventId: event.lastEventId,
388
+ ports: event.ports ? [...event.ports] : [],
389
+ source: event.source ?? null,
390
+ });
391
+ return listener.call(this, fresh);
392
+ }
393
+ }
394
+ return listener.call(this, event);
395
+ };
396
+ wrapped.__perfProfileWrap = true;
397
+ wrapped.__perfProfileOriginal = listener;
398
+ return wrapped;
399
+ }
400
+
401
+ function installWebSocketFilter() {
402
+ const proto = window.WebSocket && window.WebSocket.prototype;
403
+ if (!proto || proto.addEventListener.__perfProfileFilter) return;
404
+
405
+ const origAdd = proto.addEventListener;
406
+ const origRemove = proto.removeEventListener;
407
+ const origDispatch = proto.dispatchEvent;
408
+
409
+ proto.addEventListener = function (type, listener, options) {
410
+ return origAdd.call(this, type, type === "message" ? wrapMessageListener(listener) : listener, options);
411
+ };
412
+ proto.removeEventListener = function (type, listener, options) {
413
+ return origRemove.call(this, type, (listener && listener.__perfProfileOriginal) || listener, options);
414
+ };
415
+ proto.dispatchEvent = function (event) {
416
+ if (event && event.type === "message" && typeof event.data === "string") {
417
+ const rewritten = rewriteWebSocketFrame(event.data);
418
+ if (rewritten !== null) {
419
+ return origDispatch.call(
420
+ this,
421
+ new MessageEvent("message", {
422
+ data: rewritten,
423
+ origin: event.origin,
424
+ lastEventId: event.lastEventId,
425
+ ports: event.ports ? [...event.ports] : [],
426
+ source: event.source ?? null,
427
+ }),
428
+ );
429
+ }
430
+ }
431
+ return origDispatch.call(this, event);
432
+ };
433
+
434
+ const descriptor = Object.getOwnPropertyDescriptor(proto, "onmessage");
435
+ if (descriptor && descriptor.set && descriptor.get) {
436
+ Object.defineProperty(proto, "onmessage", {
437
+ configurable: true,
438
+ enumerable: descriptor.enumerable,
439
+ get: descriptor.get,
440
+ set(value) {
441
+ descriptor.set.call(this, typeof value === "function" ? wrapMessageListener(value) : value);
442
+ },
443
+ });
444
+ }
445
+
446
+ proto.addEventListener.__perfProfileFilter = true;
447
+ }
448
+
449
+ function installFetchFilter() {
450
+ const original = window.fetch;
451
+ if (typeof original !== "function" || original.__perfProfileFilter) return;
452
+ rawFetch = (...args) => original.call(window, ...args);
453
+
454
+ const wrapped = function (input, init) {
455
+ const url = typeof input === "string" ? input : (input && input.url) || "";
456
+ const target = isTargetUrl(url);
457
+ const augment = !target && isAugmentUrl(url);
458
+ if (filterActive && pathIdOf(url) !== null) maybeRefreshSummoner();
459
+ if (filterActive && init && typeof init.body === "string") {
460
+ const mapped = remapVariantIds(init.body);
461
+ if (mapped !== init.body) init = { ...init, body: mapped };
462
+ }
463
+ const promise = original.call(this, input, init);
464
+ if (filterActive && target && augmentIds.size === 0) {
465
+ const latch = (async () => {
466
+ try {
467
+ const response = await promise;
468
+ if (response && response.ok && augmentIds.size === 0) {
469
+ const json = await response.clone().json().catch(() => null);
470
+ if (json) harvestFromPayload(json);
471
+ }
472
+ } catch {}
473
+ pendingTargetLoads.delete(latch);
474
+ })();
475
+ pendingTargetLoads.add(latch);
476
+ }
477
+ if (!filterActive || (!target && !augment) || !isPickerOpen()) return promise;
478
+ const ready = augment && augmentIds.size === 0 ? ensureAugmentIds() : Promise.resolve();
479
+ return ready
480
+ .then(() => promise)
481
+ .then((response) => {
482
+ if (!response || !response.ok) return response;
483
+ return response
484
+ .clone()
485
+ .json()
486
+ .catch(() => null)
487
+ .then((json) => {
488
+ if (!json) return response;
489
+ const changed = augment ? unlockAugmentInventory(json) : unlockDeep(json);
490
+ if (!changed) return response;
491
+ return new Response(JSON.stringify(json), {
492
+ status: response.status,
493
+ statusText: response.statusText,
494
+ headers: response.headers,
495
+ });
496
+ });
497
+ });
498
+ };
499
+ wrapped.__perfProfileFilter = true;
500
+ window.fetch = wrapped;
501
+ }
502
+
503
+ function installXhrFilter() {
504
+ const proto = window.XMLHttpRequest && window.XMLHttpRequest.prototype;
505
+ if (!proto || proto.open.__perfProfileFilter) return;
506
+
507
+ const origOpen = proto.open;
508
+ const origSend = proto.send;
509
+ const textDescriptor = Object.getOwnPropertyDescriptor(proto, "responseText");
510
+ const responseDescriptor = Object.getOwnPropertyDescriptor(proto, "response");
511
+
512
+ proto.open = function (method, url, ...rest) {
513
+ this.__perfProfileUrl = typeof url === "string" ? url : "";
514
+ return origOpen.call(this, method, url, ...rest);
515
+ };
516
+ proto.open.__perfProfileFilter = true;
517
+
518
+ proto.send = function (...args) {
519
+ const url = this.__perfProfileUrl || "";
520
+ const target = isTargetUrl(url);
521
+ const augment = !target && isAugmentUrl(url);
522
+ if (filterActive && pathIdOf(url) !== null) maybeRefreshSummoner();
523
+ if (filterActive && typeof args[0] === "string") {
524
+ const mapped = remapVariantIds(args[0]);
525
+ if (mapped !== args[0]) args = [mapped, ...args.slice(1)];
526
+ }
527
+ const xhr = this;
528
+ if (target && filterActive && augmentIds.size === 0 && textDescriptor && textDescriptor.get) {
529
+ const latch = new Promise((resolve) => {
530
+ const finish = () => {
531
+ if (augmentIds.size === 0) {
532
+ try {
533
+ harvestFromPayload(JSON.parse(textDescriptor.get.call(xhr)));
534
+ } catch {}
535
+ }
536
+ pendingTargetLoads.delete(latch);
537
+ resolve();
538
+ };
539
+ xhr.addEventListener("load", finish, { once: true });
540
+ xhr.addEventListener("error", finish, { once: true });
541
+ xhr.addEventListener("abort", finish, { once: true });
542
+ });
543
+ pendingTargetLoads.add(latch);
544
+ }
545
+ if (textDescriptor && textDescriptor.get && (target || augment)) {
546
+ const cache = { raw: null, gate: null, filtered: null };
547
+ const gateOpen = () => filterActive && isPickerOpen();
548
+ const rewriteBody = (json) => (augment ? unlockAugmentInventory(json) : unlockDeep(json));
549
+ const filteredText = () => {
550
+ const raw = textDescriptor.get.call(xhr);
551
+ const gate = gateOpen();
552
+ if (cache.filtered === null || cache.raw !== raw || cache.gate !== gate) {
553
+ cache.raw = raw;
554
+ cache.gate = gate;
555
+ cache.filtered = raw;
556
+ if (gate) {
557
+ try {
558
+ const json = JSON.parse(raw);
559
+ const rewritten = rewriteBody(json) ? JSON.stringify(json) : null;
560
+ if (rewritten !== null) cache.filtered = rewritten;
561
+ } catch {}
562
+ }
563
+ }
564
+ return cache.filtered;
565
+ };
566
+ Object.defineProperty(xhr, "responseText", { configurable: true, get: filteredText });
567
+ if (responseDescriptor && responseDescriptor.get) {
568
+ Object.defineProperty(xhr, "response", {
569
+ configurable: true,
570
+ get() {
571
+ const type = xhr.responseType;
572
+ if (type === "" || type === "text") return filteredText();
573
+ const value = responseDescriptor.get.call(xhr);
574
+ if (type === "json" && gateOpen() && value && typeof value === "object") rewriteBody(value);
575
+ return value;
576
+ },
577
+ });
578
+ }
579
+ }
580
+ if (augment && filterActive && isPickerOpen() && augmentIds.size === 0) {
581
+ const sendArgs = args;
582
+ ensureAugmentIds().finally(() => {
583
+ try {
584
+ origSend.apply(xhr, sendArgs);
585
+ } catch {}
586
+ });
587
+ return;
588
+ }
589
+ return origSend.apply(this, args);
590
+ };
591
+ }
592
+
593
+ function installSourceFilter() {
594
+ try {
595
+ filterActive = readDefaultEnabled();
596
+ installWebSocketFilter();
597
+ installFetchFilter();
598
+ installXhrFilter();
599
+ } catch (error) {
600
+ logError("could not install the source filter.", error);
601
+ }
602
+ }
603
+
604
+ function ensureSourceFilter() {
605
+ try {
606
+ installWebSocketFilter();
607
+ installFetchFilter();
608
+ installXhrFilter();
609
+ } catch (error) {
610
+ logError("could not refresh the source filter.", error);
611
+ }
612
+ }
613
+
614
+ function onEnable(utils) {
615
+ api = utils;
616
+ filterActive = true;
617
+ ensureSourceFilter();
618
+ ensureSummonerId();
619
+ startTierGrouping();
620
+ }
621
+
622
+ function onDisable() {
623
+ filterActive = false;
624
+ stopTierGrouping();
625
+ api = null;
626
+ }
627
+
628
+ export function init() {
629
+ installSourceFilter();
630
+
631
+ const registerWith = (utils) => {
632
+ utils.registerPlugin({
633
+ id: PLUGIN_ID,
634
+ name: "Unlock Profile Skins",
635
+ description: "Unlocks every skin in the Set profile background window.",
636
+ version,
637
+ defaultEnabled: true,
638
+ onEnable,
639
+ onDisable,
640
+ });
641
+ };
642
+
643
+ if (window.PerfectionistUtils) {
644
+ registerWith(window.PerfectionistUtils);
645
+ } else {
646
+ addEventListener("perfectionist:utils-ready", (event) => registerWith(event.detail), { once: true });
647
+ }
648
+ }