vimp-engine 0.2.0 → 0.4.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 +137 -0
- package/package.json +10 -3
- package/src/config/gameCodes.js +8 -0
- package/src/config/lobby.js +23 -1
- package/src/config/master.js +8 -0
- package/src/config/opcodes.js +3 -1
- package/src/devtools/RecordingSocketManager.js +118 -0
- package/src/devtools/ScenarioRunner.js +529 -0
- package/src/devtools/VirtualClient.js +363 -0
- package/src/devtools/VirtualClock.js +169 -0
- package/src/devtools/inspectHost.js +31 -0
- package/src/devtools/invariants.js +672 -0
- package/src/devtools/pluginLoader.js +101 -0
- package/src/devtools/report.js +202 -0
- package/src/devtools/resetHostSingletons.js +23 -0
- package/src/host/DebugRecorder.js +224 -0
- package/src/host/GameCoreAdapter.js +17 -0
- package/src/host/HostGame.js +129 -7
- package/src/host/host.worker.js +46 -47
- package/src/host/meta/SocketManager.js +15 -7
- package/src/host/meta/modules/Panel.js +7 -0
- package/src/host/meta/modules/PlayerDataSync.js +29 -5
- package/src/host/meta/modules/RTTManager.js +4 -2
- package/src/host/meta/modules/Stat.js +7 -0
- package/src/host/meta/modules/TimerManager.js +15 -7
- package/src/host/meta/modules/Vote.js +10 -1
- package/src/host/meta/modules/chat/Chat.js +7 -0
- package/src/host/meta/modules/chat/index.js +1 -1
- package/src/host/meta/player/HumanParticipant.js +2 -1
- package/src/lib/AbstractTimer.js +6 -4
- package/src/lib/applyRoomOverrides.js +6 -0
- package/src/lib/clientCoreConfig.js +4 -0
- package/src/lib/clock.js +53 -0
- package/src/lib/createHostRuntime.js +108 -0
- package/src/lib/reconstructHot.js +78 -0
- package/src/lib/validators.js +10 -0
- package/tests/fixtures/miniGame/client/fakeClientCore.js +304 -0
- package/tests/fixtures/miniGame/client/index.js +37 -0
- package/tests/fixtures/miniGame/client/parts/Actor.js +18 -0
- package/tests/fixtures/miniGame/client/parts/ActorRadar.js +7 -0
- package/tests/fixtures/miniGame/config/auth.js +41 -0
- package/tests/fixtures/miniGame/config/client.js +139 -0
- package/tests/fixtures/miniGame/config/game.js +148 -0
- package/tests/fixtures/miniGame/host/ScriptedManager.js +129 -0
- package/tests/fixtures/miniGame/host/createModules.js +6 -0
- package/tests/fixtures/miniGame/host/fakeCore.js +233 -0
- package/tests/fixtures/miniGame/host/index.js +29 -0
- package/tests/fixtures/miniGame/host/spawnCommand.js +14 -0
- package/tests/fixtures/miniGame/host/systemMessages.js +6 -0
- package/tests/fixtures/miniGame.contract.test.js +76 -0
package/src/host/HostGame.js
CHANGED
|
@@ -11,7 +11,9 @@ import VoteCoordinator from './meta/core/VoteCoordinator.js';
|
|
|
11
11
|
import RoundManager from './meta/core/RoundManager.js';
|
|
12
12
|
import CommandProcessor from './meta/core/CommandProcessor.js';
|
|
13
13
|
import { sanitizeMessage } from '../lib/sanitizers.js';
|
|
14
|
+
import clock from '../lib/clock.js';
|
|
14
15
|
import GameCoreAdapter from './GameCoreAdapter.js';
|
|
16
|
+
import DebugRecorder from './DebugRecorder.js';
|
|
15
17
|
|
|
16
18
|
// Версия формата handoff-меты эстафеты Worker'ов (Этап 5.2; →2 в Этапе 6.5 —
|
|
17
19
|
// добавлены gameId/gameVersion; →3 в Этапе Д3 — нейтральное поле scripted).
|
|
@@ -70,6 +72,8 @@ export default class HostGame {
|
|
|
70
72
|
* @param {string} [opts.gameVersion] - версия игры комнаты (room.game.version,
|
|
71
73
|
* Этап 6.5): едет в handoff-мете рядом с gameId — расхождение с игрой
|
|
72
74
|
* восстанавливающего Worker'а валит init тем же путём, что и версия формата.
|
|
75
|
+
* @param {number} [opts.seed] - seed мира, которым инициализировано ядро
|
|
76
|
+
* (createHostRuntime): без него записанный сценарий невоспроизводим.
|
|
73
77
|
*/
|
|
74
78
|
constructor(
|
|
75
79
|
data,
|
|
@@ -81,9 +85,12 @@ export default class HostGame {
|
|
|
81
85
|
onMapChange = null,
|
|
82
86
|
handoff = null,
|
|
83
87
|
gameVersion = null,
|
|
88
|
+
playerDataFetch = null,
|
|
89
|
+
seed = null,
|
|
84
90
|
} = {},
|
|
85
91
|
) {
|
|
86
92
|
this._isDevMode = data.isDevMode || false;
|
|
93
|
+
this._seed = seed;
|
|
87
94
|
|
|
88
95
|
this._hostSocketId = hostSocketId;
|
|
89
96
|
this._onMapChange = onMapChange;
|
|
@@ -126,13 +133,21 @@ export default class HostGame {
|
|
|
126
133
|
// обратно на мастер по границам раунда/карты (см. RoundManager)
|
|
127
134
|
this._playerDataSync = new PlayerDataSync(this._gameId, {
|
|
128
135
|
defaultState: data.playerState?.defaultState ?? {},
|
|
136
|
+
// headless-прогону некуда ходить за профилем (относительный URL и нет
|
|
137
|
+
// мастера) — подменяется только там, в проде остаётся глобальный fetch
|
|
138
|
+
...(playerDataFetch ? { fetchImpl: playerDataFetch } : {}),
|
|
129
139
|
});
|
|
130
140
|
this._chat = new Chat();
|
|
131
141
|
this._vote = new Vote();
|
|
132
142
|
|
|
133
143
|
this._socketManager = socketManager;
|
|
134
144
|
|
|
135
|
-
this.
|
|
145
|
+
this._networkSendRate = data.timers.networkSendRate;
|
|
146
|
+
this._snapshotManager = new SnapshotThrottle(this._networkSendRate);
|
|
147
|
+
|
|
148
|
+
// рекордер живого матча (этап 6 плана plan/ai-debug) — только в dev-режиме;
|
|
149
|
+
// в проде null, и все точки записи ниже вырождаются в ?.
|
|
150
|
+
this._recorder = this._isDevMode ? new DebugRecorder() : null;
|
|
136
151
|
|
|
137
152
|
// игровые host-модули (scripted-модуль игры)
|
|
138
153
|
this._scripted = hostPlugin.createModules({
|
|
@@ -282,6 +297,9 @@ export default class HostGame {
|
|
|
282
297
|
|
|
283
298
|
// создаёт кадр игры (core-driven)
|
|
284
299
|
_onShotTick(dt) {
|
|
300
|
+
// номер тика — единственная временная координата сценария (этап 6)
|
|
301
|
+
this._recorder?.tick(dt);
|
|
302
|
+
|
|
285
303
|
// шаг ядра + проекция событий (kill/health/ammo/weapon/shake) в мету
|
|
286
304
|
this._game.updateData(dt);
|
|
287
305
|
|
|
@@ -313,7 +331,7 @@ export default class HostGame {
|
|
|
313
331
|
const chat = this._chat.shift();
|
|
314
332
|
const vote = this._vote.shift();
|
|
315
333
|
|
|
316
|
-
const serverTime =
|
|
334
|
+
const serverTime = clock.now();
|
|
317
335
|
this._seq = (this._seq + 1) >>> 0;
|
|
318
336
|
const seq = this._seq;
|
|
319
337
|
const activeList = this._participants.getActiveList();
|
|
@@ -398,7 +416,7 @@ export default class HostGame {
|
|
|
398
416
|
|
|
399
417
|
// проверяет игроков на бездействие и кикает, если превышен порог
|
|
400
418
|
_kickIdleUsers() {
|
|
401
|
-
const now =
|
|
419
|
+
const now = clock.now();
|
|
402
420
|
const usersToKick = [];
|
|
403
421
|
|
|
404
422
|
for (const user of this._participants.getHumans()) {
|
|
@@ -493,6 +511,94 @@ export default class HostGame {
|
|
|
493
511
|
this._mapList.push(...Object.keys(this._maps));
|
|
494
512
|
}
|
|
495
513
|
|
|
514
|
+
// ***** отладочный контур (этап 6 плана plan/ai-debug) ***** //
|
|
515
|
+
// Всё ниже живёт под флагом isDevMode: в проде рекордера нет, а дамп
|
|
516
|
+
// отдаёт только то, что и так знает мета.
|
|
517
|
+
|
|
518
|
+
get isRecording() {
|
|
519
|
+
return this._recorder?.isRecording === true;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// начинает запись живого матча в формат сценария headless-runner'а.
|
|
523
|
+
// Возвращает false, если dev-режим выключен — тишины быть не должно
|
|
524
|
+
startRecording() {
|
|
525
|
+
if (!this._recorder) {
|
|
526
|
+
return false;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
this._recorder.start({
|
|
530
|
+
seed: this._seed,
|
|
531
|
+
map: this._roundManager.currentMap,
|
|
532
|
+
networkSendRate: this._networkSendRate,
|
|
533
|
+
participants: this._participants.getHumans().map(user => ({
|
|
534
|
+
gameId: user.gameId,
|
|
535
|
+
name: user.name,
|
|
536
|
+
model: user.model,
|
|
537
|
+
socketId: user.socketId,
|
|
538
|
+
team: user.team === this._spectatorTeam ? null : user.team,
|
|
539
|
+
})),
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
this._debugConsole('recording started');
|
|
543
|
+
|
|
544
|
+
return true;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// останавливает запись и отдаёт сценарий (parseScenario принимает как есть)
|
|
548
|
+
stopRecording() {
|
|
549
|
+
const scenario = this._recorder?.stop() ?? null;
|
|
550
|
+
|
|
551
|
+
if (scenario) {
|
|
552
|
+
this._debugConsole(
|
|
553
|
+
`recording stopped: ${scenario.ticks} tick(s), ` +
|
|
554
|
+
`${scenario.timeline.length} op(s)`,
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
return scenario;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// курированный срез хоста: мета рядом с дампом мира ядра (этап 4) —
|
|
562
|
+
// расхождение «в ядре тело есть, на холсте пусто» видно только вместе
|
|
563
|
+
debugSnapshot() {
|
|
564
|
+
return {
|
|
565
|
+
seed: this._seed,
|
|
566
|
+
seq: this._seq,
|
|
567
|
+
tick: this._recorder?.tickCount ?? null,
|
|
568
|
+
recording: this.isRecording,
|
|
569
|
+
currentMap: this._roundManager.currentMap,
|
|
570
|
+
participants: {
|
|
571
|
+
humans: this._participants.getHumans().map(user => ({
|
|
572
|
+
gameId: user.gameId,
|
|
573
|
+
socketId: user.socketId,
|
|
574
|
+
name: user.name,
|
|
575
|
+
team: user.team,
|
|
576
|
+
isReady: user.isReady,
|
|
577
|
+
isWatching: user.isWatching,
|
|
578
|
+
})),
|
|
579
|
+
scripted: this._participants.getScripted().map(user => ({
|
|
580
|
+
gameId: user.gameId,
|
|
581
|
+
name: user.name,
|
|
582
|
+
team: user.team,
|
|
583
|
+
})),
|
|
584
|
+
activeList: this._participants.getActiveList(),
|
|
585
|
+
},
|
|
586
|
+
core: this._game.debugJson(),
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// структурированный лог хоста в консоль клиентов (порт CONSOLE): Worker
|
|
591
|
+
// изолирован от DevTools вкладки, поэтому его события иначе не видны
|
|
592
|
+
_debugConsole(message) {
|
|
593
|
+
if (!this._isDevMode) {
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
for (const user of this._participants.getNetworkedReady()) {
|
|
598
|
+
this._socketManager.sendConsole(user.socketId, message);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
496
602
|
// ***** эстафета Worker'ов (Этап 5.2) ***** //
|
|
497
603
|
|
|
498
604
|
// запрашивает перенос: на ближайшей границе раунда игра останавливается
|
|
@@ -683,6 +789,13 @@ export default class HostGame {
|
|
|
683
789
|
const gameId = this._participants.createHuman(params, socketId);
|
|
684
790
|
const name = this._participants.get(gameId).name;
|
|
685
791
|
|
|
792
|
+
this._recorder?.noteJoin({
|
|
793
|
+
gameId,
|
|
794
|
+
name,
|
|
795
|
+
model: params.model,
|
|
796
|
+
socketId,
|
|
797
|
+
});
|
|
798
|
+
|
|
686
799
|
this._chat.addUser(gameId);
|
|
687
800
|
this._vote.addUser(gameId);
|
|
688
801
|
this._stat.addUser(gameId, this._spectatorId, { name });
|
|
@@ -708,6 +821,8 @@ export default class HostGame {
|
|
|
708
821
|
|
|
709
822
|
const { team, teamId } = user;
|
|
710
823
|
|
|
824
|
+
this._recorder?.noteLeave(gameId);
|
|
825
|
+
|
|
711
826
|
this._RTTManager.removeUser(gameId);
|
|
712
827
|
this._stat.removeUser(gameId, teamId);
|
|
713
828
|
this._chat.removeUser(gameId);
|
|
@@ -717,7 +832,7 @@ export default class HostGame {
|
|
|
717
832
|
// финальная синхронизация rank/state перед уходом участника (Этап B4)
|
|
718
833
|
this._playerDataSync
|
|
719
834
|
.flush(gameId)
|
|
720
|
-
.catch(
|
|
835
|
+
.catch(err => console.warn('[playerData] final flush failed:', err.message))
|
|
721
836
|
.finally(() => this._playerDataSync.removeUser(gameId));
|
|
722
837
|
|
|
723
838
|
// если не наблюдатель — удалить танк из ядра (null-маркер ставит ядро)
|
|
@@ -740,7 +855,9 @@ export default class HostGame {
|
|
|
740
855
|
|
|
741
856
|
const [seq, action, name] = keyStr.split(':');
|
|
742
857
|
|
|
743
|
-
|
|
858
|
+
this._recorder?.noteKey(gameId, action, name);
|
|
859
|
+
|
|
860
|
+
user.lastActionTime = clock.now();
|
|
744
861
|
user.lastInputSeq = Number(seq) >>> 0;
|
|
745
862
|
|
|
746
863
|
if (user.isWatching === true) {
|
|
@@ -766,7 +883,7 @@ export default class HostGame {
|
|
|
766
883
|
return;
|
|
767
884
|
}
|
|
768
885
|
|
|
769
|
-
user.lastActionTime =
|
|
886
|
+
user.lastActionTime = clock.now();
|
|
770
887
|
|
|
771
888
|
message = sanitizeMessage(message);
|
|
772
889
|
|
|
@@ -775,6 +892,9 @@ export default class HostGame {
|
|
|
775
892
|
}
|
|
776
893
|
|
|
777
894
|
if (message) {
|
|
895
|
+
// пишется уже вычищенный текст — ровно тот, что применяется к матчу
|
|
896
|
+
this._recorder?.noteChat(gameId, message);
|
|
897
|
+
|
|
778
898
|
if (message.charAt(0) === '/') {
|
|
779
899
|
this._commandProcessor.parseCommand(gameId, message);
|
|
780
900
|
} else {
|
|
@@ -791,7 +911,9 @@ export default class HostGame {
|
|
|
791
911
|
return;
|
|
792
912
|
}
|
|
793
913
|
|
|
794
|
-
|
|
914
|
+
this._recorder?.noteVote(gameId, data);
|
|
915
|
+
|
|
916
|
+
user.lastActionTime = clock.now();
|
|
795
917
|
|
|
796
918
|
if (typeof data === 'string') {
|
|
797
919
|
if (data === 'teams') {
|
package/src/host/host.worker.js
CHANGED
|
@@ -7,17 +7,11 @@
|
|
|
7
7
|
// (JSON-строки и бинарные ArrayBuffer'ы через Transferable).
|
|
8
8
|
|
|
9
9
|
import authClientConfig from '../config/authClient.js';
|
|
10
|
-
import clientDefaults from '../config/clientDefaults.js';
|
|
11
10
|
import lobbyConfig from '../config/lobby.js';
|
|
12
11
|
import wsports from '../config/wsports.js';
|
|
13
|
-
import {
|
|
14
|
-
import { buildClientConfig } from '../lib/buildClientConfig.js';
|
|
15
|
-
import { buildCoreConfig } from '../lib/coreConfig.js';
|
|
12
|
+
import { createHostRuntime } from '../lib/createHostRuntime.js';
|
|
16
13
|
import { validateAuth } from '../lib/validators.js';
|
|
17
14
|
import { verifyIdentityToken } from '../lib/jwt.js';
|
|
18
|
-
import { assertGameConfigShape } from '../lib/gamePlugin.js';
|
|
19
|
-
import SocketManager from './meta/SocketManager.js';
|
|
20
|
-
import HostGame from './HostGame.js';
|
|
21
15
|
|
|
22
16
|
// PC (client ports): порты получения данных от клиента
|
|
23
17
|
const PC_CONFIG_READY = wsports.client.CONFIG_READY;
|
|
@@ -130,54 +124,28 @@ function makeWorkerSocket(socketId) {
|
|
|
130
124
|
// эстафеты Worker'ов (Этап 5.2): комната восстанавливается вместо
|
|
131
125
|
// холодного старта, порт-машины клиентов поднимутся минуя хендшейк
|
|
132
126
|
async function onInit(room, handoff = null) {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const core = await hostPlugin.createCore(
|
|
142
|
-
JSON.stringify(
|
|
143
|
-
buildCoreConfig(hostPlugin.gameConfig, {
|
|
144
|
-
friendlyFire: game.parts.friendlyFire,
|
|
145
|
-
seed,
|
|
146
|
-
}),
|
|
147
|
-
),
|
|
148
|
-
{ wasmUrl: room.game.wasmUrl },
|
|
149
|
-
);
|
|
150
|
-
|
|
151
|
-
clientCfg = buildClientConfig(
|
|
152
|
-
game,
|
|
153
|
-
clientDefaults,
|
|
154
|
-
hostPlugin.buildClientGameConfig(),
|
|
155
|
-
);
|
|
156
|
-
socketManager = new SocketManager(wsports.server, {
|
|
157
|
-
soundCues: game.soundCues,
|
|
158
|
-
initialVote: game.initialVote,
|
|
159
|
-
});
|
|
160
|
-
host = new HostGame(game, socketManager, core, hostPlugin, {
|
|
161
|
-
hostSocketId: room?.hostSocketId ?? null,
|
|
162
|
-
onMapChange: mapName => self.postMessage({ type: 'map_changed', mapName }),
|
|
163
|
-
handoff,
|
|
164
|
-
gameVersion: room.game.version,
|
|
127
|
+
// общая с headless-runner'ом сборка (lib/createHostRuntime.js) — чтобы
|
|
128
|
+
// отладочный прогон крутил ровно тот код, что и прод
|
|
129
|
+
const runtime = await createHostRuntime(room, {
|
|
130
|
+
hostOptions: {
|
|
131
|
+
onMapChange: mapName => self.postMessage({ type: 'map_changed', mapName }),
|
|
132
|
+
handoff,
|
|
133
|
+
},
|
|
165
134
|
});
|
|
166
135
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
}
|
|
136
|
+
hostPlugin = runtime.hostPlugin;
|
|
137
|
+
clientCfg = runtime.clientCfg;
|
|
138
|
+
socketManager = runtime.socketManager;
|
|
139
|
+
host = runtime.host;
|
|
140
|
+
|
|
141
|
+
const seed = runtime.seed;
|
|
174
142
|
|
|
175
143
|
if (handoff) {
|
|
176
144
|
handoffClients = new Map(handoff.humans.map(h => [h.socketId, h.gameId]));
|
|
177
145
|
}
|
|
178
146
|
|
|
179
147
|
// мастеру нужна фактическая карта комнаты (после эстафеты — восстановленная)
|
|
180
|
-
self.postMessage({ type: 'ready', mapName: host.currentMap });
|
|
148
|
+
self.postMessage({ type: 'ready', mapName: host.currentMap, seed });
|
|
181
149
|
}
|
|
182
150
|
|
|
183
151
|
// порт-обработчики клиента (замыкание над gameId через state)
|
|
@@ -375,6 +343,31 @@ function onDisconnect(socketId) {
|
|
|
375
343
|
clients.delete(socketId);
|
|
376
344
|
}
|
|
377
345
|
|
|
346
|
+
// отладочные действия хоста (этап 6): запись живого матча в формат сценария
|
|
347
|
+
// и дамп мира. Сбой не должен ронять Worker — уезжает в ответ строкой
|
|
348
|
+
function onDebug({ requestId, action }) {
|
|
349
|
+
let result = null;
|
|
350
|
+
let error = null;
|
|
351
|
+
|
|
352
|
+
try {
|
|
353
|
+
if (!host) {
|
|
354
|
+
error = 'host is not ready';
|
|
355
|
+
} else if (action === 'startRecording') {
|
|
356
|
+
result = host.startRecording();
|
|
357
|
+
} else if (action === 'stopRecording') {
|
|
358
|
+
result = host.stopRecording();
|
|
359
|
+
} else if (action === 'dump') {
|
|
360
|
+
result = host.debugSnapshot();
|
|
361
|
+
} else {
|
|
362
|
+
error = `unknown debug action '${action}'`;
|
|
363
|
+
}
|
|
364
|
+
} catch (e) {
|
|
365
|
+
error = e && e.message ? e.message : String(e);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
self.postMessage({ type: 'debug_result', requestId, result, error });
|
|
369
|
+
}
|
|
370
|
+
|
|
378
371
|
self.onmessage = async event => {
|
|
379
372
|
const msg = event.data;
|
|
380
373
|
|
|
@@ -414,6 +407,12 @@ self.onmessage = async event => {
|
|
|
414
407
|
host?.setHostId(msg.hostId, msg.hostSecret);
|
|
415
408
|
break;
|
|
416
409
|
|
|
410
|
+
// отладочный контур (этап 6 плана plan/ai-debug): единственный вход в
|
|
411
|
+
// авторитетную половину из главного потока — запрос/ответ по requestId
|
|
412
|
+
case 'debug':
|
|
413
|
+
onDebug(msg);
|
|
414
|
+
break;
|
|
415
|
+
|
|
417
416
|
// эстафета Worker'ов (Этап 5.2)
|
|
418
417
|
|
|
419
418
|
// запрос переноса: на ближайшей границе раунда игра остановится и
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import GAME_CODES from '../../config/gameCodes.js';
|
|
2
|
+
import clock from '../../lib/clock.js';
|
|
3
|
+
|
|
1
4
|
const TECH_CODES = {
|
|
2
5
|
fullServer: [0],
|
|
3
6
|
anotherDevice: [1],
|
|
@@ -8,12 +11,6 @@ const TECH_CODES = {
|
|
|
8
11
|
roomFull: [6], // P2P-комната заполнена (отказ без очереди ожидания)
|
|
9
12
|
};
|
|
10
13
|
|
|
11
|
-
const GAME_CODES = {
|
|
12
|
-
winnerTeam: [0],
|
|
13
|
-
roundStart: [1],
|
|
14
|
-
gameOver: [2],
|
|
15
|
-
};
|
|
16
|
-
|
|
17
14
|
export default class SocketManager {
|
|
18
15
|
/**
|
|
19
16
|
* @param {Object} ports - карта портов host→client (wsports.server).
|
|
@@ -282,7 +279,7 @@ export default class SocketManager {
|
|
|
282
279
|
this._send(socketId, this._PORT_FIRST_SHOT_DATA, [
|
|
283
280
|
this._game.getPlayersData(), // gameSnapshot
|
|
284
281
|
0, // camera
|
|
285
|
-
|
|
282
|
+
clock.now(), // serverTime
|
|
286
283
|
0, // seq
|
|
287
284
|
]);
|
|
288
285
|
this.sendStat(socketId, this._stat.getFull());
|
|
@@ -311,6 +308,17 @@ export default class SocketManager {
|
|
|
311
308
|
this._sendBinary(socketId, frameBuffer, reliable);
|
|
312
309
|
}
|
|
313
310
|
|
|
311
|
+
/**
|
|
312
|
+
* Отправка отладочного лога хоста в консоль клиента (порт CONSOLE,
|
|
313
|
+
* этап 6 плана plan/ai-debug). Worker изолирован от DevTools вкладки —
|
|
314
|
+
* без этого канала его события в браузере не видны вовсе.
|
|
315
|
+
* @param {string} socketId
|
|
316
|
+
* @param {*} data
|
|
317
|
+
*/
|
|
318
|
+
sendConsole(socketId, data) {
|
|
319
|
+
this._send(socketId, this._PORT_CONSOLE, data);
|
|
320
|
+
}
|
|
321
|
+
|
|
314
322
|
/**
|
|
315
323
|
* Отправка данных панели.
|
|
316
324
|
* @param {string} socketId
|
|
@@ -178,4 +178,11 @@ class Panel {
|
|
|
178
178
|
}
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
// Сброс синглтона. Нужен только тем, кто крутит больше одного матча в
|
|
182
|
+
// процессе — headless-runner (devtools/resetHostSingletons.js) и тесты;
|
|
183
|
+
// в браузерной вкладке матч всегда один, поэтому прод его не зовёт.
|
|
184
|
+
export const resetPanel = () => {
|
|
185
|
+
panel = null;
|
|
186
|
+
};
|
|
187
|
+
|
|
181
188
|
export default Panel;
|
|
@@ -7,7 +7,11 @@ import lobbyConfig from '../../../config/lobby.js';
|
|
|
7
7
|
// (RoundManager: смена карты, конец раунда). Схему/дефолты state объявляет
|
|
8
8
|
// игра — здесь это чёрный ящик.
|
|
9
9
|
export default class PlayerDataSync {
|
|
10
|
-
|
|
10
|
+
// дефолт fetchImpl обёрнут стрелкой, а не взят как голый `fetch`: из поля
|
|
11
|
+
// объекта он вызывался бы с `this` экземпляра, а в браузере/воркере у
|
|
12
|
+
// fetch brand-check на глобальный scope — это синхронный TypeError ещё до
|
|
13
|
+
// обращения к сети (тесты подставляют обычную функцию и мимо этого ходят)
|
|
14
|
+
constructor(gameId, { fetchImpl = (...args) => fetch(...args), defaultState = {} } = {}) {
|
|
11
15
|
this._gameId = gameId;
|
|
12
16
|
this._fetch = fetchImpl;
|
|
13
17
|
this._defaultState = defaultState;
|
|
@@ -67,23 +71,31 @@ export default class PlayerDataSync {
|
|
|
67
71
|
this._authedFetch(lobbyConfig.auth.stateUrl, token),
|
|
68
72
|
]);
|
|
69
73
|
|
|
70
|
-
|
|
74
|
+
// неуспешный ответ логируется: rankLoaded/stateLoaded гейтят весь
|
|
75
|
+
// последующий flush, поэтому молчаливый 401/404 здесь навсегда
|
|
76
|
+
// выключает синхронизацию и выглядит снаружи как «данных просто нет»
|
|
77
|
+
if (!rankRes.ok) {
|
|
78
|
+
console.warn(`[playerData] GET rank ${rankRes.status} for ${participantId}`);
|
|
79
|
+
} else if (!entry.rankLoaded) {
|
|
71
80
|
// F9: во время await мог накопиться addRank-дельта поверх
|
|
72
81
|
// стартового 0 — прибавляем, а не перетираем серверным значением
|
|
73
82
|
entry.rank += (await rankRes.json()).rank ?? 0;
|
|
74
83
|
entry.rankLoaded = true;
|
|
75
84
|
}
|
|
76
85
|
|
|
77
|
-
if (stateRes.ok
|
|
86
|
+
if (!stateRes.ok) {
|
|
87
|
+
console.warn(`[playerData] GET state ${stateRes.status} for ${participantId}`);
|
|
88
|
+
} else if (!entry.stateLoaded) {
|
|
78
89
|
const { state } = await stateRes.json();
|
|
79
90
|
|
|
80
91
|
entry.state =
|
|
81
92
|
state && Object.keys(state).length ? state : structuredClone(this._defaultState);
|
|
82
93
|
entry.stateLoaded = true;
|
|
83
94
|
}
|
|
84
|
-
} catch {
|
|
95
|
+
} catch (err) {
|
|
85
96
|
// недоступность auth-сервиса — остаёмся на дефолтах, следующий
|
|
86
97
|
// flush повторит load() перед синхронизацией (см. flush)
|
|
98
|
+
console.warn(`[playerData] load failed for ${participantId}:`, err.message);
|
|
87
99
|
}
|
|
88
100
|
|
|
89
101
|
return entry;
|
|
@@ -152,6 +164,8 @@ export default class PlayerDataSync {
|
|
|
152
164
|
}).then(res => {
|
|
153
165
|
if (res.ok) {
|
|
154
166
|
entry.pendingRankDelta -= delta;
|
|
167
|
+
} else {
|
|
168
|
+
console.warn(`[playerData] PUT rank ${res.status} for ${participantId}`);
|
|
155
169
|
}
|
|
156
170
|
}),
|
|
157
171
|
);
|
|
@@ -161,10 +175,20 @@ export default class PlayerDataSync {
|
|
|
161
175
|
requests.push(this._authedFetch(lobbyConfig.auth.stateUrl, token, {
|
|
162
176
|
method: 'PUT',
|
|
163
177
|
body: { state, hostId: this._hostId, hostSecret: this._hostSecret },
|
|
178
|
+
}).then(res => {
|
|
179
|
+
if (!res.ok) {
|
|
180
|
+
console.warn(`[playerData] PUT state ${res.status} for ${participantId}`);
|
|
181
|
+
}
|
|
164
182
|
}));
|
|
165
183
|
}
|
|
166
184
|
|
|
167
|
-
|
|
185
|
+
// allSettled глушит отказы по замыслу (сбой не должен ронять раунд), но
|
|
186
|
+
// не должен глушить их след — иначе потеря синхронизации не диагностируема
|
|
187
|
+
for (const result of await Promise.allSettled(requests)) {
|
|
188
|
+
if (result.status === 'rejected') {
|
|
189
|
+
console.warn(`[playerData] flush failed for ${participantId}:`, result.reason?.message);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
168
192
|
}
|
|
169
193
|
|
|
170
194
|
// синхронизирует всех текущих участников (границы раунда/карты)
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import clock from '../../../lib/clock.js';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* @class RTTManager
|
|
3
5
|
* @description Управляет расчетом Round Trip Time (RTT) для пользователя,
|
|
@@ -96,7 +98,7 @@ export default class RTTManager {
|
|
|
96
98
|
|
|
97
99
|
// новый пинг
|
|
98
100
|
user.pingIdCounter += 1;
|
|
99
|
-
const time =
|
|
101
|
+
const time = clock.now();
|
|
100
102
|
|
|
101
103
|
// удаление старых пингов и запись нового
|
|
102
104
|
// `outstandingPings` содержит только один, последний отправленный пинг
|
|
@@ -141,7 +143,7 @@ export default class RTTManager {
|
|
|
141
143
|
const time = user.outstandingPings.get(pingId);
|
|
142
144
|
|
|
143
145
|
if (time) {
|
|
144
|
-
const newRttSample =
|
|
146
|
+
const newRttSample = clock.now() - time;
|
|
145
147
|
const oldRtt = user.rtt;
|
|
146
148
|
|
|
147
149
|
// сглаживание значения RTT
|
|
@@ -291,4 +291,11 @@ class Stat {
|
|
|
291
291
|
}
|
|
292
292
|
}
|
|
293
293
|
|
|
294
|
+
// Сброс синглтона. Нужен только тем, кто крутит больше одного матча в
|
|
295
|
+
// процессе — headless-runner (devtools/resetHostSingletons.js) и тесты;
|
|
296
|
+
// в браузерной вкладке матч всегда один, поэтому прод его не зовёт.
|
|
297
|
+
export const resetStat = () => {
|
|
298
|
+
stat = null;
|
|
299
|
+
};
|
|
300
|
+
|
|
294
301
|
export default Stat;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import AbstractTimer from '../../../lib/AbstractTimer.js';
|
|
2
|
+
import clock from '../../../lib/clock.js';
|
|
2
3
|
|
|
3
4
|
// Singleton TimerManager
|
|
4
5
|
|
|
@@ -66,7 +67,7 @@ class TimerManager extends AbstractTimer {
|
|
|
66
67
|
// при возобновлении после эстафеты Worker'ов, Этап 5.2)
|
|
67
68
|
startMapTimer(duration = this._mapTime) {
|
|
68
69
|
this.stopMapTimer();
|
|
69
|
-
this._startMapTime =
|
|
70
|
+
this._startMapTime = clock.now();
|
|
70
71
|
this._currentMapDuration = duration;
|
|
71
72
|
this._startTimer('map', this._callbacks.onMapTimeEnd, duration);
|
|
72
73
|
}
|
|
@@ -79,7 +80,7 @@ class TimerManager extends AbstractTimer {
|
|
|
79
80
|
// возвращает оставшееся время до конца карты в миллисекундах
|
|
80
81
|
getMapTimeLeft() {
|
|
81
82
|
const timeLeft =
|
|
82
|
-
this._currentMapDuration - (
|
|
83
|
+
this._currentMapDuration - (clock.now() - this._startMapTime);
|
|
83
84
|
|
|
84
85
|
return Math.max(0, timeLeft);
|
|
85
86
|
}
|
|
@@ -95,7 +96,7 @@ class TimerManager extends AbstractTimer {
|
|
|
95
96
|
// запускает таймер до конца текущего раунда
|
|
96
97
|
startRoundTimer() {
|
|
97
98
|
this.stopRoundTimer();
|
|
98
|
-
this._startRoundTime =
|
|
99
|
+
this._startRoundTime = clock.now();
|
|
99
100
|
this._startTimer('round', this._callbacks.onRoundTimeEnd, this._roundTime);
|
|
100
101
|
}
|
|
101
102
|
|
|
@@ -106,21 +107,21 @@ class TimerManager extends AbstractTimer {
|
|
|
106
107
|
|
|
107
108
|
// возвращает оставшееся время до конца раунда в секундах
|
|
108
109
|
getRoundTimeLeft() {
|
|
109
|
-
const timeLeft = this._roundTime - (
|
|
110
|
+
const timeLeft = this._roundTime - (clock.now() - this._startRoundTime);
|
|
110
111
|
|
|
111
112
|
return Math.max(0, Math.floor(timeLeft / 1000));
|
|
112
113
|
}
|
|
113
114
|
|
|
114
115
|
// проверяет возможно сменить команду игроку в текущем раунде
|
|
115
116
|
canChangeTeamInCurrentRound() {
|
|
116
|
-
const roundTime =
|
|
117
|
+
const roundTime = clock.now() - this._startRoundTime;
|
|
117
118
|
|
|
118
119
|
return roundTime <= this._teamChangeGracePeriod;
|
|
119
120
|
}
|
|
120
121
|
|
|
121
122
|
// логика одного "тика" игрового цикла
|
|
122
123
|
_loopTick() {
|
|
123
|
-
const now =
|
|
124
|
+
const now = clock.monotonic();
|
|
124
125
|
let dt = (now - this._lastShotTime) / 1000;
|
|
125
126
|
this._lastShotTime = now;
|
|
126
127
|
|
|
@@ -146,7 +147,7 @@ class TimerManager extends AbstractTimer {
|
|
|
146
147
|
_startGameLoop() {
|
|
147
148
|
this._stopGameLoop();
|
|
148
149
|
|
|
149
|
-
this._lastShotTime =
|
|
150
|
+
this._lastShotTime = clock.monotonic();
|
|
150
151
|
this._expectedTickTime = this._lastShotTime + this._timeStep;
|
|
151
152
|
|
|
152
153
|
// запуск первого таймаута, который инициирует цикл
|
|
@@ -274,4 +275,11 @@ class TimerManager extends AbstractTimer {
|
|
|
274
275
|
}
|
|
275
276
|
}
|
|
276
277
|
|
|
278
|
+
// Сброс синглтона. Нужен только тем, кто крутит больше одного матча в
|
|
279
|
+
// процессе — headless-runner (devtools/resetHostSingletons.js) и тесты;
|
|
280
|
+
// в браузерной вкладке матч всегда один, поэтому прод его не зовёт.
|
|
281
|
+
export const resetTimerManager = () => {
|
|
282
|
+
timerManager = null;
|
|
283
|
+
};
|
|
284
|
+
|
|
277
285
|
export default TimerManager;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import clock from '../../../lib/clock.js';
|
|
2
|
+
|
|
1
3
|
// Singleton Vote
|
|
2
4
|
let vote;
|
|
3
5
|
|
|
@@ -171,9 +173,16 @@ class Vote {
|
|
|
171
173
|
}
|
|
172
174
|
|
|
173
175
|
// случайный выбор, если победителей несколько
|
|
174
|
-
const randomIndex = Math.floor(
|
|
176
|
+
const randomIndex = Math.floor(clock.random() * winners.length);
|
|
175
177
|
return winners[randomIndex];
|
|
176
178
|
}
|
|
177
179
|
}
|
|
178
180
|
|
|
181
|
+
// Сброс синглтона. Нужен только тем, кто крутит больше одного матча в
|
|
182
|
+
// процессе — headless-runner (devtools/resetHostSingletons.js) и тесты;
|
|
183
|
+
// в браузерной вкладке матч всегда один, поэтому прод его не зовёт.
|
|
184
|
+
export const resetVote = () => {
|
|
185
|
+
vote = null;
|
|
186
|
+
};
|
|
187
|
+
|
|
179
188
|
export default Vote;
|
|
@@ -63,4 +63,11 @@ class Chat {
|
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
// Сброс синглтона. Нужен только тем, кто крутит больше одного матча в
|
|
67
|
+
// процессе — headless-runner (devtools/resetHostSingletons.js) и тесты;
|
|
68
|
+
// в браузерной вкладке матч всегда один, поэтому прод его не зовёт.
|
|
69
|
+
export const resetChat = () => {
|
|
70
|
+
chat = null;
|
|
71
|
+
};
|
|
72
|
+
|
|
66
73
|
export default Chat;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export { default } from './Chat.js';
|
|
1
|
+
export { default, resetChat } from './Chat.js';
|