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
|
@@ -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;
|
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
// возвращается гостем RESUME'ом к новому хосту, иначе — уход в лобби.
|
|
7
7
|
//
|
|
8
8
|
// Исход у передачи один, и объявляется он один раз. Дедлайн deadlineMs
|
|
9
|
-
// отсчитывается от handoff_begin и перекрывает
|
|
10
|
-
// запасом на дорогу ответа: обычно
|
|
11
|
-
// (handoff_aborted), дедлайн — страховка. Медленный ответ (нет handoff_go за
|
|
9
|
+
// отсчитывается от handoff_begin и перекрывает ожидание мастером первой
|
|
10
|
+
// точки беты и его handoffTimeoutMs с запасом на дорогу ответа: обычно
|
|
11
|
+
// отмену объявляет мастер (handoff_aborted), дедлайн — страховка. Медленный ответ (нет handoff_go за
|
|
12
12
|
// slowAfterMs) — не отказ, а повод показать «медленная связь» (onSlow):
|
|
13
13
|
// поздний handoff_go до дедлайна штатно замораживает матч и шлёт финальную
|
|
14
14
|
// точку — иначе бета откатила бы всех к периодической.
|
|
@@ -19,13 +19,8 @@
|
|
|
19
19
|
// host_leaving мастеру — аварийная миграция с последней периодической
|
|
20
20
|
// точкой (при обрыве сигналинга без него: обрыв WS мастер видит сам).
|
|
21
21
|
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
// иначе отняла бы раунд у всех; у игры с midRound Worker отвечает сразу),
|
|
25
|
-
// но не позже deferMaxMs. Пока ожидание идёт, передача уже начата: повтор
|
|
26
|
-
// игнорируется, а дедлайн и onSlow отсчитываются от handoff_begin.
|
|
27
|
-
// Автотриггеры (этап 9b) ожидание сокращают (hurry — жёсткая перегрузка,
|
|
28
|
-
// скрытая вкладка) или снимают (cancelDeferred — нагрузка нормализовалась).
|
|
22
|
+
// handoff_begin уходит сразу, посреди раунда: финальная точка несёт дамп
|
|
23
|
+
// ядра, и бета продолжает раунд с того же тика.
|
|
29
24
|
//
|
|
30
25
|
// Без DOM: сигналинг, Worker хоста и таймеры инъектируются.
|
|
31
26
|
|
|
@@ -63,9 +58,7 @@ export default class PlannedHandoff {
|
|
|
63
58
|
* @param {number} [options.slowAfterMs] - нет handoff_go за это время —
|
|
64
59
|
* onSlow (передача продолжается).
|
|
65
60
|
* @param {number} [options.deadlineMs] - общий дедлайн передачи от
|
|
66
|
-
* handoff_begin (больше handoffTimeoutMs мастера).
|
|
67
|
-
* @param {number} [options.deferMaxMs] - потолок ожидания границы раунда
|
|
68
|
-
* отложенной передачи.
|
|
61
|
+
* handoff_begin (больше handoffBetaWaitMs + handoffTimeoutMs мастера).
|
|
69
62
|
* @param {Function} [options.onSlow] - () мастер отвечает медленно.
|
|
70
63
|
* @param {Function} [options.onFrozen] - ({ epoch }) матч заморожен,
|
|
71
64
|
* финальная точка запрошена; epoch — эпоха преемника.
|
|
@@ -81,8 +74,7 @@ export default class PlannedHandoff {
|
|
|
81
74
|
getRoom,
|
|
82
75
|
getController,
|
|
83
76
|
slowAfterMs = 3000,
|
|
84
|
-
deadlineMs =
|
|
85
|
-
deferMaxMs = 30000,
|
|
77
|
+
deadlineMs = 12000,
|
|
86
78
|
onSlow = () => {},
|
|
87
79
|
onFrozen = () => {},
|
|
88
80
|
onReleased = () => {},
|
|
@@ -95,7 +87,6 @@ export default class PlannedHandoff {
|
|
|
95
87
|
this._getController = getController;
|
|
96
88
|
this._slowAfterMs = slowAfterMs;
|
|
97
89
|
this._deadlineMs = deadlineMs;
|
|
98
|
-
this._deferMaxMs = deferMaxMs;
|
|
99
90
|
this._onSlow = onSlow;
|
|
100
91
|
this._onFrozen = onFrozen;
|
|
101
92
|
this._onReleasedCb = onReleased;
|
|
@@ -103,15 +94,13 @@ export default class PlannedHandoff {
|
|
|
103
94
|
this._onLeave = onLeave;
|
|
104
95
|
this._timers = timers;
|
|
105
96
|
|
|
106
|
-
// null | '
|
|
107
|
-
// handoff_go) | 'frozen' (ждём host_released)
|
|
97
|
+
// null | 'requested' (ждём handoff_go) | 'frozen' (ждём host_released)
|
|
108
98
|
this._phase = null;
|
|
109
99
|
this._room = null;
|
|
110
100
|
this._reason = null;
|
|
111
101
|
this._stay = true;
|
|
112
102
|
this._slowTimer = null;
|
|
113
103
|
this._deadlineTimer = null;
|
|
114
|
-
this._deferTimer = null;
|
|
115
104
|
}
|
|
116
105
|
|
|
117
106
|
// передача идёт: повторный start игнорируется
|
|
@@ -119,14 +108,9 @@ export default class PlannedHandoff {
|
|
|
119
108
|
return this._phase !== null;
|
|
120
109
|
}
|
|
121
110
|
|
|
122
|
-
//
|
|
123
|
-
get
|
|
124
|
-
return this._phase === '
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// причина идущей передачи (null — передачи нет)
|
|
128
|
-
get reason() {
|
|
129
|
-
return this._reason;
|
|
111
|
+
// матч заморожен в ожидании нового хоста
|
|
112
|
+
get frozen() {
|
|
113
|
+
return this._phase === 'frozen';
|
|
130
114
|
}
|
|
131
115
|
|
|
132
116
|
// идёт передача уходящего хоста (stay: false)
|
|
@@ -140,14 +124,12 @@ export default class PlannedHandoff {
|
|
|
140
124
|
* @param {string} options.reason - 'leave' | 'handover' | 'overload' |
|
|
141
125
|
* 'hidden' | 'network' (этап 9) | 'vote' (этап 10).
|
|
142
126
|
* @param {boolean} [options.stay] - остаться в комнате гостем.
|
|
143
|
-
* @param {boolean} [options.defer] - дождаться границы раунда.
|
|
144
127
|
* @returns {boolean} false — передача уже идёт или вкладка не хост.
|
|
145
128
|
*/
|
|
146
|
-
start({ reason, stay = true
|
|
129
|
+
start({ reason, stay = true }) {
|
|
147
130
|
const room = this._getRoom();
|
|
148
|
-
const controller = this._getController();
|
|
149
131
|
|
|
150
|
-
if (this.active || !room || !
|
|
132
|
+
if (this.active || !room || !this._getController()) {
|
|
151
133
|
return false;
|
|
152
134
|
}
|
|
153
135
|
|
|
@@ -159,65 +141,11 @@ export default class PlannedHandoff {
|
|
|
159
141
|
this._signaling.publisher.on(type, handler, this);
|
|
160
142
|
}
|
|
161
143
|
|
|
162
|
-
if (!defer) {
|
|
163
|
-
this._begin();
|
|
164
|
-
return true;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
this._phase = 'deferred';
|
|
168
|
-
this._deferTimer = this._timers.setTimeout(() => {
|
|
169
|
-
this._deferTimer = null;
|
|
170
|
-
this._getController()?.cancelRoundBoundary();
|
|
171
|
-
this._begin();
|
|
172
|
-
}, this._deferMaxMs);
|
|
173
|
-
controller.awaitRoundBoundary(() => this._onBoundary());
|
|
174
|
-
|
|
175
|
-
return true;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/**
|
|
179
|
-
* Не ждать границы раунда: вопрос мастеру сразу, с новой причиной.
|
|
180
|
-
* @param {string} [reason] - причина вместо исходной.
|
|
181
|
-
* @returns {boolean} false — передача не в ожидании границы.
|
|
182
|
-
*/
|
|
183
|
-
hurry(reason = this._reason) {
|
|
184
|
-
if (this._phase !== 'deferred') {
|
|
185
|
-
return false;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
this._clearDeferTimer();
|
|
189
|
-
this._getController()?.cancelRoundBoundary();
|
|
190
|
-
this._reason = reason;
|
|
191
144
|
this._begin();
|
|
192
145
|
|
|
193
146
|
return true;
|
|
194
147
|
}
|
|
195
148
|
|
|
196
|
-
/**
|
|
197
|
-
* Снять передачу, ждущую границы раунда, — тихо, без колбэков (мастер
|
|
198
|
-
* ещё не спрашивался, матч не замораживался).
|
|
199
|
-
* @returns {boolean} false — передача не в ожидании границы.
|
|
200
|
-
*/
|
|
201
|
-
cancelDeferred() {
|
|
202
|
-
if (this._phase !== 'deferred') {
|
|
203
|
-
return false;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
this._finish();
|
|
207
|
-
|
|
208
|
-
return true;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
// граница раунда наступила (у игры с midRound — сразу)
|
|
212
|
-
_onBoundary() {
|
|
213
|
-
if (this._phase !== 'deferred') {
|
|
214
|
-
return;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
this._clearDeferTimer();
|
|
218
|
-
this._begin();
|
|
219
|
-
}
|
|
220
|
-
|
|
221
149
|
// вопрос мастеру: с этого момента идут onSlow и дедлайн
|
|
222
150
|
_begin() {
|
|
223
151
|
const room = this._room;
|
|
@@ -274,10 +202,9 @@ export default class PlannedHandoff {
|
|
|
274
202
|
}
|
|
275
203
|
}
|
|
276
204
|
|
|
277
|
-
// бета не заняла комнату: эпоха прежняя, матч продолжается
|
|
278
|
-
// Отложенная передача мастера ещё не спрашивала — отмена не про неё
|
|
205
|
+
// бета не заняла комнату: эпоха прежняя, матч продолжается здесь
|
|
279
206
|
_onAborted(msg) {
|
|
280
|
-
if (this._ownRoom(msg)
|
|
207
|
+
if (this._ownRoom(msg)) {
|
|
281
208
|
this._cancel('aborted');
|
|
282
209
|
}
|
|
283
210
|
}
|
|
@@ -329,11 +256,6 @@ export default class PlannedHandoff {
|
|
|
329
256
|
_finish() {
|
|
330
257
|
this._clearSlowTimer();
|
|
331
258
|
|
|
332
|
-
if (this._phase === 'deferred') {
|
|
333
|
-
this._clearDeferTimer();
|
|
334
|
-
this._getController()?.cancelRoundBoundary();
|
|
335
|
-
}
|
|
336
|
-
|
|
337
259
|
if (this._deadlineTimer !== null) {
|
|
338
260
|
this._timers.clearTimeout(this._deadlineTimer);
|
|
339
261
|
this._deadlineTimer = null;
|
|
@@ -350,13 +272,6 @@ export default class PlannedHandoff {
|
|
|
350
272
|
this._reason = null;
|
|
351
273
|
}
|
|
352
274
|
|
|
353
|
-
_clearDeferTimer() {
|
|
354
|
-
if (this._deferTimer !== null) {
|
|
355
|
-
this._timers.clearTimeout(this._deferTimer);
|
|
356
|
-
this._deferTimer = null;
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
|
|
360
275
|
_clearSlowTimer() {
|
|
361
276
|
if (this._slowTimer !== null) {
|
|
362
277
|
this._timers.clearTimeout(this._slowTimer);
|
|
@@ -12,7 +12,8 @@ import wsports from '../../config/wsports.js';
|
|
|
12
12
|
// принимает владелец (client/main.js) по колбэкам.
|
|
13
13
|
//
|
|
14
14
|
// Состояния: connecting → handshake → inGame → reconnecting → inGame | closed;
|
|
15
|
-
// любое живое → migrating → reconnecting (новый хост) | closed.
|
|
15
|
+
// любое живое → migrating → reconnecting (новый хост) | closed. Вход, каналы
|
|
16
|
+
// которого не открылись, повторяется в connecting (joinRetries).
|
|
16
17
|
|
|
17
18
|
const PC_RESUME_REQUEST = wsports.client.RESUME_REQUEST;
|
|
18
19
|
|
|
@@ -68,10 +69,13 @@ export default class SessionSupervisor {
|
|
|
68
69
|
* @param {Object|null} [opts.reconnect] - null — переподключения нет
|
|
69
70
|
* (solo, вкладка-хост на loopback). Иначе:
|
|
70
71
|
* { createTransport() → новый транспорт с resume (уже connect'нутый),
|
|
71
|
-
* getToken() → identity-токен для RESUME_REQUEST
|
|
72
|
+
* getToken() → identity-токен для RESUME_REQUEST,
|
|
73
|
+
* createJoinTransport() → новый транспорт обычного входа (повтор
|
|
74
|
+
* входа, каналы которого не открылись; без него такой сбой
|
|
75
|
+
* терминален) }.
|
|
72
76
|
* @param {Object} [opts.timing] - { reconnectWindowMs, reconnectBaseDelayMs,
|
|
73
77
|
* reconnectMaxDelayMs, hostSilenceMs, migrationWaitMs,
|
|
74
|
-
* resumeSilenceGraceMs }
|
|
78
|
+
* resumeSilenceGraceMs, joinRetries }
|
|
75
79
|
* (config/lobby.js → session).
|
|
76
80
|
* @param {Object} [opts.clock] - часы и таймеры (тесты).
|
|
77
81
|
*/
|
|
@@ -108,8 +112,11 @@ export default class SessionSupervisor {
|
|
|
108
112
|
this._silenceMs = timing.hostSilenceMs ?? 3000;
|
|
109
113
|
this._migrationWaitMs = timing.migrationWaitMs ?? 20000;
|
|
110
114
|
this._resumeSilenceGraceMs = timing.resumeSilenceGraceMs ?? 3000;
|
|
115
|
+
this._joinRetries = timing.joinRetries ?? 2;
|
|
111
116
|
|
|
112
117
|
this._state = S.connecting;
|
|
118
|
+
// каналы хоть одного транспорта открывались (P2P поднималось)
|
|
119
|
+
this._everOpened = false;
|
|
113
120
|
this._transport = null;
|
|
114
121
|
this._handlers = null;
|
|
115
122
|
|
|
@@ -126,6 +133,8 @@ export default class SessionSupervisor {
|
|
|
126
133
|
this._awaitingFrame = false;
|
|
127
134
|
|
|
128
135
|
this._attempt = 0;
|
|
136
|
+
// повторы первого входа (каналы ещё ни разу не открывались)
|
|
137
|
+
this._joinAttempt = 0;
|
|
129
138
|
this._attemptTimer = null;
|
|
130
139
|
this._windowTimer = null;
|
|
131
140
|
this._migrationTimer = null;
|
|
@@ -139,6 +148,12 @@ export default class SessionSupervisor {
|
|
|
139
148
|
return this._state;
|
|
140
149
|
}
|
|
141
150
|
|
|
151
|
+
// каналы транспорта открывались хоть раз: false у закрытой сессии —
|
|
152
|
+
// P2P так и не поднялось (несостоявшийся вход)
|
|
153
|
+
get everOpened() {
|
|
154
|
+
return this._everOpened;
|
|
155
|
+
}
|
|
156
|
+
|
|
142
157
|
get transport() {
|
|
143
158
|
return this._transport;
|
|
144
159
|
}
|
|
@@ -552,6 +567,8 @@ export default class SessionSupervisor {
|
|
|
552
567
|
return;
|
|
553
568
|
}
|
|
554
569
|
|
|
570
|
+
this._everOpened = true;
|
|
571
|
+
|
|
555
572
|
if (this._state === S.connecting) {
|
|
556
573
|
this._setState(S.handshake);
|
|
557
574
|
return;
|
|
@@ -605,9 +622,40 @@ export default class SessionSupervisor {
|
|
|
605
622
|
return;
|
|
606
623
|
}
|
|
607
624
|
|
|
625
|
+
if (this._canRetryJoin()) {
|
|
626
|
+
this._scheduleJoinRetry();
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
|
|
608
630
|
this._terminate();
|
|
609
631
|
}
|
|
610
632
|
|
|
633
|
+
// вход, каналы которого ни разу не открылись (сбой WebRTC-согласования,
|
|
634
|
+
// ICE не сошёлся), повторяется свежим транспортом: такие сбои часто
|
|
635
|
+
// разовые. Закрытие входа после открытия каналов терминально
|
|
636
|
+
_canRetryJoin() {
|
|
637
|
+
return (
|
|
638
|
+
this._state === S.connecting &&
|
|
639
|
+
!this._everOpened &&
|
|
640
|
+
!this._userLeft &&
|
|
641
|
+
typeof this._reconnect?.createJoinTransport === 'function' &&
|
|
642
|
+
this._joinAttempt < this._joinRetries &&
|
|
643
|
+
!this._isTerminalClose()
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
_scheduleJoinRetry() {
|
|
648
|
+
this._joinAttempt += 1;
|
|
649
|
+
|
|
650
|
+
this._attemptTimer = this._clock.setTimeout(() => {
|
|
651
|
+
this._attemptTimer = null;
|
|
652
|
+
|
|
653
|
+
if (this._state === S.connecting && !this._userLeft) {
|
|
654
|
+
this._setTransport(this._reconnect.createJoinTransport());
|
|
655
|
+
}
|
|
656
|
+
}, this._backoffDelay(this._joinAttempt));
|
|
657
|
+
}
|
|
658
|
+
|
|
611
659
|
// нетерминальным бывает только обрыв посреди матча, у которого есть место
|
|
612
660
|
// для возврата и кому возвращаться
|
|
613
661
|
_canResume() {
|
|
@@ -666,17 +714,18 @@ export default class SessionSupervisor {
|
|
|
666
714
|
}
|
|
667
715
|
|
|
668
716
|
_scheduleAttempt() {
|
|
669
|
-
const delay = Math.min(
|
|
670
|
-
this._maxDelayMs,
|
|
671
|
-
this._baseDelayMs * 2 ** (this._attempt - 1),
|
|
672
|
-
);
|
|
673
|
-
|
|
674
717
|
this._attemptTimer = this._clock.setTimeout(
|
|
675
718
|
() => this._runAttempt(),
|
|
676
|
-
|
|
719
|
+
this._backoffDelay(this._attempt),
|
|
677
720
|
);
|
|
678
721
|
}
|
|
679
722
|
|
|
723
|
+
// пауза перед попыткой номер attempt (с 1): экспонента от
|
|
724
|
+
// reconnectBaseDelayMs, не выше reconnectMaxDelayMs
|
|
725
|
+
_backoffDelay(attempt) {
|
|
726
|
+
return Math.min(this._maxDelayMs, this._baseDelayMs * 2 ** (attempt - 1));
|
|
727
|
+
}
|
|
728
|
+
|
|
680
729
|
_clearReconnectTimers() {
|
|
681
730
|
if (this._attemptTimer !== null) {
|
|
682
731
|
this._clock.clearTimeout(this._attemptTimer);
|
|
@@ -217,7 +217,7 @@ export default class SignalingClient {
|
|
|
217
217
|
});
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
-
// хост → вкладка уходит (pagehide,
|
|
220
|
+
// хост → вкладка уходит (beforeunload/pagehide, Exit без передачи): мастер
|
|
221
221
|
// начинает аварийную миграцию сразу, не дожидаясь обрыва WS
|
|
222
222
|
hostLeaving(roomId, epoch) {
|
|
223
223
|
this._send({ type: 'host_leaving', roomId, epoch });
|
|
@@ -120,6 +120,8 @@ export default class WebRtcManager {
|
|
|
120
120
|
this._offerRetryMs = offerRetryMs;
|
|
121
121
|
this._retryTimer = null;
|
|
122
122
|
this._offerSent = false;
|
|
123
|
+
// ответ хоста принят в работу: следующие ответы на этот оффер — дубли
|
|
124
|
+
this._answerApplying = false;
|
|
123
125
|
// свои ICE-кандидаты: повтор оффера отправляет их заново — прежние
|
|
124
126
|
// ушли хосту, который оффер не принял
|
|
125
127
|
this._sentCandidates = [];
|
|
@@ -149,8 +151,19 @@ export default class WebRtcManager {
|
|
|
149
151
|
return this._iceType;
|
|
150
152
|
}
|
|
151
153
|
|
|
152
|
-
// инициирует установку соединения с текущим хостом
|
|
154
|
+
// инициирует установку соединения с текущим хостом комнаты. Не
|
|
155
|
+
// отклоняется: любой сбой установки (pc, каналы, оффер) = обрыв
|
|
156
|
+
// транспорта, повторы — у супервизора; уже закрытый менеджер _emitClose
|
|
157
|
+
// не тронет
|
|
153
158
|
async connect(roomId) {
|
|
159
|
+
try {
|
|
160
|
+
await this._negotiate(roomId);
|
|
161
|
+
} catch (e) {
|
|
162
|
+
this._emitClose();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async _negotiate(roomId) {
|
|
154
167
|
this._roomId = roomId;
|
|
155
168
|
|
|
156
169
|
const pc = this._peerFactory({ iceServers: this._iceServers });
|
|
@@ -245,10 +258,13 @@ export default class WebRtcManager {
|
|
|
245
258
|
return;
|
|
246
259
|
}
|
|
247
260
|
|
|
261
|
+
// ответ хоста уже применяется (_answerApplying): эпоха ставится лишь
|
|
262
|
+
// после setRemoteDescription, но повторять оффер уже поздно
|
|
248
263
|
if (
|
|
249
264
|
msg?.code !== 'migrating' ||
|
|
250
265
|
this._closed ||
|
|
251
266
|
!this._offerSent ||
|
|
267
|
+
this._answerApplying ||
|
|
252
268
|
this._epoch !== null ||
|
|
253
269
|
this._retryTimer !== null
|
|
254
270
|
) {
|
|
@@ -299,11 +315,30 @@ export default class WebRtcManager {
|
|
|
299
315
|
return;
|
|
300
316
|
}
|
|
301
317
|
|
|
318
|
+
// дубль или опоздавший ответ: соединение уже согласовано. Флаг ловит
|
|
319
|
+
// дубль, пришедший, пока первый ответ ещё применяется (signalingState
|
|
320
|
+
// меняется лишь по завершении setRemoteDescription)
|
|
321
|
+
if (
|
|
322
|
+
this._answerApplying ||
|
|
323
|
+
this._pc.signalingState !== 'have-local-offer'
|
|
324
|
+
) {
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
this._answerApplying = true;
|
|
329
|
+
|
|
330
|
+
try {
|
|
331
|
+
await this._pc.setRemoteDescription(msg.sdp);
|
|
332
|
+
} catch (e) {
|
|
333
|
+
// сбой согласования = обрыв транспорта: переподключение решает
|
|
334
|
+
// супервизор
|
|
335
|
+
this._emitClose();
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
302
339
|
if (msg.epoch !== undefined && msg.epoch !== null) {
|
|
303
340
|
this._epoch = msg.epoch;
|
|
304
341
|
}
|
|
305
|
-
|
|
306
|
-
await this._pc.setRemoteDescription(msg.sdp);
|
|
307
342
|
}
|
|
308
343
|
|
|
309
344
|
// приём удалённого ICE-кандидата (от хоста)
|
|
@@ -43,8 +43,10 @@ export default class GuestSession {
|
|
|
43
43
|
* startSession(transport, reconnect), closeLobby(),
|
|
44
44
|
* leaveRoomWith(text), chat(message), getVote() → модуль голосования }.
|
|
45
45
|
* @param {Object} [deps.factories] - конструкторы (тесты).
|
|
46
|
-
* @param {Object} [deps.timers] - { setTimeout }.
|
|
46
|
+
* @param {Object} [deps.timers] - { setTimeout, clearTimeout }.
|
|
47
47
|
* @param {Function} [deps.now]
|
|
48
|
+
* @param {EventTarget|null} [deps.network] - источник события 'online'
|
|
49
|
+
* (window).
|
|
48
50
|
*/
|
|
49
51
|
constructor({
|
|
50
52
|
signaling,
|
|
@@ -62,6 +64,7 @@ export default class GuestSession {
|
|
|
62
64
|
factories = {},
|
|
63
65
|
timers = globalThis,
|
|
64
66
|
now = () => Date.now(),
|
|
67
|
+
network = globalThis.window ?? null,
|
|
65
68
|
}) {
|
|
66
69
|
this._signaling = signaling;
|
|
67
70
|
this._ctx = ctx;
|
|
@@ -81,6 +84,7 @@ export default class GuestSession {
|
|
|
81
84
|
};
|
|
82
85
|
this._timers = timers;
|
|
83
86
|
this._now = now;
|
|
87
|
+
this._network = network;
|
|
84
88
|
|
|
85
89
|
// WebRTC-транспорты к хосту комнаты, созданные этой сессией
|
|
86
90
|
this._roomTransports = new WeakSet();
|
|
@@ -112,11 +116,13 @@ export default class GuestSession {
|
|
|
112
116
|
this._ui.closeLobby();
|
|
113
117
|
}
|
|
114
118
|
|
|
115
|
-
// попытки переподключения гостя: resume к текущему хосту
|
|
119
|
+
// попытки переподключения гостя: resume к текущему хосту комнаты; повтор
|
|
120
|
+
// несостоявшегося входа — обычным рукопожатием
|
|
116
121
|
reconnect() {
|
|
117
122
|
return {
|
|
118
123
|
createTransport: () =>
|
|
119
124
|
this.openTransport(this._ctx.roomId, { resume: true }),
|
|
125
|
+
createJoinTransport: () => this.openTransport(this._ctx.roomId),
|
|
120
126
|
getToken: () => this._getToken(),
|
|
121
127
|
};
|
|
122
128
|
}
|
|
@@ -143,15 +149,24 @@ export default class GuestSession {
|
|
|
143
149
|
);
|
|
144
150
|
// канал standby открывает хост только назначенной бете
|
|
145
151
|
transport.publisher.on('standby', channel => this._standby.attach(channel));
|
|
146
|
-
|
|
152
|
+
// сбой установки connect сам превращает в 'close' транспорта
|
|
153
|
+
transport.connect(roomId);
|
|
147
154
|
|
|
148
155
|
return transport;
|
|
149
156
|
}
|
|
150
157
|
|
|
151
|
-
// оффер ушёл, каналы ещё не
|
|
152
|
-
// комнаты нет
|
|
158
|
+
// оффер ушёл, каналы ещё не открыты (или вход ждёт повтора после
|
|
159
|
+
// несостоявшейся попытки): unknownRoom в этот момент — комнаты нет
|
|
153
160
|
offerPending() {
|
|
154
|
-
const
|
|
161
|
+
const supervisor = this._getSupervisor();
|
|
162
|
+
const transport = supervisor?.transport ?? null;
|
|
163
|
+
|
|
164
|
+
if (transport === null) {
|
|
165
|
+
return (
|
|
166
|
+
supervisor?.state === SESSION_STATES.connecting &&
|
|
167
|
+
!supervisor.everOpened
|
|
168
|
+
);
|
|
169
|
+
}
|
|
155
170
|
|
|
156
171
|
return this._roomTransports.has(transport) && !transport.isOpen;
|
|
157
172
|
}
|
|
@@ -176,6 +191,8 @@ export default class GuestSession {
|
|
|
176
191
|
bind() {
|
|
177
192
|
const publisher = this._signaling.publisher;
|
|
178
193
|
let reconnectAttempt = 0;
|
|
194
|
+
// отложенная попытка бэкоффа (null — сигналинг жив или уже подключается)
|
|
195
|
+
let reconnectTimer = null;
|
|
179
196
|
|
|
180
197
|
publisher.on('close', () => {
|
|
181
198
|
const {
|
|
@@ -196,7 +213,25 @@ export default class GuestSession {
|
|
|
196
213
|
);
|
|
197
214
|
|
|
198
215
|
reconnectAttempt += 1;
|
|
199
|
-
|
|
216
|
+
// повторный close до срабатывания: попытка остаётся одна
|
|
217
|
+
this._timers.clearTimeout(reconnectTimer);
|
|
218
|
+
reconnectTimer = this._timers.setTimeout(() => {
|
|
219
|
+
reconnectTimer = null;
|
|
220
|
+
this._signaling.connect();
|
|
221
|
+
}, delay);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// сеть вернулась: ступень бэкоффа (до reconnect.maxDelay) ждать нельзя —
|
|
225
|
+
// место бывшего хоста у преемника держится меньше
|
|
226
|
+
this._network?.addEventListener('online', () => {
|
|
227
|
+
if (reconnectTimer === null) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
this._timers.clearTimeout(reconnectTimer);
|
|
232
|
+
reconnectTimer = null;
|
|
233
|
+
reconnectAttempt = 0;
|
|
234
|
+
this._signaling.connect();
|
|
200
235
|
});
|
|
201
236
|
|
|
202
237
|
publisher.on('welcome', () => {
|