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
@@ -52,13 +52,38 @@ export default class HostController {
52
52
  * @param {Function} [opts.onError] - сбой инициализации Worker'а
53
53
  * (WASM/конфиг): комната не поднялась, нужно вернуть пользователя в лобби.
54
54
  * @param {Function} [opts.onMapChange] - смена карты в комнате (голосование/
55
- * таймер) — для актуализации mapName у мастера.
55
+ * таймер).
56
+ * @param {Function} [opts.onLobbyInfoChange] - смена строки карточки
57
+ * комнаты в лобби (string|null) — для актуализации info у мастера.
56
58
  * @param {Object} [opts.diagnostics] - журнал клиентских ошибок
57
59
  * (lib/diagnostics.js, plan/client-reports): туда уходят ошибки Worker'а.
60
+ * @param {Uint8Array} [opts.checkpoint] - сжатая контрольная точка
61
+ * (host-migration этап 5): Worker поднимает матч из неё на паузе, старт —
62
+ * startAfterRestore(). Буфер передаётся списком переноса — после
63
+ * конструктора он у вызывающего пуст.
64
+ * @param {number} [opts.seqFloor] - номер последнего кадра, который видели
65
+ * клиенты: восстановленный матч продолжит нумерацию дальше него.
66
+ * @param {boolean} [opts.preload] - прогрев преемника (host-migration этап
67
+ * 6): Worker импортирует плагин и компилирует wasm, матч не создаёт;
68
+ * готовность — onPreloaded.
69
+ * @param {Function} [opts.onPreloaded] - Worker прогрет ({ gameId,
70
+ * gameVersion, wasmCompiled }).
58
71
  */
