vimp-engine 0.35.6 → 0.36.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 (130) 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/ClientReports.js +6 -2
  9. package/src/client/components/model/Controls.js +27 -0
  10. package/src/client/components/model/Games.js +5 -1
  11. package/src/client/components/model/Lobby.js +32 -24
  12. package/src/client/components/model/LobbyAuth.js +20 -4
  13. package/src/client/components/model/RoomMenu.js +121 -0
  14. package/src/client/components/model/Stat.js +2 -1
  15. package/src/client/components/model/Vote.js +122 -4
  16. package/src/client/components/view/Auth.js +20 -10
  17. package/src/client/components/view/ClientReports.js +16 -5
  18. package/src/client/components/view/Games.js +39 -14
  19. package/src/client/components/view/Lobby.js +90 -43
  20. package/src/client/components/view/LobbyAuth.js +13 -7
  21. package/src/client/components/view/Panel.js +1 -2
  22. package/src/client/components/view/RoomMenu.js +69 -0
  23. package/src/client/debug.js +24 -1
  24. package/src/client/lib/FpsMeter.js +46 -0
  25. package/src/client/lib/JoinRetry.js +73 -0
  26. package/src/client/lib/diagnostics.js +28 -5
  27. package/src/client/lib/hostCaps.js +82 -0
  28. package/src/client/lib/hostUnloadGuard.js +108 -0
  29. package/src/client/lib/hostVoteCommand.js +92 -0
  30. package/src/client/lib/pageReload.js +29 -0
  31. package/src/client/lib/roomLink.js +257 -0
  32. package/src/client/lib/signalingErrors.js +74 -0
  33. package/src/client/main.js +674 -451
  34. package/src/client/network/HiddenHostHealthLog.js +76 -0
  35. package/src/client/network/HostConnectionManager.js +62 -8
  36. package/src/client/network/HostController.js +376 -23
  37. package/src/client/network/HostHealthPolicy.js +307 -0
  38. package/src/client/network/HostHealthReporter.js +54 -0
  39. package/src/client/network/HostPrewarm.js +235 -0
  40. package/src/client/network/LoopbackTransport.js +16 -1
  41. package/src/client/network/PlannedHandoff.js +366 -0
  42. package/src/client/network/Promotion.js +420 -0
  43. package/src/client/network/RoomPeersReporter.js +81 -0
  44. package/src/client/network/SessionSupervisor.js +730 -0
  45. package/src/client/network/SignalingClient.js +183 -30
  46. package/src/client/network/StandbyReceiver.js +287 -0
  47. package/src/client/network/StandbySender.js +319 -0
  48. package/src/client/network/TokenHandoffTimer.js +81 -0
  49. package/src/client/network/WebRtcManager.js +338 -11
  50. package/src/client/network/policyClose.js +32 -2
  51. package/src/client/network/standbyChunks.js +185 -0
  52. package/src/client/session/GuestSession.js +428 -0
  53. package/src/client/session/HandoffFlow.js +399 -0
  54. package/src/client/session/HostRole.js +708 -0
  55. package/src/client/session/Membership.js +179 -0
  56. package/src/client/session/PromotionFlow.js +325 -0
  57. package/src/client/session/RouteBoot.js +432 -0
  58. package/src/client/session/StandbyRole.js +195 -0
  59. package/src/client/session/hostRoomPrep.js +152 -0
  60. package/src/client/session/roomContext.js +32 -0
  61. package/src/client/style.css +83 -22
  62. package/src/client/views/gameShell.js +7 -0
  63. package/src/client/views/includes/informer.pug +4 -0
  64. package/src/client/views/includes/lobby.pug +0 -3
  65. package/src/client/views/includes/roomMenu.pug +9 -0
  66. package/src/client/views/index.pug +1 -0
  67. package/src/config/closeCodes.js +1 -1
  68. package/src/config/env.js +37 -0
  69. package/src/config/hostDefaults.js +24 -0
  70. package/src/config/lobby.js +154 -3
  71. package/src/config/master.js +104 -21
  72. package/src/config/wsports.js +8 -0
  73. package/src/devtools/RecordingSocketManager.js +3 -2
  74. package/src/devtools/ScenarioRunner.js +123 -8
  75. package/src/devtools/VirtualClient.js +20 -0
  76. package/src/devtools/contract/format.js +4 -1
  77. package/src/devtools/contract/index.js +1 -2
  78. package/src/devtools/contract/rules/a2-package-scripts.js +1 -3
  79. package/src/devtools/contract/rules/b8-system-messages.js +1 -1
  80. package/src/devtools/contract/rules/b9-vote-names.js +25 -1
  81. package/src/devtools/contract/rules/c11-stat-mode.js +4 -1
  82. package/src/devtools/contract/rules/c6-stat-columns.js +1 -1
  83. package/src/devtools/contract/rules/c7-key-sets.js +8 -4
  84. package/src/devtools/contract/rules/d3-snapshot-interp.js +2 -1
  85. package/src/devtools/contract/rules/e1-sound-pairs.js +3 -1
  86. package/src/devtools/contract/rules/e4-map-layers.js +13 -2
  87. package/src/devtools/contract/rules/e5-map-radar-walls.js +7 -1
  88. package/src/devtools/contract/rules/index.js +38 -5
  89. package/src/devtools/invariants.js +10 -4
  90. package/src/devtools/pluginLoader.js +1 -4
  91. package/src/devtools/report.js +17 -1
  92. package/src/host/DebugRecorder.js +6 -1
  93. package/src/host/GameCoreAdapter.js +45 -0
  94. package/src/host/HostGame.js +928 -59
  95. package/src/host/PortMachine.js +268 -20
  96. package/src/host/host.worker.js +222 -16
  97. package/src/host/meta/SocketManager.js +20 -0
  98. package/src/host/meta/core/RoundManager.js +89 -14
  99. package/src/host/meta/modules/Accolades.js +4 -1
  100. package/src/host/meta/modules/Panel.js +30 -0
  101. package/src/host/meta/modules/PlayerDataSync.js +255 -46
  102. package/src/host/meta/modules/RTTManager.js +34 -0
  103. package/src/host/meta/modules/TimerManager.js +215 -18
  104. package/src/host/meta/modules/chat/systemMessages.js +22 -0
  105. package/src/host/meta/player/HumanParticipant.js +10 -0
  106. package/src/host/meta/player/ParticipantManager.js +98 -5
  107. package/src/lib/AbstractTimer.js +63 -1
  108. package/src/lib/Publisher.js +26 -0
  109. package/src/lib/capabilities.js +5 -0
  110. package/src/lib/checkpointCodec.js +125 -0
  111. package/src/lib/clientIp.js +9 -2
  112. package/src/lib/createHostRuntime.js +95 -6
  113. package/src/lib/formOptions.js +3 -1
  114. package/src/lib/gameConfigView.js +10 -0
  115. package/src/lib/gameId.js +13 -0
  116. package/src/lib/jwt.js +3 -1
  117. package/src/lib/loadGamePackage.js +2 -2
  118. package/src/lib/resumeKey.js +38 -0
  119. package/src/lib/roomId.js +39 -0
  120. package/src/lib/roomSettings.js +59 -0
  121. package/src/lib/validators.js +7 -0
  122. package/tests/fixtures/generations/gen-api3/host/ScriptedManager.js +2 -3
  123. package/tests/fixtures/generations/gen-api3/host/fakeCore.js +31 -5
  124. package/tests/fixtures/generations/gen-api3/manifest.json +1 -3
  125. package/tests/fixtures/generations/gen-api4/host/ScriptedManager.js +2 -3
  126. package/tests/fixtures/generations/gen-api4/host/fakeCore.js +31 -5
  127. package/tests/fixtures/generations/gen-api4/manifest.json +1 -3
  128. package/tests/fixtures/miniGame/config/client.js +15 -0
  129. package/tests/fixtures/miniGame/host/ScriptedManager.js +2 -3
  130. package/tests/fixtures/miniGame/host/fakeCore.js +31 -5
