vimp-engine 0.35.5 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/bin/vimp-sim.js +19 -1
  2. package/core/Cargo.toml +1 -1
  3. package/package.json +1 -1
  4. package/src/client/components/controller/Controls.js +5 -0
  5. package/src/client/components/controller/Lobby.js +4 -4
  6. package/src/client/components/controller/RoomMenu.js +43 -0
  7. package/src/client/components/controller/Vote.js +18 -2
  8. package/src/client/components/model/ClientReports.js +6 -2
  9. package/src/client/components/model/Controls.js +27 -0
  10. package/src/client/components/model/Games.js +5 -1
  11. package/src/client/components/model/Lobby.js +32 -24
  12. package/src/client/components/model/LobbyAuth.js +20 -4
  13. package/src/client/components/model/RoomMenu.js +121 -0
  14. package/src/client/components/model/Stat.js +2 -1
  15. package/src/client/components/model/Vote.js +122 -4
  16. package/src/client/components/view/Auth.js +20 -10
  17. package/src/client/components/view/ClientReports.js +16 -5
  18. package/src/client/components/view/Games.js +39 -14
  19. package/src/client/components/view/Lobby.js +90 -43
  20. package/src/client/components/view/LobbyAuth.js +13 -7
  21. package/src/client/components/view/Panel.js +1 -2
  22. package/src/client/components/view/RoomMenu.js +69 -0
  23. package/src/client/debug.js +24 -1
  24. package/src/client/lib/FpsMeter.js +46 -0
  25. package/src/client/lib/JoinRetry.js +73 -0
  26. package/src/client/lib/diagnostics.js +28 -5
  27. package/src/client/lib/hostCaps.js +82 -0
  28. package/src/client/lib/hostUnloadGuard.js +108 -0
  29. package/src/client/lib/hostVoteCommand.js +92 -0
  30. package/src/client/lib/pageReload.js +29 -0
  31. package/src/client/lib/roomLink.js +257 -0
  32. package/src/client/lib/signalingErrors.js +74 -0
  33. package/src/client/main.js +674 -451
  34. package/src/client/network/HiddenHostHealthLog.js +76 -0
  35. package/src/client/network/HostConnectionManager.js +62 -8
  36. package/src/client/network/HostController.js +376 -23
  37. package/src/client/network/HostHealthPolicy.js +307 -0
  38. package/src/client/network/HostHealthReporter.js +54 -0
  39. package/src/client/network/HostPrewarm.js +235 -0
  40. package/src/client/network/LoopbackTransport.js +16 -1
  41. package/src/client/network/PlannedHandoff.js +366 -0
  42. package/src/client/network/Promotion.js +420 -0
  43. package/src/client/network/RoomPeersReporter.js +81 -0
  44. package/src/client/network/SessionSupervisor.js +730 -0
  45. package/src/client/network/SignalingClient.js +183 -30
  46. package/src/client/network/StandbyReceiver.js +287 -0
  47. package/src/client/network/StandbySender.js +319 -0
  48. package/src/client/network/TokenHandoffTimer.js +81 -0
  49. package/src/client/network/WebRtcManager.js +338 -11
  50. package/src/client/network/policyClose.js +32 -2
  51. package/src/client/network/standbyChunks.js +185 -0
  52. package/src/client/session/GuestSession.js +428 -0
  53. package/src/client/session/HandoffFlow.js +399 -0
  54. package/src/client/session/HostRole.js +708 -0
  55. package/src/client/session/Membership.js +179 -0
  56. package/src/client/session/PromotionFlow.js +325 -0
  57. package/src/client/session/RouteBoot.js +432 -0
  58. package/src/client/session/StandbyRole.js +195 -0
  59. package/src/client/session/hostRoomPrep.js +152 -0
  60. package/src/client/session/roomContext.js +32 -0
  61. package/src/client/style.css +83 -22
  62. package/src/client/views/gameShell.js +7 -0
  63. package/src/client/views/includes/informer.pug +4 -0
  64. package/src/client/views/includes/lobby.pug +0 -3
  65. package/src/client/views/includes/roomMenu.pug +9 -0
  66. package/src/client/views/index.pug +1 -0
  67. package/src/config/closeCodes.js +1 -1
  68. package/src/config/env.js +37 -0
  69. package/src/config/hostDefaults.js +24 -0
  70. package/src/config/lobby.js +154 -3
  71. package/src/config/master.js +104 -21
  72. package/src/config/wsports.js +8 -0
  73. package/src/devtools/RecordingSocketManager.js +3 -2
  74. package/src/devtools/ScenarioRunner.js +123 -8
  75. package/src/devtools/VirtualClient.js +20 -0
  76. package/src/devtools/contract/format.js +4 -1
  77. package/src/devtools/contract/index.js +1 -2
  78. package/src/devtools/contract/rules/a2-package-scripts.js +1 -3
  79. package/src/devtools/contract/rules/b8-system-messages.js +1 -1
  80. package/src/devtools/contract/rules/b9-vote-names.js +25 -1
  81. package/src/devtools/contract/rules/c11-stat-mode.js +4 -1
  82. package/src/devtools/contract/rules/c6-stat-columns.js +1 -1
  83. package/src/devtools/contract/rules/c7-key-sets.js +8 -4
  84. package/src/devtools/contract/rules/d3-snapshot-interp.js +2 -1
  85. package/src/devtools/contract/rules/e1-sound-pairs.js +3 -1
  86. package/src/devtools/contract/rules/e4-map-layers.js +13 -2
  87. package/src/devtools/contract/rules/e5-map-radar-walls.js +7 -1
  88. package/src/devtools/contract/rules/index.js +38 -5
  89. package/src/devtools/invariants.js +10 -4
  90. package/src/devtools/pluginLoader.js +1 -4
  91. package/src/devtools/report.js +17 -1
  92. package/src/host/DebugRecorder.js +6 -1
  93. package/src/host/GameCoreAdapter.js +45 -0
  94. package/src/host/HostGame.js +928 -59
  95. package/src/host/PortMachine.js +268 -20
  96. package/src/host/host.worker.js +222 -16
  97. package/src/host/meta/SocketManager.js +20 -0
  98. package/src/host/meta/core/RoundManager.js +89 -14
  99. package/src/host/meta/modules/Accolades.js +4 -1
  100. package/src/host/meta/modules/Panel.js +30 -0
  101. package/src/host/meta/modules/PlayerDataSync.js +255 -46
  102. package/src/host/meta/modules/RTTManager.js +34 -0
  103. package/src/host/meta/modules/TimerManager.js +215 -18
  104. package/src/host/meta/modules/chat/systemMessages.js +22 -0
  105. package/src/host/meta/player/HumanParticipant.js +10 -0
  106. package/src/host/meta/player/ParticipantManager.js +98 -5
  107. package/src/lib/AbstractTimer.js +63 -1
  108. package/src/lib/Publisher.js +26 -0
  109. package/src/lib/capabilities.js +5 -0
  110. package/src/lib/checkpointCodec.js +125 -0
  111. package/src/lib/clientIp.js +9 -2
  112. package/src/lib/createHostRuntime.js +95 -6
  113. package/src/lib/formOptions.js +3 -1
  114. package/src/lib/gameConfigView.js +10 -0
  115. package/src/lib/gameId.js +13 -0
  116. package/src/lib/jwt.js +3 -1
  117. package/src/lib/loadGamePackage.js +2 -2
  118. package/src/lib/resumeKey.js +38 -0
  119. package/src/lib/roomId.js +39 -0
  120. package/src/lib/roomSettings.js +59 -0
  121. package/src/lib/validators.js +7 -0
  122. package/tests/fixtures/generations/gen-api3/host/ScriptedManager.js +2 -3
  123. package/tests/fixtures/generations/gen-api3/host/fakeCore.js +31 -5
  124. package/tests/fixtures/generations/gen-api3/manifest.json +1 -3
  125. package/tests/fixtures/generations/gen-api4/host/ScriptedManager.js +2 -3
  126. package/tests/fixtures/generations/gen-api4/host/fakeCore.js +31 -5
  127. package/tests/fixtures/generations/gen-api4/manifest.json +1 -3
  128. package/tests/fixtures/miniGame/config/client.js +15 -0
  129. package/tests/fixtures/miniGame/host/ScriptedManager.js +2 -3
  130. package/tests/fixtures/miniGame/host/fakeCore.js +31 -5
