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.
- package/bin/vimp-sim.js +19 -1
- package/core/Cargo.toml +1 -1
- package/package.json +1 -1
- package/src/client/components/controller/Controls.js +5 -0
- package/src/client/components/controller/Lobby.js +4 -4
- package/src/client/components/controller/RoomMenu.js +43 -0
- package/src/client/components/controller/Vote.js +18 -2
- package/src/client/components/model/Chat.js +19 -2
- package/src/client/components/model/ClientReports.js +6 -2
- package/src/client/components/model/Controls.js +27 -0
- package/src/client/components/model/Games.js +5 -1
- package/src/client/components/model/Lobby.js +32 -24
- package/src/client/components/model/LobbyAuth.js +20 -4
- package/src/client/components/model/RoomMenu.js +121 -0
- package/src/client/components/model/Stat.js +2 -1
- package/src/client/components/model/Vote.js +122 -4
- package/src/client/components/view/Auth.js +20 -10
- package/src/client/components/view/ClientReports.js +16 -5
- package/src/client/components/view/Games.js +39 -14
- package/src/client/components/view/Lobby.js +90 -43
- package/src/client/components/view/LobbyAuth.js +13 -7
- package/src/client/components/view/Panel.js +1 -2
- package/src/client/components/view/RoomMenu.js +69 -0
- package/src/client/debug.js +24 -1
- package/src/client/lib/FpsMeter.js +46 -0
- package/src/client/lib/JoinRetry.js +73 -0
- package/src/client/lib/diagnostics.js +28 -5
- package/src/client/lib/hostCaps.js +82 -0
- package/src/client/lib/hostUnloadGuard.js +108 -0
- package/src/client/lib/hostVoteCommand.js +92 -0
- package/src/client/lib/pageReload.js +29 -0
- package/src/client/lib/roomLink.js +257 -0
- package/src/client/lib/signalingErrors.js +74 -0
- package/src/client/main.js +676 -451
- package/src/client/network/HiddenHostHealthLog.js +76 -0
- package/src/client/network/HostConnectionManager.js +62 -8
- package/src/client/network/HostController.js +376 -23
- package/src/client/network/HostHealthPolicy.js +307 -0
- package/src/client/network/HostHealthReporter.js +54 -0
- package/src/client/network/HostPrewarm.js +235 -0
- package/src/client/network/LoopbackTransport.js +16 -1
- package/src/client/network/PlannedHandoff.js +366 -0
- package/src/client/network/Promotion.js +420 -0
- package/src/client/network/RoomPeersReporter.js +81 -0
- package/src/client/network/SessionSupervisor.js +730 -0
- package/src/client/network/SignalingClient.js +183 -30
- package/src/client/network/StandbyReceiver.js +287 -0
- package/src/client/network/StandbySender.js +319 -0
- package/src/client/network/TokenHandoffTimer.js +81 -0
- package/src/client/network/WebRtcManager.js +338 -11
- package/src/client/network/policyClose.js +32 -2
- package/src/client/network/standbyChunks.js +185 -0
- package/src/client/session/GuestSession.js +428 -0
- package/src/client/session/HandoffFlow.js +399 -0
- package/src/client/session/HostRole.js +708 -0
- package/src/client/session/Membership.js +179 -0
- package/src/client/session/PromotionFlow.js +325 -0
- package/src/client/session/RouteBoot.js +432 -0
- package/src/client/session/StandbyRole.js +195 -0
- package/src/client/session/hostRoomPrep.js +152 -0
- package/src/client/session/roomContext.js +32 -0
- package/src/client/style.css +83 -22
- package/src/client/views/gameShell.js +7 -0
- package/src/client/views/includes/informer.pug +4 -0
- package/src/client/views/includes/lobby.pug +0 -3
- package/src/client/views/includes/roomMenu.pug +9 -0
- package/src/client/views/index.pug +1 -0
- package/src/config/chatMessages.js +45 -0
- package/src/config/closeCodes.js +1 -1
- package/src/config/env.js +37 -0
- package/src/config/hostDefaults.js +24 -0
- package/src/config/lobby.js +154 -3
- package/src/config/master.js +104 -21
- package/src/config/wsports.js +8 -0
- package/src/devtools/RecordingSocketManager.js +3 -2
- package/src/devtools/ScenarioRunner.js +123 -8
- package/src/devtools/VirtualClient.js +20 -0
- package/src/devtools/contract/format.js +4 -1
- package/src/devtools/contract/index.js +1 -2
- package/src/devtools/contract/rules/a2-package-scripts.js +1 -3
- package/src/devtools/contract/rules/b8-system-messages.js +2 -2
- package/src/devtools/contract/rules/b9-vote-names.js +25 -1
- package/src/devtools/contract/rules/c11-stat-mode.js +4 -1
- package/src/devtools/contract/rules/c6-stat-columns.js +1 -1
- package/src/devtools/contract/rules/c7-key-sets.js +8 -4
- package/src/devtools/contract/rules/d3-snapshot-interp.js +2 -1
- package/src/devtools/contract/rules/e1-sound-pairs.js +3 -1
- package/src/devtools/contract/rules/e4-map-layers.js +13 -2
- package/src/devtools/contract/rules/e5-map-radar-walls.js +7 -1
- package/src/devtools/contract/rules/index.js +38 -5
- package/src/devtools/invariants.js +10 -4
- package/src/devtools/pluginLoader.js +1 -4
- package/src/devtools/report.js +17 -1
- package/src/host/DebugRecorder.js +6 -1
- package/src/host/GameCoreAdapter.js +45 -0
- package/src/host/HostGame.js +928 -59
- package/src/host/PortMachine.js +268 -20
- package/src/host/host.worker.js +222 -16
- package/src/host/meta/SocketManager.js +20 -0
- package/src/host/meta/core/RoundManager.js +89 -14
- package/src/host/meta/modules/Accolades.js +4 -1
- package/src/host/meta/modules/Panel.js +30 -0
- package/src/host/meta/modules/PlayerDataSync.js +255 -46
- package/src/host/meta/modules/RTTManager.js +34 -0
- package/src/host/meta/modules/TimerManager.js +215 -18
- package/src/host/meta/modules/chat/systemMessages.js +29 -3
- package/src/host/meta/player/HumanParticipant.js +10 -0
- package/src/host/meta/player/ParticipantManager.js +98 -5
- package/src/lib/AbstractTimer.js +63 -1
- package/src/lib/Publisher.js +26 -0
- package/src/lib/capabilities.js +5 -0
- package/src/lib/checkpointCodec.js +125 -0
- package/src/lib/clientIp.js +9 -2
- package/src/lib/createHostRuntime.js +95 -6
- package/src/lib/formOptions.js +3 -1
- package/src/lib/gameConfigView.js +10 -0
- package/src/lib/gameId.js +13 -0
- package/src/lib/jwt.js +3 -1
- package/src/lib/loadGamePackage.js +2 -2
- package/src/lib/resumeKey.js +38 -0
- package/src/lib/roomId.js +39 -0
- package/src/lib/roomSettings.js +59 -0
- package/src/lib/validators.js +7 -0
- package/tests/fixtures/generations/gen-api3/host/ScriptedManager.js +2 -3
- package/tests/fixtures/generations/gen-api3/host/fakeCore.js +31 -5
- package/tests/fixtures/generations/gen-api3/manifest.json +1 -3
- package/tests/fixtures/generations/gen-api4/host/ScriptedManager.js +2 -3
- package/tests/fixtures/generations/gen-api4/host/fakeCore.js +31 -5
- package/tests/fixtures/generations/gen-api4/manifest.json +1 -3
- package/tests/fixtures/miniGame/config/client.js +0 -20
- package/tests/fixtures/miniGame/host/ScriptedManager.js +2 -3
- package/tests/fixtures/miniGame/host/fakeCore.js +31 -5
package/src/config/lobby.js
CHANGED
|
@@ -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
|
// живо — и текст называет то единственное, что выводит его из этого
|
package/src/config/master.js
CHANGED
|
@@ -2,7 +2,14 @@ import path from 'path';
|
|
|
2
2
|
import { fileURLToPath } from 'url';
|
|
3
3
|
|
|
4
4
|
// корень репозитория — якорь от расположения файла, не от cwd
|
|
5
|
-
const rootDir = path.resolve(
|
|
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 на комнату (проверенный
|
|
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
|
package/src/config/wsports.js
CHANGED
|
@@ -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(
|
|
18
|
-
.
|
|
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(
|
|
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
|
|
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(
|
|
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 = [
|
|
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
|
}
|