vimp-engine 0.35.6 → 0.37.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 (132) hide show
  1. package/bin/vimp-sim.js +19 -1
  2. package/core/Cargo.toml +1 -1
  3. package/package.json +1 -1
  4. package/src/client/components/controller/Controls.js +5 -0
  5. package/src/client/components/controller/Lobby.js +4 -4
  6. package/src/client/components/controller/RoomMenu.js +43 -0
  7. package/src/client/components/controller/Vote.js +18 -2
  8. package/src/client/components/model/Chat.js +19 -2
  9. package/src/client/components/model/ClientReports.js +6 -2
  10. package/src/client/components/model/Controls.js +27 -0
  11. package/src/client/components/model/Games.js +5 -1
  12. package/src/client/components/model/Lobby.js +32 -24
  13. package/src/client/components/model/LobbyAuth.js +20 -4
  14. package/src/client/components/model/RoomMenu.js +121 -0
  15. package/src/client/components/model/Stat.js +2 -1
  16. package/src/client/components/model/Vote.js +122 -4
  17. package/src/client/components/view/Auth.js +20 -10
  18. package/src/client/components/view/ClientReports.js +16 -5
  19. package/src/client/components/view/Games.js +39 -14
  20. package/src/client/components/view/Lobby.js +90 -43
  21. package/src/client/components/view/LobbyAuth.js +13 -7
  22. package/src/client/components/view/Panel.js +1 -2
  23. package/src/client/components/view/RoomMenu.js +69 -0
  24. package/src/client/debug.js +24 -1
  25. package/src/client/lib/FpsMeter.js +46 -0
  26. package/src/client/lib/JoinRetry.js +73 -0
  27. package/src/client/lib/diagnostics.js +28 -5
  28. package/src/client/lib/hostCaps.js +82 -0
  29. package/src/client/lib/hostUnloadGuard.js +108 -0
  30. package/src/client/lib/hostVoteCommand.js +92 -0
  31. package/src/client/lib/pageReload.js +29 -0
  32. package/src/client/lib/roomLink.js +257 -0
  33. package/src/client/lib/signalingErrors.js +74 -0
  34. package/src/client/main.js +676 -451
  35. package/src/client/network/HiddenHostHealthLog.js +76 -0
  36. package/src/client/network/HostConnectionManager.js +62 -8
  37. package/src/client/network/HostController.js +376 -23
  38. package/src/client/network/HostHealthPolicy.js +307 -0
  39. package/src/client/network/HostHealthReporter.js +54 -0
  40. package/src/client/network/HostPrewarm.js +235 -0
  41. package/src/client/network/LoopbackTransport.js +16 -1
  42. package/src/client/network/PlannedHandoff.js +366 -0
  43. package/src/client/network/Promotion.js +420 -0
  44. package/src/client/network/RoomPeersReporter.js +81 -0
  45. package/src/client/network/SessionSupervisor.js +730 -0
  46. package/src/client/network/SignalingClient.js +183 -30
  47. package/src/client/network/StandbyReceiver.js +287 -0
  48. package/src/client/network/StandbySender.js +319 -0
  49. package/src/client/network/TokenHandoffTimer.js +81 -0
  50. package/src/client/network/WebRtcManager.js +338 -11
  51. package/src/client/network/policyClose.js +32 -2
  52. package/src/client/network/standbyChunks.js +185 -0
  53. package/src/client/session/GuestSession.js +428 -0
  54. package/src/client/session/HandoffFlow.js +399 -0
  55. package/src/client/session/HostRole.js +708 -0
  56. package/src/client/session/Membership.js +179 -0
  57. package/src/client/session/PromotionFlow.js +325 -0
  58. package/src/client/session/RouteBoot.js +432 -0
  59. package/src/client/session/StandbyRole.js +195 -0
  60. package/src/client/session/hostRoomPrep.js +152 -0
  61. package/src/client/session/roomContext.js +32 -0
  62. package/src/client/style.css +83 -22
  63. package/src/client/views/gameShell.js +7 -0
  64. package/src/client/views/includes/informer.pug +4 -0
  65. package/src/client/views/includes/lobby.pug +0 -3
  66. package/src/client/views/includes/roomMenu.pug +9 -0
  67. package/src/client/views/index.pug +1 -0
  68. package/src/config/chatMessages.js +45 -0
  69. package/src/config/closeCodes.js +1 -1
  70. package/src/config/env.js +37 -0
  71. package/src/config/hostDefaults.js +24 -0
  72. package/src/config/lobby.js +154 -3
  73. package/src/config/master.js +104 -21
  74. package/src/config/wsports.js +8 -0
  75. package/src/devtools/RecordingSocketManager.js +3 -2
  76. package/src/devtools/ScenarioRunner.js +123 -8
  77. package/src/devtools/VirtualClient.js +20 -0
  78. package/src/devtools/contract/format.js +4 -1
  79. package/src/devtools/contract/index.js +1 -2
  80. package/src/devtools/contract/rules/a2-package-scripts.js +1 -3
  81. package/src/devtools/contract/rules/b8-system-messages.js +2 -2
  82. package/src/devtools/contract/rules/b9-vote-names.js +25 -1
  83. package/src/devtools/contract/rules/c11-stat-mode.js +4 -1
  84. package/src/devtools/contract/rules/c6-stat-columns.js +1 -1
  85. package/src/devtools/contract/rules/c7-key-sets.js +8 -4
  86. package/src/devtools/contract/rules/d3-snapshot-interp.js +2 -1
  87. package/src/devtools/contract/rules/e1-sound-pairs.js +3 -1
  88. package/src/devtools/contract/rules/e4-map-layers.js +13 -2
  89. package/src/devtools/contract/rules/e5-map-radar-walls.js +7 -1
  90. package/src/devtools/contract/rules/index.js +38 -5
  91. package/src/devtools/invariants.js +10 -4
  92. package/src/devtools/pluginLoader.js +1 -4
  93. package/src/devtools/report.js +17 -1
  94. package/src/host/DebugRecorder.js +6 -1
  95. package/src/host/GameCoreAdapter.js +45 -0
  96. package/src/host/HostGame.js +928 -59
  97. package/src/host/PortMachine.js +268 -20
  98. package/src/host/host.worker.js +222 -16
  99. package/src/host/meta/SocketManager.js +20 -0
  100. package/src/host/meta/core/RoundManager.js +89 -14
  101. package/src/host/meta/modules/Accolades.js +4 -1
  102. package/src/host/meta/modules/Panel.js +30 -0
  103. package/src/host/meta/modules/PlayerDataSync.js +255 -46
  104. package/src/host/meta/modules/RTTManager.js +34 -0
  105. package/src/host/meta/modules/TimerManager.js +215 -18
  106. package/src/host/meta/modules/chat/systemMessages.js +29 -3
  107. package/src/host/meta/player/HumanParticipant.js +10 -0
  108. package/src/host/meta/player/ParticipantManager.js +98 -5
  109. package/src/lib/AbstractTimer.js +63 -1
  110. package/src/lib/Publisher.js +26 -0
  111. package/src/lib/capabilities.js +5 -0
  112. package/src/lib/checkpointCodec.js +125 -0
  113. package/src/lib/clientIp.js +9 -2
  114. package/src/lib/createHostRuntime.js +95 -6
  115. package/src/lib/formOptions.js +3 -1
  116. package/src/lib/gameConfigView.js +10 -0
  117. package/src/lib/gameId.js +13 -0
  118. package/src/lib/jwt.js +3 -1
  119. package/src/lib/loadGamePackage.js +2 -2
  120. package/src/lib/resumeKey.js +38 -0
  121. package/src/lib/roomId.js +39 -0
  122. package/src/lib/roomSettings.js +59 -0
  123. package/src/lib/validators.js +7 -0
  124. package/tests/fixtures/generations/gen-api3/host/ScriptedManager.js +2 -3
  125. package/tests/fixtures/generations/gen-api3/host/fakeCore.js +31 -5
  126. package/tests/fixtures/generations/gen-api3/manifest.json +1 -3
  127. package/tests/fixtures/generations/gen-api4/host/ScriptedManager.js +2 -3
  128. package/tests/fixtures/generations/gen-api4/host/fakeCore.js +31 -5
  129. package/tests/fixtures/generations/gen-api4/manifest.json +1 -3
  130. package/tests/fixtures/miniGame/config/client.js +0 -20
  131. package/tests/fixtures/miniGame/host/ScriptedManager.js +2 -3
  132. package/tests/fixtures/miniGame/host/fakeCore.js +31 -5
