vimp-engine 0.35.6 → 0.37.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/Chat.js +19 -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 +676 -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/chatMessages.js +45 -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 +2 -2
- 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 +29 -3
- 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 +0 -20
- package/tests/fixtures/miniGame/host/ScriptedManager.js +2 -3
- package/tests/fixtures/miniGame/host/fakeCore.js +31 -5
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import { decodeCheckpoint } from '../../lib/checkpointCodec.js';
|
|
2
|
+
import { sanitizeRoomSettings } from '../../lib/roomSettings.js';
|
|
3
|
+
import HostController from './HostController.js';
|
|
4
|
+
import { isConfirmedGame } from './HostPrewarm.js';
|
|
5
|
+
|
|
6
|
+
// Преемник становится хостом (host-migration этап 7.4). Режим checkpoint:
|
|
7
|
+
// матч поднимается из последней контрольной точки беты — в прогретом
|
|
8
|
+
// Worker'е (HostPrewarm, этап 6), если он той же версии игры, иначе в новом.
|
|
9
|
+
// Готовность Worker'а (матч на паузе) — onReady: дальше владелец
|
|
10
|
+
// (client/session/PromotionFlow.js) берёт на себя роль хоста — register_host с
|
|
11
|
+
// promotionToken, приём офферов, возобновление своего игрока. Режим planned
|
|
12
|
+
// (плановая передача, этап 8) — то же, но сначала ждём финальную точку
|
|
13
|
+
// замороженного хоста (продолжение с того же тика); не дождались за
|
|
14
|
+
// finalWaitMs или мастер повторил promote режимом checkpoint (хост пропал
|
|
15
|
+
// посреди передачи) — берём последнюю периодическую. Режим cold
|
|
16
|
+
// (точки нет) обходится без этого класса: отложенный промоушен в
|
|
17
|
+
// sessionStorage и перезагрузка в комнату (savePendingPromotion ниже).
|
|
18
|
+
//
|
|
19
|
+
// Без DOM и сигналинга: всё внешнее инъектируется.
|
|
20
|
+
|
|
21
|
+
// ключ отложенного холодного промоушена в sessionStorage
|
|
22
|
+
export const PROMOTION_STORAGE_KEY = 'vimp.promotion';
|
|
23
|
+
|
|
24
|
+
const TOKEN_PATTERN = /^[0-9a-f]{32}$/;
|
|
25
|
+
|
|
26
|
+
// promote мастера: годен ли для промоушена этой вкладкой
|
|
27
|
+
function isPromotion(value) {
|
|
28
|
+
return (
|
|
29
|
+
value !== null &&
|
|
30
|
+
typeof value === 'object' &&
|
|
31
|
+
typeof value.roomId === 'string' &&
|
|
32
|
+
value.roomId !== '' &&
|
|
33
|
+
Number.isInteger(value.epoch) &&
|
|
34
|
+
value.epoch > 0 &&
|
|
35
|
+
typeof value.promotionToken === 'string' &&
|
|
36
|
+
TOKEN_PATTERN.test(value.promotionToken)
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Сохраняет холодный промоушен перед перезагрузкой страницы в комнату.
|
|
42
|
+
* @param {Storage|null} storage - sessionStorage.
|
|
43
|
+
* @param {Object} promote - { roomId, epoch, promotionToken, settings }.
|
|
44
|
+
* @param {string} gameId
|
|
45
|
+
* @returns {boolean} false — сохранить не удалось (хранилище недоступно).
|
|
46
|
+
*/
|
|
47
|
+
export function savePendingPromotion(storage, promote, gameId) {
|
|
48
|
+
if (!storage || !isPromotion(promote) || typeof gameId !== 'string') {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
storage.setItem(
|
|
54
|
+
PROMOTION_STORAGE_KEY,
|
|
55
|
+
JSON.stringify({
|
|
56
|
+
roomId: promote.roomId,
|
|
57
|
+
epoch: promote.epoch,
|
|
58
|
+
promotionToken: promote.promotionToken,
|
|
59
|
+
gameId,
|
|
60
|
+
settings:
|
|
61
|
+
promote.settings && typeof promote.settings === 'object'
|
|
62
|
+
? promote.settings
|
|
63
|
+
: {},
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
return true;
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Забирает отложенный промоушен: запись удаляется при любом исходе —
|
|
75
|
+
* повторная перезагрузка не должна снова занимать комнату.
|
|
76
|
+
* @param {Storage|null} storage - sessionStorage.
|
|
77
|
+
* @returns {Object|null} { roomId, epoch, promotionToken, gameId, settings }.
|
|
78
|
+
*/
|
|
79
|
+
export function takePendingPromotion(storage) {
|
|
80
|
+
if (!storage) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let raw = null;
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
raw = storage.getItem(PROMOTION_STORAGE_KEY);
|
|
88
|
+
storage.removeItem(PROMOTION_STORAGE_KEY);
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!raw) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let value;
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
value = JSON.parse(raw);
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!isPromotion(value) || typeof value.gameId !== 'string') {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
roomId: value.roomId,
|
|
111
|
+
epoch: value.epoch,
|
|
112
|
+
promotionToken: value.promotionToken,
|
|
113
|
+
gameId: value.gameId,
|
|
114
|
+
settings:
|
|
115
|
+
value.settings && typeof value.settings === 'object'
|
|
116
|
+
? value.settings
|
|
117
|
+
: {},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export default class Promotion {
|
|
122
|
+
/**
|
|
123
|
+
* @param {Object} options
|
|
124
|
+
* @param {Object} options.promote - promote мастера { roomId, epoch,
|
|
125
|
+
* promotionToken, mode: 'checkpoint' | 'planned', reason }.
|
|
126
|
+
* @param {Object} options.receiver - StandbyReceiver (latest(),
|
|
127
|
+
* waitForFinal(), lastSeenSeq).
|
|
128
|
+
* @param {number} [options.finalWaitMs] - ожидание финальной точки
|
|
129
|
+
* (mode planned).
|
|
130
|
+
* @param {number} [options.maxRestoreAgeMs] - точка, полученная раньше,
|
|
131
|
+
* не поднимается: откат на столько — хуже холодного старта.
|
|
132
|
+
* @param {Function} [options.now] - часы (мс, как receivedAt приёмника).
|
|
133
|
+
* @param {Object|null} [options.prewarm] - HostPrewarm (take()).
|
|
134
|
+
* @param {Function} options.prepareRoom - (settings, gameRef) →
|
|
135
|
+
* Promise<{ room, workerUrl, mapsVersion, codeVersion }>.
|
|
136
|
+
* @param {string} options.hostSocketId - socketId своего игрока в Worker'е.
|
|
137
|
+
* @param {Function} [options.createController] - (room, opts) →
|
|
138
|
+
* HostController (тесты).
|
|
139
|
+
* @param {Function} [options.decode] - распаковка точки (тесты).
|
|
140
|
+
* @param {Object} [options.diagnostics]
|
|
141
|
+
* @param {Object} [options.hostCallbacks] - { onLobbyInfoChange,
|
|
142
|
+
* onMapChange } — колбэки Worker'а, которые живут дольше промоушена.
|
|
143
|
+
* @param {Function} [options.hasSession] - () → есть ли у своего игрока
|
|
144
|
+
* секрет места: без него он не вернётся в поднятый матч, а комнату
|
|
145
|
+
* хостила бы вкладка без игрока — промоушен отклоняется сразу.
|
|
146
|
+
* @param {Function} [options.holdSession] - плановая передача: поставить
|
|
147
|
+
* сессию своего игрока на паузу, не закрывая транспорт к замороженному
|
|
148
|
+
* хосту — по нему идёт финальная точка.
|
|
149
|
+
* @param {Function} options.onReady - ({ controller, room, prepared,
|
|
150
|
+
* lobbyInfo }) матч поднят на паузе.
|
|
151
|
+
* @param {Function} options.onFailed - (error) поднять не удалось.
|
|
152
|
+
*/
|
|
153
|
+
constructor({
|
|
154
|
+
promote,
|
|
155
|
+
receiver,
|
|
156
|
+
finalWaitMs = 3000,
|
|
157
|
+
maxRestoreAgeMs = 15000,
|
|
158
|
+
now = () => Date.now(),
|
|
159
|
+
prewarm = null,
|
|
160
|
+
prepareRoom,
|
|
161
|
+
hostSocketId,
|
|
162
|
+
createController = (room, opts) => new HostController(room, opts),
|
|
163
|
+
decode = decodeCheckpoint,
|
|
164
|
+
diagnostics = null,
|
|
165
|
+
hostCallbacks = {},
|
|
166
|
+
hasSession = () => true,
|
|
167
|
+
holdSession = () => {},
|
|
168
|
+
onReady,
|
|
169
|
+
onFailed,
|
|
170
|
+
}) {
|
|
171
|
+
this._promote = promote;
|
|
172
|
+
this._receiver = receiver;
|
|
173
|
+
this._finalWaitMs = finalWaitMs;
|
|
174
|
+
this._maxRestoreAgeMs = maxRestoreAgeMs;
|
|
175
|
+
this._now = now;
|
|
176
|
+
this._prewarm = prewarm;
|
|
177
|
+
this._prepareRoom = prepareRoom;
|
|
178
|
+
this._hostSocketId = hostSocketId;
|
|
179
|
+
this._createController = createController;
|
|
180
|
+
this._decode = decode;
|
|
181
|
+
this._diagnostics = diagnostics;
|
|
182
|
+
this._hostCallbacks = hostCallbacks;
|
|
183
|
+
this._hasSession = hasSession;
|
|
184
|
+
this._holdSession = holdSession;
|
|
185
|
+
this._onReady = onReady;
|
|
186
|
+
this._onFailed = onFailed;
|
|
187
|
+
|
|
188
|
+
// 'idle' | 'starting' | 'ready' | 'failed' | 'cancelled'
|
|
189
|
+
this._state = 'idle';
|
|
190
|
+
this._controller = null;
|
|
191
|
+
// прервать ожидание финальной точки (mode planned)
|
|
192
|
+
this._skipFinalWait = null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
get state() {
|
|
196
|
+
return this._state;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// { roomId, epoch, promotionToken } — для register_host и promote_failed
|
|
200
|
+
get promotion() {
|
|
201
|
+
const { roomId, epoch, promotionToken } = this._promote;
|
|
202
|
+
|
|
203
|
+
return { roomId, epoch, promotionToken };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Поднимает матч из последней точки. Результат — onReady/onFailed.
|
|
208
|
+
* @returns {Promise<void>}
|
|
209
|
+
*/
|
|
210
|
+
async start() {
|
|
211
|
+
if (this._state !== 'idle') {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
this._state = 'starting';
|
|
216
|
+
|
|
217
|
+
// сама, не дожидаясь host_migrating: его порядок относительно promote
|
|
218
|
+
// и наличие зависят от версии мастера
|
|
219
|
+
if (this._promote.mode === 'planned') {
|
|
220
|
+
this._holdSession();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
await this._start();
|
|
225
|
+
} catch (e) {
|
|
226
|
+
this._fail(e);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Повторный promote той же эпохи и токена режимом checkpoint: хост пропал
|
|
232
|
+
* посреди плановой передачи, финальной точки не будет.
|
|
233
|
+
* @param {Object} promote
|
|
234
|
+
* @returns {boolean} true — это повтор нашего промоушена.
|
|
235
|
+
*/
|
|
236
|
+
degrade(promote) {
|
|
237
|
+
if (
|
|
238
|
+
promote?.roomId !== this._promote.roomId ||
|
|
239
|
+
promote?.epoch !== this._promote.epoch ||
|
|
240
|
+
promote?.promotionToken !== this._promote.promotionToken
|
|
241
|
+
) {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
this._skipFinalWait?.();
|
|
246
|
+
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* promote_cancelled или уход из комнаты: поднятый Worker гасится, ответы
|
|
252
|
+
* опоздавших колбэков игнорируются.
|
|
253
|
+
*/
|
|
254
|
+
cancel() {
|
|
255
|
+
if (this._state === 'starting') {
|
|
256
|
+
this._controller?.destroy();
|
|
257
|
+
this._controller = null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (this._state !== 'failed') {
|
|
261
|
+
this._state = 'cancelled';
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
this._skipFinalWait?.();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// точка, из которой поднимается матч: финальная (planned) или последняя
|
|
268
|
+
// периодическая
|
|
269
|
+
async _pickCheckpoint() {
|
|
270
|
+
const receiver = this._receiver;
|
|
271
|
+
|
|
272
|
+
if (this._promote.mode !== 'planned' || !receiver?.waitForFinal) {
|
|
273
|
+
return receiver?.latest() ?? null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
let final = null;
|
|
277
|
+
|
|
278
|
+
try {
|
|
279
|
+
final = await new Promise((resolve, reject) => {
|
|
280
|
+
this._skipFinalWait = () => resolve(null);
|
|
281
|
+
receiver.waitForFinal(0, this._finalWaitMs).then(resolve, reject);
|
|
282
|
+
});
|
|
283
|
+
} catch {
|
|
284
|
+
final = null; // не дождались — откат к периодической минимален
|
|
285
|
+
} finally {
|
|
286
|
+
this._skipFinalWait = null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return final ?? receiver.latest() ?? null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async _start() {
|
|
293
|
+
if (!isPromotion(this._promote)) {
|
|
294
|
+
throw new Error('invalid promote message');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (!this._hasSession()) {
|
|
298
|
+
throw new Error('no session to resume the own player with');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const latest = await this._pickCheckpoint();
|
|
302
|
+
|
|
303
|
+
if (this._state !== 'starting') {
|
|
304
|
+
return; // отменили, пока ждали финальную точку
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (!latest) {
|
|
308
|
+
throw new Error('no checkpoint to restore from');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// поток точек мог встать задолго до падения хоста (ревью F8): отказ —
|
|
312
|
+
// promote_failed, мастер переходит к холодному старту. Финальная точка
|
|
313
|
+
// плановой передачи свежая всегда
|
|
314
|
+
if (this._now() - latest.receivedAt > this._maxRestoreAgeMs) {
|
|
315
|
+
throw new Error('checkpoint is too old to restore from');
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// копия: буфер уходит в Worker списком переноса, а точка приёмника
|
|
319
|
+
// должна остаться целой (повторная попытка, отладка)
|
|
320
|
+
const bytes = latest.bytes.slice();
|
|
321
|
+
const { meta } = await this._decode(latest.bytes);
|
|
322
|
+
const gameRef = meta?.room?.game ?? null;
|
|
323
|
+
|
|
324
|
+
if (!gameRef?.id || !gameRef.version) {
|
|
325
|
+
throw new Error('checkpoint has no room.game');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// игру и её версию подтверждает мастер: точка от хоста — недоверенная
|
|
329
|
+
if (!isConfirmedGame(gameRef, this._promote.game ?? null)) {
|
|
330
|
+
throw new Error('checkpoint game is not the room game');
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (this._state !== 'starting') {
|
|
334
|
+
return; // отменили, пока распаковывали
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const settings = sanitizeRoomSettings(meta.room?.settings);
|
|
338
|
+
const seqFloor = Math.max(this._receiver.lastSeenSeq ?? 0, latest.seq ?? 0);
|
|
339
|
+
|
|
340
|
+
// прогретый Worker годится только той же версии игры, что в точке
|
|
341
|
+
const warm = this._prewarm?.take() ?? null;
|
|
342
|
+
const warmMatches =
|
|
343
|
+
warm &&
|
|
344
|
+
warm.prepared?.gameRef?.id === gameRef.id &&
|
|
345
|
+
warm.prepared?.gameRef?.version === gameRef.version;
|
|
346
|
+
|
|
347
|
+
if (warm && !warmMatches) {
|
|
348
|
+
warm.controller.destroy();
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// копия: prepareHostRoom мутирует аргумент (isDevMode, game, maps)
|
|
352
|
+
const prepared = warmMatches
|
|
353
|
+
? warm.prepared
|
|
354
|
+
: await this._prepareRoom({ ...settings }, gameRef);
|
|
355
|
+
|
|
356
|
+
if (this._state !== 'starting') {
|
|
357
|
+
if (warmMatches) {
|
|
358
|
+
warm.controller.destroy();
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// точка недоверенная: из неё — только настройки комнаты, а игра, карты
|
|
365
|
+
// и dev-режим — свои (prepared.room)
|
|
366
|
+
const room = {
|
|
367
|
+
...prepared.room,
|
|
368
|
+
...settings,
|
|
369
|
+
hostSocketId: this._hostSocketId,
|
|
370
|
+
roomId: this._promote.roomId,
|
|
371
|
+
epoch: this._promote.epoch,
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
const callbacks = {
|
|
375
|
+
seqFloor,
|
|
376
|
+
onReady: msg => this._ready(room, prepared, msg),
|
|
377
|
+
onError: msg =>
|
|
378
|
+
this._fail(new Error(msg?.message || 'worker init failed')),
|
|
379
|
+
onLobbyInfoChange: info => this._hostCallbacks.onLobbyInfoChange?.(info),
|
|
380
|
+
onMapChange: mapName => this._hostCallbacks.onMapChange?.(mapName),
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
if (warmMatches) {
|
|
384
|
+
this._controller = warm.controller;
|
|
385
|
+
this._controller.initFromCheckpoint(room, bytes, callbacks);
|
|
386
|
+
} else {
|
|
387
|
+
this._controller = this._createController(room, {
|
|
388
|
+
workerUrl: prepared.workerUrl ?? undefined,
|
|
389
|
+
diagnostics: this._diagnostics ?? undefined,
|
|
390
|
+
checkpoint: bytes,
|
|
391
|
+
...callbacks,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
_ready(room, prepared, msg) {
|
|
397
|
+
if (this._state !== 'starting') {
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
this._state = 'ready';
|
|
402
|
+
this._onReady({
|
|
403
|
+
controller: this._controller,
|
|
404
|
+
room,
|
|
405
|
+
prepared,
|
|
406
|
+
lobbyInfo: msg?.lobbyInfo ?? null,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
_fail(error) {
|
|
411
|
+
if (this._state !== 'starting') {
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
this._state = 'failed';
|
|
416
|
+
this._controller?.destroy();
|
|
417
|
+
this._controller = null;
|
|
418
|
+
this._onFailed(error);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Отчёт хоста room_peers мастеру (host-migration-review этап 4): memberId
|
|
2
|
+
// гостей, у которых открыты оба канала к хосту. Мастер по нему считает
|
|
3
|
+
// игроков в лобби и кандидатов в беты — сигнальная сессия без WebRTC-пира
|
|
4
|
+
// (фантом) не раздувает счётчик и не становится преемником. Состав пиров
|
|
5
|
+
// меняется пачками (вход, реконнект) — отправка отложена на debounceMs; раз
|
|
6
|
+
// в intervalMs отчёт повторяется (мастер мог перезапуститься).
|
|
7
|
+
//
|
|
8
|
+
// Без DOM: отправку, комнату, состав и таймеры инъектирует владелец
|
|
9
|
+
// (client/session/HostRole.js).
|
|
10
|
+
|
|
11
|
+
export default class RoomPeersReporter {
|
|
12
|
+
/**
|
|
13
|
+
* @param {Object} options
|
|
14
|
+
* @param {Function} options.send - ({ roomId, epoch, memberIds }).
|
|
15
|
+
* @param {Function} options.getRoom - () → { roomId, epoch } | null.
|
|
16
|
+
* @param {Function} options.getMemberIds - () → string[].
|
|
17
|
+
* @param {number} options.intervalMs
|
|
18
|
+
* @param {number} [options.debounceMs]
|
|
19
|
+
* @param {Object} [options.timers] - { setTimeout, clearTimeout,
|
|
20
|
+
* setInterval, clearInterval } (тесты).
|
|
21
|
+
*/
|
|
22
|
+
constructor({
|
|
23
|
+
send,
|
|
24
|
+
getRoom,
|
|
25
|
+
getMemberIds,
|
|
26
|
+
intervalMs,
|
|
27
|
+
debounceMs = 500,
|
|
28
|
+
timers = globalThis,
|
|
29
|
+
}) {
|
|
30
|
+
this._send = send;
|
|
31
|
+
this._getRoom = getRoom;
|
|
32
|
+
this._getMemberIds = getMemberIds;
|
|
33
|
+
this._debounceMs = debounceMs;
|
|
34
|
+
this._timers = timers;
|
|
35
|
+
this._debounceTimer = null;
|
|
36
|
+
this._destroyed = false;
|
|
37
|
+
this._interval = timers.setInterval(() => this.refresh(), intervalMs);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// состав пиров изменился — отчёт через debounceMs
|
|
41
|
+
notify() {
|
|
42
|
+
if (this._destroyed || this._debounceTimer !== null) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
this._debounceTimer = this._timers.setTimeout(() => {
|
|
47
|
+
this._debounceTimer = null;
|
|
48
|
+
this.refresh();
|
|
49
|
+
}, this._debounceMs);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// отчёт сейчас (хост только что зарегистрирован)
|
|
53
|
+
refresh() {
|
|
54
|
+
this._cancelDebounce();
|
|
55
|
+
|
|
56
|
+
const room = this._getRoom();
|
|
57
|
+
|
|
58
|
+
if (this._destroyed || !room) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
this._send({
|
|
63
|
+
roomId: room.roomId,
|
|
64
|
+
epoch: room.epoch,
|
|
65
|
+
memberIds: this._getMemberIds(),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
destroy() {
|
|
70
|
+
this._destroyed = true;
|
|
71
|
+
this._cancelDebounce();
|
|
72
|
+
this._timers.clearInterval(this._interval);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
_cancelDebounce() {
|
|
76
|
+
if (this._debounceTimer !== null) {
|
|
77
|
+
this._timers.clearTimeout(this._debounceTimer);
|
|
78
|
+
this._debounceTimer = null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|