vimp-engine 0.35.5 → 0.36.0

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