@@ -6,14 +6,18 @@
6
6
  // сюда приходят уже разобранные пакеты клиентов, обратно уходят wire-кадры
7
7
  // (JSON-строки и бинарные ArrayBuffer'ы через Transferable).
8
8
  //
9
- // Сам хендшейк клиента (порты 0–8) живёт в изоморфной ./PortMachine.js —
9
+ // Сам хендшейк клиента (порты 0–10) живёт в изоморфной ./PortMachine.js —
10
10
  // этот файл только адаптер: postMessage-транспорт, лобби-стратегия
11
11
  // идентичности и свитч сообщений главного потока.
12
12
 
13
13
  import authClientConfig from '../config/authClient.js';
14
14
  import lobbyConfig from '../config/lobby.js';
15
15
  import wsports from '../config/wsports.js';
16
- import { createHostRuntime } from '../lib/createHostRuntime.js';
16
+ import { encodeCheckpoint, decodeCheckpoint } from '../lib/checkpointCodec.js';
17
+ import {
18
+ createHostRuntime,
19
+ preloadHostRuntime,
20
+ } from '../lib/createHostRuntime.js';
17
21
  import PortMachine from './PortMachine.js';
18
22
  import { createTokenIdentity } from './identity.js';
19
23
 
@@ -23,6 +27,10 @@ const PS_TECH_INFORM_DATA = wsports.server.TECH_INFORM_DATA;
23
27
  let host = null;
