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,130 @@
1
+ // Cloudflare Turnstile на регистрации: ленивая загрузка скрипта, один
2
+ // виджет, токен на одну отправку формы. Без сети в тестах: документ, окно и
3
+ // таймеры инъектируются.
4
+
5
+ // code: 'blocked' — скрипт или виджет недоступны (блокировщик, ошибка
6
+ // виджета); 'expired' — проверка истекла, не дождавшись игрока
7
+ function turnstileError(code) {
8
+ const error = new Error(`Turnstile ${code}`);
9
+
10
+ error.code = code;
11
+
12
+ return error;
13
+ }
14
+
15
+ const blockedError = () => turnstileError('blocked');
16
+
17
+ /**
18
+ * @param {Object} options
19
+ * @param {string} options.scriptUrl
20
+ * @param {number} options.loadTimeoutMs
21
+ * @param {string} options.action
22
+ * @param {Document} [options.doc]
23
+ * @param {Window} [options.win]
24
+ * @param {Object} [options.timers] - { setTimeout, clearTimeout } (тесты).
25
+ */
26
+ export function createTurnstile({
27
+ scriptUrl,
28
+ loadTimeoutMs,
29
+ action,
30
+ doc = document,
31
+ win = window,
32
+ timers = globalThis,
33
+ }) {
34
+ let widgetId = null;
35
+ // виджет уже отдавал токен: перед следующим execute нужен reset
36
+ let used = false;
37
+ // текущий getToken: виджет создаётся один раз, а его callback'и должны
38
+ // завершать именно ожидающий вызов, не первый
39
+ let pending = null;
40
+
41
+ // скрипт подгружается при первом обращении; отказ (error, таймаут, нет
42
+ // win.turnstile после load) убирает элемент — Retry загрузит его заново
43
+ function loadScript() {
44
+ if (win.turnstile) {
45
+ return Promise.resolve();
46
+ }
47
+
48
+ return new Promise((resolve, reject) => {
49
+ const script = doc.createElement('script');
50
+ let timer = null;
51
+
52
+ const fail = () => {
53
+ timers.clearTimeout(timer);
54
+ script.remove();
55
+ reject(blockedError());
56
+ };
57
+
58
+ timer = timers.setTimeout(fail, loadTimeoutMs);
59
+
60
+ script.addEventListener('load', () => {
61
+ if (!win.turnstile) {
62
+ fail();
63
+ return;
64
+ }
65
+
66
+ timers.clearTimeout(timer);
67
+ resolve();
68
+ });
69
+ script.addEventListener('error', fail);
70
+
71
+ script.src = scriptUrl;
72
+ script.async = true;
73
+ doc.head.appendChild(script);
74
+ });
75
+ }
76
+
77
+ return {
78
+ // токен на одну отправку формы; отказ — Error с полем code = 'blocked'
79
+ async getToken(container, siteKey) {
80
+ await loadScript();
81
+
82
+ return new Promise((resolve, reject) => {
83
+ pending = { resolve, reject };
84
+
85
+ if (widgetId === null) {
86
+ widgetId = win.turnstile.render(container, {
87
+ sitekey: siteKey,
88
+ action,
89
+ execution: 'execute',
90
+ appearance: 'interaction-only',
91
+ callback: token => {
92
+ used = true;
93
+ pending?.resolve(token);
94
+ },
95
+ 'error-callback': () => {
96
+ // после ошибки виджету нужен reset перед следующим execute
97
+ used = true;
98
+ pending?.reject(blockedError());
99
+ return true;
100
+ },
101
+ // проверка истекла, пока игрок не ответил: без этого getToken
102
+ // не завершился бы и форма осталась заблокированной
103
+ 'timeout-callback': () => {
104
+ used = true;
105
+ pending?.reject(turnstileError('expired'));
106
+ },
107
+ 'expired-callback': () => {
108
+ used = true;
109
+ pending?.reject(turnstileError('expired'));
110
+ },
111
+ });
112
+ } else if (used) {
113
+ win.turnstile.reset(widgetId);
114
+ used = false;
115
+ }
116
+
117
+ // срока ожидания нет: интерактивная проверка может ждать клика
118
+ win.turnstile.execute(container);
119
+ });
120
+ },
121
+
122
+ // токен израсходован сервером: следующая отправка получит новый
123
+ reset() {
124
+ if (widgetId !== null) {
125
+ win.turnstile.reset(widgetId);
126
+ used = false;
127
+ }
128
+ },
129
+ };
130
+ }
@@ -69,6 +69,14 @@ import SessionSupervisor, {
69
69
  import FpsMeter from './lib/FpsMeter.js';
70
70
  import HostUnloadGuard from './lib/hostUnloadGuard.js';
71
71
  import { createPageReload } from './lib/pageReload.js';
72
+ import { createAuthApi } from './lib/authApi.js';
73
+ import {
74
+ decideSessionStorageChange,
75
+ decideStoredSession,
76
+ } from './lib/sessionSync.js';
77
+ import { createVerifier, challengeOf } from './lib/pkce.js';
78
+ import { createTurnstile } from './lib/turnstile.js';
79
+ import TokenRenewer from './network/TokenRenewer.js';
72
80
  import {
73
81
  isKickClose,
74
82
  POLICY_CLOSE_INFORMS,
@@ -78,11 +86,14 @@ import LobbyModel from './components/model/Lobby.js';
78
86
  import LobbyView from './components/view/Lobby.js';
79
87
  import LobbyCtrl from './components/controller/Lobby.js';
80
88
  import LobbyAuthModel from './components/model/LobbyAuth.js';
89
+ import ProfileModel from './components/model/Profile.js';
81
90
  import LobbyAuthView from './components/view/LobbyAuth.js';
82
91
  import LobbyAuthCtrl from './components/controller/LobbyAuth.js';
83
92
  import GamesModel from './components/model/Games.js';
84
93
  import GamesView from './components/view/Games.js';
85
94
  import GamesCtrl from './components/controller/Games.js';
95
+ import ProfileView from './components/view/Profile.js';
96
+ import ProfileCtrl from './components/controller/Profile.js';
86
97
  import { createRoomContext } from './session/roomContext.js';
87
98
  import { createHostRoomPrep } from './session/hostRoomPrep.js';
88
99
  import Membership from './session/Membership.js';
@@ -274,7 +285,6 @@ const PS_FIRST_SHOT_DATA = wsports.server.FIRST_SHOT_DATA;
274
285
  const PS_SOUND_DATA = wsports.server.SOUND_DATA;
275
286
  const PS_GAME_INFORM_DATA = wsports.server.GAME_INFORM_DATA;
276
287
  const PS_TECH_INFORM_DATA = wsports.server.TECH_INFORM_DATA;
277
- const PS_MISC = wsports.server.MISC;
278
288
  const PS_PING = wsports.server.PING;
279
289
  const PS_CLEAR = wsports.server.CLEAR;
280
290
  const PS_CONSOLE = wsports.server.CONSOLE;
@@ -290,6 +300,7 @@ const PS_RESUME_RESULT = wsports.server.RESUME_RESULT;
290
300
  // PC (client ports): порты получения данных от клиента
291
301
  const PC_CONFIG_READY = wsports.client.CONFIG_READY;
292
302
  const PC_AUTH_RESPONSE = wsports.client.AUTH_RESPONSE;
303
+ const PC_TOKEN_REFRESH = wsports.client.TOKEN_REFRESH;
293
304
  const PC_MODULES_READY = wsports.client.MODULES_READY;
294
305
  const PC_MAP_READY = wsports.client.MAP_READY;
295
306
  const PC_FIRST_SHOT_READY = wsports.client.FIRST_SHOT_READY;
@@ -978,15 +989,6 @@ socketMethods[PS_TECH_INFORM_DATA] = data => {
978
989
  }
979
990
  };
980
991
 
981
- // misc
982
- socketMethods[PS_MISC] = data => {
983
- const { key, value } = data;
984
-
985
- if (key === 'localstorageNameReplace') {
986
- localStorage['userName'] = value;
987
- }
988
- };
989
-
990
992
  // ping
991
993
  socketMethods[PS_PING] = pingId => {
992
994
  sending(PC_PONG, pingId, false);
@@ -1909,7 +1911,7 @@ const guest = new GuestSession({
1909
1911
  getPromotion: () => promotionFlow,
1910
1912
  getSupervisor,
1911
1913
  getToken,
1912
- logout: () => lobbyAuthModel.logout(),
1914
+ refreshIdentity: () => lobbyAuthModel.refresh(),
1913
1915
  config: lobbyConfig,
1914
1916
  diagnostics,
1915
1917
  ui: {
@@ -2471,8 +2473,8 @@ function initLobby() {
2471
2473
  const games = new GamesCtrl(gamesModel, gamesView);
2472
2474
 
2473
2475
  // кнопку «Moderation» лобби показывает роли admin (master-game-registry,
2474
- // этап 4). Это подсказка интерфейсу: доступ к данным проверяет мастер, а
2475
- // запись — auth-сервис, перечитывая роль из БД
2476
+ // этап 4). Это подсказка интерфейсу: права админа auth-сервис проверяет
2477
+ // сам, по нику из VIMP_ADMIN_NICKS
2476
2478
  games.setAdmin(lobbyAuthModel.getRole() === 'admin');
2477
2479
 
2478
2480
  // журнал клиентских ошибок (plan/client-reports, этап 5): кнопку «Errors»
@@ -2493,6 +2495,17 @@ function initLobby() {
2493
2495
  clientReportsView.setGames([...gamesById.values()]);
2494
2496
  clientReports.setAdmin(lobbyAuthModel.getRole() === 'admin');
2495
2497
 
2498
+ // панель профиля: привязка аккаунта, выход, выгрузка данных, удаление
2499
+ const profileModel = new ProfileModel(lobbyConfig.profile, {
2500
+ auth: lobbyAuthModel,
2501
+ api: authApi,
2502
+ });
2503
+ const profileView = new ProfileView(profileModel, lobbyConfig.profile);
2504
+ const profile = new ProfileCtrl(profileModel, profileView);
2505
+
2506
+ // выход и удаление меняют личность вкладки — только перезагрузкой
2507
+ profile.publisher.on('left', () => reloadPage());
2508
+
2496
2509
  // «Test»: манифест застейдженной версии кладётся в каталог вкладки, и
2497
2510
  // админ поднимает по нему комнату обычной кнопкой Create server
2498
2511
  games.publisher.on('staged', ({ manifest }) => {
@@ -2595,13 +2608,21 @@ function initLobby() {
2595
2608
  });
2596
2609
 
2597
2610
  lobby.open();
2611
+
2612
+ // вкладка вернулась от провайдера: итог привязки показывает панель
2613
+ const linkResult = profileModel.takeLinkResult();
2614
+
2615
+ if (linkResult) {
2616
+ profile.openWithLinkResult(linkResult);
2617
+ }
2598
2618
  }
2599
2619
 
2600
- // логин лобби (Этап B2): central auth-сервис выдаёт JWT, лобби открывается
2601
- // только после успешной авторизации (глобальный ник вместо свободного ввода
2602
- // в игре). Не зависит от сигнального сокета мастера — читает query string
2603
- // (OAuth-редирект) и localStorage независимо от 'welcome'
2620
+ // экран профиля: central auth-сервис выдаёт JWT по сессии устройства, лобби
2621
+ // открывается только когда у вкладки есть личность (профиль с глобальным
2622
+ // ником). Не зависит от сигнального сокета мастера — читает query string
2623
+ // (возврат с OAuth) и localStorage независимо от 'welcome'
2604
2624
  let lobbyAuthModel = null;
2625
+ let authApi = null;
2605
2626
  let lobbyAuthView = null;
2606
2627
 
2607
2628
  let welcomeReceived = false;
@@ -2656,13 +2677,31 @@ function leaveRoomWith(message) {
2656
2677
  }
2657
2678
  }
2658
2679
 
2659
- // точка 2 ветвления: лобби, OAuth-гейт и сигналинг живут только в lobby;
2680
+ // точка 2 ветвления: лобби, экран профиля и сигналинг живут только в lobby;
2660
2681
  // solo и dedicated идут сразу к транспорту
2661
2682
  if (isLobbyMode) {
2662
- lobbyAuthModel = new LobbyAuthModel(authClientConfig);
2683
+ authApi = createAuthApi({ serviceUrl: authClientConfig.serviceUrl });
2684
+
2685
+ lobbyAuthModel = new LobbyAuthModel(authClientConfig, {
2686
+ api: authApi,
2687
+ pkce: { createVerifier, challengeOf },
2688
+ isDev: isDevBuild,
2689
+ });
2663
2690
  lobbyAuthView = new LobbyAuthView(lobbyAuthModel, authClientConfig);
2664
2691
 
2665
- const lobbyAuthCtrl = new LobbyAuthCtrl(lobbyAuthModel, lobbyAuthView);
2692
+ // контроллер подписывается на вид сам
2693
+ new LobbyAuthCtrl(lobbyAuthModel, lobbyAuthView, {
2694
+ turnstile: createTurnstile(authClientConfig.turnstile),
2695
+ });
2696
+
2697
+ // тихое продление identity-токена: вкладка остаётся в аккаунте, пока жива
2698
+ // сессия устройства
2699
+ const tokenRenewer = new TokenRenewer({
2700
+ getExpiresAt: getTokenExpiresAt,
2701
+ renew: () => lobbyAuthModel.refresh(),
2702
+ leadMs: authClientConfig.renew.leadMs,
2703
+ retryMs: authClientConfig.renew.retryMs,
2704
+ });
2666
2705
 
2667
2706
  signaling.publisher.on('welcome', () => {
2668
2707
  welcomeReceived = true;
@@ -2695,16 +2734,81 @@ if (isLobbyMode) {
2695
2734
  handoff.armTokenHandoff();
2696
2735
  }
2697
2736
 
2698
- // повторный вход после logout: маршрут уже разобран, а логин-гейт
2699
- // спрятал лобби — вернуть его, если вкладка не в комнате
2700
- if (route.booted && !roomCtx.entered) {
2701
- lobby?.open();
2737
+ tokenRenewer.arm();
2738
+ });
2739
+
2740
+ // свежий токен — мастеру (следующим сообщением сигналинга), хосту (порт
2741
+ // TOKEN_REFRESH) и таймерам роли хоста
2742
+ lobbyAuthModel.publisher.on('token-renewed', () => {
2743
+ tokenRenewer.arm();
2744
+ membership.armTokenCapsTimer();
2745
+ // вкладка в комнате возвращает мастеру canHost по новому сроку
2746
+ membership.sendUpdate();
2747
+
2748
+ if (hostRole.controller) {
2749
+ handoff.armTokenHandoff();
2750
+ }
2751
+
2752
+ if (sessionStarted && supervisor) {
2753
+ sending(PC_TOKEN_REFRESH, { token: getToken() });
2702
2754
  }
2703
2755
  });
2704
2756
 
2705
- // OAuth-возврат: токены из query прочитаны — чистим только query, hash
2757
+ // личность вкладки меняется только перезагрузкой страницы; из комнаты в
2758
+ // лобби ведёт тоже только reloadPage(), поэтому в матче Welcome с
2759
+ // уведомлением покажет следующая загрузка
2760
+ lobbyAuthModel.publisher.on('session-lost', () => {
2761
+ lobbyAuthModel.setNotice('profileGone');
2762
+
2763
+ if (!roomCtx.roomId) {
2764
+ reloadPage();
2765
+ return;
2766
+ }
2767
+
2768
+ diagnostics.warn('engine.session.profileGone', {}, { source: 'client' });
2769
+ });
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
+
2809
+ // возврат с OAuth и dev-сессия прочитаны — чистим только query, hash
2706
2810
  // (маршрут #/<gameId>[/<roomId>]) остаётся
2707
- if (lobbyAuthCtrl.init(window.location.search)) {
2811
+ if (lobbyAuthModel.takeUrlParams(window.location.search)) {
2708
2812
  window.history.replaceState(
2709
2813
  null,
2710
2814
  '',
@@ -2712,6 +2816,8 @@ if (isLobbyMode) {
2712
2816
  );
2713
2817
  }
2714
2818
 
2819
+ lobbyAuthModel.boot();
2820
+
2715
2821
  window.addEventListener('hashchange', () => route.handleHashChange());
2716
2822
 
2717
2823
  signaling.connect();
@@ -0,0 +1,99 @@
1
+ // Тихое продление identity-токена: за leadMs до exp запрашивает свежий у
2
+ // сессии устройства. Сбой сети — повтор через retryMs без ограничения числа
3
+ // попыток: сеть вернётся — токен продлится. Запуски идут не чаще раза в
4
+ // retryMs, даже если срок остаётся в окне продления (цикла не будет).
5
+ //
6
+ // Без DOM: срок, продление и таймеры инъектирует владелец (client/main.js).
7
+
8
+ export default class TokenRenewer {
9
+ /**
10
+ * @param {Object} options
11
+ * @param {Function} options.getExpiresAt - () → мс эпохи | null.
12
+ * @param {Function} options.renew - () → Promise<'ok' | 'retry' | 'lost'>.
13
+ * @param {number} options.leadMs
14
+ * @param {number} options.retryMs
15
+ * @param {Function} [options.now]
16
+ * @param {Object} [options.timers] - { setTimeout, clearTimeout } (тесты).
17
+ */
18
+ constructor({
19
+ getExpiresAt,
20
+ renew,
21
+ leadMs,
22
+ retryMs,
23
+ now = () => Date.now(),
24
+ timers = globalThis,
25
+ }) {
26
+ this._getExpiresAt = getExpiresAt;
27
+ this._renew = renew;
28
+ this._leadMs = leadMs;
29
+ this._retryMs = retryMs;
30
+ this._now = now;
31
+ this._timers = timers;
32
+ this._timer = null;
33
+ this._lastFireAt = null;
34
+ }
35
+
36
+ // таймер на expiresAt - leadMs, но не раньше «сейчас»; срок неизвестен —
37
+ // таймера нет. Повторный arm заменяет прежний таймер
38
+ arm() {
39
+ this._schedule(this._delayToLead());
40
+ }
41
+
42
+ cancel() {
43
+ if (this._timer !== null) {
44
+ this._timers.clearTimeout(this._timer);
45
+ this._timer = null;
46
+ }
47
+ }
48
+
49
+ _delayToLead() {
50
+ const expiresAt = this._getExpiresAt();
51
+
52
+ if (expiresAt === null) {
53
+ return null;
54
+ }
55
+
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);
67
+ }
68
+
69
+ _schedule(delay) {
70
+ this.cancel();
71
+
72
+ if (delay === null) {
73
+ return;
74
+ }
75
+
76
+ this._timer = this._timers.setTimeout(() => this._fire(), delay);
77
+ }
78
+
79
+ async _fire() {
80
+ this._timer = null;
81
+ this._lastFireAt = this._now();
82
+
83
+ let result;
84
+
85
+ try {
86
+ result = await this._renew();
87
+ } catch {
88
+ // неожиданный сбой не должен обрывать цепочку продления
89
+ result = 'retry';
90
+ }
91
+
92
+ if (result === 'ok') {
93
+ this.arm();
94
+ } else if (result === 'retry') {
95
+ this._schedule(this._retryMs);
96
+ }
97
+ // 'lost': таймер не взводится, потерю объявляет модель
98
+ }
99
+ }
@@ -21,6 +21,9 @@ import WebRtcManager from '../network/WebRtcManager.js';
21
21
  // Без DOM: сигналинг, роли, супервизор, UI-действия и фабрики инъектирует
22
22
  // сборщик (client/main.js).
23
23
 
24
+ // минимум между продлениями входа по одному виду запроса
25
+ const RENEW_RETRY_MIN_MS = 60000;
26
+
24
27
  export default class GuestSession {
25
28
  /**
26
29
  * @param {Object} deps
@@ -32,7 +35,8 @@ export default class GuestSession {
32
35
  * @param {Function} deps.getPromotion - () → PromotionFlow.
33
36
  * @param {Function} deps.getSupervisor - () → SessionSupervisor | null.
34
37
  * @param {Function} deps.getToken - () → identity-токен.
35
- * @param {Function} deps.logout - выход из лобби-авторизации.
38
+ * @param {Function} deps.refreshIdentity - () → Promise<'ok'|'retry'|'lost'>,
39
+ * продление identity-токена по сессии устройства.
36
40
  * @param {Object} deps.config - lobby-конфиг (webrtc, reconnect).
37
41
  * @param {Object} deps.diagnostics
38
42
  * @param {Object} deps.ui - { ensureWebRtc() → bool, showRoomLink(roomId),
@@ -51,7 +55,7 @@ export default class GuestSession {
51
55
  getPromotion,
52
56
  getSupervisor,
53
57
  getToken,
54
- logout,
58
+ refreshIdentity,
55
59
  config,
56
60
  diagnostics,
57
61
  ui,
@@ -67,7 +71,7 @@ export default class GuestSession {
67
71
  this._getPromotion = getPromotion;
68
72
  this._getSupervisor = getSupervisor;
69
73
  this._getToken = getToken;
70
- this._logout = logout;
74
+ this._refreshIdentity = refreshIdentity;
71
75
  this._config = config;
72
76
  this._diagnostics = diagnostics;
73
77
  this._ui = ui;
@@ -83,6 +87,8 @@ export default class GuestSession {
83
87
  // открытое у гостя голосование «Change host» ({ roomId, voteId }) —
84
88
  // ответ окна уходит мастеру с этим voteId
85
89
  this._hostVote = null;
90
+ // вид запроса (msg.re) → время последнего продления (защита от цикла)
91
+ this._renewAttempts = new Map();
86
92
  }
87
93
 
88
94
  get hostVote() {
@@ -323,22 +329,15 @@ export default class GuestSession {
323
329
  if (action === 'abandonPromotion') {
324
330
  this._getPromotion().abandon({ code, report: true });
325
331
  } else if (action === 'keepPlaying') {
326
- // P2P-матчу токен не нужен; без нового текста в UI (системные
327
- // сообщения — только кодами, тексты в играх)
328
- this._diagnostics.warn(
329
- 'engine.session.tokenExpired',
330
- { re: msg.re },
331
- { source: 'client' },
332
- );
332
+ // P2P-матчу токен не нужен, без нового текста в UI (системные
333
+ // сообщения — только кодами, тексты в играх). Членство на мастере
334
+ // вернёт повторный join_room после продления; о неудаче продления
335
+ // пишет _renewAndRetry
333
336
  this._membership.stopJoinRetry();
334
- } else {
335
- this._logout();
337
+ }
336
338
 
337
- if (action === 'logoutAndLeave') {
338
- this._ui.leaveRoomWith(
339
- 'Your session has expired — please sign in again.',
340
- );
341
- }
339
+ if (action !== 'abandonPromotion') {
340
+ this._renewAndRetry(msg);
342
341
  }
343
342
  break;
344
343
  }
@@ -386,6 +385,53 @@ export default class GuestSession {
386
385
  }
387
386
  }
388
387
 
388
+ // продление входа и повтор отклонённого запроса. Не чаще раза в
389
+ // RENEW_RETRY_MIN_MS на вид запроса: мастер, отвергающий и свежий токен,
390
+ // не должен завести цикл
391
+ async _renewAndRetry(msg) {
392
+ const now = this._now();
393
+ const last = this._renewAttempts.get(msg.re);
394
+
395
+ if (last !== undefined && now - last < RENEW_RETRY_MIN_MS) {
396
+ this._warnTokenExpired(msg);
397
+ return;
398
+ }
399
+
400
+ this._renewAttempts.set(msg.re, now);
401
+
402
+ const result = await this._refreshIdentity();
403
+
404
+ if (result === 'ok') {
405
+ if (msg.re === 'join_room') {
406
+ this._membership.sendJoinRoom();
407
+ } else if (msg.re === 'register_host' || msg.re === 'reclaim_host') {
408
+ this._hostRole.reRegister();
409
+ }
410
+ return;
411
+ }
412
+
413
+ // хост, не продливший вход, не удержит комнату
414
+ if (
415
+ this._ctx.roomId &&
416
+ (msg.re === 'register_host' || msg.re === 'reclaim_host')
417
+ ) {
418
+ this._ui.leaveRoomWith(
419
+ 'Your session could not be renewed — please reload the page.',
420
+ );
421
+ return;
422
+ }
423
+
424
+ this._warnTokenExpired(msg);
425
+ }
426
+
427
+ _warnTokenExpired(msg) {
428
+ this._diagnostics.warn(
429
+ 'engine.session.tokenExpired',
430
+ { re: msg.re },
431
+ { source: 'client' },
432
+ );
433
+ }
434
+
389
435
  _ownRoom(msg) {
390
436
  return Boolean(this._ctx.roomId) && msg.roomId === this._ctx.roomId;
391
437
  }
@@ -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(),