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/dodge-helper.js CHANGED
@@ -1,128 +1,79 @@
1
1
  const PLUGIN_ID = "dodge-helper";
2
- export const version = "1.4.3";
2
+ export const version = "2.0.0";
3
3
 
4
4
  const PHASE_URI = "/lol-gameflow/v1/gameflow-phase";
5
- const SESSION_URI = "/lol-gameflow/v1/session";
5
+ const GAMEFLOW_URI = "/lol-gameflow/v1/session";
6
6
  const CHAMP_SELECT_URI = "/lol-champ-select/v1/session";
7
- const TIMER_URI = "/lol-champ-select/v1/session/timer";
7
+ const SEARCH_STATE_URI = "/lol-lobby/v2/lobby/matchmaking/search-state";
8
8
  const SUMMONER_URI = "/lol-summoner/v1/current-summoner";
9
9
  const RANKED_STATS_URI = "/lol-ranked/v1/current-ranked-stats";
10
- const DODGE_URI = "/lol-gameflow/v1/session/dodge";
10
+ // The same endpoint the client's own QUIT button uses. It leaves champ select on the server and moves the
11
+ // client back to the lobby properly.
11
12
  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;
13
+ // Riot refuses that endpoint in ranked and in the last seconds before a game starts. Closing the client is then
14
+ // the only way out (it counts as a dodge), so it is offered as an explicitly labelled confirm step.
15
+ const CLOSE_CLIENT_URI = "/process-control/v1/process/quit";
16
+
17
+ const STORE_KEY = "perfectionist:dodge-helper:accounts:v2";
18
+ const LEGACY_STORE_KEY = "perfectionist:dodge-helper:accounts:v1";
16
19
  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";
20
+ const ROOT_CLASS = "perf-dodge-quit";
21
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]);
22
+ const HOUR_MS = 60 * 60 * 1000;
23
+ const DAY_MS = 24 * HOUR_MS;
24
+ const CONFIRM_MS = 3000;
25
+
26
+ // Riot's published queue dodge penalties (support article "Queue Dodging"). The tier goes up by one per
27
+ // dodge and drops by one for every 24 hours without a dodge. Dodges count across all queues.
28
+ const LOCKOUT_MINUTES = { 1: 6, 2: 30, 3: 720 };
29
+ const ARAM_LOCKOUT_MINUTES = { 1: 15, 2: 30, 3: 720 };
30
+ const RANKED_LP = { 1: 5, 2: 15, 3: 15 };
31
+ const APEX_FIRST_DODGE_LP = 20;
29
32
  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
33
 
44
34
  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
