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
@@ -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) {
@@ -177,6 +177,39 @@ body.hide-cursor {
177
177
  background-color: #333;
178
178
  }
179
179
 
180
+ /* ячейки с подписью (panel.cells): подпись, значение и шкала */
181
+ #panel table td {
182
+ position: relative;
183
+ }
184
+
185
+ #panel table td .panel-label {
186
+ opacity: 0.6;
187
+ margin-right: 0.4em;
188
+ }
189
+
190
+ #panel table td.is-empty {
191
+ opacity: 0.5;
192
+ }
193
+
194
+ #panel table td div.panel-gauge {
195
+ position: absolute;
196
+ left: 0;
197
+ bottom: 0;
198
+ width: 0;
199
+ height: 2px;
200
+ background-color: currentColor;
201
+ }
202
+
203
+ @keyframes panel-gauge-fill {
204
+ from {
205
+ width: 0;
206
+ }
207
+
208
+ to {
209
+ width: 100%;
210
+ }
211
+ }
212
+
180
213
  /* bar-поля панели (type: 'bar' в схеме игры) — классы нейтральные,
181
214
  без привязки к игровым DOM-id */
182
215
  #panel table td div.panel-bar-wrapper {
@@ -211,6 +244,11 @@ body.hide-cursor {
211
244
  #panel table td div.panel-bar-blink {
212
245
  animation: none;
213
246
  }
247
+
248
+ #panel table td div.panel-gauge {
249
+ animation: none !important;
250
+ width: 100%;
251
+ }
214
252
  }
215
253
 
216
254
  /*stat*/
@@ -314,6 +352,29 @@ body.hide-cursor {
314
352
  text-align: right;
315
353
  }
316
354
 
355
+ /*menu hint*/
356
+
357
+ /* Подсказка личного меню (корзина) под местом HUD связи; игра
358
+ перестилизует её по #menu-hints и .menu-hint.is-on. Селектор по id
359
+ перебивает `.vimp-shell > * { display: none }` */
360
+ #menu-hints {
361
+ display: block;
362
+ position: absolute;
363
+ right: 24px;
364
+ top: calc(50% + 44px);
365
+ z-index: 4;
366
+ pointer-events: none;
367
+ color: #fff;
368
+ }
369
+
370
+ #menu-hints .menu-hint {
371
+ display: none;
372
+ }
373
+
374
+ #menu-hints .menu-hint.is-on {
375
+ display: block;
376
+ }
377
+
317
378
  /*voice*/
318
379
 
319
380
  /* Нейтральный вид HUD связи; игра перестилизует его по #voice-hud и
@@ -331,7 +392,7 @@ body.hide-cursor {
331
392
  gap: 8px;
332
393
  color: #fff;
333
394
  font-size: 14px;
334
- text-shadow: 1px 1px 1px #000;
395
+ text-shadow: 0 0 1px #000;
335
396
  }
336
397
 
337
398
  #voice-hud.is-talking,
@@ -358,6 +419,12 @@ body.hide-cursor {
358
419
  display: flex;
359
420
  }
360
421
 
422
+ /* тёмная обводка: серый и зелёный значок читаются на светлой карте */
423
+ #voice-hud .voice-icon,
424
+ #voice-hud .voice-ring {
425
+ filter: drop-shadow(0 0 1px #000);
426
+ }
427
+
361
428
  #voice-hud .voice-icon {
362
429
  width: 32px;
363
430
  height: 32px;
@@ -375,17 +442,22 @@ body.hide-cursor {
375
442
  display: none;
376
443
  }
377
444
 
445
+ #voice-hud .voice-mark {
446
+ position: relative;
447
+ display: flex;
448
+ }
449
+
378
450
  #voice-hud .voice-ring {
379
451
  display: none;
380
452
  position: absolute;
381
- left: -2px;
382
- top: -2px;
453
+ left: 50%;
454
+ top: 50%;
383
455
  width: 36px;
384
456
  height: 36px;
385
457
  fill: none;
386
458
  stroke: currentColor;
387
459
  stroke-width: 2;
388
- transform: rotate(-90deg);
460
+ transform: translate(-50%, -50%) rotate(-90deg);
389
461
  }
390
462
 
391
463
  #voice-hud.is-talking .voice-ring {
@@ -489,6 +561,11 @@ body.hide-cursor {
489
561
  overflow: hidden;
490
562
  }
491
563
 
564
+ /* недоступный пункт личного меню */
565
+ #vote .vote-list li.disabled {
566
+ opacity: 0.4;
567
+ }
568
+
492
569
  #vote .vote-nav {
493
570
  padding: 0 0 0 5px;
494
571
  margin-top: 10px;
@@ -503,9 +580,9 @@ body.hide-cursor {
503
580
  display: block;
504
581
  }
505
582
 
