vimp-engine 0.35.6 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/bin/vimp-sim.js +19 -1
  2. package/core/Cargo.toml +1 -1
  3. package/package.json +1 -1
  4. package/src/client/components/controller/Controls.js +5 -0
  5. package/src/client/components/controller/Lobby.js +4 -4
  6. package/src/client/components/controller/RoomMenu.js +43 -0
  7. package/src/client/components/controller/Vote.js +18 -2
  8. package/src/client/components/model/Chat.js +19 -2
  9. package/src/client/components/model/ClientReports.js +6 -2
  10. package/src/client/components/model/Controls.js +27 -0
  11. package/src/client/components/model/Games.js +5 -1
  12. package/src/client/components/model/Lobby.js +32 -24
  13. package/src/client/components/model/LobbyAuth.js +20 -4
  14. package/src/client/components/model/RoomMenu.js +121 -0
  15. package/src/client/components/model/Stat.js +2 -1
  16. package/src/client/components/model/Vote.js +122 -4
  17. package/src/client/components/view/Auth.js +20 -10
  18. package/src/client/components/view/ClientReports.js +16 -5
  19. package/src/client/components/view/Games.js +39 -14
  20. package/src/client/components/view/Lobby.js +90 -43
  21. package/src/client/components/view/LobbyAuth.js +13 -7
  22. package/src/client/components/view/Panel.js +1 -2
  23. package/src/client/components/view/RoomMenu.js +69 -0
  24. package/src/client/debug.js +24 -1
  25. package/src/client/lib/FpsMeter.js +46 -0
  26. package/src/client/lib/JoinRetry.js +73 -0
  27. package/src/client/lib/diagnostics.js +28 -5
  28. package/src/client/lib/hostCaps.js +82 -0
  29. package/src/client/lib/hostUnloadGuard.js +108 -0
  30. package/src/client/lib/hostVoteCommand.js +92 -0
  31. package/src/client/lib/pageReload.js +29 -0
  32. package/src/client/lib/roomLink.js +257 -0
  33. package/src/client/lib/signalingErrors.js +74 -0
  34. package/src/client/main.js +676 -451
  35. package/src/client/network/HiddenHostHealthLog.js +76 -0
  36. package/src/client/network/HostConnectionManager.js +62 -8
  37. package/src/client/network/HostController.js +376 -23
  38. package/src/client/network/HostHealthPolicy.js +307 -0
  39. package/src/client/network/HostHealthReporter.js +54 -0
  40. package/src/client/network/HostPrewarm.js +235 -0
  41. package/src/client/network/LoopbackTransport.js +16 -1
  42. package/src/client/network/PlannedHandoff.js +366 -0
  43. package/src/client/network/Promotion.js +420 -0
  44. package/src/client/network/RoomPeersReporter.js +81 -0
  45. package/src/client/network/SessionSupervisor.js +730 -0
  46. package/src/client/network/SignalingClient.js +183 -30
  47. package/src/client/network/StandbyReceiver.js +287 -0
  48. package/src/client/network/StandbySender.js +319 -0
  49. package/src/client/network/TokenHandoffTimer.js +81 -0
  50. package/src/client/network/WebRtcManager.js +338 -11
  51. package/src/client/network/policyClose.js +32 -2
  52. package/src/client/network/standbyChunks.js +185 -0
  53. package/src/client/session/GuestSession.js +428 -0
  54. package/src/client/session/HandoffFlow.js +399 -0
  55. package/src/client/session/HostRole.js +708 -0
  56. package/src/client/session/Membership.js +179 -0
  57. package/src/client/session/PromotionFlow.js +325 -0
  58. package/src/client/session/RouteBoot.js +432 -0
  59. package/src/client/session/StandbyRole.js +195 -0
  60. package/src/client/session/hostRoomPrep.js +152 -0
  61. package/src/client/session/roomContext.js +32 -0
  62. package/src/client/style.css +83 -22
  63. package/src/client/views/gameShell.js +7 -0
  64. package/src/client/views/includes/informer.pug +4 -0
  65. package/src/client/views/includes/lobby.pug +0 -3
  66. package/src/client/views/includes/roomMenu.pug +9 -0
  67. package/src/client/views/index.pug +1 -0
  68. package/src/config/chatMessages.js +45 -0
  69. package/src/config/closeCodes.js +1 -1
  70. package/src/config/env.js +37 -0
  71. package/src/config/hostDefaults.js +24 -0
  72. package/src/config/lobby.js +154 -3
  73. package/src/config/master.js +104 -21
  74. package/src/config/wsports.js +8 -0
  75. package/src/devtools/RecordingSocketManager.js +3 -2
  76. package/src/devtools/ScenarioRunner.js +123 -8
  77. package/src/devtools/VirtualClient.js +20 -0
  78. package/src/devtools/contract/format.js +4 -1
  79. package/src/devtools/contract/index.js +1 -2
  80. package/src/devtools/contract/rules/a2-package-scripts.js +1 -3
  81. package/src/devtools/contract/rules/b8-system-messages.js +2 -2
  82. package/src/devtools/contract/rules/b9-vote-names.js +25 -1
  83. package/src/devtools/contract/rules/c11-stat-mode.js +4 -1
  84. package/src/devtools/contract/rules/c6-stat-columns.js +1 -1
  85. package/src/devtools/contract/rules/c7-key-sets.js +8 -4
  86. package/src/devtools/contract/rules/d3-snapshot-interp.js +2 -1
  87. package/src/devtools/contract/rules/e1-sound-pairs.js +3 -1
  88. package/src/devtools/contract/rules/e4-map-layers.js +13 -2
  89. package/src/devtools/contract/rules/e5-map-radar-walls.js +7 -1
  90. package/src/devtools/contract/rules/index.js +38 -5
  91. package/src/devtools/invariants.js +10 -4
  92. package/src/devtools/pluginLoader.js +1 -4
  93. package/src/devtools/report.js +17 -1
  94. package/src/host/DebugRecorder.js +6 -1
  95. package/src/host/GameCoreAdapter.js +45 -0
  96. package/src/host/HostGame.js +928 -59
  97. package/src/host/PortMachine.js +268 -20
  98. package/src/host/host.worker.js +222 -16
  99. package/src/host/meta/SocketManager.js +20 -0
  100. package/src/host/meta/core/RoundManager.js +89 -14
  101. package/src/host/meta/modules/Accolades.js +4 -1
  102. package/src/host/meta/modules/Panel.js +30 -0
  103. package/src/host/meta/modules/PlayerDataSync.js +255 -46
  104. package/src/host/meta/modules/RTTManager.js +34 -0
  105. package/src/host/meta/modules/TimerManager.js +215 -18
  106. package/src/host/meta/modules/chat/systemMessages.js +29 -3
  107. package/src/host/meta/player/HumanParticipant.js +10 -0
  108. package/src/host/meta/player/ParticipantManager.js +98 -5
  109. package/src/lib/AbstractTimer.js +63 -1
  110. package/src/lib/Publisher.js +26 -0
  111. package/src/lib/capabilities.js +5 -0
  112. package/src/lib/checkpointCodec.js +125 -0
  113. package/src/lib/clientIp.js +9 -2
  114. package/src/lib/createHostRuntime.js +95 -6
  115. package/src/lib/formOptions.js +3 -1
  116. package/src/lib/gameConfigView.js +10 -0
  117. package/src/lib/gameId.js +13 -0
  118. package/src/lib/jwt.js +3 -1
  119. package/src/lib/loadGamePackage.js +2 -2
  120. package/src/lib/resumeKey.js +38 -0
  121. package/src/lib/roomId.js +39 -0
  122. package/src/lib/roomSettings.js +59 -0
  123. package/src/lib/validators.js +7 -0
  124. package/tests/fixtures/generations/gen-api3/host/ScriptedManager.js +2 -3
  125. package/tests/fixtures/generations/gen-api3/host/fakeCore.js +31 -5
  126. package/tests/fixtures/generations/gen-api3/manifest.json +1 -3
  127. package/tests/fixtures/generations/gen-api4/host/ScriptedManager.js +2 -3
  128. package/tests/fixtures/generations/gen-api4/host/fakeCore.js +31 -5
  129. package/tests/fixtures/generations/gen-api4/manifest.json +1 -3
  130. package/tests/fixtures/miniGame/config/client.js +0 -20
  131. package/tests/fixtures/miniGame/host/ScriptedManager.js +2 -3
  132. package/tests/fixtures/miniGame/host/fakeCore.js +31 -5
