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
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import AbstractTimer from '../../../lib/AbstractTimer.js';
|
|
2
2
|
import clock from '../../../lib/clock.js';
|
|
3
3
|
|
|
4
|
+
// окно метрик здоровья цикла (host-migration этап 9a): раз в столько мс
|
|
5
|
+
// цикл отдаёт onLoopStats — хост решает по ним, не пора ли отдать роль
|
|
6
|
+
const LOOP_STATS_WINDOW_MS = 1000;
|
|
7
|
+
|
|
4
8
|
// Singleton TimerManager
|
|
5
9
|
|
|
6
10
|
let timerManager;
|
|
@@ -43,6 +47,23 @@ class TimerManager extends AbstractTimer {
|
|
|
43
47
|
// максимально допустимая дельта времени в секундах,
|
|
44
48
|
// для предотвращения "прыжков" в симуляции после долгих пауз
|
|
45
49
|
this._maxDeltaTime = 0.1;
|
|
50
|
+
|
|
51
|
+
// окно метрик цикла: живёт только пока цикл крутится — на паузе метрик
|
|
52
|
+
// нет (иначе заморозка выглядела бы перегрузкой)
|
|
53
|
+
this._loopStats = null;
|
|
54
|
+
|
|
55
|
+
// заморозка (host-migration этап 5): момент паузы — пока он задан,
|
|
56
|
+
// остатки карты/раунда не убывают
|
|
57
|
+
this._pausedAt = null;
|
|
58
|
+
|
|
59
|
+
// цель отложенной смены карты: колбэк — замыкание, а контрольной точке
|
|
60
|
+
// нужна декларация (её колбэк строится заново при восстановлении)
|
|
61
|
+
this._pendingMapChange = null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// «сейчас» для остатков: на паузе время карты и раунда стоит
|
|
65
|
+
_now() {
|
|
66
|
+
return this._pausedAt ?? clock.now();
|
|
46
67
|
}
|
|
47
68
|
|
|
48
69
|
// запускает все основные игровые таймеры (карта, игровой цикл, раунд)
|
|
@@ -80,7 +101,7 @@ class TimerManager extends AbstractTimer {
|
|
|
80
101
|
// возвращает оставшееся время до конца карты в миллисекундах
|
|
81
102
|
getMapTimeLeft() {
|
|
82
103
|
const timeLeft =
|
|
83
|
-
this._currentMapDuration - (
|
|
104
|
+
this._currentMapDuration - (this._now() - this._startMapTime);
|
|
84
105
|
|
|
85
106
|
return Math.max(0, timeLeft);
|
|
86
107
|
}
|
|
@@ -93,11 +114,14 @@ class TimerManager extends AbstractTimer {
|
|
|
93
114
|
this._startRttPingTimer();
|
|
94
115
|
}
|
|
95
116
|
|
|
96
|
-
// запускает таймер до конца текущего
|
|
97
|
-
|
|
117
|
+
// запускает таймер до конца текущего раунда. duration — остаток при
|
|
118
|
+
// восстановлении из контрольной точки: старт раунда тогда «виртуальный»
|
|
119
|
+
// (в прошлом), чтобы прошедшее время раунда и грейс смены команды
|
|
120
|
+
// считались от настоящего начала
|
|
121
|
+
startRoundTimer(duration = this._roundTime) {
|
|
98
122
|
this.stopRoundTimer();
|
|
99
|
-
this._startRoundTime = clock.now();
|
|
100
|
-
this._startTimer('round', this._callbacks.onRoundTimeEnd,
|
|
123
|
+
this._startRoundTime = clock.now() - (this._roundTime - duration);
|
|
124
|
+
this._startTimer('round', this._callbacks.onRoundTimeEnd, duration);
|
|
101
125
|
}
|
|
102
126
|
|
|
103
127
|
// останавливает таймер раунда
|
|
@@ -107,14 +131,17 @@ class TimerManager extends AbstractTimer {
|
|
|
107
131
|
|
|
108
132
|
// возвращает оставшееся время до конца раунда в секундах
|
|
109
133
|
getRoundTimeLeft() {
|
|
110
|
-
|
|
134
|
+
return Math.floor(this.getRoundTimeLeftMs() / 1000);
|
|
135
|
+
}
|
|
111
136
|
|
|
112
|
-
|
|
137
|
+
// то же в миллисекундах (контрольная точка не теряет долей секунды)
|
|
138
|
+
getRoundTimeLeftMs() {
|
|
139
|
+
return Math.max(0, this._roundTime - (this._now() - this._startRoundTime));
|
|
113
140
|
}
|
|
114
141
|
|
|
115
142
|
// проверяет возможно сменить команду игроку в текущем раунде
|
|
116
143
|
canChangeTeamInCurrentRound() {
|
|
117
|
-
const roundTime =
|
|
144
|
+
const roundTime = this._now() - this._startRoundTime;
|
|
118
145
|
|
|
119
146
|
return roundTime <= this._teamChangeGracePeriod;
|
|
120
147
|
}
|
|
@@ -125,6 +152,8 @@ class TimerManager extends AbstractTimer {
|
|
|
125
152
|
let dt = (now - this._lastShotTime) / 1000;
|
|
126
153
|
this._lastShotTime = now;
|
|
127
154
|
|
|
155
|
+
this._countLoopTick(dt);
|
|
156
|
+
|
|
128
157
|
// если dt аномально большой (система "спала"), ограничить его
|
|
129
158
|
// и сбросить ожидаемое время, чтобы цикл не пытался "наверстать".
|
|
130
159
|
if (dt > this._maxDeltaTime) {
|
|
@@ -133,6 +162,7 @@ class TimerManager extends AbstractTimer {
|
|
|
133
162
|
}
|
|
134
163
|
|
|
135
164
|
this._callbacks.onShotTick(dt);
|
|
165
|
+
this._flushLoopStats(now);
|
|
136
166
|
|
|
137
167
|
const drift = now - this._expectedTickTime;
|
|
138
168
|
const nextTimeout = Math.max(0, this._timeStep - drift);
|
|
@@ -143,11 +173,47 @@ class TimerManager extends AbstractTimer {
|
|
|
143
173
|
this._startTimer('gameLoop', () => this._loopTick(), nextTimeout);
|
|
144
174
|
}
|
|
145
175
|
|
|
176
|
+
// итерация цикла в окно метрик: срезанное капом dt — время, которое
|
|
177
|
+
// симуляция не прожила (отстаёт от реального)
|
|
178
|
+
_countLoopTick(dt) {
|
|
179
|
+
const stats = this._loopStats;
|
|
180
|
+
|
|
181
|
+
stats.ticks += 1;
|
|
182
|
+
stats.maxGapMs = Math.max(stats.maxGapMs, dt * 1000);
|
|
183
|
+
|
|
184
|
+
if (dt > this._maxDeltaTime) {
|
|
185
|
+
stats.lostMs += (dt - this._maxDeltaTime) * 1000;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// окно метрик закрыто — отдать и начать новое
|
|
190
|
+
_flushLoopStats(now) {
|
|
191
|
+
const stats = this._loopStats;
|
|
192
|
+
const windowMs = now - stats.startedAt;
|
|
193
|
+
|
|
194
|
+
if (windowMs < LOOP_STATS_WINDOW_MS) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
this._loopStats = this._newLoopStats(now);
|
|
199
|
+
this._callbacks.onLoopStats?.({
|
|
200
|
+
tickRate: Math.round((stats.ticks * 10000) / windowMs) / 10,
|
|
201
|
+
maxGapMs: Math.round(stats.maxGapMs),
|
|
202
|
+
lostMs: Math.round(stats.lostMs),
|
|
203
|
+
windowMs: Math.round(windowMs),
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
_newLoopStats(now) {
|
|
208
|
+
return { startedAt: now, ticks: 0, maxGapMs: 0, lostMs: 0 };
|
|
209
|
+
}
|
|
210
|
+
|
|
146
211
|
// инициализирует и запускает игровой цикл (обновление кадров)
|
|
147
212
|
_startGameLoop() {
|
|
148
213
|
this._stopGameLoop();
|
|
149
214
|
|
|
150
215
|
this._lastShotTime = clock.monotonic();
|
|
216
|
+
this._loopStats = this._newLoopStats(this._lastShotTime);
|
|
151
217
|
this._expectedTickTime = this._lastShotTime + this._timeStep;
|
|
152
218
|
|
|
153
219
|
// запуск первого таймаута, который инициирует цикл
|
|
@@ -174,13 +240,24 @@ class TimerManager extends AbstractTimer {
|
|
|
174
240
|
}
|
|
175
241
|
|
|
176
242
|
// запускает таймер, блокирующий возможность инициировать
|
|
177
|
-
// новое голосование в заданной категории
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
243
|
+
// новое голосование в заданной категории (duration — остаток кулдауна
|
|
244
|
+
// из контрольной точки)
|
|
245
|
+
startVoteBlockTimer(
|
|
246
|
+
voteCategory,
|
|
247
|
+
onEndCallback,
|
|
248
|
+
duration = this._timeBlockedVote,
|
|
249
|
+
) {
|
|
250
|
+
this._startTimer(`voteBlock:${voteCategory}`, onEndCallback, duration);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// кулдауны голосований с остатками: [{ name, leftMs }]
|
|
254
|
+
getVoteCooldowns() {
|
|
255
|
+
return [...this._timers.keys()]
|
|
256
|
+
.filter(key => key.startsWith('voteBlock:'))
|
|
257
|
+
.map(key => ({
|
|
258
|
+
name: key.slice('voteBlock:'.length),
|
|
259
|
+
leftMs: this._timeLeft(key),
|
|
260
|
+
}));
|
|
184
261
|
}
|
|
185
262
|
|
|
186
263
|
// останавливает все таймеры блокировки
|
|
@@ -211,9 +288,15 @@ class TimerManager extends AbstractTimer {
|
|
|
211
288
|
this._stopTimer('roundRestartDelay');
|
|
212
289
|
}
|
|
213
290
|
|
|
214
|
-
// запускает отложенную смену карты (после голосования)
|
|
215
|
-
|
|
216
|
-
|
|
291
|
+
// запускает отложенную смену карты (после голосования). targetMap —
|
|
292
|
+
// декларация цели для контрольной точки; duration — остаток при
|
|
293
|
+
// восстановлении
|
|
294
|
+
startMapChangeDelay(
|
|
295
|
+
onEndCallback,
|
|
296
|
+
{ targetMap = null, duration = this._mapChangeDelay } = {},
|
|
297
|
+
) {
|
|
298
|
+
this._pendingMapChange = targetMap;
|
|
299
|
+
this._startTimer('mapChangeDelay', onEndCallback, duration);
|
|
217
300
|
}
|
|
218
301
|
|
|
219
302
|
// останавливает отложенную смену карты
|
|
@@ -221,6 +304,120 @@ class TimerManager extends AbstractTimer {
|
|
|
221
304
|
this._stopTimer('mapChangeDelay');
|
|
222
305
|
}
|
|
223
306
|
|
|
307
|
+
// ***** контрольная точка (host-migration этап 5) ***** //
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Остатки по всем дедлайнам. Активные голосования (vote:*) не
|
|
311
|
+
* переносятся: их resultFunc — замыкания.
|
|
312
|
+
* @returns {Object} { mapTimeLeft, roundTimeLeft, roundElapsed,
|
|
313
|
+
* teamChangeGraceLeft, pending, voteCooldowns }. mapTimeLeft считается
|
|
314
|
+
* и у остановленного таймера (эстафета снимает таймеры до сбора меты);
|
|
315
|
+
* roundTimeLeft null — раунд не идёт (завершается).
|
|
316
|
+
*/
|
|
317
|
+
serialize() {
|
|
318
|
+
const roundElapsed = Math.max(0, this._now() - this._startRoundTime);
|
|
319
|
+
const pending = [];
|
|
320
|
+
|
|
321
|
+
if (this._hasTimer('roundRestartDelay')) {
|
|
322
|
+
pending.push({
|
|
323
|
+
kind: 'roundRestart',
|
|
324
|
+
leftMs: this._timeLeft('roundRestartDelay'),
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (this._hasTimer('mapChangeDelay') && this._pendingMapChange !== null) {
|
|
329
|
+
pending.push({
|
|
330
|
+
kind: 'mapChange',
|
|
331
|
+
leftMs: this._timeLeft('mapChangeDelay'),
|
|
332
|
+
targetMap: this._pendingMapChange,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
mapTimeLeft: this.getMapTimeLeft(),
|
|
338
|
+
roundTimeLeft: this._hasTimer('round') ? this.getRoundTimeLeftMs() : null,
|
|
339
|
+
roundElapsed,
|
|
340
|
+
teamChangeGraceLeft: Math.max(
|
|
341
|
+
0,
|
|
342
|
+
this._teamChangeGracePeriod - roundElapsed,
|
|
343
|
+
),
|
|
344
|
+
pending,
|
|
345
|
+
voteCooldowns: this.getVoteCooldowns(),
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Запускает игровые таймеры с остатками из serialize(): карта, раунд,
|
|
351
|
+
* цикл, пинги, отложенный перезапуск раунда, кулдауны голосований.
|
|
352
|
+
* Отложенную смену карты (pending mapChange) поднимает RoundManager —
|
|
353
|
+
* колбэк его.
|
|
354
|
+
* @param {Object} state - результат serialize().
|
|
355
|
+
*/
|
|
356
|
+
resumeFromState(state) {
|
|
357
|
+
this.startMapTimer(state.mapTimeLeft ?? this._mapTime);
|
|
358
|
+
|
|
359
|
+
if (state.roundTimeLeft !== null && state.roundTimeLeft !== undefined) {
|
|
360
|
+
this.startRoundTimer(state.roundTimeLeft);
|
|
361
|
+
} else {
|
|
362
|
+
// раунд завершается (таймер снят) — прошедшее время всё равно нужно
|
|
363
|
+
// грейсу смены команды
|
|
364
|
+
this._startRoundTime = clock.now() - (state.roundElapsed ?? 0);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
for (const item of state.pending ?? []) {
|
|
368
|
+
if (item.kind === 'roundRestart') {
|
|
369
|
+
this._startTimer(
|
|
370
|
+
'roundRestartDelay',
|
|
371
|
+
this._callbacks.onRoundTimeEnd,
|
|
372
|
+
item.leftMs,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
for (const { name, leftMs } of state.voteCooldowns ?? []) {
|
|
378
|
+
this.startVoteBlockTimer(name, () => {}, leftMs);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
this._startGameLoop();
|
|
382
|
+
this._startRttPingTimer();
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
get isPaused() {
|
|
386
|
+
return this._pausedAt !== null;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// заморозка: все отсчёты (цикл, карта, раунд, отложенные вызовы,
|
|
390
|
+
// голосования, пинги, проверка бездействия) встают с остатками
|
|
391
|
+
pause() {
|
|
392
|
+
if (this._pausedAt !== null) {
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
this._pausedAt = clock.now();
|
|
397
|
+
this._pauseTimers();
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// разморозка: остатки дотикивают, цикл стартует заново (без «прыжка» dt)
|
|
401
|
+
resume() {
|
|
402
|
+
if (this._pausedAt === null) {
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const pausedFor = clock.now() - this._pausedAt;
|
|
407
|
+
|
|
408
|
+
this._pausedAt = null;
|
|
409
|
+
this._startMapTime += pausedFor;
|
|
410
|
+
this._startRoundTime += pausedFor;
|
|
411
|
+
|
|
412
|
+
const loopPaused = this._timeLeft('gameLoop') !== null;
|
|
413
|
+
|
|
414
|
+
this._resumeTimers(key => key !== 'gameLoop');
|
|
415
|
+
|
|
416
|
+
if (loopPaused) {
|
|
417
|
+
this._startGameLoop();
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
224
421
|
// логика одного "тика" для проверки на бездействие
|
|
225
422
|
_idleCheckTick() {
|
|
226
423
|
this._callbacks.onIdleCheck();
|
|
@@ -10,6 +10,15 @@ const MESSAGE_CODES = {
|
|
|
10
10
|
REPORT_KILL: 's:4', // ⚔️ {0} killed {1}!
|
|
11
11
|
USER_JOINED: 's:5', // ⚡ {0} joined the game
|
|
12
12
|
USER_LEFT: 's:6', // 👋 {0} left the game
|
|
13
|
+
HOST_CHANGED: 's:7', // Host changed (host-migration: матч у преемника)
|
|
14
|
+
// You are no longer the host (connection lost) — добавляет сам клиент
|
|
15
|
+
// бывшего хоста (host_revoked), по проводу не ходит
|
|
16
|
+
HOST_REVOKED: 's:8',
|
|
17
|
+
// вместо HOST_CHANGED, когда хост отдал роль автоматически (этап 9)
|
|
18
|
+
HOST_CHANGED_OVERLOAD: 's:9', // Host changed: the previous host was lagging
|
|
19
|
+
HOST_CHANGED_HIDDEN: 's:10', // Host changed: the previous host went inactive
|
|
20
|
+
// Host changed: the previous host had a poor connection
|
|
21
|
+
HOST_CHANGED_NETWORK: 's:11',
|
|
13
22
|
|
|
14
23
|
VOTE_CREATED: 'v:0', // A vote has been created
|
|
15
24
|
VOTE_STARTED: 'v:1', // Voting has started
|
|
@@ -17,6 +26,19 @@ const MESSAGE_CODES = {
|
|
|
17
26
|
VOTE_UNAVAILABLE: 'v:3', // Voting is temporarily unavailable
|
|
18
27
|
VOTE_PASSED: 'v:4', // Vote passed
|
|
19
28
|
VOTE_FAILED: 'v:5', // Vote failed
|
|
29
|
+
// голосование «Change host» (host-migration этап 10): ведёт мастер,
|
|
30
|
+
// сообщения добавляет сам клиент, по проводу они не ходят
|
|
31
|
+
HOST_VOTE_USAGE: 'v:6', // Usage: /changehost
|
|
32
|
+
// You are the host — use “Hand over host” in the room menu
|
|
33
|
+
HOST_VOTE_IS_HOST: 'v:7',
|
|
34
|
+
HOST_VOTE_OFFLINE: 'v:8', // No connection to the master server
|
|
35
|
+
HOST_VOTE_RECENT: 'v:9', // A host vote was held recently
|
|
36
|
+
HOST_VOTE_NO_SUCCESSOR: 'v:10', // No other player can host
|
|
37
|
+
HOST_VOTE_ACTIVE: 'v:11', // A host vote is already in progress
|
|
38
|
+
HOST_VOTE_UNAVAILABLE: 'v:12', // A host vote is not possible right now
|
|
39
|
+
HOST_VOTE_PASSED: 'v:13', // Vote to change host passed ({0}/{1})
|
|
40
|
+
HOST_VOTE_FAILED: 'v:14', // Vote to change host failed ({0}/{1})
|
|
41
|
+
HOST_VOTE_CANCELLED: 'v:15', // Host vote cancelled
|
|
20
42
|
|
|
21
43
|
MAP_CURRENT: 'm:0', // Current map: {0}
|
|
22
44
|
MAP_NEXT: 'm:1', // Next map: {0}
|
|
@@ -12,6 +12,7 @@ class HumanParticipant extends Participant {
|
|
|
12
12
|
socketId,
|
|
13
13
|
watchedGameId,
|
|
14
14
|
token = null,
|
|
15
|
+
identityName = null,
|
|
15
16
|
}) {
|
|
16
17
|
super({ gameId, name, model, team, teamId });
|
|
17
18
|
|
|
@@ -27,6 +28,15 @@ class HumanParticipant extends Participant {
|
|
|
27
28
|
this.pendingShake = null;
|
|
28
29
|
this.lastActionTime = clock.now();
|
|
29
30
|
this.lastInputSeq = 0; // номер последнего обработанного ввода (предикшен)
|
|
31
|
+
|
|
32
|
+
// возобновление сессии (host-migration этап 4). identityName — ник от
|
|
33
|
+
// стратегии идентичности до разведения дублей (#2): с ним сверяется
|
|
34
|
+
// токен RESUME_REQUEST. resumeKey — секрет места (выдаётся на входе в
|
|
35
|
+
// матч, не логируется). detachedAt — транспорт оборвался, место ждёт
|
|
36
|
+
// возврата (null — подключён)
|
|
37
|
+
this.identityName = identityName ?? name;
|
|
38
|
+
this.resumeKey = null;
|
|
39
|
+
this.detachedAt = null;
|
|
30
40
|
}
|
|
31
41
|
|
|
32
42
|
get isNetworked() {
|
|
@@ -64,6 +64,7 @@ class ParticipantManager {
|
|
|
64
64
|
socketId,
|
|
65
65
|
watchedGameId: this._activePlayersList[0] || null,
|
|
66
66
|
token: params.token,
|
|
67
|
+
identityName: params.name,
|
|
67
68
|
});
|
|
68
69
|
|
|
69
70
|
this._participants.set(gameId, participant);
|
|
@@ -93,8 +94,14 @@ class ParticipantManager {
|
|
|
93
94
|
}
|
|
94
95
|
|
|
95
96
|
// восстанавливает человека с исходным gameId (эстафета Worker'ов, Этап 5.2);
|
|
96
|
-
// занятый id или неизвестная команда — null (запись пропускается)
|
|
97
|
-
|
|
97
|
+
// занятый id или неизвестная команда — null (запись пропускается).
|
|
98
|
+
// Поля личности (ник входа, секрет места, цвет ника, токен) переносятся
|
|
99
|
+
// всегда; игровое состояние (статус, камера, слот респауна, номер ввода) —
|
|
100
|
+
// только при full: восстановлении посреди раунда (host-migration этап 5),
|
|
101
|
+
// иначе раунд всё равно начнётся заново
|
|
102
|
+
restoreHuman(record, { full = false } = {}) {
|
|
103
|
+
const { gameId, socketId, name, model, team, teamId } = record;
|
|
104
|
+
|
|
98
105
|
if (this._participants.has(gameId) || !this._teamSizes[team]) {
|
|
99
106
|
return null;
|
|
100
107
|
}
|
|
@@ -107,8 +114,20 @@ class ParticipantManager {
|
|
|
107
114
|
teamId,
|
|
108
115
|
socketId,
|
|
109
116
|
watchedGameId: this._activePlayersList[0] || null,
|
|
117
|
+
token: record.token ?? null,
|
|
118
|
+
identityName: record.identityName ?? null,
|
|
110
119
|
});
|
|
111
120
|
|
|
121
|
+
participant.chatColor = record.chatColor ?? null;
|
|
122
|
+
participant.resumeKey = record.resumeKey ?? null;
|
|
123
|
+
|
|
124
|
+
if (full) {
|
|
125
|
+
this._applyPlayState(participant, record);
|
|
126
|
+
participant.isWatching = record.isWatching !== false;
|
|
127
|
+
participant.watchedGameId = record.watchedGameId ?? null;
|
|
128
|
+
participant.lastInputSeq = record.lastInputSeq >>> 0;
|
|
129
|
+
}
|
|
130
|
+
|
|
112
131
|
this._participants.set(gameId, participant);
|
|
113
132
|
this._teamSizes[team].add(gameId);
|
|
114
133
|
|
|
@@ -117,7 +136,9 @@ class ParticipantManager {
|
|
|
117
136
|
|
|
118
137
|
// восстанавливает scripted-участника с исходным gameId
|
|
119
138
|
// (эстафета Worker'ов, Этап 5.2)
|
|
120
|
-
restoreScripted({
|
|
139
|
+
restoreScripted(record, { full = false } = {}) {
|
|
140
|
+
const { gameId, name, model, team, teamId } = record;
|
|
141
|
+
|
|
121
142
|
if (this._participants.has(gameId) || !this._teamSizes[team]) {
|
|
122
143
|
return null;
|
|
123
144
|
}
|
|
@@ -130,12 +151,83 @@ class ParticipantManager {
|
|
|
130
151
|
teamId,
|
|
131
152
|
});
|
|
132
153
|
|
|
154
|
+
participant.chatColor = record.chatColor ?? null;
|
|
155
|
+
|
|
156
|
+
if (full) {
|
|
157
|
+
this._applyPlayState(participant, record);
|
|
158
|
+
}
|
|
159
|
+
|
|
133
160
|
this._participants.set(gameId, participant);
|
|
134
161
|
this._teamSizes[team].add(gameId);
|
|
135
162
|
|
|
136
163
|
return participant;
|
|
137
164
|
}
|
|
138
165
|
|
|
166
|
+
_applyPlayState(participant, record) {
|
|
167
|
+
if (typeof record.status === 'string') {
|
|
168
|
+
participant.status = record.status;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
participant.respawnIndex = Number.isInteger(record.respawnIndex)
|
|
172
|
+
? record.respawnIndex
|
|
173
|
+
: null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ***** контрольная точка (host-migration этап 5) ***** //
|
|
177
|
+
|
|
178
|
+
// участники и топология команд. Токенов здесь нет: точка уезжает по сети
|
|
179
|
+
// к другому игроку, токен — личный секрет участника
|
|
180
|
+
serialize() {
|
|
181
|
+
return {
|
|
182
|
+
humans: this.getHumans().map(p => ({
|
|
183
|
+
gameId: p.gameId,
|
|
184
|
+
name: p.name,
|
|
185
|
+
model: p.model,
|
|
186
|
+
team: p.team,
|
|
187
|
+
teamId: p.teamId,
|
|
188
|
+
status: p.status,
|
|
189
|
+
isReady: p.isReady,
|
|
190
|
+
isWatching: p.isWatching,
|
|
191
|
+
watchedGameId: p.watchedGameId,
|
|
192
|
+
respawnIndex: p.respawnIndex,
|
|
193
|
+
lastInputSeq: p.lastInputSeq,
|
|
194
|
+
chatColor: p.chatColor,
|
|
195
|
+
resumeKey: p.resumeKey,
|
|
196
|
+
identityName: p.identityName,
|
|
197
|
+
})),
|
|
198
|
+
scripted: this.getScripted().map(p => ({
|
|
199
|
+
gameId: p.gameId,
|
|
200
|
+
name: p.name,
|
|
201
|
+
model: p.model,
|
|
202
|
+
team: p.team,
|
|
203
|
+
teamId: p.teamId,
|
|
204
|
+
status: p.status,
|
|
205
|
+
respawnIndex: p.respawnIndex,
|
|
206
|
+
chatColor: p.chatColor,
|
|
207
|
+
})),
|
|
208
|
+
teamSizes: Object.fromEntries(
|
|
209
|
+
Object.entries(this._teamSizes).map(([team, ids]) => [team, [...ids]]),
|
|
210
|
+
),
|
|
211
|
+
activePlayers: [...this._activePlayersList],
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// состав команд и список активных из точки — после restoreHuman/
|
|
216
|
+
// restoreScripted; id, не пережившие восстановление, отбрасываются
|
|
217
|
+
restoreTopology({ teamSizes = {}, activePlayers = [] } = {}) {
|
|
218
|
+
for (const [team, ids] of Object.entries(teamSizes)) {
|
|
219
|
+
if (this._teamSizes[team]) {
|
|
220
|
+
this._teamSizes[team] = new Set(
|
|
221
|
+
ids.filter(id => this._participants.has(id)),
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
this._activePlayersList = activePlayers.filter(id =>
|
|
227
|
+
this._participants.has(id),
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
139
231
|
// полностью удаляет участника из реестра (команда + список активных)
|
|
140
232
|
remove(gameId) {
|
|
141
233
|
const participant = this._participants.get(gameId);
|
|
@@ -181,9 +273,10 @@ class ParticipantManager {
|
|
|
181
273
|
return this.getAll().filter(p => p.isScripted);
|
|
182
274
|
}
|
|
183
275
|
|
|
184
|
-
// люди, готовые к игре (получатели сетевого кадра)
|
|
276
|
+
// люди, готовые к игре (получатели сетевого кадра); отсоединённые
|
|
277
|
+
// (ждут возобновления, host-migration этап 4) кадров не получают
|
|
185
278
|
getNetworkedReady() {
|
|
186
|
-
return this.getHumans().filter(p => p.isReady);
|
|
279
|
+
return this.getHumans().filter(p => p.isReady && p.detachedAt === null);
|
|
187
280
|
}
|
|
188
281
|
|
|
189
282
|
// проверяет уникальность имени по всему реестру (люди + scripted)
|
package/src/lib/AbstractTimer.js
CHANGED
|
@@ -37,7 +37,69 @@ class AbstractTimer {
|
|
|
37
37
|
};
|
|
38
38
|
|
|
39
39
|
const timerId = clock.setTimeout(wrappedCallback, duration);
|
|
40
|
-
|
|
40
|
+
|
|
41
|
+
// срок и исходный колбэк — для остатка времени и паузы (контрольная
|
|
42
|
+
// точка хоста, host-migration этап 5)
|
|
43
|
+
this._timers.set(key, {
|
|
44
|
+
timerId,
|
|
45
|
+
isInterval,
|
|
46
|
+
callback,
|
|
47
|
+
endsAt: clock.now() + duration,
|
|
48
|
+
leftMs: null,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Остаток времени setTimeout-таймера (у стоящего на паузе — замороженный).
|
|
55
|
+
* @protected
|
|
56
|
+
* @param {string} key - ключ таймера
|
|
57
|
+
* @returns {number|null} - миллисекунды; null — таймера нет или интервал
|
|
58
|
+
*/
|
|
59
|
+
_timeLeft(key) {
|
|
60
|
+
const entry = this._timers.get(key);
|
|
61
|
+
|
|
62
|
+
if (!entry || entry.isInterval) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (entry.leftMs !== null) {
|
|
67
|
+
return entry.leftMs;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return Math.max(0, entry.endsAt - clock.now());
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Ставит setTimeout-таймеры на паузу: срабатывание снимается, остаток
|
|
75
|
+
* запоминается, ключ остаётся (_hasTimer по-прежнему true).
|
|
76
|
+
* @protected
|
|
77
|
+
* @param {function} [filter] - (key) => boolean; без него — все
|
|
78
|
+
*/
|
|
79
|
+
_pauseTimers(filter = () => true) {
|
|
80
|
+
for (const [key, entry] of this._timers) {
|
|
81
|
+
if (entry.isInterval || entry.leftMs !== null || !filter(key)) {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
clock.clearTimeout(entry.timerId);
|
|
86
|
+
entry.leftMs = Math.max(0, entry.endsAt - clock.now());
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Снимает с паузы таймеры, поставленные _pauseTimers: каждый дожидается
|
|
92
|
+
* своего остатка.
|
|
93
|
+
* @protected
|
|
94
|
+
* @param {function} [filter] - (key) => boolean; без него — все
|
|
95
|
+
*/
|
|
96
|
+
_resumeTimers(filter = () => true) {
|
|
97
|
+
const paused = [...this._timers].filter(
|
|
98
|
+
([key, entry]) => entry.leftMs !== null && filter(key),
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
for (const [key, entry] of paused) {
|
|
102
|
+
this._startTimer(key, entry.callback, entry.leftMs);
|
|
41
103
|
}
|
|
42
104
|
}
|
|
43
105
|
|
package/src/lib/Publisher.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Observer pattern (Publisher)
|
|
3
3
|
* on: добавляет слушателя (подписчика)
|
|
4
|
+
* off: снимает слушателя
|
|
4
5
|
* emit: рассылает событие подписчикам
|
|
5
6
|
*/
|
|
6
7
|
class Publisher {
|
|
@@ -29,6 +30,31 @@ class Publisher {
|
|
|
29
30
|
});
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Снимает подписчика, добавленного on() с теми же аргументами.
|
|
35
|
+
* @param {string} type - Тип события.
|
|
36
|
+
* @param {Function|string} fn - Функция-обработчик или имя метода.
|
|
37
|
+
* @param {Object} [context] - Контекст, переданный в on().
|
|
38
|
+
*/
|
|
39
|
+
off(type, fn, context) {
|
|
40
|
+
const subscribers = this.subs[type];
|
|
41
|
+
|
|
42
|
+
if (!subscribers) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (typeof fn !== 'function') {
|
|
47
|
+
fn = context[fn];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// новый массив, а не splice: отписка из обработчика посреди emit не
|
|
51
|
+
// должна сдвинуть обход и пропустить соседа
|
|
52
|
+
this.subs[type] = subscribers.filter(
|
|
53
|
+
sub =>
|
|
54
|
+
sub.fn !== fn || (context !== undefined && sub.context !== context),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
32
58
|
/**
|
|
33
59
|
* Рассылает событие указанного типа всем подписчикам.
|
|
34
60
|
* @param {string} type - Тип события.
|
package/src/lib/capabilities.js
CHANGED
|
@@ -46,6 +46,11 @@ export const ENGINE_CAPABILITIES = createRegistry('engine-capabilities', [
|
|
|
46
46
|
// сервис пула `diagnostics` (журнал клиентских ошибок, plan/client-reports);
|
|
47
47
|
// опционален — игра не пишет его в `requires`
|
|
48
48
|
{ value: 'diagnostics', since: '0.35.0' },
|
|
49
|
+
// контрольная точка хоста посреди раунда (host-migration этап 5):
|
|
50
|
+
// gameConfig.migration.midRound, хуки модулей serializeState/restoreState.
|
|
51
|
+
// Писать в `requires` не нужно: старый движок флаг просто игнорирует, и
|
|
52
|
+
// игра на нём мигрирует мягко
|
|
53
|
+
{ value: 'host.migration', since: '0.36.0' },
|
|
49
54
|
]);
|
|
50
55
|
|
|
51
56
|
// плоский список имён — то, чем правило контракта B2 проверяет `requires`
|