vimp-engine 0.41.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 (34) hide show
  1. package/package.json +1 -1
  2. package/src/client/components/controller/RoomMenu.js +38 -10
  3. package/src/client/components/model/RoomMenu.js +42 -57
  4. package/src/client/components/view/RoomMenu.js +78 -24
  5. package/src/client/lib/autoRejoin.js +161 -0
  6. package/src/client/lib/hostUnloadGuard.js +40 -15
  7. package/src/client/lib/roomLink.js +32 -0
  8. package/src/client/main.js +116 -36
  9. package/src/client/network/HostConnectionManager.js +27 -1
  10. package/src/client/network/HostController.js +1 -30
  11. package/src/client/network/HostHealthPolicy.js +14 -59
  12. package/src/client/network/PlannedHandoff.js +15 -100
  13. package/src/client/network/SessionSupervisor.js +58 -9
  14. package/src/client/network/SignalingClient.js +1 -1
  15. package/src/client/network/WebRtcManager.js +38 -3
  16. package/src/client/session/GuestSession.js +42 -7
  17. package/src/client/session/HandoffFlow.js +26 -51
  18. package/src/client/session/HostRole.js +8 -6
  19. package/src/client/session/PromotionFlow.js +28 -1
  20. package/src/client/session/RouteBoot.js +28 -9
  21. package/src/client/style.css +31 -16
  22. package/src/client/views/includes/roomMenu.pug +7 -8
  23. package/src/config/chatMessages.js +1 -1
  24. package/src/config/lobby.js +22 -23
  25. package/src/devtools/ScenarioRunner.js +1 -0
  26. package/src/devtools/invariants.js +24 -2
  27. package/src/devtools/report.js +2 -2
  28. package/src/host/HostGame.js +14 -26
  29. package/src/host/PortMachine.js +34 -17
  30. package/src/host/host.worker.js +1 -13
  31. package/src/host/meta/core/RoundManager.js +0 -21
  32. package/src/host/meta/modules/chat/systemMessages.js +1 -1
  33. package/src/lib/capabilities.js +2 -4
  34. package/src/lib/gameConfigView.js +0 -6
