vimp-engine 0.35.5 → 0.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/vimp-sim.js +19 -1
- package/core/Cargo.toml +1 -1
- package/package.json +1 -1
- package/src/client/components/controller/Controls.js +5 -0
- package/src/client/components/controller/Lobby.js +4 -4
- package/src/client/components/controller/RoomMenu.js +43 -0
- package/src/client/components/controller/Vote.js +18 -2
- package/src/client/components/model/ClientReports.js +6 -2
- package/src/client/components/model/Controls.js +27 -0
- package/src/client/components/model/Games.js +5 -1
- package/src/client/components/model/Lobby.js +32 -24
- package/src/client/components/model/LobbyAuth.js +20 -4
- package/src/client/components/model/RoomMenu.js +121 -0
- package/src/client/components/model/Stat.js +2 -1
- package/src/client/components/model/Vote.js +122 -4
- package/src/client/components/view/Auth.js +20 -10
- package/src/client/components/view/ClientReports.js +16 -5
- package/src/client/components/view/Games.js +39 -14
- package/src/client/components/view/Lobby.js +90 -43
- package/src/client/components/view/LobbyAuth.js +13 -7
- package/src/client/components/view/Panel.js +1 -2
- package/src/client/components/view/RoomMenu.js +69 -0
- package/src/client/debug.js +24 -1
- package/src/client/lib/FpsMeter.js +46 -0
- package/src/client/lib/JoinRetry.js +73 -0
- package/src/client/lib/diagnostics.js +28 -5
- package/src/client/lib/hostCaps.js +82 -0
- package/src/client/lib/hostUnloadGuard.js +108 -0
- package/src/client/lib/hostVoteCommand.js +92 -0
- package/src/client/lib/pageReload.js +29 -0
- package/src/client/lib/roomLink.js +257 -0
- package/src/client/lib/signalingErrors.js +74 -0
- package/src/client/main.js +674 -451
- package/src/client/network/HiddenHostHealthLog.js +76 -0
- package/src/client/network/HostConnectionManager.js +62 -8
- package/src/client/network/HostController.js +376 -23
- package/src/client/network/HostHealthPolicy.js +307 -0
- package/src/client/network/HostHealthReporter.js +54 -0
- package/src/client/network/HostPrewarm.js +235 -0
- package/src/client/network/LoopbackTransport.js +16 -1
- package/src/client/network/PlannedHandoff.js +366 -0
- package/src/client/network/Promotion.js +420 -0
- package/src/client/network/RoomPeersReporter.js +81 -0
- package/src/client/network/SessionSupervisor.js +730 -0
- package/src/client/network/SignalingClient.js +183 -30
- package/src/client/network/StandbyReceiver.js +287 -0
- package/src/client/network/StandbySender.js +319 -0
- package/src/client/network/TokenHandoffTimer.js +81 -0
- package/src/client/network/WebRtcManager.js +338 -11
- package/src/client/network/policyClose.js +32 -2
- package/src/client/network/standbyChunks.js +185 -0
- package/src/client/session/GuestSession.js +428 -0
- package/src/client/session/HandoffFlow.js +399 -0
- package/src/client/session/HostRole.js +708 -0
- package/src/client/session/Membership.js +179 -0
- package/src/client/session/PromotionFlow.js +325 -0
- package/src/client/session/RouteBoot.js +432 -0
- package/src/client/session/StandbyRole.js +195 -0
- package/src/client/session/hostRoomPrep.js +152 -0
- package/src/client/session/roomContext.js +32 -0
- package/src/client/style.css +83 -22
- package/src/client/views/gameShell.js +7 -0
- package/src/client/views/includes/informer.pug +4 -0
- package/src/client/views/includes/lobby.pug +0 -3
- package/src/client/views/includes/roomMenu.pug +9 -0
- package/src/client/views/index.pug +1 -0
- package/src/config/closeCodes.js +1 -1
- package/src/config/env.js +37 -0
- package/src/config/hostDefaults.js +24 -0
- package/src/config/lobby.js +154 -3
- package/src/config/master.js +104 -21
- package/src/config/wsports.js +8 -0
- package/src/devtools/RecordingSocketManager.js +3 -2
- package/src/devtools/ScenarioRunner.js +123 -8
- package/src/devtools/VirtualClient.js +20 -0
- package/src/devtools/contract/format.js +4 -1
- package/src/devtools/contract/index.js +1 -2
- package/src/devtools/contract/rules/a2-package-scripts.js +1 -3
- package/src/devtools/contract/rules/b8-system-messages.js +1 -1
- package/src/devtools/contract/rules/b9-vote-names.js +25 -1
- package/src/devtools/contract/rules/c11-stat-mode.js +4 -1
- package/src/devtools/contract/rules/c6-stat-columns.js +1 -1
- package/src/devtools/contract/rules/c7-key-sets.js +8 -4
- package/src/devtools/contract/rules/d3-snapshot-interp.js +2 -1
- package/src/devtools/contract/rules/e1-sound-pairs.js +3 -1
- package/src/devtools/contract/rules/e4-map-layers.js +13 -2
- package/src/devtools/contract/rules/e5-map-radar-walls.js +7 -1
- package/src/devtools/contract/rules/index.js +38 -5
- package/src/devtools/invariants.js +10 -4
- package/src/devtools/pluginLoader.js +1 -4
- package/src/devtools/report.js +17 -1
- package/src/host/DebugRecorder.js +6 -1
- package/src/host/GameCoreAdapter.js +45 -0
- package/src/host/HostGame.js +928 -59
- package/src/host/PortMachine.js +268 -20
- package/src/host/host.worker.js +222 -16
- package/src/host/meta/SocketManager.js +20 -0
- package/src/host/meta/core/RoundManager.js +89 -14
- package/src/host/meta/modules/Accolades.js +4 -1
- package/src/host/meta/modules/Panel.js +30 -0
- package/src/host/meta/modules/PlayerDataSync.js +255 -46
- package/src/host/meta/modules/RTTManager.js +34 -0
- package/src/host/meta/modules/TimerManager.js +215 -18
- package/src/host/meta/modules/chat/systemMessages.js +22 -0
- package/src/host/meta/player/HumanParticipant.js +10 -0
- package/src/host/meta/player/ParticipantManager.js +98 -5
- package/src/lib/AbstractTimer.js +63 -1
- package/src/lib/Publisher.js +26 -0
- package/src/lib/capabilities.js +5 -0
- package/src/lib/checkpointCodec.js +125 -0
- package/src/lib/clientIp.js +9 -2
- package/src/lib/createHostRuntime.js +95 -6
- package/src/lib/formOptions.js +3 -1
- package/src/lib/gameConfigView.js +10 -0
- package/src/lib/gameId.js +13 -0
- package/src/lib/jwt.js +3 -1
- package/src/lib/loadGamePackage.js +2 -2
- package/src/lib/resumeKey.js +38 -0
- package/src/lib/roomId.js +39 -0
- package/src/lib/roomSettings.js +59 -0
- package/src/lib/validators.js +7 -0
- package/tests/fixtures/generations/gen-api3/host/ScriptedManager.js +2 -3
- package/tests/fixtures/generations/gen-api3/host/fakeCore.js +31 -5
- package/tests/fixtures/generations/gen-api3/manifest.json +1 -3
- package/tests/fixtures/generations/gen-api4/host/ScriptedManager.js +2 -3
- package/tests/fixtures/generations/gen-api4/host/fakeCore.js +31 -5
- package/tests/fixtures/generations/gen-api4/manifest.json +1 -3
- package/tests/fixtures/miniGame/config/client.js +15 -0
- package/tests/fixtures/miniGame/host/ScriptedManager.js +2 -3
- package/tests/fixtures/miniGame/host/fakeCore.js +31 -5
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// Политика автоматической передачи хоста (host-migration этап 9b): из потока
|
|
2
|
+
// здоровья Worker'а (HostController.onHealth, сэмпл раз в ~1 с), видимости
|
|
3
|
+
// вкладки и наличия беты решает, когда отдать роль бете.
|
|
4
|
+
//
|
|
5
|
+
// - Мягкая перегрузка (среднее tickRate последних сэмплов окна
|
|
6
|
+
// overloadWindowMs ниже overloadTickRate) — передача, отложенная до
|
|
7
|
+
// границы раунда (PlannedHandoff defer).
|
|
8
|
+
// - Жёсткая (среднее окна criticalWindowMs ниже criticalTickRate или lostMs
|
|
9
|
+
// > 0 у lostWindows сэмплов подряд) и скрытая вкладка дольше
|
|
10
|
+
// hiddenHandoffMs — передача сразу; ждущую границы раунда — ускорить.
|
|
11
|
+
// - Пока своя отложенная передача ждёт границы, а все сэмплы окна
|
|
12
|
+
// recoverWindowMs выше recoverTickRate (гистерезис над порогом входа) —
|
|
13
|
+
// отменить её: иначе передача сработала бы вхолостую на границе раунда.
|
|
14
|
+
//
|
|
15
|
+
// Новая передача — только при бете, после minHostTenureMs в роли хоста и
|
|
16
|
+
// autoHandoffCooldownMs с прошлой авто-передачи этой вкладки (с её запуска,
|
|
17
|
+
// при любом исходе). Отмена по нормализации кулдаун возвращает: от мигания
|
|
18
|
+
// защищают гистерезис и окна. Политика живёт дольше роли хоста — кулдаун
|
|
19
|
+
// переживает смену ролей.
|
|
20
|
+
//
|
|
21
|
+
// Без DOM: передачу, часы и таймеры инъектирует владелец
|
|
22
|
+
// (client/session/HandoffFlow.js).
|
|
23
|
+
|
|
24
|
+
export const AUTO_HANDOFF_REASONS = ['overload', 'hidden'];
|
|
25
|
+
|
|
26
|
+
// сэмпла нет дольше этого (в интервалах здоровья) — матч стоял, окна заново
|
|
27
|
+
const SAMPLE_GAP_INTERVALS = 2;
|
|
28
|
+
|
|
29
|
+
const average = values => values.reduce((a, b) => a + b, 0) / values.length;
|
|
30
|
+
|
|
31
|
+
export default class HostHealthPolicy {
|
|
32
|
+
/**
|
|
33
|
+
* @param {Object} options
|
|
34
|
+
* @param {Object} options.config - lobby.migration.auto.
|
|
35
|
+
* @param {Object} options.handoff - передача: { start({ reason, defer })
|
|
36
|
+
* → boolean, hurry(reason) → boolean, cancelDeferred() → boolean,
|
|
37
|
+
* deferredReason() → string|null (причина передачи, ждущей границы) }.
|
|
38
|
+
* @param {number} [options.healthIntervalMs] - период сэмплов Worker'а.
|
|
39
|
+
* @param {Function} [options.now] - монотонные часы, мс.
|
|
40
|
+
* @param {Object} [options.timers] - { setTimeout, clearTimeout } (тесты).
|
|
41
|
+
*/
|
|
42
|
+
constructor({
|
|
43
|
+
config,
|
|
44
|
+
handoff,
|
|
45
|
+
healthIntervalMs = 1000,
|
|
46
|
+
now = () => performance.now(),
|
|
47
|
+
timers = globalThis,
|
|
48
|
+
}) {
|
|
49
|
+
this._config = config;
|
|
50
|
+
this._handoff = handoff;
|
|
51
|
+
this._intervalMs = healthIntervalMs;
|
|
52
|
+
this._now = now;
|
|
53
|
+
this._timers = timers;
|
|
54
|
+
|
|
55
|
+
this._hostSince = null;
|
|
56
|
+
this._successor = false;
|
|
57
|
+
this._hiddenSince = null;
|
|
58
|
+
this._hiddenTimer = null;
|
|
59
|
+
this._lastAutoAt = null;
|
|
60
|
+
this._cooldownBefore = null;
|
|
61
|
+
this._samples = [];
|
|
62
|
+
this._lastSampleAt = null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// число сэмплов в окне длиной ms
|
|
66
|
+
_count(ms) {
|
|
67
|
+
return Math.max(1, Math.ceil(ms / this._intervalMs));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Вкладка получила или потеряла роль хоста.
|
|
72
|
+
* @param {boolean} isHost
|
|
73
|
+
*/
|
|
74
|
+
setHost(isHost) {
|
|
75
|
+
if (isHost === (this._hostSince !== null)) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
this._hostSince = isHost ? this._now() : null;
|
|
80
|
+
this._successor = false;
|
|
81
|
+
this._resetSamples();
|
|
82
|
+
|
|
83
|
+
// роль получена скрытой вкладкой: скрытие отсчитывается от получения
|
|
84
|
+
if (isHost && this._hiddenSince !== null) {
|
|
85
|
+
this._hiddenSince = this._hostSince;
|
|
86
|
+
this._armHiddenTimer();
|
|
87
|
+
} else if (!isHost) {
|
|
88
|
+
this._clearHiddenTimer();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// назначена ли бета (successor_assigned)
|
|
93
|
+
setSuccessor(available) {
|
|
94
|
+
this._successor = Boolean(available);
|
|
95
|
+
this._checkHidden();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
setHidden(hidden) {
|
|
99
|
+
if (hidden && this._hiddenSince === null) {
|
|
100
|
+
this._hiddenSince = this._now();
|
|
101
|
+
this._armHiddenTimer();
|
|
102
|
+
} else if (!hidden) {
|
|
103
|
+
this._hiddenSince = null;
|
|
104
|
+
this._clearHiddenTimer();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// сэмпл здоровья Worker'а
|
|
109
|
+
addHealth(health) {
|
|
110
|
+
if (this._hostSince === null || !health) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const now = this._now();
|
|
115
|
+
|
|
116
|
+
if (
|
|
117
|
+
this._lastSampleAt !== null &&
|
|
118
|
+
now - this._lastSampleAt > SAMPLE_GAP_INTERVALS * this._intervalMs
|
|
119
|
+
) {
|
|
120
|
+
this._samples = [];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
this._lastSampleAt = now;
|
|
124
|
+
this._samples.push({ tickRate: health.tickRate, lostMs: health.lostMs });
|
|
125
|
+
|
|
126
|
+
const keep = Math.max(
|
|
127
|
+
this._count(this._config.overloadWindowMs),
|
|
128
|
+
this._count(this._config.criticalWindowMs),
|
|
129
|
+
this._count(this._config.recoverWindowMs),
|
|
130
|
+
this._config.lostWindows,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
if (this._samples.length > keep) {
|
|
134
|
+
this._samples.splice(0, this._samples.length - keep);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
this._evaluate();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
destroy() {
|
|
141
|
+
this._clearHiddenTimer();
|
|
142
|
+
this._hostSince = null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
_evaluate() {
|
|
146
|
+
if (this._checkHidden()) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (this._critical()) {
|
|
151
|
+
this._handOverNow('overload');
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const ownDeferred = AUTO_HANDOFF_REASONS.includes(
|
|
156
|
+
this._handoff.deferredReason(),
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
if (ownDeferred) {
|
|
160
|
+
if (this._recovered() && this._handoff.cancelDeferred()) {
|
|
161
|
+
this._lastAutoAt = this._cooldownBefore;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (this._overloaded() && this._mayStart()) {
|
|
168
|
+
this._start('overload', true);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// скрытая вкладка дольше порога: передача сразу. true — сработало.
|
|
173
|
+
// Отказ из-за срока роли или кулдауна — повторить, когда они истекут:
|
|
174
|
+
// у стоящего матча сэмплов, которые перепроверили бы условие, нет
|
|
175
|
+
_checkHidden() {
|
|
176
|
+
if (
|
|
177
|
+
this._hostSince === null ||
|
|
178
|
+
this._hiddenSince === null ||
|
|
179
|
+
this._now() - this._hiddenSince < this._config.hiddenHandoffMs
|
|
180
|
+
) {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (this._handOverNow('hidden')) {
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const waitMs = this._blockedForMs();
|
|
189
|
+
|
|
190
|
+
if (waitMs > 0 && this._hiddenTimer === null) {
|
|
191
|
+
this._armHiddenTimer(waitMs);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// немедленная передача: ждущую границы — ускорить, иначе начать
|
|
198
|
+
_handOverNow(reason) {
|
|
199
|
+
if (!this._enabled() || !this._successor) {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (this._handoff.deferredReason() !== null) {
|
|
204
|
+
return this._handoff.hurry(reason);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return this._mayStart() && this._start(reason, false);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
_start(reason, defer) {
|
|
211
|
+
if (!this._handoff.start({ reason, defer })) {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
this._cooldownBefore = this._lastAutoAt;
|
|
216
|
+
this._lastAutoAt = this._now();
|
|
217
|
+
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
_enabled() {
|
|
222
|
+
return this._config.enabled !== false && this._hostSince !== null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// через сколько истекут срок роли и кулдаун (0 — не мешают)
|
|
226
|
+
_blockedForMs() {
|
|
227
|
+
const now = this._now();
|
|
228
|
+
const tenureLeft = this._hostSince + this._config.minHostTenureMs - now;
|
|
229
|
+
const cooldownLeft =
|
|
230
|
+
this._lastAutoAt === null
|
|
231
|
+
? 0
|
|
232
|
+
: this._lastAutoAt + this._config.autoHandoffCooldownMs - now;
|
|
233
|
+
|
|
234
|
+
return Math.max(0, tenureLeft, cooldownLeft);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
_mayStart() {
|
|
238
|
+
const now = this._now();
|
|
239
|
+
|
|
240
|
+
return (
|
|
241
|
+
this._enabled() &&
|
|
242
|
+
this._successor &&
|
|
243
|
+
now - this._hostSince >= this._config.minHostTenureMs &&
|
|
244
|
+
(this._lastAutoAt === null ||
|
|
245
|
+
now - this._lastAutoAt >= this._config.autoHandoffCooldownMs)
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// последние n сэмплов (null — окно ещё не набрано)
|
|
250
|
+
_last(n) {
|
|
251
|
+
return this._samples.length >= n ? this._samples.slice(-n) : null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
_overloaded() {
|
|
255
|
+
const window = this._last(this._count(this._config.overloadWindowMs));
|
|
256
|
+
|
|
257
|
+
return (
|
|
258
|
+
window !== null &&
|
|
259
|
+
average(window.map(s => s.tickRate)) < this._config.overloadTickRate
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
_critical() {
|
|
264
|
+
const window = this._last(this._count(this._config.criticalWindowMs));
|
|
265
|
+
const lost = this._last(this._config.lostWindows);
|
|
266
|
+
|
|
267
|
+
return (
|
|
268
|
+
(window !== null &&
|
|
269
|
+
average(window.map(s => s.tickRate)) < this._config.criticalTickRate) ||
|
|
270
|
+
(lost !== null && lost.every(s => s.lostMs > 0))
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
_recovered() {
|
|
275
|
+
const window = this._last(this._count(this._config.recoverWindowMs));
|
|
276
|
+
|
|
277
|
+
return (
|
|
278
|
+
window !== null &&
|
|
279
|
+
window.every(s => s.tickRate > this._config.recoverTickRate)
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
_resetSamples() {
|
|
284
|
+
this._samples = [];
|
|
285
|
+
this._lastSampleAt = null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
_armHiddenTimer(delayMs = this._config.hiddenHandoffMs) {
|
|
289
|
+
this._clearHiddenTimer();
|
|
290
|
+
|
|
291
|
+
if (this._hostSince === null) {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
this._hiddenTimer = this._timers.setTimeout(() => {
|
|
296
|
+
this._hiddenTimer = null;
|
|
297
|
+
this._checkHidden();
|
|
298
|
+
}, delayMs);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
_clearHiddenTimer() {
|
|
302
|
+
if (this._hiddenTimer !== null) {
|
|
303
|
+
this._timers.clearTimeout(this._hiddenTimer);
|
|
304
|
+
this._hiddenTimer = null;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Отчёт здоровья хоста мастеру (host-migration этап 9c): из сэмплов health
|
|
2
|
+
// Worker'а (раз в ~1 с) не чаще раза в intervalMs уходит host_health с
|
|
3
|
+
// последним сэмплом. Мастер по нему решает, не отдать ли роль бете из-за
|
|
4
|
+
// сетевого лага. Пока матч заморожен, Worker сэмплов не шлёт — отчётов нет,
|
|
5
|
+
// и мастер видит разрыв «непрерывности» лага.
|
|
6
|
+
//
|
|
7
|
+
// Без DOM: отправку, комнату и часы инъектирует владелец
|
|
8
|
+
// (client/session/HostRole.js).
|
|
9
|
+
|
|
10
|
+
export default class HostHealthReporter {
|
|
11
|
+
/**
|
|
12
|
+
* @param {Object} options
|
|
13
|
+
* @param {Function} options.send - ({ roomId, epoch, tickRate,
|
|
14
|
+
* peerRttMedian, peerCount }) отправка мастеру.
|
|
15
|
+
* @param {Function} options.getRoom - () → { roomId, epoch } | null.
|
|
16
|
+
* @param {number} options.intervalMs
|
|
17
|
+
* @param {Function} [options.now] - монотонные часы, мс.
|
|
18
|
+
*/
|
|
19
|
+
constructor({ send, getRoom, intervalMs, now = () => performance.now() }) {
|
|
20
|
+
this._send = send;
|
|
21
|
+
this._getRoom = getRoom;
|
|
22
|
+
this._intervalMs = intervalMs;
|
|
23
|
+
this._now = now;
|
|
24
|
+
this._lastSentAt = null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Сэмпл health Worker'а.
|
|
29
|
+
* @param {Object} health - { tickRate, peerRttMedian, peerCount, … }.
|
|
30
|
+
* @returns {boolean} отчёт отправлен.
|
|
31
|
+
*/
|
|
32
|
+
add(health) {
|
|
33
|
+
const room = this._getRoom();
|
|
34
|
+
const now = this._now();
|
|
35
|
+
|
|
36
|
+
if (
|
|
37
|
+
!room ||
|
|
38
|
+
(this._lastSentAt !== null && now - this._lastSentAt < this._intervalMs)
|
|
39
|
+
) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
this._lastSentAt = now;
|
|
44
|
+
this._send({
|
|
45
|
+
roomId: room.roomId,
|
|
46
|
+
epoch: room.epoch,
|
|
47
|
+
tickRate: health.tickRate ?? null,
|
|
48
|
+
peerRttMedian: health.peerRttMedian ?? null,
|
|
49
|
+
peerCount: health.peerCount ?? 0,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { decodeCheckpoint } from '../../lib/checkpointCodec.js';
|
|
2
|
+
import HostController from './HostController.js';
|
|
3
|
+
|
|
4
|
+
// Прогрев преемника (host-migration этап 6): по первой полной контрольной
|
|
5
|
+
// точке бета готовит комнату так же, как это делает создание комнаты
|
|
6
|
+
// (prepareHostRoom в client/session/hostRoomPrep.js: манифест игры той
|
|
7
|
+
// версии, что в точке, карты мастера, URL worker-бандла), и поднимает
|
|
8
|
+
// HostController в режиме preload —
|
|
9
|
+
// Worker импортировал плагин и скомпилировал wasm, матча нет. Промоушен
|
|
10
|
+
// (этап 7) поднимет в нём матч из точки без ожидания сети.
|
|
11
|
+
/**
|
|
12
|
+
* Игра точки совпадает с той, что подтвердил мастер (promote.game /
|
|
13
|
+
* standby_assigned.game): точку шлёт хост — другой игрок, ей нельзя верить.
|
|
14
|
+
* Мастер без поля game (allowed — null) — проверка пропускается.
|
|
15
|
+
* @param {{ id: string, version: string }} gameRef - room.game точки.
|
|
16
|
+
* @param {{ id: string, versions?: string[] }|null} allowed
|
|
17
|
+
* @returns {boolean}
|
|
18
|
+
*/
|
|
19
|
+
export function isConfirmedGame(gameRef, allowed) {
|
|
20
|
+
if (!allowed) {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
gameRef.id === allowed.id &&
|
|
26
|
+
(!Array.isArray(allowed.versions) ||
|
|
27
|
+
allowed.versions.length === 0 ||
|
|
28
|
+
allowed.versions.includes(gameRef.version))
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export default class HostPrewarm {
|
|
33
|
+
/**
|
|
34
|
+
* @param {Object} options
|
|
35
|
+
* @param {Function} options.prepareRoom - (settings, gameRef) →
|
|
36
|
+
* Promise<{ room, workerUrl, mapsVersion, codeVersion }>; gameRef — { id,
|
|
37
|
+
* version } из точки.
|
|
38
|
+
* @param {Function} [options.createController] - (room, opts) →
|
|
39
|
+
* HostController (тесты).
|
|
40
|
+
* @param {Function} [options.decode] - распаковка точки (тесты).
|
|
41
|
+
* @param {Object} [options.diagnostics]
|
|
42
|
+
* @param {Function} [options.onReady] - Worker прогрет (prepared).
|
|
43
|
+
* @param {Function} [options.onError] - прогрев не удался (Error).
|
|
44
|
+
*/
|
|
45
|
+
constructor({
|
|
46
|
+
prepareRoom,
|
|
47
|
+
createController = (room, opts) => new HostController(room, opts),
|
|
48
|
+
decode = decodeCheckpoint,
|
|
49
|
+
diagnostics = null,
|
|
50
|
+
onReady = null,
|
|
51
|
+
onError = null,
|
|
52
|
+
}) {
|
|
53
|
+
this._prepareRoom = prepareRoom;
|
|
54
|
+
this._createController = createController;
|
|
55
|
+
this._decode = decode;
|
|
56
|
+
this._diagnostics = diagnostics;
|
|
57
|
+
this._onReady = onReady;
|
|
58
|
+
this._onError = onError;
|
|
59
|
+
|
|
60
|
+
// 'idle' | 'warming' | 'ready' | 'failed' | 'destroyed'
|
|
61
|
+
this._state = 'idle';
|
|
62
|
+
this._gameKey = null;
|
|
63
|
+
// версия, прогрев которой провалился: точки идут 2 раза в секунду, и
|
|
64
|
+
// повтор на каждую означал бы шторм запросов к мастеру
|
|
65
|
+
this._failedKey = null;
|
|
66
|
+
// последний отказ точке: та же причина на следующих точках (2 раза в
|
|
67
|
+
// секунду) в журнал не идёт
|
|
68
|
+
this._rejectedKey = null;
|
|
69
|
+
this._controller = null;
|
|
70
|
+
this._prepared = null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
get state() {
|
|
74
|
+
return this._state;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// прогретый HostController (null — ещё нет)
|
|
78
|
+
get controller() {
|
|
79
|
+
return this._state === 'ready' ? this._controller : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// { room, workerUrl, mapsVersion, codeVersion, gameRef } прогретой комнаты
|
|
83
|
+
get prepared() {
|
|
84
|
+
return this._state === 'ready' ? this._prepared : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Прогрев по точке. Повторный вызов с той же игрой/версией ничего не
|
|
89
|
+
* делает; смена версии игры в точке (деплой в живой комнате) — прогрев
|
|
90
|
+
* заново.
|
|
91
|
+
* @param {Object} checkpoint - запись StandbyReceiver.latest(): { bytes,
|
|
92
|
+
* game, … }. game из дескриптора избавляет от распаковки точки (ревью
|
|
93
|
+
* F15); без него (хост старше поля) игра читается из самой точки.
|
|
94
|
+
* bytes не передаются — копия остаётся у вызывающего.
|
|
95
|
+
* @param {Object} [options]
|
|
96
|
+
* @param {{ id: string, versions?: string[] }|null} [options.allowedGame] -
|
|
97
|
+
* игра комнаты по данным мастера (standby_assigned.game).
|
|
98
|
+
* @returns {Promise<void>}
|
|
99
|
+
*/
|
|
100
|
+
async warm(checkpoint, { allowedGame = null } = {}) {
|
|
101
|
+
if (this._state === 'destroyed' || this._state === 'warming') {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let gameRef = checkpoint?.game ?? null;
|
|
106
|
+
|
|
107
|
+
if (!gameRef) {
|
|
108
|
+
// негодная точка — проблема этой точки, а не версии игры: прогретый
|
|
109
|
+
// Worker остаётся, следующая точка той же версии проверяется как обычно
|
|
110
|
+
try {
|
|
111
|
+
const { meta } = await this._decode(checkpoint?.bytes);
|
|
112
|
+
|
|
113
|
+
gameRef = meta?.room?.game ?? null;
|
|
114
|
+
} catch (e) {
|
|
115
|
+
this._reject('decode', e);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!gameRef?.id || !gameRef.version) {
|
|
121
|
+
this._reject('noGame', new Error('checkpoint has no room.game'));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
gameRef = { id: gameRef.id, version: gameRef.version };
|
|
126
|
+
|
|
127
|
+
// как и негодная точка — проблема точки: состояние не меняется
|
|
128
|
+
if (!isConfirmedGame(gameRef, allowedGame)) {
|
|
129
|
+
this._reject(
|
|
130
|
+
`foreign:${gameRef.id}@${gameRef.version}`,
|
|
131
|
+
new Error('checkpoint game is not the room game'),
|
|
132
|
+
);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
this._rejectedKey = null;
|
|
137
|
+
|
|
138
|
+
const gameKey = `${gameRef.id}@${gameRef.version}`;
|
|
139
|
+
|
|
140
|
+
if (
|
|
141
|
+
this._state === 'destroyed' ||
|
|
142
|
+
gameKey === this._gameKey ||
|
|
143
|
+
gameKey === this._failedKey
|
|
144
|
+
) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
this._terminate();
|
|
149
|
+
this._gameKey = gameKey;
|
|
150
|
+
this._state = 'warming';
|
|
151
|
+
|
|
152
|
+
let prepared;
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
// настройки точки не нужны: игра, карты и dev-режим комнаты беты —
|
|
156
|
+
// свои, а настройки комнаты промоушен берёт из самой точки (ревью F1)
|
|
157
|
+
prepared = await this._prepareRoom({}, gameRef);
|
|
158
|
+
} catch (e) {
|
|
159
|
+
this._fail(e);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (this._state !== 'warming' || this._gameKey !== gameKey) {
|
|
164
|
+
return; // destroy() или новая версия, пока готовились
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
this._prepared = { ...prepared, gameRef };
|
|
168
|
+
this._controller = this._createController(prepared.room, {
|
|
169
|
+
workerUrl: prepared.workerUrl ?? undefined,
|
|
170
|
+
diagnostics: this._diagnostics ?? undefined,
|
|
171
|
+
preload: true,
|
|
172
|
+
onPreloaded: () => {
|
|
173
|
+
if (this._state === 'warming' && this._gameKey === gameKey) {
|
|
174
|
+
this._state = 'ready';
|
|
175
|
+
this._onReady?.(this._prepared);
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
onError: msg => {
|
|
179
|
+
if (this._gameKey === gameKey) {
|
|
180
|
+
this._fail(new Error(msg?.message || 'preload failed'));
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Промоушен (host-migration этап 7.4) забирает прогретый Worker: дальше
|
|
188
|
+
* им владеет вызывающий, destroy() его уже не гасит.
|
|
189
|
+
* @returns {Object|null} { controller, prepared } или null — прогрева нет.
|
|
190
|
+
*/
|
|
191
|
+
take() {
|
|
192
|
+
if (this._state !== 'ready') {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const taken = { controller: this._controller, prepared: this._prepared };
|
|
197
|
+
|
|
198
|
+
this._controller = null;
|
|
199
|
+
this._prepared = null;
|
|
200
|
+
this._state = 'destroyed';
|
|
201
|
+
|
|
202
|
+
return taken;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// standby_released, уход из комнаты, смена эпохи
|
|
206
|
+
destroy() {
|
|
207
|
+
this._terminate();
|
|
208
|
+
this._state = 'destroyed';
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// отказ точке — в журнал один раз на причину: хост шлёт точки дважды
|
|
212
|
+
// в секунду, а состояние прогрева от отказа не меняется
|
|
213
|
+
_reject(key, error) {
|
|
214
|
+
if (key === this._rejectedKey) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
this._rejectedKey = key;
|
|
219
|
+
this._onError?.(error);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
_fail(error) {
|
|
223
|
+
this._terminate();
|
|
224
|
+
this._state = 'failed';
|
|
225
|
+
this._failedKey = this._gameKey;
|
|
226
|
+
this._gameKey = null;
|
|
227
|
+
this._onError?.(error);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
_terminate() {
|
|
231
|
+
this._controller?.destroy();
|
|
232
|
+
this._controller = null;
|
|
233
|
+
this._prepared = null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
@@ -12,9 +12,13 @@ export default class LoopbackTransport {
|
|
|
12
12
|
* @param {HostController} controller - мост к Worker'у.
|
|
13
13
|
* @param {string} [socketId] - id соединения хоста-игрока в Worker'е.
|
|
14
14
|
*/
|
|
15
|
-
|
|
15
|
+
// resume — возобновление места в матче (host-migration этап 7.4:
|
|
16
|
+
// преемник возвращает своего игрока в поднятый им матч): Worker ждёт
|
|
17
|
+
// RESUME_REQUEST, а 'open' сообщает супервизору, что его пора слать
|
|
18
|
+
constructor(controller, socketId = 'local', { resume = false } = {}) {
|
|
16
19
|
this._controller = controller;
|
|
17
20
|
this._socketId = socketId;
|
|
21
|
+
this._resume = resume;
|
|
18
22
|
this._closed = false;
|
|
19
23
|
|
|
20
24
|
this.publisher = new Publisher();
|
|
@@ -25,7 +29,18 @@ export default class LoopbackTransport {
|
|
|
25
29
|
this._controller.open(this._socketId, {
|
|
26
30
|
onMessage: payload => this.publisher.emit('message', payload),
|
|
27
31
|
onClose: () => this._emitClose(),
|
|
32
|
+
resume: this._resume,
|
|
28
33
|
});
|
|
34
|
+
|
|
35
|
+
// подписчики появляются после connect() (супервизор берёт уже
|
|
36
|
+
// подключённый транспорт) — 'open' уходит следующей микрозадачей
|
|
37
|
+
if (this._resume) {
|
|
38
|
+
queueMicrotask(() => {
|
|
39
|
+
if (!this._closed) {
|
|
40
|
+
this.publisher.emit('open');
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
29
44
|
}
|
|
30
45
|
|
|
31
46
|
// отправляет данные хосту (в Worker через роутер главного потока)
|