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.
- package/core/Cargo.toml +1 -1
- package/package.json +3 -1
- package/src/client/InputListener.js +3 -0
- package/src/client/SoundManager.js +13 -0
- package/src/client/boot.js +30 -7
- package/src/client/components/controller/ClientReports.js +1 -1
- package/src/client/components/controller/Controls.js +10 -0
- package/src/client/components/controller/Games.js +1 -1
- package/src/client/components/controller/Voice.js +31 -0
- package/src/client/components/model/ClientReports.js +1 -1
- package/src/client/components/model/Controls.js +64 -0
- package/src/client/components/model/Games.js +24 -0
- package/src/client/components/model/Voice.js +96 -0
- package/src/client/components/model/Vote.js +2 -1
- package/src/client/components/view/ClientReports.js +1 -1
- package/src/client/components/view/Games.js +19 -1
- package/src/client/components/view/Lobby.js +1 -7
- package/src/client/components/view/Voice.js +172 -0
- package/src/client/debug.js +1 -1
- package/src/client/lib/authApi.js +1 -1
- package/src/client/lib/diagnostics.js +19 -4
- package/src/client/lib/engineVersion.js +0 -6
- package/src/client/lib/footerLink.js +15 -14
- package/src/client/lib/retryOnNetworkError.js +60 -0
- package/src/client/lib/voiceState.js +78 -0
- package/src/client/main.js +152 -18
- package/src/client/network/HiddenHostHealthLog.js +40 -3
- package/src/client/network/HostConnectionManager.js +46 -5
- package/src/client/network/HostController.js +47 -4
- package/src/client/network/HostHealthPolicy.js +41 -0
- package/src/client/network/InlineHostBridge.js +8 -1
- package/src/client/network/LoopbackTransport.js +14 -0
- package/src/client/network/SessionSupervisor.js +24 -0
- package/src/client/network/WebRtcManager.js +45 -3
- package/src/client/session/HandoffFlow.js +5 -0
- package/src/client/session/HostRole.js +5 -2
- package/src/client/style.css +151 -9
- package/src/client/views/gameShell.js +3 -3
- package/src/client/views/includes/auth.pug +4 -4
- package/src/client/views/includes/lobby.pug +4 -4
- package/src/client/views/includes/reports.pug +1 -1
- package/src/client/voice/JitterBuffer.js +137 -0
- package/src/client/voice/MicCapture.js +307 -0
- package/src/client/voice/PttMachine.js +369 -0
- package/src/client/voice/VoiceClient.js +336 -0
- package/src/client/voice/VoiceCues.js +295 -0
- package/src/client/voice/VoiceEncoder.js +174 -0
- package/src/client/voice/VoicePlayback.js +558 -0
- package/src/client/voice/audioData.js +35 -0
- package/src/client/voice/captureProcessor.worklet.js +136 -0
- package/src/client/voice/opusConfig.js +49 -0
- package/src/client/voice/voiceOutput.js +72 -0
- package/src/client/voice/voiceSupport.js +71 -0
- package/src/client/voice/voiceTones.js +90 -0
- package/src/config/authClient.js +3 -3
- package/src/config/clientServices.js +2 -0
- package/src/config/lobby.js +4 -5
- package/src/config/opcodes.js +2 -1
- package/src/config/voiceDefaults.js +44 -0
- package/src/config/voiceProtocol.js +80 -0
- package/src/config/voteKeys.js +4 -0
- package/src/config/wsports.js +12 -0
- package/src/devtools/contract/loadContext.js +2 -2
- package/src/devtools/contract/rules/a7-package-repository.js +7 -8
- package/src/devtools/contract/rules/b11-voice-config.js +34 -0
- package/src/devtools/contract/rules/c12-voice-hotkey.js +70 -0
- package/src/devtools/contract/rules/index.js +5 -1
- package/src/devtools/invariants.js +1 -1
- package/src/host/DebugRecorder.js +1 -1
- package/src/host/HostGame.js +93 -15
- package/src/host/PortMachine.js +19 -3
- package/src/host/commandFailure.js +140 -0
- package/src/host/host.worker.js +15 -2
- package/src/host/meta/SocketManager.js +8 -2
- package/src/host/meta/core/CommandProcessor.js +37 -7
- package/src/host/meta/modules/voice/Voice.js +430 -0
- package/src/host/meta/modules/voice/channels.js +58 -0
- package/src/host/meta/modules/voice/index.js +1 -0
- package/src/lib/VoiceRelay.js +200 -0
- package/src/lib/applyRoomOverrides.js +1 -1
- package/src/lib/buildClientConfig.js +7 -0
- package/src/lib/capabilities.js +5 -1
- package/src/lib/checkpointCodec.js +1 -2
- package/src/lib/coreConfig.js +1 -1
- package/src/lib/createHostRuntime.js +9 -1
- package/src/lib/gameConfigView.js +11 -1
- package/src/lib/gameId.js +1 -1
- package/src/lib/gamePlugin.js +6 -2
- package/src/lib/jwt.js +1 -1
- package/src/lib/packageLink.js +18 -61
- package/src/lib/validators.js +3 -3
- package/src/lib/voiceConfig.js +136 -0
- package/src/lib/voiceMessages.js +172 -0
- package/src/lib/voicePacket.js +142 -0
- package/src/standalone/index.js +1 -1
- package/tests/fixtures/generations/gen-api3/README.md +2 -2
- package/tests/fixtures/generations/gen-api4/README.md +2 -2
- package/src/devtools/surface/abiParse.js +0 -175
- package/src/devtools/surface/collect.js +0 -413
package/src/client/main.js
CHANGED
|
@@ -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 {
|
|
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)
|
|
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
|
-
// журнал клиентских
|
|
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 подставляет константу): включает отладочный контур
|
|
352
|
-
//
|
|
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
|
-
// журнал клиентских ошибок для партов
|
|
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
|
-
// футер формы входа: версия
|
|
581
|
-
//
|
|
582
|
-
//
|
|
583
|
-
//
|
|
584
|
-
//
|
|
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
|
-
|
|
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
|
-
// иначе его события в браузере не видны
|
|
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
|
-
// журнал клиентских
|
|
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
|
-
//
|
|
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({
|
|
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
|
|
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
|
|
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
|
|
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
|
-
// журнал клиентских
|
|
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
|
-
* Отладочный
|
|
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;
|