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
@@ -1,6 +1,10 @@
1
1
  import AbstractTimer from '../../../lib/AbstractTimer.js';
2
2
  import clock from '../../../lib/clock.js';
3
3
 
4
+ // окно метрик здоровья цикла (host-migration этап 9a): раз в столько мс
5
+ // цикл отдаёт onLoopStats — хост решает по ним, не пора ли отдать роль
6
+ const LOOP_STATS_WINDOW_MS = 1000;
7
+
4
8
  // Singleton TimerManager
5
9
 
6
10
  let timerManager;
@@ -43,6 +47,23 @@ class TimerManager extends AbstractTimer {
43
47
  // максимально допустимая дельта времени в секундах,
44
48
  // для предотвращения "прыжков" в симуляции после долгих пауз
45
49
  this._maxDeltaTime = 0.1;
50
+
51
+ // окно метрик цикла: живёт только пока цикл крутится — на паузе метрик
52
+ // нет (иначе заморозка выглядела бы перегрузкой)
53
+ this._loopStats = null;
54
+
55
+ // заморозка (host-migration этап 5): момент паузы — пока он задан,
56
+ // остатки карты/раунда не убывают
57
+ this._pausedAt = null;
58
+
59
+ // цель отложенной смены карты: колбэк — замыкание, а контрольной точке
60
+ // нужна декларация (её колбэк строится заново при восстановлении)
61
+ this._pendingMapChange = null;
62
+ }
63
+
64
+ // «сейчас» для остатков: на паузе время карты и раунда стоит
65
+ _now() {
66
+ return this._pausedAt ?? clock.now();
46
67
  }
47
68
 
48
69
  // запускает все основные игровые таймеры (карта, игровой цикл, раунд)