- }
35
+ .${ROOT_CLASS} { position: fixed; z-index: 100; height: 32px; box-sizing: border-box; }
36
+ .${ROOT_CLASS} lol-uikit-flat-button { display: block; width: 100%; height: 32px; }
37
+ .${ROOT_CLASS} .perf-dodge-cost {
38
+ position: absolute; left: 50%; bottom: calc(100% + 5px); transform: translateX(-50%);
39
+ white-space: nowrap; pointer-events: none; text-align: center;
40
+ font-family: var(--font-body, "LoL Body", Arial, sans-serif); font-size: 10px; font-weight: 700;
41
+ letter-spacing: 0.25px; line-height: 14px; color: #c89b3c; text-shadow: 0 1px 2px #000;
42
+ }
43
+ .${ROOT_CLASS}.perf-dodge-armed lol-uikit-flat-button { filter: hue-rotate(-28deg) saturate(1.6); }
65
44
  `;
66
45
 
67
46
  let api = null;
68
47
  let active = false;
69
- let dodging = false;
70
48
  let unsubscribes = [];
71
- let unwatchers = [];
49
+ let unwatchFooter = null;
50
+ let removeFrameHook = null;
72
51
  let removeStyle = null;
73
- let resizeObserver = null;
74
- let mountFrame = 0;
75
- let positionFrame = 0;
76
- let networkWatch = null;
52
+ let armedTimer = null;
77
53
  let penaltyToken = 0;
78
- let pendingWriteAt = 0;
79
- let accountPromise = null;
54
+
80
55
  const state = {
81
56
  phase: null,
82
- queueId: 0,
83
- isCustom: false,
57
+ queue: null,
84
58
  showQuitButton: false,
85
- gameMode: "",
86
- account: null,
59
+ summoner: null,
87
60
  penalty: null,
88
- lastSelfDodgeAt: 0,
89
- selfDodgeRecorded: false,
90
- lockedLocalPick: false,
91
- localActionSeen: false,
92
- localActionActive: false,
93
- localActionDeadline: 0,
94
- pageHiding: false,
61
+ armed: false,
62
+ busy: false,
63
+ quitSent: false,
64
+ closeOffered: false,
95
65
  };
96
66
 
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
- }
67
+ /* ---------------------------------------------------------------------------------------------
68
+ * Dodge tier bookkeeping (per account, local).
69
+ * ------------------------------------------------------------------------------------------- */
118
70
 
119
71
  function readStore() {
120
72
  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
- }
73
+ const parsed = JSON.parse(localStorage.getItem(STORE_KEY) || "null");
74
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
75
+ } catch {}
76
+ return {};
126
77
  }
127
78
 
128
79
  function writeStore(store) {
@@ -131,636 +82,406 @@ function writeStore(store) {
131
82
  } catch {}
132
83
  }
133
84
 
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
- }
85
+ function accountKey() {
86
+ return state.summoner?.puuid || (state.summoner?.summonerId ? `sid:${state.summoner.summonerId}` : null);
141
87
  }
142
88
 
143
- function writePending(pending) {
144
- try {
145
- localStorage.setItem(PENDING_KEY, JSON.stringify(pending));
146
- } catch {}
89
+ function storedRecord() {
90
+ const key = accountKey();
91
+ if (!key) return null;
92
+ const record = readStore()[key];
93
+ return record && typeof record.tier === "number" && typeof record.at === "number" ? record : null;
147
94
  }
148
95
 
149
- function clearPending() {
150
- pendingWriteAt = 0;
151
- try {
152
- localStorage.removeItem(PENDING_KEY);
153
- } catch {}
96
+ // Current tier after decay: one step down per full 24 hours since the last dodge.
97
+ function currentTier(now = Date.now()) {
98
+ const record = storedRecord();
99
+ if (!record) return 0;
100
+ const drops = Math.floor(Math.max(0, now - record.at) / DAY_MS);
101
+ return Math.max(0, Math.min(3, record.tier - drops));
154
102
  }
155
103
 
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;
104
+ function saveTier(tier, at) {
105
+ const key = accountKey();
106
+ if (!key) return;
107
+ const store = readStore();
108
+ const previous = store[key];
109
+ // The same dodge can be observed several times (search state, gameflow); keep the first timestamp.
110
+ if (previous && previous.tier === tier && Math.abs(previous.at - at) < 10 * 60 * 1000) return;
111
+ store[key] = { tier: Math.max(1, Math.min(3, tier)), at };
112
+ writeStore(store);
162
113
  }
163
114
 
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
- });
115
+ function recordOwnDodge() {
116
+ saveTier(Math.min(3, currentTier() + 1), Date.now());
117
+ }
118
+
119
+ // The server tells us the lockout it applied. Its length identifies the tier exactly
120
+ // (6 or 15 minutes: tier 1, 30 minutes: tier 2, 12 hours: tier 3), which also corrects the count for
121
+ // dodges this plugin did not see (closing the client, a missed lock-in, another PC).
122
+ // Only readings that can't be ambiguous are used: more than 30 minutes left can only be the 12 hour
123
+ // lockout, and otherwise the reading must be within 3 minutes of a full lockout (a 30 minute lockout with
124
+ // 10 minutes left would look like a fresh 15 minute one).
125
+ const LOCKOUTS = [
126
+ [6, 1],
127
+ [15, 1],
128
+ [30, 2],
129
+ ];
130
+ const FRESH_WINDOW_S = 180;
131
+
132
+ function learnFromPenalty(remainingSeconds) {
133
+ const remaining = Number(remainingSeconds);
134
+ if (!Number.isFinite(remaining) || remaining <= 0) return;
135
+ if (remaining > 30 * 60) {
136
+ saveTier(3, Date.now() - Math.max(0, 720 * 60 - remaining) * 1000);
137
+ return;
138
+ }
139
+ for (const [minutes, tier] of LOCKOUTS) {
140
+ const full = minutes * 60;
141
+ if (remaining <= full && remaining >= full - FRESH_WINDOW_S) {
142
+ saveTier(tier, Date.now() - (full - remaining) * 1000);
143
+ return;
144
+ }
189
145
  }
190
- return accountPromise;
191
146
  }
192
147
 
193
- function isCustomQueue(queueId = state.queueId) {
194
- return state.isCustom || state.gameMode === "PRACTICETOOL" || Number(queueId) === 0;
195
- }
148
+ /* ---------------------------------------------------------------------------------------------
149
+ * Penalty preview
150
+ * ------------------------------------------------------------------------------------------- */
196
151
 
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);
152
+ function queueOf(gameflow) {
153
+ const queue = gameflow?.gameData?.queue;
154
+ if (!queue) return null;
155
+ return {
156
+ id: Number(queue.id) || 0,
157
+ type: String(queue.type ?? ""),
158
+ gameMode: String(queue.gameMode ?? ""),
159
+ isRanked: queue.isRanked === true,
160
+ isCustom: Boolean(gameflow?.gameData?.isCustomGame || queue.isCustom),
161
+ };
201
162
  }
202
163
 
203
- function isGamePhase(phase) {
204
- return GAME_PHASES.has(phase);
164
+ function isAram(queue) {
165
+ return /ARAM/i.test(queue?.type ?? "") || /^ARAM$/i.test(queue?.gameMode ?? "");
205
166
  }
206
167
 
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;
168
+ function isRankedQueue(queue) {
169
+ return Boolean(queue?.isRanked) || /^RANKED_/i.test(queue?.type ?? "");
224
170
  }
225
171
 
226
172
  function lockoutText(minutes) {
227
- if (minutes >= 720) return "12 hr queue";
228
- if (minutes >= 30) return "30 min queue";
229
- return `${minutes} min queue`;
173
+ if (minutes >= 60) return `${Math.round(minutes / 60)} hr lockout`;
174
+ return `${minutes} min lockout`;
230
175
  }
231
176
 
232
- function computePenalty(queueId = state.queueId) {
177
+ async function computePenalty(queue) {
233
178
  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) {
179
+ const aram = isAram(queue);
180
+ const lockout = (aram ? ARAM_LOCKOUT_MINUTES : LOCKOUT_MINUTES)[tier];
181
+ const penalty = { tier, lockout, lp: 0, ranked: isRankedQueue(queue), placed: false, apex: false, aram };
182
+ if (!penalty.ranked) return penalty;
183
+ const stats = await api.lcu.get(RANKED_STATS_URI).catch(() => null);
184
+ const entry = stats?.queueMap?.[queue.type] ?? null;
185
+ const tierName = String(entry?.tier ?? "").toUpperCase();
186
+ // No LP is lost before you are placed in that queue.
187
+ penalty.placed = Boolean(tierName) && tierName !== "NONE" && tierName !== "UNRANKED" && entry?.isProvisional !== true;
188
+ penalty.apex = penalty.placed && APEX_TIERS.has(tierName);
189
+ if (penalty.placed) penalty.lp = penalty.apex && tier === 1 ? APEX_FIRST_DODGE_LP : RANKED_LP[tier];
190
+ return penalty;
191
+ }
192
+
193
+ function costText(penalty) {
260
194
  if (!penalty) return "";
261
195
  const parts = [];
262
- if (penalty.ranked) parts.push(penalty.lp > 0 ? `\u2212${penalty.lp} LP` : "No LP");
263
- else parts.push("No LP");
196
+ if (penalty.ranked) parts.push(penalty.lp > 0 ? `−${penalty.lp} LP` : "No LP");
264
197
  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
- ];
198
+ return parts.join(" · ");
199
+ }
200
+
201
+ function costTitle(penalty) {
202
+ const lines = [];
278
203
  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.");
204
+ lines.push(`Your next dodge is dodge #${penalty.tier}: ${costText(penalty)}.`);
205
+ if (penalty.ranked && !penalty.placed) lines.push("You are not placed in this queue yet, so no LP is lost.");
206
+ if (penalty.apex) lines.push("Master+ dodges also affect your MMR.");
207
+ if (penalty.aram) lines.push("ARAM dodges also cost a Blessed Card.");
283
208
  }
