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.
- 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
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
import wsports from '../../config/wsports.js';
|
|
2
|
+
|
|
3
|
+
// Супервизор сессии клиента (host-migration этап 4). Сессия — рендер, MVC,
|
|
4
|
+
// клиентское ядро, карта, свой gameId — живёт дольше конкретного
|
|
5
|
+
// транспорта: обрыв WebRTC в матче не перезагружает страницу, а ведёт к
|
|
6
|
+
// переподключению к текущему хосту комнаты (мастер маршрутизирует оффер по
|
|
7
|
+
// roomId) и возобновлению места RESUME_REQUEST'ом, без повторного
|
|
8
|
+
// рукопожатия. Смена хоста (этап 7) идёт тем же путём: host_migrating
|
|
9
|
+
// замораживает сессию, host_changed возобновляет её у нового хоста.
|
|
10
|
+
//
|
|
11
|
+
// Без DOM: транспорт и часы инъектируются, решения о перезагрузке и оверлее
|
|
12
|
+
// принимает владелец (client/main.js) по колбэкам.
|
|
13
|
+
//
|
|
14
|
+
// Состояния: connecting → handshake → inGame → reconnecting → inGame | closed;
|
|
15
|
+
// любое живое → migrating → reconnecting (новый хост) | closed.
|
|
16
|
+
|
|
17
|
+
const PC_RESUME_REQUEST = wsports.client.RESUME_REQUEST;
|
|
18
|
+
|
|
19
|
+
// версия формата RESUME_REQUEST (хост отвечает 'version' на чужую)
|
|
20
|
+
const RESUME_VERSION = 1;
|
|
21
|
+
|
|
22
|
+
// предел ожидания смены хоста, продлённого мастером (host_migrating.waitMs):
|
|
23
|
+
// мусорное поле не держит гостя в замороженной сессии бесконечно
|
|
24
|
+
const MAX_MIGRATION_WAIT_MS = 120000;
|
|
25
|
+
|
|
26
|
+
const clampWait = waitMs =>
|
|
27
|
+
Number.isFinite(waitMs)
|
|
28
|
+
? Math.min(Math.max(waitMs, 0), MAX_MIGRATION_WAIT_MS)
|
|
29
|
+
: 0;
|
|
30
|
+
|
|
31
|
+
export const SESSION_STATES = Object.freeze({
|
|
32
|
+
connecting: 'connecting',
|
|
33
|
+
handshake: 'handshake',
|
|
34
|
+
inGame: 'inGame',
|
|
35
|
+
reconnecting: 'reconnecting',
|
|
36
|
+
migrating: 'migrating',
|
|
37
|
+
closed: 'closed',
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const S = SESSION_STATES;
|
|
41
|
+
|
|
42
|
+
const defaultClock = {
|
|
43
|
+
now: () => performance.now(),
|
|
44
|
+
setTimeout: (fn, ms) => setTimeout(fn, ms),
|
|
45
|
+
clearTimeout: id => clearTimeout(id),
|
|
46
|
+
setInterval: (fn, ms) => setInterval(fn, ms),
|
|
47
|
+
clearInterval: id => clearInterval(id),
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export default class SessionSupervisor {
|
|
51
|
+
/**
|
|
52
|
+
* @param {Object} opts
|
|
53
|
+
* @param {Function} opts.onMessage - входящее сообщение текущего
|
|
54
|
+
* транспорта (строка или ArrayBuffer).
|
|
55
|
+
* @param {Function} opts.onTerminal - (closeCode) сессия закрыта
|
|
56
|
+
* окончательно: путь прежнего handleDisconnect.
|
|
57
|
+
* @param {Function} [opts.onStateChange] - (state, prev).
|
|
58
|
+
* @param {Function} [opts.onResumed] - место возвращено (RESUME_RESULT ok).
|
|
59
|
+
* @param {Function} [opts.onResumeRejected] - (reason) хост отказал.
|
|
60
|
+
* @param {Function} [opts.onHostLost] - транспорт к хосту оборвался в
|
|
61
|
+
* матче (до переподключения): свидетельство мастеру (host_unreachable).
|
|
62
|
+
* @param {Function} [opts.onColdRestart] - новый хост поднял матч заново
|
|
63
|
+
* (host_changed cold): возобновлять нечего, только чистый вход.
|
|
64
|
+
* @param {Function} [opts.isTerminalClose] - (closeCode) закрытие по
|
|
65
|
+
* политике или кику: возвращаться некуда.
|
|
66
|
+
* @param {Object|null} [opts.reconnect] - null — переподключения нет
|
|
67
|
+
* (solo, dedicated, вкладка-хост на loopback). Иначе:
|
|
68
|
+
* { createTransport() → новый транспорт с resume (уже connect'нутый),
|
|
69
|
+
* getToken() → identity-токен для RESUME_REQUEST }.
|
|
70
|
+
* @param {Object} [opts.timing] - { reconnectWindowMs, reconnectBaseDelayMs,
|
|
71
|
+
* reconnectMaxDelayMs, hostSilenceMs, migrationWaitMs,
|
|
72
|
+
* resumeSilenceGraceMs }
|
|
73
|
+
* (config/lobby.js → session).
|
|
74
|
+
* @param {Object} [opts.clock] - часы и таймеры (тесты).
|
|
75
|
+
*/
|
|
76
|
+
constructor({
|
|
77
|
+
onMessage,
|
|
78
|
+
onTerminal,
|
|
79
|
+
onStateChange = () => {},
|
|
80
|
+
onResumed = () => {},
|
|
81
|
+
onResumeRejected = () => {},
|
|
82
|
+
onHostLost = () => {},
|
|
83
|
+
onColdRestart = () => {},
|
|
84
|
+
isTerminalClose = () => false,
|
|
85
|
+
reconnect = null,
|
|
86
|
+
timing = {},
|
|
87
|
+
clock = defaultClock,
|
|
88
|
+
}) {
|
|
89
|
+
this._onMessage = onMessage;
|
|
90
|
+
this._onTerminal = onTerminal;
|
|
91
|
+
this._onStateChange = onStateChange;
|
|
92
|
+
this._onResumed = onResumed;
|
|
93
|
+
this._onResumeRejected = onResumeRejected;
|
|
94
|
+
this._onHostLost = onHostLost;
|
|
95
|
+
this._onColdRestart = onColdRestart;
|
|
96
|
+
this._isTerminalClose = isTerminalClose;
|
|
97
|
+
this._reconnect = reconnect;
|
|
98
|
+
this._getToken = reconnect?.getToken ?? null;
|
|
99
|
+
this._clock = clock;
|
|
100
|
+
|
|
101
|
+
this._windowMs = timing.reconnectWindowMs ?? 15000;
|
|
102
|
+
this._baseDelayMs = timing.reconnectBaseDelayMs ?? 500;
|
|
103
|
+
this._maxDelayMs = timing.reconnectMaxDelayMs ?? 4000;
|
|
104
|
+
this._silenceMs = timing.hostSilenceMs ?? 3000;
|
|
105
|
+
this._migrationWaitMs = timing.migrationWaitMs ?? 20000;
|
|
106
|
+
this._resumeSilenceGraceMs = timing.resumeSilenceGraceMs ?? 3000;
|
|
107
|
+
|
|
108
|
+
this._state = S.connecting;
|
|
109
|
+
this._transport = null;
|
|
110
|
+
this._handlers = null;
|
|
111
|
+
|
|
112
|
+
// { gameId, resumeKey } из SESSION_DATA — без них возвращаться некуда
|
|
113
|
+
this._session = null;
|
|
114
|
+
this._userLeft = false;
|
|
115
|
+
this._loading = false;
|
|
116
|
+
this._resuming = false;
|
|
117
|
+
|
|
118
|
+
this._lastMessageAt = 0;
|
|
119
|
+
this._watchdog = null;
|
|
120
|
+
// возобновились, кадра ещё не было: восстановленный матч законно стоит,
|
|
121
|
+
// ожидая остальных (hostDefaults.resumeWaitMs) — сторожку фора
|
|
122
|
+
this._awaitingFrame = false;
|
|
123
|
+
|
|
124
|
+
this._attempt = 0;
|
|
125
|
+
this._attemptTimer = null;
|
|
126
|
+
this._windowTimer = null;
|
|
127
|
+
this._migrationTimer = null;
|
|
128
|
+
this._migrationDeadline = 0;
|
|
129
|
+
// resumeWith({ onFailed }): сбой этого возврата — не конец сессии для
|
|
130
|
+
// владельца, а его отдельный случай (преемник уже хост комнаты)
|
|
131
|
+
this._onRoleResumeFailed = null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
get state() {
|
|
135
|
+
return this._state;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
get transport() {
|
|
139
|
+
return this._transport;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// есть ли секрет места (SESSION_DATA) — без него возвращаться не с чем
|
|
143
|
+
get hasSession() {
|
|
144
|
+
return this._session !== null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// место возвращено, пакет входа ещё не дочитан (до SESSION_DATA): первый
|
|
148
|
+
// кадр в это время — не повод слать FIRST_SHOT_READY и автостарт
|
|
149
|
+
get resuming() {
|
|
150
|
+
return this._resuming;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Берёт транспорт под надзор (первичное подключение).
|
|
155
|
+
* @param {Object} transport - { publisher, send, close }.
|
|
156
|
+
*/
|
|
157
|
+
attach(transport) {
|
|
158
|
+
this._setTransport(transport);
|
|
159
|
+
this._setState(S.connecting);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Исходящее сообщение текущему транспорту.
|
|
164
|
+
*/
|
|
165
|
+
send(data, reliable = true) {
|
|
166
|
+
this._transport?.send(data, reliable);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Пользователь уходит сам (или сессию закрывает политика): закрытие
|
|
171
|
+
* транспорта терминально, переподключения не будет.
|
|
172
|
+
*/
|
|
173
|
+
close() {
|
|
174
|
+
this._userLeft = true;
|
|
175
|
+
|
|
176
|
+
if (this._state === S.reconnecting || this._state === S.migrating) {
|
|
177
|
+
this._dropTransport();
|
|
178
|
+
this._terminate();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (this._transport) {
|
|
183
|
+
this._transport.close();
|
|
184
|
+
} else {
|
|
185
|
+
this._terminate();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Первый кадр матча применён — сессия в игре.
|
|
191
|
+
*/
|
|
192
|
+
enterGame() {
|
|
193
|
+
if (this._state === S.connecting || this._state === S.handshake) {
|
|
194
|
+
this._setState(S.inGame);
|
|
195
|
+
this._startWatchdog();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Загрузка карты (MAP_DATA … FIRST_SHOT): хост в это время законно
|
|
201
|
+
* молчит, сторожок тишины не считает.
|
|
202
|
+
*/
|
|
203
|
+
setLoading(loading) {
|
|
204
|
+
this._loading = loading;
|
|
205
|
+
this._lastMessageAt = this._clock.now();
|
|
206
|
+
|
|
207
|
+
// новая карта — обычный путь входа: пакет возобновления закончен
|
|
208
|
+
if (loading) {
|
|
209
|
+
this._resuming = false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* SESSION_DATA { resumeKey, gameId }: секрет места. Возвращает true, если
|
|
215
|
+
* это последнее сообщение пакета возобновления.
|
|
216
|
+
*/
|
|
217
|
+
setSession({ resumeKey, gameId } = {}) {
|
|
218
|
+
if (typeof resumeKey === 'string' && resumeKey !== '') {
|
|
219
|
+
this._session = { resumeKey, gameId };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const finishedResume = this._resuming;
|
|
223
|
+
|
|
224
|
+
this._resuming = false;
|
|
225
|
+
|
|
226
|
+
return finishedResume;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* RESUME_RESULT от хоста.
|
|
231
|
+
* @param {Object} result - { ok, gameId, epoch } | { ok: false, reason }.
|
|
232
|
+
*/
|
|
233
|
+
resumeResult(result) {
|
|
234
|
+
if (this._state !== S.reconnecting) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (result?.ok === true) {
|
|
239
|
+
this._clearReconnectTimers();
|
|
240
|
+
this._onRoleResumeFailed = null;
|
|
241
|
+
this._resuming = true;
|
|
242
|
+
this._awaitingFrame = true;
|
|
243
|
+
this._lastMessageAt = this._clock.now();
|
|
244
|
+
this._setState(S.inGame);
|
|
245
|
+
this._startWatchdog();
|
|
246
|
+
this._onResumed(result);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// место потеряно — дальнейшие попытки бессмысленны
|
|
251
|
+
this._clearReconnectTimers();
|
|
252
|
+
this._dropTransport();
|
|
253
|
+
|
|
254
|
+
if (this._failRoleResume(result?.reason ?? 'unknown')) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
this._setState(S.closed);
|
|
259
|
+
this._onResumeRejected(result?.reason ?? 'unknown');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// сбой возврата, начатого resumeWith({ onFailed }): сессия закрыта, но
|
|
263
|
+
// путь владельца — его колбэк, а не onResumeRejected/onTerminal
|
|
264
|
+
_failRoleResume(reason) {
|
|
265
|
+
const onFailed = this._onRoleResumeFailed;
|
|
266
|
+
|
|
267
|
+
if (!onFailed) {
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
this._onRoleResumeFailed = null;
|
|
272
|
+
this._stopWatchdog();
|
|
273
|
+
this._clearReconnectTimers();
|
|
274
|
+
this._setState(S.closed);
|
|
275
|
+
onFailed(reason);
|
|
276
|
+
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ***** смена хоста (host-migration этап 7) ***** //
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* host_migrating от мастера: хост комнаты сменяется. Транспорт к старому
|
|
284
|
+
* хосту закрывается сразу (pc.close, без ожидания таймаутов ICE), и всё,
|
|
285
|
+
* что от него ещё придёт, отбрасывается: у клиента не должно быть двух
|
|
286
|
+
* источников кадров ни на миг (зомби-хост эпохи N и новый хост N+1).
|
|
287
|
+
* Ждём host_changed не дольше migrationWaitMs.
|
|
288
|
+
* @param {Object} [options]
|
|
289
|
+
* @param {boolean} [options.keepTransport] - транспорт не закрывать: так
|
|
290
|
+
* делает только бета плановой передачи — поверх того же соединения с
|
|
291
|
+
* замороженным хостом идёт канал standby с финальной точкой. Кадры
|
|
292
|
+
* транспорта всё равно отбрасываются, его закрытие не терминально;
|
|
293
|
+
* бросят его host_changed или resumeWith.
|
|
294
|
+
* @param {number} [options.waitMs] - host_migrating.waitMs: сколько мастер
|
|
295
|
+
* ещё ищет преемника; меньше migrationWaitMs ожидание не укорачивает.
|
|
296
|
+
* @returns {boolean} сессия перешла в ожидание.
|
|
297
|
+
*/
|
|
298
|
+
migrate({ keepTransport = false, waitMs } = {}) {
|
|
299
|
+
if (
|
|
300
|
+
this._reconnect === null ||
|
|
301
|
+
this._userLeft ||
|
|
302
|
+
this._state === S.closed ||
|
|
303
|
+
this._state === S.migrating
|
|
304
|
+
) {
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
this._stopWatchdog();
|
|
309
|
+
this._clearReconnectTimers();
|
|
310
|
+
|
|
311
|
+
if (!keepTransport) {
|
|
312
|
+
this._dropTransport();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
this._resuming = false;
|
|
316
|
+
this._awaitingFrame = false;
|
|
317
|
+
this._setState(S.migrating);
|
|
318
|
+
this._armMigrationTimer(Math.max(this._migrationWaitMs, clampWait(waitMs)));
|
|
319
|
+
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Повторный host_migrating той же эпохи (следующий кандидат, деградация
|
|
325
|
+
* передачи): ожидание продлевается до waitMs, но не укорачивается.
|
|
326
|
+
* @param {number} waitMs
|
|
327
|
+
* @returns {boolean} срок продлён.
|
|
328
|
+
*/
|
|
329
|
+
extendMigration(waitMs) {
|
|
330
|
+
if (this._state !== S.migrating) {
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const left = Math.max(0, this._migrationDeadline - this._clock.now());
|
|
335
|
+
const wanted = clampWait(waitMs);
|
|
336
|
+
|
|
337
|
+
if (wanted <= left) {
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
this._armMigrationTimer(wanted);
|
|
342
|
+
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
_armMigrationTimer(ms) {
|
|
347
|
+
this._clearMigrationTimer();
|
|
348
|
+
this._migrationDeadline = this._clock.now() + ms;
|
|
349
|
+
this._migrationTimer = this._clock.setTimeout(() => {
|
|
350
|
+
this._migrationTimer = null;
|
|
351
|
+
|
|
352
|
+
if (this._state === S.migrating) {
|
|
353
|
+
this._terminate();
|
|
354
|
+
}
|
|
355
|
+
}, ms);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Применён бинарный кадр хоста: восстановленный матч пошёл, фора сторожка
|
|
360
|
+
* снята.
|
|
361
|
+
*/
|
|
362
|
+
noteFrame() {
|
|
363
|
+
this._awaitingFrame = false;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* host_changed {mode} от мастера: у комнаты новый (или вернувшийся) хост.
|
|
368
|
+
* checkpoint/planned/reclaimed — матч продолжается, место возвращается
|
|
369
|
+
* RESUME'ом у нового хоста (окно и бэкофф — как при обрыве); cold — матч
|
|
370
|
+
* поднят заново, возобновлять нечего.
|
|
371
|
+
* @param {Object} msg - { mode }.
|
|
372
|
+
*/
|
|
373
|
+
hostChanged({ mode } = {}) {
|
|
374
|
+
if (
|
|
375
|
+
this._reconnect === null ||
|
|
376
|
+
this._userLeft ||
|
|
377
|
+
this._state === S.closed
|
|
378
|
+
) {
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// хост вернулся сам, а этот клиент миграции не ждал: его транспорт к
|
|
383
|
+
// тому же хосту (или попытки переподключения к нему) по-прежнему годны
|
|
384
|
+
if (mode === 'reclaimed' && this._state !== S.migrating) {
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// сообщение могло обогнать host_migrating (реконнект сигналинга) —
|
|
389
|
+
// ожидание не обязательно, но старый транспорт так же бросается
|
|
390
|
+
this._clearMigrationTimer();
|
|
391
|
+
this._stopWatchdog();
|
|
392
|
+
this._clearReconnectTimers();
|
|
393
|
+
this._dropTransport();
|
|
394
|
+
|
|
395
|
+
if (mode === 'cold') {
|
|
396
|
+
this._setState(S.closed);
|
|
397
|
+
this._onColdRestart();
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// без секрета места (хост сменился до SESSION_DATA) возвращаться не с
|
|
402
|
+
// чем — чистый вход в ту же комнату
|
|
403
|
+
if (this._session === null) {
|
|
404
|
+
this._setState(S.closed);
|
|
405
|
+
this._onResumeRejected('noSession');
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
this._startReconnect({ report: false });
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Вкладка сменила роль в комнате (host-migration этап 7.4/7.5): место
|
|
414
|
+
* возвращается RESUME'ом через заданный транспорт — loopback к Worker'у,
|
|
415
|
+
* который эта вкладка подняла преемником, или WebRTC к новому хосту у
|
|
416
|
+
* бывшего хоста. Окно возврата — как при обрыве.
|
|
417
|
+
* @param {Object} transport - уже connect'нутый, с resume.
|
|
418
|
+
* @param {Object} [options]
|
|
419
|
+
* @param {Object|null} [options.reconnect] - фабрика следующих попыток
|
|
420
|
+
* (как в конструкторе); null — других попыток нет (свой Worker:
|
|
421
|
+
* отказ loopback'а терминален), сторожок тишины выключен.
|
|
422
|
+
* @param {Function} [options.getToken] - identity-токен для
|
|
423
|
+
* RESUME_REQUEST (по умолчанию — reconnect.getToken).
|
|
424
|
+
* @param {Function} [options.onFailed] - (reason) вернуть место не
|
|
425
|
+
* вышло (нет секрета, отказ, закрытие, окно истекло): сессия закрыта,
|
|
426
|
+
* но вместо onResumeRejected/onTerminal зовётся он — преемник уже хост
|
|
427
|
+
* комнаты, и сбой своего игрока не должен её гасить.
|
|
428
|
+
* @returns {boolean} false — сессия уже закрыта.
|
|
429
|
+
*/
|
|
430
|
+
resumeWith(transport, { reconnect = null, getToken, onFailed = null } = {}) {
|
|
431
|
+
if (this._userLeft || this._state === S.closed) {
|
|
432
|
+
return false;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
this._clearMigrationTimer();
|
|
436
|
+
this._stopWatchdog();
|
|
437
|
+
this._clearReconnectTimers();
|
|
438
|
+
this._dropTransport();
|
|
439
|
+
|
|
440
|
+
this._reconnect = reconnect;
|
|
441
|
+
this._getToken = getToken ?? reconnect?.getToken ?? null;
|
|
442
|
+
this._onRoleResumeFailed = onFailed;
|
|
443
|
+
|
|
444
|
+
// без секрета места возвращаться не с чем — чистый вход
|
|
445
|
+
if (this._session === null) {
|
|
446
|
+
transport.close?.();
|
|
447
|
+
|
|
448
|
+
if (this._failRoleResume('noSession')) {
|
|
449
|
+
return true;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
this._setState(S.closed);
|
|
453
|
+
this._onResumeRejected('noSession');
|
|
454
|
+
return true;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
this._resuming = false;
|
|
458
|
+
this._attempt = 1;
|
|
459
|
+
this._setState(S.reconnecting);
|
|
460
|
+
this._startWindow();
|
|
461
|
+
this._setTransport(transport);
|
|
462
|
+
|
|
463
|
+
return true;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
_clearMigrationTimer() {
|
|
467
|
+
if (this._migrationTimer !== null) {
|
|
468
|
+
this._clock.clearTimeout(this._migrationTimer);
|
|
469
|
+
this._migrationTimer = null;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ***** транспорт ***** //
|
|
474
|
+
|
|
475
|
+
_setTransport(transport) {
|
|
476
|
+
this._dropTransport();
|
|
477
|
+
|
|
478
|
+
const handlers = {
|
|
479
|
+
message: data => this._handleMessage(transport, data),
|
|
480
|
+
close: closeCode => this._handleClose(transport, closeCode),
|
|
481
|
+
open: () => this._handleOpen(transport),
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
for (const [event, fn] of Object.entries(handlers)) {
|
|
485
|
+
transport.publisher.on(event, fn);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
this._transport = transport;
|
|
489
|
+
this._handlers = handlers;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// отписка от брошенного транспорта; сам транспорт закрывается тихо
|
|
493
|
+
// (destroy), если умеет, — его 'close' уже никому не нужен
|
|
494
|
+
_dropTransport() {
|
|
495
|
+
const transport = this._transport;
|
|
496
|
+
|
|
497
|
+
if (!transport) {
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
for (const [event, fn] of Object.entries(this._handlers)) {
|
|
502
|
+
transport.publisher.off(event, fn);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
this._transport = null;
|
|
506
|
+
this._handlers = null;
|
|
507
|
+
|
|
508
|
+
transport.destroy?.();
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
_handleMessage(transport, data) {
|
|
512
|
+
if (transport !== this._transport) {
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// сохранённый транспорт к замороженному хосту (keepTransport): у
|
|
517
|
+
// клиента не должно быть двух источников кадров
|
|
518
|
+
if (this._state === S.migrating) {
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
this._lastMessageAt = this._clock.now();
|
|
523
|
+
this._onMessage(data);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
_handleOpen(transport) {
|
|
527
|
+
if (transport !== this._transport) {
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (this._state === S.connecting) {
|
|
532
|
+
this._setState(S.handshake);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// каналы новой попытки открыты — предъявить место
|
|
537
|
+
if (this._state === S.reconnecting && this._session) {
|
|
538
|
+
transport.send(
|
|
539
|
+
JSON.stringify([
|
|
540
|
+
PC_RESUME_REQUEST,
|
|
541
|
+
{
|
|
542
|
+
v: RESUME_VERSION,
|
|
543
|
+
gameId: this._session.gameId,
|
|
544
|
+
resumeKey: this._session.resumeKey,
|
|
545
|
+
token: this._getToken?.() ?? null,
|
|
546
|
+
},
|
|
547
|
+
]),
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
_handleClose(transport, closeCode) {
|
|
553
|
+
if (transport !== this._transport) {
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
this._dropTransport();
|
|
558
|
+
|
|
559
|
+
// сохранённый транспорт (keepTransport) закрылся: исход миграции решают
|
|
560
|
+
// host_changed, promote или таймер ожидания
|
|
561
|
+
if (this._state === S.migrating) {
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (this._state === S.reconnecting) {
|
|
566
|
+
// транспорт, выданный resumeWith без фабрики попыток: повторять нечем
|
|
567
|
+
if (this._reconnect === null) {
|
|
568
|
+
if (!this._failRoleResume('closed')) {
|
|
569
|
+
this._terminate(closeCode);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
this._scheduleAttempt();
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (this._canResume(closeCode)) {
|
|
580
|
+
this._startReconnect();
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
this._terminate(closeCode);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// нетерминальным бывает только обрыв посреди матча, у которого есть место
|
|
588
|
+
// для возврата и кому возвращаться
|
|
589
|
+
_canResume(closeCode) {
|
|
590
|
+
return (
|
|
591
|
+
this._reconnect !== null &&
|
|
592
|
+
!this._userLeft &&
|
|
593
|
+
this._state === S.inGame &&
|
|
594
|
+
this._session !== null &&
|
|
595
|
+
!this._isTerminalClose(closeCode)
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// ***** переподключение ***** //
|
|
600
|
+
|
|
601
|
+
// report — обрыв замечен самим клиентом: мастер узнаёт о нём раньше,
|
|
602
|
+
// чем решит сам (после host_changed жаловаться не на что)
|
|
603
|
+
_startReconnect({ report = true } = {}) {
|
|
604
|
+
this._stopWatchdog();
|
|
605
|
+
this._resuming = false;
|
|
606
|
+
this._attempt = 0;
|
|
607
|
+
this._setState(S.reconnecting);
|
|
608
|
+
|
|
609
|
+
if (report) {
|
|
610
|
+
this._onHostLost();
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
this._startWindow();
|
|
614
|
+
this._runAttempt();
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// окно целиком: не успели вернуться — сессия закрыта
|
|
618
|
+
_startWindow() {
|
|
619
|
+
this._windowTimer = this._clock.setTimeout(() => {
|
|
620
|
+
this._windowTimer = null;
|
|
621
|
+
|
|
622
|
+
if (this._state === S.reconnecting) {
|
|
623
|
+
this._clearReconnectTimers();
|
|
624
|
+
this._dropTransport();
|
|
625
|
+
|
|
626
|
+
if (!this._failRoleResume('timeout')) {
|
|
627
|
+
this._terminate();
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}, this._windowMs);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
_runAttempt() {
|
|
634
|
+
this._attemptTimer = null;
|
|
635
|
+
|
|
636
|
+
if (this._state !== S.reconnecting) {
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
this._attempt += 1;
|
|
641
|
+
this._setTransport(this._reconnect.createTransport());
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
_scheduleAttempt() {
|
|
645
|
+
const delay = Math.min(
|
|
646
|
+
this._maxDelayMs,
|
|
647
|
+
this._baseDelayMs * 2 ** (this._attempt - 1),
|
|
648
|
+
);
|
|
649
|
+
|
|
650
|
+
this._attemptTimer = this._clock.setTimeout(
|
|
651
|
+
() => this._runAttempt(),
|
|
652
|
+
delay,
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
_clearReconnectTimers() {
|
|
657
|
+
if (this._attemptTimer !== null) {
|
|
658
|
+
this._clock.clearTimeout(this._attemptTimer);
|
|
659
|
+
this._attemptTimer = null;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
if (this._windowTimer !== null) {
|
|
663
|
+
this._clock.clearTimeout(this._windowTimer);
|
|
664
|
+
this._windowTimer = null;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// ***** сторожок тишины ***** //
|
|
669
|
+
|
|
670
|
+
// в матче хост шлёт кадры ~30/с и PING раз в 3 с: долгое молчание —
|
|
671
|
+
// транспорт мёртв, хотя браузер ещё не объявил 'failed'
|
|
672
|
+
_startWatchdog() {
|
|
673
|
+
if (this._reconnect === null || this._watchdog !== null) {
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
this._lastMessageAt = this._clock.now();
|
|
678
|
+
this._watchdog = this._clock.setInterval(
|
|
679
|
+
() => this._checkSilence(),
|
|
680
|
+
Math.max(250, Math.floor(this._silenceMs / 3)),
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
_stopWatchdog() {
|
|
685
|
+
if (this._watchdog !== null) {
|
|
686
|
+
this._clock.clearInterval(this._watchdog);
|
|
687
|
+
this._watchdog = null;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
_checkSilence() {
|
|
692
|
+
if (this._state !== S.inGame || this._loading || !this._transport) {
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const limitMs = this._awaitingFrame
|
|
697
|
+
? this._silenceMs + this._resumeSilenceGraceMs
|
|
698
|
+
: this._silenceMs;
|
|
699
|
+
|
|
700
|
+
if (this._clock.now() - this._lastMessageAt > limitMs) {
|
|
701
|
+
this._transport.close();
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// ***** завершение ***** //
|
|
706
|
+
|
|
707
|
+
_terminate(closeCode) {
|
|
708
|
+
if (this._state === S.closed) {
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
this._stopWatchdog();
|
|
713
|
+
this._clearReconnectTimers();
|
|
714
|
+
this._clearMigrationTimer();
|
|
715
|
+
this._onRoleResumeFailed = null;
|
|
716
|
+
this._setState(S.closed);
|
|
717
|
+
this._onTerminal(closeCode);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
_setState(state) {
|
|
721
|
+
const prev = this._state;
|
|
722
|
+
|
|
723
|
+
if (prev === state) {
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
this._state = state;
|
|
728
|
+
this._onStateChange(state, prev);
|
|
729
|
+
}
|
|
730
|
+
}
|