vimp-engine 0.38.2 → 0.39.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 (99) hide show
  1. package/core/Cargo.toml +1 -1
  2. package/package.json +3 -1
  3. package/src/client/InputListener.js +3 -0
  4. package/src/client/SoundManager.js +13 -0
  5. package/src/client/boot.js +30 -7
  6. package/src/client/components/controller/ClientReports.js +1 -1
  7. package/src/client/components/controller/Controls.js +10 -0
  8. package/src/client/components/controller/Games.js +1 -1
  9. package/src/client/components/controller/Voice.js +31 -0
  10. package/src/client/components/model/ClientReports.js +1 -1
  11. package/src/client/components/model/Controls.js +64 -0
  12. package/src/client/components/model/Games.js +24 -0
  13. package/src/client/components/model/Voice.js +96 -0
  14. package/src/client/components/model/Vote.js +2 -1
  15. package/src/client/components/view/ClientReports.js +1 -1
  16. package/src/client/components/view/Games.js +19 -1
  17. package/src/client/components/view/Lobby.js +1 -7
  18. package/src/client/components/view/Voice.js +172 -0
  19. package/src/client/debug.js +1 -1
  20. package/src/client/lib/authApi.js +1 -1
  21. package/src/client/lib/diagnostics.js +19 -4
  22. package/src/client/lib/engineVersion.js +0 -6
  23. package/src/client/lib/footerLink.js +15 -14
  24. package/src/client/lib/retryOnNetworkError.js +60 -0
  25. package/src/client/lib/voiceState.js +78 -0
  26. package/src/client/main.js +152 -18
  27. package/src/client/network/HiddenHostHealthLog.js +40 -3
  28. package/src/client/network/HostConnectionManager.js +46 -5
  29. package/src/client/network/HostController.js +47 -4
  30. package/src/client/network/HostHealthPolicy.js +41 -0
  31. package/src/client/network/InlineHostBridge.js +8 -1
  32. package/src/client/network/LoopbackTransport.js +14 -0
  33. package/src/client/network/SessionSupervisor.js +24 -0
  34. package/src/client/network/WebRtcManager.js +45 -3
  35. package/src/client/session/HandoffFlow.js +5 -0
  36. package/src/client/session/HostRole.js +5 -2
  37. package/src/client/style.css +151 -9
  38. package/src/client/views/gameShell.js +3 -3
  39. package/src/client/views/includes/auth.pug +4 -4
  40. package/src/client/views/includes/lobby.pug +4 -4
  41. package/src/client/views/includes/reports.pug +1 -1
  42. package/src/client/voice/JitterBuffer.js +137 -0
  43. package/src/client/voice/MicCapture.js +307 -0
  44. package/src/client/voice/PttMachine.js +369 -0
  45. package/src/client/voice/VoiceClient.js +336 -0
  46. package/src/client/voice/VoiceCues.js +295 -0
  47. package/src/client/voice/VoiceEncoder.js +174 -0
  48. package/src/client/voice/VoicePlayback.js +558 -0
  49. package/src/client/voice/audioData.js +35 -0
  50. package/src/client/voice/captureProcessor.worklet.js +136 -0
  51. package/src/client/voice/opusConfig.js +49 -0
  52. package/src/client/voice/voiceOutput.js +72 -0
  53. package/src/client/voice/voiceSupport.js +71 -0
  54. package/src/client/voice/voiceTones.js +90 -0
  55. package/src/config/authClient.js +3 -3
  56. package/src/config/clientServices.js +2 -0
  57. package/src/config/lobby.js +4 -5
  58. package/src/config/opcodes.js +2 -1
  59. package/src/config/voiceDefaults.js +44 -0
  60. package/src/config/voiceProtocol.js +80 -0
  61. package/src/config/voteKeys.js +4 -0
  62. package/src/config/wsports.js +12 -0
  63. package/src/devtools/contract/loadContext.js +2 -2
  64. package/src/devtools/contract/rules/a7-package-repository.js +7 -8
  65. package/src/devtools/contract/rules/b11-voice-config.js +34 -0
  66. package/src/devtools/contract/rules/c12-voice-hotkey.js +70 -0
  67. package/src/devtools/contract/rules/index.js +5 -1
  68. package/src/devtools/invariants.js +1 -1
  69. package/src/host/DebugRecorder.js +1 -1
  70. package/src/host/HostGame.js +93 -15
  71. package/src/host/PortMachine.js +19 -3
  72. package/src/host/commandFailure.js +140 -0
  73. package/src/host/host.worker.js +15 -2
  74. package/src/host/meta/SocketManager.js +8 -2
  75. package/src/host/meta/core/CommandProcessor.js +37 -7
  76. package/src/host/meta/modules/voice/Voice.js +430 -0
  77. package/src/host/meta/modules/voice/channels.js +58 -0
  78. package/src/host/meta/modules/voice/index.js +1 -0
  79. package/src/lib/VoiceRelay.js +200 -0
  80. package/src/lib/applyRoomOverrides.js +1 -1
  81. package/src/lib/buildClientConfig.js +7 -0
  82. package/src/lib/capabilities.js +5 -1
  83. package/src/lib/checkpointCodec.js +1 -2
  84. package/src/lib/coreConfig.js +1 -1
  85. package/src/lib/createHostRuntime.js +9 -1
  86. package/src/lib/gameConfigView.js +11 -1
  87. package/src/lib/gameId.js +1 -1
  88. package/src/lib/gamePlugin.js +6 -2
  89. package/src/lib/jwt.js +1 -1
  90. package/src/lib/packageLink.js +18 -61
  91. package/src/lib/validators.js +3 -3
  92. package/src/lib/voiceConfig.js +136 -0
  93. package/src/lib/voiceMessages.js +172 -0
  94. package/src/lib/voicePacket.js +142 -0
  95. package/src/standalone/index.js +1 -1
  96. package/tests/fixtures/generations/gen-api3/README.md +2 -2
  97. package/tests/fixtures/generations/gen-api4/README.md +2 -2
  98. package/src/devtools/surface/abiParse.js +0 -175
  99. package/src/devtools/surface/collect.js +0 -413
