perfectionist-plugins 0.0.0-stage → 1.0.1

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
@@ -1,3 +1,84 @@
1
- # Temporary Holding Version
1
+ <div align="center">
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ # Perfectionism
4
+
5
+ **Small plugins that fix the annoying parts of League of Legends champ select.**
6
+
7
+ Built for [Pengu Loader](https://pengu.lol)
8
+
9
+ ![Pengu Loader](https://img.shields.io/badge/Pengu%20Loader-plugin%20suite-8A63D2?style=for-the-badge)
10
+ ![League of Legends](https://img.shields.io/badge/League%20of%20Legends-client-C89B3C?style=for-the-badge)
11
+ ![Stars](https://img.shields.io/github/stars/SrKing-9812/Perfectionist-Plugins?style=for-the-badge&color=F5C542)
12
+
13
+ </div>
14
+
15
+ <br>
16
+
17
+ ## Plugins
18
+
19
+ <table>
20
+ <tr>
21
+ <th align="left">🛡️ Ban Blocker</th>
22
+ </tr>
23
+ <tr>
24
+ <td>
25
+ In champ select, every teammate except you gets a <b>BLOCK</b> button.
26
+ <br><br>
27
+ While a teammate is blocked, the plugin instantly prepicks the champion they are about to ban on your own pick turn. That way, their ban can never land on it.
28
+ </td>
29
+ </tr>
30
+ <tr>
31
+ <td>&nbsp;</td>
32
+ </tr>
33
+ <tr>
34
+ <th align="left">🎨 Hide Unowned Skins</th>
35
+ </tr>
36
+ <tr>
37
+ <td>
38
+ In champ select, the skin carousel only shows the skins you own for the champion you picked.
39
+ <br><br>
40
+ No more scrolling past skins you can't use.
41
+ </td>
42
+ </tr>
43
+ </table>
44
+
45
+ ## Install
46
+
47
+ 1. Download this repo.
48
+ 2. Move the `Perfectionism` folder into your Pengu Loader `plugins` folder.
49
+ 3. Restart the League Client.
50
+
51
+ That's it. The folder holds one tiny loader file — the plugins themselves come from the
52
+ [`perfectionist-plugins`](https://www.npmjs.com/package/perfectionist-plugins) npm package the
53
+ first time the client starts. Install, remove and toggle plugins in **Settings → Perfectionism**.
54
+
55
+ <br>
56
+
57
+ ## Updates
58
+
59
+ Every time the client starts, the loader checks npm for a newer version and runs it — publishing
60
+ a new version of the npm package **is** the update. If a new version appears while the client is
61
+ already open, the suite downloads it with a progress window and restarts the client at a safe
62
+ moment (never in the middle of a game). You never replace files by hand.
63
+
64
+ <br>
65
+
66
+ ## Publishing an update (maintainers)
67
+
68
+ 1. Bump `version` in `package.json`.
69
+ 2. Run `npm publish` from the repo root (first time only: `npm login` first).
70
+
71
+ That's the whole release process — every install picks the new version up on its own. Never edit
72
+ anything inside the `Perfectionism` folder: that one file stays the same for everyone.
73
+
74
+ <br>
75
+
76
+ ---
77
+
78
+ <div align="center">
79
+
80
+ Made by **Sr. King** 👑
81
+
82
+ If these helped you, a ⭐ on the repo is always nice.
83
+
84
+ </div>
package/ban-blocker.js ADDED
@@ -0,0 +1,552 @@
1
+ export const version = "3.4.0";
2
+
3
+ const PLUGIN_ID = "ban-blocker";
4
+ const STYLE_ID = "perfectionist-ban-blocker-style";
5
+ const BTN_CLASS = "bb-toggle";
6
+ const SESSION_URI = "/lol-champ-select/v1/session";
7
+
8
+ const RESEND_AFTER_MS = 150;
9
+ const RESCHEDULE_MS = RESEND_AFTER_MS + 20;
10
+ const MAX_IN_FLIGHT = 3;
11
+ const FAIL_COOLDOWN_MS = 1200;
12
+ const FAIL_COOLDOWN_MAX_MS = 30000;
13
+ const POLL_MS = 40;
14
+ const ENSURE_MS = 300;
15
+ const DETECT_RETRY_MS = 2000;
16
+
17
+ const BAN_GLYPH_SVG =
18
+ "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'>" +
19
+ "<g fill='none' stroke='white' stroke-width='2.4'>" +
20
+ "<circle cx='10' cy='10' r='7'/><path d='M5.1 14.9 14.9 5.1'/></g></svg>";
21
+ const BAN_GLYPH = `data:image/svg+xml,${encodeURIComponent(BAN_GLYPH_SVG)}`;
22
+
23
+ const CSS = `
24
+ .swap-button-component.${BTN_CLASS} {
25
+ position: absolute !important;
26
+ top: -2px !important;
27
+ right: -2px !important;
28
+ left: auto !important;
29
+ bottom: auto !important;
30
+ z-index: 2 !important;
31
+ }
32
+ .swap-button-component.${BTN_CLASS} .icon {
33
+ -webkit-mask-image: url("${BAN_GLYPH}") !important;
34
+ mask-image: url("${BAN_GLYPH}") !important;
35
+ -webkit-mask-size: 70% 70% !important;
36
+ mask-size: 70% 70% !important;
37
+ }
38
+ .swap-button-component.${BTN_CLASS}.on .frame-color {
39
+ background-color: rgb(200, 170, 110) !important;
40
+ }
41
+ .swap-button-component.${BTN_CLASS}.on .icon {
42
+ background-color: rgb(1, 10, 19) !important;
43
+ }
44
+ .swap-button-component.${BTN_CLASS}.on .frame-color:hover {
45
+ background-color: rgb(240, 230, 210) !important;
46
+ }
47
+ .swap-button-component.${BTN_CLASS}.unresolved {
48
+ opacity: 0.35 !important;
49
+ cursor: default !important;
50
+ }
51
+ `;
52
+
53
+ let api = null;
54
+ let removeStyle = null;
55
+ let unsubscribe = null;
56
+ let pollTimer = null;
57
+ let ensureTimer = null;
58
+ let detectTimer = null;
59
+ let refreshTimer = null;
60
+ let pumpTimer = null;
61
+ let observer = null;
62
+ let pollInFlight = false;
63
+ let uiScheduled = false;
64
+
65
+ const enabled = new Map();
66
+ const playerNames = new Map();
67
+ const cellToPlayer = new Map();
68
+ const buttons = new Map();
69
+ const failedUntil = new Map();
70
+ const failCount = new Map();
71
+ let rows = [];
72
+ let uiVisible = false;
73
+ let goneAway = true;
74
+ let lastSession = null;
75
+
76
+ let myActionId = null;
77
+ let target = undefined;
78
+ let sent = 0;
79
+ let sentAt = -Infinity;
80
+ let inFlight = 0;
81
+
82
+ function logError(scope, error) {
83
+ console.error(`[Ban Blocker] ${scope}`, error);
84
+ }
85
+
86
+ function norm(v) {
87
+ return String(v ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
88
+ }
89
+
90
+ function champOf(a) {
91
+ return a && a.championId > 0 ? a.championId : 0;
92
+ }
93
+
94
+ function isPicked(members, champ) {
95
+ if (!champ) return false;
96
+ return (members ?? []).some((p) => p.championId === champ && p.completed);
97
+ }
98
+
99
+ function isBlocked(id) {
100
+ return enabled.get(id) === true;
101
+ }
102
+
103
+ function playerId(p) {
104
+ if (typeof p === "number") return p;
105
+ return p?.summonerId ?? p?.puuid ?? null;
106
+ }
107
+
108
+ function banPending(s) {
109
+ const bans = (s?.actions?.flat?.() ?? []).filter((a) => a.type === "ban");
110
+ if (!bans.length) return true;
111
+ return bans.some((a) => !a.completed);
112
+ }
113
+
114
+ function pump() {
115
+ if (target === undefined || myActionId == null) return;
116
+ if (inFlight >= MAX_IN_FLIGHT) return;
117
+ if (sent === target && performance.now() - sentAt < RESEND_AFTER_MS) return;
118
+ fire(myActionId, target);
119
+ }
120
+
121
+ function fire(id, champ) {
122
+ const send = api?.lcu?.send;
123
+ if (!send || id == null) return;
124
+ sent = champ;
125
+ sentAt = performance.now();
126
+ inFlight++;
127
+ clearTimeout(pumpTimer);
128
+ pumpTimer = setTimeout(pump, RESCHEDULE_MS);
129
+ send("PATCH", `${SESSION_URI}/actions/${id}`, { championId: champ })
130
+ .then(() => {
131
+ failCount.delete(champ);
132
+ })
133
+ .catch(() => {
134
+ const n = failCount.get(champ) ?? 0;
135
+ failCount.set(champ, n + 1);
136
+ failedUntil.set(champ, performance.now() + Math.min(FAIL_COOLDOWN_MS * 2 ** n, FAIL_COOLDOWN_MAX_MS));
137
+ if (target === champ) target = undefined;
138
+ if (sent === champ) sent = 0;
139
+ })
140
+ .finally(() => {
141
+ inFlight--;
142
+ pump();
143
+ });
144
+ }
145
+
146
+ function decide(s) {
147
+ const m = s?.myTeam ?? [];
148
+ const actions = s?.actions?.flat?.() ?? [];
149
+ const me = s?.localPlayerCellId;
150
+
151
+ cellToPlayer.clear();
152
+ for (const p of m) cellToPlayer.set(p.cellId, playerId(p));
153
+
154
+ let myPick = null;
155
+ for (const a of actions) {
156
+ if (a.type === "pick" && a.actorCellId === me && !a.completed) {
157
+ myPick = a;
158
+ break;
159
+ }
160
+ }
161
+ myActionId = myPick ? myPick.id : null;
162
+
163
+ const blockedCells = new Set(m.filter((p) => isBlocked(playerId(p))).map((p) => p.cellId));
164
+
165
+ let threat = 0;
166
+ for (const a of actions) {
167
+ if (a.type !== "ban" || a.completed || a.actorCellId === me) continue;
168
+ if (!blockedCells.has(a.actorCellId)) continue;
169
+ const champ = champOf(a);
170
+ if (!champ || isPicked(m, champ)) continue;
171
+ if ((failedUntil.get(champ) ?? 0) > performance.now()) continue;
172
+ threat = champ;
173
+ }
174
+
175
+ if (threat && champOf(myPick) === threat) {
176
+ target = undefined;
177
+ sent = threat;
178
+ } else if (threat) {
179
+ target = threat;
180
+ } else {
181
+ target = undefined;
182
+ }
183
+ }
184
+
185
+ function onSessionGone() {
186
+ sent = 0;
187
+ target = undefined;
188
+ clearTimeout(pumpTimer);
189
+ failedUntil.clear();
190
+ failCount.clear();
191
+ cellToPlayer.clear();
192
+ lastSession = null;
193
+ goneAway = true;
194
+ uiVisible = false;
195
+ removeButtons();
196
+ }
197
+
198
+ function onSession(raw) {
199
+ try {
200
+ const msg = raw ?? {};
201
+ if (msg.eventType === "Delete") {
202
+ onSessionGone();
203
+ return;
204
+ }
205
+ let s = msg.data ?? msg;
206
+ if (s && !s.myTeam && typeof s.phase === "string") {
207
+ pump();
208
+ return;
209
+ }
210
+ if (!s?.myTeam) return;
211
+ goneAway = false;
212
+ lastSession = s;
213
+
214
+ decide(s);
215
+ pump();
216
+ scheduleUi();
217
+ } catch (err) {
218
+ logError("session handling failed.", err);
219
+ }
220
+ }
221
+
222
+ function detectRows() {
223
+ try {
224
+ const nodes = document.querySelectorAll("div.summoner-array.your-party div.summoner-object");
225
+ const list = nodes.length ? nodes : document.querySelectorAll("div.summoner-object.left");
226
+ const found = [];
227
+ for (const e of list) {
228
+ const slot = /slot-id-(\d+)/.exec(String(e.className));
229
+ const nameNode = e.querySelector(".summoner-name");
230
+ found.push({
231
+ el: e,
232
+ slotId: slot ? Number(slot[1]) : null,
233
+ name: (nameNode?.textContent ?? "").trim(),
234
+ circle: e.querySelector(".champion-icon-container"),
235
+ });
236
+ }
237
+ if (!found.length) return [];
238
+ return found.map((r, i) => ({
239
+ ...r,
240
+ index: i,
241
+ player: rows[i]?.player ?? null,
242
+ }));
243
+ } catch (err) {
244
+ logError("row detection failed.", err);
245
+ return [];
246
+ }
247
+ }
248
+
249
+ function getRows() {
250
+ const found = detectRows();
251
+ if (found.length) rows = found;
252
+ else if (!document.querySelector("div.summoner-object")) rows = [];
253
+ clearTimeout(detectTimer);
254
+ detectTimer = setTimeout(getRows, DETECT_RETRY_MS);
255
+ return rows;
256
+ }
257
+
258
+ function ensureName(cell) {
259
+ const id = playerId(cell);
260
+ if (id == null || playerNames.has(id)) return;
261
+ playerNames.set(id, []);
262
+ 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}`;
269
+ get(path)
270
+ .then((data) => {
271
+ const names = [norm(data?.displayName), norm(data?.gameName)].filter(Boolean);
272
+ if (names.length) playerNames.set(id, names);
273
+ else playerNames.delete(id);
274
+ })
275
+ .catch(() => playerNames.delete(id));
276
+ }
277
+
278
+ function nameMatches(rowName, id) {
279
+ const rn = norm(rowName);
280
+ if (!rn) return false;
281
+ return (playerNames.get(id) ?? []).some(
282
+ (n) => n === rn || (n.length >= 3 && rn.length >= 3 && (n.startsWith(rn) || rn.startsWith(n))),
283
+ );
284
+ }
285
+
286
+ function bindRowsToTeam(session) {
287
+ const cells = session?.myTeam ?? [];
288
+ const list = getRows();
289
+ if (!list.length || !cells.length) return;
290
+ for (const c of cells) ensureName(c);
291
+
292
+ for (const r of list) {
293
+ const cell =
294
+ (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;
297
+ }
298
+ }
299
+
300
+ function stopEvent(e) {
301
+ if (!e) return;
302
+ e.stopPropagation();
303
+ e.preventDefault();
304
+ }
305
+
306
+ 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;
314
+ }
315
+ if (id == null) return;
316
+ const myCell = lastSession?.localPlayerCellId;
317
+ if (myCell != null && btn.dataset.cell && Number(btn.dataset.cell) === myCell) return;
318
+ enabled.set(id, !isBlocked(id));
319
+ renderButtons();
320
+ if (lastSession) {
321
+ decide(lastSession);
322
+ pump();
323
+ }
324
+ queueRefresh();
325
+ }
326
+
327
+ function buildBadge(slot) {
328
+ const btn = document.createElement("div");
329
+ btn.className = `swap-button-component ${BTN_CLASS}`;
330
+ btn.dataset.slot = String(slot);
331
+ btn.dataset.bb = "1";
332
+ btn.innerHTML =
333
+ '<div class="swap-button--btn uikit-framed-icon">' +
334
+ '<div class="outer-mask interactive">' +
335
+ '<div class="frame-color" style="padding: 2px;">' +
336
+ '<div class="icon"></div>' +
337
+ "</div></div></div>";
338
+ for (const type of ["mouseenter", "mouseleave", "mousedown", "mouseup", "click"]) {
339
+ btn.addEventListener(type, (e) => {
340
+ stopEvent(e);
341
+ if (type === "click") toggle(btn);
342
+ });
343
+ }
344
+ return btn;
345
+ }
346
+
347
+ function isSelfRow(r) {
348
+ if (r.el.classList.contains("is-self")) return true;
349
+ return lastSession?.localPlayerCellId != null && r.slotId === lastSession.localPlayerCellId;
350
+ }
351
+
352
+ function nativeSwapIn(circle) {
353
+ return !!circle.querySelector(".swap-button-component:not([data-bb])");
354
+ }
355
+
356
+ function ensureButtons() {
357
+ const list = getRows();
358
+ for (const r of list) {
359
+ if (!r.circle || !r.circle.isConnected) continue;
360
+ let btn = buttons.get(r.index);
361
+ if (isSelfRow(r) || !nativeSwapIn(r.circle)) {
362
+ if (btn) {
363
+ btn.remove();
364
+ buttons.delete(r.index);
365
+ }
366
+ continue;
367
+ }
368
+ if (!btn || !btn.isConnected || btn.parentElement !== r.circle) {
369
+ if (btn) btn.remove();
370
+ for (const orphan of r.circle.querySelectorAll(`:scope > .${BTN_CLASS}`)) orphan.remove();
371
+ btn = buildBadge(r.index);
372
+ r.circle.appendChild(btn);
373
+ buttons.set(r.index, btn);
374
+ }
375
+ btn.dataset.slot = String(r.index);
376
+ btn.dataset.cell = r.slotId == null ? "" : String(r.slotId);
377
+ }
378
+ for (const [slot, btn] of [...buttons]) {
379
+ if (!list[slot] || !btn.isConnected) {
380
+ btn.remove();
381
+ buttons.delete(slot);
382
+ }
383
+ }
384
+ }
385
+
386
+ function renderButtons() {
387
+ const hasSession = !!lastSession?.myTeam;
388
+ for (const [, btn] of buttons) {
389
+ const id = btn.dataset.cell ? (cellToPlayer.get(Number(btn.dataset.cell)) ?? null) : null;
390
+ const on = id != null && isBlocked(id);
391
+ btn.classList.toggle("on", on);
392
+ btn.classList.toggle("unresolved", id == null && hasSession);
393
+ const title = on ? "Blocked — click to allow their bans" : "Block this teammate's bans";
394
+ if (btn.title !== title) btn.title = title;
395
+ }
396
+ }
397
+
398
+ function removeButtons() {
399
+ for (const btn of buttons.values()) btn.remove();
400
+ buttons.clear();
401
+ for (const el of document.querySelectorAll(`.${BTN_CLASS}[data-bb]`)) el.remove();
402
+ }
403
+
404
+ function queueRefresh() {
405
+ clearTimeout(refreshTimer);
406
+ refreshTimer = setTimeout(poll, 80);
407
+ }
408
+
409
+ function scheduleUi() {
410
+ if (uiScheduled) return;
411
+ uiScheduled = true;
412
+ queueMicrotask(() => {
413
+ uiScheduled = false;
414
+ try {
415
+ update();
416
+ } catch (err) {
417
+ logError("ui update failed.", err);
418
+ }
419
+ });
420
+ }
421
+
422
+ function update() {
423
+ if (goneAway) return;
424
+ const s = lastSession;
425
+ if (s?.myTeam) bindRowsToTeam(s);
426
+ const visible = !s?.myTeam ? true : banPending(s);
427
+ if (visible) {
428
+ uiVisible = true;
429
+ ensureButtons();
430
+ renderButtons();
431
+ } else if (uiVisible) {
432
+ uiVisible = false;
433
+ removeButtons();
434
+ }
435
+ }
436
+
437
+ async function poll() {
438
+ const get = api?.lcu?.get;
439
+ if (!get || pollInFlight) return;
440
+ if (goneAway && !document.querySelector(".summoner-object")) return;
441
+ pollInFlight = true;
442
+ try {
443
+ onSession(await get(SESSION_URI));
444
+ } catch {
445
+ onSessionGone();
446
+ } finally {
447
+ pollInFlight = false;
448
+ }
449
+ }
450
+
451
+ function onEnable(utils) {
452
+ api = utils;
453
+ try {
454
+ removeStyle = api.addStyle(STYLE_ID, CSS);
455
+ } catch (err) {
456
+ logError("style setup failed.", err);
457
+ }
458
+
459
+ pollTimer = setInterval(poll, POLL_MS);
460
+ ensureTimer = setInterval(() => {
461
+ if (uiVisible) update();
462
+ }, ENSURE_MS);
463
+
464
+ try {
465
+ observer = new MutationObserver(() => {
466
+ try {
467
+ update();
468
+ } catch (err) {
469
+ logError("ui update failed.", err);
470
+ }
471
+ });
472
+ observer.observe(document.body, { childList: true, subtree: true });
473
+ } catch (err) {
474
+ logError("observer setup failed.", err);
475
+ }
476
+
477
+ const onPush = (msg) => onSession(msg);
478
+ let offs = [];
479
+ for (const uri of [SESSION_URI, `${SESSION_URI}/timer`]) {
480
+ try {
481
+ const off = api.subscribe(uri, onPush);
482
+ if (off) offs.push(off);
483
+ } catch (err) {
484
+ logError(`subscribe failed for ${uri}.`, err);
485
+ }
486
+ }
487
+ unsubscribe = () => {
488
+ for (const off of offs) {
489
+ try {
490
+ off();
491
+ } catch (err) {
492
+ logError("unsubscribe failed.", err);
493
+ }
494
+ }
495
+ };
496
+
497
+ poll();
498
+ }
499
+
500
+ function onDisable() {
501
+ unsubscribe?.();
502
+ unsubscribe = null;
503
+ clearInterval(pollTimer);
504
+ clearInterval(ensureTimer);
505
+ clearTimeout(refreshTimer);
506
+ clearTimeout(detectTimer);
507
+ clearTimeout(pumpTimer);
508
+ pollTimer = ensureTimer = refreshTimer = detectTimer = pumpTimer = null;
509
+ observer?.disconnect();
510
+ observer = null;
511
+
512
+ uiVisible = false;
513
+ goneAway = true;
514
+ target = undefined;
515
+ sent = 0;
516
+ failedUntil.clear();
517
+ failCount.clear();
518
+ cellToPlayer.clear();
519
+ enabled.clear();
520
+ playerNames.clear();
521
+ lastSession = null;
522
+ rows = [];
523
+ removeButtons();
524
+
525
+ removeStyle?.();
526
+ removeStyle = null;
527
+ api = null;
528
+ }
529
+
530
+ export function init() {
531
+ const registerWith = (utils) => {
532
+ utils.registerPlugin({
533
+ id: PLUGIN_ID,
534
+ name: "Ban Blocker",
535
+ description: "During bans, blocking a teammate instantly claims the champion they hover to ban.",
536
+ version,
537
+ defaultEnabled: true,
538
+ onEnable,
539
+ onDisable,
540
+ });
541
+ };
542
+
543
+ if (window.PerfectionistUtils) {
544
+ registerWith(window.PerfectionistUtils);
545
+ return;
546
+ }
547
+ addEventListener(
548
+ "perfectionist:utils-ready",
549
+ (e) => registerWith(e.detail ?? window.PerfectionistUtils),
550
+ { once: true },
551
+ );
552
+ }