@@ -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
  }
@@ -29,8 +29,6 @@ export default {
29
29
  const scripts = ctx.pkg.scripts ?? {};
30
30
  const missing = REQUIRED.filter(name => !scripts[name]);
31
31
 
32
- return verdict(
33
- missing.map(name => `script "${name}" is missing`),
34
- );
32
+ return verdict(missing.map(name => `script "${name}" is missing`));
35
33
  },
36
34
  };
@@ -4,7 +4,7 @@ import { ERROR, skip, verdict } from '../result.js';
4
4
  // реестра: код в зарезервированном диапазоне затирает движковое сообщение
5
5
  // без предупреждения, и вместо «Vote passed» игрок видит игровой текст.
6
6
  // Диапазоны — длины движковых групп (client/config/chat messages).
7
- const RESERVED = { s: 6, v: 5, m: 1, c: 1, n: 1 };
7
+ const RESERVED = { s: 11, v: 15, m: 1, c: 1, n: 1 };
8
8
 
9
9
  export default {
10
10
  id: 'B8',
@@ -8,11 +8,19 @@ import { ERROR, skip, verdict } from '../result.js';
8
8
  const ENGINE_MENU = ['teamChange', 'mapChange'];
9
9
  const ENGINE_TEMPLATES = ['teamChange', 'mapChangeBySystem', 'mapChangeByUser'];
10
10
 
11
+ // Имена с '@' — голосования самого движка (host-migration этап 10:
12
+ // '@changeHost' — окно «Change host?» от мастера). Ответ на голосование с
13
+ // таким именем клиент перехватывает до хоста — игровое голосование под ним
14
+ // не дошло бы до игры никогда
15
+ const ENGINE_PREFIX = '@';
16
+ const isEngineName = name =>
17
+ typeof name === 'string' && name.startsWith(ENGINE_PREFIX);
18
+
11
19
  export default {
12
20
  id: 'B9',
13
21
  name: 'voteNames',
14
22
  level: ERROR,
15
- title: 'custom votes do not reuse the reserved vote names',
23
+ title: 'custom votes do not reuse the reserved vote names or the @ prefix',
16
24
 
17
25
  check(ctx) {
18
26
  const vote = ctx.clientConfig?.modules?.vote?.params;
@@ -25,6 +33,14 @@ export default {
25
33
  const violations = [];
26
34
 
27
35
  for (const name of templates) {
36
+ if (isEngineName(name)) {
37
+ violations.push(
38
+ `vote template "${name}" starts with "${ENGINE_PREFIX}" — names ` +
39
+ `with this prefix are reserved for engine votes`,
40
+ );
41
+ continue;
42
+ }
43
+
28
44
  if (ENGINE_MENU.includes(name) && !ENGINE_TEMPLATES.includes(name)) {
29
45
  violations.push(
30
46
  `vote template "${name}" reuses a reserved vote name — the engine ` +
@@ -36,6 +52,14 @@ export default {
36
52
  for (const entry of vote.menu ?? []) {
37
53
  const name = Array.isArray(entry) ? entry[0] : entry;
38
54
 
55
+ if (isEngineName(name)) {
56
+ violations.push(
57
+ `vote menu entry "${name}" starts with "${ENGINE_PREFIX}" — names ` +
58
+ `with this prefix are reserved for engine votes`,
59
+ );
60
+ continue;
61
+ }
62
+
39
63
  if (!ENGINE_MENU.includes(name) && !templates.includes(name)) {
40
64
  violations.push(
41
65
  `vote menu entry "${name}" has no template — the vote renders empty`,
@@ -33,7 +33,10 @@ export default {
33
33
  const host = ctx.gameConfig.statMode ?? null;
34
34
 
35
35
  if (client === host) {
36
- return verdict([], client ? `both declare '${client}'` : 'the room table');
36
+ return verdict(
37
+ [],
38
+ client ? `both declare '${client}'` : 'the room table',
39
+ );
37
40
  }
38
41
 
39
42
  if (client && !host) {
@@ -100,7 +100,7 @@ export default {
100
100
  // ли игра свои стили под этот список
101
101
  if (params.mode === 'leaderboard') {
102
102
  return styledLeaderboard(ctx.clientPlugin?.styles)
103
- ? verdict([], 'leaderboard list, laid out by the plugin\'s own styles')
103
+ ? verdict([], "leaderboard list, laid out by the plugin's own styles")
104
104
  : verdict([
105
105
  "stat declares mode 'leaderboard', but ClientPlugin.styles says " +
106
106
  'nothing about #stat .stat-leaderboard: the engine CSS gives ' +
@@ -4,7 +4,13 @@ import { ERROR, skip, verdict } from '../result.js';
4
4
  // prevPlayer наблюдатель заперт на одной камере; занятый движковый код
5
5
  // (чат, голосование, статистика, escape, enter) до игры не доходит;
6
6
  // расхождение имён с playerKeys даёт клавишу, которая ничего не шлёт.
7
- const ENGINE_CODES = { 9: 'stat', 13: 'enter', 27: 'escape', 67: 'chat', 77: 'vote' };
7
+ const ENGINE_CODES = {
8
+ 9: 'stat',
9
+ 13: 'enter',
10
+ 27: 'escape',
11
+ 67: 'chat',
12
+ 77: 'vote',
13
+ };
8
14
  const SPECTATOR_ACTIONS = ['nextPlayer', 'prevPlayer'];
9
15
 
10
16
  export default {
@@ -51,9 +57,7 @@ export default {
51
57
 
52
58
  for (const action of declared) {
53
59
  if (!bound.has(action)) {
54
- violations.push(
55
- `playerKeys."${action}" has no key in keySetList[1]`,
56
- );
60
+ violations.push(`playerKeys."${action}" has no key in keySetList[1]`);
57
61
  }
58
62
  }
59
63
 
@@ -12,7 +12,8 @@ export default {
12
12
  id: 'D3',
13
13
  name: 'snapshotInterp',
14
14
  level: ERROR,
15
- title: "interp only on f32 fields of class 'hot' blocks; role 'state' is a u8 right after the row head",
15
+ title:
16
+ "interp only on f32 fields of class 'hot' blocks; role 'state' is a u8 right after the row head",
16
17
 
17
18
  check(ctx) {
18
19
  const snapshot = ctx.gameConfig?.snapshot;
@@ -30,7 +30,9 @@ export default {
30
30
  'mp3',
31
31
  ]) {
32
32
  if (!ctx.distFiles.has(`sounds/${file}.${codec}`)) {
33
- violations.push(`sound "${name}": dist/sounds/${file}.${codec} is missing`);
33
+ violations.push(
34
+ `sound "${name}": dist/sounds/${file}.${codec} is missing`,
35
+ );
34
36
  }
35
37
  }
36
38
  }
@@ -133,7 +133,13 @@ function checkMap(name, map, violations) {
133
133
  }
134
134
 
135
135
  // 8. тайл рендер-слоя обязан существовать в тайл-листе по индексу
136
- checkLayerFrames(at, `level ${key}`, level?.layers, map.spriteSheet, violations);
136
+ checkLayerFrames(
137
+ at,
138
+ `level ${key}`,
139
+ level?.layers,
140
+ map.spriteSheet,
141
+ violations,
142
+ );
137
143
 
138
144
  // 11. высоты объёмов уровня
139
145
  checkVolumes(at, key, level?.layers, level?.volumes, violations);
@@ -325,7 +331,12 @@ function rampRunExits(grid, tile, dir) {
325
331
  return out;
326
332
  }
327
333
 
328
- const NEIGHBOURS = [[1, 0], [-1, 0], [0, 1], [0, -1]];
334
+ const NEIGHBOURS = [
335
+ [1, 0],
336
+ [-1, 0],
337
+ [0, 1],
338
+ [0, -1],
339
+ ];
329
340
 
330
341
  function checkLevelEdges(at, map, violations) {
331
342
  for (const [key, level] of Object.entries(map.levels ?? {})) {
@@ -43,7 +43,13 @@ export default {
43
43
  function checkMap(at, map, violations) {
44
44
  let checked = 0;
45
45
 
46
- checked += checkGrid(at, 'level 0', map?.layers, map?.physicsStatic, violations);
46
+ checked += checkGrid(
47
+ at,
48
+ 'level 0',
49
+ map?.layers,
50
+ map?.physicsStatic,
51
+ violations,
52
+ );
47
53
 
48
54
  for (const [key, level] of Object.entries(map?.levels ?? {})) {
49
55
  checked += checkGrid(