vimp-engine 0.2.0 → 0.4.0

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 (50) hide show
  1. package/bin/vimp-sim.js +137 -0
  2. package/package.json +10 -3
  3. package/src/config/gameCodes.js +8 -0
  4. package/src/config/lobby.js +23 -1
  5. package/src/config/master.js +8 -0
  6. package/src/config/opcodes.js +3 -1
  7. package/src/devtools/RecordingSocketManager.js +118 -0
  8. package/src/devtools/ScenarioRunner.js +529 -0
  9. package/src/devtools/VirtualClient.js +363 -0
  10. package/src/devtools/VirtualClock.js +169 -0
  11. package/src/devtools/inspectHost.js +31 -0
  12. package/src/devtools/invariants.js +672 -0
  13. package/src/devtools/pluginLoader.js +101 -0
  14. package/src/devtools/report.js +202 -0
  15. package/src/devtools/resetHostSingletons.js +23 -0
  16. package/src/host/DebugRecorder.js +224 -0
  17. package/src/host/GameCoreAdapter.js +17 -0
  18. package/src/host/HostGame.js +129 -7
  19. package/src/host/host.worker.js +46 -47
  20. package/src/host/meta/SocketManager.js +15 -7
  21. package/src/host/meta/modules/Panel.js +7 -0
  22. package/src/host/meta/modules/PlayerDataSync.js +29 -5
  23. package/src/host/meta/modules/RTTManager.js +4 -2
  24. package/src/host/meta/modules/Stat.js +7 -0
  25. package/src/host/meta/modules/TimerManager.js +15 -7
  26. package/src/host/meta/modules/Vote.js +10 -1
  27. package/src/host/meta/modules/chat/Chat.js +7 -0
  28. package/src/host/meta/modules/chat/index.js +1 -1
  29. package/src/host/meta/player/HumanParticipant.js +2 -1
  30. package/src/lib/AbstractTimer.js +6 -4
  31. package/src/lib/applyRoomOverrides.js +6 -0
  32. package/src/lib/clientCoreConfig.js +4 -0
  33. package/src/lib/clock.js +53 -0
  34. package/src/lib/createHostRuntime.js +108 -0
  35. package/src/lib/reconstructHot.js +78 -0
  36. package/src/lib/validators.js +10 -0
  37. package/tests/fixtures/miniGame/client/fakeClientCore.js +304 -0
  38. package/tests/fixtures/miniGame/client/index.js +37 -0
  39. package/tests/fixtures/miniGame/client/parts/Actor.js +18 -0
  40. package/tests/fixtures/miniGame/client/parts/ActorRadar.js +7 -0
  41. package/tests/fixtures/miniGame/config/auth.js +41 -0
  42. package/tests/fixtures/miniGame/config/client.js +139 -0
  43. package/tests/fixtures/miniGame/config/game.js +148 -0
  44. package/tests/fixtures/miniGame/host/ScriptedManager.js +129 -0
  45. package/tests/fixtures/miniGame/host/createModules.js +6 -0
  46. package/tests/fixtures/miniGame/host/fakeCore.js +233 -0
  47. package/tests/fixtures/miniGame/host/index.js +29 -0
  48. package/tests/fixtures/miniGame/host/spawnCommand.js +14 -0
  49. package/tests/fixtures/miniGame/host/systemMessages.js +6 -0
  50. package/tests/fixtures/miniGame.contract.test.js +76 -0
@@ -11,7 +11,9 @@ import VoteCoordinator from './meta/core/VoteCoordinator.js';
11
11
  import RoundManager from './meta/core/RoundManager.js';
12
12
  import CommandProcessor from './meta/core/CommandProcessor.js';
13
13
  import { sanitizeMessage } from '../lib/sanitizers.js';
14
+ import clock from '../lib/clock.js';
14
15
  import GameCoreAdapter from './GameCoreAdapter.js';
16
+ import DebugRecorder from './DebugRecorder.js';
15
17
 
16
18
  // Версия формата handoff-меты эстафеты Worker'ов (Этап 5.2; →2 в Этапе 6.5 —
17
19
  // добавлены gameId/gameVersion; →3 в Этапе Д3 — нейтральное поле scripted).