209
+ lines.push("", "Dodges within 24 hours stack: 6 min, then 30 min, then 12 hours (ranked: -5, -15, -15 LP).");
210
+ lines.push("Each 24 hours without a dodge lowers the count by one.");
284
211
  return lines.join("\n");
285
212
  }
286
213
 
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
214
  async function refreshPenalty() {
306
- if (!active || state.phase !== "ChampSelect" || !state.queueId || isCustomQueue() || state.showQuitButton) return;
307
- const queueId = state.queueId;
215
+ if (!active || state.phase !== "ChampSelect" || !state.queue || state.queue.isCustom) return;
308
216
  const token = ++penaltyToken;
309
- const penalty = await computePenalty(queueId);
310
- if (!active || token !== penaltyToken || queueId !== state.queueId) return;
217
+ const penalty = await computePenalty(state.queue);
218
+ if (!active || token !== penaltyToken) return;
311
219
  state.penalty = penalty;
312
220
  render();
313
221
  }
314
222
 
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
- }
223
+ /* ---------------------------------------------------------------------------------------------
224
+ * Button: the client's own flat button, on its own row above the chat / missions / voice buttons.
225
+ * ------------------------------------------------------------------------------------------- */
371
226
 
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
- }
227
+ function shouldShow() {
228
+ return active && state.phase === "ChampSelect" && state.queue && !state.queue.isCustom && !state.showQuitButton;
420
229
  }
