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
@@ -1,8 +1,10 @@
1
1
  import Publisher from '../../lib/Publisher.js';
2
2
 
3
3
  // Клиент сигнального WebSocket мастер-сервера (src/master/SignalingServer.js).
4
- // Только координация установки P2P: приём welcome (id соединения + iceServers),
5
- // обмен SDP-офферами/ответами и ICE-кандидатами, сигнальный ping/pong, жалобы.
4
+ // Координация установки P2P и членство в комнате: приём welcome (id
5
+ // соединения + iceServers), регистрация/возврат комнаты хостом, вход/выход
6
+ // участника, обмен SDP-офферами/ответами и ICE-кандидатами, сигнальный
7
+ // ping/pong.
6
8
  // Игровой трафик идёт по WebRTC-каналам (WebRtcManager), не через мастер.
7
9
  //
8
10
  // Входящие сообщения ретранслируются подписчикам через Publisher по полю type;
@@ -53,30 +55,192 @@ export default class SignalingClient {
53
55
  ws.onmessage = event => this._onMessage(event.data);
54
56
  }
55
57
 
56
- // клиент → SDP-оффер конкретному хосту
57
- sendOffer(hostId, sdp) {
58
- this._send({ type: 'webrtc_offer', hostId, sdp });
58
+ // клиент → SDP-оффер текущему хосту комнаты (мастер резолвит roomId).
59
+ // resume — переподключение к матчу (host-migration этап 4): хост ждёт
60
+ // RESUME_REQUEST вместо хендшейка
61
+ sendOffer(roomId, sdp, memberId, { resume = false } = {}) {
62
+ this._send({
63
+ type: 'webrtc_offer',
64
+ roomId,
65
+ sdp,
66
+ memberId,
67
+ ...(resume ? { resume: true } : {}),
68
+ });
59
69
  }
60
70
 
61
- // хост → регистрация комнаты у мастера (ответ — событие 'host_registered').
62
- // token — Bearer identity-токен хостера (server-rating этап 2): без него
63
- // мастер отклоняет регистрацию, т.к. не может атрибутировать комнату и
64
- // проверить рейтинг хостера
65
- registerHost({ name, maxPlayers, mapName, gameId, gameVersion, token }) {
71
+ // хост → регистрация комнаты у мастера (ответ — событие 'host_registered'
72
+ // с roomId/epoch/roomSecret). token — Bearer identity-токен хоста: без
73
+ // него мастер отклоняет регистрацию (комната привязана к проверенному
74
+ // пользователю)
75
+ // info — строка карточки лобби (gameConfig.lobbyInfo игры), null — нет;
76
+ // caps — возможности вкладки (host-migration этап 6, lib/hostCaps.js);
77
+ // settings — настройки комнаты для холодного перезапуска преемником
78
+ // (этап 7.6, lib/roomSettings.js); promotion — { roomId, epoch,
79
+ // promotionToken } из promote мастера (этап 7.4): преемник занимает
80
+ // существующую комнату, а не создаёт новую
81
+ registerHost({
82
+ gameId,
83
+ gameVersion,
84
+ maxPlayers,
85
+ info,
86
+ token,
87
+ memberId,
88
+ caps,
89
+ settings,
90
+ promotion = null,
91
+ }) {
66
92
  this._send({
67
93
  type: 'register_host',
68
- name,
69
- maxPlayers,
70
- mapName,
71
94
  gameId,
72
95
  gameVersion,
96
+ maxPlayers,
97
+ info,
98
+ token,
99
+ memberId,
100
+ caps,
101
+ settings,
102
+ ...(promotion
103
+ ? {
104
+ roomId: promotion.roomId,
105
+ epoch: promotion.epoch,
106
+ promotionToken: promotion.promotionToken,
107
+ }
108
+ : {}),
109
+ });
110
+ }
111
+
112
+ // хост → та же комната после реконнекта сигналинга или рестарта мастера.
113
+ // Поля комнаты (игра, вместимость, карточка) нужны мастеру, чтобы создать её
114
+ // заново, если реестр пуст
115
+ reclaimHost({
116
+ roomId,
117
+ epoch,
118
+ roomSecret,
119
+ memberId,
120
+ token,
121
+ gameId,
122
+ gameVersion,
123
+ maxPlayers,
124
+ info,
125
+ caps,
126
+ settings,
127
+ }) {
128
+ this._send({
129
+ type: 'reclaim_host',
130
+ roomId,
131
+ epoch,
132
+ roomSecret,
133
+ memberId,
73
134
  token,
135
+ gameId,
136
+ gameVersion,
137
+ maxPlayers,
138
+ info,
139
+ caps,
140
+ settings,
141
+ });
142
+ }
143
+
144
+ // хост → актуализация карточки комнаты (заодно heartbeat); info: null
145
+ // очищает строку у мастера
146
+ updateHost({ info } = {}) {
147
+ this._send({ type: 'update_host', info });
148
+ }
149
+
150
+ // гость → вошёл в матч комнаты (ответ — 'room_joined'); повторяется с тем
151
+ // же memberId после реконнекта сигналинга
152
+ // caps — возможности вкладки: по ним мастер выбирает преемника хоста
153
+ joinRoom({ roomId, memberId, token, caps }) {
154
+ this._send({ type: 'join_room', roomId, memberId, token, caps });
155
+ }
156
+
157
+ // участник → возможности изменились (вкладка спрятана/показана, сменился
158
+ // тип ICE-кандидата) — host-migration этап 6
159
+ memberUpdate(roomId, caps) {
160
+ this._send({ type: 'member_update', roomId, caps });
161
+ }
162
+
163
+ // бета → у неё есть полная контрольная точка (id и свежесть: ageMs —
164
+ // сколько назад бета её получила)
165
+ standbyStatus({ roomId, epoch, checkpointId, createdAt, ageMs }) {
166
+ this._send({
167
+ type: 'standby_status',
168
+ roomId,
169
+ epoch,
170
+ checkpointId,
171
+ createdAt,
172
+ ageMs,
173
+ });
174
+ }
175
+
176
+ // хост → участники с открытыми каналами к нему: мастер считает игроков
177
+ // и кандидатов в беты только по ним
178
+ roomPeers({ roomId, epoch, memberIds }) {
179
+ this._send({ type: 'room_peers', roomId, epoch, memberIds });
180
+ }
181
+
182
+ // хост → ответ на пробу мастера (host-migration этап 7.3): главный поток
183
+ // жив и держит сигналинг
184
+ probeAck(nonce) {
185
+ this._send({ type: 'probe_ack', nonce });
186
+ }
187
+
188
+ // гость → WebRTC к хосту комнаты оборвался (host-migration этап 7.2):
189
+ // свидетельство мастеру, он решает — проба хоста или смена
190
+ hostUnreachable(roomId, epoch) {
191
+ this._send({ type: 'host_unreachable', roomId, epoch });
192
+ }
193
+
194
+ // преемник → поднять матч не удалось (плагин, wasm, init): мастер берёт
195
+ // следующего кандидата (host-migration этап 7.4)
196
+ promoteFailed({ roomId, epoch, promotionToken }) {
197
+ this._send({ type: 'promote_failed', roomId, epoch, promotionToken });
198
+ }
199
+
200
+ // хост → плановая передача роли бете (host-migration этап 8): ответ —
201
+ // handoff_go {epoch} или handoff_unavailable {reason}. stay — останется ли
202
+ // вкладка в комнате гостем (мастеру не нужно, для журнала)
203
+ handoffBegin({ roomId, epoch, reason, stay }) {
204
+ this._send({ type: 'handoff_begin', roomId, epoch, reason, stay });
205
+ }
206
+
207
+ // хост → здоровье матча (host-migration этап 9c, раз в ~2 с): мастер
208
+ // сравнивает сеть хоста и беты и при стойком лаге шлёт request_handoff
209
+ hostHealth({ roomId, epoch, tickRate, peerRttMedian, peerCount }) {
210
+ this._send({
211
+ type: 'host_health',
212
+ roomId,
213
+ epoch,
214
+ tickRate,
215
+ peerRttMedian,
216
+ peerCount,
74
217
  });
75
218
  }
76
219
 
77
- // хост → актуализация комнаты (заодно heartbeat)
78
- updateHost({ currentPlayers, mapName } = {}) {
79
- this._send({ type: 'update_host', currentPlayers, mapName });
220
+ // хост → вкладка уходит (pagehide, «Leave server» без передачи): мастер
221
+ // начинает аварийную миграцию сразу, не дожидаясь обрыва WS
222
+ hostLeaving(roomId, epoch) {
223
+ this._send({ type: 'host_leaving', roomId, epoch });
224
+ }
225
+
226
+ // хост → в комнате нет людей, он пишет очки перед закрытием: мастер
227
+ // скрывает комнату и закрывает её для входа до host_leaving
228
+ hostClosing(roomId, epoch) {
229
+ this._send({ type: 'host_closing', roomId, epoch });
230
+ }
231
+
232
+ // гость → покинул комнату
233
+ leaveRoom(roomId) {
234
+ this._send({ type: 'leave_room', roomId });
235
+ }
236
+
237
+ // голосование «Change host» (host-migration этап 10): голоса считает мастер
238
+ hostVoteStart(roomId) {
239
+ this._send({ type: 'host_vote_start', roomId });
240
+ }
241
+
242
+ hostVoteAnswer({ roomId, voteId, value }) {
243
+ this._send({ type: 'host_vote_answer', roomId, voteId, value });
80
244
  }
81
245
 
82
246
  // хост → SDP-ответ конкретному клиенту
@@ -89,25 +253,14 @@ export default class SignalingClient {
89
253
  this._send({ type: 'pong_host', clientId, pingId });
90
254
  }
91
255
 
92
- // обмен ICE-кандидатами (targetId — hostId со стороны клиента)
256
+ // обмен ICE-кандидатами (targetId — roomId со стороны клиента)
93
257
  sendIceCandidate(targetId, candidate) {
94
258
  this._send({ type: 'ice_candidate', targetId, candidate });
95
259
  }
96
260
 
97
261
  // сигнальный ping хосту (замер приблизительный: клиент→мастер→хост)
98
- pingHost(hostId, pingId) {
99
- this._send({ type: 'ping_host', hostId, pingId });
100
- }
101
-
102
- // /like·/unlike напрямую мастеру (минуя хоста-читера), server-rating
103
- // этап 2: reason — причина (обязательна), token — Bearer identity-токен
104
- // голосующего
105
- likeHost(hostId, reason, token) {
106
- this._send({ type: 'like_host', hostId, reason, token });
107
- }
108
-
109
- unlikeHost(hostId, reason, token) {
110
- this._send({ type: 'unlike_host', hostId, reason, token });
262
+ pingHost(roomId, pingId) {
263
+ this._send({ type: 'ping_host', roomId, pingId });
111
264
  }
112
265
 
113
266
  close() {
@@ -0,0 +1,287 @@
1
+ import hostDefaults from '../../config/hostDefaults.js';
2
+ import wsports from '../../config/wsports.js';
3
+ import Publisher from '../../lib/Publisher.js';
4
+ import { decodeStandbyChunk, parseStandbyStream } from './standbyChunks.js';
5
+
6
+ const PS_SHOT_DATA = wsports.server.SHOT_DATA;
7
+
8
+ // заголовок бинарного кадра: u8 port, u8 version, u32 seq (big-endian)
9
+ const FRAME_SEQ_OFFSET = 2;
10
+ const FRAME_MIN_BYTES = FRAME_SEQ_OFFSET + 4;
11
+
12
+ // Преемник (бета, host-migration этап 6): собирает куски контрольных точек
13
+ // из канала standby и держит последнюю полную. Незавершённая точка
14
+ // выбрасывается, как только пошла более новая (в канале ordered куски одной
15
+ // точки идут подряд). Заодно помнит seq последнего кадра хоста, который
16
+ // видел этот клиент, — seqFloor восстановленного матча (этап 5).
17
+ //
18
+ // События publisher: 'checkpoint' (последняя полная точка).
19
+ export default class StandbyReceiver {
20
+ /**
21
+ * @param {Object} [options]
22
+ * @param {number} [options.maxBytes] - предел логического потока точки.
23
+ * @param {Function} [options.now] - часы (мс).
24
+ * @param {Object} [options.timers] - { setTimeout, clearTimeout } (тесты).
25
+ */
26
+ constructor({
27
+ maxBytes = hostDefaults.maxCheckpointBytes,
28
+ now = () => Date.now(),
29
+ timers = globalThis,
30
+ } = {}) {
31
+ this._maxBytes = maxBytes;
32
+ this._now = now;
33
+ this._timers = timers;
34
+
35
+ this._channel = null;
36
+ this._partial = null;
37
+ this._latest = null;
38
+ this._lastSeenSeq = null;
39
+ this._waiters = new Set();
40
+ // передача сорвалась раньше, чем дошла её финальная точка: та ещё может
41
+ // доехать (ждала разгрузки канала у хоста) и должна прийти периодической
42
+ this._expectStaleFinal = false;
43
+
44
+ this.publisher = new Publisher();
45
+ }
46
+
47
+ // seq последнего кадра хоста, который видел этот клиент (null — ни одного)
48
+ get lastSeenSeq() {
49
+ return this._lastSeenSeq;
50
+ }
51
+
52
+ /**
53
+ * Последняя полная точка.
54
+ * @returns {Object|null} { bytes, checkpointId, wireId, seq, createdAt,
55
+ * final, mode, game, receivedAt }; game — { id, version } из
56
+ * дескриптора или null (хост его не прислал).
57
+ */
58
+ latest() {
59
+ return this._latest;
60
+ }
61
+
62
+ /**
63
+ * Подключает канал standby (новый канал заменяет прежний).
64
+ * @param {RTCDataChannel} channel
65
+ */
66
+ attach(channel) {
67
+ this._detachChannel();
68
+ this._channel = channel;
69
+ channel.binaryType = 'arraybuffer';
70
+ channel.onmessage = event => this.receive(event.data);
71
+ channel.onclose = () => {
72
+ if (this._channel === channel) {
73
+ this._detachChannel();
74
+ }
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Кадр хоста из игрового транспорта: запоминает его seq.
80
+ * @param {ArrayBuffer} data
81
+ */
82
+ noteFrame(data) {
83
+ if (!(data instanceof ArrayBuffer) || data.byteLength < FRAME_MIN_BYTES) {
84
+ return;
85
+ }
86
+
87
+ const view = new DataView(data);
88
+
89
+ if (view.getUint8(0) !== PS_SHOT_DATA) {
90
+ return;
91
+ }
92
+
93
+ this._lastSeenSeq = view.getUint32(FRAME_SEQ_OFFSET, false);
94
+ }
95
+
96
+ /**
97
+ * Один кусок из канала.
98
+ * @param {ArrayBuffer|Uint8Array} raw
99
+ */
100
+ receive(raw) {
101
+ const chunk = decodeStandbyChunk(raw);
102
+
103
+ if (!chunk || chunk.totalBytes > this._maxBytes) {
104
+ return;
105
+ }
106
+
107
+ // устаревшее: точка не новее уже собранной
108
+ if (this._latest && chunk.wireId <= this._latest.wireId) {
109
+ return;
110
+ }
111
+
112
+ // периодическая, снятая не позже финальной, её не вытесняет: бета
113
+ // поднимает матч из финальной (тот же тик, без отката)
114
+ if (
115
+ this._latest?.final &&
116
+ !chunk.final &&
117
+ Number.isInteger(chunk.seq) &&
118
+ chunk.seq <= this._latest.seq
119
+ ) {
120
+ return;
121
+ }
122
+
123
+ let partial = this._partial;
124
+
125
+ if (partial && chunk.wireId < partial.wireId) {
126
+ return;
127
+ }
128
+
129
+ if (!partial || chunk.wireId > partial.wireId) {
130
+ // пошла более новая — незавершённая выбрасывается
131
+ if (chunk.index !== 0) {
132
+ this._partial = null;
133
+ return;
134
+ }
135
+
136
+ partial = {
137
+ wireId: chunk.wireId,
138
+ count: chunk.count,
139
+ totalBytes: chunk.totalBytes,
140
+ seq: chunk.seq,
141
+ final: chunk.final,
142
+ buffer: new Uint8Array(chunk.totalBytes),
143
+ offset: 0,
144
+ next: 0,
145
+ };
146
+ this._partial = partial;
147
+ }
148
+
149
+ if (
150
+ chunk.index !== partial.next ||
151
+ chunk.count !== partial.count ||
152
+ chunk.totalBytes !== partial.totalBytes ||
153
+ partial.offset + chunk.data.byteLength > partial.totalBytes
154
+ ) {
155
+ this._partial = null; // дыра или несогласованный кусок — точка битая
156
+ return;
157
+ }
158
+
159
+ partial.buffer.set(chunk.data, partial.offset);
160
+ partial.offset += chunk.data.byteLength;
161
+ partial.next += 1;
162
+
163
+ if (partial.next < partial.count) {
164
+ return;
165
+ }
166
+
167
+ this._partial = null;
168
+
169
+ if (partial.offset !== partial.totalBytes) {
170
+ return;
171
+ }
172
+
173
+ const parsed = parseStandbyStream(partial.buffer);
174
+
175
+ if (!parsed) {
176
+ return;
177
+ }
178
+
179
+ // канал ordered, а хост после финальной не шлёт периодических, пока она
180
+ // не уйдёт: опоздавшая финальная сорвавшейся передачи — первая точка
181
+ // после discardFinal, если она вообще будет
182
+ const final = partial.final && !this._expectStaleFinal;
183
+
184
+ this._expectStaleFinal = false;
185
+ this._latest = {
186
+ ...parsed,
187
+ wireId: partial.wireId,
188
+ seq: partial.seq,
189
+ final,
190
+ receivedAt: this._now(),
191
+ };
192
+
193
+ this.publisher.emit('checkpoint', this._latest);
194
+
195
+ if (final) {
196
+ for (const waiter of [...this._waiters]) {
197
+ if (partial.wireId >= waiter.minWireId) {
198
+ waiter.resolve(this._latest);
199
+ }
200
+ }
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Ждёт финальную точку (плановая передача, этап 8).
206
+ * @param {number} [minWireId] - не старше этого номера точки; уже
207
+ * полученная финальная подходит сразу.
208
+ * @param {number} [timeoutMs]
209
+ * @returns {Promise<Object>} Точка, как latest().
210
+ */
211
+ waitForFinal(minWireId = 0, timeoutMs = 5000) {
212
+ const latest = this._latest;
213
+
214
+ if (latest?.final && latest.wireId >= minWireId) {
215
+ return Promise.resolve(latest);
216
+ }
217
+
218
+ return new Promise((resolve, reject) => {
219
+ const waiter = {
220
+ minWireId,
221
+ resolve: checkpoint => {
222
+ this._timers.clearTimeout(waiter.timer);
223
+ this._waiters.delete(waiter);
224
+ resolve(checkpoint);
225
+ },
226
+ reject: error => {
227
+ this._timers.clearTimeout(waiter.timer);
228
+ this._waiters.delete(waiter);
229
+ reject(error);
230
+ },
231
+ timer: null,
232
+ };
233
+
234
+ waiter.timer = this._timers.setTimeout(
235
+ () => waiter.reject(new Error('final checkpoint timed out')),
236
+ timeoutMs,
237
+ );
238
+ this._waiters.add(waiter);
239
+ });
240
+ }
241
+
242
+ /**
243
+ * Плановая передача сорвалась (promote_cancelled): её финальная точка
244
+ * остаётся последней, но следующую передачу не завершит — хост разморожен
245
+ * и пришлёт новую финальную. Ещё не дошедшая финальная примется
246
+ * периодической.
247
+ */
248
+ discardFinal() {
249
+ if (this._latest?.final) {
250
+ this._latest = { ...this._latest, final: false };
251
+ } else {
252
+ this._expectStaleFinal = true;
253
+ }
254
+ }
255
+
256
+ // standby_released, уход из комнаты, смена эпохи: точки выбросить
257
+ destroy() {
258
+ this._detachChannel();
259
+ this._partial = null;
260
+ this._latest = null;
261
+ this._expectStaleFinal = false;
262
+
263
+ for (const waiter of [...this._waiters]) {
264
+ waiter.reject(new Error('standby released'));
265
+ }
266
+ }
267
+
268
+ _detachChannel() {
269
+ const channel = this._channel;
270
+
271
+ this._channel = null;
272
+ this._partial = null;
273
+
274
+ if (!channel) {
275
+ return;
276
+ }
277
+
278
+ channel.onmessage = null;
279
+ channel.onclose = null;
280
+
281
+ try {
282
+ channel.close();
283
+ } catch {
284
+ // уже закрыт
285
+ }
286
+ }
287
+ }