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.
Files changed (49) hide show
  1. package/core/Cargo.toml +1 -1
  2. package/package.json +1 -1
  3. package/src/client/components/controller/RoomMenu.js +38 -10
  4. package/src/client/components/controller/Vote.js +26 -0
  5. package/src/client/components/model/Controls.js +9 -1
  6. package/src/client/components/model/RoomMenu.js +42 -57
  7. package/src/client/components/model/Vote.js +202 -0
  8. package/src/client/components/view/Panel.js +176 -6
  9. package/src/client/components/view/RoomMenu.js +78 -24
  10. package/src/client/components/view/Voice.js +6 -1
  11. package/src/client/components/view/Vote.js +71 -2
  12. package/src/client/lib/autoRejoin.js +161 -0
  13. package/src/client/lib/hostUnloadGuard.js +40 -15
  14. package/src/client/lib/roomLink.js +32 -0
  15. package/src/client/main.js +120 -36
  16. package/src/client/network/HostConnectionManager.js +27 -1
  17. package/src/client/network/HostController.js +1 -30
  18. package/src/client/network/HostHealthPolicy.js +14 -59
  19. package/src/client/network/PlannedHandoff.js +15 -100
  20. package/src/client/network/SessionSupervisor.js +58 -9
  21. package/src/client/network/SignalingClient.js +1 -1
  22. package/src/client/network/WebRtcManager.js +38 -3
  23. package/src/client/session/GuestSession.js +42 -7
  24. package/src/client/session/HandoffFlow.js +26 -51
  25. package/src/client/session/HostRole.js +8 -6
  26. package/src/client/session/PromotionFlow.js +28 -1
  27. package/src/client/session/RouteBoot.js +28 -9
  28. package/src/client/style.css +112 -20
  29. package/src/client/views/includes/roomMenu.pug +7 -8
  30. package/src/client/voice/MicCapture.js +3 -1
  31. package/src/config/abiOps.js +42 -0
  32. package/src/config/chatMessages.js +1 -1
  33. package/src/config/lobby.js +22 -23
  34. package/src/config/menuName.js +3 -0
  35. package/src/devtools/ScenarioRunner.js +1 -0
  36. package/src/devtools/invariants.js +24 -2
  37. package/src/devtools/report.js +2 -2
  38. package/src/host/GameCoreAdapter.js +83 -4
  39. package/src/host/HostGame.js +103 -39
  40. package/src/host/PortMachine.js +34 -17
  41. package/src/host/host.worker.js +1 -13
  42. package/src/host/meta/core/RoundManager.js +8 -22
  43. package/src/host/meta/core/VoteCoordinator.js +56 -1
  44. package/src/host/meta/modules/TimerManager.js +16 -2
  45. package/src/host/meta/modules/Vote.js +65 -0
  46. package/src/host/meta/modules/chat/systemMessages.js +1 -1
  47. package/src/host/meta/player/ParticipantManager.js +19 -3
  48. package/src/lib/capabilities.js +19 -4
  49. 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 — use “Hand over host” in the room menu',
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',
@@ -24,17 +24,18 @@ export default {
24
24
  createDelayMaxMs: 2000,
25
25
  },
26
26
 
27
- // меню комнаты внутри матча (только лобби-режим, roomMenu.pug)
27
+ // выход из комнаты внутри матча (только лобби-режим, roomMenu.pug)
28
28
  roomMenu: {
29
29
  elems: {
30
- // кнопка меню живёт в панели, справа от таблицы
30
+ // кнопка Exit живёт в панели, справа от таблицы
31
31
  panelId: 'panel',
32
32
  menuId: 'room-menu',
33
- toggleId: 'room-menu-toggle',
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 + запас на дорогу ответа мастера); deferMaxMs — потолок
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
- // границе раунда); tokenHandoffRetryMs — через сколько повторить её, если
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: 10000,
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
- // жёсткая — среднее за criticalWindowMs ниже criticalTickRate или
245
- // lostMs > 0 у lostWindows сэмплов подряд (сразу); отложенная
246
- // отменяется, когда все сэмплы за recoverWindowMs выше recoverTickRate
247
- // (гистерезис); hiddenHandoffMs — скрытая вкладка хоста (этап 0:
248
- // троттлинг Worker'а за 1–3 с); autoHandoffCooldownMs — между
249
- // авто-передачами вкладки; minHostTenureMs — не отдавать роль, только
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
  // приёмник выгрузок отладочного контура:
@@ -0,0 +1,3 @@
1
+ // формат имени личного меню: проверяется и хостом (ответ игрока), и клиентом
2
+ // (данные недоверенного хоста)
3
+ export const MENU_NAME_PATTERN = /^[a-z][a-zA-Z0-9]{0,15}$/;
@@ -696,6 +696,7 @@ function buildReport(ctx) {
696
696
  hostState: inspectHost(host),
697
697
  participantLog,
698
698
  stepMs,
699
+ checkpointRestores,
699
700
  });
700
701
 
701
702
  return {
@@ -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) {
@@ -103,8 +103,8 @@ export function formatMarkdown(report) {
103
103
  }
104
104
  }
105
105
 
106
- // смены хоста через контрольную точку: 'soft' у игры, которая ждала
107
- // 'midRound', — первое, что автор должен увидеть
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 { ABI_OP_DEBUG_JSON } from '../config/abiOps.js';
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) {
@@ -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({ vimp: this, panel: this._panel });
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
- * ядра. Ядро прикладывается только у игры с migration.midRound и только
1208
- * если дамп снялся — иначе режим 'soft' (восстановление начнёт раунд
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' && this._migrationMidRound) {
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
- console.warn(`[checkpoint] mid-round state skipped: ${e.message}`);
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
- // меняет и возвращает gameId наблюдаемого игрока
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
- let key = activeList.indexOf(currentId);
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
- if (key !== -1) {
1624
- key = back ? key - 1 : key + 1;
1662
+ for (let i = 1; i <= size; i += 1) {
1663
+ const key = (((start + step * i) % size) + size) % size;
1625
1664
 
1626
- if (key < 0) {
1627
- key = activeList.length - 1;
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 activeList[0] || null;
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 === 'mapChange') {
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