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
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
import { WARN, skip, verdict } from '../result.js';
|
|
2
2
|
import { resolveProjectUrl } from '../../../lib/packageLink.js';
|
|
3
3
|
|
|
4
|
-
// Игра без объявленного
|
|
5
|
-
// отказ. Но
|
|
6
|
-
//
|
|
7
|
-
//
|
|
4
|
+
// Игра без объявленного repository собирается и играется — это не
|
|
5
|
+
// отказ. Но реестр игр в лобби показывает ссылку на проект, и берёт он её
|
|
6
|
+
// только из него (фолбэка нет, см. lib/packageLink.js): без них строка
|
|
7
|
+
// игры останется без ссылки, а заметить это без подсказки негде.
|
|
8
8
|
// Отсюда warn — под --strict он становится ошибкой.
|
|
9
9
|
export default {
|
|
10
10
|
id: 'A7',
|
|
11
11
|
name: 'packageRepository',
|
|
12
12
|
level: WARN,
|
|
13
|
-
title:
|
|
14
|
-
'package.json declares homepage or repository (the #auth footer links to it)',
|
|
13
|
+
title: 'package.json declares repository (the games registry links to it)',
|
|
15
14
|
|
|
16
15
|
check(ctx) {
|
|
17
16
|
if (!ctx.pkg) {
|
|
@@ -23,8 +22,8 @@ export default {
|
|
|
23
22
|
}
|
|
24
23
|
|
|
25
24
|
return verdict([
|
|
26
|
-
'
|
|
27
|
-
"the game's entry
|
|
25
|
+
'"repository" does not resolve to a project URL — ' +
|
|
26
|
+
"the game's registry entry will show no project link",
|
|
28
27
|
]);
|
|
29
28
|
},
|
|
30
29
|
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { inspectVoiceConfig } from '../../../lib/voiceConfig.js';
|
|
2
|
+
import { WARN, skip, verdict } from '../result.js';
|
|
3
|
+
|
|
4
|
+
// Блок voice необязателен: без него связь выключена. Но объявленный блок с
|
|
5
|
+
// опечаткой — тихий откат на дефолт (или зажим числа). Разбор берётся из
|
|
6
|
+
// lib/voiceConfig.js — того же модуля, что читает рантайм, иначе правило и
|
|
7
|
+
// движок разошлись бы. Хуки voiceSounds/voiceFilter статически не
|
|
8
|
+
// проверяются: их ответ виден только в браузере, и его проверяет рантайм.
|
|
9
|
+
export default {
|
|
10
|
+
id: 'B11',
|
|
11
|
+
name: 'voiceConfig',
|
|
12
|
+
level: WARN,
|
|
13
|
+
title: 'the voice block is well-formed',
|
|
14
|
+
|
|
15
|
+
check(ctx) {
|
|
16
|
+
if (!ctx.gameConfig) {
|
|
17
|
+
return skip('no gameConfig');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (ctx.gameConfig.voice === undefined || ctx.gameConfig.voice === null) {
|
|
21
|
+
return skip('no voice block — voice is off');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const { problems } = inspectVoiceConfig(ctx.gameConfig.voice);
|
|
25
|
+
|
|
26
|
+
// забытый enabled: блок есть, связи нет, и ни одно правило не скажет.
|
|
27
|
+
// enabled: false — осознанное выключение, не нарушение
|
|
28
|
+
if (ctx.gameConfig.voice.enabled === undefined) {
|
|
29
|
+
problems.push('voice block is declared but voice.enabled is not set');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return verdict(problems);
|
|
33
|
+
},
|
|
34
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import clientDefaults from '../../../config/clientDefaults.js';
|
|
2
|
+
import { VOTE_ANSWER_KEYS } from '../../../config/voteKeys.js';
|
|
3
|
+
import { inspectVoiceConfig } from '../../../lib/voiceConfig.js';
|
|
4
|
+
import { ERROR, skip, verdict } from '../result.js';
|
|
5
|
+
|
|
6
|
+
// Клавиша связи не должна совпасть ни с режимом/командой движка или игры,
|
|
7
|
+
// ни с клавишей игрового набора: ControlsModel.addKey забирает её раньше
|
|
8
|
+
// всех — совпавший режим (чат, стата, escape), команда или действие игры
|
|
9
|
+
// перестали бы работать. Коды движка берутся из clientDefaults, а не
|
|
10
|
+
// списком: правило не отстаёт от движка.
|
|
11
|
+
export default {
|
|
12
|
+
id: 'C12',
|
|
13
|
+
name: 'voiceHotkey',
|
|
14
|
+
level: ERROR,
|
|
15
|
+
title: 'the push-to-talk key collides with no other key',
|
|
16
|
+
|
|
17
|
+
check(ctx) {
|
|
18
|
+
const { config } = inspectVoiceConfig(ctx.gameConfig?.voice);
|
|
19
|
+
|
|
20
|
+
if (!config.enabled) {
|
|
21
|
+
return skip('voice is off');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const code = String(config.hotkey);
|
|
25
|
+
const violations = [];
|
|
26
|
+
const engine = clientDefaults.modules.controls;
|
|
27
|
+
const game = ctx.clientConfig?.modules?.controls;
|
|
28
|
+
|
|
29
|
+
for (const [owner, controls] of [
|
|
30
|
+
['the engine', engine],
|
|
31
|
+
['the game', game],
|
|
32
|
+
]) {
|
|
33
|
+
for (const group of ['modes', 'cmds']) {
|
|
34
|
+
const name = controls?.[group]?.[code];
|
|
35
|
+
|
|
36
|
+
if (name) {
|
|
37
|
+
violations.push(
|
|
38
|
+
`voice.hotkey ${code} is bound by ${owner} (${group}: ${name})`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// клиент отдаёт клавишу связи раньше, чем публикует код в голосование,
|
|
45
|
+
// а Vote принимает цифры 0..9: такая клавиша не выбрала бы вариант
|
|
46
|
+
if (
|
|
47
|
+
config.hotkey >= VOTE_ANSWER_KEYS[0] &&
|
|
48
|
+
config.hotkey <= VOTE_ANSWER_KEYS[1]
|
|
49
|
+
) {
|
|
50
|
+
violations.push(`voice.hotkey ${code} is a vote answer key (digits 0-9)`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const keySetList = game?.keySetList;
|
|
54
|
+
|
|
55
|
+
if (Array.isArray(keySetList)) {
|
|
56
|
+
keySetList.forEach((set, index) => {
|
|
57
|
+
const action = set?.[code];
|
|
58
|
+
|
|
59
|
+
if (action) {
|
|
60
|
+
violations.push(
|
|
61
|
+
`voice.hotkey ${code} is bound in keySetList[${index}] ` +
|
|
62
|
+
`("${action}")`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return verdict(violations);
|
|
69
|
+
},
|
|
70
|
+
};
|
|
@@ -15,6 +15,7 @@ import b7 from './b7-chat-commands.js';
|
|
|
15
15
|
import b8 from './b8-system-messages.js';
|
|
16
16
|
import b9 from './b9-vote-names.js';
|
|
17
17
|
import b10 from './b10-respawns.js';
|
|
18
|
+
import b11 from './b11-voice-config.js';
|
|
18
19
|
import c1 from './c1-client-shape.js';
|
|
19
20
|
import c2 from './c2-parts-registered.js';
|
|
20
21
|
import c3 from './c3-game-sets-coverage.js';
|
|
@@ -26,6 +27,7 @@ import c8 from './c8-baked-assets.js';
|
|
|
26
27
|
import c9 from './c9-chat-messages.js';
|
|
27
28
|
import c10 from './c10-auth-schema.js';
|
|
28
29
|
import c11 from './c11-stat-mode.js';
|
|
30
|
+
import c12 from './c12-voice-hotkey.js';
|
|
29
31
|
import d1 from './d1-snapshot-ids.js';
|
|
30
32
|
import d2 from './d2-snapshot-classes.js';
|
|
31
33
|
import d3 from './d3-snapshot-interp.js';
|
|
@@ -38,7 +40,7 @@ import e6 from './e6-sound-spatial.js';
|
|
|
38
40
|
import e7 from './e7-map-game-field.js';
|
|
39
41
|
|
|
40
42
|
// Порядок групп — порядок отчёта: A пакет и сборка, B host, C client,
|
|
41
|
-
// D снапшот, E
|
|
43
|
+
// D снапшот, E ассеты.
|
|
42
44
|
export const rules = [
|
|
43
45
|
a1,
|
|
44
46
|
a2,
|
|
@@ -57,6 +59,7 @@ export const rules = [
|
|
|
57
59
|
b8,
|
|
58
60
|
b9,
|
|
59
61
|
b10,
|
|
62
|
+
b11,
|
|
60
63
|
c1,
|
|
61
64
|
c2,
|
|
62
65
|
c3,
|
|
@@ -68,6 +71,7 @@ export const rules = [
|
|
|
68
71
|
c9,
|
|
69
72
|
c10,
|
|
70
73
|
c11,
|
|
74
|
+
c12,
|
|
71
75
|
d1,
|
|
72
76
|
d2,
|
|
73
77
|
d3,
|
|
@@ -19,7 +19,7 @@ export const SKIP = 'skip';
|
|
|
19
19
|
// не берётся судить покрытие схемы» (встроенный смоук на чужой игре)
|
|
20
20
|
export const ALL_KEYS_UNAUDITED = '*';
|
|
21
21
|
|
|
22
|
-
// список проверок
|
|
22
|
+
// список проверок
|
|
23
23
|
const CHECKS = [
|
|
24
24
|
[1, 'finiteValues', 'no NaN/Infinity in decoded fields and hot buffer'],
|
|
25
25
|
[2, 'snapshotKeysUsed', 'every snapshot key produced at least one row'],
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import clock from '../lib/clock.js';
|
|
2
2
|
|
|
3
|
-
// Рекордер живого
|
|
3
|
+
// Рекордер живого матча: пишет то, что headless
|
|
4
4
|
// не воспроизводит — реальный WebRTC, PixiJS и живой ввод человека — ровно в
|
|
5
5
|
// формат сценария headless-runner'а (devtools/ScenarioRunner.js). Баг,
|
|
6
6
|
// пойманный человеком в браузере, догоняется потом `npm run sim:replay`
|
package/src/host/HostGame.js
CHANGED
|
@@ -5,6 +5,7 @@ import Stat from './meta/modules/Stat.js';
|
|
|
5
5
|
import Chat from './meta/modules/chat/index.js';
|
|
6
6
|
import { registerCodes } from './meta/modules/chat/systemMessages.js';
|
|
7
7
|
import Vote from './meta/modules/Vote.js';
|
|
8
|
+
import Voice from './meta/modules/voice/index.js';
|
|
8
9
|
import RTTManager from './meta/modules/RTTManager.js';
|
|
9
10
|
import TimerManager from './meta/modules/TimerManager.js';
|
|
10
11
|
import ParticipantManager from './meta/player/ParticipantManager.js';
|
|
@@ -12,10 +13,12 @@ import VoteCoordinator from './meta/core/VoteCoordinator.js';
|
|
|
12
13
|
import RoundManager from './meta/core/RoundManager.js';
|
|
13
14
|
import CommandProcessor from './meta/core/CommandProcessor.js';
|
|
14
15
|
import { sanitizeMessage } from '../lib/sanitizers.js';
|
|
16
|
+
import { parseFloorMessage } from '../lib/voiceMessages.js';
|
|
15
17
|
import closeCodes from '../config/closeCodes.js';
|
|
16
18
|
import clock from '../lib/clock.js';
|
|
17
19
|
import { createResumeKey } from '../lib/resumeKey.js';
|
|
18
20
|
import GameCoreAdapter from './GameCoreAdapter.js';
|
|
21
|
+
import { commandFailureReport } from './commandFailure.js';
|
|
19
22
|
import DebugRecorder from './DebugRecorder.js';
|
|
20
23
|
import enginePkg from '../../package.json' with { type: 'json' };
|
|
21
24
|
|
|
@@ -249,10 +252,24 @@ export default class HostGame {
|
|
|
249
252
|
|
|
250
253
|
this._socketManager = socketManager;
|
|
251
254
|
|
|
255
|
+
// голосовая связь: эфир решается здесь, аудио сюда не
|
|
256
|
+
// попадает — его раздаёт ретранслятор транспортного уровня по маршрутам
|
|
257
|
+
// модуля. Эфир не выдаётся, пока матч стоит: заморозка плановой передачи,
|
|
258
|
+
// пауза эстафеты Worker'ов, ожидание старта из контрольной точки, закрытие
|
|
259
|
+
this._voice = new Voice(data.voice, {
|
|
260
|
+
participants: this._participants,
|
|
261
|
+
socketManager: this._socketManager,
|
|
262
|
+
isAvailable: () =>
|
|
263
|
+
!this._frozen &&
|
|
264
|
+
!this._handoffPaused &&
|
|
265
|
+
!this._restorePending &&
|
|
266
|
+
!this._isDestroying,
|
|
267
|
+
});
|
|
268
|
+
|
|
252
269
|
this._networkSendRate = data.timers.networkSendRate;
|
|
253
270
|
this._snapshotManager = new SnapshotThrottle(this._networkSendRate);
|
|
254
271
|
|
|
255
|
-
// рекордер живого матча
|
|
272
|
+
// рекордер живого матча — только в dev-режиме;
|
|
256
273
|
// в проде null, и все точки записи ниже вырождаются в ?.
|
|
257
274
|
this._recorder = this._isDevMode ? new DebugRecorder() : null;
|
|
258
275
|
|
|
@@ -317,19 +334,22 @@ export default class HostGame {
|
|
|
317
334
|
currentMap: data.currentMap,
|
|
318
335
|
});
|
|
319
336
|
|
|
320
|
-
this._commandProcessor = new CommandProcessor(
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
337
|
+
this._commandProcessor = new CommandProcessor(
|
|
338
|
+
{
|
|
339
|
+
participants: this._participants,
|
|
340
|
+
chat: this._chat,
|
|
341
|
+
scripted: this._scripted,
|
|
342
|
+
roundManager: this._roundManager,
|
|
343
|
+
voteCoordinator: this._voteCoordinator,
|
|
344
|
+
timerManager: this._timerManager,
|
|
345
|
+
playerDataSync: this._playerDataSync,
|
|
346
|
+
teams: this._teams,
|
|
347
|
+
spectatorTeam: this._spectatorTeam,
|
|
348
|
+
spectatorId: this._spectatorId,
|
|
349
|
+
isDevMode: this._isDevMode,
|
|
350
|
+
},
|
|
351
|
+
{ onError: (command, error) => this._reportCommandError(command, error) },
|
|
352
|
+
);
|
|
333
353
|
|
|
334
354
|
// игровые чат-команды и коды системных сообщений из HostPlugin
|
|
335
355
|
for (const command of hostPlugin.chatCommands) {
|
|
@@ -349,6 +369,7 @@ export default class HostGame {
|
|
|
349
369
|
this._checkpointSink = null; // ({ meta, core, final }) => void
|
|
350
370
|
// здоровье хоста (host-migration этап 9a): раз в окно цикла
|
|
351
371
|
this._healthSink = null; // (health) => void
|
|
372
|
+
this._diagnosticSink = null; // ({ kind, message, stack }) => void
|
|
352
373
|
this._checkpointIntervalMs = 0; // 0 — периодических точек нет
|
|
353
374
|
this._lastCheckpointAt = 0;
|
|
354
375
|
this._checkpointRequest = null; // { final } — точка на ближайшем кадре
|
|
@@ -358,6 +379,9 @@ export default class HostGame {
|
|
|
358
379
|
this._restoreMode = null; // 'midRound' | 'soft'
|
|
359
380
|
this._restoredTimers = null;
|
|
360
381
|
this._frozen = false;
|
|
382
|
+
// эстафета Worker'ов прошла границу раунда: игра стоит до terminate()
|
|
383
|
+
// этого Worker'а или resumeAfterHandoff()
|
|
384
|
+
this._handoffPaused = false;
|
|
361
385
|
// точка снята на замороженном хосте: pack_body осушил тело кадра вне
|
|
362
386
|
// цикла, и его события (удаления, взрывы) никому не ушли
|
|
363
387
|
this._drainedWhileFrozen = false;
|
|
@@ -603,6 +627,11 @@ export default class HostGame {
|
|
|
603
627
|
}
|
|
604
628
|
});
|
|
605
629
|
|
|
630
|
+
// эфиры: таймауты, отзыв, слушатели — с частотой снапшотов; после цикла
|
|
631
|
+
// рассылки, потому что getCamera в нём исправляет watchedGameId
|
|
632
|
+
// наблюдателей
|
|
633
|
+
this._voice.tick();
|
|
634
|
+
|
|
606
635
|
// граница кадра: pack_body опустошил накопители снапшота — единственный
|
|
607
636
|
// момент, когда дамп ядра корректен
|
|
608
637
|
this._maybeCheckpoint();
|
|
@@ -738,7 +767,7 @@ export default class HostGame {
|
|
|
738
767
|
this._mapList.push(...Object.keys(this._maps));
|
|
739
768
|
}
|
|
740
769
|
|
|
741
|
-
// ***** отладочный контур
|
|
770
|
+
// ***** отладочный контур ***** //
|
|
742
771
|
// Всё ниже живёт под флагом isDevMode: в проде рекордера нет, а дамп
|
|
743
772
|
// отдаёт только то, что и так знает мета.
|
|
744
773
|
|
|
@@ -810,6 +839,7 @@ export default class HostGame {
|
|
|
810
839
|
})),
|
|
811
840
|
activeList: this._participants.getActiveList(),
|
|
812
841
|
},
|
|
842
|
+
voice: this._voice.debugState(),
|
|
813
843
|
core: this._game.debugJson(),
|
|
814
844
|
};
|
|
815
845
|
}
|
|
@@ -835,6 +865,8 @@ export default class HostGame {
|
|
|
835
865
|
*/
|
|
836
866
|
async destroy() {
|
|
837
867
|
this._isDestroying = true;
|
|
868
|
+
// комната закрывается, сообщать некому
|
|
869
|
+
this._voice.reset({ notify: false });
|
|
838
870
|
|
|
839
871
|
if (this._resumeWait) {
|
|
840
872
|
clock.clearTimeout(this._resumeWait.timer);
|
|
@@ -877,6 +909,11 @@ export default class HostGame {
|
|
|
877
909
|
this._roundManager.requestHandoff(() => {
|
|
878
910
|
this._timerManager.stopGameTimers();
|
|
879
911
|
this._timerManager.stopIdleCheckTimer();
|
|
912
|
+
// без тиков эфир не закончился бы сам, а новый Worker начинает без
|
|
913
|
+
// эфиров: говорящие получают END, слушатели — STOP; до terminate()
|
|
914
|
+
// или resumeAfterHandoff() запрос получает UNAVAILABLE
|
|
915
|
+
this._handoffPaused = true;
|
|
916
|
+
this._voice.reset();
|
|
880
917
|
this._flushBeforeHandoff().then(() => cb(this._collectHandoff()));
|
|
881
918
|
});
|
|
882
919
|
}
|
|
@@ -923,6 +960,7 @@ export default class HostGame {
|
|
|
923
960
|
// отказ от эстафеты (новый Worker не поднялся): вернуть таймеры и
|
|
924
961
|
// продолжить жить на старой версии кода
|
|
925
962
|
resumeAfterHandoff() {
|
|
963
|
+
this._handoffPaused = false;
|
|
926
964
|
this._roundManager.cancelHandoff();
|
|
927
965
|
this._timerManager.startIdleCheckTimer();
|
|
928
966
|
this._timerManager.resumeGameTimers(this._timerManager.getMapTimeLeft());
|
|
@@ -1042,6 +1080,21 @@ export default class HostGame {
|
|
|
1042
1080
|
|
|
1043
1081
|
// ***** здоровье хоста (host-migration этап 9a) ***** //
|
|
1044
1082
|
|
|
1083
|
+
/**
|
|
1084
|
+
* Приёмник сбоев кода игры, пойманных хостом: { kind, message, stack }.
|
|
1085
|
+
* @param {Function|null} sink
|
|
1086
|
+
*/
|
|
1087
|
+
setDiagnosticSink(sink) {
|
|
1088
|
+
this._diagnosticSink = typeof sink === 'function' ? sink : null;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// сбой чат-команды игры: отчёт собирает commandFailure.js (одна строка
|
|
1092
|
+
// сообщения, стек без текста ошибки); бросок приёмника гасит
|
|
1093
|
+
// CommandProcessor._fail
|
|
1094
|
+
_reportCommandError(command, error) {
|
|
1095
|
+
this._diagnosticSink?.(commandFailureReport(command, error));
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1045
1098
|
/**
|
|
1046
1099
|
* Приёмник метрик здоровья: раз в окно игрового цикла (~1 с) —
|
|
1047
1100
|
* { tickRate, maxGapMs, lostMs, windowMs, peerRttMedian, peerCount }.
|
|
@@ -1052,6 +1105,15 @@ export default class HostGame {
|
|
|
1052
1105
|
this._healthSink = typeof sink === 'function' ? sink : null;
|
|
1053
1106
|
}
|
|
1054
1107
|
|
|
1108
|
+
/**
|
|
1109
|
+
* Приёмник маршрутов голосовых эфиров для ретранслятора:
|
|
1110
|
+
* ({ speakerSocketId, route }) => void, route === null — маршрут снят.
|
|
1111
|
+
* @param {Function|null} sink
|
|
1112
|
+
*/
|
|
1113
|
+
setVoiceRouteSink(sink) {
|
|
1114
|
+
this._voice.setRouteSink(sink);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1055
1117
|
// RTT — только удалённые люди: хост-игрок на loopback связь хоста не мерит
|
|
1056
1118
|
_onLoopStats(stats) {
|
|
1057
1119
|
if (!this._healthSink) {
|
|
@@ -1523,6 +1585,9 @@ export default class HostGame {
|
|
|
1523
1585
|
|
|
1524
1586
|
this._frozen = true;
|
|
1525
1587
|
this._timerManager.pause();
|
|
1588
|
+
// без тиков эфир не закончился бы сам: говорящие получают END,
|
|
1589
|
+
// слушатели — STOP
|
|
1590
|
+
this._voice.reset();
|
|
1526
1591
|
}
|
|
1527
1592
|
|
|
1528
1593
|
// разморозка (передача сорвалась): отсчёты продолжаются с остатков.
|
|
@@ -1652,6 +1717,7 @@ export default class HostGame {
|
|
|
1652
1717
|
this._stat.removeUser(gameId, teamId);
|
|
1653
1718
|
this._chat.removeUser(gameId);
|
|
1654
1719
|
this._vote.removeUser(gameId);
|
|
1720
|
+
this._voice.removeUser(gameId);
|
|
1655
1721
|
this._panel.removeUser(gameId);
|
|
1656
1722
|
|
|
1657
1723
|
// финальная синхронизация профиля перед уходом участника (Этап B4):
|
|
@@ -1682,6 +1748,18 @@ export default class HostGame {
|
|
|
1682
1748
|
}
|
|
1683
1749
|
}
|
|
1684
1750
|
|
|
1751
|
+
// управление эфиром: [1] — запрос, [0, talkId] — отпускание. В рекордер не
|
|
1752
|
+
// пишется: на симуляцию не влияет
|
|
1753
|
+
voiceFloor(gameId, data) {
|
|
1754
|
+
const message = parseFloorMessage(data);
|
|
1755
|
+
|
|
1756
|
+
if (message?.op === 'request') {
|
|
1757
|
+
this._voice.request(gameId);
|
|
1758
|
+
} else if (message?.op === 'release') {
|
|
1759
|
+
this._voice.release(gameId, message.talkId);
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1685
1763
|
// обновляет команды (формат wire: 'seq:action:name', указатель —
|
|
1686
1764
|
// 'seq:aim:x:y:flags')
|
|
1687
1765
|
updateKeys(gameId, keyStr) {
|
package/src/host/PortMachine.js
CHANGED
|
@@ -25,8 +25,10 @@ const PC_PONG = wsports.client.PONG;
|
|
|
25
25
|
const PC_RESUME_REQUEST = wsports.client.RESUME_REQUEST;
|
|
26
26
|
const PC_LEAVE = wsports.client.LEAVE;
|
|
27
27
|
const PC_TOKEN_REFRESH = wsports.client.TOKEN_REFRESH;
|
|
28
|
+
const PC_VOICE_FLOOR = wsports.client.VOICE_FLOOR;
|
|
28
29
|
|
|
29
|
-
|
|
30
|
+
// VOICE_AUDIO (13) бинарный и порт-машиной не разбирается
|
|
31
|
+
const PORT_COUNT = 13;
|
|
30
32
|
|
|
31
33
|
// версии формата RESUME_REQUEST, которые понимает хост
|
|
32
34
|
const RESUME_VERSION = 1;
|
|
@@ -41,6 +43,7 @@ const IN_GAME_PORTS = [
|
|
|
41
43
|
PC_PONG,
|
|
42
44
|
PC_LEAVE,
|
|
43
45
|
PC_TOKEN_REFRESH,
|
|
46
|
+
PC_VOICE_FLOOR,
|
|
44
47
|
];
|
|
45
48
|
|
|
46
49
|
export default class PortMachine {
|
|
@@ -244,8 +247,18 @@ export default class PortMachine {
|
|
|
244
247
|
return;
|
|
245
248
|
}
|
|
246
249
|
|
|
247
|
-
|
|
248
|
-
|
|
250
|
+
const port = Array.isArray(msg) ? msg[0] : null;
|
|
251
|
+
|
|
252
|
+
// номер порта — только целое из диапазона: строковый ключ вроде
|
|
253
|
+
// '__proto__' или 'constructor' прошёл бы проверку `enabled[...]` по
|
|
254
|
+
// прототипу массива и вызвал бы не обработчик
|
|
255
|
+
if (
|
|
256
|
+
Number.isInteger(port) &&
|
|
257
|
+
port >= 0 &&
|
|
258
|
+
port < PORT_COUNT &&
|
|
259
|
+
state.enabled[port]
|
|
260
|
+
) {
|
|
261
|
+
state.methods[port](msg[1]);
|
|
249
262
|
}
|
|
250
263
|
}
|
|
251
264
|
|
|
@@ -603,6 +616,9 @@ export default class PortMachine {
|
|
|
603
616
|
|
|
604
617
|
// 11: token refresh
|
|
605
618
|
data => this._refreshToken(socketId, state, data),
|
|
619
|
+
|
|
620
|
+
// 12: управление эфиром ([1] / [0, talkId]); разбор — HostGame.voiceFloor
|
|
621
|
+
data => host.voiceFloor(state.gameId, data),
|
|
606
622
|
];
|
|
607
623
|
}
|
|
608
624
|
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Отчёт о сбое чат-команды игры для приёмника диагностики
|
|
2
|
+
// (HostGame.setDiagnosticSink): сообщение в одну строку и стек без текста
|
|
3
|
+
// ошибки. Один отчёт на все среды — Worker (журнал платформы), dedicated
|
|
4
|
+
// (лог процесса), standalone (консоль вкладки). Чистые функции, без DOM и
|
|
5
|
+
// Node API: модуль работает в Worker'е
|
|
6
|
+
|
|
7
|
+
// символы, подделывающие строки лога: управляющие (перевод строки, ESC),
|
|
8
|
+
// невидимые форматные (bidi-переопределения, нулевая ширина, BOM), разделители
|
|
9
|
+
// строк и абзацев (U+2028/2029)
|
|
10
|
+
const LOG_UNSAFE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\u180E]/gu;
|
|
11
|
+
const MAX_STACK = 2000;
|
|
12
|
+
|
|
13
|
+
// обрезка до limit единиц UTF-16 без разрыва суррогатной пары; выполняется
|
|
14
|
+
// до очистки, чтобы огромная строка не проходила regex целиком
|
|
15
|
+
const clip = (value, limit) => {
|
|
16
|
+
if (value.length <= limit) {
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const text = value.slice(0, limit);
|
|
21
|
+
|
|
22
|
+
return /[\uD800-\uDBFF]$/.test(text) ? text.slice(0, -1) : text;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// одна строка лога: текст от игрока или из кода игры без LOG_UNSAFE
|
|
26
|
+
const oneLine = (value, limit) =>
|
|
27
|
+
clip(String(value), limit).replace(LOG_UNSAFE, ' ');
|
|
28
|
+
|
|
29
|
+
// стек сохраняет переводы строк, остальное LOG_UNSAFE заменяется; длина
|
|
30
|
+
// ограничена
|
|
31
|
+
const cleanStack = stack =>
|
|
32
|
+
clip(stack, MAX_STACK)
|
|
33
|
+
.split(/\r\n|\r|\n/)
|
|
34
|
+
.map(line => line.replace(LOG_UNSAFE, ' '))
|
|
35
|
+
.join('\n');
|
|
36
|
+
|
|
37
|
+
// кадры стека (V8: « at …»). Заголовок «имя: текст» снимается по числу
|
|
38
|
+
// строк текста — ввод игрока в тексте не отличить от кадра по виду, поиск
|
|
39
|
+
// «at» его бы не отсёк. Не сошлось (текст изменён после создания, цепочка
|
|
40
|
+
// cause, другой движок) — остаётся хвост из подряд идущих кадров; ни кадров
|
|
41
|
+
// V8, ни кадров SpiderMonkey/JSC — стек сводится к одной строке, чтобы текст
|
|
42
|
+
// из него не подделал строки лога
|
|
43
|
+
const V8_FRAME = /^ {4}at /;
|
|
44
|
+
|
|
45
|
+
// кадр SpiderMonkey/JavaScriptCore: «имя@место», текста ошибки в таком стеке
|
|
46
|
+
// нет. Имя — без пробелов, кроме служебных имён JSC (global/eval/module code,
|
|
47
|
+
// «async имя», у анонимной функции — «async »): заголовок V8 «имя: текст» за
|
|
48
|
+
// кадр не сойдёт. Место — URL со схемой, [native code] или кадр Wasm у JSC:
|
|
49
|
+
// wasm-function[N] с именем модуля или без, у WebKit до 2025-08 — [wasm code]
|
|
50
|
+
// (имя тогда «модуль.wasm-function[N]»). Голый [native code] — тоже кадр
|
|
51
|
+
const FRAME_NAME = [
|
|
52
|
+
String.raw`[^\s@]*`,
|
|
53
|
+
'(?:global|eval|module) code',
|
|
54
|
+
String.raw`async [^\s@]*`,
|
|
55
|
+
].join('|');
|
|
56
|
+
const FRAME_PLACE = [
|
|
57
|
+
String.raw`[a-z][a-z0-9+.-]*:\S+`,
|
|
58
|
+
String.raw`\[native code\]`,
|
|
59
|
+
String.raw`\[wasm code\]`,
|
|
60
|
+
String.raw`(?:[^\s@:[\]]+:)?wasm-function\[\d+\]`,
|
|
61
|
+
].join('|');
|
|
62
|
+
const AT_FRAME = new RegExp(
|
|
63
|
+
String.raw`^(?:(?:${FRAME_NAME})@(?:${FRAME_PLACE})|\[native code\])$`,
|
|
64
|
+
'i',
|
|
65
|
+
);
|
|
66
|
+
const STACK_SCAN = 10 * MAX_STACK;
|
|
67
|
+
|
|
68
|
+
const stackFrames = (stack, message) => {
|
|
69
|
+
const lines = clip(stack, STACK_SCAN).split('\n');
|
|
70
|
+
|
|
71
|
+
// обрезка по STACK_SCAN рвёт последнюю строку: неполный кадр не
|
|
72
|
+
// распознала бы ни одна ветка, и весь стек свернулся бы в строку
|
|
73
|
+
if (stack.length > STACK_SCAN && lines.length > 1) {
|
|
74
|
+
lines.pop();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// SpiderMonkey/JavaScriptCore: стек — одни кадры, без заголовка; Firefox
|
|
78
|
+
// заканчивает его переводом строки
|
|
79
|
+
let end = lines.length;
|
|
80
|
+
|
|
81
|
+
while (end > 0 && lines[end - 1] === '') {
|
|
82
|
+
end -= 1;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const body = lines.slice(0, end);
|
|
86
|
+
|
|
87
|
+
if (body.length > 0 && body.every(line => AT_FRAME.test(line))) {
|
|
88
|
+
return body.join('\n');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const rest = lines.slice(message.split('\n').length);
|
|
92
|
+
|
|
93
|
+
if (rest.length > 0 && rest.every(line => V8_FRAME.test(line))) {
|
|
94
|
+
return rest.join('\n');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let start = lines.length;
|
|
98
|
+
|
|
99
|
+
while (start > 0 && V8_FRAME.test(lines[start - 1])) {
|
|
100
|
+
start -= 1;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return start < lines.length
|
|
104
|
+
? lines.slice(start).join('\n')
|
|
105
|
+
: lines.join(' | ');
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// сбой чат-команды → { kind, message, stack }. Не бросает: недоступный
|
|
109
|
+
// message/toString — текст «[unprintable error]», недоступный стек — null.
|
|
110
|
+
// Имя команды приходит из ввода игрока, текст — из кода игры, который вправе
|
|
111
|
+
// вставить в него аргументы игрока: оба приводятся к одной строке и
|
|
112
|
+
// обрезаются. V8 повторяет текст ошибки первой строкой стека — она
|
|
113
|
+
// снимается, остаются кадры
|
|
114
|
+
export function commandFailureReport(command, error) {
|
|
115
|
+
let text;
|
|
116
|
+
let stack = null;
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
// message читается один раз: геттер мог бы вернуть разное
|
|
120
|
+
const message = error && error.message ? String(error.message) : '';
|
|
121
|
+
|
|
122
|
+
text = message || String(error);
|
|
123
|
+
|
|
124
|
+
if (error && typeof error.stack === 'string') {
|
|
125
|
+
stack = stackFrames(error.stack, message);
|
|
126
|
+
stack = cleanStack(stack) || null;
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
text ??= '[unprintable error]';
|
|
130
|
+
stack = null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const commandText = oneLine(command, 32);
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
kind: 'error',
|
|
137
|
+
message: `chat command ${commandText} failed: ${oneLine(text, 400)}`,
|
|
138
|
+
stack,
|
|
139
|
+
};
|
|
140
|
+
}
|
package/src/host/host.worker.js
CHANGED
|
@@ -135,6 +135,8 @@ async function onInit(room, handoff = null, checkpointBytes = null, seqFloor) {
|
|
|
135
135
|
// общая с headless-runner'ом сборка (lib/createHostRuntime.js) — чтобы
|
|
136
136
|
// отладочный прогон крутил ровно тот код, что и прод
|
|
137
137
|
const runtime = await createHostRuntime(room, {
|
|
138
|
+
// ретранслятор голоса — в главном потоке (HostController)
|
|
139
|
+
voiceRelay: true,
|
|
138
140
|
hostOptions: {
|
|
139
141
|
// map_changed остаётся для главного потока до host-migration этапа 2
|
|
140
142
|
onMapChange: mapName =>
|
|
@@ -151,6 +153,17 @@ async function onInit(room, handoff = null, checkpointBytes = null, seqFloor) {
|
|
|
151
153
|
// здоровье хоста (host-migration этап 9a): главный поток решает, не пора
|
|
152
154
|
// ли отдать роль
|
|
153
155
|
host.setHealthSink(health => self.postMessage({ type: 'health', health }));
|
|
156
|
+
// голосовая связь: маршруты эфиров — ретранслятору главного потока
|
|
157
|
+
// (VoiceRelay). Сам голос Worker не видит никогда
|
|
158
|
+
host.setVoiceRouteSink(({ speakerSocketId, route }) =>
|
|
159
|
+
self.postMessage({ type: 'voice_route', speakerSocketId, route }),
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
// сбой кода игры, пойманный хостом (чат-команда): в журнал клиентских
|
|
163
|
+
// ошибок тем же сообщением, что и необработанный reject
|
|
164
|
+
host.setDiagnosticSink(report =>
|
|
165
|
+
self.postMessage({ type: 'diagnostic', ...report }),
|
|
166
|
+
);
|
|
154
167
|
|
|
155
168
|
// в лобби личность игрока — claim identity-токена, проверенного по JWKS
|
|
156
169
|
// мастера (Этап B3); свободного ввода имени в форме игры нет
|
|
@@ -238,7 +251,7 @@ function onDebug({ requestId, action }) {
|
|
|
238
251
|
self.postMessage({ type: 'debug_result', requestId, result, error });
|
|
239
252
|
}
|
|
240
253
|
|
|
241
|
-
// журнал клиентских
|
|
254
|
+
// журнал клиентских ошибок: необработанный reject в
|
|
242
255
|
// Worker не всплывает в worker.onerror главного потока — пересылаем сами.
|
|
243
256
|
// Отдельный тип сообщения: 'error' занят сбоем init и запускает откат эстафеты
|
|
244
257
|
self.addEventListener('unhandledrejection', event => {
|
|
@@ -325,7 +338,7 @@ self.onmessage = async event => {
|
|
|
325
338
|
host?.setRoom({ roomId: msg.hostId, roomSecret: msg.hostSecret });
|
|
326
339
|
break;
|
|
327
340
|
|
|
328
|
-
// отладочный
|
|
341
|
+
// отладочный контур: единственный вход в
|
|
329
342
|
// авторитетную половину из главного потока — запрос/ответ по requestId
|
|
330
343
|
case 'debug':
|
|
331
344
|
onDebug(msg);
|