vimp-engine 0.38.3 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/bin/vimp-sim.js +2 -2
- package/core/Cargo.toml +2 -2
- package/package.json +3 -4
- package/src/client/InputListener.js +3 -0
- package/src/client/SoundManager.js +13 -0
- package/src/client/boot.js +11 -87
- package/src/client/components/controller/Controls.js +10 -0
- package/src/client/components/controller/Voice.js +31 -0
- package/src/client/components/model/Controls.js +64 -0
- package/src/client/components/model/RoomMenu.js +1 -1
- package/src/client/components/model/Voice.js +96 -0
- package/src/client/components/model/Vote.js +2 -1
- package/src/client/components/view/Auth.js +3 -3
- package/src/client/components/view/Voice.js +172 -0
- package/src/client/lib/formBuilder.js +7 -6
- package/src/client/lib/hostVoteCommand.js +2 -2
- package/src/client/lib/pickActiveGame.js +3 -12
- package/src/client/lib/retryOnNetworkError.js +0 -9
- package/src/client/lib/voiceState.js +78 -0
- package/src/client/main.js +160 -90
- package/src/client/network/HostConnectionManager.js +46 -5
- package/src/client/network/HostController.js +43 -0
- package/src/client/network/LoopbackTransport.js +14 -0
- package/src/client/network/SessionSupervisor.js +38 -14
- package/src/client/network/WebRtcManager.js +46 -5
- package/src/client/network/policyClose.js +10 -77
- package/src/client/style.css +150 -9
- package/src/client/views/gameShell.js +2 -4
- package/src/client/views/includes/auth.pug +1 -4
- package/src/client/views/includes/lobby.pug +3 -6
- 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/clientServices.js +2 -0
- package/src/config/closeCodes.js +7 -9
- package/src/config/gameCodes.js +1 -1
- package/src/config/hostDefaults.js +2 -2
- package/src/config/lobby.js +3 -4
- 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 +13 -1
- package/src/devtools/ScenarioRunner.js +1 -1
- package/src/devtools/builtinScenario.js +2 -2
- package/src/devtools/contract/rules/a2-package-scripts.js +1 -1
- package/src/devtools/contract/rules/a4-vite-config.js +1 -1
- 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/c4-component-dependencies.js +1 -1
- package/src/devtools/contract/rules/index.js +4 -0
- package/src/devtools/invariants.js +1 -1
- package/src/devtools/pluginLoader.js +2 -2
- package/src/host/HostGame.js +62 -4
- package/src/host/PortMachine.js +23 -24
- package/src/host/commandFailure.js +2 -2
- package/src/host/host.worker.js +7 -0
- package/src/host/identity.js +3 -3
- package/src/host/meta/SocketManager.js +6 -0
- package/src/host/meta/core/CommandProcessor.js +2 -2
- package/src/host/meta/modules/Accolades.js +1 -1
- 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/buildClientConfig.js +7 -0
- package/src/lib/capabilities.js +4 -0
- package/src/lib/createHostRuntime.js +8 -0
- package/src/lib/gameConfigView.js +11 -1
- package/src/lib/loadGamePackage.js +7 -9
- package/src/lib/offlinePlayerData.js +1 -1
- package/src/lib/validators.js +2 -2
- 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 +3 -2
- package/src/client/lib/footerLink.js +0 -30
- package/src/client/network/WebSocketTransport.js +0 -78
package/src/lib/capabilities.js
CHANGED
|
@@ -51,6 +51,10 @@ export const ENGINE_CAPABILITIES = createRegistry('engine-capabilities', [
|
|
|
51
51
|
// Писать в `requires` не нужно: старый движок флаг просто игнорирует, и
|
|
52
52
|
// игра на нём мигрирует мягко
|
|
53
53
|
{ value: 'host.migration', since: '0.36.0' },
|
|
54
|
+
// голосовая связь (gameConfig.voice, порты VOICE_*, сервис пула `voice`,
|
|
55
|
+
// хуки `hooks.voiceSounds`/`hooks.voiceFilter`); опциональна — игра не
|
|
56
|
+
// пишет её в `requires`
|
|
57
|
+
{ value: 'voice', since: '0.39.0' },
|
|
54
58
|
]);
|
|
55
59
|
|
|
56
60
|
// плоский список имён — то, чем правило контракта B2 проверяет `requires`
|
|
@@ -29,6 +29,9 @@ import HostGame from '../host/HostGame.js';
|
|
|
29
29
|
* @param {Function} [options.overrideGameConfig] - Правка собранного конфига
|
|
30
30
|
* игры перед созданием ядра; в проде не задаётся, нужна отладочному
|
|
31
31
|
* прогону (сценарий подкручивает networkSendRate и таймеры).
|
|
32
|
+
* @param {boolean} [options.voiceRelay] - У контура есть ретранслятор голоса
|
|
33
|
+
* на транспортном уровне (браузерный хост лобби); без него связь
|
|
34
|
+
* выключается, даже если игра её объявила (standalone, headless).
|
|
32
35
|
* @returns {Promise<Object>} { hostPlugin, game, seed, core, clientCfg,
|
|
33
36
|
* socketManager, host }.
|
|
34
37
|
*/
|
|
@@ -38,6 +41,7 @@ export async function createHostRuntime(room, options = {}) {
|
|
|
38
41
|
createSocketManager = defaultCreateSocketManager,
|
|
39
42
|
hostOptions = {},
|
|
40
43
|
overrideGameConfig = null,
|
|
44
|
+
voiceRelay = false,
|
|
41
45
|
} = options;
|
|
42
46
|
|
|
43
47
|
const hostPlugin = await loadHostPlugin(room);
|
|
@@ -52,6 +56,10 @@ export async function createHostRuntime(room, options = {}) {
|
|
|
52
56
|
overrideGameConfig(game);
|
|
53
57
|
}
|
|
54
58
|
|
|
59
|
+
if (!voiceRelay && game.voice?.enabled) {
|
|
60
|
+
game.voice = Object.freeze({ ...game.voice, enabled: false });
|
|
61
|
+
}
|
|
62
|
+
|
|
55
63
|
// seed уезжает наружу вызывающему: без него прогон матча невоспроизводим
|
|
56
64
|
// (реплей задаёт room.seed и получает тот же мир)
|
|
57
65
|
const seed =
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import hostDefaults from '../config/hostDefaults.js';
|
|
2
|
+
import { VOICE_CONFIG_PATHS } from '../config/voiceDefaults.js';
|
|
3
|
+
import { normalizeVoiceConfig } from './voiceConfig.js';
|
|
2
4
|
|
|
3
5
|
// Единственная точка чтения HostPlugin.gameConfig движком (этап 2 плана
|
|
4
6
|
// plugin-forward-compat). До неё движок разыменовывал конфиг игры россыпью
|
|
@@ -77,7 +79,11 @@ const FIELDS = {
|
|
|
77
79
|
// те, за которые он подставляет умолчание. Раздел слепка поверхности
|
|
78
80
|
// (contract/surface.json → gameConfigFields): исчезнувший отсюда путь —
|
|
79
81
|
// нарушение И1, потому что игра могла его написать.
|
|
80
|
-
|
|
82
|
+
// Пути связи живут в config/voiceDefaults.js.
|
|
83
|
+
export const KNOWN_GAME_CONFIG_PATHS = [
|
|
84
|
+
...Object.keys(FIELDS),
|
|
85
|
+
...VOICE_CONFIG_PATHS,
|
|
86
|
+
];
|
|
81
87
|
|
|
82
88
|
// ЗАМОРОЖЕНО (И2). Этот список может только СОКРАЩАТЬСЯ.
|
|
83
89
|
// Добавление сюда отвергнет все ранее опубликованные игры — вместо этого
|
|
@@ -182,6 +188,10 @@ export function createGameConfigView(gameConfig, gameId = 'unknown') {
|
|
|
182
188
|
);
|
|
183
189
|
}
|
|
184
190
|
|
|
191
|
+
// блок связи нормализуется целиком (а не по путям FIELDS): неверное
|
|
192
|
+
// значение тоже должно получить дефолт, а не только отсутствующее
|
|
193
|
+
view.voice = normalizeVoiceConfig(source.voice, { gameId });
|
|
194
|
+
|
|
185
195
|
assertConsistent(view, source, gameId);
|
|
186
196
|
|
|
187
197
|
return Object.freeze(view);
|
|
@@ -6,13 +6,11 @@ import { ENGINE_CAPABILITIES } from './capabilities.js';
|
|
|
6
6
|
|
|
7
7
|
// Загрузка пакета игры в Node по его собранному dist/ (Этап 4 плана
|
|
8
8
|
// standalone-sdk). В браузере плагин грузится по URL из GameManifest мастера;
|
|
9
|
-
// в Node URL-ов нет —
|
|
10
|
-
//
|
|
11
|
-
// как обычные модули. Модуль живёт в lib/, а не в devtools/: прод-сервер не
|
|
12
|
-
// должен зависеть от отладочного контура (граница из CLAUDE.md).
|
|
9
|
+
// в Node URL-ов нет — headless-runner (devtools/pluginLoader.js) читает
|
|
10
|
+
// манифест с диска и импортирует половины плагина как обычные модули.
|
|
13
11
|
//
|
|
14
12
|
// entries.wasmNode — node-сборка ядра игры (конвенция core/pkg-node,
|
|
15
|
-
// docs.
|
|
13
|
+
// VIMP docs: packaging.md). Для боевой игры она обязательна: без ядра
|
|
16
14
|
// авторитетную симуляцию крутить нечем.
|
|
17
15
|
|
|
18
16
|
/**
|
|
@@ -33,7 +31,7 @@ export async function loadGamePackage(
|
|
|
33
31
|
{ core = null, client = 'lazy' } = {},
|
|
34
32
|
) {
|
|
35
33
|
// манифест под нестандартным именем нужен headless-прогону (--game
|
|
36
|
-
// <path>/manifest-variant.json)
|
|
34
|
+
// <path>/manifest-variant.json)
|
|
37
35
|
const manifestPath = distDir.endsWith('.json')
|
|
38
36
|
? path.resolve(distDir)
|
|
39
37
|
: path.join(path.resolve(distDir), 'manifest.json');
|
|
@@ -58,8 +56,8 @@ export async function loadGamePackage(
|
|
|
58
56
|
);
|
|
59
57
|
|
|
60
58
|
// Клиентская половина в Node нужна только виртуальным клиентам
|
|
61
|
-
// headless
|
|
62
|
-
//
|
|
59
|
+
// headless-прогона (в браузер она едет статикой, и pixi.js там
|
|
60
|
+
// резолвится import map'ом). Грузить её
|
|
63
61
|
// безусловно значило бы требовать от игры самодостаточной клиентской
|
|
64
62
|
// сборки — а правило контракта A1 держит pixi.js внешним намеренно.
|
|
65
63
|
//
|
|
@@ -160,7 +158,7 @@ function assertPluginMatchesManifest(manifest, plugins) {
|
|
|
160
158
|
// лобби честно отвергается, а в solo-режиме тихо недоигрывает.
|
|
161
159
|
//
|
|
162
160
|
// Здесь это ПРЕДУПРЕЖДЕНИЕ, а не отказ, и разница принципиальная. Node-путь
|
|
163
|
-
// (
|
|
161
|
+
// (vimp-sim, inline host) читает авторитетный манифест, и его
|
|
164
162
|
// совместимость уже проверена выше (checkPluginCompatibility): рассинхрон с
|
|
165
163
|
// половинами ничего здесь не ломает. Бросить значило бы отвергнуть пакет,
|
|
166
164
|
// который грузился раньше, — прямое нарушение И4 плана
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Профиль участника в контуре без мастера и auth-сервиса (headless-прогон,
|
|
2
|
-
// standalone SDK
|
|
2
|
+
// standalone SDK). Это не заглушка ради тишины: пустой
|
|
3
3
|
// профиль и есть корректное состояние такого матча, а настоящий fetch по
|
|
4
4
|
// относительному URL вне браузерной вкладки просто не разрешается.
|
|
5
5
|
|
package/src/lib/validators.js
CHANGED
|
@@ -24,8 +24,8 @@ export const isValidNick = nick =>
|
|
|
24
24
|
// потолок длины поля формы, когда maxlength в дескрипторе не объявлен.
|
|
25
25
|
// regExp игры исполняется на хосте против строки, которую прислал клиент, и
|
|
26
26
|
// катастрофический паттерн вроде "(a+)+b" превращает три десятка символов в
|
|
27
|
-
// минуты заблокированного event loop — а хост это
|
|
28
|
-
// матчем (замирает комната целиком)
|
|
27
|
+
// минуты заблокированного event loop — а хост это Worker с авторитетным
|
|
28
|
+
// матчем (замирает комната целиком). 256 — с запасом
|
|
29
29
|
// на любое поле auth-формы (ник, имя модели): длиннее не бывает у формы и не
|
|
30
30
|
// нужно тому, кто её обошёл
|
|
31
31
|
const MAX_FIELD_LENGTH = 256;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import {
|
|
2
|
+
VOICE_DEFAULTS,
|
|
3
|
+
VOICE_HOTKEY_RANGE,
|
|
4
|
+
VOICE_KEYS,
|
|
5
|
+
VOICE_RANGES,
|
|
6
|
+
VOICE_SCOPES,
|
|
7
|
+
VOICE_TEXT_KEYS,
|
|
8
|
+
VOICE_TEXT_MAX_LENGTH,
|
|
9
|
+
} from '../config/voiceDefaults.js';
|
|
10
|
+
|
|
11
|
+
const isPlainObject = value =>
|
|
12
|
+
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Разбирает блок gameConfig.voice. Конфиг не отвергается никогда: плохое
|
|
16
|
+
* значение заменяется дефолтом (числа вне диапазона — зажимом) и попадает в
|
|
17
|
+
* problems. Одна функция на два потребителя: рантайм предупреждает, правило
|
|
18
|
+
* контракта B11 отчитывается.
|
|
19
|
+
* @param {*} raw - Что написала игра (undefined/null — блока нет).
|
|
20
|
+
* @returns {{ config: Object, problems: string[] }} config — полный блок
|
|
21
|
+
* (все ключи, вложенный texts); problems — описания на английском.
|
|
22
|
+
*/
|
|
23
|
+
export function inspectVoiceConfig(raw) {
|
|
24
|
+
const problems = [];
|
|
25
|
+
const config = { ...VOICE_DEFAULTS, texts: { ...VOICE_DEFAULTS.texts } };
|
|
26
|
+
|
|
27
|
+
if (raw === undefined || raw === null) {
|
|
28
|
+
return { config, problems };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!isPlainObject(raw)) {
|
|
32
|
+
problems.push('voice must be a plain object');
|
|
33
|
+
|
|
34
|
+
return { config, problems };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
for (const key of Object.keys(raw)) {
|
|
38
|
+
if (!VOICE_KEYS.includes(key)) {
|
|
39
|
+
problems.push(`unknown key "voice.${key}"`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (raw.enabled !== undefined) {
|
|
44
|
+
if (typeof raw.enabled === 'boolean') {
|
|
45
|
+
config.enabled = raw.enabled;
|
|
46
|
+
} else {
|
|
47
|
+
problems.push('voice.enabled must be a boolean');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (raw.scope !== undefined) {
|
|
52
|
+
if (VOICE_SCOPES.includes(raw.scope)) {
|
|
53
|
+
config.scope = raw.scope;
|
|
54
|
+
} else {
|
|
55
|
+
problems.push(`voice.scope must be one of ${VOICE_SCOPES.join(', ')}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (raw.hotkey !== undefined) {
|
|
60
|
+
const [min, max] = VOICE_HOTKEY_RANGE;
|
|
61
|
+
|
|
62
|
+
if (
|
|
63
|
+
Number.isInteger(raw.hotkey) &&
|
|
64
|
+
raw.hotkey >= min &&
|
|
65
|
+
raw.hotkey <= max
|
|
66
|
+
) {
|
|
67
|
+
config.hotkey = raw.hotkey;
|
|
68
|
+
} else {
|
|
69
|
+
problems.push(`voice.hotkey must be an integer keyCode ${min}..${max}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (const [key, [min, max]] of Object.entries(VOICE_RANGES)) {
|
|
74
|
+
const value = raw[key];
|
|
75
|
+
|
|
76
|
+
if (value === undefined) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
81
|
+
problems.push(`voice.${key} must be a finite number`);
|
|
82
|
+
} else if (value < min || value > max) {
|
|
83
|
+
config[key] = Math.min(max, Math.max(min, value));
|
|
84
|
+
problems.push(`voice.${key} must be within ${min}..${max}, clamped`);
|
|
85
|
+
} else {
|
|
86
|
+
config[key] = value;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (raw.texts !== undefined) {
|
|
91
|
+
if (!isPlainObject(raw.texts)) {
|
|
92
|
+
problems.push('voice.texts must be a plain object');
|
|
93
|
+
} else {
|
|
94
|
+
for (const [key, value] of Object.entries(raw.texts)) {
|
|
95
|
+
if (!VOICE_TEXT_KEYS.includes(key)) {
|
|
96
|
+
problems.push(`unknown key "voice.texts.${key}"`);
|
|
97
|
+
} else if (
|
|
98
|
+
typeof value !== 'string' ||
|
|
99
|
+
value.length > VOICE_TEXT_MAX_LENGTH
|
|
100
|
+
) {
|
|
101
|
+
problems.push(
|
|
102
|
+
`voice.texts.${key} must be a string up to ` +
|
|
103
|
+
`${VOICE_TEXT_MAX_LENGTH} characters`,
|
|
104
|
+
);
|
|
105
|
+
} else {
|
|
106
|
+
config.texts[key] = value;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { config, problems };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Рантайм-обёртка: каждая проблема уходит в warn.
|
|
117
|
+
* @param {*} raw - gameConfig.voice как есть.
|
|
118
|
+
* @param {Object} [options]
|
|
119
|
+
* @param {string} [options.gameId]
|
|
120
|
+
* @param {Function} [options.warn]
|
|
121
|
+
* @returns {Object} Замороженный полный блок (texts тоже заморожен).
|
|
122
|
+
*/
|
|
123
|
+
export function normalizeVoiceConfig(
|
|
124
|
+
raw,
|
|
125
|
+
{ gameId = 'unknown', warn = console.warn } = {},
|
|
126
|
+
) {
|
|
127
|
+
const { config, problems } = inspectVoiceConfig(raw);
|
|
128
|
+
|
|
129
|
+
for (const problem of problems) {
|
|
130
|
+
warn(`game "${gameId}": voice: ${problem}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
config.texts = Object.freeze(config.texts);
|
|
134
|
+
|
|
135
|
+
return Object.freeze(config);
|
|
136
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import {
|
|
2
|
+
VOICE_DENY,
|
|
3
|
+
VOICE_END,
|
|
4
|
+
VOICE_FLOOR_OP,
|
|
5
|
+
VOICE_MAX_TALK_ID,
|
|
6
|
+
VOICE_MSG,
|
|
7
|
+
VOICE_NAME_MAX_LENGTH,
|
|
8
|
+
} from '../config/voiceProtocol.js';
|
|
9
|
+
import { VOICE_RANGES } from '../config/voiceDefaults.js';
|
|
10
|
+
import { isVoiceSpeakerId } from './voicePacket.js';
|
|
11
|
+
|
|
12
|
+
// Единственное место, где описана раскладка кортежей VOICE_DATA и
|
|
13
|
+
// VOICE_FLOOR: хост собирает ими, клиент и хост разбирают. Изоморфный.
|
|
14
|
+
|
|
15
|
+
const COLOR_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
16
|
+
const DENY_REASONS = new Set(Object.values(VOICE_DENY));
|
|
17
|
+
const END_REASONS = new Set(Object.values(VOICE_END));
|
|
18
|
+
|
|
19
|
+
const isTalkId = value =>
|
|
20
|
+
Number.isInteger(value) && value >= 1 && value <= VOICE_MAX_TALK_ID;
|
|
21
|
+
|
|
22
|
+
const clampMs = (value, max) => Math.min(max, Math.max(0, Math.round(value)));
|
|
23
|
+
|
|
24
|
+
// хост → клиент (собирает модуль Voice)
|
|
25
|
+
export const grantMessage = (talkId, maxDurationMs) => [
|
|
26
|
+
VOICE_MSG.GRANT,
|
|
27
|
+
talkId,
|
|
28
|
+
maxDurationMs,
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
export const denyMessage = (reason, retryAfterMs) => [
|
|
32
|
+
VOICE_MSG.DENY,
|
|
33
|
+
reason,
|
|
34
|
+
retryAfterMs,
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
export const endMessage = (talkId, reason) => [VOICE_MSG.END, talkId, reason];
|
|
38
|
+
|
|
39
|
+
export const startMessage = ({
|
|
40
|
+
speakerId,
|
|
41
|
+
talkId,
|
|
42
|
+
name,
|
|
43
|
+
teamId,
|
|
44
|
+
chatColor,
|
|
45
|
+
}) => [
|
|
46
|
+
VOICE_MSG.START,
|
|
47
|
+
speakerId,
|
|
48
|
+
talkId,
|
|
49
|
+
name,
|
|
50
|
+
teamId ?? null,
|
|
51
|
+
chatColor ?? null,
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
export const stopMessage = (speakerId, talkId) => [
|
|
55
|
+
VOICE_MSG.STOP,
|
|
56
|
+
speakerId,
|
|
57
|
+
talkId,
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
// клиент → хост (собирает VoiceClient)
|
|
61
|
+
export const floorRequestMessage = () => [VOICE_FLOOR_OP.REQUEST];
|
|
62
|
+
|
|
63
|
+
export const floorReleaseMessage = talkId => [VOICE_FLOOR_OP.RELEASE, talkId];
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Разбор VOICE_DATA на клиенте. Хост в P2P — браузер другого игрока:
|
|
67
|
+
* принимается только кортеж, прошедший все проверки.
|
|
68
|
+
* @param {*} data
|
|
69
|
+
* @returns {{ type: 'grant', talkId: number, maxDurationMs: number }
|
|
70
|
+
* | { type: 'deny', reason: number, retryAfterMs: number }
|
|
71
|
+
* | { type: 'end', talkId: number, reason: number }
|
|
72
|
+
* | { type: 'start', speakerId: number, talkId: number, name: string,
|
|
73
|
+
* teamId: number|null, color: string|null }
|
|
74
|
+
* | { type: 'stop', speakerId: number, talkId: number }|null}
|
|
75
|
+
*/
|
|
76
|
+
export function parseVoiceMessage(data) {
|
|
77
|
+
if (!Array.isArray(data)) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
switch (data[0]) {
|
|
82
|
+
case VOICE_MSG.GRANT: {
|
|
83
|
+
const [, talkId, maxDurationMs] = data;
|
|
84
|
+
|
|
85
|
+
if (!isTalkId(talkId) || !Number.isFinite(maxDurationMs)) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
type: 'grant',
|
|
91
|
+
talkId,
|
|
92
|
+
maxDurationMs: clampMs(maxDurationMs, VOICE_RANGES.maxDurationMs[1]),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
case VOICE_MSG.DENY: {
|
|
97
|
+
const [, reason, retryAfterMs] = data;
|
|
98
|
+
|
|
99
|
+
if (!DENY_REASONS.has(reason) || !Number.isFinite(retryAfterMs)) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
type: 'deny',
|
|
105
|
+
reason,
|
|
106
|
+
retryAfterMs: clampMs(retryAfterMs, VOICE_RANGES.cooldownMs[1]),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
case VOICE_MSG.END: {
|
|
111
|
+
const [, talkId, reason] = data;
|
|
112
|
+
|
|
113
|
+
if (!isTalkId(talkId) || !END_REASONS.has(reason)) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { type: 'end', talkId, reason };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
case VOICE_MSG.START: {
|
|
121
|
+
const [, speakerId, talkId, name, teamId, color] = data;
|
|
122
|
+
|
|
123
|
+
if (!isVoiceSpeakerId(speakerId) || !isTalkId(talkId)) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
type: 'start',
|
|
129
|
+
speakerId,
|
|
130
|
+
talkId,
|
|
131
|
+
name:
|
|
132
|
+
typeof name === 'string' ? name.slice(0, VOICE_NAME_MAX_LENGTH) : '',
|
|
133
|
+
teamId: Number.isInteger(teamId) ? teamId : null,
|
|
134
|
+
color: typeof color === 'string' && COLOR_RE.test(color) ? color : null,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
case VOICE_MSG.STOP: {
|
|
139
|
+
const [, speakerId, talkId] = data;
|
|
140
|
+
|
|
141
|
+
if (!isVoiceSpeakerId(speakerId) || !isTalkId(talkId)) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return { type: 'stop', speakerId, talkId };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
default:
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Разбор VOICE_FLOOR на хосте.
|
|
155
|
+
* @param {*} data
|
|
156
|
+
* @returns {{ op: 'request' } | { op: 'release', talkId: number }|null}
|
|
157
|
+
*/
|
|
158
|
+
export function parseFloorMessage(data) {
|
|
159
|
+
if (!Array.isArray(data)) {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (data[0] === VOICE_FLOOR_OP.REQUEST) {
|
|
164
|
+
return { op: 'request' };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (data[0] === VOICE_FLOOR_OP.RELEASE && isTalkId(data[1])) {
|
|
168
|
+
return { op: 'release', talkId: data[1] };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import wsports from '../config/wsports.js';
|
|
2
|
+
import {
|
|
3
|
+
VOICE_HEADER_BYTES,
|
|
4
|
+
VOICE_MAX_PACKET_BYTES,
|
|
5
|
+
VOICE_MAX_SPEAKER_ID,
|
|
6
|
+
VOICE_PACKET_VERSION,
|
|
7
|
+
} from '../config/voiceProtocol.js';
|
|
8
|
+
|
|
9
|
+
// Кодек голосового пакета (раскладка — config/voiceProtocol.js). Изоморфный:
|
|
10
|
+
// им пользуются клиент и ретранслятор вкладки хоста.
|
|
11
|
+
|
|
12
|
+
const isByte = value => Number.isInteger(value) && value >= 0 && value <= 0xff;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* id говорящего на проводе: целое 0..VOICE_MAX_SPEAKER_ID.
|
|
16
|
+
* @param {*} value
|
|
17
|
+
* @returns {boolean}
|
|
18
|
+
*/
|
|
19
|
+
export function isVoiceSpeakerId(value) {
|
|
20
|
+
return Number.isInteger(value) && value >= 0 && value <= VOICE_MAX_SPEAKER_ID;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Нормализует вход к Uint8Array без копии: ArrayBuffer, любой
|
|
25
|
+
* ArrayBufferView (в т.ч. Node Buffer — он и есть Uint8Array).
|
|
26
|
+
* @param {*} data
|
|
27
|
+
* @returns {Uint8Array|null} null — не бинарные данные.
|
|
28
|
+
*/
|
|
29
|
+
export function toBytes(data) {
|
|
30
|
+
if (data instanceof Uint8Array) {
|
|
31
|
+
return data;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (data instanceof ArrayBuffer) {
|
|
35
|
+
return new Uint8Array(data);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (ArrayBuffer.isView(data)) {
|
|
39
|
+
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Собирает пакет: новый Uint8Array(VOICE_HEADER_BYTES + payload.length).
|
|
47
|
+
* speakerId — 0 у клиента. seq обрезается до u16.
|
|
48
|
+
* @param {Object} packet
|
|
49
|
+
* @param {number} packet.port - Байт 0 (wsports.client/server.VOICE_AUDIO).
|
|
50
|
+
* @param {number} [packet.speakerId]
|
|
51
|
+
* @param {number} packet.talkId
|
|
52
|
+
* @param {number} packet.seq
|
|
53
|
+
* @param {Uint8Array} packet.payload - Opus-пакет.
|
|
54
|
+
* @returns {Uint8Array}
|
|
55
|
+
* @throws {RangeError} port/speakerId/talkId не целые 0..255.
|
|
56
|
+
*/
|
|
57
|
+
export function encodeVoicePacket({
|
|
58
|
+
port,
|
|
59
|
+
speakerId = 0,
|
|
60
|
+
talkId,
|
|
61
|
+
seq,
|
|
62
|
+
payload,
|
|
63
|
+
}) {
|
|
64
|
+
if (!isByte(port) || !isByte(speakerId) || !isByte(talkId)) {
|
|
65
|
+
throw new RangeError('voice packet: port, speakerId, talkId must be u8');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const bytes = new Uint8Array(VOICE_HEADER_BYTES + payload.length);
|
|
69
|
+
|
|
70
|
+
bytes[0] = port;
|
|
71
|
+
bytes[1] = VOICE_PACKET_VERSION;
|
|
72
|
+
bytes[2] = speakerId;
|
|
73
|
+
bytes[3] = talkId;
|
|
74
|
+
|
|
75
|
+
new DataView(bytes.buffer).setUint16(4, seq & 0xffff);
|
|
76
|
+
bytes.set(payload, VOICE_HEADER_BYTES);
|
|
77
|
+
|
|
78
|
+
return bytes;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Читает и проверяет заголовок: длина в [VOICE_HEADER_BYTES + 1,
|
|
83
|
+
* VOICE_MAX_PACKET_BYTES], версия === VOICE_PACKET_VERSION.
|
|
84
|
+
* @param {Uint8Array} bytes
|
|
85
|
+
* @returns {{ port: number, version: number, speakerId: number,
|
|
86
|
+
* talkId: number, seq: number }|null}
|
|
87
|
+
*/
|
|
88
|
+
export function readVoiceHeader(bytes) {
|
|
89
|
+
if (
|
|
90
|
+
!bytes ||
|
|
91
|
+
bytes.length <= VOICE_HEADER_BYTES ||
|
|
92
|
+
bytes.length > VOICE_MAX_PACKET_BYTES ||
|
|
93
|
+
bytes[1] !== VOICE_PACKET_VERSION
|
|
94
|
+
) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
port: bytes[0],
|
|
100
|
+
version: bytes[1],
|
|
101
|
+
speakerId: bytes[2],
|
|
102
|
+
talkId: bytes[3],
|
|
103
|
+
seq: new DataView(bytes.buffer, bytes.byteOffset).getUint16(4),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Готовит пакет клиента к раздаче: байт 0 ← server.VOICE_AUDIO, байт 2 ←
|
|
109
|
+
* speakerId. Правит буфер на месте (ретранслятор владеет им).
|
|
110
|
+
* @param {Uint8Array} bytes
|
|
111
|
+
* @param {number} speakerId - 0..VOICE_MAX_SPEAKER_ID.
|
|
112
|
+
* @returns {Uint8Array}
|
|
113
|
+
*/
|
|
114
|
+
export function stampForListeners(bytes, speakerId) {
|
|
115
|
+
if (!isVoiceSpeakerId(speakerId)) {
|
|
116
|
+
throw new RangeError('voice packet: speakerId out of range');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
bytes[0] = wsports.server.VOICE_AUDIO;
|
|
120
|
+
bytes[2] = speakerId;
|
|
121
|
+
|
|
122
|
+
return bytes;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Полезная нагрузка (Opus) без копии.
|
|
127
|
+
* @param {Uint8Array} bytes
|
|
128
|
+
* @returns {Uint8Array}
|
|
129
|
+
*/
|
|
130
|
+
export function voicePayload(bytes) {
|
|
131
|
+
return bytes.subarray(VOICE_HEADER_BYTES);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Знаковая разность номеров u16 с переполнением: seqDiff(1, 65535) === 2.
|
|
136
|
+
* @param {number} a
|
|
137
|
+
* @param {number} b
|
|
138
|
+
* @returns {number}
|
|
139
|
+
*/
|
|
140
|
+
export function seqDiff(a, b) {
|
|
141
|
+
return ((a - b + 32768) & 0xffff) - 32768;
|
|
142
|
+
}
|
package/src/standalone/index.js
CHANGED
|
@@ -44,7 +44,7 @@ import { ensureGameShell } from '../client/views/gameShell.js';
|
|
|
44
44
|
* @param {boolean} [options.devMode] - room.isDevMode: рекордер и хостовый
|
|
45
45
|
* CONSOLE-лог.
|
|
46
46
|
* @param {string|null} [options.reportUrl] - Адрес приёма журнала клиентских
|
|
47
|
-
* ошибок (`POST`, формат — docs.
|
|
47
|
+
* ошибок (`POST`, формат — VIMP docs: standalone.md). По
|
|
48
48
|
* умолчанию `null`: в solo нет бокса, и отчёты не отправляются.
|
|
49
49
|
* @returns {Promise<Object>} `{ stop() }` — останов матча.
|
|
50
50
|
*/
|
|
@@ -71,7 +71,8 @@ export async function startStandaloneGame({
|
|
|
71
71
|
// возможность, которой в этой сборке движка нет, иначе всплыла бы где-то
|
|
72
72
|
// в середине хендшейка непрозрачной ошибкой. Мастера тут нет, а значит нет
|
|
73
73
|
// и GameManifest с его `requires` — источником служат сами половины
|
|
74
|
-
// плагина (необязательное поле `requires`,
|
|
74
|
+
// плагина (необязательное поле `requires`,
|
|
75
|
+
// VIMP docs: plugin-api.md § "requires and engine capabilities")
|
|
75
76
|
// либо опция вызова, если встраивающий знает лучше. Одна проверка вместо
|
|
76
77
|
// двух: сообщение адресовано разработчику встраивания, делить его по
|
|
77
78
|
// половинам незачем
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
// Ссылка на документацию в футере формы входа игры. Показывается только в
|
|
2
|
-
// dev-сборке: разработчик игры при локальном запуске видит, где читать
|
|
3
|
-
// документацию, а игрок на проде — нет. Футер лобби несёт ту же ссылку
|
|
4
|
-
// статичной разметкой (lobby.pug). Тот же адрес — литералом в lobby.pug и в
|
|
5
|
-
// `homepage` пакета; совпадение сторожит tests/client/footerLink.test.js.
|
|
6
|
-
export const DOCS_URL = 'https://docs.vimp.lgick.dev';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* @param {HTMLAnchorElement|null} anchor - якорь футера
|
|
10
|
-
* @param {boolean} enabled - true только в dev-сборке
|
|
11
|
-
*/
|
|
12
|
-
export function renderDocsLink(anchor, enabled) {
|
|
13
|
-
if (!anchor) {
|
|
14
|
-
return;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
if (!enabled) {
|
|
18
|
-
anchor.textContent = '';
|
|
19
|
-
anchor.removeAttribute('href');
|
|
20
|
-
anchor.removeAttribute('target');
|
|
21
|
-
anchor.removeAttribute('rel');
|
|
22
|
-
|
|
23
|
-
return;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
anchor.textContent = 'docs';
|
|
27
|
-
anchor.href = DOCS_URL;
|
|
28
|
-
anchor.target = '_blank';
|
|
29
|
-
anchor.rel = 'noopener';
|
|
30
|
-
}
|