506
- /*room menu (host-migration, этап 3): кнопка внутри #panel справа от
507
- таблицы (RoomMenuView.placeInPanel); order — таблицу PanelView достраивает
508
- позже, по CONFIG_DATA. Без панели (игра её не показывает) — угол экрана;
583
+ /*room menu: кнопка Exit внутри #panel справа от таблицы
584
+ (RoomMenuView.placeInPanel); order — таблицу PanelView достраивает позже,
585
+ по CONFIG_DATA. Без панели (игра её не показывает) — угол экрана;
509
586
  z-index 7 — выше прозрачного #game-informer (6) и ниже лобби (8)*/
510
587
 
511
588
  #room-menu {
@@ -523,7 +600,7 @@ body.hide-cursor {
523
600
  flex: 0 0 auto;
524
601
  }
525
602
 
526
- #room-menu-toggle {
603
+ #room-menu-exit {
527
604
  display: block;
528
605
  margin-left: auto;
529
606
  height: 24px;
@@ -532,29 +609,44 @@ body.hide-cursor {
532
609
  background: transparent;
533
610
  }
534
611
 
535
- /* fixed — иначе список обрезал бы overflow: hidden панели */
536
- #room-menu-list {
612
+ /* fixed — иначе статус обрезал бы overflow: hidden панели */
613
+ #room-menu-status {
537
614
  position: fixed;
538
615
  top: 24px;
539
616
  right: 0;
540
- padding: 4px;
617
+ padding: 2px 4px;
541
618
  background: #fff;
542
- }
543
-
544
- #room-menu-list input {
545
- display: block;
546
- width: 100%;
619
+ /* тёмный: фон белый */
620
+ color: #333;
547
621
  white-space: nowrap;
548
- margin-top: 5px;
549
622
  }
550
623
 
551
- #room-menu-status {
552
- padding: 2px 4px;
553
- /* тёмный: фон списка белый */
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;
554
636
  color: #333;
637
+ font-size: 14px;
555
638
  white-space: nowrap;
556
639
  }
557
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
+
558
650
  /*chat*/
559
651
 
560
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')
@@ -186,7 +186,9 @@ export default class MicCapture {
186
186
  try {
187
187
  stream = await this._mediaDevices.getUserMedia({
188
188
  audio: {
189
- echoCancellation: true,
189
+ // эхоподавление включает системную обработку голоса (macOS) и
190
+ // приглушает весь остальной звук на время открытия и закрытия
191
+ echoCancellation: false,
190
192
  noiseSuppression: true,
191
193
  autoGainControl: true,
192
194
  channelCount: 1,
@@ -23,6 +23,43 @@ export const abiOps = createRegistry('abiOps', [
23
23
  payload: 'none',
24
24
  response: 'json',
25
25
  },
26
+ // ответ игрока на личное меню: { id, name, path } (путь листа «2.1»)
27
+ {
28
+ value: 'menu.answer',
29
+ since: 4,
30
+ payload: 'json',
31
+ response: 'none',
32
+ },
33
+ // участник покинул матч: { id } — ядро игры удаляет всё, что хранит о нём
34
+ {
35
+ value: 'actor.forget',
36
+ since: 4,
37
+ payload: 'json',
38
+ response: 'none',
39
+ },
40
+ // новая игра: движок обнулил счёт, ядро игры сбрасывает накопленное
41
+ // между раундами
42
+ {
43
+ value: 'match.reset',
44
+ since: 4,
45
+ payload: 'json',
46
+ response: 'none',
47
+ },
48
+ // состояние игры между раундами: запрос { roundOver }, ответ — JSON игры,
49
+ // который переживает мягкую миграцию хоста и эстафету Worker'а
50
+ {
51
+ value: 'state.persist',
52
+ since: 4,
53
+ payload: 'json',
54
+ response: 'json',
55
+ },
56
+ // тот же JSON обратно новому ядру до первого раунда
57
+ {
58
+ value: 'state.restore',
59
+ since: 4,
60
+ payload: 'json',
61
+ response: 'none',
62
+ },
26
63
  ]);
27
64
 
28
65
  // плоский список активных опкодов — то, что вправе позвать движок
@@ -33,5 +70,10 @@ export const ABI_OPS = abiOps.values();
33
70
  // опкод уехал бы в прод, не попав ни в слепок поверхности, ни в
34
71
  // CHANGELOG. Имя опкода читается ТОЛЬКО отсюда.
35
72
  export const ABI_OP_DEBUG_JSON = 'debug.json';
73
+ export const ABI_OP_MENU_ANSWER = 'menu.answer';
74
+ export const ABI_OP_ACTOR_FORGET = 'actor.forget';
75
+ export const ABI_OP_MATCH_RESET = 'match.reset';
76
+ export const ABI_OP_STATE_PERSIST = 'state.persist';
77
+ export const ABI_OP_STATE_RESTORE = 'state.restore';
36
78
 
37
79
  export default abiOps;