421
230
 
422
- function onButtonClick(event) {
423
- event.preventDefault();
424
- event.stopPropagation();
425
- quitOrDodge();
231
+ function buttonLabel() {
232
+ if (state.busy) return "Leaving...";
233
+ if (state.closeOffered) return state.armed ? "Confirm close" : "Close client";
234
+ if (state.armed) return "Confirm";
235
+ return isRankedQueue(state.queue) ? "Dodge" : "Quit";
426
236
  }
427
237
 
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;
238
+ // Sits on its own row right above the chat / missions / voice buttons, exactly as wide as that group.
239
+ function place(root, footer) {
240
+ const chat = footer.querySelector("lol-social-chat-toggle-button");
241
+ const voice = footer.querySelector(".champ-select-voice-button-wrapper");
242
+ const box = footer.getBoundingClientRect();
243
+ const left = Math.round((chat ?? footer).getBoundingClientRect().left);
244
+ const right = Math.round((voice ?? footer).getBoundingClientRect().right);
245
+ const top = Math.round(box.top - 32 - 8);
246
+ const width = Math.max(96, right - left);
247
+ const key = `${left}:${top}:${width}`;
248
+ if (root.dataset.place === key) return;
249
+ root.dataset.place = key;
250
+ root.style.left = `${left}px`;
251
+ root.style.top = `${top}px`;
252
+ root.style.width = `${width}px`;
436
253
  }
437
254
 
438
- function ensureMount() {
439
- if (state.phase !== "ChampSelect" || isCustomQueue() || state.showQuitButton) {
440
- unmount();
255
+ function render() {
256
+ const footer = document.querySelector(FOOTER_SELECTOR);
257
+ let root = document.querySelector(`.${ROOT_CLASS}`);
258
+ if (!shouldShow() || !footer) {
259
+ root?.remove();
441
260
  return;
442
261
  }
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();
262
+ if (!root || !root.isConnected) {
263
+ root?.remove();
264
+ root = document.createElement("div");
265
+ root.className = ROOT_CLASS;
266
+ const cost = document.createElement("div");
267
+ cost.className = "perf-dodge-cost";
268
+ const button = document.createElement("lol-uikit-flat-button");
269
+ button.addEventListener("click", onClick);
270
+ root.append(cost, button);
271
+ document.body.append(root);
272
+ }
273
+ place(root, footer);
274
+ const button = root.querySelector("lol-uikit-flat-button");
275
+ const cost = root.querySelector(".perf-dodge-cost");
276
+ const label = buttonLabel();
277
+ if (button.textContent !== label) button.textContent = label;
278
+ if (state.busy) button.setAttribute("disabled", "true");
279
+ else button.removeAttribute("disabled");
280
+ root.classList.toggle("perf-dodge-armed", state.armed);
281
+ const text = costText(state.penalty);
282
+ if (cost.textContent !== text) cost.textContent = text;
283
+ const title = state.closeOffered
284
+ ? "Riot doesn't let the client leave this champ select (ranked, or the game is about to start). Closing the client dodges; reopen League afterwards."
285
+ : state.armed
286
+ ? "Click again to leave champ select."
287
+ : costTitle(state.penalty);
288
+ if (root.title !== title) root.title = title;
452
289
  }
453
290
 
454
- function unmount() {
455
- document.getElementById(ROOT_ID)?.remove();
456
- document.getElementById(LAYER_ID)?.remove();
291
+ function disarm() {
292
+ clearTimeout(armedTimer);
293
+ armedTimer = null;
294
+ state.armed = false;
457
295
  }
458
296
 
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";
297
+ function onClick(event) {
298
+ event.preventDefault();
299
+ event.stopPropagation();
300
+ if (state.busy || !shouldShow()) return;
301
+ // Two clicks: a stray click can't cost LP.
302
+ if (!state.armed) {
303
+ state.armed = true;
304
+ clearTimeout(armedTimer);
305
+ armedTimer = setTimeout(() => {
306
+ disarm();
307
+ render();
308
+ }, CONFIRM_MS);
309
+ render();
480
310
  return;
481
311
  }
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);
312
+ disarm();
313
+ if (state.closeOffered) closeClient();
314
+ else leave();
508
315
  }
509
316
 
510
- function onWindowResize() {
511
- schedulePosition();
512
- observeLayout();
317
+ function closeClient() {
318
+ state.busy = true;
319
+ render();
320
+ recordOwnDodge();
321
+ fetch(CLOSE_CLIENT_URI, { method: "POST" }).catch(() => {});
513
322
  }
514
323
 
515
- function onPageHide() {
516
- if (!active || state.phase !== "ChampSelect" || isCustomQueue()) return;
517
- state.pageHiding = true;
518
- savePending(true);
324
+ async function leave() {
325
+ state.busy = true;
326
+ render();
327
+ try {
328
+ const response = await fetch(QUIT_URI, { method: "POST" });
329
+ if (!response.ok) {
330
+ state.closeOffered = true;
331
+ state.armed = true;
332
+ clearTimeout(armedTimer);
333
+ armedTimer = setTimeout(() => {
334
+ disarm();
335
+ render();
336
+ }, CONFIRM_MS * 2);
337
+ throw new Error(`quit responded ${response.status}`);
338
+ }
339
+ state.quitSent = true;
340
+ if (!state.queue?.isCustom) recordOwnDodge();
341
+ } catch (error) {
342
+ console.warn("[Dodge Helper] Could not leave champ select:", error);
343
+ } finally {
344
+ state.busy = false;
345
+ render();
346
+ }
519
347
  }
520
348
 
521
- function syncWatching() {
522
- if (state.phase === "ChampSelect" && !isCustomQueue()) startDodgeWatcher();
523
- else removeDodgeWatcher();
524
- }
349
+ /* ---------------------------------------------------------------------------------------------
350
+ * Gameflow
351
+ * ------------------------------------------------------------------------------------------- */
525
352
 
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
- );
353
+ async function loadSummoner() {
354
+ if (state.summoner) return state.summoner;
355
+ const summoner = await api.lcu.get(SUMMONER_URI).catch(() => null);
356
+ if (summoner && active) state.summoner = { puuid: summoner.puuid || "", summonerId: summoner.summonerId || 0 };
357
+ return state.summoner;
530
358
  }
531
359
 
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
- });
360
+ function migrateLegacyStore() {
361
+ try {
362
+ const legacy = JSON.parse(localStorage.getItem(LEGACY_STORE_KEY) || "null");
363
+ if (!legacy || typeof legacy !== "object") return;
364
+ const store = readStore();
365
+ for (const [key, record] of Object.entries(legacy)) {
366
+ if (!store[key] && typeof record?.tier === "number" && typeof record?.at === "number") store[key] = record;
563
367
  }
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
- });
368
+ writeStore(store);
369
+ localStorage.removeItem(LEGACY_STORE_KEY);
370
+ localStorage.removeItem("perfectionist:dodge-helper:pending:v1");
371
+ } catch {}
597
372
  }