@@ -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) {
@@ -1711,13 +1747,19 @@ function handleDisconnect() {
1711
1747
  inlineHost = null;
1712
1748
 
1713
1749
  // терминальную причину закрытия (кик, полная комната — TECH_INFORM перед
1714
- // закрытием) не затираем
1750
+ // закрытием) не затираем. Каналы так и не открылись — вход не состоялся:
1751
+ // уход на главную, как при несостоявшемся старте комнаты
1715
1752
  if (!terminalInformShown) {
1716
- socketMethods[PS_TECH_INFORM_DATA](
1717
- isLobbyMode
1718
- ? 'Host left — the room is closed. Finding another room…'
1719
- : 'The match is over — connection to the host is closed.',
1720
- );
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);
1721
1763
  }
1722
1764
 
1723
1765
  // в solo перезагружаться некуда: лобби нет, а матч поднимается с нуля
@@ -1808,6 +1850,11 @@ function isSessionPaused() {
1808
1850
  // (зацикленные звуки не должны гудеть одной нотой всю паузу). Рендер не
1809
1851
  // останавливается — кадр стоит, независимые от снапшотов анимации идут
1810
1852
  function handleSessionState(state, prev) {
1853
+ // окно «Leave the game?» не переживает паузу: клавишами владеет она
1854
+ if (state !== SESSION_STATES.inGame) {
1855
+ roomMenu?.closeConfirm();
1856
+ }
1857
+
1811
1858
  if (state === SESSION_STATES.migrating) {
1812
1859
  showSessionOverlay('Switching host…');
1813
1860
  voiceClient?.suspend();
@@ -1852,6 +1899,7 @@ function handleSessionResumed() {
1852
1899
  clientCore?.reset();
1853
1900
  // «Change host?» ведёт мастер — оно паузу переживает
1854
1901
  modules.vote?.removeHostVotes();
1902
+ roomMenu?.closeConfirm();
1855
1903
  modules.controls?.enableKeys();
1856
1904
 
1857
1905
  if (document.visibilityState !== 'hidden') {
@@ -1867,7 +1915,9 @@ function handleResumeRejected(reason) {
1867
1915
  reloadToRoom();
1868
1916
  }
1869
1917
 
1870
- // ссылка на текущую комнату (иначе — на быструю игру той же игры)
1918
+ // ссылка на текущую комнату (иначе — на быструю игру той же игры). В ту же
1919
+ // комнату — с автовходом: место потеряно, а не покинуто, и форма входа
1920
+ // игры после перезагрузки не показывается
1871
1921
  function reloadToRoom() {
1872
1922
  const gameId = activeGameManifest?.id;
1873
1923
 
@@ -1876,11 +1926,29 @@ function reloadToRoom() {
1876
1926
  return;
1877
1927
  }
1878
1928
 
1879
- reloadPage(
1880
- roomCtx.roomId
1881
- ? formatRoomLink(gameId, roomCtx.roomId)
1882
- : formatGameLink(gameId),
1883
- );
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
+ }
1884
1952
  }
1885
1953
 
1886
1954
  // хост начал рукопожатие заново посреди живой сессии (порт — какой
@@ -1902,8 +1970,8 @@ let lobby = null;
1902
1970
  // storage): две вкладки одного профиля — два участника
1903
1971
  const memberId = isLobbyMode ? crypto.randomUUID() : null;
1904
1972
 
1905
- // меню комнаты внутри матча (этапы 3 и 8d) и защита вкладки хоста от
1906
- // случайного закрытия (этап 8.4): только лобби-режим
1973
+ // кнопка Exit внутри матча и защита вкладки хоста от случайного закрытия:
1974
+ // только лобби-режим
1907
1975
  let roomMenu = null;
1908
1976
  let unloadGuard = null;
1909
1977
  // любая программная перезагрузка — только через неё (client/lib/pageReload.js)
@@ -1964,7 +2032,7 @@ const hostRole = new HostRole({
1964
2032
  ui: {
1965
2033
  showRoomLink,
1966
2034
  refreshRoomControls,
1967
- setHandoffMenu: state => roomMenu?.setHandoff(state),
2035
+ setExitStatus: state => roomMenu?.setHandoff(state),
1968
2036
  isHidden: isPageHidden,
1969
2037
  informTech,
1970
2038
  ensureWebRtc: ensureWebRtcAvailable,
@@ -1998,7 +2066,7 @@ const handoff = new HandoffFlow({
1998
2066
  mute: () => soundManager.mute(),
1999
2067
  unmute: () => soundManager.unmute(),
2000
2068
  isHidden: isPageHidden,
2001
- setHandoffMenu: state => roomMenu?.setHandoff(state),
2069
+ setExitStatus: state => roomMenu?.setHandoff(state),
2002
2070
  sendLeave: () => sending(PC_LEAVE),
2003
2071
  reloadPage,
2004
2072
  },
@@ -2059,39 +2127,50 @@ if (isLobbyMode) {
2059
2127
  roomMenuModel,
2060
2128
  new RoomMenuView(roomMenuModel, lobbyConfig.roomMenu),
2061
2129
  {
2062
- onLeave: () => handoff.leaveByUser(),
2063
- 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
+ },
2064
2139
  },
2065
2140
  );
2066
2141
 
2067
2142
  unloadGuard = new HostUnloadGuard({
2068
2143
  // мастер начинает аварийную миграцию сразу, не дожидаясь обрыва WS
2069
2144
  onHostLeave: () => {
2070
- if (hostRole.room) {
2071
- signaling.hostLeaving(hostRole.room.roomId, hostRole.room.epoch);
2145
+ if (!hostRole.room) {
2146
+ return false;
2072
2147
  }
2148
+
2149
+ signaling.hostLeaving(hostRole.room.roomId, hostRole.room.epoch);
2150
+ promotionFlow.markLeaveAnnounced();
2151
+
2152
+ return true;
2073
2153
  },
2074
2154
  // место освобождается сразу, а не через resumeGraceMs
2075
2155
  onGuestLeave: () => handoff.announceGuestLeave(),
2076
2156
  });
2077
2157
  }
2078
2158
 
2079
- // роль вкладки в комнате для меню и защиты закрытия: хост — пока у неё
2159
+ // роль вкладки в комнате для защиты закрытия: хост — пока у неё
2080
2160
  // Worker комнаты (и во время промоушена), гость — пока она в комнате
2081
2161
  function refreshRoomControls() {
2082
2162
  const role = !roomCtx.roomId ? null : hostRole.controller ? 'host' : 'guest';
2083
2163
  const othersPresent = role === 'host' && hostRole.peerCount > 0;
2084
2164
 
2085
- roomMenu?.setRole({
2165
+ unloadGuard?.update({
2086
2166
  role,
2087
2167
  othersPresent,
2088
2168
  hasSuccessor: role === 'host' && hostRole.successorMemberId !== null,
2089
2169
  });
2090
- unloadGuard?.update({ role, othersPresent });
2091
2170
  }
2092
2171
 
2093
2172
  // адресная строка показывает ссылку на комнату, пока вкладка в ней: её
2094
- // можно скопировать прямо оттуда, как и пунктом меню комнаты
2173
+ // можно скопировать прямо оттуда
2095
2174
  function showRoomLink(gameId, roomId) {
2096
2175
  if (!gameId) {
2097
2176
  return;
@@ -2733,6 +2812,7 @@ const route = new RouteBoot({
2733
2812
  connectToRoom: roomId => guest.connectToRoom(roomId),
2734
2813
  createRoom: (room, options) => hostRole.createRoom(room, options),
2735
2814
  fetchServers,
2815
+ autoRejoin,
2736
2816
  ui: {
2737
2817
  initLobby,
2738
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
 
@@ -2,27 +2,21 @@
2
2
  // здоровья Worker'а (HostController.onHealth, сэмпл раз в ~1 с), видимости
3
3
  // вкладки и наличия беты решает, когда отдать роль бете.
4
4
  //
5
- // - Мягкая перегрузка (среднее tickRate последних сэмплов окна
6
- // overloadWindowMs ниже overloadTickRate) — передача, отложенная до
7
- // границы раунда (PlannedHandoff defer).
8
- // - Жёсткая (среднее окна criticalWindowMs ниже criticalTickRate или lostMs
9
- // > 0 у lostWindows сэмплов подряд) и скрытая вкладка дольше
10
- // hiddenHandoffMs — передача сразу; ждущую границы раунда — ускорить.
11
- // - Пока своя отложенная передача ждёт границы, а все сэмплы окна
12
- // recoverWindowMs выше recoverTickRate (гистерезис над порогом входа) —
13
- // отменить её: иначе передача сработала бы вхолостую на границе раунда.
5
+ // Передача (плановая, стартует сразу) — при любом из условий:
6
+ // - мягкая перегрузка: среднее tickRate последних сэмплов окна
7
+ // overloadWindowMs ниже overloadTickRate;
8
+ // - жёсткая: среднее окна criticalWindowMs ниже criticalTickRate или
9
+ // lostMs > 0 у lostWindows сэмплов подряд;
10
+ // - скрытая вкладка дольше hiddenHandoffMs.
14
11
  //
15
12
  // Новая передача — только при бете, после minHostTenureMs в роли хоста и
16
13
  // autoHandoffCooldownMs с прошлой авто-передачи этой вкладки (с её запуска,
17
- // при любом исходе). Отмена по нормализации кулдаун возвращает: от мигания
18
- // защищают гистерезис и окна. Политика живёт дольше роли хоста — кулдаун
19
- // переживает смену ролей.
14
+ // при любом исходе). Политика живёт дольше роли хоста — кулдаун переживает
15
+ // смену ролей.
20
16
  //
21
17
  // Без DOM: передачу, часы и таймеры инъектирует владелец
22
18
  // (client/session/HandoffFlow.js).
23
19
 
24
- export const AUTO_HANDOFF_REASONS = ['overload', 'hidden'];
25
-
26
20
  // сэмпла нет дольше этого (в интервалах здоровья) — матч стоял, окна заново
27
21
  const SAMPLE_GAP_INTERVALS = 2;
28
22
 
@@ -32,9 +26,8 @@ export default class HostHealthPolicy {
32
26
  /**
33
27
  * @param {Object} options
34
28
  * @param {Object} options.config - lobby.migration.auto.
35
- * @param {Object} options.handoff - передача: { start({ reason, defer })
36
- * → boolean, hurry(reason) → boolean, cancelDeferred() → boolean,
37
- * deferredReason() → string|null (причина передачи, ждущей границы) }.
29
+ * @param {Object} options.handoff - передача: { start({ reason }) →
30
+ * boolean (false — передача уже идёт или вкладка не хост) }.
38
31
  * @param {number} [options.healthIntervalMs] - период сэмплов Worker'а.
39
32
  * @param {Function} [options.now] - монотонные часы, мс.
40
33
  * @param {Object} [options.timers] - { setTimeout, clearTimeout } (тесты).
@@ -58,7 +51,6 @@ export default class HostHealthPolicy {
58
51
  this._hiddenTimer = null;
59
52
  this._hiddenOutcome = null;
60
53
  this._lastAutoAt = null;
61
- this._cooldownBefore = null;
62
54
  this._samples = [];
63
55
  this._lastSampleAt = null;
64
56
  }
@@ -147,7 +139,6 @@ export default class HostHealthPolicy {
147
139
  const keep = Math.max(
148
140
  this._count(this._config.overloadWindowMs),
149
141
  this._count(this._config.criticalWindowMs),
150
- this._count(this._config.recoverWindowMs),
151
142
  this._config.lostWindows,
152
143
  );
153
144
 
@@ -168,25 +159,8 @@ export default class HostHealthPolicy {
168
159
  return;
169
160
  }
170
161
 
171
- if (this._critical()) {
162
+ if (this._critical() || this._overloaded()) {
172
163
  this._handOverNow('overload');
173
- return;
174
- }
175
-
176
- const ownDeferred = AUTO_HANDOFF_REASONS.includes(
177
- this._handoff.deferredReason(),
178
- );
179
-
180
- if (ownDeferred) {
181
- if (this._recovered() && this._handoff.cancelDeferred()) {
182
- this._lastAutoAt = this._cooldownBefore;
183
- }
184
-
185
- return;
186
- }
187
-
188
- if (this._overloaded() && this._mayStart()) {
189
- this._start('overload', true);
190
164
  }
191
165
  }
192
166
 
@@ -235,25 +209,15 @@ export default class HostHealthPolicy {
235
209
  return waitMs > 0 ? 'blocked' : 'unexplained';
236
210
  }
237
211
 
238
- // немедленная передача: ждущую границы — ускорить, иначе начать
239
212
  _handOverNow(reason) {
240
- if (!this._enabled() || !this._successor) {
241
- return false;
242
- }
243
-
244
- if (this._handoff.deferredReason() !== null) {
245
- return this._handoff.hurry(reason);
246
- }
247
-
248
- return this._mayStart() && this._start(reason, false);
213
+ return this._mayStart() && this._start(reason);
249
214
  }
250
215
 
251
- _start(reason, defer) {
252
- if (!this._handoff.start({ reason, defer })) {
216
+ _start(reason) {
217
+ if (!this._handoff.start({ reason })) {
253
218
  return false;
254
219
  }
255
220
 
256
- this._cooldownBefore = this._lastAutoAt;
257
221
  this._lastAutoAt = this._now();
258
222
 
259
223
  return true;
@@ -312,15 +276,6 @@ export default class HostHealthPolicy {
312
276
  );
313
277
  }
314
278
 
315
- _recovered() {
316
- const window = this._last(this._count(this._config.recoverWindowMs));
317
-
318
- return (
319
- window !== null &&
320
- window.every(s => s.tickRate > this._config.recoverTickRate)
321
- );
322
- }
323
-
324
279
  _resetSamples() {
325
280
  this._samples = [];
326
281
  this._lastSampleAt = null;