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
@@ -26,7 +26,7 @@ const FIXTURE_UNUSED_KEYS = ['e1'];
26
26
  const firstKey = dict => Object.keys(dict ?? {})[0];
27
27
 
28
28
  // Клавиша для смоука обязана быть удерживаемой: по конвенции playerKeys
29
- // (docs/ai/04-client-plugin.md) `type: 1` — триггер, у которого 'up'
29
+ // (docs/en/plugin-api.md) `type: 1` — триггер, у которого 'up'
30
30
  // игнорируется, и «нажал — отпустил» не даёт ни удержания, ни движения.
31
31
  // Порядок объявления в playerKeys контрактом не задан, поэтому берём первую
32
32
  // без type; если игра объявила одни триггеры — первую любую, смоук всё равно
@@ -1,6 +1,6 @@
1
1
  import { ERROR, skip, verdict } from '../result.js';
2
2
 
3
- // Набор npm-скриптов пакета игры (docs/ai/02-packaging.md). Это не вкусовое
3
+ // Набор npm-скриптов пакета игры (docs/en/packaging.md). Это не вкусовое
4
4
  // требование: инструкции движка и CI сгенерированной игры зовут их по имени,
5
5
  // а отсутствующий core:build:node тихо оставляет headless-прогон без ядра.
