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
@@ -5,6 +5,39 @@ export default {
5
5
  // REST-эндпоинт мастера со списком серверов (GET /servers)
6
6
  serversUrl: '/servers',
7
7
 
8
+ // комната по прямому id (GET /rooms/:roomId, host-migration этап 3):
9
+ // прямая ссылка #/<gameId>/<roomId> проверяется до входа
10
+ roomUrl: roomId => `/rooms/${encodeURIComponent(roomId)}`,
11
+
12
+ // быстрая игра (GET /quickplay/:gameId, ревью F16): лучшая комната игры
13
+ // от мастера вместо всего списка; недоступен — GET /servers?search
14
+ quickPlayUrl: gameId => `/quickplay/${encodeURIComponent(gameId)}`,
15
+
16
+ // быстрая игра по ссылке #/<gameId>: подходящей комнаты нет — создать
17
+ // свою с настройками формы по умолчанию (false — показать лобби этой игры);
18
+ // createDelayMinMs…createDelayMaxMs — случайная пауза перед созданием и
19
+ // повторный GET /servers (host-migration этап 7): гости закрытой комнаты
20
+ // уходят в быструю игру разом, и без паузы каждый создал бы свою
21
+ quickPlay: {
22
+ autoCreate: true,
23
+ createDelayMinMs: 500,
24
+ createDelayMaxMs: 2000,
25
+ },
26
+
27
+ // меню комнаты внутри матча (только лобби-режим, roomMenu.pug)
28
+ roomMenu: {
29
+ elems: {
30
+ // кнопка меню живёт в панели, справа от таблицы
31
+ panelId: 'panel',
32
+ menuId: 'room-menu',
33
+ toggleId: 'room-menu-toggle',
34
+ listId: 'room-menu-list',
35
+ leaveId: 'room-menu-leave',
36
+ handoverId: 'room-menu-handover',
37
+ statusId: 'room-menu-status',
38
+ },
39
+ },
40
+
8
41
  // каталог игр мастера (Этап 6.3, GameCatalog): roomDefaults формы создания
9
42
  // комнаты и ClientPlugin берутся отсюда вместо статической композиции
10
43
  gamesManifestUrl: '/games/manifest.json',
@@ -31,6 +64,10 @@ export default {
31
64
  // (деплой игры мог обновиться независимо от деплоя движка)
32
65
  game: {
33
66
  manifestUrl: gameId => `/games/${gameId}/manifest.json`,
67
+ // манифест конкретной версии: прогрев преемника (host-migration этап 6)
68
+ // поднимает ту версию игры, что крутится в комнате
69
+ versionManifestUrl: (gameId, version) =>
70
+ `/games/${gameId}/${encodeURIComponent(version)}/manifest.json`,
34
71
  },
35
72
 
36
73
  // манифест worker-бандла мастера (Этап 5.2): Worker комнаты создаётся по
@@ -141,6 +178,123 @@ export default {
141
178
  maxDelay: 30000,
142
179
  },
143
180
 
181
+ // установка P2P-соединения с хостом (host-migration этап 4): каналы не
182
+ // открылись за connectTimeoutMs — попытка считается провалившейся;
183
+ // offerRetryMs — пауза перед повтором оффера, отклонённого мастером на
184
+ // время плановой передачи хоста (error migrating, этап 8)
185
+ webrtc: {
186
+ connectTimeoutMs: 10000,
187
+ offerRetryMs: 1000,
188
+ },
189
+
190
+ // миграция хоста (host-migration этап 6; значения — замеры этапа 0):
191
+ // checkpointIntervalMs — период контрольных точек хоста для беты (поток
192
+ // ≤ ~150 КБ/с, serialize ≤ 25 % бюджета тика); standbyChunkBytes — кусок
193
+ // точки в канале standby (≤ минимального maxMessageSize с запасом);
194
+ // standbyHighWaterBytes — bufferedAmount, выше которого периодическая
195
+ // точка пропускается; standbyStatusIntervalMs — как часто бета сообщает
196
+ // мастеру свою последнюю точку (с её возрастом); maxRestoreAgeMs — точку,
197
+ // полученную раньше, преемник не поднимает (promote_failed → холодный
198
+ // старт; больше master:room:checkpointMaxAgeMs на дорогу статуса);
199
+ // standbyReopenDelayMs…standbyReopenMaxDelayMs — экспоненциальная пауза
200
+ // перед повторным открытием канала standby, закрывшегося при живом пире
201
+ // беты; finalWaitMs — сколько бета при плановой
202
+ // передаче (этап 8) ждёт финальную точку замороженного хоста, прежде чем
203
+ // взять последнюю периодическую; handoffSlowMs — нет handoff_go за это
204
+ // время — «медленная связь» (передача продолжается); handoffDeadlineMs —
205
+ // общий дедлайн плановой передачи от handoff_begin (master:room:
206
+ // handoffTimeoutMs + запас на дорогу ответа мастера); deferMaxMs — потолок
207
+ // ожидания границы раунда передачей в игре без migration.midRound (этап 8d);
208
+ // peersReportIntervalMs — период повтора room_peers хоста мастеру (кто
209
+ // подключён по WebRTC; при смене состава отчёт уходит сразу, с дебаунсом);
210
+ // minTokenLifetimeMs — вкладка, чей вход истечёт раньше, не объявляет
211
+ // canHost и отказывается от промоушена (токен предъявляется мастеру
212
+ // посреди матча, продления нет); tokenHandoffLeadMs — за столько до
213
+ // истечения входа хост с бетой сам передаёт роль (плановая передача на
214
+ // границе раунда); tokenHandoffRetryMs — через сколько повторить её, если
215
+ // беты ещё нет, идёт эстафета Worker'ов или передача сорвалась (повторы —
216
+ // до истечения входа); leaveFlushTimeoutMs — сколько «Leave
217
+ // server» хоста без людей ждёт записи очков участников перед закрытием
218
+ // комнаты (HostController.shutdown)
219
+ migration: {
220
+ checkpointIntervalMs: 500,
221
+ standbyChunkBytes: 65536,
222
+ standbyHighWaterBytes: 1024 * 1024,
223
+ standbyStatusIntervalMs: 5000,
224
+ maxRestoreAgeMs: 15000,
225
+ standbyReopenDelayMs: 1000,
226
+ standbyReopenMaxDelayMs: 10000,
227
+ finalWaitMs: 3000,
228
+ handoffSlowMs: 3000,
229
+ handoffDeadlineMs: 10000,
230
+ deferMaxMs: 30000,
231
+ peersReportIntervalMs: 15000,
232
+ minTokenLifetimeMs: 600000,
233
+ tokenHandoffLeadMs: 300000,
234
+ tokenHandoffRetryMs: 5000,
235
+ leaveFlushTimeoutMs: 3000,
236
+
237
+ // автотриггеры передачи (этап 9b, HostHealthPolicy; сэмпл — сообщение
238
+ // health Worker'а раз в ~1 с): мягкая перегрузка — среднее tickRate за
239
+ // overloadWindowMs ниже overloadTickRate (передача ждёт границы раунда);
240
+ // жёсткая — среднее за criticalWindowMs ниже criticalTickRate или
241
+ // lostMs > 0 у lostWindows сэмплов подряд (сразу); отложенная
242
+ // отменяется, когда все сэмплы за recoverWindowMs выше recoverTickRate
243
+ // (гистерезис); hiddenHandoffMs — скрытая вкладка хоста (этап 0:
244
+ // троттлинг Worker'а за 1–3 с); autoHandoffCooldownMs — между
245
+ // авто-передачами вкладки; minHostTenureMs — не отдавать роль, только
246
+ // что её получив; enabled — общий выключатель
247
+ auto: {
248
+ enabled: true,
249
+ overloadTickRate: 100,
250
+ overloadWindowMs: 5000,
251
+ criticalTickRate: 60,
252
+ criticalWindowMs: 3000,
253
+ lostWindows: 3,
254
+ recoverTickRate: 110,
255
+ recoverWindowMs: 5000,
256
+ hiddenHandoffMs: 1500,
257
+ autoHandoffCooldownMs: 90000,
258
+ minHostTenureMs: 30000,
259
+ // этап 9c: host_health мастеру (правило сетевого лага) не чаще
260
+ // hostHealthIntervalMs; гость шлёт свой FPS рендера (caps.fps) раз в
261
+ // fpsReportIntervalMs. Просьбу мастера request_handoff хост
262
+ // выполняет, только если enabled
263
+ hostHealthIntervalMs: 2000,
264
+ fpsReportIntervalMs: 10000,
265
+ },
266
+ },
267
+
268
+ // супервизор сессии гостя (host-migration этап 4, SessionSupervisor):
269
+ // reconnectWindowMs — сколько после обрыва транспорта пытаться вернуться в
270
+ // матч (повторы с бэкоффом reconnectBaseDelayMs…reconnectMaxDelayMs);
271
+ // hostSilenceMs — молчание хоста в игре, после которого транспорт
272
+ // считается мёртвым (кадры идут ~30/с, PING — раз в 3 с);
273
+ // migrationWaitMs — сколько после host_migrating ждать host_changed, затем
274
+ // комната считается закрытой (быструю игру); migrationPollMs — период
275
+ // повторного GET /rooms/:roomId, пока комната по ссылке в 'migrating';
276
+ // host_migrating.waitMs мастера (сколько он ещё ищет преемника) ожидание
277
+ // продлевает, но не укорачивает; linkWaitMaxMs — крайний срок ожидания
278
+ // комнаты по ссылке, пока она меняет хоста; resumeSilenceGraceMs — фора
279
+ // сторожку тишины после возобновления до первого кадра: восстановленный
280
+ // матч стоит до hostDefaults.resumeWaitMs, ожидая остальных. Связки с
281
+ // таймингами мастера и Worker'а — tests/config/migrationTimings.test.js;
282
+ // joinRetryWindowMs — сколько гость повторяет join_room на unknownRoom
283
+ // после рестарта мастера, пока хост не вернёт комнату reclaim_host (больше
284
+ // reconnect.maxDelay — бэкоффа сигналинга хоста — с запасом на его
285
+ // reclaim_host)
286
+ session: {
287
+ reconnectWindowMs: 15000,
288
+ reconnectBaseDelayMs: 500,
289
+ reconnectMaxDelayMs: 4000,
290
+ hostSilenceMs: 3000,
291
+ migrationWaitMs: 40000,
292
+ migrationPollMs: 1000,
293
+ linkWaitMaxMs: 90000,
294
+ resumeSilenceGraceMs: 3000,
295
+ joinRetryWindowMs: 45000,
296
+ },
297
+
144
298
  // приёмник выгрузок отладочного контура (этап 6 плана plan/done/ai-debug):
145
299
  // маршрут поднимается мастером только в dev, в проде вернёт 404
146
300
  debugReportUrl: '/debug/report',
@@ -163,7 +317,6 @@ export default {
163
317
  searchId: 'lobby-search',
164
318
  moreId: 'lobby-more',
165
319
  emptyId: 'lobby-empty',
166
- nameId: 'lobby-name',
167
320
  hostBtnId: 'lobby-host',
168
321
  // строка отказа под кнопкой: загрузка ClientPlugin выбранной игры может
169
322
  // не удаться, и лобби обязано остаться рабочим
@@ -327,8 +480,6 @@ export default {
327
480
  // раунда-карты/огонь по своим/карта по умолчанию — из roomDefaults
328
481
  // манифеста активной игры (Этап 6.3), не бандлятся здесь
329
482
  create: {
330
- defaultName: 'My Server',
331
-
332
483
  // каталог платформы пуст: реестр ещё ничего не одобрил либо модератор
333
484
  // снял с раздачи последнюю игру. Комнату создавать не на чем, но лобби
334
485
  // живо — и текст называет то единственное, что выводит его из этого
@@ -2,7 +2,14 @@ import path from 'path';
2
2
  import { fileURLToPath } from 'url';
3
3
 
4
4
  // корень репозитория — якорь от расположения файла, не от cwd
5
- const rootDir = path.resolve(fileURLToPath(import.meta.url), '..', '..', '..', '..', '..');
5
+ const rootDir = path.resolve(
6
+ fileURLToPath(import.meta.url),
7
+ '..',
8
+ '..',
9
+ '..',
10
+ '..',
11
+ '..',
12
+ );
6
13
 
7
14
  export default {
8
15
  name: 'VIMP Master Server',
@@ -66,14 +73,106 @@ export default {
66
73
 
67
74
  // ограничения регистрируемых комнат
68
75
  host: {
69
- maxNameLength: 30, // длина имени комнаты
70
76
  // санитарная рамка вместимости комнаты для случая, когда игра комнаты
71
77
  // неизвестна мастеру (gameId: null у старых хостов или id не из
72
78
  // каталога). Потолок известной игры задаёт её манифест
73
79
  // (roomDefaults.maxPlayers) — движок его не ограничивает
74
80
  maxPlayersLimit: 8,
75
- heartbeatTimeout: 30000, // нет heartbeat дольше — комната удаляется
76
- sweepInterval: 10000, // период проверки протухших комнат
81
+ heartbeatTimeout: 30000, // нет heartbeat дольше — хост потерян
82
+ sweepInterval: 10000, // период уборки реестра комнат
83
+ },
84
+
85
+ // жизнь комнаты (host-migration, этап 2): комната живёт, пока в ней есть
86
+ // участник
87
+ room: {
88
+ // отсоединённый участник ещё считается в комнате (реконнект сигналинга)
89
+ memberGraceMs: 15000,
90
+ // обрыв WS хоста запускает миграцию сразу (reclaim_host до регистрации
91
+ // преемника её отменяет, этап 7.0); если повысить некого — комната
92
+ // ждёт reclaim_host столько, потом уборка мигрирует или закрывает её
93
+ hostReclaimGraceMs: 10000,
94
+ // потолок строки карточки комнаты в лобби (gameConfig.lobbyInfo игры)
95
+ maxInfoLength: 48,
96
+ // RTT участников (host-migration этап 6): ws.ping() каждой сессии с
97
+ // этим периодом; EMA RTT и джиттера — единая линейка связности для
98
+ // хоста и кандидатов
99
+ rttProbeIntervalMs: 5000,
100
+ // сессия без pong дольше — terminate(): «тихо умерший» хост находится
101
+ // быстрее, чем по heartbeatTimeout
102
+ wsDeadAfterMs: 12000,
103
+ // преемник (бета): кандидат должен пробыть в комнате не меньше этого
104
+ minMemberAgeMs: 10000,
105
+ // плановый пересчёт преемника (помимо join/leave/member_update)
106
+ successorReviewMs: 15000,
107
+ // гистерезис смены беты: лучший кандидат держит score не хуже
108
+ // successorSwitchRatio × score текущего дольше successorSwitchSustainMs
109
+ successorSwitchSustainMs: 30000,
110
+ successorSwitchRatio: 0.65,
111
+ // аварийная миграция (этап 7): бета — преемник с точкой, если она
112
+ // получила её не раньше checkpointMaxAgeMs назад (по standby_status.ageMs;
113
+ // статус идёт раз в 5 с — окно больше двух периодов), иначе cold-промоушен любого
114
+ // способного; преемник не занял комнату за promotionTimeoutMs
115
+ // (coldPromotionTimeoutMs — он перезагружает страницу) — следующий
116
+ checkpointMaxAgeMs: 12000,
117
+ promotionTimeoutMs: 10000,
118
+ coldPromotionTimeoutMs: 25000,
119
+ // отчёты host_unreachable: хост не ответил на probe за probeTimeoutMs —
120
+ // потерян; ответил, но за reportWindowMs отчиталась половина гостей —
121
+ // принудительная миграция, не чаще forcedMigrationCooldownMs на комнату.
122
+ // Гости и отчёты считаются по аккаунтам; кворум — не меньше
123
+ // minUnreachableReporters разных пользователей (в паре с плохим P2P один
124
+ // гость не снимает хоста — иначе роль «пинг-понгом» ходит по кругу)
125
+ probeTimeoutMs: 2000,
126
+ reportWindowMs: 5000,
127
+ forcedMigrationCooldownMs: 30000,
128
+ minUnreachableReporters: 2,
129
+ // плановая передача (этап 8): преемник не занял комнату за
130
+ // handoffTimeoutMs — передача отменяется, хост размораживает матч
131
+ handoffTimeoutMs: 8000,
132
+ // host_migrating.waitMs — дедлайн текущей попытки промоушена (передачи)
133
+ // плюс migrationNoticeMarginMs: столько гость ждёт host_changed
134
+ migrationNoticeMarginMs: 5000,
135
+ // автотриггеры (этап 9c). Сетевой лаг хоста: медиана его RTT до гостей
136
+ // (host_health) выше lagRttThresholdMs непрерывно lagSustainMs, а score
137
+ // беты лучше score хоста хотя бы на lagImprovementRatio — хосту
138
+ // request_handoff. Не раньше autoMigrationCooldownMs с прошлой
139
+ // авто-смены хоста в комнате (overload/hidden/network — кулдаун общий)
140
+ // и с получения роли текущим хостом
141
+ lagRttThresholdMs: 250,
142
+ lagSustainMs: 10000,
143
+ lagImprovementRatio: 0.35,
144
+ autoMigrationCooldownMs: 90000,
145
+ // бета — не ниже этого FPS рендера (caps.fps гостя; неизвестный FPS не
146
+ // отсеивает). Аварийный промоушен порог не применяет
147
+ minSuccessorFps: 30,
148
+ // голосование «Change host» (этап 10, /changehost; только лобби-режим):
149
+ // голоса считает мастер. Длительность голосования; повторный старт в
150
+ // комнате и тем же пользователем — не раньше кулдаунов; прошедшее
151
+ // голосование просит хоста отдать роль, не начал за voteForceAfterMs —
152
+ // принудительная миграция; снятый хост не бета и не хост комнаты
153
+ // demotedCooldownMs (кроме случая, когда больше принять некому).
154
+ // Один голос на аккаунт; голосует и начинает участник, пробывший в
155
+ // комнате не меньше minVoterAgeMs (свежие вкладки не накручивают голоса).
156
+ // После смены хоста голосование ждёт первого room_peers нового хоста —
157
+ // до него подтверждёнными считаются все, и фантомы попали бы в число
158
+ // голосующих, — но не дольше peersReportGraceMs (больше
159
+ // lobby.migration.peersReportIntervalMs: потерянный первый отчёт
160
+ // повторится внутри окна; хост старше room_peers отчёта не пришлёт)
161
+ vote: {
162
+ hostVoteDurationMs: 15000,
163
+ roomVoteCooldownMs: 120000,
164
+ userStartCooldownMs: 60000,
165
+ voteForceAfterMs: 5000,
166
+ demotedCooldownMs: 600000,
167
+ minVoterAgeMs: 30000,
168
+ peersReportGraceMs: 20000,
169
+ },
170
+ // GET /rooms/:roomId (прямая ссылка, этап 3) на IP: открытие ссылки —
171
+ // один запрос, лимит только против перебора roomId
172
+ lookupRateLimit: {
173
+ limit: 20,
174
+ windowMs: 1000,
175
+ },
77
176
  },
78
177
 
79
178
  // GET /auth/leaderboard (code review L2): TTL кэша на мастере (мс) и
@@ -97,7 +196,7 @@ export default {
97
196
  // стороне хоста, а мастер держит потолок для сломанного или злонамеренного
98
197
  // сервера, который этот интервал обошёл
99
198
  playerData: {
100
- // PUT /auth/rank + /auth/state на комнату (проверенный hostId) в минуту.
199
+ // PUT /auth/rank + /auth/state на комнату (проверенный roomId) в минуту.
101
200
  // Честная комната на 32 при lobbyConfig.playerData.minFlushInterval в
102
201
  // 5 минут пишет 64 запроса за эти 5 минут, то есть ~13/мин; остальное —
103
202
  // запас на срочные границы (уход участника обходит интервал), и его
@@ -110,22 +209,6 @@ export default {
110
209
  maxGameScore: 10000,
111
210
  },
112
211
 
113
- // рейтинг хостера комнаты (server-rating этап 2, plan/server-rating/
114
- // stage_2.md): /like·/unlike гостей комнаты вместо соц-модерации /ban.
115
- // Дефолт движка для всех игр; auth-сервис хранит и клампит рейтинг
116
- // персистентно и глобально (нужно и для аннулирования rank/skills, этап 4) —
117
- // это значение зеркалируется в packages/auth/src/config/auth.js:rating,
118
- // фактический кламп/blocked считает auth, не мастер
119
- rating: {
120
- min: -10,
121
- max: 10,
122
- blockAt: -10,
123
- // период опроса auth за актуальным рейтингом активных хостеров (этап 3,
124
- // stage_3.md) — держит кэш GET /servers свежим (счёт мог измениться на
125
- // другом мастере), голос/регистрация на этом мастере обновляют кэш сразу
126
- refreshInterval: 30000,
127
- },
128
-
129
212
  // заголовки безопасности (гигиена среды, Этап 5.4). CSP на статику/.wasm в
130
213
  // проде ставит Nginx (см. docs/deployment.md) — здесь single source of truth
131
214
  // политики; мастер применяет её к своим ответам только в проде (в dev CSP
@@ -31,6 +31,10 @@ export default {
31
31
  // места участников в глобальном топе (snakes-v3 этап 4): косметика
32
32
  // для parts игры, в снапшот не идёт
33
33
  ACCOLADES_DATA: 18,
34
+ // возобновление сессии (host-migration этап 4): секрет участника
35
+ // { resumeKey, gameId } и ответ на RESUME_REQUEST
36
+ SESSION_DATA: 19,
37
+ RESUME_RESULT: 20,
34
38
  },
35
39
  // порты получения данных от клиента
36
40
  client: {
@@ -43,5 +47,9 @@ export default {
43
47
  CHAT_DATA: 6,
44
48
  VOTE_DATA: 7,
45
49
  PONG: 8,
50
+ // возобновление места после обрыва транспорта (host-migration этап 4)
51
+ RESUME_REQUEST: 9,
52
+ // игрок уходит сам: хост снимает его сразу, без ожидания возврата
53
+ LEAVE: 10,
46
54
  },
47
55
  };
@@ -14,8 +14,9 @@ import SocketManager from '../host/meta/SocketManager.js';
14
14
 
15
15
  // имена отправителей берутся из прототипа: ручной список неизбежно отстаёт
16
16
  // от боевого класса, что уже случалось
17
- const SENDER_METHODS = Object.getOwnPropertyNames(SocketManager.prototype)
18
- .filter(name => name.startsWith('send'));
17
+ const SENDER_METHODS = Object.getOwnPropertyNames(
18
+ SocketManager.prototype,
19
+ ).filter(name => name.startsWith('send'));
19
20
 
20
21
  class RecordingSocketManager extends SocketManager {
21
22
  /**
@@ -1,5 +1,7 @@
1
1
  import { createHostRuntime } from '../lib/createHostRuntime.js';
2
+ import { encodeCheckpoint, decodeCheckpoint } from '../lib/checkpointCodec.js';
2
3
  import { offlinePlayerData } from '../lib/offlinePlayerData.js';
4
+ import { resumeKeysEqual } from '../lib/resumeKey.js';
3
5
  import VirtualClock, { flushMicrotasks } from './VirtualClock.js';
4
6
  import RecordingSocketManager from './RecordingSocketManager.js';
5
7
  import VirtualClient from './VirtualClient.js';
@@ -51,7 +53,9 @@ export function parseScenario(raw) {
51
53
  }
52
54
 
53
55
  if (raw.version !== 1) {
54
- throw new Error(`scenario: unsupported version ${raw.version} (expected 1)`);
56
+ throw new Error(
57
+ `scenario: unsupported version ${raw.version} (expected 1)`,
58
+ );
55
59
  }
56
60
 
57
61
  if (!Array.isArray(raw.participants) || !raw.participants.length) {
@@ -77,6 +81,15 @@ export function parseScenario(raw) {
77
81
  );
78
82
  }
79
83
 
84
+ const checkpointEvery = raw.checkpointEvery ?? null;
85
+
86
+ if (
87
+ checkpointEvery !== null &&
88
+ !(Number.isFinite(checkpointEvery) && checkpointEvery > 0)
89
+ ) {
90
+ throw new Error('scenario: checkpointEvery must be a positive number (ms)');
91
+ }
92
+
80
93
  return {
81
94
  version: 1,
82
95
  seed: raw.seed ?? 1,
@@ -96,6 +109,9 @@ export function parseScenario(raw) {
96
109
  ticks: raw.ticks ?? 600,
97
110
  dumpTicks: raw.dumpTicks ?? null,
98
111
  room: raw.room ?? {},
112
+ // смена хоста через контрольную точку каждые N мс матча (host-migration
113
+ // этап 5); то же разово — шаг таймлайна { op: 'checkpointRestore' }
114
+ checkpointEvery,
99
115
  };
100
116
  }
101
117
 
@@ -169,7 +185,13 @@ async function execute(scenario, plugin, virtualClock, { captureFrames }) {
169
185
  },
170
186
  };
171
187
 
172
- const runtime = await createHostRuntime(room, {
188
+ const hostOptions = {
189
+ onMapChange: mapName =>
190
+ mapChanges.push({ tick: currentTick, map: mapName }),
191
+ playerDataFetch: emptyProfileFetch,
192
+ };
193
+
194
+ let runtime = await createHostRuntime(room, {
173
195
  loadHostPlugin: () => plugin.hostPlugin,
174
196
  // транспорт собирается той же фабрикой, что и боевой: наследник обязан
175
197
  // получить те же порты и ту же игровую параметризацию
@@ -182,11 +204,7 @@ async function execute(scenario, plugin, virtualClock, { captureFrames }) {
182
204
  return socketManager;
183
205
  },
184
206
  overrideGameConfig: game => mergeConfig(game, scenario.config),
185
- hostOptions: {
186
- onMapChange: mapName =>
187
- mapChanges.push({ tick: currentTick, map: mapName }),
188
- playerDataFetch: emptyProfileFetch,
189
- },
207
+ hostOptions,
190
208
  });
191
209
 
192
210
  const { clientCfg, game, seed } = runtime;
@@ -194,6 +212,78 @@ async function execute(scenario, plugin, virtualClock, { captureFrames }) {
194
212
 
195
213
  host = runtime.host;
196
214
 
215
+ // смены хоста через контрольную точку (host-migration этап 5)
216
+ const checkpointRestores = [];
217
+
218
+ // Старый хост замораживается и отдаёт финальную точку; точка проходит
219
+ // боевой кодек (сжатие → распаковка), из неё поднимается НОВЫЙ runtime на
220
+ // том же транспорте, и каждый клиент возвращается в своё место RESUME'ом —
221
+ // ровно как к преемнику в проде. Сбой любого шага — исключение: прогон
222
+ // красный, а не тихо продолжившийся на старом хосте
223
+ const restoreFromCheckpoint = async tick => {
224
+ let taken = null;
225
+
226
+ host.setCheckpointSink(checkpoint => {
227
+ taken = checkpoint;
228
+ });
229
+ host.freeze();
230
+ host.requestCheckpoint({ final: true });
231
+ host.setCheckpointSink(null);
232
+
233
+ if (!taken) {
234
+ throw new Error(`scenario: no checkpoint taken at tick ${tick}`);
235
+ }
236
+
237
+ const bytes = await encodeCheckpoint(taken.meta, taken.core);
238
+ const checkpoint = await decodeCheckpoint(bytes);
239
+
240
+ resetHostSingletons();
241
+
242
+ runtime = await createHostRuntime(room, {
243
+ loadHostPlugin: () => plugin.hostPlugin,
244
+ createSocketManager: () => socketManager,
245
+ overrideGameConfig: next => mergeConfig(next, scenario.config),
246
+ hostOptions: { ...hostOptions, checkpoint, seqFloor: taken.meta.seq },
247
+ });
248
+ host = runtime.host;
249
+
250
+ for (const client of clients.values()) {
251
+ const target = host.getResumeTarget(client.gameId);
252
+ const request = client.resumeRequest();
253
+
254
+ if (!target || !resumeKeysEqual(request.resumeKey, target.resumeKey)) {
255
+ throw new Error(
256
+ `scenario: participant ${client.gameId} cannot resume after ` +
257
+ `checkpoint at tick ${tick}`,
258
+ );
259
+ }
260
+
261
+ host.resumeUser(client.gameId, client.socketId, null);
262
+ }
263
+
264
+ host.startAfterRestore();
265
+ checkpointRestores.push({
266
+ tick,
267
+ mode: checkpoint.meta.mode,
268
+ bytes: bytes.byteLength,
269
+ });
270
+
271
+ await flushMicrotasks();
272
+ };
273
+
274
+ const checkpointTicks = new Set();
275
+
276
+ if (scenario.checkpointEvery) {
277
+ for (let tick = 1; tick <= scenario.ticks; tick += 1) {
278
+ if (
279
+ Math.floor((tick * stepMs) / scenario.checkpointEvery) >
280
+ Math.floor(((tick - 1) * stepMs) / scenario.checkpointEvery)
281
+ ) {
282
+ checkpointTicks.add(tick);
283
+ }
284
+ }
285
+ }
286
+
197
287
  // очередь операций сценария, разложенная по номеру тика: ввод хост
198
288
  // применяет синхронно при приходе сообщения (HostGame.updateKeys), очереди
199
289
  // у него нет — значит сценарий обязан задавать именно границу тика
@@ -216,7 +306,16 @@ async function execute(scenario, plugin, virtualClock, { captureFrames }) {
216
306
  currentTick = tick;
217
307
  socketManager.tick = tick;
218
308
 
309
+ if (checkpointTicks.has(tick)) {
310
+ await restoreFromCheckpoint(tick);
311
+ }
312
+
219
313
  for (const op of opsByTick.get(tick) ?? []) {
314
+ if (op.op === 'checkpointRestore') {
315
+ await restoreFromCheckpoint(tick);
316
+ continue;
317
+ }
318
+
220
319
  await applyOp(op, {
221
320
  host,
222
321
  clientCfg,
@@ -269,6 +368,7 @@ async function execute(scenario, plugin, virtualClock, { captureFrames }) {
269
368
  seed,
270
369
  stepMs,
271
370
  host,
371
+ checkpointRestores,
272
372
  core: runtime.core,
273
373
  game,
274
374
  clientCfg,
@@ -336,6 +436,11 @@ function routeFrame(frame, clients, pendingShots, virtualClock, host) {
336
436
  return;
337
437
  }
338
438
 
439
+ if (frame.method === 'sendSessionData') {
440
+ client.noteSession(frame.args[0]);
441
+ return;
442
+ }
443
+
339
444
  const bucket = RECORDED_PORTS[frame.method];
340
445
 
341
446
  if (bucket) {
@@ -463,7 +568,9 @@ async function joinParticipant(op, ctx) {
463
568
  const participant = scenario.participants.find(p => p.id === op.who);
464
569
 
465
570
  if (!participant) {
466
- throw new Error(`scenario: join references unknown participant '${op.who}'`);
571
+ throw new Error(
572
+ `scenario: join references unknown participant '${op.who}'`,
573
+ );
467
574
  }
468
575
 
469
576
  const socketId = participant.socketId ?? `sock-${participant.id}`;
@@ -502,6 +609,11 @@ async function joinParticipant(op, ctx) {
502
609
  host.sendMap(gameId);
503
610
  host.mapReady(gameId);
504
611
  host.firstShotReady(gameId);
612
+
613
+ // секрет места — как SESSION_DATA порт-машины лобби на первом кадре:
614
+ // с ним клиент вернётся к новому хосту после смены (checkpointRestore)
615
+ client.noteSession({ resumeKey: host.issueResumeKey(gameId), gameId });
616
+
505
617
  byParticipant.set(op.who, { socketId, gameId });
506
618
  participantLog.push({
507
619
  who: op.who,
@@ -550,6 +662,7 @@ function buildReport(ctx) {
550
662
  seed,
551
663
  stepMs,
552
664
  host,
665
+ checkpointRestores,
553
666
  core,
554
667
  game,
555
668
  clientCfg,
@@ -599,6 +712,8 @@ function buildReport(ctx) {
599
712
  durationMs: stepMs * scenario.ticks,
600
713
  currentMap: host.currentMap,
601
714
  mapChanges,
715
+ // смены хоста через контрольную точку: тик, режим, размер сжатой точки
716
+ checkpointRestores,
602
717
  participants: [...byParticipant.entries()].map(([id, entry]) => ({
603
718
  id,
604
719
  ...entry,
@@ -98,6 +98,10 @@ class VirtualClient {
98
98
  this.frameCount = 0;
99
99
  this.decodeErrors = [];
100
100
 
101
+ // секрет места (SESSION_DATA): с ним клиент возвращается к новому хосту
102
+ // после смены (RESUME, host-migration этапы 4–5)
103
+ this.resumeKey = null;
104
+
101
105
  // материал проверок инвариантов (devtools/invariants.js): что реально
102
106
  // доехало до сцены за весь прогон, а не только к финальному тику —
103
107
  // сущность могла появиться и исчезнуть между дампами
@@ -116,6 +120,22 @@ class VirtualClient {
116
120
  this.truncated = { nonFinite: 0, hotLayoutErrors: 0, divergence: 0 };
117
121
  }
118
122
 
123
+ // SESSION_DATA { resumeKey, gameId }: хост выдал (или ротировал) секрет
124
+ noteSession(data) {
125
+ if (data && typeof data.resumeKey === 'string') {
126
+ this.resumeKey = data.resumeKey;
127
+ }
128
+
129
+ if (data && data.gameId !== undefined) {
130
+ this.gameId = data.gameId;
131
+ }
132
+ }
133
+
134
+ // тело RESUME_REQUEST — то же, что шлёт браузерный клиент
135
+ resumeRequest() {
136
+ return { v: 1, gameId: this.gameId, resumeKey: this.resumeKey };
137
+ }
138
+
119
139
  // байты кадра из sendShot — ровно то, что ушло бы в data channel
120
140
  pushFrame(bytes, localNow) {
121
141
  const data = toUint8(bytes);
@@ -13,7 +13,10 @@ const MARKS = { [PASS]: '✅', [FAIL]: '❌', [SKIP]: '⏭️' };
13
13
  */
14
14
  export function formatContract(report, { quiet = false } = {}) {
15
15
  const { summary } = report;
16
- const lines = [`# Contract report — ${report.game.id ?? '(unknown game)'}`, ''];
16
+ const lines = [
17
+ `# Contract report — ${report.game.id ?? '(unknown game)'}`,
18
+ '',
19
+ ];
17
20
 
18
21
  lines.push(`- game: \`${report.game.dir}\``);
19
22
  lines.push(`- engine API: v${report.game.engineApi}`);
@@ -86,7 +86,6 @@ export function runRules(ctx, ruleList = rules) {
86
86
  */
87
87
  export function hasBlockingFailure(report, strict = false) {
88
88
  return report.results.some(
89
- result =>
90
- result.status === FAIL && (strict || result.level === ERROR),
89
+ result => result.status === FAIL && (strict || result.level === ERROR),
91
90
  );
92
91
  }