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
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
import { sanitizeRoomSettings } from '../../lib/roomSettings.js';
|
|
2
|
+
import HiddenHostHealthLog from '../network/HiddenHostHealthLog.js';
|
|
3
|
+
import HostConnectionManager from '../network/HostConnectionManager.js';
|
|
4
|
+
import HostController from '../network/HostController.js';
|
|
5
|
+
import HostHealthReporter from '../network/HostHealthReporter.js';
|
|
6
|
+
import LoopbackTransport from '../network/LoopbackTransport.js';
|
|
7
|
+
import RoomPeersReporter from '../network/RoomPeersReporter.js';
|
|
8
|
+
import StandbySender from '../network/StandbySender.js';
|
|
9
|
+
import { supportsModuleWorker } from '../network/workerSupport.js';
|
|
10
|
+
|
|
11
|
+
// Роль хоста (общая для создания комнаты и промоушена, host-migration этап
|
|
12
|
+
// 7.4): Worker комнаты, приём офферов, поток контрольных точек бете,
|
|
13
|
+
// регистрация у мастера и её возврат после реконнекта сигналинга,
|
|
14
|
+
// обновление карт и эстафета Worker'ов на новую версию кода.
|
|
15
|
+
//
|
|
16
|
+
// Без DOM: сигналинг, плановую передачу, промоушен, UI-действия и фабрики
|
|
17
|
+
// инъектирует сборщик (client/main.js).
|
|
18
|
+
|
|
19
|
+
// сравнимый ключ составного codeVersion (Этап 6.5): движок + игра —
|
|
20
|
+
// расхождение любой половины (деплой движка ИЛИ деплой игры) запускает
|
|
21
|
+
// эстафету
|
|
22
|
+
export function codeVersionKey(cv) {
|
|
23
|
+
return cv
|
|
24
|
+
? `${cv.engine ?? ''}:${cv.game?.id ?? ''}:${cv.game?.version ?? ''}`
|
|
25
|
+
: null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function codeVersionChanged(remote, local) {
|
|
29
|
+
return codeVersionKey(remote) !== codeVersionKey(local);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export default class HostRole {
|
|
33
|
+
/**
|
|
34
|
+
* @param {Object} deps
|
|
35
|
+
* @param {Object} deps.signaling - SignalingClient.
|
|
36
|
+
* @param {Object} deps.ctx - client/session/roomContext.js.
|
|
37
|
+
* @param {Object} deps.membership - client/session/Membership.js.
|
|
38
|
+
* @param {Object} deps.config - lobby-конфиг.
|
|
39
|
+
* @param {Object} deps.diagnostics
|
|
40
|
+
* @param {Object} deps.prep - client/session/hostRoomPrep.js.
|
|
41
|
+
* @param {Function} deps.getActiveGame - () → манифест активной игры.
|
|
42
|
+
* @param {Function} deps.getToken - () → identity-токен.
|
|
43
|
+
* @param {Function} deps.getHandoff - () → HandoffFlow.
|
|
44
|
+
* @param {Function} deps.onPromotionRegistered - (hostPromotion) комната
|
|
45
|
+
* занята преемником (PromotionFlow.finish).
|
|
46
|
+
* @param {Object} deps.ui - { showRoomLink(gameId, roomId),
|
|
47
|
+
* refreshRoomControls(), setHandoffMenu(state), isHidden(),
|
|
48
|
+
* informTech(text), ensureWebRtc() → bool, startSession(transport),
|
|
49
|
+
* closeLobby(), onStartFailed(msg) }.
|
|
50
|
+
* @param {boolean} [deps.isLobbyMode]
|
|
51
|
+
* @param {Function|null} [deps.debug] - debugLog dev-сборки.
|
|
52
|
+
* @param {Object} [deps.factories] - конструкторы (тесты).
|
|
53
|
+
* @param {Object} [deps.timers] - { setInterval, clearInterval }.
|
|
54
|
+
*/
|
|
55
|
+
constructor({
|
|
56
|
+
signaling,
|
|
57
|
+
ctx,
|
|
58
|
+
membership,
|
|
59
|
+
config,
|
|
60
|
+
diagnostics,
|
|
61
|
+
prep,
|
|
62
|
+
getActiveGame,
|
|
63
|
+
getToken,
|
|
64
|
+
getHandoff,
|
|
65
|
+
onPromotionRegistered,
|
|
66
|
+
ui,
|
|
67
|
+
isLobbyMode = true,
|
|
68
|
+
debug = null,
|
|
69
|
+
factories = {},
|
|
70
|
+
timers = globalThis,
|
|
71
|
+
}) {
|
|
72
|
+
this._signaling = signaling;
|
|
73
|
+
this._ctx = ctx;
|
|
74
|
+
this._membership = membership;
|
|
75
|
+
this._config = config;
|
|
76
|
+
this._diagnostics = diagnostics;
|
|
77
|
+
this._prep = prep;
|
|
78
|
+
this._getActiveGame = getActiveGame;
|
|
79
|
+
this._getToken = getToken;
|
|
80
|
+
this._getHandoff = getHandoff;
|
|
81
|
+
this._onPromotionRegistered = onPromotionRegistered;
|
|
82
|
+
this._ui = ui;
|
|
83
|
+
this._isLobbyMode = isLobbyMode;
|
|
84
|
+
this._debug = debug;
|
|
85
|
+
this._timers = timers;
|
|
86
|
+
this._factories = {
|
|
87
|
+
supportsModuleWorker,
|
|
88
|
+
controller: (room, options) => new HostController(room, options),
|
|
89
|
+
connections: (sig, controller, options) =>
|
|
90
|
+
new HostConnectionManager(sig, controller, options),
|
|
91
|
+
peersReporter: options => new RoomPeersReporter(options),
|
|
92
|
+
standbySender: options => new StandbySender(options),
|
|
93
|
+
hiddenLog: options => new HiddenHostHealthLog(options),
|
|
94
|
+
healthReporter: options => new HostHealthReporter(options),
|
|
95
|
+
loopback: (controller, socketId, options) =>
|
|
96
|
+
new LoopbackTransport(controller, socketId, options),
|
|
97
|
+
...factories,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
this._controller = null;
|
|
101
|
+
this._connections = null;
|
|
102
|
+
this._heartbeat = null;
|
|
103
|
+
// { roomId, epoch, roomSecret } из host_registered — с ними реконнект
|
|
104
|
+
// сигналинга возвращает ту же комнату (reclaim_host)
|
|
105
|
+
this._room = null;
|
|
106
|
+
// настройки комнаты, которую хостит вкладка (поля register_host)
|
|
107
|
+
this._roomConfig = null;
|
|
108
|
+
// строка карточки комнаты в лобби (из 'ready'; далее — lobby_info
|
|
109
|
+
// Worker'а): её задаёт игра (gameConfig.lobbyInfo), null — показывать
|
|
110
|
+
// нечего
|
|
111
|
+
this._lobbyInfo = null;
|
|
112
|
+
// промоушен, ждущий host_registered: { mode: 'checkpoint'|'cold',
|
|
113
|
+
// promotion: { roomId, epoch, promotionToken }, reason }
|
|
114
|
+
this._promotion = null;
|
|
115
|
+
// повторная регистрация комнаты у мастера (reconnect сигналинга)
|
|
116
|
+
this._registration = null;
|
|
117
|
+
// версия каталога карт мастера, с которой поднята комната (Этап 5.1)
|
|
118
|
+
this._mapsVersion = null;
|
|
119
|
+
// составной codeVersion комнаты (Этап 5.2/6.5): { engine, game: { id,
|
|
120
|
+
// version } }; null — обновления кода отключены (манифест недоступен)
|
|
121
|
+
this._codeVersion = null;
|
|
122
|
+
// версия, своп на которую не удался — не ретраить её на каждом
|
|
123
|
+
// re-register
|
|
124
|
+
this._failedCodeVersion = null;
|
|
125
|
+
// защита от параллельных эстафет Worker'ов
|
|
126
|
+
this._swapInProgress = false;
|
|
127
|
+
// эстафета дошла до swapWorker (дальше её снимает только контроллер)
|
|
128
|
+
this._swapStarted = false;
|
|
129
|
+
// эстафету отложила или вытеснила плановая передача: не состоится
|
|
130
|
+
// передача — обновление кода нужно снова (resumeCodeUpdate)
|
|
131
|
+
this._codeUpdatePending = false;
|
|
132
|
+
// номер эстафеты: teardown() начинает новый — finally прежней флаги новой
|
|
133
|
+
// роли не трогает
|
|
134
|
+
this._swapGeneration = 0;
|
|
135
|
+
// бета, назначенная мастером комнате этой вкладки (successor_assigned)
|
|
136
|
+
this._successorMemberId = null;
|
|
137
|
+
|
|
138
|
+
// канал standby к бете и поток контрольных точек
|
|
139
|
+
this._standbySender = null;
|
|
140
|
+
// сводка здоровья матча за эпизод скрытой вкладки (этап 9a)
|
|
141
|
+
this._hiddenLog = null;
|
|
142
|
+
// host_health мастеру (этап 9c; лобби-режим): правило сетевого лага
|
|
143
|
+
this._healthReporter = null;
|
|
144
|
+
// room_peers мастеру: кто подключён к хосту по WebRTC (счётчик лобби и
|
|
145
|
+
// кандидаты в беты — только они)
|
|
146
|
+
this._peersReporter = null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
get controller() {
|
|
150
|
+
return this._controller;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
get room() {
|
|
154
|
+
return this._room;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
get roomConfig() {
|
|
158
|
+
return this._roomConfig;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
get promotion() {
|
|
162
|
+
return this._promotion;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
get codeVersion() {
|
|
166
|
+
return this._codeVersion;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
get swapInProgress() {
|
|
170
|
+
return this._swapInProgress;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
get successorMemberId() {
|
|
174
|
+
return this._successorMemberId;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// число людей, подключённых к хосту по WebRTC
|
|
178
|
+
get peerCount() {
|
|
179
|
+
return this._connections?.peerCount ?? 0;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Поднимает комнату в этой же вкладке (Worker хоста): хост-игрок играет
|
|
184
|
+
* через loopback, удалённые клиенты — по WebRTC (answerer). promotion —
|
|
185
|
+
* отложенный холодный промоушен (этап 7.4): вместо новой комнаты — занять
|
|
186
|
+
* существующую.
|
|
187
|
+
* @param {Object} room - настройки комнаты.
|
|
188
|
+
* @param {Object} [options]
|
|
189
|
+
* @param {Object|null} [options.promotion] - { roomId, epoch,
|
|
190
|
+
* promotionToken }.
|
|
191
|
+
* @returns {Promise<void>}
|
|
192
|
+
*/
|
|
193
|
+
async createRoom(room, { promotion = null } = {}) {
|
|
194
|
+
// фича-детект вместо classic-фолбэка (запретил бы ESM/инлайн WASM,
|
|
195
|
+
// см. PLAN.md риск №5): честная ошибка, join остаётся доступен
|
|
196
|
+
if (!this._factories.supportsModuleWorker()) {
|
|
197
|
+
this._ui.informTech(
|
|
198
|
+
'This browser cannot be a host: ES module Web Workers are ' +
|
|
199
|
+
'unsupported. You can still join existing rooms.',
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (!this._ui.ensureWebRtc()) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
this._ctx.entered = true;
|
|
210
|
+
|
|
211
|
+
const prepared = await this._prep.prepareHostRoom(room);
|
|
212
|
+
|
|
213
|
+
const controller = this._factories.controller(room, {
|
|
214
|
+
workerUrl: prepared.workerUrl,
|
|
215
|
+
diagnostics: this._diagnostics,
|
|
216
|
+
onReady: readyMsg => {
|
|
217
|
+
// seed мира приезжает в 'ready' (этап 1): без него запись матча
|
|
218
|
+
// невоспроизводима, поэтому он виден в консоли сразу
|
|
219
|
+
this._debug?.('room ready', {
|
|
220
|
+
map: readyMsg?.mapName,
|
|
221
|
+
seed: readyMsg?.seed,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
this.startRegistration(readyMsg?.lobbyInfo ?? null);
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
onLobbyInfoChange: info => this.handleLobbyInfo(info),
|
|
228
|
+
|
|
229
|
+
// Worker не поднялся (WASM/конфиг): гасим комнату и возвращаемся в
|
|
230
|
+
// лобби
|
|
231
|
+
onError: msg => {
|
|
232
|
+
// холодный преемник не справился — мастер возьмёт следующего
|
|
233
|
+
if (this._promotion) {
|
|
234
|
+
this._signaling.promoteFailed(this._promotion.promotion);
|
|
235
|
+
this._promotion = null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
this._ui.onStartFailed(msg);
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
this.adopt(controller, room, prepared, {
|
|
243
|
+
promotion: promotion ? { mode: 'cold', promotion } : null,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// хост-игрок в этой же вкладке (socketId согласован с kick-исключением)
|
|
247
|
+
// свой клиент хоста на loopback не рвётся: супервизор для него сквозной
|
|
248
|
+
const transport = this._factories.loopback(
|
|
249
|
+
controller,
|
|
250
|
+
this._config.create.hostSocketId,
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
this._ui.startSession(transport);
|
|
254
|
+
transport.connect();
|
|
255
|
+
|
|
256
|
+
this._ui.closeLobby();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Вкладка становится хостом для готового (или поднимающегося) Worker'а:
|
|
261
|
+
* приём офферов, поток контрольных точек бете, регистрация у мастера.
|
|
262
|
+
* Обработчики сигналинга роли — в bind (подписаны один раз).
|
|
263
|
+
* @param {HostController} controller
|
|
264
|
+
* @param {Object} room - настройки комнаты (room.game — манифест игры).
|
|
265
|
+
* @param {Object} prepared - prepareHostRoom: { mapsVersion, codeVersion }.
|
|
266
|
+
* @param {Object} [options]
|
|
267
|
+
* @param {Object|null} [options.promotion] - { mode, promotion, reason } —
|
|
268
|
+
* занять комнату преемником (reason — причина передачи из promote).
|
|
269
|
+
*/
|
|
270
|
+
adopt(controller, room, prepared, { promotion = null } = {}) {
|
|
271
|
+
const migration = this._config.migration;
|
|
272
|
+
const handoff = this._getHandoff();
|
|
273
|
+
|
|
274
|
+
this._mapsVersion = prepared.mapsVersion;
|
|
275
|
+
this._codeVersion = prepared.codeVersion;
|
|
276
|
+
this._controller = controller;
|
|
277
|
+
this._roomConfig = room;
|
|
278
|
+
this._lobbyInfo = null;
|
|
279
|
+
this._promotion = promotion;
|
|
280
|
+
this._room = null;
|
|
281
|
+
// хост — участник своей комнаты через register_host, не join_room
|
|
282
|
+
this._membership.forget();
|
|
283
|
+
this._membership.armTokenCapsTimer();
|
|
284
|
+
handoff.armTokenHandoff();
|
|
285
|
+
|
|
286
|
+
this._diagnostics.setContext({ role: 'host' });
|
|
287
|
+
|
|
288
|
+
// удалённые клиенты по WebRTC
|
|
289
|
+
this._connections = this._factories.connections(
|
|
290
|
+
this._signaling,
|
|
291
|
+
controller,
|
|
292
|
+
{
|
|
293
|
+
iceServers: this._signaling.iceServers,
|
|
294
|
+
// бета могла подключиться позже назначения или переподключиться;
|
|
295
|
+
// число людей в комнате — меню и защите закрытия
|
|
296
|
+
onPeersChange: () => {
|
|
297
|
+
this._standbySender?.refresh();
|
|
298
|
+
this._peersReporter?.notify();
|
|
299
|
+
this._ui.refreshRoomControls();
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
);
|
|
303
|
+
this._peersReporter = this._factories.peersReporter({
|
|
304
|
+
send: report => this._signaling.roomPeers(report),
|
|
305
|
+
getRoom: () => this._room,
|
|
306
|
+
getMemberIds: () => this._connections?.connectedMemberIds() ?? [],
|
|
307
|
+
intervalMs: migration.peersReportIntervalMs,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// преемник (host-migration этап 6): канал standby к бете, назначенной
|
|
311
|
+
// мастером, и поток контрольных точек по нему
|
|
312
|
+
this._standbySender = this._factories.standbySender({
|
|
313
|
+
controller,
|
|
314
|
+
connections: this._connections,
|
|
315
|
+
intervalMs: migration.checkpointIntervalMs,
|
|
316
|
+
chunkBytes: migration.standbyChunkBytes,
|
|
317
|
+
highWaterBytes: migration.standbyHighWaterBytes,
|
|
318
|
+
reopenDelayMs: migration.standbyReopenDelayMs,
|
|
319
|
+
reopenMaxDelayMs: migration.standbyReopenMaxDelayMs,
|
|
320
|
+
diagnostics: this._diagnostics,
|
|
321
|
+
onStats: this._debug ? stats => this._debug('standby sent', stats) : null,
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// метрики здоровья Worker'а (этап 9a): скрытая вкладка троттлит цикл —
|
|
325
|
+
// насколько, уходит в журнал одной сводкой за эпизод
|
|
326
|
+
this._hiddenLog = this._factories.hiddenLog({
|
|
327
|
+
warn: (code, summary) =>
|
|
328
|
+
this._diagnostics.warn(code, summary, { source: 'client' }),
|
|
329
|
+
hidden: this._ui.isHidden(),
|
|
330
|
+
});
|
|
331
|
+
this._healthReporter = this._isLobbyMode
|
|
332
|
+
? this._factories.healthReporter({
|
|
333
|
+
send: report => this._signaling.hostHealth(report),
|
|
334
|
+
getRoom: () => this._room,
|
|
335
|
+
intervalMs: migration.auto.hostHealthIntervalMs,
|
|
336
|
+
})
|
|
337
|
+
: null;
|
|
338
|
+
controller.onHealth(health => {
|
|
339
|
+
this._hiddenLog?.add(health);
|
|
340
|
+
this._getHandoff().addHealth(health);
|
|
341
|
+
this._healthReporter?.add(health);
|
|
342
|
+
});
|
|
343
|
+
handoff.setHost(true);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Worker готов: регистрация комнаты у мастера и heartbeat
|
|
347
|
+
startRegistration(lobbyInfo) {
|
|
348
|
+
this._lobbyInfo = lobbyInfo;
|
|
349
|
+
|
|
350
|
+
// периодический heartbeat/актуализация карточки у мастера; число
|
|
351
|
+
// игроков мастер считает по участникам комнаты сам
|
|
352
|
+
const update = () => this._signaling.updateHost({ info: this._lobbyInfo });
|
|
353
|
+
|
|
354
|
+
// регистрация комнаты; при reconnect сигналинга — возврат той же
|
|
355
|
+
// комнаты (reclaim_host), а fresh=true — новая комната, когда вернуть
|
|
356
|
+
// прежнюю нельзя (её id занят или секрет не принят). Преемник до
|
|
357
|
+
// host_registered занимает комнату promotionToken'ом
|
|
358
|
+
this._registration = ({ fresh = false } = {}) => {
|
|
359
|
+
const room = this._roomConfig;
|
|
360
|
+
const fields = {
|
|
361
|
+
gameId: room.game.id,
|
|
362
|
+
gameVersion: room.game.version,
|
|
363
|
+
maxPlayers: room.maxPlayers,
|
|
364
|
+
info: this._lobbyInfo,
|
|
365
|
+
token: this._getToken(),
|
|
366
|
+
memberId: this._membership.memberId,
|
|
367
|
+
caps: this._membership.caps(),
|
|
368
|
+
// для холодного перезапуска комнаты преемником (этап 7.6)
|
|
369
|
+
settings: sanitizeRoomSettings(room),
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
if (this._promotion) {
|
|
373
|
+
this._signaling.registerHost({
|
|
374
|
+
...fields,
|
|
375
|
+
promotion: this._promotion.promotion,
|
|
376
|
+
});
|
|
377
|
+
} else if (this._room && !fresh) {
|
|
378
|
+
this._signaling.reclaimHost({ ...fields, ...this._room });
|
|
379
|
+
} else {
|
|
380
|
+
this._room = null;
|
|
381
|
+
this._signaling.registerHost(fields);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
this._timers.clearInterval(this._heartbeat);
|
|
385
|
+
this._heartbeat = this._timers.setInterval(
|
|
386
|
+
update,
|
|
387
|
+
this._config.create.heartbeatInterval,
|
|
388
|
+
);
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
this._registration();
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// повтор регистрации (welcome после реконнекта; fresh — новая комната)
|
|
395
|
+
reRegister(options) {
|
|
396
|
+
this._registration?.(options);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// строка карточки сменилась (карта, опция игры) — сразу отразить в лобби
|
|
400
|
+
// мастера
|
|
401
|
+
handleLobbyInfo(info) {
|
|
402
|
+
this._lobbyInfo = info;
|
|
403
|
+
|
|
404
|
+
if (this._registration) {
|
|
405
|
+
this._signaling.updateHost({ info });
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// скрытая вкладка: эпизод сводки здоровья (этап 9a)
|
|
410
|
+
setHidden(hidden) {
|
|
411
|
+
this._hiddenLog?.setHidden(hidden);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// закрытие скрытой вкладки: эпизод закрывается сводкой сейчас. true —
|
|
415
|
+
// сводка была (вкладка хостит)
|
|
416
|
+
flushHiddenLog() {
|
|
417
|
+
if (!this._hiddenLog) {
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
this._hiddenLog.flush();
|
|
422
|
+
|
|
423
|
+
return true;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// снимает роль хоста: heartbeat, WebRTC-пиры, поток точек, Worker
|
|
427
|
+
teardown() {
|
|
428
|
+
const handoff = this._getHandoff();
|
|
429
|
+
|
|
430
|
+
handoff.abort();
|
|
431
|
+
handoff.cancelTokenHandoff();
|
|
432
|
+
// прежняя эстафета досчитает своё и выйдет (контроллер сменился); новая
|
|
433
|
+
// роль начинает с чистых флагов
|
|
434
|
+
this._swapGeneration += 1;
|
|
435
|
+
this._swapInProgress = false;
|
|
436
|
+
this._swapStarted = false;
|
|
437
|
+
this._codeUpdatePending = false;
|
|
438
|
+
this._timers.clearInterval(this._heartbeat);
|
|
439
|
+
this._heartbeat = null;
|
|
440
|
+
this._standbySender?.destroy();
|
|
441
|
+
this._standbySender = null;
|
|
442
|
+
this._hiddenLog?.flush();
|
|
443
|
+
this._hiddenLog = null;
|
|
444
|
+
this._healthReporter = null;
|
|
445
|
+
this._peersReporter?.destroy();
|
|
446
|
+
this._peersReporter = null;
|
|
447
|
+
handoff.setHost(false);
|
|
448
|
+
this._connections?.destroy();
|
|
449
|
+
this._connections = null;
|
|
450
|
+
this._controller?.destroy();
|
|
451
|
+
this._controller = null;
|
|
452
|
+
this._diagnostics.setContext({ role: 'client' });
|
|
453
|
+
this._registration = null;
|
|
454
|
+
this._room = null;
|
|
455
|
+
this._roomConfig = null;
|
|
456
|
+
this._promotion = null;
|
|
457
|
+
this._successorMemberId = null;
|
|
458
|
+
this._ui.setHandoffMenu(null);
|
|
459
|
+
this._ui.refreshRoomControls();
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// подписки сигналинга роли хоста (лобби-режим, один раз на страницу: роль
|
|
463
|
+
// может прийти и уйти несколько раз — создание, промоушен, host_revoked)
|
|
464
|
+
bind() {
|
|
465
|
+
const publisher = this._signaling.publisher;
|
|
466
|
+
|
|
467
|
+
// хост: к кому открыть канал standby
|
|
468
|
+
publisher.on('successor_assigned', msg => {
|
|
469
|
+
if (this._standbySender && msg.roomId === this._room?.roomId) {
|
|
470
|
+
this._successorMemberId = msg.successorMemberId ?? null;
|
|
471
|
+
this._standbySender.setSuccessor(this._successorMemberId);
|
|
472
|
+
this._getHandoff().setSuccessor(this._successorMemberId !== null);
|
|
473
|
+
this._ui.refreshRoomControls();
|
|
474
|
+
|
|
475
|
+
// в окне передачи по сроку входа бета появилась — передать сразу
|
|
476
|
+
if (this._successorMemberId !== null) {
|
|
477
|
+
this._getHandoff().armTokenHandoff();
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
// мастер отвечает актуальными версиями каталога карт и worker-бандла:
|
|
483
|
+
// расхождение (деплой, пока комната жила) — подтянуть каталог к
|
|
484
|
+
// следующей смене карты / заменить Worker эстафетой на границе раунда
|
|
485
|
+
// (Этап 5.2)
|
|
486
|
+
publisher.on('host_registered', msg => {
|
|
487
|
+
if (!this._controller) {
|
|
488
|
+
return; // комната уже погашена
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
this._room = {
|
|
492
|
+
roomId: msg.roomId,
|
|
493
|
+
epoch: msg.epoch,
|
|
494
|
+
roomSecret: msg.roomSecret,
|
|
495
|
+
};
|
|
496
|
+
this._ctx.roomId = msg.roomId;
|
|
497
|
+
this._ctx.epoch = msg.epoch;
|
|
498
|
+
this._ui.showRoomLink(this._roomConfig.game.id, msg.roomId);
|
|
499
|
+
|
|
500
|
+
// roomId + секрет эпохи не известны Worker'у до этого момента —
|
|
501
|
+
// прокидываем их, чтобы PlayerDataSync атрибутировал последующие
|
|
502
|
+
// rank/state-flush к этой комнате (секрет доказывает мастеру владение)
|
|
503
|
+
this._controller.setRoom(this._room);
|
|
504
|
+
// мастер (новый или после рестарта) узнаёт подключённых сразу
|
|
505
|
+
this._peersReporter?.refresh();
|
|
506
|
+
|
|
507
|
+
if (this._promotion) {
|
|
508
|
+
const promotion = this._promotion;
|
|
509
|
+
|
|
510
|
+
this._promotion = null;
|
|
511
|
+
this._onPromotionRegistered(promotion);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (msg.mapsVersion && msg.mapsVersion !== this._mapsVersion) {
|
|
515
|
+
this.refreshMaps();
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (
|
|
519
|
+
msg.codeVersion &&
|
|
520
|
+
this._codeVersion &&
|
|
521
|
+
codeVersionChanged(msg.codeVersion, this._codeVersion)
|
|
522
|
+
) {
|
|
523
|
+
this.refreshWorker();
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// проба мастера (host-migration этап 7.3): гости жалуются, что хост
|
|
528
|
+
// недоступен — главный поток подтверждает, что жив. Отвечает сразу, без
|
|
529
|
+
// Worker'а: проба проверяет вкладку и её сигналинг, не матч
|
|
530
|
+
publisher.on('probe', msg => {
|
|
531
|
+
if (this._controller && msg.roomId === this._room?.roomId) {
|
|
532
|
+
this._signaling.probeAck(msg.nonce);
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
// сигнал мастера об обновлении каталога карт/кода
|
|
537
|
+
publisher.on('update_available', msg => {
|
|
538
|
+
if (!this._controller) {
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (!msg.mapsVersion || msg.mapsVersion !== this._mapsVersion) {
|
|
543
|
+
this.refreshMaps();
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (
|
|
547
|
+
msg.codeVersion &&
|
|
548
|
+
this._codeVersion &&
|
|
549
|
+
codeVersionChanged(msg.codeVersion, this._codeVersion)
|
|
550
|
+
) {
|
|
551
|
+
this.refreshWorker();
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// перечитывает каталог карт мастера и передаёт в Worker: применится со
|
|
557
|
+
// следующей смены карты (текущий раунд не трогается)
|
|
558
|
+
async refreshMaps() {
|
|
559
|
+
try {
|
|
560
|
+
const catalog = await this._prep.fetchMasterMaps();
|
|
561
|
+
|
|
562
|
+
this._mapsVersion = catalog.version;
|
|
563
|
+
this._controller?.updateMaps(catalog.maps);
|
|
564
|
+
} catch (e) {
|
|
565
|
+
console.warn('[maps] refresh from master failed:', e);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Этап 5.2/6.5: эстафета Worker'ов — новая версия кода (движка ИЛИ игры)
|
|
570
|
+
// у мастера. Worker заменяется на границе раунда без разрыва P2P; сбой
|
|
571
|
+
// свопа не смертелен — комната продолжает жить на прежней версии
|
|
572
|
+
async refreshWorker() {
|
|
573
|
+
const controller = this._controller;
|
|
574
|
+
|
|
575
|
+
if (this._swapInProgress || !controller) {
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// идёт плановая передача: преемник и так поднимется на актуальном коде,
|
|
580
|
+
// а не состоится она — обновление вернёт resumeCodeUpdate
|
|
581
|
+
if (this._getHandoff().active) {
|
|
582
|
+
this._codeUpdatePending = true;
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const generation = this._swapGeneration;
|
|
587
|
+
|
|
588
|
+
this._swapInProgress = true;
|
|
589
|
+
this._swapStarted = false;
|
|
590
|
+
this._codeUpdatePending = false;
|
|
591
|
+
|
|
592
|
+
let manifest = null;
|
|
593
|
+
let game = null;
|
|
594
|
+
|
|
595
|
+
try {
|
|
596
|
+
manifest = await this._prep.fetchWorkerManifest();
|
|
597
|
+
const gameManifest = await this._prep.fetchGameManifest(
|
|
598
|
+
this._getActiveGame().id,
|
|
599
|
+
);
|
|
600
|
+
|
|
601
|
+
game = {
|
|
602
|
+
id: gameManifest.id,
|
|
603
|
+
version: gameManifest.version,
|
|
604
|
+
hostEntryUrl: gameManifest.entries.host,
|
|
605
|
+
wasmUrl: gameManifest.entries.wasm,
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
const nextCodeVersion = {
|
|
609
|
+
engine: manifest.version,
|
|
610
|
+
game: { id: game.id, version: game.version },
|
|
611
|
+
};
|
|
612
|
+
const nextKey = codeVersionKey(nextCodeVersion);
|
|
613
|
+
|
|
614
|
+
if (
|
|
615
|
+
!manifest.version ||
|
|
616
|
+
!manifest.url ||
|
|
617
|
+
nextKey === codeVersionKey(this._codeVersion) ||
|
|
618
|
+
nextKey === codeVersionKey(this._failedCodeVersion)
|
|
619
|
+
) {
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// пока качались манифесты, роль сняли
|
|
624
|
+
if (this._controller !== controller) {
|
|
625
|
+
console.info('[worker] swap dropped: host role ended');
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// ...или началась плановая передача (preemptSwap): до swapWorker
|
|
630
|
+
// эстафета не доходит. Передача, начатая и уже сорвавшаяся за это
|
|
631
|
+
// время, её не останавливает
|
|
632
|
+
if (this._getHandoff().active) {
|
|
633
|
+
this._codeUpdatePending = true;
|
|
634
|
+
console.info('[worker] swap preempted by planned host handoff');
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
this._swapStarted = true;
|
|
639
|
+
await controller.swapWorker(manifest.url, game);
|
|
640
|
+
|
|
641
|
+
if (this._controller === controller) {
|
|
642
|
+
this._codeVersion = nextCodeVersion;
|
|
643
|
+
this._failedCodeVersion = null;
|
|
644
|
+
console.info(`[worker] room migrated to code version ${nextKey}`);
|
|
645
|
+
}
|
|
646
|
+
} catch (e) {
|
|
647
|
+
// роль снята (destroy отвергает своп) — версия не сломана
|
|
648
|
+
if (this._controller !== controller) {
|
|
649
|
+
console.info('[worker] swap dropped: host role ended');
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// своп снят контроллером: его вытеснила плановая передача или закрытие
|
|
654
|
+
// комнаты — версия не сломана, обновление вернёт resumeCodeUpdate
|
|
655
|
+
if (e.message === 'swap preempted') {
|
|
656
|
+
this._codeUpdatePending = true;
|
|
657
|
+
console.info('[worker] swap preempted');
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
if (manifest?.version) {
|
|
662
|
+
this._failedCodeVersion = { engine: manifest.version, game };
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
console.warn('[worker] swap to new version failed:', e);
|
|
666
|
+
} finally {
|
|
667
|
+
if (generation === this._swapGeneration) {
|
|
668
|
+
this._swapInProgress = false;
|
|
669
|
+
this._swapStarted = false;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Можно ли вытеснить эстафету Worker'ов плановой передачей: нет эстафеты,
|
|
676
|
+
* она ещё качает манифесты или ждёт границы раунда. Своп, уже переносящий
|
|
677
|
+
* состояние, вытеснить нельзя — передача ждёт (своп в контроллере есть
|
|
678
|
+
* только после swapWorker, то есть после _swapStarted).
|
|
679
|
+
* @returns {boolean}
|
|
680
|
+
*/
|
|
681
|
+
canPreemptSwap() {
|
|
682
|
+
return this._controller?.swapCarryingState !== true;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Плановая передача началась и вытесняет эстафету: ждущую границы раунда
|
|
687
|
+
* снимает контроллер, а ещё качающую манифесты снимать не нужно — после
|
|
688
|
+
* загрузки её остановит сама идущая передача. Зовётся только после
|
|
689
|
+
* положительного canPreemptSwap().
|
|
690
|
+
*/
|
|
691
|
+
preemptSwap() {
|
|
692
|
+
if (this._swapStarted) {
|
|
693
|
+
this._controller?.cancelPendingSwap();
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Передача, отложившая или вытеснившая эстафету, не состоялась (сорвалась
|
|
699
|
+
* или снята до границы раунда) — обновление кода нужно снова. Без
|
|
700
|
+
* отложенного обновления ничего не делает: манифесты не качаются зря.
|
|
701
|
+
* @returns {Promise<void>}
|
|
702
|
+
*/
|
|
703
|
+
async resumeCodeUpdate() {
|
|
704
|
+
if (this._codeUpdatePending) {
|
|
705
|
+
await this.refreshWorker();
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|