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