vimp-engine 0.1.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.
Files changed (45) hide show
  1. package/README.md +9 -0
  2. package/package.json +34 -0
  3. package/src/config/authClient.js +47 -0
  4. package/src/config/clientDefaults.js +113 -0
  5. package/src/config/hostDefaults.js +79 -0
  6. package/src/config/lobby.js +104 -0
  7. package/src/config/master.js +103 -0
  8. package/src/config/opcodes.js +29 -0
  9. package/src/config/wsports.js +35 -0
  10. package/src/host/GameCoreAdapter.js +206 -0
  11. package/src/host/HostGame.js +857 -0
  12. package/src/host/host.worker.js +438 -0
  13. package/src/host/meta/SocketManager.js +431 -0
  14. package/src/host/meta/core/CommandProcessor.js +99 -0
  15. package/src/host/meta/core/RoundManager.js +646 -0
  16. package/src/host/meta/core/VoteCoordinator.js +71 -0
  17. package/src/host/meta/modules/Panel.js +181 -0
  18. package/src/host/meta/modules/PlayerDataSync.js +176 -0
  19. package/src/host/meta/modules/RTTManager.js +168 -0
  20. package/src/host/meta/modules/Stat.js +294 -0
  21. package/src/host/meta/modules/TimerManager.js +277 -0
  22. package/src/host/meta/modules/Vote.js +179 -0
  23. package/src/host/meta/modules/chat/Chat.js +66 -0
  24. package/src/host/meta/modules/chat/index.js +1 -0
  25. package/src/host/meta/modules/chat/systemMessages.js +51 -0
  26. package/src/host/meta/player/HumanParticipant.js +36 -0
  27. package/src/host/meta/player/Participant.js +22 -0
  28. package/src/host/meta/player/ParticipantManager.js +247 -0
  29. package/src/host/meta/player/ScriptedParticipant.js +19 -0
  30. package/src/lib/AbstractTimer.js +84 -0
  31. package/src/lib/Publisher.js +43 -0
  32. package/src/lib/applyRoomOverrides.js +48 -0
  33. package/src/lib/buildClientConfig.js +48 -0
  34. package/src/lib/clientCoreConfig.js +59 -0
  35. package/src/lib/config.js +83 -0
  36. package/src/lib/coreConfig.js +60 -0
  37. package/src/lib/factory.js +21 -0
  38. package/src/lib/formatters.js +33 -0
  39. package/src/lib/gamePlugin.js +95 -0
  40. package/src/lib/jwt.js +103 -0
  41. package/src/lib/math.js +75 -0
  42. package/src/lib/rateLimiter.js +40 -0
  43. package/src/lib/sanitizers.js +17 -0
  44. package/src/lib/security.js +45 -0
  45. package/src/lib/validators.js +52 -0