598
373
 
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 });
374
+ function applySearchState(search) {
375
+ if (!search || !state.summoner) return;
376
+ for (const error of Array.isArray(search.errors) ? search.errors : []) {
377
+ if (!/DODGE/i.test(String(error?.errorType ?? ""))) continue;
378
+ const who = Number(error.penalizedSummonerId) || 0;
379
+ if (who && state.summoner.summonerId && who !== state.summoner.summonerId) continue;
380
+ learnFromPenalty(error.penaltyTimeRemaining);
618
381
  }
619
- clearPending();
620
382
  }
621
383
 
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();
384
+ async function checkPenaltyFromServer() {
385
+ const search = await api.lcu.get(SEARCH_STATE_URI).catch(() => null);
386
+ if (active) applySearchState(search);
637
387
  }
638
388
 
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();
389
+ async function onChampSelectEnded(nextPhase) {
390
+ const gameflow = await api.lcu.get(GAMEFLOW_URI).catch(() => null);
391
+ const dodgers = (gameflow?.gameDodge?.dodgeIds ?? []).map(Number);
392
+ const me = Number(state.summoner?.summonerId) || 0;
393
+ // A missed lock-in or a closed client is a dodge too; the gameflow names who dodged.
394
+ if (me && dodgers.includes(me) && !state.quitSent && !state.queue?.isCustom) recordOwnDodge();
395
+ state.quitSent = false;
396
+ if (nextPhase === "Lobby" || nextPhase === "None") setTimeout(() => active && checkPenaltyFromServer(), 1500);
647
397
  }