59
72
  constructor(
60
73
  room,
61
- { workerFactory, workerUrl, onReady, onError, onMapChange, diagnostics } = {},
74
+ {
75
+ workerFactory,
76
+ workerUrl,
77
+ onReady,
78
+ onError,
79
+ onMapChange,
80
+ onLobbyInfoChange,
81
+ diagnostics,
82
+ checkpoint = null,
83
+ seqFloor = 0,
84
+ preload = false,
85
+ onPreloaded,
86
+ } = {},
62
87
  ) {
63
88
  this._room = room;
64
89
  this._workerFactory = workerFactory;
@@ -69,21 +94,47 @@ export default class HostController {
69
94
  this._onReady = onReady;
70
95
  this._onError = onError;
71
96
  this._onMapChange = onMapChange;
97
+ this._onLobbyInfoChange = onLobbyInfoChange;
98
+ this._onPreloaded = onPreloaded;
72
99
  this._ready = false;
73
100
  this._deliveries = new Map(); // socketId → { onMessage, onClose }
74
- this._pendingConnects = []; // socketId, ожидающие готовности Worker'а
101
+ this._pendingConnects = []; // connect-сообщения до готовности Worker'а
75
102
 
76
103
  this._swap = null; // состояние эстафеты (Этап 5.2)
77
104
 
105
+ // ожидание границы раунда плановой передачей (host-migration этап 8d)
106
+ this._roundBoundaryCallback = null;
107
+
108
+ // подписчики готовых контрольных точек (host-migration этап 5)
109
+ this._checkpointListeners = new Set();
110
+ // период включённых периодических точек (null — выключены): новый Worker
111
+ // эстафеты получает его заново, иначе поток точек бете молча встал бы
112
+ this._checkpointIntervalMs = null;
113
+
114
+ // подписчики метрик здоровья хоста (host-migration этап 9a)
115
+ this._healthListeners = new Set();
116
+
78
117
  // отладочные запросы в Worker (этап 6 плана plan/done/ai-debug):
79
118
  // requestId → { resolve, reject }
80
119
  this._debugRequests = new Map();
81
120
  this._debugRequestId = 0;
82
121
 
122
+ // ожидание ответа Worker'а на shutdown (закрытие комнаты)
123
+ this._shutdownResolve = null;
124
+ this._shutdownPromise = null;
125
+
83
126
  this._worker.onmessage = e => this._onWorkerMessage(e.data);
84
127
 
85
- // старт авторитетной части в Worker'е
86
- this._worker.postMessage({ type: 'init', room });
128
+ // старт авторитетной части в Worker'е; контрольная точка — без копии
129
+ if (preload) {
130
+ this._worker.postMessage({ type: 'preload', room });
131
+ } else if (checkpoint) {
132
+ this._worker.postMessage({ type: 'init', room, checkpoint, seqFloor }, [
133
+ checkpoint.buffer,
134
+ ]);
135
+ } else {
136
+ this._worker.postMessage({ type: 'init', room });
137
+ }
87
138
  }
88
139
 
89
140
  _createWorker(url) {
@@ -115,21 +166,28 @@ export default class HostController {
115
166
  }
116
167
 
117
168
  // регистрирует клиента и (при готовности) поднимает его соединение в Worker'е
118
- open(socketId, { onMessage, onClose }) {
169
+ // resume — переподключение гостя к матчу (host-migration этап 4): Worker
170
+ // ждёт RESUME_REQUEST вместо хендшейка. Флаг едет только когда поднят —
171
+ // сообщение остаётся тем же, что понимал Worker до этапа 4
172
+ open(socketId, { onMessage, onClose, resume = false }) {
119
173
  this._deliveries.set(socketId, { onMessage, onClose });
120
174
 
175
+ const msg = resume
176
+ ? { type: 'connect', socketId, resume: true }
177
+ : { type: 'connect', socketId };
178
+
121
179
  if (!this._ready) {
122
- this._pendingConnects.push(socketId);
180
+ this._pendingConnects.push(msg);
123
181
  return;
124
182
  }
125
183
 
126
184
  // пауза эстафеты: connect доедет в новый Worker (или в старый при отмене)
127
185
  if (this._swap?.paused) {
128
- this._enqueueSwapMessage({ type: 'connect', socketId });
186
+ this._enqueueSwapMessage(msg);
129
187
  return;
130
188
  }
131
189
 
132
- this._worker.postMessage({ type: 'connect', socketId });
190
+ this._worker.postMessage(msg);
133
191
  }
134
192
 
135
193
  // пересылает входящее сообщение клиента в Worker
@@ -158,15 +216,15 @@ export default class HostController {
158
216
  this._worker.postMessage(msg);
159
217
  }
160
218
 
161
- // сообщает Worker'у hostId + per-room секрет, подтверждённые мастером при
162
- // register_host (кодревью №1, plan/server-rating/review.md) — не известны
163
- // при создании Worker'а (постится в него раньше ответа мастера).
164
- // Сохраняются в _room, чтобы эстафета (swapWorker) тоже понесла их в новый
165
- // Worker через 'init'
166
- setHostId(hostId, hostSecret) {
167
- this._room.hostId = hostId;
168
- this._room.hostSecret = hostSecret;
169
- this._worker.postMessage({ type: 'set_host_id', hostId, hostSecret });
219
+ // сообщает Worker'у roomId + секрет эпохи, подтверждённые мастером в
220
+ // host_registered — не известны при создании Worker'а (постится в него
221
+ // раньше ответа мастера). Сохраняются в _room, чтобы эстафета (swapWorker)
222
+ // тоже понесла их в новый Worker через 'init'
223
+ setRoom({ roomId, roomSecret, epoch }) {
224
+ this._room.roomId = roomId;
225
+ this._room.roomSecret = roomSecret;
226
+ this._room.epoch = epoch;
227
+ this._worker.postMessage({ type: 'set_room', roomId, roomSecret, epoch });
170
228
  }
171
229
 
172
230
  // передаёт обновлённый каталог карт мастера в Worker (Этап 5.1);
@@ -223,6 +281,180 @@ export default class HostController {
223
281
  });
224
282
  }
225
283
 
284
+ /**
285
+ * Своп уже забрал состояние Worker'а (после prepare_handoff): снять его
286
+ * нельзя.
287
+ * @returns {boolean}
288
+ */
289
+ get swapCarryingState() {
290
+ return this._swap?.paused === true;
291
+ }
292
+
293
+ /**
294
+ * Снять эстафету, ждущую границы раунда (плановая передача хоста
295
+ * важнее). Своп уже переносит состояние — снять нельзя.
296
+ * @returns {boolean} эстафета снята.
297
+ */
298
+ cancelPendingSwap() {
299
+ if (!this._swap || this._swap.paused) {
300
+ return false;
301
+ }
302
+
303
+ const { reject } = this._swap;
304
+
305
+ this._swap = null;
306
+ this._worker.postMessage({ type: 'cancel_handoff' });
307
+ reject(new Error('swap preempted'));
308
+
309
+ return true;
310
+ }
311
+
312
+ // ***** контрольные точки (host-migration этап 5) ***** //
313
+
314
+ /**
315
+ * Периодические контрольные точки: Worker снимает их на границе кадра не
316
+ * чаще intervalMs и отдаёт подписчикам onCheckpoint.
317
+ * @param {number} intervalMs
318
+ */
319
+ startCheckpoints(intervalMs) {
320
+ this._checkpointIntervalMs = intervalMs;
321
+
322
+ // пауза эстафеты: состояние доедет до нужного Worker'а в конце свопа
323
+ if (!this._swap?.paused) {
324
+ this._worker.postMessage({ type: 'checkpoint_start', intervalMs });
325
+ }
326
+ }
327
+
328
+ stopCheckpoints() {
329
+ this._checkpointIntervalMs = null;
330
+
331
+ if (!this._swap?.paused) {
332
+ this._worker.postMessage({ type: 'checkpoint_stop' });
333
+ }
334
+ }
335
+
336
+ // текущее состояние периодических точек — Worker'у после эстафеты
337
+ _postCheckpointState(worker) {
338
+ worker.postMessage(
339
+ this._checkpointIntervalMs === null
340
+ ? { type: 'checkpoint_stop' }
341
+ : {
342
+ type: 'checkpoint_start',
343
+ intervalMs: this._checkpointIntervalMs,
344
+ },
345
+ );
346
+ }
347
+
348
+ /**
349
+ * Одна точка на ближайшей границе кадра.
350
+ * @param {Object} [options]
351
+ * @param {boolean} [options.final] - финальная (перед плановой передачей).
352
+ */
353
+ requestCheckpoint({ final = false } = {}) {
354
+ const msg = { type: 'checkpoint_request', final };
355
+
356
+ if (this._swap?.paused) {
357
+ this._enqueueSwapMessage(msg);
358
+ return;
359
+ }
360
+
361
+ this._worker.postMessage(msg);
362
+ }
363
+
364
+ /**
365
+ * Подписка на готовые точки: cb({ checkpointId, seq, createdAt, final,
366
+ * mode, bytes }).
367
+ * @param {Function} cb
368
+ * @returns {Function} Отписка.
369
+ */
370
+ onCheckpoint(cb) {
371
+ this._checkpointListeners.add(cb);
372
+
373
+ return () => this._checkpointListeners.delete(cb);
374
+ }
375
+
376
+ /**
377
+ * Подписка на метрики здоровья хоста (host-migration этап 9a): раз в ~1 с,
378
+ * пока матч идёт — cb({ tickRate, maxGapMs, lostMs, windowMs,
379
+ * peerRttMedian, peerCount }). Переживает эстафету Worker'ов.
380
+ * @param {Function} cb
381
+ * @returns {Function} Отписка.
382
+ */
383
+ onHealth(cb) {
384
+ this._healthListeners.add(cb);
385
+
386
+ return () => this._healthListeners.delete(cb);
387
+ }
388
+
389
+ /**
390
+ * Плановая передача хоста (host-migration этап 8d): cb сработает на
391
+ * ближайшей границе раунда — у игры с migration.midRound сразу (решает
392
+ * Worker). Новый вызов заменяет прежний колбэк.
393
+ * @param {Function} cb
394
+ */
395
+ awaitRoundBoundary(cb) {
396
+ this._roundBoundaryCallback = cb;
397
+ this._worker.postMessage({ type: 'round_boundary_wait' });
398
+ }
399
+
400
+ cancelRoundBoundary() {
401
+ if (this._roundBoundaryCallback) {
402
+ this._roundBoundaryCallback = null;
403
+ this._worker.postMessage({ type: 'round_boundary_cancel' });
404
+ }
405
+ }
406
+
407
+ // заморозка матча: цикл и отсчёты встают с остатками
408
+ freeze() {
409
+ this._worker.postMessage({ type: 'freeze' });
410
+ }
411
+
412
+ unfreeze() {
413
+ this._worker.postMessage({ type: 'unfreeze' });
414
+ }
415
+
416
+ // матч поднят из контрольной точки — запустить цикл и таймеры.
417
+ // waitForResume — промоушен преемника: Worker ждёт возврата людей точки
418
+ // (не дольше resumeWaitMs) и заводит не вернувшимся ожидание; reason —
419
+ // причина передачи из promote (сообщение игрокам о смене хоста)
420
+ startAfterRestore({ waitForResume = false, reason = null } = {}) {
421
+ if (!waitForResume) {
422
+ this._worker.postMessage({ type: 'start_after_restore' });
423
+ return;
424
+ }
425
+
426
+ this._worker.postMessage(
427
+ reason
428
+ ? { type: 'start_after_restore', waitForResume: true, reason }
429
+ : { type: 'start_after_restore', waitForResume: true },
430
+ );
431
+ }
432
+
433
+ /**
434
+ * Поднимает матч из контрольной точки в прогретом Worker'е (режим
435
+ * preload, host-migration этап 7.4). Колбэки — те же, что у конструктора:
436
+ * у прогрева они были свои.
437
+ * @param {Object} room - настройки комнаты (roomId, epoch, game, …).
438
+ * @param {Uint8Array} checkpoint - сжатая точка; буфер передаётся
439
+ * списком переноса.
440
+ * @param {Object} [opts] - { seqFloor, onReady, onError, onMapChange,
441
+ * onLobbyInfoChange }.
442
+ */
443
+ initFromCheckpoint(
444
+ room,
445
+ checkpoint,
446
+ { seqFloor = 0, onReady, onError, onMapChange, onLobbyInfoChange } = {},
447
+ ) {
448
+ this._room = room;
449
+ this._onReady = onReady;
450
+ this._onError = onError;
451
+ this._onMapChange = onMapChange;
452
+ this._onLobbyInfoChange = onLobbyInfoChange;
453
+ this._worker.postMessage({ type: 'init', room, checkpoint, seqFloor }, [
454
+ checkpoint.buffer,
455
+ ]);
456
+ }
457
+
226
458
  /**
227
459
  * Отладочный контур (этап 6 плана plan/done/ai-debug): начинает запись живого
228
460
  * матча в формат сценария headless-runner'а.
@@ -266,7 +498,9 @@ export default class HostController {
266
498
  const timer = setTimeout(() => {
267
499
  this._debugRequests.delete(requestId);
268
500
  reject(
269
- new Error(`debug request '${action}' timed out after ${timeoutMs} ms`),
501
+ new Error(
502
+ `debug request '${action}' timed out after ${timeoutMs} ms`,
503
+ ),
270
504
  );
271
505
  }, timeoutMs);
272
506
 
@@ -294,14 +528,68 @@ export default class HostController {
294
528
  this._debugRequests.clear();
295
529
  }
296
530
 
531
+ /**
532
+ * Корректное закрытие комнаты: Worker закрывает игры участников и пишет
533
+ * профили (HostGame.destroy). Разрешается по ответу Worker'а или по
534
+ * таймауту — Worker не гасится, это делает destroy(). Эстафета Worker'ов,
535
+ * ждущая границы раунда, сначала снимается (cancel_handoff); при
536
+ * эстафете, уже переносящей состояние, — сразу выполненный промис.
537
+ * @param {Object} [options]
538
+ * @param {number} [options.timeoutMs]
539
+ * @returns {Promise<void>}
540
+ */
541
+ shutdown({ timeoutMs = 3000 } = {}) {
542
+ // эстафета уже переносит состояние: старый Worker записал очки перед
543
+ // отдачей меты (_flushBeforeHandoff) — закрывать нечего
544
+ if (this._swap?.paused) {
545
+ return Promise.resolve();
546
+ }
547
+
548
+ // повторный вызов (второй клик «Leave server») — тот же промис: второй
549
+ // HostGame.destroy() поверх идущего повторил бы flush (двойной зачёт)
550
+ if (this._shutdownPromise) {
551
+ return this._shutdownPromise;
552
+ }
553
+
554
+ // эстафета ждёт границы раунда: ничего не переносила и очков не писала.
555
+ // Комната закрывается — эстафета больше не нужна, а очки — нужны
556
+ if (this._swap) {
557
+ this.cancelPendingSwap();
558
+ }
559
+
560
+ this._shutdownPromise = new Promise(resolve => {
561
+ // страховка поверх таймаута Worker'а: зависший Worker не ответит вовсе
562
+ const timer = setTimeout(
563
+ () => this._shutdownResolve?.(),
564
+ timeoutMs + 500,
565
+ );
566
+
567
+ this._shutdownResolve = () => {
568
+ clearTimeout(timer);
569
+ this._shutdownResolve = null;
570
+ resolve();
571
+ };
572
+ this._worker.postMessage({ type: 'shutdown', timeoutMs });
573
+ });
574
+
575
+ return this._shutdownPromise;
576
+ }
577
+
297
578
  // останавливает Worker (закрытие комнаты)
298
579
  destroy() {
580
+ this._shutdownResolve?.();
581
+
299
582
  this._rejectDebugRequests('host destroyed');
300
583
 
301
584
  if (this._swap) {
585
+ const { reject } = this._swap;
586
+
302
587
  this._swap.next?.terminate();
303
588
  this._clearSwapTimeout();
304
589
  this._swap = null;
590
+ // своп ждёт HostRole.refreshWorker: без отказа его finally не
591
+ // выполнится, и флаг эстафеты залипнет до перезагрузки страницы
592
+ reject(new Error('host destroyed'));
305
593
  }
306
594
 
307
595
  this._worker.terminate();
@@ -324,8 +612,16 @@ export default class HostController {
324
612
 
325
613
  // старый Worker достиг границы раунда и отдал состояние: поднять новый
326
614
  _onHandoffState(state) {
615
+ // своп снят (cancelPendingSwap), а Worker успел отдать состояние раньше,
616
+ // чем получил cancel_handoff: его таймеры стоят — вернуть к игре. Если
617
+ // своп сняло закрытие комнаты, возвращать нечего: Worker уже в
618
+ // HostGame.destroy(). После destroy() сообщение ничего не сделает
327
619
  if (!this._swap) {
328
- return; // своп уже отменён (destroy)
620
+ if (!this._shutdownPromise) {
621
+ this._worker.postMessage({ type: 'resume' });
622
+ }
623
+
624
+ return;
329
625
  }
330
626
 
331
627
  this._swap.paused = true;
@@ -359,6 +655,14 @@ export default class HostController {
359
655
  _onNextWorkerMessage(msg) {
360
656
  if (msg.type === 'ready') {
361
657
  this._finishSwap();
658
+
659
+ // новый Worker своё начальное значение отдельным lobby_info не шлёт
660
+ // (считает его уже сообщённым), а оно могло измениться: новая версия
661
+ // игры включила lobbyInfo, или карта/текст модулей другие. Worker без
662
+ // поля (старше этапа 2 host-migration) карточку не трогает
663
+ if ('lobbyInfo' in msg) {
664
+ this._onLobbyInfoChange?.(msg.lobbyInfo ?? null);
665
+ }
362
666
  } else if (msg.type === 'error') {
363
667
  this._reportWorkerMessage(msg, 'error');
364
668
  this._abortSwap(msg.message);
@@ -396,6 +700,11 @@ export default class HostController {
396
700
 
397
701
  next.postMessage({ type: 'handoff_complete' });
398
702
 
703
+ // новый Worker не знает, что точки были включены
704
+ if (this._checkpointIntervalMs !== null) {
705
+ this._postCheckpointState(next);
706
+ }
707
+
399
708
  this._worker.terminate();
400
709
  this._worker = next;
401
710
  this._worker.onmessage = e => this._onWorkerMessage(e.data);
@@ -418,6 +727,9 @@ export default class HostController {
418
727
  this._worker.postMessage(msg);
419
728
  }
420
729
 
730
+ // за паузу точки могли включить или выключить
731
+ this._postCheckpointState(this._worker);
732
+
421
733
  this._swap = null;
422
734
 
423
735
  reject(new Error(reason || 'worker swap failed'));
@@ -428,8 +740,8 @@ export default class HostController {
428
740
  case 'ready':
429
741
  this._ready = true;
430
742
 
431
- for (const socketId of this._pendingConnects) {
432
- this._worker.postMessage({ type: 'connect', socketId });
743
+ for (const msg of this._pendingConnects) {
744
+ this._worker.postMessage(msg);
433
745
  }
434
746
 
435
747
  this._pendingConnects.length = 0;
@@ -441,6 +753,10 @@ export default class HostController {
441
753
  this._onError?.(msg);
442
754
  break;
443
755
 
756
+ case 'preloaded':
757
+ this._onPreloaded?.(msg);
758
+ break;
759
+
444
760
  case 'diagnostic':
445
761
  this._reportWorkerMessage(msg, msg.kind);
446
762
  break;
@@ -449,10 +765,45 @@ export default class HostController {
449
765
  this._onMapChange?.(msg.mapName);
450
766
  break;
451
767
 
768
+ case 'lobby_info':
769
+ this._onLobbyInfoChange?.(msg.info ?? null);
770
+ break;
771
+
452
772
  case 'handoff_state':
453
773
  this._onHandoffState(msg.state);
454
774
  break;
455
775
 
776
+ case 'round_boundary': {
777
+ const cb = this._roundBoundaryCallback;
778
+
779
+ this._roundBoundaryCallback = null;
780
+ cb?.();
781
+ break;
782
+ }
783
+
784
+ case 'checkpoint': {
785
+ const checkpoint = { ...msg };
786
+
787
+ delete checkpoint.type;
788
+
789
+ for (const listener of this._checkpointListeners) {
790
+ listener(checkpoint);
791
+ }
792
+
793
+ break;
794
+ }
795
+
796
+ case 'health':
797
+ for (const listener of this._healthListeners) {
798
+ listener(msg.health);
799
+ }
800
+
801
+ break;
802
+
803
+ case 'shutdown_done':
804
+ this._shutdownResolve?.();
805
+ break;
806
+
456
807
  case 'debug_result': {
457
808
  const pending = this._debugRequests.get(msg.requestId);
458
809
 
@@ -470,7 +821,9 @@ export default class HostController {
470
821
  }
471
822
 
472
823
  case 'to_client':
473
- this._deliveries.get(msg.socketId)?.onMessage(msg.payload, msg.reliable);
824
+ this._deliveries
825
+ .get(msg.socketId)
826
+ ?.onMessage(msg.payload, msg.reliable);
474
827
  break;
475
828
 
476
829
  case 'close_client':