vimp-engine 0.37.1 → 0.38.1

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.
Files changed (49) hide show
  1. package/package.json +1 -1
  2. package/src/client/boot.js +1 -1
  3. package/src/client/components/controller/ClientReports.js +2 -1
  4. package/src/client/components/controller/Games.js +1 -1
  5. package/src/client/components/controller/LobbyAuth.js +101 -15
  6. package/src/client/components/controller/Profile.js +95 -0
  7. package/src/client/components/model/Games.js +1 -1
  8. package/src/client/components/model/LobbyAuth.js +382 -115
  9. package/src/client/components/model/Profile.js +144 -0
  10. package/src/client/components/view/LobbyAuth.js +150 -62
  11. package/src/client/components/view/Profile.js +372 -0
  12. package/src/client/lib/authApi.js +106 -0
  13. package/src/client/lib/hostCaps.js +3 -2
  14. package/src/client/lib/pkce.js +23 -0
  15. package/src/client/lib/roomLink.js +1 -1
  16. package/src/client/lib/sessionSync.js +45 -0
  17. package/src/client/lib/signalingErrors.js +3 -3
  18. package/src/client/lib/turnstile.js +130 -0
  19. package/src/client/main.js +132 -26
  20. package/src/client/network/TokenRenewer.js +99 -0
  21. package/src/client/session/GuestSession.js +63 -17
  22. package/src/client/session/Membership.js +2 -1
  23. package/src/client/style.css +128 -16
  24. package/src/client/views/includes/lobby.pug +11 -6
  25. package/src/client/views/includes/lobbyAuth.pug +34 -16
  26. package/src/client/views/includes/profile.pug +73 -0
  27. package/src/client/views/index.pug +1 -0
  28. package/src/config/authClient.js +55 -30
  29. package/src/config/chatMessages.js +1 -2
  30. package/src/config/env.js +2 -2
  31. package/src/config/lobby.js +45 -1
  32. package/src/config/master.js +8 -5
  33. package/src/config/wsports.js +4 -1
  34. package/src/devtools/builtinScenario.js +1 -1
  35. package/src/devtools/contract/rules/a2-package-scripts.js +1 -1
  36. package/src/devtools/contract/rules/a4-vite-config.js +1 -1
  37. package/src/devtools/contract/rules/b8-system-messages.js +1 -1
  38. package/src/devtools/invariants.js +1 -1
  39. package/src/devtools/pluginLoader.js +1 -1
  40. package/src/host/GameCoreAdapter.js +0 -3
  41. package/src/host/HostGame.js +28 -0
  42. package/src/host/PortMachine.js +50 -1
  43. package/src/host/meta/SocketManager.js +0 -13
  44. package/src/host/meta/core/RoundManager.js +0 -19
  45. package/src/host/meta/modules/PlayerDataSync.js +29 -3
  46. package/src/host/meta/modules/chat/systemMessages.js +1 -4
  47. package/src/lib/loadGamePackage.js +1 -1
  48. package/src/lib/validators.js +8 -0
  49. package/src/standalone/index.js +1 -1
