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,432 @@
|
|
|
1
|
+
import { mergeRoomDefaults } from '../lib/formBuilder.js';
|
|
2
|
+
import {
|
|
3
|
+
classifyRoomPoll,
|
|
4
|
+
decideRouteAction,
|
|
5
|
+
parseRoute,
|
|
6
|
+
pickQuickPlayRoom,
|
|
7
|
+
quickPlayCreateDelay,
|
|
8
|
+
} from '../lib/roomLink.js';
|
|
9
|
+
import { takePendingPromotion } from '../network/Promotion.js';
|
|
10
|
+
|
|
11
|
+
// Прямые ссылки (host-migration, этап 3): без hash — лобби, #/<gameId> —
|
|
12
|
+
// быстрая игра, #/<gameId>/<roomId> — вход в комнату (client/lib/roomLink.js).
|
|
13
|
+
// Разбор маршрута после логина и welcome, ожидание мигрирующей комнаты,
|
|
14
|
+
// холодный промоушен после перезагрузки и смена hash.
|
|
15
|
+
//
|
|
16
|
+
// Без глобального DOM: адрес, лобби, информер, сеть и вход в комнату
|
|
17
|
+
// инъектирует сборщик (client/main.js).
|
|
18
|
+
|
|
19
|
+
// ожидание брошено: hash сменился, маршрут разбирается заново
|
|
20
|
+
export const ROUTE_ABANDONED = Symbol('routeAbandoned');
|
|
21
|
+
|
|
22
|
+
// дефолты формы создания комнаты — то, что ушло бы в комнату по Create без
|
|
23
|
+
// правки полей: roomDefaults манифеста + default'ы самой схемы формы
|
|
24
|
+
export function roomDefaultsOf(manifest) {
|
|
25
|
+
const defaults = { ...manifest.roomDefaults };
|
|
26
|
+
|
|
27
|
+
if (Array.isArray(manifest.roomForm)) {
|
|
28
|
+
for (const field of mergeRoomDefaults(
|
|
29
|
+
manifest.roomForm,
|
|
30
|
+
manifest.roomDefaults ?? {},
|
|
31
|
+
)) {
|
|
32
|
+
if (field.default !== undefined) {
|
|
33
|
+
defaults[field.name] = field.default;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return defaults;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export default class RouteBoot {
|
|
42
|
+
/**
|
|
43
|
+
* @param {Object} deps
|
|
44
|
+
* @param {Object} deps.ctx - client/session/roomContext.js.
|
|
45
|
+
* @param {Object} deps.signaling - SignalingClient.
|
|
46
|
+
* @param {Object} deps.membership - client/session/Membership.js.
|
|
47
|
+
* @param {Object} deps.config - lobby-конфиг (session, quickPlay, create,
|
|
48
|
+
* roomUrl, quickPlayUrl).
|
|
49
|
+
* @param {Map} deps.gamesById - каталог манифестов вкладки.
|
|
50
|
+
* @param {Function} deps.selectActiveGame - (gameId, { report }) → bool.
|
|
51
|
+
* @param {Function} deps.connectToRoom - (roomId) вход гостем.
|
|
52
|
+
* @param {Function} deps.createRoom - (room, { promotion }) поднять
|
|
53
|
+
* комнату в этой вкладке.
|
|
54
|
+
* @param {Function} deps.fetchServers - ({ search }) → список | null.
|
|
55
|
+
* @param {Object} deps.ui - { initLobby(), selectLobbyGame(gameId),
|
|
56
|
+
* informTech(text?), onInformerClick(handler), getHash(),
|
|
57
|
+
* setRoute(hash), reloadPage(hash) }.
|
|
58
|
+
* @param {Function} [deps.fetch]
|
|
59
|
+
* @param {Function} [deps.getStorage] - () → sessionStorage (может бросить).
|
|
60
|
+
* @param {Function} [deps.sleep] - (ms) → Promise.
|
|
61
|
+
* @param {Function} [deps.now] - монотонные часы, мс.
|
|
62
|
+
*/
|
|
63
|
+
constructor({
|
|
64
|
+
ctx,
|
|
65
|
+
signaling,
|
|
66
|
+
membership,
|
|
67
|
+
config,
|
|
68
|
+
gamesById,
|
|
69
|
+
selectActiveGame,
|
|
70
|
+
connectToRoom,
|
|
71
|
+
createRoom,
|
|
72
|
+
fetchServers,
|
|
73
|
+
ui,
|
|
74
|
+
fetch = (...args) => globalThis.fetch(...args),
|
|
75
|
+
getStorage = () => window.sessionStorage,
|
|
76
|
+
sleep = ms => new Promise(resolve => setTimeout(resolve, ms)),
|
|
77
|
+
now = () => performance.now(),
|
|
78
|
+
}) {
|
|
79
|
+
this._ctx = ctx;
|
|
80
|
+
this._signaling = signaling;
|
|
81
|
+
this._membership = membership;
|
|
82
|
+
this._config = config;
|
|
83
|
+
this._gamesById = gamesById;
|
|
84
|
+
this._selectActiveGame = selectActiveGame;
|
|
85
|
+
this._connectToRoom = connectToRoom;
|
|
86
|
+
this._createRoom = createRoom;
|
|
87
|
+
this._fetchServers = fetchServers;
|
|
88
|
+
this._ui = ui;
|
|
89
|
+
this._fetch = fetch;
|
|
90
|
+
this._getStorage = getStorage;
|
|
91
|
+
this._sleep = sleep;
|
|
92
|
+
this._now = now;
|
|
93
|
+
|
|
94
|
+
// маршрут разбирается один раз: повторный welcome (реконнект
|
|
95
|
+
// сигналинга) бутстрап не повторяет
|
|
96
|
+
this._booted = false;
|
|
97
|
+
// маршрут в работе (ждёт /rooms или /servers): hashchange в это время не
|
|
98
|
+
// запускает второй бутстрап — тот дошёл бы до connectToRoom/createRoom
|
|
99
|
+
// параллельно первому (два транспорта в одной вкладке). Новый hash
|
|
100
|
+
// разбирается после текущего, если вкладка так и не вошла в комнату
|
|
101
|
+
this._running = false;
|
|
102
|
+
this._rerun = false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
get booted() {
|
|
106
|
+
return this._booted;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// первый разбор маршрута (после логина и welcome)
|
|
110
|
+
boot() {
|
|
111
|
+
if (this._booted) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
this._booted = true;
|
|
116
|
+
this.run();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async run() {
|
|
120
|
+
if (this._running) {
|
|
121
|
+
this._rerun = true;
|
|
122
|
+
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
this._running = true;
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
do {
|
|
130
|
+
this._rerun = false;
|
|
131
|
+
await this._bootRoute();
|
|
132
|
+
} while (this._rerun && !this._ctx.entered);
|
|
133
|
+
} finally {
|
|
134
|
+
this._running = false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// hashchange: вне комнаты — новый маршрут бутстрапом; в комнате — ссылка на
|
|
139
|
+
// неё же ничего не меняет, любой другой маршрут — перезагрузкой (матч не
|
|
140
|
+
// разбирается без неё). replaceState (setRoute) это событие не порождает
|
|
141
|
+
handleHashChange() {
|
|
142
|
+
if (!this._booted) {
|
|
143
|
+
return; // бутстрап ещё впереди и сам прочитает hash
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!this._ctx.entered) {
|
|
147
|
+
this.run();
|
|
148
|
+
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const hash = this._ui.getHash();
|
|
153
|
+
const route = parseRoute(hash);
|
|
154
|
+
|
|
155
|
+
if (route.kind === 'room' && route.roomId === this._ctx.roomId) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
this._ui.reloadPage(hash);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// разбор маршрута после логина и welcome (только лобби-режим)
|
|
163
|
+
async _bootRoute() {
|
|
164
|
+
const route = parseRoute(this._ui.getHash());
|
|
165
|
+
const pending = this._takeRoutePromotion(route);
|
|
166
|
+
|
|
167
|
+
if (pending && (await this._promoteCold(pending))) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let roomInfo =
|
|
172
|
+
route.kind === 'room' ? await this._fetchRoom(route.roomId) : null;
|
|
173
|
+
let decision = decideRouteAction(route, roomInfo, this._gamesById);
|
|
174
|
+
|
|
175
|
+
if (decision.action === 'wait') {
|
|
176
|
+
roomInfo = await this._waitRoomOnline(decision.roomId);
|
|
177
|
+
|
|
178
|
+
if (roomInfo === ROUTE_ABANDONED) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
decision = decideRouteAction(route, roomInfo, this._gamesById);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
switch (decision.action) {
|
|
186
|
+
case 'join':
|
|
187
|
+
await this._joinFromRoute(decision.gameId, decision.roomId);
|
|
188
|
+
break;
|
|
189
|
+
|
|
190
|
+
case 'quickPlay':
|
|
191
|
+
await this._quickPlay(decision.gameId);
|
|
192
|
+
break;
|
|
193
|
+
|
|
194
|
+
default:
|
|
195
|
+
if (route.kind === 'none') {
|
|
196
|
+
this._ui.initLobby();
|
|
197
|
+
} else {
|
|
198
|
+
this._showLobby({ informer: decision.informer });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// комната по прямому id; null — её нет или мастер не ответил (тогда —
|
|
204
|
+
// быстрая игра той же игры, как для мёртвой ссылки)
|
|
205
|
+
async _fetchRoom(roomId) {
|
|
206
|
+
return (await this._pollRoom(roomId)).info;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// GET /rooms/:roomId со статусом: ожиданию миграции важно отличить
|
|
210
|
+
// «комнаты нет» (404) от сбоя запроса. status null — запрос не дошёл
|
|
211
|
+
async _pollRoom(roomId) {
|
|
212
|
+
try {
|
|
213
|
+
const res = await this._fetch(this._config.roomUrl(roomId));
|
|
214
|
+
|
|
215
|
+
return { status: res.status, info: res.ok ? await res.json() : null };
|
|
216
|
+
} catch {
|
|
217
|
+
return { status: null, info: null };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// информер, который игрок закрывает кликом: причина отказа маршрута видна
|
|
222
|
+
// поверх лобби, но лобби остаётся рабочим
|
|
223
|
+
_showDismissibleInformer(text) {
|
|
224
|
+
if (text) {
|
|
225
|
+
this._ui.informTech(text);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
this._ui.onInformerClick(() => this._ui.informTech());
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// лобби вместо маршрута: адрес — без hash (иначе F5 повторил бы маршрут),
|
|
232
|
+
// игра маршрута — выбрана в селекторе
|
|
233
|
+
_showLobby({ gameId, informer } = {}) {
|
|
234
|
+
this._ui.setRoute('');
|
|
235
|
+
this._ui.initLobby();
|
|
236
|
+
|
|
237
|
+
if (gameId && this._gamesById.has(gameId)) {
|
|
238
|
+
this._ui.selectLobbyGame(gameId);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (informer) {
|
|
242
|
+
this._showDismissibleInformer(informer);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// активация игры маршрута: лобби ещё нет (строку формы, куда пишет
|
|
247
|
+
// selectActiveGame по умолчанию, initLobby тут же перетёр бы), поэтому
|
|
248
|
+
// отказ показывается поверх лобби информером
|
|
249
|
+
async _activateRouteGame(gameId) {
|
|
250
|
+
let failure = null;
|
|
251
|
+
|
|
252
|
+
if (
|
|
253
|
+
await this._selectActiveGame(gameId, {
|
|
254
|
+
report: text => (failure = text),
|
|
255
|
+
})
|
|
256
|
+
) {
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
this._showLobby({ gameId, informer: failure });
|
|
261
|
+
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// вход по маршруту — лобби не показывается вовсе: игрок сразу видит экран
|
|
266
|
+
// авторизации игры (AUTH_DATA → #auth)
|
|
267
|
+
async _joinFromRoute(gameId, roomId) {
|
|
268
|
+
if (await this._activateRouteGame(gameId)) {
|
|
269
|
+
this._connectToRoom(roomId);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// самая наполненная неполная комната игры: её выбирает мастер; мастер без
|
|
274
|
+
// роута или сбой — выбор по полному списку, как раньше
|
|
275
|
+
async _findQuickPlayRoom(gameId) {
|
|
276
|
+
try {
|
|
277
|
+
const res = await this._fetch(this._config.quickPlayUrl(gameId));
|
|
278
|
+
|
|
279
|
+
if (res.ok) {
|
|
280
|
+
const { room } = await res.json();
|
|
281
|
+
|
|
282
|
+
return room && typeof room.roomId === 'string' ? room : null;
|
|
283
|
+
}
|
|
284
|
+
} catch {
|
|
285
|
+
// ниже — запасной путь
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const list = await this._fetchServers({ search: gameId });
|
|
289
|
+
|
|
290
|
+
return pickQuickPlayRoom(list?.servers, gameId);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// быстрая игра: самая наполненная неполная комната игры, иначе своя
|
|
294
|
+
async _quickPlay(gameId) {
|
|
295
|
+
const quickPlay = this._config.quickPlay;
|
|
296
|
+
let room = await this._findQuickPlayRoom(gameId);
|
|
297
|
+
|
|
298
|
+
// гости закрытой комнаты приходят сюда разом: случайная пауза и второй
|
|
299
|
+
// взгляд на список — комнату создаст первый, остальные в неё войдут
|
|
300
|
+
if (!room && quickPlay.autoCreate) {
|
|
301
|
+
await this._sleep(quickPlayCreateDelay(quickPlay));
|
|
302
|
+
room = await this._findQuickPlayRoom(gameId);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (room) {
|
|
306
|
+
await this._joinFromRoute(gameId, room.roomId);
|
|
307
|
+
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (!quickPlay.autoCreate) {
|
|
312
|
+
this._showLobby({ gameId });
|
|
313
|
+
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const manifest = this._gamesById.get(gameId);
|
|
318
|
+
|
|
319
|
+
if (!(await this._activateRouteGame(gameId))) {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
await this._createRoom({
|
|
324
|
+
hostSocketId: this._config.create.hostSocketId,
|
|
325
|
+
...roomDefaultsOf(manifest),
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// браузер не может быть хостом (createRoom уже показал причину): лобби
|
|
329
|
+
// под ней, войти в чужую комнату он всё ещё может
|
|
330
|
+
if (!this._ctx.entered) {
|
|
331
|
+
this._showLobby({ gameId });
|
|
332
|
+
this._showDismissibleInformer();
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// комната по ссылке меняет хоста: ждём, пока она снова станет online.
|
|
337
|
+
// null — комната пропала или так и не дождались (тогда — быстрая игра);
|
|
338
|
+
// ROUTE_ABANDONED — игрок ушёл по другой ссылке (run повторит разбор)
|
|
339
|
+
async _waitRoomOnline(roomId) {
|
|
340
|
+
// крайний срок — с запасом на цепочку кандидатов (дедлайн каждого — у
|
|
341
|
+
// мастера); 404 обрывает ожидание раньше
|
|
342
|
+
const { linkWaitMaxMs, migrationPollMs } = this._config.session;
|
|
343
|
+
const deadline = this._now() + linkWaitMaxMs;
|
|
344
|
+
|
|
345
|
+
this._ui.informTech('Switching host…');
|
|
346
|
+
|
|
347
|
+
try {
|
|
348
|
+
while (this._now() < deadline) {
|
|
349
|
+
await this._sleep(migrationPollMs);
|
|
350
|
+
|
|
351
|
+
if (this._rerun) {
|
|
352
|
+
return ROUTE_ABANDONED;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const poll = await this._pollRoom(roomId);
|
|
356
|
+
|
|
357
|
+
if (this._rerun) {
|
|
358
|
+
return ROUTE_ABANDONED;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
switch (classifyRoomPoll(poll)) {
|
|
362
|
+
case 'online':
|
|
363
|
+
return poll.info;
|
|
364
|
+
case 'gone':
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return null;
|
|
370
|
+
} finally {
|
|
371
|
+
this._ui.informTech();
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// холодный промоушен (host-migration этап 7.4): страница перезагружена в
|
|
376
|
+
// комнату, чтобы поднять её матч заново. Запись снимается при любом исходе
|
|
377
|
+
_takeRoutePromotion(route) {
|
|
378
|
+
let storage = null;
|
|
379
|
+
|
|
380
|
+
try {
|
|
381
|
+
storage = this._getStorage();
|
|
382
|
+
} catch {
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const pending = takePendingPromotion(storage);
|
|
387
|
+
|
|
388
|
+
return pending &&
|
|
389
|
+
route.kind === 'room' &&
|
|
390
|
+
route.gameId === pending.gameId &&
|
|
391
|
+
route.roomId === pending.roomId
|
|
392
|
+
? pending
|
|
393
|
+
: null;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// занимает комнату холодным промоушеном. false — вкладка хостить не может,
|
|
397
|
+
// маршрут разбирается как обычный вход гостем
|
|
398
|
+
async _promoteCold(pending) {
|
|
399
|
+
// вход истёк за время перезагрузки — комнату займёт другой кандидат
|
|
400
|
+
if (!this._membership.tokenAllowsHostRole()) {
|
|
401
|
+
this._signaling.promoteFailed(pending);
|
|
402
|
+
|
|
403
|
+
return false;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (!(await this._activateRouteGame(pending.gameId))) {
|
|
407
|
+
this._signaling.promoteFailed(pending);
|
|
408
|
+
|
|
409
|
+
return true;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
this._ctx.roomId = pending.roomId;
|
|
413
|
+
|
|
414
|
+
await this._createRoom(
|
|
415
|
+
{
|
|
416
|
+
hostSocketId: this._config.create.hostSocketId,
|
|
417
|
+
...roomDefaultsOf(this._gamesById.get(pending.gameId)),
|
|
418
|
+
...pending.settings,
|
|
419
|
+
},
|
|
420
|
+
{ promotion: pending },
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
if (this._ctx.entered) {
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
this._ctx.roomId = null;
|
|
428
|
+
this._signaling.promoteFailed(pending);
|
|
429
|
+
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import HostPrewarm from '../network/HostPrewarm.js';
|
|
2
|
+
import StandbyReceiver from '../network/StandbyReceiver.js';
|
|
3
|
+
|
|
4
|
+
// Роль беты (host-migration этап 6): назначение мастера ({ roomId, epoch,
|
|
5
|
+
// game }), приём контрольных точек по каналу standby, прогретый Worker и
|
|
6
|
+
// периодический standby_status.
|
|
7
|
+
//
|
|
8
|
+
// Без DOM: сигналинг, подготовку комнаты, конфиг и фабрики инъектирует
|
|
9
|
+
// сборщик (client/main.js).
|
|
10
|
+
|
|
11
|
+
export default class StandbyRole {
|
|
12
|
+
/**
|
|
13
|
+
* @param {Object} deps
|
|
14
|
+
* @param {Object} deps.signaling - SignalingClient.
|
|
15
|
+
* @param {Object} deps.ctx - client/session/roomContext.js.
|
|
16
|
+
* @param {Function} deps.isHost - () → вкладка держит Worker комнаты.
|
|
17
|
+
* @param {Function} deps.prepareRoom - prepareHostRoom (hostRoomPrep.js).
|
|
18
|
+
* @param {Object} deps.diagnostics
|
|
19
|
+
* @param {Object} deps.config - lobby.migration.
|
|
20
|
+
* @param {Function|null} [deps.debug] - debugLog dev-сборки.
|
|
21
|
+
* @param {Function} [deps.createReceiver]
|
|
22
|
+
* @param {Function} [deps.createPrewarm]
|
|
23
|
+
* @param {Function} [deps.now]
|
|
24
|
+
* @param {Object} [deps.timers] - { setInterval, clearInterval }.
|
|
25
|
+
*/
|
|
26
|
+
constructor({
|
|
27
|
+
signaling,
|
|
28
|
+
ctx,
|
|
29
|
+
isHost,
|
|
30
|
+
prepareRoom,
|
|
31
|
+
diagnostics,
|
|
32
|
+
config,
|
|
33
|
+
debug = null,
|
|
34
|
+
createReceiver = () => new StandbyReceiver(),
|
|
35
|
+
createPrewarm = options => new HostPrewarm(options),
|
|
36
|
+
now = () => Date.now(),
|
|
37
|
+
timers = globalThis,
|
|
38
|
+
}) {
|
|
39
|
+
this._signaling = signaling;
|
|
40
|
+
this._ctx = ctx;
|
|
41
|
+
this._isHost = isHost;
|
|
42
|
+
this._prepareRoom = prepareRoom;
|
|
43
|
+
this._diagnostics = diagnostics;
|
|
44
|
+
this._config = config;
|
|
45
|
+
this._debug = debug;
|
|
46
|
+
this._createReceiver = createReceiver;
|
|
47
|
+
this._createPrewarm = createPrewarm;
|
|
48
|
+
this._now = now;
|
|
49
|
+
this._timers = timers;
|
|
50
|
+
|
|
51
|
+
this._role = null;
|
|
52
|
+
this._receiver = null;
|
|
53
|
+
this._prewarm = null;
|
|
54
|
+
this._statusTimer = null;
|
|
55
|
+
this._received = 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// назначение мастера { roomId, epoch, game } или null
|
|
59
|
+
get role() {
|
|
60
|
+
return this._role;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
get receiver() {
|
|
64
|
+
return this._receiver;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
get prewarm() {
|
|
68
|
+
return this._prewarm;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
ensureReceiver() {
|
|
72
|
+
if (!this._receiver) {
|
|
73
|
+
this._receiver = this._createReceiver();
|
|
74
|
+
this._receiver.publisher.on('checkpoint', checkpoint =>
|
|
75
|
+
this._onCheckpoint(checkpoint),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return this._receiver;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// канал standby открывает хост только назначенной бете
|
|
83
|
+
attach(channel) {
|
|
84
|
+
this.ensureReceiver().attach(channel);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// бета помнит seq последнего кадра хоста (seqFloor, этап 6) — до
|
|
88
|
+
// push_frame: ядро может забрать буфер
|
|
89
|
+
noteFrame(data) {
|
|
90
|
+
this._receiver?.noteFrame(data);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// сорвавшаяся плановая передача: её финальная точка следующую не завершит
|
|
94
|
+
discardFinal() {
|
|
95
|
+
this._receiver?.discardFinal();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// standby_released, уход из комнаты, смена эпохи: прогретый Worker
|
|
99
|
+
// гасится, точки выбрасываются
|
|
100
|
+
teardown() {
|
|
101
|
+
this._timers.clearInterval(this._statusTimer);
|
|
102
|
+
this._statusTimer = null;
|
|
103
|
+
this._receiver?.destroy();
|
|
104
|
+
this._receiver = null;
|
|
105
|
+
this._prewarm?.destroy();
|
|
106
|
+
this._prewarm = null;
|
|
107
|
+
this._role = null;
|
|
108
|
+
this._received = 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// подписки сигналинга роли беты (лобби-режим, один раз на страницу)
|
|
112
|
+
bind() {
|
|
113
|
+
// гость: мастер назначил его бетой
|
|
114
|
+
this._signaling.publisher.on('standby_assigned', msg => {
|
|
115
|
+
if (this._isHost() || msg.roomId !== this._ctx.roomId) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (this._role && this._role.epoch !== msg.epoch) {
|
|
120
|
+
this.teardown();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// game — игра комнаты по данным мастера: прогрев сверяет с ней точку
|
|
124
|
+
this._role = {
|
|
125
|
+
roomId: msg.roomId,
|
|
126
|
+
epoch: msg.epoch,
|
|
127
|
+
game: msg.game ?? null,
|
|
128
|
+
};
|
|
129
|
+
this.ensureReceiver();
|
|
130
|
+
this._startDuties();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
this._signaling.publisher.on('standby_released', msg => {
|
|
134
|
+
if (msg.roomId === this._ctx.roomId) {
|
|
135
|
+
this.teardown();
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// полная точка собрана: мастеру — что она есть, Worker'у — прогрев
|
|
141
|
+
_onCheckpoint(checkpoint) {
|
|
142
|
+
this._received += 1;
|
|
143
|
+
|
|
144
|
+
this._debug?.('standby received', {
|
|
145
|
+
count: this._received,
|
|
146
|
+
bytes: checkpoint.bytes.byteLength,
|
|
147
|
+
checkpointId: checkpoint.checkpointId,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
this._startDuties();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// канал мог открыться раньше, чем пришло standby_assigned: обязанности
|
|
154
|
+
// беты начинаются, когда есть и назначение, и точка
|
|
155
|
+
_startDuties() {
|
|
156
|
+
const latest = this._receiver?.latest();
|
|
157
|
+
|
|
158
|
+
if (!this._role || !latest) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (this._statusTimer === null) {
|
|
163
|
+
this._reportStatus();
|
|
164
|
+
this._statusTimer = this._timers.setInterval(
|
|
165
|
+
() => this._reportStatus(),
|
|
166
|
+
this._config.standbyStatusIntervalMs,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
this._prewarm ??= this._createPrewarm({
|
|
171
|
+
prepareRoom: this._prepareRoom,
|
|
172
|
+
diagnostics: this._diagnostics,
|
|
173
|
+
onReady: prepared => {
|
|
174
|
+
this._debug?.('standby worker prewarmed', prepared.gameRef);
|
|
175
|
+
},
|
|
176
|
+
onError: error => console.warn('[standby] prewarm failed:', error),
|
|
177
|
+
});
|
|
178
|
+
this._prewarm.warm(latest, { allowedGame: this._role.game });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
_reportStatus() {
|
|
182
|
+
const latest = this._receiver?.latest();
|
|
183
|
+
|
|
184
|
+
if (this._role && latest) {
|
|
185
|
+
this._signaling.standbyStatus({
|
|
186
|
+
roomId: this._role.roomId,
|
|
187
|
+
epoch: this._role.epoch,
|
|
188
|
+
checkpointId: latest.checkpointId,
|
|
189
|
+
createdAt: latest.createdAt,
|
|
190
|
+
// мастер судит о свежести по возрасту точки, а не по приходу статуса
|
|
191
|
+
ageMs: Math.max(0, this._now() - latest.receivedAt),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|