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.
Files changed (34) hide show
  1. package/package.json +1 -1
  2. package/src/client/components/controller/RoomMenu.js +38 -10
  3. package/src/client/components/model/RoomMenu.js +42 -57
  4. package/src/client/components/view/RoomMenu.js +78 -24
  5. package/src/client/lib/autoRejoin.js +161 -0
  6. package/src/client/lib/hostUnloadGuard.js +40 -15
  7. package/src/client/lib/roomLink.js +32 -0
  8. package/src/client/main.js +116 -36
  9. package/src/client/network/HostConnectionManager.js +27 -1
  10. package/src/client/network/HostController.js +1 -30
  11. package/src/client/network/HostHealthPolicy.js +14 -59
  12. package/src/client/network/PlannedHandoff.js +15 -100
  13. package/src/client/network/SessionSupervisor.js +58 -9
  14. package/src/client/network/SignalingClient.js +1 -1
  15. package/src/client/network/WebRtcManager.js +38 -3
  16. package/src/client/session/GuestSession.js +42 -7
  17. package/src/client/session/HandoffFlow.js +26 -51
  18. package/src/client/session/HostRole.js +8 -6
  19. package/src/client/session/PromotionFlow.js +28 -1
  20. package/src/client/session/RouteBoot.js +28 -9
  21. package/src/client/style.css +31 -16
  22. package/src/client/views/includes/roomMenu.pug +7 -8
  23. package/src/config/chatMessages.js +1 -1
  24. package/src/config/lobby.js +22 -23
  25. package/src/devtools/ScenarioRunner.js +1 -0
  26. package/src/devtools/invariants.js +24 -2
  27. package/src/devtools/report.js +2 -2
  28. package/src/host/HostGame.js +14 -26
  29. package/src/host/PortMachine.js +34 -17
  30. package/src/host/host.worker.js +1 -13
  31. package/src/host/meta/core/RoundManager.js +0 -21
  32. package/src/host/meta/modules/chat/systemMessages.js +1 -1
  33. package/src/lib/capabilities.js +2 -4
  34. 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
- // Плановая передача хоста (host-migration этап 8.2) и уход из комнаты:
9
- // «Hand over host», «Leave server», автотриггеры (этап 9b), просьба мастера
10
- // (сеть — этап 9c, голосование — этап 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
- * setHandoffMenu(state|null), sendLeave(), reloadPage(hash) }.
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
- deferMaxMs: migration.deferMaxMs,
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, defer }) => this.start({ reason, stay: true, defer }),
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
- // передача уже ждёт границы раунда — сразу, с причиной vote; уже
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
- * Отдать роль хоста бете без отката: «Leave server» (stay: false — затем в
176
- * лобби), «Hand over host» (stay: true — дальше гостем). Повторный вызов во
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, defer = stay }) {
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, defer });
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.setHandoffMenu('pending');
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, defer: 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
- // «Leave server» из меню комнаты (этап 8.3). Хост при других людях отдаёт
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.setHandoffMenu('failed');
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(), setHandoffMenu(state), isHidden(),
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.setHandoffMenu(null);
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
- this.demote(msg.epoch);
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
- let storage = null;
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) {
@@ -580,9 +580,9 @@ body.hide-cursor {
580
580
  display: block;
581
581
  }
582
582
 
583
- /*room menu (host-migration, этап 3): кнопка внутри #panel справа от
584
- таблицы (RoomMenuView.placeInPanel); order — таблицу PanelView достраивает
585
- позже, по CONFIG_DATA. Без панели (игра её не показывает) — угол экрана;
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-toggle {
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 — иначе список обрезал бы overflow: hidden панели */
613
- #room-menu-list {
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
- #room-menu-status {
629
- padding: 2px 4px;
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
- //- меню комнаты (host-migration, этапы 3 и 8d): только лобби-режим, пока
2
- //- вкладка в комнате. «Hand over host» — только у хоста, когда передавать
3
- //- есть кому; статус передачи — на месте заблокированных пунктов
1
+ //- выход из комнаты: только лобби-режим, пока вкладка в комнате
4
2
  div#room-menu(style='display:none')
5
- input#room-menu-toggle(type='button', value='☰', title='Room menu')
6
- div#room-menu-list(style='display:none')
7
- input#room-menu-handover(type='button', value='Hand over host', style='display:none')
8
- input#room-menu-leave(type='button', value='Leave server')
9
- div#room-menu-status(style='display:none')
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 — 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
  // приёмник выгрузок отладочного контура:
@@ -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) {