@@ -14,14 +14,45 @@ import CommandProcessor from './meta/core/CommandProcessor.js';
14
14
  import { sanitizeMessage } from '../lib/sanitizers.js';
15
15
  import closeCodes from '../config/closeCodes.js';
16
16
  import clock from '../lib/clock.js';
17
+ import { createResumeKey } from '../lib/resumeKey.js';
17
18
  import GameCoreAdapter from './GameCoreAdapter.js';
18
19
  import DebugRecorder from './DebugRecorder.js';
20
+ import enginePkg from '../../package.json' with { type: 'json' };
19
21
 
20
22
  // Версия формата handoff-меты эстафеты Worker'ов (Этап 5.2; →2 в Этапе 6.5 —
21
- // добавлены gameId/gameVersion; →3 в Этапе Д3 — нейтральное поле scripted).
23
+ // добавлены gameId/gameVersion; →3 в Этапе Д3 — нейтральное поле scripted;
24
+ // →4 в host-migration этапе 5 — контрольная точка посреди раунда).
22
25
  // Несовместимая версия валит init нового Worker'а — главный поток
23
- // возобновляет старый, комната продолжает жить на прежней версии кода
24
- export const HANDOFF_VERSION = 3;
26
+ // возобновляет старый, комната продолжает жить на прежней версии кода.
27
+ // Восстановление принимает и v3: смена кода в открытой комнате передаёт
28
+ // состояние от Worker'а предыдущей версии
29
+ export const HANDOFF_VERSION = 4;
30
+
31
+ // версии handoff-меты, которые понимает восстановление
32
+ const LEGACY_HANDOFF_VERSION = 3;
33
+
34
+ // на сколько номер кадра уходит вперёд при восстановлении из контрольной
35
+ // точки: клиенты видели кадры новее точки, и кадр со старым номером
36
+ // интерполятор отбросил бы как устаревший
37
+ const RESTORE_SEQ_GAP = 30;
38
+
39
+ // заглушка соединения отсоединённого участника (host-migration этап 4):
40
+ // рассылки RoundManager/Chat/Vote на время паузы молча пропадают
41
+ const DETACHED_SOCKET = {
42
+ send: () => {},
43
+ sendBinary: () => {},
44
+ close: () => {},
45
+ };
46
+
47
+ const detachedSocketId = gameId => `detached:${gameId}`;
48
+
49
+ // причина автоматической передачи (promote.reason) → системное сообщение
50
+ // вместо HOST_CHANGED
51
+ const HOST_CHANGED_BY_REASON = {
52
+ overload: 'HOST_CHANGED_OVERLOAD',
53
+ hidden: 'HOST_CHANGED_HIDDEN',
54
+ network: 'HOST_CHANGED_NETWORK',
55
+ };
25
56
 
26
57
  // Троттлинг отправки кадров (замена SnapshotManager: ядро само копит события
27
58
  // и дренирует их в pack_body, здесь нужен только контроль частоты).
@@ -48,6 +79,16 @@ class SnapshotThrottle {
48
79
  reset() {
49
80
  this._tick = 0;
50
81
  }
82
+
83
+ // фаза троттлинга — едет в контрольной точке (host-migration этап 5),
84
+ // чтобы ритм отправки кадров не сбился после восстановления
85
+ get tick() {
86
+ return this._tick;
87
+ }
88
+
89
+ set tick(value) {
90
+ this._tick = Math.min(Math.max(0, value >>> 0), this._sendRate - 1);
91
+ }
51
92
  }
52
93
 
53
94
  // Host-фасад: авторитетная часть матча в Worker'е хоста. Симуляция,
