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.
Files changed (132) hide show
  1. package/bin/vimp-sim.js +19 -1
  2. package/core/Cargo.toml +1 -1
  3. package/package.json +1 -1
  4. package/src/client/components/controller/Controls.js +5 -0
  5. package/src/client/components/controller/Lobby.js +4 -4
  6. package/src/client/components/controller/RoomMenu.js +43 -0
  7. package/src/client/components/controller/Vote.js +18 -2
  8. package/src/client/components/model/Chat.js +19 -2
  9. package/src/client/components/model/ClientReports.js +6 -2
  10. package/src/client/components/model/Controls.js +27 -0
  11. package/src/client/components/model/Games.js +5 -1
  12. package/src/client/components/model/Lobby.js +32 -24
  13. package/src/client/components/model/LobbyAuth.js +20 -4
  14. package/src/client/components/model/RoomMenu.js +121 -0
  15. package/src/client/components/model/Stat.js +2 -1
  16. package/src/client/components/model/Vote.js +122 -4
  17. package/src/client/components/view/Auth.js +20 -10
  18. package/src/client/components/view/ClientReports.js +16 -5
  19. package/src/client/components/view/Games.js +39 -14
  20. package/src/client/components/view/Lobby.js +90 -43
  21. package/src/client/components/view/LobbyAuth.js +13 -7
  22. package/src/client/components/view/Panel.js +1 -2
  23. package/src/client/components/view/RoomMenu.js +69 -0
  24. package/src/client/debug.js +24 -1
  25. package/src/client/lib/FpsMeter.js +46 -0
  26. package/src/client/lib/JoinRetry.js +73 -0
  27. package/src/client/lib/diagnostics.js +28 -5
  28. package/src/client/lib/hostCaps.js +82 -0
  29. package/src/client/lib/hostUnloadGuard.js +108 -0
  30. package/src/client/lib/hostVoteCommand.js +92 -0
  31. package/src/client/lib/pageReload.js +29 -0
  32. package/src/client/lib/roomLink.js +257 -0
  33. package/src/client/lib/signalingErrors.js +74 -0
  34. package/src/client/main.js +676 -451
  35. package/src/client/network/HiddenHostHealthLog.js +76 -0
  36. package/src/client/network/HostConnectionManager.js +62 -8
  37. package/src/client/network/HostController.js +376 -23
  38. package/src/client/network/HostHealthPolicy.js +307 -0
  39. package/src/client/network/HostHealthReporter.js +54 -0
  40. package/src/client/network/HostPrewarm.js +235 -0
  41. package/src/client/network/LoopbackTransport.js +16 -1
  42. package/src/client/network/PlannedHandoff.js +366 -0
  43. package/src/client/network/Promotion.js +420 -0
  44. package/src/client/network/RoomPeersReporter.js +81 -0
  45. package/src/client/network/SessionSupervisor.js +730 -0
  46. package/src/client/network/SignalingClient.js +183 -30
  47. package/src/client/network/StandbyReceiver.js +287 -0
  48. package/src/client/network/StandbySender.js +319 -0
  49. package/src/client/network/TokenHandoffTimer.js +81 -0
  50. package/src/client/network/WebRtcManager.js +338 -11
  51. package/src/client/network/policyClose.js +32 -2
  52. package/src/client/network/standbyChunks.js +185 -0
  53. package/src/client/session/GuestSession.js +428 -0
  54. package/src/client/session/HandoffFlow.js +399 -0
  55. package/src/client/session/HostRole.js +708 -0
  56. package/src/client/session/Membership.js +179 -0
  57. package/src/client/session/PromotionFlow.js +325 -0
  58. package/src/client/session/RouteBoot.js +432 -0
  59. package/src/client/session/StandbyRole.js +195 -0
  60. package/src/client/session/hostRoomPrep.js +152 -0
  61. package/src/client/session/roomContext.js +32 -0
  62. package/src/client/style.css +83 -22
  63. package/src/client/views/gameShell.js +7 -0
  64. package/src/client/views/includes/informer.pug +4 -0
  65. package/src/client/views/includes/lobby.pug +0 -3
  66. package/src/client/views/includes/roomMenu.pug +9 -0
  67. package/src/client/views/index.pug +1 -0
  68. package/src/config/chatMessages.js +45 -0
  69. package/src/config/closeCodes.js +1 -1
  70. package/src/config/env.js +37 -0
  71. package/src/config/hostDefaults.js +24 -0
  72. package/src/config/lobby.js +154 -3
  73. package/src/config/master.js +104 -21
  74. package/src/config/wsports.js +8 -0
  75. package/src/devtools/RecordingSocketManager.js +3 -2
  76. package/src/devtools/ScenarioRunner.js +123 -8
  77. package/src/devtools/VirtualClient.js +20 -0
  78. package/src/devtools/contract/format.js +4 -1
  79. package/src/devtools/contract/index.js +1 -2
  80. package/src/devtools/contract/rules/a2-package-scripts.js +1 -3
  81. package/src/devtools/contract/rules/b8-system-messages.js +2 -2
  82. package/src/devtools/contract/rules/b9-vote-names.js +25 -1
  83. package/src/devtools/contract/rules/c11-stat-mode.js +4 -1
  84. package/src/devtools/contract/rules/c6-stat-columns.js +1 -1
  85. package/src/devtools/contract/rules/c7-key-sets.js +8 -4
  86. package/src/devtools/contract/rules/d3-snapshot-interp.js +2 -1
  87. package/src/devtools/contract/rules/e1-sound-pairs.js +3 -1
  88. package/src/devtools/contract/rules/e4-map-layers.js +13 -2
  89. package/src/devtools/contract/rules/e5-map-radar-walls.js +7 -1
  90. package/src/devtools/contract/rules/index.js +38 -5
  91. package/src/devtools/invariants.js +10 -4
  92. package/src/devtools/pluginLoader.js +1 -4
  93. package/src/devtools/report.js +17 -1
  94. package/src/host/DebugRecorder.js +6 -1
  95. package/src/host/GameCoreAdapter.js +45 -0
  96. package/src/host/HostGame.js +928 -59
  97. package/src/host/PortMachine.js +268 -20
  98. package/src/host/host.worker.js +222 -16
  99. package/src/host/meta/SocketManager.js +20 -0
  100. package/src/host/meta/core/RoundManager.js +89 -14
  101. package/src/host/meta/modules/Accolades.js +4 -1
  102. package/src/host/meta/modules/Panel.js +30 -0
  103. package/src/host/meta/modules/PlayerDataSync.js +255 -46
  104. package/src/host/meta/modules/RTTManager.js +34 -0
  105. package/src/host/meta/modules/TimerManager.js +215 -18
  106. package/src/host/meta/modules/chat/systemMessages.js +29 -3
  107. package/src/host/meta/player/HumanParticipant.js +10 -0
  108. package/src/host/meta/player/ParticipantManager.js +98 -5
  109. package/src/lib/AbstractTimer.js +63 -1
  110. package/src/lib/Publisher.js +26 -0
  111. package/src/lib/capabilities.js +5 -0
  112. package/src/lib/checkpointCodec.js +125 -0
  113. package/src/lib/clientIp.js +9 -2
  114. package/src/lib/createHostRuntime.js +95 -6
  115. package/src/lib/formOptions.js +3 -1
  116. package/src/lib/gameConfigView.js +10 -0
  117. package/src/lib/gameId.js +13 -0
  118. package/src/lib/jwt.js +3 -1
  119. package/src/lib/loadGamePackage.js +2 -2
  120. package/src/lib/resumeKey.js +38 -0
  121. package/src/lib/roomId.js +39 -0
  122. package/src/lib/roomSettings.js +59 -0
  123. package/src/lib/validators.js +7 -0
  124. package/tests/fixtures/generations/gen-api3/host/ScriptedManager.js +2 -3
  125. package/tests/fixtures/generations/gen-api3/host/fakeCore.js +31 -5
  126. package/tests/fixtures/generations/gen-api3/manifest.json +1 -3
  127. package/tests/fixtures/generations/gen-api4/host/ScriptedManager.js +2 -3
  128. package/tests/fixtures/generations/gen-api4/host/fakeCore.js +31 -5
  129. package/tests/fixtures/generations/gen-api4/manifest.json +1 -3
  130. package/tests/fixtures/miniGame/config/client.js +0 -20
  131. package/tests/fixtures/miniGame/host/ScriptedManager.js +2 -3
  132. package/tests/fixtures/miniGame/host/fakeCore.js +31 -5