648
398
 
649
- async function handlePhase(raw) {
399
+ async function onPhase(raw) {
650
400
  if (!active) return;
651
- const previous = state.phase;
652
- const phase = typeof raw === "string" ? raw : unwrap(raw);
401
+ const phase = typeof raw === "string" ? raw : raw?.data;
653
402
  if (typeof phase !== "string" || !phase) return;
403
+ const previous = state.phase;
654
404
  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),
405
+ if (previous === "ChampSelect" && phase !== "ChampSelect") onChampSelectEnded(phase);
406
+ if (phase === "ChampSelect" && previous !== "ChampSelect") {
407
+ disarm();
408
+ state.penalty = null;
409
+ state.quitSent = false;
410
+ await loadSummoner();
411
+ const [gameflow, champSelect] = await Promise.all([
412
+ api.lcu.get(GAMEFLOW_URI).catch(() => null),
669
413
  api.lcu.get(CHAMP_SELECT_URI).catch(() => null),
670
- api.lcu.get(TIMER_URI).catch(() => null),
671
414
  ]);
672
- if (!active) return;
673
- if (session) applySession(session);
674
- if (champSelect) applyChampSelect(champSelect);
675
- if (timer) syncTimer(timer);
676
- ensureMount();
415
+ if (!active || state.phase !== "ChampSelect") return;
416
+ if (gameflow) state.queue = queueOf(gameflow) ?? state.queue;
417
+ if (champSelect) state.showQuitButton = Boolean(champSelect.showQuitButton);
677
418
  refreshPenalty();
678
- savePending();
679
- } else {
680
- syncWatching();
681
- unmount();
682
- state.penalty = null;
419
+ }
420
+ if (phase !== "ChampSelect") {
421
+ disarm();
683
422
  state.showQuitButton = false;
684
- if (isGamePhase(phase) || phase === "Lobby" || phase === "None") clearPending();
423
+ state.closeOffered = false;
685
424
  }
425
+ render();
686
426
  }
687
427
 
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);
428
+ function onGameflow(message) {
429
+ const queue = queueOf(message?.data ?? message);
430
+ if (!queue) return;
431
+ const changed = !state.queue || queue.id !== state.queue.id || queue.type !== state.queue.type;
432
+ state.queue = queue;
433
+ if (changed && state.phase === "ChampSelect") refreshPenalty();
434
+ render();
699
435
  }