24
28
  let portMachine = null;
25
29
 
30
+ // комната закрывается (shutdown): новых не пускаем — иначе вошедший вылетел
31
+ // бы через секунды вместе с комнатой
32
+ let shuttingDown = false;
33
+
26
34
  // эстафета Worker'ов (Этап 5.2): socketId → gameId участников, восстановленных
27
35
  // из handoff-меты — их порт-машины поднимаются минуя хендшейк
28
36
  let handoffClients = null;
@@ -68,21 +76,81 @@ function makeWorkerSocket(socketId) {
68
76
  };
69
77
  }
70
78
 
79
+ // участники эстафеты внутри вкладки: v3 — плоский humans, v4 — формат
80
+ // контрольной точки (participants.humans)
81
+ function handoffHumans(handoff) {
82
+ return handoff.participants?.humans ?? handoff.humans ?? [];
83
+ }
84
+
85
+ // готовая контрольная точка уходит главному потоку списком переноса — без
86
+ // копии: 2 точки в секунду не должны стоить сборщику мусора лишних буферов.
87
+ // Буфер кодека принадлежит результату целиком (не view на память wasm)
88
+ function postCheckpoint({ meta, core, final }) {
89
+ encodeCheckpoint(meta, core)
90
+ .then(bytes => {
91
+ const owned =
92
+ bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength
93
+ ? bytes
94
+ : bytes.slice();
95
+
96
+ self.postMessage(
97
+ {
98
+ type: 'checkpoint',
99
+ checkpointId: meta.checkpointId,
100
+ seq: meta.seq,
101
+ createdAt: meta.createdAt,
102
+ final,
103
+ mode: meta.mode,
104
+ // игра точки — в дескриптор канала standby: бете не нужно
105
+ // распаковывать точку ради версии игры (ревью F15)
106
+ game: meta.room?.game
107
+ ? { id: meta.room.game.id, version: meta.room.game.version }
108
+ : null,
109
+ bytes: owned,
110
+ },
111
+ [owned.buffer],
112
+ );
113
+ })
114
+ .catch(e => {
115
+ self.postMessage({
116
+ type: 'diagnostic',
117
+ kind: 'checkpoint',
118
+ message: e && e.message ? e.message : String(e),
119
+ stack: e && typeof e.stack === 'string' ? e.stack : null,
120
+ });
121
+ });
122
+ }
123
+
71
124
  // инициализация хоста: HostPlugin игры (динамический import по
72
125
  // GameManifest, Этап 6.4), ядро, мета, игровой цикл. handoff — состояние
73
126
  // эстафеты Worker'ов (Этап 5.2): комната восстанавливается вместо
