vimp-engine 0.35.6 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/bin/vimp-sim.js +19 -1
  2. package/core/Cargo.toml +1 -1
  3. package/package.json +1 -1
  4. package/src/client/components/controller/Controls.js +5 -0
  5. package/src/client/components/controller/Lobby.js +4 -4
  6. package/src/client/components/controller/RoomMenu.js +43 -0
  7. package/src/client/components/controller/Vote.js +18 -2
  8. package/src/client/components/model/ClientReports.js +6 -2
  9. package/src/client/components/model/Controls.js +27 -0
  10. package/src/client/components/model/Games.js +5 -1
  11. package/src/client/components/model/Lobby.js +32 -24
  12. package/src/client/components/model/LobbyAuth.js +20 -4
  13. package/src/client/components/model/RoomMenu.js +121 -0
  14. package/src/client/components/model/Stat.js +2 -1
  15. package/src/client/components/model/Vote.js +122 -4
  16. package/src/client/components/view/Auth.js +20 -10
  17. package/src/client/components/view/ClientReports.js +16 -5
  18. package/src/client/components/view/Games.js +39 -14
  19. package/src/client/components/view/Lobby.js +90 -43
  20. package/src/client/components/view/LobbyAuth.js +13 -7
  21. package/src/client/components/view/Panel.js +1 -2
  22. package/src/client/components/view/RoomMenu.js +69 -0
  23. package/src/client/debug.js +24 -1
  24. package/src/client/lib/FpsMeter.js +46 -0
  25. package/src/client/lib/JoinRetry.js +73 -0
  26. package/src/client/lib/diagnostics.js +28 -5
  27. package/src/client/lib/hostCaps.js +82 -0
  28. package/src/client/lib/hostUnloadGuard.js +108 -0
  29. package/src/client/lib/hostVoteCommand.js +92 -0
  30. package/src/client/lib/pageReload.js +29 -0
  31. package/src/client/lib/roomLink.js +257 -0
  32. package/src/client/lib/signalingErrors.js +74 -0
  33. package/src/client/main.js +674 -451
  34. package/src/client/network/HiddenHostHealthLog.js +76 -0
  35. package/src/client/network/HostConnectionManager.js +62 -8
  36. package/src/client/network/HostController.js +376 -23
  37. package/src/client/network/HostHealthPolicy.js +307 -0
  38. package/src/client/network/HostHealthReporter.js +54 -0
  39. package/src/client/network/HostPrewarm.js +235 -0
  40. package/src/client/network/LoopbackTransport.js +16 -1
  41. package/src/client/network/PlannedHandoff.js +366 -0
  42. package/src/client/network/Promotion.js +420 -0
  43. package/src/client/network/RoomPeersReporter.js +81 -0
  44. package/src/client/network/SessionSupervisor.js +730 -0
  45. package/src/client/network/SignalingClient.js +183 -30
  46. package/src/client/network/StandbyReceiver.js +287 -0
  47. package/src/client/network/StandbySender.js +319 -0
  48. package/src/client/network/TokenHandoffTimer.js +81 -0
  49. package/src/client/network/WebRtcManager.js +338 -11
  50. package/src/client/network/policyClose.js +32 -2
  51. package/src/client/network/standbyChunks.js +185 -0
  52. package/src/client/session/GuestSession.js +428 -0
  53. package/src/client/session/HandoffFlow.js +399 -0
  54. package/src/client/session/HostRole.js +708 -0
  55. package/src/client/session/Membership.js +179 -0
  56. package/src/client/session/PromotionFlow.js +325 -0
  57. package/src/client/session/RouteBoot.js +432 -0
  58. package/src/client/session/StandbyRole.js +195 -0
  59. package/src/client/session/hostRoomPrep.js +152 -0
  60. package/src/client/session/roomContext.js +32 -0
  61. package/src/client/style.css +83 -22
  62. package/src/client/views/gameShell.js +7 -0
  63. package/src/client/views/includes/informer.pug +4 -0
  64. package/src/client/views/includes/lobby.pug +0 -3
  65. package/src/client/views/includes/roomMenu.pug +9 -0
  66. package/src/client/views/index.pug +1 -0
  67. package/src/config/closeCodes.js +1 -1
  68. package/src/config/env.js +37 -0
  69. package/src/config/hostDefaults.js +24 -0
  70. package/src/config/lobby.js +154 -3
  71. package/src/config/master.js +104 -21
  72. package/src/config/wsports.js +8 -0
  73. package/src/devtools/RecordingSocketManager.js +3 -2
  74. package/src/devtools/ScenarioRunner.js +123 -8
  75. package/src/devtools/VirtualClient.js +20 -0
  76. package/src/devtools/contract/format.js +4 -1
  77. package/src/devtools/contract/index.js +1 -2
  78. package/src/devtools/contract/rules/a2-package-scripts.js +1 -3
  79. package/src/devtools/contract/rules/b8-system-messages.js +1 -1
  80. package/src/devtools/contract/rules/b9-vote-names.js +25 -1
  81. package/src/devtools/contract/rules/c11-stat-mode.js +4 -1
  82. package/src/devtools/contract/rules/c6-stat-columns.js +1 -1
  83. package/src/devtools/contract/rules/c7-key-sets.js +8 -4
  84. package/src/devtools/contract/rules/d3-snapshot-interp.js +2 -1
  85. package/src/devtools/contract/rules/e1-sound-pairs.js +3 -1
  86. package/src/devtools/contract/rules/e4-map-layers.js +13 -2
  87. package/src/devtools/contract/rules/e5-map-radar-walls.js +7 -1
  88. package/src/devtools/contract/rules/index.js +38 -5
  89. package/src/devtools/invariants.js +10 -4
  90. package/src/devtools/pluginLoader.js +1 -4
  91. package/src/devtools/report.js +17 -1
  92. package/src/host/DebugRecorder.js +6 -1
  93. package/src/host/GameCoreAdapter.js +45 -0
  94. package/src/host/HostGame.js +928 -59
  95. package/src/host/PortMachine.js +268 -20
  96. package/src/host/host.worker.js +222 -16
  97. package/src/host/meta/SocketManager.js +20 -0
  98. package/src/host/meta/core/RoundManager.js +89 -14
  99. package/src/host/meta/modules/Accolades.js +4 -1
  100. package/src/host/meta/modules/Panel.js +30 -0
  101. package/src/host/meta/modules/PlayerDataSync.js +255 -46
  102. package/src/host/meta/modules/RTTManager.js +34 -0
  103. package/src/host/meta/modules/TimerManager.js +215 -18
  104. package/src/host/meta/modules/chat/systemMessages.js +22 -0
  105. package/src/host/meta/player/HumanParticipant.js +10 -0
  106. package/src/host/meta/player/ParticipantManager.js +98 -5
  107. package/src/lib/AbstractTimer.js +63 -1
  108. package/src/lib/Publisher.js +26 -0
  109. package/src/lib/capabilities.js +5 -0
  110. package/src/lib/checkpointCodec.js +125 -0
  111. package/src/lib/clientIp.js +9 -2
  112. package/src/lib/createHostRuntime.js +95 -6
  113. package/src/lib/formOptions.js +3 -1
  114. package/src/lib/gameConfigView.js +10 -0
  115. package/src/lib/gameId.js +13 -0
  116. package/src/lib/jwt.js +3 -1
  117. package/src/lib/loadGamePackage.js +2 -2
  118. package/src/lib/resumeKey.js +38 -0
  119. package/src/lib/roomId.js +39 -0
  120. package/src/lib/roomSettings.js +59 -0
  121. package/src/lib/validators.js +7 -0
  122. package/tests/fixtures/generations/gen-api3/host/ScriptedManager.js +2 -3
  123. package/tests/fixtures/generations/gen-api3/host/fakeCore.js +31 -5
  124. package/tests/fixtures/generations/gen-api3/manifest.json +1 -3
  125. package/tests/fixtures/generations/gen-api4/host/ScriptedManager.js +2 -3
  126. package/tests/fixtures/generations/gen-api4/host/fakeCore.js +31 -5
  127. package/tests/fixtures/generations/gen-api4/manifest.json +1 -3
  128. package/tests/fixtures/miniGame/config/client.js +15 -0
  129. package/tests/fixtures/miniGame/host/ScriptedManager.js +2 -3
  130. package/tests/fixtures/miniGame/host/fakeCore.js +31 -5