@@ -0,0 +1,857 @@
1
+ import Panel from './meta/modules/Panel.js';
2
+ import PlayerDataSync from './meta/modules/PlayerDataSync.js';
3
+ import Stat from './meta/modules/Stat.js';
4
+ import Chat from './meta/modules/chat/index.js';
5
+ import { registerCodes } from './meta/modules/chat/systemMessages.js';
6
+ import Vote from './meta/modules/Vote.js';
7
+ import RTTManager from './meta/modules/RTTManager.js';
8
+ import TimerManager from './meta/modules/TimerManager.js';
9
+ import ParticipantManager from './meta/player/ParticipantManager.js';
10
+ import VoteCoordinator from './meta/core/VoteCoordinator.js';
11
+ import RoundManager from './meta/core/RoundManager.js';
12
+ import CommandProcessor from './meta/core/CommandProcessor.js';
13
+ import { sanitizeMessage } from '../lib/sanitizers.js';
14
+ import GameCoreAdapter from './GameCoreAdapter.js';
15
+
16
+ // Версия формата handoff-меты эстафеты Worker'ов (Этап 5.2; →2 в Этапе 6.5 —
17
+ // добавлены gameId/gameVersion; →3 в Этапе Д3 — нейтральное поле scripted).
18
+ // Несовместимая версия валит init нового Worker'а — главный поток
19
+ // возобновляет старый, комната продолжает жить на прежней версии кода
20
+ export const HANDOFF_VERSION = 3;
21
+
22
+ // Троттлинг отправки кадров (замена SnapshotManager: ядро само копит события
23
+ // и дренирует их в pack_body, здесь нужен только контроль частоты).
24
+ class SnapshotThrottle {
25
+ constructor(sendRate) {
26
+ this._sendRate = Math.max(1, sendRate || 1);
27
+ this._tick = 0;
28
+ }
29
+
30
+ // тик игрового цикла: true — этот кадр отправляем, false — пропуск
31
+ shouldSend() {
32
+ this._tick += 1;
33
+
34
+ if (this._tick < this._sendRate) {
35
+ return false;
36
+ }
37
+
38
+ this._tick = 0;
39
+
40
+ return true;
41
+ }
42
+
43
+ // сброс (смена карты) — совместимость с интерфейсом SnapshotManager
44
+ reset() {
45
+ this._tick = 0;
46
+ }
47
+ }
48
+
49
+ // Host-фасад: авторитетная часть матча в Worker'е хоста. Симуляция,
50
+ // scripted-участники и упаковка снапшотов — в Rust-ядре через GameCoreAdapter, мета (RoundManager,
51
+ // участники, чат, голосования, статистика, панель) — JS-модули ./meta/.
52
+ // Питается стандартным словарём событий ядра (adapter._drainEvents →
53
+ // panel/reportKill/shake; 'custom' — HostPlugin.onCoreEvent).
54
+ export default class HostGame {
55
+ /**
56
+ * @param {Object} data - конфиг игры (merge hostDefaults + игровой
57
+ * конфиг HostPlugin.gameConfig; см. host.worker.js).
58
+ * @param {Object} socketManager - транспорт (per-user send/close).
59
+ * @param {GameCore} core - экземпляр WASM-ядра.
60
+ * @param {Object} hostPlugin - HostPlugin игры, загруженной динамически по
61
+ * GameManifest (Этап 6.4): onCoreEvent/createModules/chatCommands/
62
+ * systemMessages.
63
+ * @param {Object} [opts]
64
+ * @param {string} [opts.hostSocketId] - socketId хоста-игрока (loopback):
65
+ * исключается из kick-политик — его отключение убивает комнату для всех.
66
+ * @param {Function} [opts.onMapChange] - вызывается с именем карты при её
67
+ * смене (голосование/таймер) — для актуализации комнаты у мастера.
68
+ * @param {Object} [opts.handoff] - handoff-мета эстафеты Worker'ов
69
+ * (Этап 5.2): восстановление комнаты вместо холодного старта.
70
+ * @param {string} [opts.gameVersion] - версия игры комнаты (room.game.version,
71
+ * Этап 6.5): едет в handoff-мете рядом с gameId — расхождение с игрой
72
+ * восстанавливающего Worker'а валит init тем же путём, что и версия формата.
73
+ */
74
+ constructor(
75
+ data,
76
+ socketManager,
77
+ core,
78
+ hostPlugin,
79
+ {
80
+ hostSocketId = null,
81
+ onMapChange = null,
82
+ handoff = null,
83
+ gameVersion = null,
84
+ } = {},
85
+ ) {
86
+ this._isDevMode = data.isDevMode || false;
87
+
88
+ this._hostSocketId = hostSocketId;
89
+ this._onMapChange = onMapChange;
90
+
91
+ // составной codeVersion (Этап 6.5): id — из самого загруженного плагина
92
+ // (источник истины), version — то, что заявил Worker при инициализации
93
+ this._gameId = hostPlugin.id;
94
+ this._gameVersion = gameVersion;
95
+
96
+ this._maps = data.maps;
97
+ this._mapList = Object.keys(data.maps);
98
+ this._spectatorKeys = data.spectatorKeys;
99
+ this._maxPlayers = data.maxPlayers;
100
+ this._chatMaxLength = data.chatMaxLength;
101
+
102
+ this._idleTimeoutForPlayer = data.idleKickTimeout?.player || null;
103
+ this._idleTimeoutForSpectator = data.idleKickTimeout?.spectator || null;
104
+
105
+ this._teams = data.teams;
106
+ this._spectatorTeam = data.spectatorTeam;
107
+ this._spectatorId = this._teams[this._spectatorTeam];
108
+
109
+ // единый реестр участников (игроки + scripted)
110
+ this._participants = new ParticipantManager(
111
+ this._teams,
112
+ this._spectatorTeam,
113
+ this._maxPlayers,
114
+ data.scripted,
115
+ );
116
+
117
+ // симуляция — в ядре; адаптер под интерфейс Game.js
118
+ this._game = new GameCoreAdapter(core, {
119
+ participants: this._participants,
120
+ onCoreEvent: hostPlugin.onCoreEvent,
121
+ });
122
+
123
+ this._panel = new Panel(data.panel);
124
+ this._stat = new Stat(data.stat, this._teams);
125
+ // rank/state участников (Этап B4) — подгрузка на join, синхронизация
126
+ // обратно на мастер по границам раунда/карты (см. RoundManager)
127
+ this._playerDataSync = new PlayerDataSync(this._gameId, {
128
+ defaultState: data.playerState?.defaultState ?? {},
129
+ });
130
+ this._chat = new Chat();
131
+ this._vote = new Vote();
132
+
133
+ this._socketManager = socketManager;
134
+
135
+ this._snapshotManager = new SnapshotThrottle(data.timers.networkSendRate);
136
+
137
+ // игровые host-модули (scripted-модуль игры)
138
+ this._scripted = hostPlugin.createModules({
139
+ participants: this._participants,
140
+ coreAdapter: this._game,
141
+ panel: this._panel,
142
+ stat: this._stat,
143
+ chat: this._chat,
144
+ socketManager: this._socketManager,
145
+ scripted: data.scripted,
146
+ }).scripted;
147
+
148
+ this._RTTManager = new RTTManager(data.rtt, {
149
+ onKickForMissedPings: gameId => this._kickForMissedPings(gameId),
150
+ onKickForMaxLatency: gameId => this._kickForMaxLatency(gameId),
151
+ });
152
+
153
+ this._timerManager = new TimerManager(data.timers, {
154
+ onMapTimeEnd: () => this._roundManager.onMapTimeEnd(),
155
+ onRoundTimeEnd: () => this._roundManager.initiateNewRound(),
156
+ onShotTick: dt => this._onShotTick(dt),
157
+ onIdleCheck: () => this._kickIdleUsers(),
158
+ onSendPing: () => this._sendPing(),
159
+ });
160
+
161
+ this._voteCoordinator = new VoteCoordinator({
162
+ vote: this._vote,
163
+ chat: this._chat,
164
+ timerManager: this._timerManager,
165
+ });
166
+
167
+ this._roundManager = new RoundManager({
168
+ participants: this._participants,
169
+ game: this._game,
170
+ panel: this._panel,
171
+ stat: this._stat,
172
+ chat: this._chat,
173
+ socketManager: this._socketManager,
174
+ timerManager: this._timerManager,
175
+ scripted: this._scripted,
176
+ voteCoordinator: this._voteCoordinator,
177
+ snapshotManager: this._snapshotManager,
178
+ playerDataSync: this._playerDataSync,
179
+ teams: this._teams,
180
+ spectatorTeam: this._spectatorTeam,
181
+ spectatorId: this._spectatorId,
182
+ maps: data.maps,
183
+ mapList: this._mapList,
184
+ mapsInVote: data.mapsInVote,
185
+ mapScale: data.mapScale,
186
+ mapSetId: data.mapSetId,
187
+ currentMap: data.currentMap,
188
+ });
189
+
190
+ this._commandProcessor = new CommandProcessor({
191
+ participants: this._participants,
192
+ chat: this._chat,
193
+ scripted: this._scripted,
194
+ roundManager: this._roundManager,
195
+ voteCoordinator: this._voteCoordinator,
196
+ timerManager: this._timerManager,
197
+ playerDataSync: this._playerDataSync,
198
+ teams: this._teams,
199
+ spectatorTeam: this._spectatorTeam,
200
+ spectatorId: this._spectatorId,
201
+ isDevMode: this._isDevMode,
202
+ });
203
+
204
+ // игровые чат-команды и коды системных сообщений из HostPlugin
205
+ for (const command of hostPlugin.chatCommands) {
206
+ this._commandProcessor.registerCommand(command.name, command.handler);
207
+ }
208
+
209
+ registerCodes(hostPlugin.systemMessages);
210
+
211
+ // инкрементный номер snapshot-кадра
212
+ this._seq = 0;
213
+
214
+ // эстафета Worker'ов (Этап 5.2)
215
+ this._handoffRestored = false;
216
+ this._handoffMapTimeLeft = null;
217
+
218
+ // внедрение зависимостей (ядро отдаёт панель/фасад события через адаптер)
219
+ this._socketManager.injectServices(this._game, this._panel, this._stat);
220
+ this._game.injectServices({ vimp: this, panel: this._panel });
221
+ this._panel.injectTimerManager(this._timerManager);
222
+
223
+ this._timerManager.startIdleCheckTimer();
224
+
225
+ // эстафета Worker'ов (Этап 5.2): восстановление вместо холодного старта;
226
+ // игровой цикл и первый раунд запустит completeHandoff() после
227
+ // переподключения клиентов главным потоком
228
+ if (handoff) {
229
+ this._restoreFromHandoff(handoff);
230
+ } else {
231
+ this._roundManager.createMap();
232
+ }
233
+
234
+ // отслеживание смены карты (для актуализации комнаты в лобби мастера)
235
+ this._lastReportedMap = this._roundManager.currentMap;
236
+ }
237
+
238
+ // комната заполнена людьми — новые подключения отклоняются.
239
+ // Scripted-участники место не занимают: при входе игрока в полную команду
240
+ // один из них кикается (RoundManager.changeTeam → removeOneForHuman),
241
+ // уступая слот
242
+ get isFull() {
243
+ return this._participants.getHumans().length >= this._maxPlayers;
244
+ }
245
+
246
+ // лимит участников комнаты (для сообщения об отказе)
247
+ get maxPlayers() {
248
+ return this._maxPlayers;
249
+ }
250
+
251
+ // текущая карта комнаты (после эстафеты может отличаться от room.map)
252
+ get currentMap() {
253
+ return this._roundManager.currentMap;
254
+ }
255
+
256
+ // хост-игрок не кикается: закрытие его loopback = смерть комнаты для всех
257
+ _isHostPlayer(user) {
258
+ return this._hostSocketId !== null && user.socketId === this._hostSocketId;
259
+ }
260
+
261
+ // кикает за задержку в ответе на ping
262
+ _kickForMaxLatency(gameId) {
263
+ const user = this._participants.get(gameId);
264
+
265
+ if (user && !this._isHostPlayer(user)) {
266
+ console.warn(`[RTT] Kick ${user.name} — pong latency exceeded`);
267
+ this._socketManager.close(user.socketId, 4003, 'kickForMaxLatency');
268
+ this.removeUser(gameId);
269
+ }
270
+ }
271
+
272
+ // кикает за превышение прокусков ответа на ping
273
+ _kickForMissedPings(gameId) {
274
+ const user = this._participants.get(gameId);
275
+
276
+ if (user && !this._isHostPlayer(user)) {
277
+ console.warn(`[RTT] Kick ${user.name} — no response to pings`);
278
+ this._socketManager.close(user.socketId, 4004, 'kickForMissedPings');
279
+ this.removeUser(gameId);
280
+ }
281
+ }
282
+
283
+ // создаёт кадр игры (core-driven)
284
+ _onShotTick(dt) {
285
+ // шаг ядра + проекция событий (kill/health/ammo/weapon/shake) в мету
286
+ this._game.updateData(dt);
287
+
288
+ // контроль частоты отправки
289
+ if (!this._snapshotManager.shouldSend()) {
290
+ return;
291
+ }
292
+
293
+ // смена карты (голосование/таймер) — уведомить главный поток
294
+ const currentMap = this._roundManager.currentMap;
295
+
296
+ if (currentMap !== this._lastReportedMap) {
297
+ this._lastReportedMap = currentMap;
298
+ this._onMapChange?.(currentMap);
299
+ }
300
+
301
+ // список удаляемых с полотна игроков ведёт RoundManager, но null-маркеры
302
+ // в кадр кладёт само ядро (remove_actor) — здесь лишь опустошаем очередь,
303
+ // чтобы она не росла
304
+ const removedPlayersList = this._roundManager.removedPlayersList;
305
+
306
+ while (removedPlayersList.length) {
307
+ removedPlayersList.pop();
308
+ }
309
+
310
+ const userList = this._participants.getNetworkedReady();
311
+ const panelUpdates = this._panel.processUpdates();
312
+ const stat = this._stat.getLast();
313
+ const chat = this._chat.shift();
314
+ const vote = this._vote.shift();
315
+
316
+ const serverTime = Date.now();
317
+ this._seq = (this._seq + 1) >>> 0;
318
+ const seq = this._seq;
319
+ const activeList = this._participants.getActiveList();
320
+
321
+ // broadcast-часть кадра пакуется в ядре один раз за тик
322
+ this._game.packBody();
323
+
324
+ // событийные блоки тела (трассеры/бомбы/взрывы/удаления) требуют надёжной
325
+ // доставки (WebRTC meta); чисто позиционный кадр идёт по state
326
+ const bodyHasEvents = this._game.bodyHasEvents();
327
+
328
+ // вычисляет камеру наблюдения для пользователя
329
+ const getCamera = user => {
330
+ let camera;
331
+
332
+ if (user.isWatching === true) {
333
+ if (activeList.length) {
334
+ if (!activeList.includes(user.watchedGameId)) {
335
+ user.watchedGameId = activeList[0];
336
+ }
337
+
338
+ camera = this._game.getPosition(user.watchedGameId);
339
+ } else {
340
+ camera = [0, 0];
341
+ }
342
+ } else {
343
+ camera = this._game.getPosition(user.gameId);
344
+ }
345
+
346
+ if (user.forceCameraReset === true) {
347
+ camera[2] = true;
348
+ user.forceCameraReset = false;
349
+ }
350
+
351
+ if (user.pendingShake) {
352
+ camera[3] = user.pendingShake;
353
+ user.pendingShake = null;
354
+ }
355
+
356
+ return camera;
357
+ };
358
+
359
+ userList.forEach(user => {
360
+ const gameId = user.gameId;
361
+ const socketId = user.socketId;
362
+
363
+ const camera = getCamera(user);
364
+
365
+ // player-блок предикшена собирает ядро по playerId (наблюдатель → -1)
366
+ const playerId = user.isWatching === false ? gameId : null;
367
+
368
+ // per-user события кадра: forceReset (camera[2]) и shake (camera[3])
369
+ // тоже требуют надёжной доставки
370
+ const reliable =
371
+ bodyHasEvents || camera[2] === true || Boolean(camera[3]);
372
+
373
+ this._socketManager.sendShot(
374
+ socketId,
375
+ this._game.packFrame(camera, serverTime, seq, playerId),
376
+ reliable,
377
+ );
378
+
379
+ if (panelUpdates[gameId]) {
380
+ this._socketManager.sendPanel(socketId, panelUpdates[gameId]);
381
+ }
382
+
383
+ if (stat) {
384
+ this._socketManager.sendStat(socketId, stat);
385
+ }
386
+
387
+ const chatUser = chat || this._chat.shiftByUser(gameId);
388
+ if (chatUser) {
389
+ this._socketManager.sendChat(socketId, chatUser);
390
+ }
391
+
392
+ const voteUser = vote || this._vote.shiftByUser(gameId);
393
+ if (voteUser) {
394
+ this._socketManager.sendVote(socketId, voteUser);
395
+ }
396
+ });
397
+ }
398
+
399
+ // проверяет игроков на бездействие и кикает, если превышен порог
400
+ _kickIdleUsers() {
401
+ const now = Date.now();
402
+ const usersToKick = [];
403
+
404
+ for (const user of this._participants.getHumans()) {
405
+ if (user.isReady !== true || this._isHostPlayer(user)) {
406
+ continue;
407
+ }
408
+
409
+ const idleThreshold =
410
+ user.teamId === this._spectatorId
411
+ ? this._idleTimeoutForSpectator
412
+ : this._idleTimeoutForPlayer;
413
+
414
+ if (idleThreshold !== null) {
415
+ const idleTime = now - user.lastActionTime;
416
+
417
+ if (idleTime > idleThreshold) {
418
+ usersToKick.push(user);
419
+ }
420
+ }
421
+ }
422
+
423
+ usersToKick.forEach(user => {
424
+ this._socketManager.close(user.socketId, 4005, 'kickIdle');
425
+ this.removeUser(user.gameId);
426
+ });
427
+ }
428
+
429
+ // отправляет ping всем пользователям
430
+ _sendPing() {
431
+ const users = this._RTTManager.scheduleNextPing();
432
+
433
+ for (const [gameId, { pingIdCounter }] of users) {
434
+ const user = this._participants.get(gameId);
435
+
436
+ this._socketManager.sendPing(user.socketId, pingIdCounter);
437
+ }
438
+ }
439
+
440
+ // отправляет карту (прокси к RoundManager)
441
+ sendMap(gameId) {
442
+ this._roundManager.sendMap(gameId);
443
+ }
444
+
445
+ // сообщает о загрузке карты
446
+ mapReady(gameId) {
447
+ const user = this._participants.get(gameId);
448
+
449
+ if (!user) {
450
+ return;
451
+ }
452
+
453
+ if (user.currentMap !== this._roundManager.currentMap) {
454
+ this.sendMap(gameId);
455
+ return;
456
+ }
457
+
458
+ if (user.isReady === false) {
459
+ this._socketManager.sendFirstShot(user.socketId);
460
+ }
461
+ }
462
+
463
+ // сообщает о готовности игрока к игре
464
+ firstShotReady(gameId) {
465
+ const user = this._participants.get(gameId);
466
+
467
+ if (!user) {
468
+ return;
469
+ }
470
+
471
+ const socketId = user.socketId;
472
+
473
+ user.isReady = true;
474
+ this._socketManager.sendTechInform(socketId);
475
+ this._socketManager.sendFirstVote(socketId);
476
+ this._chat.pushSystem('USER_JOINED', [user.name]);
477
+ }
478
+
479
+ // обрабатывает уничтожение игрока (прокси к RoundManager; из событий ядра)
480
+ reportKill(victimId, killerId = null) {
481
+ this._roundManager.reportKill(victimId, killerId);
482
+ }
483
+
484
+ // обновляет каталог карт (Этап 5.1). Новые данные применяются со следующей
485
+ // смены карты: _maps и _mapList правятся на месте — эти же ссылки держат
486
+ // RoundManager (createMap) и голосования (parseVote 'maps')
487
+ updateMaps(maps) {
488
+ for (const [name, data] of Object.entries(maps)) {
489
+ this._maps[name] = data;
490
+ }
491
+
492
+ this._mapList.length = 0;
493
+ this._mapList.push(...Object.keys(this._maps));
494
+ }
495
+
496
+ // ***** эстафета Worker'ов (Этап 5.2) ***** //
497
+
498
+ // запрашивает перенос: на ближайшей границе раунда игра останавливается
499
+ // и cb получает handoff-мету. Ядро не дампится — мир пересоздаётся стартом
500
+ // раунда в новом Worker'е (см. RoundManager._startRound)
501
+ requestHandoff(cb) {
502
+ this._roundManager.requestHandoff(() => {
503
+ this._timerManager.stopGameTimers();
504
+ this._timerManager.stopIdleCheckTimer();
505
+ cb(this._collectHandoff());
506
+ });
507
+ }
508
+
509
+ // отказ от эстафеты (новый Worker не поднялся): вернуть таймеры и
510
+ // продолжить жить на старой версии кода
511
+ resumeAfterHandoff() {
512
+ this._roundManager.cancelHandoff();
513
+ this._timerManager.startIdleCheckTimer();
514
+ this._timerManager.resumeGameTimers(this._timerManager.getMapTimeLeft());
515
+ this._roundManager.initiateNewRound();
516
+ }
517
+
518
+ // завершение эстафеты в новом Worker'е: клиенты переподключены главным
519
+ // потоком — вычистить не переживших паузу, вернуть таймеры (карта — с
520
+ // остатком времени) и стартовать первый раунд
521
+ completeHandoff(connectedSocketIds) {
522
+ if (!this._handoffRestored) {
523
+ return;
524
+ }
525
+
526
+ this._handoffRestored = false;
527
+
528
+ for (const user of this._participants.getHumans()) {
529
+ if (!connectedSocketIds.has(user.socketId)) {
530
+ this.removeUser(user.gameId);
531
+ }
532
+ }
533
+
534
+ this._timerManager.resumeGameTimers(this._handoffMapTimeLeft);
535
+ this._roundManager.initiateNewRound();
536
+ }
537
+
538
+ // собирает переносимую мету: участники, счёт, карта с остатком времени,
539
+ // seq кадров. Осознанно не переносятся: чат-история, активные голосования,
540
+ // RTT-статистика, panel (значения живут в ядре и сбрасываются раундом)
541
+ _collectHandoff() {
542
+ const humans = this._participants
543
+ .getHumans()
544
+ .filter(user => user.isReady)
545
+ .map(user => ({
546
+ gameId: user.gameId,
547
+ socketId: user.socketId,
548
+ name: user.name,
549
+ model: user.model,
550
+ team: user.team,
551
+ teamId: user.teamId,
552
+ }));
553
+
554
+ const scripted = this._participants.getScripted().map(participant => ({
555
+ gameId: participant.gameId,
556
+ name: participant.name,
557
+ model: participant.model,
558
+ team: participant.team,
559
+ teamId: participant.teamId,
560
+ }));
561
+
562
+ return {
563
+ version: HANDOFF_VERSION,
564
+ gameId: this._gameId,
565
+ gameVersion: this._gameVersion,
566
+ seq: this._seq,
567
+ currentMap: this._roundManager.currentMap,
568
+ mapTimeLeft: this._timerManager.getMapTimeLeft(),
569
+ humans,
570
+ scripted,
571
+ stat: this._stat.serialize(),
572
+ };
573
+ }
574
+
575
+ // восстанавливает комнату из handoff-меты. Ошибка (несовместимый формат,
576
+ // чужая игра, карта ушла из каталога) валит init Worker'а — главный поток
577
+ // возобновляет старый Worker, комната живёт на прежней версии
578
+ _restoreFromHandoff(meta) {
579
+ if (!meta || meta.version !== HANDOFF_VERSION) {
580
+ throw new Error(`unsupported handoff version: ${meta && meta.version}`);
581
+ }
582
+
583
+ // составной codeVersion (Этап 6.5): своп меняет только версию кода в
584
+ // рамках той же игры — смена самой игры комнаты handoff'ом не
585
+ // предусмотрена, рассинхрон id — явный сбой конфигурации свопа
586
+ if (meta.gameId !== undefined && meta.gameId !== this._gameId) {
587
+ throw new Error(
588
+ `handoff game mismatch: expected "${this._gameId}", got "${meta.gameId}"`,
589
+ );
590
+ }
591
+
592
+ if (!this._maps[meta.currentMap]) {
593
+ throw new Error(`handoff map missing from catalog: ${meta.currentMap}`);
594
+ }
595
+
596
+ this._seq = meta.seq >>> 0;
597
+ this._handoffMapTimeLeft = meta.mapTimeLeft;
598
+
599
+ for (const record of meta.humans) {
600
+ const user = this._participants.restoreHuman(record);
601
+
602
+ if (!user) {
603
+ continue;
604
+ }
605
+
606
+ // хендшейк уже пройден в старом Worker'е (переносятся только isReady)
607
+ user.isReady = true;
608
+ user.currentMap = meta.currentMap;
609
+
610
+ this._chat.addUser(user.gameId);
611
+ this._vote.addUser(user.gameId);
612
+ this._panel.addUser(user.gameId);
613
+ this._RTTManager.addUser(user.gameId);
614
+ }
615
+
616
+ for (const record of meta.scripted) {
617
+ const participant = this._participants.restoreScripted(record);
618
+
619
+ if (participant) {
620
+ this._panel.addUser(participant.gameId);
621
+ }
622
+ }
623
+
624
+ // счёт переезжает целиком; строки не переживших эстафету (не завершили
625
+ // хендшейк в старом Worker'е) вычищаются — клиенты получат обновление
626
+ const keepIds = new Set(this._participants.getAll().map(p => p.gameId));
627
+
628
+ this._stat.restore(meta.stat, keepIds);
629
+
630
+ this._roundManager.restoreMap(meta.currentMap);
631
+ this._handoffRestored = true;
632
+ }
633
+
634
+ // меняет и возвращает gameId наблюдаемого игрока
635
+ _getNextActivePlayerForUser(gameId, back) {
636
+ const currentId = this._participants.get(gameId)?.watchedGameId;
637
+ const activeList = this._participants.getActiveList();
638
+ let key = activeList.indexOf(currentId);
639
+
640
+ if (key !== -1) {
641
+ key = back ? key - 1 : key + 1;
642
+
643
+ if (key < 0) {
644
+ key = activeList.length - 1;
645
+ } else if (key >= activeList.length) {
646
+ key = 0;
647
+ }
648
+
649
+ return activeList[key];
650
+ }
651
+
652
+ return activeList[0] || null;
653
+ }
654
+
655
+ // активирует тряску камеры у игрока (из события ядра)
656
+ triggerCameraShake(gameId, shakeParams) {
657
+ const user = this._participants.get(gameId);
658
+
659
+ if (user) {
660
+ user.pendingShake = `${shakeParams.intensity}:${shakeParams.duration}`;
661
+ }
662
+ }
663
+
664
+ // освобождает слот под человека: если суммарный лимит (люди + scripted)
665
+ // выбран, кикается один scripted-участник — из команды, где их больше всего
666
+ _freeSlotForHuman() {
667
+ if (!this._participants.isFull) {
668
+ return;
669
+ }
670
+
671
+ const counts = this._scripted.getCountsPerTeam();
672
+ const team = Object.keys(counts).sort((a, b) => counts[b] - counts[a])[0];
673
+
674
+ if (team) {
675
+ this._scripted.removeOneForHuman(team);
676
+ }
677
+ }
678
+
679
+ // создаёт нового игрока
680
+ createUser(params, socketId, cb) {
681
+ this._freeSlotForHuman();
682
+
683
+ const gameId = this._participants.createHuman(params, socketId);
684
+ const name = this._participants.get(gameId).name;
685
+
686
+ this._chat.addUser(gameId);
687
+ this._vote.addUser(gameId);
688
+ this._stat.addUser(gameId, this._spectatorId, { name });
689
+ this._panel.addUser(gameId);
690
+ this._RTTManager.addUser(gameId);
691
+
692
+ // подгрузка rank/state с мастера (Этап B4) — асинхронно, не блокирует
693
+ // вход; сбой auth-сервиса оставляет участника с дефолтами
694
+ this._playerDataSync.load(gameId, params.token);
695
+
696
+ queueMicrotask(() => {
697
+ cb(gameId);
698
+ });
699
+ }
700
+
701
+ // удаляет игрока полностью из игры
702
+ removeUser(gameId) {
703
+ const user = this._participants.get(gameId);
704
+
705
+ if (!user) {
706
+ return;
707
+ }
708
+
709
+ const { team, teamId } = user;
710
+
711
+ this._RTTManager.removeUser(gameId);
712
+ this._stat.removeUser(gameId, teamId);
713
+ this._chat.removeUser(gameId);
714
+ this._vote.removeUser(gameId);
715
+ this._panel.removeUser(gameId);
716
+
717
+ // финальная синхронизация rank/state перед уходом участника (Этап B4)
718
+ this._playerDataSync
719
+ .flush(gameId)
720
+ .catch(() => {})
721
+ .finally(() => this._playerDataSync.removeUser(gameId));
722
+
723
+ // если не наблюдатель — удалить танк из ядра (null-маркер ставит ядро)
724
+ if (team !== this._spectatorTeam) {
725
+ this._game.removePlayer(gameId);
726
+ }
727
+
728
+ this._participants.remove(gameId);
729
+
730
+ this._chat.pushSystem('USER_LEFT', [user.name]);
731
+ }
732
+
733
+ // обновляет команды (формат wire: 'seq:action:name')
734
+ updateKeys(gameId, keyStr) {
735
+ const user = this._participants.get(gameId);
736
+
737
+ if (!user) {
738
+ return;
739
+ }
740
+
741
+ const [seq, action, name] = keyStr.split(':');
742
+
743
+ user.lastActionTime = Date.now();
744
+ user.lastInputSeq = Number(seq) >>> 0;
745
+
746
+ if (user.isWatching === true) {
747
+ if (action === 'down') {
748
+ if (name === this._spectatorKeys.nextPlayer) {
749
+ user.watchedGameId = this._getNextActivePlayerForUser(gameId);
750
+ user.forceCameraReset = true;
751
+ } else if (name === this._spectatorKeys.prevPlayer) {
752
+ user.watchedGameId = this._getNextActivePlayerForUser(gameId, true);
753
+ user.forceCameraReset = true;
754
+ }
755
+ }
756
+ } else {
757
+ this._game.applyInput(gameId, user.lastInputSeq, action, name);
758
+ }
759
+ }
760
+
761
+ // добавляет сообщение
762
+ pushMessage(gameId, message) {
763
+ const user = this._participants.get(gameId);
764
+
765
+ if (!user || user.isReady === false) {
766
+ return;
767
+ }
768
+
769
+ user.lastActionTime = Date.now();
770
+
771
+ message = sanitizeMessage(message);
772
+
773
+ if (message.length > this._chatMaxLength) {
774
+ message = message.slice(0, this._chatMaxLength);
775
+ }
776
+
777
+ if (message) {
778
+ if (message.charAt(0) === '/') {
779
+ this._commandProcessor.parseCommand(gameId, message);
780
+ } else {
781
+ this._chat.push(message, user.name, user.teamId);
782
+ }
783
+ }
784
+ }
785
+
786
+ // обрабатывает vote-данные пользователя
787
+ parseVote(gameId, data) {
788
+ const user = this._participants.get(gameId);
789
+
790
+ if (!user || user.isReady === false) {
791
+ return;
792
+ }
793
+
794
+ user.lastActionTime = Date.now();
795
+
796
+ if (typeof data === 'string') {
797
+ if (data === 'teams') {
798
+ this._vote.pushByUser(gameId, Object.keys(this._teams));
799
+ } else if (data === 'maps') {
800
+ this._vote.pushByUser(
801
+ gameId,
802
+ this._mapList.filter(map => map !== this._roundManager.currentMap),
803
+ );
804
+ }
805
+ } else if (typeof data === 'object' && data !== null) {
806
+ const [type, value] = data;
807
+
808
+ if (type === 'mapChange') {
809
+ if (this._participants.getHumans().length === 1) {
810
+ this._roundManager.forceChangeMap(value);
811
+ } else {
812
+ this._roundManager.changeMap(gameId, value);
813
+ }
814
+ } else if (type === 'teamChange') {
815
+ this._roundManager.changeTeam(gameId, value);
816
+ } else {
817
+ this._vote.addInVote(type, value);
818
+ this._chat.pushSystemByUser(gameId, 'VOTE_ACCEPTED');
819
+ }
820
+ }
821
+ }
822
+
823
+ // rank/state участника (Этап B4) — для игровых модулей и чат-команды /rank
824
+ // (Этап B5, CommandProcessor)
825
+ getPlayerRank(gameId) {
826
+ return this._playerDataSync.getRank(gameId);
827
+ }
828
+
829
+ getPlayerState(gameId) {
830
+ return this._playerDataSync.getState(gameId);
831
+ }
832
+
833
+ setPlayerState(gameId, state) {
834
+ this._playerDataSync.setState(gameId, state);
835
+ }
836
+
837
+ // hostId + per-room секрет комнаты, подтверждённые мастером при
838
+ // register_host (кодревью №1) — не известны при создании HostGame (Worker
839
+ // стартует раньше ответа мастера); нужны PlayerDataSync для атрибуции
840
+ // rank/state-flush (секрет доказывает мастеру владение комнатой)
841
+ setHostId(hostId, hostSecret) {
842
+ this._playerDataSync.setHostId(hostId, hostSecret);
843
+ }
844
+
845
+ // обновляет значение round trip time
846
+ updateRTT(gameId, pingId) {
847
+ const latency = this._RTTManager.handlePong(gameId, pingId);
848
+
849
+ if (latency !== null) {
850
+ const user = this._participants.get(gameId);
851
+
852
+ if (user) {
853
+ this._stat.updateUser(gameId, user.teamId, { latency });
854
+ }
855
+ }
856
+ }
857
+ }