perfectionist-plugins 1.0.0 → 1.0.2

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/ban-blocker.js CHANGED
@@ -1,617 +1,552 @@
1
- /*
2
- * ban-blocker.js
3
- * Ban Blocker (Perfectionism, Pengu Loader).
4
- *
5
- * Every teammate except you gets a small BLOCK button on their champion circle in champ select.
6
- * While a teammate is blocked, the plugin instantly prepicks the champion they are about to ban
7
- * on your own pick action: the client won't let a team ban a champion one of its players is
8
- * hovering, so their ban never lands. The claim hover is held for the rest of the ban phase.
9
- *
10
- * Requires perfectionist-utils.js. Toggle it in the "Perfectionism" settings tab.
11
- */
12
-
13
- export const version = "3.4.0";
14
-
15
- const PLUGIN_ID = "ban-blocker";
16
- const STYLE_ID = "perfectionist-ban-blocker-style";
17
- const BTN_CLASS = "bb-toggle";
18
- const SESSION_URI = "/lol-champ-select/v1/session";
19
-
20
- // ---- engine constants ----
21
-
22
- const RESEND_AFTER_MS = 150; // re-assert cadence against their lock attempts
23
- const RESCHEDULE_MS = RESEND_AFTER_MS + 20; // re-arm even if a request never settles
24
- const MAX_IN_FLIGHT = 3;
25
- const FAIL_COOLDOWN_MS = 1200;
26
- const FAIL_COOLDOWN_MAX_MS = 30000;
27
- const POLL_MS = 40; // fast hover detection; pushes keep it working when timers get throttled
28
- const ENSURE_MS = 300; // badge re-attach backstop
29
- const DETECT_RETRY_MS = 2000;
30
-
31
- // Ban symbol (ring with a slash) used as the icon mask; vector so it stays crisp when scaled down.
32
- const BAN_GLYPH_SVG =
33
- "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'>" +
34
- "<g fill='none' stroke='white' stroke-width='2.4'>" +
35
- "<circle cx='10' cy='10' r='7'/><path d='M5.1 14.9 14.9 5.1'/></g></svg>";
36
- const BAN_GLYPH = `data:image/svg+xml,${encodeURIComponent(BAN_GLYPH_SVG)}`;
37
-
38
- /* The shared swap-button classes draw the ring, centre and hover glow; position, glyph and the "on" state are ours. */
39
- const CSS = `
40
- .swap-button-component.${BTN_CLASS} {
41
- position: absolute !important;
42
- top: -2px !important;
43
- right: -2px !important;
44
- left: auto !important;
45
- bottom: auto !important;
46
- z-index: 2 !important;
47
- }
48
- .swap-button-component.${BTN_CLASS} .icon {
49
- -webkit-mask-image: url("${BAN_GLYPH}") !important;
50
- mask-image: url("${BAN_GLYPH}") !important;
51
- -webkit-mask-size: 70% 70% !important;
52
- mask-size: 70% 70% !important;
53
- }
54
- .swap-button-component.${BTN_CLASS}.on .frame-color {
55
- background-color: rgb(200, 170, 110) !important;
56
- }
57
- .swap-button-component.${BTN_CLASS}.on .icon {
58
- background-color: rgb(1, 10, 19) !important;
59
- }
60
- .swap-button-component.${BTN_CLASS}.on .frame-color:hover {
61
- background-color: rgb(240, 230, 210) !important;
62
- }
63
- .swap-button-component.${BTN_CLASS}.unresolved {
64
- opacity: 0.35 !important;
65
- cursor: default !important;
66
- }
67
- `;
68
-
69
- // ---- state ----
70
-
71
- let api = null;
72
- let removeStyle = null;
73
- let unsubscribe = null;
74
- let pollTimer = null;
75
- let ensureTimer = null;
76
- let detectTimer = null;
77
- let refreshTimer = null;
78
- let pumpTimer = null;
79
- let observer = null;
80
- let pollInFlight = false;
81
- let uiScheduled = false;
82
-
83
- const enabled = new Map(); // player id -> blocked
84
- const playerNames = new Map(); // player id -> normalized names (empty array = lookup pending)
85
- const cellToPlayer = new Map(); // champ select cell id -> player id (rebuilt from every session)
86
- const buttons = new Map(); // row slot -> badge element
87
- const failedUntil = new Map(); // championId -> retry time after a rejected hover
88
- const failCount = new Map(); // championId -> consecutive rejections
89
- let rows = [];
90
- let uiVisible = false;
91
- let goneAway = true; // champ select is over (or never started): never attach badges
92
- let lastSession = null;
93
-
94
- // ---- engine state ----
95
-
96
- let myActionId = null; // my pick action id
97
- let target = undefined; // the champion my pick action must hover (undefined = nothing to do)
98
- let sent = 0; // championId last sent to my pick action
99
- let sentAt = -Infinity;
100
- let inFlight = 0;
101
-
102
- function logError(scope, error) {
103
- console.error(`[Ban Blocker] ${scope}`, error);
104
- }
105
-
106
- // ---- session helpers ----
107
-
108
- function norm(v) {
109
- return String(v ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
110
- }
111
-
112
- function champOf(a) {
113
- return a && a.championId > 0 ? a.championId : 0;
114
- }
115
-
116
- function isPicked(members, champ) {
117
- if (!champ) return false;
118
- return (members ?? []).some((p) => p.championId === champ && p.completed);
119
- }
120
-
121
- function isBlocked(id) {
122
- return enabled.get(id) === true;
123
- }
124
-
125
- function playerId(p) {
126
- if (typeof p === "number") return p;
127
- return p?.summonerId ?? p?.puuid ?? null;
128
- }
129
-
130
- /** The ban phase is live while any ban action is still open — or before any exists yet. */
131
- function banPending(s) {
132
- const bans = (s?.actions?.flat?.() ?? []).filter((a) => a.type === "ban");
133
- if (!bans.length) return true;
134
- return bans.some((a) => !a.completed);
135
- }
136
-
137
- // ---- the claim engine ----
138
-
139
- /** Re-asserts my pick hover: immediately on target change, otherwise at most every RESEND_AFTER_MS. */
140
- function pump() {
141
- if (target === undefined || myActionId == null) return;
142
- if (inFlight >= MAX_IN_FLIGHT) return;
143
- if (sent === target && performance.now() - sentAt < RESEND_AFTER_MS) return;
144
- fire(myActionId, target);
145
- }
146
-
147
- function fire(id, champ) {
148
- const send = api?.lcu?.send;
149
- if (!send || id == null) return;
150
- sent = champ;
151
- sentAt = performance.now();
152
- inFlight++;
153
- clearTimeout(pumpTimer);
154
- pumpTimer = setTimeout(pump, RESCHEDULE_MS);
155
- send("PATCH", `${SESSION_URI}/actions/${id}`, { championId: champ })
156
- .then(() => {
157
- failCount.delete(champ);
158
- })
159
- .catch(() => {
160
- // the server rejected the hover (banned/locked champion): back off exponentially
161
- const n = failCount.get(champ) ?? 0;
162
- failCount.set(champ, n + 1);
163
- failedUntil.set(champ, performance.now() + Math.min(FAIL_COOLDOWN_MS * 2 ** n, FAIL_COOLDOWN_MAX_MS));
164
- if (target === champ) target = undefined;
165
- if (sent === champ) sent = 0;
166
- })
167
- .finally(() => {
168
- inFlight--;
169
- pump();
170
- });
171
- }
172
-
173
- /** Pure session work, no DOM: what a blocked teammate is about to ban becomes my pick hover. Prepicks are ignored. */
174
- function decide(s) {
175
- const m = s?.myTeam ?? [];
176
- const actions = s?.actions?.flat?.() ?? [];
177
- const me = s?.localPlayerCellId;
178
-
179
- cellToPlayer.clear();
180
- for (const p of m) cellToPlayer.set(p.cellId, playerId(p));
181
-
182
- let myPick = null;
183
- for (const a of actions) {
184
- if (a.type === "pick" && a.actorCellId === me && !a.completed) {
185
- myPick = a;
186
- break;
187
- }
188
- }
189
- myActionId = myPick ? myPick.id : null;
190
-
191
- const blockedCells = new Set(m.filter((p) => isBlocked(playerId(p))).map((p) => p.cellId));
192
-
193
- // only a ban action is a threat: that hover is what they are about to ban
194
- let threat = 0;
195
- for (const a of actions) {
196
- if (a.type !== "ban" || a.completed || a.actorCellId === me) continue;
197
- if (!blockedCells.has(a.actorCellId)) continue;
198
- const champ = champOf(a);
199
- if (!champ || isPicked(m, champ)) continue;
200
- if ((failedUntil.get(champ) ?? 0) > performance.now()) continue;
201
- threat = champ;
202
- }
203
-
204
- // The claim hover is held, never withdrawn: while my pick hovers the champion the server
205
- // refuses every ban of it. Withdrawing it, even briefly, opens a window for their next attempt.
206
- if (threat && champOf(myPick) === threat) {
207
- target = undefined; // my pick already holds it: unbannable
208
- sent = threat;
209
- } else if (threat) {
210
- target = threat;
211
- } else {
212
- target = undefined;
213
- }
214
- }
215
-
216
- function onSessionGone() {
217
- sent = 0;
218
- target = undefined;
219
- clearTimeout(pumpTimer);
220
- failedUntil.clear();
221
- failCount.clear();
222
- cellToPlayer.clear();
223
- lastSession = null;
224
- goneAway = true;
225
- uiVisible = false;
226
- removeButtons();
227
- }
228
-
229
- function onSession(raw) {
230
- try {
231
- const msg = raw ?? {};
232
- if (msg.eventType === "Delete") {
233
- onSessionGone();
234
- return;
235
- }
236
- let s = msg.data ?? msg;
237
- // timer pushes carry only the timer: a free pump heartbeat, nothing else
238
- if (s && !s.myTeam && typeof s.phase === "string") {
239
- pump();
240
- return;
241
- }
242
- // partial pushes (a session shell before myTeam fills in) must never wipe our state
243
- if (!s?.myTeam) return;
244
- goneAway = false;
245
- lastSession = s;
246
-
247
- // hot path: session scan and claim, synchronously
248
- decide(s);
249
- pump();
250
- scheduleUi();
251
- } catch (err) {
252
- logError("session handling failed.", err);
253
- }
254
- }
255
-
256
- // ---- row detection (ally roster) ----
257
-
258
- /**
259
- * The ally roster rows are div.summoner-object elements; their slot-id-N class is the champ
260
- * select cell id. The BLOCK button lives in the row's .champion-icon-container, next to the
261
- * swap button, so it rides the row's own animations.
262
- */
263
- function detectRows() {
264
- try {
265
- const nodes = document.querySelectorAll("div.summoner-array.your-party div.summoner-object");
266
- const list = nodes.length ? nodes : document.querySelectorAll("div.summoner-object.left");
267
- const found = [];
268
- for (const e of list) {
269
- const slot = /slot-id-(\d+)/.exec(String(e.className));
270
- const nameNode = e.querySelector(".summoner-name");
271
- found.push({
272
- el: e,
273
- slotId: slot ? Number(slot[1]) : null,
274
- name: (nameNode?.textContent ?? "").trim(),
275
- circle: e.querySelector(".champion-icon-container"),
276
- });
277
- }
278
- if (!found.length) return [];
279
- // document order is visual order; no rect reads here, they would force layout
280
- return found.map((r, i) => ({
281
- ...r,
282
- index: i,
283
- player: rows[i]?.player ?? null, // keep the binding until the session re-binds it
284
- }));
285
- } catch (err) {
286
- logError("row detection failed.", err);
287
- return [];
288
- }
289
- }
290
-
291
- function getRows() {
292
- const found = detectRows();
293
- if (found.length) rows = found;
294
- else if (!document.querySelector("div.summoner-object")) rows = []; // champ select is gone
295
- // otherwise the roster is briefly detached mid re-render: keep the last good layout
296
- clearTimeout(detectTimer);
297
- detectTimer = setTimeout(getRows, DETECT_RETRY_MS);
298
- return rows;
299
- }
300
-
301
- // ---- player names (so identity follows players through position swaps) ----
302
-
303
- function ensureName(cell) {
304
- const id = playerId(cell);
305
- if (id == null || playerNames.has(id)) return;
306
- playerNames.set(id, []);
307
- const get = api?.lcu?.get;
308
- if (!get) {
309
- playerNames.delete(id);
310
- return;
311
- }
312
- const byPuuid = typeof id === "string" && id.length > 20;
313
- const path = byPuuid ? `/lol-summoner/v2/summoners/puuid/${id}` : `/lol-summoner/v1/summoners/${id}`;
314
- get(path)
315
- .then((data) => {
316
- const names = [norm(data?.displayName), norm(data?.gameName)].filter(Boolean);
317
- if (names.length) playerNames.set(id, names);
318
- else playerNames.delete(id);
319
- })
320
- .catch(() => playerNames.delete(id));
321
- }
322
-
323
- function nameMatches(rowName, id) {
324
- const rn = norm(rowName);
325
- if (!rn) return false;
326
- return (playerNames.get(id) ?? []).some(
327
- (n) => n === rn || (n.length >= 3 && rn.length >= 3 && (n.startsWith(rn) || rn.startsWith(n))),
328
- );
329
- }
330
-
331
- /** Maps roster rows onto myTeam cells via the slot-id-N class; name matching is the fallback without slot ids. */
332
- function bindRowsToTeam(session) {
333
- const cells = session?.myTeam ?? [];
334
- const list = getRows();
335
- if (!list.length || !cells.length) return;
336
- for (const c of cells) ensureName(c);
337
-
338
- for (const r of list) {
339
- const cell =
340
- (r.slotId != null ? cells.find((c) => c.cellId === r.slotId) : null) ??
341
- cells.find((c) => nameMatches(r.name, playerId(c)));
342
- r.player = cell ? playerId(cell) : null;
343
- }
344
- }
345
-
346
- // ---- badges ----
347
-
348
- function stopEvent(e) {
349
- if (!e) return;
350
- e.stopPropagation();
351
- e.preventDefault();
352
- }
353
-
354
- function toggle(btn) {
355
- // the badge carries its champ select cell id, which maps straight to the player
356
- let id = btn.dataset.cell ? (cellToPlayer.get(Number(btn.dataset.cell)) ?? null) : null;
357
- if (id == null) {
358
- // fall back to the row binding (clients without slot ids)
359
- if (lastSession) bindRowsToTeam(lastSession);
360
- const list = getRows();
361
- const rowEl = btn.closest(".summoner-object");
362
- const r = list.find((x) => x.el === rowEl) ?? list[Number(btn.dataset.slot)];
363
- id = r?.player ?? null;
364
- }
365
- if (id == null) return;
366
- // never block myself
367
- const myCell = lastSession?.localPlayerCellId;
368
- if (myCell != null && btn.dataset.cell && Number(btn.dataset.cell) === myCell) return;
369
- enabled.set(id, !isBlocked(id));
370
- renderButtons();
371
- // arm (or release) the claim immediately, without waiting for the next push
372
- if (lastSession) {
373
- decide(lastSession);
374
- pump();
375
- }
376
- queueRefresh();
377
- }
378
-
379
- /** A copy of champ select's own swap button, so the client's stylesheet draws it exactly like the original. */
380
- function buildBadge(slot) {
381
- const btn = document.createElement("div");
382
- btn.className = `swap-button-component ${BTN_CLASS}`;
383
- btn.dataset.slot = String(slot);
384
- btn.dataset.bb = "1"; // ownership marker: teardown only ever sweeps our own clones
385
- btn.innerHTML =
386
- '<div class="swap-button--btn uikit-framed-icon">' +
387
- '<div class="outer-mask interactive">' +
388
- '<div class="frame-color" style="padding: 2px;">' +
389
- '<div class="icon"></div>' +
390
- "</div></div></div>";
391
- for (const type of ["mouseenter", "mouseleave", "mousedown", "mouseup", "click"]) {
392
- btn.addEventListener(type, (e) => {
393
- stopEvent(e);
394
- if (type === "click") toggle(btn);
395
- });
396
- }
397
- return btn;
398
- }
399
-
400
- function isSelfRow(r) {
401
- if (r.el.classList.contains("is-self")) return true;
402
- return lastSession?.localPlayerCellId != null && r.slotId === lastSession.localPlayerCellId;
403
- }
404
-
405
- /** champ select's own swap button on this circle (ours carries data-bb and is not one) */
406
- function nativeSwapIn(circle) {
407
- return !!circle.querySelector(".swap-button-component:not([data-bb])");
408
- }
409
-
410
- function ensureButtons() {
411
- const list = getRows();
412
- for (const r of list) {
413
- if (!r.circle || !r.circle.isConnected) continue;
414
- let btn = buttons.get(r.index);
415
- // The badge rides the client's own swap button and never sits on my own row.
416
- if (isSelfRow(r) || !nativeSwapIn(r.circle)) {
417
- if (btn) {
418
- btn.remove();
419
- buttons.delete(r.index);
420
- }
421
- continue;
422
- }
423
- if (!btn || !btn.isConnected || btn.parentElement !== r.circle) {
424
- if (btn) btn.remove();
425
- // a stale clone may survive inside the circle after an Ember re-render
426
- for (const orphan of r.circle.querySelectorAll(`:scope > .${BTN_CLASS}`)) orphan.remove();
427
- btn = buildBadge(r.index);
428
- r.circle.appendChild(btn);
429
- buttons.set(r.index, btn);
430
- }
431
- btn.dataset.slot = String(r.index);
432
- btn.dataset.cell = r.slotId == null ? "" : String(r.slotId);
433
- }
434
- for (const [slot, btn] of [...buttons]) {
435
- if (!list[slot] || !btn.isConnected) {
436
- btn.remove();
437
- buttons.delete(slot);
438
- }
439
- }
440
- }
441
-
442
- function renderButtons() {
443
- const hasSession = !!lastSession?.myTeam;
444
- for (const [, btn] of buttons) {
445
- const id = btn.dataset.cell ? (cellToPlayer.get(Number(btn.dataset.cell)) ?? null) : null;
446
- const on = id != null && isBlocked(id);
447
- btn.classList.toggle("on", on);
448
- btn.classList.toggle("unresolved", id == null && hasSession);
449
- const title = on ? "Blocked — click to allow their bans" : "Block this teammate's bans";
450
- if (btn.title !== title) btn.title = title;
451
- }
452
- }
453
-
454
- function removeButtons() {
455
- for (const btn of buttons.values()) btn.remove();
456
- buttons.clear();
457
- for (const el of document.querySelectorAll(`.${BTN_CLASS}[data-bb]`)) el.remove();
458
- }
459
-
460
- function queueRefresh() {
461
- clearTimeout(refreshTimer);
462
- refreshTimer = setTimeout(poll, 80);
463
- }
464
-
465
- /** UI work only, off the claim hot path. Runs as a microtask so badges land in the same paint as the rows. */
466
- function scheduleUi() {
467
- if (uiScheduled) return;
468
- uiScheduled = true;
469
- queueMicrotask(() => {
470
- uiScheduled = false;
471
- try {
472
- update();
473
- } catch (err) {
474
- logError("ui update failed.", err);
475
- }
476
- });
477
- }
478
-
479
- function update() {
480
- if (goneAway) return;
481
- const s = lastSession;
482
- if (s?.myTeam) bindRowsToTeam(s);
483
- // badges follow the client's own swap button on each circle; the session decides their on/off state
484
- const visible = !s?.myTeam ? true : banPending(s);
485
- if (visible) {
486
- uiVisible = true;
487
- ensureButtons();
488
- renderButtons();
489
- } else if (uiVisible) {
490
- uiVisible = false;
491
- removeButtons();
492
- }
493
- }
494
-
495
- async function poll() {
496
- const get = api?.lcu?.get;
497
- if (!get || pollInFlight) return;
498
- // outside champ select there is no roster and nothing to poll for
499
- if (goneAway && !document.querySelector(".summoner-object")) return;
500
- pollInFlight = true;
501
- try {
502
- onSession(await get(SESSION_URI));
503
- } catch {
504
- // outside champion select the session endpoint errors: hide immediately
505
- onSessionGone();
506
- } finally {
507
- pollInFlight = false;
508
- }
509
- }
510
-
511
- // ---- lifecycle ----
512
-
513
- function onEnable(utils) {
514
- api = utils;
515
- // one failing piece must not take the rest down
516
- try {
517
- removeStyle = api.addStyle(STYLE_ID, CSS);
518
- } catch (err) {
519
- logError("style setup failed.", err);
520
- }
521
-
522
- pollTimer = setInterval(poll, POLL_MS);
523
- ensureTimer = setInterval(() => {
524
- if (uiVisible) update();
525
- }, ENSURE_MS);
526
-
527
- try {
528
- // the observer callback is a microtask, so badges land in the same paint as the rows
529
- observer = new MutationObserver(() => {
530
- try {
531
- update();
532
- } catch (err) {
533
- logError("ui update failed.", err);
534
- }
535
- });
536
- observer.observe(document.body, { childList: true, subtree: true });
537
- } catch (err) {
538
- logError("observer setup failed.", err);
539
- }
540
-
541
- const onPush = (msg) => onSession(msg);
542
- let offs = [];
543
- for (const uri of [SESSION_URI, `${SESSION_URI}/timer`]) {
544
- try {
545
- const off = api.subscribe(uri, onPush);
546
- if (off) offs.push(off);
547
- } catch (err) {
548
- logError(`subscribe failed for ${uri}.`, err);
549
- }
550
- }
551
- unsubscribe = () => {
552
- for (const off of offs) {
553
- try {
554
- off();
555
- } catch (err) {
556
- logError("unsubscribe failed.", err);
557
- }
558
- }
559
- };
560
-
561
- poll();
562
- }
563
-
564
- function onDisable() {
565
- unsubscribe?.();
566
- unsubscribe = null;
567
- clearInterval(pollTimer);
568
- clearInterval(ensureTimer);
569
- clearTimeout(refreshTimer);
570
- clearTimeout(detectTimer);
571
- clearTimeout(pumpTimer);
572
- pollTimer = ensureTimer = refreshTimer = detectTimer = pumpTimer = null;
573
- observer?.disconnect();
574
- observer = null;
575
-
576
- uiVisible = false;
577
- goneAway = true;
578
- target = undefined;
579
- sent = 0;
580
- failedUntil.clear();
581
- failCount.clear();
582
- cellToPlayer.clear();
583
- enabled.clear();
584
- playerNames.clear();
585
- lastSession = null;
586
- rows = [];
587
- removeButtons();
588
-
589
- removeStyle?.();
590
- removeStyle = null;
591
- api = null;
592
- }
593
-
594
- /** Pengu entry point. Registers with the Perfectionism core. */
595
- export function init() {
596
- const registerWith = (utils) => {
597
- utils.registerPlugin({
598
- id: PLUGIN_ID,
599
- name: "Ban Blocker",
600
- description: "During bans, blocking a teammate instantly claims the champion they hover to ban.",
601
- version,
602
- defaultEnabled: true,
603
- onEnable,
604
- onDisable,
605
- });
606
- };
607
-
608
- if (window.PerfectionistUtils) {
609
- registerWith(window.PerfectionistUtils);
610
- return;
611
- }
612
- addEventListener(
613
- "perfectionist:utils-ready",
614
- (e) => registerWith(e.detail ?? window.PerfectionistUtils),
615
- { once: true },
616
- );
617
- }
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
+ }