vimp-engine 0.40.0 → 0.42.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 (49) hide show
  1. package/core/Cargo.toml +1 -1
  2. package/package.json +1 -1
  3. package/src/client/components/controller/RoomMenu.js +38 -10
  4. package/src/client/components/controller/Vote.js +26 -0
  5. package/src/client/components/model/Controls.js +9 -1
  6. package/src/client/components/model/RoomMenu.js +42 -57
  7. package/src/client/components/model/Vote.js +202 -0
  8. package/src/client/components/view/Panel.js +176 -6
  9. package/src/client/components/view/RoomMenu.js +78 -24
  10. package/src/client/components/view/Voice.js +6 -1
  11. package/src/client/components/view/Vote.js +71 -2
  12. package/src/client/lib/autoRejoin.js +161 -0
  13. package/src/client/lib/hostUnloadGuard.js +40 -15
  14. package/src/client/lib/roomLink.js +32 -0
  15. package/src/client/main.js +120 -36
  16. package/src/client/network/HostConnectionManager.js +27 -1
  17. package/src/client/network/HostController.js +1 -30
  18. package/src/client/network/HostHealthPolicy.js +14 -59
  19. package/src/client/network/PlannedHandoff.js +15 -100
  20. package/src/client/network/SessionSupervisor.js +58 -9
  21. package/src/client/network/SignalingClient.js +1 -1
  22. package/src/client/network/WebRtcManager.js +38 -3
  23. package/src/client/session/GuestSession.js +42 -7
  24. package/src/client/session/HandoffFlow.js +26 -51
  25. package/src/client/session/HostRole.js +8 -6
  26. package/src/client/session/PromotionFlow.js +28 -1
  27. package/src/client/session/RouteBoot.js +28 -9
  28. package/src/client/style.css +112 -20
  29. package/src/client/views/includes/roomMenu.pug +7 -8
  30. package/src/client/voice/MicCapture.js +3 -1
  31. package/src/config/abiOps.js +42 -0
  32. package/src/config/chatMessages.js +1 -1
  33. package/src/config/lobby.js +22 -23
  34. package/src/config/menuName.js +3 -0
  35. package/src/devtools/ScenarioRunner.js +1 -0
  36. package/src/devtools/invariants.js +24 -2
  37. package/src/devtools/report.js +2 -2
  38. package/src/host/GameCoreAdapter.js +83 -4
  39. package/src/host/HostGame.js +103 -39
  40. package/src/host/PortMachine.js +34 -17
  41. package/src/host/host.worker.js +1 -13
  42. package/src/host/meta/core/RoundManager.js +8 -22
  43. package/src/host/meta/core/VoteCoordinator.js +56 -1
  44. package/src/host/meta/modules/TimerManager.js +16 -2
  45. package/src/host/meta/modules/Vote.js +65 -0
  46. package/src/host/meta/modules/chat/systemMessages.js +1 -1
  47. package/src/host/meta/player/ParticipantManager.js +19 -3
  48. package/src/lib/capabilities.js +19 -4
  49. package/src/lib/gameConfigView.js +6 -5
@@ -1,25 +1,29 @@
1
- // Защита комнаты от случайного закрытия вкладки хоста (host-migration этап
2
- // 8.4).
1
+ // Защита комнаты от случайного закрытия вкладки хоста.
3
2
  //
4
3
  // beforeunload висит, только пока вкладка — текущий хост и в комнате есть
5
- // другие люди: Ctrl+W/F5 спросит «Leave site?». Финального дампа во время
6
- // диалога не будет — пока он открыт, главный поток стоит, а именно он
7
- // ретранслирует Worker → DataChannel; гости это время видят замерший матч.
4
+ // другие люди. При назначенной бете обработчик сразу объявляет уход мастеру
5
+ // (host_leaving — аварийная миграция к ней начинается, пока висит диалог
6
+ // «Leave site?»); без беты — нет: мастер закрыл бы комнату всем, даже если
7
+ // вкладка останется. Затем просит диалог. «Leave» — вкладка закрывается;
8
+ // «Cancel» — мастер снимает её с роли (host_revoked), и она остаётся в
9
+ // комнате гостем. Финального дампа нет: пока диалог открыт, главный поток
10
+ // стоит, а именно он ретранслирует Worker → DataChannel, — откат не больше
11
+ // интервала периодических точек.
8
12
  //