@@ -26,13 +26,16 @@ 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 { renderProjectLink } from './lib/footerLink.js';
38
+ import { renderDocsLink } from './lib/footerLink.js';
36
39
  import { createGameActivator } from './lib/gameActivator.js';
37
40
  import createAutostart from './lib/autostart.js';
38
41
  import { applyCatalogState } from './lib/catalogState.js';
@@ -45,7 +48,10 @@ import {
45
48
  import { createContextTracker } from './lib/contextTracker.js';
46
49
  import { createLocalPlayer } from './lib/localPlayer.js';
47
50
  import { createAccolades } from './lib/accolades.js';
51
+ import { createVoiceState } from './lib/voiceState.js';
52
+ import { parseVoiceMessage } from '../lib/voiceMessages.js';
48
53
  import { createDiagnostics } from './lib/diagnostics.js';
54
+ import { fetchRetrying, isNetworkFailure } from './lib/retryOnNetworkError.js';
49
55
  import { dispatchSocketMessage } from './lib/socketDispatch.js';
50
56
  import { pickActiveGame, isGameAvailable } from './lib/pickActiveGame.js';
51
57
  import { readCoreAbi, dispatchCoreOp, ABI_UNKNOWN } from '../lib/coreAbi.js';
@@ -183,13 +189,25 @@ function bindActiveGame(manifest, plugin) {
183
189
  const injectedBoot = getBootConfig();
184
190
  const manifestPromise = injectedBoot
185
191
  ? null
186
- : fetchGamesManifest(lobbyConfig.gamesManifestUrl).catch(err => err);
192
+ : fetchGamesManifest(lobbyConfig.gamesManifestUrl, (...args) =>
193
+ fetchRetrying(fetch, ...args),
194
+ ).catch(err => err);
195
+
196
+ let boot;
197
+
198
+ try {
199
+ boot = injectedBoot ?? (await resolveBootConfig());
200
+ } catch (e) {
201
+ // сети нет и режим неизвестен: репортёр ещё не создан (ему нужен режим),
202
+ // игроку остаётся надпись отказа
203
+ showBootFailure(`Failed to load the game: ${e.message}`);
204
+ throw e;
205
+ }
187
206
 
188
- const boot = injectedBoot ?? (await resolveBootConfig());
189
207
  const bootMode = boot.mode;
190
208
  const isLobbyMode = bootMode === 'lobby';
191
209
 
192
- // журнал клиентских ошибок (plan/client-reports): ставится как можно
210
+ // журнал клиентских ошибок: ставится как можно
193
211
  // раньше, чтобы ловить и сбои самого старта. Лобби и dedicated шлют на
194
212
  // свой бокс; SDK (solo) — только если встраивающий передал reportUrl
195
213
  const diagnostics = createDiagnostics({
@@ -273,6 +291,12 @@ try {
273
291
  }
274
292
  } catch (e) {
275
293
  showBootFailure(`Failed to load the game: ${e.message}`, gameContainer);
294
+
295
+ // сбой сети — не ошибка кода: в журнал он не уходит
296
+ if (isNetworkFailure(e)) {
297
+ diagnostics.ignore(e);
298
+ }
299
+
276
300
  throw e;
277
301
  }
278
302
 
@@ -294,6 +318,7 @@ const PS_CHAT_DATA = wsports.server.CHAT_DATA;
294
318
  const PS_VOTE_DATA = wsports.server.VOTE_DATA;
295
319
  const PS_KEYSET_DATA = wsports.server.KEYSET_DATA;
296
320
  const PS_ACCOLADES_DATA = wsports.server.ACCOLADES_DATA;
321
+ const PS_VOICE_DATA = wsports.server.VOICE_DATA;
297
322
  const PS_SESSION_DATA = wsports.server.SESSION_DATA;
298
323
  const PS_RESUME_RESULT = wsports.server.RESUME_RESULT;
299
324
 
@@ -306,6 +331,7 @@ const PC_MAP_READY = wsports.client.MAP_READY;
306
331
  const PC_FIRST_SHOT_READY = wsports.client.FIRST_SHOT_READY;
307
332
  const PC_KEYS_DATA = wsports.client.KEYS_DATA;
308
333
  const PC_CHAT_DATA = wsports.client.CHAT_DATA;
334
+ const PC_VOICE_FLOOR = wsports.client.VOICE_FLOOR;
309
335
  const PC_VOTE_DATA = wsports.client.VOTE_DATA;
310
336
  const PC_PONG = wsports.client.PONG;
311
337
  const PC_LEAVE = wsports.client.LEAVE;
@@ -348,8 +374,8 @@ const renderContexts = {};
348
374
  // контекста (хост её повторно не пришлёт)
349
375
  let lastMapData = null;
350
376
 
351
- // dev-сборка (Vite подставляет константу): включает отладочный контур этапа 6
352
- // плана plan/done/ai-debug — рекордер в комнате и window.__vimpDebug. В прод-бандле
377
+ // dev-сборка (Vite подставляет константу): включает отладочный контур —
378
+ // рекордер в комнате и window.__vimpDebug. В прод-бандле
353
379
  // ветка вырезается сборкой, поведение не меняется
354
380
  const isDevBuild =
355
381
  typeof import.meta.env !== 'undefined' && import.meta.env.DEV === true;
@@ -414,6 +440,15 @@ const localPlayer = createLocalPlayer(() => clientCore);
414
440
  // сервис пула зависимостей: «какое место у этой сущности в глобальном
415
441
  // топе?». Места считает хост, part рисует за них знак (см. lib/accolades.js)
416
442
  const accolades = createAccolades();
443
+ // голосовая связь: кто в эфире (сервис партов `voice`) — хранилище пишет
444
+ // движок по VOICE_DATA, парты только читают (lib/voiceState.js)
445
+ const voice = createVoiceState();
446
+ // подсистема голоса грузится лениво — только у игры с включённой связью
447
+ let voiceClient = null;
448
+ let voiceConfig = null;
449
+ // номер запуска подсистемы: чанк, догрузившийся после закрытия матча,
450
+ // уничтожает свой клиент сам
451
+ let voiceSession = 0;
417
452
  let inputSeq = 0; // номер отправленного ввода (KEYS_DATA)
418
453
 
419
454
  // обратный индекс снапшот-схемы игры (CONFIG_DATA.snapshot):
@@ -432,6 +467,9 @@ socketMethods[PS_CONFIG_DATA] = async data => {
432
467
  return;
433
468
  }
434
469
 
470
+ voiceConfig = data.voice ?? null;
471
+ voice.store.setEnabled(voiceConfig?.enabled === true);
472
+
435
473
  gameSets = data.parts.gameSets;
436
474
  entitiesOnCanvas = data.parts.entitiesOnCanvas;
437
475
 
@@ -522,12 +560,15 @@ socketMethods[PS_CONFIG_DATA] = async data => {
522
560
  // места в глобальном топе: знак за место рисует part игры, движок
523
561
  // раздаёт только числа (см. lib/accolades.js)
524
562
  accolades,
563
+ // индикаторы эфира связи для партов игры: кто сейчас говорит
564
+ // (только чтение, см. lib/voiceState.js)
565
+ voice: voice.service,
525
566
  // база ассетов игры — тем же каналом, что и путь к звукам (см. выше):
526
567
  // картинки карт живут в пакете игры (dist/img/), движок их не раздаёт.
527
568
  // part, объявивший сервис в componentDependencies, строит URL сам —
528
569
  // движок не знает ни имён файлов, ни их раскладки внутри пакета
529
570
  assetsBase: activeGameManifest.assetsBase,
530
- // журнал клиентских ошибок для партов игры (plan/client-reports):
571
+ // журнал клиентских ошибок для партов игры:
531
572
  // warn(code, details) — своё предупреждение, capture(error) — пойманная
532
573
  // ошибка. Сервис опционален: игра не пишет его в requires
533
574
  diagnostics: {
@@ -577,21 +618,19 @@ socketMethods[PS_AUTH_DATA] = data => {
577
618
 
578
619
  document.getElementById('logo').textContent = texts?.title || 'VIMP';
579
620
 
580
- // футер формы входа: версия и ссылка пакета ИГРЫ. Здесь, а не в AuthView:
581
- // его elems приходят из authSchema.elems игрового плагина, и футер каркаса
582
- // — не его забота. Метаданные кладёт в манифест мастер (GameCatalog читает
583
- // package.json пакета); в standalone-манифесте их может не быть — тогда
584
- // ячейки пустые, раскладку space-between это не ломает
621
+ // футер формы входа: версия пакета ИГРЫ и (в dev-сборке) ссылка на
622
+ // документацию. Здесь, а не в AuthView: его elems приходят из
623
+ // authSchema.elems игрового плагина, и футер каркаса — не его забота.
624
+ // Версию кладёт в манифест мастер (GameCatalog читает package.json
625
+ // пакета); в standalone-манифесте её может не быть — тогда ячейка пустая,
626
+ // раскладку space-between это не ломает
585
627
  const authVersion = document.getElementById('auth-version');
586
628
 
587
629
  if (authVersion) {
588
630
  authVersion.textContent = activeGameManifest?.packageVersion ?? '';
589
631
  }
590
632
 
591
- renderProjectLink(
592
- document.getElementById('auth-package-link'),
593
- activeGameManifest?.packageUrl,
594
- );
633
+ renderDocsLink(document.getElementById('auth-docs-link'), isDevBuild);
595
634
 
596
635
  // память клиента (localStorage) + принудительное значение поля с
597
636
  // единственным вариантом. Здесь, а не в AuthView: solo-путь ниже отвечает
@@ -892,6 +931,19 @@ socketMethods[PS_ACCOLADES_DATA] = data => {
892
931
  modules.stat?.applyAccolades?.();
893
932
  };
894
933
 
934
+ // голосовая связь: кортеж разбирается один раз (lib/voiceMessages.js) —
935
+ // хост в P2P чужой, непрошедшее проверку не доходит ни до кого
936
+ socketMethods[PS_VOICE_DATA] = data => {
937
+ const message = parseVoiceMessage(data);
938
+
939
+ if (!message) {
940
+ return;
941
+ }
942
+
943
+ voice.store.apply(message);
944
+ voiceClient?.handleFloorMessage(message);
945
+ };
946
+
895
947
  // chat data
896
948
  socketMethods[PS_CHAT_DATA] = data => {
897
949
  modules.chat.add(data);
@@ -1034,7 +1086,7 @@ socketMethods[PS_RESUME_RESULT] = data => {
1034
1086
  };
1035
1087
 
1036
1088
  // console: логи авторитетной половины (Worker изолирован от DevTools вкладки —
1037
- // иначе его события в браузере не видны вовсе, этап 6 плана plan/done/ai-debug)
1089
+ // иначе его события в браузере не видны вовсе)
1038
1090
  socketMethods[PS_CONSOLE] = data => {
1039
1091
  console.log(`${DEBUG_PREFIX}[host]`, data);
1040
1092
  };
@@ -1197,6 +1249,20 @@ function runModules(data) {
1197
1249
 
1198
1250
  modules.vote = new VoteCtrl(voteModel, voteView);
1199
1251
 
1252
+ //==========================================//
1253
+ // Voice Module (голосовая связь)
1254
+ //==========================================//
1255
+
1256
+ if (voiceConfig?.enabled) {
1257
+ const voiceModel = new VoiceModel({ texts: voiceConfig.texts });
1258
+ const voiceView = new VoiceView(voiceModel, gameContainer);
1259
+
1260
+ modules.voice = new VoiceCtrl(voiceModel, voiceView);
1261
+ modules.controls.setPushToTalk(voiceConfig.hotkey);
1262
+ controlsModel.publisher.on('ptt', held => voiceClient?.setPttHeld(held));
1263
+ startVoiceClient();
1264
+ }
1265
+
1200
1266
  //==========================================//
1201
1267
  // Подписка на события
1202
1268
  //==========================================//
@@ -1217,6 +1283,8 @@ function runModules(data) {
1217
1283
  'keyUp',
1218
1284
  modules.controls.remove.bind(modules.controls),
1219
1285
  );
1286
+ // потеря фокуса окна отпускает связь: keyup снаружи не придёт
1287
+ inputListener.publisher.on('blur', () => modules.controls?.releasePtt());
1220
1288
  inputListener.publisher.on(
1221
1289
  'mouseAction',
1222
1290
  modules.controls.resetCursorHideTimer.bind(modules.controls),
@@ -1320,6 +1388,58 @@ function sending(name, data, reliable = true) {
1320
1388
  supervisor?.send(JSON.stringify([name, data]), reliable);
1321
1389
  }
1322
1390
 
1391
+ // подсистема голоса — отдельный чанк: игре без связи и standalone он не нужен
1392
+ function startVoiceClient() {
1393
+ const session = ++voiceSession;
1394
+
1395
+ import('./voice/VoiceClient.js')
1396
+ .then(async ({ default: VoiceClient }) => {
1397
+ const client = new VoiceClient({
1398
+ config: voiceConfig,
1399
+ sendFloor: payload => sending(PC_VOICE_FLOOR, payload),
1400
+ sendAudio: bytes => supervisor?.sendVoice(bytes),
1401
+ soundManager,
1402
+ localPlayer,
1403
+ hud: modules.voice,
1404
+ voiceStore: voice.store,
1405
+ // стиль связи — хуки игры: звуки сигналов и окраска речи; старый
1406
+ // плагин их не имеет — нейтральные тоны, чистый голос
1407
+ voiceSounds: clientPlugin.hooks.voiceSounds,
1408
+ voiceFilter: clientPlugin.hooks.voiceFilter,
1409
+ diagnostics,
1410
+ });
1411
+
1412
+ try {
1413
+ await client.init();
1414
+ } catch (error) {
1415
+ // недособранный клиент освобождает то, что успел создать; сбой
1416
+ // уборки не подменяет первопричину
1417
+ try {
1418
+ client.destroy();
1419
+ } catch {
1420
+ // клиент собран не до конца
1421
+ }
1422
+
1423
+ throw error;
1424
+ }
1425
+
1426
+ // матч закрылся, пока грузился чанк: клиент не нужен
1427
+ if (session !== voiceSession) {
1428
+ client.destroy();
1429
+ return;
1430
+ }
1431
+
1432
+ voiceClient = client;
1433
+ })
1434
+ .catch(error => {
1435
+ diagnostics.capture(error, { source: 'voice' });
1436
+
1437
+ if (session === voiceSession) {
1438
+ modules.voice?.showOwn('error', { textKey: 'unavailable' });
1439
+ }
1440
+ });
1441
+ }
1442
+
1323
1443
  // отправка сообщения чата хосту (точка перехвата команд, адресованных
1324
1444
  // мастеру, а не хосту)
1325
1445
  function handleChatSend(message) {
@@ -1380,6 +1500,8 @@ function handleVisibilityChange() {
1380
1500
  // если вкладка неактивна, выключение звука
1381
1501
  if (document.visibilityState === 'hidden') {
1382
1502
  hiddenAt = performance.now();
1503
+ // скрытая вкладка не получит keyup
1504
+ modules.controls?.releasePtt();
1383
1505
  soundManager.mute();
1384
1506
  // иначе включение звука при возвращении (кроме паузы переподключения и
1385
1507
  // смены хоста — его включит возобновление сессии)
@@ -1577,6 +1699,13 @@ function handleDisconnect(closeCode) {
1577
1699
  lastMapData = null;
1578
1700
 
1579
1701
  document.removeEventListener('visibilitychange', handleVisibilityChange);
1702
+ // строго до soundManager.destroy(): Howler.unload() закрывает контекст, на
1703
+ // котором сидят захват и воспроизведение связи
1704
+ voiceSession += 1;
1705
+ voiceClient?.destroy();
1706
+ voiceClient = null;
1707
+ voice.store.clear();
1708
+ modules.voice?.destroy();
1580
1709
  soundManager.destroy();
1581
1710
  modules.controls?.disableKeys();
1582
1711
 
@@ -1670,6 +1799,7 @@ function startSession(transport, reconnect = null) {
1670
1799
  onTerminal: handleDisconnect,
1671
1800
  onStateChange: handleSessionState,
1672
1801
  onResumed: handleSessionResumed,
1802
+ onVoice: bytes => voiceClient?.handleAudio(bytes),
1673
1803
  onResumeRejected: handleResumeRejected,
1674
1804
  onHostLost: reportHostUnreachable,
1675
1805
  onColdRestart: handleColdRestart,
@@ -1713,6 +1843,8 @@ function isSessionPaused() {
1713
1843
  function handleSessionState(state, prev) {
1714
1844
  if (state === SESSION_STATES.migrating) {
1715
1845
  showSessionOverlay('Switching host…');
1846
+ voiceClient?.suspend();
1847
+ voice.store.clear();
1716
1848
  modules.controls?.disableKeys();
1717
1849
  soundManager.mute();
1718
1850
  } else if (state === SESSION_STATES.reconnecting) {
@@ -1721,6 +1853,8 @@ function handleSessionState(state, prev) {
1721
1853
  showSessionOverlay('Reconnecting…');
1722
1854
  }
1723
1855
 
1856
+ voiceClient?.suspend();
1857
+ voice.store.clear();
1724
1858
  modules.controls?.disableKeys();
1725
1859
  soundManager.mute();
1726
1860
  } else if (state === SESSION_STATES.inGame) {
@@ -2478,7 +2612,7 @@ function initLobby() {
2478
2612
  // сам, по нику из VIMP_ADMIN_NICKS
2479
2613
  games.setAdmin(lobbyAuthModel.getRole() === 'admin');
2480
2614
 
2481
- // журнал клиентских ошибок (plan/client-reports, этап 5): кнопку «Errors»
2615
+ // журнал клиентских ошибок: кнопку «Errors»
2482
2616
  // видит только админ; фильтр по игре — каталог вкладки
2483
2617
  const clientReportsModel = new ClientReportsModel(
2484
2618
  lobbyConfig.clientReports,
@@ -7,8 +7,14 @@
7
7
  // Эпизод закрывается возвратом видимости, потерей роли хоста или
8
8
  // выгрузкой страницы (flush). Следующие эпизоды той же сессии журнал
9
9
  // склеивает с первым: растёт счётчик, details остаются первого — осознанно.
10
- // Эпизод без единой метрики (матч стоял, скрытие короче окна цикла) не
11
- // пишется. Без DOM: видимость сообщает владелец.
10
+ // Пишется только эпизод, в котором матч просел (потеряно время или частота
11
+ // ниже порога), это задело удалённых игроков (peers > 0), а передача хоста
12
+ // не началась без видимой причины (handoff 'unexplained'): просадка без
13
+ // игроков, ожидаемый отказ (нет преемника, срок роли, кулдаун, выключено) и
14
+ // начатая передача — штатный троттлинг, а сводка здорового эпизода заняла бы
15
+ // место details первой записи. Эпизод без единой метрики (матч стоял,
16
+ // скрытие короче окна цикла) не пишется. Без DOM: видимость и исход
17
+ // передачи сообщает владелец.
12
18
 
13
19
  export const HIDDEN_HEALTH_CODE = 'engine.host.hiddenHealth';
14
20
 
@@ -16,11 +22,22 @@ export default class HiddenHostHealthLog {
16
22
  /**
17
23
  * @param {Object} options
18
24
  * @param {Function} options.warn - (code, summary) запись в журнал.
25
+ * @param {number} options.minHealthyTickRate - ниже — эпизод просел.
26
+ * @param {Function} options.handoffOutcome - () => исход автопередачи
27
+ * (HostHealthPolicy.hiddenHandoff).
19
28
  * @param {boolean} [options.hidden] - вкладка скрыта в момент создания.
20
29
  * @param {Function} [options.now] - монотонные часы, мс.
21
30
  */
22
- constructor({ warn, hidden = false, now = () => performance.now() }) {
31
+ constructor({
32
+ warn,
33
+ minHealthyTickRate,
34
+ handoffOutcome,
35
+ hidden = false,
36
+ now = () => performance.now(),
37
+ }) {
23
38
  this._warn = warn;
39
+ this._minHealthyTickRate = minHealthyTickRate;
40
+ this._handoffOutcome = handoffOutcome;
24
41
  this._now = now;
25
42
  this._episode = null;
26
43
 
@@ -35,6 +52,7 @@ export default class HiddenHostHealthLog {
35
52
  minTickRate: Infinity,
36
53
  maxGapMs: 0,
37
54
  lostMs: 0,
55
+ peers: 0,
38
56
  };
39
57
  } else if (!hidden) {
40
58
  this.flush();
@@ -53,6 +71,11 @@ export default class HiddenHostHealthLog {
53
71
  episode.minTickRate = Math.min(episode.minTickRate, health.tickRate);
54
72
  episode.maxGapMs = Math.max(episode.maxGapMs, health.maxGapMs);
55
73
  episode.lostMs += health.lostMs;
74
+
75
+ // peers — по просевшим метрикам: сколько игроков задела просадка
76
+ if (health.lostMs > 0 || health.tickRate < this._minHealthyTickRate) {
77
+ episode.peers = Math.max(episode.peers, health.peerCount ?? 0);
78
+ }
56
79
  }
57
80
 
58
81
  // закрыть эпизод (если идёт) и записать сводку
@@ -65,12 +88,26 @@ export default class HiddenHostHealthLog {
65
88
  return;
66
89
  }
67
90
 
91
+ // peers считается по просевшим метрикам: 0 — здоровый эпизод либо
92
+ // просадка без удалённых игроков
93
+ if (episode.peers === 0) {
94
+ return;
95
+ }
96
+
97
+ const handoff = this._handoffOutcome();
98
+
99
+ if (handoff !== 'unexplained') {
100
+ return;
101
+ }
102
+
68
103
  this._warn(HIDDEN_HEALTH_CODE, {
69
104
  hiddenMs: Math.round(this._now() - episode.startedAt),
70
105
  samples: episode.samples,
71
106
  minTickRate: episode.minTickRate,
72
107
  maxGapMs: episode.maxGapMs,
73
108
  lostMs: episode.lostMs,
109
+ peers: episode.peers,
110
+ handoff,
74
111
  });
75
112
  }
76
113
  }
@@ -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;
@@ -1,3 +1,5 @@
1
+ import VoiceRelay from '../../lib/VoiceRelay.js';
2
+
1
3
  // Мост главного потока между Worker'ом хоста (авторитетная симуляция) и
2
4
  // транспортами клиентов. Worker не имеет доступа к RTCPeerConnection — главный
3
5
  // поток роутит пакеты: исходящие кадры Worker'а (to_client) → нужному клиенту,
@@ -9,6 +11,9 @@
9
11
  // версию кода без разрыва P2P — старый Worker отдаёт handoff-состояние на
10
12
  // границе раунда, новый поднимается с ним, клиенты переподключаются
11
13
  // внутренними connect'ами (WebRTC-каналы живут здесь и не трогаются).
14
+ //
15
+ // Здесь же живёт ретранслятор голоса (VoiceRelay): пакеты голоса в Worker не
16
+ // уходят, маршруты ему присылает мета-модуль Voice сообщением voice_route.
12
17
 
13
18
  // предохранитель от зависшего init нового Worker'а: не дождались ready —
14
19
  // своп отменяется, комната продолжает жить на старом Worker'е
@@ -56,7 +61,7 @@ export default class HostController {
56
61
  * @param {Function} [opts.onLobbyInfoChange] - смена строки карточки
57
62
  * комнаты в лобби (string|null) — для актуализации info у мастера.
58
63
  * @param {Object} [opts.diagnostics] - журнал клиентских ошибок
59
- * (lib/diagnostics.js, plan/client-reports): туда уходят ошибки Worker'а.
64
+ * (lib/diagnostics.js): туда уходят ошибки Worker'а.
60
65
  * @param {Uint8Array} [opts.checkpoint] - сжатая контрольная точка
61
66
  * (host-migration этап 5): Worker поднимает матч из неё на паузе, старт —
62
67
  * startAfterRestore(). Буфер передаётся списком переноса — после
@@ -102,6 +107,8 @@ export default class HostController {
102
107
 
103
108
  this._swap = null; // состояние эстафеты (Этап 5.2)
104
109
 
110
+ this._voiceRelay = new VoiceRelay();
111
+
105
112
  // ожидание границы раунда плановой передачей (host-migration этап 8d)
106
113
  this._roundBoundaryCallback = null;
107
114
 
@@ -114,7 +121,7 @@ export default class HostController {
114
121
  // подписчики метрик здоровья хоста (host-migration этап 9a)
115
122
  this._healthListeners = new Set();
116
123
 
117
- // отладочные запросы в Worker (этап 6 плана plan/done/ai-debug):
124
+ // отладочные запросы в Worker:
118
125
  // requestId → { resolve, reject }
119
126
  this._debugRequests = new Map();
120
127
  this._debugRequestId = 0;
@@ -149,7 +156,7 @@ export default class HostController {
149
156
  });
150
157
  }
151
158
 
152
- // журнал клиентских ошибок (plan/client-reports): неперехваченная ошибка
159
+ // журнал клиентских ошибок: неперехваченная ошибка
153
160
  // Worker'а приходит сюда. preventDefault не зовётся — консольный вывод
154
161
  // браузера остаётся как есть
155
162
  _watchWorkerErrors(worker) {
@@ -202,8 +209,36 @@ export default class HostController {
202
209
  this._worker.postMessage(msg);
203
210
  }
204
211
 
212
+ /**
213
+ * Выход голоса к соединению (WebRTC-канал гостя или loopback хоста-игрока).
214
+ * @param {string} socketId
215
+ * @param {{ send: Function, bufferedAmount?: Function }} sink
216
+ */
217
+ attachVoice(socketId, sink) {
218
+ this._voiceRelay.attach(socketId, sink);
219
+ }
220
+
221
+ /**
222
+ * Отцепляет выход голоса (канал voice закрылся при живом соединении).
223
+ * @param {string} socketId
224
+ */
225
+ detachVoice(socketId) {
226
+ this._voiceRelay.detach(socketId);
227
+ }
228
+
229
+ /**
230
+ * Пакет голоса от соединения: Worker его не видит.
231
+ * @param {string} socketId
232
+ * @param {ArrayBuffer|Uint8Array} data
233
+ */
234
+ voiceIngress(socketId, data) {
235
+ this._voiceRelay.ingress(socketId, data);
236
+ }
237
+
205
238
  // отключает клиента
206
239
  disconnect(socketId) {
240
+ // ретранслятор живёт в главном потоке: ждать паузу эстафеты ему нечего
241
+ this._voiceRelay.detach(socketId);
207
242
  this._deliveries.delete(socketId);
208
243
 
209
244
  const msg = { type: 'disconnect', socketId };
@@ -456,7 +491,7 @@ export default class HostController {
456
491
  }
457
492
 
458
493
  /**
459
- * Отладочный контур (этап 6 плана plan/done/ai-debug): начинает запись живого
494
+ * Отладочный контур: начинает запись живого
460
495
  * матча в формат сценария headless-runner'а.
461
496
  * @returns {Promise<boolean>} false — dev-режим в комнате выключен.
462
497
  */
@@ -580,6 +615,7 @@ export default class HostController {
580
615
  this._shutdownResolve?.();
581
616
 
582
617
  this._rejectDebugRequests('host destroyed');
618
+ this._voiceRelay.clear();
583
619
 
584
620
  if (this._swap) {
585
621
  const { reject } = this._swap;
@@ -705,6 +741,9 @@ export default class HostController {
705
741
  this._postCheckpointState(next);
706
742
  }
707
743
 
744
+ // маршруты старого Worker'а мертвы (новый начинает без эфиров); выходы
745
+ // остаются — каналы WebRTC эстафету переживают
746
+ this._voiceRelay.clear();
708
747
  this._worker.terminate();
709
748
  this._worker = next;
710
749
  this._worker.onmessage = e => this._onWorkerMessage(e.data);
@@ -761,6 +800,10 @@ export default class HostController {
761
800
  this._reportWorkerMessage(msg, msg.kind);
762
801
  break;
763
802
 
803
+ case 'voice_route':
804
+ this._voiceRelay.applyRoute(msg);
805
+ break;
806
+
764
807
  case 'map_changed':
765
808
  this._onMapChange?.(msg.mapName);
766
809
  break;