@@ -16,8 +16,85 @@ import Publisher from '../../lib/Publisher.js';
16
16
  // Клиент — инициатор (offerer): создаёт каналы и оффер, обменивается с хостом
17
17
  // SDP/ICE через сигнальный WebSocket (SignalingClient). RTCPeerConnection
18
18
  // инъектируется фабрикой ради тестируемости.
19
+ //
20
+ // Хост может открыть третий канал — standby (host-migration этап 6): только
21
+ // у назначенного мастером преемника; он отдаётся событием 'standby'.
22
+
23
+ /**
24
+ * Тип своего локального ICE-кандидата в выбранной паре (host-migration этап
25
+ * 6): 'relay' значит, что этому игроку для связи нужен ретранслятор.
26
+ * Выбранная пара — по transport.selectedCandidatePairId, а без него — пара
27
+ * nominated && succeeded.
28
+ * @param {RTCStatsReport|Map} stats
29
+ * @returns {string|null} 'host'|'srflx'|'prflx'|'relay' или null.
30
+ */
31
+ export function selectedLocalCandidateType(stats) {
32
+ if (!stats) {
33
+ return null;
34
+ }
35
+
36
+ const reports = [...stats.values()];
37
+ let pair = null;
38
+
39
+ for (const report of reports) {
40
+ if (report.type === 'transport' && report.selectedCandidatePairId) {
41
+ pair = stats.get(report.selectedCandidatePairId) ?? null;
42
+
43
+ if (pair) {
44
+ break;
45
+ }
46
+ }
47
+ }
48
+
49
+ pair ??=
50
+ reports.find(
51
+ report =>
52
+ report.type === 'candidate-pair' &&
53
+ report.nominated === true &&
54
+ report.state === 'succeeded',
55
+ ) ?? null;
56
+
57
+ if (!pair) {
58
+ return null;
59
+ }
60
+
61
+ return stats.get(pair.localCandidateId)?.candidateType ?? null;
62
+ }
63
+
19
64
  export default class WebRtcManager {
20
- constructor(signaling, { iceServers, peerFactory } = {}) {
65
+ /**
66
+ * @param {SignalingClient} signaling
67
+ * @param {Object} [opts]
68
+ * @param {Array} [opts.iceServers]
69
+ * @param {Function} [opts.peerFactory] - фабрика RTCPeerConnection (тесты).
70
+ * @param {string} [opts.memberId] - id вкладки (ключ участника на мастере).
71
+ * @param {boolean} [opts.resume] - переподключение к матчу: хост не начнёт
72
+ * хендшейк, а будет ждать RESUME_REQUEST (host-migration этап 4).
73
+ * @param {number} [opts.minEpoch] - эпоха хоста, известная клиенту: ответы
74
+ * более старых эпох — чужие (сообщения прежнего хоста).
75
+ * @param {number} [opts.connectTimeoutMs] - каналы не открылись за это
76
+ * время — close('connectTimeout'); 0 — без таймаута.
77
+ * @param {number} [opts.offerRetryMs] - пауза перед повтором оффера, на
78
+ * который мастер ответил error {code: 'migrating'} (комната в плановой
79
+ * передаче хоста, host-migration этап 8).
80
+ * @param {number} [opts.iceStatsIntervalMs] - период перечитывания типа
81
+ * ICE-кандидата после открытия каналов; 0 — только один раз.
82
+ * @param {Object} [opts.timers] - { setTimeout, clearTimeout } (тесты).
83
+ */
84
+ constructor(
85
+ signaling,
86
+ {
87
+ iceServers,
88
+ peerFactory,
89
+ memberId,
90
+ resume = false,
91
+ minEpoch = null,
92
+ connectTimeoutMs = 0,
93
+ offerRetryMs = 1000,
94
+ iceStatsIntervalMs = 30000,
95
+ timers = globalThis,
96
+ } = {},
97
+ ) {
21
98
  this._signaling = signaling;
22
99
  this._iceServers = iceServers || signaling.iceServers;
23
100
  this._peerFactory =
@@ -26,20 +103,51 @@ export default class WebRtcManager {
26
103
  this._pc = null;
27
104
  this._meta = null;
28
105
  this._state = null;
29
- this._hostId = null;
106
+ this._roomId = null;
107
+ // id вкладки — хост хранит его рядом с clientId пира
108
+ this._memberId = memberId ?? null;
109
+ this._resume = resume === true;
110
+ this._minEpoch = minEpoch;
111
+ // эпоха хоста, ответившего на этот оффер: дальше чужие эпохи отсекаются
112
+ this._epoch = null;
113
+ this._connectTimeoutMs = connectTimeoutMs;
114
+ this._timers = timers;
115
+ this._connectTimer = null;
116
+ this._offerRetryMs = offerRetryMs;
117
+ this._retryTimer = null;
118
+ this._offerSent = false;
119
+ // свои ICE-кандидаты: повтор оффера отправляет их заново — прежние
120
+ // ушли хосту, который оффер не принял
121
+ this._sentCandidates = [];
30
122
  this._openChannels = 0;
31
123
  this._closed = false;
124
+ // тип своего ICE-кандидата в выбранной паре (null — ещё не известен)
125
+ this._iceType = null;
126
+ this._iceStatsIntervalMs = iceStatsIntervalMs;
127
+ this._iceTimer = null;
32
128
 
33
129
  this.publisher = new Publisher();
34
130
 
35
- // подписки на сигнальные ответы конкретно этого соединения
131
+ // подписки на сигнальные ответы конкретно этого соединения; destroy()
132
+ // их снимает — иначе брошенный менеджер ел бы ответы нового
36
133
  this._signaling.publisher.on('webrtc_answer', 'onAnswer', this);
37
134
  this._signaling.publisher.on('ice_candidate', 'onRemoteCandidate', this);
135
+ this._signaling.publisher.on('error', 'onSignalingError', this);
136
+ }
137
+
138
+ // эпоха хоста, ответившего на оффер (null — ответа ещё не было)
139
+ get epoch() {
140
+ return this._epoch;
141
+ }
142
+
143
+ // тип своего локального ICE-кандидата в выбранной паре с хостом
144
+ get iceType() {
145
+ return this._iceType;
38
146
  }
39
147
 
40
- // инициирует установку соединения с хостом
41
- async connect(hostId) {
42
- this._hostId = hostId;
148
+ // инициирует установку соединения с текущим хостом комнаты
149
+ async connect(roomId) {
150
+ this._roomId = roomId;
43
151
 
44
152
  const pc = this._peerFactory({ iceServers: this._iceServers });
45
153
 
@@ -59,9 +167,20 @@ export default class WebRtcManager {
59
167
  this._wireChannel(this._meta);
60
168
  this._wireChannel(this._state);
61
169
 
170
+ // канал standby открывает хост — только преемнику (этап 6)
171
+ pc.ondatachannel = event => {
172
+ if (event.channel?.label === 'standby') {
173
+ event.channel.binaryType = 'arraybuffer';
174
+ this.publisher.emit('standby', event.channel);
175
+ }
176
+ };
177
+
178
+ this._startConnectTimer();
179
+
62
180
  pc.onicecandidate = event => {
63
181
  if (event.candidate) {
64
- this._signaling.sendIceCandidate(hostId, event.candidate);
182
+ this._sentCandidates.push(event.candidate);
183
+ this._signaling.sendIceCandidate(roomId, event.candidate);
65
184
  }
66
185
  };
67
186
 
@@ -79,21 +198,108 @@ export default class WebRtcManager {
79
198
 
80
199
  await pc.setLocalDescription(offer);
81
200
 
82
- this._signaling.sendOffer(hostId, pc.localDescription);
201
+ // менеджер могли закрыть, пока собирался оффер
202
+ if (this._closed) {
203
+ return;
204
+ }
205
+
206
+ this._offerSent = true;
207
+ this._sendOffer();
208
+ }
209
+
210
+ _sendOffer() {
211
+ this._signaling.sendOffer(
212
+ this._roomId,
213
+ this._pc.localDescription,
214
+ this._memberId,
215
+ { resume: this._resume },
216
+ );
83
217
  }
84
218
 
85
- // приём SDP-ответа хоста (подписка на сигнальный канал)
219
+ // отказ мастера на оффер этой попытки: комната передаёт хоста (этап 8) —
220
+ // тот же оффер через offerRetryMs, к хосту, который будет к тому времени.
221
+ // Окно connectTimeoutMs отсчитывается заново: передача ограничена
222
+ // таймаутом мастера, ожидание не бесконечно
223
+ onSignalingError(msg) {
224
+ // ответ про другую комнату или на другой запрос (join_room, голосование)
225
+ // к этому офферу не относится
226
+ if (msg?.roomId && msg.roomId !== this._roomId) {
227
+ return;
228
+ }
229
+
230
+ if (msg?.re && msg.re !== 'webrtc_offer') {
231
+ return;
232
+ }
233
+
234
+ if (
235
+ msg?.code !== 'migrating' ||
236
+ this._closed ||
237
+ !this._offerSent ||
238
+ this._epoch !== null ||
239
+ this._retryTimer !== null
240
+ ) {
241
+ return;
242
+ }
243
+
244
+ this._clearConnectTimer();
245
+ this._retryTimer = this._timers.setTimeout(() => {
246
+ this._retryTimer = null;
247
+
248
+ if (this._closed) {
249
+ return;
250
+ }
251
+
252
+ this._startConnectTimer();
253
+ this._sendOffer();
254
+
255
+ for (const candidate of this._sentCandidates) {
256
+ this._signaling.sendIceCandidate(this._roomId, candidate);
257
+ }
258
+ }, this._offerRetryMs);
259
+ }
260
+
261
+ // сообщение этой попытки: своя комната и эпоха не старше известной.
262
+ // Эпоха фиксируется первым ответом — кандидаты прежнего хоста той же
263
+ // комнаты после этого отсекаются
264
+ _isOwnEpoch(epoch) {
265
+ if (epoch === undefined || epoch === null) {
266
+ return true;
267
+ }
268
+
269
+ if (this._epoch !== null) {
270
+ return epoch === this._epoch;
271
+ }
272
+
273
+ return this._minEpoch === null || epoch >= this._minEpoch;
274
+ }
275
+
276
+ // приём SDP-ответа хоста (подписка на сигнальный канал); hostId — алиас
277
+ // roomId от мастера до host-migration этапа 2
86
278
  async onAnswer(msg) {
87
- if (!this._pc || msg.hostId !== this._hostId) {
279
+ if (
280
+ !this._pc ||
281
+ this._closed ||
282
+ (msg.roomId ?? msg.hostId) !== this._roomId ||
283
+ !this._isOwnEpoch(msg.epoch)
284
+ ) {
88
285
  return;
89
286
  }
90
287
 
288
+ if (msg.epoch !== undefined && msg.epoch !== null) {
289
+ this._epoch = msg.epoch;
290
+ }
291
+
91
292
  await this._pc.setRemoteDescription(msg.sdp);
92
293
  }
93
294
 
94
295
  // приём удалённого ICE-кандидата (от хоста)
95
296
  async onRemoteCandidate(msg) {
96
- if (!this._pc || msg.fromId !== this._hostId) {
297
+ if (
298
+ !this._pc ||
299
+ this._closed ||
300
+ msg.fromId !== this._roomId ||
301
+ !this._isOwnEpoch(msg.epoch)
302
+ ) {
97
303
  return;
98
304
  }
99
305
 
@@ -117,13 +323,127 @@ export default class WebRtcManager {
117
323
  this._emitClose();
118
324
  }
119
325
 
326
+ // тихое закрытие: отписка от сигналинга и закрытие pc без события 'close'
327
+ // (менеджер брошен владельцем — например, супервизор сменил попытку)
328
+ destroy() {
329
+ this._unsubscribe();
330
+
331
+ if (this._closed) {
332
+ return;
333
+ }
334
+
335
+ this._closed = true;
336
+ this._clearConnectTimer();
337
+ this._clearRetryTimer();
338
+ this._clearIceTimer();
339
+ this._dropChannelHandlers();
340
+ this._pc?.close();
341
+ }
342
+
343
+ _unsubscribe() {
344
+ this._signaling.publisher.off('webrtc_answer', 'onAnswer', this);
345
+ this._signaling.publisher.off('ice_candidate', 'onRemoteCandidate', this);
346
+ this._signaling.publisher.off('error', 'onSignalingError', this);
347
+ }
348
+
349
+ // оба канала открыты и менеджер не закрыт
350
+ get isOpen() {
351
+ return this._openChannels === 2 && !this._closed;
352
+ }
353
+
354
+ // перечитывает тип ICE-кандидата; смена — событие 'iceType'
355
+ async refreshIceType() {
356
+ if (!this._pc || this._closed || typeof this._pc.getStats !== 'function') {
357
+ return this._iceType;
358
+ }
359
+
360
+ let type = null;
361
+
362
+ try {
363
+ type = selectedLocalCandidateType(await this._pc.getStats());
364
+ } catch {
365
+ return this._iceType; // pc закрылся посреди запроса
366
+ }
367
+
368
+ if (!this._closed && type !== this._iceType) {
369
+ this._iceType = type;
370
+ this.publisher.emit('iceType', type);
371
+ }
372
+
373
+ return this._iceType;
374
+ }
375
+
376
+ // после открытия каналов — сразу и далее раз в iceStatsIntervalMs: пара
377
+ // может смениться (ICE restart, смена сети)
378
+ _watchIceType() {
379
+ this.refreshIceType();
380
+
381
+ if (this._iceStatsIntervalMs > 0) {
382
+ this._iceTimer = this._timers.setTimeout(() => {
383
+ this._iceTimer = null;
384
+ this._watchIceType();
385
+ }, this._iceStatsIntervalMs);
386
+ }
387
+ }
388
+
389
+ _clearIceTimer() {
390
+ if (this._iceTimer !== null) {
391
+ this._timers.clearTimeout(this._iceTimer);
392
+ this._iceTimer = null;
393
+ }
394
+ }
395
+
396
+ // каналы не открылись вовремя (хост молчит, ICE не сошёлся) — попытка
397
+ // провалена; 'failed' браузер может объявлять десятки секунд
398
+ _startConnectTimer() {
399
+ if (this._connectTimeoutMs > 0) {
400
+ this._connectTimer = this._timers.setTimeout(() => {
401
+ this._connectTimer = null;
402
+ this._emitClose();
403
+ }, this._connectTimeoutMs);
404
+ }
405
+ }
406
+
407
+ _clearRetryTimer() {
408
+ if (this._retryTimer !== null) {
409
+ this._timers.clearTimeout(this._retryTimer);
410
+ this._retryTimer = null;
411
+ }
412
+ }
413
+
414
+ _clearConnectTimer() {
415
+ if (this._connectTimer !== null) {
416
+ this._timers.clearTimeout(this._connectTimer);
417
+ this._connectTimer = null;
418
+ }
419
+ }
420
+
421
+ // поздние события каналов закрытого менеджера не должны ничего эмитить
422
+ _dropChannelHandlers() {
423
+ for (const channel of [this._meta, this._state]) {
424
+ if (channel) {
425
+ channel.onopen = null;
426
+ channel.onmessage = null;
427
+ channel.onclose = null;
428
+ }
429
+ }
430
+
431
+ if (this._pc) {
432
+ this._pc.onicecandidate = null;
433
+ this._pc.onconnectionstatechange = null;
434
+ this._pc.ondatachannel = null;
435
+ }
436
+ }
437
+
120
438
  _wireChannel(channel) {
121
439
  channel.onopen = () => {
122
440
  this._openChannels += 1;
123
441
 
124
442
  // оба канала открыты — транспорт готов (как ws.onopen)
125
443
  if (this._openChannels === 2) {
444
+ this._clearConnectTimer();
126
445
  this.publisher.emit('open');
446
+ this._watchIceType();
127
447
  }
128
448
  };
129
449
 
@@ -131,12 +451,19 @@ export default class WebRtcManager {
131
451
  channel.onclose = () => this._emitClose();
132
452
  }
133
453
 
454
+ // кода закрытия у data channel нет: 'close' эмитится без аргумента, как
455
+ // и раньше (код приходит только от WebSocket-транспорта dedicated)
134
456
  _emitClose() {
135
457
  if (this._closed) {
136
458
  return;
137
459
  }
138
460
 
139
461
  this._closed = true;
462
+ this._clearConnectTimer();
463
+ this._clearRetryTimer();
464
+ this._clearIceTimer();
465
+ this._unsubscribe();
466
+ this._dropChannelHandlers();
140
467
 
141
468
  if (this._pc) {
142
469
  this._pc.close();
@@ -24,8 +24,15 @@
24
24
 
25
25
  import closeCodes from '../../config/closeCodes.js';
26
26
 
27
- const { invalidOrigin, roomFull, handshakeTimeout, tooManyConnections } =
28
- closeCodes;
27
+ const {
28
+ invalidOrigin,
29
+ roomFull,
30
+ handshakeTimeout,
31
+ tooManyConnections,
32
+ kickIdle,
33
+ kickForMaxLatency,
34
+ kickForMissedPings,
35
+ } = closeCodes;
29
36
 
30
37
  const NO_RELOAD_CLOSE_CODES = new Set([
31
38
  invalidOrigin,
@@ -52,3 +59,26 @@ export const POLICY_CLOSE_INFORMS = {
52
59
  export function shouldReloadAfterClose(closeCode) {
53
60
  return !NO_RELOAD_CLOSE_CODES.has(closeCode);
54
61
  }
62
+
63
+ // Кики хоста (host/HostGame.js). В P2P кода закрытия нет вовсе — причина
64
+ // приезжает только TECH_INFORM-ключом перед закрытием (индексы
65
+ // TECH_CODES из host/meta/SocketManager.js = индексы techInformList);
66
+ // в dedicated есть и код. Лобби-режим уводит кикнутого на главную без
67
+ // перезагрузки (client/lib/roomLink.js decideExitRoute): быстрая игра по
68
+ // hash вернула бы его в ту же комнату
69
+ const KICK_CLOSE_CODES = new Set([
70
+ kickIdle,
71
+ kickForMaxLatency,
72
+ kickForMissedPings,
73
+ ]);
74
+
75
+ export const KICK_TECH_KEYS = new Set([3, 4, 5]);
76
+
77
+ /**
78
+ * @param {number} [closeCode] - Код закрытия транспорта (только WebSocket).
79
+ * @param {number|null} [techKey] - Последний терминальный TECH_INFORM-ключ.
80
+ * @returns {boolean} Закрыто ли соединение киком хоста.
81
+ */
82
+ export function isKickClose(closeCode, techKey) {
83
+ return KICK_CLOSE_CODES.has(closeCode) || KICK_TECH_KEYS.has(techKey);
84
+ }
@@ -0,0 +1,185 @@
1
+ // Нарезка контрольной точки для канала standby (host-migration этап 6):
2
+ // хост → преемник. SCTP-сообщение ограничено maxMessageSize, поэтому точка
3
+ // идёт кусками ≤ standbyChunkBytes. Общий модуль отправителя
4
+ // (StandbySender) и приёмника (StandbyReceiver).
5
+ //
6
+ // Логический поток одной точки: [u16 descLen][desc JSON utf-8][байты
7
+ // точки], desc = { checkpointId, createdAt, mode, game } — приёмнику не
8
+ // нужно распаковывать точку, чтобы сообщить мастеру её id и свежесть, а
9
+ // прогреву — чтобы узнать игру (game: { id, version } или null; хост старше
10
+ // поля его не шлёт, старый приёмник его игнорирует).
11
+ //
12
+ // Кусок: 24-байтный заголовок (little-endian) + данные потока:
13
+ // 0 u32 wireId — номер точки у отправителя (растёт)
14
+ // 4 u32 index — номер куска
15
+ // 8 u32 count — кусков в точке
16
+ // 12 u32 totalBytes — длина логического потока
17
+ // 16 u32 seq — номер кадра хоста на момент точки
18
+ // 20 u8 final — финальная точка (плановая передача, этап 8)
19
+ // 21 u8 version — версия формата куска
20
+ // 22 u16 reserved
21
+
22
+ export const STANDBY_HEADER_BYTES = 24;
23
+ export const STANDBY_CHUNK_VERSION = 1;
24
+
25
+ const DESC_LEN_BYTES = 2;
26
+ const GAME_FIELD_MAX_LENGTH = 64;
27
+
28
+ /**
29
+ * Режет точку на куски.
30
+ * @param {Object} checkpoint - { checkpointId, createdAt, mode, game, seq,
31
+ * final, bytes } (как отдаёт HostController.onCheckpoint).
32
+ * @param {number} wireId
33
+ * @param {number} chunkBytes - потолок размера куска вместе с заголовком.
34
+ * @returns {Uint8Array[]}
35
+ */
36
+ export function encodeStandbyChunks(checkpoint, wireId, chunkBytes) {
37
+ const payloadBytes = chunkBytes - STANDBY_HEADER_BYTES;
38
+
39
+ if (!(payloadBytes > 0)) {
40
+ throw new Error(`standby chunk must exceed ${STANDBY_HEADER_BYTES} bytes`);
41
+ }
42
+
43
+ const desc = new TextEncoder().encode(
44
+ JSON.stringify({
45
+ checkpointId: checkpoint.checkpointId,
46
+ createdAt: checkpoint.createdAt,
47
+ mode: checkpoint.mode ?? null,
48
+ game: parseGame(checkpoint.game),
49
+ }),
50
+ );
51
+ const bytes = checkpoint.bytes;
52
+ const stream = new Uint8Array(
53
+ DESC_LEN_BYTES + desc.byteLength + bytes.byteLength,
54
+ );
55
+
56
+ new DataView(stream.buffer).setUint16(0, desc.byteLength, true);
57
+ stream.set(desc, DESC_LEN_BYTES);
58
+ stream.set(bytes, DESC_LEN_BYTES + desc.byteLength);
59
+
60
+ const count = Math.max(1, Math.ceil(stream.byteLength / payloadBytes));
61
+ const chunks = [];
62
+
63
+ for (let index = 0; index < count; index += 1) {
64
+ const data = stream.subarray(
65
+ index * payloadBytes,
66
+ (index + 1) * payloadBytes,
67
+ );
68
+ const chunk = new Uint8Array(STANDBY_HEADER_BYTES + data.byteLength);
69
+ const view = new DataView(chunk.buffer);
70
+
71
+ view.setUint32(0, wireId >>> 0, true);
72
+ view.setUint32(4, index, true);
73
+ view.setUint32(8, count, true);
74
+ view.setUint32(12, stream.byteLength, true);
75
+ view.setUint32(16, (checkpoint.seq ?? 0) >>> 0, true);
76
+ view.setUint8(20, checkpoint.final ? 1 : 0);
77
+ view.setUint8(21, STANDBY_CHUNK_VERSION);
78
+ chunk.set(data, STANDBY_HEADER_BYTES);
79
+ chunks.push(chunk);
80
+ }
81
+
82
+ return chunks;
83
+ }
84
+
85
+ /**
86
+ * Читает заголовок куска.
87
+ * @param {ArrayBuffer|Uint8Array} raw
88
+ * @returns {Object|null} { wireId, index, count, totalBytes, seq, final,
89
+ * data } или null для битого/чужого куска.
90
+ */
91
+ export function decodeStandbyChunk(raw) {
92
+ const bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw);
93
+
94
+ if (bytes.byteLength < STANDBY_HEADER_BYTES) {
95
+ return null;
96
+ }
97
+
98
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
99
+
100
+ if (view.getUint8(21) !== STANDBY_CHUNK_VERSION) {
101
+ return null;
102
+ }
103
+
104
+ const chunk = {
105
+ wireId: view.getUint32(0, true),
106
+ index: view.getUint32(4, true),
107
+ count: view.getUint32(8, true),
108
+ totalBytes: view.getUint32(12, true),
109
+ seq: view.getUint32(16, true),
110
+ final: view.getUint8(20) === 1,
111
+ data: bytes.subarray(STANDBY_HEADER_BYTES),
112
+ };
113
+
114
+ if (chunk.count === 0 || chunk.index >= chunk.count) {
115
+ return null;
116
+ }
117
+
118
+ return chunk;
119
+ }
120
+
121
+ /**
122
+ * Разбирает собранный логический поток.
123
+ * @param {Uint8Array} stream
124
+ * @returns {{ checkpointId: string, createdAt: number, mode: string|null,
125
+ * game: { id: string, version: string }|null, bytes: Uint8Array }|null}
126
+ */
127
+ export function parseStandbyStream(stream) {
128
+ if (stream.byteLength < DESC_LEN_BYTES) {
129
+ return null;
130
+ }
131
+
132
+ const descLen = new DataView(
133
+ stream.buffer,
134
+ stream.byteOffset,
135
+ stream.byteLength,
136
+ ).getUint16(0, true);
137
+
138
+ if (DESC_LEN_BYTES + descLen > stream.byteLength) {
139
+ return null;
140
+ }
141
+
142
+ let desc;
143
+
144
+ try {
145
+ desc = JSON.parse(
146
+ new TextDecoder().decode(
147
+ stream.subarray(DESC_LEN_BYTES, DESC_LEN_BYTES + descLen),
148
+ ),
149
+ );
150
+ } catch {
151
+ return null;
152
+ }
153
+
154
+ if (
155
+ !desc ||
156
+ typeof desc.checkpointId !== 'string' ||
157
+ !Number.isFinite(desc.createdAt)
158
+ ) {
159
+ return null;
160
+ }
161
+
162
+ return {
163
+ checkpointId: desc.checkpointId,
164
+ createdAt: desc.createdAt,
165
+ mode: typeof desc.mode === 'string' ? desc.mode : null,
166
+ game: parseGame(desc.game),
167
+ // своя копия: буфер можно отдать в Worker списком переноса
168
+ bytes: stream.slice(DESC_LEN_BYTES + descLen),
169
+ };
170
+ }
171
+
172
+ // игра дескриптора — только { id, version } из непустых коротких строк:
173
+ // дескриптор шлёт хост, то есть другой игрок
174
+ function parseGame(game) {
175
+ const isField = value =>
176
+ typeof value === 'string' &&
177
+ value.length > 0 &&
178
+ value.length <= GAME_FIELD_MAX_LENGTH;
179
+
180
+ if (!game || typeof game !== 'object' || !isField(game.id)) {
181
+ return null;
182
+ }
183
+
184
+ return isField(game.version) ? { id: game.id, version: game.version } : null;
185
+ }