@@ -80,7 +101,7 @@ class TimerManager extends AbstractTimer {
80
101
  // возвращает оставшееся время до конца карты в миллисекундах
81
102
  getMapTimeLeft() {
82
103
  const timeLeft =
83
- this._currentMapDuration - (clock.now() - this._startMapTime);
104
+ this._currentMapDuration - (this._now() - this._startMapTime);
84
105
 
85
106
  return Math.max(0, timeLeft);
86
107
  }
@@ -93,11 +114,14 @@ class TimerManager extends AbstractTimer {
93
114
  this._startRttPingTimer();
94
115
  }
95
116
 
96
- // запускает таймер до конца текущего раунда
97
- startRoundTimer() {
117
+ // запускает таймер до конца текущего раунда. duration — остаток при
118
+ // восстановлении из контрольной точки: старт раунда тогда «виртуальный»
119
+ // (в прошлом), чтобы прошедшее время раунда и грейс смены команды
120
+ // считались от настоящего начала
121
+ startRoundTimer(duration = this._roundTime) {
98
122
  this.stopRoundTimer();
99
- this._startRoundTime = clock.now();
100
- this._startTimer('round', this._callbacks.onRoundTimeEnd, this._roundTime);
123
+ this._startRoundTime = clock.now() - (this._roundTime - duration);
124
+ this._startTimer('round', this._callbacks.onRoundTimeEnd, duration);
101
125
  }
102
126
 
103
127
  // останавливает таймер раунда
@@ -107,14 +131,17 @@ class TimerManager extends AbstractTimer {
107
131
 
108
132
  // возвращает оставшееся время до конца раунда в секундах
109
133
  getRoundTimeLeft() {
110
- const timeLeft = this._roundTime - (clock.now() - this._startRoundTime);
134
+ return Math.floor(this.getRoundTimeLeftMs() / 1000);
135
+ }
111
136
 
112
- return Math.max(0, Math.floor(timeLeft / 1000));
137
+ // то же в миллисекундах (контрольная точка не теряет долей секунды)
138
+ getRoundTimeLeftMs() {
139
+ return Math.max(0, this._roundTime - (this._now() - this._startRoundTime));
113
140
  }
114
141
 
115
142
  // проверяет возможно сменить команду игроку в текущем раунде
116
143
  canChangeTeamInCurrentRound() {
117
- const roundTime = clock.now() - this._startRoundTime;
144
+ const roundTime = this._now() - this._startRoundTime;
118
145
 
119
146
  return roundTime <= this._teamChangeGracePeriod;
120
147
  }
@@ -125,6 +152,8 @@ class TimerManager extends AbstractTimer {
125
152
  let dt = (now - this._lastShotTime) / 1000;
126
153
  this._lastShotTime = now;
127
154
 
155
+ this._countLoopTick(dt);
156
+
128
157
  // если dt аномально большой (система "спала"), ограничить его
129
158
  // и сбросить ожидаемое время, чтобы цикл не пытался "наверстать".
130
159
  if (dt > this._maxDeltaTime) {
@@ -133,6 +162,7 @@ class TimerManager extends AbstractTimer {
133
162
  }
134
163
 
135
164
  this._callbacks.onShotTick(dt);
165
+ this._flushLoopStats(now);
136
166
 
137
167
  const drift = now - this._expectedTickTime;
138
168
  const nextTimeout = Math.max(0, this._timeStep - drift);
@@ -143,11 +173,47 @@ class TimerManager extends AbstractTimer {
143
173
  this._startTimer('gameLoop', () => this._loopTick(), nextTimeout);
144
174
  }
145
175
 
176
+ // итерация цикла в окно метрик: срезанное капом dt — время, которое
177
+ // симуляция не прожила (отстаёт от реального)
178
+ _countLoopTick(dt) {
179
+ const stats = this._loopStats;
180
+
181
+ stats.ticks += 1;
182
+ stats.maxGapMs = Math.max(stats.maxGapMs, dt * 1000);
183
+
184
+ if (dt > this._maxDeltaTime) {
185
+ stats.lostMs += (dt - this._maxDeltaTime) * 1000;
186
+ }
187
+ }
188
+
189
+ // окно метрик закрыто — отдать и начать новое
190
+ _flushLoopStats(now) {
191
+ const stats = this._loopStats;
192
+ const windowMs = now - stats.startedAt;
193
+
194
+ if (windowMs < LOOP_STATS_WINDOW_MS) {
195
+ return;
196
+ }
197
+
198
+ this._loopStats = this._newLoopStats(now);
199
+ this._callbacks.onLoopStats?.({
200
+ tickRate: Math.round((stats.ticks * 10000) / windowMs) / 10,
201
+ maxGapMs: Math.round(stats.maxGapMs),
202
+ lostMs: Math.round(stats.lostMs),
203
+ windowMs: Math.round(windowMs),
204
+ });
205
+ }
206
+
207
+ _newLoopStats(now) {
208
+ return { startedAt: now, ticks: 0, maxGapMs: 0, lostMs: 0 };
209
+ }
210
+
146
211
  // инициализирует и запускает игровой цикл (обновление кадров)
147
212
  _startGameLoop() {
148
213
  this._stopGameLoop();
149
214
 
150
215
  this._lastShotTime = clock.monotonic();
216
+ this._loopStats = this._newLoopStats(this._lastShotTime);
151
217
  this._expectedTickTime = this._lastShotTime + this._timeStep;
152
218
 
153
219
  // запуск первого таймаута, который инициирует цикл
@@ -174,13 +240,24 @@ class TimerManager extends AbstractTimer {
174
240
  }
175
241
 
176
242
  // запускает таймер, блокирующий возможность инициировать
177
- // новое голосование в заданной категории
178
- startVoteBlockTimer(voteCategory, onEndCallback) {
179
- this._startTimer(
180
- `voteBlock:${voteCategory}`,
181
- onEndCallback,
182
- this._timeBlockedVote,
183
- );
243
+ // новое голосование в заданной категории (duration — остаток кулдауна
244
+ // из контрольной точки)
245
+ startVoteBlockTimer(
246
+ voteCategory,
247
+ onEndCallback,
248
+ duration = this._timeBlockedVote,
249
+ ) {
250
+ this._startTimer(`voteBlock:${voteCategory}`, onEndCallback, duration);
251
+ }
252
+
253
+ // кулдауны голосований с остатками: [{ name, leftMs }]
254
+ getVoteCooldowns() {
255
+ return [...this._timers.keys()]
256
+ .filter(key => key.startsWith('voteBlock:'))
257
+ .map(key => ({
258
+ name: key.slice('voteBlock:'.length),
259
+ leftMs: this._timeLeft(key),
260
+ }));
184
261
  }
185
262
 
186
263
  // останавливает все таймеры блокировки
@@ -211,9 +288,15 @@ class TimerManager extends AbstractTimer {
211
288
  this._stopTimer('roundRestartDelay');
212
289
  }
213
290
 
214
- // запускает отложенную смену карты (после голосования)
215
- startMapChangeDelay(onEndCallback) {
216
- this._startTimer('mapChangeDelay', onEndCallback, this._mapChangeDelay);
291
+ // запускает отложенную смену карты (после голосования). targetMap —
292
+ // декларация цели для контрольной точки; duration — остаток при
293
+ // восстановлении
294
+ startMapChangeDelay(
295
+ onEndCallback,
296
+ { targetMap = null, duration = this._mapChangeDelay } = {},
297
+ ) {
298
+ this._pendingMapChange = targetMap;
299
+ this._startTimer('mapChangeDelay', onEndCallback, duration);
217
300
  }
218
301
 
219
302
  // останавливает отложенную смену карты
@@ -221,6 +304,120 @@ class TimerManager extends AbstractTimer {
221
304
  this._stopTimer('mapChangeDelay');
222
305
  }
223
306
 
307
+ // ***** контрольная точка (host-migration этап 5) ***** //
308
+
309
+ /**
310
+ * Остатки по всем дедлайнам. Активные голосования (vote:*) не
311
+ * переносятся: их resultFunc — замыкания.
312
+ * @returns {Object} { mapTimeLeft, roundTimeLeft, roundElapsed,
313
+ * teamChangeGraceLeft, pending, voteCooldowns }. mapTimeLeft считается
314
+ * и у остановленного таймера (эстафета снимает таймеры до сбора меты);
315
+ * roundTimeLeft null — раунд не идёт (завершается).
316
+ */
317
+ serialize() {
318
+ const roundElapsed = Math.max(0, this._now() - this._startRoundTime);
319
+ const pending = [];
320
+
321
+ if (this._hasTimer('roundRestartDelay')) {
322
+ pending.push({
323
+ kind: 'roundRestart',
324
+ leftMs: this._timeLeft('roundRestartDelay'),
325
+ });
326
+ }
327
+
328
+ if (this._hasTimer('mapChangeDelay') && this._pendingMapChange !== null) {
329
+ pending.push({
330
+ kind: 'mapChange',
331
+ leftMs: this._timeLeft('mapChangeDelay'),
332
+ targetMap: this._pendingMapChange,
333
+ });
334
+ }
335
+
336
+ return {
337
+ mapTimeLeft: this.getMapTimeLeft(),
338
+ roundTimeLeft: this._hasTimer('round') ? this.getRoundTimeLeftMs() : null,
339
+ roundElapsed,
340
+ teamChangeGraceLeft: Math.max(
341
+ 0,
342
+ this._teamChangeGracePeriod - roundElapsed,
343
+ ),
344
+ pending,
345
+ voteCooldowns: this.getVoteCooldowns(),
346
+ };
347
+ }
348
+
349
+ /**
350
+ * Запускает игровые таймеры с остатками из serialize(): карта, раунд,
351
+ * цикл, пинги, отложенный перезапуск раунда, кулдауны голосований.
352
+ * Отложенную смену карты (pending mapChange) поднимает RoundManager —
353
+ * колбэк его.
354
+ * @param {Object} state - результат serialize().
355
+ */
356
+ resumeFromState(state) {
357
+ this.startMapTimer(state.mapTimeLeft ?? this._mapTime);
358
+
359
+ if (state.roundTimeLeft !== null && state.roundTimeLeft !== undefined) {
360
+ this.startRoundTimer(state.roundTimeLeft);
361
+ } else {
362
+ // раунд завершается (таймер снят) — прошедшее время всё равно нужно
363
+ // грейсу смены команды
364
+ this._startRoundTime = clock.now() - (state.roundElapsed ?? 0);
365
+ }
366
+
367
+ for (const item of state.pending ?? []) {
368
+ if (item.kind === 'roundRestart') {
369
+ this._startTimer(
370
+ 'roundRestartDelay',
371
+ this._callbacks.onRoundTimeEnd,
372
+ item.leftMs,
373
+ );
374
+ }
375
+ }
376
+
377
+ for (const { name, leftMs } of state.voteCooldowns ?? []) {
378
+ this.startVoteBlockTimer(name, () => {}, leftMs);
379
+ }
380
+
381
+ this._startGameLoop();
382
+ this._startRttPingTimer();
383
+ }
384
+
385
+ get isPaused() {
386
+ return this._pausedAt !== null;
387
+ }
388
+
389
+ // заморозка: все отсчёты (цикл, карта, раунд, отложенные вызовы,
390
+ // голосования, пинги, проверка бездействия) встают с остатками
391
+ pause() {
392
+ if (this._pausedAt !== null) {
393
+ return;
394
+ }
395
+
396
+ this._pausedAt = clock.now();
397
+ this._pauseTimers();
398
+ }
399
+
400
+ // разморозка: остатки дотикивают, цикл стартует заново (без «прыжка» dt)
401
+ resume() {
402
+ if (this._pausedAt === null) {
403
+ return;
404
+ }
405
+
406
+ const pausedFor = clock.now() - this._pausedAt;
407
+
408
+ this._pausedAt = null;
409
+ this._startMapTime += pausedFor;
410
+ this._startRoundTime += pausedFor;
411
+
412
+ const loopPaused = this._timeLeft('gameLoop') !== null;
413
+
414
+ this._resumeTimers(key => key !== 'gameLoop');
415
+
416
+ if (loopPaused) {
417
+ this._startGameLoop();
418
+ }
419
+ }
420
+
224
421
  // логика одного "тика" для проверки на бездействие
225
422
  _idleCheckTick() {
226
423
  this._callbacks.onIdleCheck();
@@ -1,8 +1,9 @@
1
1
  // Реестр кодов системных сообщений чата. Движковые группы: s (статусы),
2
2
  // v (голосования), m (карты), c (команды), n (имена). Игровые коды
3
3
  // (у танков — группа b:*) добавляются через registerCodes и не должны
4
- // пересекаться с движковыми группами. Тексты шаблонов — на клиенте.
5
- const MESSAGE_CODES = {
4
+ // пересекаться с движковыми группами. Английские тексты движковых кодов — config/chatMessages.js,
5
+ // игровых — на клиенте (конфиг игры).
6
+ export const ENGINE_MESSAGE_CODES = Object.freeze({
6
7
  TEAMS_TEAM_FULL: 's:0', // Team {0} is full. Your current team: {1}
7
8
  TEAMS_YOUR_TEAM: 's:1', // Your team: {0}
8
9
  TEAMS_NEW_TEAM: 's:2', // Your new team: {0}
@@ -10,6 +11,15 @@ const MESSAGE_CODES = {
10
11
  REPORT_KILL: 's:4', // ⚔️ {0} killed {1}!
11
12
  USER_JOINED: 's:5', // ⚡ {0} joined the game
12
13
  USER_LEFT: 's:6', // 👋 {0} left the game
14
+ HOST_CHANGED: 's:7', // Host changed (host-migration: матч у преемника)
15
+ // You are no longer the host (connection lost) — добавляет сам клиент
16
+ // бывшего хоста (host_revoked), по проводу не ходит
17
+ HOST_REVOKED: 's:8',
18
+ // вместо HOST_CHANGED, когда хост отдал роль автоматически (этап 9)
19
+ HOST_CHANGED_OVERLOAD: 's:9', // Host changed: the previous host was lagging
20
+ HOST_CHANGED_HIDDEN: 's:10', // Host changed: the previous host went inactive
21
+ // Host changed: the previous host had a poor connection
22
+ HOST_CHANGED_NETWORK: 's:11',
13
23
 
14
24
  VOTE_CREATED: 'v:0', // A vote has been created
15
25
  VOTE_STARTED: 'v:1', // Voting has started
@@ -17,6 +27,19 @@ const MESSAGE_CODES = {
17
27
  VOTE_UNAVAILABLE: 'v:3', // Voting is temporarily unavailable
18
28
  VOTE_PASSED: 'v:4', // Vote passed
19
29
  VOTE_FAILED: 'v:5', // Vote failed
30
+ // голосование «Change host» (host-migration этап 10): ведёт мастер,
31
+ // сообщения добавляет сам клиент, по проводу они не ходят
32
+ HOST_VOTE_USAGE: 'v:6', // Usage: /changehost
33
+ // You are the host — use “Hand over host” in the room menu
34
+ HOST_VOTE_IS_HOST: 'v:7',
35
+ HOST_VOTE_OFFLINE: 'v:8', // No connection to the master server
36
+ HOST_VOTE_RECENT: 'v:9', // A host vote was held recently
37
+ HOST_VOTE_NO_SUCCESSOR: 'v:10', // No other player can host
38
+ HOST_VOTE_ACTIVE: 'v:11', // A host vote is already in progress
39
+ HOST_VOTE_UNAVAILABLE: 'v:12', // A host vote is not possible right now
40
+ HOST_VOTE_PASSED: 'v:13', // Vote to change host passed ({0}/{1})
41
+ HOST_VOTE_FAILED: 'v:14', // Vote to change host failed ({0}/{1})
42
+ HOST_VOTE_CANCELLED: 'v:15', // Host vote cancelled
20
43
 
21
44
  MAP_CURRENT: 'm:0', // Current map: {0}
22
45
  MAP_NEXT: 'm:1', // Next map: {0}
@@ -26,7 +49,10 @@ const MESSAGE_CODES = {
26
49
 
27
50
  NAME_INVALID: 'n:0', // Invalid name
28
51
  NAME_CHANGED: 'n:1', // {0} changed name to {1}
29
- };
52
+ });
53
+
54
+ // изменяемый реестр: движковые коды + игровые из registerCodes
55
+ const MESSAGE_CODES = { ...ENGINE_MESSAGE_CODES };
30
56
 
31
57
  /**
32
58
  * Регистрирует игровые коды системных сообщений (merge в реестр движка).
@@ -12,6 +12,7 @@ class HumanParticipant extends Participant {
12
12
  socketId,
13
13
  watchedGameId,
14
14
  token = null,
15
+ identityName = null,
15
16
  }) {
16
17
  super({ gameId, name, model, team, teamId });
17
18
 
@@ -27,6 +28,15 @@ class HumanParticipant extends Participant {
27
28
  this.pendingShake = null;
28
29
  this.lastActionTime = clock.now();
29
30
  this.lastInputSeq = 0; // номер последнего обработанного ввода (предикшен)
31
+
32
+ // возобновление сессии (host-migration этап 4). identityName — ник от
33
+ // стратегии идентичности до разведения дублей (#2): с ним сверяется
34
+ // токен RESUME_REQUEST. resumeKey — секрет места (выдаётся на входе в
35
+ // матч, не логируется). detachedAt — транспорт оборвался, место ждёт
36
+ // возврата (null — подключён)
37
+ this.identityName = identityName ?? name;
38
+ this.resumeKey = null;
39
+ this.detachedAt = null;
30
40
  }
31
41
 
32
42
  get isNetworked() {
@@ -64,6 +64,7 @@ class ParticipantManager {
64
64
  socketId,
65
65
  watchedGameId: this._activePlayersList[0] || null,
66
66
  token: params.token,
67
+ identityName: params.name,
67
68
  });
68
69
 
69
70
  this._participants.set(gameId, participant);
@@ -93,8 +94,14 @@ class ParticipantManager {
93
94
  }
94
95
 
95
96
  // восстанавливает человека с исходным gameId (эстафета Worker'ов, Этап 5.2);
96
- // занятый id или неизвестная команда — null (запись пропускается)
97
- restoreHuman({ gameId, socketId, name, model, team, teamId }) {
97
+ // занятый id или неизвестная команда — null (запись пропускается).
98
+ // Поля личности (ник входа, секрет места, цвет ника, токен) переносятся
99
+ // всегда; игровое состояние (статус, камера, слот респауна, номер ввода) —
100
+ // только при full: восстановлении посреди раунда (host-migration этап 5),
101
+ // иначе раунд всё равно начнётся заново
102
+ restoreHuman(record, { full = false } = {}) {
103
+ const { gameId, socketId, name, model, team, teamId } = record;
104
+
98
105
  if (this._participants.has(gameId) || !this._teamSizes[team]) {
99
106
  return null;
100
107
  }
@@ -107,8 +114,20 @@ class ParticipantManager {
107
114
  teamId,
108
115
  socketId,
109
116
  watchedGameId: this._activePlayersList[0] || null,
117
+ token: record.token ?? null,
118
+ identityName: record.identityName ?? null,
110
119
  });
111
120
 
121
+ participant.chatColor = record.chatColor ?? null;
122
+ participant.resumeKey = record.resumeKey ?? null;
123
+
124
+ if (full) {
125
+ this._applyPlayState(participant, record);
126
+ participant.isWatching = record.isWatching !== false;
127
+ participant.watchedGameId = record.watchedGameId ?? null;
128
+ participant.lastInputSeq = record.lastInputSeq >>> 0;
129
+ }
130
+
112
131
  this._participants.set(gameId, participant);
113
132
  this._teamSizes[team].add(gameId);
114
133
 
@@ -117,7 +136,9 @@ class ParticipantManager {
117
136
 
118
137
  // восстанавливает scripted-участника с исходным gameId
119
138
  // (эстафета Worker'ов, Этап 5.2)
120
- restoreScripted({ gameId, name, model, team, teamId }) {
139
+ restoreScripted(record, { full = false } = {}) {
140
+ const { gameId, name, model, team, teamId } = record;
141
+
121
142
  if (this._participants.has(gameId) || !this._teamSizes[team]) {
122
143
  return null;
123
144
  }
@@ -130,12 +151,83 @@ class ParticipantManager {
130
151
  teamId,
131
152
  });
132
153
 
154
+ participant.chatColor = record.chatColor ?? null;
155
+
156
+ if (full) {
157
+ this._applyPlayState(participant, record);
158
+ }
159
+
133
160
  this._participants.set(gameId, participant);
134
161
  this._teamSizes[team].add(gameId);
135
162
 
136
163
  return participant;
137
164
  }
138
165
 
166
+ _applyPlayState(participant, record) {
167
+ if (typeof record.status === 'string') {
168
+ participant.status = record.status;
169
+ }
170
+
171
+ participant.respawnIndex = Number.isInteger(record.respawnIndex)
172
+ ? record.respawnIndex
173
+ : null;
174
+ }
175
+
176
+ // ***** контрольная точка (host-migration этап 5) ***** //
177
+
178
+ // участники и топология команд. Токенов здесь нет: точка уезжает по сети
179
+ // к другому игроку, токен — личный секрет участника
180
+ serialize() {
181
+ return {
182
+ humans: this.getHumans().map(p => ({
183
+ gameId: p.gameId,
184
+ name: p.name,
185
+ model: p.model,
186
+ team: p.team,
187
+ teamId: p.teamId,
188
+ status: p.status,
189
+ isReady: p.isReady,
190
+ isWatching: p.isWatching,
191
+ watchedGameId: p.watchedGameId,
192
+ respawnIndex: p.respawnIndex,
193
+ lastInputSeq: p.lastInputSeq,
194
+ chatColor: p.chatColor,
195
+ resumeKey: p.resumeKey,
196
+ identityName: p.identityName,
197
+ })),
198
+ scripted: this.getScripted().map(p => ({
199
+ gameId: p.gameId,
200
+ name: p.name,
201
+ model: p.model,
202
+ team: p.team,
203
+ teamId: p.teamId,
204
+ status: p.status,
205
+ respawnIndex: p.respawnIndex,
206
+ chatColor: p.chatColor,
207
+ })),
208
+ teamSizes: Object.fromEntries(
209
+ Object.entries(this._teamSizes).map(([team, ids]) => [team, [...ids]]),
210
+ ),
211
+ activePlayers: [...this._activePlayersList],
212
+ };
213
+ }
214
+
215
+ // состав команд и список активных из точки — после restoreHuman/
216
+ // restoreScripted; id, не пережившие восстановление, отбрасываются
217
+ restoreTopology({ teamSizes = {}, activePlayers = [] } = {}) {
218
+ for (const [team, ids] of Object.entries(teamSizes)) {
219
+ if (this._teamSizes[team]) {
220
+ this._teamSizes[team] = new Set(
221
+ ids.filter(id => this._participants.has(id)),
222
+ );
223
+ }
224
+ }
225
+
226
+ this._activePlayersList = activePlayers.filter(id =>
227
+ this._participants.has(id),
228
+ );
229
+ }
230
+
139
231
  // полностью удаляет участника из реестра (команда + список активных)
140
232
  remove(gameId) {
141
233
  const participant = this._participants.get(gameId);
@@ -181,9 +273,10 @@ class ParticipantManager {
181
273
  return this.getAll().filter(p => p.isScripted);
182
274
  }
183
275
 
184
- // люди, готовые к игре (получатели сетевого кадра)
276
+ // люди, готовые к игре (получатели сетевого кадра); отсоединённые
277
+ // (ждут возобновления, host-migration этап 4) кадров не получают
185
278
  getNetworkedReady() {
186
- return this.getHumans().filter(p => p.isReady);
279
+ return this.getHumans().filter(p => p.isReady && p.detachedAt === null);
187
280
  }
188
281
 
189
282
  // проверяет уникальность имени по всему реестру (люди + scripted)
@@ -37,7 +37,69 @@ class AbstractTimer {
37
37
  };
38
38
 
39
39
  const timerId = clock.setTimeout(wrappedCallback, duration);
40
- this._timers.set(key, { timerId, isInterval });
40
+
41
+ // срок и исходный колбэк — для остатка времени и паузы (контрольная
42
+ // точка хоста, host-migration этап 5)
43
+ this._timers.set(key, {
44
+ timerId,
45
+ isInterval,
46
+ callback,
47
+ endsAt: clock.now() + duration,
48
+ leftMs: null,
49
+ });
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Остаток времени setTimeout-таймера (у стоящего на паузе — замороженный).
55
+ * @protected
56
+ * @param {string} key - ключ таймера
57
+ * @returns {number|null} - миллисекунды; null — таймера нет или интервал
58
+ */
59
+ _timeLeft(key) {
60
+ const entry = this._timers.get(key);
61
+
62
+ if (!entry || entry.isInterval) {
63
+ return null;
64
+ }
65
+
66
+ if (entry.leftMs !== null) {
67
+ return entry.leftMs;
68
+ }
69
+
70
+ return Math.max(0, entry.endsAt - clock.now());
71
+ }
72
+
73
+ /**
74
+ * Ставит setTimeout-таймеры на паузу: срабатывание снимается, остаток
75
+ * запоминается, ключ остаётся (_hasTimer по-прежнему true).
76
+ * @protected
77
+ * @param {function} [filter] - (key) => boolean; без него — все
78
+ */
79
+ _pauseTimers(filter = () => true) {
80
+ for (const [key, entry] of this._timers) {
81
+ if (entry.isInterval || entry.leftMs !== null || !filter(key)) {
82
+ continue;
83
+ }
84
+
85
+ clock.clearTimeout(entry.timerId);
86
+ entry.leftMs = Math.max(0, entry.endsAt - clock.now());
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Снимает с паузы таймеры, поставленные _pauseTimers: каждый дожидается
92
+ * своего остатка.
93
+ * @protected
94
+ * @param {function} [filter] - (key) => boolean; без него — все
95
+ */
96
+ _resumeTimers(filter = () => true) {
97
+ const paused = [...this._timers].filter(
98
+ ([key, entry]) => entry.leftMs !== null && filter(key),
99
+ );
100
+
101
+ for (const [key, entry] of paused) {
102
+ this._startTimer(key, entry.callback, entry.leftMs);
41
103
  }
42
104
  }
43
105
 
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Observer pattern (Publisher)
3
3
  * on: добавляет слушателя (подписчика)
4
+ * off: снимает слушателя
4
5
  * emit: рассылает событие подписчикам
5
6
  */
6
7
  class Publisher {
@@ -29,6 +30,31 @@ class Publisher {
29
30
  });
30
31
  }
31
32
 
33
+ /**
34
+ * Снимает подписчика, добавленного on() с теми же аргументами.
35
+ * @param {string} type - Тип события.
36
+ * @param {Function|string} fn - Функция-обработчик или имя метода.
37
+ * @param {Object} [context] - Контекст, переданный в on().
38
+ */
39
+ off(type, fn, context) {
40
+ const subscribers = this.subs[type];
41
+
42
+ if (!subscribers) {
43
+ return;
44
+ }
45
+
46
+ if (typeof fn !== 'function') {
47
+ fn = context[fn];
48
+ }
49
+
50
+ // новый массив, а не splice: отписка из обработчика посреди emit не
51
+ // должна сдвинуть обход и пропустить соседа
52
+ this.subs[type] = subscribers.filter(
53
+ sub =>
54
+ sub.fn !== fn || (context !== undefined && sub.context !== context),
55
+ );
56
+ }
57
+
32
58
  /**
33
59
  * Рассылает событие указанного типа всем подписчикам.
34
60
  * @param {string} type - Тип события.
@@ -46,6 +46,11 @@ export const ENGINE_CAPABILITIES = createRegistry('engine-capabilities', [
46
46
  // сервис пула `diagnostics` (журнал клиентских ошибок, plan/client-reports);
47
47
  // опционален — игра не пишет его в `requires`
48
48
  { value: 'diagnostics', since: '0.35.0' },
49
+ // контрольная точка хоста посреди раунда (host-migration этап 5):
50
+ // gameConfig.migration.midRound, хуки модулей serializeState/restoreState.
51
+ // Писать в `requires` не нужно: старый движок флаг просто игнорирует, и
52
+ // игра на нём мигрирует мягко
53
+ { value: 'host.migration', since: '0.36.0' },
49
54
  ]);
50
55
 
51
56
  // плоский список имён — то, чем правило контракта B2 проверяет `requires`