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