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
|
@@ -5,10 +5,9 @@ import PlannedHandoff, {
|
|
|
5
5
|
import { SESSION_STATES } from '../network/SessionSupervisor.js';
|
|
6
6
|
import TokenHandoffTimer from '../network/TokenHandoffTimer.js';
|
|
7
7
|
|
|
8
|
-
// Плановая передача хоста
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// (host-migration-review этап 15).
|
|
8
|
+
// Плановая передача хоста и уход из комнаты: кнопка Exit, автотриггеры
|
|
9
|
+
// (перегрузка, скрытая вкладка), просьба мастера (сеть, голосование),
|
|
10
|
+
// передача по сроку входа.
|
|
12
11
|
//
|
|
13
12
|
// Без DOM: сигналинг, роль хоста, супервизор, UI-действия и фабрики
|
|
14
13
|
// инъектирует сборщик (client/main.js).
|
|
@@ -27,7 +26,7 @@ export default class HandoffFlow {
|
|
|
27
26
|
* @param {Object} deps.config - lobby.migration.
|
|
28
27
|
* @param {Object} deps.ui - { showSessionOverlay(text|null),
|
|
29
28
|
* disableControls(), enableControls(), mute(), unmute(), isHidden(),
|
|
30
|
-
*
|
|
29
|
+
* setExitStatus(state|null), sendLeave(), reloadPage(hash) }.
|
|
31
30
|
* @param {Object} [deps.factories] - конструкторы (тесты).
|
|
32
31
|
*/
|
|
33
32
|
constructor({
|
|
@@ -81,6 +80,11 @@ export default class HandoffFlow {
|
|
|
81
80
|
return this._planned?.leaving === true;
|
|
82
81
|
}
|
|
83
82
|
|
|
83
|
+
// матч заморожен до прихода нового хоста
|
|
84
|
+
get frozen() {
|
|
85
|
+
return this._planned?.frozen === true;
|
|
86
|
+
}
|
|
87
|
+
|
|
84
88
|
// подписки сигналинга передачи (лобби-режим, один раз на страницу)
|
|
85
89
|
bind() {
|
|
86
90
|
const migration = this._config;
|
|
@@ -91,8 +95,7 @@ export default class HandoffFlow {
|
|
|
91
95
|
getController: () => this._hostRole.controller,
|
|
92
96
|
slowAfterMs: migration.handoffSlowMs,
|
|
93
97
|
deadlineMs: migration.handoffDeadlineMs,
|
|
94
|
-
|
|
95
|
-
onSlow: () => this._ui.setHandoffMenu('slow'),
|
|
98
|
+
onSlow: () => this._ui.setExitStatus('slow'),
|
|
96
99
|
onFrozen: () => this._onFrozen(),
|
|
97
100
|
onReleased: msg => this._onReleased(msg),
|
|
98
101
|
onAborted: msg => this._onAborted(msg),
|
|
@@ -103,29 +106,13 @@ export default class HandoffFlow {
|
|
|
103
106
|
this._policy = this._factories.healthPolicy({
|
|
104
107
|
config: migration.auto,
|
|
105
108
|
handoff: {
|
|
106
|
-
start: ({ reason
|
|
107
|
-
hurry: reason => this._planned.hurry(reason),
|
|
108
|
-
// нагрузка нормализовалась до границы раунда — передача не нужна
|
|
109
|
-
cancelDeferred: () => {
|
|
110
|
-
const cancelled = this._planned.cancelDeferred();
|
|
111
|
-
|
|
112
|
-
if (cancelled) {
|
|
113
|
-
this._ui.setHandoffMenu(null);
|
|
114
|
-
// нагрузка нормализовалась до границы раунда — передача не
|
|
115
|
-
// нужна, а отложенное или вытесненное ею обновление кода — нужно
|
|
116
|
-
this._hostRole.resumeCodeUpdate();
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
return cancelled;
|
|
120
|
-
},
|
|
121
|
-
deferredReason: () =>
|
|
122
|
-
this._planned.deferred ? this._planned.reason : null,
|
|
109
|
+
start: ({ reason }) => this.start({ reason, stay: true }),
|
|
123
110
|
},
|
|
124
111
|
});
|
|
125
112
|
this._policy.setHidden(this._ui.isHidden());
|
|
126
113
|
|
|
127
114
|
// мастер просит отдать роль: сеть хоста заметно хуже, чем у беты (этап
|
|
128
|
-
// 9c)
|
|
115
|
+
// 9c) — передача сразу; выключенные автотриггеры — отказ
|
|
129
116
|
this._signaling.publisher.on('request_handoff', msg => {
|
|
130
117
|
const room = this._hostRole.room;
|
|
131
118
|
const ownRoom =
|
|
@@ -134,13 +121,10 @@ export default class HandoffFlow {
|
|
|
134
121
|
msg.epoch === room.epoch;
|
|
135
122
|
|
|
136
123
|
// хоста сняли голосованием (этап 10): передача сразу, при любых
|
|
137
|
-
// настройках автотриггеров — иначе мастер снимет его
|
|
124
|
+
// настройках автотриггеров — иначе мастер снимет его аварийно. Уже
|
|
125
|
+
// идущая передача (start её не повторяет) — её исход решит мастер
|
|
138
126
|
if (msg.reason === 'vote' && ownRoom) {
|
|
139
|
-
|
|
140
|
-
// идущая — её исход решит мастер
|
|
141
|
-
if (!this._planned?.hurry('vote')) {
|
|
142
|
-
this.start({ reason: 'vote', stay: true, defer: false });
|
|
143
|
-
}
|
|
127
|
+
this.start({ reason: 'vote', stay: true });
|
|
144
128
|
|
|
145
129
|
return;
|
|
146
130
|
}
|
|
@@ -150,11 +134,7 @@ export default class HandoffFlow {
|
|
|
150
134
|
migration.auto.enabled !== false &&
|
|
151
135
|
ownRoom
|
|
152
136
|
) {
|
|
153
|
-
this.start({
|
|
154
|
-
reason: 'network',
|
|
155
|
-
stay: true,
|
|
156
|
-
defer: msg.defer !== false,
|
|
157
|
-
});
|
|
137
|
+
this.start({ reason: 'network', stay: true });
|
|
158
138
|
}
|
|
159
139
|
});
|
|
160
140
|
|
|
@@ -172,22 +152,17 @@ export default class HandoffFlow {
|
|
|
172
152
|
}
|
|
173
153
|
|
|
174
154
|
/**
|
|
175
|
-
* Отдать роль хоста бете без отката:
|
|
176
|
-
*
|
|
177
|
-
* время идущей передачи игнорируется.
|
|
178
|
-
*
|
|
179
|
-
* defer (по умолчанию — для stay): передача ждёт границы раунда, если игра
|
|
180
|
-
* не умеет продолжать посреди него (без migration.midRound мягкая точка
|
|
181
|
-
* начала бы раунд у беты заново); решает Worker, потолок —
|
|
182
|
-
* lobby.migration.deferMaxMs. Уходящий хост не ждёт.
|
|
155
|
+
* Отдать роль хоста бете без отката: Exit (stay: false — затем в лобби),
|
|
156
|
+
* автотриггеры и просьбы мастера (stay: true — дальше гостем). Повторный
|
|
157
|
+
* вызов во время идущей передачи игнорируется. Передача начинается сразу, посреди
|
|
158
|
+
* раунда: бета продолжает его с того же тика.
|
|
183
159
|
* @param {Object} options
|
|
184
160
|
* @param {string} options.reason - 'leave' | 'handover' | 'overload' |
|
|
185
161
|
* 'hidden' | 'network' | 'vote'.
|
|
186
162
|
* @param {boolean} [options.stay]
|
|
187
|
-
* @param {boolean} [options.defer]
|
|
188
163
|
* @returns {boolean} передача началась.
|
|
189
164
|
*/
|
|
190
|
-
start({ reason, stay = true
|
|
165
|
+
start({ reason, stay = true }) {
|
|
191
166
|
const controller = this._hostRole.controller;
|
|
192
167
|
|
|
193
168
|
// промоушен сам владеет Worker'ом; передача уже идёт; комната ещё не
|
|
@@ -207,14 +182,14 @@ export default class HandoffFlow {
|
|
|
207
182
|
return false;
|
|
208
183
|
}
|
|
209
184
|
|
|
210
|
-
const started = this._planned.start({ reason, stay
|
|
185
|
+
const started = this._planned.start({ reason, stay });
|
|
211
186
|
|
|
212
187
|
// эстафета Worker'ов уступает только начавшейся передаче (преемник и так
|
|
213
188
|
// поднимется на актуальном коде); всё синхронно — состояние свопа между
|
|
214
189
|
// проверкой и вытеснением не меняется
|
|
215
190
|
if (started) {
|
|
216
191
|
this._hostRole.preemptSwap();
|
|
217
|
-
this._ui.
|
|
192
|
+
this._ui.setExitStatus(stay ? 'switching' : 'pending');
|
|
218
193
|
}
|
|
219
194
|
|
|
220
195
|
return started;
|
|
@@ -235,7 +210,7 @@ export default class HandoffFlow {
|
|
|
235
210
|
tryStart: () =>
|
|
236
211
|
Boolean(this._hostRole.controller) &&
|
|
237
212
|
this._hostRole.successorMemberId !== null &&
|
|
238
|
-
this.start({ reason: 'handover', stay: true
|
|
213
|
+
this.start({ reason: 'handover', stay: true }),
|
|
239
214
|
});
|
|
240
215
|
this._tokenHandoff.arm();
|
|
241
216
|
}
|
|
@@ -266,7 +241,7 @@ export default class HandoffFlow {
|
|
|
266
241
|
return this._policy?.hiddenHandoff() ?? null;
|
|
267
242
|
}
|
|
268
243
|
|
|
269
|
-
//
|
|
244
|
+
// Exit (подтверждён в окне). Хост при других людях отдаёт
|
|
270
245
|
// роль бете без отката и уходит; один в комнате — закрывает её
|
|
271
246
|
// (host_leaving: людей нет — мастер закрывает комнату сразу). Гость
|
|
272
247
|
// снимается у хоста сразу (LEAVE), а не через resumeGraceMs
|
|
@@ -358,7 +333,7 @@ export default class HandoffFlow {
|
|
|
358
333
|
// продолжается (Worker разморожен, гостям ушла полная синхронизация)
|
|
359
334
|
_onAborted({ reason, frozen }) {
|
|
360
335
|
console.warn(`[handoff] not completed: ${reason}`);
|
|
361
|
-
this._ui.
|
|
336
|
+
this._ui.setExitStatus(null);
|
|
362
337
|
|
|
363
338
|
const { hideOverlay, restoreInput } = controlsAfterAbort({
|
|
364
339
|
frozen,
|
|
@@ -44,7 +44,7 @@ export default class HostRole {
|
|
|
44
44
|
* @param {Function} deps.onPromotionRegistered - (hostPromotion) комната
|
|
45
45
|
* занята преемником (PromotionFlow.finish).
|
|
46
46
|
* @param {Object} deps.ui - { showRoomLink(gameId, roomId),
|
|
47
|
-
* refreshRoomControls(),
|
|
47
|
+
* refreshRoomControls(), setExitStatus(state), isHidden(),
|
|
48
48
|
* informTech(text), ensureWebRtc() → bool, startSession(transport),
|
|
49
49
|
* closeLobby(), onStartFailed(msg) }.
|
|
50
50
|
* @param {boolean} [deps.isLobbyMode]
|
|
@@ -285,7 +285,9 @@ export default class HostRole {
|
|
|
285
285
|
|
|
286
286
|
this._diagnostics.setContext({ role: 'host' });
|
|
287
287
|
|
|
288
|
-
// удалённые клиенты по WebRTC
|
|
288
|
+
// удалённые клиенты по WebRTC; adopt без teardown не оставляет прежний
|
|
289
|
+
// менеджер подписанным на офферы
|
|
290
|
+
this._connections?.destroy();
|
|
289
291
|
this._connections = this._factories.connections(
|
|
290
292
|
this._signaling,
|
|
291
293
|
controller,
|
|
@@ -458,7 +460,7 @@ export default class HostRole {
|
|
|
458
460
|
this._roomConfig = null;
|
|
459
461
|
this._promotion = null;
|
|
460
462
|
this._successorMemberId = null;
|
|
461
|
-
this._ui.
|
|
463
|
+
this._ui.setExitStatus(null);
|
|
462
464
|
this._ui.refreshRoomControls();
|
|
463
465
|
}
|
|
464
466
|
|
|
@@ -698,9 +700,9 @@ export default class HostRole {
|
|
|
698
700
|
}
|
|
699
701
|
|
|
700
702
|
/**
|
|
701
|
-
* Передача, отложившая или вытеснившая эстафету,
|
|
702
|
-
*
|
|
703
|
-
*
|
|
703
|
+
* Передача, отложившая или вытеснившая эстафету, сорвалась — обновление
|
|
704
|
+
* кода нужно снова. Без отложенного обновления ничего не делает:
|
|
705
|
+
* манифесты не качаются зря.
|
|
704
706
|
* @returns {Promise<void>}
|
|
705
707
|
*/
|
|
706
708
|
async resumeCodeUpdate() {
|
|
@@ -75,6 +75,9 @@ export default class PromotionFlow {
|
|
|
75
75
|
|
|
76
76
|
// промоушен из контрольной точки, Worker которого ещё поднимается
|
|
77
77
|
this._inFlight = null;
|
|
78
|
+
// роль хоста, с которой вкладка сама объявила уход (host_leaving из
|
|
79
|
+
// диалога закрытия): { roomId, epoch } | null
|
|
80
|
+
this._leaveAnnounced = null;
|
|
78
81
|
}
|
|
79
82
|
|
|
80
83
|
get inFlight() {
|
|
@@ -112,11 +115,34 @@ export default class PromotionFlow {
|
|
|
112
115
|
this._hostRole.controller &&
|
|
113
116
|
msg.roomId === this._hostRole.room?.roomId
|
|
114
117
|
) {
|
|
115
|
-
|
|
118
|
+
// уход объявлен самой вкладкой («Cancel» в диалоге закрытия):
|
|
119
|
+
// «связь потеряна» было бы ложью
|
|
120
|
+
this.demote(msg.epoch, { notice: !this._leftThisRole() });
|
|
116
121
|
}
|
|
117
122
|
});
|
|
118
123
|
}
|
|
119
124
|
|
|
125
|
+
// вкладка-хост сама объявила уход (host_leaving из диалога закрытия):
|
|
126
|
+
// отметка — за текущей ролью, другую роль она не касается
|
|
127
|
+
markLeaveAnnounced() {
|
|
128
|
+
const room = this._hostRole.room;
|
|
129
|
+
|
|
130
|
+
this._leaveAnnounced = room
|
|
131
|
+
? { roomId: room.roomId, epoch: room.epoch }
|
|
132
|
+
: null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// снимают ту роль хоста, с которой вкладка объявила уход
|
|
136
|
+
_leftThisRole() {
|
|
137
|
+
const room = this._hostRole.room;
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
this._leaveAnnounced !== null &&
|
|
141
|
+
room?.roomId === this._leaveAnnounced.roomId &&
|
|
142
|
+
room.epoch === this._leaveAnnounced.epoch
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
120
146
|
handlePromote(msg) {
|
|
121
147
|
// повтор promote нашей плановой передачи режимом checkpoint: хост пропал
|
|
122
148
|
// посреди неё — финальную точку больше не ждём
|
|
@@ -282,6 +308,7 @@ export default class PromotionFlow {
|
|
|
282
308
|
return;
|
|
283
309
|
}
|
|
284
310
|
|
|
311
|
+
this._leaveAnnounced = null;
|
|
285
312
|
this._hostRole.teardown();
|
|
286
313
|
this._standby.teardown();
|
|
287
314
|
this._ctx.epoch = minEpoch;
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
quickPlayCreateDelay,
|
|
8
8
|
} from '../lib/roomLink.js';
|
|
9
9
|
import { takePendingPromotion } from '../network/Promotion.js';
|
|
10
|
+
import { takeAutoRejoin } from '../lib/autoRejoin.js';
|
|
10
11
|
|
|
11
12
|
// Прямые ссылки (host-migration, этап 3): без hash — лобби, #/<gameId> —
|
|
12
13
|
// быстрая игра, #/<gameId>/<roomId> — вход в комнату (client/lib/roomLink.js).
|
|
@@ -57,8 +58,12 @@ export default class RouteBoot {
|
|
|
57
58
|
* setRoute(hash), reloadPage(hash) }.
|
|
58
59
|
* @param {Function} [deps.fetch]
|
|
59
60
|
* @param {Function} [deps.getStorage] - () → sessionStorage (может бросить).
|
|
61
|
+
* @param {Object|null} [deps.autoRejoin] - client/lib/autoRejoin.js
|
|
62
|
+
* AutoRejoin: взводится, если маршрут ведёт в комнату метки автовхода.
|
|
60
63
|
* @param {Function} [deps.sleep] - (ms) → Promise.
|
|
61
64
|
* @param {Function} [deps.now] - монотонные часы, мс.
|
|
65
|
+
* @param {Function} [deps.wallNow] - часы, переживающие перезагрузку
|
|
66
|
+
* (срок метки автовхода), мс.
|
|
62
67
|
*/
|
|
63
68
|
constructor({
|
|
64
69
|
ctx,
|
|
@@ -73,8 +78,10 @@ export default class RouteBoot {
|
|
|
73
78
|
ui,
|
|
74
79
|
fetch = (...args) => globalThis.fetch(...args),
|
|
75
80
|
getStorage = () => window.sessionStorage,
|
|
81
|
+
autoRejoin = null,
|
|
76
82
|
sleep = ms => new Promise(resolve => setTimeout(resolve, ms)),
|
|
77
83
|
now = () => performance.now(),
|
|
84
|
+
wallNow = () => Date.now(),
|
|
78
85
|
}) {
|
|
79
86
|
this._ctx = ctx;
|
|
80
87
|
this._signaling = signaling;
|
|
@@ -88,8 +95,10 @@ export default class RouteBoot {
|
|
|
88
95
|
this._ui = ui;
|
|
89
96
|
this._fetch = fetch;
|
|
90
97
|
this._getStorage = getStorage;
|
|
98
|
+
this._autoRejoin = autoRejoin;
|
|
91
99
|
this._sleep = sleep;
|
|
92
100
|
this._now = now;
|
|
101
|
+
this._wallNow = wallNow;
|
|
93
102
|
|
|
94
103
|
// маршрут разбирается один раз: повторный welcome (реконнект
|
|
95
104
|
// сигналинга) бутстрап не повторяет
|
|
@@ -162,6 +171,8 @@ export default class RouteBoot {
|
|
|
162
171
|
// разбор маршрута после логина и welcome (только лобби-режим)
|
|
163
172
|
async _bootRoute() {
|
|
164
173
|
const route = parseRoute(this._ui.getHash());
|
|
174
|
+
// метка автовхода снимается с любым маршрутом: живёт только до него
|
|
175
|
+
const rejoin = takeAutoRejoin(this._storage(), this._wallNow());
|
|
165
176
|
const pending = this._takeRoutePromotion(route);
|
|
166
177
|
|
|
167
178
|
if (pending && (await this._promoteCold(pending))) {
|
|
@@ -184,6 +195,13 @@ export default class RouteBoot {
|
|
|
184
195
|
|
|
185
196
|
switch (decision.action) {
|
|
186
197
|
case 'join':
|
|
198
|
+
if (
|
|
199
|
+
rejoin?.gameId === decision.gameId &&
|
|
200
|
+
rejoin.roomId === decision.roomId
|
|
201
|
+
) {
|
|
202
|
+
this._autoRejoin?.arm(rejoin);
|
|
203
|
+
}
|
|
204
|
+
|
|
187
205
|
await this._joinFromRoute(decision.gameId, decision.roomId);
|
|
188
206
|
break;
|
|
189
207
|
|
|
@@ -375,15 +393,7 @@ export default class RouteBoot {
|
|
|
375
393
|
// холодный промоушен (host-migration этап 7.4): страница перезагружена в
|
|
376
394
|
// комнату, чтобы поднять её матч заново. Запись снимается при любом исходе
|
|
377
395
|
_takeRoutePromotion(route) {
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
try {
|
|
381
|
-
storage = this._getStorage();
|
|
382
|
-
} catch {
|
|
383
|
-
return null;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
const pending = takePendingPromotion(storage);
|
|
396
|
+
const pending = takePendingPromotion(this._storage());
|
|
387
397
|
|
|
388
398
|
return pending &&
|
|
389
399
|
route.kind === 'room' &&
|
|
@@ -393,6 +403,15 @@ export default class RouteBoot {
|
|
|
393
403
|
: null;
|
|
394
404
|
}
|
|
395
405
|
|
|
406
|
+
// sessionStorage или null (хранилище запрещено)
|
|
407
|
+
_storage() {
|
|
408
|
+
try {
|
|
409
|
+
return this._getStorage();
|
|
410
|
+
} catch {
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
396
415
|
// занимает комнату холодным промоушеном. false — вкладка хостить не может,
|
|
397
416
|
// маршрут разбирается как обычный вход гостем
|
|
398
417
|
async _promoteCold(pending) {
|
package/src/client/style.css
CHANGED
|
@@ -580,9 +580,9 @@ body.hide-cursor {
|
|
|
580
580
|
display: block;
|
|
581
581
|
}
|
|
582
582
|
|
|
583
|
-
/*room menu
|
|
584
|
-
|
|
585
|
-
|
|
583
|
+
/*room menu: кнопка Exit внутри #panel справа от таблицы
|
|
584
|
+
(RoomMenuView.placeInPanel); order — таблицу PanelView достраивает позже,
|
|
585
|
+
по CONFIG_DATA. Без панели (игра её не показывает) — угол экрана;
|
|
586
586
|
z-index 7 — выше прозрачного #game-informer (6) и ниже лобби (8)*/
|
|
587
587
|
|
|
588
588
|
#room-menu {
|
|
@@ -600,7 +600,7 @@ body.hide-cursor {
|
|
|
600
600
|
flex: 0 0 auto;
|
|
601
601
|
}
|
|
602
602
|
|
|
603
|
-
#room-menu-
|
|
603
|
+
#room-menu-exit {
|
|
604
604
|
display: block;
|
|
605
605
|
margin-left: auto;
|
|
606
606
|
height: 24px;
|
|
@@ -609,29 +609,44 @@ body.hide-cursor {
|
|
|
609
609
|
background: transparent;
|
|
610
610
|
}
|
|
611
611
|
|
|
612
|
-
/* fixed — иначе
|
|
613
|
-
#room-menu-
|
|
612
|
+
/* fixed — иначе статус обрезал бы overflow: hidden панели */
|
|
613
|
+
#room-menu-status {
|
|
614
614
|
position: fixed;
|
|
615
615
|
top: 24px;
|
|
616
616
|
right: 0;
|
|
617
|
-
padding: 4px;
|
|
617
|
+
padding: 2px 4px;
|
|
618
618
|
background: #fff;
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
#room-menu-list input {
|
|
622
|
-
display: block;
|
|
623
|
-
width: 100%;
|
|
619
|
+
/* тёмный: фон белый */
|
|
620
|
+
color: #333;
|
|
624
621
|
white-space: nowrap;
|
|
625
|
-
margin-top: 5px;
|
|
626
622
|
}
|
|
627
623
|
|
|
628
|
-
#
|
|
629
|
-
|
|
630
|
-
|
|
624
|
+
/* окно «Leave the game?»: по центру поверх игры, ниже #session-overlay (8) —
|
|
625
|
+
пауза сессии важнее */
|
|
626
|
+
#room-exit-confirm {
|
|
627
|
+
position: fixed;
|
|
628
|
+
top: 50%;
|
|
629
|
+
left: 50%;
|
|
630
|
+
transform: translate(-50%, -50%);
|
|
631
|
+
flex-wrap: wrap;
|
|
632
|
+
justify-content: center;
|
|
633
|
+
gap: 8px;
|
|
634
|
+
padding: 12px 16px;
|
|
635
|
+
background: #fff;
|
|
631
636
|
color: #333;
|
|
637
|
+
font-size: 14px;
|
|
632
638
|
white-space: nowrap;
|
|
633
639
|
}
|
|
634
640
|
|
|
641
|
+
#room-exit-confirm-text {
|
|
642
|
+
flex: 0 0 100%;
|
|
643
|
+
text-align: center;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
#room-exit-confirm input {
|
|
647
|
+
min-width: 64px;
|
|
648
|
+
}
|
|
649
|
+
|
|
635
650
|
/*chat*/
|
|
636
651
|
|
|
637
652
|
#chat {
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
//-
|
|
2
|
-
//- вкладка в комнате. «Hand over host» — только у хоста, когда передавать
|
|
3
|
-
//- есть кому; статус передачи — на месте заблокированных пунктов
|
|
1
|
+
//- выход из комнаты: только лобби-режим, пока вкладка в комнате
|
|
4
2
|
div#room-menu(style='display:none')
|
|
5
|
-
input#room-menu-
|
|
6
|
-
div#room-menu-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
3
|
+
input#room-menu-exit(type='button', value='Exit', title='Leave the game')
|
|
4
|
+
div#room-menu-status(style='display:none')
|
|
5
|
+
div#room-exit-confirm(style='display:none')
|
|
6
|
+
div#room-exit-confirm-text Leave the game?
|
|
7
|
+
input#room-exit-confirm-yes(type='button', value='Yes')
|
|
8
|
+
input#room-exit-confirm-cancel(type='button', value='Cancel')
|
|
@@ -29,7 +29,7 @@ export default {
|
|
|
29
29
|
'Vote passed',
|
|
30
30
|
'Vote failed',
|
|
31
31
|
'Usage: /changehost',
|
|
32
|
-
'You are the host —
|
|
32
|
+
'You are the host — the room hands the host role over automatically when needed',
|
|
33
33
|
'No connection to the master server',
|
|
34
34
|
'A host vote was held recently',
|
|
35
35
|
'No other player can host',
|
package/src/config/lobby.js
CHANGED
|
@@ -24,17 +24,18 @@ export default {
|
|
|
24
24
|
createDelayMaxMs: 2000,
|
|
25
25
|
},
|
|
26
26
|
|
|
27
|
-
//
|
|
27
|
+
// выход из комнаты внутри матча (только лобби-режим, roomMenu.pug)
|
|
28
28
|
roomMenu: {
|
|
29
29
|
elems: {
|
|
30
|
-
// кнопка
|
|
30
|
+
// кнопка Exit живёт в панели, справа от таблицы
|
|
31
31
|
panelId: 'panel',
|
|
32
32
|
menuId: 'room-menu',
|
|
33
|
-
|
|
34
|
-
listId: 'room-menu-list',
|
|
35
|
-
leaveId: 'room-menu-leave',
|
|
36
|
-
handoverId: 'room-menu-handover',
|
|
33
|
+
exitId: 'room-menu-exit',
|
|
37
34
|
statusId: 'room-menu-status',
|
|
35
|
+
// окно подтверждения «Leave the game?»
|
|
36
|
+
confirmId: 'room-exit-confirm',
|
|
37
|
+
confirmYesId: 'room-exit-confirm-yes',
|
|
38
|
+
confirmCancelId: 'room-exit-confirm-cancel',
|
|
38
39
|
},
|
|
39
40
|
},
|
|
40
41
|
|
|
@@ -206,16 +207,15 @@ export default {
|
|
|
206
207
|
// взять последнюю периодическую; handoffSlowMs — нет handoff_go за это
|
|
207
208
|
// время — «медленная связь» (передача продолжается); handoffDeadlineMs —
|
|
208
209
|
// общий дедлайн плановой передачи от handoff_begin (master:room:
|
|
209
|
-
// handoffTimeoutMs + запас на дорогу ответа мастера);
|
|
210
|
-
// ожидания границы раунда передачей в игре без migration.midRound (этап 8d);
|
|
210
|
+
// handoffBetaWaitMs + handoffTimeoutMs + запас на дорогу ответа мастера);
|
|
211
211
|
// peersReportIntervalMs — период повтора room_peers хоста мастеру (кто
|
|
212
212
|
// подключён по WebRTC; при смене состава отчёт уходит сразу, с дебаунсом);
|
|
213
213
|
// minTokenLifetimeMs — вкладка, чей вход истечёт раньше, не объявляет
|
|
214
214
|
// canHost и отказывается от промоушена (токен предъявляется мастеру
|
|
215
215
|
// посреди матча; обычно его заранее продлевает TokenRenewer, порог работает,
|
|
216
216
|
// пока продление не удаётся); tokenHandoffLeadMs — за столько до
|
|
217
|
-
// истечения входа хост с бетой сам передаёт роль (плановая передача
|
|
218
|
-
//
|
|
217
|
+
// истечения входа хост с бетой сам передаёт роль (плановая передача);
|
|
218
|
+
// tokenHandoffRetryMs — через сколько повторить её, если
|
|
219
219
|
// беты ещё нет, идёт эстафета Worker'ов или передача сорвалась (повторы —
|
|
220
220
|
// до истечения входа); leaveFlushTimeoutMs — сколько «Leave
|
|
221
221
|
// server» хоста без людей ждёт записи очков участников перед закрытием
|
|
@@ -230,8 +230,7 @@ export default {
|
|
|
230
230
|
standbyReopenMaxDelayMs: 10000,
|
|
231
231
|
finalWaitMs: 3000,
|
|
232
232
|
handoffSlowMs: 3000,
|
|
233
|
-
handoffDeadlineMs:
|
|
234
|
-
deferMaxMs: 30000,
|
|
233
|
+
handoffDeadlineMs: 12000,
|
|
235
234
|
peersReportIntervalMs: 15000,
|
|
236
235
|
minTokenLifetimeMs: 600000,
|
|
237
236
|
tokenHandoffLeadMs: 300000,
|
|
@@ -240,14 +239,13 @@ export default {
|
|
|
240
239
|
|
|
241
240
|
// автотриггеры передачи (этап 9b, HostHealthPolicy; сэмпл — сообщение
|
|
242
241
|
// health Worker'а раз в ~1 с): мягкая перегрузка — среднее tickRate за
|
|
243
|
-
// overloadWindowMs ниже overloadTickRate
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
// что её получив; enabled — общий выключатель
|
|
242
|
+
// overloadWindowMs ниже overloadTickRate; жёсткая — среднее за
|
|
243
|
+
// criticalWindowMs ниже criticalTickRate или lostMs > 0 у lostWindows
|
|
244
|
+
// сэмплов подряд; передача в обоих случаях сразу; hiddenHandoffMs —
|
|
245
|
+
// скрытая вкладка хоста (этап 0: троттлинг Worker'а за 1–3 с);
|
|
246
|
+
// autoHandoffCooldownMs — между авто-передачами вкладки;
|
|
247
|
+
// minHostTenureMs — не отдавать роль, только что её получив; enabled —
|
|
248
|
+
// общий выключатель
|
|
251
249
|
auto: {
|
|
252
250
|
enabled: true,
|
|
253
251
|
overloadTickRate: 100,
|
|
@@ -255,8 +253,6 @@ export default {
|
|
|
255
253
|
criticalTickRate: 60,
|
|
256
254
|
criticalWindowMs: 3000,
|
|
257
255
|
lostWindows: 3,
|
|
258
|
-
recoverTickRate: 110,
|
|
259
|
-
recoverWindowMs: 5000,
|
|
260
256
|
hiddenHandoffMs: 1500,
|
|
261
257
|
autoHandoffCooldownMs: 90000,
|
|
262
258
|
minHostTenureMs: 30000,
|
|
@@ -286,7 +282,9 @@ export default {
|
|
|
286
282
|
// joinRetryWindowMs — сколько гость повторяет join_room на unknownRoom
|
|
287
283
|
// после рестарта мастера, пока хост не вернёт комнату reclaim_host (больше
|
|
288
284
|
// reconnect.maxDelay — бэкоффа сигналинга хоста — с запасом на его
|
|
289
|
-
// reclaim_host)
|
|
285
|
+
// reclaim_host); joinRetries — сколько раз повторить вход, каналы
|
|
286
|
+
// которого ни разу не открылись (сбой WebRTC-согласования, ICE), прежде
|
|
287
|
+
// чем показать «P2P connection failed» (задержки — бэкофф reconnect*)
|
|
290
288
|
session: {
|
|
291
289
|
reconnectWindowMs: 15000,
|
|
292
290
|
reconnectBaseDelayMs: 500,
|
|
@@ -297,6 +295,7 @@ export default {
|
|
|
297
295
|
linkWaitMaxMs: 90000,
|
|
298
296
|
resumeSilenceGraceMs: 3000,
|
|
299
297
|
joinRetryWindowMs: 45000,
|
|
298
|
+
joinRetries: 2,
|
|
300
299
|
},
|
|
301
300
|
|
|
302
301
|
// приёмник выгрузок отладочного контура:
|
|
@@ -33,6 +33,7 @@ const CHECKS = [
|
|
|
33
33
|
[10, 'roundLifecycle', 'round ends, winner announced, respawns happened'],
|
|
34
34
|
[11, 'actorLeak', 'players_data() matches the active participants'],
|
|
35
35
|
[12, 'determinism', 'two runs produce a byte-identical frame stream'],
|
|
36
|
+
[13, 'checkpointState', 'every host change carries the core dump'],
|
|
36
37
|
];
|
|
37
38
|
|
|
38
39
|
const TITLES = new Map(
|
|
@@ -40,8 +41,8 @@ const TITLES = new Map(
|
|
|
40
41
|
);
|
|
41
42
|
|
|
42
43
|
/**
|
|
43
|
-
* Выполняет проверки 1–11 по итогам прогона (12 — только по
|
|
44
|
-
* см. checkDeterminism).
|
|
44
|
+
* Выполняет проверки 1–11 и 13 по итогам прогона (12 — только по
|
|
45
|
+
* требованию, см. checkDeterminism).
|
|
45
46
|
* @param {Object} ctx
|
|
46
47
|
* @param {Object} ctx.scenario - Нормализованный сценарий.
|
|
47
48
|
* @param {Object} ctx.game - Конфиг игры хоста (после room-переопределений).
|
|
@@ -53,6 +54,8 @@ const TITLES = new Map(
|
|
|
53
54
|
* @param {Array} ctx.participantLog - [{ who, socketId, gameId, joinTick,
|
|
54
55
|
* leaveTick }].
|
|
55
56
|
* @param {number} ctx.stepMs
|
|
57
|
+
* @param {Array} [ctx.checkpointRestores] - [{ tick, mode, bytes }] смен
|
|
58
|
+
* хоста прогона.
|
|
56
59
|
* @returns {Array<Object>} Результаты проверок.
|
|
57
60
|
*/
|
|
58
61
|
export function checkInvariants(ctx) {
|
|
@@ -69,6 +72,7 @@ export function checkInvariants(ctx) {
|
|
|
69
72
|
roundLifecycle(ctx),
|
|
70
73
|
actorLeak(ctx),
|
|
71
74
|
result('determinism', SKIP, [], 'run with --determinism'),
|
|
75
|
+
checkpointState(ctx),
|
|
72
76
|
];
|
|
73
77
|
}
|
|
74
78
|
|
|
@@ -629,6 +633,24 @@ function actorLeak({ core, hostState }) {
|
|
|
629
633
|
return verdict('actorLeak', violations);
|
|
630
634
|
}
|
|
631
635
|
|
|
636
|
+
// 13: смена хоста продолжает раунд с того же тика; точка 'soft' значит,
|
|
637
|
+
// что дамп ядра или serializeState модуля бросил, и раунд начался заново
|
|
638
|
+
function checkpointState({ checkpointRestores = [] }) {
|
|
639
|
+
if (checkpointRestores.length === 0) {
|
|
640
|
+
return result('checkpointState', SKIP, [], 'no host change in this run');
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const violations = checkpointRestores
|
|
644
|
+
.filter(restore => restore.mode !== 'midRound')
|
|
645
|
+
.map(
|
|
646
|
+
restore =>
|
|
647
|
+
`tick ${restore.tick}: checkpoint mode '${restore.mode}' — the core ` +
|
|
648
|
+
'dump or a module serializeState threw (see the [checkpoint] warning)',
|
|
649
|
+
);
|
|
650
|
+
|
|
651
|
+
return verdict('checkpointState', violations);
|
|
652
|
+
}
|
|
653
|
+
|
|
632
654
|
// ***** вспомогательное ***** //
|
|
633
655
|
|
|
634
656
|
function result(name, status, violations, note) {
|