74
- // холодного старта, порт-машины клиентов поднимутся минуя хендшейк
75
- async function onInit(room, handoff = null) {
127
+ // холодного старта, порт-машины клиентов поднимутся минуя хендшейк.
128
+ // checkpoint — сжатая контрольная точка (host-migration этап 5): матч
129
+ // поднимается на паузе, люди ждут RESUME, старт — start_after_restore
130
+ async function onInit(room, handoff = null, checkpointBytes = null, seqFloor) {
131
+ const checkpoint = checkpointBytes
132
+ ? await decodeCheckpoint(checkpointBytes)
133
+ : null;
134
+
76
135
  // общая с headless-runner'ом сборка (lib/createHostRuntime.js) — чтобы
77
136
  // отладочный прогон крутил ровно тот код, что и прод
78
137
  const runtime = await createHostRuntime(room, {
79
138
  hostOptions: {
80
- onMapChange: mapName => self.postMessage({ type: 'map_changed', mapName }),
81
- handoff,
139
+ // map_changed остаётся для главного потока до host-migration этапа 2
140
+ onMapChange: mapName =>
141
+ self.postMessage({ type: 'map_changed', mapName }),
142
+ onLobbyInfoChange: info => self.postMessage({ type: 'lobby_info', info }),
143
+ handoff: checkpoint ? null : handoff,
144
+ checkpoint,
145
+ seqFloor: Number(seqFloor) || 0,
82
146
  },
83
147
  });
84
148
 
85
149
  host = runtime.host;
150
+ host.setCheckpointSink(postCheckpoint);
151
+ // здоровье хоста (host-migration этап 9a): главный поток решает, не пора
152
+ // ли отдать роль
153
+ host.setHealthSink(health => self.postMessage({ type: 'health', health }));
86
154
 
87
155
  // в лобби личность игрока — claim identity-токена, проверенного по JWKS
88
156
  // мастера (Этап B3); свободного ввода имени в форме игры нет
@@ -96,25 +164,44 @@ async function onInit(room, handoff = null) {
96
164
  jwksUrl: lobbyConfig.auth.jwksUrl,
97
165
  issuer: authClientConfig.issuer,
98
166
  }),
167
+ // возобновление сессии (host-migration этап 4) — только в лобби:
168
+ // оборвавшийся гость держит место resumeGraceMs и возвращается в него
169
+ resumeGraceMs: runtime.game.resumeGraceMs,
170
+ resumeRequestTimeoutMs: runtime.game.resumeRequestTimeoutMs,
99
171
  });
100
172
 
101
173
  const seed = runtime.seed;
102
174
 
103
- if (handoff) {
104
- handoffClients = new Map(handoff.humans.map(h => [h.socketId, h.gameId]));
175
+ if (handoff && !checkpoint) {
176
+ handoffClients = new Map(
177
+ handoffHumans(handoff).map(h => [h.socketId, h.gameId]),
178
+ );
105
179
  }
106
180
 
107
181
  // мастеру нужна фактическая карта комнаты (после эстафеты — восстановленная)
108
- self.postMessage({ type: 'ready', mapName: host.currentMap, seed });
182
+ self.postMessage({
183
+ type: 'ready',
184
+ mapName: host.currentMap,
185
+ lobbyInfo: host.lobbyInfo,
186
+ seed,
187
+ });
109
188
  }
110
189
 
111
190
  // новое подключение клиента: участник из handoff-меты уже восстановлен в
112
191
  // HostGame — его порт-машина поднимается сразу в игровом состоянии
