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
|
@@ -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) {
|
package/src/devtools/report.js
CHANGED
|
@@ -103,8 +103,8 @@ export function formatMarkdown(report) {
|
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
// смены хоста через контрольную точку: 'soft'
|
|
107
|
-
//
|
|
106
|
+
// смены хоста через контрольную точку: 'soft' (дамп ядра не снялся) —
|
|
107
|
+
// первое, что автор должен увидеть
|
|
108
108
|
if (report.checkpointRestores?.length) {
|
|
109
109
|
lines.push('');
|
|
110
110
|
lines.push('## Host checkpoints');
|
|
@@ -10,7 +10,14 @@
|
|
|
10
10
|
// (spawn_scripted_actor/remove_scripted_actor), человек — только танк
|
|
11
11
|
// (spawn_actor/remove_actor).
|
|
12
12
|
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
ABI_OP_ACTOR_FORGET,
|
|
15
|
+
ABI_OP_DEBUG_JSON,
|
|
16
|
+
ABI_OP_MATCH_RESET,
|
|
17
|
+
ABI_OP_MENU_ANSWER,
|
|
18
|
+
ABI_OP_STATE_PERSIST,
|
|
19
|
+
ABI_OP_STATE_RESTORE,
|
|
20
|
+
} from '../config/abiOps.js';
|
|
14
21
|
import { readCoreAbi, dispatchCoreOp } from '../lib/coreAbi.js';
|
|
15
22
|
|
|
16
23
|
export default class GameCoreAdapter {
|
|
@@ -26,7 +33,7 @@ export default class GameCoreAdapter {
|
|
|
26
33
|
this._core = core;
|
|
27
34
|
this._participants = participants;
|
|
28
35
|
this._onCoreEvent = onCoreEvent;
|
|
29
|
-
this._services = {}; // { vimp, panel } — инъекция как у Game.js
|
|
36
|
+
this._services = {}; // { vimp, panel, vote } — инъекция как у Game.js
|
|
30
37
|
|
|
31
38
|
// Метода нет (или самоописание нечитаемо) — ядро собрано до появления
|
|
32
39
|
// механизма. Это не ошибка: поколение 0, ни одного опционального опкода
|
|
@@ -44,7 +51,7 @@ export default class GameCoreAdapter {
|
|
|
44
51
|
return this._abi;
|
|
45
52
|
}
|
|
46
53
|
|
|
47
|
-
// получает сервисы (аналог Game.injectServices): { vimp, panel }
|
|
54
|
+
// получает сервисы (аналог Game.injectServices): { vimp, panel, vote }
|
|
48
55
|
injectServices(services) {
|
|
49
56
|
Object.assign(this._services, services);
|
|
50
57
|
}
|
|
@@ -159,7 +166,7 @@ export default class GameCoreAdapter {
|
|
|
159
166
|
// vimp }); 'custom' (игровой смысл вне словаря) — в HostPlugin.onCoreEvent
|
|
160
167
|
_drainEvents() {
|
|
161
168
|
const events = JSON.parse(this._core.take_events());
|
|
162
|
-
const { panel, vimp } = this._services;
|
|
169
|
+
const { panel, vimp, vote } = this._services;
|
|
163
170
|
|
|
164
171
|
for (const event of events) {
|
|
165
172
|
switch (event.type) {
|
|
@@ -182,6 +189,20 @@ export default class GameCoreAdapter {
|
|
|
182
189
|
});
|
|
183
190
|
break;
|
|
184
191
|
|
|
192
|
+
case 'menu':
|
|
193
|
+
this._pushPersonal(vote, event.id, { menu: event.menu });
|
|
194
|
+
break;
|
|
195
|
+
|
|
196
|
+
case 'menuClose':
|
|
197
|
+
this._pushPersonal(vote, event.id, { menuClose: event.name });
|
|
198
|
+
break;
|
|
199
|
+
|
|
200
|
+
case 'menuHint':
|
|
201
|
+
this._pushPersonal(vote, event.id, {
|
|
202
|
+
menuHint: { name: event.name, on: event.on },
|
|
203
|
+
});
|
|
204
|
+
break;
|
|
205
|
+
|
|
185
206
|
case 'custom':
|
|
186
207
|
this._onCoreEvent?.(event.data, this._services);
|
|
187
208
|
break;
|
|
@@ -189,6 +210,60 @@ export default class GameCoreAdapter {
|
|
|
189
210
|
}
|
|
190
211
|
}
|
|
191
212
|
|
|
213
|
+
// личное сообщение меню адресату; получатель — только человек с сокетом
|
|
214
|
+
// (бот и ушедший id отбрасываются: у них нет личной очереди)
|
|
215
|
+
_pushPersonal(vote, id, payload) {
|
|
216
|
+
const gameId = String(id);
|
|
217
|
+
|
|
218
|
+
if (vote && this._participants.get(gameId)?.isNetworked) {
|
|
219
|
+
vote.pushPersonal(gameId, payload);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ***** опкоды игры ***** //
|
|
224
|
+
|
|
225
|
+
// ответ игрока на личное меню; ядро без опкода молча игнорирует
|
|
226
|
+
menuAnswer(gameId, name, path) {
|
|
227
|
+
this._op(
|
|
228
|
+
ABI_OP_MENU_ANSWER,
|
|
229
|
+
encodeJson({ id: Number(gameId), name, path }),
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// участник покинул матч (в том числе наблюдатель)
|
|
234
|
+
forgetActor(gameId) {
|
|
235
|
+
this._op(ABI_OP_ACTOR_FORGET, encodeJson({ id: Number(gameId) }));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// новая игра: движок обнулил счёт
|
|
239
|
+
resetMatch() {
|
|
240
|
+
this._op(ABI_OP_MATCH_RESET, encodeJson({}));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Состояние игры между раундами для мягкой миграции и эстафеты.
|
|
245
|
+
* @param {{roundOver: boolean}} request - `roundOver` — раунд закончился,
|
|
246
|
+
* но ядро его ещё не подвело (эстафета на границе раунда).
|
|
247
|
+
* @returns {Object|null} JSON игры; `null` — ядро опкода не знает.
|
|
248
|
+
*/
|
|
249
|
+
persistState({ roundOver }) {
|
|
250
|
+
const { handled, bytes } = this._op(
|
|
251
|
+
ABI_OP_STATE_PERSIST,
|
|
252
|
+
encodeJson({ roundOver }),
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
if (handled && bytes !== null) {
|
|
256
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// отдаёт новому ядру JSON, снятый persistState
|
|
263
|
+
restorePersisted(state) {
|
|
264
|
+
this._op(ABI_OP_STATE_RESTORE, encodeJson(state));
|
|
265
|
+
}
|
|
266
|
+
|
|
192
267
|
// ***** упаковка снапшота ***** //
|
|
193
268
|
|
|
194
269
|
// пакует broadcast-тело кадра (один раз за отправляемый кадр)
|
|
@@ -333,6 +408,10 @@ export default class GameCoreAdapter {
|
|
|
333
408
|
}
|
|
334
409
|
}
|
|
335
410
|
|
|
411
|
+
function encodeJson(value) {
|
|
412
|
+
return new TextEncoder().encode(JSON.stringify(value));
|
|
413
|
+
}
|
|
414
|
+
|
|
336
415
|
// JsError wasm-bindgen и обычный Error — у обоих есть message; строка из
|
|
337
416
|
// throw внутри фейка — тоже ответ
|
|
338
417
|
function errorText(e) {
|
package/src/host/HostGame.js
CHANGED
|
@@ -15,6 +15,7 @@ import CommandProcessor from './meta/core/CommandProcessor.js';
|
|
|
15
15
|
import { sanitizeMessage } from '../lib/sanitizers.js';
|
|
16
16
|
import { parseFloorMessage } from '../lib/voiceMessages.js';
|
|
17
17
|
import closeCodes from '../config/closeCodes.js';
|
|
18
|
+
import { MENU_NAME_PATTERN } from '../config/menuName.js';
|
|
18
19
|
import clock from '../lib/clock.js';
|
|
19
20
|
import { createResumeKey } from '../lib/resumeKey.js';
|
|
20
21
|
import GameCoreAdapter from './GameCoreAdapter.js';
|
|
@@ -155,9 +156,6 @@ export default class HostGame {
|
|
|
155
156
|
this._isDevMode = data.isDevMode || false;
|
|
156
157
|
this._seed = seed;
|
|
157
158
|
|
|
158
|
-
// контрольная точка (host-migration этап 5): игра гарантирует, что дамп
|
|
159
|
-
// ядра и хуки модулей переносят всё её состояние посреди раунда
|
|
160
|
-
this._migrationMidRound = data.migration?.midRound === true;
|
|
161
159
|
this._handoffFlushTimeoutMs = data.handoffFlushTimeoutMs ?? 3000;
|
|
162
160
|
// промоушен преемника (host-migration этап 7.4): сколько восстановленный
|
|
163
161
|
// матч ждёт возврата людей точки перед стартом
|
|
@@ -224,6 +222,7 @@ export default class HostGame {
|
|
|
224
222
|
participants: this._participants,
|
|
225
223
|
onCoreEvent: hostPlugin.onCoreEvent,
|
|
226
224
|
});
|
|
225
|
+
this._participants.setAliveCheck(id => this._game.isAlive(id));
|
|
227
226
|
|
|
228
227
|
this._panel = new Panel(data.panel);
|
|
229
228
|
this._stat = new Stat(data.stat, this._teams);
|
|
@@ -307,6 +306,7 @@ export default class HostGame {
|
|
|
307
306
|
vote: this._vote,
|
|
308
307
|
chat: this._chat,
|
|
309
308
|
timerManager: this._timerManager,
|
|
309
|
+
voteHoldTime: data.voteHoldTime,
|
|
310
310
|
});
|
|
311
311
|
|
|
312
312
|
this._roundManager = new RoundManager({
|
|
@@ -374,6 +374,9 @@ export default class HostGame {
|
|
|
374
374
|
this._lastCheckpointAt = 0;
|
|
375
375
|
this._checkpointRequest = null; // { final } — точка на ближайшем кадре
|
|
376
376
|
this._checkpointCounter = 0;
|
|
377
|
+
// дамп упал на прошлой точке: повторный сбой не спамит консоль дважды в
|
|
378
|
+
// секунду, а успех снимает флаг — следующий сбой снова виден
|
|
379
|
+
this._dumpFailing = false;
|
|
377
380
|
// матч поднят из точки и ждёт startAfterRestore: цикл и таймеры стоят
|
|
378
381
|
this._restorePending = false;
|
|
379
382
|
this._restoreMode = null; // 'midRound' | 'soft'
|
|
@@ -394,7 +397,11 @@ export default class HostGame {
|
|
|
394
397
|
|
|
395
398
|
// внедрение зависимостей (ядро отдаёт панель/фасад события через адаптер)
|
|
396
399
|
this._socketManager.injectServices(this._game, this._panel, this._stat);
|
|
397
|
-
this._game.injectServices({
|
|
400
|
+
this._game.injectServices({
|
|
401
|
+
vimp: this,
|
|
402
|
+
panel: this._panel,
|
|
403
|
+
vote: this._vote,
|
|
404
|
+
});
|
|
398
405
|
this._panel.injectTimerManager(this._timerManager);
|
|
399
406
|
|
|
400
407
|
this._timerManager.startIdleCheckTimer();
|
|
@@ -924,23 +931,6 @@ export default class HostGame {
|
|
|
924
931
|
this._roundManager.cancelHandoff();
|
|
925
932
|
}
|
|
926
933
|
|
|
927
|
-
// плановая передача хоста (host-migration этап 8d): игра без
|
|
928
|
-
// migration.midRound переносит только мету — передача посреди раунда
|
|
929
|
-
// отняла бы его у всех, поэтому она ждёт следующего раунда. Игра с
|
|
930
|
-
// midRound продолжает у преемника с того же тика — ждать нечего
|
|
931
|
-
awaitRoundBoundary(cb) {
|
|
932
|
-
if (this._migrationMidRound) {
|
|
933
|
-
cb();
|
|
934
|
-
return;
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
this._roundManager.onRoundBoundary(cb);
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
cancelRoundBoundary() {
|
|
941
|
-
this._roundManager.cancelRoundBoundary();
|
|
942
|
-
}
|
|
943
|
-
|
|
944
934
|
// финальная синхронизация профилей, но не дольше handoffFlushTimeoutMs:
|
|
945
935
|
// зависший auth-сервис не должен держать эстафету — неотправленное едет
|
|
946
936
|
// в состоянии (playerData) и уйдёт из нового Worker'а
|
|
@@ -1204,9 +1194,9 @@ export default class HostGame {
|
|
|
1204
1194
|
|
|
1205
1195
|
/**
|
|
1206
1196
|
* Собирает контрольную точку: мета (формат HANDOFF_VERSION 4) и дамп
|
|
1207
|
-
* ядра.
|
|
1208
|
-
*
|
|
1209
|
-
*
|
|
1197
|
+
* ядра. Сетевая точка ('checkpoint') всегда пробует дамп; не снялся —
|
|
1198
|
+
* режим 'soft' (восстановление начнёт раунд заново). Эстафета Worker'ов
|
|
1199
|
+
* ('boundary') стоит на границе раунда и ядро не несёт.
|
|
1210
1200
|
* @param {'checkpoint'|'boundary'} kind
|
|
1211
1201
|
* @returns {{meta: Object, core: Uint8Array|null}}
|
|
1212
1202
|
*/
|
|
@@ -1214,14 +1204,19 @@ export default class HostGame {
|
|
|
1214
1204
|
let core = null;
|
|
1215
1205
|
let plugin = null;
|
|
1216
1206
|
|
|
1217
|
-
if (kind === 'checkpoint'
|
|
1207
|
+
if (kind === 'checkpoint') {
|
|
1218
1208
|
try {
|
|
1219
1209
|
plugin = this._serializeModules();
|
|
1220
1210
|
core = this._game.serializeState();
|
|
1211
|
+
this._dumpFailing = false;
|
|
1221
1212
|
} catch (e) {
|
|
1222
1213
|
// точка остаётся полезной и без ядра: аварийный преемник начнёт
|
|
1223
1214
|
// раунд заново, а не потеряет комнату
|
|
1224
|
-
|
|
1215
|
+
if (!this._dumpFailing) {
|
|
1216
|
+
console.warn(`[checkpoint] mid-round state skipped: ${e.message}`);
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
this._dumpFailing = true;
|
|
1225
1220
|
core = null;
|
|
1226
1221
|
plugin = null;
|
|
1227
1222
|
}
|
|
@@ -1264,6 +1259,20 @@ export default class HostGame {
|
|
|
1264
1259
|
// преемник снимает их сам — иначе в мире остались бы бесхозные тела
|
|
1265
1260
|
participants.dropped = dropped;
|
|
1266
1261
|
|
|
1262
|
+
// малое состояние игры между раундами (опкод state.persist): ядро в
|
|
1263
|
+
// точку не попало, а профили игры должны пережить мягкий перезапуск.
|
|
1264
|
+
// Эстафета стоит на границе раунда — игра подводит закончившийся раунд
|
|
1265
|
+
let persist = null;
|
|
1266
|
+
|
|
1267
|
+
if (core === null) {
|
|
1268
|
+
try {
|
|
1269
|
+
persist = this._game.persistState({ roundOver: kind === 'boundary' });
|
|
1270
|
+
} catch (e) {
|
|
1271
|
+
console.warn(`[checkpoint] persisted state skipped: ${e.message}`);
|
|
1272
|
+
persist = null;
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1267
1276
|
const meta = {
|
|
1268
1277
|
version: HANDOFF_VERSION,
|
|
1269
1278
|
kind,
|
|
@@ -1303,6 +1312,7 @@ export default class HostGame {
|
|
|
1303
1312
|
panel: this._panel.serialize(),
|
|
1304
1313
|
playerData: this._playerDataSync.serialize(),
|
|
1305
1314
|
plugin: plugin ?? {},
|
|
1315
|
+
persist,
|
|
1306
1316
|
// текст карточки лобби, выставленный модулем игры (lobby.setInfo):
|
|
1307
1317
|
// модули нового Worker'а создаются с нуля и сами его не повторят
|
|
1308
1318
|
lobbyInfo: this._lobbyInfoOverride,
|
|
@@ -1463,6 +1473,29 @@ export default class HostGame {
|
|
|
1463
1473
|
this._restoreModules(meta.plugin);
|
|
1464
1474
|
}
|
|
1465
1475
|
|
|
1476
|
+
if (!midRound && meta.persist !== undefined && meta.persist !== null) {
|
|
1477
|
+
try {
|
|
1478
|
+
this._game.restorePersisted?.(meta.persist);
|
|
1479
|
+
} catch (e) {
|
|
1480
|
+
console.warn(`[checkpoint] persisted state skipped: ${e.message}`);
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
// участник точки, которого восстановление не вернуло (не попал в точку,
|
|
1485
|
+
// restoreHuman/restoreScripted отказали), не должен оставить профиль в
|
|
1486
|
+
// ядре: его унаследовал бы новичок с тем же gameId
|
|
1487
|
+
const listed = [
|
|
1488
|
+
...meta.participants.humans.map(record => record.gameId),
|
|
1489
|
+
...meta.participants.scripted.map(record => record.gameId),
|
|
1490
|
+
...(meta.participants.dropped ?? []),
|
|
1491
|
+
];
|
|
1492
|
+
|
|
1493
|
+
for (const gameId of listed) {
|
|
1494
|
+
if (!this._participants.get(gameId)) {
|
|
1495
|
+
this._forgetActor(gameId);
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1466
1499
|
this._restoreMode = midRound ? 'midRound' : 'soft';
|
|
1467
1500
|
this._restoredTimers = meta.timers ?? {};
|
|
1468
1501
|
this._restorePending = true;
|
|
@@ -1614,25 +1647,27 @@ export default class HostGame {
|
|
|
1614
1647
|
return this._frozen;
|
|
1615
1648
|
}
|
|
1616
1649
|
|
|
1617
|
-
//
|
|
1650
|
+
// gameId следующего (или предыдущего) наблюдаемого игрока по кругу. Остовы
|
|
1651
|
+
// погибших в списке активных остаются до конца раунда, но камера на них не
|
|
1652
|
+
// переключается; живых нет — наблюдатель остаётся на текущем
|
|
1618
1653
|
_getNextActivePlayerForUser(gameId, back) {
|
|
1619
1654
|
const currentId = this._participants.get(gameId)?.watchedGameId;
|
|
1620
1655
|
const activeList = this._participants.getActiveList();
|
|
1621
|
-
|
|
1656
|
+
const size = activeList.length;
|
|
1657
|
+
const found = activeList.indexOf(currentId);
|
|
1658
|
+
// текущего нет в списке: счёт идёт с края, в нужную сторону
|
|
1659
|
+
const start = found === -1 ? (back ? 0 : -1) : found;
|
|
1660
|
+
const step = back ? -1 : 1;
|
|
1622
1661
|
|
|
1623
|
-
|
|
1624
|
-
key =
|
|
1662
|
+
for (let i = 1; i <= size; i += 1) {
|
|
1663
|
+
const key = (((start + step * i) % size) + size) % size;
|
|
1625
1664
|
|
|
1626
|
-
if (key
|
|
1627
|
-
|
|
1628
|
-
} else if (key >= activeList.length) {
|
|
1629
|
-
key = 0;
|
|
1665
|
+
if (this._game.isAlive(activeList[key])) {
|
|
1666
|
+
return activeList[key];
|
|
1630
1667
|
}
|
|
1631
|
-
|
|
1632
|
-
return activeList[key];
|
|
1633
1668
|
}
|
|
1634
1669
|
|
|
1635
|
-
return
|
|
1670
|
+
return currentId ?? null;
|
|
1636
1671
|
}
|
|
1637
1672
|
|
|
1638
1673
|
// активирует тряску камеры у игрока (из события ядра)
|
|
@@ -1736,6 +1771,10 @@ export default class HostGame {
|
|
|
1736
1771
|
this._game.removePlayer(gameId);
|
|
1737
1772
|
}
|
|
1738
1773
|
|
|
1774
|
+
// профиль игры (деньги и т.п.) живёт дольше танка: id ушедшего сразу
|
|
1775
|
+
// достаётся новичку
|
|
1776
|
+
this._forgetActor(gameId);
|
|
1777
|
+
|
|
1739
1778
|
this._participants.remove(gameId);
|
|
1740
1779
|
|
|
1741
1780
|
this._chat.pushSystem('USER_LEFT', [user.name]);
|
|
@@ -1869,7 +1908,9 @@ export default class HostGame {
|
|
|
1869
1908
|
} else if (typeof data === 'object' && data !== null) {
|
|
1870
1909
|
const [type, value] = data;
|
|
1871
1910
|
|
|
1872
|
-
if (type === '
|
|
1911
|
+
if (typeof type === 'string' && type.startsWith('menu:')) {
|
|
1912
|
+
this._menuAnswer(gameId, type.slice(5), value);
|
|
1913
|
+
} else if (type === 'mapChange') {
|
|
1873
1914
|
if (this._participants.getHumans().length === 1) {
|
|
1874
1915
|
this._roundManager.forceChangeMap(value);
|
|
1875
1916
|
} else {
|
|
@@ -1884,6 +1925,29 @@ export default class HostGame {
|
|
|
1884
1925
|
}
|
|
1885
1926
|
}
|
|
1886
1927
|
|
|
1928
|
+
// исключение ядра не должно прервать уход участника или миграцию
|
|
1929
|
+
_forgetActor(gameId) {
|
|
1930
|
+
try {
|
|
1931
|
+
this._game.forgetActor?.(gameId);
|
|
1932
|
+
} catch (e) {
|
|
1933
|
+
console.warn(`[actor.forget] skipped: ${e.message}`);
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
// ответ игрока на личное меню: проверка формы и передача ядру игры;
|
|
1938
|
+
// `id` — всегда отправитель, клиент его не указывает
|
|
1939
|
+
_menuAnswer(gameId, name, path) {
|
|
1940
|
+
if (
|
|
1941
|
+
!MENU_NAME_PATTERN.test(name) ||
|
|
1942
|
+
typeof path !== 'string' ||
|
|
1943
|
+
!/^[1-9](\.[1-9])?$/.test(path)
|
|
1944
|
+
) {
|
|
1945
|
+
return;
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
this._game.menuAnswer(gameId, name, path);
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1887
1951
|
// rank/state участника (Этап B4) — для игровых модулей и чат-команды /rank
|
|
1888
1952
|
// (Этап B5, CommandProcessor)
|
|
1889
1953
|
getPlayerRank(gameId) {
|
|
@@ -1999,7 +2063,7 @@ export default class HostGame {
|
|
|
1999
2063
|
// транспорт участника оборвался без LEAVE: место держится до возврата.
|
|
2000
2064
|
// Слот занят (isFull его считает), актор остаётся в мире, но все его
|
|
2001
2065
|
// команды отпущены; RTT- и idle-кики его не трогают. false — держать
|
|
2002
|
-
// нечего (
|
|
2066
|
+
// нечего (нет ключа места, хост-игрок, уже отсоединён) — снимать сразу
|
|
2003
2067
|
detachUser(gameId) {
|
|
2004
2068
|
const user = this._participants.get(gameId);
|
|
2005
2069
|
|