vimp-engine 0.35.5 → 0.36.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 (130) hide show
  1. package/bin/vimp-sim.js +19 -1
  2. package/core/Cargo.toml +1 -1
  3. package/package.json +1 -1
  4. package/src/client/components/controller/Controls.js +5 -0
  5. package/src/client/components/controller/Lobby.js +4 -4
  6. package/src/client/components/controller/RoomMenu.js +43 -0
  7. package/src/client/components/controller/Vote.js +18 -2
  8. package/src/client/components/model/ClientReports.js +6 -2
  9. package/src/client/components/model/Controls.js +27 -0
  10. package/src/client/components/model/Games.js +5 -1
  11. package/src/client/components/model/Lobby.js +32 -24
  12. package/src/client/components/model/LobbyAuth.js +20 -4
  13. package/src/client/components/model/RoomMenu.js +121 -0
  14. package/src/client/components/model/Stat.js +2 -1
  15. package/src/client/components/model/Vote.js +122 -4
  16. package/src/client/components/view/Auth.js +20 -10
  17. package/src/client/components/view/ClientReports.js +16 -5
  18. package/src/client/components/view/Games.js +39 -14
  19. package/src/client/components/view/Lobby.js +90 -43
  20. package/src/client/components/view/LobbyAuth.js +13 -7
  21. package/src/client/components/view/Panel.js +1 -2
  22. package/src/client/components/view/RoomMenu.js +69 -0
  23. package/src/client/debug.js +24 -1
  24. package/src/client/lib/FpsMeter.js +46 -0
  25. package/src/client/lib/JoinRetry.js +73 -0
  26. package/src/client/lib/diagnostics.js +28 -5
  27. package/src/client/lib/hostCaps.js +82 -0
  28. package/src/client/lib/hostUnloadGuard.js +108 -0
  29. package/src/client/lib/hostVoteCommand.js +92 -0
  30. package/src/client/lib/pageReload.js +29 -0
  31. package/src/client/lib/roomLink.js +257 -0
  32. package/src/client/lib/signalingErrors.js +74 -0
  33. package/src/client/main.js +674 -451
  34. package/src/client/network/HiddenHostHealthLog.js +76 -0
  35. package/src/client/network/HostConnectionManager.js +62 -8
  36. package/src/client/network/HostController.js +376 -23
  37. package/src/client/network/HostHealthPolicy.js +307 -0
  38. package/src/client/network/HostHealthReporter.js +54 -0
  39. package/src/client/network/HostPrewarm.js +235 -0
  40. package/src/client/network/LoopbackTransport.js +16 -1
  41. package/src/client/network/PlannedHandoff.js +366 -0
  42. package/src/client/network/Promotion.js +420 -0
  43. package/src/client/network/RoomPeersReporter.js +81 -0
  44. package/src/client/network/SessionSupervisor.js +730 -0
  45. package/src/client/network/SignalingClient.js +183 -30
  46. package/src/client/network/StandbyReceiver.js +287 -0
  47. package/src/client/network/StandbySender.js +319 -0
  48. package/src/client/network/TokenHandoffTimer.js +81 -0
  49. package/src/client/network/WebRtcManager.js +338 -11
  50. package/src/client/network/policyClose.js +32 -2
  51. package/src/client/network/standbyChunks.js +185 -0
  52. package/src/client/session/GuestSession.js +428 -0
  53. package/src/client/session/HandoffFlow.js +399 -0
  54. package/src/client/session/HostRole.js +708 -0
  55. package/src/client/session/Membership.js +179 -0
  56. package/src/client/session/PromotionFlow.js +325 -0
  57. package/src/client/session/RouteBoot.js +432 -0
  58. package/src/client/session/StandbyRole.js +195 -0
  59. package/src/client/session/hostRoomPrep.js +152 -0
  60. package/src/client/session/roomContext.js +32 -0
  61. package/src/client/style.css +83 -22
  62. package/src/client/views/gameShell.js +7 -0
  63. package/src/client/views/includes/informer.pug +4 -0
  64. package/src/client/views/includes/lobby.pug +0 -3
  65. package/src/client/views/includes/roomMenu.pug +9 -0
  66. package/src/client/views/index.pug +1 -0
  67. package/src/config/closeCodes.js +1 -1
  68. package/src/config/env.js +37 -0
  69. package/src/config/hostDefaults.js +24 -0
  70. package/src/config/lobby.js +154 -3
  71. package/src/config/master.js +104 -21
  72. package/src/config/wsports.js +8 -0
  73. package/src/devtools/RecordingSocketManager.js +3 -2
  74. package/src/devtools/ScenarioRunner.js +123 -8
  75. package/src/devtools/VirtualClient.js +20 -0
  76. package/src/devtools/contract/format.js +4 -1
  77. package/src/devtools/contract/index.js +1 -2
  78. package/src/devtools/contract/rules/a2-package-scripts.js +1 -3
  79. package/src/devtools/contract/rules/b8-system-messages.js +1 -1
  80. package/src/devtools/contract/rules/b9-vote-names.js +25 -1
  81. package/src/devtools/contract/rules/c11-stat-mode.js +4 -1
  82. package/src/devtools/contract/rules/c6-stat-columns.js +1 -1
  83. package/src/devtools/contract/rules/c7-key-sets.js +8 -4
  84. package/src/devtools/contract/rules/d3-snapshot-interp.js +2 -1
  85. package/src/devtools/contract/rules/e1-sound-pairs.js +3 -1
  86. package/src/devtools/contract/rules/e4-map-layers.js +13 -2
  87. package/src/devtools/contract/rules/e5-map-radar-walls.js +7 -1
  88. package/src/devtools/contract/rules/index.js +38 -5
  89. package/src/devtools/invariants.js +10 -4
  90. package/src/devtools/pluginLoader.js +1 -4
  91. package/src/devtools/report.js +17 -1
  92. package/src/host/DebugRecorder.js +6 -1
  93. package/src/host/GameCoreAdapter.js +45 -0
  94. package/src/host/HostGame.js +928 -59
  95. package/src/host/PortMachine.js +268 -20
  96. package/src/host/host.worker.js +222 -16
  97. package/src/host/meta/SocketManager.js +20 -0
  98. package/src/host/meta/core/RoundManager.js +89 -14
  99. package/src/host/meta/modules/Accolades.js +4 -1
  100. package/src/host/meta/modules/Panel.js +30 -0
  101. package/src/host/meta/modules/PlayerDataSync.js +255 -46
  102. package/src/host/meta/modules/RTTManager.js +34 -0
  103. package/src/host/meta/modules/TimerManager.js +215 -18
  104. package/src/host/meta/modules/chat/systemMessages.js +22 -0
  105. package/src/host/meta/player/HumanParticipant.js +10 -0
  106. package/src/host/meta/player/ParticipantManager.js +98 -5
  107. package/src/lib/AbstractTimer.js +63 -1
  108. package/src/lib/Publisher.js +26 -0
  109. package/src/lib/capabilities.js +5 -0
  110. package/src/lib/checkpointCodec.js +125 -0
  111. package/src/lib/clientIp.js +9 -2
  112. package/src/lib/createHostRuntime.js +95 -6
  113. package/src/lib/formOptions.js +3 -1
  114. package/src/lib/gameConfigView.js +10 -0
  115. package/src/lib/gameId.js +13 -0
  116. package/src/lib/jwt.js +3 -1
  117. package/src/lib/loadGamePackage.js +2 -2
  118. package/src/lib/resumeKey.js +38 -0
  119. package/src/lib/roomId.js +39 -0
  120. package/src/lib/roomSettings.js +59 -0
  121. package/src/lib/validators.js +7 -0
  122. package/tests/fixtures/generations/gen-api3/host/ScriptedManager.js +2 -3
  123. package/tests/fixtures/generations/gen-api3/host/fakeCore.js +31 -5
  124. package/tests/fixtures/generations/gen-api3/manifest.json +1 -3
  125. package/tests/fixtures/generations/gen-api4/host/ScriptedManager.js +2 -3
  126. package/tests/fixtures/generations/gen-api4/host/fakeCore.js +31 -5
  127. package/tests/fixtures/generations/gen-api4/manifest.json +1 -3
  128. package/tests/fixtures/miniGame/config/client.js +15 -0
  129. package/tests/fixtures/miniGame/host/ScriptedManager.js +2 -3
  130. package/tests/fixtures/miniGame/host/fakeCore.js +31 -5
