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
|
@@ -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) {
|
|
@@ -1711,13 +1747,19 @@ function handleDisconnect() {
|
|
|
1711
1747
|
inlineHost = null;
|
|
1712
1748
|
|
|
1713
1749
|
// терминальную причину закрытия (кик, полная комната — TECH_INFORM перед
|
|
1714
|
-
// закрытием) не
|
|
1750
|
+
// закрытием) не затираем. Каналы так и не открылись — вход не состоялся:
|
|
1751
|
+
// уход на главную, как при несостоявшемся старте комнаты
|
|
1715
1752
|
if (!terminalInformShown) {
|
|
1716
|
-
|
|
1717
|
-
isLobbyMode
|
|
1718
|
-
|
|
1719
|
-
|
|
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
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
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
|
-
//
|
|
1906
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2063
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
// повторный вызов (второй
|
|
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
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// -
|
|
9
|
-
// > 0 у lostWindows сэмплов
|
|
10
|
-
//
|
|
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
|
|
36
|
-
*
|
|
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
|
-
|
|
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
|
|
252
|
-
if (!this._handoff.start({ reason
|
|
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;
|