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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vimp-engine",
3
- "version": "0.41.0",
3
+ "version": "0.42.0",
4
4
  "description": "VIMP engine SDK: browser host and client runtime, standalone mode and CLI tools (vimp-sim, vimp-contract) for VIMP game plugins",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -3,7 +3,8 @@
3
3
  let roomMenuCtrl;
4
4
 
5
5
  export default class RoomMenuCtrl {
6
- // actions — { onLeave, onHandover }: действия пунктов (client/main.js)
6
+ // actions — { onExit, onConfirmOpen, onConfirmClose } (client/main.js):
7
+ // выход подтверждён; окно открылось; окно закрыто без выхода
7
8
  constructor(model, view, actions = {}) {
8
9
  if (roomMenuCtrl) {
9
10
  return roomMenuCtrl;
@@ -13,22 +14,41 @@ export default class RoomMenuCtrl {
13
14
 
14
15
  this._model = model;
15
16
  this._view = view;
17
+ this._actions = actions;
16
18
 
17
19
  this._vPublic = view.publisher;
18
- this._vPublic.on('toggle', () => model.toggle());
19
- // меню остаётся открытым: на месте пунктов — статус передачи
20
- this._vPublic.on('leave', () => actions.onLeave?.());
21
- this._vPublic.on('handover', () => actions.onHandover?.());
20
+ this._vPublic.on('exit', () => {
21
+ if (model.openConfirm()) {
22
+ actions.onConfirmOpen?.();
23
+ }
24
+ });
25
+ this._vPublic.on('yes', () => {
26
+ if (!model.isConfirmOpen()) {
27
+ return;
28
+ }
29
+
30
+ model.closeConfirm();
31
+ actions.onExit?.();
32
+ });
33
+ this._vPublic.on('cancel', () => {
34
+ if (!model.isConfirmOpen()) {
35
+ return;
36
+ }
37
+
38
+ model.closeConfirm();
39
+ actions.onConfirmClose?.();
40
+ });
22
41
  }
23
42
 
24
- // абсолютная ссылка на комнату, в которой вкладка; null — скрыть меню
43
+ // абсолютная ссылка на комнату, в которой вкладка; null — скрыть кнопку
25
44
  setLink(link) {
26
45
  this._model.setLink(link);
27
46
  }
28
47
 
29
- // { role, othersPresent, hasSuccessor } — см. RoomMenuModel.setRole
30
- setRole(state) {
31
- this._model.setRole(state);
48
+ // закрыть окно без выхода и без возврата клавиш: пауза сессии сама ими
49
+ // управляет
50
+ closeConfirm() {
51
+ this._model.closeConfirm();
32
52
  }
33
53
 
34
54
  // true — кнопка внутри панели (игра её показывает), false — в углу
@@ -36,8 +56,16 @@ export default class RoomMenuCtrl {
36
56
  this._view.placeInPanel(inPanel);
37
57
  }
38
58
 
39
- // null | 'pending' | 'slow' | 'failed'
59
+ // null | 'pending' | 'switching' | 'slow'. Передача закрывает открытое
60
+ // окно — как Cancel: клавиши, выключенные окном, возвращает onConfirmClose
61
+ // (иначе срыв передачи до заморозки оставил бы хоста без управления)
40
62
  setHandoff(status) {
63
+ const wasOpen = this._model.isConfirmOpen();
64
+
41
65
  this._model.setHandoff(status);
66
+
67
+ if (wasOpen && !this._model.isConfirmOpen()) {
68
+ this._actions.onConfirmClose?.();
69
+ }
42
70
  }
43
71
  }
@@ -2,19 +2,18 @@ import Publisher from '../../../lib/Publisher.js';
2
2
 
3
3
  // Singleton RoomMenuModel
4
4
  //
5
- // Меню комнаты внутри матча (host-migration, этапы 3 и 8d). Ссылка на
6
- // текущую комнату — признак «вкладка в комнате». Пункты:
7
- // - «Leave server» — у всех;
8
- // - «Hand over host» — только у текущего хоста, когда в комнате есть другие
9
- // люди и мастер назначил бету (иначе передавать некому).
10
- // Пока идёт передача, пункты заблокированы, на их месте статус. Только
5
+ // Выход из комнаты внутри матча: одна кнопка Exit у всех ролей, видна, пока
6
+ // вкладка в комнате (ссылка на комнату — признак «в комнате»). Клик
7
+ // открывает окно подтверждения «Leave the game?». Пока идёт плановая
8
+ // передача хоста, кнопка заблокирована, на её месте статус. Только
11
9
  // лобби-режим: в solo ссылки на комнату нет
12
10
 
13
- // handoff: null | 'pending' | 'slow' | 'failed'
11
+ // handoff: null | 'pending' (Exit хоста) | 'switching' (автопередача,
12
+ // вкладка остаётся) | 'slow' (бета долго не отвечает)
14
13
  const STATUS_TEXT = {
15
- pending: 'Handing over…',
14
+ pending: 'Leaving…',
15
+ switching: 'Switching host…',
16
16
  slow: 'Slow connection…',
17
- failed: 'Host handover failed',
18
17
  };
19
18
 
20
19
  let roomMenuModel;
@@ -28,20 +27,18 @@ export default class RoomMenuModel {
28
27
  roomMenuModel = this;
29
28
 
30
29
  this._link = null;
31
- this._open = false;
32
- this._role = null; // null | 'host' | 'guest'
33
- this._othersPresent = false;
34
- this._hasSuccessor = false;
35
30
  this._handoff = null;
31
+ // окно подтверждения открыто
32
+ this._confirm = false;
36
33
  this.publisher = new Publisher();
37
34
  }
38
35
 
39
- // абсолютная ссылка на комнату; null — вкладка не в комнате, меню скрыто
36
+ // абсолютная ссылка на комнату; null — вкладка не в комнате, кнопка скрыта
40
37
  setLink(link) {
41
38
  this._link = link || null;
42
39
 
43
40
  if (!this._link) {
44
- this.setOpen(false);
41
+ this._confirm = false;
45
42
  }
46
43
 
47
44
  this._emitState();
@@ -51,70 +48,58 @@ export default class RoomMenuModel {
51
48
  return this._link;
52
49
  }
53
50
 
54
- /**
55
- * Роль вкладки в комнате.
56
- * @param {Object} state
57
- * @param {'host'|'guest'|null} state.role
58
- * @param {boolean} [state.othersPresent] - в комнате есть другие люди.
59
- * @param {boolean} [state.hasSuccessor] - мастер назначил бету.
60
- */
61
- setRole({ role, othersPresent = false, hasSuccessor = false }) {
62
- this._role = role ?? null;
63
- this._othersPresent = othersPresent === true;
64
- this._hasSuccessor = hasSuccessor === true;
65
- this._emitState();
66
- }
67
-
68
- // ход плановой передачи: null | 'pending' | 'slow' | 'failed'
51
+ // ход плановой передачи: null | 'pending' | 'switching' | 'slow'. Идущая
52
+ // передача закрывает окно: Yes посреди неё сорвал бы плановую передачу в
53
+ // аварийную
69
54
  setHandoff(status) {
70
55
  this._handoff = status ?? null;
56
+
57
+ if (this._handoff !== null) {
58
+ this._confirm = false;
59
+ }
60
+
71
61
  this._emitState();
72
62
  }
73
63
 
74
- setOpen(open) {
75
- const next = Boolean(open) && this._link !== null;
64
+ isConfirmOpen() {
65
+ return this._confirm;
66
+ }
76
67
 
77
- if (next === this._open) {
78
- return;
68
+ // окно открывается только у доступной кнопки; true — открылось сейчас
69
+ openConfirm() {
70
+ if (this._confirm || this._link === null || this._busy()) {
71
+ return false;
79
72
  }
80
73
 
81
- this._open = next;
82
- this.publisher.emit('open', next);
74
+ this._confirm = true;
75
+ this._emitState();
83
76
 
84
- // ошибка передачи показана — закрытое меню её забывает
85
- if (!next && this._handoff === 'failed') {
86
- this.setHandoff(null);
87
- }
77
+ return true;
88
78
  }
89
79
 
90
- toggle() {
91
- this.setOpen(!this._open);
80
+ closeConfirm() {
81
+ this._confirm = false;
82
+ this._emitState();
92
83
  }
93
84
 
94
85
  /**
95
86
  * Что показывать.
96
- * @returns {{ visible: boolean, leave: Object, handover: Object,
97
- * status: string|null }} leave/handover — { visible, disabled }.
87
+ * @returns {{ visible: boolean, exit: { disabled: boolean },
88
+ * status: string|null, confirm: boolean }}
98
89
  */
99
90
  getState() {
100
- const inRoom = this._link !== null;
101
- const busy = this._handoff === 'pending' || this._handoff === 'slow';
102
-
103
91
  return {
104
- visible: inRoom,
105
- leave: { visible: inRoom, disabled: busy },
106
- handover: {
107
- visible:
108
- inRoom &&
109
- this._role === 'host' &&
110
- this._othersPresent &&
111
- this._hasSuccessor,
112
- disabled: busy,
113
- },
92
+ visible: this._link !== null,
93
+ exit: { disabled: this._busy() },
114
94
  status: STATUS_TEXT[this._handoff] ?? null,
95
+ confirm: this._confirm,
115
96
  };
116
97
  }
117
98
 
99
+ _busy() {
100
+ return this._handoff !== null;
101
+ }
102
+
118
103
  _emitState() {
119
104
  this.publisher.emit('state', this.getState());
120
105
  }
@@ -5,8 +5,8 @@ import Publisher from '../../../lib/Publisher.js';
5
5
  let roomMenuView;
6
6
 
7
7
  export default class RoomMenuView {
8
- // config — lobbyConfig.roomMenu: { elems: { panelId, menuId, toggleId,
9
- // listId, leaveId, handoverId, statusId } }
8
+ // config — lobbyConfig.roomMenu: { elems: { panelId, menuId, exitId,
9
+ // statusId, confirmId, confirmYesId, confirmCancelId } }
10
10
  constructor(model, config) {
11
11
  if (roomMenuView) {
12
12
  return roomMenuView;
@@ -17,39 +17,36 @@ export default class RoomMenuView {
17
17
  const { elems } = config;
18
18
 
19
19
  this._menu = document.getElementById(elems.menuId);
20
- this._list = document.getElementById(elems.listId);
21
- this._leave = document.getElementById(elems.leaveId);
22
- this._handover = document.getElementById(elems.handoverId);
20
+ this._exit = document.getElementById(elems.exitId);
23
21
  this._status = document.getElementById(elems.statusId);
22
+ this._confirm = document.getElementById(elems.confirmId);
24
23
  this._panel = document.getElementById(elems.panelId);
25
24
  this._home = this._menu.parentElement;
25
+ this._onKeyDown = event => this._keyDown(event);
26
+ this._listening = false;
26
27
 
27
28
  this.publisher = new Publisher();
28
29
 
29
- document
30
- .getElementById(elems.toggleId)
31
- .addEventListener('click', () => this.publisher.emit('toggle'));
32
- this._leave.addEventListener('click', () => this.publisher.emit('leave'));
33
- this._handover.addEventListener('click', () =>
34
- this.publisher.emit('handover'),
35
- );
30
+ this._bindButton(this._exit, 'exit');
31
+ this._bindButton(document.getElementById(elems.confirmYesId), 'yes');
32
+ this._bindButton(document.getElementById(elems.confirmCancelId), 'cancel');
36
33
 
37
34
  this._mPublic = model.publisher;
38
35
  this._mPublic.on('state', 'showState', this);
39
- this._mPublic.on('open', 'showOpen', this);
40
36
  }
41
37
 
42
- showState({ visible, leave, handover, status }) {
38
+ showState({ visible, exit, status, confirm }) {
43
39
  this._menu.style.display = visible ? 'block' : 'none';
44
- showItem(this._leave, leave);
45
- showItem(this._handover, handover);
40
+ this._exit.disabled = exit.disabled;
46
41
  this._status.textContent = status ?? '';
47
42
  this._status.style.display = status ? 'block' : 'none';
43
+ this._confirm.style.display = confirm ? 'flex' : 'none';
44
+ this._listen(confirm);
48
45
  }
49
46
 
50
- // меню — часть панели (справа от таблицы), но разметка отдельная: каркас
51
- // панели общий со standalone SDK, где меню комнаты нет. Игра без панели в
52
- // initIdList держит её скрытой — тогда меню остаётся кнопкой в углу
47
+ // кнопка — часть панели (справа от таблицы), но разметка отдельная: каркас
48
+ // панели общий со standalone SDK, где выхода из комнаты нет. Игра без
49
+ // панели в initIdList держит её скрытой — тогда кнопка остаётся в углу
53
50
  placeInPanel(inPanel) {
54
51
  const parent = inPanel && this._panel ? this._panel : this._home;
55
52
 
@@ -58,12 +55,69 @@ export default class RoomMenuView {
58
55
  }
59
56
  }
60
57
 
61
- showOpen(open) {
62
- this._list.style.display = open ? 'block' : 'none';
58
+ // фокус с кнопки снимается: иначе Space/Enter в бою нажали бы её снова
59
+ _bindButton(elem, type) {
60
+ elem.addEventListener('click', () => {
61
+ elem.blur();
62
+ this.publisher.emit(type);
63
+ });
64
+ }
65
+
66
+ // фаза захвата, только пока окно открыто: Enter/Esc окна не доходят до
67
+ // игры
68
+ _listen(on) {
69
+ if (on === this._listening) {
70
+ return;
71
+ }
72
+
73
+ this._listening = on;
74
+
75
+ if (on) {
76
+ document.addEventListener('keydown', this._onKeyDown, true);
77
+ } else {
78
+ document.removeEventListener('keydown', this._onKeyDown, true);
79
+ }
80
+ }
81
+
82
+ _keyDown(event) {
83
+ // Enter/Esc в поле ввода (чат) принадлежат ему
84
+ if (isTextInput(event.target)) {
85
+ return;
86
+ }
87
+
88
+ const type =
89
+ event.key === 'Enter' ? 'yes' : event.key === 'Escape' ? 'cancel' : null;
90
+
91
+ if (!type) {
92
+ return;
93
+ }
94
+
95
+ event.stopPropagation();
96
+ event.preventDefault();
97
+ this.publisher.emit(type);
63
98
  }
64
99
  }
65
100
 
66
- function showItem(elem, { visible, disabled }) {
67
- elem.style.display = visible ? 'block' : 'none';
68
- elem.disabled = disabled;
101
+ const TEXT_INPUT_TYPES = new Set([
102
+ 'text',
103
+ 'search',
104
+ 'email',
105
+ 'password',
106
+ 'url',
107
+ 'tel',
108
+ 'number',
109
+ ]);
110
+
111
+ function isTextInput(elem) {
112
+ if (!elem || elem.nodeType !== 1) {
113
+ return false;
114
+ }
115
+
116
+ if (elem.isContentEditable || elem.tagName === 'TEXTAREA') {
117
+ return true;
118
+ }
119
+
120
+ return (
121
+ elem.tagName === 'INPUT' && TEXT_INPUT_TYPES.has(elem.type.toLowerCase())
122
+ );
69
123
  }
@@ -0,0 +1,161 @@
1
+ // Автовход после перезагрузки в ту же комнату: место в матче потеряно
2
+ // (отказ возобновления, холодный рестарт, ошибка протокола), страница
3
+ // перезагружается на ссылку комнаты — и форма входа игры не показывается,
4
+ // клиент отвечает хосту сохранёнными значениями формы.
5
+ //
6
+ // Метка — в sessionStorage: живёт только в этой вкладке и переживает
7
+ // перезагрузку. Забирается (и удаляется) при разборе маршрута: взводится в
8
+ // памяти, только если маршрут ведёт в ту же комнату. Схема — как у
9
+ // отложенного промоушена (network/Promotion.js).
10
+
11
+ export const AUTO_REJOIN_STORAGE_KEY = 'vimp.autoRejoin';
12
+
13
+ // перезагрузка с загрузкой ассетов укладывается с запасом; столько же после
14
+ // автовхода повторная метка не ставится (цикл перезагрузок)
15
+ export const AUTO_REJOIN_TTL_MS = 60000;
16
+
17
+ const isString = value => typeof value === 'string' && value !== '';
18
+
19
+ /**
20
+ * Ставит метку автовхода перед перезагрузкой.
21
+ * @param {Storage|null} storage - sessionStorage.
22
+ * @param {{ gameId: string, roomId: string }} target
23
+ * @param {number} now
24
+ * @returns {boolean} false — сохранить не удалось.
25
+ */
26
+ export function saveAutoRejoin(storage, { gameId, roomId } = {}, now) {
27
+ if (!storage || !isString(gameId) || !isString(roomId)) {
28
+ return false;
29
+ }
30
+
31
+ try {
32
+ storage.setItem(
33
+ AUTO_REJOIN_STORAGE_KEY,
34
+ JSON.stringify({ gameId, roomId, until: now + AUTO_REJOIN_TTL_MS }),
35
+ );
36
+
37
+ return true;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Забирает метку: удаляется при любом исходе.
45
+ * @param {Storage|null} storage - sessionStorage.
46
+ * @param {number} now
47
+ * @returns {{ gameId: string, roomId: string, until: number }|null}
48
+ * null — метки нет, она битая или просрочена.
49
+ */
50
+ export function takeAutoRejoin(storage, now) {
51
+ if (!storage) {
52
+ return null;
53
+ }
54
+
55
+ let raw = null;
56
+
57
+ try {
58
+ raw = storage.getItem(AUTO_REJOIN_STORAGE_KEY);
59
+ storage.removeItem(AUTO_REJOIN_STORAGE_KEY);
60
+ } catch {
61
+ return null;
62
+ }
63
+
64
+ if (!raw) {
65
+ return null;
66
+ }
67
+
68
+ let value;
69
+
70
+ try {
71
+ value = JSON.parse(raw);
72
+ } catch {
73
+ return null;
74
+ }
75
+
76
+ if (
77
+ !isString(value?.gameId) ||
78
+ !isString(value.roomId) ||
79
+ !Number.isFinite(value.until) ||
80
+ now >= value.until
81
+ ) {
82
+ return null;
83
+ }
84
+
85
+ return { gameId: value.gameId, roomId: value.roomId, until: value.until };
86
+ }
87
+
88
+ /**
89
+ * Автовход в пределах одной загрузки страницы: взвод маршрутом, ответ без
90
+ * формы, отказ хоста и запрет повторной метки сразу после автовхода.
91
+ */
92
+ export class AutoRejoin {
93
+ /**
94
+ * @param {Object} [options]
95
+ * @param {Function} [options.now]
96
+ */
97
+ constructor({ now = () => Date.now() } = {}) {
98
+ this._now = now;
99
+ // { roomId, until } — маршрут ведёт в комнату метки
100
+ this._armed = null;
101
+ // ответ без формы ушёл хосту, AUTH_RESULT ещё не пришёл
102
+ this._pending = false;
103
+ // когда эта загрузка вошла автовходом
104
+ this._usedAt = null;
105
+ }
106
+
107
+ /**
108
+ * Маршрут после перезагрузки входит в комнату метки.
109
+ * @param {{ roomId: string, until: number }} mark
110
+ */
111
+ arm({ roomId, until }) {
112
+ this._armed = { roomId, until };
113
+ }
114
+
115
+ /**
116
+ * PS_AUTH_DATA комнаты: true — ответить без формы. Взвод снимается при
117
+ * любом исходе.
118
+ * @param {string|null} roomId
119
+ * @returns {boolean}
120
+ */
121
+ begin(roomId) {
122
+ const armed = this._armed;
123
+ const now = this._now();
124
+
125
+ this._armed = null;
126
+
127
+ if (!armed || armed.roomId !== roomId || now >= armed.until) {
128
+ return false;
129
+ }
130
+
131
+ this._pending = true;
132
+ this._usedAt = now;
133
+
134
+ return true;
135
+ }
136
+
137
+ /**
138
+ * PS_AUTH_RESULT: true — хост отбил автовход, нужна форма с ошибкой.
139
+ * @param {*} err
140
+ * @returns {boolean}
141
+ */
142
+ result(err) {
143
+ const pending = this._pending;
144
+
145
+ this._pending = false;
146
+
147
+ return pending && Boolean(err);
148
+ }
149
+
150
+ /**
151
+ * Можно ли ставить метку перед перезагрузкой: сразу после автовхода —
152
+ * нет, иначе повторяющийся сбой (ошибка протокола) перезагружал бы
153
+ * страницу без конца; следующий круг покажет форму.
154
+ * @returns {boolean}
155
+ */
156
+ canMark() {
157
+ return (
158
+ this._usedAt === null || this._now() - this._usedAt >= AUTO_REJOIN_TTL_MS
159
+ );
160
+ }
161
+ }
@@ -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
  }