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/README.md +52 -52
- package/auto-accept.js +296 -294
- package/auto-claimer.js +1 -37
- package/auto-read.js +107 -42
- package/ban-blocker.js +483 -732
- package/better-client.js +362 -793
- package/dodge-helper.js +377 -713
- package/hide-unowned-skins.js +63 -687
- package/package.json +40 -40
- package/perfectionist-utils.js +539 -301
- package/unlock-profile-skins.js +173 -652
package/dodge-helper.js
CHANGED
|
@@ -1,143 +1,79 @@
|
|
|
1
1
|
const PLUGIN_ID = "dodge-helper";
|
|
2
|
-
export const version = "
|
|
2
|
+
export const version = "2.0.0";
|
|
3
3
|
|
|
4
4
|
const PHASE_URI = "/lol-gameflow/v1/gameflow-phase";
|
|
5
|
-
const
|
|
5
|
+
const GAMEFLOW_URI = "/lol-gameflow/v1/session";
|
|
6
6
|
const CHAMP_SELECT_URI = "/lol-champ-select/v1/session";
|
|
7
|
-
const
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const
|
|
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
|
|
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
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
49
|
+
let unwatchFooter = null;
|
|
50
|
+
let removeFrameHook = null;
|
|
87
51
|
let removeStyle = null;
|
|
88
|
-
let
|
|
89
|
-
let mountFrame = 0;
|
|
90
|
-
let positionFrame = 0;
|
|
91
|
-
let networkWatch = null;
|
|
52
|
+
let armedTimer = null;
|
|
92
53
|
let penaltyToken = 0;
|
|
93
|
-
|
|
94
|
-
let accountPromise = null;
|
|
54
|
+
|
|
95
55
|
const state = {
|
|
96
56
|
phase: null,
|
|
97
|
-
|
|
98
|
-
isCustom: false,
|
|
57
|
+
queue: null,
|
|
99
58
|
showQuitButton: false,
|
|
100
|
-
|
|
101
|
-
account: null,
|
|
59
|
+
summoner: null,
|
|
102
60
|
penalty: null,
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
138
|
-
} catch {
|
|
139
|
-
|
|
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
|
|
150
|
-
|
|
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
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
172
|
-
const
|
|
173
|
-
if (!
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
148
|
+
/* ---------------------------------------------------------------------------------------------
|
|
149
|
+
* Penalty preview
|
|
150
|
+
* ------------------------------------------------------------------------------------------- */
|
|
211
151
|
|
|
212
|
-
function
|
|
213
|
-
const
|
|
214
|
-
if (
|
|
215
|
-
return
|
|
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
|
|
219
|
-
return
|
|
164
|
+
function isAram(queue) {
|
|
165
|
+
return /ARAM/i.test(queue?.type ?? "") || /^ARAM$/i.test(queue?.gameMode ?? "");
|
|
220
166
|
}
|
|
221
167
|
|
|
222
|
-
|
|
223
|
-
|
|
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 >=
|
|
243
|
-
|
|
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(
|
|
177
|
+
async function computePenalty(queue) {
|
|
248
178
|
const tier = Math.min(currentTier() + 1, 3);
|
|
249
|
-
const aram =
|
|
250
|
-
const lockout = aram
|
|
251
|
-
const
|
|
252
|
-
if (!
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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 ?
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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.
|
|
295
|
-
if (!penalty.
|
|
296
|
-
if (penalty.
|
|
297
|
-
if (penalty.
|
|
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.
|
|
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(
|
|
325
|
-
if (!active || token !== penaltyToken
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
|
378
|
-
|
|
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
|
|
388
|
-
|
|
389
|
-
if (
|
|
390
|
-
state.
|
|
391
|
-
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
|
466
|
-
|
|
467
|
-
|
|
291
|
+
function disarm() {
|
|
292
|
+
clearTimeout(armedTimer);
|
|
293
|
+
armedTimer = null;
|
|
294
|
+
state.armed = false;
|
|
468
295
|
}
|
|
469
296
|
|
|
470
|
-
function
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
if (
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
|
511
|
-
|
|
512
|
-
|
|
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
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
}
|
|
349
|
+
/* ---------------------------------------------------------------------------------------------
|
|
350
|
+
* Gameflow
|
|
351
|
+
* ------------------------------------------------------------------------------------------- */
|
|
547
352
|
|
|
548
|
-
function
|
|
549
|
-
|
|
550
|
-
|
|
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
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
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
|
|
645
|
-
|
|
646
|
-
|
|
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
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
state.
|
|
665
|
-
|
|
666
|
-
state.
|
|
667
|
-
|
|
668
|
-
|
|
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
|
|
399
|
+
async function onPhase(raw) {
|
|
672
400
|
if (!active) return;
|
|
673
|
-
const
|
|
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 (
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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 (
|
|
696
|
-
if (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
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
unmount();
|
|
704
|
-
state.penalty = null;
|
|
419
|
+
}
|
|
420
|
+
if (phase !== "ChampSelect") {
|
|
421
|
+
disarm();
|
|
705
422
|
state.showQuitButton = false;
|
|
706
|
-
|
|
423
|
+
state.closeOffered = false;
|
|
707
424
|
}
|
|
425
|
+
render();
|
|
708
426
|
}
|
|
709
427
|
|
|
710
|
-
function
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
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
|
|
757
|
-
|
|
758
|
-
|
|
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
|
-
|
|
766
|
-
clearLegacyAnchor();
|
|
463
|
+
migrateLegacyStore();
|
|
767
464
|
removeStyle = utils.addStyle(STYLE_ID, CSS);
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
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
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
if (
|
|
801
|
-
|
|
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
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
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
|
|
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
|
-
|
|
872
|
-
});
|
|
873
|
-
}
|
|
536
|
+
addEventListener("perfectionist:utils-ready", (event) => registerWith(event.detail ?? window.PerfectionistUtils), { once: true });
|
|
537
|
+
}
|