@@ -68,7 +109,10 @@ export default class HostGame {
68
109
  * @param {string} [opts.hostSocketId] - socketId хоста-игрока (loopback):
69
110
  * исключается из kick-политик — его отключение убивает комнату для всех.
70
111
  * @param {Function} [opts.onMapChange] - вызывается с именем карты при её
71
- * смене (голосование/таймер) — для актуализации комнаты у мастера.
112
+ * смене (голосование/таймер).
113
+ * @param {Function} [opts.onLobbyInfoChange] - вызывается со строкой
114
+ * карточки лобби (gameConfig.lobbyInfo / lobby.setInfo) или null при её
115
+ * смене — для актуализации комнаты у мастера.
72
116
  * @param {Object} [opts.handoff] - handoff-мета эстафеты Worker'ов
73
117
  * (Этап 5.2): восстановление комнаты вместо холодного старта.
74
118
  * @param {string} [opts.gameVersion] - версия игры комнаты (room.game.version,
@@ -76,6 +120,15 @@ export default class HostGame {
76
120
  * восстанавливающего Worker'а валит init тем же путём, что и версия формата.
77
121
  * @param {number} [opts.seed] - seed мира, которым инициализировано ядро
78
122
  * (createHostRuntime): без него записанный сценарий невоспроизводим.
123
+ * @param {Object} [opts.checkpoint] - распакованная контрольная точка
124
+ * { meta, core } (host-migration этап 5): матч поднимается из неё на
125
+ * паузе, люди — отсоединёнными (ждут RESUME), цикл запустит
126
+ * startAfterRestore().
127
+ * @param {number} [opts.seqFloor] - номер последнего кадра, который могли
128
+ * видеть клиенты: seq восстановленного матча уходит дальше него.
129
+ * @param {Object} [opts.roomSettings] - настройки комнаты (карта, лимит,
130
+ * таймеры, friendly fire) — едут в контрольной точке.
131
+ * @param {string} [opts.mapsVersion] - версия каталога карт мастера.
79
132
  */
80
133
  constructor(
81
134
  data,
@@ -85,17 +138,40 @@ export default class HostGame {
85
138
  {
86
139
  hostSocketId = null,
87
140
  onMapChange = null,
141
+ onLobbyInfoChange = null,
88
142
  handoff = null,
89
143
  gameVersion = null,
90
144
  playerDataFetch = null,
91
145
  seed = null,
146
+ checkpoint = null,
147
+ seqFloor = 0,
148
+ roomSettings = null,
149
+ mapsVersion = null,
92
150
  } = {},
93
151
  ) {
94
152
  this._isDevMode = data.isDevMode || false;
95
153
  this._seed = seed;
96
154
 
155
+ // контрольная точка (host-migration этап 5): игра гарантирует, что дамп
156
+ // ядра и хуки модулей переносят всё её состояние посреди раунда
157
+ this._migrationMidRound = data.migration?.midRound === true;
158
+ this._handoffFlushTimeoutMs = data.handoffFlushTimeoutMs ?? 3000;
159
+ // промоушен преемника (host-migration этап 7.4): сколько восстановленный
160
+ // матч ждёт возврата людей точки перед стартом
161
+ this._resumeWaitMs = data.resumeWaitMs ?? 3000;
162
+ // настройки комнаты (то, что читает applyRoomOverrides): преемник
163
+ // собирает по ним тот же конфиг ядра
164
+ this._roomSettings = roomSettings;
165
+ this._mapsVersion = mapsVersion;
166
+ this._roomId = null;
167
+
97
168
  this._hostSocketId = hostSocketId;
98
169
  this._onMapChange = onMapChange;
170
+ this._onLobbyInfoChange = onLobbyInfoChange;
171
+ // строка карточки лобби: 'map' — текущая карта, иначе ничего; значение
172
+ // модулей игры (lobby.setInfo) важнее
173
+ this._lobbyInfoMode = data.lobbyInfo === 'map' ? 'map' : null;
174
+ this._lobbyInfoOverride = null;
99
175
 
100
176
  // составной codeVersion (Этап 6.5): id — из самого загруженного плагина
101
177
  // (источник истины), version — то, что заявил Worker при инициализации
@@ -105,6 +181,11 @@ export default class HostGame {
105
181
  this._maps = data.maps;
106
182
  this._mapList = Object.keys(data.maps);
107
183
  this._spectatorKeys = data.spectatorKeys;
184
+ // команды актора: отсоединённому участнику (host-migration этап 4) их
185
+ // все отпускают — иначе танк ехал бы и стрелял всю паузу
186
+ this._playerKeyNames = Object.keys(data.playerKeys ?? {});
187
+ // эпоха комнаты (setRoom) — уходит в RESUME_RESULT
188
+ this._epoch = null;
108
189
  this._maxPlayers = data.maxPlayers;
109
190
  this._chatMaxLength = data.chatMaxLength;
110
191
 
@@ -175,8 +256,9 @@ export default class HostGame {
175
256
  // в проде null, и все точки записи ниже вырождаются в ?.
176
257
  this._recorder = this._isDevMode ? new DebugRecorder() : null;
177
258
 
178
- // игровые host-модули (scripted-модуль игры)
179
- this._scripted = hostPlugin.createModules({
259
+ // игровые host-модули (scripted-модуль игры); весь объект нужен
260
+ // контрольной точке — хуки serializeState/restoreState по имени модуля
261
+ this._modules = hostPlugin.createModules({
180
262
  participants: this._participants,
181
263
  coreAdapter: this._game,
182
264
  panel: this._panel,
@@ -184,7 +266,11 @@ export default class HostGame {
184
266
  chat: this._chat,
185
267
  socketManager: this._socketManager,
186
268
  scripted: data.scripted,
187
- }).scripted;
269
+ // строка карточки комнаты в лобби; на движке старше этого поля нет —
270
+ // игра зовёт lobby?.setInfo
271
+ lobby: { setInfo: text => this.setLobbyInfo(text) },
272
+ });
273
+ this._scripted = this._modules.scripted;
188
274
 
189
275
  this._RTTManager = new RTTManager(data.rtt, {
190
276
  onKickForMissedPings: gameId => this._kickForMissedPings(gameId),
@@ -197,6 +283,7 @@ export default class HostGame {
197
283
  onShotTick: dt => this._onShotTick(dt),
198
284
  onIdleCheck: () => this._kickIdleUsers(),
199
285
  onSendPing: () => this._sendPing(),
286
+ onLoopStats: stats => this._onLoopStats(stats),
200
287
  });
201
288
 
202
289
  this._voteCoordinator = new VoteCoordinator({
@@ -258,6 +345,26 @@ export default class HostGame {
258
345
  this._handoffRestored = false;
259
346
  this._handoffMapTimeLeft = null;
260
347
 
348
+ // контрольные точки (host-migration этап 5)
349
+ this._checkpointSink = null; // ({ meta, core, final }) => void
350
+ // здоровье хоста (host-migration этап 9a): раз в окно цикла
351
+ this._healthSink = null; // (health) => void
352
+ this._checkpointIntervalMs = 0; // 0 — периодических точек нет
353
+ this._lastCheckpointAt = 0;
354
+ this._checkpointRequest = null; // { final } — точка на ближайшем кадре
355
+ this._checkpointCounter = 0;
356
+ // матч поднят из точки и ждёт startAfterRestore: цикл и таймеры стоят
357
+ this._restorePending = false;
358
+ this._restoreMode = null; // 'midRound' | 'soft'
359
+ this._restoredTimers = null;
360
+ this._frozen = false;
361
+ // точка снята на замороженном хосте: pack_body осушил тело кадра вне
362
+ // цикла, и его события (удаления, взрывы) никому не ушли
363
+ this._drainedWhileFrozen = false;
364
+ // ожидание возврата людей перед стартом восстановленного матча
365
+ // (startAfterResume): { timer, onStart, reason }
366
+ this._resumeWait = null;
367
+
261
368
  // матч закрывается (destroy): снятие участников исход раунда не решает
262
369
  this._isDestroying = false;
263
370
 
@@ -271,14 +378,17 @@ export default class HostGame {
271
378
  // эстафета Worker'ов (Этап 5.2): восстановление вместо холодного старта;
272
379
  // игровой цикл и первый раунд запустит completeHandoff() после
273
380
  // переподключения клиентов главным потоком
274
- if (handoff) {
381
+ if (checkpoint) {
382
+ this._restoreFromCheckpoint(checkpoint, seqFloor);
383
+ } else if (handoff) {
275
384
  this._restoreFromHandoff(handoff);
276
385
  } else {
277
386
  this._roundManager.createMap();
278
387
  }
279
388
 
280
- // отслеживание смены карты (для актуализации комнаты в лобби мастера)
389
+ // отслеживание смены карты и строки карточки лобби
281
390
  this._lastReportedMap = this._roundManager.currentMap;
391
+ this._lastReportedLobbyInfo = this.lobbyInfo;
282
392
  }
283
393
 
284
394
  // комната заполнена людьми — новые подключения отклоняются.
@@ -299,6 +409,24 @@ export default class HostGame {
299
409
  return this._roundManager.currentMap;
300
410
  }
301
411
 
412
+ // строка карточки комнаты в лобби или null — показывать нечего
413
+ get lobbyInfo() {
414
+ if (this._lobbyInfoOverride !== null) {
415
+ return this._lobbyInfoOverride;
416
+ }
417
+
418
+ return this._lobbyInfoMode === 'map'
419
+ ? (this._roundManager?.currentMap ?? null)
420
+ : null;
421
+ }
422
+
423
+ // значение модулей игры: непустая строка перекрывает gameConfig.lobbyInfo,
424
+ // null (или пустое) снимает его
425
+ setLobbyInfo(text) {
426
+ this._lobbyInfoOverride =
427
+ typeof text === 'string' && text.trim() !== '' ? text.trim() : null;
428
+ }
429
+
302
430
  // хост-игрок не кикается: закрытие его loopback = смерть комнаты для всех
303
431
  _isHostPlayer(user) {
304
432
  return this._hostSocketId !== null && user.socketId === this._hostSocketId;
@@ -355,6 +483,14 @@ export default class HostGame {
355
483
  this._onMapChange?.(currentMap);
356
484
  }
357
485
 
486
+ // строка карточки лобби — уведомить главный поток (мастер)
487
+ const lobbyInfo = this.lobbyInfo;
488
+
489
+ if (lobbyInfo !== this._lastReportedLobbyInfo) {
490
+ this._lastReportedLobbyInfo = lobbyInfo;
491
+ this._onLobbyInfoChange?.(lobbyInfo);
492
+ }
493
+
358
494
  // список удаляемых с полотна игроков ведёт RoundManager, но null-маркеры
359
495
  // в кадр кладёт само ядро (remove_actor) — здесь лишь опустошаем очередь,
360
496
  // чтобы она не росла
@@ -466,6 +602,10 @@ export default class HostGame {
466
602
  this._socketManager.sendVote(socketId, voteUser);
467
603
  }
468
604
  });
605
+
606
+ // граница кадра: pack_body опустошил накопители снапшота — единственный
607
+ // момент, когда дамп ядра корректен
608
+ this._maybeCheckpoint();
469
609
  }
470
610
 
471
611
  // проверяет игроков на бездействие и кикает, если превышен порог
@@ -474,7 +614,12 @@ export default class HostGame {
474
614
  const usersToKick = [];
475
615
 
476
616
  for (const user of this._participants.getHumans()) {
477
- if (user.isReady !== true || this._isHostPlayer(user)) {
617
+ // отсоединённого снимет истечение ожидания возврата, а не бездействие
618
+ if (
619
+ user.isReady !== true ||
620
+ user.detachedAt !== null ||
621
+ this._isHostPlayer(user)
622
+ ) {
478
623
  continue;
479
624
  }
480
625
 
@@ -540,6 +685,13 @@ export default class HostGame {
540
685
  return;
541
686
  }
542
687
 
688
+ // уже готовый участник получил пакет синхронизации (разморозка после
689
+ // сорвавшейся передачи) и ответил на его первый кадр — это не вход в
690
+ // матч: ни USER_JOINED, ни initialVote, ни повторной выдачи актора
691
+ if (user.isReady === true) {
692
+ return;
693
+ }
694
+
543
695
  const socketId = user.socketId;
544
696
 
545
697
  user.isReady = true;
@@ -683,6 +835,12 @@ export default class HostGame {
683
835
  */
684
836
  async destroy() {
685
837
  this._isDestroying = true;
838
+
839
+ if (this._resumeWait) {
840
+ clock.clearTimeout(this._resumeWait.timer);
841
+ this._resumeWait = null;
842
+ }
843
+
686
844
  this._timerManager.stopGameTimers();
687
845
  this._timerManager.stopIdleCheckTimer();
688
846
  this._timerManager.stopAllVoteTimers();
@@ -712,13 +870,54 @@ export default class HostGame {
712
870
 
713
871
  // запрашивает перенос: на ближайшей границе раунда игра останавливается
714
872
  // и cb получает handoff-мету. Ядро не дампится — мир пересоздаётся стартом
715
- // раунда в новом Worker'е (см. RoundManager._startRound)
873
+ // раунда в новом Worker'е (см. RoundManager._startRound). Перед отдачей
874
+ // профили синхронизируются на мастер: terminate() старого Worker'а
875
+ // оборвал бы летящие записи
716
876
  requestHandoff(cb) {
717
877
  this._roundManager.requestHandoff(() => {
718
878
  this._timerManager.stopGameTimers();
719
879
  this._timerManager.stopIdleCheckTimer();
720
- cb(this._collectHandoff());
880
+ this._flushBeforeHandoff().then(() => cb(this._collectHandoff()));
881
+ });
882
+ }
883
+
884
+ // отмена эстафеты, ещё ждущей границы раунда: плановая передача хоста её
885
+ // вытесняет (ревью, этап 8); повторный вызов безвреден
886
+ cancelHandoff() {
887
+ this._roundManager.cancelHandoff();
888
+ }
889
+
890
+ // плановая передача хоста (host-migration этап 8d): игра без
891
+ // migration.midRound переносит только мету — передача посреди раунда
892
+ // отняла бы его у всех, поэтому она ждёт следующего раунда. Игра с
893
+ // midRound продолжает у преемника с того же тика — ждать нечего
894
+ awaitRoundBoundary(cb) {
895
+ if (this._migrationMidRound) {
896
+ cb();
897
+ return;
898
+ }
899
+
900
+ this._roundManager.onRoundBoundary(cb);
901
+ }
902
+
903
+ cancelRoundBoundary() {
904
+ this._roundManager.cancelRoundBoundary();
905
+ }
906
+
907
+ // финальная синхронизация профилей, но не дольше handoffFlushTimeoutMs:
908
+ // зависший auth-сервис не должен держать эстафету — неотправленное едет
909
+ // в состоянии (playerData) и уйдёт из нового Worker'а
910
+ _flushBeforeHandoff() {
911
+ let timer = null;
912
+
913
+ const timeout = new Promise(resolve => {
914
+ timer = clock.setTimeout(resolve, this._handoffFlushTimeoutMs);
721
915
  });
916
+
917
+ return Promise.race([
918
+ this._playerDataSync.flushAll({ urgent: true }),
919
+ timeout,
920
+ ]).finally(() => clock.clearTimeout(timer));
722
921
  }
723
922
 
724
923
  // отказ от эстафеты (новый Worker не поднялся): вернуть таймеры и
@@ -739,6 +938,7 @@ export default class HostGame {
739
938
  }
740
939
 
741
940
  this._handoffRestored = false;
941
+ this._restorePending = false;
742
942
 
743
943
  for (const user of this._participants.getHumans()) {
744
944
  if (!connectedSocketIds.has(user.socketId)) {
@@ -750,60 +950,38 @@ export default class HostGame {
750
950
  this._roundManager.initiateNewRound();
751
951
  }
752
952
 
753
- // собирает переносимую мету: участники, счёт, карта с остатком времени,
754
- // seq кадров. Осознанно не переносятся: чат-история, активные голосования,
755
- // RTT-статистика, panel (значения живут в ядре и сбрасываются раундом)
953
+ // мета эстафеты внутри вкладки: формат контрольной точки (kind:
954
+ // 'boundary', всегда мягкий режим) плюс токены участников. Токены —
955
+ // только здесь: состояние уходит postMessage'ем в соседний Worker той же
956
+ // вкладки и никогда — по сети (сетевые точки их не несут)
756
957
  _collectHandoff() {
757
- const humans = this._participants
758
- .getHumans()
759
- .filter(user => user.isReady)
760
- .map(user => ({
761
- gameId: user.gameId,
762
- socketId: user.socketId,
763
- name: user.name,
764
- model: user.model,
765
- team: user.team,
766
- teamId: user.teamId,
767
- }));
768
-
769
- const scripted = this._participants.getScripted().map(participant => ({
770
- gameId: participant.gameId,
771
- name: participant.name,
772
- model: participant.model,
773
- team: participant.team,
774
- teamId: participant.teamId,
775
- }));
958
+ const { meta } = this._collectState('boundary');
959
+ const localTokens = {};
776
960
 
777
- return {
778
- version: HANDOFF_VERSION,
779
- gameId: this._gameId,
780
- gameVersion: this._gameVersion,
781
- seq: this._seq,
782
- currentMap: this._roundManager.currentMap,
783
- mapTimeLeft: this._timerManager.getMapTimeLeft(),
784
- humans,
785
- scripted,
786
- stat: this._stat.serialize(),
787
- };
961
+ for (const user of this._participants.getHumans()) {
962
+ if (typeof user.token === 'string' && user.token !== '') {
963
+ localTokens[user.gameId] = user.token;
964
+ }
965
+ }
966
+
967
+ return { ...meta, localTokens };
788
968
  }
789
969
 
790
970
  // восстанавливает комнату из handoff-меты. Ошибка (несовместимый формат,
791
971
  // чужая игра, карта ушла из каталога) валит init Worker'а — главный поток
792
972
  // возобновляет старый Worker, комната живёт на прежней версии
793
973
  _restoreFromHandoff(meta) {
794
- if (!meta || meta.version !== HANDOFF_VERSION) {
795
- throw new Error(`unsupported handoff version: ${meta && meta.version}`);
974
+ if (meta && meta.version === HANDOFF_VERSION) {
975
+ this._restoreState(meta, null, { boundary: true });
976
+ return;
796
977
  }
797
978
 
798
- // составной codeVersion (Этап 6.5): своп меняет только версию кода в
799
- // рамках той же игры — смена самой игры комнаты handoff'ом не
800
- // предусмотрена, рассинхрон id — явный сбой конфигурации свопа
801
- if (meta.gameId !== undefined && meta.gameId !== this._gameId) {
802
- throw new Error(
803
- `handoff game mismatch: expected "${this._gameId}", got "${meta.gameId}"`,
804
- );
979
+ if (!meta || meta.version !== LEGACY_HANDOFF_VERSION) {
980
+ throw new Error(`unsupported handoff version: ${meta && meta.version}`);
805
981
  }
806
982
 
983
+ this._assertSameGame(meta.gameId);
984
+
807
985
  if (!this._maps[meta.currentMap]) {
808
986
  throw new Error(`handoff map missing from catalog: ${meta.currentMap}`);
809
987
  }
@@ -811,6 +989,11 @@ export default class HostGame {
811
989
  this._seq = meta.seq >>> 0;
812
990
  this._handoffMapTimeLeft = meta.mapTimeLeft;
813
991
 
992
+ // мета старого Worker'а поля может не нести — тогда текста модулей нет
993
+ if (typeof meta.lobbyInfo === 'string') {
994
+ this.setLobbyInfo(meta.lobbyInfo);
995
+ }
996
+
814
997
  for (const record of meta.humans) {
815
998
  const user = this._participants.restoreHuman(record);
816
999
 
@@ -846,6 +1029,526 @@ export default class HostGame {
846
1029
  this._handoffRestored = true;
847
1030
  }
848
1031
 
1032
+ // составной codeVersion (Этап 6.5): своп меняет только версию кода в
1033
+ // рамках той же игры — смена самой игры комнаты handoff'ом не
1034
+ // предусмотрена, рассинхрон id — явный сбой конфигурации свопа
1035
+ _assertSameGame(gameId) {
1036
+ if (gameId !== undefined && gameId !== this._gameId) {
1037
+ throw new Error(
1038
+ `handoff game mismatch: expected "${this._gameId}", got "${gameId}"`,
1039
+ );
1040
+ }
1041
+ }
1042
+
1043
+ // ***** здоровье хоста (host-migration этап 9a) ***** //
1044
+
1045
+ /**
1046
+ * Приёмник метрик здоровья: раз в окно игрового цикла (~1 с) —
1047
+ * { tickRate, maxGapMs, lostMs, windowMs, peerRttMedian, peerCount }.
1048
+ * Пока матч заморожен или стоит, метрик нет.
1049
+ * @param {Function|null} sink
1050
+ */
1051
+ setHealthSink(sink) {
1052
+ this._healthSink = typeof sink === 'function' ? sink : null;
1053
+ }
1054
+
1055
+ // RTT — только удалённые люди: хост-игрок на loopback связь хоста не мерит
1056
+ _onLoopStats(stats) {
1057
+ if (!this._healthSink) {
1058
+ return;
1059
+ }
1060
+
1061
+ const { median, count } = this._RTTManager.getRttStats(gameId => {
1062
+ const user = this._participants.get(gameId);
1063
+
1064
+ return Boolean(user) && !this._isHostPlayer(user);
1065
+ });
1066
+
1067
+ this._healthSink({ ...stats, peerRttMedian: median, peerCount: count });
1068
+ }
1069
+
1070
+ // ***** контрольная точка (host-migration этап 5) ***** //
1071
+
1072
+ /**
1073
+ * Приёмник готовых точек (Worker кодирует и отдаёт главному потоку).
1074
+ * @param {Function|null} sink - ({ meta, core, final }) => void.
1075
+ */
1076
+ setCheckpointSink(sink) {
1077
+ this._checkpointSink = typeof sink === 'function' ? sink : null;
1078
+ }
1079
+
1080
+ // периодические точки: снимаются на границе кадра не чаще intervalMs
1081
+ startCheckpoints(intervalMs) {
1082
+ this._checkpointIntervalMs = Math.max(0, Number(intervalMs) || 0);
1083
+ this._lastCheckpointAt = 0;
1084
+ }
1085
+
1086
+ stopCheckpoints() {
1087
+ this._checkpointIntervalMs = 0;
1088
+ }
1089
+
1090
+ // одна точка на ближайшей границе кадра. Цикл стоит (заморозка или матч
1091
+ // ждёт старта после восстановления) — следующего кадра не будет, точка
1092
+ // снимается сразу
1093
+ requestCheckpoint({ final = false } = {}) {
1094
+ if (this._frozen || this._restorePending) {
1095
+ this._emitCheckpoint(final === true, { packFirst: true });
1096
+ return;
1097
+ }
1098
+
1099
+ this._checkpointRequest = { final: final === true };
1100
+ }
1101
+
1102
+ _maybeCheckpoint() {
1103
+ if (!this._checkpointSink) {
1104
+ return;
1105
+ }
1106
+
1107
+ const now = clock.now();
1108
+ let final = false;
1109
+
1110
+ if (this._checkpointRequest) {
1111
+ final = this._checkpointRequest.final;
1112
+ this._checkpointRequest = null;
1113
+ } else if (
1114
+ this._checkpointIntervalMs === 0 ||
1115
+ now - this._lastCheckpointAt < this._checkpointIntervalMs
1116
+ ) {
1117
+ return;
1118
+ }
1119
+
1120
+ this._emitCheckpoint(final);
1121
+ }
1122
+
1123
+ _emitCheckpoint(final, { packFirst = false } = {}) {
1124
+ if (!this._checkpointSink) {
1125
+ return;
1126
+ }
1127
+
1128
+ // вне цикла граница кадра не гарантирована: дренаж накопителей —
1129
+ // предусловие дампа; недоставленные события перекроет полный кадр,
1130
+ // который возобновлённый клиент получает на входе
1131
+ if (packFirst) {
1132
+ this._game.packBody();
1133
+ this._drainedWhileFrozen ||= this._frozen;
1134
+ }
1135
+
1136
+ this._lastCheckpointAt = clock.now();
1137
+
1138
+ const { meta, core } = this._collectState('checkpoint');
1139
+
1140
+ this._checkpointSink({ meta, core, final });
1141
+ }
1142
+
1143
+ /**
1144
+ * Собирает контрольную точку: мета (формат HANDOFF_VERSION 4) и дамп
1145
+ * ядра. Ядро прикладывается только у игры с migration.midRound и только
1146
+ * если дамп снялся — иначе режим 'soft' (восстановление начнёт раунд
1147
+ * заново).
1148
+ * @param {'checkpoint'|'boundary'} kind
1149
+ * @returns {{meta: Object, core: Uint8Array|null}}
1150
+ */
1151
+ _collectState(kind) {
1152
+ let core = null;
1153
+ let plugin = null;
1154
+
1155
+ if (kind === 'checkpoint' && this._migrationMidRound) {
1156
+ try {
1157
+ plugin = this._serializeModules();
1158
+ core = this._game.serializeState();
1159
+ } catch (e) {
1160
+ // точка остаётся полезной и без ядра: аварийный преемник начнёт
1161
+ // раунд заново, а не потеряет комнату
1162
+ console.warn(`[checkpoint] mid-round state skipped: ${e.message}`);
1163
+ core = null;
1164
+ plugin = null;
1165
+ }
1166
+ }
1167
+
1168
+ const createdAt = clock.now();
1169
+ const currentMap = this._roundManager.currentMap;
1170
+ const round = this._roundManager.serialize();
1171
+ const participants = this._participants.serialize();
1172
+ const isBoundary = kind === 'boundary';
1173
+ const dropped = [];
1174
+
1175
+ this._checkpointCounter += 1;
1176
+
1177
+ participants.humans = participants.humans
1178
+ .filter(record => {
1179
+ const user = this._participants.get(record.gameId);
1180
+
1181
+ // внутри вкладки переносятся завершившие хендшейк (переподключатся
1182
+ // тем же socketId); по сети — те, кому есть чем вернуться (resumeKey)
1183
+ const keep = isBoundary ? user.isReady : Boolean(user.resumeKey);
1184
+
1185
+ if (!keep) {
1186
+ dropped.push(record.gameId);
1187
+ }
1188
+
1189
+ return keep;
1190
+ })
1191
+ .map(record => {
1192
+ const user = this._participants.get(record.gameId);
1193
+
1194
+ return {
1195
+ ...record,
1196
+ isHostPlayer: this._isHostPlayer(user),
1197
+ ...(isBoundary ? { socketId: user.socketId } : {}),
1198
+ };
1199
+ });
1200
+
1201
+ // не попавшие в точку люди: их акторы остаются в дампе ядра, и
1202
+ // преемник снимает их сам — иначе в мире остались бы бесхозные тела
1203
+ participants.dropped = dropped;
1204
+
1205
+ const meta = {
1206
+ version: HANDOFF_VERSION,
1207
+ kind,
1208
+ mode: core ? 'midRound' : 'soft',
1209
+ gameId: this._gameId,
1210
+ gameVersion: this._gameVersion,
1211
+ engineVersion: enginePkg.version,
1212
+ createdAt,
1213
+ checkpointId: `${createdAt.toString(36)}-${this._checkpointCounter}`,
1214
+ seq: this._seq,
1215
+ snapshotTick: this._snapshotManager.tick,
1216
+ room: {
1217
+ roomId: this._roomId,
1218
+ epoch: this._epoch,
1219
+ settings: this._roomSettings,
1220
+ game: { id: this._gameId, version: this._gameVersion },
1221
+ },
1222
+ map: {
1223
+ name: currentMap,
1224
+ data: this._roundManager.baseMapData,
1225
+ mapsVersion: this._mapsVersion,
1226
+ override: round.override,
1227
+ },
1228
+ // матч поднят из точки и ещё не запущен: таймеры стоят, их остатки —
1229
+ // те, что приехали в точке
1230
+ timers: this._restorePending
1231
+ ? structuredClone(this._restoredTimers)
1232
+ : this._timerManager.serialize(),
1233
+ round: {
1234
+ isRoundEnding: round.isRoundEnding,
1235
+ wipedTeamIds: round.wipedTeamIds,
1236
+ removedPlayers: round.removedPlayers,
1237
+ startMapNumber: round.startMapNumber,
1238
+ },
1239
+ participants,
1240
+ stat: this._stat.serialize(),
1241
+ panel: this._panel.serialize(),
1242
+ playerData: this._playerDataSync.serialize(),
1243
+ plugin: plugin ?? {},
1244
+ // текст карточки лобби, выставленный модулем игры (lobby.setInfo):
1245
+ // модули нового Worker'а создаются с нуля и сами его не повторят
1246
+ lobbyInfo: this._lobbyInfoOverride,
1247
+ };
1248
+
1249
+ // JSON-круг: точка уезжает по сети и в соседний Worker, ссылки на живые
1250
+ // объекты меты (stat, карта) не должны правиться задним числом
1251
+ return { meta: JSON.parse(JSON.stringify(meta)), core };
1252
+ }
1253
+
1254
+ // состояние JS-модулей игры: ключ — имя модуля в объекте createModules
1255
+ _serializeModules() {
1256
+ const out = {};
1257
+
1258
+ for (const [name, module] of Object.entries(this._modules ?? {})) {
1259
+ if (typeof module?.serializeState === 'function') {
1260
+ out[name] = module.serializeState();
1261
+ }
1262
+ }
1263
+
1264
+ return out;
1265
+ }
1266
+
1267
+ _restoreModules(state = {}) {
1268
+ for (const [name, module] of Object.entries(this._modules ?? {})) {
1269
+ if (
1270
+ Object.hasOwn(state, name) &&
1271
+ typeof module?.restoreState === 'function'
1272
+ ) {
1273
+ module.restoreState(state[name]);
1274
+ }
1275
+ }
1276
+ }
1277
+
1278
+ // матч из контрольной точки, снятой (возможно) на другой машине
1279
+ _restoreFromCheckpoint({ meta, core = null } = {}, seqFloor = 0) {
1280
+ this._restoreState(meta, core, { boundary: false, seqFloor });
1281
+ }
1282
+
1283
+ // общий путь восстановления формата v4: эстафета внутри вкладки
1284
+ // (boundary — клиенты переподключаются теми же socketId) и контрольная
1285
+ // точка (люди поднимаются отсоединёнными и ждут RESUME)
1286
+ _restoreState(meta, core, { boundary, seqFloor = 0 }) {
1287
+ if (!meta || meta.version !== HANDOFF_VERSION) {
1288
+ throw new Error(`unsupported handoff version: ${meta && meta.version}`);
1289
+ }
1290
+
1291
+ this._assertSameGame(meta.gameId);
1292
+
1293
+ const mapName = meta.map?.name;
1294
+ const mapData = meta.map?.data ?? this._maps[mapName];
1295
+
1296
+ if (!mapData) {
1297
+ throw new Error(`handoff map missing from catalog: ${mapName}`);
1298
+ }
1299
+
1300
+ const midRound = !boundary && meta.mode === 'midRound' && Boolean(core);
1301
+
1302
+ // ядро — первым: битый дамп валит init до того, как мета что-то
1303
+ // поменяла
1304
+ if (midRound) {
1305
+ this._game.deserializeState(core);
1306
+ }
1307
+
1308
+ this._seq = boundary
1309
+ ? meta.seq >>> 0
1310
+ : (Math.max(meta.seq >>> 0, seqFloor >>> 0) + RESTORE_SEQ_GAP) >>> 0;
1311
+
1312
+ if (typeof meta.lobbyInfo === 'string') {
1313
+ this.setLobbyInfo(meta.lobbyInfo);
1314
+ }
1315
+
1316
+ const tokens = boundary ? (meta.localTokens ?? {}) : {};
1317
+ const now = clock.now();
1318
+
1319
+ for (const record of meta.participants.humans) {
1320
+ const user = this._participants.restoreHuman(
1321
+ {
1322
+ ...record,
1323
+ socketId: boundary
1324
+ ? record.socketId
1325
+ : detachedSocketId(record.gameId),
1326
+ token: tokens[record.gameId] ?? null,
1327
+ },
1328
+ { full: midRound },
1329
+ );
1330
+
1331
+ if (!user) {
1332
+ continue;
1333
+ }
1334
+
1335
+ user.isReady = boundary || record.isReady !== false;
1336
+ user.currentMap = mapName;
1337
+
1338
+ this._chat.addUser(user.gameId);
1339
+ this._vote.addUser(user.gameId);
1340
+ this._panel.addUser(user.gameId);
1341
+
1342
+ if (boundary) {
1343
+ this._RTTManager.addUser(user.gameId);
1344
+ } else {
1345
+ // место ждёт возврата участника (RESUME): рассылки уходят в заглушку
1346
+ user.detachedAt = now;
1347
+ user.lastActionTime = now;
1348
+ this._socketManager.addUser(user.socketId, DETACHED_SOCKET);
1349
+ }
1350
+ }
1351
+
1352
+ for (const record of meta.participants.scripted) {
1353
+ const participant = this._participants.restoreScripted(record, {
1354
+ full: midRound,
1355
+ });
1356
+
1357
+ if (participant) {
1358
+ this._panel.addUser(participant.gameId);
1359
+ }
1360
+ }
1361
+
1362
+ if (midRound) {
1363
+ this._participants.restoreTopology(meta.participants);
1364
+ this._panel.restore(meta.panel);
1365
+
1366
+ for (const gameId of meta.participants.dropped ?? []) {
1367
+ if (!this._participants.get(gameId)) {
1368
+ this._game.removePlayer(gameId);
1369
+ }
1370
+ }
1371
+ }
1372
+
1373
+ const keepIds = new Set(this._participants.getAll().map(p => p.gameId));
1374
+
1375
+ this._stat.restore(meta.stat, keepIds);
1376
+
1377
+ // профили: накопленное, но не отправленное переезжает; токены — только
1378
+ // в эстафете внутри вкладки. Участник, чей профиль так и не доехал с
1379
+ // мастера, догружается сразу (иначе rank/state не писались бы до конца
1380
+ // сессии)
1381
+ const playerData = Object.fromEntries(
1382
+ Object.entries(meta.playerData ?? {}).filter(([id]) => keepIds.has(id)),
1383
+ );
1384
+
1385
+ this._playerDataSync.restore(playerData, { tokens });
1386
+
1387
+ for (const [gameId, token] of Object.entries(tokens)) {
1388
+ if (keepIds.has(gameId) && !this._playerDataSync.isLoaded(gameId)) {
1389
+ this._playerDataSync.load(gameId, token);
1390
+ }
1391
+ }
1392
+
1393
+ this._roundManager.restoreMap(mapName, {
1394
+ mapData,
1395
+ override: midRound ? meta.map.override : null,
1396
+ });
1397
+
1398
+ if (midRound) {
1399
+ this._roundManager.restoreRound(meta.round);
1400
+ this._snapshotManager.tick = meta.snapshotTick;
1401
+ this._restoreModules(meta.plugin);
1402
+ }
1403
+
1404
+ this._restoreMode = midRound ? 'midRound' : 'soft';
1405
+ this._restoredTimers = meta.timers ?? {};
1406
+ this._restorePending = true;
1407
+
1408
+ if (boundary) {
1409
+ this._handoffRestored = true;
1410
+ this._handoffMapTimeLeft = this._restoredTimers.mapTimeLeft ?? null;
1411
+ }
1412
+ }
1413
+
1414
+ // режим восстановления матча: 'midRound' | 'soft' | null (холодный старт)
1415
+ get restoreMode() {
1416
+ return this._restoreMode;
1417
+ }
1418
+
1419
+ // ждёт ли восстановленный матч запуска (startAfterRestore)
1420
+ get isRestorePending() {
1421
+ return this._restorePending;
1422
+ }
1423
+
1424
+ /**
1425
+ * Запускает матч, поднятый из контрольной точки: посреди раунда —
1426
+ * таймеры с остатками и цикл с того же тика; в мягком режиме — карта с
1427
+ * остатком и новый раунд (как после эстафеты).
1428
+ * @returns {boolean} false — запускать нечего.
1429
+ */
1430
+ startAfterRestore() {
1431
+ if (!this._restorePending) {
1432
+ return false;
1433
+ }
1434
+
1435
+ const timers = this._restoredTimers ?? {};
1436
+
1437
+ this._restorePending = false;
1438
+ this._handoffRestored = false;
1439
+
1440
+ if (this._restoreMode === 'midRound') {
1441
+ this._timerManager.resumeFromState(timers);
1442
+
1443
+ for (const item of timers.pending ?? []) {
1444
+ if (item.kind === 'mapChange' && this._maps[item.targetMap]) {
1445
+ this._roundManager.scheduleMapChange(item.targetMap, item.leftMs);
1446
+ }
1447
+ }
1448
+ } else {
1449
+ this._timerManager.resumeGameTimers(timers.mapTimeLeft);
1450
+ this._roundManager.initiateNewRound();
1451
+ }
1452
+
1453
+ return true;
1454
+ }
1455
+
1456
+ /**
1457
+ * Старт матча, поднятого из точки преемником (host-migration этап 7.4):
1458
+ * когда возобновились все люди точки или прошло resumeWaitMs — что
1459
+ * раньше. Пауза переключения не съедает окно возврата: onStart получает
1460
+ * gameId так и не вернувшихся, их ожидание (grace) заводится с этого
1461
+ * момента.
1462
+ * @param {Function} [onStart] - (detachedGameIds) после старта.
1463
+ * @param {Object} [options]
1464
+ * @param {string|null} [options.reason] - причина миграции из promote:
1465
+ * у автоматической передачи игроки видят её вместо «Host changed».
1466
+ * @returns {boolean} false — запускать нечего или ожидание уже идёт.
1467
+ */
1468
+ startAfterResume(onStart = null, { reason = null } = {}) {
1469
+ if (!this._restorePending || this._resumeWait) {
1470
+ return false;
1471
+ }
1472
+
1473
+ this._resumeWait = { timer: null, onStart, reason };
1474
+
1475
+ if (this.detachedGameIds().length === 0) {
1476
+ this._finishResumeWait();
1477
+ } else {
1478
+ this._resumeWait.timer = clock.setTimeout(
1479
+ () => this._finishResumeWait(),
1480
+ this._resumeWaitMs,
1481
+ );
1482
+ }
1483
+
1484
+ return true;
1485
+ }
1486
+
1487
+ _finishResumeWait() {
1488
+ const wait = this._resumeWait;
1489
+
1490
+ if (!wait) {
1491
+ return;
1492
+ }
1493
+
1494
+ this._resumeWait = null;
1495
+ clock.clearTimeout(wait.timer);
1496
+
1497
+ if (!this.startAfterRestore()) {
1498
+ return;
1499
+ }
1500
+
1501
+ this._chat.pushSystem(
1502
+ Object.hasOwn(HOST_CHANGED_BY_REASON, wait.reason)
1503
+ ? HOST_CHANGED_BY_REASON[wait.reason]
1504
+ : 'HOST_CHANGED',
1505
+ );
1506
+ wait.onStart?.(this.detachedGameIds());
1507
+ }
1508
+
1509
+ // gameId людей, ждущих возобновления
1510
+ detachedGameIds() {
1511
+ return this._participants
1512
+ .getHumans()
1513
+ .filter(user => user.detachedAt !== null)
1514
+ .map(user => user.gameId);
1515
+ }
1516
+
1517
+ // заморозка (финальная точка плановой передачи): цикл и все отсчёты
1518
+ // встают с остатками
1519
+ freeze() {
1520
+ if (this._frozen) {
1521
+ return;
1522
+ }
1523
+
1524
+ this._frozen = true;
1525
+ this._timerManager.pause();
1526
+ }
1527
+
1528
+ // разморозка (передача сорвалась): отсчёты продолжаются с остатков.
1529
+ // Если за заморозку снималась точка, события осушенного тела потеряны —
1530
+ // оставшимся на связи участникам уходит полная синхронизация (пакет входа
1531
+ // RESUME), иначе на полотне остались бы «призраки» удалённых сущностей
1532
+ unfreeze() {
1533
+ if (!this._frozen) {
1534
+ return;
1535
+ }
1536
+
1537
+ this._frozen = false;
1538
+ this._timerManager.resume();
1539
+
1540
+ if (this._drainedWhileFrozen) {
1541
+ this._drainedWhileFrozen = false;
1542
+ this._participants
1543
+ .getNetworkedReady()
1544
+ .forEach(user => this._sendResumeEntry(user));
1545
+ }
1546
+ }
1547
+
1548
+ get isFrozen() {
1549
+ return this._frozen;
1550
+ }
1551
+
849
1552
  // меняет и возвращает gameId наблюдаемого игрока
850
1553
  _getNextActivePlayerForUser(gameId, back) {
851
1554
  const currentId = this._participants.get(gameId)?.watchedGameId;
@@ -940,6 +1643,11 @@ export default class HostGame {
940
1643
 
941
1644
  this._recorder?.noteLeave(gameId);
942
1645
 
1646
+ // место не дождалось возврата — снять и заглушку соединения
1647
+ if (user.isNetworked && user.detachedAt !== null) {
1648
+ this._socketManager.removeUser(user.socketId);
1649
+ }
1650
+
943
1651
  this._RTTManager.removeUser(gameId);
944
1652
  this._stat.removeUser(gameId, teamId);
945
1653
  this._chat.removeUser(gameId);
@@ -1184,12 +1892,173 @@ export default class HostGame {
1184
1892
  return this._playerDataSync.flushAll({ urgent });
1185
1893
  }
1186
1894
 
1187
- // hostId + per-room секрет комнаты, подтверждённые мастером при
1188
- // register_host (кодревью №1) — не известны при создании HostGame (Worker
1189
- // стартует раньше ответа мастера); нужны PlayerDataSync для атрибуции
1895
+ // roomId + секрет эпохи комнаты, подтверждённые мастером в
1896
+ // host_registered — не известны при создании HostGame (Worker стартует
1897
+ // раньше ответа мастера); нужны PlayerDataSync для атрибуции
1190
1898
  // rank/state-flush (секрет доказывает мастеру владение комнатой)
1191
- setHostId(hostId, hostSecret) {
1192
- this._playerDataSync.setHostId(hostId, hostSecret);
1899
+ setRoom({ roomId, roomSecret, epoch } = {}) {
1900
+ this._roomId = roomId ?? null;
1901
+ this._epoch = epoch ?? null;
1902
+ this._playerDataSync.setRoom({ roomId, roomSecret, epoch });
1903
+ }
1904
+
1905
+ // ***** возобновление сессии (host-migration этап 4) ***** //
1906
+
1907
+ // выдаёт участнику секрет возобновления, если его ещё нет; ключ —
1908
+ // только для нового выданного (null — уже выдан или участника нет)
1909
+ issueResumeKey(gameId) {
1910
+ const user = this._participants.get(gameId);
1911
+
1912
+ if (!user?.isNetworked || user.resumeKey) {
1913
+ return null;
1914
+ }
1915
+
1916
+ user.resumeKey = createResumeKey();
1917
+
1918
+ return user.resumeKey;
1919
+ }
1920
+
1921
+ // транспорт участника оборвался без LEAVE: место держится до возврата.
1922
+ // Слот занят (isFull его считает), актор остаётся в мире, но все его
1923
+ // команды отпущены; RTT- и idle-кики его не трогают. false — держать
1924
+ // нечего (не вошёл в матч, хост-игрок, уже отсоединён) — снимать сразу
1925
+ detachUser(gameId) {
1926
+ const user = this._participants.get(gameId);
1927
+
1928
+ if (
1929
+ !user?.isNetworked ||
1930
+ !user.resumeKey ||
1931
+ user.detachedAt !== null ||
1932
+ this._isHostPlayer(user)
1933
+ ) {
1934
+ return false;
1935
+ }
1936
+
1937
+ if (user.isWatching !== true) {
1938
+ for (const name of this._playerKeyNames) {
1939
+ this._game.applyInput(gameId, user.lastInputSeq, 'up', name);
1940
+ }
1941
+ }
1942
+
1943
+ this._RTTManager.removeUser(gameId);
1944
+ user.detachedAt = clock.now();
1945
+
1946
+ // рассылки на это время уходят в никуда: сокета нет, а id соединения
1947
+ // мог уже достаться другому подключению
1948
+ user.socketId = detachedSocketId(gameId);
1949
+ this._socketManager.addUser(user.socketId, DETACHED_SOCKET);
1950
+
1951
+ return true;
1952
+ }
1953
+
1954
+ // участник ждёт возобновления
1955
+ isDetached(gameId) {
1956
+ const user = this._participants.get(gameId);
1957
+
1958
+ return Boolean(user && user.detachedAt !== null);
1959
+ }
1960
+
1961
+ // что нужно порт-машине для проверки RESUME_REQUEST: ник, под которым
1962
+ // участник входил, и его секрет. null — возвращаться некуда
1963
+ getResumeTarget(gameId) {
1964
+ const user = this._participants.get(gameId);
1965
+
1966
+ if (!user?.isNetworked || !user.resumeKey) {
1967
+ return null;
1968
+ }
1969
+
1970
+ return {
1971
+ identityName: user.identityName,
1972
+ resumeKey: user.resumeKey,
1973
+ socketId: user.socketId,
1974
+ };
1975
+ }
1976
+
1977
+ // привязывает участника к новому соединению и шлёт ему всё, что нужно,
1978
+ // чтобы продолжить с того же места: RESUME_RESULT, затем пакет входа
1979
+ // (очистка, полный кадр, stat/panel/keyset/accolades) и новый секрет.
1980
+ // Участник мог быть и не отсоединён — перехват полуоткрытой сессии
1981
+ resumeUser(gameId, socketId, token) {
1982
+ const user = this._participants.get(gameId);
1983
+
1984
+ if (!user) {
1985
+ return false;
1986
+ }
1987
+
1988
+ if (user.detachedAt !== null) {
1989
+ this._socketManager.removeUser(user.socketId);
1990
+ this._RTTManager.addUser(gameId);
1991
+ user.detachedAt = null;
1992
+ }
1993
+
1994
+ user.socketId = socketId;
1995
+ user.lastActionTime = clock.now();
1996
+
1997
+ if (typeof token === 'string' && token !== '') {
1998
+ user.token = token;
1999
+ this._playerDataSync.attachToken(gameId, token);
2000
+ }
2001
+
2002
+ this._socketManager.sendResumeResult(socketId, {
2003
+ ok: true,
2004
+ gameId,
2005
+ epoch: this._epoch,
2006
+ });
2007
+
2008
+ if (user.isReady) {
2009
+ this._sendResumeEntry(user);
2010
+ } else {
2011
+ // карта сменилась, пока участник отсутствовал (или он вышел посреди
2012
+ // загрузки): обычный путь загрузки карты
2013
+ this.sendMap(gameId);
2014
+ }
2015
+
2016
+ // ключ ротируется: прежний мог уйти вместе с полуоткрытым соединением
2017
+ user.resumeKey = createResumeKey();
2018
+ this._socketManager.sendSessionData(socketId, {
2019
+ resumeKey: user.resumeKey,
2020
+ gameId,
2021
+ });
2022
+
2023
+ // вернулся последний из ожидаемых — восстановленный матч стартует сразу
2024
+ if (this._resumeWait && this.detachedGameIds().length === 0) {
2025
+ this._finishResumeWait();
2026
+ }
2027
+
2028
+ return true;
2029
+ }
2030
+
2031
+ // пакет входа возобновлённого участника — тот же набор, что видит игрок
2032
+ // на первом кадре (sendFirstShot) и при выдаче актора
2033
+ _sendResumeEntry(user) {
2034
+ const { socketId, gameId } = user;
2035
+
2036
+ // keyset наблюдателя — до очистки полотна, как в RoundManager.createMap:
2037
+ // иначе клиентский предикт успел бы пересоздать сущность после CLEAR
2038
+ this._socketManager.sendSpectatorDefaultShot(socketId);
2039
+ this._socketManager.sendClear(socketId);
2040
+ // CLEAR без списка стирает и карту, а первый кадр несёт лишь частичные
2041
+ // данные её сущностей — карту клиент собирает заново. Не
2042
+ // RoundManager.sendMap: это не загрузка, участник остаётся готовым.
2043
+ // Метка resume — клиенту: MAP_READY не нужен (обычная загрузка
2044
+ // неготового участника тоже приходит, пока клиент возобновляется)
2045
+ this._socketManager.sendMap(socketId, {
2046
+ ...this._roundManager.currentMapData,
2047
+ resume: true,
2048
+ });
2049
+ this._socketManager.sendFirstShot(socketId);
2050
+
2051
+ if (user.isWatching !== true) {
2052
+ this._socketManager.sendPlayerDefaultShot(socketId, gameId);
2053
+ }
2054
+
2055
+ const accoladesNow = this._accolades.current();
2056
+
2057
+ if (Object.keys(accoladesNow.places).length) {
2058
+ this._socketManager.sendAccolades(socketId, accoladesNow);
2059
+ }
2060
+
2061
+ user.forceCameraReset = true;
1193
2062
  }
1194
2063
 
1195
2064
  // обновляет значение round trip time