vimp-engine 0.38.3 → 0.40.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 (88) hide show
  1. package/README.md +2 -2
  2. package/bin/vimp-sim.js +2 -2
  3. package/core/Cargo.toml +2 -2
  4. package/package.json +3 -4
  5. package/src/client/InputListener.js +3 -0
  6. package/src/client/SoundManager.js +13 -0
  7. package/src/client/boot.js +11 -87
  8. package/src/client/components/controller/Controls.js +10 -0
  9. package/src/client/components/controller/Voice.js +31 -0
  10. package/src/client/components/model/Controls.js +64 -0
  11. package/src/client/components/model/RoomMenu.js +1 -1
  12. package/src/client/components/model/Voice.js +96 -0
  13. package/src/client/components/model/Vote.js +2 -1
  14. package/src/client/components/view/Auth.js +3 -3
  15. package/src/client/components/view/Voice.js +172 -0
  16. package/src/client/lib/formBuilder.js +7 -6
  17. package/src/client/lib/hostVoteCommand.js +2 -2
  18. package/src/client/lib/pickActiveGame.js +3 -12
  19. package/src/client/lib/retryOnNetworkError.js +0 -9
  20. package/src/client/lib/voiceState.js +78 -0
  21. package/src/client/main.js +160 -90
  22. package/src/client/network/HostConnectionManager.js +46 -5
  23. package/src/client/network/HostController.js +43 -0
  24. package/src/client/network/LoopbackTransport.js +14 -0
  25. package/src/client/network/SessionSupervisor.js +38 -14
  26. package/src/client/network/WebRtcManager.js +46 -5
  27. package/src/client/network/policyClose.js +10 -77
  28. package/src/client/style.css +150 -9
  29. package/src/client/views/gameShell.js +2 -4
  30. package/src/client/views/includes/auth.pug +1 -4
  31. package/src/client/views/includes/lobby.pug +3 -6
  32. package/src/client/voice/JitterBuffer.js +137 -0
  33. package/src/client/voice/MicCapture.js +307 -0
  34. package/src/client/voice/PttMachine.js +369 -0
  35. package/src/client/voice/VoiceClient.js +336 -0
  36. package/src/client/voice/VoiceCues.js +295 -0
  37. package/src/client/voice/VoiceEncoder.js +174 -0
  38. package/src/client/voice/VoicePlayback.js +558 -0
  39. package/src/client/voice/audioData.js +35 -0
  40. package/src/client/voice/captureProcessor.worklet.js +136 -0
  41. package/src/client/voice/opusConfig.js +49 -0
  42. package/src/client/voice/voiceOutput.js +72 -0
  43. package/src/client/voice/voiceSupport.js +71 -0
  44. package/src/client/voice/voiceTones.js +90 -0
  45. package/src/config/clientServices.js +2 -0
  46. package/src/config/closeCodes.js +7 -9
  47. package/src/config/gameCodes.js +1 -1
  48. package/src/config/hostDefaults.js +2 -2
  49. package/src/config/lobby.js +3 -4
  50. package/src/config/voiceDefaults.js +44 -0
  51. package/src/config/voiceProtocol.js +80 -0
  52. package/src/config/voteKeys.js +4 -0
  53. package/src/config/wsports.js +13 -1
  54. package/src/devtools/ScenarioRunner.js +1 -1
  55. package/src/devtools/builtinScenario.js +2 -2
  56. package/src/devtools/contract/rules/a2-package-scripts.js +1 -1
  57. package/src/devtools/contract/rules/a4-vite-config.js +1 -1
  58. package/src/devtools/contract/rules/b11-voice-config.js +34 -0
  59. package/src/devtools/contract/rules/c12-voice-hotkey.js +70 -0
  60. package/src/devtools/contract/rules/c4-component-dependencies.js +1 -1
  61. package/src/devtools/contract/rules/index.js +4 -0
  62. package/src/devtools/invariants.js +1 -1
  63. package/src/devtools/pluginLoader.js +2 -2
  64. package/src/host/HostGame.js +62 -4
  65. package/src/host/PortMachine.js +23 -24
  66. package/src/host/commandFailure.js +2 -2
  67. package/src/host/host.worker.js +7 -0
  68. package/src/host/identity.js +3 -3
  69. package/src/host/meta/SocketManager.js +6 -0
  70. package/src/host/meta/core/CommandProcessor.js +2 -2
  71. package/src/host/meta/modules/Accolades.js +1 -1
  72. package/src/host/meta/modules/voice/Voice.js +430 -0
  73. package/src/host/meta/modules/voice/channels.js +58 -0
  74. package/src/host/meta/modules/voice/index.js +1 -0
  75. package/src/lib/VoiceRelay.js +200 -0
  76. package/src/lib/buildClientConfig.js +7 -0
  77. package/src/lib/capabilities.js +4 -0
  78. package/src/lib/createHostRuntime.js +8 -0
  79. package/src/lib/gameConfigView.js +11 -1
  80. package/src/lib/loadGamePackage.js +7 -9
  81. package/src/lib/offlinePlayerData.js +1 -1
  82. package/src/lib/validators.js +2 -2
  83. package/src/lib/voiceConfig.js +136 -0
  84. package/src/lib/voiceMessages.js +172 -0
  85. package/src/lib/voicePacket.js +142 -0
  86. package/src/standalone/index.js +3 -2
  87. package/src/client/lib/footerLink.js +0 -30
  88. package/src/client/network/WebSocketTransport.js +0 -78