113
- function onConnect(socketId) {
192
+ // resume — переподключение гостя (host-migration этап 4): порт-машина ждёт
193
+ // RESUME_REQUEST вместо хендшейка
194
+ function onConnect(socketId, resume = false) {
114
195
  if (!portMachine) {
115
196
  return;
116
197
  }
117
198
 
199
+ // закрытие без кода: в лобби-режиме гость уходит искать другую комнату
200
+ if (shuttingDown) {
201
+ self.postMessage({ type: 'close_client', socketId });
202
+ return;
203
+ }
204
+
118
205
  const restoredGameId = handoffClients?.get(socketId);
119
206
 
120
207
  if (restoredGameId !== undefined) {
@@ -123,7 +210,7 @@ function onConnect(socketId) {
123
210
  return;
124
211
  }
125
212
 
126
- portMachine.connect(socketId);
213
+ portMachine.connect(socketId, { resume });
127
214
  }
128
215
 
129
216
  // отладочные действия хоста (этап 6): запись живого матча в формат сценария
@@ -171,7 +258,7 @@ self.onmessage = async event => {
171
258
  switch (msg.type) {
172
259
  case 'init':
173
260
  try {
174
- await onInit(msg.room, msg.handoff);
261
+ await onInit(msg.room, msg.handoff, msg.checkpoint, msg.seqFloor);
175
262
  } catch (e) {
176
263
  // сбой загрузки WASM/конфига/handoff-меты — сообщить главному
177
264
  // потоку, не виснуть (при эстафете тот возобновит старый Worker)
@@ -185,8 +272,29 @@ self.onmessage = async event => {
185
272
  }
186
273
  break;
187
274
 
275
+ // прогрев Worker'а преемника (host-migration этап 6): плагин и wasm
276
+ // готовы, матча нет — его поднимет init с контрольной точкой (этап 7)
277
+ case 'preload':
278
+ try {
279
+ const { wasmCompiled } = await preloadHostRuntime(msg.room);
280
+
281
+ self.postMessage({
282
+ type: 'preloaded',
283
+ gameId: msg.room?.game?.id ?? null,
284
+ gameVersion: msg.room?.game?.version ?? null,
285
+ wasmCompiled,
286
+ });
287
+ } catch (e) {
288
+ self.postMessage({
289
+ type: 'error',
290
+ message: e && e.message ? e.message : String(e),
291
+ stack: e && typeof e.stack === 'string' ? e.stack : null,
292
+ });
293
+ }
294
+ break;
295
+
188
296
  case 'connect':
189
- onConnect(msg.socketId);
297
+ onConnect(msg.socketId, msg.resume === true);
190
298
  break;
191
299
 
192
300
  case 'message':
@@ -201,10 +309,20 @@ self.onmessage = async event => {
201
309
  host?.updateMaps(msg.maps);
202
310
  break;
203
311
 
204
- // мастер подтвердил регистрацию комнаты (кодревью №1) — hostId+секрет
205
- // нужны PlayerDataSync для атрибуции последующих rank/state-flush
312
+ // мастер подтвердил регистрацию комнаты — roomId+секрет эпохи нужны
313
+ // PlayerDataSync для атрибуции последующих rank/state-flush
314
+ case 'set_room':
315
+ host?.setRoom({
316
+ roomId: msg.roomId,
317
+ roomSecret: msg.roomSecret,
318
+ epoch: msg.epoch,
319
+ });
320
+ break;
321
+
322
+ // имя того же сообщения у главного потока до host-migration этапа 2:
323
+ // страница, загруженная до деплоя, поднимает Worker по свежему манифесту
206
324
  case 'set_host_id':
207
- host?.setHostId(msg.hostId, msg.hostSecret);
325
+ host?.setRoom({ roomId: msg.hostId, roomSecret: msg.hostSecret });
208
326
  break;
209
327
 
210
328
  // отладочный контур (этап 6 плана plan/done/ai-debug): единственный вход в
@@ -223,6 +341,11 @@ self.onmessage = async event => {
223
341
  );
224
342
  break;
225
343
 
344
+ // плановая передача хоста вытесняет эстафету, ещё ждущую границы раунда
345
+ case 'cancel_handoff':
346
+ host?.cancelHandoff();
347
+ break;
348
+
226
349
  // новый Worker не поднялся — продолжаем жить на этой версии
227
350
  case 'resume':
228
351
  host?.resumeAfterHandoff();
@@ -233,5 +356,88 @@ self.onmessage = async event => {
233
356
  handoffClients = null;
234
357
  host?.completeHandoff(new Set(portMachine ? portMachine.socketIds : []));
235
358
  break;
359
+
360
+ // контрольные точки (host-migration этап 5)
361
+
362
+ // периодические точки: снимаются на границе кадра не чаще intervalMs
363
+ case 'checkpoint_start':
364
+ host?.startCheckpoints(msg.intervalMs);
365
+ break;
366
+
367
+ case 'checkpoint_stop':
368
+ host?.stopCheckpoints();
369
+ break;
370
+
371
+ // одна точка на ближайшей границе кадра (final — финальная перед
372
+ // плановой передачей)
373
+ case 'checkpoint_request':
374
+ host?.requestCheckpoint({ final: msg.final === true });
375
+ break;
376
+
377
+ // матч поднят из точки: запустить цикл и таймеры. waitForResume —
378
+ // промоушен преемника (этап 7.4): старт, когда вернулись все люди точки
379
+ // или истёк resumeWaitMs; не вернувшимся — ожидание resumeGraceMs.
380
+ // reason (этап 9) — причина передачи для сообщения игрокам; старый
381
+ // главный поток её не шлёт
382
+ case 'start_after_restore':
383
+ if (msg.waitForResume === true) {
384
+ host?.startAfterResume(ids => portMachine?.startGraceFor(ids), {
385
+ reason: typeof msg.reason === 'string' ? msg.reason : null,
386
+ });
387
+ } else {
388
+ host?.startAfterRestore();
389
+ }
390
+ break;
391
+
392
+ // плановая передача хоста (этап 8d): дождаться границы раунда (у игры
393
+ // с migration.midRound — ответ сразу)
394
+ case 'round_boundary_wait':
395
+ host?.awaitRoundBoundary(() =>
396
+ self.postMessage({ type: 'round_boundary' }),
397
+ );
398
+ break;
399
+
400
+ case 'round_boundary_cancel':
401
+ host?.cancelRoundBoundary();
402
+ break;
403
+
404
+ case 'freeze':
405
+ host?.freeze();
406
+ break;
407
+
408
+ case 'unfreeze':
409
+ host?.unfreeze();
410
+ break;
411
+
412
+ // хост закрывает комнату («Leave server» последнего человека): закрыть
413
+ // игры участников и дождаться записи профилей — не дольше timeoutMs
414
+ case 'shutdown': {
415
+ const timeoutMs =
416
+ Number(msg.timeoutMs) > 0 ? Number(msg.timeoutMs) : 3000;
417
+ let timer = null;
418
+
419
+ shuttingDown = true;
420
+
421
+ try {
422
+ await Promise.race([
423
+ host?.destroy(),
424
+ new Promise(resolve => {
425
+ timer = setTimeout(resolve, timeoutMs);
426
+ }),
427
+ ]);
428
+ } catch (e) {
429
+ self.postMessage({
430
+ type: 'diagnostic',
431
+ kind: 'shutdown',
432
+ message: e && e.message ? e.message : String(e),
433
+ stack: e && typeof e.stack === 'string' ? e.stack : null,
434
+ });
435
+ } finally {
436
+ clearTimeout(timer);
437
+ }
438
+
439
+ self.postMessage({ type: 'shutdown_done' });
440
+ break;
441
+ }
236
442
  }
237
443
  };
@@ -40,6 +40,8 @@ export default class SocketManager {
40
40
  this._PORT_VOTE_DATA = ports.VOTE_DATA;
41
41
  this._PORT_KEYSET_DATA = ports.KEYSET_DATA;
42
42
  this._PORT_ACCOLADES_DATA = ports.ACCOLADES_DATA;
43
+ this._PORT_SESSION_DATA = ports.SESSION_DATA;
44
+ this._PORT_RESUME_RESULT = ports.RESUME_RESULT;
43
45
 
44
46
  this._soundCues = soundCues;
45
47
  this._initialVote = initialVote;
@@ -436,6 +438,24 @@ export default class SocketManager {
436
438
  }
437
439
  }
438
440
 
441
+ /**
442
+ * Отправка секрета возобновления сессии (host-migration этап 4).
443
+ * @param {string} socketId
444
+ * @param {Object} data - { resumeKey, gameId }.
445
+ */
446
+ sendSessionData(socketId, data) {
447
+ this._send(socketId, this._PORT_SESSION_DATA, data);
448
+ }
449
+
450
+ /**
451
+ * Ответ на RESUME_REQUEST (host-migration этап 4).
452
+ * @param {string} socketId
453
+ * @param {Object} data - { ok: true, gameId, epoch } | { ok: false, reason }.
454
+ */
455
+ sendResumeResult(socketId, data) {
456
+ this._send(socketId, this._PORT_RESUME_RESULT, data);
457
+ }
458
+
439
459
  /**
440
460
  * Отправка команды смены имени.
441
461
  * @param {string} socketId
@@ -79,9 +79,15 @@ class RoundManager {
79
79
  // идёт дальше, и второй раз `deaths` той же команде не пишется
80
80
  this._wipedTeamIds = new Set();
81
81
  this._removedPlayersList = []; // gameId игроков для удаления с полотна
82
+ // карта, подменённая игрой (overrideMapData) в клиентском виде, или null —
83
+ // контрольной точке она нужна отдельно от карты каталога
84
+ this._mapOverride = null;
85
+ this._baseMapData = null;
82
86
 
83
87
  // эстафета Worker'ов (Этап 5.2): колбэк переноса на границе раунда
84
88
  this._handoffCallback = null;
89
+ // плановая передача хоста ждёт границы раунда (host-migration этап 8d)
90
+ this._boundaryCallback = null;
85
91
  }
86
92
 
87
93
  get currentMap() {
@@ -92,6 +98,12 @@ class RoundManager {
92
98
  return this._currentMapData;
93
99
  }
94
100
 
101
+ // JSON текущей карты в виде каталога (до масштабирования и подмены игрой)
102
+ // — контрольная точка везёт его с собой: каталог преемника мог измениться
103
+ get baseMapData() {
104
+ return this._baseMapData;
105
+ }
106
+
95
107
  get removedPlayersList() {
96
108
  return this._removedPlayersList;
97
109
  }
@@ -113,11 +125,15 @@ class RoundManager {
113
125
  }
114
126
 
115
127
  // подготавливает данные текущей карты (масштабирование) без пересоздания
116
- // мира — общий шаг createMap и восстановления эстафеты (Этап 5.2)
117
- _prepareMapData() {
128
+ // мира — общий шаг createMap и восстановления эстафеты (Этап 5.2).
129
+ // source — JSON карты из контрольной точки (host-migration этап 5): та
130
+ // самая карта, что лежит в дампе ядра, даже если каталог уже другой
131
+ _prepareMapData(source = this._maps[this._currentMap]) {
132
+ this._mapOverride = null;
133
+ this._baseMapData = source;
118
134
  this._currentMapData = {
119
135
  scale: this._mapScale,
120
- ...this._maps[this._currentMap],
136
+ ...source,
121
137
  };
122
138
 
123
139
  // если нет индивидуального конструктора для создания карты
@@ -130,13 +146,58 @@ class RoundManager {
130
146
 
131
147
  // восстанавливает карту после эстафеты Worker'ов (Этап 5.2): без
132
148
  // пересоздания мира и рассылки клиентам — мир соберёт первый
133
- // initiateNewRound после переноса
134
- restoreMap(mapName) {
149
+ // initiateNewRound после переноса, а при восстановлении посреди раунда
150
+ // (host-migration этап 5) он уже внутри дампа ядра: здесь только
151
+ // JS-сторона, ни createMap, ни load_map. scripted.createMap запоминает
152
+ // точки респауна и ботов в ядре не создаёт
153
+ restoreMap(mapName, { mapData = null, override = null } = {}) {
135
154
  this._currentMap = mapName;
136
- this._prepareMapData();
155
+ this._prepareMapData(mapData ?? this._maps[mapName]);
156
+
157
+ if (override) {
158
+ this.overrideMapData(override);
159
+ }
160
+
137
161
  this._scripted.createMap(this._scaledMapData);
138
162
  }
139
163
 
164
+ // состояние раунда для контрольной точки (host-migration этап 5)
165
+ serialize() {
166
+ return {
167
+ currentMap: this._currentMap,
168
+ override: this._mapOverride,
169
+ isRoundEnding: this._isRoundEnding,
170
+ wipedTeamIds: [...this._wipedTeamIds],
171
+ removedPlayers: [...this._removedPlayersList],
172
+ startMapNumber: this._startMapNumber,
173
+ };
174
+ }
175
+
176
+ // флаги раунда из контрольной точки (карту поднимает restoreMap)
177
+ restoreRound({
178
+ isRoundEnding = false,
179
+ wipedTeamIds = [],
180
+ removedPlayers = [],
181
+ startMapNumber = 0,
182
+ } = {}) {
183
+ this._isRoundEnding = isRoundEnding === true;
184
+ this._wipedTeamIds = new Set(wipedTeamIds);
185
+ this._removedPlayersList = [...removedPlayers];
186
+ this._startMapNumber = startMapNumber;
187
+ }
188
+
189
+ // отложенная смена карты: цель объявлена таймеру, чтобы контрольная точка
190
+ // могла её перенести (duration — остаток при восстановлении)
191
+ scheduleMapChange(mapName, duration) {
192
+ this._timerManager.startMapChangeDelay(
193
+ () => {
194
+ this._currentMap = mapName;
195
+ this.createMap();
196
+ },
197
+ { targetMap: mapName, duration },
198
+ );
199
+ }
200
+
140
201
  // создает карту
141
202
  createMap() {
142
203
  this._prepareMapData();
@@ -245,6 +306,7 @@ class RoundManager {
245
306
  if (mapData) {
246
307
  this._currentMapData = mapData;
247
308
  this._scaledMapData = scaleMapData(mapData);
309
+ this._mapOverride = mapData;
248
310
  }
249
311
  }
250
312
 
@@ -265,6 +327,18 @@ class RoundManager {
265
327
  this._handoffCallback = null;
266
328
  }
267
329
 
330
+ // плановая передача хоста в игре без migration.midRound (host-migration
331
+ // этап 8d): колбэк сработает один раз, когда стартует следующий раунд.
332
+ // Раунд не придерживается — бета всё равно начнёт его заново из мягкой
333
+ // точки, а сорвавшаяся передача не должна оставить комнату без раунда
334
+ onRoundBoundary(cb) {
335
+ this._boundaryCallback = cb;
336
+ }
337
+
338
+ cancelRoundBoundary() {
339
+ this._boundaryCallback = null;
340
+ }
341
+
268
342
  // запуск нового раунда
269
343
  initiateNewRound() {
270
344
  this._timerManager.stopRoundTimer();
@@ -282,6 +356,13 @@ class RoundManager {
282
356
 
283
357
  this._startRound();
284
358
  this._timerManager.startRoundTimer();
359
+
360
+ if (this._boundaryCallback) {
361
+ const cb = this._boundaryCallback;
362
+
363
+ this._boundaryCallback = null;
364
+ cb();
365
+ }
285
366
  }
286
367
 
287
368
  // начало раунда
@@ -862,10 +943,7 @@ class RoundManager {
862
943
  if (result === 'Yes' && this._maps[mapName]) {
863
944
  this._chat.pushSystem('VOTE_PASSED');
864
945
  this._chat.pushSystem('MAP_NEXT', [mapName]);
865
- this._timerManager.startMapChangeDelay(() => {
866
- this._currentMap = mapName;
867
- this.createMap();
868
- });
946
+ this.scheduleMapChange(mapName);
869
947
  } else {
870
948
  this._chat.pushSystem('VOTE_FAILED');
871
949
  }
@@ -890,10 +968,7 @@ class RoundManager {
890
968
  if (resultingMapName && this._maps[resultingMapName]) {
891
969
  this._chat.pushSystem('VOTE_PASSED');
892
970
  this._chat.pushSystem('MAP_NEXT', [resultingMapName]);
893
- this._timerManager.startMapChangeDelay(() => {
894
- this._currentMap = resultingMapName;
895
- this.createMap();
896
- });
971
+ this.scheduleMapChange(resultingMapName);
897
972
  } else {
898
973
  // если никто не проголосовал, продлеваем время текущей карты
899
974
  this._timerManager.stopMapTimer();
@@ -104,7 +104,10 @@ export default class Accolades {
104
104
  return;
105
105
  }
106
106
 
107
- if (this._lastRefreshAt !== null && now - this._lastRefreshAt < this._interval) {
107
+ if (
108
+ this._lastRefreshAt !== null &&
109
+ now - this._lastRefreshAt < this._interval
110
+ ) {
108
111
  return;
109
112
  }
110
113
 
@@ -172,6 +172,36 @@ class Panel {
172
172
  return panelData;
173
173
  }
174
174
 
175
+ // значения панели участников для контрольной точки (host-migration
176
+ // этап 5): { [gameId]: values }. Несостоявшиеся изменения не переносятся —
177
+ // возобновлённый клиент получает панель целиком
178
+ serialize() {
179
+ return Object.fromEntries(
180
+ Object.entries(this._data).map(([gameId, user]) => [
181
+ gameId,
182
+ { ...user.values },
183
+ ]),
184
+ );
185
+ }
186
+
187
+ // применяет значения из точки к уже заведённым (addUser) участникам;
188
+ // поля вне схемы панели отбрасываются
189
+ restore(state = {}) {
190
+ for (const [gameId, values] of Object.entries(state)) {
191
+ const user = this._data[gameId];
192
+
193
+ if (!user) {
194
+ continue;
195
+ }
196
+
197
+ for (const [param, value] of Object.entries(values ?? {})) {
198
+ if (Object.hasOwn(this._fields, param)) {
199
+ user.values[param] = value;
200
+ }
201
+ }
202
+ }
203
+ }
204
+
175
205
  // возвращает пустые данные (ключи без значений)
176
206
  getEmptyPanel() {
177
207
  return [`t:${this._lastSentRoundTime}`].concat(this._emptyPanel);