700
436
 
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;
437
+ function onChampSelect(message) {
438
+ const session = message?.data ?? message;
439
+ if (!session || typeof session !== "object" || message?.eventType === "Delete") return;
440
+ const show = Boolean(session.showQuitButton);
441
+ if (show !== state.showQuitButton) {
442
+ state.showQuitButton = show;
714
443
  render();
715
444
  }
716
445
  }
717
446
 
718
- function clearLegacyAnchor() {
719
- for (const element of document.querySelectorAll(".perf-dodge-anchor")) {
720
- element.classList.remove("perf-dodge-anchor");
721
- }
447
+ function onSearchState(message) {
448
+ applySearchState(message?.data ?? message);
449
+ }
450
+
451
+ function onFrame() {
452
+ // Ember can re-render the footer; put the button back if it went missing.
453
+ if (state.phase !== "ChampSelect") return;
454
+ const footer = document.querySelector(FOOTER_SELECTOR);
455
+ const root = document.querySelector(`.${ROOT_CLASS}`);
456
+ if (shouldShow() ? !(root && footer) : root) render();
457
+ else if (root && footer) place(root, footer);
722
458
  }
723
459
 
724
460
  async function onEnable(utils) {
725
461
  api = utils;
726
462
  active = true;
727
- unmount();
728
- clearLegacyAnchor();
463
+ migrateLegacyStore();
729
464
  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 {}
465
+ const handlers = [
466
+ [PHASE_URI, onPhase],
467
+ [GAMEFLOW_URI, onGameflow],
468
+ [CHAMP_SELECT_URI, onChampSelect],
469
+ [SEARCH_STATE_URI, onSearchState],
470
+ ];
471
+ for (const [uri, handler] of handlers) {
472
+ const off = utils.subscribe(uri, handler);
473
+ if (off) unsubscribes.push(off);
756
474
  }
475
+ unwatchFooter = utils.watch(FOOTER_SELECTOR, () => render());
476
+ removeFrameHook = utils.onFrame ? utils.onFrame(onFrame) : null;
477
+ window.addEventListener("resize", render);
757
478
 
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();
479
+ await loadSummoner();
480
+ if (!active) return;
481
+ const phase = await utils.lcu.get(PHASE_URI).catch(() => null);
482
+ if (!active) return;
483
+ if (phase === "Lobby" || phase === "None") checkPenaltyFromServer();
484
+ if (typeof phase === "string") await onPhase(phase);
764
485
  }
765
486
 
766
487
  function onDisable() {
@@ -771,44 +492,27 @@ function onDisable() {
771
492
  } catch {}
772
493
  }
773
494
  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();
495
+ unwatchFooter?.();
496
+ unwatchFooter = null;
497
+ removeFrameHook?.();
498
+ removeFrameHook = null;
499
+ window.removeEventListener("resize", render);
500
+ disarm();
501
+ document.querySelector(`.${ROOT_CLASS}`)?.remove();
792
502
  removeStyle?.();
793
503
  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;
504
+ Object.assign(state, {
505
+ phase: null,
506
+ queue: null,
507
+ showQuitButton: false,
508
+ summoner: null,
509
+ penalty: null,
510
+ armed: false,
511
+ busy: false,
512
+ quitSent: false,
513
+ closeOffered: false,
514
+ });
515
+ penaltyToken += 1;
812
516
  api = null;
813
517
  }
814
518
 
@@ -818,7 +522,7 @@ export function init() {
818
522
  id: PLUGIN_ID,
819
523
  name: "Dodge Helper",
820
524
  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.",
525
+ "Adds a DODGE button to champ select (QUIT outside ranked) that leaves cleanly, showing the LP and queue lockout your next dodge will cost.",
822
526
  version,
823
527
  onEnable,
824
528
  onDisable,
@@ -829,7 +533,5 @@ export function init() {
829
533
  registerWith(window.PerfectionistUtils);
830
534
  return;
831
535
  }
832
- addEventListener("perfectionist:utils-ready", (event) => registerWith(event.detail ?? window.PerfectionistUtils), {
833
- once: true,
834
- });
536
+ addEventListener("perfectionist:utils-ready", (event) => registerWith(event.detail ?? window.PerfectionistUtils), { once: true });
835
537
  }