@@ -70,6 +72,8 @@ export default class HostGame {
70
72
  * @param {string} [opts.gameVersion] - версия игры комнаты (room.game.version,
71
73
  * Этап 6.5): едет в handoff-мете рядом с gameId — расхождение с игрой
72
74
  * восстанавливающего Worker'а валит init тем же путём, что и версия формата.
75
+ * @param {number} [opts.seed] - seed мира, которым инициализировано ядро
76
+ * (createHostRuntime): без него записанный сценарий невоспроизводим.
73
77
  */
74
78
  constructor(
75
79
  data,
@@ -81,9 +85,12 @@ export default class HostGame {
81
85
  onMapChange = null,
82
86
  handoff = null,
83
87
  gameVersion = null,
88
+ playerDataFetch = null,
89
+ seed = null,
84
90
  } = {},
85
91
  ) {
86
92
  this._isDevMode = data.isDevMode || false;
93
+ this._seed = seed;
87
94
 
88
95
  this._hostSocketId = hostSocketId;
89
96
  this._onMapChange = onMapChange;
@@ -126,13 +133,21 @@ export default class HostGame {
126
133
  // обратно на мастер по границам раунда/карты (см. RoundManager)
127
134
  this._playerDataSync = new PlayerDataSync(this._gameId, {
128
135
  defaultState: data.playerState?.defaultState ?? {},
136
+ // headless-прогону некуда ходить за профилем (относительный URL и нет
137
+ // мастера) — подменяется только там, в проде остаётся глобальный fetch
138
+ ...(playerDataFetch ? { fetchImpl: playerDataFetch } : {}),
129
139
  });
130
140
  this._chat = new Chat();
131
141
  this._vote = new Vote();
132
142
 
133
143
  this._socketManager = socketManager;
134
144
 
135
- this._snapshotManager = new SnapshotThrottle(data.timers.networkSendRate);
145
+ this._networkSendRate = data.timers.networkSendRate;
146
+ this._snapshotManager = new SnapshotThrottle(this._networkSendRate);
147
+
148
+ // рекордер живого матча (этап 6 плана plan/ai-debug) — только в dev-режиме;
149
+ // в проде null, и все точки записи ниже вырождаются в ?.
150
+ this._recorder = this._isDevMode ? new DebugRecorder() : null;
136
151
 
137
152
  // игровые host-модули (scripted-модуль игры)
138
153
  this._scripted = hostPlugin.createModules({
@@ -282,6 +297,9 @@ export default class HostGame {
282
297
 
283
298
  // создаёт кадр игры (core-driven)
284
299
  _onShotTick(dt) {
300
+ // номер тика — единственная временная координата сценария (этап 6)
301
+ this._recorder?.tick(dt);
302
+
285
303
  // шаг ядра + проекция событий (kill/health/ammo/weapon/shake) в мету
286
304
  this._game.updateData(dt);
287
305
 
@@ -313,7 +331,7 @@ export default class HostGame {
313
331
  const chat = this._chat.shift();
314
332
  const vote = this._vote.shift();
315
333
 
316
- const serverTime = Date.now();
334
+ const serverTime = clock.now();
317
335
  this._seq = (this._seq + 1) >>> 0;
318
336
  const seq = this._seq;
319
337
  const activeList = this._participants.getActiveList();
@@ -398,7 +416,7 @@ export default class HostGame {
398
416
 
399
417
  // проверяет игроков на бездействие и кикает, если превышен порог
400
418
  _kickIdleUsers() {
401
- const now = Date.now();
419
+ const now = clock.now();
402
420
  const usersToKick = [];
403
421
 
404
422
  for (const user of this._participants.getHumans()) {
@@ -493,6 +511,94 @@ export default class HostGame {
493
511
  this._mapList.push(...Object.keys(this._maps));
494
512
  }
495
513
 
514
+ // ***** отладочный контур (этап 6 плана plan/ai-debug) ***** //
515
+ // Всё ниже живёт под флагом isDevMode: в проде рекордера нет, а дамп
516
+ // отдаёт только то, что и так знает мета.
517
+
518
+ get isRecording() {
519
+ return this._recorder?.isRecording === true;
520
+ }
521
+
522
+ // начинает запись живого матча в формат сценария headless-runner'а.
523
+ // Возвращает false, если dev-режим выключен — тишины быть не должно
524
+ startRecording() {
525
+ if (!this._recorder) {
526
+ return false;
527
+ }
528
+
529
+ this._recorder.start({
530
+ seed: this._seed,
531
+ map: this._roundManager.currentMap,
532
+ networkSendRate: this._networkSendRate,
533
+ participants: this._participants.getHumans().map(user => ({
534
+ gameId: user.gameId,
535
+ name: user.name,
536
+ model: user.model,
537
+ socketId: user.socketId,
538
+ team: user.team === this._spectatorTeam ? null : user.team,
539
+ })),
540
+ });
541
+
542
+ this._debugConsole('recording started');
543
+
544
+ return true;
545
+ }
546
+
547
+ // останавливает запись и отдаёт сценарий (parseScenario принимает как есть)
548
+ stopRecording() {
549
+ const scenario = this._recorder?.stop() ?? null;
550
+
551
+ if (scenario) {
552
+ this._debugConsole(
553
+ `recording stopped: ${scenario.ticks} tick(s), ` +
554
+ `${scenario.timeline.length} op(s)`,
555
+ );
556
+ }
557
+
558
+ return scenario;
559
+ }
560
+
561
+ // курированный срез хоста: мета рядом с дампом мира ядра (этап 4) —
562
+ // расхождение «в ядре тело есть, на холсте пусто» видно только вместе
563
+ debugSnapshot() {
564
+ return {
565
+ seed: this._seed,
566
+ seq: this._seq,
567
+ tick: this._recorder?.tickCount ?? null,
568
+ recording: this.isRecording,
569
+ currentMap: this._roundManager.currentMap,
570
+ participants: {
571
+ humans: this._participants.getHumans().map(user => ({
572
+ gameId: user.gameId,
573
+ socketId: user.socketId,
574
+ name: user.name,
575
+ team: user.team,
576
+ isReady: user.isReady,
577
+ isWatching: user.isWatching,
578
+ })),
579
+ scripted: this._participants.getScripted().map(user => ({
580
+ gameId: user.gameId,
581
+ name: user.name,
582
+ team: user.team,
583
+ })),
584
+ activeList: this._participants.getActiveList(),
585
+ },
586
+ core: this._game.debugJson(),
587
+ };
588
+ }
589
+
590
+ // структурированный лог хоста в консоль клиентов (порт CONSOLE): Worker
591
+ // изолирован от DevTools вкладки, поэтому его события иначе не видны
592
+ _debugConsole(message) {
593
+ if (!this._isDevMode) {
594
+ return;
595
+ }
596
+
597
+ for (const user of this._participants.getNetworkedReady()) {
598
+ this._socketManager.sendConsole(user.socketId, message);
599
+ }
600
+ }
601
+
496
602
  // ***** эстафета Worker'ов (Этап 5.2) ***** //
497
603
 
498
604
  // запрашивает перенос: на ближайшей границе раунда игра останавливается
@@ -683,6 +789,13 @@ export default class HostGame {
683
789
  const gameId = this._participants.createHuman(params, socketId);
684
790
  const name = this._participants.get(gameId).name;
685
791
 
792
+ this._recorder?.noteJoin({
793
+ gameId,
794
+ name,
795
+ model: params.model,
796
+ socketId,
797
+ });
798
+
686
799
  this._chat.addUser(gameId);
687
800
  this._vote.addUser(gameId);
688
801
  this._stat.addUser(gameId, this._spectatorId, { name });
@@ -708,6 +821,8 @@ export default class HostGame {
708
821
 
709
822
  const { team, teamId } = user;
710
823
 
824
+ this._recorder?.noteLeave(gameId);
825
+
711
826
  this._RTTManager.removeUser(gameId);
712
827
  this._stat.removeUser(gameId, teamId);
713
828
  this._chat.removeUser(gameId);
@@ -717,7 +832,7 @@ export default class HostGame {
717
832
  // финальная синхронизация rank/state перед уходом участника (Этап B4)
718
833
  this._playerDataSync
719
834
  .flush(gameId)
720
- .catch(() => {})
835
+ .catch(err => console.warn('[playerData] final flush failed:', err.message))
721
836
  .finally(() => this._playerDataSync.removeUser(gameId));
722
837
 
723
838
  // если не наблюдатель — удалить танк из ядра (null-маркер ставит ядро)
@@ -740,7 +855,9 @@ export default class HostGame {
740
855
 
741
856
  const [seq, action, name] = keyStr.split(':');
742
857
 
743
- user.lastActionTime = Date.now();
858
+ this._recorder?.noteKey(gameId, action, name);
859
+
860
+ user.lastActionTime = clock.now();
744
861
  user.lastInputSeq = Number(seq) >>> 0;
745
862
 
746
863
  if (user.isWatching === true) {
@@ -766,7 +883,7 @@ export default class HostGame {
766
883
  return;
767
884
  }
768
885
 
769
- user.lastActionTime = Date.now();
886
+ user.lastActionTime = clock.now();
770
887
 
771
888
  message = sanitizeMessage(message);
772
889
 
@@ -775,6 +892,9 @@ export default class HostGame {
775
892
  }
776
893
 
777
894
  if (message) {
895
+ // пишется уже вычищенный текст — ровно тот, что применяется к матчу
896
+ this._recorder?.noteChat(gameId, message);
897
+
778
898
  if (message.charAt(0) === '/') {
779
899
  this._commandProcessor.parseCommand(gameId, message);
780
900
  } else {
@@ -791,7 +911,9 @@ export default class HostGame {
791
911
  return;
792
912
  }
793
913
 
794
- user.lastActionTime = Date.now();
914
+ this._recorder?.noteVote(gameId, data);
915
+
916
+ user.lastActionTime = clock.now();
795
917
 
796
918
  if (typeof data === 'string') {
797
919
  if (data === 'teams') {
@@ -7,17 +7,11 @@
7
7
  // (JSON-строки и бинарные ArrayBuffer'ы через Transferable).
8
8
 
9
9
  import authClientConfig from '../config/authClient.js';
10
- import clientDefaults from '../config/clientDefaults.js';
11
10
  import lobbyConfig from '../config/lobby.js';
12
11
  import wsports from '../config/wsports.js';
13
- import { applyRoomOverrides } from '../lib/applyRoomOverrides.js';
14
- import { buildClientConfig } from '../lib/buildClientConfig.js';
15
- import { buildCoreConfig } from '../lib/coreConfig.js';
12
+ import { createHostRuntime } from '../lib/createHostRuntime.js';
16
13
  import { validateAuth } from '../lib/validators.js';
17
14
  import { verifyIdentityToken } from '../lib/jwt.js';
18
- import { assertGameConfigShape } from '../lib/gamePlugin.js';
19
- import SocketManager from './meta/SocketManager.js';
20
- import HostGame from './HostGame.js';
21
15
 
22
16
  // PC (client ports): порты получения данных от клиента
23
17
  const PC_CONFIG_READY = wsports.client.CONFIG_READY;
@@ -130,54 +124,28 @@ function makeWorkerSocket(socketId) {
130
124
  // эстафеты Worker'ов (Этап 5.2): комната восстанавливается вместо
131
125
  // холодного старта, порт-машины клиентов поднимутся минуя хендшейк
132
126
  async function onInit(room, handoff = null) {
133
- const pluginModule = await import(/* @vite-ignore */ room.game.hostEntryUrl);
134
-
135
- hostPlugin = pluginModule.default;
136
- assertGameConfigShape(hostPlugin);
137
-
138
- const game = applyRoomOverrides(room, hostPlugin);
139
- const seed = (Math.random() * 2 ** 32) >>> 0;
140
-
141
- const core = await hostPlugin.createCore(
142
- JSON.stringify(
143
- buildCoreConfig(hostPlugin.gameConfig, {
144
- friendlyFire: game.parts.friendlyFire,
145
- seed,
146
- }),
147
- ),
148
- { wasmUrl: room.game.wasmUrl },
149
- );
150
-
151
- clientCfg = buildClientConfig(
152
- game,
153
- clientDefaults,
154
- hostPlugin.buildClientGameConfig(),
155
- );
156
- socketManager = new SocketManager(wsports.server, {
157
- soundCues: game.soundCues,
158
- initialVote: game.initialVote,
159
- });
160
- host = new HostGame(game, socketManager, core, hostPlugin, {
161
- hostSocketId: room?.hostSocketId ?? null,
162
- onMapChange: mapName => self.postMessage({ type: 'map_changed', mapName }),
163
- handoff,
164
- gameVersion: room.game.version,
127
+ // общая с headless-runner'ом сборка (lib/createHostRuntime.js) — чтобы
128
+ // отладочный прогон крутил ровно тот код, что и прод
129
+ const runtime = await createHostRuntime(room, {
130
+ hostOptions: {
131
+ onMapChange: mapName => self.postMessage({ type: 'map_changed', mapName }),
132
+ handoff,
133
+ },
165
134
  });
166
135
 
167
- // эстафета Worker'ов (Этап 5.2) несёт уже известные hostId+секрет в room —
168
- // новый Worker не должен ждать повторного register_host, чтобы возобновить
169
- // атрибуцию rank/state-flush (кодревью №1); при холодном старте они ещё не
170
- // назначены мастером, придут позже через 'set_host_id'
171
- if (room?.hostId) {
172
- host.setHostId(room.hostId, room.hostSecret);
173
- }
136
+ hostPlugin = runtime.hostPlugin;
137
+ clientCfg = runtime.clientCfg;
138
+ socketManager = runtime.socketManager;
139
+ host = runtime.host;
140
+
141
+ const seed = runtime.seed;
174
142
 
175
143
  if (handoff) {
176
144
  handoffClients = new Map(handoff.humans.map(h => [h.socketId, h.gameId]));
177
145
  }
178
146
 
179
147
  // мастеру нужна фактическая карта комнаты (после эстафеты — восстановленная)
180
- self.postMessage({ type: 'ready', mapName: host.currentMap });
148
+ self.postMessage({ type: 'ready', mapName: host.currentMap, seed });
181
149
  }
182
150
 
183
151
  // порт-обработчики клиента (замыкание над gameId через state)
@@ -375,6 +343,31 @@ function onDisconnect(socketId) {
375
343
  clients.delete(socketId);
376
344
  }
377
345
 
346
+ // отладочные действия хоста (этап 6): запись живого матча в формат сценария
347
+ // и дамп мира. Сбой не должен ронять Worker — уезжает в ответ строкой
348
+ function onDebug({ requestId, action }) {
349
+ let result = null;
350
+ let error = null;
351
+
352
+ try {
353
+ if (!host) {
354
+ error = 'host is not ready';
355
+ } else if (action === 'startRecording') {
356
+ result = host.startRecording();
357
+ } else if (action === 'stopRecording') {
358
+ result = host.stopRecording();
359
+ } else if (action === 'dump') {
360
+ result = host.debugSnapshot();
361
+ } else {
362
+ error = `unknown debug action '${action}'`;
363
+ }
364
+ } catch (e) {
365
+ error = e && e.message ? e.message : String(e);
366
+ }
367
+
368
+ self.postMessage({ type: 'debug_result', requestId, result, error });
369
+ }
370
+
378
371
  self.onmessage = async event => {
379
372
  const msg = event.data;
380
373
 
@@ -414,6 +407,12 @@ self.onmessage = async event => {
414
407
  host?.setHostId(msg.hostId, msg.hostSecret);
415
408
  break;
416
409
 
410
+ // отладочный контур (этап 6 плана plan/ai-debug): единственный вход в
411
+ // авторитетную половину из главного потока — запрос/ответ по requestId
412
+ case 'debug':
413
+ onDebug(msg);
414
+ break;
415
+
417
416
  // эстафета Worker'ов (Этап 5.2)
418
417
 
419
418
  // запрос переноса: на ближайшей границе раунда игра остановится и
@@ -1,3 +1,6 @@
1
+ import GAME_CODES from '../../config/gameCodes.js';
2
+ import clock from '../../lib/clock.js';
3
+
1
4
  const TECH_CODES = {
2
5
  fullServer: [0],
3
6
  anotherDevice: [1],
@@ -8,12 +11,6 @@ const TECH_CODES = {
8
11
  roomFull: [6], // P2P-комната заполнена (отказ без очереди ожидания)
9
12
  };
10
13
 
11
- const GAME_CODES = {
12
- winnerTeam: [0],
13
- roundStart: [1],
14
- gameOver: [2],
15
- };
16
-
17
14
  export default class SocketManager {
18
15
  /**
19
16
  * @param {Object} ports - карта портов host→client (wsports.server).
@@ -282,7 +279,7 @@ export default class SocketManager {
282
279
  this._send(socketId, this._PORT_FIRST_SHOT_DATA, [
283
280
  this._game.getPlayersData(), // gameSnapshot
284
281
  0, // camera
285
- Date.now(), // serverTime
282
+ clock.now(), // serverTime
286
283
  0, // seq
287
284
  ]);
288
285
  this.sendStat(socketId, this._stat.getFull());
@@ -311,6 +308,17 @@ export default class SocketManager {
311
308
  this._sendBinary(socketId, frameBuffer, reliable);
312
309
  }
313
310
 
311
+ /**
312
+ * Отправка отладочного лога хоста в консоль клиента (порт CONSOLE,
313
+ * этап 6 плана plan/ai-debug). Worker изолирован от DevTools вкладки —
314
+ * без этого канала его события в браузере не видны вовсе.
315
+ * @param {string} socketId
316
+ * @param {*} data
317
+ */
318
+ sendConsole(socketId, data) {
319
+ this._send(socketId, this._PORT_CONSOLE, data);
320
+ }
321
+
314
322
  /**
315
323
  * Отправка данных панели.
316
324
  * @param {string} socketId
@@ -178,4 +178,11 @@ class Panel {
178
178
  }
179
179
  }
180
180
 
181
+ // Сброс синглтона. Нужен только тем, кто крутит больше одного матча в
182
+ // процессе — headless-runner (devtools/resetHostSingletons.js) и тесты;
183
+ // в браузерной вкладке матч всегда один, поэтому прод его не зовёт.
184
+ export const resetPanel = () => {
185
+ panel = null;
186
+ };
187
+
181
188
  export default Panel;
@@ -7,7 +7,11 @@ import lobbyConfig from '../../../config/lobby.js';
7
7
  // (RoundManager: смена карты, конец раунда). Схему/дефолты state объявляет
8
8
  // игра — здесь это чёрный ящик.
9
9
  export default class PlayerDataSync {
10
- constructor(gameId, { fetchImpl = fetch, defaultState = {} } = {}) {
10
+ // дефолт fetchImpl обёрнут стрелкой, а не взят как голый `fetch`: из поля
11
+ // объекта он вызывался бы с `this` экземпляра, а в браузере/воркере у
12
+ // fetch brand-check на глобальный scope — это синхронный TypeError ещё до
13
+ // обращения к сети (тесты подставляют обычную функцию и мимо этого ходят)
14
+ constructor(gameId, { fetchImpl = (...args) => fetch(...args), defaultState = {} } = {}) {
11
15
  this._gameId = gameId;
12
16
  this._fetch = fetchImpl;
13
17
  this._defaultState = defaultState;
@@ -67,23 +71,31 @@ export default class PlayerDataSync {
67
71
  this._authedFetch(lobbyConfig.auth.stateUrl, token),
68
72
  ]);
69
73
 
70
- if (rankRes.ok && !entry.rankLoaded) {
74
+ // неуспешный ответ логируется: rankLoaded/stateLoaded гейтят весь
75
+ // последующий flush, поэтому молчаливый 401/404 здесь навсегда
76
+ // выключает синхронизацию и выглядит снаружи как «данных просто нет»
77
+ if (!rankRes.ok) {
78
+ console.warn(`[playerData] GET rank ${rankRes.status} for ${participantId}`);
79
+ } else if (!entry.rankLoaded) {
71
80
  // F9: во время await мог накопиться addRank-дельта поверх
72
81
  // стартового 0 — прибавляем, а не перетираем серверным значением
73
82
  entry.rank += (await rankRes.json()).rank ?? 0;
74
83
  entry.rankLoaded = true;
75
84
  }
76
85
 
77
- if (stateRes.ok && !entry.stateLoaded) {
86
+ if (!stateRes.ok) {
87
+ console.warn(`[playerData] GET state ${stateRes.status} for ${participantId}`);
88
+ } else if (!entry.stateLoaded) {
78
89
  const { state } = await stateRes.json();
79
90
 
80
91
  entry.state =
81
92
  state && Object.keys(state).length ? state : structuredClone(this._defaultState);
82
93
  entry.stateLoaded = true;
83
94
  }
84
- } catch {
95
+ } catch (err) {
85
96
  // недоступность auth-сервиса — остаёмся на дефолтах, следующий
86
97
  // flush повторит load() перед синхронизацией (см. flush)
98
+ console.warn(`[playerData] load failed for ${participantId}:`, err.message);
87
99
  }
88
100
 
89
101
  return entry;
@@ -152,6 +164,8 @@ export default class PlayerDataSync {
152
164
  }).then(res => {
153
165
  if (res.ok) {
154
166
  entry.pendingRankDelta -= delta;
167
+ } else {
168
+ console.warn(`[playerData] PUT rank ${res.status} for ${participantId}`);
155
169
  }
156
170
  }),
157
171
  );
@@ -161,10 +175,20 @@ export default class PlayerDataSync {
161
175
  requests.push(this._authedFetch(lobbyConfig.auth.stateUrl, token, {
162
176
  method: 'PUT',
163
177
  body: { state, hostId: this._hostId, hostSecret: this._hostSecret },
178
+ }).then(res => {
179
+ if (!res.ok) {
180
+ console.warn(`[playerData] PUT state ${res.status} for ${participantId}`);
181
+ }
164
182
  }));
165
183
  }
166
184
 
167
- await Promise.allSettled(requests);
185
+ // allSettled глушит отказы по замыслу (сбой не должен ронять раунд), но
186
+ // не должен глушить их след — иначе потеря синхронизации не диагностируема
187
+ for (const result of await Promise.allSettled(requests)) {
188
+ if (result.status === 'rejected') {
189
+ console.warn(`[playerData] flush failed for ${participantId}:`, result.reason?.message);
190
+ }
191
+ }
168
192
  }
169
193
 
170
194
  // синхронизирует всех текущих участников (границы раунда/карты)
@@ -1,3 +1,5 @@
1
+ import clock from '../../../lib/clock.js';
2
+
1
3
  /**
2
4
  * @class RTTManager
3
5
  * @description Управляет расчетом Round Trip Time (RTT) для пользователя,
@@ -96,7 +98,7 @@ export default class RTTManager {
96
98
 
97
99
  // новый пинг
98
100
  user.pingIdCounter += 1;
99
- const time = Date.now();
101
+ const time = clock.now();
100
102
 
101
103
  // удаление старых пингов и запись нового
102
104
  // `outstandingPings` содержит только один, последний отправленный пинг
@@ -141,7 +143,7 @@ export default class RTTManager {
141
143
  const time = user.outstandingPings.get(pingId);
142
144
 
143
145
  if (time) {
144
- const newRttSample = Date.now() - time;
146
+ const newRttSample = clock.now() - time;
145
147
  const oldRtt = user.rtt;
146
148
 
147
149
  // сглаживание значения RTT
@@ -291,4 +291,11 @@ class Stat {
291
291
  }
292
292
  }
293
293
 
294
+ // Сброс синглтона. Нужен только тем, кто крутит больше одного матча в
295
+ // процессе — headless-runner (devtools/resetHostSingletons.js) и тесты;
296
+ // в браузерной вкладке матч всегда один, поэтому прод его не зовёт.
297
+ export const resetStat = () => {
298
+ stat = null;
299
+ };
300
+
294
301
  export default Stat;
@@ -1,4 +1,5 @@
1
1
  import AbstractTimer from '../../../lib/AbstractTimer.js';
2
+ import clock from '../../../lib/clock.js';
2
3
 
3
4
  // Singleton TimerManager
4
5
 
@@ -66,7 +67,7 @@ class TimerManager extends AbstractTimer {
66
67
  // при возобновлении после эстафеты Worker'ов, Этап 5.2)
67
68
  startMapTimer(duration = this._mapTime) {
68
69
  this.stopMapTimer();
69
- this._startMapTime = Date.now();
70
+ this._startMapTime = clock.now();
70
71
  this._currentMapDuration = duration;
71
72
  this._startTimer('map', this._callbacks.onMapTimeEnd, duration);
72
73
  }
@@ -79,7 +80,7 @@ class TimerManager extends AbstractTimer {
79
80
  // возвращает оставшееся время до конца карты в миллисекундах
80
81
  getMapTimeLeft() {
81
82
  const timeLeft =
82
- this._currentMapDuration - (Date.now() - this._startMapTime);
83
+ this._currentMapDuration - (clock.now() - this._startMapTime);
83
84
 
84
85
  return Math.max(0, timeLeft);
85
86
  }
@@ -95,7 +96,7 @@ class TimerManager extends AbstractTimer {
95
96
  // запускает таймер до конца текущего раунда
96
97
  startRoundTimer() {
97
98
  this.stopRoundTimer();
98
- this._startRoundTime = Date.now();
99
+ this._startRoundTime = clock.now();
99
100
  this._startTimer('round', this._callbacks.onRoundTimeEnd, this._roundTime);
100
101
  }
101
102
 
@@ -106,21 +107,21 @@ class TimerManager extends AbstractTimer {
106
107
 
107
108
  // возвращает оставшееся время до конца раунда в секундах
108
109
  getRoundTimeLeft() {
109
- const timeLeft = this._roundTime - (Date.now() - this._startRoundTime);
110
+ const timeLeft = this._roundTime - (clock.now() - this._startRoundTime);
110
111
 
111
112
  return Math.max(0, Math.floor(timeLeft / 1000));
112
113
  }
113
114
 
114
115
  // проверяет возможно сменить команду игроку в текущем раунде
115
116
  canChangeTeamInCurrentRound() {
116
- const roundTime = Date.now() - this._startRoundTime;
117
+ const roundTime = clock.now() - this._startRoundTime;
117
118
 
118
119
  return roundTime <= this._teamChangeGracePeriod;
119
120
  }
120
121
 
121
122
  // логика одного "тика" игрового цикла
122
123
  _loopTick() {
123
- const now = performance.now();
124
+ const now = clock.monotonic();
124
125
  let dt = (now - this._lastShotTime) / 1000;
125
126
  this._lastShotTime = now;
126
127
 
@@ -146,7 +147,7 @@ class TimerManager extends AbstractTimer {
146
147
  _startGameLoop() {
147
148
  this._stopGameLoop();
148
149
 
149
- this._lastShotTime = performance.now();
150
+ this._lastShotTime = clock.monotonic();
150
151
  this._expectedTickTime = this._lastShotTime + this._timeStep;
151
152
 
152
153
  // запуск первого таймаута, который инициирует цикл
@@ -274,4 +275,11 @@ class TimerManager extends AbstractTimer {
274
275
  }
275
276
  }
276
277
 
278
+ // Сброс синглтона. Нужен только тем, кто крутит больше одного матча в
279
+ // процессе — headless-runner (devtools/resetHostSingletons.js) и тесты;
280
+ // в браузерной вкладке матч всегда один, поэтому прод его не зовёт.
281
+ export const resetTimerManager = () => {
282
+ timerManager = null;
283
+ };
284
+
277
285
  export default TimerManager;
@@ -1,3 +1,5 @@
1
+ import clock from '../../../lib/clock.js';
2
+
1
3
  // Singleton Vote
2
4
  let vote;
3
5
 
@@ -171,9 +173,16 @@ class Vote {
171
173
  }
172
174
 
173
175
  // случайный выбор, если победителей несколько
174
- const randomIndex = Math.floor(Math.random() * winners.length);
176
+ const randomIndex = Math.floor(clock.random() * winners.length);
175
177
  return winners[randomIndex];
176
178
  }
177
179
  }
178
180
 
181
+ // Сброс синглтона. Нужен только тем, кто крутит больше одного матча в
182
+ // процессе — headless-runner (devtools/resetHostSingletons.js) и тесты;
183
+ // в браузерной вкладке матч всегда один, поэтому прод его не зовёт.
184
+ export const resetVote = () => {
185
+ vote = null;
186
+ };
187
+
179
188
  export default Vote;
@@ -63,4 +63,11 @@ class Chat {
63
63
  }
64
64
  }
65
65
 
66
+ // Сброс синглтона. Нужен только тем, кто крутит больше одного матча в
67
+ // процессе — headless-runner (devtools/resetHostSingletons.js) и тесты;
68
+ // в браузерной вкладке матч всегда один, поэтому прод его не зовёт.
69
+ export const resetChat = () => {
70
+ chat = null;
71
+ };
72
+
66
73
  export default Chat;
@@ -1 +1 @@
1
- export { default } from './Chat.js';
1
+ export { default, resetChat } from './Chat.js';