@@ -26,17 +26,19 @@ import StatCtrl from './components/controller/Stat.js';
26
26
  import VoteModel from './components/model/Vote.js';
27
27
  import VoteView from './components/view/Vote.js';
28
28
  import VoteCtrl from './components/controller/Vote.js';
29
+ import VoiceModel from './components/model/Voice.js';
30
+ import VoiceView from './components/view/Voice.js';
31
+ import VoiceCtrl from './components/controller/Voice.js';
29
32
  import {
30
33
  buildForm,
31
34
  mergeRoomDefaults,
32
35
  bindLiveErrors,
33
36
  } from './lib/formBuilder.js';
34
37
  import { normalizeAuthParams } from './lib/authParams.js';
35
- import { renderDocsLink } from './lib/footerLink.js';
36
38
  import { createGameActivator } from './lib/gameActivator.js';
37
39
  import createAutostart from './lib/autostart.js';
38
40
  import { applyCatalogState } from './lib/catalogState.js';
39
- import { getBootConfig, resolveBootConfig } from './boot.js';
41
+ import { resolveBootConfig } from './boot.js';
40
42
  import {
41
43
  ensureGameShell,
42
44
  ensureCanvas,
@@ -45,6 +47,8 @@ import {
45
47
  import { createContextTracker } from './lib/contextTracker.js';
46
48
  import { createLocalPlayer } from './lib/localPlayer.js';
47
49
  import { createAccolades } from './lib/accolades.js';
50
+ import { createVoiceState } from './lib/voiceState.js';
51
+ import { parseVoiceMessage } from '../lib/voiceMessages.js';
48
52
  import { createDiagnostics } from './lib/diagnostics.js';
49
53
  import { fetchRetrying, isNetworkFailure } from './lib/retryOnNetworkError.js';
50
54
  import { dispatchSocketMessage } from './lib/socketDispatch.js';
@@ -62,7 +66,6 @@ import { validateAuth } from '../lib/validators.js';
62
66
  import SoundManager from './SoundManager.js';
63
67
  import SignalingClient from './network/SignalingClient.js';
64
68
  import LoopbackTransport from './network/LoopbackTransport.js';
65
- import WebSocketTransport from './network/WebSocketTransport.js';
66
69
  import InlineHostBridge from './network/InlineHostBridge.js';
67
70
  import SessionSupervisor, {
68
71
  SESSION_STATES,
@@ -78,11 +81,7 @@ import {
78
81
  import { createVerifier, challengeOf } from './lib/pkce.js';
79
82
  import { createTurnstile } from './lib/turnstile.js';
80
83
  import TokenRenewer from './network/TokenRenewer.js';
81
- import {
82
- isKickClose,
83
- POLICY_CLOSE_INFORMS,
84
- shouldReloadAfterClose,
85
- } from './network/policyClose.js';
84
+ import { isKickClose } from './network/policyClose.js';
86
85
  import LobbyModel from './components/model/Lobby.js';
87
86
  import LobbyView from './components/view/Lobby.js';
88
87
  import LobbyCtrl from './components/controller/Lobby.js';
@@ -140,8 +139,8 @@ import runHotTick from './lib/hotTick.js';
140
139
 
141
140
  // Динамическая загрузка игры по каталогу мастера (Этап 6.3): ClientPlugin
142
141
  // (parts, bakers, игровой CSS, хуки ядра) грузится по entries.client манифеста
143
- // — движок не импортирует игру статически. Первой активируется gamesManifest[0]
144
- // (или boot.gameId), но активная игра не заморожена: выбор в #lobby-game и
142
+ // — движок не импортирует игру статически. Первой активируется первая
143
+ // доступная игра каталога, но активная игра не заморожена: выбор в #lobby-game и
145
144
  // вход в чужую комнату переключают её через gameActivator (см. bindActiveGame).
146
145
  // Переключение безопасно ровно до старта матча: всё пер-игровое состояние
147
146
  // (Factory, Pixi-приложения, clientCore, звук) появляется только в CONFIG_DATA,
@@ -174,37 +173,24 @@ function bindActiveGame(manifest, plugin) {
174
173
  }
175
174
 
176
175
  // режим загрузки (Этап 2 плана standalone-sdk): lobby — прод с мастером,
177
- // solo — хост в этой же вкладке (standalone SDK), dedicated — прямой WS к
178
- // Node-серверу. Ветвлений ровно пять: манифест, сигналинг/лобби, транспорт,
179
- // авто-аутентификация и точка монтирования канвасов.
180
- //
181
- // каталог манифестов нужен только лобби-контуру, а его запрос не зависит от
182
- // ответа /config — пускаем оба в полёт разом, иначе старт лобби платит лишний
183
- // последовательный round-trip
184
- const injectedBoot = getBootConfig();
185
- const manifestPromise = injectedBoot
186
- ? null
187
- : fetchGamesManifest(lobbyConfig.gamesManifestUrl, (...args) =>
188
- fetchRetrying(fetch, ...args),
189
- ).catch(err => err);
190
-
191
- let boot;
192
-
193
- try {
194
- boot = injectedBoot ?? (await resolveBootConfig());
195
- } catch (e) {
196
- // сети нет и режим неизвестен: репортёр ещё не создан (ему нужен режим),
197
- // игроку остаётся надпись отказа
198
- showBootFailure(`Failed to load the game: ${e.message}`);
199
- throw e;
200
- }
201
-
176
+ // solo — хост в этой же вкладке (standalone SDK). Ветвлений ровно пять:
177
+ // манифест, сигналинг/лобби, транспорт, авто-аутентификация и точка
178
+ // монтирования канвасов.
179
+ const boot = resolveBootConfig();
202
180
  const bootMode = boot.mode;
203
181
  const isLobbyMode = bootMode === 'lobby';
204
182
 
183
+ // каталог манифестов нужен только лобби-контуру: запрос уходит до создания
184
+ // журнала и каркаса, а ждут его только в загрузке игры ниже
185
+ const manifestPromise = isLobbyMode
186
+ ? fetchGamesManifest(lobbyConfig.gamesManifestUrl, (...args) =>
187
+ fetchRetrying(fetch, ...args),
188
+ ).catch(err => err)
189
+ : null;
190
+
205
191
  // журнал клиентских ошибок: ставится как можно
206
- // раньше, чтобы ловить и сбои самого старта. Лобби и dedicated шлют на
207
- // свой бокс; SDK (solo) — только если встраивающий передал reportUrl
192
+ // раньше, чтобы ловить и сбои самого старта. Лобби шлёт на свой бокс;
193
+ // SDK (solo) — только если встраивающий передал reportUrl
208
194
  const diagnostics = createDiagnostics({
209
195
  url:
210
196
  bootMode === 'solo'
@@ -253,7 +239,7 @@ try {
253
239
 
254
240
  try {
255
241
  // недоступная игра активной быть не может (lib/pickActiveGame.js)
256
- activeGameManifest = pickActiveGame(gamesManifest, boot.gameId);
242
+ activeGameManifest = pickActiveGame(gamesManifest);
257
243
  syncDiagnosticsGame();
258
244
  } catch (e) {
259
245
  // «каталог непустой, но играбельного в нём нет» (движок обновили, все
@@ -261,7 +247,7 @@ try {
261
247
  // лобби это то же самое, что пустой каталог: играть не во что, но
262
248
  // модерация и заявка на месте, и только они это чинят. Терять их из-за
263
249
  // такого нельзя, поэтому причина едет строкой отказа лобби, а не
264
- // терминальным оверлеем. В solo/dedicated отказ остаётся отказом
250
+ // терминальным оверлеем. В solo отказ остаётся отказом
265
251
  if (!isLobbyMode) {
266
252
  throw e;
267
253
  }
@@ -273,7 +259,7 @@ try {
273
259
  // пустой каталог — состояние лобби, а не отказ загрузки: модератор вправе
274
260
  // отключить последнюю игру, реестр вправе ещё ничего не одобрить, и вернуть
275
261
  // каталог к жизни можно только из панели, которая живёт в этой же вкладке.
276
- // В solo/dedicated игры нет — это отказ: там весь смысл вкладки в матче
262
+ // В solo игры нет — это отказ: там весь смысл вкладки в матче
277
263
  if (!activeGameManifest && !isLobbyMode) {
278
264
  throw new Error('master has no games in its catalog');
279
265
  }
@@ -313,6 +299,7 @@ const PS_CHAT_DATA = wsports.server.CHAT_DATA;
313
299
  const PS_VOTE_DATA = wsports.server.VOTE_DATA;
314
300
  const PS_KEYSET_DATA = wsports.server.KEYSET_DATA;
315
301
  const PS_ACCOLADES_DATA = wsports.server.ACCOLADES_DATA;
302
+ const PS_VOICE_DATA = wsports.server.VOICE_DATA;
316
303
  const PS_SESSION_DATA = wsports.server.SESSION_DATA;
317
304
  const PS_RESUME_RESULT = wsports.server.RESUME_RESULT;
318
305
 
@@ -325,13 +312,14 @@ const PC_MAP_READY = wsports.client.MAP_READY;
325
312
  const PC_FIRST_SHOT_READY = wsports.client.FIRST_SHOT_READY;
326
313
  const PC_KEYS_DATA = wsports.client.KEYS_DATA;
327
314
  const PC_CHAT_DATA = wsports.client.CHAT_DATA;
315
+ const PC_VOICE_FLOOR = wsports.client.VOICE_FLOOR;
328
316
  const PC_VOTE_DATA = wsports.client.VOTE_DATA;
329
317
  const PC_PONG = wsports.client.PONG;
330
318
  const PC_LEAVE = wsports.client.LEAVE;
331
319
 
332
320
  // сигнальный WebSocket мастера (лобби + установка P2P); игровой трафик идёт
333
321
  // по WebRTC (transport), не через мастер
334
- // (только lobby: solo и dedicated мастера не имеют вовсе)
322
+ // (только lobby: у solo мастера нет вовсе)
335
323
  const wsProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
336
324
  const signaling = isLobbyMode
337
325
  ? new SignalingClient(`${wsProtocol}//${location.host}/`)
@@ -339,7 +327,7 @@ const signaling = isLobbyMode
339
327
 
340
328
  // супервизор сессии (host-migration этап 4): держит текущий транспорт к
341
329
  // хосту и переживает его обрыв — переподключается и возвращает место в
342
- // матче. Создаётся при входе в комнату (или в solo/dedicated-матч)
330
+ // матче. Создаётся при входе в комнату (или в solo-матч)
343
331
  let supervisor = null;
344
332
 
345
333
  // оверлей «Reconnecting…» на время переподключения (только лобби-режим)
@@ -399,7 +387,7 @@ const GAME_ROUND_START_CODE = GAME_CODES.roundStart[0];
399
387
  // соединения важнее общего сообщения handleDisconnect
400
388
  let terminalInformShown = false;
401
389
  // ключ последнего терминального tech-кода: по нему лобби-режим отличает кик
402
- // (policyClose.isKickClose) от закрытия комнаты — в P2P кода закрытия нет
390
+ // (policyClose.isKickClose) от закрытия комнаты — у транспортов кода закрытия нет
403
391
  let terminalTechKey = null;
404
392
  // снятие предыдущего animationend-листенера логотипа при повторном
405
393
  // playLogoRoundStart — иначе листенеры копятся при частых стартах раунда
@@ -433,6 +421,15 @@ const localPlayer = createLocalPlayer(() => clientCore);
433
421
  // сервис пула зависимостей: «какое место у этой сущности в глобальном
434
422
  // топе?». Места считает хост, part рисует за них знак (см. lib/accolades.js)
435
423
  const accolades = createAccolades();
424
+ // голосовая связь: кто в эфире (сервис партов `voice`) — хранилище пишет
425
+ // движок по VOICE_DATA, парты только читают (lib/voiceState.js)
426
+ const voice = createVoiceState();
427
+ // подсистема голоса грузится лениво — только у игры с включённой связью
428
+ let voiceClient = null;
429
+ let voiceConfig = null;
430
+ // номер запуска подсистемы: чанк, догрузившийся после закрытия матча,
431
+ // уничтожает свой клиент сам
432
+ let voiceSession = 0;
436
433
  let inputSeq = 0; // номер отправленного ввода (KEYS_DATA)
437
434
 
438
435
  // обратный индекс снапшот-схемы игры (CONFIG_DATA.snapshot):
@@ -451,6 +448,9 @@ socketMethods[PS_CONFIG_DATA] = async data => {
451
448
  return;
452
449
  }
453
450
 
451
+ voiceConfig = data.voice ?? null;
452
+ voice.store.setEnabled(voiceConfig?.enabled === true);
453
+
454
454
  gameSets = data.parts.gameSets;
455
455
  entitiesOnCanvas = data.parts.entitiesOnCanvas;
456
456
 
@@ -541,6 +541,9 @@ socketMethods[PS_CONFIG_DATA] = async data => {
541
541
  // места в глобальном топе: знак за место рисует part игры, движок
542
542
  // раздаёт только числа (см. lib/accolades.js)
543
543
  accolades,
544
+ // индикаторы эфира связи для партов игры: кто сейчас говорит
545
+ // (только чтение, см. lib/voiceState.js)
546
+ voice: voice.service,
544
547
  // база ассетов игры — тем же каналом, что и путь к звукам (см. выше):
545
548
  // картинки карт живут в пакете игры (dist/img/), движок их не раздаёт.
546
549
  // part, объявивший сервис в componentDependencies, строит URL сам —
@@ -596,9 +599,9 @@ socketMethods[PS_AUTH_DATA] = data => {
596
599
 
597
600
  document.getElementById('logo').textContent = texts?.title || 'VIMP';
598
601
 
599
- // футер формы входа: версия пакета ИГРЫ и (в dev-сборке) ссылка на
600
- // документацию. Здесь, а не в AuthView: его elems приходят из
601
- // authSchema.elems игрового плагина, и футер каркаса — не его забота.
602
+ // футер формы входа: версия пакета ИГРЫ. Здесь, а не в AuthView: его
603
+ // elems приходят из authSchema.elems игрового плагина, и футер каркаса —
604
+ // не его забота.
602
605
  // Версию кладёт в манифест мастер (GameCatalog читает package.json
603
606
  // пакета); в standalone-манифесте её может не быть — тогда ячейка пустая,
604
607
  // раскладку space-between это не ломает
@@ -608,8 +611,6 @@ socketMethods[PS_AUTH_DATA] = data => {
608
611
  authVersion.textContent = activeGameManifest?.packageVersion ?? '';
609
612
  }
610
613
 
611
- renderDocsLink(document.getElementById('auth-docs-link'), isDevBuild);
612
-
613
614
  // память клиента (localStorage) + принудительное значение поля с
614
615
  // единственным вариантом. Здесь, а не в AuthView: solo-путь ниже отвечает
615
616
  // хосту вообще без формы и обязан прийти к тем же значениям
@@ -909,6 +910,19 @@ socketMethods[PS_ACCOLADES_DATA] = data => {
909
910
  modules.stat?.applyAccolades?.();
910
911
  };
911
912
 
913
+ // голосовая связь: кортеж разбирается один раз (lib/voiceMessages.js) —
914
+ // хост в P2P чужой, непрошедшее проверку не доходит ни до кого
915
+ socketMethods[PS_VOICE_DATA] = data => {
916
+ const message = parseVoiceMessage(data);
917
+
918
+ if (!message) {
919
+ return;
920
+ }
921
+
922
+ voice.store.apply(message);
923
+ voiceClient?.handleFloorMessage(message);
924
+ };
925
+
912
926
  // chat data
913
927
  socketMethods[PS_CHAT_DATA] = data => {
914
928
  modules.chat.add(data);
@@ -952,7 +966,7 @@ socketMethods[PS_GAME_INFORM_DATA] = data => {
952
966
 
953
967
  // проигрывает shimmer-волну по логотипу в начале раунда; логотип живёт вне
954
968
  // PanelView.containerId, поэтому панель им не управляет
955
- // (см. docs.vimp.lgick.dev/en/client-api)
969
+ // (см. VIMP docs: client-api.md)
956
970
  const playLogoRoundStart = () => {
957
971
  const logo = document.getElementById('logo');
958
972
 
@@ -1214,6 +1228,20 @@ function runModules(data) {
1214
1228
 
1215
1229
  modules.vote = new VoteCtrl(voteModel, voteView);
1216
1230
 
1231
+ //==========================================//
1232
+ // Voice Module (голосовая связь)
1233
+ //==========================================//
1234
+
1235
+ if (voiceConfig?.enabled) {
1236
+ const voiceModel = new VoiceModel({ texts: voiceConfig.texts });
1237
+ const voiceView = new VoiceView(voiceModel, gameContainer);
1238
+
1239
+ modules.voice = new VoiceCtrl(voiceModel, voiceView);
1240
+ modules.controls.setPushToTalk(voiceConfig.hotkey);
1241
+ controlsModel.publisher.on('ptt', held => voiceClient?.setPttHeld(held));
1242
+ startVoiceClient();
1243
+ }
1244
+
1217
1245
  //==========================================//
1218
1246
  // Подписка на события
1219
1247
  //==========================================//
@@ -1234,6 +1262,8 @@ function runModules(data) {
1234
1262
  'keyUp',
1235
1263
  modules.controls.remove.bind(modules.controls),
1236
1264
  );
1265
+ // потеря фокуса окна отпускает связь: keyup снаружи не придёт
1266
+ inputListener.publisher.on('blur', () => modules.controls?.releasePtt());
1237
1267
  inputListener.publisher.on(
1238
1268
  'mouseAction',
1239
1269
  modules.controls.resetCursorHideTimer.bind(modules.controls),
@@ -1337,6 +1367,58 @@ function sending(name, data, reliable = true) {
1337
1367
  supervisor?.send(JSON.stringify([name, data]), reliable);
1338
1368
  }
1339
1369
 
1370
+ // подсистема голоса — отдельный чанк: игре без связи и standalone он не нужен
1371
+ function startVoiceClient() {
1372
+ const session = ++voiceSession;
1373
+
1374
+ import('./voice/VoiceClient.js')
1375
+ .then(async ({ default: VoiceClient }) => {
1376
+ const client = new VoiceClient({
1377
+ config: voiceConfig,
1378
+ sendFloor: payload => sending(PC_VOICE_FLOOR, payload),
1379
+ sendAudio: bytes => supervisor?.sendVoice(bytes),
1380
+ soundManager,
1381
+ localPlayer,
1382
+ hud: modules.voice,
1383
+ voiceStore: voice.store,
1384
+ // стиль связи — хуки игры: звуки сигналов и окраска речи; старый
1385
+ // плагин их не имеет — нейтральные тоны, чистый голос
1386
+ voiceSounds: clientPlugin.hooks.voiceSounds,
1387
+ voiceFilter: clientPlugin.hooks.voiceFilter,
1388
+ diagnostics,
1389
+ });
1390
+
1391
+ try {
1392
+ await client.init();
1393
+ } catch (error) {
1394
+ // недособранный клиент освобождает то, что успел создать; сбой
1395
+ // уборки не подменяет первопричину
1396
+ try {
1397
+ client.destroy();
1398
+ } catch {
1399
+ // клиент собран не до конца
1400
+ }
1401
+
1402
+ throw error;
1403
+ }
1404
+
1405
+ // матч закрылся, пока грузился чанк: клиент не нужен
1406
+ if (session !== voiceSession) {
1407
+ client.destroy();
1408
+ return;
1409
+ }
1410
+
1411
+ voiceClient = client;
1412
+ })
1413
+ .catch(error => {
1414
+ diagnostics.capture(error, { source: 'voice' });
1415
+
1416
+ if (session === voiceSession) {
1417
+ modules.voice?.showOwn('error', { textKey: 'unavailable' });
1418
+ }
1419
+ });
1420
+ }
1421
+
1340
1422
  // отправка сообщения чата хосту (точка перехвата команд, адресованных
1341
1423
  // мастеру, а не хосту)
1342
1424
  function handleChatSend(message) {
@@ -1397,6 +1479,8 @@ function handleVisibilityChange() {
1397
1479
  // если вкладка неактивна, выключение звука
1398
1480
  if (document.visibilityState === 'hidden') {
1399
1481
  hiddenAt = performance.now();
1482
+ // скрытая вкладка не получит keyup
1483
+ modules.controls?.releasePtt();
1400
1484
  soundManager.mute();
1401
1485
  // иначе включение звука при возвращении (кроме паузы переподключения и
1402
1486
  // смены хоста — его включит возобновление сессии)
@@ -1567,10 +1651,8 @@ function handleMessage(data) {
1567
1651
 
1568
1652
  // терминальное закрытие сессии (супервизор: возвращаться некуда — кик,
1569
1653
  // политика, уход хоста, окно переподключения истекло). Останавливаем
1570
- // рендер, показываем заглушку и возвращаемся в лобби перезагрузкой.
1571
- // closeCode приходит только от WebSocket-транспорта (dedicated), остальные
1572
- // транспорты эмитят close без него
1573
- function handleDisconnect(closeCode) {
1654
+ // рендер, показываем заглушку и возвращаемся в лобби перезагрузкой
1655
+ function handleDisconnect() {
1574
1656
  showSessionOverlay(null);
1575
1657
 
1576
1658
  // app.stop() здесь не зовём: при sharedTicker это Ticker.shared.stop()
@@ -1594,6 +1676,13 @@ function handleDisconnect(closeCode) {
1594
1676
  lastMapData = null;
1595
1677
 
1596
1678
  document.removeEventListener('visibilitychange', handleVisibilityChange);
1679
+ // строго до soundManager.destroy(): Howler.unload() закрывает контекст, на
1680
+ // котором сидят захват и воспроизведение связи
1681
+ voiceSession += 1;
1682
+ voiceClient?.destroy();
1683
+ voiceClient = null;
1684
+ voice.store.clear();
1685
+ modules.voice?.destroy();
1597
1686
  soundManager.destroy();
1598
1687
  modules.controls?.disableKeys();
1599
1688
 
@@ -1617,31 +1706,18 @@ function handleDisconnect(closeCode) {
1617
1706
  inlineHost?.destroy();
1618
1707
  inlineHost = null;
1619
1708
 
1620
- // запасной текст политического отказа: если сервер прислал причину сам
1621
- // (TECH_INFORM перед закрытием — так приезжает 4006, полная комната), ниже
1622
- // не пишется вообще ничего, и побеждает серверный текст
1623
- const policyInform = POLICY_CLOSE_INFORMS[closeCode];
1624
-
1625
- // терминальную причину закрытия (кик, полная комната) не затираем
1709
+ // терминальную причину закрытия (кик, полная комната — TECH_INFORM перед
1710
+ // закрытием) не затираем
1626
1711
  if (!terminalInformShown) {
1627
1712
  socketMethods[PS_TECH_INFORM_DATA](
1628
- policyInform ??
1629
- (isLobbyMode
1630
- ? 'Host left — the room is closed. Finding another room…'
1631
- : 'The match is over — connection to the host is closed.'),
1713
+ isLobbyMode
1714
+ ? 'Host left — the room is closed. Finding another room…'
1715
+ : 'The match is over — connection to the host is closed.',
1632
1716
  );
1633
1717
  }
1634
1718
 
1635
- // в solo перезагружаться некуда: лобби нет, а матч поднимается с нуля.
1636
- // В dedicated сервер жив — переподключение перезагрузкой уместно, но не
1637
- // тогда, когда он сам нас и отбил по политике (network/policyClose.js)
1638
- if (bootMode === 'solo' || !shouldReloadAfterClose(closeCode)) {
1639
- return;
1640
- }
1641
-
1719
+ // в solo перезагружаться некуда: лобби нет, а матч поднимается с нуля
1642
1720
  if (!isLobbyMode) {
1643
- setTimeout(() => reloadPage(), 3000);
1644
-
1645
1721
  return;
1646
1722
  }
1647
1723
 
@@ -1651,7 +1727,7 @@ function handleDisconnect(closeCode) {
1651
1727
  // — на главную без перезагрузки (причина остаётся в #tech-informer, клик
1652
1728
  // по нему открывает лобби); закрытие комнаты — в быструю игру той же игры
1653
1729
  const exit = decideExitRoute({
1654
- kicked: isKickClose(closeCode, terminalTechKey) || roomCtx.startFailed,
1730
+ kicked: isKickClose(terminalTechKey) || roomCtx.startFailed,
1655
1731
  gameId: activeGameManifest?.id,
1656
1732
  });
1657
1733
 
@@ -1687,13 +1763,13 @@ function startSession(transport, reconnect = null) {
1687
1763
  onTerminal: handleDisconnect,
1688
1764
  onStateChange: handleSessionState,
1689
1765
  onResumed: handleSessionResumed,
1766
+ onVoice: bytes => voiceClient?.handleAudio(bytes),
1690
1767
  onResumeRejected: handleResumeRejected,
1691
1768
  onHostLost: reportHostUnreachable,
1692
1769
  onColdRestart: handleColdRestart,
1693
- // кик (TECH_INFORM перед закрытием) и отказы политики не лечатся
1770
+ // кик и полная комната (TECH_INFORM перед закрытием) не лечатся
1694
1771
  // переподключением
1695
- isTerminalClose: closeCode =>
1696
- terminalInformShown || !shouldReloadAfterClose(closeCode),
1772
+ isTerminalClose: () => terminalInformShown,
1697
1773
  reconnect,
1698
1774
  timing: lobbyConfig.session,
1699
1775
  });
@@ -1730,6 +1806,8 @@ function isSessionPaused() {
1730
1806
  function handleSessionState(state, prev) {
1731
1807
  if (state === SESSION_STATES.migrating) {
1732
1808
  showSessionOverlay('Switching host…');
1809
+ voiceClient?.suspend();
1810
+ voice.store.clear();
1733
1811
  modules.controls?.disableKeys();
1734
1812
  soundManager.mute();
1735
1813
  } else if (state === SESSION_STATES.reconnecting) {
@@ -1738,6 +1816,8 @@ function handleSessionState(state, prev) {
1738
1816
  showSessionOverlay('Reconnecting…');
1739
1817
  }
1740
1818
 
1819
+ voiceClient?.suspend();
1820
+ voice.store.clear();
1741
1821
  modules.controls?.disableKeys();
1742
1822
  soundManager.mute();
1743
1823
  } else if (state === SESSION_STATES.inGame) {
@@ -2104,14 +2184,6 @@ async function connectSolo() {
2104
2184
  transport.connect();
2105
2185
  }
2106
2186
 
2107
- // dedicated-режим: прямой WebSocket к Node-серверу игры
2108
- function connectDedicated() {
2109
- const transport = new WebSocketTransport(boot.wsUrl);
2110
-
2111
- startSession(transport);
2112
- transport.connect();
2113
- }
2114
-
2115
2187
  // REST-запрос списка серверов у мастера (поиск игнорирует пагинацию)
2116
2188
  async function fetchServers({ offset, limit, search }) {
2117
2189
  const params = new URLSearchParams();
@@ -2200,7 +2272,7 @@ let roomFormFields = new Map();
2200
2272
  let roomFormDescriptors = [];
2201
2273
 
2202
2274
  // генерирует форму создания комнаты по явной схеме манифеста (roomForm,
2203
- // docs.vimp.lgick.dev/en/plugin-api "Form schema") — движок не выводит контролы
2275
+ // VIMP docs: plugin-api.md § "Form schema") — движок не выводит контролы
2204
2276
  // из типа значения, схема плагина полностью описывает форму
2205
2277
  function populateRoomForm(manifest) {
2206
2278
  const container = document.getElementById(lobbyConfig.elems.fieldsId);
@@ -2588,7 +2660,7 @@ function initLobby() {
2588
2660
  // валидация (pattern/required/min/max) — единственная граница
2589
2661
  // room-формы: она едет клиенту как JSON манифеста, JS-валидаторы
2590
2662
  // (как в auth-форме) туда не сериализуются
2591
- // (docs.vimp.lgick.dev/en/plugin-api "Form schema"); авторитетный клампинг
2663
+ // (VIMP docs: plugin-api.md § "Form schema"); авторитетный клампинг
2592
2664
  // всё равно в applyRoomOverrides.js (вызывается из host.worker.js при
2593
2665
  // создании комнаты). Проверяем до активации игры: неверная форма не должна
2594
2666
  // стоить загрузки плагина. arm() и рисует ошибки, и снимает фильтр «только
@@ -2696,7 +2768,7 @@ function leaveRoomWith(message) {
2696
2768
  }
2697
2769
 
2698
2770
  // точка 2 ветвления: лобби, экран профиля и сигналинг живут только в lobby;
2699
- // solo и dedicated идут сразу к транспорту
2771
+ // solo идёт сразу к транспорту
2700
2772
  if (isLobbyMode) {
2701
2773
  authApi = createAuthApi({ serviceUrl: authClientConfig.serviceUrl });
2702
2774
 
@@ -2839,7 +2911,7 @@ if (isLobbyMode) {
2839
2911
  window.addEventListener('hashchange', () => route.handleHashChange());
2840
2912
 
2841
2913
  signaling.connect();
2842
- } else if (bootMode === 'solo') {
2914
+ } else {
2843
2915
  try {
2844
2916
  await connectSolo();
2845
2917
  } catch (e) {
@@ -2848,6 +2920,4 @@ if (isLobbyMode) {
2848
2920
  );
2849
2921
  throw e;
2850
2922
  }
2851
- } else {
2852
- connectDedicated();
2853
2923
  }
@@ -1,10 +1,12 @@
1
1
  // WebRTC-answerer браузерного хоста (Фаза 2 Этапа 4). Зеркало WebRtcManager:
2
- // клиент — offerer (создаёт каналы meta/state и оффер), хост здесь — answerer.
2
+ // клиент — offerer (создаёт каналы meta/state/voice и оффер), хост здесь — answerer.
3
3
  // Живёт в главном потоке (RTCPeerConnection в Worker'е недоступны); входящие
4
4
  // кадры каналов уходят в Worker через HostController, исходящие Worker-кадры
5
5
  // раскладываются по каналам meta (reliable) / state (unreliable) по флагу
6
6
  // reliable из ядра (body_has_events). Медленным пирам state-кадры дропаются
7
- // по bufferedAmount (бэкпрешер) — meta не дропается никогда.
7
+ // по bufferedAmount (бэкпрешер) — meta не дропается никогда. Канал voice в
8
+ // готовность не входит и в Worker не ходит: пакеты голоса идут ретранслятору
9
+ // HostController.
8
10
  export default class HostConnectionManager {
9
11
  /**
10
12
  * @param {SignalingClient} signaling - сигнальный WS мастера.
@@ -26,7 +28,7 @@ export default class HostConnectionManager {
26
28
  this._threshold = opts.backpressureThreshold ?? 262144; // 256 КБ
27
29
  this._onPeersChange = opts.onPeersChange;
28
30
 
29
- // clientId → { pc, meta, state, openCount, memberId }
31
+ // clientId → { pc, meta, state, voice, openCount, memberId }
30
32
  this._peers = new Map();
31
33
 
32
34
  signaling.publisher.on('webrtc_offer', 'onOffer', this);
@@ -59,6 +61,7 @@ export default class HostConnectionManager {
59
61
  pc,
60
62
  meta: null,
61
63
  state: null,
64
+ voice: null,
62
65
  openCount: 0,
63
66
  memberId,
64
67
  resume,
@@ -122,11 +125,21 @@ export default class HostConnectionManager {
122
125
  }
123
126
 
124
127
  _wireChannel(clientId, peer, channel) {
128
+ if (channel.label === 'voice') {
129
+ this._wireVoiceChannel(clientId, peer, channel);
130
+ return;
131
+ }
132
+
133
+ // чужая метка — не наш канал: ни обработчиков, ни счёта готовности
134
+ if (channel.label !== 'meta' && channel.label !== 'state') {
135
+ return;
136
+ }
137
+
125
138
  channel.binaryType = 'arraybuffer';
126
139
 
127
140
  if (channel.label === 'meta') {
128
141
  peer.meta = channel;
129
- } else if (channel.label === 'state') {
142
+ } else {
130
143
  peer.state = channel;
131
144
  }
132
145
 
@@ -159,6 +172,34 @@ export default class HostConnectionManager {
159
172
  };
160
173
  }
161
174
 
175
+ _wireVoiceChannel(clientId, peer, channel) {
176
+ channel.binaryType = 'arraybuffer';
177
+ peer.voice = channel;
178
+
179
+ // голос — только ретранслятору главного потока; в Worker он не уходит
180
+ // никогда
181
+ channel.onmessage = event =>
182
+ this._controller.voiceIngress(clientId, event.data);
183
+
184
+ channel.onopen = () => {
185
+ if (this._peers.get(clientId) !== peer) {
186
+ return;
187
+ }
188
+
189
+ this._controller.attachVoice(clientId, {
190
+ send: bytes => {
191
+ if (channel.readyState === 'open') {
192
+ channel.send(bytes);
193
+ }
194
+ },
195
+ bufferedAmount: () => channel.bufferedAmount,
196
+ });
197
+ };
198
+
199
+ // потеря одного канала голоса — не повод рвать матч
200
+ channel.onclose = () => this._controller.detachVoice(clientId);
201
+ }
202
+
162
203
  // раскладывает исходящий Worker-кадр по каналам meta/state
163
204
  _deliver(peer, payload, reliable) {
164
205
  // JSON-протокол и событийные кадры — надёжно по meta; позиционные кадры
@@ -189,7 +230,7 @@ export default class HostConnectionManager {
189
230
  this._controller.disconnect(clientId); // Worker: removeUser
190
231
 
191
232
  // снять обработчики каналов — замыкания не должны удерживать peer
192
- for (const channel of [peer.meta, peer.state]) {
233
+ for (const channel of [peer.meta, peer.state, peer.voice]) {
193
234
  if (channel) {
194
235
  channel.onopen = null;
195
236
  channel.onmessage = null;