@@ -18,8 +18,9 @@ const LOGIN_ERROR_MESSAGES = {
18
18
  };
19
19
 
20
20
  // Представление логина лобби: переключает login/nick секции lobbyAuth.pug и
21
- // бейдж пользователя в lobby.pug (#lobby-user), скрывает/показывает #lobby
22
- // целиком — до авторизации список серверов недоступен (B2)
21
+ // бейдж пользователя в lobby.pug (#lobby-user), скрывает #lobby целиком —
22
+ // до авторизации список серверов недоступен (B2). Показывает лобби main.js,
23
+ // когда маршрут в него ведёт: вход по ссылке минует лобби вовсе
23
24
  export default class LobbyAuthView {
24
25
  constructor(model, config) {
25
26
  if (lobbyAuthView) {
@@ -31,7 +32,9 @@ export default class LobbyAuthView {
31
32
  const { elems, providerButtonClass } = config;
32
33
 
33
34
  this._elems = elems;
34
- this._providerButtons = document.querySelectorAll(`.${providerButtonClass}`);
35
+ this._providerButtons = document.querySelectorAll(
36
+ `.${providerButtonClass}`,
37
+ );
35
38
 
36
39
  this._container = document.getElementById(elems.containerId);
37
40
  this._loginSection = document.getElementById(elems.loginSectionId);
@@ -77,7 +80,9 @@ export default class LobbyAuthView {
77
80
  this._user.style.display = 'none';
78
81
 
79
82
  this._providerButtons.forEach(btn => {
80
- btn.style.display = providers.includes(btn.dataset.provider) ? '' : 'none';
83
+ btn.style.display = providers.includes(btn.dataset.provider)
84
+ ? ''
85
+ : 'none';
81
86
  });
82
87
  }
83
88
 
@@ -92,17 +97,18 @@ export default class LobbyAuthView {
92
97
 
93
98
  showLobby({ nick }) {
94
99
  this._container.style.display = 'none';
95
- this._lobby.style.display = 'flex';
96
100
  this._user.style.display = 'flex';
97
101
  this._userNick.textContent = nick;
98
102
  }
99
103
 
100
104
  renderLoginError(code) {
101
- this._loginError.textContent = LOGIN_ERROR_MESSAGES[code] || 'Sign-in failed';
105
+ this._loginError.textContent =
106
+ LOGIN_ERROR_MESSAGES[code] || 'Sign-in failed';
102
107
  }
103
108
 
104
109
  renderNickError(code) {
105
- this._nickError.textContent = NICK_ERROR_MESSAGES[code] || 'Something went wrong';
110
+ this._nickError.textContent =
111
+ NICK_ERROR_MESSAGES[code] || 'Something went wrong';
106
112
  }
107
113
 
108
114
  _emitNick() {
@@ -234,8 +234,7 @@ export default class PanelView {
234
234
  }
235
235
 
236
236
  const { blocksToShow, blink } = pendingTarget;
237
- const step =
238
- blocksToShow > 0 ? BAR_FILL_DURATION_MS / blocksToShow : 0;
237
+ const step = blocksToShow > 0 ? BAR_FILL_DURATION_MS / blocksToShow : 0;
239
238
 
240
239
  for (let index = 0; index < blocksToShow; index += 1) {
241
240
  bar.blockTimers.push(
@@ -0,0 +1,69 @@
1
+ import Publisher from '../../../lib/Publisher.js';
2
+
3
+ // Singleton RoomMenuView
4
+
5
+ let roomMenuView;
6
+
7
+ export default class RoomMenuView {
8
+ // config — lobbyConfig.roomMenu: { elems: { panelId, menuId, toggleId,
9
+ // listId, leaveId, handoverId, statusId } }
10
+ constructor(model, config) {
11
+ if (roomMenuView) {
12
+ return roomMenuView;
13
+ }
14
+
15
+ roomMenuView = this;
16
+
17
+ const { elems } = config;
18
+
19
+ this._menu = document.getElementById(elems.menuId);
20
+ this._list = document.getElementById(elems.listId);
21
+ this._leave = document.getElementById(elems.leaveId);
22
+ this._handover = document.getElementById(elems.handoverId);
23
+ this._status = document.getElementById(elems.statusId);
24
+ this._panel = document.getElementById(elems.panelId);
25
+ this._home = this._menu.parentElement;
26
+
27
+ this.publisher = new Publisher();
28
+
29
+ document
30
+ .getElementById(elems.toggleId)
31
+ .addEventListener('click', () => this.publisher.emit('toggle'));
32
+ this._leave.addEventListener('click', () => this.publisher.emit('leave'));
33
+ this._handover.addEventListener('click', () =>
34
+ this.publisher.emit('handover'),
35
+ );
36
+
37
+ this._mPublic = model.publisher;
38
+ this._mPublic.on('state', 'showState', this);
39
+ this._mPublic.on('open', 'showOpen', this);
40
+ }
41
+
42
+ showState({ visible, leave, handover, status }) {
43
+ this._menu.style.display = visible ? 'block' : 'none';
44
+ showItem(this._leave, leave);
45
+ showItem(this._handover, handover);
46
+ this._status.textContent = status ?? '';
47
+ this._status.style.display = status ? 'block' : 'none';
48
+ }
49
+
50
+ // меню — часть панели (справа от таблицы), но разметка отдельная: каркас
51
+ // панели общий со standalone SDK, где меню комнаты нет. Игра без панели в
52
+ // initIdList держит её скрытой — тогда меню остаётся кнопкой в углу
53
+ placeInPanel(inPanel) {
54
+ const parent = inPanel && this._panel ? this._panel : this._home;
55
+
56
+ if (this._menu.parentElement !== parent) {
57
+ parent.appendChild(this._menu);
58
+ }
59
+ }
60
+
61
+ showOpen(open) {
62
+ this._list.style.display = open ? 'block' : 'none';
63
+ }
64
+ }
65
+
66
+ function showItem(elem, { visible, disabled }) {
67
+ elem.style.display = visible ? 'block' : 'none';
68
+ elem.disabled = disabled;
69
+ }
@@ -24,6 +24,8 @@ export function debugLog(...args) {
24
24
  * если вкладка не хостит комнату).
25
25
  * @param {Function} params.getClientCore - Текущий ClientCore.
26
26
  * @param {string} params.reportUrl - Эндпоинт выгрузки на мастере.
27
+ * @param {Function} [params.startHandoff] - ({ reason, stay }) => boolean —
28
+ * плановая передача хоста (host-migration этап 8), пока у неё нет меню.
27
29
  * @param {Function} [params.fetchImpl] - fetch (подменяется в тестах).
28
30
  * @param {Function} [params.log] - Логгер (подменяется в тестах).
29
31
  * @returns {Object} API для window.__vimpDebug.
@@ -32,6 +34,7 @@ export function createDebugApi({
32
34
  getHostController,
33
35
  getClientCore,
34
36
  reportUrl,
37
+ startHandoff = null,
35
38
  fetchImpl,
36
39
  log = debugLog,
37
40
  }) {
@@ -105,7 +108,9 @@ export function createDebugApi({
105
108
  async startRecording() {
106
109
  const started = await requireHost().startRecording();
107
110
 
108
- log(started ? 'recording started' : 'recording unavailable (no dev mode)');
111
+ log(
112
+ started ? 'recording started' : 'recording unavailable (no dev mode)',
113
+ );
109
114
 
110
115
  return started;
111
116
  },
@@ -154,5 +159,23 @@ export function createDebugApi({
154
159
 
155
160
  return dump;
156
161
  },
162
+
163
+ /**
164
+ * Плановая передача роли хоста бете (host-migration этап 8): stay —
165
+ * остаться в комнате гостем, иначе уход в лобби.
166
+ * @param {Object} [options]
167
+ * @param {string} [options.reason]
168
+ * @param {boolean} [options.stay]
169
+ * @returns {boolean} передача началась.
170
+ */
171
+ handoff({ reason = 'handover', stay = true } = {}) {
172
+ requireHost();
173
+
174
+ const started = startHandoff?.({ reason, stay }) === true;
175
+
176
+ log(started ? `handoff started (${reason})` : 'handoff not started');
177
+
178
+ return started;
179
+ },
157
180
  };
158
181
  }
@@ -0,0 +1,46 @@
1
+ // Средний FPS рендера за окно (host-migration этап 9c): гость сообщает его
2
+ // мастеру в caps.fps, и мастер не назначает бетой слабую вкладку.
3
+ // Ticker.shared.FPS для этого не годится — это скорость одного последнего
4
+ // кадра: пауза сборщика мусора в момент замера сняла бы бету с прогревом.
5
+
6
+ // окно короче — замер шумный, не сообщается
7
+ const MIN_WINDOW_MS = 1000;
8
+
9
+ export default class FpsMeter {
10
+ /**
11
+ * @param {Object} [options]
12
+ * @param {Function} [options.now] - монотонные часы, мс.
13
+ */
14
+ constructor({ now = () => performance.now() } = {}) {
15
+ this._now = now;
16
+ this._frames = 0;
17
+ this._since = now();
18
+ }
19
+
20
+ // кадр отрисован (колбэк тикера)
21
+ frame() {
22
+ this._frames++;
23
+ }
24
+
25
+ // окно заново: кадры до этого момента не в счёт (вкладка была скрыта)
26
+ reset() {
27
+ this._frames = 0;
28
+ this._since = this._now();
29
+ }
30
+
31
+ /**
32
+ * Средний FPS с прошлого замера; окно начинается заново.
33
+ * @returns {number|null} null — кадров не было или окно короче 1 с.
34
+ */
35
+ sample() {
36
+ const elapsedMs = this._now() - this._since;
37
+ const fps =
38
+ this._frames > 0 && elapsedMs >= MIN_WINDOW_MS
39
+ ? (this._frames * 1000) / elapsedMs
40
+ : null;
41
+
42
+ this.reset();
43
+
44
+ return fps;
45
+ }
46
+ }
@@ -0,0 +1,73 @@
1
+ // Повтор join_room на unknownRoom (host-migration-review этап 6): после
2
+ // рестарта мастера реестр комнат пуст, и комната появляется, только когда
3
+ // хост вернёт её reclaim_host. Гость, чей сигналинг переподключился раньше
4
+ // хоста, повторяет вход с бэкоффом, пока не истечёт окно.
5
+ //
6
+ // Без DOM: отправку, таймеры и часы инъектирует владелец
7
+ // (client/session/Membership.js).
8
+
9
+ const DEFAULT_DELAYS_MS = [1000, 2000, 4000, 8000, 8000, 8000];
10
+
11
+ export default class JoinRetry {
12
+ /**
13
+ * @param {Object} opts
14
+ * @param {Function} opts.send - () отправить join_room.
15
+ * @param {number[]} [opts.delaysMs] - задержки попыток; последняя
16
+ * повторяется.
17
+ * @param {number} [opts.windowMs] - сколько всего пытаться (от первого
18
+ * schedule() после stop()).
19
+ * @param {Object} [opts.timers] - { setTimeout, clearTimeout }.
20
+ * @param {Function} [opts.now]
21
+ */
22
+ constructor({
23
+ send,
24
+ delaysMs = DEFAULT_DELAYS_MS,
25
+ windowMs = 30000,
26
+ timers = globalThis,
27
+ now = () => Date.now(),
28
+ }) {
29
+ this._send = send;
30
+ this._delaysMs = delaysMs;
31
+ this._windowMs = windowMs;
32
+ this._timers = timers;
33
+ this._now = now;
34
+ this._timer = null;
35
+ this._attempt = 0;
36
+ this._startedAt = null;
37
+ }
38
+
39
+ // следующая попытка, если окно не истекло
40
+ schedule() {
41
+ if (this._timer !== null) {
42
+ return;
43
+ }
44
+
45
+ const now = this._now();
46
+
47
+ if (this._startedAt === null) {
48
+ this._startedAt = now;
49
+ } else if (now - this._startedAt >= this._windowMs) {
50
+ return;
51
+ }
52
+
53
+ const delay =
54
+ this._delaysMs[Math.min(this._attempt, this._delaysMs.length - 1)];
55
+
56
+ this._attempt += 1;
57
+ this._timer = this._timers.setTimeout(() => {
58
+ this._timer = null;
59
+ this._send();
60
+ }, delay);
61
+ }
62
+
63
+ // снять таймер и сбросить окно: вход состоялся или вкладка ушла
64
+ stop() {
65
+ if (this._timer !== null) {
66
+ this._timers.clearTimeout(this._timer);
67
+ this._timer = null;
68
+ }
69
+
70
+ this._attempt = 0;
71
+ this._startedAt = null;
72
+ }
73
+ }
@@ -37,7 +37,14 @@ const EXTENSION_PREFIXES = [
37
37
  ];
38
38
 
39
39
  // служебные значения blockedURI — не URL, берутся как есть
40
- const CSP_KEYWORDS = new Set(['', 'inline', 'eval', 'wasm-eval', 'data', 'blob']);
40
+ const CSP_KEYWORDS = new Set([
41
+ '',
42
+ 'inline',
43
+ 'eval',
44
+ 'wasm-eval',
45
+ 'data',
46
+ 'blob',
47
+ ]);
41
48
 
42
49
  const encoder = new TextEncoder();
43
50
 
@@ -72,7 +79,12 @@ function topFrame(stack) {
72
79
  return '';
73
80
  }
74
81
 
75
- return stack.split('\n').find(line => FRAME_RE.test(line))?.trim() ?? '';
82
+ return (
83
+ stack
84
+ .split('\n')
85
+ .find(line => FRAME_RE.test(line))
86
+ ?.trim() ?? ''
87
+ );
76
88
  }
77
89
 
78
90
  // снимок деталей: простой объект ≤ 2048 байт JSON, иначе пометка усечения
@@ -127,7 +139,12 @@ function isExtensionUrl(value) {
127
139
  function defaultSend(url, json) {
128
140
  if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
129
141
  try {
130
- if (navigator.sendBeacon(url, new Blob([json], { type: 'application/json' }))) {
142
+ if (
143
+ navigator.sendBeacon(
144
+ url,
145
+ new Blob([json], { type: 'application/json' }),
146
+ )
147
+ ) {
131
148
  return;
132
149
  }
133
150
  } catch {
@@ -237,7 +254,10 @@ export function createDiagnostics({
237
254
  // молча съедал бы приросты счётчиков непрерывно повторяющейся ошибки
238
255
  const sinceLast = lastSentAt === null ? Infinity : now() - lastSentAt;
239
256
 
240
- timer = setTimeout(flush, Math.max(flushDelayMs, minIntervalMs - sinceLast));
257
+ timer = setTimeout(
258
+ flush,
259
+ Math.max(flushDelayMs, minIntervalMs - sinceLast),
260
+ );
241
261
  }
242
262
 
243
263
  function guarded(fn) {
@@ -301,7 +321,10 @@ export function createDiagnostics({
301
321
  // сработавшее тысячу раз, одна запись со счётчиком
302
322
  function reportCsp(event) {
303
323
  guarded(() => {
304
- if (isExtensionUrl(event.blockedURI) || isExtensionUrl(event.sourceFile)) {
324
+ if (
325
+ isExtensionUrl(event.blockedURI) ||
326
+ isExtensionUrl(event.sourceFile)
327
+ ) {
305
328
  return;
306
329
  }
307
330
 
@@ -0,0 +1,82 @@
1
+ import { supportsModuleWorker } from '../network/workerSupport.js';
2
+
3
+ // Возможности участника комнаты (host-migration этап 6): мастер по ним
4
+ // выбирает преемника хоста (master/successor.js). Шлются в join_room /
5
+ // register_host и в member_update при смене видимости вкладки или типа
6
+ // ICE-кандидата, а гостем — раз в 10 с ради свежего FPS (этап 9c).
7
+
8
+ /**
9
+ * Мобильное устройство: Client Hints, а без них — грубый указатель.
10
+ * @param {Object} [env] - { navigator, matchMedia } (тесты).
11
+ * @returns {boolean}
12
+ */
13
+ export function isMobileDevice(env = globalThis) {
14
+ const hinted = env.navigator?.userAgentData?.mobile;
15
+
16
+ if (typeof hinted === 'boolean') {
17
+ return hinted;
18
+ }
19
+
20
+ try {
21
+ return env.matchMedia?.('(pointer: coarse)').matches === true;
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Может ли вкладка поднять Worker хоста и принимать WebRTC.
29
+ * @param {Object} [env] - { RTCPeerConnection, WebAssembly, navigator,
30
+ * matchMedia, moduleWorker: () => boolean } (тесты).
31
+ * @returns {boolean}
32
+ */
33
+ export function canHostIn(env = globalThis) {
34
+ const moduleWorker = env.moduleWorker ?? supportsModuleWorker;
35
+
36
+ return (
37
+ typeof env.RTCPeerConnection !== 'undefined' &&
38
+ typeof env.WebAssembly === 'object' &&
39
+ env.WebAssembly !== null &&
40
+ !isMobileDevice(env) &&
41
+ moduleWorker()
42
+ );
43
+ }
44
+
45
+ /**
46
+ * Собирает caps участника.
47
+ * @param {Object} options
48
+ * @param {boolean} options.canHost - результат canHostIn (считается один
49
+ * раз: проба Worker'а не бесплатна).
50
+ * @param {string|null} [options.iceType] - тип своего локального
51
+ * ICE-кандидата в выбранной паре с хостом ('host'|'srflx'|'prflx'|'relay').
52
+ * @param {number|null} [options.fps] - FPS рендера (гость; мастер не
53
+ * назначает бетой вкладку ниже master.room.minSuccessorFps).
54
+ * @param {Object} [env] - { document, navigator, matchMedia } (тесты).
55
+ * @returns {{ canHost: boolean, mobile: boolean, hidden: boolean,
56
+ * iceType: string|null, fps: number|null }}
57
+ */
58
+ export function buildHostCaps(
59
+ { canHost, iceType = null, fps = null },
60
+ env = globalThis,
61
+ ) {
62
+ return {
63
+ canHost: canHost === true,
64
+ mobile: isMobileDevice(env),
65
+ hidden: env.document?.hidden === true,
66
+ iceType: iceType ?? null,
67
+ fps: Number.isFinite(fps) ? Math.round(fps) : null,
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Хватит ли срока токена, чтобы вкладка могла принять роль хоста: хост
73
+ * предъявляет токен мастеру посреди матча (register_host, reclaim_host), а
74
+ * продления нет.
75
+ * @param {number|null} expiresAt - мс эпохи; null — неизвестно (не мешает).
76
+ * @param {number} minLifetimeMs
77
+ * @param {number} [now]
78
+ * @returns {boolean}
79
+ */
80
+ export function tokenAllowsHosting(expiresAt, minLifetimeMs, now = Date.now()) {
81
+ return expiresAt === null || expiresAt - now > minLifetimeMs;
82
+ }
@@ -0,0 +1,108 @@
1
+ // Защита комнаты от случайного закрытия вкладки хоста (host-migration этап
2
+ // 8.4).
3
+ //
4
+ // beforeunload висит, только пока вкладка — текущий хост и в комнате есть
5
+ // другие люди: Ctrl+W/F5 спросит «Leave site?». Финального дампа во время
6
+ // диалога не будет — пока он открыт, главный поток стоит, а именно он
7
+ // ретранслирует Worker → DataChannel; гости это время видят замерший матч.
8
+ //
9
+ // pagehide срабатывает и после «Leave», и при закрытии без диалога (на
10
+ // мобильных beforeunload ненадёжен — там остаётся только он): хост сразу
11
+ // говорит мастеру host_leaving (аварийная миграция без ожидания обрыва WS),
12
+ // гость — LEAVE хосту и leave_room мастеру (его место освобождается сразу,
13
+ // а не через resumeGraceMs). Всё best-effort: недошедшее перекроет обрыв
14
+ // соединений.
15
+ //
16
+ // exit() — вкладка уходит сама («Leave server», переход в лобби): диалог не
17
+ // нужен, уход уже объявлен.
18
+
19
+ export default class HostUnloadGuard {
20
+ /**
21
+ * @param {Object} options
22
+ * @param {Function} options.onHostLeave - () pagehide у хоста.
23
+ * @param {Function} options.onGuestLeave - () pagehide у гостя комнаты.
24
+ * @param {EventTarget} [options.target] - window (тесты — свой).
25
+ */
26
+ constructor({ onHostLeave, onGuestLeave, target = globalThis }) {
27
+ this._onHostLeave = onHostLeave;
28
+ this._onGuestLeave = onGuestLeave;
29
+ this._target = target;
30
+
31
+ // null — не в комнате | 'host' | 'guest'
32
+ this._role = null;
33
+ this._othersPresent = false;
34
+ this._armed = false;
35
+ this._exiting = false;
36
+
37
+ this._onBeforeUnload = event => {
38
+ event.preventDefault();
39
+ // старые браузеры показывают диалог только по returnValue
40
+ event.returnValue = '';
41
+ };
42
+ this._onPageHide = () => this._pageHide();
43
+
44
+ target.addEventListener('pagehide', this._onPageHide);
45
+ }
46
+
47
+ // диалог закрытия сейчас висит
48
+ get armed() {
49
+ return this._armed;
50
+ }
51
+
52
+ /**
53
+ * Роль вкладки в комнате и есть ли в ней другие люди.
54
+ * @param {Object} state
55
+ * @param {'host'|'guest'|null} state.role
56
+ * @param {boolean} [state.othersPresent]
57
+ */
58
+ update({ role, othersPresent = false }) {
59
+ this._role = role ?? null;
60
+ this._othersPresent = othersPresent === true;
61
+ this._sync();
62
+ }
63
+
64
+ // вкладка уходит сама: ни диалога, ни повторного объявления ухода
65
+ exit() {
66
+ this._exiting = true;
67
+ this._sync();
68
+ }
69
+
70
+ destroy() {
71
+ this.exit();
72
+ this._target.removeEventListener('pagehide', this._onPageHide);
73
+ }
74
+
75
+ _sync() {
76
+ const armed =
77
+ !this._exiting && this._role === 'host' && this._othersPresent;
78
+
79
+ if (armed === this._armed) {
80
+ return;
81
+ }
82
+
83
+ this._armed = armed;
84
+
85
+ if (armed) {
86
+ this._target.addEventListener('beforeunload', this._onBeforeUnload);
87
+ } else {
88
+ this._target.removeEventListener('beforeunload', this._onBeforeUnload);
89
+ }
90
+ }
91
+
92
+ _pageHide() {
93
+ if (this._exiting) {
94
+ return;
95
+ }
96
+
97
+ // страница уходит: второй pagehide (bfcache) ничего не повторит
98
+ this._exiting = true;
99
+
100
+ if (this._role === 'host') {
101
+ this._onHostLeave();
102
+ } else if (this._role === 'guest') {
103
+ this._onGuestLeave();
104
+ }
105
+
106
+ this._sync();
107
+ }
108
+ }
@@ -0,0 +1,92 @@
1
+ // Голосование «Change host» (host-migration этап 10): команда /changehost и
2
+ // окно у гостей. Голоса считает мастер, поэтому команда в лобби-режиме не
3
+ // уходит хосту. В solo/dedicated мастера нет — команда остаётся обычным
4
+ // текстом для игры.
5
+
6
+ // имя голосования движка; префикс '@' зарезервирован движком
7
+ export const CHANGE_HOST_VOTE = '@changeHost';
8
+
9
+ export const CHANGE_HOST_VALUES = ['Yes', 'No'];
10
+
11
+ const COMMAND = '/changehost';
12
+
13
+ /**
14
+ * Разбор сообщения чата.
15
+ * @param {string} text
16
+ * @param {string} bootMode - 'lobby' | 'solo' | 'dedicated'.
17
+ * @returns {'start'|'usage'|null} null — не перехватывать.
18
+ */
19
+ export function parseChangeHost(text, bootMode) {
20
+ if (bootMode !== 'lobby' || typeof text !== 'string') {
21
+ return null;
22
+ }
23
+
24
+ const [command, ...args] = text.trim().split(/\s+/);
25
+
26
+ if (command !== COMMAND) {
27
+ return null;
28
+ }
29
+
30
+ return args.length === 0 ? 'start' : 'usage';
31
+ }
32
+
33
+ /**
34
+ * Заголовок окна голосования у гостя.
35
+ * @param {string|null} nick - инициатор.
36
+ * @returns {string}
37
+ */
38
+ export function changeHostTitle(nick) {
39
+ return nick ? `Change host? (started by ${nick})` : 'Change host?';
40
+ }
41
+
42
+ /**
43
+ * Ответ окна ('Yes'/'No') — значение host_vote_answer.
44
+ * @param {string} value
45
+ * @returns {'yes'|'no'|null}
46
+ */
47
+ export function answerValue(value) {
48
+ const index = CHANGE_HOST_VALUES.indexOf(value);
49
+
50
+ return index === -1 ? null : index === 0 ? 'yes' : 'no';
51
+ }
52
+
53
+ // причина отказа мастера → код системного сообщения
54
+ const REJECTION_KEYS = {
55
+ host: 'HOST_VOTE_IS_HOST',
56
+ active: 'HOST_VOTE_ACTIVE',
57
+ roomCooldown: 'HOST_VOTE_RECENT',
58
+ userCooldown: 'HOST_VOTE_RECENT',
59
+ };
60
+
61
+ /**
62
+ * Ключ системного сообщения для ошибки мастера на host_vote_start.
63
+ * @param {Object} error - { code, reason }.
64
+ * @returns {string|null} null — ошибка не про голосование.
65
+ */
66
+ export function rejectionMessageKey({ code, reason } = {}) {
67
+ if (code === 'noSuccessor') {
68
+ return 'HOST_VOTE_NO_SUCCESSOR';
69
+ }
70
+
71
+ if (code === 'voteRejected') {
72
+ return REJECTION_KEYS[reason] ?? 'HOST_VOTE_UNAVAILABLE';
73
+ }
74
+
75
+ return null;
76
+ }
77
+
78
+ /**
79
+ * Системное сообщение итога голосования.
80
+ * @param {Object} result - host_vote_result.
81
+ * @returns {{ key: string, params: Array<string> }}
82
+ */
83
+ export function resultMessage({ passed, cancelled, yes, eligibleCount }) {
84
+ if (cancelled) {
85
+ return { key: 'HOST_VOTE_CANCELLED', params: [] };
86
+ }
87
+
88
+ return {
89
+ key: passed ? 'HOST_VOTE_PASSED' : 'HOST_VOTE_FAILED',
90
+ params: [String(yes), String(eligibleCount)],
91
+ };
92
+ }
@@ -0,0 +1,29 @@
1
+ import { reloadTo } from './roomLink.js';
2
+
3
+ /**
4
+ * Программная перезагрузка страницы: сначала guard.exit() — иначе
5
+ * pagehide принял бы её за закрытие вкладки, и гость (бета перед холодным
6
+ * промоушеном, возврат в ту же комнату) объявил бы уход и потерял место.
7
+ * Единственное место, где клиент перезагружает страницу (ESLint).
8
+ * @param {Object} deps
9
+ * @param {Function} deps.getGuard - () → HostUnloadGuard | null.
10
+ * @param {Function} [deps.reloadToHash] - (hashPart) — тесты.
11
+ * @param {Function} [deps.reloadSame] - () — тесты.
12
+ * @returns {Function} (hashPart?) => void; hashPart не задан — тот же адрес.
13
+ */
14
+ export function createPageReload({
15
+ getGuard,
16
+ reloadToHash = reloadTo,
17
+ // eslint-disable-next-line no-restricted-syntax -- сама обёртка
18
+ reloadSame = () => window.location.reload(),
19
+ }) {
20
+ return hashPart => {
21
+ getGuard()?.exit();
22
+
23
+ if (hashPart === undefined) {
24
+ reloadSame();
25
+ } else {
26
+ reloadToHash(hashPart);
27
+ }
28
+ };
29
+ }