@@ -0,0 +1,432 @@
1
+ import { mergeRoomDefaults } from '../lib/formBuilder.js';
2
+ import {
3
+ classifyRoomPoll,
4
+ decideRouteAction,
5
+ parseRoute,
6
+ pickQuickPlayRoom,
7
+ quickPlayCreateDelay,
8
+ } from '../lib/roomLink.js';
9
+ import { takePendingPromotion } from '../network/Promotion.js';
10
+
11
+ // Прямые ссылки (host-migration, этап 3): без hash — лобби, #/<gameId> —
12
+ // быстрая игра, #/<gameId>/<roomId> — вход в комнату (client/lib/roomLink.js).
13
+ // Разбор маршрута после логина и welcome, ожидание мигрирующей комнаты,
14
+ // холодный промоушен после перезагрузки и смена hash.
15
+ //
16
+ // Без глобального DOM: адрес, лобби, информер, сеть и вход в комнату
17
+ // инъектирует сборщик (client/main.js).
18
+
19
+ // ожидание брошено: hash сменился, маршрут разбирается заново
20
+ export const ROUTE_ABANDONED = Symbol('routeAbandoned');
21
+
22
+ // дефолты формы создания комнаты — то, что ушло бы в комнату по Create без
23
+ // правки полей: roomDefaults манифеста + default'ы самой схемы формы
24
+ export function roomDefaultsOf(manifest) {
25
+ const defaults = { ...manifest.roomDefaults };
26
+
27
+ if (Array.isArray(manifest.roomForm)) {
28
+ for (const field of mergeRoomDefaults(
29
+ manifest.roomForm,
30
+ manifest.roomDefaults ?? {},
31
+ )) {
32
+ if (field.default !== undefined) {
33
+ defaults[field.name] = field.default;
34
+ }
35
+ }
36
+ }
37
+
38
+ return defaults;
39
+ }
40
+
41
+ export default class RouteBoot {
42
+ /**
43
+ * @param {Object} deps
44
+ * @param {Object} deps.ctx - client/session/roomContext.js.
45
+ * @param {Object} deps.signaling - SignalingClient.
46
+ * @param {Object} deps.membership - client/session/Membership.js.
47
+ * @param {Object} deps.config - lobby-конфиг (session, quickPlay, create,
48
+ * roomUrl, quickPlayUrl).
49
+ * @param {Map} deps.gamesById - каталог манифестов вкладки.
50
+ * @param {Function} deps.selectActiveGame - (gameId, { report }) → bool.
51
+ * @param {Function} deps.connectToRoom - (roomId) вход гостем.
52
+ * @param {Function} deps.createRoom - (room, { promotion }) поднять
53
+ * комнату в этой вкладке.
54
+ * @param {Function} deps.fetchServers - ({ search }) → список | null.
55
+ * @param {Object} deps.ui - { initLobby(), selectLobbyGame(gameId),
56
+ * informTech(text?), onInformerClick(handler), getHash(),
57
+ * setRoute(hash), reloadPage(hash) }.
58
+ * @param {Function} [deps.fetch]
59
+ * @param {Function} [deps.getStorage] - () → sessionStorage (может бросить).
60
+ * @param {Function} [deps.sleep] - (ms) → Promise.
61
+ * @param {Function} [deps.now] - монотонные часы, мс.
62
+ */
63
+ constructor({
64
+ ctx,
65
+ signaling,
66
+ membership,
67
+ config,
68
+ gamesById,
69
+ selectActiveGame,
70
+ connectToRoom,
71
+ createRoom,
72
+ fetchServers,
73
+ ui,
74
+ fetch = (...args) => globalThis.fetch(...args),
75
+ getStorage = () => window.sessionStorage,
76
+ sleep = ms => new Promise(resolve => setTimeout(resolve, ms)),
77
+ now = () => performance.now(),
78
+ }) {
79
+ this._ctx = ctx;
80
+ this._signaling = signaling;
81
+ this._membership = membership;
82
+ this._config = config;
83
+ this._gamesById = gamesById;
84
+ this._selectActiveGame = selectActiveGame;
85
+ this._connectToRoom = connectToRoom;
86
+ this._createRoom = createRoom;
87
+ this._fetchServers = fetchServers;
88
+ this._ui = ui;
89
+ this._fetch = fetch;
90
+ this._getStorage = getStorage;
91
+ this._sleep = sleep;
92
+ this._now = now;
93
+
94
+ // маршрут разбирается один раз: повторный welcome (реконнект
95
+ // сигналинга) бутстрап не повторяет
96
+ this._booted = false;
97
+ // маршрут в работе (ждёт /rooms или /servers): hashchange в это время не
98
+ // запускает второй бутстрап — тот дошёл бы до connectToRoom/createRoom
99
+ // параллельно первому (два транспорта в одной вкладке). Новый hash
100
+ // разбирается после текущего, если вкладка так и не вошла в комнату
101
+ this._running = false;
102
+ this._rerun = false;
103
+ }
104
+
105
+ get booted() {
106
+ return this._booted;
107
+ }
108
+
109
+ // первый разбор маршрута (после логина и welcome)
110
+ boot() {
111
+ if (this._booted) {
112
+ return;
113
+ }
114
+
115
+ this._booted = true;
116
+ this.run();
117
+ }
118
+
119
+ async run() {
120
+ if (this._running) {
121
+ this._rerun = true;
122
+
123
+ return;
124
+ }
125
+
126
+ this._running = true;
127
+
128
+ try {
129
+ do {
130
+ this._rerun = false;
131
+ await this._bootRoute();
132
+ } while (this._rerun && !this._ctx.entered);
133
+ } finally {
134
+ this._running = false;
135
+ }
136
+ }
137
+
138
+ // hashchange: вне комнаты — новый маршрут бутстрапом; в комнате — ссылка на
139
+ // неё же ничего не меняет, любой другой маршрут — перезагрузкой (матч не
140
+ // разбирается без неё). replaceState (setRoute) это событие не порождает
141
+ handleHashChange() {
142
+ if (!this._booted) {
143
+ return; // бутстрап ещё впереди и сам прочитает hash
144
+ }
145
+
146
+ if (!this._ctx.entered) {
147
+ this.run();
148
+
149
+ return;
150
+ }
151
+
152
+ const hash = this._ui.getHash();
153
+ const route = parseRoute(hash);
154
+
155
+ if (route.kind === 'room' && route.roomId === this._ctx.roomId) {
156
+ return;
157
+ }
158
+
159
+ this._ui.reloadPage(hash);
160
+ }
161
+
162
+ // разбор маршрута после логина и welcome (только лобби-режим)
163
+ async _bootRoute() {
164
+ const route = parseRoute(this._ui.getHash());
165
+ const pending = this._takeRoutePromotion(route);
166
+
167
+ if (pending && (await this._promoteCold(pending))) {
168
+ return;
169
+ }
170
+
171
+ let roomInfo =
172
+ route.kind === 'room' ? await this._fetchRoom(route.roomId) : null;
173
+ let decision = decideRouteAction(route, roomInfo, this._gamesById);
174
+
175
+ if (decision.action === 'wait') {
176
+ roomInfo = await this._waitRoomOnline(decision.roomId);
177
+
178
+ if (roomInfo === ROUTE_ABANDONED) {
179
+ return;
180
+ }
181
+
182
+ decision = decideRouteAction(route, roomInfo, this._gamesById);
183
+ }
184
+
185
+ switch (decision.action) {
186
+ case 'join':
187
+ await this._joinFromRoute(decision.gameId, decision.roomId);
188
+ break;
189
+
190
+ case 'quickPlay':
191
+ await this._quickPlay(decision.gameId);
192
+ break;
193
+
194
+ default:
195
+ if (route.kind === 'none') {
196
+ this._ui.initLobby();
197
+ } else {
198
+ this._showLobby({ informer: decision.informer });
199
+ }
200
+ }
201
+ }
202
+
203
+ // комната по прямому id; null — её нет или мастер не ответил (тогда —
204
+ // быстрая игра той же игры, как для мёртвой ссылки)
205
+ async _fetchRoom(roomId) {
206
+ return (await this._pollRoom(roomId)).info;
207
+ }
208
+
209
+ // GET /rooms/:roomId со статусом: ожиданию миграции важно отличить
210
+ // «комнаты нет» (404) от сбоя запроса. status null — запрос не дошёл
211
+ async _pollRoom(roomId) {
212
+ try {
213
+ const res = await this._fetch(this._config.roomUrl(roomId));
214
+
215
+ return { status: res.status, info: res.ok ? await res.json() : null };
216
+ } catch {
217
+ return { status: null, info: null };
218
+ }
219
+ }
220
+
221
+ // информер, который игрок закрывает кликом: причина отказа маршрута видна
222
+ // поверх лобби, но лобби остаётся рабочим
223
+ _showDismissibleInformer(text) {
224
+ if (text) {
225
+ this._ui.informTech(text);
226
+ }
227
+
228
+ this._ui.onInformerClick(() => this._ui.informTech());
229
+ }
230
+
231
+ // лобби вместо маршрута: адрес — без hash (иначе F5 повторил бы маршрут),
232
+ // игра маршрута — выбрана в селекторе
233
+ _showLobby({ gameId, informer } = {}) {
234
+ this._ui.setRoute('');
235
+ this._ui.initLobby();
236
+
237
+ if (gameId && this._gamesById.has(gameId)) {
238
+ this._ui.selectLobbyGame(gameId);
239
+ }
240
+
241
+ if (informer) {
242
+ this._showDismissibleInformer(informer);
243
+ }
244
+ }
245
+
246
+ // активация игры маршрута: лобби ещё нет (строку формы, куда пишет
247
+ // selectActiveGame по умолчанию, initLobby тут же перетёр бы), поэтому
248
+ // отказ показывается поверх лобби информером
249
+ async _activateRouteGame(gameId) {
250
+ let failure = null;
251
+
252
+ if (
253
+ await this._selectActiveGame(gameId, {
254
+ report: text => (failure = text),
255
+ })
256
+ ) {
257
+ return true;
258
+ }
259
+
260
+ this._showLobby({ gameId, informer: failure });
261
+
262
+ return false;
263
+ }
264
+
265
+ // вход по маршруту — лобби не показывается вовсе: игрок сразу видит экран
266
+ // авторизации игры (AUTH_DATA → #auth)
267
+ async _joinFromRoute(gameId, roomId) {
268
+ if (await this._activateRouteGame(gameId)) {
269
+ this._connectToRoom(roomId);
270
+ }
271
+ }
272
+
273
+ // самая наполненная неполная комната игры: её выбирает мастер; мастер без
274
+ // роута или сбой — выбор по полному списку, как раньше
275
+ async _findQuickPlayRoom(gameId) {
276
+ try {
277
+ const res = await this._fetch(this._config.quickPlayUrl(gameId));
278
+
279
+ if (res.ok) {
280
+ const { room } = await res.json();
281
+
282
+ return room && typeof room.roomId === 'string' ? room : null;
283
+ }
284
+ } catch {
285
+ // ниже — запасной путь
286
+ }
287
+
288
+ const list = await this._fetchServers({ search: gameId });
289
+
290
+ return pickQuickPlayRoom(list?.servers, gameId);
291
+ }
292
+
293
+ // быстрая игра: самая наполненная неполная комната игры, иначе своя
294
+ async _quickPlay(gameId) {
295
+ const quickPlay = this._config.quickPlay;
296
+ let room = await this._findQuickPlayRoom(gameId);
297
+
298
+ // гости закрытой комнаты приходят сюда разом: случайная пауза и второй
299
+ // взгляд на список — комнату создаст первый, остальные в неё войдут
300
+ if (!room && quickPlay.autoCreate) {
301
+ await this._sleep(quickPlayCreateDelay(quickPlay));
302
+ room = await this._findQuickPlayRoom(gameId);
303
+ }
304
+
305
+ if (room) {
306
+ await this._joinFromRoute(gameId, room.roomId);
307
+
308
+ return;
309
+ }
310
+
311
+ if (!quickPlay.autoCreate) {
312
+ this._showLobby({ gameId });
313
+
314
+ return;
315
+ }
316
+
317
+ const manifest = this._gamesById.get(gameId);
318
+
319
+ if (!(await this._activateRouteGame(gameId))) {
320
+ return;
321
+ }
322
+
323
+ await this._createRoom({
324
+ hostSocketId: this._config.create.hostSocketId,
325
+ ...roomDefaultsOf(manifest),
326
+ });
327
+
328
+ // браузер не может быть хостом (createRoom уже показал причину): лобби
329
+ // под ней, войти в чужую комнату он всё ещё может
330
+ if (!this._ctx.entered) {
331
+ this._showLobby({ gameId });
332
+ this._showDismissibleInformer();
333
+ }
334
+ }
335
+
336
+ // комната по ссылке меняет хоста: ждём, пока она снова станет online.
337
+ // null — комната пропала или так и не дождались (тогда — быстрая игра);
338
+ // ROUTE_ABANDONED — игрок ушёл по другой ссылке (run повторит разбор)
339
+ async _waitRoomOnline(roomId) {
340
+ // крайний срок — с запасом на цепочку кандидатов (дедлайн каждого — у
341
+ // мастера); 404 обрывает ожидание раньше
342
+ const { linkWaitMaxMs, migrationPollMs } = this._config.session;
343
+ const deadline = this._now() + linkWaitMaxMs;
344
+
345
+ this._ui.informTech('Switching host…');
346
+
347
+ try {
348
+ while (this._now() < deadline) {
349
+ await this._sleep(migrationPollMs);
350
+
351
+ if (this._rerun) {
352
+ return ROUTE_ABANDONED;
353
+ }
354
+
355
+ const poll = await this._pollRoom(roomId);
356
+
357
+ if (this._rerun) {
358
+ return ROUTE_ABANDONED;
359
+ }
360
+
361
+ switch (classifyRoomPoll(poll)) {
362
+ case 'online':
363
+ return poll.info;
364
+ case 'gone':
365
+ return null;
366
+ }
367
+ }
368
+
369
+ return null;
370
+ } finally {
371
+ this._ui.informTech();
372
+ }
373
+ }
374
+
375
+ // холодный промоушен (host-migration этап 7.4): страница перезагружена в
376
+ // комнату, чтобы поднять её матч заново. Запись снимается при любом исходе
377
+ _takeRoutePromotion(route) {
378
+ let storage = null;
379
+
380
+ try {
381
+ storage = this._getStorage();
382
+ } catch {
383
+ return null;
384
+ }
385
+
386
+ const pending = takePendingPromotion(storage);
387
+
388
+ return pending &&
389
+ route.kind === 'room' &&
390
+ route.gameId === pending.gameId &&
391
+ route.roomId === pending.roomId
392
+ ? pending
393
+ : null;
394
+ }
395
+
396
+ // занимает комнату холодным промоушеном. false — вкладка хостить не может,
397
+ // маршрут разбирается как обычный вход гостем
398
+ async _promoteCold(pending) {
399
+ // вход истёк за время перезагрузки — комнату займёт другой кандидат
400
+ if (!this._membership.tokenAllowsHostRole()) {
401
+ this._signaling.promoteFailed(pending);
402
+
403
+ return false;
404
+ }
405
+
406
+ if (!(await this._activateRouteGame(pending.gameId))) {
407
+ this._signaling.promoteFailed(pending);
408
+
409
+ return true;
410
+ }
411
+
412
+ this._ctx.roomId = pending.roomId;
413
+
414
+ await this._createRoom(
415
+ {
416
+ hostSocketId: this._config.create.hostSocketId,
417
+ ...roomDefaultsOf(this._gamesById.get(pending.gameId)),
418
+ ...pending.settings,
419
+ },
420
+ { promotion: pending },
421
+ );
422
+
423
+ if (this._ctx.entered) {
424
+ return true;
425
+ }
426
+
427
+ this._ctx.roomId = null;
428
+ this._signaling.promoteFailed(pending);
429
+
430
+ return false;
431
+ }
432
+ }
@@ -0,0 +1,195 @@
1
+ import HostPrewarm from '../network/HostPrewarm.js';
2
+ import StandbyReceiver from '../network/StandbyReceiver.js';
3
+
4
+ // Роль беты (host-migration этап 6): назначение мастера ({ roomId, epoch,
5
+ // game }), приём контрольных точек по каналу standby, прогретый Worker и
6
+ // периодический standby_status.
7
+ //
8
+ // Без DOM: сигналинг, подготовку комнаты, конфиг и фабрики инъектирует
9
+ // сборщик (client/main.js).
10
+
11
+ export default class StandbyRole {
12
+ /**
13
+ * @param {Object} deps
14
+ * @param {Object} deps.signaling - SignalingClient.
15
+ * @param {Object} deps.ctx - client/session/roomContext.js.
16
+ * @param {Function} deps.isHost - () → вкладка держит Worker комнаты.
17
+ * @param {Function} deps.prepareRoom - prepareHostRoom (hostRoomPrep.js).
18
+ * @param {Object} deps.diagnostics
19
+ * @param {Object} deps.config - lobby.migration.
20
+ * @param {Function|null} [deps.debug] - debugLog dev-сборки.
21
+ * @param {Function} [deps.createReceiver]
22
+ * @param {Function} [deps.createPrewarm]
23
+ * @param {Function} [deps.now]
24
+ * @param {Object} [deps.timers] - { setInterval, clearInterval }.
25
+ */
26
+ constructor({
27
+ signaling,
28
+ ctx,
29
+ isHost,
30
+ prepareRoom,
31
+ diagnostics,
32
+ config,
33
+ debug = null,
34
+ createReceiver = () => new StandbyReceiver(),
35
+ createPrewarm = options => new HostPrewarm(options),
36
+ now = () => Date.now(),
37
+ timers = globalThis,
38
+ }) {
39
+ this._signaling = signaling;
40
+ this._ctx = ctx;
41
+ this._isHost = isHost;
42
+ this._prepareRoom = prepareRoom;
43
+ this._diagnostics = diagnostics;
44
+ this._config = config;
45
+ this._debug = debug;
46
+ this._createReceiver = createReceiver;
47
+ this._createPrewarm = createPrewarm;
48
+ this._now = now;
49
+ this._timers = timers;
50
+
51
+ this._role = null;
52
+ this._receiver = null;
53
+ this._prewarm = null;
54
+ this._statusTimer = null;
55
+ this._received = 0;
56
+ }
57
+
58
+ // назначение мастера { roomId, epoch, game } или null
59
+ get role() {
60
+ return this._role;
61
+ }
62
+
63
+ get receiver() {
64
+ return this._receiver;
65
+ }
66
+
67
+ get prewarm() {
68
+ return this._prewarm;
69
+ }
70
+
71
+ ensureReceiver() {
72
+ if (!this._receiver) {
73
+ this._receiver = this._createReceiver();
74
+ this._receiver.publisher.on('checkpoint', checkpoint =>
75
+ this._onCheckpoint(checkpoint),
76
+ );
77
+ }
78
+
79
+ return this._receiver;
80
+ }
81
+
82
+ // канал standby открывает хост только назначенной бете
83
+ attach(channel) {
84
+ this.ensureReceiver().attach(channel);
85
+ }
86
+
87
+ // бета помнит seq последнего кадра хоста (seqFloor, этап 6) — до
88
+ // push_frame: ядро может забрать буфер
89
+ noteFrame(data) {
90
+ this._receiver?.noteFrame(data);
91
+ }
92
+
93
+ // сорвавшаяся плановая передача: её финальная точка следующую не завершит
94
+ discardFinal() {
95
+ this._receiver?.discardFinal();
96
+ }
97
+
98
+ // standby_released, уход из комнаты, смена эпохи: прогретый Worker
99
+ // гасится, точки выбрасываются
100
+ teardown() {
101
+ this._timers.clearInterval(this._statusTimer);
102
+ this._statusTimer = null;
103
+ this._receiver?.destroy();
104
+ this._receiver = null;
105
+ this._prewarm?.destroy();
106
+ this._prewarm = null;
107
+ this._role = null;
108
+ this._received = 0;
109
+ }
110
+
111
+ // подписки сигналинга роли беты (лобби-режим, один раз на страницу)
112
+ bind() {
113
+ // гость: мастер назначил его бетой
114
+ this._signaling.publisher.on('standby_assigned', msg => {
115
+ if (this._isHost() || msg.roomId !== this._ctx.roomId) {
116
+ return;
117
+ }
118
+
119
+ if (this._role && this._role.epoch !== msg.epoch) {
120
+ this.teardown();
121
+ }
122
+
123
+ // game — игра комнаты по данным мастера: прогрев сверяет с ней точку
124
+ this._role = {
125
+ roomId: msg.roomId,
126
+ epoch: msg.epoch,
127
+ game: msg.game ?? null,
128
+ };
129
+ this.ensureReceiver();
130
+ this._startDuties();
131
+ });
132
+
133
+ this._signaling.publisher.on('standby_released', msg => {
134
+ if (msg.roomId === this._ctx.roomId) {
135
+ this.teardown();
136
+ }
137
+ });
138
+ }
139
+
140
+ // полная точка собрана: мастеру — что она есть, Worker'у — прогрев
141
+ _onCheckpoint(checkpoint) {
142
+ this._received += 1;
143
+
144
+ this._debug?.('standby received', {
145
+ count: this._received,
146
+ bytes: checkpoint.bytes.byteLength,
147
+ checkpointId: checkpoint.checkpointId,
148
+ });
149
+
150
+ this._startDuties();
151
+ }
152
+
153
+ // канал мог открыться раньше, чем пришло standby_assigned: обязанности
154
+ // беты начинаются, когда есть и назначение, и точка
155
+ _startDuties() {
156
+ const latest = this._receiver?.latest();
157
+
158
+ if (!this._role || !latest) {
159
+ return;
160
+ }
161
+
162
+ if (this._statusTimer === null) {
163
+ this._reportStatus();
164
+ this._statusTimer = this._timers.setInterval(
165
+ () => this._reportStatus(),
166
+ this._config.standbyStatusIntervalMs,
167
+ );
168
+ }
169
+
170
+ this._prewarm ??= this._createPrewarm({
171
+ prepareRoom: this._prepareRoom,
172
+ diagnostics: this._diagnostics,
173
+ onReady: prepared => {
174
+ this._debug?.('standby worker prewarmed', prepared.gameRef);
175
+ },
176
+ onError: error => console.warn('[standby] prewarm failed:', error),
177
+ });
178
+ this._prewarm.warm(latest, { allowedGame: this._role.game });
179
+ }
180
+
181
+ _reportStatus() {
182
+ const latest = this._receiver?.latest();
183
+
184
+ if (this._role && latest) {
185
+ this._signaling.standbyStatus({
186
+ roomId: this._role.roomId,
187
+ epoch: this._role.epoch,
188
+ checkpointId: latest.checkpointId,
189
+ createdAt: latest.createdAt,
190
+ // мастер судит о свежести по возрасту точки, а не по приходу статуса
191
+ ageMs: Math.max(0, this._now() - latest.receivedAt),
192
+ });
193
+ }
194
+ }
195
+ }