@@ -0,0 +1,372 @@
1
+ import Publisher from '../../../lib/Publisher.js';
2
+
3
+ // Singleton ProfileView
4
+
5
+ let profileView;
6
+
7
+ // Тексты ошибок по областям и кодам auth: модель кодов не переводит, вид не
8
+ // ходит в сеть. Язык интерфейса — английский, как у остального лобби
9
+ const GENERIC_ERROR = 'Something went wrong. Please try again.';
10
+
11
+ const LINK_ERRORS = {
12
+ identityInUse:
13
+ 'This account is already linked to another profile. To use that profile, delete this one and choose Restore profile on the Welcome screen.',
14
+ providerAlreadyLinked:
15
+ 'An account of this provider is already linked to your profile.',
16
+ denied: 'Linking was cancelled.',
17
+ };
18
+
19
+ const DELETE_ERRORS = {
20
+ confirmMismatch: 'The nick does not match.',
21
+ adminAccount: 'Admin profiles cannot be deleted.',
22
+ };
23
+
24
+ const AREA_ERRORS = {
25
+ link: code => LINK_ERRORS[code] ?? GENERIC_ERROR,
26
+ session: () => 'Could not sign out. Please try again.',
27
+ export: () => 'Could not prepare the file. Please try again.',
28
+ delete: code =>
29
+ DELETE_ERRORS[code] ?? 'Could not delete the profile. Please try again.',
30
+ };
31
+
32
+ const LINK_SUCCESS =
33
+ 'Account linked. You can now restore this profile in any browser.';
34
+ const NO_PROVIDERS = 'Account linking is not available on this server.';
35
+
36
+ // Представление панели профиля: статус, привязка аккаунтов с тремя
37
+ // согласиями, выход, выгрузка данных, удаление с вводом ника. В сеть не
38
+ // ходит и состояния не держит — рисует то, что пришло от модели, и
39
+ // публикует намерения пользователя. Текст в DOM — только через textContent.
40
+ //
41
+ // События: open, close, link <provider>, unlink <provider>, sign-out,
42
+ // export, delete <введённый ник>.
43
+ export default class ProfileView {
44
+ /**
45
+ * @param {Object} model - ProfileModel (источник событий и данных).
46
+ * @param {Object} config - Блок `profile` конфига лобби.
47
+ */
48
+ constructor(model, config) {
49
+ if (profileView) {
50
+ return profileView;
51
+ }
52
+
53
+ profileView = this;
54
+
55
+ const { elems } = config;
56
+ const byId = id => document.getElementById(id);
57
+
58
+ this._model = model;
59
+ this._config = config;
60
+ this._elems = elems;
61
+
62
+ this._panel = byId(elems.panelId);
63
+ this._lobby = byId(elems.lobbyId);
64
+ this._open = byId(elems.openBtnId);
65
+ this._close = byId(elems.closeBtnId);
66
+ this._nick = byId(elems.nickId);
67
+ this._status = byId(elems.statusId);
68
+ this._benefits = byId(elems.benefitsId);
69
+ this._providers = byId(elems.providersId);
70
+ this._consent = byId(elems.consentId);
71
+ this._checks = [
72
+ byId(elems.consentTermsId),
73
+ byId(elems.consentDataId),
74
+ byId(elems.consentAgeId),
75
+ ];
76
+ this._linkNotice = byId(elems.linkNoticeId);
77
+ this._linkError = byId(elems.linkErrorId);
78
+ this._sessionCard = byId(elems.sessionCardId);
79
+ this._signOut = byId(elems.signOutBtnId);
80
+ this._exportBtn = byId(elems.exportBtnId);
81
+ this._deleteAdminNote = byId(elems.deleteAdminNoteId);
82
+ this._deleteStart = byId(elems.deleteStartBtnId);
83
+ this._deleteConfirm = byId(elems.deleteConfirmId);
84
+ this._deleteNick = byId(elems.deleteNickId);
85
+ this._deleteInput = byId(elems.deleteInputId);
86
+ this._deleteSubmit = byId(elems.deleteSubmitBtnId);
87
+ this._deleteCancel = byId(elems.deleteCancelBtnId);
88
+
89
+ // строка ошибки каждой области — в своей карточке
90
+ this._errorLines = {
91
+ link: this._linkError,
92
+ session: byId(elems.sessionErrorId),
93
+ export: byId(elems.exportErrorId),
94
+ delete: byId(elems.deleteErrorId),
95
+ };
96
+
97
+ this._linkBtns = [];
98
+ this._unlinkBtns = [];
99
+ this._busy = false;
100
+
101
+ this.publisher = new Publisher();
102
+
103
+ this._open.onclick = () => this.publisher.emit('open');
104
+ this._close.onclick = () => this.hide();
105
+ this._signOut.onclick = () => this.publisher.emit('sign-out');
106
+ this._exportBtn.onclick = () => this.publisher.emit('export');
107
+
108
+ this._checks.forEach(check => {
109
+ check.onchange = () => this._syncControls();
110
+ });
111
+
112
+ this._deleteStart.onclick = () => {
113
+ this._deleteConfirm.style.display = 'block';
114
+ this._deleteInput.focus();
115
+ };
116
+ this._deleteCancel.onclick = () => this._collapseDelete();
117
+ this._deleteInput.oninput = () => this._syncControls();
118
+ this._deleteSubmit.onclick = () =>
119
+ this.publisher.emit('delete', this._deleteInput.value);
120
+
121
+ model.publisher.on('profile', () => this.render());
122
+ model.publisher.on('error', 'renderError', this);
123
+ model.publisher.on('export', 'download', this);
124
+ model.publisher.on('busy', busy => {
125
+ this._busy = busy;
126
+ this._syncControls();
127
+ });
128
+
129
+ this._renderLegalLinks();
130
+ }
131
+
132
+ // панель вместо #lobby; профиль рисуется заново, чекбоксы и блок
133
+ // удаления сброшены
134
+ show() {
135
+ this._panel.style.display = 'flex';
136
+ this._lobby.style.display = 'none';
137
+
138
+ this._checks.forEach(check => {
139
+ check.checked = false;
140
+ });
141
+ this._collapseDelete();
142
+ this._clearMessages();
143
+ this.render();
144
+ }
145
+
146
+ hide() {
147
+ // закрытой панели закрывать нечего
148
+ if (this._panel.style.display === 'none') {
149
+ return;
150
+ }
151
+
152
+ this._panel.style.display = 'none';
153
+ this._lobby.style.display = 'flex';
154
+ }
155
+
156
+ // true — все три согласия отмечены
157
+ consentGiven() {
158
+ return this._checks.every(check => check.checked);
159
+ }
160
+
161
+ render() {
162
+ const profile = this._model.getProfile();
163
+ const clientConfig = this._model.getClientConfig();
164
+
165
+ if (!profile || !clientConfig) {
166
+ return;
167
+ }
168
+
169
+ const titles = this._config.providerTitles;
170
+ const title = provider => titles[provider] ?? provider;
171
+ const { inactiveDays, inactiveDaysOAuth } = clientConfig.accounts;
172
+ const linked = new Map(profile.providers.map(p => [p.provider, p]));
173
+
174
+ this._nick.textContent = profile.nick;
175
+ this._deleteNick.textContent = profile.nick;
176
+
177
+ this._renderStatus(profile, title);
178
+ this._renderBenefits(inactiveDays, inactiveDaysOAuth);
179
+
180
+ this._providers.replaceChildren();
181
+ this._linkBtns = [];
182
+ this._unlinkBtns = [];
183
+
184
+ if (clientConfig.providers.length === 0) {
185
+ this._providers.textContent = NO_PROVIDERS;
186
+ }
187
+
188
+ clientConfig.providers.forEach(provider => {
189
+ this._providers.append(
190
+ this._renderProviderRow(
191
+ provider,
192
+ title(provider),
193
+ linked.get(provider),
194
+ ),
195
+ );
196
+ });
197
+
198
+ // согласия нужны, пока есть что привязывать
199
+ this._consent.style.display = clientConfig.providers.some(
200
+ provider => !linked.has(provider),
201
+ )
202
+ ? 'block'
203
+ : 'none';
204
+
205
+ this._sessionCard.style.display =
206
+ profile.providers.length > 0 ? 'block' : 'none';
207
+
208
+ const deletable = profile.deletable !== false;
209
+
210
+ this._deleteAdminNote.style.display = deletable ? 'none' : 'block';
211
+ this._deleteStart.style.display = deletable ? '' : 'none';
212
+
213
+ if (!deletable) {
214
+ this._collapseDelete();
215
+ }
216
+
217
+ this._renderLegalLinks();
218
+ this._syncControls();
219
+ }
220
+
221
+ _renderStatus(profile, title) {
222
+ const days = profile.inactiveDays;
223
+
224
+ if (profile.role === 'admin') {
225
+ this._status.textContent =
226
+ 'Admin profile. It is not deleted for inactivity.';
227
+ } else if (profile.providers.length === 0) {
228
+ this._status.textContent = `Not linked. This profile exists only in this browser and is deleted after ${days} days of inactivity.`;
229
+ } else {
230
+ const names = profile.providers.map(p => title(p.provider)).join(', ');
231
+
232
+ this._status.textContent = `Linked: ${names}. The profile is deleted after ${days} days of inactivity.`;
233
+ }
234
+ }
235
+
236
+ _renderBenefits(baseDays, oauthDays) {
237
+ const items = [
238
+ 'Restore your profile in any browser or on any device',
239
+ `The profile is kept for ${oauthDays} days of inactivity instead of ${baseDays}`,
240
+ 'Sign out and sign back in',
241
+ ];
242
+
243
+ this._benefits.replaceChildren(
244
+ ...items.map(text => {
245
+ const li = document.createElement('li');
246
+
247
+ li.textContent = text;
248
+
249
+ return li;
250
+ }),
251
+ );
252
+ }
253
+
254
+ _renderProviderRow(provider, title, link) {
255
+ const row = document.createElement('div');
256
+ const btn = document.createElement('input');
257
+
258
+ btn.type = 'button';
259
+ row.className = 'profile-provider';
260
+
261
+ if (link) {
262
+ const label = document.createElement('span');
263
+
264
+ label.textContent = `${title} — linked on ${new Date(link.linkedAt).toLocaleDateString()}`;
265
+ btn.value = 'Unlink';
266
+ btn.onclick = () => this.publisher.emit('unlink', provider);
267
+ this._unlinkBtns.push(btn);
268
+ row.append(label, btn);
269
+ } else {
270
+ btn.value = `Link ${title}`;
271
+ btn.onclick = () => this.publisher.emit('link', provider);
272
+ this._linkBtns.push(btn);
273
+ row.append(btn);
274
+ }
275
+
276
+ return row;
277
+ }
278
+
279
+ // ссылки на условия и политику: в панели и в футере лобби
280
+ _renderLegalLinks() {
281
+ const legal = this._model.getClientConfig()?.legal;
282
+
283
+ if (!legal) {
284
+ return;
285
+ }
286
+
287
+ const elems = this._elems;
288
+ const set = (nodes, href) => {
289
+ nodes.forEach(node => {
290
+ if (node) {
291
+ node.href = href;
292
+ }
293
+ });
294
+ };
295
+
296
+ set(
297
+ [
298
+ ...this._panel.getElementsByClassName(elems.termsLinkClass),
299
+ document.getElementById(elems.footerTermsId),
300
+ ],
301
+ legal.termsUrl,
302
+ );
303
+ set(
304
+ [
305
+ ...this._panel.getElementsByClassName(elems.privacyLinkClass),
306
+ document.getElementById(elems.footerPrivacyId),
307
+ ],
308
+ legal.privacyUrl,
309
+ );
310
+ }
311
+
312
+ // доступность кнопок от согласий, ввода ника и занятости (запрос идёт)
313
+ _syncControls() {
314
+ const busy = this._busy;
315
+ const nick = this._model.getProfile()?.nick;
316
+ const consent = this.consentGiven();
317
+
318
+ this._linkBtns.forEach(btn => {
319
+ btn.disabled = busy || !consent;
320
+ });
321
+ this._unlinkBtns.forEach(btn => {
322
+ btn.disabled = busy;
323
+ });
324
+ this._signOut.disabled = busy;
325
+ this._exportBtn.disabled = busy;
326
+ this._deleteSubmit.disabled =
327
+ busy || !nick || this._deleteInput.value !== nick;
328
+ }
329
+
330
+ _collapseDelete() {
331
+ this._deleteConfirm.style.display = 'none';
332
+ this._deleteInput.value = '';
333
+ this._syncControls();
334
+ }
335
+
336
+ _clearMessages() {
337
+ this._linkNotice.textContent = '';
338
+
339
+ Object.values(this._errorLines).forEach(line => {
340
+ line.textContent = '';
341
+ });
342
+ }
343
+
344
+ // итог привязки, завершённой при возврате с провайдера
345
+ renderLinkResult({ ok, code }) {
346
+ this._clearMessages();
347
+
348
+ if (ok) {
349
+ this._linkNotice.textContent = LINK_SUCCESS;
350
+ } else {
351
+ this._linkError.textContent = AREA_ERRORS.link(code);
352
+ }
353
+ }
354
+
355
+ renderError({ area, code }) {
356
+ this._clearMessages();
357
+ this._errorLines[area].textContent = AREA_ERRORS[area](code);
358
+ }
359
+
360
+ // выгрузка данных профиля файлом
361
+ download({ data, fileName }) {
362
+ const url = URL.createObjectURL(
363
+ new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }),
364
+ );
365
+ const link = document.createElement('a');
366
+
367
+ link.href = url;
368
+ link.download = fileName;
369
+ link.click();
370
+ URL.revokeObjectURL(url);
371
+ }
372
+ }
@@ -0,0 +1,106 @@
1
+ // REST-клиент auth-сервиса (packages/auth): регистрация, сессия, OAuth,
2
+ // управление профилем. Без DOM; fetch инъектируется (тесты).
3
+ //
4
+ // Каждый метод возвращает { ok: true, status, data } или
5
+ // { ok: false, status, error }: error — поле `error` тела ответа ('unknown',
6
+ // если его нет), 'network' — fetch бросил исключение (status 0).
7
+
8
+ const JSON_HEADERS = { 'Content-Type': 'application/json' };
9
+
10
+ // Единственный код, которым auth-сервис сообщает, что сессии нет.
11
+ // invalidSession («секрет не того формата») вердиктом не считается: клиент
12
+ // шлёт только секрет верного формата, и такой ответ значит, что заголовок
13
+ // Authorization не дошёл до сервиса (редирект, прокси, сбой сервиса). Как и
14
+ // 401 без кода, это сбой: секрет по нему не стирается — у профиля без
15
+ // привязки он единственный ключ
16
+ export const isSessionRejected = res =>
17
+ res.status === 401 && res.error === 'sessionUnknown';
18
+
19
+ /**
20
+ * @param {Object} options
21
+ * @param {string} options.serviceUrl - базовый URL auth-сервиса.
22
+ * @param {Function} [options.fetchImpl]
23
+ */
24
+ export function createAuthApi({
25
+ serviceUrl,
26
+ fetchImpl = (...args) => fetch(...args),
27
+ }) {
28
+ // куки не используются: личность — заголовок Authorization
29
+ async function request(method, path, { session, body } = {}) {
30
+ const headers = {};
31
+
32
+ if (body !== undefined) {
33
+ Object.assign(headers, JSON_HEADERS);
34
+ }
35
+
36
+ if (session) {
37
+ headers.Authorization = `Session ${session}`;
38
+ }
39
+
40
+ const init = { method, headers };
41
+
42
+ if (body !== undefined) {
43
+ init.body = JSON.stringify(body);
44
+ }
45
+
46
+ let res;
47
+
48
+ try {
49
+ res = await fetchImpl(`${serviceUrl}${path}`, init);
50
+ } catch {
51
+ return { ok: false, status: 0, error: 'network' };
52
+ }
53
+
54
+ const data = res.status === 204 ? null : await res.json().catch(() => null);
55
+
56
+ if (!res.ok) {
57
+ return {
58
+ ok: false,
59
+ status: res.status,
60
+ error: (data && data.error) || 'unknown',
61
+ };
62
+ }
63
+
64
+ return { ok: true, status: res.status, data };
65
+ }
66
+
67
+ return {
68
+ clientConfig: () => request('GET', '/client-config'),
69
+
70
+ register: ({ nick, turnstileToken, termsVersion }) =>
71
+ request('POST', '/register', {
72
+ body: { nick, turnstileToken, termsVersion },
73
+ }),
74
+
75
+ token: session => request('POST', '/token', { session }),
76
+
77
+ logout: session => request('POST', '/logout', { session }),
78
+
79
+ restore: ({ code, verifier, termsVersion }) =>
80
+ request('POST', '/session/restore', {
81
+ body: { code, verifier, termsVersion },
82
+ }),
83
+
84
+ linkOauth: (session, { code, verifier, consentVersion }) =>
85
+ request('POST', '/account/oauth', {
86
+ session,
87
+ body: { code, verifier, consentVersion },
88
+ }),
89
+
90
+ unlinkOauth: (session, provider) =>
91
+ request('DELETE', `/account/oauth/${encodeURIComponent(provider)}`, {
92
+ session,
93
+ }),
94
+
95
+ deleteAccount: (session, confirmNick) =>
96
+ request('DELETE', '/account', { session, body: { confirmNick } }),
97
+
98
+ exportData: session => request('GET', '/account/export', { session }),
99
+
100
+ // навигация вкладки, не fetch: URL отдаётся вызывающему
101
+ oauthStartUrl: (provider, { mode, returnUrl, challenge }) =>
102
+ `${serviceUrl}/oauth/${provider}/start?mode=${encodeURIComponent(mode)}` +
103
+ `&returnUrl=${encodeURIComponent(returnUrl)}` +
104
+ `&challenge=${encodeURIComponent(challenge)}`,
105
+ };
106
+ }
@@ -70,8 +70,9 @@ export function buildHostCaps(
70
70
 
71
71
  /**
72
72
  * Хватит ли срока токена, чтобы вкладка могла принять роль хоста: хост
73
- * предъявляет токен мастеру посреди матча (register_host, reclaim_host), а
74
- * продления нет.
73
+ * предъявляет токен мастеру посреди матча (register_host, reclaim_host).
74
+ * Токен обычно продлевается заранее; проверка — страховка на случай, когда
75
+ * продление не удаётся.
75
76
  * @param {number|null} expiresAt - мс эпохи; null — неизвестно (не мешает).
76
77
  * @param {number} minLifetimeMs
77
78
  * @param {number} [now]
@@ -0,0 +1,23 @@
1
+ // PKCE-подобная привязка результата OAuth к начавшей вкладке: verifier
2
+ // остаётся в её sessionStorage, на auth уходит только challenge.
3
+
4
+ const toBase64Url = bytes =>
5
+ btoa(String.fromCharCode(...bytes))
6
+ .replace(/\+/g, '-')
7
+ .replace(/\//g, '_')
8
+ .replace(/=+$/, '');
9
+
10
+ // 32 случайных байта → base64url (43 символа)
11
+ export function createVerifier(cryptoImpl = crypto) {
12
+ return toBase64Url(cryptoImpl.getRandomValues(new Uint8Array(32)));
13
+ }
14
+
15
+ // base64url(sha256(verifier)); Web Crypto, поэтому асинхронно
16
+ export async function challengeOf(verifier, cryptoImpl = crypto) {
17
+ const digest = await cryptoImpl.subtle.digest(
18
+ 'SHA-256',
19
+ new TextEncoder().encode(verifier),
20
+ );
21
+
22
+ return toBase64Url(new Uint8Array(digest));
23
+ }
@@ -5,7 +5,7 @@
5
5
  // #/<gameId>/<roomId> — прямой вход в комнату
6
6
  //
7
7
  // Маршрут живёт в hash: страница одна (SPA), сервер про маршруты не знает, а
8
- // OAuth-возврат hash сохраняет (returnUrl LobbyAuthModel.loginUrl). Решения
8
+ // OAuth-возврат hash сохраняет (returnUrl LobbyAuthModel.restoreUrl). Решения
9
9
  // «что делать с маршрутом» и «куда уходить из комнаты» — чистые функции:
10
10
  // main.js исполняется при импорте, и проверить ветвление внутри него нечем.
11
11
 
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Что делать вкладке лобби, когда в localStorage лежит не тот секрет
3
+ * сессии, с которым она работает: личность вкладки меняется только
4
+ * перезагрузкой.
5
+ * @param {Object} ctx
6
+ * @param {string|null} ctx.storedSession - секрет в хранилище.
7
+ * @param {string|null} ctx.currentSession - секрет, с которым работает вкладка.
8
+ * @param {boolean} ctx.inRoom - вкладка в комнате.
9
+ * @returns {'reload'|'ignore'}
10
+ */
11
+ export function decideStoredSession({ storedSession, currentSession, inRoom }) {
12
+ if (storedSession === currentSession) {
13
+ return 'ignore';
14
+ }
15
+
16
+ // матч доигрывается под прежней личностью; новую покажет следующая загрузка
17
+ return inRoom ? 'ignore' : 'reload';
18
+ }
19
+
20
+ /**
21
+ * То же решение по событию storage: секрет изменил другой контекст.
22
+ * @param {Object} ctx
23
+ * @param {{key: string|null, newValue: string|null}} ctx.event - событие storage.
24
+ * @param {string} ctx.sessionKey - ключ секрета сессии.
25
+ * @param {string|null} ctx.currentSession - секрет, с которым работает вкладка.
26
+ * @param {boolean} ctx.inRoom - вкладка в комнате.
27
+ * @returns {'reload'|'ignore'}
28
+ */
29
+ export function decideSessionStorageChange({
30
+ event,
31
+ sessionKey,
32
+ currentSession,
33
+ inRoom,
34
+ }) {
35
+ // key === null — хранилище очищено целиком
36
+ if (event.key !== null && event.key !== sessionKey) {
37
+ return 'ignore';
38
+ }
39
+
40
+ return decideStoredSession({
41
+ storedSession: event.key === null ? null : event.newValue,
42
+ currentSession,
43
+ inRoom,
44
+ });
45
+ }
@@ -52,13 +52,13 @@ export function decideUnknownRoom({
52
52
  }
53
53
 
54
54
  /**
55
- * Что делать с error {code: 'invalidToken'} мастера (истёкший вход).
55
+ * Что делать с error {code: 'invalidToken'} мастера (токен отклонён).
56
56
  * @param {Object} ctx
57
57
  * @param {Object} ctx.msg - сообщение ошибки ({ code, re, roomId }).
58
58
  * @param {boolean} ctx.promoting - вкладка занимает комнату (hostPromotion).
59
59
  * @param {boolean} ctx.inRoom - вкладка в комнате.
60
60
  * @param {string|null} ctx.sessionState - состояние SessionSupervisor.
61
- * @returns {'abandonPromotion'|'keepPlaying'|'logoutAndLeave'|'logout'}
61
+ * @returns {'abandonPromotion'|'keepPlaying'|'renewAndRetry'}
62
62
  */
63
63
  export function decideInvalidToken({ msg, promoting, inRoom, sessionState }) {
64
64
  if (msg.re === 'register_host' && promoting) {
@@ -70,5 +70,5 @@ export function decideInvalidToken({ msg, promoting, inRoom, sessionState }) {
70
70
  return 'keepPlaying';
71
71
  }
72
72
 
73
- return inRoom ? 'logoutAndLeave' : 'logout';
73
+ return 'renewAndRetry';
74
74
  }