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.
- package/LICENSE +21 -0
- package/README.md +12 -6
- package/bin/vimp-sim.js +2 -2
- package/core/Cargo.toml +4 -3
- package/package.json +12 -5
- package/src/client/components/controller/ClientReports.js +2 -1
- package/src/client/components/controller/Games.js +1 -1
- package/src/client/components/controller/LobbyAuth.js +6 -2
- package/src/client/components/controller/Profile.js +13 -3
- package/src/client/components/model/LobbyAuth.js +56 -18
- package/src/client/components/model/Profile.js +6 -4
- package/src/client/components/view/Auth.js +4 -2
- package/src/client/components/view/LobbyAuth.js +7 -7
- package/src/client/components/view/Profile.js +0 -1
- package/src/client/lib/authApi.js +9 -0
- package/src/client/lib/formBuilder.js +9 -8
- package/src/client/lib/hostCaps.js +3 -2
- package/src/client/lib/sessionSync.js +45 -0
- package/src/client/main.js +55 -13
- package/src/client/network/TokenRenewer.js +16 -7
- package/src/client/network/WebSocketTransport.js +1 -1
- package/src/client/session/Membership.js +2 -1
- package/src/client/style.css +17 -8
- package/src/client/views/includes/lobby.pug +2 -1
- package/src/client/views/includes/lobbyAuth.pug +12 -12
- package/src/config/authClient.js +3 -3
- package/src/config/gameCodes.js +2 -2
- package/src/config/lobby.js +2 -3
- package/src/config/wsports.js +2 -2
- package/src/devtools/builtinScenario.js +3 -3
- package/src/devtools/contract/rules/a2-package-scripts.js +4 -3
- package/src/devtools/contract/rules/a4-vite-config.js +4 -3
- package/src/devtools/contract/rules/a7-package-repository.js +6 -5
- package/src/devtools/contract/rules/c4-component-dependencies.js +5 -5
- package/src/devtools/invariants.js +1 -1
- package/src/devtools/pluginLoader.js +3 -3
- package/src/host/PortMachine.js +6 -5
- package/src/lib/loadGamePackage.js +1 -1
- package/src/lib/packageLink.js +11 -10
- package/src/standalone/index.js +5 -5
- package/bin/vimp-surface.js +0 -88
- package/src/config/env.js +0 -104
- package/src/config/master.js +0 -288
- package/src/lib/clientIp.js +0 -96
- package/src/lib/rateLimiter.js +0 -40
- 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
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
|
44
|
-
|
|
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.
|
|
3
|
+
version = "0.23.3"
|
|
4
4
|
edition = "2024"
|
|
5
|
-
description = "VIMP
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "VIMP
|
|
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://
|
|
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.
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
//
|
|
409
|
+
// модерации), не право: права админа auth-сервис проверяет сам, по нику
|
|
410
|
+
// из VIMP_ADMIN_NICKS
|
|
390
411
|
getRole() {
|
|
391
412
|
return decodeJwtPayload(this._token)?.role ?? 'user';
|
|
392
413
|
}
|
|
393
414
|
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
|
|
398
|
-
const
|
|
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
|
-
|
|
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
|
|
111
|
-
//
|
|
112
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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–
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|