perfectionist-plugins 2.4.4 → 2.4.6

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/dodge-helper.js CHANGED
@@ -1,561 +1,835 @@
1
- const PLUGIN_ID = "dodge-helper";
2
- export const version = "1.4.2";
3
-
4
- const PHASE_URI = "/lol-gameflow/v1/gameflow-phase";
5
- const SESSION_URI = "/lol-gameflow/v1/session";
6
- const CHAMP_SELECT_URI = "/lol-champ-select/v1/session";
7
- const SUMMONER_URI = "/lol-summoner/v1/current-summoner";
8
- const RANKED_STATS_URI = "/lol-ranked/v1/current-ranked-stats";
9
- const DODGE_URI = "/lol-gameflow/v1/session/dodge";
10
- const QUIT_URI = "/lol-lobby-team-builder/champ-select/v1/session/quit";
11
- const STORE_KEY = "perfectionist:dodge-helper:accounts:v1";
12
- const DAY_MS = 24 * 60 * 60 * 1000;
13
- const STYLE_ID = "perf-dodge-helper-style";
14
- const ROOT_ID = "perfDodgeHelper";
15
- const BUTTON_ID = "perfDodgeHelperButton";
16
- const LP_ID = "perfDodgeHelperLp";
17
-
18
- const RANKED_QUEUES = new Map([
19
- [420, "RANKED_SOLO_5x5"],
20
- [440, "RANKED_FLEX_SR"],
21
- ]);
22
- const APEX_TIERS = new Set(["MASTER", "GRANDMASTER", "CHALLENGER"]);
23
- const PENALTY_TIERS = {
24
- 1: { lp: 5, lockout: "6-minute" },
25
- 2: { lp: 15, lockout: "30-minute" },
26
- 3: { lp: 15, lockout: "12-hour" },
27
- };
28
-
29
- const CSS = `
30
- .perf-dodge-anchor { position: relative !important; overflow: visible !important; }
31
- #${ROOT_ID} {
32
- position: relative !important; display: flex !important; align-items: flex-end !important;
33
- justify-content: center !important; width: 125px !important; height: 32px !important; z-index: 20 !important;
34
- }
35
- #${ROOT_ID}.perf-dodge-quit { position: absolute !important; left: 0 !important; bottom: 0 !important; }
36
- #${ROOT_ID}.perf-dodge-bar { margin-left: 12px !important; }
37
- #${ROOT_ID}.perf-dodge-floating { position: fixed !important; right: 145px !important; bottom: 21px !important; }
38
- #${ROOT_ID} lol-uikit-flat-button { width: 125px !important; height: 32px !important; }
39
- #${ROOT_ID} .perf-dodge-lp {
40
- position: absolute !important; left: 0 !important; right: 0 !important; bottom: calc(100% + 4px) !important;
41
- text-align: center !important; white-space: nowrap !important;
42
- font-family: var(--font-body, "LoL Body", Arial, sans-serif) !important;
43
- font-size: 11px !important; font-weight: 700 !important; letter-spacing: 0.4px !important;
44
- line-height: 1 !important; color: #cdbe91 !important;
45
- }`;
46
-
47
- let api = null;
48
- let active = false;
49
- let dodging = false;
50
- let unsubscribes = [];
51
- let removeStyle = null;
52
- let domObserver = null;
53
- let domTimer = null;
54
- let networkWatch = null;
55
- let penaltyToken = 0;
56
- const state = {
57
- phase: null,
58
- queueId: 0,
59
- isCustom: false,
60
- showQuitButton: false,
61
- gameMode: "",
62
- account: null,
63
- penalty: null,
64
- lastSelfDodgeAt: 0,
65
- lockedLocalPick: false,
66
- anchor: null,
67
- };
68
-
69
- function unwrap(raw) {
70
- if (typeof raw === "string") {
71
- const text = raw.trim();
72
- if (text.startsWith("{") || text.startsWith("[")) {
73
- try {
74
- return unwrap(JSON.parse(text));
75
- } catch {
76
- return raw;
77
- }
78
- }
79
- return raw;
80
- }
81
- if (Array.isArray(raw) && raw.length >= 3 && typeof raw[1] === "string") {
82
- return unwrap(raw[2]?.data ?? raw[2]);
83
- }
84
- if (raw && typeof raw === "object" && "data" in raw) {
85
- const keys = Object.keys(raw);
86
- if (keys.includes("uri") || keys.includes("eventType") || keys.length <= 2) return unwrap(raw.data);
87
- }
88
- return raw;
89
- }
90
-
91
- function readStore() {
92
- try {
93
- const parsed = JSON.parse(localStorage.getItem(STORE_KEY) || "{}");
94
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
95
- } catch {
96
- return {};
97
- }
98
- }
99
-
100
- function accountKey() {
101
- const account = state.account;
102
- if (!account) return null;
103
- if (account.puuid) return account.puuid;
104
- if (account.summonerId) return `sid:${account.summonerId}`;
105
- return null;
106
- }
107
-
108
- function currentTier() {
109
- const key = accountKey();
110
- if (!key) return 0;
111
- const record = readStore()[key];
112
- if (!record || typeof record.tier !== "number" || typeof record.at !== "number") return 0;
113
- const drops = Math.floor((Date.now() - record.at) / DAY_MS);
114
- return Math.max(0, Math.min(record.tier - drops, 3));
115
- }
116
-
117
- async function loadAccount() {
118
- if (state.account) return state.account;
119
- const summoner = await api.lcu.get(SUMMONER_URI).catch(() => null);
120
- if (summoner) {
121
- state.account = { puuid: summoner.puuid || "", summonerId: summoner.summonerId || 0 };
122
- }
123
- return state.account;
124
- }
125
-
126
- async function recordDodge() {
127
- if (!active) return;
128
- if (hasNativeQuit()) return;
129
- const now = Date.now();
130
- if (now - state.lastSelfDodgeAt < 1500) return;
131
- state.lastSelfDodgeAt = now;
132
- await loadAccount();
133
- const key = accountKey();
134
- if (!key) return;
135
- const store = readStore();
136
- const tier = Math.min(currentTier() + 1, 3);
137
- store[key] = { tier, at: now };
138
- try {
139
- localStorage.setItem(STORE_KEY, JSON.stringify(store));
140
- } catch {}
141
- }
142
-
143
- async function computePenalty(queueId) {
144
- const rankedKey = RANKED_QUEUES.get(queueId);
145
- if (!rankedKey) return { ranked: false, lp: 0, tier: 0, lockout: "" };
146
- await loadAccount();
147
- const stats = await api.lcu.get(RANKED_STATS_URI).catch(() => null);
148
- const tierName = String(stats?.queueMap?.[rankedKey]?.tier ?? "").toUpperCase();
149
- if (!tierName || tierName === "UNRANKED") return { ranked: true, lp: 0, tier: 0, lockout: "" };
150
- const tier = Math.min(currentTier() + 1, 3);
151
- const apex = APEX_TIERS.has(tierName);
152
- return {
153
- ranked: true,
154
- lp: apex && tier === 1 ? 20 : PENALTY_TIERS[tier].lp,
155
- lockout: PENALTY_TIERS[tier].lockout,
156
- tier,
157
- };
158
- }
159
-
160
- function isCustomQueue() {
161
- return state.isCustom || state.gameMode === "PRACTICETOOL" || state.queueId === 0;
162
- }
163
-
164
- function hasNativeQuit() {
165
- return state.showQuitButton || isCustomQueue();
166
- }
167
-
168
- function render() {
169
- const button = document.getElementById(BUTTON_ID);
170
- const lp = document.getElementById(LP_ID);
171
- if (!button || !lp) return;
172
- const ranked = RANKED_QUEUES.has(state.queueId);
173
- const label = ranked ? "DODGE" : "QUIT";
174
- if (button.textContent !== label) button.textContent = label;
175
- const penalty = state.penalty;
176
- const amount = ranked && penalty?.ranked ? penalty.lp : 0;
177
- const text = amount > 0 ? `\u2212${amount} LP` : "";
178
- if (lp.textContent !== text) lp.textContent = text;
179
- lp.style.display = text ? "" : "none";
180
- if (ranked) {
181
- button.title =
182
- amount > 0
183
- ? `Dodge champion select. This costs about ${amount} LP and a ${penalty?.lockout ?? "queue"} lockout. Your dodge tier is shared across queues and drops one step every 24 hours.`
184
- : "Dodge champion select. This queue is unranked, so no LP is lost, but the dodge still advances your shared dodge tier.";
185
- } else {
186
- button.title = "Leave champion select. This counts as a dodge and advances your shared dodge tier.";
187
- }
188
- lp.title =
189
- ranked && amount > 0
190
- ? "Estimated LP for this dodge, from this account's shared dodge tier. The tier drops one step every 24 hours."
191
- : "";
192
- }
193
-
194
- async function refreshPenalty() {
195
- if (!active || state.phase !== "ChampSelect" || !state.queueId || hasNativeQuit()) return;
196
- const queueId = state.queueId;
197
- const token = ++penaltyToken;
198
- const penalty = await computePenalty(queueId);
199
- if (!active || token !== penaltyToken || queueId !== state.queueId) return;
200
- state.penalty = penalty;
201
- render();
202
- bindButton(document.getElementById(BUTTON_ID));
203
- requestAnimationFrame(() => bindButton(document.getElementById(BUTTON_ID)));
204
- }
205
-
206
- function applySession(raw) {
207
- const session = unwrap(raw);
208
- if (!session) return;
209
- const queue = session.gameData?.queue;
210
- if (queue) {
211
- const queueId = Number(queue.id ?? 0) || 0;
212
- const isCustom = Boolean(session.gameData?.isCustomGame || queue.isCustom);
213
- const gameMode = String(queue.gameMode ?? "");
214
- if (queueId !== state.queueId || isCustom !== state.isCustom || gameMode !== state.gameMode) {
215
- state.queueId = queueId;
216
- state.isCustom = isCustom;
217
- state.gameMode = gameMode;
218
- state.penalty = null;
219
- if (state.phase === "ChampSelect") refreshPenalty();
220
- }
221
- }
222
- ensureMount();
223
- }
224
-
225
- function syncLocalPick(session) {
226
- const cellId = Number(session?.localPlayerCellId);
227
- if (!Number.isFinite(cellId)) return;
228
- const actions = Array.isArray(session.actions) ? session.actions.flat() : [];
229
- const locked = actions.some(
230
- (action) => Number(action?.actorCellId) === cellId && action?.type === "pick" && action?.completed === true
231
- );
232
- if (locked) state.lockedLocalPick = true;
233
- }
234
-
235
- function applyChampSelect(raw) {
236
- const session = unwrap(raw);
237
- if (!session) return;
238
- state.showQuitButton = Boolean(session.showQuitButton);
239
- const queueId = Number(session.queueId ?? 0) || 0;
240
- if (queueId > 0) state.queueId = queueId;
241
- if (session.isCustomGame) state.isCustom = true;
242
- syncLocalPick(session);
243
- ensureMount();
244
- syncWatching();
245
- }
246
-
247
- function unmount() {
248
- for (const element of document.querySelectorAll(`#${ROOT_ID}`)) element.remove();
249
- if (state.anchor) {
250
- state.anchor.classList.remove("perf-dodge-anchor");
251
- state.anchor = null;
252
- }
253
- }
254
-
255
- function onButtonClick(event) {
256
- event.preventDefault();
257
- event.stopPropagation();
258
- quitOrDodge();
259
- }
260
-
261
- function bindButton(button) {
262
- if (!button) return;
263
- if (!button.__perfClickBound) {
264
- button.__perfClickBound = true;
265
- button.addEventListener("click", onButtonClick, true);
266
- }
267
- const inner = button.shadowRoot?.querySelector(".lol-uikit-flat-button");
268
- if (inner && !inner.__perfClickBound) {
269
- inner.__perfClickBound = true;
270
- inner.addEventListener("click", onButtonClick);
271
- }
272
- }
273
-
274
- function build() {
275
- const root = document.createElement("div");
276
- root.id = ROOT_ID;
277
- const lp = document.createElement("span");
278
- lp.id = LP_ID;
279
- lp.className = "perf-dodge-lp";
280
- const button = document.createElement("lol-uikit-flat-button");
281
- button.id = BUTTON_ID;
282
- bindButton(button);
283
- root.append(lp, button);
284
- return root;
285
- }
286
-
287
- function host() {
288
- const quitBar = document.querySelector(".bottom-right-buttons");
289
- if (quitBar && quitBar.getBoundingClientRect().width > 1) return { element: quitBar, kind: "quit" };
290
- const screen = document.querySelector(".champion-select");
291
- if (screen) return { element: screen, kind: "floating" };
292
- const bars = [...document.querySelectorAll(".champ-select-action-content")].filter(
293
- (element) => element.getBoundingClientRect().width > 1
294
- );
295
- return bars[0] ? { element: bars[0], kind: "bar" } : null;
296
- }
297
-
298
- function ensureMount() {
299
- if (!active || state.phase !== "ChampSelect" || hasNativeQuit()) {
300
- unmount();
301
- return;
302
- }
303
- const target = host();
304
- if (!target) {
305
- unmount();
306
- return;
307
- }
308
- if (state.anchor && state.anchor !== target.element) {
309
- state.anchor.classList.remove("perf-dodge-anchor");
310
- state.anchor = null;
311
- }
312
- if (target.kind === "quit") {
313
- target.element.classList.add("perf-dodge-anchor");
314
- state.anchor = target.element;
315
- }
316
- const roots = [...document.querySelectorAll(`#${ROOT_ID}`)];
317
- for (const extra of roots.slice(1)) extra.remove();
318
- const root = roots[0] ?? build();
319
- if (root.parentElement !== target.element) target.element.prepend(root);
320
- else if (target.kind === "quit" && target.element.firstElementChild !== root) target.element.prepend(root);
321
- root.classList.toggle("perf-dodge-quit", target.kind === "quit");
322
- root.classList.toggle("perf-dodge-bar", target.kind === "bar");
323
- root.classList.toggle("perf-dodge-floating", target.kind === "floating");
324
- render();
325
- bindButton(document.getElementById(BUTTON_ID));
326
- requestAnimationFrame(() => bindButton(document.getElementById(BUTTON_ID)));
327
- setTimeout(() => bindButton(document.getElementById(BUTTON_ID)), 500);
328
- }
329
-
330
- function scheduleMount() {
331
- if (domTimer) return;
332
- domTimer = setTimeout(() => {
333
- domTimer = null;
334
- ensureMount();
335
- }, 200);
336
- }
337
-
338
- function startDomObserver() {
339
- if (domObserver) return;
340
- const target =
341
- document.querySelector(".champion-select") ?? document.querySelector(".screen-root") ?? document.body;
342
- domObserver = new MutationObserver(scheduleMount);
343
- domObserver.observe(target, { childList: true, subtree: true });
344
- }
345
-
346
- function stopDomObserver() {
347
- domObserver?.disconnect();
348
- domObserver = null;
349
- clearTimeout(domTimer);
350
- domTimer = null;
351
- }
352
-
353
- function isDodgeUrl(url) {
354
- return /\/(lol-gameflow\/v1\/session\/dodge|lol-lobby-team-builder\/champ-select\/v1\/session\/quit)(\?|$)/i.test(
355
- String(url)
356
- );
357
- }
358
-
359
- function installDodgeWatcher() {
360
- if (networkWatch) return;
361
- const originalFetch = window.fetch;
362
- const originalOpen = XMLHttpRequest.prototype.open;
363
- const originalSend = XMLHttpRequest.prototype.send;
364
-
365
- const ourFetch = function (...args) {
366
- const url = String(args[0]?.url ?? args[0] ?? "");
367
- const method = String(args[1]?.method ?? "GET").toUpperCase();
368
- const promise = originalFetch.apply(this, args);
369
- if (method === "POST" && isDodgeUrl(url)) {
370
- promise
371
- .then((response) => {
372
- if (response.ok) recordDodge();
373
- })
374
- .catch(() => {});
375
- }
376
- return promise;
377
- };
378
-
379
- const ourOpen = function (method, url, ...rest) {
380
- this.__perfDodgeUrl = String(url);
381
- this.__perfDodgeMethod = String(method ?? "GET").toUpperCase();
382
- return originalOpen.call(this, method, url, ...rest);
383
- };
384
-
385
- const ourSend = function (body) {
386
- if (this.__perfDodgeMethod === "POST" && isDodgeUrl(this.__perfDodgeUrl)) {
387
- this.addEventListener("load", () => {
388
- if (this.status >= 200 && this.status < 300) recordDodge();
389
- });
390
- }
391
- return originalSend.call(this, body);
392
- };
393
-
394
- window.fetch = ourFetch;
395
- XMLHttpRequest.prototype.open = ourOpen;
396
- XMLHttpRequest.prototype.send = ourSend;
397
-
398
- networkWatch = {
399
- restore() {
400
- if (window.fetch === ourFetch) window.fetch = originalFetch;
401
- if (XMLHttpRequest.prototype.open === ourOpen) XMLHttpRequest.prototype.open = originalOpen;
402
- if (XMLHttpRequest.prototype.send === ourSend) XMLHttpRequest.prototype.send = originalSend;
403
- },
404
- };
405
- }
406
-
407
- function removeDodgeWatcher() {
408
- networkWatch?.restore();
409
- networkWatch = null;
410
- }
411
-
412
- function syncWatching() {
413
- if (state.phase === "ChampSelect" && !hasNativeQuit()) {
414
- startDomObserver();
415
- installDodgeWatcher();
416
- } else {
417
- stopDomObserver();
418
- removeDodgeWatcher();
419
- }
420
- }
421
-
422
- async function quitOrDodge() {
423
- if (dodging || !state.queueId || hasNativeQuit()) return;
424
- dodging = true;
425
- const button = document.getElementById(BUTTON_ID);
426
- if (button) button.disabled = true;
427
- const ranked = RANKED_QUEUES.has(state.queueId);
428
- const uri = ranked ? DODGE_URI : QUIT_URI;
429
- try {
430
- await api.lcu.send("POST", uri);
431
- await recordDodge();
432
- setTimeout(() => {
433
- const current = document.getElementById(BUTTON_ID);
434
- if (current) current.disabled = false;
435
- }, 1500);
436
- } catch (error) {
437
- console.warn("[Dodge Helper] quit failed:", error);
438
- if (button) button.disabled = false;
439
- } finally {
440
- dodging = false;
441
- }
442
- }
443
-
444
- async function handlePhase(raw) {
445
- const previous = state.phase;
446
- const phase = typeof raw === "string" ? raw : unwrap(raw);
447
- if (typeof phase === "string" && phase) state.phase = phase;
448
- if (previous === "ChampSelect" && state.phase !== "ChampSelect" && state.phase !== "GameStart" && state.phase !== "InProgress") {
449
- if (RANKED_QUEUES.has(state.queueId) && !state.lockedLocalPick && !hasNativeQuit()) {
450
- recordDodge();
451
- }
452
- state.lockedLocalPick = false;
453
- }
454
- if (state.phase === "ChampSelect" && previous !== "ChampSelect") state.lockedLocalPick = false;
455
- if (state.phase === "ChampSelect") {
456
- await loadAccount();
457
- const [session, champSelect] = await Promise.all([
458
- api.lcu.get(SESSION_URI).catch(() => null),
459
- api.lcu.get(CHAMP_SELECT_URI).catch(() => null),
460
- ]);
461
- if (session) applySession(session);
462
- if (champSelect) applyChampSelect(champSelect);
463
- ensureMount();
464
- refreshPenalty();
465
- } else {
466
- stopDomObserver();
467
- removeDodgeWatcher();
468
- unmount();
469
- state.penalty = null;
470
- state.showQuitButton = false;
471
- }
472
- }
473
-
474
- function handleSession(raw) {
475
- applySession(raw);
476
- }
477
-
478
- function handleChampSelect(raw) {
479
- applyChampSelect(raw);
480
- if (state.phase === "ChampSelect") {
481
- ensureMount();
482
- refreshPenalty();
483
- }
484
- }
485
-
486
- function onEnable(utils) {
487
- api = utils;
488
- active = true;
489
- removeStyle = utils.addStyle(STYLE_ID, CSS);
490
-
491
- const handlers = {
492
- [PHASE_URI]: handlePhase,
493
- [SESSION_URI]: handleSession,
494
- [CHAMP_SELECT_URI]: handleChampSelect,
495
- };
496
- for (const [uri, handler] of Object.entries(handlers)) {
497
- try {
498
- const off = utils.subscribe(uri, handler);
499
- if (off) unsubscribes.push(off);
500
- } catch {}
501
- }
502
-
503
- loadAccount();
504
- utils.lcu
505
- .get(PHASE_URI)
506
- .then((phase) => handlePhase(unwrap(phase)))
507
- .catch(() => {});
508
- utils.lcu
509
- .get(SESSION_URI)
510
- .then((session) => session && applySession(session))
511
- .catch(() => {});
512
- }
513
-
514
- function onDisable() {
515
- active = false;
516
- for (const off of unsubscribes) {
517
- try {
518
- off();
519
- } catch {}
520
- }
521
- unsubscribes = [];
522
- stopDomObserver();
523
- removeDodgeWatcher();
524
- unmount();
525
- removeStyle?.();
526
- removeStyle = null;
527
- state.phase = null;
528
- state.queueId = 0;
529
- state.isCustom = false;
530
- state.showQuitButton = false;
531
- state.gameMode = "";
532
- state.account = null;
533
- state.penalty = null;
534
- state.lastSelfDodgeAt = 0;
535
- state.lockedLocalPick = false;
536
- penaltyToken = 0;
537
- api = null;
538
- }
539
-
540
- export function init() {
541
- const registerWith = (utils) => {
542
- utils.registerPlugin({
543
- id: PLUGIN_ID,
544
- name: "Dodge Helper",
545
- description:
546
- "Adds a DODGE/QUIT button to champion select: DODGE with the estimated LP loss for ranked queues, QUIT for every other mode. Hidden when the client already shows its own QUIT button.",
547
- version,
548
- defaultEnabled: true,
549
- onEnable,
550
- onDisable,
551
- });
552
- };
553
-
554
- if (window.PerfectionistUtils) {
555
- registerWith(window.PerfectionistUtils);
556
- return;
557
- }
558
- addEventListener("perfectionist:utils-ready", (event) => registerWith(event.detail ?? window.PerfectionistUtils), {
559
- once: true,
560
- });
561
- }
1
+ const PLUGIN_ID = "dodge-helper";
2
+ export const version = "1.4.3";
3
+
4
+ const PHASE_URI = "/lol-gameflow/v1/gameflow-phase";
5
+ const SESSION_URI = "/lol-gameflow/v1/session";
6
+ const CHAMP_SELECT_URI = "/lol-champ-select/v1/session";
7
+ const TIMER_URI = "/lol-champ-select/v1/session/timer";
8
+ const SUMMONER_URI = "/lol-summoner/v1/current-summoner";
9
+ const RANKED_STATS_URI = "/lol-ranked/v1/current-ranked-stats";
10
+ const DODGE_URI = "/lol-gameflow/v1/session/dodge";
11
+ const QUIT_URI = "/lol-lobby-team-builder/champ-select/v1/session/quit";
12
+ const STORE_KEY = "perfectionist:dodge-helper:accounts:v1";
13
+ const PENDING_KEY = "perfectionist:dodge-helper:pending:v1";
14
+ const DAY_MS = 24 * 60 * 60 * 1000;
15
+ const PENDING_MAX_AGE_MS = 2 * 60 * 60 * 1000;
16
+ const STYLE_ID = "perf-dodge-helper-style";
17
+ const LAYER_ID = "perfDodgeHelperLayer";
18
+ const ROOT_ID = "perfDodgeHelper";
19
+ const BUTTON_ID = "perfDodgeHelperButton";
20
+ const COST_ID = "perfDodgeHelperCost";
21
+ const FOOTER_SELECTOR = ".bottom-right-buttons";
22
+ const SCREEN_SELECTOR = ".champion-select";
23
+
24
+ const RANKED_QUEUES = new Map([
25
+ [420, "RANKED_SOLO_5x5"],
26
+ [440, "RANKED_FLEX_SR"],
27
+ ]);
28
+ const ARAM_QUEUES = new Set([450, 2400, 2401, 2402]);
29
+ const APEX_TIERS = new Set(["MASTER", "GRANDMASTER", "CHALLENGER"]);
30
+ const PENALTY_TIERS = {
31
+ 1: { lp: 5, lockout: 6 },
32
+ 2: { lp: 15, lockout: 30 },
33
+ 3: { lp: 15, lockout: 720 },
34
+ };
35
+ const GAME_PHASES = new Set([
36
+ "GameStart",
37
+ "InProgress",
38
+ "Reconnect",
39
+ "WaitingForStats",
40
+ "PreEndOfGame",
41
+ "EndOfGame",
42
+ ]);
43
+
44
+ const CSS = `
45
+ #${LAYER_ID} {
46
+ position: fixed !important; inset: 0 !important; z-index: 99999 !important;
47
+ pointer-events: none !important;
48
+ }
49
+ #${ROOT_ID} {
50
+ position: fixed !important; display: flex !important; align-items: flex-end !important;
51
+ justify-content: center !important; width: 125px !important; height: 32px !important;
52
+ pointer-events: auto !important; z-index: 1 !important;
53
+ }
54
+ #${ROOT_ID} lol-uikit-flat-button { width: 125px !important; height: 32px !important; }
55
+ #${ROOT_ID} .perf-dodge-cost {
56
+ position: absolute !important; left: 50% !important; bottom: calc(100% + 4px) !important;
57
+ transform: translateX(-50%) !important; box-sizing: border-box !important;
58
+ max-width: 245px !important; padding: 3px 7px !important;
59
+ background: rgba(1, 10, 19, 0.88) !important; border: 1px solid #785a28 !important;
60
+ color: #cdbe91 !important; font-family: var(--font-body, "LoL Body", Arial, sans-serif) !important;
61
+ font-size: 10px !important; font-weight: 700 !important; letter-spacing: 0.3px !important;
62
+ line-height: 1.25 !important; text-align: center !important; white-space: nowrap !important;
63
+ pointer-events: none !important;
64
+ }
65
+ `;
66
+
67
+ let api = null;
68
+ let active = false;
69
+ let dodging = false;
70
+ let unsubscribes = [];
71
+ let unwatchers = [];
72
+ let removeStyle = null;
73
+ let resizeObserver = null;
74
+ let mountFrame = 0;
75
+ let positionFrame = 0;
76
+ let networkWatch = null;
77
+ let penaltyToken = 0;
78
+ let pendingWriteAt = 0;
79
+ let accountPromise = null;
80
+ const state = {
81
+ phase: null,
82
+ queueId: 0,
83
+ isCustom: false,
84
+ showQuitButton: false,
85
+ gameMode: "",
86
+ account: null,
87
+ penalty: null,
88
+ lastSelfDodgeAt: 0,
89
+ selfDodgeRecorded: false,
90
+ lockedLocalPick: false,
91
+ localActionSeen: false,
92
+ localActionActive: false,
93
+ localActionDeadline: 0,
94
+ pageHiding: false,
95
+ };
96
+
97
+ function unwrap(raw) {
98
+ if (typeof raw === "string") {
99
+ const text = raw.trim();
100
+ if (text.startsWith("{") || text.startsWith("[")) {
101
+ try {
102
+ return unwrap(JSON.parse(text));
103
+ } catch {
104
+ return raw;
105
+ }
106
+ }
107
+ return raw;
108
+ }
109
+ if (Array.isArray(raw) && raw.length >= 3 && typeof raw[1] === "string") {
110
+ return unwrap(raw[2]?.data ?? raw[2]);
111
+ }
112
+ if (raw && typeof raw === "object" && "data" in raw) {
113
+ const keys = Object.keys(raw);
114
+ if (keys.includes("uri") || keys.includes("eventType") || keys.length <= 2) return unwrap(raw.data);
115
+ }
116
+ return raw;
117
+ }
118
+
119
+ function readStore() {
120
+ try {
121
+ const parsed = JSON.parse(localStorage.getItem(STORE_KEY) || "{}");
122
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
123
+ } catch {
124
+ return {};
125
+ }
126
+ }
127
+
128
+ function writeStore(store) {
129
+ try {
130
+ localStorage.setItem(STORE_KEY, JSON.stringify(store));
131
+ } catch {}
132
+ }
133
+
134
+ function readPending() {
135
+ try {
136
+ const parsed = JSON.parse(localStorage.getItem(PENDING_KEY) || "null");
137
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+
143
+ function writePending(pending) {
144
+ try {
145
+ localStorage.setItem(PENDING_KEY, JSON.stringify(pending));
146
+ } catch {}
147
+ }
148
+
149
+ function clearPending() {
150
+ pendingWriteAt = 0;
151
+ try {
152
+ localStorage.removeItem(PENDING_KEY);
153
+ } catch {}
154
+ }
155
+
156
+ function accountKey() {
157
+ const account = state.account;
158
+ if (!account) return null;
159
+ if (account.puuid) return account.puuid;
160
+ if (account.summonerId) return `sid:${account.summonerId}`;
161
+ return null;
162
+ }
163
+
164
+ function currentTier() {
165
+ const key = accountKey();
166
+ if (!key) return 0;
167
+ const record = readStore()[key];
168
+ if (!record || typeof record.tier !== "number" || typeof record.at !== "number") return 0;
169
+ const drops = Math.max(0, Math.floor((Date.now() - record.at) / DAY_MS));
170
+ return Math.max(0, Math.min(record.tier - drops, 3));
171
+ }
172
+
173
+ async function loadAccount() {
174
+ if (state.account) return state.account;
175
+ if (!accountPromise) {
176
+ accountPromise = api.lcu
177
+ .get(SUMMONER_URI)
178
+ .then((summoner) => {
179
+ if (!active) return null;
180
+ if (summoner) {
181
+ state.account = { puuid: summoner.puuid || "", summonerId: summoner.summonerId || 0 };
182
+ }
183
+ return state.account;
184
+ })
185
+ .catch(() => null)
186
+ .finally(() => {
187
+ accountPromise = null;
188
+ });
189
+ }
190
+ return accountPromise;
191
+ }
192
+
193
+ function isCustomQueue(queueId = state.queueId) {
194
+ return state.isCustom || state.gameMode === "PRACTICETOOL" || Number(queueId) === 0;
195
+ }
196
+
197
+ function isAramQueue(queueId = state.queueId) {
198
+ const id = Number(queueId);
199
+ if (RANKED_QUEUES.has(id)) return false;
200
+ return ARAM_QUEUES.has(id) || /ARAM/i.test(state.gameMode);
201
+ }
202
+
203
+ function isGamePhase(phase) {
204
+ return GAME_PHASES.has(phase);
205
+ }
206
+
207
+ async function recordDodge(options = {}) {
208
+ if (!active) return false;
209
+ const queueId = Number(options.queueId ?? state.queueId) || 0;
210
+ if (!options.force && isCustomQueue(queueId)) return false;
211
+ const now = Date.now();
212
+ if (now - state.lastSelfDodgeAt < 1500) return false;
213
+ state.lastSelfDodgeAt = now;
214
+ await loadAccount();
215
+ const key = accountKey();
216
+ if (!key) return false;
217
+ const store = readStore();
218
+ const tier = Math.min(currentTier() + 1, 3);
219
+ store[key] = { tier, at: now };
220
+ writeStore(store);
221
+ clearPending();
222
+ state.selfDodgeRecorded = true;
223
+ return true;
224
+ }
225
+
226
+ function lockoutText(minutes) {
227
+ if (minutes >= 720) return "12 hr queue";
228
+ if (minutes >= 30) return "30 min queue";
229
+ return `${minutes} min queue`;
230
+ }
231
+
232
+ function computePenalty(queueId = state.queueId) {
233
+ const tier = Math.min(currentTier() + 1, 3);
234
+ const aram = isAramQueue(queueId);
235
+ const lockout = aram && tier === 1 ? 15 : PENALTY_TIERS[tier].lockout;
236
+ const rankedKey = RANKED_QUEUES.get(Number(queueId));
237
+ if (!rankedKey) {
238
+ return Promise.resolve({ ranked: false, tier, lp: 0, lockout, apex: false, aram });
239
+ }
240
+ return api.lcu
241
+ .get(RANKED_STATS_URI)
242
+ .catch(() => null)
243
+ .then((stats) => {
244
+ const tierName = String(stats?.queueMap?.[rankedKey]?.tier ?? "").toUpperCase();
245
+ const rated = Boolean(tierName) && tierName !== "UNRANKED";
246
+ const apex = rated && APEX_TIERS.has(tierName);
247
+ return {
248
+ ranked: true,
249
+ rated,
250
+ tier,
251
+ lp: rated ? (apex && tier === 1 ? 20 : PENALTY_TIERS[tier].lp) : 0,
252
+ lockout,
253
+ apex,
254
+ aram,
255
+ };
256
+ });
257
+ }
258
+
259
+ function penaltyLabel(penalty) {
260
+ if (!penalty) return "";
261
+ const parts = [];
262
+ if (penalty.ranked) parts.push(penalty.lp > 0 ? `\u2212${penalty.lp} LP` : "No LP");
263
+ else parts.push("No LP");
264
+ parts.push(lockoutText(penalty.lockout));
265
+ if (penalty.apex) parts.push("MMR impact");
266
+ return parts.join(" \u00b7 ");
267
+ }
268
+
269
+ function penaltyTitle(penalty) {
270
+ const lines = [
271
+ "Queue dodge penalty",
272
+ "1st dodge: -5 LP and a 6-minute queue lockout.",
273
+ "2nd dodge: -15 LP and a 30-minute queue lockout.",
274
+ "3rd+ dodge: -15 LP and a 12-hour queue lockout.",
275
+ "Apex (Master+): the first dodge costs 20 LP instead of 5, and Apex dodges affect MMR.",
276
+ "Your dodge tier drops one step 24 hours after your last dodge.",
277
+ ];
278
+ if (penalty) {
279
+ lines.unshift(`Next dodge (tier ${penalty.tier}): ${penaltyLabel(penalty)}.`);
280
+ if (!penalty.ranked) lines.push("No LP is lost outside ranked queues; the queue lockout still applies.");
281
+ if (penalty.aram) lines.push("ARAM: the first dodge has a 15-minute queue lockout.");
282
+ if (penalty.ranked && !penalty.rated) lines.push("This ranked queue is still unranked, so no LP is lost.");
283
+ }
284
+ return lines.join("\n");
285
+ }
286
+
287
+ function render() {
288
+ const button = document.getElementById(BUTTON_ID);
289
+ const cost = document.getElementById(COST_ID);
290
+ if (!button || !cost) return;
291
+ const ranked = RANKED_QUEUES.has(Number(state.queueId));
292
+ const label = ranked ? "DODGE" : "QUIT";
293
+ if (button.textContent !== label) button.textContent = label;
294
+ button.disabled = dodging;
295
+ const penalty = state.penalty;
296
+ const text = penaltyLabel(penalty);
297
+ if (cost.textContent !== text) cost.textContent = text;
298
+ cost.style.display = text ? "" : "none";
299
+ const title = penalty ? penaltyTitle(penalty) : "Loading the current dodge penalty...";
300
+ button.title = title;
301
+ cost.title = title;
302
+ button.setAttribute("aria-label", `${label}. ${title}`);
303
+ }
304
+
305
+ async function refreshPenalty() {
306
+ if (!active || state.phase !== "ChampSelect" || !state.queueId || isCustomQueue() || state.showQuitButton) return;
307
+ const queueId = state.queueId;
308
+ const token = ++penaltyToken;
309
+ const penalty = await computePenalty(queueId);
310
+ if (!active || token !== penaltyToken || queueId !== state.queueId) return;
311
+ state.penalty = penalty;
312
+ render();
313
+ }
314
+
315
+ function applySession(raw) {
316
+ const session = unwrap(raw);
317
+ if (!session) return;
318
+ const queue = session.gameData?.queue;
319
+ if (queue) {
320
+ const queueId = Number(queue.id ?? 0) || 0;
321
+ const isCustom = Boolean(session.gameData?.isCustomGame || queue.isCustom);
322
+ const gameMode = String(queue.gameMode ?? "");
323
+ if (queueId !== state.queueId || isCustom !== state.isCustom || gameMode !== state.gameMode) {
324
+ state.queueId = queueId;
325
+ state.isCustom = isCustom;
326
+ state.gameMode = gameMode;
327
+ state.penalty = null;
328
+ if (state.phase === "ChampSelect") refreshPenalty();
329
+ }
330
+ }
331
+ ensureMount();
332
+ syncWatching();
333
+ savePending();
334
+ }
335
+
336
+ function syncLocalPick(session) {
337
+ const cellId = Number(session?.localPlayerCellId);
338
+ if (!Number.isFinite(cellId)) return;
339
+ const actions = Array.isArray(session.actions) ? session.actions.flat() : [];
340
+ const picks = actions.filter((action) => Number(action?.actorCellId) === cellId && action?.type === "pick");
341
+ if (!picks.length) return;
342
+ const locked = picks.some((action) => action?.completed === true);
343
+ if (locked) {
344
+ state.lockedLocalPick = true;
345
+ state.localActionSeen = false;
346
+ state.localActionActive = false;
347
+ state.localActionDeadline = 0;
348
+ return;
349
+ }
350
+ state.lockedLocalPick = false;
351
+ const active = picks.find((action) => action?.isInProgress === true);
352
+ state.localActionActive = Boolean(active);
353
+ if (active) {
354
+ state.localActionSeen = true;
355
+ const timeLeft = Number(session?.timer?.timeLeft);
356
+ if (Number.isFinite(timeLeft)) {
357
+ state.localActionDeadline = Date.now() + Math.max(0, timeLeft) * 1000;
358
+ }
359
+ }
360
+ }
361
+
362
+ function syncTimer(raw) {
363
+ const timer = unwrap(raw);
364
+ if (!timer || typeof timer !== "object") return;
365
+ if (!state.localActionActive || state.lockedLocalPick) return;
366
+ const timeLeft = Number(timer.timeLeft);
367
+ if (!Number.isFinite(timeLeft) || timeLeft < 0) return;
368
+ state.localActionSeen = true;
369
+ state.localActionDeadline = Date.now() + timeLeft * 1000;
370
+ }
371
+
372
+ function applyChampSelect(raw) {
373
+ const session = unwrap(raw);
374
+ if (!session) return;
375
+ state.showQuitButton = Boolean(session.showQuitButton);
376
+ const queueId = Number(session.queueId ?? 0) || 0;
377
+ if (queueId > 0 && queueId !== state.queueId) {
378
+ state.queueId = queueId;
379
+ state.penalty = null;
380
+ }
381
+ if (typeof session.isCustomGame === "boolean") state.isCustom = session.isCustomGame;
382
+ syncLocalPick(session);
383
+ if (state.localActionActive && !state.localActionDeadline) {
384
+ api.lcu
385
+ .get(TIMER_URI)
386
+ .catch(() => null)
387
+ .then((timer) => {
388
+ if (active && timer) syncTimer(timer);
389
+ });
390
+ }
391
+ ensureMount();
392
+ syncWatching();
393
+ savePending();
394
+ }
395
+
396
+ function build() {
397
+ const root = document.createElement("div");
398
+ root.id = ROOT_ID;
399
+ const cost = document.createElement("div");
400
+ cost.id = COST_ID;
401
+ cost.className = "perf-dodge-cost";
402
+ const button = document.createElement("lol-uikit-flat-button");
403
+ button.id = BUTTON_ID;
404
+ bindButton(button);
405
+ root.append(cost, button);
406
+ return root;
407
+ }
408
+
409
+ function bindButton(button) {
410
+ if (!button) return;
411
+ if (!button.__perfClickBound) {
412
+ button.__perfClickBound = true;
413
+ button.addEventListener("click", onButtonClick, true);
414
+ }
415
+ const inner = button.shadowRoot?.querySelector(".lol-uikit-flat-button");
416
+ if (inner && !inner.__perfClickBound) {
417
+ inner.__perfClickBound = true;
418
+ inner.addEventListener("click", onButtonClick);
419
+ }
420
+ }
421
+
422
+ function onButtonClick(event) {
423
+ event.preventDefault();
424
+ event.stopPropagation();
425
+ quitOrDodge();
426
+ }
427
+
428
+ function ensureLayer() {
429
+ let layer = document.getElementById(LAYER_ID);
430
+ if (!layer) {
431
+ layer = document.createElement("div");
432
+ layer.id = LAYER_ID;
433
+ document.body.append(layer);
434
+ }
435
+ return layer;
436
+ }
437
+
438
+ function ensureMount() {
439
+ if (state.phase !== "ChampSelect" || isCustomQueue() || state.showQuitButton) {
440
+ unmount();
441
+ return;
442
+ }
443
+ const layer = ensureLayer();
444
+ let root = document.getElementById(ROOT_ID);
445
+ const created = !root;
446
+ if (!root) root = build();
447
+ if (root.parentElement !== layer) layer.append(root);
448
+ bindButton(document.getElementById(BUTTON_ID));
449
+ render();
450
+ schedulePosition();
451
+ if (created) observeLayout();
452
+ }
453
+
454
+ function unmount() {
455
+ document.getElementById(ROOT_ID)?.remove();
456
+ document.getElementById(LAYER_ID)?.remove();
457
+ }
458
+
459
+ function visibleFooter() {
460
+ const footer = document.querySelector(FOOTER_SELECTOR);
461
+ if (!footer) return null;
462
+ const rect = footer.getBoundingClientRect();
463
+ if (rect.width < 20 || rect.height < 10) return null;
464
+ if (rect.top < window.innerHeight * 0.5) return null;
465
+ return rect;
466
+ }
467
+
468
+ function positionRoot() {
469
+ positionFrame = 0;
470
+ const root = document.getElementById(ROOT_ID);
471
+ if (!root) return;
472
+ const footer = visibleFooter();
473
+ const width = root.offsetWidth || 125;
474
+ const height = root.offsetHeight || 32;
475
+ if (footer && footer.left > width + 24) {
476
+ root.style.left = `${Math.round(footer.left - width - 10)}px`;
477
+ root.style.top = `${Math.round(Math.min(window.innerHeight - height - 8, Math.max(8, footer.bottom - height)))}px`;
478
+ root.style.right = "auto";
479
+ root.style.bottom = "auto";
480
+ return;
481
+ }
482
+ root.style.left = "auto";
483
+ root.style.top = "auto";
484
+ root.style.right = "145px";
485
+ root.style.bottom = "21px";
486
+ }
487
+
488
+ function schedulePosition() {
489
+ if (positionFrame) return;
490
+ positionFrame = requestAnimationFrame(positionRoot);
491
+ }
492
+
493
+ function scheduleMount() {
494
+ if (mountFrame) return;
495
+ mountFrame = requestAnimationFrame(() => {
496
+ mountFrame = 0;
497
+ ensureMount();
498
+ });
499
+ }
500
+
501
+ function observeLayout() {
502
+ if (typeof ResizeObserver !== "function" || !resizeObserver) return;
503
+ resizeObserver.disconnect();
504
+ const footer = document.querySelector(FOOTER_SELECTOR);
505
+ if (footer) resizeObserver.observe(footer);
506
+ const screen = document.querySelector(SCREEN_SELECTOR);
507
+ if (screen) resizeObserver.observe(screen);
508
+ }
509
+
510
+ function onWindowResize() {
511
+ schedulePosition();
512
+ observeLayout();
513
+ }
514
+
515
+ function onPageHide() {
516
+ if (!active || state.phase !== "ChampSelect" || isCustomQueue()) return;
517
+ state.pageHiding = true;
518
+ savePending(true);
519
+ }
520
+
521
+ function syncWatching() {
522
+ if (state.phase === "ChampSelect" && !isCustomQueue()) startDodgeWatcher();
523
+ else removeDodgeWatcher();
524
+ }
525
+
526
+ function isDodgeUrl(url) {
527
+ return /(?:lol-gameflow\/v1\/session\/dodge|lol-(?:champ-select|lobby-team-builder)\/.*\/session\/quit)(?:\?|$)/i.test(
528
+ String(url),
529
+ );
530
+ }
531
+
532
+ function startDodgeWatcher() {
533
+ if (networkWatch) return;
534
+ const originalFetch = window.fetch;
535
+ const originalOpen = XMLHttpRequest.prototype.open;
536
+ const originalSend = XMLHttpRequest.prototype.send;
537
+
538
+ const ourFetch = function (...args) {
539
+ const url = String(args[0]?.url ?? args[0] ?? "");
540
+ const method = String(args[1]?.method ?? args[0]?.method ?? "GET").toUpperCase();
541
+ const promise = originalFetch.apply(this, args);
542
+ if (method === "POST" && isDodgeUrl(url)) {
543
+ promise
544
+ .then((response) => {
545
+ if (response.ok) recordDodge();
546
+ })
547
+ .catch(() => {});
548
+ }
549
+ return promise;
550
+ };
551
+
552
+ const ourOpen = function (method, url, ...rest) {
553
+ this.__perfDodgeUrl = String(url);
554
+ this.__perfDodgeMethod = String(method ?? "GET").toUpperCase();
555
+ return originalOpen.call(this, method, url, ...rest);
556
+ };
557
+
558
+ const ourSend = function (body) {
559
+ if (this.__perfDodgeMethod === "POST" && isDodgeUrl(this.__perfDodgeUrl)) {
560
+ this.addEventListener("load", () => {
561
+ if (this.status >= 200 && this.status < 300) recordDodge();
562
+ });
563
+ }
564
+ return originalSend.call(this, body);
565
+ };
566
+
567
+ window.fetch = ourFetch;
568
+ XMLHttpRequest.prototype.open = ourOpen;
569
+ XMLHttpRequest.prototype.send = ourSend;
570
+
571
+ networkWatch = {
572
+ restore() {
573
+ if (window.fetch === ourFetch) window.fetch = originalFetch;
574
+ if (XMLHttpRequest.prototype.open === ourOpen) XMLHttpRequest.prototype.open = originalOpen;
575
+ if (XMLHttpRequest.prototype.send === ourSend) XMLHttpRequest.prototype.send = originalSend;
576
+ },
577
+ };
578
+ }
579
+
580
+ function removeDodgeWatcher() {
581
+ networkWatch?.restore();
582
+ networkWatch = null;
583
+ }
584
+
585
+ function savePending(closed = false) {
586
+ if (!active || state.phase !== "ChampSelect" || isCustomQueue()) return;
587
+ const now = Date.now();
588
+ if (!closed && now - pendingWriteAt < 1000) return;
589
+ pendingWriteAt = now;
590
+ writePending({
591
+ key: accountKey(),
592
+ seenAt: now,
593
+ queueId: state.queueId,
594
+ locked: state.lockedLocalPick === true,
595
+ closed: Boolean(closed),
596
+ });
597
+ }
598
+
599
+ async function reconcilePending(phase) {
600
+ const pending = readPending();
601
+ if (!pending) return;
602
+ if (phase === "ChampSelect" || isGamePhase(phase)) {
603
+ clearPending();
604
+ return;
605
+ }
606
+ const age = Date.now() - Number(pending.seenAt || 0);
607
+ if (!Number.isFinite(age) || age < 0 || age > PENDING_MAX_AGE_MS) {
608
+ clearPending();
609
+ return;
610
+ }
611
+ const key = accountKey();
612
+ if (pending.key && key && pending.key !== key) {
613
+ clearPending();
614
+ return;
615
+ }
616
+ if (pending.closed === true && pending.locked !== true) {
617
+ await recordDodge({ force: true, queueId: Number(pending.queueId) || 0 });
618
+ }
619
+ clearPending();
620
+ }
621
+
622
+ async function handleChampSelectEnd(nextPhase) {
623
+ if (isGamePhase(nextPhase)) {
624
+ clearPending();
625
+ resetSession();
626
+ return;
627
+ }
628
+ if (!isCustomQueue() && !state.selfDodgeRecorded && !state.lockedLocalPick) {
629
+ const timedOut =
630
+ state.localActionSeen &&
631
+ state.localActionDeadline > 0 &&
632
+ Date.now() >= state.localActionDeadline - 900;
633
+ if (timedOut) await recordDodge();
634
+ }
635
+ clearPending();
636
+ resetSession();
637
+ }
638
+
639
+ function resetSession() {
640
+ state.lockedLocalPick = false;
641
+ state.localActionSeen = false;
642
+ state.localActionActive = false;
643
+ state.localActionDeadline = 0;
644
+ state.penalty = null;
645
+ penaltyToken += 1;
646
+ render();
647
+ }
648
+
649
+ async function handlePhase(raw) {
650
+ if (!active) return;
651
+ const previous = state.phase;
652
+ const phase = typeof raw === "string" ? raw : unwrap(raw);
653
+ if (typeof phase !== "string" || !phase) return;
654
+ state.phase = phase;
655
+
656
+ if (previous === "ChampSelect" && phase !== "ChampSelect") {
657
+ await handleChampSelectEnd(phase);
658
+ }
659
+
660
+ if (phase === "ChampSelect") {
661
+ if (previous !== "ChampSelect") {
662
+ state.selfDodgeRecorded = false;
663
+ state.pageHiding = false;
664
+ resetSession();
665
+ }
666
+ await loadAccount();
667
+ const [session, champSelect, timer] = await Promise.all([
668
+ api.lcu.get(SESSION_URI).catch(() => null),
669
+ api.lcu.get(CHAMP_SELECT_URI).catch(() => null),
670
+ api.lcu.get(TIMER_URI).catch(() => null),
671
+ ]);
672
+ if (!active) return;
673
+ if (session) applySession(session);
674
+ if (champSelect) applyChampSelect(champSelect);
675
+ if (timer) syncTimer(timer);
676
+ ensureMount();
677
+ refreshPenalty();
678
+ savePending();
679
+ } else {
680
+ syncWatching();
681
+ unmount();
682
+ state.penalty = null;
683
+ state.showQuitButton = false;
684
+ if (isGamePhase(phase) || phase === "Lobby" || phase === "None") clearPending();
685
+ }
686
+ }
687
+
688
+ function handleSession(raw) {
689
+ applySession(raw);
690
+ }
691
+
692
+ function handleChampSelect(raw) {
693
+ applyChampSelect(raw);
694
+ if (state.phase === "ChampSelect") refreshPenalty();
695
+ }
696
+
697
+ function handleTimer(raw) {
698
+ syncTimer(raw);
699
+ }
700
+
701
+ async function quitOrDodge() {
702
+ if (!active || dodging || !state.queueId || isCustomQueue() || state.showQuitButton) return;
703
+ dodging = true;
704
+ render();
705
+ const ranked = RANKED_QUEUES.has(Number(state.queueId));
706
+ const uri = ranked ? DODGE_URI : QUIT_URI;
707
+ try {
708
+ await api.lcu.send("POST", uri);
709
+ await recordDodge();
710
+ } catch (error) {
711
+ console.warn("[Dodge Helper] quit failed:", error);
712
+ } finally {
713
+ dodging = false;
714
+ render();
715
+ }
716
+ }
717
+
718
+ function clearLegacyAnchor() {
719
+ for (const element of document.querySelectorAll(".perf-dodge-anchor")) {
720
+ element.classList.remove("perf-dodge-anchor");
721
+ }
722
+ }
723
+
724
+ async function onEnable(utils) {
725
+ api = utils;
726
+ active = true;
727
+ unmount();
728
+ clearLegacyAnchor();
729
+ removeStyle = utils.addStyle(STYLE_ID, CSS);
730
+ resizeObserver = typeof ResizeObserver === "function" ? new ResizeObserver(schedulePosition) : null;
731
+ window.addEventListener("resize", onWindowResize);
732
+ window.addEventListener("pagehide", onPageHide);
733
+ window.addEventListener("beforeunload", onPageHide);
734
+
735
+ const handlers = {
736
+ [PHASE_URI]: handlePhase,
737
+ [SESSION_URI]: handleSession,
738
+ [CHAMP_SELECT_URI]: handleChampSelect,
739
+ [TIMER_URI]: handleTimer,
740
+ };
741
+ for (const [uri, handler] of Object.entries(handlers)) {
742
+ try {
743
+ const off = utils.subscribe(uri, handler);
744
+ if (off) unsubscribes.push(off);
745
+ } catch {}
746
+ }
747
+
748
+ for (const selector of [FOOTER_SELECTOR, SCREEN_SELECTOR]) {
749
+ try {
750
+ const off = utils.watch(selector, () => {
751
+ observeLayout();
752
+ scheduleMount();
753
+ });
754
+ if (off) unwatchers.push(off);
755
+ } catch {}
756
+ }
757
+
758
+ await loadAccount();
759
+ const phase = unwrap(await utils.lcu.get(PHASE_URI).catch(() => null));
760
+ if (!active || typeof phase !== "string" || !phase) return;
761
+ await reconcilePending(phase);
762
+ if (active) await handlePhase(phase);
763
+ observeLayout();
764
+ }
765
+
766
+ function onDisable() {
767
+ active = false;
768
+ for (const off of unsubscribes) {
769
+ try {
770
+ off();
771
+ } catch {}
772
+ }
773
+ unsubscribes = [];
774
+ for (const off of unwatchers) {
775
+ try {
776
+ off();
777
+ } catch {}
778
+ }
779
+ unwatchers = [];
780
+ window.removeEventListener("resize", onWindowResize);
781
+ window.removeEventListener("pagehide", onPageHide);
782
+ window.removeEventListener("beforeunload", onPageHide);
783
+ resizeObserver?.disconnect();
784
+ resizeObserver = null;
785
+ cancelAnimationFrame(mountFrame);
786
+ mountFrame = 0;
787
+ cancelAnimationFrame(positionFrame);
788
+ positionFrame = 0;
789
+ removeDodgeWatcher();
790
+ unmount();
791
+ if (!state.pageHiding) clearPending();
792
+ removeStyle?.();
793
+ removeStyle = null;
794
+ state.phase = null;
795
+ state.queueId = 0;
796
+ state.isCustom = false;
797
+ state.showQuitButton = false;
798
+ state.gameMode = "";
799
+ state.account = null;
800
+ state.penalty = null;
801
+ state.lastSelfDodgeAt = 0;
802
+ state.selfDodgeRecorded = false;
803
+ state.lockedLocalPick = false;
804
+ state.localActionSeen = false;
805
+ state.localActionActive = false;
806
+ state.localActionDeadline = 0;
807
+ state.pageHiding = false;
808
+ dodging = false;
809
+ pendingWriteAt = 0;
810
+ penaltyToken = 0;
811
+ accountPromise = null;
812
+ api = null;
813
+ }
814
+
815
+ export function init() {
816
+ const registerWith = (utils) => {
817
+ utils.registerPlugin({
818
+ id: PLUGIN_ID,
819
+ name: "Dodge Helper",
820
+ description:
821
+ "Adds a DODGE/QUIT button to champion select, showing the LP and queue penalty for the next dodge. It also counts accidental dodges from a missed lock-in or closing the client.",
822
+ version,
823
+ onEnable,
824
+ onDisable,
825
+ });
826
+ };
827
+
828
+ if (window.PerfectionistUtils) {
829
+ registerWith(window.PerfectionistUtils);
830
+ return;
831
+ }
832
+ addEventListener("perfectionist:utils-ready", (event) => registerWith(event.detail ?? window.PerfectionistUtils), {
833
+ once: true,
834
+ });
835
+ }