9
13
  // pagehide срабатывает и после «Leave», и при закрытии без диалога (на
10
- // мобильных beforeunload ненадёжен — там остаётся только он): хост сразу
11
- // говорит мастеру host_leaving (аварийная миграция без ожидания обрыва WS),
12
- // гость — LEAVE хосту и leave_room мастеру (его место освобождается сразу,
13
- // а не через resumeGraceMs). Всё best-effort: недошедшее перекроет обрыв
14
- // соединений.
14
+ // мобильных beforeunload ненадёжен — там остаётся только он): хост, ещё не
15
+ // объявивший уход, говорит мастеру host_leaving, гость — LEAVE хосту и
16
+ // leave_room мастеру (его место освобождается сразу, а не через
17
+ // resumeGraceMs). Всё best-effort: недошедшее перекроет обрыв соединений.
15
18
  //
16
- // exit() — вкладка уходит сама («Leave server», переход в лобби): диалог не
17
- // нужен, уход уже объявлен.
19
+ // exit() — вкладка уходит сама (Exit, переход в лобби): диалог не нужен,
20
+ // уход уже объявлен.
18
21
 
19
22
  export default class HostUnloadGuard {
20
23
  /**
21
24
  * @param {Object} options
22
- * @param {Function} options.onHostLeave - () pagehide у хоста.
25
+ * @param {Function} options.onHostLeave - () → boolean: хост уходит
26
+ * (beforeunload при бете или pagehide); true — host_leaving отправлен.
23
27
  * @param {Function} options.onGuestLeave - () pagehide у гостя комнаты.
24
28
  * @param {EventTarget} [options.target] - window (тесты — свой).
25
29
  */
@@ -31,10 +35,17 @@ export default class HostUnloadGuard {
31
35
  // null — не в комнате | 'host' | 'guest'
32
36
  this._role = null;
33
37
  this._othersPresent = false;
38
+ this._hasSuccessor = false;
34
39
  this._armed = false;
35
40
  this._exiting = false;
41
+ // host_leaving уже отправлен из beforeunload этой роли хоста
42
+ this._hostLeaveSent = false;
36
43
 
37
44
  this._onBeforeUnload = event => {
45
+ if (this._role === 'host' && this._hasSuccessor) {
46
+ this._announceHostLeave();
47
+ }
48
+
38
49
  event.preventDefault();
39
50
  // старые браузеры показывают диалог только по returnValue
40
51
  event.returnValue = '';
@@ -54,10 +65,17 @@ export default class HostUnloadGuard {
54
65
  * @param {Object} state
55
66
  * @param {'host'|'guest'|null} state.role
56
67
  * @param {boolean} [state.othersPresent]
68
+ * @param {boolean} [state.hasSuccessor] - мастер назначил бету.
57
69
  */
58
- update({ role, othersPresent = false }) {
70
+ update({ role, othersPresent = false, hasSuccessor = false }) {
59
71
  this._role = role ?? null;
60
72
  this._othersPresent = othersPresent === true;
73
+ this._hasSuccessor = hasSuccessor === true;
74
+
75
+ if (this._role !== 'host') {
76
+ this._hostLeaveSent = false;
77
+ }
78
+
61
79
  this._sync();
62
80
  }
63
81
 
@@ -98,11 +116,18 @@ export default class HostUnloadGuard {
98
116
  this._exiting = true;
99
117
 
100
118
  if (this._role === 'host') {
101
- this._onHostLeave();
119
+ this._announceHostLeave();
102
120
  } else if (this._role === 'guest') {
103
121
  this._onGuestLeave();
104
122
  }
105
123
 
106
124
  this._sync();
107
125
  }
126
+
127
+ // host_leaving один раз на роль хоста; флаг — только если ушёл
128
+ _announceHostLeave() {
129
+ if (!this._hostLeaveSent && this._onHostLeave() === true) {
130
+ this._hostLeaveSent = true;
131
+ }
132
+ }
108
133
  }
@@ -237,6 +237,38 @@ export function pickQuickPlayRoom(servers, gameId) {
237
237
  return best;
238
238
  }
239
239
 
240
+ /**
241
+ * Сообщение терминального закрытия сессии (если причину ещё не показал
242
+ * TECH_INFORM).
243
+ * @param {Object} opts
244
+ * @param {boolean} opts.isLobbyMode
245
+ * @param {boolean} opts.transportOpened - каналы транспорта открывались
246
+ * (SessionSupervisor.everOpened).
247
+ * @returns {{message: string, joinFailed: boolean}} joinFailed — вход не
248
+ * состоялся (P2P не поднялось): уход на главную, как при несостоявшемся
249
+ * старте комнаты.
250
+ */
251
+ export function decideCloseNotice({ isLobbyMode, transportOpened }) {
252
+ if (!isLobbyMode) {
253
+ return {
254
+ message: 'The match is over — connection to the host is closed.',
255
+ joinFailed: false,
256
+ };
257
+ }
258
+
259
+ if (!transportOpened) {
260
+ return {
261
+ message: 'P2P connection failed. Click to return to the lobby.',
262
+ joinFailed: true,
263
+ };
264
+ }
265
+
266
+ return {
267
+ message: 'Host left — the room is closed. Finding another room…',
268
+ joinFailed: false,
269
+ };
270
+ }
271
+
240
272
  /**
241
273
  * Куда уходить после разрыва с комнатой (лобби-режим).
242
274
  * @param {Object} reason
@@ -73,6 +73,7 @@ import SessionSupervisor, {
73
73
  import FpsMeter from './lib/FpsMeter.js';
74
74
  import HostUnloadGuard from './lib/hostUnloadGuard.js';
75
75
  import { createPageReload } from './lib/pageReload.js';
76
+ import { AutoRejoin, saveAutoRejoin } from './lib/autoRejoin.js';
76
77
  import { createAuthApi } from './lib/authApi.js';
77
78
  import {
78
79
  decideSessionStorageChange,
@@ -111,6 +112,7 @@ import RoomMenuView from './components/view/RoomMenu.js';
111
112
  import RoomMenuCtrl from './components/controller/RoomMenu.js';
112
113
  import {
113
114
  absoluteLink,
115
+ decideCloseNotice,
114
116
  decideExitRoute,
115
117
  formatGameLink,
116
118
  formatRoomLink,
@@ -589,13 +591,19 @@ socketMethods[PS_CONFIG_DATA] = async data => {
589
591
  });
590
592
  };
591
593
 
594
+ // последний PS_AUTH_DATA: форма строится из него и тогда, когда хост отбил
595
+ // автовход
596
+ let lastAuthData = null;
597
+ // автовход после перезагрузки в ту же комнату (взводит RouteBoot)
598
+ const autoRejoin = new AutoRejoin();
599
+
592
600
  // auth data
593
601
  socketMethods[PS_AUTH_DATA] = data => {
594
602
  if (typeof data !== 'object' || data === null) {
595
603
  return;
596
604
  }
597
605
 
598
- const { elems, params, texts } = data;
606
+ const { params, texts } = data;
599
607
 
600
608
  document.getElementById('logo').textContent = texts?.title || 'VIMP';
601
609
 
@@ -623,6 +631,22 @@ socketMethods[PS_AUTH_DATA] = data => {
623
631
  return;
624
632
  }
625
633
 
634
+ lastAuthData = data;
635
+
636
+ // перезагрузка после потери места (reloadToRoom): ответ сохранёнными
637
+ // значениями формы — теми же, что подставила бы форма
638
+ if (autoRejoin.begin(roomCtx.roomId)) {
639
+ showSessionOverlay('Reconnecting…');
640
+ sendAuthResponse(defaultsFrom(params));
641
+
642
+ return;
643
+ }
644
+
645
+ showAuthForm(data);
646
+ };
647
+
648
+ // форма входа игры (#auth)
649
+ function showAuthForm({ elems, params, texts }) {
626
650
  // клиент проверяет только движковые правила (isValidName): игровые
627
651
  // валидаторы (isValidModel) не идут по проводу и не грузятся с
628
652
  // ClientPlugin (HostPlugin.authSchema — только у хоста). Хост валидирует
@@ -633,19 +657,22 @@ socketMethods[PS_AUTH_DATA] = data => {
633
657
  const authView = new AuthView(authModel, elems, texts, params);
634
658
  modules.auth = new AuthCtrl(authModel, authView);
635
659
 
636
- authModel.publisher.on('socket', data => {
637
- // игровой хук авторизации (модель танка для реплик движения и выстрелов)
638
- if (clientCore) {
639
- clientPlugin.hooks.onAuth(clientCore, data);
640
- }
641
-
642
- // ник больше не вводится в игровой форме — токен лобби несёт claim
643
- // 'nick', хост проверяет его подпись по /jwks и берёт ник оттуда (Этап B3)
644
- sending(PC_AUTH_RESPONSE, { ...data, token: lobbyAuthModel?.getToken() });
645
- });
660
+ authModel.publisher.on('socket', sendAuthResponse);
646
661
 
647
662
  modules.auth.init(params);
648
- };
663
+ }
664
+
665
+ // ответ хосту на форму входа (форма или автовход)
666
+ function sendAuthResponse(data) {
667
+ // игровой хук авторизации (модель танка для реплик движения и выстрелов)
668
+ if (clientCore) {
669
+ clientPlugin.hooks.onAuth(clientCore, data);
670
+ }
671
+
672
+ // ник в игровой форме не вводится — токен лобби несёт claim 'nick', хост
673
+ // проверяет его подпись по /jwks и берёт ник оттуда
674
+ sending(PC_AUTH_RESPONSE, { ...data, token: lobbyAuthModel?.getToken() });
675
+ }
649
676
 
650
677
  // значения формы по умолчанию (schema → { name: value }): база авто-ответа
651
678
  function defaultsFrom(params) {
@@ -654,6 +681,15 @@ function defaultsFrom(params) {
654
681
 
655
682
  // auth errors
656
683
  socketMethods[PS_AUTH_RESULT] = async err => {
684
+ // автовход отбит хостом (выбор формы больше не годится) — обычная форма
685
+ // с ошибкой: игрок исправит выбор
686
+ if (autoRejoin.result(err) && lastAuthData) {
687
+ showSessionOverlay(null);
688
+ showAuthForm(lastAuthData);
689
+ modules.auth.parseRes(err);
690
+ return;
691
+ }
692
+
657
693
  if (modules.auth) {
658
694
  modules.auth.parseRes(err);
659
695
  } else if (err) {
@@ -1045,6 +1081,10 @@ socketMethods[PS_CLEAR] = function (setIdList) {
1045
1081
  }
1046
1082
  }
1047
1083
 
1084
+ // личное меню и подсказки прошлого раунда гаснут; ядро игры зажжёт
1085
+ // подсказку заново при спавне
1086
+ modules.vote?.clearPersonal();
1087
+
1048
1088
  clientCore?.reset();
1049
1089
  soundManager.reset();
1050
1090
  };
@@ -1707,13 +1747,19 @@ function handleDisconnect() {
1707
1747
  inlineHost = null;
1708
1748
 
1709
1749
  // терминальную причину закрытия (кик, полная комната — TECH_INFORM перед
1710
- // закрытием) не затираем
1750
+ // закрытием) не затираем. Каналы так и не открылись — вход не состоялся:
1751
+ // уход на главную, как при несостоявшемся старте комнаты
1711
1752
  if (!terminalInformShown) {
1712
- socketMethods[PS_TECH_INFORM_DATA](
1713
- isLobbyMode
1714
- ? 'Host left — the room is closed. Finding another room…'
1715
- : 'The match is over — connection to the host is closed.',
1716
- );
1753
+ const notice = decideCloseNotice({
1754
+ isLobbyMode,
1755
+ transportOpened: supervisor?.everOpened === true,
1756
+ });
1757
+
1758
+ if (notice.joinFailed) {
1759
+ roomCtx.startFailed = true;
1760
+ }
1761
+
1762
+ socketMethods[PS_TECH_INFORM_DATA](notice.message);
1717
1763
  }
1718
1764
 
1719
1765
  // в solo перезагружаться некуда: лобби нет, а матч поднимается с нуля
@@ -1804,6 +1850,11 @@ function isSessionPaused() {
1804
1850
  // (зацикленные звуки не должны гудеть одной нотой всю паузу). Рендер не
1805
1851
  // останавливается — кадр стоит, независимые от снапшотов анимации идут
1806
1852
  function handleSessionState(state, prev) {
1853
+ // окно «Leave the game?» не переживает паузу: клавишами владеет она
1854
+ if (state !== SESSION_STATES.inGame) {
1855
+ roomMenu?.closeConfirm();
1856
+ }
1857
+
1807
1858
  if (state === SESSION_STATES.migrating) {
1808
1859
  showSessionOverlay('Switching host…');
1809
1860
  voiceClient?.suspend();
@@ -1848,6 +1899,7 @@ function handleSessionResumed() {
1848
1899
  clientCore?.reset();
1849
1900
  // «Change host?» ведёт мастер — оно паузу переживает
1850
1901
  modules.vote?.removeHostVotes();
1902
+ roomMenu?.closeConfirm();
1851
1903
  modules.controls?.enableKeys();
1852
1904
 
1853
1905
  if (document.visibilityState !== 'hidden') {
@@ -1863,7 +1915,9 @@ function handleResumeRejected(reason) {
1863
1915
  reloadToRoom();
1864
1916
  }
1865
1917
 
1866
- // ссылка на текущую комнату (иначе — на быструю игру той же игры)
1918
+ // ссылка на текущую комнату (иначе — на быструю игру той же игры). В ту же
1919
+ // комнату — с автовходом: место потеряно, а не покинуто, и форма входа
1920
+ // игры после перезагрузки не показывается
1867
1921
  function reloadToRoom() {
1868
1922
  const gameId = activeGameManifest?.id;
1869
1923
 
@@ -1872,11 +1926,29 @@ function reloadToRoom() {
1872
1926
  return;
1873
1927
  }
1874
1928
 
1875
- reloadPage(
1876
- roomCtx.roomId
1877
- ? formatRoomLink(gameId, roomCtx.roomId)
1878
- : formatGameLink(gameId),
1879
- );
1929
+ if (roomCtx.roomId) {
1930
+ if (autoRejoin.canMark()) {
1931
+ saveAutoRejoin(
1932
+ sessionStorageOrNull(),
1933
+ { gameId, roomId: roomCtx.roomId },
1934
+ Date.now(),
1935
+ );
1936
+ }
1937
+
1938
+ reloadPage(formatRoomLink(gameId, roomCtx.roomId));
1939
+ return;
1940
+ }
1941
+
1942
+ reloadPage(formatGameLink(gameId));
1943
+ }
1944
+
1945
+ // sessionStorage или null: доступ к нему бросает, если хранилище запрещено
1946
+ function sessionStorageOrNull() {
1947
+ try {
1948
+ return window.sessionStorage;
1949
+ } catch {
1950
+ return null;
1951
+ }
1880
1952
  }
1881
1953
 
1882
1954
  // хост начал рукопожатие заново посреди живой сессии (порт — какой
@@ -1898,8 +1970,8 @@ let lobby = null;
1898
1970
  // storage): две вкладки одного профиля — два участника
1899
1971
  const memberId = isLobbyMode ? crypto.randomUUID() : null;
1900
1972
 
1901
- // меню комнаты внутри матча (этапы 3 и 8d) и защита вкладки хоста от
1902
- // случайного закрытия (этап 8.4): только лобби-режим
1973
+ // кнопка Exit внутри матча и защита вкладки хоста от случайного закрытия:
1974
+ // только лобби-режим
1903
1975
  let roomMenu = null;
1904
1976
  let unloadGuard = null;
1905
1977
  // любая программная перезагрузка — только через неё (client/lib/pageReload.js)
@@ -1960,7 +2032,7 @@ const hostRole = new HostRole({
1960
2032
  ui: {
1961
2033
  showRoomLink,
1962
2034
  refreshRoomControls,
1963
- setHandoffMenu: state => roomMenu?.setHandoff(state),
2035
+ setExitStatus: state => roomMenu?.setHandoff(state),
1964
2036
  isHidden: isPageHidden,
1965
2037
  informTech,
1966
2038
  ensureWebRtc: ensureWebRtcAvailable,
@@ -1994,7 +2066,7 @@ const handoff = new HandoffFlow({
1994
2066
  mute: () => soundManager.mute(),
1995
2067
  unmute: () => soundManager.unmute(),
1996
2068
  isHidden: isPageHidden,
1997
- setHandoffMenu: state => roomMenu?.setHandoff(state),
2069
+ setExitStatus: state => roomMenu?.setHandoff(state),
1998
2070
  sendLeave: () => sending(PC_LEAVE),
1999
2071
  reloadPage,
2000
2072
  },
@@ -2055,39 +2127,50 @@ if (isLobbyMode) {
2055
2127
  roomMenuModel,
2056
2128
  new RoomMenuView(roomMenuModel, lobbyConfig.roomMenu),
2057
2129
  {
2058
- onLeave: () => handoff.leaveByUser(),
2059
- onHandover: () => handoff.start({ reason: 'handover', stay: true }),
2130
+ onExit: () => handoff.leaveByUser(),
2131
+ // игровые клавиши молчат, пока окно открыто; при Yes вкладка уходит
2132
+ onConfirmOpen: () => modules.controls?.disableKeys(),
2133
+ // в паузе сессии или замороженной передаче клавиши включит её конец
2134
+ onConfirmClose: () => {
2135
+ if (!handoff.frozen && !isSessionPaused()) {
2136
+ modules.controls?.enableKeys();
2137
+ }
2138
+ },
2060
2139
  },
2061
2140
  );
2062
2141
 
2063
2142
  unloadGuard = new HostUnloadGuard({
2064
2143
  // мастер начинает аварийную миграцию сразу, не дожидаясь обрыва WS
2065
2144
  onHostLeave: () => {
2066
- if (hostRole.room) {
2067
- signaling.hostLeaving(hostRole.room.roomId, hostRole.room.epoch);
2145
+ if (!hostRole.room) {
2146
+ return false;
2068
2147
  }
2148
+
2149
+ signaling.hostLeaving(hostRole.room.roomId, hostRole.room.epoch);
2150
+ promotionFlow.markLeaveAnnounced();
2151
+
2152
+ return true;
2069
2153
  },
2070
2154
  // место освобождается сразу, а не через resumeGraceMs
2071
2155
  onGuestLeave: () => handoff.announceGuestLeave(),
2072
2156
  });
2073
2157
  }
2074
2158
 
2075
- // роль вкладки в комнате для меню и защиты закрытия: хост — пока у неё
2159
+ // роль вкладки в комнате для защиты закрытия: хост — пока у неё
2076
2160
  // Worker комнаты (и во время промоушена), гость — пока она в комнате
2077
2161
  function refreshRoomControls() {
2078
2162
  const role = !roomCtx.roomId ? null : hostRole.controller ? 'host' : 'guest';
2079
2163
  const othersPresent = role === 'host' && hostRole.peerCount > 0;
2080
2164
 
2081
- roomMenu?.setRole({
2165
+ unloadGuard?.update({
2082
2166
  role,
2083
2167
  othersPresent,
2084
2168
  hasSuccessor: role === 'host' && hostRole.successorMemberId !== null,
2085
2169
  });
2086
- unloadGuard?.update({ role, othersPresent });
2087
2170
  }
2088
2171
 
2089
2172
  // адресная строка показывает ссылку на комнату, пока вкладка в ней: её
2090
- // можно скопировать прямо оттуда, как и пунктом меню комнаты
2173
+ // можно скопировать прямо оттуда
2091
2174
  function showRoomLink(gameId, roomId) {
2092
2175
  if (!gameId) {
2093
2176
  return;
@@ -2729,6 +2812,7 @@ const route = new RouteBoot({
2729
2812
  connectToRoom: roomId => guest.connectToRoom(roomId),
2730
2813
  createRoom: (room, options) => hostRole.createRoom(room, options),
2731
2814
  fetchServers,
2815
+ autoRejoin,
2732
2816
  ui: {
2733
2817
  initLobby,
2734
2818
  selectLobbyGame: gameId => {
@@ -30,6 +30,7 @@ export default class HostConnectionManager {
30
30
 
31
31
  // clientId → { pc, meta, state, voice, openCount, memberId }
32
32
  this._peers = new Map();
33
+ this._destroyed = false;
33
34
 
34
35
  signaling.publisher.on('webrtc_offer', 'onOffer', this);
35
36
  signaling.publisher.on('ice_candidate', 'onRemoteCandidate', this);
@@ -38,6 +39,10 @@ export default class HostConnectionManager {
38
39
 
39
40
  // приём SDP-оффера клиента: создаёт peer, отвечает answer
40
41
  async onOffer(msg) {
42
+ if (this._destroyed) {
43
+ return;
44
+ }
45
+
41
46
  // memberId — id вкладки гостя (переживает реконнект сигналинга);
42
47
  // понадобится назначению преемника (host-migration, этап 6)
43
48
  const { clientId, sdp, memberId = null } = msg;
@@ -101,11 +106,21 @@ export default class HostConnectionManager {
101
106
  return;
102
107
  }
103
108
 
109
+ // менеджер уничтожили, пока шло согласование: ответ от закрытого pc
110
+ // гость принял бы первым и ждал бы мёртвое соединение
111
+ if (this._destroyed) {
112
+ return;
113
+ }
114
+
104
115
  this._signaling.sendAnswer(clientId, pc.localDescription);
105
116
  }
106
117
 
107
118
  // приём ICE-кандидата клиента
108
119
  async onRemoteCandidate(msg) {
120
+ if (this._destroyed) {
121
+ return;
122
+ }
123
+
109
124
  const peer = this._peers.get(msg.fromId);
110
125
 
111
126
  if (!peer) {
@@ -121,6 +136,10 @@ export default class HostConnectionManager {
121
136
 
122
137
  // сигнальный ping клиента из лобби → pong (замер приблизительный)
123
138
  onPing(msg) {
139
+ if (this._destroyed) {
140
+ return;
141
+ }
142
+
124
143
  this._signaling.pongHost(msg.clientId, msg.pingId);
125
144
  }
126
145
 
@@ -282,8 +301,15 @@ export default class HostConnectionManager {
282
301
  return this._peers.size;
283
302
  }
284
303
 
285
- // закрывает все соединения (закрытие комнаты)
304
+ // закрывает соединения и снимает подписки сигналинга (снятие роли
305
+ // хоста): брошенный менеджер иначе отвечал бы на офферы следующей роли.
306
+ // Флаг — страховка от события, разосланного в том же emit
286
307
  destroy() {
308
+ this._destroyed = true;
309
+ this._signaling.publisher.off('webrtc_offer', 'onOffer', this);
310
+ this._signaling.publisher.off('ice_candidate', 'onRemoteCandidate', this);
311
+ this._signaling.publisher.off('ping_host', 'onPing', this);
312
+
287
313
  for (const clientId of [...this._peers.keys()]) {
288
314
  this._closePeer(clientId);
289
315
  }
@@ -109,9 +109,6 @@ export default class HostController {
109
109
 
110
110
  this._voiceRelay = new VoiceRelay();
111
111
 
112
- // ожидание границы раунда плановой передачей (host-migration этап 8d)
113
- this._roundBoundaryCallback = null;
114
-
115
112
  // подписчики готовых контрольных точек (host-migration этап 5)
116
113
  this._checkpointListeners = new Set();
117
114
  // период включённых периодических точек (null — выключены): новый Worker
@@ -421,24 +418,6 @@ export default class HostController {
421
418
  return () => this._healthListeners.delete(cb);
422
419
  }
423
420
 
424
- /**
425
- * Плановая передача хоста (host-migration этап 8d): cb сработает на
426
- * ближайшей границе раунда — у игры с migration.midRound сразу (решает
427
- * Worker). Новый вызов заменяет прежний колбэк.
428
- * @param {Function} cb
429
- */
430
- awaitRoundBoundary(cb) {
431
- this._roundBoundaryCallback = cb;
432
- this._worker.postMessage({ type: 'round_boundary_wait' });
433
- }
434
-
435
- cancelRoundBoundary() {
436
- if (this._roundBoundaryCallback) {
437
- this._roundBoundaryCallback = null;
438
- this._worker.postMessage({ type: 'round_boundary_cancel' });
439
- }
440
- }
441
-
442
421
  // заморозка матча: цикл и отсчёты встают с остатками
443
422
  freeze() {
444
423
  this._worker.postMessage({ type: 'freeze' });
@@ -580,7 +559,7 @@ export default class HostController {
580
559
  return Promise.resolve();
581
560
  }
582
561
 
583
- // повторный вызов (второй клик «Leave server») — тот же промис: второй
562
+ // повторный вызов (второй Exit) — тот же промис: второй
584
563
  // HostGame.destroy() поверх идущего повторил бы flush (двойной зачёт)
585
564
  if (this._shutdownPromise) {
586
565
  return this._shutdownPromise;
@@ -816,14 +795,6 @@ export default class HostController {
816
795
  this._onHandoffState(msg.state);
817
796
  break;
818
797
 
819
- case 'round_boundary': {
820
- const cb = this._roundBoundaryCallback;
821
-
822
- this._roundBoundaryCallback = null;
823
- cb?.();
824
- break;
825
- }
826
-
827
798
  case 'checkpoint': {
828
799
  const checkpoint = { ...msg };
829
800