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.
- package/core/Cargo.toml +1 -1
- package/package.json +1 -1
- package/src/client/components/controller/RoomMenu.js +38 -10
- package/src/client/components/controller/Vote.js +26 -0
- package/src/client/components/model/Controls.js +9 -1
- package/src/client/components/model/RoomMenu.js +42 -57
- package/src/client/components/model/Vote.js +202 -0
- package/src/client/components/view/Panel.js +176 -6
- package/src/client/components/view/RoomMenu.js +78 -24
- package/src/client/components/view/Voice.js +6 -1
- package/src/client/components/view/Vote.js +71 -2
- 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 +120 -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 +112 -20
- package/src/client/views/includes/roomMenu.pug +7 -8
- package/src/client/voice/MicCapture.js +3 -1
- package/src/config/abiOps.js +42 -0
- package/src/config/chatMessages.js +1 -1
- package/src/config/lobby.js +22 -23
- package/src/config/menuName.js +3 -0
- package/src/devtools/ScenarioRunner.js +1 -0
- package/src/devtools/invariants.js +24 -2
- package/src/devtools/report.js +2 -2
- package/src/host/GameCoreAdapter.js +83 -4
- package/src/host/HostGame.js +103 -39
- package/src/host/PortMachine.js +34 -17
- package/src/host/host.worker.js +1 -13
- package/src/host/meta/core/RoundManager.js +8 -22
- package/src/host/meta/core/VoteCoordinator.js +56 -1
- package/src/host/meta/modules/TimerManager.js +16 -2
- package/src/host/meta/modules/Vote.js +65 -0
- package/src/host/meta/modules/chat/systemMessages.js +1 -1
- package/src/host/meta/player/ParticipantManager.js +19 -3
- package/src/lib/capabilities.js +19 -4
- package/src/lib/gameConfigView.js +6 -5
|
@@ -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
|
}
|
|
@@ -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
|
package/src/client/main.js
CHANGED
|
@@ -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 {
|
|
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',
|
|
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
|
-
|
|
1713
|
-
isLobbyMode
|
|
1714
|
-
|
|
1715
|
-
|
|
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
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
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
|
-
//
|
|
1902
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2059
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
// повторный вызов (второй
|
|
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
|
|