6
6
  const REQUIRED = [
@@ -2,7 +2,7 @@ import { ERROR, skip, verdict } from '../result.js';
2
2
 
3
3
  // vite.config.js проверяется как текст, а не импортом: конфиг —
4
4
  // функция от mode и вне Vite не исполняется. Каждый пункт здесь ловит
5
- // молчаливую поломку сборки, а не стилистику (docs/ai/02-packaging.md):
5
+ // молчаливую поломку сборки, а не стилистику (docs/en/packaging.md):
6
6
  // без preserveEntrySignatures default-экспорт плагина вытряхивает
7
7
  // tree-shaking, а build.lib всегда инлайнит ассеты.
8
8
  const REQUIRED = [
@@ -4,7 +4,7 @@ import { ERROR, skip, verdict } from '../result.js';
4
4
  // реестра: код в зарезервированном диапазоне затирает движковое сообщение
5
5
  // без предупреждения, и вместо «Vote passed» игрок видит игровой текст.
6
6
  // Диапазоны — длины движковых групп (config/chatMessages.js).
7
- export const RESERVED = { s: 11, v: 15, m: 1, c: 1, n: 1 };
7
+ export const RESERVED = { s: 11, v: 15, m: 1, c: 1 };
8
8
 
9
9
  export default {
10
10
  id: 'B8',
@@ -484,7 +484,7 @@ function predictionDrift({ clients, scenario }) {
484
484
  // Компоненты player-блока — игровая раскладка, движок знает только их
485
485
  // порядок, поэтому нарушение адресуется индексом. Исключение — уровень 0
486
486
  // (source 'camera'): там движок сравнивает ровно мировые x/y, и называть их
487
- // «#0/#1» значит прятать причину (см. docs/ai/13-debugging.md).
487
+ // «#0/#1» значит прятать причину (см. docs/en/debugging.md).
488
488
  const CAMERA_COMPONENTS = ['x', 'y'];
489
489
 
490
490
  function formatDivergence(record) {
@@ -12,7 +12,7 @@ import { loadGamePackage } from '../lib/loadGamePackage.js';
12
12
  // 3. ничего — фикстура miniGame из этого репозитория.
13
13
  //
14
14
  // entries.wasmNode — новое ОПЦИОНАЛЬНОЕ поле манифеста: конвенция
15
- // core/pkg-node/ уже описана в docs/ai/02-packaging.md, не хватало только
15
+ // core/pkg-node/ уже описана в docs/en/packaging.md, не хватало только
16
16
  // пути в манифесте. Его отсутствие не ошибка — просто игра не прогоняется
17
17
  // headless своим настоящим ядром.
18
18
 
@@ -106,9 +106,6 @@ export default class GameCoreAdapter {
106
106
  return JSON.parse(this._core.remove_players_and_shots());
107
107
  }
108
108
 
109
- // имена — мета (ParticipantManager/Stat), ядру не нужны
110
- changeName() {}
111
-
112
109
  // перемещает танк при смене команды/респауне (Game.changePlayerData)
113
110
  changePlayerData(gameId, data) {
114
111
  const [x, y, angle] = data.respawnData;
@@ -1974,6 +1974,34 @@ export default class HostGame {
1974
1974
  };
1975
1975
  }
1976
1976
 
1977
+ // есть ли у участника identity-токен: в standalone и dedicated личность
1978
+ // гостевая, и обновлять там нечего
1979
+ hasToken(gameId) {
1980
+ const token = this._participants.get(gameId)?.token;
1981
+
1982
+ return typeof token === 'string' && token !== '';
1983
+ }
1984
+
1985
+ // ник, под которым участник вошёл (тот же, что сверяет RESUME_REQUEST)
1986
+ getIdentityName(gameId) {
1987
+ return this._participants.get(gameId)?.identityName ?? null;
1988
+ }
1989
+
1990
+ // свежий токен участника (порт TOKEN_REFRESH): им пишутся rank/state, и
1991
+ // он же поедет в эстафету Worker'ов
1992
+ refreshToken(gameId, token) {
1993
+ const user = this._participants.get(gameId);
1994
+
1995
+ if (!user) {
1996
+ return false;
1997
+ }
1998
+
1999
+ user.token = token;
2000
+ this._playerDataSync.attachToken(gameId, token);
2001
+
2002
+ return true;
2003
+ }
2004
+
1977
2005
  // привязывает участника к новому соединению и шлёт ему всё, что нужно,
1978
2006
  // чтобы продолжить с того же места: RESUME_RESULT, затем пакет входа
1979
2007
  // (очистка, полный кадр, stat/panel/keyset/accolades) и новый секрет.
@@ -23,8 +23,9 @@ const PC_VOTE_DATA = wsports.client.VOTE_DATA;
23
23
  const PC_PONG = wsports.client.PONG;
24
24
  const PC_RESUME_REQUEST = wsports.client.RESUME_REQUEST;
25
25
  const PC_LEAVE = wsports.client.LEAVE;
26
+ const PC_TOKEN_REFRESH = wsports.client.TOKEN_REFRESH;
26
27
 
27
- const PORT_COUNT = 11;
28
+ const PORT_COUNT = 12;
28
29
 
29
30
  // версии формата RESUME_REQUEST, которые понимает хост
30
31
  const RESUME_VERSION = 1;
@@ -38,6 +39,7 @@ const IN_GAME_PORTS = [
38
39
  PC_VOTE_DATA,
39
40
  PC_PONG,
40
41
  PC_LEAVE,
42
+ PC_TOKEN_REFRESH,
41
43
  ];
42
44
 
43
45
  export default class PortMachine {
@@ -139,6 +141,7 @@ export default class PortMachine {
139
141
  const state = {
140
142
  gameId: undefined,
141
143
  enabled: new Array(PORT_COUNT).fill(false),
144
+ refreshing: false,
142
145
  };
143
146
 
144
147
  this._clients.set(socketId, state);
@@ -153,6 +156,7 @@ export default class PortMachine {
153
156
  const state = {
154
157
  gameId: undefined,
155
158
  enabled: new Array(PORT_COUNT).fill(false),
159
+ refreshing: false,
156
160
  resumeTimer: null,
157
161
  };
158
162
 
@@ -210,6 +214,7 @@ export default class PortMachine {
210
214
  const state = {
211
215
  gameId,
212
216
  enabled: new Array(PORT_COUNT).fill(false),
217
+ refreshing: false,
213
218
  };
214
219
 
215
220
  state.methods = this._buildPortMethods(socketId, state);
@@ -379,6 +384,47 @@ export default class PortMachine {
379
384
  .catch(() => reject('auth'));
380
385
  }
381
386
 
387
+ // TOKEN_REFRESH { token }: токен принимается, только если он подписан
388
+ // auth-сервисом и выдан на тот же ник, под которым участник вошёл
389
+ _refreshToken(socketId, state, data) {
390
+ const { gameId } = state;
391
+
392
+ if (
393
+ typeof data?.token !== 'string' ||
394
+ gameId === undefined ||
395
+ state.refreshing ||
396
+ !this._host.hasToken(gameId)
397
+ ) {
398
+ return;
399
+ }
400
+
401
+ // не больше одной проверки подписи в полёте на соединение
402
+ state.refreshing = true;
403
+
404
+ this._identity
405
+ .resolve({ token: data.token }, socketId)
406
+ .then(name => {
407
+ if (!this._clients.has(socketId)) {
408
+ return;
409
+ }
410
+
411
+ if (name !== this._host.getIdentityName(gameId)) {
412
+ console.warn(
413
+ '[host] TOKEN_REFRESH: token of another identity ignored',
414
+ );
415
+ return;
416
+ }
417
+
418
+ this._host.refreshToken(gameId, data.token);
419
+ })
420
+ .catch(() => {
421
+ // недействительный токен: участник остаётся со старым
422
+ })
423
+ .finally(() => {
424
+ state.refreshing = false;
425
+ });
426
+ }
427
+
382
428
  // перехват полуоткрытой сессии: участник ещё числится за прежним
383
429
  // соединением (обрыв, которого хост не заметил) — закрыть его, не снимая
384
430
  // участника; его поздний disconnect уже ничего не найдёт
@@ -553,6 +599,9 @@ export default class PortMachine {
553
599
 
554
600
  // 10: leave
555
601
  () => this._leave(socketId, state),
602
+
603
+ // 11: token refresh
604
+ data => this._refreshToken(socketId, state, data),
556
605
  ];
557
606
  }
558
607
  }
@@ -30,7 +30,6 @@ export default class SocketManager {
30
30
  this._PORT_SOUND_DATA = ports.SOUND_DATA;
31
31
  this._PORT_GAME_INFORM_DATA = ports.GAME_INFORM_DATA;
32
32
  this._PORT_TECH_INFORM_DATA = ports.TECH_INFORM_DATA;
33
- this._PORT_MISC = ports.MISC;
34
33
  this._PORT_PING = ports.PING;
35
34
  this._PORT_CLEAR = ports.CLEAR;
36
35
  this._PORT_CONSOLE = ports.CONSOLE;
@@ -455,16 +454,4 @@ export default class SocketManager {
455
454
  sendResumeResult(socketId, data) {
456
455
  this._send(socketId, this._PORT_RESUME_RESULT, data);
457
456
  }
458
-
459
- /**
460
- * Отправка команды смены имени.
461
- * @param {string} socketId
462
- * @param {string} name
463
- */
464
- sendName(socketId, name) {
465
- this._send(socketId, this._PORT_MISC, {
466
- key: 'localstorageNameReplace',
467
- value: name,
468
- });
469
- }
470
457
  }
@@ -1,5 +1,3 @@
1
- import { isValidName } from '../../../lib/validators.js';
2
-
3
1
  // масштабирование данных карты (физика, респауны) под mapScale
4
2
  function scaleMapData(mapData) {
5
3
  const scale = mapData.scale;
@@ -522,23 +520,6 @@ class RoundManager {
522
520
  return true;
523
521
  }
524
522
 
525
- // меняет ник игрока
526
- changeName(gameId, name) {
527
- const user = this._participants.get(gameId);
528
- const oldName = user.name;
529
-
530
- if (isValidName(name)) {
531
- name = this._participants.checkName(name);
532
- user.name = name;
533
- this._game.changeName(gameId, name);
534
- this._stat.updateUser(gameId, user.teamId, { name });
535
- this._chat.pushSystem('NAME_CHANGED', [oldName, name]);
536
- this._socketManager.sendName(user.socketId, name);
537
- } else {
538
- this._chat.pushSystemByUser(gameId, 'NAME_INVALID');
539
- }
540
- }
541
-
542
523
  // меняет команду игрока
543
524
  changeTeam(gameId, newTeam) {
544
525
  this._applyTeamChange(gameId, newTeam);
@@ -663,7 +663,7 @@ export default class PlayerDataSync {
663
663
  do {
664
664
  entry.flushAgain = false;
665
665
  await this._sync(participantId, entry);
666
- } while (entry.flushAgain);
666
+ } while (entry.flushAgain && !entry.awaitingToken);
667
667
  } finally {
668
668
  entry.inFlight = false;
669
669
  entry.inFlightPromise = null;
@@ -673,6 +673,16 @@ export default class PlayerDataSync {
673
673
  return entry.inFlightPromise;
674
674
  }
675
675
 
676
+ // 401: запрос ушёл с токеном usedToken. Если за время полёта приложен
677
+ // свежий (attachToken), ждать нечего — повтор уходит в этой же серии
678
+ _onTokenExpired(entry, usedToken) {
679
+ if (entry.token === usedToken) {
680
+ entry.awaitingToken = true;
681
+ } else {
682
+ entry.flushAgain = true;
683
+ }
684
+ }
685
+
676
686
  // F4: если исходная load() не удалась, PUT дефолтом затёр бы реальные
677
687
  // сохранённые рейтинг/state — вместо этого повторяем load() и шлём PUT
678
688
  // только для того, что реально загрузилось
@@ -756,8 +766,19 @@ export default class PlayerDataSync {
756
766
  // получит тот же ответ. Оставить запись открытой значит слать
757
767
  // заведомый мусор на каждом flush до конца жизни комнаты, а
758
768
  // накопленное после неё не ушло бы вовсе. Очки теряются, и это
759
- // честнее вечного цикла (сам отказ уже в логе выше)
760
- if (res.status >= 400 && res.status < 500 && res.status !== 429) {
769
+ // честнее вечного цикла (сам отказ уже в логе выше).
770
+ // 401 — отказ не по содержанию, а по сроку токена: запись
771
+ // остаётся открытой и уйдёт со свежим (attachToken)
772
+ if (res.status === 401) {
773
+ this._onTokenExpired(entry, token);
774
+ console.warn(
775
+ `[playerData] PUT rank 401 for ${participantId} — waiting for a fresh token`,
776
+ );
777
+ } else if (
778
+ res.status >= 400 &&
779
+ res.status < 500 &&
780
+ res.status !== 429
781
+ ) {
761
782
  drop();
762
783
  }
763
784
  }
@@ -787,6 +808,11 @@ export default class PlayerDataSync {
787
808
  console.warn(
788
809
  `[playerData] PUT state ${res.status} for ${participantId}`,
789
810
  );
811
+
812
+ // токен истёк: состояние уйдёт со свежим (attachToken)
813
+ if (res.status === 401) {
814
+ this._onTokenExpired(entry, token);
815
+ }
790
816
  }
791
817
  }),
792
818
  );
@@ -1,5 +1,5 @@
1
1
  // Реестр кодов системных сообщений чата. Движковые группы: s (статусы),
2
- // v (голосования), m (карты), c (команды), n (имена). Игровые коды
2
+ // v (голосования), m (карты), c (команды). Игровые коды
3
3
  // (у танков — группа b:*) добавляются через registerCodes и не должны
4
4
  // пересекаться с движковыми группами. Английские тексты движковых кодов — config/chatMessages.js,
5
5
  // игровых — на клиенте (конфиг игры).
@@ -46,9 +46,6 @@ export const ENGINE_MESSAGE_CODES = Object.freeze({
46
46
 
47
47
  COMMANDS_NOT_FOUND: 'c:0', // Command not found
48
48
  RANK: 'c:1', // Your rank: {0}
49
-
50
- NAME_INVALID: 'n:0', // Invalid name
51
- NAME_CHANGED: 'n:1', // {0} changed name to {1}
52
49
  });
53
50
 
54
51
  // изменяемый реестр: движковые коды + игровые из registerCodes
@@ -12,7 +12,7 @@ import { ENGINE_CAPABILITIES } from './capabilities.js';
12
12
  // должен зависеть от отладочного контура (граница из CLAUDE.md).
13
13
  //
14
14
  // entries.wasmNode — node-сборка ядра игры (конвенция core/pkg-node,
15
- // docs/ai/02-packaging.md). Для боевой игры она обязательна: без ядра
15
+ // docs/en/packaging.md). Для боевой игры она обязательна: без ядра
16
16
  // авторитетную симуляцию крутить нечем.
17
17
 
18
18
  /**
@@ -13,6 +13,14 @@ const NAME_REGEXP = new RegExp('^[a-zA-Z]([\\w\\s#]{0,13})[\\w]{1}$');
13
13
  export const isValidName = name =>
14
14
  typeof name === 'string' && NAME_REGEXP.test(name);
15
15
 
16
+ // ник профиля: копия правила auth-сервиса (packages/auth/src/lib/validators.js)
17
+ // — общей рантайм-зависимости у пакетов нет. Строже isValidName: внутри ника
18
+ // допустим только обычный пробел, не любой \s
19
+ const NICK_REGEXP = new RegExp('^[a-zA-Z]([\\w #]{0,13})[\\w]{1}$');
20
+
21
+ export const isValidNick = nick =>
22
+ typeof nick === 'string' && NICK_REGEXP.test(nick);
23
+
16
24
  // потолок длины поля формы, когда maxlength в дескрипторе не объявлен.
17
25
  // regExp игры исполняется на хосте против строки, которую прислал клиент, и
18
26
  // катастрофический паттерн вроде "(a+)+b" превращает три десятка символов в
@@ -8,7 +8,7 @@ import { ensureGameShell } from '../client/views/gameShell.js';
8
8
 
9
9
  // Standalone SDK (Этап 3 плана standalone-sdk): единственная точка входа для
10
10
  // репозитория игры — `npm run dev` открывает вкладку с работающим матчем без
11
- // мастера, без OAuth и без экрана лобби.
11
+ // мастера, без входа и без экрана лобби.
12
12
  //
13
13
  // Отличия от прода (осознанные, см. plan/standalone-sdk/README.md):
14
14
  // - хост крутится в главном потоке (inline), а не в Worker'е: HostPlugin