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.
- package/package.json +1 -1
- package/src/client/components/controller/RoomMenu.js +38 -10
- package/src/client/components/model/RoomMenu.js +42 -57
- package/src/client/components/view/RoomMenu.js +78 -24
- package/src/client/lib/autoRejoin.js +161 -0
- package/src/client/lib/hostUnloadGuard.js +40 -15
- package/src/client/lib/roomLink.js +32 -0
- package/src/client/main.js +116 -36
- package/src/client/network/HostConnectionManager.js +27 -1
- package/src/client/network/HostController.js +1 -30
- package/src/client/network/HostHealthPolicy.js +14 -59
- package/src/client/network/PlannedHandoff.js +15 -100
- package/src/client/network/SessionSupervisor.js +58 -9
- package/src/client/network/SignalingClient.js +1 -1
- package/src/client/network/WebRtcManager.js +38 -3
- package/src/client/session/GuestSession.js +42 -7
- package/src/client/session/HandoffFlow.js +26 -51
- package/src/client/session/HostRole.js +8 -6
- package/src/client/session/PromotionFlow.js +28 -1
- package/src/client/session/RouteBoot.js +28 -9
- package/src/client/style.css +31 -16
- package/src/client/views/includes/roomMenu.pug +7 -8
- package/src/config/chatMessages.js +1 -1
- package/src/config/lobby.js +22 -23
- package/src/devtools/ScenarioRunner.js +1 -0
- package/src/devtools/invariants.js +24 -2
- package/src/devtools/report.js +2 -2
- package/src/host/HostGame.js +14 -26
- package/src/host/PortMachine.js +34 -17
- package/src/host/host.worker.js +1 -13
- package/src/host/meta/core/RoundManager.js +0 -21
- package/src/host/meta/modules/chat/systemMessages.js +1 -1
- package/src/lib/capabilities.js +2 -4
- package/src/lib/gameConfigView.js +0 -6
package/package.json
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
let roomMenuCtrl;
|
|
4
4
|
|
|
5
5
|
export default class RoomMenuCtrl {
|
|
6
|
-
// actions — {
|
|
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('
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
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' | '
|
|
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
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// люди и мастер назначил бету (иначе передавать некому).
|
|
10
|
-
// Пока идёт передача, пункты заблокированы, на их месте статус. Только
|
|
5
|
+
// Выход из комнаты внутри матча: одна кнопка Exit у всех ролей, видна, пока
|
|
6
|
+
// вкладка в комнате (ссылка на комнату — признак «в комнате»). Клик
|
|
7
|
+
// открывает окно подтверждения «Leave the game?». Пока идёт плановая
|
|
8
|
+
// передача хоста, кнопка заблокирована, на её месте статус. Только
|
|
11
9
|
// лобби-режим: в solo ссылки на комнату нет
|
|
12
10
|
|
|
13
|
-
// handoff: null | 'pending'
|
|
11
|
+
// handoff: null | 'pending' (Exit хоста) | 'switching' (автопередача,
|
|
12
|
+
// вкладка остаётся) | 'slow' (бета долго не отвечает)
|
|
14
13
|
const STATUS_TEXT = {
|
|
15
|
-
pending: '
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
64
|
+
isConfirmOpen() {
|
|
65
|
+
return this._confirm;
|
|
66
|
+
}
|
|
76
67
|
|
|
77
|
-
|
|
78
|
-
|
|
68
|
+
// окно открывается только у доступной кнопки; true — открылось сейчас
|
|
69
|
+
openConfirm() {
|
|
70
|
+
if (this._confirm || this._link === null || this._busy()) {
|
|
71
|
+
return false;
|
|
79
72
|
}
|
|
80
73
|
|
|
81
|
-
this.
|
|
82
|
-
this.
|
|
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
|
-
|
|
91
|
-
this.
|
|
80
|
+
closeConfirm() {
|
|
81
|
+
this._confirm = false;
|
|
82
|
+
this._emitState();
|
|
92
83
|
}
|
|
93
84
|
|
|
94
85
|
/**
|
|
95
86
|
* Что показывать.
|
|
96
|
-
* @returns {{ visible: boolean,
|
|
97
|
-
* status: string|null
|
|
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:
|
|
105
|
-
|
|
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,
|
|
9
|
-
//
|
|
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.
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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,
|
|
38
|
+
showState({ visible, exit, status, confirm }) {
|
|
43
39
|
this._menu.style.display = visible ? 'block' : 'none';
|
|
44
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
// Защита комнаты от случайного закрытия вкладки
|
|
2
|
-
// 8.4).
|
|
1
|
+
// Защита комнаты от случайного закрытия вкладки хоста.
|
|
3
2
|
//
|
|
4
3
|
// beforeunload висит, только пока вкладка — текущий хост и в комнате есть
|
|
5
|
-
// другие
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// соединений.
|
|
14
|
+
// мобильных beforeunload ненадёжен — там остаётся только он): хост, ещё не
|
|
15
|
+
// объявивший уход, говорит мастеру host_leaving, гость — LEAVE хосту и
|
|
16
|
+
// leave_room мастеру (его место освобождается сразу, а не через
|
|
17
|
+
// resumeGraceMs). Всё best-effort: недошедшее перекроет обрыв соединений.
|
|
15
18
|
//
|
|
16
|
-
// exit() — вкладка уходит сама (
|
|
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 - ()
|
|
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.
|
|
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
|
}
|