perfectionist-plugins 2.4.7 → 2.5.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/ban-blocker.js CHANGED
@@ -1,677 +1,483 @@
1
- export const version = "3.6.2";
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 = 400;
12
- const FAIL_COOLDOWN_MAX_MS = 3000;
13
- const POLL_MS = 500;
14
- const ENSURE_MS = 300;
15
- const DETECT_RETRY_MS = 250;
16
- const GONE_RESET_MS = 1500;
17
-
18
- const BAN_GLYPH_SVG =
19
- "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'>" +
20
- "<g fill='none' stroke='white' stroke-width='2.4'>" +
21
- "<circle cx='10' cy='10' r='7'/><path d='M5.1 14.9 14.9 5.1'/></g></svg>";
22
- const BAN_GLYPH = `data:image/svg+xml,${encodeURIComponent(BAN_GLYPH_SVG)}`;
23
-
24
- const CSS = `
25
- .swap-button-component.${BTN_CLASS} {
26
- position: absolute !important;
27
- top: -2px !important;
28
- right: -2px !important;
29
- left: auto !important;
30
- bottom: auto !important;
31
- z-index: 2 !important;
32
- }
33
- .swap-button-component.${BTN_CLASS} .icon {
34
- -webkit-mask-image: url("${BAN_GLYPH}") !important;
35
- mask-image: url("${BAN_GLYPH}") !important;
36
- -webkit-mask-size: 70% 70% !important;
37
- mask-size: 70% 70% !important;
38
- }
39
- .swap-button-component.${BTN_CLASS}.on .frame-color {
40
- background-color: rgb(200, 170, 110) !important;
41
- }
42
- .swap-button-component.${BTN_CLASS}.on .icon {
43
- background-color: rgb(1, 10, 19) !important;
44
- }
45
- .swap-button-component.${BTN_CLASS}.on .frame-color:hover {
46
- background-color: rgb(240, 230, 210) !important;
47
- }
48
- .swap-button-component.${BTN_CLASS}.unresolved {
49
- opacity: 0.35 !important;
50
- cursor: default !important;
51
- }
52
- `;
53
-
54
- let api = null;
55
- let removeStyle = null;
56
- let unsubscribe = null;
57
- let pollTimer = null;
58
- let ensureTimer = null;
59
- let refreshTimer = null;
60
- let pumpTimer = null;
61
- let goneTimer = null;
62
- let observer = null;
63
- let pollInFlight = false;
64
- let uiScheduled = false;
65
- let lastDetect = -Infinity;
66
-
67
- const enabled = new Map();
68
- const alias = new Map();
69
- const fetchedNames = new Set();
70
- const playerNames = new Map();
71
- const cellToPlayer = new Map();
72
- const buttons = new Map();
73
- const swapSeen = new Set();
74
- const failedUntil = new Map();
75
- const failCount = new Map();
76
- let rows = [];
77
- let uiVisible = false;
78
- let goneAway = true;
79
- let lastSession = null;
80
-
81
- let myActionId = null;
82
- let target = undefined;
83
- let sent = 0;
84
- let sentAt = -Infinity;
85
- let inFlight = 0;
86
-
87
- function logError(scope, error) {
88
- console.error(`[Ban Blocker] ${scope}`, error);
89
- }
90
-
91
- function norm(v) {
92
- return String(v ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
93
- }
94
-
95
- function champOf(a) {
96
- return a && a.championId > 0 ? a.championId : 0;
97
- }
98
-
99
- function isPicked(members, champ) {
100
- if (!champ) return false;
101
- return (members ?? []).some((p) => p.championId === champ && p.completed);
102
- }
103
-
104
- function canonOf(id) {
105
- return (id != null && alias.get(id)) || id;
106
- }
107
-
108
- function isBlocked(id) {
109
- return enabled.get(canonOf(id)) === true;
110
- }
111
-
112
- function isWeakKey(k) {
113
- return typeof k === "string" && k.startsWith("cell:");
114
- }
115
-
116
- function payloadName(p) {
117
- return norm(`${p?.gameName ?? ""}${p?.tagLine ?? ""}`) || norm(p?.summonerName) || "";
118
- }
119
-
120
- function keyOf(p) {
121
- if (typeof p === "number") return p;
122
- const strong = p?.summonerId || p?.puuid || p?.obfuscatedPuuid || p?.obfuscatedSummonerId;
123
- if (strong) return strong;
124
- const n = payloadName(p);
125
- if (n) return `name:${n}`;
126
- return p?.cellId != null ? `cell:${p.cellId}` : null;
127
- }
128
-
129
- function linkPlayer(p, rowName, cellId) {
130
- const strong =
131
- typeof p === "number" ? p : p?.summonerId || p?.puuid || p?.obfuscatedPuuid || p?.obfuscatedSummonerId;
132
- const pn = typeof p === "number" ? "" : payloadName(p);
133
- const rn = norm(rowName);
134
- const keys = [];
135
- if (strong) keys.push(strong);
136
- if (pn) keys.push(`name:${pn}`);
137
- if (rn && rn !== pn) keys.push(`name:${rn}`);
138
- const weak = cellId != null ? `cell:${cellId}` : null;
139
-
140
- let root = null;
141
- for (const k of keys) {
142
- const r = alias.get(k);
143
- if (r != null) {
144
- root = r;
145
- break;
146
- }
147
- }
148
- if (root == null && keys.length) {
149
- root = keys[0];
150
- const prev = weak ? alias.get(weak) : null;
151
- if (prev != null && isWeakKey(prev) && enabled.has(prev)) {
152
- if (!enabled.has(root)) enabled.set(root, enabled.get(prev));
153
- enabled.delete(prev);
154
- }
155
- }
156
- if (root == null) root = (weak && alias.get(weak)) || weak;
157
- if (root == null) return null;
158
- for (const k of keys) alias.set(k, root);
159
- if (weak) alias.set(weak, root);
160
- return root;
161
- }
162
-
163
- function pickStarted(s) {
164
- const actions = s?.actions?.flat?.() ?? [];
165
- return actions.some((a) => a.type === "pick" && (a.isInProgress || a.completed));
166
- }
167
-
168
- function banPending(s) {
169
- const actions = s?.actions?.flat?.() ?? [];
170
- const bans = actions.filter((a) => a.type === "ban");
171
- if (!bans.length) return true;
172
- if (bans.some((a) => !a.completed)) return true;
173
- const phase = s?.timer?.phase;
174
- if (phase && phase !== "BAN_PICK") return false;
175
- return !pickStarted(s);
176
- }
177
-
178
- function pump() {
179
- if (target === undefined || myActionId == null) return;
180
- if (inFlight >= MAX_IN_FLIGHT) return;
181
- if (sent === target && performance.now() - sentAt < RESEND_AFTER_MS) return;
182
- fire(myActionId, target);
183
- }
184
-
185
- function fire(id, champ) {
186
- if (id == null) return;
187
- sent = champ;
188
- sentAt = performance.now();
189
- inFlight++;
190
- clearTimeout(pumpTimer);
191
- pumpTimer = setTimeout(pump, RESCHEDULE_MS);
192
- fetch(`${SESSION_URI}/actions/${id}`, {
193
- method: "PATCH",
194
- headers: { "Content-Type": "application/json" },
195
- body: JSON.stringify({ championId: champ }),
196
- })
197
- .then((r) => {
198
- if (r.ok) {
199
- failCount.delete(champ);
200
- return;
201
- }
202
- const n = failCount.get(champ) ?? 0;
203
- failCount.set(champ, n + 1);
204
- failedUntil.set(champ, performance.now() + Math.min(FAIL_COOLDOWN_MS * 2 ** n, FAIL_COOLDOWN_MAX_MS));
205
- if (target === champ) target = undefined;
206
- if (sent === champ) sent = 0;
207
- })
208
- .catch(() => {})
209
- .finally(() => {
210
- inFlight--;
211
- pump();
212
- });
213
- }
214
-
215
- function decide(s) {
216
- const m = s?.myTeam ?? [];
217
- const actions = s?.actions?.flat?.() ?? [];
218
- const me = s?.localPlayerCellId;
219
-
220
- cellToPlayer.clear();
221
- for (const p of m) {
222
- const row = rows.find((r) => (r.cell ?? r.slotId) === p.cellId);
223
- cellToPlayer.set(p.cellId, linkPlayer(p, row?.name, p.cellId));
224
- }
225
-
226
- let myPick = null;
227
- for (const a of actions) {
228
- if (a.type === "pick" && a.actorCellId === me && !a.completed) {
229
- myPick = a;
230
- break;
231
- }
232
- }
233
- myActionId = myPick ? myPick.id : null;
234
-
235
- const blockedCells = new Set(m.filter((p) => isBlocked(cellToPlayer.get(p.cellId))).map((p) => p.cellId));
236
-
237
- let threat = 0;
238
- for (const a of actions) {
239
- if (a.type !== "ban" || a.completed || a.actorCellId === me) continue;
240
- if (!blockedCells.has(a.actorCellId)) continue;
241
- const champ = champOf(a);
242
- if (!champ || isPicked(m, champ)) continue;
243
- if ((failedUntil.get(champ) ?? 0) > performance.now()) continue;
244
- threat = champ;
245
- }
246
-
247
- if (threat && champOf(myPick) === threat) {
248
- target = undefined;
249
- sent = threat;
250
- } else if (threat) {
251
- target = threat;
252
- } else {
253
- target = undefined;
254
- }
255
- }
256
-
257
- function onSessionGone() {
258
- sent = 0;
259
- target = undefined;
260
- clearTimeout(pumpTimer);
261
- failedUntil.clear();
262
- failCount.clear();
263
- cellToPlayer.clear();
264
- lastSession = null;
265
- goneAway = true;
266
- uiVisible = false;
267
- swapSeen.clear();
268
- removeButtons();
269
- if (!goneTimer) {
270
- goneTimer = setTimeout(() => {
271
- goneTimer = null;
272
- enabled.clear();
273
- alias.clear();
274
- playerNames.clear();
275
- fetchedNames.clear();
276
- }, GONE_RESET_MS);
277
- }
278
- }
279
-
280
- function onSession(raw) {
281
- try {
282
- const msg = raw ?? {};
283
- if (msg.eventType === "Delete") {
284
- onSessionGone();
285
- return;
286
- }
287
- let s = msg.data ?? msg;
288
- if (s && !s.myTeam && typeof s.phase === "string") {
289
- pump();
290
- return;
291
- }
292
- if (!s?.myTeam) return;
293
- clearTimeout(goneTimer);
294
- goneTimer = null;
295
- goneAway = false;
296
- lastSession = s;
297
-
298
- decide(s);
299
- pump();
300
- scheduleUi();
301
- } catch (err) {
302
- logError("session handling failed.", err);
303
- }
304
- }
305
-
306
- function detectRows() {
307
- try {
308
- const nodes = document.querySelectorAll("div.summoner-array.your-party div.summoner-object");
309
- const list = nodes.length ? nodes : document.querySelectorAll("div.summoner-object.left");
310
- const found = [];
311
- for (const e of list) {
312
- const slot = /slot-id-(\d+)/.exec(String(e.className));
313
- const nameNode = e.querySelector(".summoner-name");
314
- found.push({
315
- el: e,
316
- slotId: slot ? Number(slot[1]) : null,
317
- name: (nameNode?.textContent ?? "").trim(),
318
- circle: e.querySelector(".champion-icon-container"),
319
- });
320
- }
321
- if (!found.length) return [];
322
- return found.map((r, i) => ({
323
- ...r,
324
- index: i,
325
- player: rows[i]?.el === r.el ? (rows[i]?.player ?? null) : null,
326
- cell: rows[i]?.el === r.el ? (rows[i]?.cell ?? null) : null,
327
- }));
328
- } catch (err) {
329
- logError("row detection failed.", err);
330
- return [];
331
- }
332
- }
333
-
334
- function getRows() {
335
- const connected = rows.length > 0 && rows.every((r) => r.el.isConnected);
336
- if (connected) return rows;
337
- const now = performance.now();
338
- if (rows.length === 0 && now - lastDetect < DETECT_RETRY_MS) return rows;
339
- lastDetect = now;
340
- const found = detectRows();
341
- if (found.length) rows = found;
342
- else if (!document.querySelector("div.summoner-object")) rows = [];
343
- return rows;
344
- }
345
-
346
- function ensureName(cell) {
347
- const strong = cell?.summonerId || cell?.puuid;
348
- if (!strong || fetchedNames.has(strong)) return;
349
- const get = api?.lcu?.get;
350
- if (!get) return;
351
- fetchedNames.add(strong);
352
- const byPuuid = typeof strong === "string" && strong.length > 20;
353
- const path = byPuuid ? `/lol-summoner/v2/summoners/puuid/${strong}` : `/lol-summoner/v1/summoners/${strong}`;
354
- get(path)
355
- .then((data) => {
356
- const names = [norm(data?.displayName), norm(data?.gameName)].filter(Boolean);
357
- if (!names.length) return;
358
- const root = canonOf(strong);
359
- playerNames.set(root, [...new Set([...(playerNames.get(root) ?? []), ...names])]);
360
- })
361
- .catch(() => fetchedNames.delete(strong));
362
- }
363
-
364
- function nameMatches(rowName, id) {
365
- const rn = norm(rowName);
366
- if (!rn) return false;
367
- return (playerNames.get(canonOf(id)) ?? []).some(
368
- (n) => n === rn || (n.length >= 3 && rn.length >= 3 && (n.startsWith(rn) || rn.startsWith(n))),
369
- );
370
- }
371
-
372
- function bindRowsToTeam(session) {
373
- const cells = session?.myTeam ?? [];
374
- const list = getRows();
375
- if (!list.length || !cells.length) return;
376
- for (const c of cells) ensureName(c);
377
-
378
- const selfIdx = list.findIndex((r) => isSelfRow(r));
379
- const selfCellIdx =
380
- session?.localPlayerCellId != null ? cells.findIndex((c) => c.cellId === session.localPlayerCellId) : -1;
381
- let pick;
382
- if (selfIdx >= 0 && selfCellIdx >= 0) {
383
- const selfSlot = list[selfIdx].slotId;
384
- if (selfSlot != null && selfSlot === cells[selfCellIdx].cellId) {
385
- pick = (r) => (r.slotId != null ? cells.find((c) => c.cellId === r.slotId) : null);
386
- } else if (selfIdx === selfCellIdx) {
387
- pick = (r) => (r.slotId != null ? cells[r.slotId] : null) ?? cells[r.index];
388
- } else {
389
- pick = () => null;
390
- }
391
- } else {
392
- pick = (r) => (r.slotId != null ? cells.find((c) => c.cellId === r.slotId) : null);
393
- }
394
-
395
- for (const r of list) {
396
- const cell = pick(r) ?? cells.find((c) => nameMatches(r.name, keyOf(c)));
397
- if (cell) {
398
- r.cell = cell.cellId;
399
- r.player = linkPlayer(cell, r.name, cell.cellId);
400
- cellToPlayer.set(cell.cellId, r.player);
401
- } else {
402
- r.player = null;
403
- }
404
- }
405
- }
406
-
407
- function stopEvent(e) {
408
- if (!e) return;
409
- e.stopPropagation();
410
- e.preventDefault();
411
- }
412
-
413
- function playerForBadge(btn) {
414
- const rowEl = btn.closest(".summoner-object");
415
- const r = rowEl ? rows.find((x) => x.el === rowEl) : rows[Number(btn.dataset.slot)];
416
- const byCell = btn.dataset.cell ? cellToPlayer.get(Number(btn.dataset.cell)) ?? null : null;
417
- return canonOf(byCell ?? r?.player ?? null);
418
- }
419
-
420
- function toggle(btn) {
421
- let id = playerForBadge(btn);
422
- if (id == null && lastSession) {
423
- bindRowsToTeam(lastSession);
424
- id = playerForBadge(btn);
425
- }
426
- if (id == null) return;
427
- const myCell = lastSession?.localPlayerCellId;
428
- if (myCell != null && btn.dataset.cell && Number(btn.dataset.cell) === myCell) return;
429
- enabled.set(id, !isBlocked(id));
430
- renderButtons();
431
- if (lastSession) {
432
- decide(lastSession);
433
- pump();
434
- }
435
- queueRefresh();
436
- }
437
-
438
- function buildBadge(slot) {
439
- const btn = document.createElement("div");
440
- btn.className = `swap-button-component ${BTN_CLASS}`;
441
- btn.dataset.slot = String(slot);
442
- btn.dataset.bb = "1";
443
- btn.innerHTML =
444
- '<div class="swap-button--btn uikit-framed-icon">' +
445
- '<div class="outer-mask interactive">' +
446
- '<div class="frame-color" style="padding: 2px;">' +
447
- '<div class="icon"></div>' +
448
- "</div></div></div>";
449
- for (const type of ["mouseenter", "mouseleave", "mousedown", "mouseup", "click"]) {
450
- btn.addEventListener(type, (e) => {
451
- stopEvent(e);
452
- if (type === "click") toggle(btn);
453
- });
454
- }
455
- return btn;
456
- }
457
-
458
- function isSelfRow(r) {
459
- if (r.el.classList.contains("is-self")) return true;
460
- return lastSession?.localPlayerCellId != null && (r.cell ?? r.slotId) === lastSession.localPlayerCellId;
461
- }
462
-
463
- function rowKey(r) {
464
- return String(r.slotId ?? r.cell ?? r.index);
465
- }
466
-
467
- function nativeSwapIn(circle) {
468
- if (!circle) return false;
469
- return [...circle.querySelectorAll(".swap-button-component")].some((el) => el.dataset.bb !== "1");
470
- }
471
-
472
- function ensureButtons() {
473
- const list = getRows();
474
- for (const r of list) {
475
- if (!r.circle || !r.circle.isConnected) r.circle = r.el.querySelector(".champion-icon-container");
476
- if (!r.circle || !r.circle.isConnected) continue;
477
- const key = rowKey(r);
478
- let btn = buttons.get(r.index);
479
- if (isSelfRow(r)) {
480
- if (btn) {
481
- btn.remove();
482
- buttons.delete(r.index);
483
- }
484
- continue;
485
- }
486
- if (nativeSwapIn(r.circle)) swapSeen.add(key);
487
- if (!swapSeen.has(key)) {
488
- if (btn) {
489
- btn.remove();
490
- buttons.delete(r.index);
491
- }
492
- continue;
493
- }
494
- if (!btn || !btn.isConnected || btn.parentElement !== r.circle) {
495
- if (btn) btn.remove();
496
- for (const orphan of r.circle.querySelectorAll(`:scope > .${BTN_CLASS}`)) orphan.remove();
497
- btn = buildBadge(r.index);
498
- r.circle.appendChild(btn);
499
- buttons.set(r.index, btn);
500
- }
501
- btn.dataset.slot = String(r.index);
502
- const cell = r.cell ?? r.slotId;
503
- btn.dataset.cell = cell == null ? "" : String(cell);
504
- }
505
- for (const [slot, btn] of [...buttons]) {
506
- if (!list[slot] || !btn.isConnected) {
507
- btn.remove();
508
- buttons.delete(slot);
509
- }
510
- }
511
- }
512
-
513
- function renderButtons() {
514
- const hasSession = !!lastSession?.myTeam;
515
- for (const [, btn] of buttons) {
516
- const id = playerForBadge(btn);
517
- const on = id != null && isBlocked(id);
518
- btn.classList.toggle("on", on);
519
- btn.classList.toggle("unresolved", id == null && hasSession);
520
- const title = on ? "Blocked — click to allow their bans" : "Block this teammate's bans";
521
- if (btn.title !== title) btn.title = title;
522
- }
523
- }
524
-
525
- function removeButtons() {
526
- for (const btn of buttons.values()) btn.remove();
527
- buttons.clear();
528
- for (const el of document.querySelectorAll(`.${BTN_CLASS}[data-bb]`)) el.remove();
529
- }
530
-
531
- function queueRefresh() {
532
- clearTimeout(refreshTimer);
533
- refreshTimer = setTimeout(poll, 80);
534
- }
535
-
536
- function scheduleUi() {
537
- if (uiScheduled) return;
538
- uiScheduled = true;
539
- queueMicrotask(() => {
540
- uiScheduled = false;
541
- try {
542
- update();
543
- } catch (err) {
544
- logError("ui update failed.", err);
545
- }
546
- });
547
- }
548
-
549
- function update() {
550
- if (goneAway) return;
551
- const s = lastSession;
552
- if (s?.myTeam) bindRowsToTeam(s);
553
- const visible = !s?.myTeam ? true : banPending(s);
554
- if (visible) {
555
- uiVisible = true;
556
- ensureButtons();
557
- renderButtons();
558
- } else if (uiVisible) {
559
- uiVisible = false;
560
- swapSeen.clear();
561
- removeButtons();
562
- }
563
- }
564
-
565
- async function poll() {
566
- const get = api?.lcu?.get;
567
- if (!get || pollInFlight) return;
568
- if (goneAway && !document.querySelector(".summoner-object")) return;
569
- pollInFlight = true;
570
- try {
571
- onSession(await get(SESSION_URI));
572
- } catch {
573
- onSessionGone();
574
- } finally {
575
- pollInFlight = false;
576
- }
577
- }
578
-
579
- function onEnable(utils) {
580
- api = utils;
581
- try {
582
- removeStyle = api.addStyle(STYLE_ID, CSS);
583
- } catch (err) {
584
- logError("style setup failed.", err);
585
- }
586
-
587
- pollTimer = setInterval(poll, POLL_MS);
588
- ensureTimer = setInterval(() => {
589
- if (uiVisible) update();
590
- }, ENSURE_MS);
591
-
592
- try {
593
- observer = new MutationObserver(() => scheduleUi());
594
- observer.observe(document.body, { childList: true, subtree: true });
595
- } catch (err) {
596
- logError("observer setup failed.", err);
597
- }
598
-
599
- const onPush = (msg) => onSession(msg);
600
- let offs = [];
601
- for (const uri of [SESSION_URI, `${SESSION_URI}/timer`]) {
602
- try {
603
- const off = api.subscribe(uri, onPush);
604
- if (off) offs.push(off);
605
- } catch (err) {
606
- logError(`subscribe failed for ${uri}.`, err);
607
- }
608
- }
609
- unsubscribe = () => {
610
- for (const off of offs) {
611
- try {
612
- off();
613
- } catch (err) {
614
- logError("unsubscribe failed.", err);
615
- }
616
- }
617
- };
618
-
619
- poll();
620
- }
621
-
622
- function onDisable() {
623
- unsubscribe?.();
624
- unsubscribe = null;
625
- clearInterval(pollTimer);
626
- clearInterval(ensureTimer);
627
- clearTimeout(refreshTimer);
628
- clearTimeout(pumpTimer);
629
- clearTimeout(goneTimer);
630
- pollTimer = ensureTimer = refreshTimer = pumpTimer = goneTimer = null;
631
- observer?.disconnect();
632
- observer = null;
633
-
634
- uiVisible = false;
635
- swapSeen.clear();
636
- goneAway = true;
637
- target = undefined;
638
- sent = 0;
639
- failedUntil.clear();
640
- failCount.clear();
641
- cellToPlayer.clear();
642
- enabled.clear();
643
- playerNames.clear();
644
- alias.clear();
645
- fetchedNames.clear();
646
- lastSession = null;
647
- rows = [];
648
- lastDetect = -Infinity;
649
- removeButtons();
650
-
651
- removeStyle?.();
652
- removeStyle = null;
653
- api = null;
654
- }
655
-
656
- export function init() {
657
- const registerWith = (utils) => {
658
- utils.registerPlugin({
659
- id: PLUGIN_ID,
660
- name: "Ban Blocker",
661
- description: "During bans, blocking a teammate instantly claims the champion they hover to ban.",
662
- version,
663
- onEnable,
664
- onDisable,
665
- });
666
- };
667
-
668
- if (window.PerfectionistUtils) {
669
- registerWith(window.PerfectionistUtils);
670
- return;
671
- }
672
- addEventListener(
673
- "perfectionist:utils-ready",
674
- (e) => registerWith(e.detail ?? window.PerfectionistUtils),
675
- { once: true },
676
- );
677
- }
1
+ export const version = "4.0.0";
2
+
3
+ const PLUGIN_ID = "ban-blocker";
4
+ const STYLE_ID = "perfectionist-ban-blocker-style";
5
+ const BADGE_CLASS = "bb-toggle";
6
+ const SESSION_URI = "/lol-champ-select/v1/session";
7
+ const ACTIONS_URI = "/lol-champ-select/v1/session/actions";
8
+ const PICKABLE_URI = "/lol-champ-select/v1/pickable-champion-ids";
9
+ const CHAMPION_SUMMARY_URI = "/lol-game-data/assets/v1/champion-summary.json";
10
+ const ROWS_SELECTOR = ".summoner-array.your-party";
11
+
12
+ // A PATCH that the session has not reflected yet is re-sent after this long (the server can drop an intent
13
+ // that lands while the session is mid-update). Kept short: a teammate can lock a ban in a fraction of a second.
14
+ const CONFIRM_MS = 220;
15
+ const MAX_SENDS = 4;
16
+ // Safety net only: the session WebSocket push is the fast path. Polling runs only while a block is active.
17
+ const POLL_MS = 1000;
18
+
19
+ const BAN_GLYPH_SVG =
20
+ "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'>" +
21
+ "<g fill='none' stroke='white' stroke-width='2.4'>" +
22
+ "<circle cx='10' cy='10' r='7'/><path d='M5.1 14.9 14.9 5.1'/></g></svg>";
23
+ const BAN_GLYPH = `data:image/svg+xml,${encodeURIComponent(BAN_GLYPH_SVG)}`;
24
+
25
+ const CSS = `
26
+ .swap-button-component.${BADGE_CLASS} {
27
+ position: absolute !important; top: -2px !important; right: -2px !important; left: auto !important; bottom: auto !important;
28
+ z-index: 50 !important; pointer-events: auto !important;
29
+ }
30
+ .swap-button-component.${BADGE_CLASS} .icon {
31
+ -webkit-mask-image: url("${BAN_GLYPH}") !important; mask-image: url("${BAN_GLYPH}") !important;
32
+ -webkit-mask-size: 70% 70% !important; mask-size: 70% 70% !important;
33
+ }
34
+ .swap-button-component.${BADGE_CLASS}.on .frame-color { background-color: rgb(200, 170, 110) !important; }
35
+ .swap-button-component.${BADGE_CLASS}.on .icon { background-color: rgb(1, 10, 19) !important; }
36
+ .swap-button-component.${BADGE_CLASS}.on .frame-color:hover { background-color: rgb(240, 230, 210) !important; }
37
+ .swap-button-component.${BADGE_CLASS}.on.warn .frame-color { background-color: rgb(190, 30, 55) !important; }
38
+ `;
39
+
40
+ let api = null;
41
+ let active = false;
42
+ let removeStyle = null;
43
+ let unsubscribe = null;
44
+ let unwatchRows = null;
45
+ let removeFrameHook = null;
46
+ let pollTimer = null;
47
+ let confirmTimer = null;
48
+
49
+ let session = null;
50
+ let sessionKey = null;
51
+ let pickable = null;
52
+ let pickableLoading = false;
53
+ let championNames = null;
54
+
55
+ // Blocks follow the player, not the seat: swapping positions or pick order moves players between cells.
56
+ const blocked = new Set();
57
+ const cellPlayer = new Map();
58
+ const hoverSince = new Map();
59
+ const unprotectable = new Map();
60
+ const refusedUntil = new Map();
61
+ const REFUSED_MS = 1500;
62
+ const badges = new Map();
63
+
64
+ // What we last asked the server for, and what the user had declared before we took over.
65
+ let sent = { actionId: null, championId: 0, at: 0, count: 0 };
66
+ let userIntent = null;
67
+ let ownsIntent = false;
68
+ let restoreDone = false;
69
+
70
+ function logError(scope, error) {
71
+ console.error(`[Ban Blocker] ${scope}`, error);
72
+ }
73
+
74
+ function flatActions(s) {
75
+ const out = [];
76
+ for (const group of Array.isArray(s?.actions) ? s.actions : []) {
77
+ if (Array.isArray(group)) out.push(...group);
78
+ else if (group) out.push(group);
79
+ }
80
+ return out;
81
+ }
82
+
83
+ function myPickAction(s, actions) {
84
+ const me = s?.localPlayerCellId;
85
+ return actions.find((a) => a.type === "pick" && a.actorCellId === me && !a.completed) ?? null;
86
+ }
87
+
88
+ function banPhaseOpen(s, actions) {
89
+ return actions.some((a) => a.type === "ban" && !a.completed);
90
+ }
91
+
92
+ function playerKey(p) {
93
+ return p?.puuid || p?.obfuscatedPuuid || (p?.summonerId ? `s:${p.summonerId}` : "") || p?.obfuscatedSummonerId || `cell:${p?.cellId}`;
94
+ }
95
+
96
+ function isBlockedCell(cell) {
97
+ const key = cellPlayer.get(cell);
98
+ return key !== undefined && blocked.has(key);
99
+ }
100
+
101
+ function takenByAlly(s, championId) {
102
+ return (s?.myTeam ?? []).some((p) => p.championId === championId && p.cellId !== s.localPlayerCellId);
103
+ }
104
+
105
+ /* ---------------------------------------------------------------------------------------------
106
+ * Decision engine: runs synchronously on every session push and fires the PATCH in the same tick.
107
+ * ------------------------------------------------------------------------------------------- */
108
+
109
+ function pickThreat(s, actions) {
110
+ const me = s.localPlayerCellId;
111
+ const now = performance.now();
112
+ let best = null;
113
+ for (const a of actions) {
114
+ if (a.type !== "ban" || a.completed || a.actorCellId === me || !isBlockedCell(a.actorCellId)) continue;
115
+ const champ = a.championId > 0 ? a.championId : 0;
116
+ const prev = hoverSince.get(a.actorCellId);
117
+ if (!prev || prev.championId !== champ) hoverSince.set(a.actorCellId, { championId: champ, at: now });
118
+ if (!champ || takenByAlly(s, champ)) continue;
119
+ if ((pickable && !pickable.has(champ)) || (refusedUntil.get(champ) ?? 0) > now) {
120
+ unprotectable.set(a.actorCellId, champ);
121
+ continue;
122
+ }
123
+ unprotectable.delete(a.actorCellId);
124
+ const since = hoverSince.get(a.actorCellId).at;
125
+ const candidate = { championId: champ, cell: a.actorCellId, inProgress: a.isInProgress === true, since };
126
+ if (!best) {
127
+ best = candidate;
128
+ continue;
129
+ }
130
+ // Prefer bans that are live right now, then keep the champion we already hold (no flip-flopping),
131
+ // then the most recent hover (the one most likely to be locked next).
132
+ const score = (c) => (c.inProgress ? 4 : 0) + (c.championId === sent.championId ? 2 : 0);
133
+ const diff = score(candidate) - score(best);
134
+ if (diff > 0 || (diff === 0 && candidate.since > best.since)) best = candidate;
135
+ }
136
+ return best;
137
+ }
138
+
139
+ function patch(actionId, championId) {
140
+ sent = {
141
+ actionId,
142
+ championId,
143
+ at: performance.now(),
144
+ count: sent.actionId === actionId && sent.championId === championId ? sent.count + 1 : 1,
145
+ };
146
+ fetch(`${ACTIONS_URI}/${actionId}`, {
147
+ method: "PATCH",
148
+ headers: { "Content-Type": "application/json" },
149
+ body: JSON.stringify({ championId }),
150
+ })
151
+ .then((response) => {
152
+ if (response.ok || !active) return;
153
+ // The server refused this champion for us (not owned, disabled, mid-update...). Back off briefly and
154
+ // let the next evaluation protect another hovered champion if there is one.
155
+ refusedUntil.set(championId, performance.now() + REFUSED_MS);
156
+ if (session) evaluate(session);
157
+ renderBadges();
158
+ })
159
+ .catch(() => {});
160
+ scheduleConfirm();
161
+ }
162
+
163
+ function scheduleConfirm() {
164
+ clearTimeout(confirmTimer);
165
+ confirmTimer = setTimeout(() => {
166
+ confirmTimer = null;
167
+ if (session) evaluate(session);
168
+ }, CONFIRM_MS);
169
+ }
170
+
171
+ function evaluate(s) {
172
+ const actions = flatActions(s);
173
+ const myPick = myPickAction(s, actions);
174
+ if (!myPick || myPick.isInProgress) return;
175
+ const current = myPick.championId > 0 ? myPick.championId : 0;
176
+
177
+ const threat = blocked.size ? pickThreat(s, actions) : null;
178
+ if (threat) {
179
+ if (!ownsIntent) {
180
+ ownsIntent = true;
181
+ restoreDone = false;
182
+ userIntent = current;
183
+ }
184
+ if (current === threat.championId) return;
185
+ const resend = sent.actionId === myPick.id && sent.championId === threat.championId;
186
+ if (resend && (sent.count >= MAX_SENDS || performance.now() - sent.at < CONFIRM_MS)) return;
187
+ patch(myPick.id, threat.championId);
188
+ return;
189
+ }
190
+
191
+ // Nothing to protect any more. Once the blocked teammates are done banning, give the user back the pick
192
+ // they had declared before we stepped in.
193
+ if (!ownsIntent || restoreDone) return;
194
+ const blockedBansLeft = actions.some((a) => a.type === "ban" && !a.completed && isBlockedCell(a.actorCellId));
195
+ if (blockedBansLeft && blocked.size) return;
196
+ restoreDone = true;
197
+ ownsIntent = false;
198
+ if (current === sent.championId && userIntent !== null && userIntent !== current) patch(myPick.id, userIntent);
199
+ }
200
+
201
+ /* ---------------------------------------------------------------------------------------------
202
+ * Session handling
203
+ * ------------------------------------------------------------------------------------------- */
204
+
205
+ // Identifies one champ select. It must not change when players swap seats, or the blocks would be lost.
206
+ function keyOf(s) {
207
+ if (Number(s?.gameId) > 0) return `game:${s.gameId}`;
208
+ return `team:${(s?.myTeam ?? []).map(playerKey).sort().join(",")}`;
209
+ }
210
+
211
+ function resetSession() {
212
+ blocked.clear();
213
+ cellPlayer.clear();
214
+ hoverSince.clear();
215
+ unprotectable.clear();
216
+ refusedUntil.clear();
217
+ sent = { actionId: null, championId: 0, at: 0, count: 0 };
218
+ userIntent = null;
219
+ ownsIntent = false;
220
+ restoreDone = false;
221
+ pickable = null;
222
+ session = null;
223
+ sessionKey = null;
224
+ clearTimeout(confirmTimer);
225
+ confirmTimer = null;
226
+ syncPolling();
227
+ removeBadges();
228
+ }
229
+
230
+ function loadPickable() {
231
+ if (pickableLoading || !api) return;
232
+ pickableLoading = true;
233
+ api.lcu
234
+ .get(PICKABLE_URI)
235
+ .then((ids) => {
236
+ if (active && Array.isArray(ids) && ids.length) pickable = new Set(ids);
237
+ })
238
+ .catch(() => {})
239
+ .finally(() => {
240
+ pickableLoading = false;
241
+ });
242
+ }
243
+
244
+ function applySession(s) {
245
+ if (!s || !Array.isArray(s.myTeam)) return;
246
+ const key = keyOf(s);
247
+ if (key !== sessionKey) {
248
+ resetSession();
249
+ sessionKey = key;
250
+ }
251
+ session = s;
252
+ cellPlayer.clear();
253
+ for (const member of s.myTeam) cellPlayer.set(member.cellId, playerKey(member));
254
+ if (!pickable) loadPickable();
255
+ try {
256
+ evaluate(s);
257
+ } catch (error) {
258
+ logError("decision failed.", error);
259
+ }
260
+ syncPolling();
261
+ renderBadges();
262
+ }
263
+
264
+ function onSessionPush(message) {
265
+ if (!active) return;
266
+ if (message?.eventType === "Delete") {
267
+ resetSession();
268
+ return;
269
+ }
270
+ applySession(message?.data ?? message);
271
+ }
272
+
273
+ function syncPolling() {
274
+ const wanted = active && session && blocked.size > 0 && banPhaseOpen(session, flatActions(session));
275
+ if (wanted && !pollTimer) {
276
+ pollTimer = setInterval(() => {
277
+ api?.lcu
278
+ .get(SESSION_URI)
279
+ .then((s) => applySession(s))
280
+ .catch(() => {});
281
+ }, POLL_MS);
282
+ } else if (!wanted && pollTimer) {
283
+ clearInterval(pollTimer);
284
+ pollTimer = null;
285
+ }
286
+ }
287
+
288
+ /* ---------------------------------------------------------------------------------------------
289
+ * UI: one BLOCK badge on every teammate's champion icon while bans are still open.
290
+ * ------------------------------------------------------------------------------------------- */
291
+
292
+ // Ally rows render in myTeam order (placeholder rows are "removed"). The local player's row (.is-self)
293
+ // confirms the alignment; slot ids are the fallback for layouts where the order differs.
294
+ function allyRows(s) {
295
+ const party = document.querySelector(ROWS_SELECTOR);
296
+ if (!party) return [];
297
+ const rows = [...party.querySelectorAll(".summoner-object")].filter(
298
+ (row) => !row.closest(".summoner-wrapper")?.classList.contains("removed"),
299
+ );
300
+ const team = s?.myTeam ?? [];
301
+ if (!rows.length || !team.length) return [];
302
+ const selfRow = rows.findIndex((row) => row.classList.contains("is-self"));
303
+ const selfCell = team.findIndex((p) => p.cellId === s.localPlayerCellId);
304
+ if (rows.length === team.length && (selfRow < 0 || selfRow === selfCell)) {
305
+ return rows.map((row, index) => ({ row, cell: team[index].cellId }));
306
+ }
307
+ const cells = new Set(team.map((p) => p.cellId));
308
+ const offset = team.length && Math.min(...team.map((p) => p.cellId)) >= 5 ? 5 : 0;
309
+ return rows
310
+ .map((row) => {
311
+ const slot = /slot-id-(\d+)/.exec(row.className);
312
+ if (!slot) return null;
313
+ const id = Number(slot[1]);
314
+ const cell = cells.has(id) ? id : cells.has(id + offset) ? id + offset : null;
315
+ return cell === null ? null : { row, cell };
316
+ })
317
+ .filter(Boolean);
318
+ }
319
+
320
+ function championName(id) {
321
+ if (!championNames && api) {
322
+ championNames = new Map();
323
+ api.lcu
324
+ .get(CHAMPION_SUMMARY_URI, { ttl: 60 * 60 * 1000 })
325
+ .then((list) => {
326
+ for (const c of Array.isArray(list) ? list : []) championNames.set(c.id, c.name);
327
+ renderBadges();
328
+ })
329
+ .catch(() => {
330
+ championNames = null;
331
+ });
332
+ }
333
+ return championNames?.get(id) ?? "that champion";
334
+ }
335
+
336
+ function badgeTitle(cell) {
337
+ if (!isBlockedCell(cell)) return "Block this teammate's bans";
338
+ const champ = unprotectable.get(cell);
339
+ if (champ) return `Blocked, but ${championName(champ)} can't be declared as your pick (not available to you), so this ban can't be stopped.`;
340
+ return "Blocked: the champion they hover to ban is declared as your pick. Click to allow their bans.";
341
+ }
342
+
343
+ function buildBadge(cell) {
344
+ const badge = document.createElement("div");
345
+ badge.className = `swap-button-component ${BADGE_CLASS}`;
346
+ badge.dataset.cell = String(cell);
347
+ badge.innerHTML =
348
+ '<div class="swap-button--btn uikit-framed-icon"><div class="outer-mask interactive">' +
349
+ '<div class="frame-color" style="padding: 2px;"><div class="icon"></div></div></div></div>';
350
+ const stop = (event) => {
351
+ event.stopPropagation();
352
+ event.preventDefault();
353
+ };
354
+ for (const type of ["mouseenter", "mouseleave", "mousedown", "mouseup", "pointerdown", "pointerup"]) badge.addEventListener(type, stop);
355
+ badge.addEventListener("click", (event) => {
356
+ stop(event);
357
+ toggle(Number(badge.dataset.cell));
358
+ });
359
+ return badge;
360
+ }
361
+
362
+ function toggle(cell) {
363
+ if (!session || cell === session.localPlayerCellId) return;
364
+ const key = cellPlayer.get(cell);
365
+ if (key === undefined) return;
366
+ if (blocked.has(key)) {
367
+ blocked.delete(key);
368
+ unprotectable.delete(cell);
369
+ } else {
370
+ blocked.add(key);
371
+ }
372
+ evaluate(session);
373
+ syncPolling();
374
+ renderBadges();
375
+ }
376
+
377
+ function removeBadges() {
378
+ for (const badge of badges.values()) badge.remove();
379
+ badges.clear();
380
+ for (const stray of document.querySelectorAll(`.${BADGE_CLASS}`)) stray.remove();
381
+ }
382
+
383
+ function renderBadges() {
384
+ if (!active) return;
385
+ const s = session;
386
+ if (!s || !banPhaseOpen(s, flatActions(s))) {
387
+ if (badges.size) removeBadges();
388
+ return;
389
+ }
390
+ const seen = new Set();
391
+ for (const { row, cell } of allyRows(s)) {
392
+ if (cell === s.localPlayerCellId) continue;
393
+ const host = row.querySelector(".champion-icon-container");
394
+ if (!host) continue;
395
+ let badge = badges.get(cell);
396
+ if (!badge || badge.parentElement !== host) {
397
+ badge?.remove();
398
+ badge = buildBadge(cell);
399
+ host.appendChild(badge);
400
+ badges.set(cell, badge);
401
+ }
402
+ seen.add(cell);
403
+ const on = isBlockedCell(cell);
404
+ badge.classList.toggle("on", on);
405
+ badge.classList.toggle("warn", on && unprotectable.has(cell));
406
+ const title = badgeTitle(cell);
407
+ if (badge.title !== title) badge.title = title;
408
+ }
409
+ for (const [cell, badge] of badges) {
410
+ if (!seen.has(cell)) {
411
+ badge.remove();
412
+ badges.delete(cell);
413
+ }
414
+ }
415
+ }
416
+
417
+ function onFrame() {
418
+ // Ember re-renders rows freely; re-attach badges only when one went missing.
419
+ if (!session) return;
420
+ for (const badge of badges.values()) {
421
+ if (!badge.isConnected) {
422
+ renderBadges();
423
+ return;
424
+ }
425
+ }
426
+ if (badges.size === 0 && document.querySelector(ROWS_SELECTOR)) renderBadges();
427
+ }
428
+
429
+ /* ------------------------------------------------------------------------------------------- */
430
+
431
+ function onEnable(utils) {
432
+ api = utils;
433
+ active = true;
434
+ removeStyle = utils.addStyle(STYLE_ID, CSS);
435
+ unsubscribe = utils.subscribe(SESSION_URI, onSessionPush);
436
+ unwatchRows = utils.watch(ROWS_SELECTOR, () => {
437
+ if (!session) {
438
+ utils.lcu
439
+ .get(SESSION_URI)
440
+ .then((s) => applySession(s))
441
+ .catch(() => {});
442
+ } else renderBadges();
443
+ });
444
+ removeFrameHook = utils.onFrame ? utils.onFrame(onFrame) : null;
445
+ utils.lcu
446
+ .get(SESSION_URI)
447
+ .then((s) => applySession(s))
448
+ .catch(() => {});
449
+ }
450
+
451
+ function onDisable() {
452
+ active = false;
453
+ unsubscribe?.();
454
+ unwatchRows?.();
455
+ removeFrameHook?.();
456
+ unsubscribe = unwatchRows = removeFrameHook = null;
457
+ resetSession();
458
+ clearInterval(pollTimer);
459
+ pollTimer = null;
460
+ removeStyle?.();
461
+ removeStyle = null;
462
+ championNames = null;
463
+ api = null;
464
+ }
465
+
466
+ export function init() {
467
+ const registerWith = (utils) => {
468
+ utils.registerPlugin({
469
+ id: PLUGIN_ID,
470
+ name: "Ban Blocker",
471
+ description: "During bans, blocking a teammate instantly claims the champion they hover to ban.",
472
+ version,
473
+ onEnable,
474
+ onDisable,
475
+ });
476
+ };
477
+
478
+ if (window.PerfectionistUtils) {
479
+ registerWith(window.PerfectionistUtils);
480
+ return;
481
+ }
482
+ addEventListener("perfectionist:utils-ready", (event) => registerWith(event.detail ?? window.PerfectionistUtils), { once: true });
483
+ }