vimp-engine 0.38.0 → 0.38.2

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 (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +12 -6
  3. package/bin/vimp-sim.js +2 -2
  4. package/core/Cargo.toml +4 -3
  5. package/package.json +12 -5
  6. package/src/client/components/controller/ClientReports.js +2 -1
  7. package/src/client/components/controller/Games.js +1 -1
  8. package/src/client/components/controller/LobbyAuth.js +6 -2
  9. package/src/client/components/controller/Profile.js +13 -3
  10. package/src/client/components/model/LobbyAuth.js +56 -18
  11. package/src/client/components/model/Profile.js +6 -4
  12. package/src/client/components/view/Auth.js +4 -2
  13. package/src/client/components/view/LobbyAuth.js +7 -7
  14. package/src/client/components/view/Profile.js +0 -1
  15. package/src/client/lib/authApi.js +9 -0
  16. package/src/client/lib/formBuilder.js +9 -8
  17. package/src/client/lib/hostCaps.js +3 -2
  18. package/src/client/lib/sessionSync.js +45 -0
  19. package/src/client/main.js +55 -13
  20. package/src/client/network/TokenRenewer.js +16 -7
  21. package/src/client/network/WebSocketTransport.js +1 -1
  22. package/src/client/session/Membership.js +2 -1
  23. package/src/client/style.css +17 -8
  24. package/src/client/views/includes/lobby.pug +2 -1
  25. package/src/client/views/includes/lobbyAuth.pug +12 -12
  26. package/src/config/authClient.js +3 -3
  27. package/src/config/gameCodes.js +2 -2
  28. package/src/config/lobby.js +2 -3
  29. package/src/config/wsports.js +2 -2
  30. package/src/devtools/builtinScenario.js +3 -3
  31. package/src/devtools/contract/rules/a2-package-scripts.js +4 -3
  32. package/src/devtools/contract/rules/a4-vite-config.js +4 -3
  33. package/src/devtools/contract/rules/a7-package-repository.js +6 -5
  34. package/src/devtools/contract/rules/c4-component-dependencies.js +5 -5
  35. package/src/devtools/invariants.js +1 -1
  36. package/src/devtools/pluginLoader.js +3 -3
  37. package/src/host/PortMachine.js +6 -5
  38. package/src/lib/loadGamePackage.js +1 -1
  39. package/src/lib/packageLink.js +11 -10
  40. package/src/standalone/index.js +5 -5
  41. package/bin/vimp-surface.js +0 -88
  42. package/src/config/env.js +0 -104
  43. package/src/config/master.js +0 -288
  44. package/src/lib/clientIp.js +0 -96
  45. package/src/lib/rateLimiter.js +0 -40
  46. package/src/lib/security.js +0 -45
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 lgick
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,9 +1,15 @@
1
1
  # vimp-engine
2
2
 
3
- VIMP — движок-приложение: мастер (лобби, WebRTC-сигналинг), P2P-транспорт,
4
- Worker-хост, мета-модули матча, MVC-каркас клиента. Игровые правила
5
- (например, `@vimp-games/tanks`) подключаются динамически через
6
- [plugin-api](https://github.com/lgick/vimp/blob/main/docs/en/plugin-api.md)
7
- — этот пакет их не содержит.
3
+ The VIMP engine SDK for game plugins: the browser host and client runtime,
4
+ the standalone mode (`vimp-engine/standalone`) and the CLI tools `vimp-sim`
5
+ and `vimp-contract`. Game rules live in separate plugin packages that the
6
+ engine loads at runtime through the
7
+ [plugin contract](https://docs.vimp.lgick.dev/en/plugin-api).
8
8
 
9
- Документация: https://github.com/lgick/vimp/tree/main/docs/en
9
+ Start a game:
10
+
11
+ ```bash
12
+ npm create vimp-game my-game
13
+ ```
14
+
15
+ Documentation: https://docs.vimp.lgick.dev
package/bin/vimp-sim.js CHANGED
@@ -40,8 +40,8 @@ const BUILTIN_NOTICE =
40
40
  'notice: the built-in smoke scenario is running against --game. It only\n' +
41
41
  'notice: proves the loop closes on this plugin: key coverage (invariant 2)\n' +
42
42
  'notice: and prediction drift (invariant 9) are skipped — both need a\n' +
43
- 'notice: scenario written for your game. See § Scenario format in the\n' +
44
- "notice: engine's docs/en/debugging.md (github.com/lgick/vimp), then run\n" +
43
+ 'notice: scenario written for your game. See § Scenario format at\n' +
44
+ 'notice: https://docs.vimp.lgick.dev/en/debugging#scenario-format, then run\n' +
45
45
  'notice: vimp-sim --scenario <file>.\n\n';
46
46
 
47
47
  // типичная опечатка «--core без --game»: фикстурное ядро — обычный JS и
package/core/Cargo.toml CHANGED
@@ -1,10 +1,11 @@
1
1
  [package]
2
2
  name = "vimp-engine-core"
3
- version = "0.23.2"
3
+ version = "0.23.3"
4
4
  edition = "2024"
5
- description = "VIMP — движковый каркас симуляции (физика, карта, снапшот-фрейминг, интерполяция/предикт/raycast-примитивы, нав-утилиты). Без wasm-bindgen — обёртки для WASM-ABI собирает game-crate."
5
+ description = "VIMP engine simulation core: physics, map, snapshot framing, interpolation/prediction/raycast primitives, navigation utilities. No wasm-bindgen — the game crate builds the WASM ABI wrappers."
6
6
  license = "MIT"
7
- repository = "https://github.com/lgick/vimp"
7
+ homepage = "https://docs.vimp.lgick.dev/en/core"
8
+ documentation = "https://docs.rs/vimp-engine-core"
8
9
  readme = "README.md"
9
10
 
10
11
  [lib]
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "vimp-engine",
3
- "version": "0.38.0",
4
- "description": "VIMP — движок-приложение (мастер, P2P-транспорт, Worker-хост, мета, MVC-каркас клиента)",
3
+ "version": "0.38.2",
4
+ "description": "VIMP engine SDK: browser host and client runtime, standalone mode and CLI tools (vimp-sim, vimp-contract) for VIMP game plugins",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
8
- "url": "git+ssh://git@github.com/lgick/vimp.git"
8
+ "url": "git+ssh://git@github.com/lgick/vimp.git",
9
+ "directory": "packages/engine"
9
10
  },
10
- "homepage": "https://github.com/lgick/vimp#readme",
11
+ "homepage": "https://docs.vimp.lgick.dev",
11
12
  "type": "module",
12
13
  "engines": {
13
14
  "node": ">=20.11.0"
@@ -23,7 +24,12 @@
23
24
  },
24
25
  "files": [
25
26
  "src/lib",
27
+ "!src/lib/clientIp.js",
28
+ "!src/lib/rateLimiter.js",
29
+ "!src/lib/security.js",
26
30
  "src/config",
31
+ "!src/config/env.js",
32
+ "!src/config/master.js",
27
33
  "src/host",
28
34
  "src/client",
29
35
  "!src/client/_*",
@@ -31,7 +37,8 @@
31
37
  "src/devtools",
32
38
  "tests/fixtures",
33
39
  "core/Cargo.toml",
34
- "bin"
40
+ "bin",
41
+ "!bin/vimp-surface.js"
35
42
  ],
36
43
  "scripts": {
37
44
  "predev": "node scripts/sync-pixi-vendor.mjs",
@@ -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 });
@@ -13,7 +13,8 @@ export default class AuthView {
13
13
  // texts — игровые тексты формы (authSchema.texts: title, sections);
14
14
  // сам каркас (auth.pug) нейтрален и текстов игры не содержит.
15
15
  // params — та же дескрипторная схема формы, что и у room-формы
16
- // (docs/en/plugin-api.md "Form schema"), едет по сети в PS_AUTH_DATA
16
+ // (docs.vimp.lgick.dev/en/plugin-api "Form schema"), едет по сети в
17
+ // PS_AUTH_DATA
17
18
  constructor(model, elems, texts = null, params = null) {
18
19
  if (authView) {
19
20
  return authView;
@@ -63,7 +64,8 @@ export default class AuthView {
63
64
  }
64
65
 
65
66
  // строит контролы формы игрока из той же дескрипторной схемы, что и
66
- // room-форма (единый formBuilder, docs/en/plugin-api.md "Form schema")
67
+ // room-форма (единый formBuilder, docs.vimp.lgick.dev/en/plugin-api "Form
68
+ // schema")
67
69
  _renderFields(elems, params) {
68
70
  if (!Array.isArray(params)) {
69
71
  return;
@@ -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-сервиса.
@@ -4,9 +4,9 @@ import { resolveDescriptor } from '../../lib/formControls.js';
4
4
  import { toDisplay, toStored, isNumericField } from '../../lib/formUnit.js';
5
5
 
6
6
  // Общий билдер полей форм (room-форма и auth-форма используют один
7
- // контракт дескрипторов — docs/en/plugin-api.md, раздел "Form schema").
8
- // control: 'select'|'text'|'checkbox'|'radio'; все контролы — нативные
9
- // элементы формы, без визуальной кастомизации.
7
+ // контракт дескрипторов — docs.vimp.lgick.dev/en/plugin-api, раздел "Form
8
+ // schema"). control: 'select'|'text'|'checkbox'|'radio'; все контролы —
9
+ // нативные элементы формы, без визуальной кастомизации.
10
10
  //
11
11
  // Имя контрола приезжает от игры и разрешается через append-only реестр
12
12
  // (lib/formControls.js): имена, выведенные из эксплуатации в v3 ('range',
@@ -270,7 +270,8 @@ const builders = {
270
270
  // control» на имени, которое реестр считает живым
271
271
  export const FORM_CONTROLS = Object.keys(builders);
272
272
 
273
- // строит одно поле формы по дескриптору (docs/en/plugin-api.md, "Form schema")
273
+ // строит одно поле формы по дескриптору (docs.vimp.lgick.dev/en/plugin-api,
274
+ // "Form schema")
274
275
  export function buildField(descriptor, ctx = {}) {
275
276
  // алиас выведенного контрола ('range' → numeric 'text') разрешается
276
277
  // здесь же, где строится поле, и теми же правилами, что в валидации
@@ -291,10 +292,10 @@ export function buildField(descriptor, ctx = {}) {
291
292
  }
292
293
 
293
294
  // засеивает default каждого дескриптора room-формы значением из roomDefaults
294
- // (docs/en/plugin-api.md "Form schema": roomDefaults остаётся единственным
295
- // источником значений по умолчанию); явный descriptor.default, если он есть
296
- // в схеме, побеждает. Вынесено из populateRoomForm (main.js), чтобы этот же
297
- // код был накрыт тестом, а не продублирован в нём
295
+ // (docs.vimp.lgick.dev/en/plugin-api "Form schema": roomDefaults остаётся
296
+ // единственным источником значений по умолчанию); явный descriptor.default,
297
+ // если он есть в схеме, побеждает. Вынесено из populateRoomForm (main.js),
298
+ // чтобы этот же код был накрыт тестом, а не продублирован в нём
298
299
  export function mergeRoomDefaults(descriptors, roomDefaults) {
299
300
  return descriptors.map(descriptor => ({
300
301
  default: roomDefaults[descriptor.name],
@@ -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
+ }