vimp-engine 0.38.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vimp-engine",
3
- "version": "0.38.0",
3
+ "version": "0.38.1",
4
4
  "description": "VIMP — движок-приложение (мастер, P2P-транспорт, Worker-хост, мета, MVC-каркас клиента)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -25,7 +25,8 @@ export default class ClientReportsCtrl {
25
25
  }
26
26
 
27
27
  // роль решает только видимость кнопки «Errors»: доступ к данным проверяет
28
- // мастер, а auth перечитывает роль из БД
28
+ // мастер, а права админа auth-сервис проверяет сам, по нику из
29
+ // VIMP_ADMIN_NICKS
29
30
  setAdmin(isAdmin) {
30
31
  this._view.setAdmin(isAdmin);
31
32
  }
@@ -42,7 +42,7 @@ export default class GamesCtrl {
42
42
  }
43
43
 
44
44
  // роль решает только видимость кнопки модерации: доступ к данным
45
- // проверяет мастер, а запись — auth-сервис по роли из БД
45
+ // проверяет мастер, а запись — auth-сервис, по нику из VIMP_ADMIN_NICKS
46
46
  setAdmin(isAdmin) {
47
47
  this._view.setAdmin(isAdmin);
48
48
 
@@ -101,8 +101,11 @@ export default class LobbyAuthCtrl {
101
101
  return this.register(this._view.getNickValue());
102
102
  }
103
103
 
104
+ // флаг закрывает только построение URL (хэш verifier считается
105
+ // асинхронно): два построения подряд — лишняя работа. После navigate() он
106
+ // снят — отменённый переход или возврат кнопкой «Назад» не должен
107
+ // оставить экран без регистрации и входа
104
108
  async restore(provider) {
105
- // двойной клик перезаписал бы verifier попытки, уже ушедшей на auth
106
109
  if (this._busy) {
107
110
  return;
108
111
  }
@@ -112,8 +115,9 @@ export default class LobbyAuthCtrl {
112
115
  try {
113
116
  this._navigate(await this._model.restoreUrl(provider));
114
117
  } catch {
115
- this._busy = false;
116
118
  this._view.renderRestoreError('failed');
119
+ } finally {
120
+ this._busy = false;
117
121
  }
118
122
  }
119
123
 
@@ -32,6 +32,7 @@ export default class ProfileCtrl {
32
32
  this._model = model;
33
33
  this._view = view;
34
34
  this._navigate = navigate;
35
+ this._linking = false;
35
36
 
36
37
  this.publisher = new Publisher();
37
38
 
@@ -52,13 +53,22 @@ export default class ProfileCtrl {
52
53
  this._view.show();
53
54
  }
54
55
 
55
- // кнопки вида неактивны без согласий; проверка здесь — второй рубеж
56
+ // кнопки вида неактивны без согласий; проверка здесь — второй рубеж.
57
+ // Флаг закрывает только построение URL — как у LobbyAuthCtrl.restore()
56
58
  async link(provider) {
57
- if (!this._view.consentGiven()) {
59
+ if (this._linking || !this._view.consentGiven()) {
58
60
  return;
59
61
  }
60
62
 
61
- this._navigate(await this._model.linkUrl(provider));
63
+ this._linking = true;
64
+
65
+ try {
66
+ this._navigate(await this._model.linkUrl(provider));
67
+ } catch {
68
+ this._view.renderError({ area: 'link', code: 'failed' });
69
+ } finally {
70
+ this._linking = false;
71
+ }
62
72
  }
63
73
 
64
74
  unlink(provider) {
@@ -1,6 +1,7 @@
1
1
  import Publisher from '../../../lib/Publisher.js';
2
2
  import { decodeJwtPayload } from '../../../lib/jwt.js';
3
3
  import { isValidNick } from '../../../lib/validators.js';
4
+ import { isSessionRejected } from '../../lib/authApi.js';
4
5
 
5
6
  // Singleton LobbyAuthModel
6
7
 
@@ -25,6 +26,7 @@ export default class LobbyAuthModel {
25
26
  * @param {Storage} [deps.sessionStore] - попытка OAuth, уведомление.
26
27
  * @param {boolean} [deps.isDev] - принимать ?devSession= (dev-сборка).
27
28
  * @param {Function} [deps.getLocation]
29
+ * @param {Function} [deps.now] - () → мс эпохи по часам устройства.
28
30
  */
29
31
  constructor(
30
32
  config,
@@ -35,6 +37,7 @@ export default class LobbyAuthModel {
35
37
  sessionStore = sessionStorage,
36
38
  isDev = false,
37
39
  getLocation = () => window.location,
40
+ now = () => Date.now(),
38
41
  } = {},
39
42
  ) {
40
43
  if (lobbyAuthModel) {
@@ -50,9 +53,11 @@ export default class LobbyAuthModel {
50
53
  this._sessionStore = sessionStore;
51
54
  this._isDev = isDev;
52
55
  this._getLocation = getLocation;
56
+ this._now = now;
53
57
 
54
58
  this._session = null;
55
59
  this._token = null;
60
+ this._tokenExpiresAt = null;
56
61
  this._profile = null;
57
62
  this._clientConfig = null;
58
63
  this._urlParams = { code: null, error: null, devSession: null };
@@ -100,7 +105,15 @@ export default class LobbyAuthModel {
100
105
 
101
106
  this.publisher.emit('loading');
102
107
 
103
- const cfg = await this._api.clientConfig();
108
+ // секрет читается до сети: настройки и токен уходят одновременно, лобби
109
+ // вернувшегося игрока открывается на круг запроса раньше
110
+ const stored = this._storage.getItem(sessionStorageKey);
111
+ // значение не того формата секретом не является: на auth не уходит
112
+ const session = stored && SESSION_RE.test(stored) ? stored : null;
113
+ const [cfg, res] = await Promise.all([
114
+ this._api.clientConfig(),
115
+ session ? this._api.token(session) : null,
116
+ ]);
104
117
 
105
118
  if (!cfg.ok) {
106
119
  this.publisher.emit('service-error');
@@ -115,8 +128,6 @@ export default class LobbyAuthModel {
115
128
 
116
129
  this._urlParams = { code: null, error: null, devSession: null };
117
130
 
118
- const session = this._storage.getItem(sessionStorageKey);
119
-
120
131
  if (!session) {
121
132
  await this._bootWithoutSession(attempt, code, error);
122
133
  return;
@@ -124,13 +135,14 @@ export default class LobbyAuthModel {
124
135
 
125
136
  this._session = session;
126
137
 
127
- const res = await this._api.token(session);
128
-
129
138
  if (res.ok) {
130
139
  this._adopt({ token: res.data.token, profile: res.data.profile });
140
+ // уведомление писала вкладка, потерявшая сессию в комнате; под принятой
141
+ // личностью оно ни к чему и не должно всплыть на Welcome позже
142
+ this._takeNotice();
131
143
  await this._finishLink(attempt, code, error);
132
144
  this._emitAuthenticated();
133
- } else if (res.status === 401) {
145
+ } else if (isSessionRejected(res)) {
134
146
  this._forgetSession();
135
147
  this.publisher.emit('welcome', { notice: 'profileGone' });
136
148
  } else {
@@ -244,7 +256,7 @@ export default class LobbyAuthModel {
244
256
  this._storage.setItem(this._config.sessionStorageKey, session);
245
257
  }
246
258
 
247
- this._token = token;
259
+ this._setToken(token);
248
260
  this._profile = profile;
249
261
  }
250
262
 
@@ -257,9 +269,18 @@ export default class LobbyAuthModel {
257
269
  }
258
270
 
259
271
  _forgetSession() {
260
- this._storage.removeItem(this._config.sessionStorageKey);
272
+ const key = this._config.sessionStorageKey;
273
+
274
+ // в хранилище может лежать секрет ДРУГОГО профиля: его записала вкладка,
275
+ // сменившая личность браузера, пока эта доигрывала матч под прежней.
276
+ // Стирается только свой — у профиля без привязки он единственный ключ
277
+ if (this._storage.getItem(key) === this._session) {
278
+ this._storage.removeItem(key);
279
+ }
280
+
261
281
  this._session = null;
262
282
  this._token = null;
283
+ this._tokenExpiresAt = null;
263
284
  this._profile = null;
264
285
  }
265
286
 
@@ -335,7 +356,7 @@ export default class LobbyAuthModel {
335
356
  const res = await this._api.token(this._session);
336
357
 
337
358
  if (res.ok) {
338
- this._token = res.data.token;
359
+ this._setToken(res.data.token);
339
360
  this._profile = res.data.profile;
340
361
  this.publisher.emit('token-renewed', {
341
362
  expiresAt: this.getTokenExpiresAt(),
@@ -343,7 +364,7 @@ export default class LobbyAuthModel {
343
364
  return 'ok';
344
365
  }
345
366
 
346
- if (res.status === 401) {
367
+ if (isSessionRejected(res)) {
347
368
  this._forgetSession();
348
369
  this.publisher.emit('session-lost');
349
370
  return 'lost';
@@ -385,19 +406,36 @@ export default class LobbyAuthModel {
385
406
  }
386
407
 
387
408
  // роль из payload токена — ПОДСКАЗКА ИНТЕРФЕЙСУ (показывать ли кнопку
388
- // модерации), не право: доступ к данным проверяет мастер по подписи, а
389
- // запись — auth-сервис, перечитывая роль из БД
409
+ // модерации), не право: права админа auth-сервис проверяет сам, по нику
410
+ // из VIMP_ADMIN_NICKS
390
411
  getRole() {
391
412
  return decodeJwtPayload(this._token)?.role ?? 'user';
392
413
  }
393
414
 
394
- // срок входа (мс эпохи) или null — неизвестен. Вкладка с кончающимся
395
- // токеном не принимает роль хоста: register_host/reclaim_host предъявят
396
- // его мастеру посреди матча
397
- getTokenExpiresAt() {
398
- const exp = decodeJwtPayload(this._token)?.exp;
415
+ // Срок токена в шкале часов УСТРОЙСТВА: момент получения плюс время жизни
416
+ // из самого токена (exp − iat — оба выставлены часами auth-сервиса).
417
+ // Сдвиг часов устройства на срок не влияет. Токен без iat — срок по exp
418
+ _setToken(token) {
419
+ const payload = decodeJwtPayload(token);
420
+ const exp = payload?.exp;
421
+ const iat = payload?.iat;
399
422
 
400
- return typeof exp === 'number' ? exp * 1000 : null;
423
+ this._token = token;
424
+
425
+ if (typeof exp !== 'number') {
426
+ this._tokenExpiresAt = null;
427
+ } else if (typeof iat === 'number' && exp > iat) {
428
+ this._tokenExpiresAt = this._now() + (exp - iat) * 1000;
429
+ } else {
430
+ this._tokenExpiresAt = exp * 1000;
431
+ }
432
+ }
433
+
434
+ // срок входа (мс эпохи по часам устройства) или null — неизвестен.
435
+ // Вкладка с кончающимся токеном не принимает роль хоста:
436
+ // register_host/reclaim_host предъявят его мастеру посреди матча
437
+ getTokenExpiresAt() {
438
+ return this._tokenExpiresAt;
401
439
  }
402
440
 
403
441
  getProfile() {
@@ -1,4 +1,5 @@
1
1
  import Publisher from '../../../lib/Publisher.js';
2
+ import { isSessionRejected } from '../../lib/authApi.js';
2
3
 
3
4
  // Singleton ProfileModel
4
5
 
@@ -107,9 +108,10 @@ export default class ProfileModel {
107
108
  }
108
109
 
109
110
  // один запрос за раз; успех — data тела ответа (null у 204); отказ —
110
- // undefined и событие error, а при 401 (сессия мертва: профиль удалён в
111
- // другой вкладке, срок вышел) — вместо error событие left с уведомлением
112
- // profileGone, уже после снятия busy; запрос во время другого не стартует
111
+ // undefined и событие error. Если auth-сервис не нашёл сессию
112
+ // (401 sessionUnknown: профиль удалён или сессия закрыта в другой вкладке) —
113
+ // вместо error событие left с уведомлением profileGone, уже после снятия
114
+ // busy; запрос во время другого не стартует
113
115
  async _run(area, request) {
114
116
  if (this._busy) {
115
117
  return undefined;
@@ -131,7 +133,7 @@ export default class ProfileModel {
131
133
  return res.data;
132
134
  }
133
135
 
134
- if (res.status === 401) {
136
+ if (isSessionRejected(res)) {
135
137
  this._leave('profileGone');
136
138
  } else {
137
139
  this.publisher.emit('error', { area, code: res.error });
@@ -1,4 +1,5 @@
1
1
  import Publisher from '../../../lib/Publisher.js';
2
+ import { ENGINE_VERSION } from '../../lib/engineVersion.js';
2
3
 
3
4
  // Singleton LobbyAuthView
4
5
 
@@ -6,7 +7,7 @@ let lobbyAuthView;
6
7
 
7
8
  const REGISTER_ERRORS = {
8
9
  invalidNick:
9
- 'Invalid nick: 2–14 characters, starting with a letter. Letters, digits, spaces, "_" and "#" are allowed; the last character is a letter, digit or "_".',
10
+ 'Invalid nick: 2–15 characters, starting with a letter. Letters, digits, spaces, "_" and "#" are allowed; the last character is a letter, digit or "_".',
10
11
  nickTaken: 'This nick is already taken.',
11
12
  turnstileBlocked:
12
13
  'Security check blocked. Please disable your ad blocker (or Brave Shields) and click Retry.',
@@ -14,7 +15,7 @@ const REGISTER_ERRORS = {
14
15
  turnstileFailed: 'Security check failed. Please try again.',
15
16
  turnstileUnavailable:
16
17
  'Registration is temporarily unavailable. Please try again later.',
17
- rateLimited: 'Too many attempts. Please wait a minute and try again.',
18
+ rateLimited: 'Too many attempts. Please try again later.',
18
19
  termsRequired: 'The terms have been updated. Reload the page and try again.',
19
20
  };
20
21
 
@@ -56,6 +57,7 @@ export default class LobbyAuthView {
56
57
  this._statusText = byId(elems.statusTextId);
57
58
  this._statusRetry = byId(elems.statusRetryId);
58
59
  this._welcome = byId(elems.welcomeId);
60
+ this._footer = byId(elems.footerId);
59
61
  this._notice = byId(elems.noticeId);
60
62
  this._nickInput = byId(elems.nickInputId);
61
63
  this._nickError = byId(elems.nickErrorId);
@@ -64,7 +66,7 @@ export default class LobbyAuthView {
64
66
  this._register = byId(elems.registerId);
65
67
  this._terms = byId(elems.termsId);
66
68
  this._privacy = byId(elems.privacyId);
67
- this._lifetime = byId(elems.lifetimeId);
69
+ byId(elems.versionId).textContent = ENGINE_VERSION;
68
70
  this._restore = byId(elems.restoreId);
69
71
  this._providers = byId(elems.providersId);
70
72
  this._restoreError = byId(elems.restoreErrorId);
@@ -114,6 +116,7 @@ export default class LobbyAuthView {
114
116
  this._enterAuth();
115
117
  this._status.style.display = 'block';
116
118
  this._welcome.style.display = 'none';
119
+ this._footer.style.display = 'none';
117
120
  this._statusText.textContent = text;
118
121
  this._statusRetry.style.display = retry ? '' : 'none';
119
122
  }
@@ -125,14 +128,11 @@ export default class LobbyAuthView {
125
128
  this._enterAuth();
126
129
  this._status.style.display = 'none';
127
130
  this._welcome.style.display = 'block';
131
+ this._footer.style.display = '';
128
132
 
129
133
  this._notice.textContent = NOTICES[notice] ?? '';
130
134
  this._terms.href = clientConfig.legal.termsUrl;
131
135
  this._privacy.href = clientConfig.legal.privacyUrl;
132
- this._lifetime.textContent =
133
- 'Your profile is kept in this browser and is deleted after ' +
134
- `${clientConfig.accounts.inactiveDays} days of inactivity. ` +
135
- 'Link an account later to keep it longer.';
136
136
 
137
137
  this._providerButtons.forEach(btn => {
138
138
  btn.style.display = providers.includes(btn.dataset.provider)
@@ -65,7 +65,6 @@ export default class ProfileView {
65
65
  this._close = byId(elems.closeBtnId);
66
66
  this._nick = byId(elems.nickId);
67
67
  this._status = byId(elems.statusId);
68
- this._linkCard = byId(elems.linkCardId);
69
68
  this._benefits = byId(elems.benefitsId);
70
69
  this._providers = byId(elems.providersId);
71
70
  this._consent = byId(elems.consentId);
@@ -7,6 +7,15 @@
7
7
 
8
8
  const JSON_HEADERS = { 'Content-Type': 'application/json' };
9
9
 
10
+ // Единственный код, которым auth-сервис сообщает, что сессии нет.
11
+ // invalidSession («секрет не того формата») вердиктом не считается: клиент
12
+ // шлёт только секрет верного формата, и такой ответ значит, что заголовок
13
+ // Authorization не дошёл до сервиса (редирект, прокси, сбой сервиса). Как и
14
+ // 401 без кода, это сбой: секрет по нему не стирается — у профиля без
15
+ // привязки он единственный ключ
16
+ export const isSessionRejected = res =>
17
+ res.status === 401 && res.error === 'sessionUnknown';
18
+
10
19
  /**
11
20
  * @param {Object} options
12
21
  * @param {string} options.serviceUrl - базовый URL auth-сервиса.
@@ -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,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
+ }
@@ -70,6 +70,10 @@ import FpsMeter from './lib/FpsMeter.js';
70
70
  import HostUnloadGuard from './lib/hostUnloadGuard.js';
71
71
  import { createPageReload } from './lib/pageReload.js';
72
72
  import { createAuthApi } from './lib/authApi.js';
73
+ import {
74
+ decideSessionStorageChange,
75
+ decideStoredSession,
76
+ } from './lib/sessionSync.js';
73
77
  import { createVerifier, challengeOf } from './lib/pkce.js';
74
78
  import { createTurnstile } from './lib/turnstile.js';
75
79
  import TokenRenewer from './network/TokenRenewer.js';
@@ -2469,8 +2473,8 @@ function initLobby() {
2469
2473
  const games = new GamesCtrl(gamesModel, gamesView);
2470
2474
 
2471
2475
  // кнопку «Moderation» лобби показывает роли admin (master-game-registry,
2472
- // этап 4). Это подсказка интерфейсу: доступ к данным проверяет мастер, а
2473
- // запись — auth-сервис, перечитывая роль из БД
2476
+ // этап 4). Это подсказка интерфейсу: права админа auth-сервис проверяет
2477
+ // сам, по нику из VIMP_ADMIN_NICKS
2474
2478
  games.setAdmin(lobbyAuthModel.getRole() === 'admin');
2475
2479
 
2476
2480
  // журнал клиентских ошибок (plan/client-reports, этап 5): кнопку «Errors»
@@ -2695,7 +2699,6 @@ if (isLobbyMode) {
2695
2699
  const tokenRenewer = new TokenRenewer({
2696
2700
  getExpiresAt: getTokenExpiresAt,
2697
2701
  renew: () => lobbyAuthModel.refresh(),
2698
- onLost: () => {},
2699
2702
  leadMs: authClientConfig.renew.leadMs,
2700
2703
  retryMs: authClientConfig.renew.retryMs,
2701
2704
  });
@@ -2765,6 +2768,44 @@ if (isLobbyMode) {
2765
2768
  diagnostics.warn('engine.session.profileGone', {}, { source: 'client' });
2766
2769
  });
2767
2770
 
2771
+ // другая вкладка сменила личность браузера (регистрация, Restore, выход,
2772
+ // удаление): вкладка вне комнаты перечитывает её перезагрузкой
2773
+ window.addEventListener('storage', event => {
2774
+ if (event.storageArea !== localStorage) {
2775
+ return;
2776
+ }
2777
+
2778
+ const action = decideSessionStorageChange({
2779
+ event,
2780
+ sessionKey: authClientConfig.sessionStorageKey,
2781
+ currentSession: lobbyAuthModel.getSession(),
2782
+ inRoom: Boolean(roomCtx.roomId),
2783
+ });
2784
+
2785
+ if (action === 'reload') {
2786
+ reloadPage();
2787
+ }
2788
+ });
2789
+
2790
+ // страница, восстановленная из bfcache (возврат кнопкой «Назад» от
2791
+ // провайдера OAuth), событий storage не получала: личность сверяется с
2792
+ // хранилищем заново
2793
+ window.addEventListener('pageshow', event => {
2794
+ if (!event.persisted) {
2795
+ return;
2796
+ }
2797
+
2798
+ const action = decideStoredSession({
2799
+ storedSession: localStorage.getItem(authClientConfig.sessionStorageKey),
2800
+ currentSession: lobbyAuthModel.getSession(),
2801
+ inRoom: Boolean(roomCtx.roomId),
2802
+ });
2803
+
2804
+ if (action === 'reload') {
2805
+ reloadPage();
2806
+ }
2807
+ });
2808
+
2768
2809
  // возврат с OAuth и dev-сессия прочитаны — чистим только query, hash
2769
2810
  // (маршрут #/<gameId>[/<roomId>]) остаётся
2770
2811
  if (lobbyAuthModel.takeUrlParams(window.location.search)) {
@@ -1,6 +1,7 @@
1
1
  // Тихое продление identity-токена: за leadMs до exp запрашивает свежий у
2
2
  // сессии устройства. Сбой сети — повтор через retryMs без ограничения числа
3
- // попыток: сеть вернётся — токен продлится.
3
+ // попыток: сеть вернётся — токен продлится. Запуски идут не чаще раза в
4
+ // retryMs, даже если срок остаётся в окне продления (цикла не будет).
4
5
  //
5
6
  // Без DOM: срок, продление и таймеры инъектирует владелец (client/main.js).
6
7
 
@@ -9,7 +10,6 @@ export default class TokenRenewer {
9
10
  * @param {Object} options
10
11
  * @param {Function} options.getExpiresAt - () → мс эпохи | null.
11
12
  * @param {Function} options.renew - () → Promise<'ok' | 'retry' | 'lost'>.
12
- * @param {Function} options.onLost - сессия потеряна, продлевать нечем.
13
13
  * @param {number} options.leadMs
14
14
  * @param {number} options.retryMs
15
15
  * @param {Function} [options.now]
@@ -18,7 +18,6 @@ export default class TokenRenewer {
18
18
  constructor({
19
19
  getExpiresAt,
20
20
  renew,
21
- onLost,
22
21
  leadMs,
23
22
  retryMs,
24
23
  now = () => Date.now(),
@@ -26,12 +25,12 @@ export default class TokenRenewer {
26
25
  }) {
27
26
  this._getExpiresAt = getExpiresAt;
28
27
  this._renew = renew;
29
- this._onLost = onLost;
30
28
  this._leadMs = leadMs;
31
29
  this._retryMs = retryMs;
32
30
  this._now = now;
33
31
  this._timers = timers;
34
32
  this._timer = null;
33
+ this._lastFireAt = null;
35
34
  }
36
35
 
37
36
  // таймер на expiresAt - leadMs, но не раньше «сейчас»; срок неизвестен —
@@ -54,7 +53,17 @@ export default class TokenRenewer {
54
53
  return null;
55
54
  }
56
55
 
57
- return Math.max(0, expiresAt - this._leadMs - this._now());
56
+ const now = this._now();
57
+ // не раньше retryMs после предыдущего запуска: срок, который и со
58
+ // свежим токеном остаётся в окне продления, не должен завести цикл.
59
+ // Сверху порог ограничен тем же retryMs: часы устройства могли перевести
60
+ // назад, и разность с прошлым запуском уже ничего не значит
61
+ const floor =
62
+ this._lastFireAt === null
63
+ ? 0
64
+ : Math.min(this._retryMs, this._lastFireAt + this._retryMs - now);
65
+
66
+ return Math.max(0, floor, expiresAt - this._leadMs - now);
58
67
  }
59
68
 
60
69
  _schedule(delay) {
@@ -69,6 +78,7 @@ export default class TokenRenewer {
69
78
 
70
79
  async _fire() {
71
80
  this._timer = null;
81
+ this._lastFireAt = this._now();
72
82
 
73
83
  let result;
74
84
 
@@ -83,8 +93,7 @@ export default class TokenRenewer {
83
93
  this.arm();
84
94
  } else if (result === 'retry') {
85
95
  this._schedule(this._retryMs);
86
- } else {
87
- this._onLost();
88
96
  }
97
+ // 'lost': таймер не взводится, потерю объявляет модель
89
98
  }
90
99
  }
@@ -85,7 +85,8 @@ export default class Membership {
85
85
  }
86
86
 
87
87
  // хватит ли срока входа на роль хоста: register_host/reclaim_host
88
- // предъявляют токен мастеру посреди матча, а продления нет
88
+ // предъявляют токен мастеру посреди матча. Страховка на случай, когда
89
+ // тихое продление токена не удаётся
89
90
  tokenAllowsHostRole() {
90
91
  return tokenAllowsHosting(
91
92
  this._getTokenExpiresAt(),
@@ -589,6 +589,7 @@ body.hide-cursor {
589
589
  держится в одном месте (#lobby-footer лежит в секции lobby ниже только
590
590
  разметкой) */
591
591
  #auth-link,
592
+ #lobby-auth-footer,
592
593
  #lobby-footer {
593
594
  height: 15px;
594
595
  text-shadow: none;
@@ -601,15 +602,18 @@ body.hide-cursor {
601
602
  padding: 0 5px;
602
603
  }
603
604
 
605
+ #lobby-auth-footer,
604
606
  #lobby-footer {
605
607
  font-size: 10px;
606
608
  padding: 0;
607
609
  }
608
610
 
611
+ #lobby-auth-footer p,
609
612
  #lobby-footer p {
610
613
  margin: 0 5px;
611
614
  }
612
615
 
616
+ #lobby-auth-footer p:not(:last-child)::after,
613
617
  #lobby-footer p:not(:last-child)::after {
614
618
  content: '|';
615
619
  padding-left: 10px;
@@ -624,6 +628,7 @@ body.hide-cursor {
624
628
  }
625
629
 
626
630
  #auth-link p a,
631
+ #lobby-auth-footer p a,
627
632
  #lobby-footer p a {
628
633
  color: #ff0;
629
634
  }
@@ -1117,16 +1122,20 @@ body.hide-cursor {
1117
1122
  margin: 8px auto;
1118
1123
  }
1119
1124
 
1120
- #lobby-auth-legal,
1121
- #lobby-auth-lifetime {
1122
- color: #aaa;
1123
- font-size: 11px;
1124
- line-height: 14px;
1125
- margin: 8px 0;
1125
+ /* виджет Turnstile шириной 300 px не влезает в карточку 256 px */
1126
+ #lobby-auth-welcome {
1127
+ width: 300px;
1126
1128
  }
1127
1129
 
1128
- #lobby-auth-legal a {
1129
- color: #ccc;
1130
+ #lobby-auth-turnstile {
1131
+ display: flex;
1132
+ justify-content: center;
1133
+ }
1134
+
1135
+ /* футер вне рамки: по центру под карточкой */
1136
+ #lobby-auth-footer {
1137
+ justify-content: center;
1138
+ margin: 10px auto 0;
1130
1139
  }
1131
1140
 
1132
1141
  /* нейтральная плашка уведомления (профиль не найден, выход, удаление) */
@@ -9,24 +9,13 @@ div#lobby-auth
9
9
 
10
10
  div#lobby-auth-welcome.card(style='display:none')
11
11
  h2= 'Welcome to VIMP!'
12
- p= 'Pick a nick and play. No e-mail or password needed.'
13
12
  p#lobby-auth-notice.form-notice
14
- input#lobby-auth-nick-input.field-text(type='text', maxlength='14', placeholder='Your nick', autocomplete='off')
13
+ input#lobby-auth-nick-input.field-text(type='text', maxlength='15', placeholder='Your nick', autocomplete='off')
15
14
  div#lobby-auth-nick-error.form-error
16
15
  input#lobby-auth-turnstile-retry(type='button', value='Retry', style='display:none')
17
16
  //- контейнер виджета Turnstile: виден, только когда проверке нужен клик
18
17
  div#lobby-auth-turnstile
19
18
  input#lobby-auth-register(type='button', value='Create profile')
20
- p#lobby-auth-legal
21
- | By creating a profile you agree to the
22
- |
23
- a#lobby-auth-terms(target='_blank', rel='noopener') Terms of Service
24
- |
25
- | and the
26
- |
27
- a#lobby-auth-privacy(target='_blank', rel='noopener') Privacy Policy
28
- | .
29
- p#lobby-auth-lifetime
30
19
  hr.lobby-divider
31
20
  input#lobby-auth-restore(type='button', value='Restore profile')
32
21
  div#lobby-auth-providers(style='display:none')
@@ -35,3 +24,14 @@ div#lobby-auth
35
24
  input.lobby-auth-provider(type='button', data-provider='discord', value='Continue with Discord')
36
25
  input.lobby-auth-provider(type='button', data-provider='github', value='Continue with GitHub')
37
26
  div#lobby-auth-restore-error.form-error
27
+
28
+ //- футер под карточкой Welcome: ссылки заполняет LobbyAuthView,
29
+ //- версию движка — он же (engineVersion.js)
30
+ div#lobby-auth-footer(style='display:none')
31
+ p
32
+ a#lobby-auth-terms(target='_blank', rel='noopener') Terms of Service
33
+ | ·
34
+ |
35
+ a#lobby-auth-privacy(target='_blank', rel='noopener') Privacy Policy
36
+ p#lobby-auth-version
37
+ p &copy; #{new Date().getFullYear()} VIMP
@@ -59,6 +59,7 @@ export default {
59
59
  statusTextId: 'lobby-auth-status-text',
60
60
  statusRetryId: 'lobby-auth-status-retry',
61
61
  welcomeId: 'lobby-auth-welcome',
62
+ footerId: 'lobby-auth-footer',
62
63
  noticeId: 'lobby-auth-notice',
63
64
  nickInputId: 'lobby-auth-nick-input',
64
65
  nickErrorId: 'lobby-auth-nick-error',
@@ -67,7 +68,7 @@ export default {
67
68
  registerId: 'lobby-auth-register',
68
69
  termsId: 'lobby-auth-terms',
69
70
  privacyId: 'lobby-auth-privacy',
70
- lifetimeId: 'lobby-auth-lifetime',
71
+ versionId: 'lobby-auth-version',
71
72
  restoreId: 'lobby-auth-restore',
72
73
  providersId: 'lobby-auth-providers',
73
74
  restoreErrorId: 'lobby-auth-restore-error',
@@ -213,7 +213,8 @@ export default {
213
213
  // подключён по WebRTC; при смене состава отчёт уходит сразу, с дебаунсом);
214
214
  // minTokenLifetimeMs — вкладка, чей вход истечёт раньше, не объявляет
215
215
  // canHost и отказывается от промоушена (токен предъявляется мастеру
216
- // посреди матча, продления нет); tokenHandoffLeadMs — за столько до
216
+ // посреди матча; обычно его заранее продлевает TokenRenewer, порог работает,
217
+ // пока продление не удаётся); tokenHandoffLeadMs — за столько до
217
218
  // истечения входа хост с бетой сам передаёт роль (плановая передача на
218
219
  // границе раунда); tokenHandoffRetryMs — через сколько повторить её, если
219
220
  // беты ещё нет, идёт эстафета Worker'ов или передача сорвалась (повторы —
@@ -459,7 +460,6 @@ export default {
459
460
  closeBtnId: 'profile-close',
460
461
  nickId: 'profile-nick',
461
462
  statusId: 'profile-status',
462
- linkCardId: 'profile-link',
463
463
  benefitsId: 'profile-link-benefits',
464
464
  providersId: 'profile-providers',
465
465
  consentId: 'profile-consent',
@@ -475,7 +475,6 @@ export default {
475
475
  sessionErrorId: 'profile-session-error',
476
476
  exportBtnId: 'profile-export',
477
477
  exportErrorId: 'profile-export-error',
478
- deleteCardId: 'profile-delete',
479
478
  deleteAdminNoteId: 'profile-delete-admin',
480
479
  deleteStartBtnId: 'profile-delete-start',
481
480
  deleteConfirmId: 'profile-delete-confirm',