@@ -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 { supportsModuleWorker } from './network/workerSupport.js';
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' ? (boot.reportUrl ?? null) : lobbyConfig.clientReportUrl,
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
- // активное P2P-соединение с хостом (создаётся при выборе сервера в лобби)
277
- let transport = null;
310
+ // супервизор сессии (host-migration этап 4): держит текущий транспорт к
311
+ // хосту и переживает его обрыв — переподключается и возвращает место в
312
+ // матче. Создаётся при входе в комнату (или в solo/dedicated-матч)
313
+ let supervisor = null;
278
314
 
279
- // hostId комнаты, к которой подключён гость (для /like·/unlike напрямую
280
- // мастеру). У хоста-игрока (своя вкладка) остаётся null — за себя не проголосовать
281
- let currentHostId = null;
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', data => sending(PC_VOTE_DATA, data));
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
- transport?.send(JSON.stringify([name, data]), reliable);
1317
+ supervisor?.send(JSON.stringify([name, data]), reliable);
1216
1318
  }
1217
1319
 
1218
- // перехватывает /like·/unlike <причина> и шлёт голос напрямую мастеру по
1219
- // сигнальному WS, минуя хоста: его CommandProcessor мог бы отфильтровать
1220
- // голос против самого себя (server-rating этап 2, замена /ban). Причина
1221
- // обязательна (публично не отображается). Остальной чат уходит хосту
1320
+ // отправка сообщения чата хосту (точка перехвата команд, адресованных
1321
+ // мастеру, а не хосту)
1222
1322
  function handleChatSend(message) {
1223
- const likeMatch = message === '/like' || message.startsWith('/like ');
1224
- const unlikeMatch = message === '/unlike' || message.startsWith('/unlike ');
1225
-
1226
- // вне лобби мастера нет — голосовать некому, команда уходит хосту как
1227
- // обычное сообщение чата
1228
- if (isLobbyMode && (likeMatch || unlikeMatch)) {
1229
- const command = likeMatch ? '/like' : '/unlike';
1230
- const reason = message.slice(command.length).trim();
1231
- const token = lobbyAuthModel.getToken();
1232
-
1233
- if (!currentHostId) {
1234
- modules.chat.add([`${command} is available to room guests only`]);
1235
- } else if (!token) {
1236
- modules.chat.add([`${command} requires signing in`]);
1237
- } else if (!reason) {
1238
- modules.chat.add([`${command} requires a reason: ${command} <reason>`]);
1239
- } else if (!signaling.connected) {
1240
- modules.chat.add(['No connection to the master server — vote not sent']);
1241
- } else if (likeMatch) {
1242
- signaling.likeHost(currentHostId, reason, token);
1243
- modules.chat.add(['Vote sent to the master server']);
1244
- } else {
1245
- signaling.unlikeHost(currentHostId, reason, token);
1246
- modules.chat.add(['Vote sent to the master server']);
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(PC_CHAT_DATA, message);
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
- soundManager.unmute();
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
- // разрыв P2P: выход хоста = смерть комнаты (host-migration нет). Останавливаем
1436
- // рендер, показываем заглушку и возвращаемся в лобби перезагрузкой. closeCode
1437
- // приходит только от WebSocket-транспорта (dedicated), остальные транспорты
1438
- // эмитят close без него
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
- // если мы были хостом — гасим комнату: heartbeat, WebRTC-пиры, Worker
1465
- if (hostHeartbeat) {
1466
- clearInterval(hostHeartbeat);
1467
- hostHeartbeat = null;
1580
+ // гость выбыл из матча (кик уводит на главную без перезагрузки — сессия
1581
+ // сигналинга жива): снять членство, иначе вкладка «призраком» числится в
1582
+ // комнате. Best-effort; хост комнату так не покидает — его снимает мастер
1583
+ if (isLobbyMode && roomCtx.roomId && !hostRole.controller) {
1584
+ signaling.leaveRoom(roomCtx.roomId);
1468
1585
  }
1469
1586
 
1470
- hostConnections?.destroy();
1471
- hostController?.destroy();
1472
- hostConnections = null;
1473
- hostController = null; // останавливает и reconnect-петлю сигналинга
1474
- diagnostics.setContext({ role: 'client' });
1475
- hostRegistration = null;
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. Returning to lobby…'
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 !== 'solo' && shouldReloadAfterClose(closeCode)) {
1500
- setTimeout(() => location.reload(), 3000);
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
- transport?.close();
1657
+ supervisor?.close();
1512
1658
  }
1513
1659
 
1514
- // БУТСТРАП: лобби и установка P2P через мастер-сервер
1515
-
1516
- let lobby = null;
1517
-
1518
- // ресурсы роли хоста (комната в этой же вкладке)
1519
- let hostController = null;
1520
- let hostConnections = null;
1521
- let hostHeartbeat = null;
1522
-
1523
- // solo: авторитетный хост в главном потоке (без Worker'а)
1524
- let inlineHost = null;
1525
-
1526
- if (isDevBuild) {
1527
- window.__vimpDebug = createDebugApi({
1528
- getHostController: () => hostController,
1529
- getClientCore: () => clientCore,
1530
- reportUrl: lobbyConfig.debugReportUrl,
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
- debugLog(
1534
- 'window.__vimpDebug is available: dump, startRecording, stopRecording, divergence',
1535
- );
1681
+ supervisor.attach(transport);
1536
1682
  }
1537
1683
 
1538
- // WebRTC обязателен для P2P-игры. В Firefox RTCPeerConnection может
1539
- // отсутствовать (media.peerconnection.enabled = false, resistFingerprinting,
1540
- // приватные сборки) — честное сообщение вместо падения с чёрным экраном
1541
- function ensureWebRtcAvailable() {
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
- socketMethods[PS_TECH_INFORM_DATA](
1547
- 'WebRTC is unavailable in this browser: P2P play is impossible. ' +
1548
- 'In Firefox check that media.peerconnection.enabled is on.',
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
- return false;
1699
+ // сессия на паузе: транспорта к хосту нет, мир стоит
1700
+ function isSessionPaused() {
1701
+ return (
1702
+ supervisor?.state === SESSION_STATES.reconnecting ||
1703
+ supervisor?.state === SESSION_STATES.migrating
1704
+ );
1552
1705
  }
1553
1706
 
1554
- // устанавливает P2P-соединение с выбранным хостом и уходит из лобби
1555
- function connectToHost(hostId) {
1556
- if (!ensureWebRtcAvailable()) {
1557
- return;
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
- currentHostId = hostId;
1728
+ // транспорт к хосту оборвался в матче: мастер узнаёт об этом от гостя
1729
+ // раньше, чем сам (host-migration этап 7.2) — проба хоста или его смена.
1730
+ // Эпоха — хоста, к которому был транспорт: чужую мастер отбросит
1731
+ function reportHostUnreachable() {
1732
+ if (isLobbyMode) {
1733
+ guest.reportHostUnreachable();
1734
+ }
1735
+ }
1561
1736
 
1562
- transport = new WebRtcManager(signaling, {
1563
- iceServers: signaling.iceServers,
1564
- });
1737
+ // новый хост поднял матч заново (cold): возобновлять нечего — чистый вход в
1738
+ // ту же комнату с полным рукопожатием
1739
+ function handleColdRestart() {
1740
+ reloadToRoom();
1741
+ }
1565
1742
 
1566
- transport.publisher.on('message', handleMessage);
1567
- transport.publisher.on('close', handleDisconnect);
1568
- transport.connect(hostId);
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
- lobby?.close();
1753
+ if (document.visibilityState !== 'hidden') {
1754
+ soundManager.unmute();
1755
+ }
1571
1756
  }
1572
1757
 
1573
- // поднимает комнату в этой же вкладке (Worker хоста): хост-игрок играет через
1574
- // loopback, удалённые клиенты — по WebRTC (answerer). Клиентский код одинаков,
1575
- // отличается лишь транспорт. Выход хоста = смерть комнаты — как у клиента
1576
- async function connectAsHost(room) {
1577
- // фича-детект вместо classic-фолбэка (запретил бы ESM/инлайн WASM,
1578
- // см. PLAN.md риск №5): честная ошибка, join остаётся доступен
1579
- if (!supportsModuleWorker()) {
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
- return;
1586
- }
1766
+ // ссылка на текущую комнату (иначе — на быструю игру той же игры)
1767
+ function reloadToRoom() {
1768
+ const gameId = activeGameManifest?.id;
1587
1769
 
1588
- if (!ensureWebRtcAvailable()) {
1770
+ if (!gameId) {
1771
+ reloadPage('');
1589
1772
  return;
1590
1773
  }
1591
1774
 
1592
- // фактическая карта комнаты (из 'ready'; далее актуализируется map_changed)
1593
- let currentMapName = null;
1775
+ reloadPage(
1776
+ roomCtx.roomId
1777
+ ? formatRoomLink(gameId, roomCtx.roomId)
1778
+ : formatGameLink(gameId),
1779
+ );
1780
+ }
1594
1781
 
1595
- // отладочный контур (этап 6): рекордер живого матча и хостовый CONSOLE-лог
1596
- // поднимаются только в dev-сборке
1597
- room.isDevMode = isDevBuild;
1782
+ // хост начал рукопожатие заново посреди живой сессии (порт — какой
1783
+ // именно). Второе ядро/Application не строим: в лобби — чистый вход в ту же
1784
+ // комнату, в остальных режимах только журнал
1785
+ function handleProtocolError(port) {
1786
+ diagnostics.warn('engine.session.protocol', { port }, { source: 'client' });
1598
1787
 
1599
- // Этап 6.4: Worker грузит HostPlugin динамически по entries.host/entries.wasm
1600
- // активной игры — движок больше не знает игру статически
1601
- room.game = {
1602
- id: activeGameManifest.id,
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
- // Этап 5.1: комната стартует на актуальных картах мастера;
1609
- // недоступность каталога некритична — Worker возьмёт карты из бандла
1610
- try {
1611
- const catalog = await fetchMasterMaps();
1793
+ // БУТСТРАП: лобби и установка P2P через мастер-сервер
1612
1794
 
1613
- room.maps = catalog.maps;
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
- // Этап 5.2: Worker создаётся по манифесту мастера — бандл страницы после
1620
- // деплоя исчезает из раздачи; без манифеста (dev) — бандловый URL,
1621
- // обновления кода отключены
1622
- let workerUrl = null;
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
- try {
1625
- const manifest = await fetchWorkerManifest();
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
- // составной codeVersion (Этап 6.5): движок (worker-бандл) + игра
1628
- // (id/version активного манифеста, с которым комната стартует)
1629
- hostCodeVersion = {
1630
- engine: manifest.version,
1631
- game: { id: activeGameManifest.id, version: activeGameManifest.version },
1632
- };
1633
- workerUrl = manifest.url;
1634
- } catch (e) {
1635
- console.warn('[worker] master manifest unavailable, using bundled:', e);
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
- diagnostics.setContext({ role: 'host' });
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
- hostController = new HostController(room, {
1641
- workerUrl,
1642
- diagnostics,
1643
- onReady: readyMsg => {
1644
- currentMapName = readyMsg?.mapName;
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
- // seed мира приезжает в 'ready' (этап 1): без него запись матча
1647
- // невоспроизводима, поэтому он виден в консоли сразу
1648
- if (isDevBuild) {
1649
- debugLog('room ready', {
1650
- map: currentMapName,
1651
- seed: readyMsg?.seed,
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
- // периодический heartbeat/актуализация комнаты у мастера
1656
- const update = () =>
1657
- signaling.updateHost({
1658
- currentPlayers: 1 + (hostConnections?.peerCount || 0),
1659
- mapName: currentMapName,
1660
- });
1661
-
1662
- // регистрация комнаты; повторно вызывается при reconnect сигналинга
1663
- hostRegistration = () => {
1664
- signaling.registerHost({
1665
- name: room.name,
1666
- maxPlayers: room.maxPlayers,
1667
- mapName: currentMapName,
1668
- gameId: room.game.id,
1669
- gameVersion: room.game.version,
1670
- token: lobbyAuthModel.getToken(),
1671
- });
1672
-
1673
- clearInterval(hostHeartbeat);
1674
- hostHeartbeat = setInterval(
1675
- update,
1676
- lobbyConfig.create.heartbeatInterval,
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
- hostRegistration();
1681
- },
1951
+ if (isLobbyMode) {
1952
+ const roomMenuModel = new RoomMenuModel();
1682
1953
 
1683
- // смена карты голосованием/таймером — сразу отразить в лобби мастера
1684
- onMapChange: mapName => {
1685
- currentMapName = mapName;
1686
- signaling.updateHost({ mapName });
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
- // Worker не поднялся (WASM/конфиг): гасим комнату и возвращаемся в лобби
1690
- onError: msg => {
1691
- handleDisconnect();
1692
- socketMethods[PS_TECH_INFORM_DATA](
1693
- `Failed to start the room: ${msg.message || 'unknown error'}. Returning to lobby…`,
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
- // удалённые клиенты по WebRTC; актуализация currentPlayers при их вход/выходе
1699
- hostConnections = new HostConnectionManager(signaling, hostController, {
1700
- iceServers: signaling.iceServers,
1701
- onPeersChange: count => signaling.updateHost({ currentPlayers: 1 + count }),
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
- // хост-игрок в этой же вкладке (socketId согласован с kick-исключением)
1705
- transport = new LoopbackTransport(
1706
- hostController,
1707
- lobbyConfig.create.hostSocketId,
1708
- );
1989
+ // адресная строка показывает ссылку на комнату, пока вкладка в ней: её
1990
+ // можно скопировать прямо оттуда, как и пунктом меню комнаты
1991
+ function showRoomLink(gameId, roomId) {
1992
+ if (!gameId) {
1993
+ return;
1994
+ }
1709
1995
 
1710
- transport.publisher.on('message', handleMessage);
1711
- transport.publisher.on('close', handleDisconnect);
1712
- transport.connect();
1996
+ const link = formatRoomLink(gameId, roomId);
1713
1997
 
1714
- // сигнальный WS хоста должен жить постоянно (офферы, heartbeat, выдача) —
1715
- // при разрыве переподключаемся с бэкоффом; welcome вызовет re-register
1716
- let reconnectAttempt = 0;
1998
+ setRoute(link);
1999
+ roomMenu?.setLink(absoluteLink(link));
2000
+ refreshRoomControls();
2001
+ }
1717
2002
 
1718
- signaling.publisher.on('close', () => {
1719
- if (!hostController) {
1720
- return; // комната уже погашена
1721
- }
2003
+ // средний FPS рендера гостя за интервал отчёта (этап 9c)
2004
+ const fpsMeter = new FpsMeter();
1722
2005
 
1723
- const { baseDelay, maxDelay } = lobbyConfig.reconnect;
1724
- const delay = Math.min(maxDelay, baseDelay * 2 ** reconnectAttempt);
2006
+ document.addEventListener('visibilitychange', () => {
2007
+ const hidden = isPageHidden();
1725
2008
 
1726
- reconnectAttempt += 1;
1727
- setTimeout(() => signaling.connect(), delay);
1728
- });
2009
+ hostRole.setHidden(hidden);
2010
+ handoff.setHidden(hidden);
1729
2011
 
1730
- signaling.publisher.on('welcome', () => {
1731
- reconnectAttempt = 0;
1732
- hostRegistration?.();
1733
- });
2012
+ // кадры скрытой вкладки (их нет) не тянут средний FPS вниз
2013
+ if (!hidden) {
2014
+ fpsMeter.reset();
2015
+ }
2016
+ });
1734
2017
 
1735
- // мастер отвечает актуальными версиями каталога карт и worker-бандла:
1736
- // расхождение (деплой, пока комната жила) — подтянуть каталог к следующей
1737
- // смене карты / заменить Worker эстафетой на границе раунда (Этап 5.2)
1738
- signaling.publisher.on('host_registered', msg => {
1739
- // кодревью №1 (plan/server-rating/review.md): hostId + per-room секрет не
1740
- // известны Worker'у до этого момента — прокидываем их, чтобы PlayerDataSync
1741
- // атрибутировал последующие rank/state-flush к этой комнате (секрет
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
- if (
1750
- msg.codeVersion &&
1751
- hostCodeVersion &&
1752
- codeVersionChanged(msg.codeVersion, hostCodeVersion)
1753
- ) {
1754
- refreshHostWorker();
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
- // сигнал мастера об обновлении каталога карт/кода (hot-reload в будущем)
1759
- signaling.publisher.on('update_available', msg => {
1760
- if (!msg.mapsVersion || msg.mapsVersion !== hostMapsVersion) {
1761
- refreshHostMaps();
1762
- }
2037
+ debugLog(
2038
+ 'window.__vimpDebug is available: dump, startRecording, stopRecording, divergence',
2039
+ );
2040
+ }
1763
2041
 
1764
- if (
1765
- msg.codeVersion &&
1766
- hostCodeVersion &&
1767
- codeVersionChanged(msg.codeVersion, hostCodeVersion)
1768
- ) {
1769
- refreshHostWorker();
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
- lobby?.close();
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.publisher.on('message', handleMessage);
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.publisher.on('message', handleMessage);
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
- async function selectActiveGame(gameId) {
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
- showLobbyError(
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
- * черновика не подменит одобренный), connectAsHost поднимает комнату, а
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', ({ hostId, pingId }) => {
2255
- signaling.pingHost(hostId, pingId);
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 ({ hostId, gameId }) => {
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
- connectToHost(hostId);
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, () => lobbyAuthModel.getToken());
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(lobbyConfig.clientReports, () =>
2339
- lobbyAuthModel.getToken(),
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.then(async () => {
2365
- if (activeGameManifest) {
2366
- return; // предыдущий черновик уже стал активным
2367
- }
2513
+ stagedActivation = stagedActivation
2514
+ .then(async () => {
2515
+ if (activeGameManifest) {
2516
+ return; // предыдущий черновик уже стал активным
2517
+ }
2368
2518
 
2369
- // только на успехе: отказ активации уже написал в ту же строку СВОЮ
2370
- // причину («Failed to load X: …»), и общее «игр пока не опубликовано»
2371
- // затёрло бы её — админ увидел бы не то, что случилось
2372
- if (await selectActiveGame(manifest.id)) {
2373
- syncCatalogState();
2374
- }
2375
- }).catch(e => {
2376
- // отказ не должен заклинить очередь: следующий черновик обязан
2377
- // получить свой шанс стать активной игрой
2378
- console.error('[game] staged activation failed:', e);
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
- connectAsHost({
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
- initLobby();
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(null, '', window.location.pathname);
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 {