vimp-engine 0.35.6 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/vimp-sim.js +19 -1
- package/core/Cargo.toml +1 -1
- package/package.json +1 -1
- package/src/client/components/controller/Controls.js +5 -0
- package/src/client/components/controller/Lobby.js +4 -4
- package/src/client/components/controller/RoomMenu.js +43 -0
- package/src/client/components/controller/Vote.js +18 -2
- package/src/client/components/model/Chat.js +19 -2
- package/src/client/components/model/ClientReports.js +6 -2
- package/src/client/components/model/Controls.js +27 -0
- package/src/client/components/model/Games.js +5 -1
- package/src/client/components/model/Lobby.js +32 -24
- package/src/client/components/model/LobbyAuth.js +20 -4
- package/src/client/components/model/RoomMenu.js +121 -0
- package/src/client/components/model/Stat.js +2 -1
- package/src/client/components/model/Vote.js +122 -4
- package/src/client/components/view/Auth.js +20 -10
- package/src/client/components/view/ClientReports.js +16 -5
- package/src/client/components/view/Games.js +39 -14
- package/src/client/components/view/Lobby.js +90 -43
- package/src/client/components/view/LobbyAuth.js +13 -7
- package/src/client/components/view/Panel.js +1 -2
- package/src/client/components/view/RoomMenu.js +69 -0
- package/src/client/debug.js +24 -1
- package/src/client/lib/FpsMeter.js +46 -0
- package/src/client/lib/JoinRetry.js +73 -0
- package/src/client/lib/diagnostics.js +28 -5
- package/src/client/lib/hostCaps.js +82 -0
- package/src/client/lib/hostUnloadGuard.js +108 -0
- package/src/client/lib/hostVoteCommand.js +92 -0
- package/src/client/lib/pageReload.js +29 -0
- package/src/client/lib/roomLink.js +257 -0
- package/src/client/lib/signalingErrors.js +74 -0
- package/src/client/main.js +676 -451
- package/src/client/network/HiddenHostHealthLog.js +76 -0
- package/src/client/network/HostConnectionManager.js +62 -8
- package/src/client/network/HostController.js +376 -23
- package/src/client/network/HostHealthPolicy.js +307 -0
- package/src/client/network/HostHealthReporter.js +54 -0
- package/src/client/network/HostPrewarm.js +235 -0
- package/src/client/network/LoopbackTransport.js +16 -1
- package/src/client/network/PlannedHandoff.js +366 -0
- package/src/client/network/Promotion.js +420 -0
- package/src/client/network/RoomPeersReporter.js +81 -0
- package/src/client/network/SessionSupervisor.js +730 -0
- package/src/client/network/SignalingClient.js +183 -30
- package/src/client/network/StandbyReceiver.js +287 -0
- package/src/client/network/StandbySender.js +319 -0
- package/src/client/network/TokenHandoffTimer.js +81 -0
- package/src/client/network/WebRtcManager.js +338 -11
- package/src/client/network/policyClose.js +32 -2
- package/src/client/network/standbyChunks.js +185 -0
- package/src/client/session/GuestSession.js +428 -0
- package/src/client/session/HandoffFlow.js +399 -0
- package/src/client/session/HostRole.js +708 -0
- package/src/client/session/Membership.js +179 -0
- package/src/client/session/PromotionFlow.js +325 -0
- package/src/client/session/RouteBoot.js +432 -0
- package/src/client/session/StandbyRole.js +195 -0
- package/src/client/session/hostRoomPrep.js +152 -0
- package/src/client/session/roomContext.js +32 -0
- package/src/client/style.css +83 -22
- package/src/client/views/gameShell.js +7 -0
- package/src/client/views/includes/informer.pug +4 -0
- package/src/client/views/includes/lobby.pug +0 -3
- package/src/client/views/includes/roomMenu.pug +9 -0
- package/src/client/views/index.pug +1 -0
- package/src/config/chatMessages.js +45 -0
- package/src/config/closeCodes.js +1 -1
- package/src/config/env.js +37 -0
- package/src/config/hostDefaults.js +24 -0
- package/src/config/lobby.js +154 -3
- package/src/config/master.js +104 -21
- package/src/config/wsports.js +8 -0
- package/src/devtools/RecordingSocketManager.js +3 -2
- package/src/devtools/ScenarioRunner.js +123 -8
- package/src/devtools/VirtualClient.js +20 -0
- package/src/devtools/contract/format.js +4 -1
- package/src/devtools/contract/index.js +1 -2
- package/src/devtools/contract/rules/a2-package-scripts.js +1 -3
- package/src/devtools/contract/rules/b8-system-messages.js +2 -2
- package/src/devtools/contract/rules/b9-vote-names.js +25 -1
- package/src/devtools/contract/rules/c11-stat-mode.js +4 -1
- package/src/devtools/contract/rules/c6-stat-columns.js +1 -1
- package/src/devtools/contract/rules/c7-key-sets.js +8 -4
- package/src/devtools/contract/rules/d3-snapshot-interp.js +2 -1
- package/src/devtools/contract/rules/e1-sound-pairs.js +3 -1
- package/src/devtools/contract/rules/e4-map-layers.js +13 -2
- package/src/devtools/contract/rules/e5-map-radar-walls.js +7 -1
- package/src/devtools/contract/rules/index.js +38 -5
- package/src/devtools/invariants.js +10 -4
- package/src/devtools/pluginLoader.js +1 -4
- package/src/devtools/report.js +17 -1
- package/src/host/DebugRecorder.js +6 -1
- package/src/host/GameCoreAdapter.js +45 -0
- package/src/host/HostGame.js +928 -59
- package/src/host/PortMachine.js +268 -20
- package/src/host/host.worker.js +222 -16
- package/src/host/meta/SocketManager.js +20 -0
- package/src/host/meta/core/RoundManager.js +89 -14
- package/src/host/meta/modules/Accolades.js +4 -1
- package/src/host/meta/modules/Panel.js +30 -0
- package/src/host/meta/modules/PlayerDataSync.js +255 -46
- package/src/host/meta/modules/RTTManager.js +34 -0
- package/src/host/meta/modules/TimerManager.js +215 -18
- package/src/host/meta/modules/chat/systemMessages.js +29 -3
- package/src/host/meta/player/HumanParticipant.js +10 -0
- package/src/host/meta/player/ParticipantManager.js +98 -5
- package/src/lib/AbstractTimer.js +63 -1
- package/src/lib/Publisher.js +26 -0
- package/src/lib/capabilities.js +5 -0
- package/src/lib/checkpointCodec.js +125 -0
- package/src/lib/clientIp.js +9 -2
- package/src/lib/createHostRuntime.js +95 -6
- package/src/lib/formOptions.js +3 -1
- package/src/lib/gameConfigView.js +10 -0
- package/src/lib/gameId.js +13 -0
- package/src/lib/jwt.js +3 -1
- package/src/lib/loadGamePackage.js +2 -2
- package/src/lib/resumeKey.js +38 -0
- package/src/lib/roomId.js +39 -0
- package/src/lib/roomSettings.js +59 -0
- package/src/lib/validators.js +7 -0
- package/tests/fixtures/generations/gen-api3/host/ScriptedManager.js +2 -3
- package/tests/fixtures/generations/gen-api3/host/fakeCore.js +31 -5
- package/tests/fixtures/generations/gen-api3/manifest.json +1 -3
- package/tests/fixtures/generations/gen-api4/host/ScriptedManager.js +2 -3
- package/tests/fixtures/generations/gen-api4/host/fakeCore.js +31 -5
- package/tests/fixtures/generations/gen-api4/manifest.json +1 -3
- package/tests/fixtures/miniGame/config/client.js +0 -20
- package/tests/fixtures/miniGame/host/ScriptedManager.js +2 -3
- package/tests/fixtures/miniGame/host/fakeCore.js +31 -5
package/src/client/main.js
CHANGED
|
@@ -50,6 +50,7 @@ import { dispatchSocketMessage } from './lib/socketDispatch.js';
|
|
|
50
50
|
import { pickActiveGame, isGameAvailable } from './lib/pickActiveGame.js';
|
|
51
51
|
import { readCoreAbi, dispatchCoreOp, ABI_UNKNOWN } from '../lib/coreAbi.js';
|
|
52
52
|
import { ABI_OP_DEBUG_JSON } from '../config/abiOps.js';
|
|
53
|
+
import engineChatMessages from '../config/chatMessages.js';
|
|
53
54
|
import { createDebugApi, debugLog, DEBUG_PREFIX } from './debug.js';
|
|
54
55
|
import { buildClientCoreConfig } from '../lib/clientCoreConfig.js';
|
|
55
56
|
import { buildSnapshotKeysById } from '../lib/reconstructHot.js';
|
|
@@ -59,14 +60,17 @@ import { sanitizeMessage } from '../lib/sanitizers.js';
|
|
|
59
60
|
import { validateAuth } from '../lib/validators.js';
|
|
60
61
|
import SoundManager from './SoundManager.js';
|
|
61
62
|
import SignalingClient from './network/SignalingClient.js';
|
|
62
|
-
import WebRtcManager from './network/WebRtcManager.js';
|
|
63
|
-
import HostController from './network/HostController.js';
|
|
64
|
-
import HostConnectionManager from './network/HostConnectionManager.js';
|
|
65
63
|
import LoopbackTransport from './network/LoopbackTransport.js';
|
|
66
64
|
import WebSocketTransport from './network/WebSocketTransport.js';
|
|
67
65
|
import InlineHostBridge from './network/InlineHostBridge.js';
|
|
68
|
-
import {
|
|
66
|
+
import SessionSupervisor, {
|
|
67
|
+
SESSION_STATES,
|
|
68
|
+
} from './network/SessionSupervisor.js';
|
|
69
|
+
import FpsMeter from './lib/FpsMeter.js';
|
|
70
|
+
import HostUnloadGuard from './lib/hostUnloadGuard.js';
|
|
71
|
+
import { createPageReload } from './lib/pageReload.js';
|
|
69
72
|
import {
|
|
73
|
+
isKickClose,
|
|
70
74
|
POLICY_CLOSE_INFORMS,
|
|
71
75
|
shouldReloadAfterClose,
|
|
72
76
|
} from './network/policyClose.js';
|
|
@@ -79,13 +83,38 @@ import LobbyAuthCtrl from './components/controller/LobbyAuth.js';
|
|
|
79
83
|
import GamesModel from './components/model/Games.js';
|
|
80
84
|
import GamesView from './components/view/Games.js';
|
|
81
85
|
import GamesCtrl from './components/controller/Games.js';
|
|
86
|
+
import { createRoomContext } from './session/roomContext.js';
|
|
87
|
+
import { createHostRoomPrep } from './session/hostRoomPrep.js';
|
|
88
|
+
import Membership from './session/Membership.js';
|
|
89
|
+
import StandbyRole from './session/StandbyRole.js';
|
|
90
|
+
import HostRole from './session/HostRole.js';
|
|
91
|
+
import HandoffFlow from './session/HandoffFlow.js';
|
|
92
|
+
import PromotionFlow from './session/PromotionFlow.js';
|
|
93
|
+
import GuestSession from './session/GuestSession.js';
|
|
94
|
+
import RouteBoot from './session/RouteBoot.js';
|
|
82
95
|
import ClientReportsModel from './components/model/ClientReports.js';
|
|
83
96
|
import ClientReportsView from './components/view/ClientReports.js';
|
|
84
97
|
import ClientReportsCtrl from './components/controller/ClientReports.js';
|
|
98
|
+
import RoomMenuModel from './components/model/RoomMenu.js';
|
|
99
|
+
import RoomMenuView from './components/view/RoomMenu.js';
|
|
100
|
+
import RoomMenuCtrl from './components/controller/RoomMenu.js';
|
|
101
|
+
import {
|
|
102
|
+
absoluteLink,
|
|
103
|
+
decideExitRoute,
|
|
104
|
+
formatGameLink,
|
|
105
|
+
formatRoomLink,
|
|
106
|
+
setRoute,
|
|
107
|
+
} from './lib/roomLink.js';
|
|
108
|
+
import {
|
|
109
|
+
CHANGE_HOST_VOTE,
|
|
110
|
+
answerValue,
|
|
111
|
+
parseChangeHost,
|
|
112
|
+
} from './lib/hostVoteCommand.js';
|
|
85
113
|
import BakingProvider from './providers/BakingProvider.js';
|
|
86
114
|
import DependencyProvider from './providers/DependencyProvider.js';
|
|
87
115
|
import wsports from '../config/wsports.js';
|
|
88
116
|
import GAME_CODES from '../config/gameCodes.js';
|
|
117
|
+
import { buildSystemMessage } from '../host/meta/modules/chat/systemMessages.js';
|
|
89
118
|
import {
|
|
90
119
|
fetchGamesManifest,
|
|
91
120
|
fetchGameManifest as fetchGamePluginManifest,
|
|
@@ -154,7 +183,9 @@ const isLobbyMode = bootMode === 'lobby';
|
|
|
154
183
|
// свой бокс; SDK (solo) — только если встраивающий передал reportUrl
|
|
155
184
|
const diagnostics = createDiagnostics({
|
|
156
185
|
url:
|
|
157
|
-
bootMode === 'solo'
|
|
186
|
+
bootMode === 'solo'
|
|
187
|
+
? (boot.reportUrl ?? null)
|
|
188
|
+
: lobbyConfig.clientReportUrl,
|
|
158
189
|
context: {
|
|
159
190
|
mode: bootMode,
|
|
160
191
|
role: 'client',
|
|
@@ -253,6 +284,8 @@ const PS_CHAT_DATA = wsports.server.CHAT_DATA;
|
|
|
253
284
|
const PS_VOTE_DATA = wsports.server.VOTE_DATA;
|
|
254
285
|
const PS_KEYSET_DATA = wsports.server.KEYSET_DATA;
|
|
255
286
|
const PS_ACCOLADES_DATA = wsports.server.ACCOLADES_DATA;
|
|
287
|
+
const PS_SESSION_DATA = wsports.server.SESSION_DATA;
|
|
288
|
+
const PS_RESUME_RESULT = wsports.server.RESUME_RESULT;
|
|
256
289
|
|
|
257
290
|
// PC (client ports): порты получения данных от клиента
|
|
258
291
|
const PC_CONFIG_READY = wsports.client.CONFIG_READY;
|
|
@@ -264,6 +297,7 @@ const PC_KEYS_DATA = wsports.client.KEYS_DATA;
|
|
|
264
297
|
const PC_CHAT_DATA = wsports.client.CHAT_DATA;
|
|
265
298
|
const PC_VOTE_DATA = wsports.client.VOTE_DATA;
|
|
266
299
|
const PC_PONG = wsports.client.PONG;
|
|
300
|
+
const PC_LEAVE = wsports.client.LEAVE;
|
|
267
301
|
|
|
268
302
|
// сигнальный WebSocket мастера (лобби + установка P2P); игровой трафик идёт
|
|
269
303
|
// по WebRTC (transport), не через мастер
|
|
@@ -273,12 +307,14 @@ const signaling = isLobbyMode
|
|
|
273
307
|
? new SignalingClient(`${wsProtocol}//${location.host}/`)
|
|
274
308
|
: null;
|
|
275
309
|
|
|
276
|
-
//
|
|
277
|
-
|
|
310
|
+
// супервизор сессии (host-migration этап 4): держит текущий транспорт к
|
|
311
|
+
// хосту и переживает его обрыв — переподключается и возвращает место в
|
|
312
|
+
// матче. Создаётся при входе в комнату (или в solo/dedicated-матч)
|
|
313
|
+
let supervisor = null;
|
|
278
314
|
|
|
279
|
-
//
|
|
280
|
-
|
|
281
|
-
|
|
315
|
+
// оверлей «Reconnecting…» на время переподключения (только лобби-режим)
|
|
316
|
+
const sessionOverlay = document.getElementById('session-overlay');
|
|
317
|
+
const sessionOverlayText = document.getElementById('session-overlay-text');
|
|
282
318
|
|
|
283
319
|
const modules = {};
|
|
284
320
|
|
|
@@ -332,6 +368,9 @@ const GAME_ROUND_START_CODE = GAME_CODES.roundStart[0];
|
|
|
332
368
|
// показан ли терминальный tech-код (кик, полная комната): причина закрытия
|
|
333
369
|
// соединения важнее общего сообщения handleDisconnect
|
|
334
370
|
let terminalInformShown = false;
|
|
371
|
+
// ключ последнего терминального tech-кода: по нему лобби-режим отличает кик
|
|
372
|
+
// (policyClose.isKickClose) от закрытия комнаты — в P2P кода закрытия нет
|
|
373
|
+
let terminalTechKey = null;
|
|
335
374
|
// снятие предыдущего animationend-листенера логотипа при повторном
|
|
336
375
|
// playLogoRoundStart — иначе листенеры копятся при частых стартах раунда
|
|
337
376
|
let logoAnimationEndHandler = null;
|
|
@@ -342,6 +381,10 @@ let entitiesOnCanvas = {}; // сущности, отображаемые на п
|
|
|
342
381
|
let currentMapSetId; // текущий id набора конструкторов для карт
|
|
343
382
|
const socketMethods = []; // методы для обработки сокет-данных
|
|
344
383
|
|
|
384
|
+
// модули матча запущены (AUTH_RESULT без ошибки): повторный успешный
|
|
385
|
+
// AUTH_RESULT в живой сессии — ошибка протокола
|
|
386
|
+
let sessionStarted = false;
|
|
387
|
+
|
|
345
388
|
// клиентское ядро (WASM, срез 2.6): интерполяция снапшотов, предикт своего
|
|
346
389
|
// танка, визуальный спавн выстрелов и распаковка кадров v3 — создаётся при
|
|
347
390
|
// получении конфига; wasm — результат init() для zero-copy чтения памяти
|
|
@@ -371,6 +414,13 @@ let snapshotKeysById = null;
|
|
|
371
414
|
|
|
372
415
|
// config data
|
|
373
416
|
socketMethods[PS_CONFIG_DATA] = async data => {
|
|
417
|
+
// повторное рукопожатие в живой сессии (например, хост не понял resume)
|
|
418
|
+
// построило бы второе ядро и второй Application поверх первых
|
|
419
|
+
if (clientCore) {
|
|
420
|
+
handleProtocolError('CONFIG_DATA');
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
|
|
374
424
|
gameSets = data.parts.gameSets;
|
|
375
425
|
entitiesOnCanvas = data.parts.entitiesOnCanvas;
|
|
376
426
|
|
|
@@ -587,6 +637,12 @@ socketMethods[PS_AUTH_RESULT] = async err => {
|
|
|
587
637
|
}
|
|
588
638
|
|
|
589
639
|
if (!err) {
|
|
640
|
+
if (sessionStarted) {
|
|
641
|
+
handleProtocolError('AUTH_RESULT');
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
sessionStarted = true;
|
|
590
646
|
await soundManager.init(soundData);
|
|
591
647
|
runModules(modulesConfig);
|
|
592
648
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
@@ -599,13 +655,33 @@ socketMethods[PS_AUTH_RESULT] = async err => {
|
|
|
599
655
|
}
|
|
600
656
|
}
|
|
601
657
|
|
|
658
|
+
roomMenu?.setInPanel(initIdList.includes('panel'));
|
|
659
|
+
|
|
602
660
|
sending(PC_MODULES_READY);
|
|
661
|
+
|
|
662
|
+
// вход в комнату состоялся — мастер узнаёт состав комнаты от участника
|
|
663
|
+
// (хост — участник своей комнаты с register_host)
|
|
664
|
+
if (roomCtx.roomId && !hostRole.controller) {
|
|
665
|
+
membership.sendJoinRoom();
|
|
666
|
+
}
|
|
603
667
|
}
|
|
604
668
|
};
|
|
605
669
|
|
|
606
670
|
// map data
|
|
607
671
|
socketMethods[PS_MAP_DATA] = data => {
|
|
608
672
|
lastMapData = data;
|
|
673
|
+
|
|
674
|
+
// карта пакета возобновления (host-migration): участник уже в матче —
|
|
675
|
+
// это не загрузка (setLoading закончил бы пакет), MAP_READY хост не ждёт.
|
|
676
|
+
// Признак — метка хоста, не supervisor.resuming: неготовому участнику
|
|
677
|
+
// (карта сменилась за паузу) хост в том же окне шлёт обычную загрузку
|
|
678
|
+
if (data?.resume === true) {
|
|
679
|
+
applyMapData(data, { notifyHost: false });
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// хост законно молчит до первого кадра новой карты (сторожок тишины)
|
|
684
|
+
supervisor?.setLoading(true);
|
|
609
685
|
applyMapData(data);
|
|
610
686
|
};
|
|
611
687
|
|
|
@@ -751,9 +827,17 @@ socketMethods[PS_FIRST_SHOT_DATA] = data => {
|
|
|
751
827
|
const [game, camera] = data;
|
|
752
828
|
|
|
753
829
|
applyShot(game, camera);
|
|
830
|
+
supervisor?.setLoading(false);
|
|
831
|
+
|
|
832
|
+
// кадр пакета возобновления (host-migration этап 4): участник уже в
|
|
833
|
+
// матче — подтверждать нечего, разовый автостарт уже отработал
|
|
834
|
+
if (supervisor?.resuming) {
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
754
837
|
|
|
755
838
|
// подтверждение получения первого шота
|
|
756
839
|
sending(PC_FIRST_SHOT_READY);
|
|
840
|
+
supervisor?.enterGame();
|
|
757
841
|
|
|
758
842
|
// solo: выход из наблюдателей и чат-команды игры (боты) — см. autostart.js
|
|
759
843
|
runAutostart();
|
|
@@ -877,6 +961,7 @@ socketMethods[PS_TECH_INFORM_DATA] = data => {
|
|
|
877
961
|
// терминальные коды (кик, полная комната) — причина закрытия соединения,
|
|
878
962
|
// последующий handleDisconnect не должен затирать её общим сообщением
|
|
879
963
|
terminalInformShown = key !== TECH_LOADING_CODE;
|
|
964
|
+
terminalTechKey = terminalInformShown ? key : null;
|
|
880
965
|
} else {
|
|
881
966
|
message = data;
|
|
882
967
|
}
|
|
@@ -887,6 +972,7 @@ socketMethods[PS_TECH_INFORM_DATA] = data => {
|
|
|
887
972
|
} else {
|
|
888
973
|
modules.controls?.enableKeys();
|
|
889
974
|
terminalInformShown = false;
|
|
975
|
+
terminalTechKey = null;
|
|
890
976
|
techInformer.textContent = '';
|
|
891
977
|
techInformer.style.display = 'none';
|
|
892
978
|
}
|
|
@@ -929,6 +1015,21 @@ socketMethods[PS_CLEAR] = function (setIdList) {
|
|
|
929
1015
|
soundManager.reset();
|
|
930
1016
|
};
|
|
931
1017
|
|
|
1018
|
+
// session data: секрет места в матче (host-migration этап 4) — с ним
|
|
1019
|
+
// супервизор вернётся после обрыва. Последнее сообщение пакета
|
|
1020
|
+
// возобновления: набор клавиш в пакете уже пришёл, и удерживаемые клавиши,
|
|
1021
|
+
// которые хост отпустил на время паузы, можно нажать снова
|
|
1022
|
+
socketMethods[PS_SESSION_DATA] = data => {
|
|
1023
|
+
if (supervisor?.setSession(data ?? {})) {
|
|
1024
|
+
modules.controls?.resendHeld();
|
|
1025
|
+
}
|
|
1026
|
+
};
|
|
1027
|
+
|
|
1028
|
+
// resume result: ответ хоста на RESUME_REQUEST
|
|
1029
|
+
socketMethods[PS_RESUME_RESULT] = data => {
|
|
1030
|
+
supervisor?.resumeResult(data);
|
|
1031
|
+
};
|
|
1032
|
+
|
|
932
1033
|
// console: логи авторитетной половины (Worker изолирован от DevTools вкладки —
|
|
933
1034
|
// иначе его события в браузере не видны вовсе, этап 6 плана plan/done/ai-debug)
|
|
934
1035
|
socketMethods[PS_CONSOLE] = data => {
|
|
@@ -1047,6 +1148,7 @@ function runModules(data) {
|
|
|
1047
1148
|
cacheMin: chatData.params.cacheMin,
|
|
1048
1149
|
cacheMax: chatData.params.cacheMax,
|
|
1049
1150
|
messages: chatData.params.messages,
|
|
1151
|
+
defaultMessages: engineChatMessages,
|
|
1050
1152
|
sanitizeMessage,
|
|
1051
1153
|
formatMessage,
|
|
1052
1154
|
});
|
|
@@ -1183,7 +1285,7 @@ function runModules(data) {
|
|
|
1183
1285
|
sending(PC_KEYS_DATA, `${inputSeq}:aim:${wx}:${wy}:${flags}`);
|
|
1184
1286
|
});
|
|
1185
1287
|
chatModel.publisher.on('socket', handleChatSend);
|
|
1186
|
-
voteModel.publisher.on('socket',
|
|
1288
|
+
voteModel.publisher.on('socket', handleVoteSend);
|
|
1187
1289
|
|
|
1188
1290
|
//==========================================//
|
|
1189
1291
|
// Рендер-цикл интерполяции
|
|
@@ -1212,44 +1314,47 @@ function openMode(mode) {
|
|
|
1212
1314
|
// reliable=false — по ненадёжному state-каналу (только pong: замер RTT
|
|
1213
1315
|
// должен отражать сетевой путь, а не reliable-поток с ретрансмиссиями)
|
|
1214
1316
|
function sending(name, data, reliable = true) {
|
|
1215
|
-
|
|
1317
|
+
supervisor?.send(JSON.stringify([name, data]), reliable);
|
|
1216
1318
|
}
|
|
1217
1319
|
|
|
1218
|
-
//
|
|
1219
|
-
//
|
|
1220
|
-
// голос против самого себя (server-rating этап 2, замена /ban). Причина
|
|
1221
|
-
// обязательна (публично не отображается). Остальной чат уходит хосту
|
|
1320
|
+
// отправка сообщения чата хосту (точка перехвата команд, адресованных
|
|
1321
|
+
// мастеру, а не хосту)
|
|
1222
1322
|
function handleChatSend(message) {
|
|
1223
|
-
const
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1323
|
+
const changeHost = parseChangeHost(message, bootMode);
|
|
1324
|
+
|
|
1325
|
+
if (changeHost === null) {
|
|
1326
|
+
sending(PC_CHAT_DATA, message);
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// голосование «Change host» считает мастер (этап 10): хост не должен
|
|
1331
|
+
// видеть и не может заблокировать голосование против себя
|
|
1332
|
+
const notice = key => modules.chat?.add(buildSystemMessage(key));
|
|
1333
|
+
|
|
1334
|
+
if (changeHost === 'usage') {
|
|
1335
|
+
notice('HOST_VOTE_USAGE');
|
|
1336
|
+
} else if (hostRole.controller) {
|
|
1337
|
+
notice('HOST_VOTE_IS_HOST');
|
|
1338
|
+
} else if (!signaling?.connected || !roomCtx.roomId) {
|
|
1339
|
+
notice('HOST_VOTE_OFFLINE');
|
|
1340
|
+
} else {
|
|
1341
|
+
signaling.hostVoteStart(roomCtx.roomId);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
// ответ в окне голосования: «Change host?» — мастеру, остальное — хосту
|
|
1346
|
+
function handleVoteSend(data) {
|
|
1347
|
+
if (Array.isArray(data) && data[0] === CHANGE_HOST_VOTE) {
|
|
1348
|
+
const value = answerValue(data[1]);
|
|
1349
|
+
|
|
1350
|
+
if (guest.hostVote && value && signaling?.connected) {
|
|
1351
|
+
signaling.hostVoteAnswer({ ...guest.hostVote, value });
|
|
1247
1352
|
}
|
|
1248
1353
|
|
|
1249
1354
|
return;
|
|
1250
1355
|
}
|
|
1251
1356
|
|
|
1252
|
-
sending(
|
|
1357
|
+
sending(PC_VOTE_DATA, data);
|
|
1253
1358
|
}
|
|
1254
1359
|
|
|
1255
1360
|
// распаковывает данные
|
|
@@ -1273,9 +1378,12 @@ function handleVisibilityChange() {
|
|
|
1273
1378
|
if (document.visibilityState === 'hidden') {
|
|
1274
1379
|
hiddenAt = performance.now();
|
|
1275
1380
|
soundManager.mute();
|
|
1276
|
-
// иначе включение звука при возвращении
|
|
1381
|
+
// иначе включение звука при возвращении (кроме паузы переподключения и
|
|
1382
|
+
// смены хоста — его включит возобновление сессии)
|
|
1277
1383
|
} else {
|
|
1278
|
-
|
|
1384
|
+
if (!isSessionPaused()) {
|
|
1385
|
+
soundManager.unmute();
|
|
1386
|
+
}
|
|
1279
1387
|
|
|
1280
1388
|
const hiddenMs = hiddenAt === null ? 0 : performance.now() - hiddenAt;
|
|
1281
1389
|
|
|
@@ -1422,6 +1530,11 @@ function handleMessage(data) {
|
|
|
1422
1530
|
// бинарный кадр (snapshot, порт SHOT_DATA) — в ядро: распаковка, вставка
|
|
1423
1531
|
// в буфер по seq, reconciliation предикта по player-блоку
|
|
1424
1532
|
if (data instanceof ArrayBuffer) {
|
|
1533
|
+
// бета помнит seq последнего кадра хоста (seqFloor, этап 6) — до
|
|
1534
|
+
// push_frame: ядро может забрать буфер
|
|
1535
|
+
standby.noteFrame(data);
|
|
1536
|
+
// восстановленный матч пошёл — сторожок тишины снимает фору
|
|
1537
|
+
supervisor?.noteFrame();
|
|
1425
1538
|
clientCore?.push_frame(new Uint8Array(data), performance.now());
|
|
1426
1539
|
|
|
1427
1540
|
return;
|
|
@@ -1432,11 +1545,14 @@ function handleMessage(data) {
|
|
|
1432
1545
|
dispatchSocketMessage(socketMethods, unpacking(data));
|
|
1433
1546
|
}
|
|
1434
1547
|
|
|
1435
|
-
//
|
|
1436
|
-
//
|
|
1437
|
-
//
|
|
1438
|
-
//
|
|
1548
|
+
// терминальное закрытие сессии (супервизор: возвращаться некуда — кик,
|
|
1549
|
+
// политика, уход хоста, окно переподключения истекло). Останавливаем
|
|
1550
|
+
// рендер, показываем заглушку и возвращаемся в лобби перезагрузкой.
|
|
1551
|
+
// closeCode приходит только от WebSocket-транспорта (dedicated), остальные
|
|
1552
|
+
// транспорты эмитят close без него
|
|
1439
1553
|
function handleDisconnect(closeCode) {
|
|
1554
|
+
showSessionOverlay(null);
|
|
1555
|
+
|
|
1440
1556
|
// app.stop() здесь не зовём: при sharedTicker это Ticker.shared.stop()
|
|
1441
1557
|
// глобально, а autoStart вернёт тикер к жизни при первом add() из любого
|
|
1442
1558
|
// part'а — уже без renderTick. Рендер снят строкой выше, страницу и так
|
|
@@ -1461,18 +1577,21 @@ function handleDisconnect(closeCode) {
|
|
|
1461
1577
|
soundManager.destroy();
|
|
1462
1578
|
modules.controls?.disableKeys();
|
|
1463
1579
|
|
|
1464
|
-
//
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1580
|
+
// гость выбыл из матча (кик уводит на главную без перезагрузки — сессия
|
|
1581
|
+
// сигналинга жива): снять членство, иначе вкладка «призраком» числится в
|
|
1582
|
+
// комнате. Best-effort; хост комнату так не покидает — его снимает мастер
|
|
1583
|
+
if (isLobbyMode && roomCtx.roomId && !hostRole.controller) {
|
|
1584
|
+
signaling.leaveRoom(roomCtx.roomId);
|
|
1468
1585
|
}
|
|
1469
1586
|
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1587
|
+
// если мы были хостом — гасим комнату: heartbeat, WebRTC-пиры, Worker
|
|
1588
|
+
promotionFlow.cancel();
|
|
1589
|
+
hostRole.teardown();
|
|
1590
|
+
standby.teardown();
|
|
1591
|
+
roomCtx.roomId = null;
|
|
1592
|
+
membership.forget();
|
|
1593
|
+
membership.clearTokenCapsTimer();
|
|
1594
|
+
refreshRoomControls();
|
|
1476
1595
|
|
|
1477
1596
|
// solo: хост крутится в этом же потоке — его таймеры переживут матч
|
|
1478
1597
|
inlineHost?.destroy();
|
|
@@ -1488,7 +1607,7 @@ function handleDisconnect(closeCode) {
|
|
|
1488
1607
|
socketMethods[PS_TECH_INFORM_DATA](
|
|
1489
1608
|
policyInform ??
|
|
1490
1609
|
(isLobbyMode
|
|
1491
|
-
? 'Host left — the room is closed.
|
|
1610
|
+
? 'Host left — the room is closed. Finding another room…'
|
|
1492
1611
|
: 'The match is over — connection to the host is closed.'),
|
|
1493
1612
|
);
|
|
1494
1613
|
}
|
|
@@ -1496,9 +1615,36 @@ function handleDisconnect(closeCode) {
|
|
|
1496
1615
|
// в solo перезагружаться некуда: лобби нет, а матч поднимается с нуля.
|
|
1497
1616
|
// В dedicated сервер жив — переподключение перезагрузкой уместно, но не
|
|
1498
1617
|
// тогда, когда он сам нас и отбил по политике (network/policyClose.js)
|
|
1499
|
-
if (bootMode
|
|
1500
|
-
|
|
1618
|
+
if (bootMode === 'solo' || !shouldReloadAfterClose(closeCode)) {
|
|
1619
|
+
return;
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
if (!isLobbyMode) {
|
|
1623
|
+
setTimeout(() => reloadPage(), 3000);
|
|
1624
|
+
|
|
1625
|
+
return;
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
roomMenu?.setLink(null);
|
|
1629
|
+
|
|
1630
|
+
// лобби-режим (host-migration, этап 3): кик и несостоявшийся старт комнаты
|
|
1631
|
+
// — на главную без перезагрузки (причина остаётся в #tech-informer, клик
|
|
1632
|
+
// по нему открывает лобби); закрытие комнаты — в быструю игру той же игры
|
|
1633
|
+
const exit = decideExitRoute({
|
|
1634
|
+
kicked: isKickClose(closeCode, terminalTechKey) || roomCtx.startFailed,
|
|
1635
|
+
gameId: activeGameManifest?.id,
|
|
1636
|
+
});
|
|
1637
|
+
|
|
1638
|
+
if (exit.reload === undefined) {
|
|
1639
|
+
setRoute(exit.route);
|
|
1640
|
+
techInformer.addEventListener('click', () => reloadPage(), {
|
|
1641
|
+
once: true,
|
|
1642
|
+
});
|
|
1643
|
+
|
|
1644
|
+
return;
|
|
1501
1645
|
}
|
|
1646
|
+
|
|
1647
|
+
setTimeout(() => reloadPage(exit.reload), 3000);
|
|
1502
1648
|
}
|
|
1503
1649
|
|
|
1504
1650
|
/**
|
|
@@ -1508,269 +1654,405 @@ function handleDisconnect(closeCode) {
|
|
|
1508
1654
|
* teardown у SDK нет специально — путь останова один на все режимы.
|
|
1509
1655
|
*/
|
|
1510
1656
|
export function stopGame() {
|
|
1511
|
-
|
|
1657
|
+
supervisor?.close();
|
|
1512
1658
|
}
|
|
1513
1659
|
|
|
1514
|
-
//
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1660
|
+
// ***** супервизор сессии (host-migration этап 4) ***** //
|
|
1661
|
+
|
|
1662
|
+
// берёт транспорт под надзор. reconnect — фабрика попыток переподключения
|
|
1663
|
+
// (только гость в лобби); без неё любое закрытие терминально, как раньше
|
|
1664
|
+
function startSession(transport, reconnect = null) {
|
|
1665
|
+
supervisor = new SessionSupervisor({
|
|
1666
|
+
onMessage: handleMessage,
|
|
1667
|
+
onTerminal: handleDisconnect,
|
|
1668
|
+
onStateChange: handleSessionState,
|
|
1669
|
+
onResumed: handleSessionResumed,
|
|
1670
|
+
onResumeRejected: handleResumeRejected,
|
|
1671
|
+
onHostLost: reportHostUnreachable,
|
|
1672
|
+
onColdRestart: handleColdRestart,
|
|
1673
|
+
// кик (TECH_INFORM перед закрытием) и отказы политики не лечатся
|
|
1674
|
+
// переподключением
|
|
1675
|
+
isTerminalClose: closeCode =>
|
|
1676
|
+
terminalInformShown || !shouldReloadAfterClose(closeCode),
|
|
1677
|
+
reconnect,
|
|
1678
|
+
timing: lobbyConfig.session,
|
|
1531
1679
|
});
|
|
1532
1680
|
|
|
1533
|
-
|
|
1534
|
-
'window.__vimpDebug is available: dump, startRecording, stopRecording, divergence',
|
|
1535
|
-
);
|
|
1681
|
+
supervisor.attach(transport);
|
|
1536
1682
|
}
|
|
1537
1683
|
|
|
1538
|
-
//
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
if (typeof RTCPeerConnection !== 'undefined') {
|
|
1543
|
-
return true;
|
|
1684
|
+
// оверлей сессии: text — показать с текстом, null — скрыть
|
|
1685
|
+
function showSessionOverlay(text) {
|
|
1686
|
+
if (!sessionOverlay) {
|
|
1687
|
+
return;
|
|
1544
1688
|
}
|
|
1545
1689
|
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1690
|
+
if (text) {
|
|
1691
|
+
sessionOverlayText.textContent = text;
|
|
1692
|
+
sessionOverlay.style.display = 'flex';
|
|
1693
|
+
} else {
|
|
1694
|
+
sessionOverlayText.textContent = '';
|
|
1695
|
+
sessionOverlay.style.display = 'none';
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1550
1698
|
|
|
1551
|
-
|
|
1699
|
+
// сессия на паузе: транспорта к хосту нет, мир стоит
|
|
1700
|
+
function isSessionPaused() {
|
|
1701
|
+
return (
|
|
1702
|
+
supervisor?.state === SESSION_STATES.reconnecting ||
|
|
1703
|
+
supervisor?.state === SESSION_STATES.migrating
|
|
1704
|
+
);
|
|
1552
1705
|
}
|
|
1553
1706
|
|
|
1554
|
-
//
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1707
|
+
// пауза переподключения или смены хоста: мир стоит, ввод и звук выключены
|
|
1708
|
+
// (зацикленные звуки не должны гудеть одной нотой всю паузу). Рендер не
|
|
1709
|
+
// останавливается — кадр стоит, независимые от снапшотов анимации идут
|
|
1710
|
+
function handleSessionState(state, prev) {
|
|
1711
|
+
if (state === SESSION_STATES.migrating) {
|
|
1712
|
+
showSessionOverlay('Switching host…');
|
|
1713
|
+
modules.controls?.disableKeys();
|
|
1714
|
+
soundManager.mute();
|
|
1715
|
+
} else if (state === SESSION_STATES.reconnecting) {
|
|
1716
|
+
// после смены хоста оверлей тот же до конца возобновления
|
|
1717
|
+
if (prev !== SESSION_STATES.migrating) {
|
|
1718
|
+
showSessionOverlay('Reconnecting…');
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
modules.controls?.disableKeys();
|
|
1722
|
+
soundManager.mute();
|
|
1723
|
+
} else if (state === SESSION_STATES.inGame) {
|
|
1724
|
+
showSessionOverlay(null);
|
|
1558
1725
|
}
|
|
1726
|
+
}
|
|
1559
1727
|
|
|
1560
|
-
|
|
1728
|
+
// транспорт к хосту оборвался в матче: мастер узнаёт об этом от гостя
|
|
1729
|
+
// раньше, чем сам (host-migration этап 7.2) — проба хоста или его смена.
|
|
1730
|
+
// Эпоха — хоста, к которому был транспорт: чужую мастер отбросит
|
|
1731
|
+
function reportHostUnreachable() {
|
|
1732
|
+
if (isLobbyMode) {
|
|
1733
|
+
guest.reportHostUnreachable();
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1561
1736
|
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1737
|
+
// новый хост поднял матч заново (cold): возобновлять нечего — чистый вход в
|
|
1738
|
+
// ту же комнату с полным рукопожатием
|
|
1739
|
+
function handleColdRestart() {
|
|
1740
|
+
reloadToRoom();
|
|
1741
|
+
}
|
|
1565
1742
|
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1743
|
+
// место возвращено: предсказание начинается с чистого листа (вводы,
|
|
1744
|
+
// ушедшие в оборванный транспорт, хост не получит никогда — переигрывать
|
|
1745
|
+
// их нельзя), голосование хоста паузу не пережило. Дальше хост шлёт пакет
|
|
1746
|
+
// входа: CLEAR, полный кадр, набор клавиш, SESSION_DATA
|
|
1747
|
+
function handleSessionResumed() {
|
|
1748
|
+
clientCore?.reset();
|
|
1749
|
+
// «Change host?» ведёт мастер — оно паузу переживает
|
|
1750
|
+
modules.vote?.removeHostVotes();
|
|
1751
|
+
modules.controls?.enableKeys();
|
|
1569
1752
|
|
|
1570
|
-
|
|
1753
|
+
if (document.visibilityState !== 'hidden') {
|
|
1754
|
+
soundManager.unmute();
|
|
1755
|
+
}
|
|
1571
1756
|
}
|
|
1572
1757
|
|
|
1573
|
-
//
|
|
1574
|
-
//
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
socketMethods[PS_TECH_INFORM_DATA](
|
|
1581
|
-
'This browser cannot be a host: ES module Web Workers are ' +
|
|
1582
|
-
'unsupported. You can still join existing rooms.',
|
|
1583
|
-
);
|
|
1758
|
+
// место потеряно (ожидание истекло, ключ не принят): чистый вход в ту же
|
|
1759
|
+
// комнату с полным рукопожатием
|
|
1760
|
+
function handleResumeRejected(reason) {
|
|
1761
|
+
console.warn(`[session] resume rejected: ${reason}`);
|
|
1762
|
+
showSessionOverlay(null);
|
|
1763
|
+
reloadToRoom();
|
|
1764
|
+
}
|
|
1584
1765
|
|
|
1585
|
-
|
|
1586
|
-
|
|
1766
|
+
// ссылка на текущую комнату (иначе — на быструю игру той же игры)
|
|
1767
|
+
function reloadToRoom() {
|
|
1768
|
+
const gameId = activeGameManifest?.id;
|
|
1587
1769
|
|
|
1588
|
-
if (!
|
|
1770
|
+
if (!gameId) {
|
|
1771
|
+
reloadPage('');
|
|
1589
1772
|
return;
|
|
1590
1773
|
}
|
|
1591
1774
|
|
|
1592
|
-
|
|
1593
|
-
|
|
1775
|
+
reloadPage(
|
|
1776
|
+
roomCtx.roomId
|
|
1777
|
+
? formatRoomLink(gameId, roomCtx.roomId)
|
|
1778
|
+
: formatGameLink(gameId),
|
|
1779
|
+
);
|
|
1780
|
+
}
|
|
1594
1781
|
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1782
|
+
// хост начал рукопожатие заново посреди живой сессии (порт — какой
|
|
1783
|
+
// именно). Второе ядро/Application не строим: в лобби — чистый вход в ту же
|
|
1784
|
+
// комнату, в остальных режимах только журнал
|
|
1785
|
+
function handleProtocolError(port) {
|
|
1786
|
+
diagnostics.warn('engine.session.protocol', { port }, { source: 'client' });
|
|
1598
1787
|
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
version: activeGameManifest.version,
|
|
1604
|
-
hostEntryUrl: activeGameManifest.entries.host,
|
|
1605
|
-
wasmUrl: activeGameManifest.entries.wasm,
|
|
1606
|
-
};
|
|
1788
|
+
if (isLobbyMode) {
|
|
1789
|
+
reloadToRoom();
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1607
1792
|
|
|
1608
|
-
|
|
1609
|
-
// недоступность каталога некритична — Worker возьмёт карты из бандла
|
|
1610
|
-
try {
|
|
1611
|
-
const catalog = await fetchMasterMaps();
|
|
1793
|
+
// БУТСТРАП: лобби и установка P2P через мастер-сервер
|
|
1612
1794
|
|
|
1613
|
-
|
|
1614
|
-
hostMapsVersion = catalog.version;
|
|
1615
|
-
} catch (e) {
|
|
1616
|
-
console.warn('[maps] master catalog unavailable, using bundled maps:', e);
|
|
1617
|
-
}
|
|
1795
|
+
let lobby = null;
|
|
1618
1796
|
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1797
|
+
// id вкладки — ключ участника комнаты на мастере; живёт в памяти (не в
|
|
1798
|
+
// storage): две вкладки одного профиля — два участника
|
|
1799
|
+
const memberId = isLobbyMode ? crypto.randomUUID() : null;
|
|
1800
|
+
|
|
1801
|
+
// меню комнаты внутри матча (этапы 3 и 8d) и защита вкладки хоста от
|
|
1802
|
+
// случайного закрытия (этап 8.4): только лобби-режим
|
|
1803
|
+
let roomMenu = null;
|
|
1804
|
+
let unloadGuard = null;
|
|
1805
|
+
// любая программная перезагрузка — только через неё (client/lib/pageReload.js)
|
|
1806
|
+
const reloadPage = createPageReload({ getGuard: () => unloadGuard });
|
|
1807
|
+
|
|
1808
|
+
// ***** сценарии комнаты (client/session/*): здесь только сборка ***** //
|
|
1809
|
+
|
|
1810
|
+
const isPageHidden = () => document.visibilityState === 'hidden';
|
|
1811
|
+
const informTech = text => socketMethods[PS_TECH_INFORM_DATA](text);
|
|
1812
|
+
const chatNotice = message => modules.chat?.add(message);
|
|
1813
|
+
const getSupervisor = () => supervisor;
|
|
1814
|
+
const getToken = () => lobbyAuthModel.getToken();
|
|
1815
|
+
const getTokenExpiresAt = () => lobbyAuthModel.getTokenExpiresAt();
|
|
1816
|
+
|
|
1817
|
+
// комната вкладки: id, эпоха хоста, членство, вход и несостоявшийся старт
|
|
1818
|
+
const roomCtx = createRoomContext();
|
|
1819
|
+
|
|
1820
|
+
const hostRoomPrep = createHostRoomPrep({
|
|
1821
|
+
getActiveGame: () => activeGameManifest,
|
|
1822
|
+
config: lobbyConfig,
|
|
1823
|
+
isDevBuild,
|
|
1824
|
+
fetchGamePluginManifest,
|
|
1825
|
+
});
|
|
1623
1826
|
|
|
1624
|
-
|
|
1625
|
-
|
|
1827
|
+
const membership = new Membership({
|
|
1828
|
+
signaling,
|
|
1829
|
+
ctx: roomCtx,
|
|
1830
|
+
memberId,
|
|
1831
|
+
getToken,
|
|
1832
|
+
getTokenExpiresAt,
|
|
1833
|
+
isHost: () => Boolean(hostRole.controller),
|
|
1834
|
+
config: lobbyConfig,
|
|
1835
|
+
});
|
|
1626
1836
|
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
}
|
|
1837
|
+
const standby = new StandbyRole({
|
|
1838
|
+
signaling,
|
|
1839
|
+
ctx: roomCtx,
|
|
1840
|
+
isHost: () => Boolean(hostRole.controller),
|
|
1841
|
+
prepareRoom: hostRoomPrep.prepareHostRoom,
|
|
1842
|
+
diagnostics,
|
|
1843
|
+
config: lobbyConfig.migration,
|
|
1844
|
+
debug: isDevBuild ? debugLog : null,
|
|
1845
|
+
});
|
|
1637
1846
|
|
|
1638
|
-
|
|
1847
|
+
const hostRole = new HostRole({
|
|
1848
|
+
signaling,
|
|
1849
|
+
ctx: roomCtx,
|
|
1850
|
+
membership,
|
|
1851
|
+
config: lobbyConfig,
|
|
1852
|
+
diagnostics,
|
|
1853
|
+
prep: hostRoomPrep,
|
|
1854
|
+
getActiveGame: () => activeGameManifest,
|
|
1855
|
+
getToken,
|
|
1856
|
+
getHandoff: () => handoff,
|
|
1857
|
+
onPromotionRegistered: hostPromotion => promotionFlow.finish(hostPromotion),
|
|
1858
|
+
isLobbyMode,
|
|
1859
|
+
debug: isDevBuild ? debugLog : null,
|
|
1860
|
+
ui: {
|
|
1861
|
+
showRoomLink,
|
|
1862
|
+
refreshRoomControls,
|
|
1863
|
+
setHandoffMenu: state => roomMenu?.setHandoff(state),
|
|
1864
|
+
isHidden: isPageHidden,
|
|
1865
|
+
informTech,
|
|
1866
|
+
ensureWebRtc: ensureWebRtcAvailable,
|
|
1867
|
+
startSession: transport => startSession(transport),
|
|
1868
|
+
closeLobby: () => lobby?.close(),
|
|
1869
|
+
// Worker не поднялся (WASM/конфиг): гасим комнату и возвращаемся в лобби
|
|
1870
|
+
onStartFailed: msg => {
|
|
1871
|
+
roomCtx.startFailed = true;
|
|
1872
|
+
handleDisconnect();
|
|
1873
|
+
informTech(
|
|
1874
|
+
`Failed to start the room: ${msg.message || 'unknown error'}. Click to return to the lobby.`,
|
|
1875
|
+
);
|
|
1876
|
+
},
|
|
1877
|
+
},
|
|
1878
|
+
});
|
|
1639
1879
|
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1880
|
+
const handoff = new HandoffFlow({
|
|
1881
|
+
signaling,
|
|
1882
|
+
ctx: roomCtx,
|
|
1883
|
+
hostRole,
|
|
1884
|
+
standby,
|
|
1885
|
+
membership,
|
|
1886
|
+
getPromotion: () => promotionFlow,
|
|
1887
|
+
getSupervisor,
|
|
1888
|
+
getTokenExpiresAt,
|
|
1889
|
+
config: lobbyConfig.migration,
|
|
1890
|
+
ui: {
|
|
1891
|
+
showSessionOverlay,
|
|
1892
|
+
disableControls: () => modules.controls?.disableKeys(),
|
|
1893
|
+
enableControls: () => modules.controls?.enableKeys(),
|
|
1894
|
+
mute: () => soundManager.mute(),
|
|
1895
|
+
unmute: () => soundManager.unmute(),
|
|
1896
|
+
isHidden: isPageHidden,
|
|
1897
|
+
setHandoffMenu: state => roomMenu?.setHandoff(state),
|
|
1898
|
+
sendLeave: () => sending(PC_LEAVE),
|
|
1899
|
+
reloadPage,
|
|
1900
|
+
},
|
|
1901
|
+
});
|
|
1645
1902
|
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1903
|
+
const guest = new GuestSession({
|
|
1904
|
+
signaling,
|
|
1905
|
+
ctx: roomCtx,
|
|
1906
|
+
membership,
|
|
1907
|
+
hostRole,
|
|
1908
|
+
standby,
|
|
1909
|
+
getPromotion: () => promotionFlow,
|
|
1910
|
+
getSupervisor,
|
|
1911
|
+
getToken,
|
|
1912
|
+
logout: () => lobbyAuthModel.logout(),
|
|
1913
|
+
config: lobbyConfig,
|
|
1914
|
+
diagnostics,
|
|
1915
|
+
ui: {
|
|
1916
|
+
ensureWebRtc: ensureWebRtcAvailable,
|
|
1917
|
+
showRoomLink: roomId => showRoomLink(activeGameManifest?.id, roomId),
|
|
1918
|
+
startSession,
|
|
1919
|
+
closeLobby: () => lobby?.close(),
|
|
1920
|
+
leaveRoomWith,
|
|
1921
|
+
chat: chatNotice,
|
|
1922
|
+
getVote: () => modules.vote,
|
|
1923
|
+
},
|
|
1924
|
+
});
|
|
1654
1925
|
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1926
|
+
const promotionFlow = new PromotionFlow({
|
|
1927
|
+
signaling,
|
|
1928
|
+
ctx: roomCtx,
|
|
1929
|
+
hostRole,
|
|
1930
|
+
standby,
|
|
1931
|
+
membership,
|
|
1932
|
+
getHandoff: () => handoff,
|
|
1933
|
+
getGuest: () => guest,
|
|
1934
|
+
getSupervisor,
|
|
1935
|
+
getActiveGameId: () => activeGameManifest?.id,
|
|
1936
|
+
getToken,
|
|
1937
|
+
prepareRoom: hostRoomPrep.prepareHostRoom,
|
|
1938
|
+
config: lobbyConfig,
|
|
1939
|
+
diagnostics,
|
|
1940
|
+
ui: {
|
|
1941
|
+
reloadPage,
|
|
1942
|
+
reloadToRoom,
|
|
1943
|
+
showSessionOverlay,
|
|
1944
|
+
disableControls: () => modules.controls?.disableKeys(),
|
|
1945
|
+
mute: () => soundManager.mute(),
|
|
1946
|
+
informTech,
|
|
1947
|
+
chat: chatNotice,
|
|
1948
|
+
},
|
|
1949
|
+
});
|
|
1679
1950
|
|
|
1680
|
-
|
|
1681
|
-
|
|
1951
|
+
if (isLobbyMode) {
|
|
1952
|
+
const roomMenuModel = new RoomMenuModel();
|
|
1682
1953
|
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1954
|
+
roomMenu = new RoomMenuCtrl(
|
|
1955
|
+
roomMenuModel,
|
|
1956
|
+
new RoomMenuView(roomMenuModel, lobbyConfig.roomMenu),
|
|
1957
|
+
{
|
|
1958
|
+
onLeave: () => handoff.leaveByUser(),
|
|
1959
|
+
onHandover: () => handoff.start({ reason: 'handover', stay: true }),
|
|
1687
1960
|
},
|
|
1961
|
+
);
|
|
1688
1962
|
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1963
|
+
unloadGuard = new HostUnloadGuard({
|
|
1964
|
+
// мастер начинает аварийную миграцию сразу, не дожидаясь обрыва WS
|
|
1965
|
+
onHostLeave: () => {
|
|
1966
|
+
if (hostRole.room) {
|
|
1967
|
+
signaling.hostLeaving(hostRole.room.roomId, hostRole.room.epoch);
|
|
1968
|
+
}
|
|
1695
1969
|
},
|
|
1970
|
+
// место освобождается сразу, а не через resumeGraceMs
|
|
1971
|
+
onGuestLeave: () => handoff.announceGuestLeave(),
|
|
1696
1972
|
});
|
|
1973
|
+
}
|
|
1697
1974
|
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1975
|
+
// роль вкладки в комнате для меню и защиты закрытия: хост — пока у неё
|
|
1976
|
+
// Worker комнаты (и во время промоушена), гость — пока она в комнате
|
|
1977
|
+
function refreshRoomControls() {
|
|
1978
|
+
const role = !roomCtx.roomId ? null : hostRole.controller ? 'host' : 'guest';
|
|
1979
|
+
const othersPresent = role === 'host' && hostRole.peerCount > 0;
|
|
1980
|
+
|
|
1981
|
+
roomMenu?.setRole({
|
|
1982
|
+
role,
|
|
1983
|
+
othersPresent,
|
|
1984
|
+
hasSuccessor: role === 'host' && hostRole.successorMemberId !== null,
|
|
1702
1985
|
});
|
|
1986
|
+
unloadGuard?.update({ role, othersPresent });
|
|
1987
|
+
}
|
|
1703
1988
|
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1989
|
+
// адресная строка показывает ссылку на комнату, пока вкладка в ней: её
|
|
1990
|
+
// можно скопировать прямо оттуда, как и пунктом меню комнаты
|
|
1991
|
+
function showRoomLink(gameId, roomId) {
|
|
1992
|
+
if (!gameId) {
|
|
1993
|
+
return;
|
|
1994
|
+
}
|
|
1709
1995
|
|
|
1710
|
-
|
|
1711
|
-
transport.publisher.on('close', handleDisconnect);
|
|
1712
|
-
transport.connect();
|
|
1996
|
+
const link = formatRoomLink(gameId, roomId);
|
|
1713
1997
|
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1998
|
+
setRoute(link);
|
|
1999
|
+
roomMenu?.setLink(absoluteLink(link));
|
|
2000
|
+
refreshRoomControls();
|
|
2001
|
+
}
|
|
1717
2002
|
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
return; // комната уже погашена
|
|
1721
|
-
}
|
|
2003
|
+
// средний FPS рендера гостя за интервал отчёта (этап 9c)
|
|
2004
|
+
const fpsMeter = new FpsMeter();
|
|
1722
2005
|
|
|
1723
|
-
|
|
1724
|
-
|
|
2006
|
+
document.addEventListener('visibilitychange', () => {
|
|
2007
|
+
const hidden = isPageHidden();
|
|
1725
2008
|
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
});
|
|
2009
|
+
hostRole.setHidden(hidden);
|
|
2010
|
+
handoff.setHidden(hidden);
|
|
1729
2011
|
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
}
|
|
2012
|
+
// кадры скрытой вкладки (их нет) не тянут средний FPS вниз
|
|
2013
|
+
if (!hidden) {
|
|
2014
|
+
fpsMeter.reset();
|
|
2015
|
+
}
|
|
2016
|
+
});
|
|
1734
2017
|
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
// доказывает мастеру владение hostId)
|
|
1743
|
-
hostController?.setHostId(msg.hostId, msg.hostSecret);
|
|
1744
|
-
|
|
1745
|
-
if (msg.mapsVersion && msg.mapsVersion !== hostMapsVersion) {
|
|
1746
|
-
refreshHostMaps();
|
|
1747
|
-
}
|
|
2018
|
+
// закрытие скрытой вкладки: эпизод закрывается сводкой сейчас — журнал свой
|
|
2019
|
+
// pagehide уже отработал (подписан раньше), поэтому отправка явная
|
|
2020
|
+
window.addEventListener('pagehide', () => {
|
|
2021
|
+
if (hostRole.flushHiddenLog()) {
|
|
2022
|
+
diagnostics.flush();
|
|
2023
|
+
}
|
|
2024
|
+
});
|
|
1748
2025
|
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
2026
|
+
// solo: авторитетный хост в главном потоке (без Worker'а)
|
|
2027
|
+
let inlineHost = null;
|
|
2028
|
+
|
|
2029
|
+
if (isDevBuild) {
|
|
2030
|
+
window.__vimpDebug = createDebugApi({
|
|
2031
|
+
getHostController: () => hostRole.controller,
|
|
2032
|
+
getClientCore: () => clientCore,
|
|
2033
|
+
reportUrl: lobbyConfig.debugReportUrl,
|
|
2034
|
+
startHandoff: options => handoff.start(options),
|
|
1756
2035
|
});
|
|
1757
2036
|
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
}
|
|
2037
|
+
debugLog(
|
|
2038
|
+
'window.__vimpDebug is available: dump, startRecording, stopRecording, divergence',
|
|
2039
|
+
);
|
|
2040
|
+
}
|
|
1763
2041
|
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
2042
|
+
// WebRTC обязателен для P2P-игры. В Firefox RTCPeerConnection может
|
|
2043
|
+
// отсутствовать (media.peerconnection.enabled = false, resistFingerprinting,
|
|
2044
|
+
// приватные сборки) — честное сообщение вместо падения с чёрным экраном
|
|
2045
|
+
function ensureWebRtcAvailable() {
|
|
2046
|
+
if (typeof RTCPeerConnection !== 'undefined') {
|
|
2047
|
+
return true;
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
socketMethods[PS_TECH_INFORM_DATA](
|
|
2051
|
+
'WebRTC is unavailable in this browser: P2P play is impossible. ' +
|
|
2052
|
+
'In Firefox check that media.peerconnection.enabled is on.',
|
|
2053
|
+
);
|
|
1772
2054
|
|
|
1773
|
-
|
|
2055
|
+
return false;
|
|
1774
2056
|
}
|
|
1775
2057
|
|
|
1776
2058
|
// solo-режим: авторитетный матч в этом же потоке (standalone SDK). Ни
|
|
@@ -1796,163 +2078,20 @@ async function connectSolo() {
|
|
|
1796
2078
|
// хендшейк начинается в connect() — хост к этому моменту обязан быть готов
|
|
1797
2079
|
await inlineHost.ready;
|
|
1798
2080
|
|
|
1799
|
-
transport = new LoopbackTransport(inlineHost, socketId);
|
|
2081
|
+
const transport = new LoopbackTransport(inlineHost, socketId);
|
|
1800
2082
|
|
|
1801
|
-
transport
|
|
1802
|
-
transport.publisher.on('close', handleDisconnect);
|
|
2083
|
+
startSession(transport);
|
|
1803
2084
|
transport.connect();
|
|
1804
2085
|
}
|
|
1805
2086
|
|
|
1806
2087
|
// dedicated-режим: прямой WebSocket к Node-серверу игры
|
|
1807
2088
|
function connectDedicated() {
|
|
1808
|
-
transport = new WebSocketTransport(boot.wsUrl);
|
|
2089
|
+
const transport = new WebSocketTransport(boot.wsUrl);
|
|
1809
2090
|
|
|
1810
|
-
transport
|
|
1811
|
-
transport.publisher.on('close', handleDisconnect);
|
|
2091
|
+
startSession(transport);
|
|
1812
2092
|
transport.connect();
|
|
1813
2093
|
}
|
|
1814
2094
|
|
|
1815
|
-
// версия каталога карт мастера, с которой поднята комната (Этап 5.1)
|
|
1816
|
-
let hostMapsVersion = null;
|
|
1817
|
-
|
|
1818
|
-
// составной codeVersion комнаты (Этап 5.2/6.5): { engine, game: { id, version } };
|
|
1819
|
-
// null — обновления кода отключены (манифест недоступен при старте)
|
|
1820
|
-
let hostCodeVersion = null;
|
|
1821
|
-
|
|
1822
|
-
// версия, своп на которую не удался — не ретраить её на каждом re-register
|
|
1823
|
-
let failedCodeVersion = null;
|
|
1824
|
-
|
|
1825
|
-
// сравнимый ключ составного codeVersion (Этап 6.5): движок + игра —
|
|
1826
|
-
// расхождение любой половины (деплой движка ИЛИ деплой игры) запускает эстафету
|
|
1827
|
-
function codeVersionKey(cv) {
|
|
1828
|
-
return cv
|
|
1829
|
-
? `${cv.engine ?? ''}:${cv.game?.id ?? ''}:${cv.game?.version ?? ''}`
|
|
1830
|
-
: null;
|
|
1831
|
-
}
|
|
1832
|
-
|
|
1833
|
-
function codeVersionChanged(remote, local) {
|
|
1834
|
-
return codeVersionKey(remote) !== codeVersionKey(local);
|
|
1835
|
-
}
|
|
1836
|
-
|
|
1837
|
-
// защита от параллельных эстафет Worker'ов
|
|
1838
|
-
let workerSwapInProgress = false;
|
|
1839
|
-
|
|
1840
|
-
// повторная регистрация комнаты у мастера (reconnect сигналинга)
|
|
1841
|
-
let hostRegistration = null;
|
|
1842
|
-
|
|
1843
|
-
// Этап 5.1/6.4: скачивает каталог карт мастера активной игры (манифест +
|
|
1844
|
-
// все карты)
|
|
1845
|
-
async function fetchMasterMaps() {
|
|
1846
|
-
const manifestRes = await fetch(lobbyConfig.maps.manifestUrl(activeGameManifest));
|
|
1847
|
-
|
|
1848
|
-
if (!manifestRes.ok) {
|
|
1849
|
-
throw new Error(`maps manifest: HTTP ${manifestRes.status}`);
|
|
1850
|
-
}
|
|
1851
|
-
|
|
1852
|
-
const manifest = await manifestRes.json();
|
|
1853
|
-
|
|
1854
|
-
const entries = await Promise.all(
|
|
1855
|
-
manifest.maps.map(async name => {
|
|
1856
|
-
const url = `${lobbyConfig.maps.baseUrl(activeGameManifest)}/${encodeURIComponent(name)}`;
|
|
1857
|
-
const res = await fetch(url);
|
|
1858
|
-
|
|
1859
|
-
if (!res.ok) {
|
|
1860
|
-
throw new Error(`map ${name}: HTTP ${res.status}`);
|
|
1861
|
-
}
|
|
1862
|
-
|
|
1863
|
-
return [name, await res.json()];
|
|
1864
|
-
}),
|
|
1865
|
-
);
|
|
1866
|
-
|
|
1867
|
-
return { version: manifest.version, maps: Object.fromEntries(entries) };
|
|
1868
|
-
}
|
|
1869
|
-
|
|
1870
|
-
// перечитывает каталог карт мастера и передаёт в Worker:
|
|
1871
|
-
// применится со следующей смены карты (текущий раунд не трогается)
|
|
1872
|
-
async function refreshHostMaps() {
|
|
1873
|
-
try {
|
|
1874
|
-
const catalog = await fetchMasterMaps();
|
|
1875
|
-
|
|
1876
|
-
hostMapsVersion = catalog.version;
|
|
1877
|
-
hostController?.updateMaps(catalog.maps);
|
|
1878
|
-
} catch (e) {
|
|
1879
|
-
console.warn('[maps] refresh from master failed:', e);
|
|
1880
|
-
}
|
|
1881
|
-
}
|
|
1882
|
-
|
|
1883
|
-
// Этап 5.2: скачивает манифест worker-бандла мастера ({ version, url })
|
|
1884
|
-
async function fetchWorkerManifest() {
|
|
1885
|
-
const res = await fetch(lobbyConfig.worker.manifestUrl);
|
|
1886
|
-
|
|
1887
|
-
if (!res.ok) {
|
|
1888
|
-
throw new Error(`worker manifest: HTTP ${res.status}`);
|
|
1889
|
-
}
|
|
1890
|
-
|
|
1891
|
-
return res.json();
|
|
1892
|
-
}
|
|
1893
|
-
|
|
1894
|
-
// Этап 6.5: перечитывает манифест активной игры мастера — своп не должен
|
|
1895
|
-
// нести новому Worker'у закэшированный с момента создания комнаты
|
|
1896
|
-
// hostEntryUrl/wasmUrl (деплой игры мог обновиться независимо от движка)
|
|
1897
|
-
async function fetchGameManifest(gameId) {
|
|
1898
|
-
return fetchGamePluginManifest(lobbyConfig.game.manifestUrl(gameId));
|
|
1899
|
-
}
|
|
1900
|
-
|
|
1901
|
-
// Этап 5.2/6.5: эстафета Worker'ов — новая версия кода (движка ИЛИ игры) у
|
|
1902
|
-
// мастера. Worker заменяется на границе раунда без разрыва P2P; сбой свопа
|
|
1903
|
-
// не смертелен — комната продолжает жить на прежней версии
|
|
1904
|
-
async function refreshHostWorker() {
|
|
1905
|
-
if (workerSwapInProgress || !hostController) {
|
|
1906
|
-
return;
|
|
1907
|
-
}
|
|
1908
|
-
|
|
1909
|
-
workerSwapInProgress = true;
|
|
1910
|
-
|
|
1911
|
-
let manifest = null;
|
|
1912
|
-
let game = null;
|
|
1913
|
-
|
|
1914
|
-
try {
|
|
1915
|
-
manifest = await fetchWorkerManifest();
|
|
1916
|
-
const gameManifest = await fetchGameManifest(activeGameManifest.id);
|
|
1917
|
-
|
|
1918
|
-
game = {
|
|
1919
|
-
id: gameManifest.id,
|
|
1920
|
-
version: gameManifest.version,
|
|
1921
|
-
hostEntryUrl: gameManifest.entries.host,
|
|
1922
|
-
wasmUrl: gameManifest.entries.wasm,
|
|
1923
|
-
};
|
|
1924
|
-
|
|
1925
|
-
const nextCodeVersion = {
|
|
1926
|
-
engine: manifest.version,
|
|
1927
|
-
game: { id: game.id, version: game.version },
|
|
1928
|
-
};
|
|
1929
|
-
const nextKey = codeVersionKey(nextCodeVersion);
|
|
1930
|
-
|
|
1931
|
-
if (
|
|
1932
|
-
!manifest.version ||
|
|
1933
|
-
!manifest.url ||
|
|
1934
|
-
nextKey === codeVersionKey(hostCodeVersion) ||
|
|
1935
|
-
nextKey === codeVersionKey(failedCodeVersion)
|
|
1936
|
-
) {
|
|
1937
|
-
return;
|
|
1938
|
-
}
|
|
1939
|
-
|
|
1940
|
-
await hostController.swapWorker(manifest.url, game);
|
|
1941
|
-
|
|
1942
|
-
hostCodeVersion = nextCodeVersion;
|
|
1943
|
-
failedCodeVersion = null;
|
|
1944
|
-
console.info(`[worker] room migrated to code version ${nextKey}`);
|
|
1945
|
-
} catch (e) {
|
|
1946
|
-
if (manifest?.version) {
|
|
1947
|
-
failedCodeVersion = { engine: manifest.version, game };
|
|
1948
|
-
}
|
|
1949
|
-
|
|
1950
|
-
console.warn('[worker] swap to new version failed:', e);
|
|
1951
|
-
} finally {
|
|
1952
|
-
workerSwapInProgress = false;
|
|
1953
|
-
}
|
|
1954
|
-
}
|
|
1955
|
-
|
|
1956
2095
|
// REST-запрос списка серверов у мастера (поиск игнорирует пагинацию)
|
|
1957
2096
|
async function fetchServers({ offset, limit, search }) {
|
|
1958
2097
|
const params = new URLSearchParams();
|
|
@@ -2102,8 +2241,9 @@ function clearLobbyError() {
|
|
|
2102
2241
|
}
|
|
2103
2242
|
|
|
2104
2243
|
// активирует игру и перепривязывает манифест/плагин/CSS; false — отказ уже
|
|
2105
|
-
// показан
|
|
2106
|
-
|
|
2244
|
+
// показан игроку (report, по умолчанию строкой формы лобби), лобби остаётся
|
|
2245
|
+
// рабочим
|
|
2246
|
+
async function selectActiveGame(gameId, { report = showLobbyError } = {}) {
|
|
2107
2247
|
// сверка идёт с самим манифестом, а не с его id: каталог вкладки может
|
|
2108
2248
|
// пополниться застейдженной версией той же игры (registerGameManifest), и
|
|
2109
2249
|
// сравнение по id оставило бы активной одобренную версию
|
|
@@ -2125,7 +2265,7 @@ async function selectActiveGame(gameId) {
|
|
|
2125
2265
|
return true;
|
|
2126
2266
|
} catch (e) {
|
|
2127
2267
|
console.error(`[game] failed to activate "${gameId}":`, e);
|
|
2128
|
-
|
|
2268
|
+
report(
|
|
2129
2269
|
`Failed to load ${gamesById.get(gameId)?.title ?? gameId}: ${e.message}`,
|
|
2130
2270
|
);
|
|
2131
2271
|
|
|
@@ -2146,7 +2286,7 @@ const stagedGameIds = new Set();
|
|
|
2146
2286
|
* так админ может поднять по нему комнату, не трогая каталог игроков.
|
|
2147
2287
|
* Дальше всё работает существующим путём: createGameActivator грузит
|
|
2148
2288
|
* ClientPlugin по entries.client (ключ кеша версионный, поэтому плагин
|
|
2149
|
-
* черновика не подменит одобренный),
|
|
2289
|
+
* черновика не подменит одобренный), hostRole.createRoom поднимает комнату, а
|
|
2150
2290
|
* мастер помечает её скрытой по версии манифеста.
|
|
2151
2291
|
* @param {Object} manifest - Манифест застейдженной версии из /admin/games.
|
|
2152
2292
|
* @returns {void}
|
|
@@ -2251,8 +2391,8 @@ function initLobby() {
|
|
|
2251
2391
|
});
|
|
2252
2392
|
|
|
2253
2393
|
// умный пинг видимого сервера — сигнальным путём (замер приблизительный)
|
|
2254
|
-
lobbyModel.publisher.on('ping-request', ({
|
|
2255
|
-
signaling.pingHost(
|
|
2394
|
+
lobbyModel.publisher.on('ping-request', ({ roomId, pingId }) => {
|
|
2395
|
+
signaling.pingHost(roomId, pingId);
|
|
2256
2396
|
});
|
|
2257
2397
|
|
|
2258
2398
|
signaling.publisher.on('pong_host', msg => {
|
|
@@ -2262,7 +2402,7 @@ function initLobby() {
|
|
|
2262
2402
|
// выбор сервера → активация игры комнаты и установка P2P. gameId нет у
|
|
2263
2403
|
// хостов старше 6.4 — тогда заходим на активной игре, как раньше: неизвестная
|
|
2264
2404
|
// версия не повод запретить вход
|
|
2265
|
-
lobbyModel.publisher.on('join', async ({
|
|
2405
|
+
lobbyModel.publisher.on('join', async ({ roomId, gameId }) => {
|
|
2266
2406
|
if (gameId && gamesById.has(gameId)) {
|
|
2267
2407
|
if (!(await selectActiveGame(gameId))) {
|
|
2268
2408
|
return;
|
|
@@ -2275,7 +2415,7 @@ function initLobby() {
|
|
|
2275
2415
|
return;
|
|
2276
2416
|
}
|
|
2277
2417
|
|
|
2278
|
-
|
|
2418
|
+
guest.connectToRoom(roomId);
|
|
2279
2419
|
});
|
|
2280
2420
|
|
|
2281
2421
|
// Leaderboard (lobby-page-plan): контроллер сигнализирует, для какой игры
|
|
@@ -2324,7 +2464,9 @@ function initLobby() {
|
|
|
2324
2464
|
// реестр игр (master-game-registry, этап 4): заявка разработчика и панель
|
|
2325
2465
|
// модерации живут в том же лобби. Триплет поднимается вместе с лобби —
|
|
2326
2466
|
// панель доступна любому авторизованному, кнопку модерации показывает роль
|
|
2327
|
-
const gamesModel = new GamesModel(lobbyConfig.games, () =>
|
|
2467
|
+
const gamesModel = new GamesModel(lobbyConfig.games, () =>
|
|
2468
|
+
lobbyAuthModel.getToken(),
|
|
2469
|
+
);
|
|
2328
2470
|
const gamesView = new GamesView(gamesModel, lobbyConfig.games);
|
|
2329
2471
|
const games = new GamesCtrl(gamesModel, gamesView);
|
|
2330
2472
|
|
|
@@ -2335,11 +2477,18 @@ function initLobby() {
|
|
|
2335
2477
|
|
|
2336
2478
|
// журнал клиентских ошибок (plan/client-reports, этап 5): кнопку «Errors»
|
|
2337
2479
|
// видит только админ; фильтр по игре — каталог вкладки
|
|
2338
|
-
const clientReportsModel = new ClientReportsModel(
|
|
2339
|
-
|
|
2480
|
+
const clientReportsModel = new ClientReportsModel(
|
|
2481
|
+
lobbyConfig.clientReports,
|
|
2482
|
+
() => lobbyAuthModel.getToken(),
|
|
2483
|
+
);
|
|
2484
|
+
const clientReportsView = new ClientReportsView(
|
|
2485
|
+
clientReportsModel,
|
|
2486
|
+
lobbyConfig.clientReports,
|
|
2487
|
+
);
|
|
2488
|
+
const clientReports = new ClientReportsCtrl(
|
|
2489
|
+
clientReportsModel,
|
|
2490
|
+
clientReportsView,
|
|
2340
2491
|
);
|
|
2341
|
-
const clientReportsView = new ClientReportsView(clientReportsModel, lobbyConfig.clientReports);
|
|
2342
|
-
const clientReports = new ClientReportsCtrl(clientReportsModel, clientReportsView);
|
|
2343
2492
|
|
|
2344
2493
|
clientReportsView.setGames([...gamesById.values()]);
|
|
2345
2494
|
clientReports.setAdmin(lobbyAuthModel.getRole() === 'admin');
|
|
@@ -2361,22 +2510,24 @@ function initLobby() {
|
|
|
2361
2510
|
// мастера синхронным циклом, и без очереди два черновика при пустом
|
|
2362
2511
|
// каталоге ушли бы активироваться одновременно — активной осталась бы
|
|
2363
2512
|
// одна игра, а форма комнаты собралась бы по другой
|
|
2364
|
-
stagedActivation = stagedActivation
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2513
|
+
stagedActivation = stagedActivation
|
|
2514
|
+
.then(async () => {
|
|
2515
|
+
if (activeGameManifest) {
|
|
2516
|
+
return; // предыдущий черновик уже стал активным
|
|
2517
|
+
}
|
|
2368
2518
|
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2519
|
+
// только на успехе: отказ активации уже написал в ту же строку СВОЮ
|
|
2520
|
+
// причину («Failed to load X: …»), и общее «игр пока не опубликовано»
|
|
2521
|
+
// затёрло бы её — админ увидел бы не то, что случилось
|
|
2522
|
+
if (await selectActiveGame(manifest.id)) {
|
|
2523
|
+
syncCatalogState();
|
|
2524
|
+
}
|
|
2525
|
+
})
|
|
2526
|
+
.catch(e => {
|
|
2527
|
+
// отказ не должен заклинить очередь: следующий черновик обязан
|
|
2528
|
+
// получить свой шанс стать активной игрой
|
|
2529
|
+
console.error('[game] staged activation failed:', e);
|
|
2530
|
+
});
|
|
2380
2531
|
});
|
|
2381
2532
|
|
|
2382
2533
|
// создание комнаты в этой же вкладке (хост-игрок через loopback)
|
|
@@ -2402,8 +2553,6 @@ function initLobby() {
|
|
|
2402
2553
|
liveErrors.disarm();
|
|
2403
2554
|
});
|
|
2404
2555
|
|
|
2405
|
-
const nameInput = document.getElementById(lobbyConfig.elems.nameId);
|
|
2406
|
-
|
|
2407
2556
|
hostBtn?.addEventListener('click', async () => {
|
|
2408
2557
|
// валидация (pattern/required/min/max) — единственная граница
|
|
2409
2558
|
// room-формы: она едет клиенту как JSON манифеста, JS-валидаторы
|
|
@@ -2419,8 +2568,6 @@ function initLobby() {
|
|
|
2419
2568
|
}
|
|
2420
2569
|
|
|
2421
2570
|
const manifest = gamesById.get(gameSelect?.value) ?? activeGameManifest;
|
|
2422
|
-
const name =
|
|
2423
|
-
(nameInput?.value || '').trim() || lobbyConfig.create.defaultName;
|
|
2424
2571
|
|
|
2425
2572
|
// значения формы снимаем до await: активация асинхронна, а roomFormFields
|
|
2426
2573
|
// пересобираются при смене игры — в комнату должно уехать то, что игрок
|
|
@@ -2441,8 +2588,7 @@ function initLobby() {
|
|
|
2441
2588
|
return;
|
|
2442
2589
|
}
|
|
2443
2590
|
|
|
2444
|
-
|
|
2445
|
-
name,
|
|
2591
|
+
hostRole.createRoom({
|
|
2446
2592
|
hostSocketId: lobbyConfig.create.hostSocketId,
|
|
2447
2593
|
...overrides,
|
|
2448
2594
|
});
|
|
@@ -2461,9 +2607,52 @@ let lobbyAuthView = null;
|
|
|
2461
2607
|
let welcomeReceived = false;
|
|
2462
2608
|
let authenticated = false;
|
|
2463
2609
|
|
|
2610
|
+
// прямые ссылки и быстрая игра (client/session/RouteBoot.js)
|
|
2611
|
+
const route = new RouteBoot({
|
|
2612
|
+
ctx: roomCtx,
|
|
2613
|
+
signaling,
|
|
2614
|
+
membership,
|
|
2615
|
+
config: lobbyConfig,
|
|
2616
|
+
gamesById,
|
|
2617
|
+
selectActiveGame,
|
|
2618
|
+
connectToRoom: roomId => guest.connectToRoom(roomId),
|
|
2619
|
+
createRoom: (room, options) => hostRole.createRoom(room, options),
|
|
2620
|
+
fetchServers,
|
|
2621
|
+
ui: {
|
|
2622
|
+
initLobby,
|
|
2623
|
+
selectLobbyGame: gameId => {
|
|
2624
|
+
const gameSelect = document.getElementById(lobbyConfig.elems.gameId);
|
|
2625
|
+
|
|
2626
|
+
if (gameSelect) {
|
|
2627
|
+
gameSelect.value = gameId;
|
|
2628
|
+
gameSelect.dispatchEvent(new Event('change'));
|
|
2629
|
+
}
|
|
2630
|
+
},
|
|
2631
|
+
informTech,
|
|
2632
|
+
onInformerClick: handler =>
|
|
2633
|
+
techInformer.addEventListener('click', handler, { once: true }),
|
|
2634
|
+
getHash: () => location.hash,
|
|
2635
|
+
setRoute,
|
|
2636
|
+
reloadPage,
|
|
2637
|
+
},
|
|
2638
|
+
});
|
|
2639
|
+
|
|
2464
2640
|
function maybeInitLobby() {
|
|
2465
2641
|
if (welcomeReceived && authenticated) {
|
|
2466
|
-
|
|
2642
|
+
route.boot();
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
// показывает терминальную причину и возвращает в лобби (путь
|
|
2647
|
+
// handleDisconnect: закрытие транспорта → перезагрузка)
|
|
2648
|
+
function leaveRoomWith(message) {
|
|
2649
|
+
socketMethods[PS_TECH_INFORM_DATA](message);
|
|
2650
|
+
terminalInformShown = true;
|
|
2651
|
+
|
|
2652
|
+
if (supervisor) {
|
|
2653
|
+
supervisor.close();
|
|
2654
|
+
} else {
|
|
2655
|
+
handleDisconnect();
|
|
2467
2656
|
}
|
|
2468
2657
|
}
|
|
2469
2658
|
|
|
@@ -2480,15 +2669,51 @@ if (isLobbyMode) {
|
|
|
2480
2669
|
maybeInitLobby();
|
|
2481
2670
|
});
|
|
2482
2671
|
|
|
2672
|
+
guest.bind();
|
|
2673
|
+
hostRole.bind();
|
|
2674
|
+
standby.bind();
|
|
2675
|
+
promotionFlow.bind();
|
|
2676
|
+
handoff.bind();
|
|
2677
|
+
|
|
2678
|
+
document.addEventListener('visibilitychange', () => membership.sendUpdate());
|
|
2679
|
+
|
|
2680
|
+
// свежий средний FPS гостя мастеру (этап 9c); хост его не шлёт
|
|
2681
|
+
Ticker.shared.add(() => fpsMeter.frame());
|
|
2682
|
+
setInterval(
|
|
2683
|
+
() => membership.setFps(fpsMeter.sample()),
|
|
2684
|
+
lobbyConfig.migration.auto.fpsReportIntervalMs,
|
|
2685
|
+
);
|
|
2686
|
+
|
|
2483
2687
|
lobbyAuthModel.publisher.on('authenticated', () => {
|
|
2484
2688
|
authenticated = true;
|
|
2485
2689
|
maybeInitLobby();
|
|
2690
|
+
// новый вход — новый срок: canHost пересчитается по нему, передача роли
|
|
2691
|
+
// хоста — тоже
|
|
2692
|
+
membership.armTokenCapsTimer();
|
|
2693
|
+
|
|
2694
|
+
if (hostRole.controller) {
|
|
2695
|
+
handoff.armTokenHandoff();
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
// повторный вход после logout: маршрут уже разобран, а логин-гейт
|
|
2699
|
+
// спрятал лобби — вернуть его, если вкладка не в комнате
|
|
2700
|
+
if (route.booted && !roomCtx.entered) {
|
|
2701
|
+
lobby?.open();
|
|
2702
|
+
}
|
|
2486
2703
|
});
|
|
2487
2704
|
|
|
2705
|
+
// OAuth-возврат: токены из query прочитаны — чистим только query, hash
|
|
2706
|
+
// (маршрут #/<gameId>[/<roomId>]) остаётся
|
|
2488
2707
|
if (lobbyAuthCtrl.init(window.location.search)) {
|
|
2489
|
-
window.history.replaceState(
|
|
2708
|
+
window.history.replaceState(
|
|
2709
|
+
null,
|
|
2710
|
+
'',
|
|
2711
|
+
window.location.pathname + window.location.hash,
|
|
2712
|
+
);
|
|
2490
2713
|
}
|
|
2491
2714
|
|
|
2715
|
+
window.addEventListener('hashchange', () => route.handleHashChange());
|
|
2716
|
+
|
|
2492
2717
|
signaling.connect();
|
|
2493
2718
|
} else if (bootMode === 'solo') {
|
|
2494
2719
|
try {
|